Compare commits

..
25 Commits
Author SHA1 Message Date
mathiasandClaude Opus 4.8 3014ee0d60 feat(web): summarization-mode UI — all-videos list, Summarize queue, mode toggle
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Has been skipped
The list now shows ALL videos (ListVideos), not just summaries. Summarized cards
are unchanged; discovered-but-unsummarized videos render with a muted "pending"
treatment and either a "Summarize" button or a "Queued" chip.

- POST /v/{videoId}/summarize queues a video (RequestSummarize) and returns the
  refreshed card — it does NOT run the engine inline; `tapir run` is the single
  summarization driver, which picks up the flag on its next pass.
- Account page gains an Automatic/Manual toggle (POST /account/summarize-mode →
  SetAutoSummarize), shown as the current mode with a one-click switch.
- Both new POSTs degrade without JS (redirect back); HTMX swaps the fragment.

VideoCard and summarizeModeControl are extracted templ fragments reused as the
HTMX swap targets. views_templ.go regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:31:29 +02:00
mathiasandClaude Opus 4.8 a269d4a200 feat(runner): manual summarization mode in the run loop
RunOnce now reads the user's mode (GetAutoSummarize) at the start of each pass:
- Auto (unchanged): summarize every unseen video.
- Manual: still UpsertVideo for every candidate (discovery — the user sees new
  videos in the list), but skip ProcessNewVideo unless the video is queued
  (RequestedVideoIDs). A queued video is summarized, then its flag is cleared
  (ClearSummarizeRequested) so it is not re-processed and the UI drops "Queued".

New Stats.SkippedManual counts discovered-but-unqueued videos. The VideoStore
port gains the three methods; the existing auto-mode tests set auto:true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:28:20 +02:00
mathiasandClaude Opus 4.8 bdbdce7de1 feat(store): summarization-mode methods + all-videos read
Add the store surface for summarization mode:
- SetAutoSummarize / GetAutoSummarize — per-user auto/manual toggle; an absent
  user reads as manual (the safe default).
