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>
150 lines
8.8 KiB
Markdown
150 lines
8.8 KiB
Markdown
# 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.
|