16 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.beshows 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 runjob 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 ofcmd/tapir). It is a read/write surface over the existingstore; 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 thestoreread/write methods and a thinauth/oidcsession 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 existingSummaryRowreads 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)
Authentication is delegated to Dex (OIDC provider at TAPIR_OIDC_ISSUER, default
https://auth.d-ma.be). Dex is configured with two connectors: a local-password connector
(for invited users created via tapir invite) and a Google OIDC upstream connector. Any
authenticated Dex subject can register a Tapir account (ADR-012: allowlist removed).
- 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); scopesopenid 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 (seeinternal/webregistration 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
/healthzand/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; use the keys above.)
8. Deployment — k3s + Flux GitOps
- Image:
Dockerfile(multi-stage, distroless/static, non-root). Built by gitea CI (act_runner + buildah) on push tomain, pushed to the homelab registry (k8s-registry-pullsecret already exists), GitHub mirror per thegitea-ciskill. - Manifests live in
mathias/infraunderk3s/apps/tapir/(NOT this repo — app vs deployment separation, homelab-integration.md). Flux watchesinframainand reconciles:Deployment(1 replica Stage 0),Service,Ingressfortapir.d-ma.be(TLS via the homelab cert flow / edge),ExternalSecret(ESO) materialisingTAPIR_*secrets from theHomeLab1Password vault into a k8sSecretmounted as env.
- DB in-cluster: the deployed service connects to
postgres18via its ClusterIP DSN (no port-forward); migrations apply on first connect (store.Migrate). Thetapirrole/db already exist; the in-cluster DSN goes inop://HomeLab/TAPIR_DB_DSN_INCLUSTER(or reuse with host swapped) and is surfaced via ESO. - YouTube refresh token in-cluster:
tapir authis interactive (host-only). Run it once on the host, store the resulting refresh token in 1Password, and have the deployed pod'sSecretStoreresolve 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 therunjob does — decide whetherrunis a CronJob in the same deploy or stays host-side for now (recommend: CronJob ink3s/apps/tapir/once the web UI is up).
9. Prerequisites (maintainer setup, before/with the build)
- Register a Dex static client
tapir-webin the Dex config (ininfra) with redirecthttps://tapir.d-ma.be/auth/callback; client id/secret → 1PTAPIR_DEX_CLIENT_ID/TAPIR_DEX_CLIENT_SECRET. (No allowlist subject to capture — ADR-012 dropped it.) - DNS/edge for
tapir.d-ma.be→ the k3s ingress (piguard NPM perimeter / existing*.d-ma.bepattern) + TLS cert. - 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_actionsmigration,SetAction/ClearAction/ActionsFor, join intoSummaryRow. 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 servewiring. Handler tests. - Lane D — deploy:
Dockerfile, gitea CI image build + mirror, and theinfrak3s/apps/tapir/manifests (Deployment/Service/Ingress/ExternalSecret) + Flux. (Touches theinfrarepo, 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 quiet "Summarize now" button on non-summarized video cards. Pending cards POST to /v/{id}/summarize (queues + triggers engine); rate-limited cards POST to /v/{id}/retry-now (clears backoff + triggers engine). Both use the same .btn-quiet style and label — the internal pipeline distinction is invisible to the user. The page HTMX-polls GET /v/{id}/status while processing. Videos with TranscriptStatus == "none" show "No transcript available" with no button — this is a terminal honest state. (§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 lists new videos unsummarized and queues via summarize_requested; a mode toggle at /account/summarize-mode. Default is true for new users (migration 011, ADR-018); existing rows back-filled via migration 012. |
Control over compute/noise — only summarize what the user cares about. | migration 006 (748d5eb, bdbdce7, 3014ee0, a269d4a); migration 011/012 |
| 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 |
| Invite onboarding | Second registration path alongside Google OIDC. tapir invite <email> (CLI) creates a Dex local-password CRD in the auth namespace and prints an invite URL valid for 7 days. /invite/{token} (web) is a public page where the recipient sets a password; on submit, the Dex password is activated and the user is redirected to login. Token expiry is 7 days (inviteTTL = 7 * 24 * time.Hour in cmd/tapir/invite.go). The token is single-use: ClaimInvitation consumes it atomically on POST. |
Allows inviting users who do not have a Google account or who should not use the Google OIDC upstream. | migration 009; cmd/tapir/invite.go; internal/web/invite.go |
| Summarized-only filter | ?summarized=1 query param on the list view. When set, only videos with a completed summary (SummaryRow.Summarized = true) are shown. Rendered as a "Summarized only" checkbox in the filter form. Summarized videos also sort to the top of the unfiltered list (ORDER BY (s.id IS NOT NULL) DESC, seen_at DESC). |
Lets users focus on videos that are ready to read; newly landing summaries are visible at the top without filtering. | internal/web/view.go (Filter.OnlySummarized, ListVideos ORDER BY) |
| "Summarize now" foreground path | Unified quiet nudge button on actionable non-summarized cards. Five explicit card states — (1) summarized: chip + no button; (2) no captions (transcript_status = 'none'): "No transcript available", no button; (3) queued: "Queued" chip, no button; (4) rate-limited: "Fetching soon…" + "Summarize now" → POST /v/{id}/retry-now (clears rate_limited_at, triggers engine); (5) pending: "Not summarized" + "Summarize now" → POST /v/{id}/summarize (queues + triggers engine). One verb, one style (.btn-quiet); backend difference invisible to user. Both handlers call ProcessVideo through globalFetchGate. Rate gate respected, not bypassed — this is onboarding prioritisation. |
Fast onboarding value; honest dead-end for no-captions videos (no button that fails). | internal/web/handlers.go (handleRetryNow, handleRequestSummarize); internal/web/views.templ (VideoCard) |
| Pipeline stats bar | A one-line status bar above the video list: N summarized · M fetching soon · K no captions. Computed from the unfiltered row set; hidden when all videos are summarized. Gives the user a clear read on pipeline state without any interaction. |
Replaces the "why is nothing happening?" confusion when most videos are pending or rate-limited. | internal/web/view.go (PipelineStats, pipelineStats) |
| Unavailable channels (account page) | The /account page shows a "Unavailable channels" section when any channels returned HTTP 404 on the last discovery pass. Lists channel name, an "unavailable" badge, and the first-seen date. Data sourced from the channel_errors table (migration 013). |
Surfaces silent failures so users know why some subscribed channels produce no new videos. | migration 013; internal/web/account.go; internal/adapters/youtube/youtube.go (domain.ErrChannelUnavailable) |