Files
tapir/docs/ui-spec.md
T
mathiasandClaude Opus 4.8 e62df0027d refactor(oidc): drop single-subject allowlist, authenticate-only (ADR-012)
ADR-011's single-user authz (ID-token subject must equal AllowedSubject,
else 403) is replaced by ADR-012's model: Dex authentication is the only
gate — any Dex-authenticated subject may establish a session. Whether that
subject has a tapir user, and routing to registration if not, is decided
downstream in internal/web (next commit).

Removals (noted): oidc.Config.AllowedSubject + its required-field check + the
callback 403 branch; config.Config.AllowedSubject + TAPIR_ALLOWED_SUBJECT env
wiring; the AllowedSubject arg in cmdServe. ui-spec.md updated to reflect the
supersession. Sessions, cookie signing, login/callback/logout unchanged.

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

8.8 KiB

Tapir — Web UI Spec (Stage 0)

Implementation-level spec for the first web surface. Scope is Stage 0: a reading surface for the maintainer's own summaries, instrumented to capture the Stage-0 headline test ("reads summaries weekly and acts on ≥1"). Deployed at tapir.d-ma.be via the homelab GitOps flow. See VISION.md (Stage 0 gate), DECISIONS.md (ADR-011), docs/data-model.md.

1. Goal & success criteria

  • The maintainer can, from a browser, read Tapir's summaries (list + full view) and act on them (watch / skip / save), with the action recorded.
  • Auth is real (Dex OIDC login), but authorization is single-user: an allowlist of one subject. No user management, no per-tenant isolation (those are Stage 1+).
  • Runs as a deployed k3s service behind tapir.d-ma.be, secrets via ESO, DB in-cluster — i.e. the same binary, "homelab mode" wiring (ADR-003).
  • Done when: logging in at tapir.d-ma.be shows the maintainer's real summaries, opening one shows highlights/takeaways, and clicking watch/skip/save persists and is queryable.

2. Non-goals (Stage 1+ — do NOT build)

  • Multiple users, sign-up, user CRUD, per-tenant data isolation/RLS.
  • Editing summaries, subscription management UI, triggering runs from the browser (the run loop stays a tapir run job for now — revisit later).
  • Any billing / public marketing surface. Mobile-native. Real-time push.

3. Architecture

  • New transport, not new core. Add tapir serve (subcommand of cmd/tapir). It is a read/write surface over the existing store; the engine, ports, and adapters are untouched (ADR-003 — standalone vs homelab is wiring).
  • Stack: Go stdlib net/http + Templ (typed templates) + HTMX (progressive interactions, no SPA). House default; a summary reader is read-heavy and low-interactivity.
  • Layers: internal/web/ (handlers, session, middleware, Templ components) depends on the store read/write methods and a thin auth/oidc session package. No business logic in handlers beyond presentation + action recording.

4. Pages & interactions

Route Method What
/healthz GET liveness/readiness (no auth)
/auth/login GET redirect to Dex authorize
/auth/callback GET OIDC code exchange → session cookie → redirect to /
/auth/logout POST clear session
/ GET summary list (auth) — newest first; columns: title · channel · published · AI provider · fallback badge · current action state. Filters: channel, date range (query params, HTMX-swapped)
/v/{videoId} GET full summary: text, highlights (list), takeaways (list), metadata, action buttons
/v/{videoId}/action POST record/clear an action {watched|skipped|saved} (HTMX, returns the updated button group fragment — no full reload)
  • HTMX patterns: filters and action buttons POST/GET and swap a fragment (hx-target, hx-swap). Full-page fallback works without JS (forms degrade).
  • Action semantics: the three actions are independent toggles per (user, video) — "saved" can coexist with "watched"; "skipped" is mutually exclusive with "watched" (clicking one clears the other). Re-clicking an active action clears it.

5. Data model addition

New table summary_actions (migration, golang-migrate, per estate convention):

summary_actions
  id          uuid pk
  user_id     uuid   not null      -- isolation column (dormant authz at Stage 0)
  video_id    text   not null
  action      text   not null      -- 'watched' | 'skipped' | 'saved'
  acted_at    timestamptz not null
  unique (user_id, video_id, action)
  • Store methods (new file internal/adapters/store/actions.go, do not edit existing store files): SetAction(ctx, userID, videoID, action) error, ClearAction(...), ActionsFor(ctx, userID, videoIDs []string) (map[videoID][]action, error) for the list, and join into the existing SummaryRow reads so list/detail show current state.
  • This column is what makes the Stage-0 metric ("did I act on a summary?") queryable.

