Files
tapir/internal/config/config.go
T
mathiasandClaude Opus 4.8 71689ced60 feat(web): Stage-0 reader UI — Templ+HTMX pages + tapir serve
Add the lane-C reader surface: list, detail, and an action button-group
fragment over the lane-A store reads/actions, behind the web.Auth seam.

- Templ components (base layout, list+filters, detail, ActionButtons) with
  committed *_templ.go so go build/task check work without the templ binary;
  `task generate` regenerates. Filters and action toggles are HTMX-swapped and
  degrade to plain form GET/POST (POST→303→GET) without JS.
- Handlers (internal/web): GET / (channel+date filters, in-memory),
  GET /v/{videoId}, POST /v/{videoId}/action (re-click clears, else SetAction;
  store enforces watched↔skipped exclusion), GET /healthz (no auth). Store ops
  run as the configured UserID; Auth only gates.
- `tapir serve` wires store + StubAuth{Subject: cfg.UserID} + http.Server on
  TAPIR_HTTP_ADDR (default :8080), graceful shutdown on signal. Handlers depend
  only on web.Auth — Conductor swaps StubAuth → oidc.DexAuth at merge (one line
  in cmdServe).
- Handler tests: real store (embedded-postgres) + StubAuth — list rows+state,
  HTMX fragment vs full page, channel filter, detail highlights/takeaways,
  404, action toggle+clear, no-JS redirect, bad-verb 400.

New dep: github.com/a-h/templ — the house default for typed server-rendered
HTML (CLAUDE.md stack, ui-spec.md §3). Generated code is committed so the
templ binary is build-time-optional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:47:47 +02:00

187 lines
6.1 KiB
Go

// Package config parses Tapir's runtime configuration from environment
// variables into a typed struct. No secrets are baked into code: the gateway
// key and OAuth client secret are read from the environment (later resolved via
// op/ESO), and the OAuth refresh token is never held here — it lives behind the
// SecretStore port, addressed by the opaque TokenRef.
//
// Defaults target the homelab snapshot in docs/homelab-integration.md; every
// value is overridable so the same binary runs standalone or in-cluster.
package config
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Config is Tapir's fully-resolved runtime configuration.
type Config struct {
// UserID is the Tapir user the run operates as. Stage 0 has exactly one.
// It must be a UUID: it keys the UUID user_id/video_id columns (data-model).
UserID string
// GatewayURL is the OpenAI-compatible LiteLLM base URL (".../v1").
GatewayURL string
// GatewayKey authorizes the gateway. Read from env, never committed.
GatewayKey string
// SummarizerModel is the alias in host/name form, e.g. "koala/phi4-mini".
SummarizerModel string
// SummarizerTimeout bounds a single completion call. Thinking models are
// slow, so the default is generous.
SummarizerTimeout time.Duration
// DBDSN is the Postgres DSN for the store sink.
DBDSN string
// YouTube OAuth app credentials (the registered client), read from env.
YTClientID string
YTClientSecret string
// YTTokenRef is the opaque SecretStore reference under which the YouTube
// refresh token is persisted/resolved. Not the token itself.
YTTokenRef string
// SecretsFile is the path to the local file-backed SecretStore (0600). A
// Stage-0 stand-in for op/ESO, swappable behind the SecretStore port.
SecretsFile string
// OAuthRedirectAddr is the host:port the `auth` command's local listener
// binds for the OAuth redirect, e.g. "localhost:8080".
OAuthRedirectAddr string
// PollInterval, when > 0, makes `run` loop on that cadence; 0 means run once.
PollInterval time.Duration
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
HTTPAddr string
}
// Defaults (see docs/homelab-integration.md). All overridable via env.
const (
defaultGatewayURL = "http://koala:30401/v1"
defaultSummarizerModel = "koala/phi4-mini"
defaultSummarizerTimeout = 5 * time.Minute
defaultYTTokenRef = "youtube/refresh_token"
defaultOAuthRedirectAddr = "localhost:8080"
defaultHTTPAddr = ":8080"
)
// Load reads the environment into a Config, applying defaults. It does not
// validate that required fields are present — call ValidateForAuth or
// ValidateForRun for the command being run, so each command demands only what
// it needs.
func Load() (Config, error) {
c := Config{
UserID: os.Getenv("TAPIR_USER_ID"),
GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL),
GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"),
SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel),
DBDSN: os.Getenv("TAPIR_DB_DSN"),
YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"),
YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"),
YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef),
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
}
timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout)
if err != nil {
return Config{}, err
}
c.SummarizerTimeout = timeout
interval, err := durationOr("TAPIR_POLL_INTERVAL", 0)
if err != nil {
return Config{}, err
}
c.PollInterval = interval
return c, nil
}
// ValidateForAuth checks the fields the `auth` command needs: the YouTube OAuth
// app credentials, a place to persist the token, and the redirect listener.
func (c Config) ValidateForAuth() error {
return c.require(map[string]string{
"TAPIR_YT_CLIENT_ID": c.YTClientID,
"TAPIR_YT_CLIENT_SECRET": c.YTClientSecret,
"TAPIR_YT_TOKEN_REF": c.YTTokenRef,
"TAPIR_SECRETS_FILE": c.SecretsFile,
})
}
// ValidateForRun checks the fields the `run` command needs end to end.
func (c Config) ValidateForRun() error {
return c.require(map[string]string{
"TAPIR_USER_ID": c.UserID,
"TAPIR_GATEWAY_URL": c.GatewayURL,
"TAPIR_SUMMARIZER_MODEL": c.SummarizerModel,
"TAPIR_DB_DSN": c.DBDSN,
"TAPIR_YT_CLIENT_ID": c.YTClientID,
"TAPIR_YT_CLIENT_SECRET": c.YTClientSecret,
"TAPIR_YT_TOKEN_REF": c.YTTokenRef,
"TAPIR_SECRETS_FILE": c.SecretsFile,
})
}
// ValidateForServe checks the fields the `serve` command needs: a user to act
// as, the store DSN, and a listen address. Auth (Dex) config is validated by the
// auth layer the Conductor wires in, not here.
func (c Config) ValidateForServe() error {
return c.require(map[string]string{
"TAPIR_USER_ID": c.UserID,
"TAPIR_DB_DSN": c.DBDSN,
"TAPIR_HTTP_ADDR": c.HTTPAddr,
})
}
func (c Config) require(fields map[string]string) error {
var missing []string
for name, val := range fields {
if strings.TrimSpace(val) == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return fmt.Errorf("missing required config: %s", strings.Join(sortedMissing(missing), ", "))
}
return nil
}
func sortedMissing(xs []string) []string {
sort.Strings(xs)
return xs
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func durationOr(key string, fallback time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("config: %s=%q: %w", key, v, err)
}
return d, nil
}
// defaultSecretsFile resolves to <user-config-dir>/tapir/secrets.json, falling
// back to a cwd-relative path when the config dir is unavailable.
func defaultSecretsFile() string {
dir, err := os.UserConfigDir()
if err != nil {
return "tapir-secrets.json"
}
return filepath.Join(dir, "tapir", "secrets.json")
}