Files
tapir/docs/ui-spec.md
T
mathiasandClaude Opus 4.8 21e6ddd61e docs(ui-spec): record as-built deviations and additions
The Stage-0 ui-spec (ADR-011) predated multi-user and several UX
features. Appended a "Deviations and additions (as-built)" table —
without rewriting the spec — recording each feature shipped beyond it
(multi-user+RLS, registration gate, per-user YouTube connect, account
management, immediate web summarization, charmbracelet spinner,
auto/manual mode, public landing page) with the why and the
commit/ADR that covers each. Preserves the intent-vs-reality split.

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

12 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.


Deviations and additions (as-built)

This spec describes the Stage-0 single-user reader (ADR-011). What actually shipped through v0.4.0 went further — Stage 1 (ADR-012) opened multi-user, and several UX features were added on top. Recorded here (append-only; the spec above is left intact) so intent and reality stay distinguishable.

As-built feature What it is Why Covered by
Multi-user + RLS isolation Several Dex users per deployment; isolation enforced by Postgres RLS, not the single-subject allowlist of §6. Maintainer opened Stage 1 ahead of the formal Stage-0 gate, with DB-enforced isolation as the guardrail that keeps it safe. ADR-012; migration 003 (6775e5f, f28fdc0, 2ae66da)
Registration gate A Dex subject with no users row is routed to /register, which creates the users row + a user_identities mapping. (§2 listed "sign-up / user CRUD" as a non-goal.) Explicit registration is how a multi-user surface stays honest — no just-in-time row creation. ADR-012; f396e01
Per-user YouTube web connect /oauth/youtube/connect/oauth/youtube/callback stores a per-user refresh-token ref + a video_connections row. (The spec assumed a host-side tapir auth only.) Multi-user means each user connects their own account from the browser. ADR-006, ADR-012; migration 005 (0c9531a, 2aad79b)
Account management /account page with disconnect and delete account; delete removes only Tapir-side state and leaves the Dex identity intact. (§2 listed isolation/CRUD as non-goals.) A real account needs a way out; deletion semantics are deliberately Tapir-side only. ADR-013; 22eafcf, c7624d9, 17d5e8c
Immediate web summarization A "Summarize" button (POST /v/{id}/summarize) runs the engine in a background goroutine inside serve; the page HTMX-polls GET /v/{id}/status. (§2 said "triggering runs from the browser … do NOT build".) Reading a list you can't act on is half a product; on-demand summarize closes the loop without waiting for a batch tapir run. ADR-012, ADR-014; 25215cb, 8c6c7ca
Charmbracelet tapir spinner An animated in-flight indicator (charm palette) shown while a summarize is processing; an honest "queued/waiting" state under rate-limiting rather than a stuck spinner. The spinner must tell the truth when the timedtext endpoint rate-limits (429), not imply imminence. ADR-014; 25215cb, a4aeb5e
Auto/manual summarization mode Per-user auto_summarize; manual (default) lists new videos unsummarized and queues via summarize_requested; a mode toggle at /account/summarize-mode. Control over compute/noise — only summarize what the user cares about. migration 006 (748d5eb, bdbdce7, 3014ee0, a269d4a)
Public landing page /welcome mounted outside the auth guard; unauthenticated / redirects there; logout returns there (not /auth/login). (The spec guarded everything except /healthz and /auth/*.) A first-time visitor needs a public "what is this / get started" page before the login wall. d83943c, 0fdf2f7, 3a27bf1, d208110, 8ca374e, f15f57f

The original Stage-0 goals (read summaries, record watch/skip/save actions, Dex login, GitOps deploy) still hold — these are additions over that base, not replacements. The architecture stance is unchanged: every item above is web-surface or store work; the engine/ports/sinks core was not modified (ADR-003).