6. Auth (Dex OIDC, single-user authz)

  • Flow: standard Authorization Code. Use coreos/go-oidc + golang.org/x/oauth2 (justify the deps in the commit; both are the homelab-standard OIDC libs and small).
  • Discover issuer https://auth.d-ma.be (TAPIR_OIDC_ISSUER); scopes openid profile email.
  • On callback: verify ID token, extract sub (and email). ADR-012 superseded the ADR-011 single-subject allowlist: any Dex-authenticated subject may sign in; a subject with no tapir user is routed to explicit registration (see internal/web registration gate).
  • Session: signed, httpOnly, Secure cookie (HS256 with TAPIR_SESSION_SECRET); short TTL
    • sliding refresh. Server-side session store can be in-memory at Stage 0 (single replica).
  • Middleware guards every route except /healthz and /auth/*.
  • Note: this is mcp-chassis's cousin but NOT the same code — mcp-chassis validates inbound Bearer JWTs for MCP APIs; this is a browser session login. Don't force-fit it.

7. Config additions (typed, env, via ESO in-cluster)

TAPIR_HTTP_ADDR (:8080), TAPIR_PUBLIC_URL (https://tapir.d-ma.be), TAPIR_OIDC_ISSUER (https://auth.d-ma.be), TAPIR_DEX_CLIENT_ID, TAPIR_DEX_CLIENT_SECRET, TAPIR_OIDC_REDIRECT_URL (https://tapir.d-ma.be/auth/callback), TAPIR_SESSION_SECRET. Reuses existing TAPIR_DB_DSN, TAPIR_USER_ID (the StubAuth dev subject only). No secrets committed. (TAPIR_ALLOWED_SUBJECT was removed by ADR-012.)

8. Deployment — k3s + Flux GitOps

  • Image: Dockerfile (multi-stage, distroless/static, non-root). Built by gitea CI (act_runner + buildah) on push to main, pushed to the homelab registry (k8s-registry-pull secret already exists), GitHub mirror per the gitea-ci skill.
  • Manifests live in mathias/infra under k3s/apps/tapir/ (NOT this repo — app vs deployment separation, homelab-integration.md). Flux watches infra main and reconciles:
    • Deployment (1 replica Stage 0), Service, Ingress for tapir.d-ma.be (TLS via the homelab cert flow / edge), ExternalSecret (ESO) materialising TAPIR_* secrets from the HomeLab 1Password vault into a k8s Secret mounted as env.
  • DB in-cluster: the deployed service connects to postgres18 via its ClusterIP DSN (no port-forward); migrations apply on first connect (store.Migrate). The tapir role/db already exist; the in-cluster DSN goes in op://HomeLab/TAPIR_DB_DSN_INCLUSTER (or reuse with host swapped) and is surfaced via ESO.
  • YouTube refresh token in-cluster: tapir auth is interactive (host-only). Run it once on the host, store the resulting refresh token in 1Password, and have the deployed pod's SecretStore resolve it from the ESO-synced secret (an ESO/k8s-secret-backed SecretStore impl, swappable behind the port). The web UI itself does not need the YouTube token; only the run job does — decide whether run is a CronJob in the same deploy or stays host-side for now (recommend: CronJob in k3s/apps/tapir/ once the web UI is up).

9. Prerequisites (maintainer setup, before/with the build)

  1. Register a Dex static client tapir-web in the Dex config (in infra) with redirect https://tapir.d-ma.be/auth/callback; client id/secret → 1P TAPIR_DEX_CLIENT_ID / TAPIR_DEX_CLIENT_SECRET. (No allowlist subject to capture — ADR-012 dropped it.)
  2. DNS/edge for tapir.d-ma.be → the k3s ingress (piguard NPM perimeter / existing *.d-ma.be pattern) + TLS cert.
  3. Confirm the registry host/path the gitea CI pushes to and the Flux path infra/k3s/apps/tapir/.

10. Records / ADR

ADR-011 records: the web read-surface at Stage 0, Dex authentication now with trivial single-user authz (deviating from the data-model's "auth dormant at Stage 0" note, with rationale), the summary_actions model, and public tapir.d-ma.be ingress + GitOps deploy.

11. Build decomposition (gated swarm)

Gate (lane A) commits first; B/C/D follow.

  • Lane A — store actions + migration (the gate): summary_actions migration, SetAction/ClearAction/ActionsFor, join into SummaryRow. embedded-postgres tests.
  • Lane B — Dex OIDC session + middleware: login/callback/logout, session cookie, allowlist, route guard. Tests with a fake issuer (httptest), no live Dex.
  • Lane C — Templ+HTMX pages: list (+filters), detail (+action button group fragment), layout/styles, tapir serve wiring. Handler tests.
  • Lane D — deploy: Dockerfile, gitea CI image build + mirror, and the infra k3s/apps/tapir/ manifests (Deployment/Service/Ingress/ExternalSecret) + Flux. (Touches the infra repo, not just this one.)

task check green per lane; B/C/D rebase on A. Deploy (D) lands last, after the binary serves locally.