- RequestSummarize — queue one video (summarize_requested=TRUE); ErrNotFound
  when the video is absent or not owned (RLS hides another user's row).
- RequestedVideoIDs / ClearSummarizeRequested — the run-loop side: load the
  queued set per pass (mirrors SeenVideoIDs), clear after summarizing.
- ListVideos / GetVideoRow — drive from the videos table LEFT JOIN summaries so
  discovered-but-unsummarized videos appear with empty summary fields. SummaryRow
  gains additive Summarized + SummarizeRequested fields; the summary-only reads
  are untouched.

RLS proof: rls_test.go gains a cross-user "queue B's video" write asserting it
touches zero rows (store-level scoping can't prove this — the test pool is a
superuser that bypasses RLS, same caveat documented there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:26:52 +02:00
mathiasandClaude Opus 4.8 748d5eb0bd feat(store): migration 006 — per-user auto_summarize + per-video summarize_requested
Add the schema for the summarization-mode feature: a per-user auto/manual
toggle (users.auto_summarize, default FALSE = manual) and a per-video manual
queue flag (videos.summarize_requested, default FALSE).

Both columns land on tables that already have ENABLE + FORCE ROW LEVEL
SECURITY (migration 003), so they inherit per-user isolation automatically —
no policy changes needed. auto_summarize is per-user, not global, per ADR-012.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:26:43 +02:00
mathias fa57ee0532 docs(homelab): Stage-1 facts — RLS non-super DSN, per-user token PVC, web connect URI
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Failing after 2s
Per the docs-currency gate (ADR-012 shipped): record the Stage-1 deployment
facts that future sessions would otherwise rediscover the hard way.
2026-06-03 18:28:29 +02:00
mathiasandClaude Opus 4.8 22eafcf43f feat(web): account page with disconnect + delete-account
CI / Lint / Test / Vet (push) Successful in 17s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Has been skipped
GET /account shows the registered display name, the signed-in email, the
user's connected video accounts (status + when), a Connect-YouTube link
when none is connected, and the disconnect / delete controls. Linked from
the header nav.

POST /account/disconnect/{provider}: deletes the OAuth token from the
SecretStore (resolved from the connection's own token_ref, provider-
agnostic) and the connection row. Does NOT delete the account.

POST /account/delete: confirm-before-destroy (a <details> disclosure gates
the destructive submit — works without JS). Captures token refs, calls
store.DeleteUser (cascades all rows), purges every secret, then routes to
/auth/logout to clear the session. Tapir-side only — Dex is left untouched
(decision 2026-06-03).

Account handlers depend on a narrow SecretRemover (Delete) and the extended
Store port; cmd/tapir serve shares one file-backed SecretStore between the
connect flow and account management.

Tests: account page renders connections + name + Connect link; disconnect
removes token (fake records Delete) + row and keeps the account; delete
wipes users/summaries/connections/identities and purges the token, then
redirects to logout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:55:02 +02:00
mathiasandClaude Opus 4.8 2fe4833434 feat(web): reusable flash/notification banner (PRG)
Add a one-shot flash component used across the app — connect success,
disconnect, account delete, and registration — instead of per-page ad-hoc
markup. setFlash queues a short-lived HttpOnly+SameSite cookie carrying an
opaque code; takeFlash consumes it on the next full-page render (not on
HTMX fragments). flashBanner maps the code to a styled, role=status banner;
the message text lives server-side in flashMessages so the cookie never
carries free text and a forged/unknown code renders nothing.

Wire it into the list page (the PRG landing spot for connect/registration)
and set it on registration and connect-callback success. Styled with the
existing design-system tokens; header gains an Account nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:52:03 +02:00
mathiasandClaude Opus 4.8 17d5e8c393 feat(secrets): FileStore.Delete to purge a user's OAuth tokens
Account disconnect/delete needs to remove the per-user YouTube refresh
token from the SecretStore. Add Delete(ref) on the file-backed store,
mirroring Put: atomic temp-file+rename, 0600, no-op on an absent ref.

Kept off the read-only ports.SecretStore (Get) — write/delete follow the
existing auth.TokenWriter convention of narrow capability interfaces, so
the youtube adapter's read-only dependency is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:50:20 +02:00
mathiasandClaude Opus 4.8 c7624d97fe feat(store): DeleteUser + DisplayName for account management
DeleteUser permanently removes a user and all owned data, scoped via
withUser. The users-row ON DELETE CASCADE reaches videos, transcripts,
summaries → sink_deliveries, video_connections, and the user_identities
map (cascades bypass RLS, so a scoped connection still wipes child rows).
summary_actions carries user_id but has NO FK to users (migration 002),
so it is deleted explicitly in the same scoped transaction. Idempotent.

Tapir-side only (decision 2026-06-03): Dex identity is left untouched;
secrets live in the SecretStore and are removed by the account handler.

DisplayName returns the registered name for the account page.

Test proves deletion removes every row for the target user across all
isolated tables (incl. video_connections AND user_identities) and leaves
another user's rows fully intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:49:50 +02:00
mathiasandClaude Opus 4.8 2aad79b2a8 feat(web): web-initiated YouTube OAuth connect flow (ADR-006)
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Failing after 3s
Add GET /oauth/youtube/connect and /oauth/youtube/callback, mounted inside
the login + registration guard so CurrentUserID is always set and every
connection binds to the authenticated tapir user.

- connect: generate a per-user CSRF state (single-use, short TTL, bound to
  the user), redirect to Google consent with access_type=offline and
  prompt=consent so a refresh token comes back.
- callback: verify the state belongs to this user, exchange the code via the
  existing auth.Exchange, persist the refresh token under a PER-USER ref
  (web.YouTubeTokenRef = "youtube/<userID>/refresh_token") so tenants never
  collide, then UpsertConnection (provider=youtube, status=active). Any
  failure renders a clean error page and leaves no half-written state.

Reuses auth.Exchange and adds auth.AuthCodeURL (offline + consent) rather
than the CLI's listener/terminal flow (ADR-006: web flow, not CLI). The
ConnectHandler depends on a narrow web.Connections port, not the concrete
store. Wired in cmdServe only when YT client credentials are present;
TAPIR_YT_CONNECT_REDIRECT_URL configures the callback URL. Per-user token-ref
scheme documented in docs/homelab-integration.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:12:42 +02:00
mathiasandClaude Opus 4.8 0c9531a9b8 feat(store): video_connections table + RLS + connection store methods
Add the video_connections table (data-model VIDEO_CONNECTION) with FORCE
row-level security keyed off tapir.current_user_id, identical to migration
003's per-user isolation pattern — connections are user-owned data and must
be isolated at the DB layer, not only by application WHERE clauses.

Store methods (UpsertConnection / ConnectionsForUser / DeleteConnection) all
route through withUser so RLS scopes every access. UpsertConnection is
idempotent on (user_id, provider). The OAuth refresh token never lives here;
token_ref is the opaque SecretStore reference.

Extend the RLS isolation proof to cover video_connections: seeded per user,
included in the deny-all + scoped-read assertions, and added to the
cross-user write-invisibility and survivor checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:12:34 +02:00
mathiasandClaude Opus 4.8 7b4960e417 feat(web): registration gate + per-request user-id seam (ADR-012)
CI / Lint / Test / Vet (push) Successful in 9s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Failing after 3s
Multi-user web surface. Two layered middlewares: Auth.Middleware (Dex
session required) wraps registrationGate, which resolves the authenticated
subject -> tapir user_id once per request via the new web.Identity port and
stashes it. A subject with no tapir user is redirected to GET /register
(display name + accept-terms); POST /register calls RegisterUser then
redirects to /. /register is inside the auth guard but exempt from the gate
(/auth/* and /healthz too).

Current-user seam: CurrentUserID(r) (string, bool) returns the resolved id
from the request context. The list/detail/action handlers now scope by it,
replacing the single configured App.UserID (removed). App gains an Identity
field; *store.Store satisfies both Store and Identity. cmd/tapir wires
Identity: st and drops UserID.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:00:30 +02:00
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
mathiasandClaude Opus 4.8 f396e01243 feat(store): user_identities map + UserBySubject/RegisterUser (ADR-012)
Add the Dex-subject -> tapir-user_id bridge for multi-user Stage 1.

Migration 004 creates user_identities (dex_subject PK, user_id UNIQUE FK
ON DELETE CASCADE). It is intentionally NOT RLS-enabled: it holds no user
data and must be readable BEFORE a user_id is known (the lookup is what
yields the id used to set tapir.current_user_id). RLS here would be a
chicken-and-egg deadlock; data isolation stays on the user-owned tables.

UserBySubject resolves subject -> user_id as a plain pool query (pre-scope,
no withUser). RegisterUser generates the UUID app-side (stdlib crypto/rand,
no new dep) so the forced-RLS WITH CHECK (id = GUC) passes, then inserts the
users row via withUser(newID) and the identity row in the same transaction.
Re-registration of a subject errors with ErrSubjectRegistered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:53:34 +02:00
mathias 9bff59037f merge: video embed on detail page (Worker R2)
CI / Lint / Test / Vet (push) Successful in 13s
CI / Build & Import (push) Successful in 11s
CI / Mirror to GitHub (push) Failing after 2s
# Conflicts:
#	internal/web/views_templ.go
2026-06-03 15:38:31 +02:00
mathias 6d9f3c49ed merge: summary preview in list (Worker R1) 2026-06-03 15:37:42 +02:00
mathias 2ae66da0e0 merge: RLS isolation foundation — forced RLS + withUser scoping + isolation test (Worker I, ADR-012) 2026-06-03 15:37:24 +02:00
mathiasandClaude Opus 4.8 f28fdc0292 test(store): prove RLS isolation as a non-superuser role
The isolation proof for ADR-012. embedded-postgres's default user is a
SUPERUSER, which bypasses RLS regardless of FORCE — a test run as it would be
fake-green. So this test creates a dedicated non-superuser role ("app", mirroring
the prod owner tapir), grants it DML, asserts rolsuper is false, and runs every
scoped query as that role.

Assertions: (1) deny-all — with no GUC set, every isolated table returns zero
rows, proving the enforcement path is live, not bypassed; (2) a connection scoped
to user A sees exactly its own one row in every table (and B likewise); (3)
cross-user UPDATE/DELETE aimed at B's rows touches zero rows; (4) B's rows survive
unchanged, verified via the superuser pool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:15:58 +02:00
mathiasandClaude Opus 4.8 7b139c2cd7 feat(store): route all DB access through withUser for structural scoping
Add Store.withUser(ctx, userID, fn) — a single choke point that BEGINs a tx,
sets the transaction-local GUC tapir.current_user_id via
set_config(..., true), runs fn, and commits. set_config is used over SET LOCAL
because it is parameterizable; the local flag means the value auto-resets on
commit/rollback so a pooled connection never leaks one request's user into the
next.

Route all 9 DB-touching methods through it (Deliver, HasSummary, SeenVideoIDs,
ListSummaries, GetSummaryByVideo, SetAction, ClearAction, ActionsFor, and
UpsertVideo; attachActions flows via ActionsFor). Scoping is now structural —
not a per-query opt-in someone can forget — and arms the migration-003 RLS
policies. Method signatures and existing WHERE clauses are unchanged (defence in
depth; superuser DSNs in existing tests bypass RLS so behaviour is preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:15:52 +02:00
mathiasandClaude Opus 4.8 6775e5f53d feat(store): migration 003 — enforce per-user isolation via forced RLS
Enable AND FORCE row-level security on every user-owned table (users, videos,
transcripts, summaries, summary_actions, sink_deliveries) per ADR-012. Each
policy keys off the per-request GUC tapir.current_user_id; an unset GUC yields
NULL → deny-all (the safe default).

FORCE is load-bearing: the app connects as the table owner (tapir), and owners
bypass RLS unless forced. Without FORCE the policies are dead for the prod user.

sink_deliveries has no user_id; its policy derives ownership from the summary it
belongs to via EXISTS against the GUC, so it is self-contained rather than
silently depending on summaries' own RLS being applied to a subquery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:15:44 +02:00
mathias 8210f927ee feat(web): summary preview under each list card
Render a muted one-line lede beneath the title/meta of each summary
card via previewText(r.Summary, 160). New .card-preview token clamps to
one line. Regenerated views_templ.go committed (CI has no templ binary).
2026-06-03 15:11:42 +02:00
mathias dc4b06baf4 feat(web): embed video on detail page via nocookie iframe
Responsive 16:9 youtube-nocookie iframe rendered when the id is valid;
omitted (graceful) otherwise so summary/highlights/takeaways still show.
Regenerated views_templ.go.
2026-06-03 15:11:13 +02:00
mathias 23fa5427b7 feat(web): previewText truncation helper for summary cards
One-line lede for list cards: collapses whitespace, prefers the first
sentence within budget, else word-boundary truncation with an ellipsis.
Pure and rune-based (multibyte-safe). Table-driven tests cover empty,
short, first-sentence, word-boundary, and multibyte cases.
2026-06-03 15:10:50 +02:00
mathias 897a21a1d6 feat(store): expose provider_video_id on SummaryRow
Read-only addition (column + field + scan) so the web layer can build a
video embed URL. No write-path or restructuring.
2026-06-03 15:10:29 +02:00
mathias b2d1909b13 feat(web): embedURL helper for privacy-friendly nocookie embeds
Validates an 11-char YouTube id and returns the youtube-nocookie embed
URL, or ("", false) so callers omit a broken iframe. Table-driven test.
2026-06-03 15:10:06 +02:00
47 changed files with 4648 additions and 564 deletions
+26 -9
View File
@@ -144,10 +144,11 @@ func cmdRun(ctx context.Context, log *slog.Logger) error {
return r.Loop(ctx, cfg.PollInterval)
}
// cmdServe runs the Stage-0 web UI: the summary reader over the existing store
// (ADR-003 — a new transport, not new core). Auth is the StubAuth allow-all seam
// keyed to the configured user; the Conductor swaps in oidc.DexAuth at merge —
// the only line that changes is the `authn` assignment below.
// cmdServe runs the Stage-1 web UI: the summary reader over the existing store
// (ADR-003 — a new transport, not new core). Auth (web.Auth) gates access; the
// registration gate resolves the authenticated subject to a tapir user_id and
// scopes every store access by it (ADR-012). With Dex configured, real OIDC login
// is used; otherwise StubAuth (dev only). The store doubles as the Identity port.
func cmdServe(ctx context.Context, log *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
@@ -164,9 +165,9 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
defer st.Close()
// Auth seam (handlers depend on web.Auth only). With Dex configured
// (TAPIR_OIDC_ISSUER set) serve uses real OIDC login with single-user
// allowlist authz (ADR-011); otherwise it falls back to the allow-all
// StubAuth for local dev — never expose StubAuth publicly.
// (TAPIR_OIDC_ISSUER set) serve uses real OIDC login — any Dex subject may
// authenticate, then registers a tapir user (ADR-012); otherwise it falls
// back to the allow-all StubAuth for local dev — never expose StubAuth publicly.
var authn web.Auth
if cfg.DexConfigured() {
authn, err = oidc.New(ctx, oidc.Config{
@@ -175,7 +176,6 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
ClientSecret: cfg.DexClientSecret,
RedirectURL: cfg.OIDCRedirectURL,
SessionSecret: cfg.SessionSecret,
AllowedSubject: cfg.AllowedSubject,
})
if err != nil {
return fmt.Errorf("dex oidc: %w", err)
@@ -186,7 +186,24 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
log.Warn("web auth: STUB allow-all (no TAPIR_OIDC_ISSUER) — local dev only, do not expose")
}
app := &web.App{Store: st, Auth: authn, UserID: cfg.UserID, Log: log}
// The file-backed SecretStore is shared by the connect flow (writes tokens)
// and account management (deletes them on disconnect / delete-account).
secretStore := secrets.NewFileStore(cfg.SecretsFile)
app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log}
// Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client
// credentials are present; the refresh token persists through the SecretStore
// under a per-user ref (web.YouTubeTokenRef). Live connect also needs the
// callback URL registered in the Google OAuth client's authorized redirects.
if cfg.YTClientID != "" && cfg.YTClientSecret != "" {
app.Connect = web.NewConnectHandler(auth.Config{
ClientID: cfg.YTClientID,
ClientSecret: cfg.YTClientSecret,
RedirectURL: cfg.YTConnectRedirectURL,
}, secretStore, st, log)
log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL)
}
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: app.Router(),
+37
View File
@@ -79,6 +79,14 @@ This maps directly onto the copied `llm` package: `Client` is the OpenAI-compati
`SecretStore` port (`youtube.New(cfg, secrets)`). Pinning the actual vault-item name only
changes wiring/config, not the adapter — so this `confirm` does not block the adapter. Decide
the name when wiring the live connection and record it here.
- **Per-user token-ref scheme (Stage 1 web connect):** the web connect flow
(`/oauth/youtube/connect``/oauth/youtube/callback`) persists each user's refresh token
under a **per-user ref `youtube/<userID>/refresh_token`** (`web.YouTubeTokenRef`), not the
Stage-0 single `youtube/refresh_token`. This is what keeps tokens isolated across tenants
behind the `SecretStore` port; the `video_connections` row stores only this opaque
`token_ref`, never the token. The connect callback URL is
`TAPIR_YT_CONNECT_REDIRECT_URL` (default `https://tapir.d-ma.be/oauth/youtube/callback`) and
must be in the Google OAuth client's authorized redirects for live connect.
## Hosts (for reference)
@@ -154,3 +162,32 @@ allow per-provider when a user connects one.
_Snapshot date 2026-06-02. Items marked **confirm** were not verified to a pinned source at
snapshot time — check brain or the live cluster before depending on them._
## Stage 1 — multi-user facts (verified 2026-06-03)
### Postgres RLS (ADR-012)
- **The deployed DSN MUST connect as a non-superuser, non-BYPASSRLS role.** The
app uses the `tapir` role (table owner, non-superuser). `FORCE ROW LEVEL
SECURITY` is applied on all user-owned tables; a superuser DSN silently bypasses
FORCE and isolation is dead in prod. Verify: `SELECT rolsuper FROM pg_roles
WHERE rolname = 'tapir'` must return `f`.
- Scoping is via `set_config('tapir.current_user_id', $userID, true)` (transaction-
local, auto-resets on commit — never leaks across a pooled connection).
### Per-user YouTube token persistence
- Stage-1 uses the **file-backed SecretStore** at `TAPIR_SECRETS_FILE=/data/secrets.json`
mounted from a **PVC** (`tapir-secrets`, 64Mi, RWO). Tokens survive pod restarts.
Upgrading to an ESO-backed per-user SecretStore is backlog (infra#86).
- Per-user token ref scheme: `youtube/<userID>/refresh_token` (Worker C, ADR-006).
The Stage-0 single ref `youtube/refresh_token` is no longer used by `serve`; it
remains valid for the CLI `tapir run` (single-user, host-side).
### Web YouTube connect
- Redirect URI (registered in Google OAuth client, type Web): `https://tapir.d-ma.be/oauth/youtube/callback`.
- Config env: `TAPIR_YT_CONNECT_REDIRECT_URL=https://tapir.d-ma.be/oauth/youtube/callback`.
`TAPIR_YT_CLIENT_ID` / `TAPIR_YT_CLIENT_SECRET` from the Web client (not the Desktop client used for the CLI).
### Identity resolution
- `user_identities(dex_subject → user_id)` table is **intentionally NOT RLS-enabled**
(it's auth plumbing, holds no user data; data isolation is on the user-owned tables).
All data access after subject resolution goes through `withUser`.
+7 -5
View File
@@ -77,8 +77,9 @@ summary_actions
- **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.
- 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/*`.
@@ -89,8 +90,9 @@ summary_actions
`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.
`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
@@ -117,7 +119,7 @@ summary_actions
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`.
`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
+37
View File
@@ -89,6 +89,43 @@ func (s *FileStore) Put(ref, value string) error {
return nil
}
// Delete removes the secret stored under ref, persisting the file atomically
// (temp file + rename) with 0600 permissions. Deleting an absent ref — or one in
// a file that does not exist yet — is a no-op, not an error. Used by account
// management (disconnect / delete-account) to purge a user's OAuth tokens.
func (s *FileStore) Delete(ref string) error {
s.mu.Lock()
defer s.mu.Unlock()
m, err := s.load()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil // nothing to delete
}
return err
}
if _, ok := m[ref]; !ok {
return nil // already absent
}
delete(m, ref)
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return fmt.Errorf("secrets: create dir: %w", err)
}
b, err := json.Marshal(m)
if err != nil {
return fmt.Errorf("secrets: marshal: %w", err)
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("secrets: write temp: %w", err)
}
if err := os.Rename(tmp, s.path); err != nil {
return fmt.Errorf("secrets: rename: %w", err)
}
return nil
}
// load reads the backing file. A missing file yields an empty map (not an
// error) for Get's caller, except Put distinguishes os.ErrNotExist.
func (s *FileStore) load() (map[string]string, error) {
+34
View File
@@ -49,6 +49,40 @@ func TestPutIsOwnerOnly(t *testing.T) {
}
}
func TestDeleteRemovesRefAndLeavesOthers(t *testing.T) {
path := filepath.Join(t.TempDir(), "secrets.json")
s := secrets.NewFileStore(path)
if err := s.Put("youtube/u1/refresh_token", "rt-1"); err != nil {
t.Fatalf("Put: %v", err)
}
if err := s.Put("youtube/u2/refresh_token", "rt-2"); err != nil {
t.Fatalf("Put: %v", err)
}
if err := s.Delete("youtube/u1/refresh_token"); err != nil {
t.Fatalf("Delete: %v", err)
}
// The deleted ref is gone (persisted: re-open from disk)...
s2 := secrets.NewFileStore(path)
if _, err := s2.Get(context.Background(), "youtube/u1/refresh_token"); !errors.Is(err, secrets.ErrNotFound) {
t.Errorf("Get deleted ref: err = %v, want ErrNotFound", err)
}
// ...and the other user's secret survives.
if got, err := s2.Get(context.Background(), "youtube/u2/refresh_token"); err != nil || got != "rt-2" {
t.Errorf("Get surviving ref = (%q, %v), want (%q, nil)", got, err, "rt-2")
}
}
func TestDeleteAbsentRefIsNoop(t *testing.T) {
// Deleting an unknown ref — or from a file that does not exist yet — is a
// no-op, not an error (mirrors store.DeleteConnection semantics).
s := secrets.NewFileStore(filepath.Join(t.TempDir(), "secrets.json"))
if err := s.Delete("missing"); err != nil {
t.Errorf("Delete absent ref: %v, want nil", err)
}
}
func TestPutMergesEntries(t *testing.T) {
path := filepath.Join(t.TempDir(), "secrets.json")
s := secrets.NewFileStore(path)
+50
View File
@@ -0,0 +1,50 @@
package store
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
)
// DeleteUser permanently removes a user and all of their data. It runs through
// withUser so RLS confines every statement to the calling user's own rows.
//
// Deleting the users row cascades (ON DELETE CASCADE) to videos, transcripts,
// summaries (→ sink_deliveries), video_connections, and the user_identities map
// — referential-integrity cascades bypass RLS, so a user's child rows are removed
// even though the deleting connection is scoped. summary_actions is the exception:
// it carries a user_id but has NO foreign key to users (migration 002), so the
// cascade does not reach it; it is deleted explicitly in the same scoped
// transaction. Deleting an absent user is a no-op (idempotent).
//
// This is tapir-side only (decision 2026-06-03): it removes all tapir data; the
// Dex login identity is left untouched — a later login simply re-enters
// registration. The user's secrets (OAuth tokens) live in the SecretStore, not
// the DB, and are removed by the caller (the account handler).
func (s *Store) DeleteUser(ctx context.Context, userID string) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`DELETE FROM summary_actions WHERE user_id = $1`, userID); err != nil {
return fmt.Errorf("store: delete summary_actions: %w", err)
}
if _, err := tx.Exec(ctx,
`DELETE FROM users WHERE id = $1`, userID); err != nil {
return fmt.Errorf("store: delete user: %w", err)
}
return nil
})
}
// DisplayName returns the user's registered display name (empty if unset). Scoped
// by user_id via withUser, like every read in this package.
func (s *Store) DisplayName(ctx context.Context, userID string) (string, error) {
var name string
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx,
`SELECT COALESCE(display_name, '') FROM users WHERE id = $1`, userID).Scan(&name)
}); err != nil {
return "", fmt.Errorf("store: display name: %w", err)
}
return name, nil
}
+106
View File
@@ -0,0 +1,106 @@
package store_test
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
)
// seedIdentity inserts the un-RLS'd dex_subject → user_id mapping for a user, so
// the cascade-on-delete to user_identities can be asserted.
func seedIdentity(t *testing.T, p *pgxpool.Pool, subject, userID string) {
t.Helper()
_, err := p.Exec(context.Background(),
`INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, userID)
require.NoError(t, err)
}
// countFor counts rows owned by userID in table. The users table is keyed on its
// own id; every other isolated table on user_id.
func countFor(t *testing.T, p *pgxpool.Pool, table, userID string) int {
t.Helper()
col := "user_id"
if table == "users" {
col = "id"
}
var n int
require.NoError(t, p.QueryRow(context.Background(),
`SELECT count(*) FROM `+table+` WHERE `+col+` = $1`, userID).Scan(&n))
return n
}
func countDeliveries(t *testing.T, p *pgxpool.Pool, summaryID string) int {
t.Helper()
var n int
require.NoError(t, p.QueryRow(context.Background(),
`SELECT count(*) FROM sink_deliveries WHERE summary_id = $1`, summaryID).Scan(&n))
return n
}
func countIdentities(t *testing.T, p *pgxpool.Pool, userID string) int {
t.Helper()
var n int
require.NoError(t, p.QueryRow(context.Background(),
`SELECT count(*) FROM user_identities WHERE user_id = $1`, userID).Scan(&n))
return n
}
// TestDeleteUserRemovesAllRowsForUserOnly is the account-deletion isolation proof
// (Worker N+M): DeleteUser wipes every row owned by the target user — across the
// cascade-linked tables, the user_identities map (ON DELETE CASCADE), AND
// summary_actions (which has NO FK to users, so the users-row cascade does not
// reach it and DeleteUser must delete it explicitly) — while leaving another
// user's rows completely intact.
func TestDeleteUserRemovesAllRowsForUserOnly(t *testing.T) {
ctx := context.Background()
newStore(t) // apply migrations
super := rawPool(t)
resetDB(t, super)
a := seedUser(t, super, userA)
b := seedUser(t, super, userB)
seedIdentity(t, super, "subject-a", userA)
seedIdentity(t, super, "subject-b", userB)
s := newStore(t)
require.NoError(t, s.DeleteUser(ctx, userA))
// Every user-keyed isolated table: zero rows for A, exactly one for B.
for _, table := range userIsolatedTables {
require.Equal(t, 0, countFor(t, super, table, userA),
"A's %s rows must be deleted", table)
require.Equal(t, 1, countFor(t, super, table, userB),
"B's %s rows must survive A's deletion", table)
}
// sink_deliveries is keyed by summary, not user_id (cascade from summaries).
require.Equal(t, 0, countDeliveries(t, super, a.summaryID), "A's deliveries must cascade-delete")
require.Equal(t, 1, countDeliveries(t, super, b.summaryID), "B's deliveries must survive")
// The cascade must reach user_identities (explicitly asserted per the mission).
require.Equal(t, 0, countIdentities(t, super, userA), "A's identity mapping must cascade-delete")
require.Equal(t, 1, countIdentities(t, super, userB), "B's identity mapping must survive")
}
func TestDeleteUserIsIdempotent(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
// Deleting an absent user is a no-op, not an error.
require.NoError(t, s.DeleteUser(ctx, userA))
}
func TestDisplayNameReturnsRegisteredName(t *testing.T) {
ctx := context.Background()
s := newStore(t)
p := rawPool(t)
resetDB(t, p)
_, err := p.Exec(ctx, `INSERT INTO users (id, display_name) VALUES ($1, $2)`, userA, "Ada")
require.NoError(t, err)
name, err := s.DisplayName(ctx, userA)
require.NoError(t, err)
require.Equal(t, "Ada", name)
}
+16 -15
View File
@@ -3,6 +3,8 @@ package store
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
)
// allowedActions is the closed set of action verbs persisted in summary_actions.
@@ -39,12 +41,7 @@ func (s *Store) SetAction(ctx context.Context, userID, videoID, action string) e
return err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("store: begin set action: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
if opposite, ok := oppositeAction[action]; ok {
if _, err := tx.Exec(ctx,
`DELETE FROM summary_actions
@@ -61,11 +58,8 @@ func (s *Store) SetAction(ctx context.Context, userID, videoID, action string) e
userID, videoID, action); err != nil {
return fmt.Errorf("store: set action: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit set action: %w", err)
}
return nil
})
}
// ClearAction removes an action for (user, video). Clearing an action that is
@@ -74,13 +68,15 @@ func (s *Store) ClearAction(ctx context.Context, userID, videoID, action string)
if err := validateAction(action); err != nil {
return err
}
if _, err := s.pool.Exec(ctx,
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`DELETE FROM summary_actions
WHERE user_id = $1 AND video_id = $2 AND action = $3`,
userID, videoID, action); err != nil {
return fmt.Errorf("store: clear action: %w", err)
}
return nil
})
}
// ActionsFor returns the active actions per video for the given user, keyed by
@@ -91,25 +87,30 @@ func (s *Store) ActionsFor(ctx context.Context, userID string, videoIDs []string
if len(videoIDs) == 0 {
return out, nil
}
rows, err := s.pool.Query(ctx,
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
`SELECT video_id, action FROM summary_actions
WHERE user_id = $1 AND video_id = ANY($2)
ORDER BY video_id, action`,
userID, videoIDs)
if err != nil {
return nil, fmt.Errorf("store: actions for: %w", err)
return fmt.Errorf("store: actions for: %w", err)
}
defer rows.Close()
for rows.Next() {
var videoID, action string
if err := rows.Scan(&videoID, &action); err != nil {
return nil, fmt.Errorf("store: scan action: %w", err)
return fmt.Errorf("store: scan action: %w", err)
}
out[videoID] = append(out[videoID], action)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate actions: %w", err)
return fmt.Errorf("store: iterate actions: %w", err)
}
return nil
}); err != nil {
return nil, err
}
return out, nil
}
+100
View File
@@ -0,0 +1,100 @@
package store
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Connection is one connected video account (data-model.md VIDEO_CONNECTION).
// TokenRef is the opaque SecretStore reference that resolves to the OAuth refresh
// token — never the token itself. ConnectedAt is set by the DB and is read-only
// on writes (UpsertConnection ignores it).
type Connection struct {
Provider string
ProviderAccount string
TokenRef string
Status string
ConnectedAt time.Time
}
// UpsertConnection records (or refreshes) the user's connection to a provider,
// keyed on (user_id, provider): re-connecting the same provider overwrites the
// token_ref/status/account and bumps connected_at, never duplicating. Like every
// access in this package it routes through withUser, so RLS scopes the write to
// the calling user — a connection can only be written for the current user.
func (s *Store) UpsertConnection(ctx context.Context, userID string, c Connection) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`INSERT INTO video_connections
(user_id, provider, provider_account, token_ref, status)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, provider) DO UPDATE SET
provider_account = EXCLUDED.provider_account,
token_ref = EXCLUDED.token_ref,
status = EXCLUDED.status,
connected_at = now()`,
userID, c.Provider, nullIfEmpty(c.ProviderAccount), c.TokenRef, c.Status,
); err != nil {
return fmt.Errorf("store: upsert connection: %w", err)
}
return nil
})
}
// ConnectionsForUser returns the user's connections, most-recently-connected
// first. Scoped by user_id via withUser: one user never sees another's.
func (s *Store) ConnectionsForUser(ctx context.Context, userID string) ([]Connection, error) {
var out []Connection
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
`SELECT provider, COALESCE(provider_account, ''), token_ref, status, connected_at
FROM video_connections
WHERE user_id = $1
ORDER BY connected_at DESC, provider`,
userID)
if err != nil {
return fmt.Errorf("store: connections for user: %w", err)
}
defer rows.Close()
for rows.Next() {
var c Connection
if err := rows.Scan(&c.Provider, &c.ProviderAccount, &c.TokenRef, &c.Status, &c.ConnectedAt); err != nil {
return fmt.Errorf("store: scan connection: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("store: iterate connections: %w", err)
}
return nil
}); err != nil {
return nil, err
}
return out, nil
}
// DeleteConnection removes the user's connection to a provider. Deleting an
// absent connection is a no-op (no error).
func (s *Store) DeleteConnection(ctx context.Context, userID, provider string) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`DELETE FROM video_connections WHERE user_id = $1 AND provider = $2`,
userID, provider); err != nil {
return fmt.Errorf("store: delete connection: %w", err)
}
return nil
})
}
// nullIfEmpty maps "" to a SQL NULL so an unknown provider_account is stored as
// NULL (the column is nullable) rather than an empty string.
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
+111
View File
@@ -0,0 +1,111 @@
package store_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
// seedUserRow inserts a bare users row (FK target for a connection) as the
// superuser pool, which bypasses RLS.
func seedUserRow(t *testing.T, userID string) {
t.Helper()
_, err := rawPool(t).Exec(context.Background(),
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)
require.NoError(t, err)
}
func TestUpsertConnectionInsertsRow(t *testing.T) {
ctx := context.Background()
s := newStore(t)
p := rawPool(t)
resetDB(t, p)
seedUserRow(t, userA)
require.NoError(t, s.UpsertConnection(ctx, userA, store.Connection{
Provider: "youtube",
ProviderAccount: "chan@example.com",
TokenRef: "youtube/" + userA + "/refresh_token",
Status: "active",
}))
conns, err := s.ConnectionsForUser(ctx, userA)
require.NoError(t, err)
require.Len(t, conns, 1)
require.Equal(t, "youtube", conns[0].Provider)
require.Equal(t, "chan@example.com", conns[0].ProviderAccount)
require.Equal(t, "youtube/"+userA+"/refresh_token", conns[0].TokenRef)
require.Equal(t, "active", conns[0].Status)
require.False(t, conns[0].ConnectedAt.IsZero(), "connected_at set by the DB default")
}
func TestUpsertConnectionIsIdempotentOnUserProvider(t *testing.T) {
ctx := context.Background()
s := newStore(t)
p := rawPool(t)
resetDB(t, p)
seedUserRow(t, userA)
require.NoError(t, s.UpsertConnection(ctx, userA, store.Connection{
Provider: "youtube", TokenRef: "ref-1", Status: "active",
}))
// Re-connect the same provider: must update in place, not duplicate.
require.NoError(t, s.UpsertConnection(ctx, userA, store.Connection{
Provider: "youtube", TokenRef: "ref-2", Status: "revoked",
}))
var count int
require.NoError(t, p.QueryRow(ctx,
`SELECT count(*) FROM video_connections WHERE user_id = $1 AND provider = 'youtube'`,
userA).Scan(&count))
require.Equal(t, 1, count, "second connect must update, not duplicate")
conns, err := s.ConnectionsForUser(ctx, userA)
require.NoError(t, err)
require.Len(t, conns, 1)
require.Equal(t, "ref-2", conns[0].TokenRef, "token_ref overwritten")
require.Equal(t, "revoked", conns[0].Status, "status overwritten")
}
func TestDeleteConnection(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
seedUserRow(t, userA)
require.NoError(t, s.UpsertConnection(ctx, userA, store.Connection{
Provider: "youtube", TokenRef: "ref", Status: "active",
}))
require.NoError(t, s.DeleteConnection(ctx, userA, "youtube"))
conns, err := s.ConnectionsForUser(ctx, userA)
require.NoError(t, err)
require.Empty(t, conns)
// Deleting an absent connection is a no-op, not an error.
require.NoError(t, s.DeleteConnection(ctx, userA, "youtube"))
}
func TestConnectionsForUserIsScoped(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
seedUserRow(t, userA)
seedUserRow(t, userB)
require.NoError(t, s.UpsertConnection(ctx, userA, store.Connection{
Provider: "youtube", TokenRef: "a-ref", Status: "active",
}))
// User B must not see user A's connection.
connsB, err := s.ConnectionsForUser(ctx, userB)
require.NoError(t, err)
require.Empty(t, connsB, "user B must not see user A's connections")
connsA, err := s.ConnectionsForUser(ctx, userA)
require.NoError(t, err)
require.Len(t, connsA, 1)
}
+91
View File
@@ -0,0 +1,91 @@
package store
import (
"context"
"crypto/rand"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
// ErrSubjectRegistered is returned by RegisterUser when the Dex subject already
// maps to a tapir user. Registration is explicit and once-per-subject (ADR-012).
var ErrSubjectRegistered = errors.New("store: subject already registered")
// UserBySubject resolves a Dex subject to its tapir user_id via the un-RLS'd
// user_identities map. It runs as a plain pool query WITHOUT withUser: this is
// the pre-scope lookup whose result becomes the GUC for every subsequent
// user-scoped access, so it cannot itself depend on that GUC being set. found is
// false (no error) when the subject has no mapping yet — the caller routes such
// requests to registration.
func (s *Store) UserBySubject(ctx context.Context, subject string) (userID string, found bool, err error) {
err = s.pool.QueryRow(ctx,
`SELECT user_id FROM user_identities WHERE dex_subject = $1`, subject).Scan(&userID)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("store: user by subject: %w", err)
}
return userID, true, nil
}
// RegisterUser creates the tapir user for a Dex subject and the identity mapping
// that points to it, returning the new user_id. It errors with
// ErrSubjectRegistered if the subject already maps.
//
// Bootstrapping note (generate-uuid-then-scope): the users table is FORCE'd RLS
// with a WITH CHECK that defaults to the USING predicate id =
// current_setting('tapir.current_user_id') (migration 003). A users row can
// therefore only be inserted while the connection is ALREADY scoped to that
// row's own id — a chicken-and-egg if the id were DB-generated. So we generate
// the UUID app-side, scope to it via withUser(newID, ...), and insert the users
// row inside that scope so the WITH CHECK passes. The user_identities row is
// un-RLS'd auth plumbing; it is written in the SAME transaction so a user and
// its mapping are always consistent.
func (s *Store) RegisterUser(ctx context.Context, subject, displayName string) (userID string, err error) {
newID, err := newUUIDv4()
if err != nil {
return "", fmt.Errorf("store: register user: %w", err)
}
// Fast, clear rejection of a re-registration. The dex_subject PRIMARY KEY is
// the authoritative guard (a concurrent insert would still violate it); this
// check just turns the common case into a meaningful error instead of a raw
// constraint violation.
if _, found, err := s.UserBySubject(ctx, subject); err != nil {
return "", err
} else if found {
return "", fmt.Errorf("%w: %q", ErrSubjectRegistered, subject)
}
if err := s.withUser(ctx, newID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`INSERT INTO users (id, display_name) VALUES ($1, $2)`, newID, displayName); err != nil {
return fmt.Errorf("store: insert user: %w", err)
}
if _, err := tx.Exec(ctx,
`INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`,
subject, newID); err != nil {
return fmt.Errorf("store: insert identity: %w", err)
}
return nil
}); err != nil {
return "", err
}
return newID, nil
}
// newUUIDv4 returns a random RFC-4122 v4 UUID string. Generated app-side (stdlib
// crypto/rand, no new dependency) so the id is known before the row is scoped and
// inserted — see RegisterUser's bootstrapping note.
func newUUIDv4() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generate uuid: %w", err)
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
+103
View File
@@ -0,0 +1,103 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
const (
subjectA = "dex|alice-123"
subjectB = "dex|bob-456"
)
func TestUserBySubjectUnknownReturnsNotFound(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
id, found, err := s.UserBySubject(ctx, subjectA)
require.NoError(t, err)
require.False(t, found)
require.Empty(t, id)
}
func TestRegisterUserCreatesUserAndIdentity(t *testing.T) {
ctx := context.Background()
s := newStore(t)
p := rawPool(t)
resetDB(t, p)
id, err := s.RegisterUser(ctx, subjectA, "Alice")
require.NoError(t, err)
require.NotEmpty(t, id)
// Exactly one users row with the returned id and the given display name.
var users int
var name string
require.NoError(t, p.QueryRow(ctx,
`SELECT count(*), coalesce(max(display_name), '') FROM users WHERE id = $1`, id).
Scan(&users, &name))
require.Equal(t, 1, users)
require.Equal(t, "Alice", name)
// Exactly one identity row mapping the subject to that id.
var idents int
require.NoError(t, p.QueryRow(ctx,
`SELECT count(*) FROM user_identities WHERE dex_subject = $1 AND user_id = $2`,
subjectA, id).Scan(&idents))
require.Equal(t, 1, idents)
// And it now resolves straight through.
got, found, err := s.UserBySubject(ctx, subjectA)
require.NoError(t, err)
require.True(t, found)
require.Equal(t, id, got)
}
func TestRegisterUserRejectsDuplicateSubject(t *testing.T) {
ctx := context.Background()
s := newStore(t)
p := rawPool(t)
resetDB(t, p)
first, err := s.RegisterUser(ctx, subjectA, "Alice")
require.NoError(t, err)
_, err = s.RegisterUser(ctx, subjectA, "Alice Again")
require.Error(t, err)
require.True(t, errors.Is(err, store.ErrSubjectRegistered))
// No second user was created; the original mapping is intact.
var users, idents int
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users))
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM user_identities`).Scan(&idents))
require.Equal(t, 1, users)
require.Equal(t, 1, idents)
got, found, err := s.UserBySubject(ctx, subjectA)
require.NoError(t, err)
require.True(t, found)
require.Equal(t, first, got)
}
func TestRegisterUserDistinctSubjectsGetDistinctUsers(t *testing.T) {
ctx := context.Background()
s := newStore(t)
p := rawPool(t)
resetDB(t, p)
idA, err := s.RegisterUser(ctx, subjectA, "Alice")
require.NoError(t, err)
idB, err := s.RegisterUser(ctx, subjectB, "Bob")
require.NoError(t, err)
require.NotEqual(t, idA, idB)
var users int
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users))
require.Equal(t, 2, users)
}
@@ -0,0 +1,23 @@
DROP POLICY IF EXISTS sink_deliveries_isolation ON sink_deliveries;
ALTER TABLE sink_deliveries NO FORCE ROW LEVEL SECURITY;
ALTER TABLE sink_deliveries DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS summary_actions_isolation ON summary_actions;
ALTER TABLE summary_actions NO FORCE ROW LEVEL SECURITY;
ALTER TABLE summary_actions DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS summaries_isolation ON summaries;
ALTER TABLE summaries NO FORCE ROW LEVEL SECURITY;
ALTER TABLE summaries DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS transcripts_isolation ON transcripts;
ALTER TABLE transcripts NO FORCE ROW LEVEL SECURITY;
ALTER TABLE transcripts DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS videos_isolation ON videos;
ALTER TABLE videos NO FORCE ROW LEVEL SECURITY;
ALTER TABLE videos DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS users_isolation ON users;
ALTER TABLE users NO FORCE ROW LEVEL SECURITY;
ALTER TABLE users DISABLE ROW LEVEL SECURITY;
@@ -0,0 +1,68 @@
-- Migration 003: enforce per-user isolation at the DB layer via row-level
-- security (ADR-012, data-model.md "Isolation invariant"). Stage 1 ships
-- multi-user WITH this enforcement; it is the proof that user A cannot read or
-- write user B's rows even if application-level WHERE clauses are wrong.
--
-- How it works:
-- * Every policy keys off the per-request GUC tapir.current_user_id, set by the
-- store's withUser helper via set_config('tapir.current_user_id', $1, true)
-- (transaction-local — auto-reset on commit/rollback, never leaks across a
-- pooled connection's requests).
-- * current_setting('tapir.current_user_id', true) uses missing_ok = true: an
-- UNSET GUC yields NULL, so the predicate is NULL → no rows match → deny-all.
-- That is the safe default and is asserted in rls_test.go.
-- * FORCE ROW LEVEL SECURITY: the app connects as the table OWNER (tapir), and
-- owners BYPASS RLS unless forced. Without FORCE the policies below are dead
-- for the production user. FORCE makes the owner subject to them. (A superuser
-- DSN still bypasses RLS regardless — the test connects as a non-superuser,
-- non-BYPASSRLS role so the enforcement is real, not theatre.)
-- users: the row's own id IS the user_id for this table.
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE users FORCE ROW LEVEL SECURITY;
CREATE POLICY users_isolation ON users
FOR ALL
USING (id = current_setting('tapir.current_user_id', true)::uuid);
ALTER TABLE videos ENABLE ROW LEVEL SECURITY;
ALTER TABLE videos FORCE ROW LEVEL SECURITY;
CREATE POLICY videos_isolation ON videos
FOR ALL
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
ALTER TABLE transcripts ENABLE ROW LEVEL SECURITY;
ALTER TABLE transcripts FORCE ROW LEVEL SECURITY;
CREATE POLICY transcripts_isolation ON transcripts
FOR ALL
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
ALTER TABLE summaries ENABLE ROW LEVEL SECURITY;
ALTER TABLE summaries FORCE ROW LEVEL SECURITY;
CREATE POLICY summaries_isolation ON summaries
FOR ALL
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
ALTER TABLE summary_actions ENABLE ROW LEVEL SECURITY;
ALTER TABLE summary_actions FORCE ROW LEVEL SECURITY;
CREATE POLICY summary_actions_isolation ON summary_actions
FOR ALL
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
-- sink_deliveries has NO user_id of its own; ownership is derived from the
-- summary it belongs to. We key the policy directly off the GUC via EXISTS
-- (rather than `summary_id IN (SELECT id FROM summaries)`) so it is self-contained
-- and does not silently depend on summaries' own RLS being applied to the
-- subquery. The WITH CHECK clause (defaulting to USING under FOR ALL) means a
-- delivery row can only be inserted/updated when its summary is owned by the
-- current user.
ALTER TABLE sink_deliveries ENABLE ROW LEVEL SECURITY;
ALTER TABLE sink_deliveries FORCE ROW LEVEL SECURITY;
CREATE POLICY sink_deliveries_isolation ON sink_deliveries
FOR ALL
USING (
EXISTS (
SELECT 1 FROM summaries s
WHERE s.id = sink_deliveries.summary_id
AND s.user_id = current_setting('tapir.current_user_id', true)::uuid
)
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS user_identities;
@@ -0,0 +1,22 @@
-- Migration 004: the Dex-subject → tapir-user map (ADR-012 Stage 1, multi-user).
-- A Dex-authenticated subject is the login identity; the tapir user_id (UUID) is
-- what every user-owned, force-RLS table keys off. This table is the bridge:
-- resolve subject → user_id here (auth plumbing, pre-scope), THEN scope all data
-- access by that id via the store's withUser helper.
--
-- INTENTIONALLY NOT RLS-ENABLED. The forced-RLS isolation (migration 003) guards
-- the user-OWNED data tables. user_identities holds no user data — only an opaque
-- (dex_subject ↔ user_id) pair — and must be readable BEFORE a user_id is known
-- (that lookup is what yields the id used to set tapir.current_user_id). Putting
-- RLS here would be a chicken-and-egg deadlock (you'd need the GUC to read the row
-- that tells you the GUC). Data isolation lives on the user-owned tables, not here.
CREATE TABLE user_identities (
dex_subject TEXT PRIMARY KEY,
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE user_identities IS
'Dex subject -> tapir user_id map. Auth plumbing, deliberately NOT RLS-enabled '
'(no user data; must be read pre-scope to resolve the id used for RLS). '
'ON DELETE CASCADE so deleting a user cleans up its identity mapping.';
@@ -0,0 +1 @@
DROP TABLE IF EXISTS video_connections;
@@ -0,0 +1,30 @@
-- Migration 005: video_connections — a user's connected video account
-- (data-model.md VIDEO_CONNECTION). The OAuth refresh token never lives here;
-- token_ref is the opaque SecretStore reference that resolves to it. Revocation
-- flips status, it does not delete the row (history is kept).
--
-- One connection per (user, provider): re-connecting the same provider upserts
-- in place (the connect flow's ON CONFLICT (user_id, provider) target).
CREATE TABLE video_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_account TEXT,
token_ref TEXT NOT NULL,
status TEXT NOT NULL,
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT video_connections_user_provider_unique UNIQUE (user_id, provider)
);
CREATE INDEX idx_video_connections_user_id ON video_connections(user_id);
-- Per-user isolation, identical to migration 003's pattern: this is user-owned
-- data, so user A must never read or write user B's connections even with a wrong
-- application-level WHERE. ENABLE + FORCE so the table owner (tapir) is subject to
-- the policy too; the policy keys off the per-request GUC tapir.current_user_id
-- set by the store's withUser helper. An unset GUC yields NULL -> deny-all.
ALTER TABLE video_connections ENABLE ROW LEVEL SECURITY;
ALTER TABLE video_connections FORCE ROW LEVEL SECURITY;
CREATE POLICY video_connections_isolation ON video_connections
FOR ALL
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
@@ -0,0 +1,2 @@
ALTER TABLE videos DROP COLUMN IF EXISTS summarize_requested;
ALTER TABLE users DROP COLUMN IF EXISTS auto_summarize;
@@ -0,0 +1,17 @@
-- Migration 006: summarization mode (per-user auto/manual + per-video queue).
--
-- auto_summarize is a per-user setting (not a global one): multi-user ready per
-- ADR-012. FALSE default makes MANUAL the out-of-the-box behavior — `tapir run`
-- discovers new videos but only summarizes the ones the user explicitly queued.
--
-- summarize_requested is the per-video manual queue flag. The web "Summarize"
-- button sets it TRUE; the next `tapir run` picks it up, summarizes, and clears
-- it back to FALSE. In auto mode it is unused.
--
-- No RLS policy changes needed: both columns are added to tables that already
-- carry user_id and have ENABLE + FORCE ROW LEVEL SECURITY (migration 003). A new
-- column on an RLS-protected table inherits that protection automatically — the
-- existing users_isolation / videos_isolation policies gate every row, so these
-- columns are only ever readable/writable for the row's own user.
ALTER TABLE users ADD COLUMN auto_summarize BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE videos ADD COLUMN summarize_requested BOOLEAN NOT NULL DEFAULT FALSE;
+197 -16
View File
@@ -26,6 +26,7 @@ var ErrNotFound = errors.New("store: summary not found")
// JOIN source — callers already fall back gracefully on an empty Channel.
type SummaryRow struct {
VideoID string
ProviderVideoID string // videos.provider_video_id; empty when no videos row
Title string // videos.title; empty when no videos row
Channel string // videos.provider for now; empty when no videos row
URL string // videos.url; empty when no videos row
@@ -38,6 +39,16 @@ type SummaryRow struct {
FallbackUsed bool
CreatedAt time.Time
Actions []string // current active actions for this video; nil when none
// Summarized reports whether a summary exists for this video. The summary-only
// reads (ListSummaries/GetSummaryByVideo) always yield true; the all-videos
// read (ListVideos) yields false for a discovered-but-unsummarized video, whose
// Summary/Highlights/AIProvider fields are then empty.
Summarized bool
// SummarizeRequested reflects videos.summarize_requested: the manual queue flag
// set by the web "Summarize" button and cleared by the next `tapir run`. Only
// populated by ListVideos/GetVideoRow (summary-only reads leave it false).
SummarizeRequested bool
}
// selectSummary is the shared projection for both reads. videos is LEFT JOINed
@@ -45,6 +56,7 @@ type SummaryRow struct {
// crosses users and a missing videos row yields nulls, not a dropped summary.
const selectSummary = `
SELECT s.video_id,
COALESCE(v.provider_video_id, ''),
COALESCE(v.title, ''),
COALESCE(v.provider, ''),
COALESCE(v.url, ''),
@@ -66,27 +78,32 @@ func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]
if limit <= 0 {
limit = 50
}
rows, err := s.pool.Query(ctx,
var out []SummaryRow
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
selectSummary+`
WHERE s.user_id = $1
ORDER BY s.created_at DESC
LIMIT $2`,
userID, limit)
if err != nil {
return nil, fmt.Errorf("store: list summaries: %w", err)
return fmt.Errorf("store: list summaries: %w", err)
}
defer rows.Close()
var out []SummaryRow
for rows.Next() {
row, err := scanSummaryRow(rows)
if err != nil {
return nil, err
return err
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate summaries: %w", err)
return fmt.Errorf("store: iterate summaries: %w", err)
}
return nil
}); err != nil {
return nil, err
}
if err := s.attachActions(ctx, userID, out); err != nil {
return nil, err
@@ -94,29 +111,192 @@ func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]
return out, nil
}
// GetSummaryByVideo returns the full summary for (userID, videoID), including
// highlights and takeaways. Returns ErrNotFound when the user has no such
// summary. Scoped by user_id.
func (s *Store) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*SummaryRow, error) {
rows, err := s.pool.Query(ctx,
selectSummary+`
WHERE s.user_id = $1 AND s.video_id = $2`,
// selectVideo is the all-videos projection: it drives from the videos table and
// LEFT JOINs the (at most one) summary, so a discovered-but-unsummarized video
// still appears with empty summary fields. The column order mirrors selectSummary
// for the shared fields, then appends summarized + summarize_requested. created_at
// falls back to the video's seen_at when there is no summary, so the read-side row
// always carries a sortable timestamp.
const selectVideo = `
SELECT v.id,
v.provider_video_id,
COALESCE(v.title, ''),
v.provider,
COALESCE(v.url, ''),
v.published_at,
COALESCE(s.summary, ''),
s.highlights,
s.takeaways,
COALESCE(s.ai_provider, ''),
COALESCE(s.ai_model, ''),
COALESCE(s.fallback_used, FALSE),
COALESCE(s.created_at, v.seen_at),
(s.id IS NOT NULL) AS summarized,
v.summarize_requested
FROM videos v
LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id`
// ListVideos returns ALL of the user's videos — summarized and not — most recent
// first by seen_at, capped at limit (non-positive defaults to 50). Unsummarized
// videos come back with Summarized=false and empty summary fields, so the list
// view can render them with a "Summarize" affordance. Scoped by user_id.
func (s *Store) ListVideos(ctx context.Context, userID string, limit int) ([]SummaryRow, error) {
if limit <= 0 {
limit = 50
}
var out []SummaryRow
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
selectVideo+`
WHERE v.user_id = $1
ORDER BY v.seen_at DESC
LIMIT $2`,
userID, limit)
if err != nil {
return fmt.Errorf("store: list videos: %w", err)
}
defer rows.Close()
for rows.Next() {
row, err := scanVideoRow(rows)
if err != nil {
return err
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("store: iterate videos: %w", err)
}
return nil
}); err != nil {
return nil, err
}
if err := s.attachActions(ctx, userID, out); err != nil {
return nil, err
}
return out, nil
}
// GetVideoRow returns a single video row (summarized or not) for (userID,
// videoID), used to re-render one card after queuing it. Returns ErrNotFound when
// the user has no such video. Scoped by user_id.
func (s *Store) GetVideoRow(ctx context.Context, userID, videoID string) (*SummaryRow, error) {
var (
row SummaryRow
found bool
)
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
selectVideo+`
WHERE v.user_id = $1 AND v.id = $2`,
userID, videoID)
if err != nil {
return nil, fmt.Errorf("store: get summary: %w", err)
return fmt.Errorf("store: get video: %w", err)
}
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: get summary: %w", err)
return fmt.Errorf("store: get video: %w", err)
}
return nil
}
row, err = scanVideoRow(rows)
if err != nil {
return err
}
found = true
return nil
}); err != nil {
return nil, err
}
if !found {
return nil, ErrNotFound
}
row, err := scanSummaryRow(rows)
if err != nil {
holder := []SummaryRow{row}
if err := s.attachActions(ctx, userID, holder); err != nil {
return nil, err
}
return &holder[0], nil
}
// scanVideoRow reads one row in the selectVideo column order. published_at is
// nullable so it scans through a pointer.
func scanVideoRow(rows pgx.Row) (SummaryRow, error) {
var (
row SummaryRow
highlights []byte
takeaways []byte
publishedAt *time.Time
)
if err := rows.Scan(
&row.VideoID,
&row.ProviderVideoID,
&row.Title,
&row.Channel,
&row.URL,
&publishedAt,
&row.Summary,
&highlights,
&takeaways,
&row.AIProvider,
&row.AIModel,
&row.FallbackUsed,
&row.CreatedAt,
&row.Summarized,
&row.SummarizeRequested,
); err != nil {
return SummaryRow{}, fmt.Errorf("store: scan video: %w", err)
}
if publishedAt != nil {
row.PublishedAt = *publishedAt
}
var err error
if row.Highlights, err = unmarshalList(highlights); err != nil {
return SummaryRow{}, fmt.Errorf("store: unmarshal highlights: %w", err)
}
if row.Takeaways, err = unmarshalList(takeaways); err != nil {
return SummaryRow{}, fmt.Errorf("store: unmarshal takeaways: %w", err)
}
return row, nil
}
// GetSummaryByVideo returns the full summary for (userID, videoID), including
// highlights and takeaways. Returns ErrNotFound when the user has no such
// summary. Scoped by user_id.
func (s *Store) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*SummaryRow, error) {
var (
row SummaryRow
found bool
)
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
selectSummary+`
WHERE s.user_id = $1 AND s.video_id = $2`,
userID, videoID)
if err != nil {
return fmt.Errorf("store: get summary: %w", err)
}
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
return fmt.Errorf("store: get summary: %w", err)
}
return nil
}
row, err = scanSummaryRow(rows)
if err != nil {
return err
}
found = true
return nil
}); err != nil {
return nil, err
}
if !found {
return nil, ErrNotFound
}
holder := []SummaryRow{row}
if err := s.attachActions(ctx, userID, holder); err != nil {
return nil, err
@@ -135,6 +315,7 @@ func scanSummaryRow(rows pgx.Row) (SummaryRow, error) {
)
if err := rows.Scan(
&row.VideoID,
&row.ProviderVideoID,
&row.Title,
&row.Channel,
&row.URL,
+228
View File
@@ -0,0 +1,228 @@
package store_test
import (
"context"
"fmt"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
)
// This is the isolation proof for ADR-012: per-user isolation is enforced by the
// database (migration 003 RLS policies), not merely by application WHERE clauses.
//
// CRITICAL: embedded-postgres's default user (postgres) is a SUPERUSER, which
// BYPASSES RLS regardless of FORCE ROW LEVEL SECURITY. A test that ran scoped
// queries as postgres would be fake-green — it would pass even with the policies
// removed. So this test creates a dedicated NON-SUPERUSER, non-BYPASSRLS role
// ("app", mirroring the production table-owner role tapir which FORCE subjects to
// RLS) and runs every scoped query as that role. The deny-all sanity check below
// (no GUC set → zero rows) proves the enforcement path is live, not bypassed.
// userIsolatedTables are the tables that carry a user_id and whose policy keys
// directly off the tapir.current_user_id GUC.
var userIsolatedTables = []string{
"users", "videos", "transcripts", "summaries", "summary_actions", "video_connections",
}
// allIsolatedTables adds sink_deliveries, whose ownership is derived from its
// summary (no user_id column of its own).
var allIsolatedTables = append(append([]string{}, userIsolatedTables...), "sink_deliveries")
// seeded captures the DB-generated ids for one user's row chain.
type seeded struct {
userID string
videoID string // videos.id (UUID), reused as summaries.video_id
summaryID string
}
// seedUser inserts one full chain (user → video → transcript → summary →
// action → delivery) as the superuser pool, which bypasses RLS so both users'
// data lands regardless of the GUC.
func seedUser(t *testing.T, p *pgxpool.Pool, userID string) seeded {
t.Helper()
ctx := context.Background()
_, err := p.Exec(ctx, `INSERT INTO users (id) VALUES ($1)`, userID)
require.NoError(t, err)
var videoID string
require.NoError(t, p.QueryRow(ctx,
`INSERT INTO videos (user_id, provider, provider_video_id, title)
VALUES ($1, 'youtube', $2, 'title') RETURNING id`,
userID, "vid-"+userID).Scan(&videoID))
_, err = p.Exec(ctx,
`INSERT INTO transcripts (video_id, user_id, source, content)
VALUES ($1, $2, 'captions', 'words')`, videoID, userID)
require.NoError(t, err)
var summaryID string
require.NoError(t, p.QueryRow(ctx,
`INSERT INTO summaries (user_id, video_id, summary) VALUES ($1, $2, 'sum')
RETURNING id`, userID, videoID).Scan(&summaryID))
_, err = p.Exec(ctx,
`INSERT INTO summary_actions (user_id, video_id, action)
VALUES ($1, $2, 'watched')`, userID, videoID)
require.NoError(t, err)
_, err = p.Exec(ctx,
`INSERT INTO sink_deliveries (summary_id, sink, status)
VALUES ($1, 'store', 'delivered')`, summaryID)
require.NoError(t, err)
_, err = p.Exec(ctx,
`INSERT INTO video_connections (user_id, provider, token_ref, status)
VALUES ($1, 'youtube', $2, 'active')`, userID, "youtube/"+userID+"/refresh_token")
require.NoError(t, err)
return seeded{userID: userID, videoID: videoID, summaryID: summaryID}
}
// appPool creates a non-superuser role with DML grants and returns a pool
// connected AS that role, so RLS is actually enforced for it.
func appPool(t *testing.T, super *pgxpool.Pool) *pgxpool.Pool {
t.Helper()
ctx := context.Background()
// Idempotent across test runs (schema/role persist for the TestMain PG).
_, _ = super.Exec(ctx, `DROP ROLE IF EXISTS app`)
_, err := super.Exec(ctx, `CREATE ROLE app LOGIN PASSWORD 'app'`)
require.NoError(t, err)
_, err = super.Exec(ctx, `GRANT USAGE ON SCHEMA public TO app`)
require.NoError(t, err)
_, err = super.Exec(ctx,
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app`)
require.NoError(t, err)
appDSN := strings.Replace(dsn, "postgres:postgres@", "app:app@", 1)
p, err := pgxpool.New(ctx, appDSN)
require.NoError(t, err)
t.Cleanup(p.Close)
// Sanity: the app role must NOT be a superuser / must not bypass RLS, else
// this whole test is theatre.
var isSuper bool
require.NoError(t, p.QueryRow(ctx,
`SELECT rolsuper FROM pg_roles WHERE rolname = current_user`).Scan(&isSuper))
require.False(t, isSuper, "app role must be non-superuser or RLS is bypassed")
return p
}
// scopedCount counts rows in table as the app role, optionally scoped to a user
// via the transaction-local GUC. An empty scope sets no GUC (deny-all path).
func scopedCount(t *testing.T, p *pgxpool.Pool, scope, table string) int {
t.Helper()
ctx := context.Background()
tx, err := p.Begin(ctx)
require.NoError(t, err)
defer tx.Rollback(ctx) //nolint:errcheck
if scope != "" {
_, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, scope)
require.NoError(t, err)
}
var n int
require.NoError(t, tx.QueryRow(ctx, `SELECT count(*) FROM `+table).Scan(&n))
return n
}
// scopedRowsAffected runs a write as the app role scoped to scope and returns the
// rows affected, so we can assert a cross-user write touches zero rows.
func scopedRowsAffected(t *testing.T, p *pgxpool.Pool, scope, sql string, args ...any) int64 {
t.Helper()
ctx := context.Background()
tx, err := p.Begin(ctx)
require.NoError(t, err)
defer tx.Rollback(ctx) //nolint:errcheck
_, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, scope)
require.NoError(t, err)
ct, err := tx.Exec(ctx, sql, args...)
require.NoError(t, err) // RLS hides the rows; it is NOT a permission error
require.NoError(t, tx.Commit(ctx))
return ct.RowsAffected()
}
func TestRLSEnforcesPerUserIsolation(t *testing.T) {
newStore(t) // apply migrations (incl. 003 RLS) as superuser
super := rawPool(t)
resetDB(t, super)
a := seedUser(t, super, userA)
b := seedUser(t, super, userB)
app := appPool(t, super)
// 1. Deny-all: with NO GUC set, every isolated table returns zero rows. This
// proves RLS is actually ON (a bypassed/superuser path would see all rows).
for _, table := range allIsolatedTables {
require.Equal(t, 0, scopedCount(t, app, "", table),
"unset tapir.current_user_id must yield deny-all on %s", table)
}
// 2. Scoped reads: A sees exactly its own one row per table; likewise B. A
// seeing B's row (or vice versa) would mean isolation is broken.
for _, table := range allIsolatedTables {
require.Equal(t, 1, scopedCount(t, app, userA, table),
"user A scoped read must see exactly its own row in %s", table)
require.Equal(t, 1, scopedCount(t, app, userB, table),
"user B scoped read must see exactly its own row in %s", table)
}
// 3. Cross-user writes are invisible: scoped to A, an UPDATE/DELETE aimed at
// B's rows affects zero rows (RLS hides them from the write, too).
writes := []struct {
name string
sql string
arg any // identifies B's row(s)
}{
{"update users", `UPDATE users SET display_name = 'hacked' WHERE id = $1`, b.userID},
{"update videos", `UPDATE videos SET title = 'hacked' WHERE user_id = $1`, b.userID},
{"queue videos summarize", `UPDATE videos SET summarize_requested = TRUE WHERE id = $1`, b.videoID},
{"update transcripts", `UPDATE transcripts SET content = 'hacked' WHERE user_id = $1`, b.userID},
{"update summaries", `UPDATE summaries SET summary = 'hacked' WHERE user_id = $1`, b.userID},
{"update summary_actions", `UPDATE summary_actions SET action = 'skipped' WHERE user_id = $1`, b.userID},
{"update sink_deliveries", `UPDATE sink_deliveries SET status = 'hacked' WHERE summary_id = $1`, b.summaryID},
{"update video_connections", `UPDATE video_connections SET token_ref = 'hacked' WHERE user_id = $1`, b.userID},
{"delete summaries", `DELETE FROM summaries WHERE user_id = $1`, b.userID},
{"delete summary_actions", `DELETE FROM summary_actions WHERE user_id = $1`, b.userID},
{"delete sink_deliveries", `DELETE FROM sink_deliveries WHERE summary_id = $1`, b.summaryID},
{"delete video_connections", `DELETE FROM video_connections WHERE user_id = $1`, b.userID},
}
for _, w := range writes {
require.Equal(t, int64(0), scopedRowsAffected(t, app, userA, w.sql, w.arg),
"user A scoped %s must touch zero of user B's rows", w.name)
}
// 4. B's rows survived unchanged (the writes above neither modified nor
// deleted them), verified via the superuser pool which bypasses RLS.
ctx := context.Background()
var bSummary string
require.NoError(t, super.QueryRow(ctx,
`SELECT summary FROM summaries WHERE user_id = $1`, b.userID).Scan(&bSummary))
require.Equal(t, "sum", bSummary, "B's summary must be untouched by A's writes")
var bSummaries, bActions, bDeliveries, bConnections int
require.NoError(t, super.QueryRow(ctx,
`SELECT count(*) FROM summaries WHERE user_id = $1`, b.userID).Scan(&bSummaries))
require.NoError(t, super.QueryRow(ctx,
`SELECT count(*) FROM summary_actions WHERE user_id = $1`, b.userID).Scan(&bActions))
require.NoError(t, super.QueryRow(ctx,
fmt.Sprintf(`SELECT count(*) FROM sink_deliveries WHERE summary_id = '%s'`, b.summaryID)).Scan(&bDeliveries))
require.NoError(t, super.QueryRow(ctx,
`SELECT count(*) FROM video_connections WHERE user_id = $1 AND token_ref <> 'hacked'`, b.userID).Scan(&bConnections))
require.Equal(t, 1, bSummaries, "A's DELETE must not have removed B's summary")
require.Equal(t, 1, bActions, "A's DELETE must not have removed B's action")
require.Equal(t, 1, bDeliveries, "A's DELETE must not have removed B's delivery")
require.Equal(t, 1, bConnections, "A's writes must not have touched B's connection")
var bRequested bool
require.NoError(t, super.QueryRow(ctx,
`SELECT summarize_requested FROM videos WHERE user_id = $1`, b.userID).Scan(&bRequested))
require.False(t, bRequested, "A scoped must not have queued B's video for summarization")
_ = a // a's ids are seeded for the symmetric read assertions above
}
+59 -19
View File
@@ -19,6 +19,7 @@ import (
"github.com/golang-migrate/migrate/v4"
migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver for migrate
@@ -90,6 +91,46 @@ func (s *Store) Close() {
// Name identifies this sink in delivery records.
func (s *Store) Name() string { return "store" }
// withUser is the single choke point through which EVERY DB access in this
// package flows, so per-user isolation is structural — not a per-query opt-in
// someone can forget. It:
//
// - BEGINs a transaction,
// - sets the per-request GUC tapir.current_user_id via
// set_config('tapir.current_user_id', $1, true). The set_config form is used
// instead of `SET LOCAL` because it is parameterizable (SET cannot bind a
// value through the driver); the third arg true = local = transaction-scoped,
// so it auto-resets on commit/rollback and a pooled connection never leaks one
// request's user into the next,
// - runs fn against that transaction,
// - COMMITs (or ROLLBACKs on error).
//
// The migration-003 RLS policies key off this GUC: a row is visible/writable only
// when its owner = current_setting('tapir.current_user_id'). RLS enforces only
// when the app connects as a non-superuser, non-BYPASSRLS role (in production the
// table owner tapir, made subject via FORCE ROW LEVEL SECURITY). A superuser DSN
// bypasses RLS regardless — see rls_test.go, which connects as a dedicated
// non-superuser role to prove the enforcement is real.
func (s *Store) withUser(ctx context.Context, userID string, fn func(pgx.Tx) error) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("store: begin: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
if _, err := tx.Exec(ctx,
`SELECT set_config('tapir.current_user_id', $1, true)`, userID); err != nil {
return fmt.Errorf("store: scope user: %w", err)
}
if err := fn(tx); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit: %w", err)
}
return nil
}
// Deliver upserts the summary idempotently on (user_id, video_id) and records the
// store delivery. Re-delivering the same summary updates in place — it never
// errors or duplicates. The whole write is one transaction so a summary and its
@@ -104,14 +145,9 @@ func (s *Store) Deliver(ctx context.Context, sum domain.Summary) error {
return fmt.Errorf("store: marshal takeaways: %w", err)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("store: begin: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
// Ensure the owning user exists (FK target). The store sink receives only a
// Summary, so a minimal user row is enough at Stage 0.
return s.withUser(ctx, sum.UserID, func(tx pgx.Tx) error {
// Ensure the owning user exists (FK target). The store sink receives only
// a Summary, so a minimal user row is enough at Stage 0.
if _, err := tx.Exec(ctx,
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
sum.UserID); err != nil {
@@ -147,20 +183,19 @@ func (s *Store) Deliver(ctx context.Context, sum domain.Summary) error {
summaryID); err != nil {
return fmt.Errorf("store: record delivery: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit: %w", err)
}
return nil
})
}
// HasSummary reports whether a summary already exists for (userID, videoID).
// This is the per-video durable dedup check.
func (s *Store) HasSummary(ctx context.Context, userID, videoID string) (bool, error) {
var exists bool
if err := s.pool.QueryRow(ctx,
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM summaries WHERE user_id = $1 AND video_id = $2)`,
userID, videoID).Scan(&exists); err != nil {
userID, videoID).Scan(&exists)
}); err != nil {
return false, fmt.Errorf("store: has summary: %w", err)
}
return exists, nil
@@ -170,23 +205,28 @@ func (s *Store) HasSummary(ctx context.Context, userID, videoID string) (bool, e
// user. The watcher uses it to skip re-summarizing across restarts. Scoped by
// user_id, so one user never sees another's videos.
func (s *Store) SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error) {
rows, err := s.pool.Query(ctx,
seen := make(map[string]bool)
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
`SELECT video_id FROM summaries WHERE user_id = $1`, userID)
if err != nil {
return nil, fmt.Errorf("store: seen video ids: %w", err)
return fmt.Errorf("store: seen video ids: %w", err)
}
defer rows.Close()
seen := make(map[string]bool)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("store: scan video id: %w", err)
return fmt.Errorf("store: scan video id: %w", err)
}
seen[id] = true
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate video ids: %w", err)
return fmt.Errorf("store: iterate video ids: %w", err)
}
return nil
}); err != nil {
return nil, err
}
return seen, nil
}
+112
View File
@@ -0,0 +1,112 @@
package store
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
// SetAutoSummarize sets the user's auto/manual summarization mode. TRUE =
// automatic (every new video is summarized by `tapir run`); FALSE = manual (the
// user queues videos individually). Per-user, not global (ADR-012). Scoped via
// withUser, so RLS confines the UPDATE to the calling user's own row.
func (s *Store) SetAutoSummarize(ctx context.Context, userID string, enabled bool) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
// Ensure the row exists (FK/identity target) before the UPDATE — mirrors
// the Deliver/UpsertVideo paths, so toggling mode works even before the
// first summary lands.
if _, err := tx.Exec(ctx,
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
userID); err != nil {
return fmt.Errorf("store: upsert user: %w", err)
}
if _, err := tx.Exec(ctx,
`UPDATE users SET auto_summarize = $1 WHERE id = $2`, enabled, userID); err != nil {
return fmt.Errorf("store: set auto summarize: %w", err)
}
return nil
})
}
// GetAutoSummarize reports the user's summarization mode (TRUE = automatic). An
// absent user row reads as FALSE (manual), the safe default. Scoped via withUser.
func (s *Store) GetAutoSummarize(ctx context.Context, userID string) (bool, error) {
var enabled bool
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx,
`SELECT auto_summarize FROM users WHERE id = $1`, userID).Scan(&enabled)
if errors.Is(err, pgx.ErrNoRows) {
enabled = false
return nil
}
return err
}); err != nil {
return false, fmt.Errorf("store: get auto summarize: %w", err)
}
return enabled, nil
}
// RequestSummarize queues a single video for manual summarization by setting its
// summarize_requested flag. The next `tapir run` picks it up and clears the flag.
// Returns ErrNotFound when the video does not exist or is not owned by the user
// (RLS hides another user's row, so the UPDATE matches zero rows). Scoped via
// withUser.
func (s *Store) RequestSummarize(ctx context.Context, userID, videoID string) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`UPDATE videos SET summarize_requested = TRUE WHERE id = $1`, videoID)
if err != nil {
return fmt.Errorf("store: request summarize: %w", err)
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// RequestedVideoIDs returns the set of the user's video ids currently flagged for
// manual summarization. The run loop loads it once per pass (mirroring
// SeenVideoIDs) to decide which discovered videos to process in manual mode.
// Scoped by user_id.
func (s *Store) RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error) {
requested := make(map[string]bool)
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
`SELECT id FROM videos WHERE user_id = $1 AND summarize_requested = TRUE`, userID)
if err != nil {
return fmt.Errorf("store: requested video ids: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return fmt.Errorf("store: scan requested id: %w", err)
}
requested[id] = true
}
if err := rows.Err(); err != nil {
return fmt.Errorf("store: iterate requested ids: %w", err)
}
return nil
}); err != nil {
return nil, err
}
return requested, nil
}
// ClearSummarizeRequested resets a video's manual queue flag, called by the run
// loop after a queued video is successfully summarized so it is not re-processed
// and the list view drops the "Queued" chip. Scoped via withUser.
func (s *Store) ClearSummarizeRequested(ctx context.Context, userID, videoID string) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`UPDATE videos SET summarize_requested = FALSE WHERE id = $1`, videoID); err != nil {
return fmt.Errorf("store: clear summarize requested: %w", err)
}
return nil
})
}
@@ -0,0 +1,126 @@
package store_test
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
// seedBareVideo inserts a videos row with no summary, so the all-videos read and
// the manual-queue flag can be exercised without a delivered summary.
func seedBareVideo(t *testing.T, p *pgxpool.Pool, userID, videoID, title string) {
t.Helper()
_, err := p.Exec(context.Background(),
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)
require.NoError(t, err)
_, err = p.Exec(context.Background(),
`INSERT INTO videos (id, user_id, provider, provider_video_id, title)
VALUES ($1, $2, 'youtube', $3, $4)`,
videoID, userID, "pv-"+videoID[:8], title)
require.NoError(t, err)
}
func TestAutoSummarizeRoundTripDefaultsFalse(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
// Unknown / fresh user defaults to manual (false).
got, err := s.GetAutoSummarize(ctx, userA)
require.NoError(t, err)
require.False(t, got, "default mode is manual")
require.NoError(t, s.SetAutoSummarize(ctx, userA, true))
got, err = s.GetAutoSummarize(ctx, userA)
require.NoError(t, err)
require.True(t, got, "set to automatic round-trips")
require.NoError(t, s.SetAutoSummarize(ctx, userA, false))
got, err = s.GetAutoSummarize(ctx, userA)
require.NoError(t, err)
require.False(t, got, "set back to manual round-trips")
}
func TestRequestSummarizeSetsFlag(t *testing.T) {
ctx := context.Background()
s := newStore(t)
p := rawPool(t)
resetDB(t, p)
seedBareVideo(t, p, userA, videoX, "X Title")
require.NoError(t, s.RequestSummarize(ctx, userA, videoX))
requested, err := s.RequestedVideoIDs(ctx, userA)
require.NoError(t, err)
require.Equal(t, map[string]bool{videoX: true}, requested)
// Clearing drops it from the requested set.
require.NoError(t, s.ClearSummarizeRequested(ctx, userA, videoX))
requested, err = s.RequestedVideoIDs(ctx, userA)
require.NoError(t, err)
require.Empty(t, requested)
}
func TestRequestSummarizeMissingVideo(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
err := s.RequestSummarize(ctx, userA, videoX)
require.ErrorIs(t, err, store.ErrNotFound, "queuing a non-existent video reports not found")
}
func TestListVideosReturnsSummarizedAndUnsummarized(t *testing.T) {
ctx := context.Background()
s := newStore(t)
p := rawPool(t)
resetDB(t, p)
// videoX: discovered AND summarized. videoY: discovered, not yet summarized.
seedBareVideo(t, p, userA, videoX, "Summarized One")
seedBareVideo(t, p, userA, videoY, "Pending One")
require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "body x")))
require.NoError(t, s.RequestSummarize(ctx, userA, videoY))
rows, err := s.ListVideos(ctx, userA, 50)
require.NoError(t, err)
require.Len(t, rows, 2, "both summarized and unsummarized videos are listed")
byID := map[string]store.SummaryRow{}
for _, r := range rows {
byID[r.VideoID] = r
}
require.True(t, byID[videoX].Summarized)
require.Equal(t, "body x", byID[videoX].Summary)
require.False(t, byID[videoX].SummarizeRequested)
require.False(t, byID[videoY].Summarized, "no summary -> Summarized false")
require.Empty(t, byID[videoY].Summary, "unsummarized row has empty summary")
require.True(t, byID[videoY].SummarizeRequested, "queued video carries the flag")
}
func TestListVideosIsUserScoped(t *testing.T) {
ctx := context.Background()
s := newStore(t)
p := rawPool(t)
resetDB(t, p)
seedBareVideo(t, p, userA, videoX, "A only")
rows, err := s.ListVideos(ctx, userB, 50)
require.NoError(t, err)
require.Empty(t, rows, "user B must not see user A's videos")
}
func TestGetVideoRowNotFound(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
_, err := s.GetVideoRow(ctx, userA, videoX)
require.ErrorIs(t, err, store.ErrNotFound)
}
+14 -17
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"time"
"github.com/jackc/pgx/v5"
"gitea.d-ma.be/mathias/tapir/internal/domain"
)
@@ -29,25 +31,20 @@ func (s *Store) UpsertVideo(ctx context.Context, v domain.Video) (string, error)
return "", fmt.Errorf("store: upsert video: empty provider video id")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return "", fmt.Errorf("store: begin: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
// Ensure the owning user exists (FK target) — same as the Deliver path.
if _, err := tx.Exec(ctx,
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
v.UserID); err != nil {
return "", fmt.Errorf("store: upsert user: %w", err)
}
provider := string(v.Provider)
if provider == "" {
provider = string(domain.ProviderYouTube)
}
var id string
if err := s.withUser(ctx, v.UserID, func(tx pgx.Tx) error {
// Ensure the owning user exists (FK target) — same as the Deliver path.
if _, err := tx.Exec(ctx,
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
v.UserID); err != nil {
return fmt.Errorf("store: upsert user: %w", err)
}
if err := tx.QueryRow(ctx,
`INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at)
VALUES ($1, $2, $3, $4, $5, $6)
@@ -58,11 +55,11 @@ func (s *Store) UpsertVideo(ctx context.Context, v domain.Video) (string, error)
RETURNING id`,
v.UserID, provider, v.ProviderVideoID, v.Title, v.URL, nullTime(v.PublishedAt),
).Scan(&id); err != nil {
return "", fmt.Errorf("store: upsert video: %w", err)
return fmt.Errorf("store: upsert video: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return "", fmt.Errorf("store: commit: %w", err)
return nil
}); err != nil {
return "", err
}
return id, nil
}
+11
View File
@@ -72,6 +72,17 @@ func oauthConfig(c Config) *oauth2.Config {
}
}
// AuthCodeURL builds the provider consent URL the web connect flow redirects to
// (internal/web). It reuses oauthConfig and pins access_type=offline + prompt=
// consent so Google returns a refresh token even on a repeat authorization —
// without one, Exchange would reject the result. state is the per-request CSRF
// token the caller binds to the user and verifies on the callback.
func AuthCodeURL(c Config, state string) string {
return oauthConfig(c).AuthCodeURL(state,
oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("prompt", "consent"))
}
// Exchange swaps an authorization code for a token and persists the refresh
// token through the writer. It errors if the provider returned no refresh token
// (e.g. consent was not forced with offline access), since without one the
+10 -5
View File
@@ -42,6 +42,11 @@ type Config struct {
// YTTokenRef is the opaque SecretStore reference under which the YouTube
// refresh token is persisted/resolved. Not the token itself.
YTTokenRef string
// YTConnectRedirectURL is the public callback URL the web connect flow
// registers with Google, e.g. "https://tapir.d-ma.be/oauth/youtube/callback".
// Must be in the OAuth client's authorized redirects. Distinct from the CLI
// auth command's localhost listener and from the Dex OIDC redirect.
YTConnectRedirectURL string
// SecretsFile is the path to the local file-backed SecretStore (0600). A
// Stage-0 stand-in for op/ESO, swappable behind the SecretStore port.
@@ -57,15 +62,14 @@ type Config struct {
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
HTTPAddr string
// Dex OIDC (web login, ADR-011). When OIDCIssuer is empty, `serve` falls back
// to the allow-all StubAuth (local dev). When set, serve uses Dex with
// single-user allowlist authz.
// Dex OIDC (web login, ADR-011/012). When OIDCIssuer is empty, `serve` falls
// back to the allow-all StubAuth (local dev). When set, serve uses Dex: any
// Dex-authenticated subject may sign in, then registers a tapir user (ADR-012).
OIDCIssuer string
DexClientID string
DexClientSecret string
OIDCRedirectURL string
SessionSecret string
AllowedSubject string
}
// DexConfigured reports whether Dex OIDC login is wired (issuer present). When
@@ -78,6 +82,7 @@ const (
defaultSummarizerModel = "koala/phi4-mini"
defaultSummarizerTimeout = 5 * time.Minute
defaultYTTokenRef = "youtube/refresh_token"
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
defaultOAuthRedirectAddr = "localhost:8080"
defaultHTTPAddr = ":8080"
)
@@ -96,6 +101,7 @@ func Load() (Config, error) {
YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"),
YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"),
YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef),
YTConnectRedirectURL: envOr("TAPIR_YT_CONNECT_REDIRECT_URL", defaultYTConnectRedirectURL),
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
@@ -104,7 +110,6 @@ func Load() (Config, error) {
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"),
SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"),
AllowedSubject: os.Getenv("TAPIR_ALLOWED_SUBJECT"),
}
timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout)
+41 -2
View File
@@ -23,10 +23,15 @@ import (
)
// VideoStore is the durable persistence the run loop needs: assign a stable id +
// metadata, and read the already-summarized set. *store.Store satisfies it.
// metadata, read the already-summarized set, and (for manual summarization mode)
// read the user's mode + queued videos and clear a video's queue flag once it has
// been summarized. *store.Store satisfies it.
type VideoStore interface {
UpsertVideo(ctx context.Context, v domain.Video) (string, error)
SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error)
GetAutoSummarize(ctx context.Context, userID string) (bool, error)
RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error)
ClearSummarizeRequested(ctx context.Context, userID, videoID string) error
}
// Processor runs the core use case for a single video. *usecase.Engine
@@ -59,6 +64,7 @@ type Stats struct {
Summarized int
SkippedSeen int
SkippedNoText int
SkippedManual int // discovered but not queued, in manual mode
Errors int
}
@@ -82,6 +88,22 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
return stats, fmt.Errorf("runner: load seen videos: %w", err)
}
// Summarization mode (per-user, ADR-012). Auto = summarize every unseen video
// (the original behavior). Manual = still discover/persist videos so the user
// sees them, but only summarize the ones explicitly queued via the web UI
// (summarize_requested). The queued set is loaded once per pass, like seen.
auto, err := r.store.GetAutoSummarize(ctx, r.userID)
if err != nil {
return stats, fmt.Errorf("runner: load summarize mode: %w", err)
}
var requested map[string]bool
if !auto {
requested, err = r.store.RequestedVideoIDs(ctx, r.userID)
if err != nil {
return stats, fmt.Errorf("runner: load requested videos: %w", err)
}
}
subs, err := r.src.ListSubscriptions(ctx, r.userID)
if err != nil {
return stats, fmt.Errorf("runner: list subscriptions: %w", err)
@@ -112,6 +134,14 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
}
seen[id] = true // also guard against the same video within this pass
// Manual mode: skip summarization for videos the user has not queued.
// Discovery already happened (UpsertVideo above), so the new video is
// visible in the list; it just isn't summarized until requested.
if !auto && !requested[id] {
stats.SkippedManual++
continue
}
if fetchDelay > 0 {
time.Sleep(fetchDelay)
}
@@ -127,6 +157,15 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
r.log.Info("skipped video (no transcript)", "video", v.ProviderVideoID, "title", v.Title)
case res.Summary != nil:
stats.Summarized++
// In manual mode the video was processed because it was queued;
// clear the flag so it is not re-summarized and the UI drops the
// "Queued" chip. (Auto mode never sets the flag.)
if !auto {
if err := r.store.ClearSummarizeRequested(ctx, r.userID, id); err != nil {
errs = append(errs, fmt.Errorf("clear summarize flag %q: %w", v.ProviderVideoID, err))
stats.Errors++
}
}
r.log.Info("summarized video", "video", v.ProviderVideoID, "title", v.Title,
"provider", res.Summary.AIProvider, "model", res.Summary.AIModel)
}
@@ -145,7 +184,7 @@ func (r *Runner) Loop(ctx context.Context, interval time.Duration) error {
r.log.Info("run pass complete",
"candidates", stats.Candidates, "summarized", stats.Summarized,
"skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
"errors", stats.Errors)
"skipped_manual", stats.SkippedManual, "errors", stats.Errors)
if err != nil {
r.log.Warn("run pass had errors", "err", err)
}
+66 -4
View File
@@ -40,9 +40,14 @@ func (f *fakeSource) FetchTranscript(_ context.Context, v domain.Video) (domain.
// fakeStore assigns deterministic ids ("id-"+provider video id) so a pre-seeded
// seen set lines up with UpsertVideo output, modelling cross-restart dedup.
// auto controls the summarization mode; requested is the manual-mode queue keyed
// by store id; cleared records the ids whose queue flag the runner reset.
type fakeStore struct {
seen map[string]bool
upserted []domain.Video
auto bool
requested map[string]bool
cleared []string
}
func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) {
@@ -58,6 +63,23 @@ func (f *fakeStore) SeenVideoIDs(_ context.Context, _ string) (map[string]bool,
return cp, nil
}
func (f *fakeStore) GetAutoSummarize(_ context.Context, _ string) (bool, error) {
return f.auto, nil
}
func (f *fakeStore) RequestedVideoIDs(_ context.Context, _ string) (map[string]bool, error) {
cp := make(map[string]bool, len(f.requested))
for k, v := range f.requested {
cp[k] = v
}
return cp, nil
}
func (f *fakeStore) ClearSummarizeRequested(_ context.Context, _, videoID string) error {
f.cleared = append(f.cleared, videoID)
return nil
}
type fakeSummarizer struct{}
func (fakeSummarizer) Summarize(_ context.Context, v domain.Video, _ domain.Transcript) (domain.Summary, error) {
@@ -91,7 +113,7 @@ func TestRunOnce_SummarizesNewVideos(t *testing.T) {
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
}
st := &fakeStore{seen: map[string]bool{}}
st := &fakeStore{seen: map[string]bool{}, auto: true}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger())
@@ -114,7 +136,7 @@ func TestRunOnce_SkipsAlreadySummarized(t *testing.T) {
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
}
// v1 was summarized in a prior run (durable seen set).
st := &fakeStore{seen: map[string]bool{"id-v1": true}}
st := &fakeStore{seen: map[string]bool{"id-v1": true}, auto: true}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger())
@@ -133,7 +155,7 @@ func TestRunOnce_SkipsVideosWithoutTranscript(t *testing.T) {
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
transcripts: map[string]domain.Transcript{"v1": {Source: domain.SourceNone}},
}
st := &fakeStore{seen: map[string]bool{}}
st := &fakeStore{seen: map[string]bool{}, auto: true}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger())
@@ -145,13 +167,53 @@ func TestRunOnce_SkipsVideosWithoutTranscript(t *testing.T) {
require.Empty(t, sink.delivered, "no summary delivered when there is no transcript")
}
func TestRunOnce_ManualMode_SkipsUnrequested(t *testing.T) {
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
}
// Manual mode, nothing queued: discover (upsert) but summarize nothing.
st := &fakeStore{seen: map[string]bool{}, auto: false, requested: map[string]bool{}}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger())
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 2, stats.Candidates)
require.Equal(t, 2, stats.SkippedManual, "manual mode skips unqueued videos")
require.Equal(t, 0, stats.Summarized)
require.Empty(t, sink.delivered, "no summary in manual mode without a request")
require.Len(t, st.upserted, 2, "discovery still persists every candidate")
}
func TestRunOnce_ManualMode_ProcessesRequested(t *testing.T) {
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
}
// Manual mode, v1 queued (by store id). Only v1 is summarized; its flag clears.
st := &fakeStore{seen: map[string]bool{}, auto: false, requested: map[string]bool{"id-v1": true}}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger())
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 1, stats.Summarized, "only the queued video is summarized")
require.Equal(t, 1, stats.SkippedManual, "the unqueued video is skipped")
require.Len(t, sink.delivered, 1)
require.Equal(t, "id-v1", sink.delivered[0].VideoID)
require.Equal(t, []string{"id-v1"}, st.cleared, "the queue flag is cleared after summarizing")
}
func TestRunOnce_UpsertsEveryCandidate(t *testing.T) {
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
}
// Even an already-seen video gets upserted so its metadata stays fresh.
st := &fakeStore{seen: map[string]bool{"id-v1": true}}
st := &fakeStore{seen: map[string]bool{"id-v1": true}, auto: true}
eng := usecase.NewEngine(src, fakeSummarizer{}, &recordingSink{})
r := runner.New(src, st, eng, testUser, quietLogger())
+126
View File
@@ -0,0 +1,126 @@
package web
import (
"net/http"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
// handleAccount renders the account page: the user's display name, the
// authenticated email, their connected video accounts (with a Connect link when
// YouTube is not connected), and the disconnect / delete-account controls.
func (a *App) handleAccount(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
if err != nil {
a.serverError(w, r, "list connections", err)
return
}
name, err := a.Store.DisplayName(r.Context(), userID)
if err != nil {
a.serverError(w, r, "display name", err)
return
}
var email string
if u, ok := a.Auth.CurrentUser(r); ok {
email = u.Email
}
auto, err := a.Store.GetAutoSummarize(r.Context(), userID)
if err != nil {
a.serverError(w, r, "summarize mode", err)
return
}
a.render(w, r, AccountPage(name, email, conns, auto, takeFlash(w, r)))
}
// handleDisconnect removes a provider connection: it deletes the OAuth token from
// the SecretStore (resolved from the connection's own token_ref) and the
// connection row. It does NOT delete the account. Redirects back to /account with
// a flash. Disconnecting an absent provider is a no-op (idempotent).
func (a *App) handleDisconnect(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
provider := r.PathValue("provider")
if provider == "" {
http.Error(w, "missing provider", http.StatusBadRequest)
return
}
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
if err != nil {
a.serverError(w, r, "list connections", err)
return
}
// Remove the token before the row, using the connection's own ref so this is
// provider-agnostic. A SecretStore failure is logged, not fatal — the row
// removal below still revokes access from Tapir's side.
if ref := tokenRefFor(conns, provider); ref != "" && a.Secrets != nil {
if err := a.Secrets.Delete(ref); err != nil {
a.logger().Error("disconnect: delete token", "provider", provider, "err", err)
}
}
if err := a.Store.DeleteConnection(r.Context(), userID, provider); err != nil {
a.serverError(w, r, "delete connection", err)
return
}
setFlash(w, flashDisconnected)
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
// handleDeleteAccount permanently deletes the user: it removes all tapir data
// (DeleteUser cascades the rows) and every one of the user's secrets, then logs
// the user out. Tapir-side only (decision 2026-06-03) — the Dex identity is left
// untouched, so a later login simply re-enters registration.
func (a *App) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
// Capture the secret refs BEFORE the rows are deleted — DeleteUser cascades
// the video_connections away.
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
if err != nil {
a.serverError(w, r, "list connections", err)
return
}
if err := a.Store.DeleteUser(r.Context(), userID); err != nil {
a.serverError(w, r, "delete user", err)
return
}
// Best-effort secret cleanup: the account is already gone, so a SecretStore
// failure is logged, never resurrects the account.
if a.Secrets != nil {
for _, c := range conns {
if c.TokenRef == "" {
continue
}
if err := a.Secrets.Delete(c.TokenRef); err != nil {
a.logger().Error("delete account: delete token", "ref", c.TokenRef, "err", err)
}
}
}
// Clear the session by routing through the auth logout endpoint, then land on
// the list page with a flash (StubAuth logout is a no-op; Dex clears the
// session cookie and redirects to login).
setFlash(w, flashDeleted)
http.Redirect(w, r, "/auth/logout", http.StatusSeeOther)
}
// tokenRefFor returns the SecretStore ref for the user's connection to provider,
// or "" if there is none.
func tokenRefFor(conns []store.Connection, provider string) string {
for _, c := range conns {
if c.Provider == provider {
return c.TokenRef
}
}
return ""
}
+153
View File
@@ -0,0 +1,153 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/web"
)
// fakeSecrets is a SecretRemover that records the refs it was asked to delete, so
// account tests can assert the OAuth token cleanup without a real secret file.
type fakeSecrets struct {
mu sync.Mutex
deleted []string
}
func (f *fakeSecrets) Delete(ref string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.deleted = append(f.deleted, ref)
return nil
}
func (f *fakeSecrets) deletedRefs() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.deleted...)
}
// newAccountApp builds the App as the registered stub user with a recording fake
// SecretStore, so disconnect/delete can be exercised end-to-end.
func newAccountApp(t *testing.T) (*web.App, *fakeSecrets) {
t.Helper()
s := newStore(t)
fs := &fakeSecrets{}
return &web.App{
Store: s,
Identity: s,
Auth: web.StubAuth{U: web.User{Subject: stubSubject, Email: "ada@example.com"}},
Secrets: fs,
}, fs
}
func seedConnection(t *testing.T, app *web.App, provider, account, ref string) {
t.Helper()
require.NoError(t, app.Store.(*store.Store).UpsertConnection(context.Background(), userID, store.Connection{
Provider: provider,
ProviderAccount: account,
TokenRef: ref,
Status: "active",
}))
}
func TestAccountPageShowsConnectionAndName(t *testing.T) {
ctx := context.Background()
app, _ := newAccountApp(t)
p := rawPool(t)
resetDB(t, p)
_, err := p.Exec(ctx, `UPDATE users SET display_name = $1 WHERE id = $2`, "Ada", userID)
require.NoError(t, err)
seedConnection(t, app, "youtube", "ada@channel", web.YouTubeTokenRef(userID))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Ada", "display name shown")
require.Contains(t, html, "ada@example.com", "signed-in email shown")
require.Contains(t, html, "ada@channel", "connected account shown")
require.Contains(t, html, "YouTube")
require.Contains(t, html, "/account/disconnect/youtube", "a Disconnect control is present")
require.NotContains(t, html, "/oauth/youtube/connect", "no Connect link while already connected")
// Confirm-before-destroy: the delete is behind a disclosure, not a bare button.
require.Contains(t, html, "/account/delete")
require.Contains(t, html, "<details", "delete is gated behind a confirm step")
require.Contains(t, html, "cannot be undone")
}
func TestAccountPageShowsConnectLinkWhenNotConnected(t *testing.T) {
app, _ := newAccountApp(t)
resetDB(t, rawPool(t))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "/oauth/youtube/connect", "Connect link offered when not connected")
require.Contains(t, html, "Connect YouTube")
}
func TestDisconnectRemovesTokenAndConnectionKeepsAccount(t *testing.T) {
ctx := context.Background()
app, fs := newAccountApp(t)
resetDB(t, rawPool(t))
ref := web.YouTubeTokenRef(userID)
seedConnection(t, app, "youtube", "ada@channel", ref)
req := httptest.NewRequest(http.MethodPost, "/account/disconnect/youtube", nil)
rec := do(t, app, req)
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/account", rec.Header().Get("Location"))
// Token purged from the SecretStore.
require.Contains(t, fs.deletedRefs(), ref, "the per-user YouTube token must be deleted")
// Connection row gone...
conns, err := app.Store.(*store.Store).ConnectionsForUser(ctx, userID)
require.NoError(t, err)
require.Empty(t, conns, "the connection row must be removed")
// ...but the account itself survives (disconnect is not delete).
name, err := app.Store.(*store.Store).DisplayName(ctx, userID)
require.NoError(t, err)
_ = name
var users int
require.NoError(t, rawPool(t).QueryRow(ctx, `SELECT count(*) FROM users WHERE id = $1`, userID).Scan(&users))
require.Equal(t, 1, users, "disconnect must not delete the account")
}
func TestDeleteAccountWipesDataAndSecretsAndLogsOut(t *testing.T) {
ctx := context.Background()
app, fs := newAccountApp(t)
p := rawPool(t)
resetDB(t, p)
ref := web.YouTubeTokenRef(userID)
seedConnection(t, app, "youtube", "ada@channel", ref)
require.NoError(t, deliver(ctx, app, videoX, "a summary")) // some user data to wipe
rec := do(t, app, httptest.NewRequest(http.MethodPost, "/account/delete", nil))
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/auth/logout", rec.Header().Get("Location"), "delete logs the user out")
// The user's secrets were removed (the per-user YouTube token).
require.Contains(t, fs.deletedRefs(), ref, "delete must purge the user's OAuth tokens")
// The account and its data are gone.
for _, q := range []string{
`SELECT count(*) FROM users WHERE id = $1`,
`SELECT count(*) FROM summaries WHERE user_id = $1`,
`SELECT count(*) FROM video_connections WHERE user_id = $1`,
`SELECT count(*) FROM user_identities WHERE user_id = $1`,
} {
var n int
require.NoError(t, p.QueryRow(ctx, q, userID).Scan(&n))
require.Equal(t, 0, n, "delete must remove all rows: %s", q)
}
}
+216
View File
@@ -0,0 +1,216 @@
package web
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"sync"
"time"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/auth"
)
// Connections is the narrow write port the connect flow depends on (Clean
// Architecture: the handler depends on this interface, not the concrete store).
// *store.Store satisfies it; tests substitute a fake.
type Connections interface {
UpsertConnection(ctx context.Context, userID string, c store.Connection) error
}
// connectStateTTL bounds how long a generated CSRF state is valid between the
// connect redirect and the provider callback.
const connectStateTTL = 10 * time.Minute
// ConnectHandler runs the web-initiated YouTube OAuth connect flow. It is mounted
// INSIDE the login + registration guard (Router), so CurrentUserID is always set
// — every connection is bound to the authenticated tapir user. It reuses
// auth.AuthCodeURL / auth.Exchange (ADR-006: the web flow, not the CLI listener).
//
// The minted refresh token is persisted under a PER-USER SecretStore ref
// (YouTubeTokenRef) so tenants never share or overwrite each other's token.
type ConnectHandler struct {
// OAuth carries the registered client id/secret, the callback RedirectURL,
// and (in tests) the Endpoint override. TokenRef is set per-user per request,
// not here.
OAuth auth.Config
Secrets auth.TokenWriter // persists the refresh token (secrets.FileStore)
Conns Connections
Log *slog.Logger
states *connectStateStore
now func() time.Time
}
// NewConnectHandler wires the connect flow. now defaults to time.Now; the CSRF
// state store is in-memory (single-instance Stage 1).
func NewConnectHandler(oauth auth.Config, secrets auth.TokenWriter, conns Connections, log *slog.Logger) *ConnectHandler {
return &ConnectHandler{
OAuth: oauth,
Secrets: secrets,
Conns: conns,
Log: log,
states: newConnectStateStore(),
now: time.Now,
}
}
// YouTubeTokenRef is the per-user SecretStore reference under which a user's
// YouTube OAuth refresh token is persisted: "youtube/<userID>/refresh_token".
// Per-user (not the Stage-0 single "youtube/refresh_token") so connections never
// collide across tenants.
func YouTubeTokenRef(userID string) string {
return "youtube/" + userID + "/refresh_token"
}
// handleConnect generates a per-user CSRF state, stores it bound to the user with
// a short TTL, and redirects to Google's consent screen (offline + prompt=consent
// so a refresh token comes back).
func (h *ConnectHandler) handleConnect(w http.ResponseWriter, r *http.Request) {
userID, ok := CurrentUserID(r)
if !ok {
h.serverError(w, r, "current user", errNoCurrentUser)
return
}
state, err := randomState()
if err != nil {
h.serverError(w, r, "generate state", err)
return
}
h.states.put(state, userID, h.now().Add(connectStateTTL))
http.Redirect(w, r, auth.AuthCodeURL(h.OAuth, state), http.StatusFound)
}
// handleCallback verifies the CSRF state (present, unexpired, bound to THIS user),
// exchanges the code for a refresh token under the per-user ref, and records the
// connection. Any failure renders a clean error page and leaves no half-written
// state (Exchange persists nothing without a refresh token; the connection row is
// only written after a successful exchange).
func (h *ConnectHandler) handleCallback(w http.ResponseWriter, r *http.Request) {
userID, ok := CurrentUserID(r)
if !ok {
h.serverError(w, r, "current user", errNoCurrentUser)
return
}
q := r.URL.Query()
if e := q.Get("error"); e != "" {
h.failure(w, http.StatusBadRequest, "Authorization was declined.")
return
}
boundUser, ok := h.states.take(q.Get("state"), h.now())
if !ok || boundUser != userID {
// Missing, unknown, expired, or another user's state — reject as CSRF.
h.failure(w, http.StatusBadRequest, "Invalid or expired authorization state. Please try connecting again.")
return
}
code := q.Get("code")
if code == "" {
h.failure(w, http.StatusBadRequest, "Authorization returned no code.")
return
}
oauthCfg := h.OAuth
oauthCfg.TokenRef = YouTubeTokenRef(userID)
if err := auth.Exchange(r.Context(), oauthCfg, h.Secrets, code); err != nil {
h.logger().Error("connect: exchange code", "err", err)
h.failure(w, http.StatusBadGateway, "Could not complete authorization with YouTube. Please try again.")
return
}
if err := h.Conns.UpsertConnection(r.Context(), userID, store.Connection{
Provider: "youtube",
TokenRef: oauthCfg.TokenRef,
Status: "active",
}); err != nil {
h.logger().Error("connect: upsert connection", "err", err)
h.failure(w, http.StatusInternalServerError, "Authorized, but could not save the connection. Please try again.")
return
}
setFlash(w, flashConnected)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *ConnectHandler) logger() *slog.Logger {
if h.Log != nil {
return h.Log
}
return slog.Default()
}
func (h *ConnectHandler) serverError(w http.ResponseWriter, r *http.Request, op string, err error) {
h.logger().Error("connect handler error", "op", op, "path", r.URL.Path, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
// failure renders a minimal, self-contained error page with a link back. No
// templ dependency so it can render even if a connection is half-set-up upstream.
func (h *ConnectHandler) failure(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = io.WriteString(w, `<!doctype html><html lang="en"><head><meta charset="utf-8">`+
`<title>Connection failed</title></head><body>`+
`<h1>Could not connect your YouTube account</h1>`+
`<p>`+template.HTMLEscapeString(msg)+`</p>`+
`<p><a href="/">Back to Tapir</a></p></body></html>`)
}
// randomState returns a 128-bit hex CSRF token.
func randomState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("web: generate state: %w", err)
}
return hex.EncodeToString(b), nil
}
// connectStateEntry binds a CSRF state to the user who initiated the connect and
// when it expires.
type connectStateEntry struct {
userID string
expiry time.Time
}
// connectStateStore maps a CSRF state to its bound user between the connect
// redirect and the callback. Entries are one-time (take deletes) and short-lived,
// defeating replay and CSRF on the callback.
type connectStateStore struct {
mu sync.Mutex
m map[string]connectStateEntry
}
func newConnectStateStore() *connectStateStore {
return &connectStateStore{m: make(map[string]connectStateEntry)}
}
func (s *connectStateStore) put(state, userID string, expiry time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[state] = connectStateEntry{userID: userID, expiry: expiry}
}
// take consumes the user bound to state, returning ok=false if state is empty,
// unknown, or expired.
func (s *connectStateStore) take(state string, now time.Time) (string, bool) {
if state == "" {
return "", false
}
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.m[state]
if !ok {
return "", false
}
delete(s.m, state)
if !now.Before(e.expiry) {
return "", false
}
return e.userID, true
}
+175
View File
@@ -0,0 +1,175 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/auth"
"gitea.d-ma.be/mathias/tapir/internal/web"
)
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
type fakeWriter struct {
mu sync.Mutex
ref, val string
calls int
}
func (w *fakeWriter) Put(ref, value string) error {
w.mu.Lock()
defer w.mu.Unlock()
w.ref, w.val, w.calls = ref, value, w.calls+1
return nil
}
// fakeConns captures UpsertConnection calls without a database.
type fakeConns struct {
mu sync.Mutex
calls int
userID string
conn store.Connection
}
func (c *fakeConns) UpsertConnection(_ context.Context, userID string, conn store.Connection) error {
c.mu.Lock()
defer c.mu.Unlock()
c.calls, c.userID, c.conn = c.calls+1, userID, conn
return nil
}
// tokenServer fakes Google's token endpoint, returning body for any POST.
func tokenServer(t *testing.T, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
// newConnectApp builds a registered-stub-user App with a wired ConnectHandler.
// The OAuth endpoint points at srvURL so Exchange never contacts live Google.
func newConnectApp(t *testing.T, srvURL string, secrets auth.TokenWriter, conns web.Connections) *web.App {
t.Helper()
s := newStore(t) // applies migrations
resetDB(t, rawPool(t)) // seeds stubSubject -> userID so the gate resolves a user
connect := web.NewConnectHandler(auth.Config{
ClientID: "cid",
ClientSecret: "csecret",
RedirectURL: "https://tapir.d-ma.be/oauth/youtube/callback",
Endpoint: oauth2.Endpoint{AuthURL: srvURL + "/auth", TokenURL: srvURL + "/token"},
}, secrets, conns, nil)
return &web.App{
Store: s,
Identity: s,
Auth: web.StubAuth{U: web.User{Subject: stubSubject}},
Connect: connect,
}
}
// connectState drives GET /oauth/youtube/connect and returns the CSRF state from
// the consent redirect, so the callback test can present a valid state.
func connectState(t *testing.T, app *web.App) string {
t.Helper()
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/oauth/youtube/connect", nil))
require.Equal(t, http.StatusFound, rec.Code)
loc := rec.Header().Get("Location")
u, err := url.Parse(loc)
require.NoError(t, err)
q := u.Query()
require.Equal(t, "offline", q.Get("access_type"), "must request offline access for a refresh token")
require.Equal(t, "consent", q.Get("prompt"), "must force consent for a refresh token")
state := q.Get("state")
require.NotEmpty(t, state, "consent URL must carry a CSRF state")
return state
}
func TestConnectRedirectsToConsent(t *testing.T) {
srv := tokenServer(t, `{}`)
app := newConnectApp(t, srv.URL, &fakeWriter{}, &fakeConns{})
_ = connectState(t, app) // assertions live in the helper
}
func TestCallbackExchangesAndRecordsConnection(t *testing.T) {
srv := tokenServer(t,
`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
w := &fakeWriter{}
conns := &fakeConns{}
app := newConnectApp(t, srv.URL, w, conns)
state := connectState(t, app)
rec := do(t, app, httptest.NewRequest(http.MethodGet,
"/oauth/youtube/callback?state="+state+"&code=the-code", nil))
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/", rec.Header().Get("Location"))
// Token persisted under the per-user ref.
wantRef := web.YouTubeTokenRef(userID)
require.Equal(t, wantRef, w.ref, "refresh token stored under the per-user ref")
require.Equal(t, "rt-secret", w.val)
// Connection recorded for the authenticated user.
require.Equal(t, 1, conns.calls)
require.Equal(t, userID, conns.userID)
require.Equal(t, "youtube", conns.conn.Provider)
require.Equal(t, "active", conns.conn.Status)
require.Equal(t, wantRef, conns.conn.TokenRef)
}
func TestCallbackRejectsMissingState(t *testing.T) {
srv := tokenServer(t,
`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
w := &fakeWriter{}
conns := &fakeConns{}
app := newConnectApp(t, srv.URL, w, conns)
rec := do(t, app, httptest.NewRequest(http.MethodGet,
"/oauth/youtube/callback?code=the-code", nil)) // no state
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Equal(t, 0, w.calls, "nothing persisted on missing state")
require.Equal(t, 0, conns.calls, "no connection recorded on missing state")
}
func TestCallbackRejectsUnknownState(t *testing.T) {
srv := tokenServer(t,
`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
w := &fakeWriter{}
conns := &fakeConns{}
app := newConnectApp(t, srv.URL, w, conns)
// A state never issued by connect must be rejected (CSRF).
rec := do(t, app, httptest.NewRequest(http.MethodGet,
"/oauth/youtube/callback?state=deadbeef&code=the-code", nil))
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Equal(t, 0, w.calls)
require.Equal(t, 0, conns.calls)
}
func TestCallbackStateIsSingleUse(t *testing.T) {
srv := tokenServer(t,
`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
w := &fakeWriter{}
conns := &fakeConns{}
app := newConnectApp(t, srv.URL, w, conns)
state := connectState(t, app)
url := "/oauth/youtube/callback?state=" + state + "&code=the-code"
rec := do(t, app, httptest.NewRequest(http.MethodGet, url, nil))
require.Equal(t, http.StatusSeeOther, rec.Code)
// Replaying the same state must fail — it was consumed.
rec = do(t, app, httptest.NewRequest(http.MethodGet, url, nil))
require.Equal(t, http.StatusBadRequest, rec.Code, "state is single-use")
require.Equal(t, 1, conns.calls, "replay must not record a second connection")
}
+54
View File
@@ -0,0 +1,54 @@
package web
import "net/http"
// flashCookie carries a one-shot notification code between a POST→redirect and
// the next rendered page (PRG pattern). The value is a non-sensitive code (not
// user data), so it is not signed; HttpOnly + SameSite=Lax + a short MaxAge bound
// it. The flashBanner component maps the code to a styled message.
const flashCookie = "tapir_flash"
// Flash codes. Kept small and stable — the message + severity live in
// flashMessages (view.go), not here, so the cookie never carries free text.
const (
flashConnected = "connected"
flashConnectFailed = "connect_failed"
flashDisconnected = "disconnected"
flashDeleted = "deleted"
flashRegistered = "registered"
)
// flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to
// survive the redirect, short enough that a stale banner never reappears.
const flashMaxAge = 60
// setFlash queues a one-shot notification surfaced by the next full page render.
func setFlash(w http.ResponseWriter, code string) {
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: code,
Path: "/",
MaxAge: flashMaxAge,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
// takeFlash returns the pending flash code (if any) and clears the cookie so the
// banner shows exactly once. Call it only on full-page renders, not HTMX
// fragments, so a fragment swap never consumes a flash meant for the next page.
func takeFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie(flashCookie)
if err != nil || c.Value == "" {
return ""
}
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return c.Value
}
+54
View File
@@ -0,0 +1,54 @@
package web
import (
"context"
"strings"
"testing"
)
// TestFlashBannerRendersEachKind proves the reusable notification component
// renders a banner with the right message and severity class for every flash
// code, and renders nothing for an empty or unknown (e.g. forged) code.
func TestFlashBannerRendersEachKind(t *testing.T) {
cases := []struct {
code string
wantText string
wantKind string
}{
{flashConnected, "YouTube account connected", "flash-success"},
{flashConnectFailed, "Could not connect", "flash-error"},
{flashDisconnected, "Account disconnected", "flash-success"},
{flashDeleted, "account and all its data were deleted", "flash-success"},
{flashRegistered, "Welcome to Tapir", "flash-success"},
}
for _, tc := range cases {
t.Run(tc.code, func(t *testing.T) {
var sb strings.Builder
if err := flashBanner(tc.code).Render(context.Background(), &sb); err != nil {
t.Fatalf("render: %v", err)
}
got := sb.String()
if !strings.Contains(got, tc.wantText) {
t.Errorf("banner %q = %q, want it to contain %q", tc.code, got, tc.wantText)
}
if !strings.Contains(got, tc.wantKind) {
t.Errorf("banner %q = %q, want severity class %q", tc.code, got, tc.wantKind)
}
if !strings.Contains(got, `role="status"`) {
t.Errorf("banner %q must carry role=status for assistive tech, got %q", tc.code, got)
}
})
}
}
func TestFlashBannerRendersNothingForUnknownCode(t *testing.T) {
for _, code := range []string{"", "bogus", "<script>"} {
var sb strings.Builder
if err := flashBanner(code).Render(context.Background(), &sb); err != nil {
t.Fatalf("render: %v", err)
}
if got := strings.TrimSpace(sb.String()); got != "" {
t.Errorf("flashBanner(%q) = %q, want empty (no banner)", code, got)
}
}
}
+149 -15
View File
@@ -16,22 +16,51 @@ import (
// the concrete *store.Store). *store.Store satisfies it; tests can substitute a
// fake without a database.
type Store interface {
ListSummaries(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
ListVideos(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
GetVideoRow(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
SetAction(ctx context.Context, userID, videoID, action string) error
ClearAction(ctx context.Context, userID, videoID, action string) error
// Summarization mode: the per-user auto/manual toggle and the per-video
// manual queue (the "Summarize" button). The runner consumes the queue.
GetAutoSummarize(ctx context.Context, userID string) (bool, error)
SetAutoSummarize(ctx context.Context, userID string, enabled bool) error
RequestSummarize(ctx context.Context, userID, videoID string) error
// Account management (the /account page, disconnect, delete-account).
ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
DeleteConnection(ctx context.Context, userID, provider string) error
DeleteUser(ctx context.Context, userID string) error
DisplayName(ctx context.Context, userID string) (string, error)
}
// App is the Stage-0 web surface: handlers over the store, gated by an Auth
// implementation. UserID is the single configured tapir user every store
// operation runs as (ADR-011 — Auth only gates access; it does not select the
// store identity).
// SecretRemover deletes secret material by its opaque ref. *secrets.FileStore
// satisfies it; account tests use a fake. The account handlers depend only on
// this narrow capability (not the read-side ports.SecretStore), mirroring how the
// connect flow depends on auth.TokenWriter for the write side.
type SecretRemover interface {
Delete(ref string) error
}
// App is the Stage-1 web surface: handlers over the store, gated by an Auth
// implementation (authentication) and a registration gate (which resolves the
// authenticated subject to its tapir user_id and stashes it per request). Every
// data handler scopes by that resolved id — CurrentUserID(r) — not by a single
// configured user (ADR-012, multi-user with enforced isolation).
type App struct {
Store Store
Identity Identity
Auth Auth
UserID string
Log *slog.Logger
// Connect runs the web-initiated YouTube OAuth connect flow. Optional: when
// nil (e.g. dev without YouTube client credentials), the /oauth/youtube/*
// routes are not mounted.
Connect *ConnectHandler
// Secrets removes a user's OAuth tokens on disconnect / delete-account. The
// account routes require it; cmd/tapir wires the file-backed store.
Secrets SecretRemover
}
func (a *App) logger() *slog.Logger {
@@ -54,8 +83,29 @@ func (a *App) Router() http.Handler {
app.HandleFunc("GET /{$}", a.handleList)
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
app.HandleFunc("GET /register", a.handleRegisterForm)
app.HandleFunc("POST /register", a.handleRegister)
root.Handle("/", a.Auth.Middleware(app))
// Account management: view connections, disconnect a provider, delete the
// account. Gated like every app route, so CurrentUserID is set.
app.HandleFunc("GET /account", a.handleAccount)
app.HandleFunc("POST /account/disconnect/{provider}", a.handleDisconnect)
app.HandleFunc("POST /account/delete", a.handleDeleteAccount)
app.HandleFunc("POST /account/summarize-mode", a.handleSummarizeMode)
// Web-initiated YouTube connect (ADR-006). Gated like every app route, so
// CurrentUserID is set and the connection binds to the authenticated user.
if a.Connect != nil {
app.HandleFunc("GET /oauth/youtube/connect", a.Connect.handleConnect)
app.HandleFunc("GET /oauth/youtube/callback", a.Connect.handleCallback)
}
// Two layers: Auth.Middleware requires a Dex session (you must be logged in);
// registrationGate requires a tapir user (else → /register) and stashes the
// resolved user_id. /register lives inside the auth guard but is exempt from
// the registration gate (you must be able to reach it before you have a user).
root.Handle("/", a.Auth.Middleware(a.registrationGate(app)))
return root
}
@@ -69,6 +119,10 @@ func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
// query string. An HTMX request gets only the table fragment so the filter form
// can swap #summary-list in place; a plain request gets the full page.
func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
q := r.URL.Query()
f := Filter{
Channel: q.Get("channel"),
@@ -76,9 +130,9 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
To: q.Get("to"),
}
rows, err := a.Store.ListSummaries(r.Context(), a.UserID, 0)
rows, err := a.Store.ListVideos(r.Context(), userID, 0)
if err != nil {
a.serverError(w, r, "list summaries", err)
a.serverError(w, r, "list videos", err)
return
}
rows = f.apply(rows)
@@ -87,13 +141,17 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
a.render(w, r, summaryList(rows))
return
}
a.render(w, r, ListPage(rows, f))
a.render(w, r, ListPage(rows, f, takeFlash(w, r)))
}
// handleDetail renders one summary in full (highlights, takeaways, action group).
func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
videoID := r.PathValue("videoId")
row, err := a.Store.GetSummaryByVideo(r.Context(), a.UserID, videoID)
row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
@@ -110,6 +168,10 @@ func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
// the refreshed button-group fragment for HTMX; without JS it redirects back to
// the detail page (POST→redirect→GET).
func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
videoID := r.PathValue("videoId")
action := r.FormValue("action")
if !isActionVerb(action) {
@@ -117,23 +179,23 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
return
}
current, err := a.Store.ActionsFor(r.Context(), a.UserID, []string{videoID})
current, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID})
if err != nil {
a.serverError(w, r, "read actions", err)
return
}
if actionSet(current[videoID])[action] {
err = a.Store.ClearAction(r.Context(), a.UserID, videoID, action)
err = a.Store.ClearAction(r.Context(), userID, videoID, action)
} else {
err = a.Store.SetAction(r.Context(), a.UserID, videoID, action)
err = a.Store.SetAction(r.Context(), userID, videoID, action)
}
if err != nil {
a.serverError(w, r, "toggle action", err)
return
}
updated, err := a.Store.ActionsFor(r.Context(), a.UserID, []string{videoID})
updated, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID})
if err != nil {
a.serverError(w, r, "read actions", err)
return
@@ -146,10 +208,82 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
}
// handleRequestSummarize queues a video for manual summarization. It does NOT run
// the engine inline — it only flips summarize_requested; the next `tapir run`
// picks it up (the single summarization driver). For HTMX it returns the refreshed
// card (now showing "Queued"); without JS it redirects back to the list.
func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
videoID := r.PathValue("videoId")
err := a.Store.RequestSummarize(r.Context(), userID, videoID)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
a.serverError(w, r, "request summarize", err)
return
}
if !isHTMX(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
if err != nil {
a.serverError(w, r, "get video", err)
return
}
a.render(w, r, VideoCard(*row))
}
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
// submits the desired new value (enabled=true|false). For HTMX it returns the
// refreshed mode control; without JS it redirects back to the account page.
func (a *App) handleSummarizeMode(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
enabled := r.FormValue("enabled") == "true"
if err := a.Store.SetAutoSummarize(r.Context(), userID, enabled); err != nil {
a.serverError(w, r, "set summarize mode", err)
return
}
if !isHTMX(r) {
http.Redirect(w, r, "/account", http.StatusSeeOther)
return
}
a.render(w, r, summarizeModeControl(enabled))
}
// currentUserID returns the tapir user_id the registration gate resolved for this
// request. Behind the gate it is always present; a miss means a handler was
// reached without scoping (a wiring bug), so it answers 500 and reports false.
func (a *App) currentUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
id, ok := CurrentUserID(r)
if !ok {
a.serverError(w, r, "current user", errNoCurrentUser)
}
return id, ok
}
// render writes a templ component as HTML. A render error is logged, not retried:
// headers may already be flushed, so there is nothing useful to send the client.
func (a *App) render(w http.ResponseWriter, r *http.Request, c templ.Component) {
a.renderStatus(w, r, http.StatusOK, c)
}
// renderStatus writes a templ component as HTML with an explicit status code (the
// Content-Type must be set before WriteHeader, so this is the single place that
// orders them correctly).
func (a *App) renderStatus(w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := c.Render(r.Context(), w); err != nil {
a.logger().Error("render", "path", r.URL.Path, "err", err)
}
+136 -7
View File
@@ -45,6 +45,9 @@ const (
userID = "11111111-1111-1111-1111-111111111111"
videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
videoY = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
// stubSubject is the StubAuth Dex subject the registration gate resolves to
// the fixed userID (mapping seeded by resetDB).
stubSubject = "stub-subject-xyz"
)
func newStore(t *testing.T) *store.Store {
@@ -63,21 +66,48 @@ func rawPool(t *testing.T) *pgxpool.Pool {
return p
}
func resetDB(t *testing.T, p *pgxpool.Pool) {
// truncateAll wipes every table to a pristine state (user_identities is cleared
// via the ON DELETE CASCADE from users). Registration tests use this directly so
// no subject is pre-registered.
func truncateAll(t *testing.T, p *pgxpool.Pool) {
t.Helper()
_, err := p.Exec(context.Background(),
`TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
require.NoError(t, err)
}
// newApp builds the App under test: the real store, StubAuth (allow-all) keyed to
// the configured user. This is exactly cmd/tapir's serve wiring minus Dex.
// resetDB truncates, then seeds the StubAuth identity (stubSubject → userID) so
// the registration gate resolves the stub user and the existing handler tests can
// keep seeding and scoping by the fixed userID.
func resetDB(t *testing.T, p *pgxpool.Pool) {
t.Helper()
truncateAll(t, p)
ctx := context.Background()
_, err := p.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)
require.NoError(t, err)
_, err = p.Exec(ctx,
`INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)
ON CONFLICT (dex_subject) DO NOTHING`, stubSubject, userID)
require.NoError(t, err)
}
// newApp builds the App under test as the registered stub user (subject
// stubSubject, resolved to userID by resetDB). This is cmd/tapir's serve wiring
// minus Dex: the store is both the Store and the Identity port.
func newApp(t *testing.T) *web.App {
t.Helper()
return newAppAs(t, stubSubject)
}
// newAppAs builds the App under test with a specific StubAuth Dex subject, so
// registration-gate tests can drive registered vs unregistered subjects.
func newAppAs(t *testing.T, subject string) *web.App {
t.Helper()
s := newStore(t)
return &web.App{
Store: newStore(t),
Auth: web.StubAuth{U: web.User{Subject: userID}},
UserID: userID,
Store: s,
Identity: s,
Auth: web.StubAuth{U: web.User{Subject: subject}},
}
}
@@ -155,8 +185,10 @@ func TestListRendersRowsAndActionState(t *testing.T) {
func TestListHTMXReturnsFragment(t *testing.T) {
ctx := context.Background()
app := newApp(t)
resetDB(t, rawPool(t))
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "body x"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("HX-Request", "true")
@@ -260,6 +292,103 @@ func TestActionRejectsUnknownVerb(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestListShowsSummarizeButtonForUnsummarized(t *testing.T) {
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
// A discovered-but-unsummarized video (no summary delivered).
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Pending Title", "unsummarized videos are listed too")
require.Contains(t, html, "Summarize", "a Summarize button is offered")
require.Contains(t, html, "/v/"+videoX+"/summarize", "button posts to the queue endpoint")
require.Contains(t, html, "card-pending", "muted pending treatment")
require.NotContains(t, html, "Queued", "not queued yet")
}
func TestRequestSummarizeQueuesAndRendersCard(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
rec := postSummarize(t, app, videoX, true)
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Queued", "card now shows the queued state")
require.NotContains(t, html, ">Summarize<", "the Summarize button is gone once queued")
// The flag is persisted, so the next run picks it up.
row, err := app.Store.GetVideoRow(ctx, userID, videoX)
require.NoError(t, err)
require.True(t, row.SummarizeRequested)
}
func TestRequestSummarizeNonHTMXRedirects(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
rec := postSummarize(t, app, videoX, false)
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/", rec.Header().Get("Location"))
row, err := app.Store.GetVideoRow(ctx, userID, videoX)
require.NoError(t, err)
require.True(t, row.SummarizeRequested, "queued on the no-JS path too")
}
func TestRequestSummarizeNotFound(t *testing.T) {
app := newApp(t)
resetDB(t, rawPool(t))
rec := postSummarize(t, app, videoX, true)
require.Equal(t, http.StatusNotFound, rec.Code, "queuing an unknown video is a 404")
}
func TestSummarizeModeToggle(t *testing.T) {
ctx := context.Background()
app := newApp(t)
resetDB(t, rawPool(t))
// Account page defaults to manual.
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Manual", "default mode shown")
require.Contains(t, html, "Switch to automatic")
// Toggle to automatic via HTMX returns the refreshed control.
req := httptest.NewRequest(http.MethodPost, "/account/summarize-mode",
strings.NewReader("enabled=true"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
rec = do(t, app, req)
require.Equal(t, http.StatusOK, rec.Code)
html = body(t, rec)
require.Contains(t, html, "Automatic")
require.Contains(t, html, "Switch to manual")
got, err := app.Store.GetAutoSummarize(ctx, userID)
require.NoError(t, err)
require.True(t, got, "mode persisted")
}
func postSummarize(t *testing.T, app *web.App, videoID string, htmx bool) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/v/"+videoID+"/summarize", nil)
if htmx {
req.Header.Set("HX-Request", "true")
}
return do(t, app, req)
}
// deliver stores a summary through the App's store under test.
func deliver(ctx context.Context, app *web.App, videoID, text string) error {
return app.Store.(*store.Store).Deliver(ctx, summary(videoID, text))
+12 -19
View File
@@ -4,11 +4,13 @@
// interface, so swapping the stub for Dex is a wiring choice in cmd/tapir, not
// a code change (ADR-003).
//
// Authentication is real (Dex OIDC); authorization is single-user — the ID
// token's subject must equal Config.AllowedSubject or the request is refused
// with 403. Sessions are server-side (in-memory, fine for the single Stage-0
// replica) addressed by an HMAC-signed (HS256) HttpOnly Secure SameSite=Lax
// cookie with a short TTL and sliding refresh. Tokens are never logged.
// Authentication is real (Dex OIDC) and is the only gate: any Dex-authenticated
// subject may sign in (ADR-012 dropped ADR-011's single-subject allowlist).
// Authorization/registration is layered on top in internal/web (an authenticated
// subject with no tapir user is routed to registration). Sessions are server-side
// (in-memory, fine for the single Stage-1 replica) addressed by an HMAC-signed
// (HS256) HttpOnly Secure SameSite=Lax cookie with a short TTL and sliding
// refresh. Tokens are never logged.
//
// 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.
@@ -28,8 +30,8 @@ import (
)
// Config is the OIDC + session configuration. cmd/tapir maps these from
// TAPIR_OIDC_*/TAPIR_DEX_*/TAPIR_SESSION_SECRET/TAPIR_ALLOWED_SUBJECT; this
// package takes the resolved struct.
// TAPIR_OIDC_*/TAPIR_DEX_*/TAPIR_SESSION_SECRET; this package takes the resolved
// struct.
type Config struct {
// Issuer is the Dex issuer URL, e.g. https://auth.d-ma.be. Discovery
// (.well-known/openid-configuration) runs against it in New.
@@ -42,9 +44,6 @@ type Config struct {
RedirectURL string
// SessionSecret keys the HS256 session-cookie signature. Never logged.
SessionSecret string
// AllowedSubject is the single Dex subject permitted to sign in. Everyone
// else is refused 403 (single-user authz, ADR-011).
AllowedSubject string
}
const (
@@ -107,7 +106,6 @@ func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
"client secret": cfg.ClientSecret,
"redirect url": cfg.RedirectURL,
"session secret": cfg.SessionSecret,
"allowed subject": cfg.AllowedSubject,
} {
if strings.TrimSpace(val) == "" {
return nil, fmt.Errorf("oidc: missing %s", name)
@@ -242,14 +240,9 @@ func (d *DexAuth) handleCallback(w http.ResponseWriter, r *http.Request) {
return
}
// Single-user authz: only the allowlisted subject may sign in. On mismatch
// we echo the caller's own subject (an opaque id, not a secret) so the
// maintainer can bootstrap TAPIR_ALLOWED_SUBJECT on first login.
if idToken.Subject != d.cfg.AllowedSubject {
http.Error(w, "forbidden — not the allowlisted subject. your subject is: "+idToken.Subject, http.StatusForbidden)
return
}
// Authentication is the only gate (ADR-012): 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, not here.
var claims struct {
Email string `json:"email"`
}
+19 -12
View File
@@ -20,7 +20,7 @@ import (
const (
testClientID = "tapir-web"
allowedSub = "allowed-subject-123"
testSubject = "dex-subject-123"
)
// fakeIssuer is an httptest-backed OIDC provider: it serves a discovery
@@ -120,7 +120,6 @@ func newAuth(t *testing.T, f *fakeIssuer) *oidc.DexAuth {
ClientSecret: "test-client-secret",
RedirectURL: "http://tapir.test/auth/callback",
SessionSecret: "test-session-secret-please-change",
AllowedSubject: allowedSub,
}, oidc.WithInsecureCookies())
require.NoError(t, err)
return auth
@@ -140,12 +139,12 @@ func login(t *testing.T, auth *oidc.DexAuth) (state, nonce string) {
return q.Get("state"), q.Get("nonce")
}
// authenticate completes a full login+callback for the allowlisted subject and
// returns the resulting session cookie.
// authenticate completes a full login+callback for the test subject and returns
// the resulting session cookie.
func authenticate(t *testing.T, auth *oidc.DexAuth, f *fakeIssuer) *http.Cookie {
t.Helper()
state, nonce := login(t, auth)
f.sub, f.email, f.nonce = allowedSub, "maintainer@d-ma.be", nonce
f.sub, f.email, f.nonce = testSubject, "maintainer@d-ma.be", nonce
rec := httptest.NewRecorder()
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
@@ -189,7 +188,7 @@ func TestLoginRedirectsToAuthorize(t *testing.T) {
require.Contains(t, q.Get("scope"), "openid")
}
func TestCallbackAllowedSubjectSetsSession(t *testing.T) {
func TestCallbackSetsSession(t *testing.T) {
f := newFakeIssuer(t)
auth := newAuth(t, f)
@@ -199,26 +198,35 @@ func TestCallbackAllowedSubjectSetsSession(t *testing.T) {
req.AddCookie(cookie)
user, ok := auth.CurrentUser(req)
require.True(t, ok)
require.Equal(t, allowedSub, user.Subject)
require.Equal(t, testSubject, user.Subject)
require.Equal(t, "maintainer@d-ma.be", user.Email)
require.True(t, cookie.HttpOnly)
require.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
}
func TestCallbackNonAllowedSubjectForbidden(t *testing.T) {
// TestCallbackAnySubjectAuthenticates proves the single-subject allowlist is gone
// (ADR-012): a subject other than any prior allowlist still gets a session.
func TestCallbackAnySubjectAuthenticates(t *testing.T) {
f := newFakeIssuer(t)
auth := newAuth(t, f)
state, nonce := login(t, auth)
f.sub, f.email, f.nonce = "intruder-999", "intruder@elsewhere.test", nonce
f.sub, f.email, f.nonce = "some-other-subject-999", "other@elsewhere.test", nonce
rec := httptest.NewRecorder()
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
"/auth/callback?code=valid-code&state="+state, nil))
require.Equal(t, http.StatusForbidden, rec.Code)
require.Empty(t, rec.Result().Cookies(), "no session for a rejected subject")
require.Equal(t, http.StatusFound, rec.Code)
require.Equal(t, "/", rec.Header().Get("Location"))
cookie := sessionCookie(t, rec.Result())
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie)
user, ok := auth.CurrentUser(req)
require.True(t, ok)
require.Equal(t, "some-other-subject-999", user.Subject)
}
func TestCallbackUnknownStateRejected(t *testing.T) {
@@ -309,7 +317,6 @@ func TestExpiredSessionRejected(t *testing.T) {
ClientSecret: "test-client-secret",
RedirectURL: "http://tapir.test/auth/callback",
SessionSecret: "test-session-secret-please-change",
AllowedSubject: allowedSub,
}, oidc.WithInsecureCookies(),
oidc.WithSessionTTL(time.Minute),
oidc.WithClock(func() time.Time { return clock }))
+86
View File
@@ -0,0 +1,86 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestUnregisteredSubjectRedirectedToRegister(t *testing.T) {
app := newAppAs(t, "unregistered-sub")
truncateAll(t, rawPool(t))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusFound, rec.Code)
require.Equal(t, "/register", rec.Header().Get("Location"))
}
func TestRegisterPageReachableWhenUnregistered(t *testing.T) {
app := newAppAs(t, "unregistered-sub")
truncateAll(t, rawPool(t))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/register", nil))
require.Equal(t, http.StatusOK, rec.Code, "/register is exempt from the gate")
require.Contains(t, body(t, rec), "Complete your registration")
}
func TestRegisteredSubjectPassesThrough(t *testing.T) {
app := newApp(t) // stubSubject
resetDB(t, rawPool(t))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, body(t, rec), "<html", "registered subject gets the app, not a redirect")
}
func TestRegisterCreatesExactlyOneUserAndIdentity(t *testing.T) {
ctx := context.Background()
const sub = "brand-new-subject"
app := newAppAs(t, sub)
p := rawPool(t)
truncateAll(t, p)
req := httptest.NewRequest(http.MethodPost, "/register",
strings.NewReader("display_name=Newbie&accept_terms=yes"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := do(t, app, req)
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/", rec.Header().Get("Location"))
// Exactly one identity row for the subject, and its user exists.
var idents int
var newID string
require.NoError(t, p.QueryRow(ctx,
`SELECT count(*), coalesce(max(user_id::text), '') FROM user_identities WHERE dex_subject = $1`,
sub).Scan(&idents, &newID))
require.Equal(t, 1, idents)
var users int
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users WHERE id = $1`, newID).Scan(&users))
require.Equal(t, 1, users)
// Returning subject resolves straight through — no second user created.
rec = do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusOK, rec.Code)
var totalUsers, totalIdents int
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&totalUsers))
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM user_identities`).Scan(&totalIdents))
require.Equal(t, 1, totalUsers, "a second request must not register again")
require.Equal(t, 1, totalIdents)
}
func TestRegisterRejectsMissingFields(t *testing.T) {
app := newAppAs(t, "incomplete-subject")
truncateAll(t, rawPool(t))
req := httptest.NewRequest(http.MethodPost, "/register",
strings.NewReader("display_name=&accept_terms=")) // both missing
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := do(t, app, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
+133
View File
@@ -0,0 +1,133 @@
package web
import (
"context"
"errors"
"net/http"
"strings"
)
// Identity is the narrow port the web layer uses to resolve a Dex subject to a
// tapir user and to register new ones (ADR-012). *store.Store satisfies it; tests
// can substitute a fake. It is deliberately separate from Store: identity
// resolution runs pre-scope (un-RLS'd map), whereas Store runs user-scoped.
type Identity interface {
UserBySubject(ctx context.Context, subject string) (userID string, found bool, err error)
RegisterUser(ctx context.Context, subject, displayName string) (userID string, err error)
}
// errNoCurrentUser indicates a scoped handler ran without a resolved user_id —
// only possible if it was reached outside the registration gate (a wiring bug).
var errNoCurrentUser = errors.New("web: no current user in request context")
// userIDCtxKey types the per-request resolved tapir user_id stored by the
// registration gate. Unexported so only this package can set it.
type userIDCtxKey struct{}
func withUserID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, userIDCtxKey{}, id)
}
// CurrentUserID returns the tapir user_id (UUID) the registration gate resolved
// for the request from the authenticated Dex subject. ok is false for requests
// that never passed the gate (e.g. /register, /auth/*). This is the seam handlers
// — and downstream features (per-user YouTube connect, account management) —
// scope every store access by.
func CurrentUserID(r *http.Request) (string, bool) {
id, ok := r.Context().Value(userIDCtxKey{}).(string)
return id, ok && id != ""
}
// registrationGate sits inside Auth.Middleware. For a gated request it resolves
// the authenticated subject → tapir user_id once and stashes it for handlers; a
// subject with no tapir user is redirected to /register. Exempt paths pass
// straight through (/register so an unregistered user can reach the form; /auth/*
// and /healthz are already public but listed for safety).
func (a *App) registrationGate(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isRegistrationExempt(r.URL.Path) {
h.ServeHTTP(w, r)
return
}
user, ok := a.Auth.CurrentUser(r)
if !ok {
// Auth.Middleware should have caught this; redirect defensively.
http.Redirect(w, r, loginPath, http.StatusFound)
return
}
userID, found, err := a.Identity.UserBySubject(r.Context(), user.Subject)
if err != nil {
a.serverError(w, r, "resolve identity", err)
return
}
if !found {
http.Redirect(w, r, registerPath, http.StatusFound)
return
}
h.ServeHTTP(w, r.WithContext(withUserID(r.Context(), userID)))
})
}
// handleRegisterForm renders the registration form for an authenticated, not-yet-
// registered subject. An already-registered subject is sent to the app root.
func (a *App) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
user, ok := a.Auth.CurrentUser(r)
if !ok {
http.Redirect(w, r, loginPath, http.StatusFound)
return
}
if _, found, err := a.Identity.UserBySubject(r.Context(), user.Subject); err != nil {
a.serverError(w, r, "resolve identity", err)
return
} else if found {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
a.render(w, r, RegisterPage(user.Email, ""))
}
// handleRegister creates the tapir user for the authenticated subject from the
// submitted display name (terms must be accepted), then redirects to the app
// root. A double-submit by an already-registered subject is idempotent.
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
user, ok := a.Auth.CurrentUser(r)
if !ok {
http.Redirect(w, r, loginPath, http.StatusFound)
return
}
if _, found, err := a.Identity.UserBySubject(r.Context(), user.Subject); err != nil {
a.serverError(w, r, "resolve identity", err)
return
} else if found {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
displayName := strings.TrimSpace(r.FormValue("display_name"))
accepted := r.FormValue("accept_terms") != ""
if displayName == "" || !accepted {
a.renderStatus(w, r, http.StatusBadRequest,
RegisterPage(user.Email, "Enter a display name and accept the terms to continue."))
return
}
if _, err := a.Identity.RegisterUser(r.Context(), user.Subject, displayName); err != nil {
a.serverError(w, r, "register user", err)
return
}
setFlash(w, flashRegistered)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
const (
registerPath = "/register"
loginPath = "/auth/login"
)
func isRegistrationExempt(p string) bool {
return p == registerPath || p == "/healthz" || strings.HasPrefix(p, "/auth/")
}
+207 -1
View File
@@ -1,6 +1,7 @@
package web
import (
"regexp"
"strings"
"time"
@@ -9,6 +10,21 @@ import (
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
// youtubeIDRe matches a canonical 11-char YouTube video id (the provider's
// base64url alphabet). Anything else is rejected so we never emit a broken
// embed src.
var youtubeIDRe = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`)
// embedURL builds a privacy-friendly nocookie embed URL for a YouTube video id.
// It returns ("", false) for any id that isn't a valid 11-char YouTube id, so
// the caller can omit the embed instead of rendering a broken iframe.
func embedURL(providerVideoID string) (string, bool) {
if !youtubeIDRe.MatchString(providerVideoID) {
return "", false
}
return "https://www.youtube-nocookie.com/embed/" + providerVideoID, true
}
// actionVerbs is the fixed, ordered set of action toggles rendered in the button
// group. It mirrors the store's allowed actions (store/actions.go); order here is
// the display order, not the store's.
@@ -100,6 +116,51 @@ func detailMeta(r store.SummaryRow) string {
return strings.Join(parts, " · ")
}
// previewText renders a one-line lede for a summary card: it collapses internal
// whitespace, then returns the first sentence when one ends within max runes,
// otherwise truncates at max runes on a word boundary (never mid-word) and
// appends an ellipsis. Empty/short input is returned unchanged (no ellipsis).
// Pure and multibyte-safe — all length work is on runes, not bytes.
func previewText(s string, max int) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = strings.Join(strings.Fields(s), " ")
runes := []rune(s)
// Prefer the first sentence when it terminates within the budget.
if end := firstSentenceEnd(runes); end > 0 && end <= max {
return string(runes[:end])
}
if len(runes) <= max {
return s
}
// Truncate at max runes, then back off to the last word boundary so no
// partial word is emitted. Space is single-byte, so the byte-index slice
// lands cleanly on a rune boundary.
cut := string(runes[:max])
if i := strings.LastIndexByte(cut, ' '); i > 0 {
cut = cut[:i]
}
return strings.TrimRight(cut, " ") + "…"
}
// firstSentenceEnd returns the rune index just past the first sentence
// terminator (. ! ?) that is followed by whitespace or the end of input, or 0
// when there is none.
func firstSentenceEnd(runes []rune) int {
for i, r := range runes {
if r == '.' || r == '!' || r == '?' {
if i+1 == len(runes) || runes[i+1] == ' ' {
return i + 1
}
}
}
return 0
}
// videoURL builds the internal detail-page path for a video id.
func videoURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID)
@@ -110,11 +171,101 @@ func actionURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/action")
}
// summarizeURL builds the manual-queue POST path for a video id.
func summarizeURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/summarize")
}
// summarizeModeLabel names the current mode for display.
func summarizeModeLabel(auto bool) string {
if auto {
return "Automatic"
}
return "Manual"
}
// summarizeModeToggleLabel is the caption on the toggle button — it names the mode
// the click switches TO (the opposite of the current one).
func summarizeModeToggleLabel(auto bool) string {
if auto {
return "Switch to manual"
}
return "Switch to automatic"
}
// boolStr renders a bool as the "enabled" form value the toggle submits.
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
// externalURL passes a stored source URL through templ's URL sanitiser.
func externalURL(u string) templ.SafeURL {
return templ.URL(u)
}
// flashView is the rendered form of a flash code: a severity (drives the banner
// colour) and the human message. Keeping the text here — not in the cookie —
// means the cookie only ever carries an opaque, validated code.
type flashView struct {
Kind string // "success" | "error"
Message string
}
// flashMessages maps each flash code to its banner. An unknown code renders no
// banner (flashFor returns ok=false), so a forged cookie value is inert.
var flashMessages = map[string]flashView{
flashConnected: {"success", "YouTube account connected."},
flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."},
flashDisconnected: {"success", "Account disconnected."},
flashDeleted: {"success", "Your account and all its data were deleted."},
flashRegistered: {"success", "Welcome to Tapir — your account is ready."},
}
func flashFor(code string) (flashView, bool) {
f, ok := flashMessages[code]
return f, ok
}
// providerLabels maps a provider key to its display name for the account page.
var providerLabels = map[string]string{
"youtube": "YouTube",
"vimeo": "Vimeo",
}
func providerLabel(p string) string {
if l, ok := providerLabels[p]; ok {
return l
}
return p
}
// displayNameOr falls back to a placeholder when the user has no display name set.
func displayNameOr(name string) string {
if name == "" {
return "(not set)"
}
return name
}
// hasYouTube reports whether the user already has a YouTube connection, so the
// account page hides the Connect link when one exists.
func hasYouTube(conns []store.Connection) bool {
for _, c := range conns {
if c.Provider == "youtube" {
return true
}
}
return false
}
// disconnectURL builds the disconnect POST path for a provider.
func disconnectURL(provider string) templ.SafeURL {
return templ.SafeURL("/account/disconnect/" + provider)
}
// Filter holds the list-view query parameters. Empty fields mean "no constraint".
// Dates are kept as the raw YYYY-MM-DD strings so the form re-renders the user's
// input verbatim; parsing happens in matchFilter.
@@ -200,8 +351,9 @@ body { font: 15px/1.6 system-ui, -apple-system, sans-serif; margin: 0; color: va
a { color: var(--accent); text-decoration: none; }
a:hover, a:focus-visible { text-decoration: underline; }
a:visited { color: var(--accent); }
header { padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line); background: var(--card); }
header { padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line); background: var(--card); display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
.brand { font-weight: 700; font-size: 1.05rem; color: var(--accent); }
.nav { display: flex; gap: var(--s3); font-size: .9rem; }
main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.muted { color: var(--muted); }
@@ -219,16 +371,34 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--s3) var(--s4); display: flex; flex-direction: column; gap: var(--s2); }
.card-title { font-size: 1.1rem; font-weight: 600; line-height: 1.3; }
.card-meta { color: var(--muted); font-size: .85rem; }
.card-preview { color: var(--muted); font-size: .9rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 1; line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.card-foot { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; margin-top: var(--s1); }
.chip { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: .72rem; font-weight: 600; }
.card-state { color: var(--muted); font-size: .8rem; }
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
/* pending (discovered-but-unsummarized) card: muted until summarized */
.card-pending { border-style: dashed; }
.card-pending .card-title { color: var(--muted); font-weight: 600; }
/* summarization mode toggle on the account page */
.summarize-mode { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; }
.summarize-mode p { margin: 0; }
.summarize-mode form { margin: 0; }
/* empty state */
.empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); }
.empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); }
.empty code { background: var(--accent-weak); color: var(--accent); padding: .1rem .35rem; border-radius: .3rem; }
/* flash / notification banner */
.flash { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); }
.flash-success { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); }
.flash-error { background: #fce8e6; color: #8a1c10; border-color: #d9534f; }
@media (prefers-color-scheme: dark) {
.flash-error { background: #3a1714; color: #f3b5ae; border-color: #a6362e; }
}
/* htmx loading feedback */
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }
@@ -238,6 +408,8 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.detail h1 { font-size: 1.7rem; line-height: 1.25; margin: 0 0 var(--s2); }
.detail .meta { color: var(--muted); font-size: .9rem; margin: 0 0 var(--s2); display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
.detail .source { margin: 0 0 var(--s4); font-size: .9rem; }
.detail .embed { margin: 0 0 var(--s4); aspect-ratio: 16 / 9; border-radius: var(--radius); overflow: hidden; background: #000; border: 1px solid var(--line); }
.detail .embed iframe { display: block; width: 100%; height: 100%; border: 0; }
.detail section { margin-top: var(--s4); }
.detail section h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-top: 1px solid var(--line); padding-top: var(--s3); margin: 0 0 var(--s2); }
.detail .body { white-space: pre-wrap; line-height: 1.7; margin: 0; }
@@ -252,6 +424,40 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.actions .action:active { transform: translateY(1px); }
.actions .action.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
/* account page */
.account { max-width: 40rem; }
.account h1 { font-size: 1.7rem; margin: 0 0 var(--s4); }
.account section { margin-top: var(--s5); }
.account section h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-top: 1px solid var(--line); padding-top: var(--s3); margin: 0 0 var(--s3); }
.account-meta { display: grid; grid-template-columns: max-content 1fr; gap: var(--s1) var(--s3); margin: 0; }
.account-meta dt { color: var(--muted); font-size: .85rem; }
.account-meta dd { margin: 0; }
.conn-list { list-style: none; margin: 0 0 var(--s3); padding: 0; display: grid; gap: var(--s2); }
.conn { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--s3); display: flex; flex-direction: column; gap: var(--s1); }
.conn-main { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
.conn-provider { font-weight: 600; }
.conn-meta { font-size: .8rem; }
.conn form { margin-top: var(--s1); }
.btn-secondary { font: inherit; font-weight: 600; padding: .4rem .9rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); cursor: pointer; }
.btn-secondary:hover { border-color: var(--accent); }
.btn-secondary:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* delete danger zone — destructive action behind a confirm disclosure */
.danger-zone h2 { border-top-color: #d9534f; }
.confirm-delete > summary { display: inline-block; list-style: none; cursor: pointer; font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid #d9534f; border-radius: var(--radius); background: transparent; color: #c0392b; }
.confirm-delete > summary::-webkit-details-marker { display: none; }
.confirm-delete > summary:hover { background: #fce8e6; }
.confirm-delete[open] > summary { margin-bottom: var(--s3); }
.confirm-body { border: 1px solid #d9534f; border-radius: var(--radius); padding: var(--s3); background: #fce8e6; color: #8a1c10; }
.btn-danger { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid #d9534f; border-radius: var(--radius); background: #d9534f; color: #fff; cursor: pointer; }
.btn-danger:hover { filter: brightness(1.05); }
.btn-danger:focus-visible { outline: 2px solid #d9534f; outline-offset: 1px; }
@media (prefers-color-scheme: dark) {
.confirm-body { background: #3a1714; color: #f3b5ae; }
.confirm-delete > summary { color: #f3b5ae; }
.confirm-delete > summary:hover { background: #3a1714; }
}
@media (max-width: 640px) {
main { padding: var(--s3) var(--s2); }
.filters { gap: var(--s2); }
+29
View File
@@ -0,0 +1,29 @@
package web
import "testing"
func TestEmbedURL(t *testing.T) {
tests := []struct {
name string
id string
wantURL string
wantOK bool
}{
{"valid 11-char id", "dQw4w9WgXcQ", "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ", true},
{"valid with dash and underscore", "a_b-cD12345", "https://www.youtube-nocookie.com/embed/a_b-cD12345", true},
{"empty", "", "", false},
{"too short", "abc", "", false},
{"too long", "dQw4w9WgXcQX", "", false},
{"invalid char", "dQw4w9WgXc!", "", false},
{"space", "dQw4w9WgX Q", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotURL, gotOK := embedURL(tt.id)
if gotURL != tt.wantURL || gotOK != tt.wantOK {
t.Errorf("embedURL(%q) = (%q, %v), want (%q, %v)",
tt.id, gotURL, gotOK, tt.wantURL, tt.wantOK)
}
})
}
}
+104
View File
@@ -0,0 +1,104 @@
package web
import "testing"
func TestPreviewText(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
max int
want string
}{
{
name: "empty input",
in: "",
max: 160,
want: "",
},
{
name: "whitespace-only input",
in: " \n\t ",
max: 160,
want: "",
},
{
name: "short string unchanged",
in: "A tidy little summary",
max: 160,
want: "A tidy little summary",
},
{
name: "short single sentence unchanged",
in: "Hello there.",
max: 160,
want: "Hello there.",
},
{
name: "first sentence taken when more follows",
in: "First sentence. Second sentence that we drop.",
max: 160,
want: "First sentence.",
},
{
name: "first sentence with question mark",
in: "What is this? It is a preview.",
max: 160,
want: "What is this?",
},
{
name: "long string truncated on word boundary with ellipsis",
// 5 ten-char words past the limit; max cuts mid "ones".
in: "alpha bravo charlie delta echo foxtrot golf hotel india juliet",
max: 30,
// runes[:30] = "alpha bravo charlie delta echo"; ends exactly on a
// word so the next char would be a space — backs off to last space.
want: "alpha bravo charlie delta…",
},
{
name: "no mid-word cut",
in: "internationalization frameworks everywhere today",
max: 25,
// runes[:25] = "internationalization fram" — back off to the space
// after the first word; never emit a partial word.
want: "internationalization…",
},
{
name: "multibyte safe truncation",
// Accented + emoji runes; cutting on rune indices must not split a
// multibyte sequence.
in: "café déjà vû señor naïve résumé piñata fiancé",
max: 20,
want: "café déjà vû señor…",
},
{
name: "multibyte short unchanged",
in: "café señor",
max: 160,
want: "café señor",
},
{
name: "collapses internal whitespace",
in: "line one\n\n line two\tline three",
max: 160,
want: "line one line two line three",
},
{
name: "sentence beyond max falls back to char truncation",
in: "alpha bravo charlie delta echo foxtrot golf. short.",
max: 20,
want: "alpha bravo charlie…",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := previewText(tt.in, tt.max)
if got != tt.want {
t.Errorf("previewText(%q, %d) = %q, want %q", tt.in, tt.max, got, tt.want)
}
})
}
}
+195 -13
View File
@@ -20,7 +20,10 @@ templ Layout(title string) {
@templ.Raw(styleTag)
</head>
<body>
<header><a href="/" class="brand">Tapir</a></header>
<header>
<a href="/" class="brand">Tapir</a>
<nav class="nav"><a href="/account">Account</a></nav>
</header>
<main>
{ children... }
</main>
@@ -28,10 +31,23 @@ templ Layout(title string) {
</html>
}
// flashBanner renders a one-shot notification for a flash code (connect success/
// failure, disconnect, delete, registration). An empty or unknown code renders
// nothing, so it is safe to drop into any page unconditionally. Reused across the
// app — not per-page ad-hoc markup.
templ flashBanner(code string) {
if f, ok := flashFor(code); ok {
<div class={ "flash", "flash-" + f.Kind } role="status" aria-live="polite">{ f.Message }</div>
}
}
// ListPage is the full summary list with the filter form. HTMX swaps only the
// #summary-list region; a non-HTMX request renders the whole page.
templ ListPage(rows []store.SummaryRow, f Filter) {
// #summary-list region; a non-HTMX request renders the whole page. flash carries
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival
// after a POST→redirect.
templ ListPage(rows []store.SummaryRow, f Filter, flash string) {
@Layout("Tapir — Summaries") {
@flashBanner(flash)
@filterForm(f)
<div id="summary-list">
@summaryList(rows)
@@ -57,25 +73,46 @@ templ filterForm(f Filter) {
</form>
}
// summaryList is the swappable list fragment: one card per summary (title link,
// channel · date meta, provider chip, fallback badge, action state). Cards
// reflow to a single column on mobile; an empty list shows a friendly first-run
// state instead of a blank table.
// summaryList is the swappable list fragment: one card per video (summarized or
// not). Cards reflow to a single column on mobile; an empty list shows a friendly
// first-run state instead of a blank table.
templ summaryList(rows []store.SummaryRow) {
if len(rows) == 0 {
<div class="empty">
<strong>No summaries yet</strong>
<span>Summaries appear here as your subscriptions are processed run <code>tapir run</code> to fetch and summarize new videos.</span>
<strong>No videos yet</strong>
<span>Videos appear here as your subscriptions are processed run <code>tapir run</code> to fetch them. In manual mode, use the Summarize button to queue one.</span>
</div>
} else {
<ul class="cards">
for _, r := range rows {
<li class="card">
@VideoCard(r)
}
</ul>
}
}
// VideoCard is one list card, also returned standalone by POST /v/{id}/summarize
// (HTMX swaps it in place via outerHTML). A summarized video links to its detail
// page and shows its provider chip / fallback badge / action state. An
// unsummarized video gets a muted "pending" treatment and either a "Summarize"
// button (to queue it) or a "Queued" chip when already requested.
templ VideoCard(r store.SummaryRow) {
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
if r.Summarized {
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
} else {
<div class="card-title">{ displayTitle(r) }</div>
}
if cardMeta(r) != "" {
<div class="card-meta">{ cardMeta(r) }</div>
}
if r.Summarized {
if p := previewText(r.Summary, 160); p != "" {
<div class="card-preview">{ p }</div>
}
}
<div class="card-foot">
if r.Summarized {
if r.AIProvider != "" {
<span class="chip">{ r.AIProvider }</span>
}
@@ -85,11 +122,22 @@ templ summaryList(rows []store.SummaryRow) {
if len(r.Actions) > 0 {
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
}
} else if r.SummarizeRequested {
<span class="chip">Queued</span>
<span class="card-state muted">waiting for the next run</span>
} else {
<form
method="post"
action={ summarizeURL(r.VideoID) }
hx-post={ string(summarizeURL(r.VideoID)) }
hx-target={ "#video-" + r.VideoID }
hx-swap="outerHTML"
>
<button type="submit" class="btn-secondary">Summarize</button>
</form>
}
</div>
</li>
}
</ul>
}
}
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
@@ -106,6 +154,18 @@ templ DetailPage(r store.SummaryRow) {
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
}
</p>
if url, ok := embedURL(r.ProviderVideoID); ok {
<div class="embed">
<iframe
src={ url }
title={ displayTitle(r) }
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe>
</div>
}
if r.URL != "" {
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source </a></p>
}
@@ -138,6 +198,128 @@ templ DetailPage(r store.SummaryRow) {
}
}
// RegisterPage is the explicit registration step (ADR-012): an authenticated Dex
// subject with no tapir user picks a display name and accepts the terms to create
// their account. errMsg, when set, reports a validation problem on the prior POST.
templ RegisterPage(email, errMsg string) {
@Layout("Tapir — Register") {
<article class="register">
<h1>Complete your registration</h1>
if email != "" {
<p class="meta">Signed in as { email }.</p>
}
<p>Choose a display name to finish setting up your Tapir account.</p>
if errMsg != "" {
<p class="error" role="alert">{ errMsg }</p>
}
<form method="post" action="/register" class="register-form">
<label>
Display name
<input type="text" name="display_name" required autofocus/>
</label>
<label class="checkbox">
<input type="checkbox" name="accept_terms" value="yes" required/>
I accept the terms of use
</label>
<button type="submit" class="btn">Register</button>
</form>
</article>
}
}
// AccountPage is the account-management view: the registered display name and
// signed-in email, the user's connected video accounts (each with a Disconnect
// control), a Connect-YouTube link when none is connected, and the delete-account
// danger zone. flash surfaces a one-shot notification (disconnect/connect).
templ AccountPage(displayName, email string, conns []store.Connection, autoSummarize bool, flash string) {
@Layout("Tapir — Account") {
@flashBanner(flash)
<article class="account">
<h1>Account</h1>
<dl class="account-meta">
<dt>Display name</dt>
<dd>{ displayNameOr(displayName) }</dd>
if email != "" {
<dt>Signed in as</dt>
<dd>{ email }</dd>
}
</dl>
<section>
<h2>Summarization</h2>
<p class="muted">
Automatic summarizes every new video as it is discovered. Manual lets you
pick which videos to summarize new videos appear in your list with a
Summarize button.
</p>
@summarizeModeControl(autoSummarize)
</section>
<section>
<h2>Connected accounts</h2>
if len(conns) == 0 {
<p class="muted">No connected video accounts yet.</p>
} else {
<ul class="conn-list">
for _, c := range conns {
<li class="conn">
<div class="conn-main">
<span class="conn-provider">{ providerLabel(c.Provider) }</span>
if c.ProviderAccount != "" {
<span class="muted">{ c.ProviderAccount }</span>
}
<span class="chip">{ c.Status }</span>
</div>
<div class="conn-meta muted">connected { c.ConnectedAt.Format("2006-01-02") }</div>
<form method="post" action={ disconnectURL(c.Provider) }>
<button type="submit" class="btn-secondary">Disconnect</button>
</form>
</li>
}
</ul>
}
if !hasYouTube(conns) {
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
}
</section>
<section class="danger-zone">
<h2>Delete account</h2>
<p class="muted">
Permanently remove your Tapir account and all of its data summaries,
watch/skip/save actions, and connected accounts. This cannot be undone.
</p>
<details class="confirm-delete">
<summary class="btn-danger">Delete account…</summary>
<div class="confirm-body">
<p>This permanently deletes your account and all data. Are you sure?</p>
<form method="post" action="/account/delete">
<button type="submit" class="btn-danger">Yes, permanently delete my account</button>
</form>
</div>
</details>
</section>
</article>
}
}
// summarizeModeControl is the auto/manual toggle, also returned standalone by
// POST /account/summarize-mode (HTMX swaps it via outerHTML). The hidden field
// submits the desired NEW value, so a single submit flips the mode; without JS the
// form posts and the handler redirects back to /account.
templ summarizeModeControl(auto bool) {
<div id="summarize-mode" class="summarize-mode">
<p>Current mode: <strong>{ summarizeModeLabel(auto) }</strong></p>
<form
method="post"
action="/account/summarize-mode"
hx-post="/account/summarize-mode"
hx-target="#summarize-mode"
hx-swap="outerHTML"
>
<input type="hidden" name="enabled" value={ boolStr(!auto) }/>
<button type="submit" class="btn-secondary">{ summarizeModeToggleLabel(auto) }</button>
</form>
</div>
}
// ActionButtons is the toggle group fragment returned by POST /v/{id}/action.
// Each button submits its verb; HTMX swaps this element in place (outerHTML),
// and without JS the form POSTs and the handler redirects back to the detail
File diff suppressed because it is too large Load Diff