diff --git a/DECISIONS.md b/DECISIONS.md index 6fb51dd..8e54ecf 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -219,6 +219,40 @@ path tracked in gitea-mcp #35 (item #1). --- +## ADR-011 — Web read-surface at Stage 0: Dex authn (single-user authz), action signal, public ingress + GitOps + +**Status:** Accepted (2026-06-02) + +**Context.** The Stage-0 CLI (`list`/`show`) reads summaries but doesn't capture the Stage-0 +*headline test* — whether the maintainer **acts** on a summary (watches/skips/saves because of +it). A browser surface is wanted, and the maintainer chose to deploy it properly: +**`tapir.d-ma.be` via the homelab ingress, full k3s/Flux GitOps**, with **Dex login from the +start** rather than a Tailscale-only no-auth dev page. The data-model note says "auth is +dormant at Stage 0", so logging in early is a deliberate deviation worth recording. + +**Decision.** +1. Add a Stage-0 web reader (`tapir serve`, HTMX+Templ) over the existing `store` — a new + transport, not a core change (ADR-003). Pages: summary list + full view + **watch/skip/save** + actions recorded in a new `summary_actions` table. The action signal instruments the + Stage-0 success metric directly. +2. **Authentication** via Dex OIDC (ADR-002 already names Dex for identity). **Authorization + stays trivial**: an allowlist of one subject (the maintainer). NO user CRUD, NO per-tenant + isolation — that authz/isolation work is the real Stage-1/2 line and stays deferred. The + distinction (authn now, authz/tenancy later) is what keeps this honest with VISION. +3. **Deploy** at `tapir.d-ma.be` via the existing homelab pattern: gitea CI (buildah) → + registry → **Flux** reconciling manifests in `mathias/infra` `k3s/apps/tapir/`; secrets via + **ESO + 1Password**; `postgres18` reached in-cluster. This is application of existing + convention, not a new infra decision. + +**Consequences.** The maintainer gets a real, authenticated reading surface and the Stage-0 +metric becomes queryable. A browser session-login path now exists (distinct from +`mcp-chassis`'s inbound Bearer-JWT validation — not the same code). Wiring login early adds a +Dex static-client registration + ingress/TLS as prerequisites. If multiple users ever arrive, +authorization/isolation is a new ADR (Stage 1) — this one deliberately does not build it. +Full spec: `docs/ui-spec.md`. + +--- + ## Rejected alternatives Approaches considered during the 2026-06-02 planning + grill session and **deliberately not diff --git a/docs/ui-spec.md b/docs/ui-spec.md new file mode 100644 index 0000000..fc31fc7 --- /dev/null +++ b/docs/ui-spec.md @@ -0,0 +1,147 @@ +# 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); **allowlist check** against + `TAPIR_ALLOWED_SUBJECT` (the maintainer's Dex subject) — reject everyone else with 403. +- **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`, +`TAPIR_ALLOWED_SUBJECT`. Reuses existing `TAPIR_DB_DSN`, `TAPIR_USER_ID`. No secrets committed. + +## 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`. Capture your Dex `sub` for `TAPIR_ALLOWED_SUBJECT`. +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.