Compare commits

..
68 Commits
Author SHA1 Message Date
mathiasandClaude Opus 4.8 72bf8a5553 docs(env): document TAPIR_PUBLIC_URL for tapir invite
CI / Lint / Test / Vet (push) Successful in 12s
CI / Build & Import (push) Successful in 10s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:21:06 +02:00
mathiasandClaude Opus 4.8 dece5dec44 feat(web): public /invite/{token} set-password + account-creation flow
The Stage-1 onboarding path: an invited user opens their emailed link,
sets a password, and Tapir creates their Dex local-password account so
they can log in. Mounted on root OUTSIDE Auth.Middleware — the visitor
has no Dex session yet; the token in the path is the capability.

handleInviteForm previews the token (no consume) and shows the form, or
a clear "expired / already used" page. handleInviteSubmit validates the
password BEFORE consuming the token (a typo is retryable), then claims
the invite exactly once, bcrypt-hashes (cost 12), and creates the Dex
account — mapping ErrPasswordExists -> "log in instead" and ErrForbidden
-> "contact the administrator". Off-cluster (App.Dex nil) it degrades to
a "deployed-only" message without burning the token. On success it sets
an account_created flash and redirects to /auth/login.

Welcome sub-text now states access is invite-only. Handlers depend on
narrow ports (InvitationStore, DexPasswordCreator) so tests use fakes;
cmdServe wires the store + an in-cluster dex.PasswordClient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:19:22 +02:00
mathiasandClaude Opus 4.8 893886a60a feat(cli): tapir invite <email> + TAPIR_PUBLIC_URL config
Mints a single-use invitation and prints the absolute claim URL for the
operator to send. The URL base is TAPIR_PUBLIC_URL (default
https://tapir.d-ma.be). runInvite is factored from config/store wiring so
it's unit-tested against a fake inviter — no Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:15:24 +02:00
mathiasandClaude Opus 4.8 e44485df16 feat(dex): in-cluster Password CR client for local-password accounts
Writes passwords.dex.coreos.com CRs against the in-cluster Kubernetes
API using the pod's service-account token + cluster CA (no kubectl /
client-go dependency). NewPasswordClient returns ErrNotInCluster off
cluster so the web layer degrades gracefully in dev.

Load-bearing: Dex's kubernetes storage types Password.Hash as []byte,
which k8s JSON-marshals as base64 — so the `hash` field carries the
base64 of the bcrypt string, not the raw string. Storing the raw string
makes Dex's base64-decode-on-login produce garbage and every login fail.

409 -> ErrPasswordExists, 401/403 -> ErrForbidden (RBAC missing) so the
handler can give precise messages. Tested against an httptest TLS server.

bcrypt cost-12 hashing lives in the web handler; golang.org/x/crypto was
already a transitive dep (now promoted in go.sum).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:14:08 +02:00
mathiasandClaude Opus 4.8 8b7ef07ba3 feat(store): invitations table + create/peek/claim methods
Stage-1 email onboarding: Mathias mints an invite, the recipient claims
it to set a Dex password. Invitations exist before their user, so the
table carries no user_id FK and is deliberately outside RLS — the
32-byte crypto-random token is the capability (single-use, time-boxed).

ClaimInvitation consumes atomically (UPDATE ... WHERE used_at IS NULL
... RETURNING) so concurrent claims of one token can't both succeed.
PeekInvitation validates the link for the form without consuming it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:12:40 +02:00
mathias 1cf58768ed docs: spec the Stage 0 usage-measurement build (login events)
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
Small tapir slice to make the gate measurable as written: append-only
login_events (RLS, per-user-per-day throttle) + a union query over reads
(login_events) and acts (summary_actions) for distinct-active-weeks. Carries the
honesty caveats (unprompted not measurable; data accrues from deploy; week-bucket
noise at low N) and the delete-cascade footgun (no FK, needs explicit delete +
test) from the prior delete work. Out of scope: analytics, prompt-tracking,
dashboards.
2026-06-03 20:59:49 +00:00
mathias f45ba35e25 docs: VISION Stage 0 — keep "unprompted" as ideal, note measurement gap
CI / Lint / Test / Vet (push) Has been cancelled
CI / Build & Import (push) Has been cancelled
Reframes "unprompted" from an enforced criterion to a named measurement
limitation: organic-vs-prompted returns aren't distinguishable from any data
Tapir holds, so in practice all returns are counted and the result read with that
caveat (a nudged return is a weaker signal). Adds a "how it's measured" note
pointing at summary_actions (acts) + a new append-only login-events table
(read-returns), which accrue from deploy onward. Honest about the gap rather than
silently dropping the word.
2026-06-03 20:59:14 +00:00
mathiasandClaude Opus 4.8 943554a96c feat(web): "Retrying later" badge for rate-limited videos
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
A discovered-but-unsummarized video whose caption fetch was rate-limited now
shows a passive  "Retrying later" chip (dim CharmDim styling, not the accent)
instead of the Summarize button — the user cannot fix a 429, the runner retries
automatically once the backoff window expires. Regenerated views_templ.go.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:56:48 +02:00
mathiasandClaude Opus 4.8 40a614c8d4 feat(runner): 429 backoff — skip still-throttled videos, persist status
After ProcessNewVideo the runner records transcript_status per outcome:
rate_limited (stamps the backoff clock), none, or fetched. Before fetching, a
video inside the TAPIR_FETCH_BACKOFF window is skipped (SkippedRateLimited) so a
just-429'd caption endpoint is not re-hit; once the window expires it retries.

Backoff/clock injected via variadic Options (WithBackoff, WithClock) so existing
New call sites and the fake-driven loop tests stay valid. Backoff 0 = always retry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:56:07 +02:00
mathiasandClaude Opus 4.8 ce2fc62ef8 feat(config): TAPIR_FETCH_BACKOFF for rate-limit retry window
Adds FetchBackoff (Go duration, default 1h) controlling how long the run loop
waits before re-fetching a transcript that returned HTTP 429. Zero means always
retry. Not required by ValidateForRun — a zero/unset value is a valid policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:54:24 +02:00
mathiasandClaude Opus 4.8 0ceacc8230 feat(usecase): surface TranscriptSource on ProcessResult
The engine already distinguishes SourceNone from SourceRateLimited internally
but collapsed both into Skipped. Expose the source string so the runner can
persist the right transcript_status and apply rate-limit backoff, without the
engine taking on any store/retry concern (dependencies still point inward).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:53:11 +02:00
mathiasandClaude Opus 4.8 1e81965519 feat(store): TranscriptStatus read field + status setter/loader
Surfaces videos.transcript_status (migration 007) on SummaryRow and adds
SetTranscriptStatus / GetTranscriptStatus / RateLimitedVideoIDs.

SetTranscriptStatus is the single choke point for the rate-limit lifecycle:
"rate_limited" stamps rate_limited_at = NOW(), every other status clears it,
so the runner's backoff window and the UI badge read one consistent source.
RateLimitedVideoIDs is the per-pass loader (mirrors SeenVideoIDs) the runner
uses to skip still-throttled videos without re-hitting the caption endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:52:48 +02:00
mathias f50c072d65 fix(web): add Log out to the persistent nav header
CI / Lint / Test / Vet (push) Successful in 12s
CI / Build & Import (push) Successful in 10s
Logout was only reachable from /welcome. Users who are logged in had no way
to sign out from any app page (list, detail, account). Added to the shared
nav alongside Account.
2026-06-03 22:49:14 +02:00
mathias e6f508824b docs: add ADR-016 — Stage 0 gate revised to "me or a friend", behavioural
CI / Lint / Test / Vet (push) Successful in 19s
CI / Build & Import (push) Successful in 11s
Records the gate change: Stage 0 now passes when either the maintainer or an
onboarded friend returns unprompted in >=2 separate weeks. Behavioural (return
usage), not feedback-based, to resist politeness bias. Includes an honest
self-scrutiny note that this is a guardrail edit made while the original gate was
unmet — examined on that basis and proceeding because it broadens who supplies the
signal without softening the kind of signal required. Adds feedback-based-gate to
rejected alternatives.
2026-06-03 20:46:40 +00:00
mathiasandClaude Opus 4.8 689500c85e feat(store): migration 007 — per-video transcript status
CI / Lint / Test / Vet (push) Successful in 20s
CI / Build & Import (push) Successful in 10s
Adds videos.transcript_status (NULL|none|fetched|rate_limited) and
videos.rate_limited_at, so the runner can record a 429 and skip re-fetching a
still-throttled video until a backoff window elapses. Columns inherit the
existing videos RLS policy (migration 003); no policy change needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:45:29 +02:00
mathiasandClaude Opus 4.8 4678d473b8 feat(youtube): map caption 429 to SourceRateLimited
The baseUrl fetch mapped every non-200 to SourceNone, recording a 429 as a
permanent "no captions". 429 is the IP being rate-limited, not an absent
transcript. Return SourceRateLimited (still a graceful degrade, no error) so
the runner can retry after a backoff window. Other non-200s (403/404/5xx)
stay SourceNone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:45:29 +02:00
mathiasandClaude Opus 4.8 c63b2de66d fix(web): actionable empty state for the summary list
Videos rows are created by `tapir run`, not when a YouTube account is
connected, so a freshly-connected account correctly shows an empty list —
but the old empty state ("No videos yet") gave no clue why or what to do.
Split it on whether the user has any connection:

- connected, no videos: a distinct accent callout telling them to run
  `tapir run` to discover subscriptions.
- not connected: a prompt with a Connect YouTube button.

handleList fetches connections only when the list is empty. Includes
web-shot captures of all three states under docs/ux-review/fixes/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:45:29 +02:00
mathiasandClaude Opus 4.8 27aa319f1d feat(domain): add SourceRateLimited transcript source
429 from the caption endpoint means the IP is rate-limited (retry later),
not that the video has no captions. Distinguishing it from SourceNone is the
prerequisite for the runner's backoff/retry logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:45:29 +02:00
mathiasandClaude Opus 4.8 61d4d5bc4a fix(web): clarify landing CTA copy for new users
The "Get Started" button drops users straight into the shared Dex flow,
which has no separate "register" option — registration completes
automatically after first login. Users new to Tapir had no signal that
signing in is also how they sign up. Reword the sub-text to say so
explicitly, keeping the single Dex CTA (sign-in and sign-up are one flow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:45:29 +02:00
mathiasandClaude Opus 4.8 483730cd03 fix(web): make anchor-styled buttons readable
The .btn class sets color:var(--accent-fg), but the generic a{} and
a:visited{} rules outrank it on <a> elements, so anchor buttons (the
landing "Get Started" CTA, "Connect YouTube") rendered their label
accent-on-accent — invisible. Add a.btn / a.btn:visited to restore the
button foreground.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:45:29 +02:00
mathias 477701fea2 docs: revise Stage 0 gate to "useful to me or a friend" (behavioural)
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 10s
Replaces the original "useful to me, specifically" gate with "me OR a friend
returns unprompted in >=2 separate weeks" — friendly-user signal counts, but the
test stays behavioural (return usage) not feedback-based, to resist politeness
bias. Folds the old Stage 1 ("a trusted user returns") into the new Stage 0 (they
were near-identical), renumbers hardening to Stage 1, and updates the drift
signals (the gate can be softened by mistaking polite feedback for evidence;
multi-user shipping ahead of the gate was a recorded exception per ADR-012, not a
precedent). Rationale recorded in ADR-016.
2026-06-03 20:44:19 +00:00
mathias 17fad140a6 docs: add ADR-015 — per-user credentials envelope-encrypted in PG18
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
Records the infra#88 spike decision: per-user OAuth tokens are runtime app-state,
not config, so they live envelope-encrypted in PG18 under RLS (key from 1P via the
existing read-only SA) rather than in the vault. Infra creds stay ESO/1Password —
two mechanisms because they're two different things. Includes the falsification
conditions (frequent rotation; estate audit policy; key-rotation cost) so the
choice is earned not assumed. Adds the two rejected candidates (vault-write SA;
Supabase) to the rejected-alternatives table. Full reasoning in the #88 decision
doc; build + reboot-validation in #89.
2026-06-03 20:29:50 +00:00
mathias eb24a24b9c chore(ci): remove mirror job (SSH key rotation pending)
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
Mirror to github.com was failing with 'unsupported in libcrypto' on every run
(OpenSSL 3.6 / OpenSSH 10 dropped support for the existing key format). Removed
rather than leave it polluting the CI signal. Re-add when the deploy key is
rotated to ed25519.
2026-06-03 22:21:22 +02:00
mathiasandClaude Opus 4.8 21e6ddd61e docs(ui-spec): record as-built deviations and additions
The Stage-0 ui-spec (ADR-011) predated multi-user and several UX
features. Appended a "Deviations and additions (as-built)" table —
without rewriting the spec — recording each feature shipped beyond it
(multi-user+RLS, registration gate, per-user YouTube connect, account
management, immediate web summarization, charmbracelet spinner,
auto/manual mode, public landing page) with the why and the
commit/ADR that covers each. Preserves the intent-vs-reality split.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:20:04 +02:00
mathiasandClaude Opus 4.8 152aab7a4a docs(use-cases): add registration, summarize-mode, landing scenarios
The .feature spec lagged shipped behaviour. Added three files, no
duplication of existing scenarios:
- registration.feature: new Dex subject -> registration gate (users +
  user_identities), returning subject straight through, account delete
  is tapir-side only and leaves other users intact, clean
  re-registration (ADR-012, ADR-013).
- summarize_mode.feature: auto summarizes every new video; manual
  (default) leaves them unsummarized until queued; queued video is
  processed and the flag cleared (migration 006).
- landing_page.feature: unauthenticated / -> /welcome, Get Started for
  guests, summary link + logout for authed users, logout -> /welcome.

Scoped to built features only — no Vimeo/Whisper/billing scenarios.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:19:15 +02:00
mathiasandClaude Opus 4.8 1018dc0df9 docs(architecture): document the tapir serve web surface (ADR-011/012)
The C4 view predated the web transport. Added a "Web surface" section
with a component diagram and prose covering: tapir serve (HTMX+Templ
over the unchanged store), oidc authenticate-only session, registration
gate (users + user_identities), per-user YouTube web connect callback,
account disconnect/delete (ADR-013 tapir-side only), immediate
summarization via background goroutine + HTMX status poll, and
auto/manual summarize mode (migration 006). Relabeled the L2 http node
to tapir serve. Corrected the out-of-scope isolation bullet: RLS is live
(ADR-012, migration 003), not deferred. ADR-003 stance preserved — the
engine/ports/sinks core is untouched; the web is a new transport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:18:23 +02:00
mathiasandClaude Opus 4.8 74f4fd7f2a docs(data-model): reconcile schema with migrations 002-006
The ER diagram and notes predated migrations 002-006. Brought them to
code truth:
- add summary_actions (002), user_identities (004), video_connections
  (005) with real columns/constraints; rename token_secret_ref ->
  token_ref to match migration 005.
- add users.auto_summarize and videos.summarize_requested (006).
- note FORCE RLS coverage and sink_deliveries' EXISTS-derived policy
  (003); user_identities NOT RLS'd.
- mark AI_CREDENTIAL and SUBSCRIPTION as planned (no table exists; only
  videos.subscription_id, no FK).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:17:15 +02:00
mathiasandClaude Opus 4.8 0cc441d6ce docs(data-model): isolation enforcement is live (RLS), not dormant
The "Isolation invariant" section said enforcement was dormant at
Stage 0. ADR-012 turned it on: Postgres RLS ENABLE+FORCE on every
user-owned table (migration 003_rls.up.sql), keyed off the
tapir.current_user_id GUC set by the store's withUser helper, deny-by-
default on an unset GUC. The Stage-2 two-user isolation test
(internal/adapters/store/rls_test.go) is pulled forward and passing.
Kept the ADR-011 single-user history honest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:15:13 +02:00
mathiasandClaude Opus 4.8 8415a97d15 docs(claude): update build state section to v0.4.0 reality
The "Current build state" section described a scaffolded, intentionally
RED repo (ErrNotImplemented, go 1.23, unverified confirm items). All
stale: build is v0.4.0 green, go 1.26.1 (go.mod), Stage 1 multi-user
with RLS shipped (ADR-012, migrations 001-006). Rewrote to describe the
actual adapters, cmd subcommands (incl. serve), and how to build/run.
Replaced the "confirm" list with the resolved homelab facts (LiteLLM
koala:30401, model-as-config) now pinned in homelab-integration.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:14:42 +02:00
mathiasandClaude Opus 4.8 fb425cbf9a chore(decisions): reorder ADR-010 to numeric position
ADR-010 sat between ADR-008 and ADR-009. Moved ADR-009 (TBD) ahead of
ADR-010 (timedtext caption acquisition) so the file reads 001..014 in
numeric order. Pure structural move, no content change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:13:50 +02:00
mathiasandClaude Opus 4.8 e77edf58ef docs(web): update auth.go comments for multi-user reality (ADR-012)
Package comment said "Stage-0 ... (ADR-011)" and User.Subject said
"single-user allowlist (ADR-011)". Both stale: ADR-012 opened Stage 1
(multi-user, RLS-enforced isolation). Subject is now the user_identities
lookup key (migration 004) resolving to a per-user UUID; an unknown
subject hits the registration gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:13:13 +02:00
mathiasandClaude Opus 4.8 f15f57f9ed test(web): cover the /welcome landing page and its routing
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 11s
CI / Mirror to GitHub (push) Failing after 3s
Add a configurable fakeAuth (StubAuth can't express the logged-out case)
and assert: /welcome renders the logged-out CTA with no session and the
logged-in controls with one, unauthenticated / redirects to /welcome,
unauthenticated deep links redirect to /auth/login, and an authenticated
root still renders the list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:51:21 +02:00
mathiasandClaude Opus 4.8 d208110002 feat(web): mount /welcome handler outside the auth guard
Wire GET /welcome on the root mux alongside /healthz, outside
Auth.Middleware. handleWelcome peeks the session via CurrentUser (no
redirect) and renders the logged-out or logged-in WelcomePage variant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:50:36 +02:00
mathiasandClaude Opus 4.8 3a27bf1126 feat(web): WelcomePage landing component
Add the public landing page: a static Charm-box tapir mascot (reusing the
shared tapirLine helpers and charm palette), a tagline, and a single
'Get Started' CTA into the shared Dex flow — sign-in and sign-up are the
same URL (ADR-012). Logged-in visitors get a greeting plus links back into
the app and to log out. Regenerated views_templ.go committed alongside.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:50:21 +02:00
mathiasandClaude Opus 4.8 8ca374e657 fix(web): logout redirects to /welcome, not /auth/login
Logout was bouncing the just-logged-out visitor straight back into a Dex
login. Land them on the public /welcome page instead — an intentional UX
fix. Cookie clearing and server-side session deletion are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:49:08 +02:00
mathiasandClaude Opus 4.8 0fdf2f7218 feat(web): route unauthenticated root to /welcome, deep links to login
An unauthenticated visit to / now lands on the public /welcome page
instead of bouncing straight to Dex. Deeper guarded paths still redirect
to /auth/login so the post-login round-trip returns the visitor to the
page they asked for. isPublicPath runs first, so no redirect loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:48:51 +02:00
mathiasandClaude Opus 4.8 d83943c86a feat(web): make /welcome a public path
The landing page must render without a session. Add /welcome to
isPublicPath so the auth middleware lets it through (alongside /healthz
and /auth/*), and assert the bypass in the public-paths test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:48:25 +02:00
mathias 6b817f11b9 docs: add landing-page + doc-reconciliation build spec
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Failing after 3s
CI / Build & Import (push) Successful in 10s
Workstream A: public /welcome landing page (bubbletea aesthetic, one Dex login
flow, logged-in shortcuts) — with the oidc.go facts verified against main, incl.
the two corrections that only surface from reading the code (logout must redirect
to /welcome not /auth/login; bare-/ vs deep-link redirect split).

Workstream B: reconcile the guardrail docs against deployed reality (v0.4.0) —
auth.go comments, data-model isolation status + migrations 002-006 schema,
architecture web surface, use-case scenarios for the Stage-1 features, ADR
ordering, and a requirements-vs-shipped deviation check. Structured as two
parallel workstreams so the doc audit isn't done cursorily alongside the build.
2026-06-03 19:18:40 +00:00
mathias 672a0c8580 docs: add ADR-013 (delete semantics) and ADR-014 (429 handling + UX)
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Failing after 3s
CI / Build & Import (push) Successful in 10s
ADR-013 records the deliberate choice that account deletion is Tapir-side only
(cascade + secret purge), leaving the shared Dex identity intact — clean
re-registration, but a noted GDPR-shaped gap if Future C ever arrives.

ADR-014 specifies timedtext 429 handling: Retry-After-aware backoff, a single
per-egress-IP rate gate shared by the batch and click paths, and honest in-flight
UX (summarizing / queued-waiting / no-transcript) so a rate-limited fetch never
presents as a stuck spinner or error. Whisper stays deferred pending measurement
of the sustainable rate, which this work finally makes measurable.
2026-06-03 19:03:52 +00:00
mathiasandClaude Opus 4.8 a4aeb5efcd feat(web): charmbracelet-aesthetic tapir spinner with charm palette
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Has been skipped
Replace the plain ASCII spinner with a Charmbracelet-style TUI panel rendered in
the browser: a dark terminal card holding a rounded purple box (╭─╮╰─╯│) around a
pink block-char tapir (▄▓▀█) with mint eyes (◕ ◕) and a snout that wiggles ∩→∪→~
across three cross-faded frames, plus a lipgloss-style progress bar whose mint
fill grows over a dim track via an 8s CSS clip animation.

Colours come from named palette consts (CharmPurple/Pink/Mint/Cream/Dim) applied
as inline span styles, so the box and CSS share one source of truth. Frames are
built by a small run/line helper that pads each row to a fixed interior width —
an internal test asserts every box row is the same cell width so the border stays
flush. Honours prefers-reduced-motion (static frame + partial bar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:11:17 +02:00
mathiasandClaude Opus 4.8 8c6c7ca947 feat(cmd): shared buildProcessor + wire immediate web summarization
CI / Lint / Test / Vet (push) Successful in 20s
CI / Build & Import (push) Successful in 11s
CI / Mirror to GitHub (push) Failing after 3s
Extract the engine wiring (YouTube source, AI-router summarizer, store sink)
into buildProcessor, shared by cmdRun and cmdServe. It returns (nil, nil) — not
an error — on incomplete config, which is the queue-only fallback for serve.
engineProcessor adapts the engine to web.Processor: load the video row, run the
engine, clear the manual queue flag on a produced summary (mirrors the runner).
cmdServe wires it onto web.App.Processor; cmdRun reuses buildProcessor so the
wiring is no longer duplicated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:50:06 +02:00
mathiasandClaude Opus 4.8 25215cbcbd feat(web): immediate summarize + status poll + ASCII tapir spinner
Clicking "Summarize" now runs the summary in the background (when a Processor is
wired) instead of only queuing it. The handler flips the DB flag, kicks off the
work on a detached context, and returns an animated "processing" card that polls
GET /v/{id}/status every 2s via HTMX. Status returns the summary card once it
lands (no poll → polling stops), the animation while in-flight, or the Queued
card otherwise. Queue-only behaviour is unchanged when no Processor is set.

The spinner is a CSS-only cross-fade of three ASCII tapir frames — no JS, honours
prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:48:52 +02:00
mathiasandClaude Opus 4.8 404f74c55c feat(web): Processor port + in-flight ProcessingSet on App
Add the seam for immediate web-triggered summarization. Processor is the
optional single-video summarize port (nil = queue-only, unchanged behaviour);
ProcessingSet is an ephemeral, concurrency-safe set of in-flight (user,video)
ids so a status endpoint can show progress until the summary lands. State is
deliberately in-memory only — the DB holds the durable truth across restarts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:46:20 +02:00
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
92 changed files with 8420 additions and 725 deletions
+10
View File
@@ -46,3 +46,13 @@ TAPIR_SECRETS_FILE=
# --- run loop ------------------------------------------------------------- # --- run loop -------------------------------------------------------------
# Empty/0 = single pass. Set (e.g. 15m) to poll on that cadence. # Empty/0 = single pass. Set (e.g. 15m) to poll on that cadence.
TAPIR_POLL_INTERVAL= TAPIR_POLL_INTERVAL=
# How long to wait before re-fetching a transcript that returned HTTP 429
# (rate_limited). Inside the window the video is skipped without hitting the
# caption endpoint; after it expires the video is retried. 0 = always retry.
# Go duration; default 1h.
TAPIR_FETCH_BACKOFF=
# --- invitations (tapir invite) -------------------------------------------
# Public base URL used to build the invite link `tapir invite <email>` prints.
# Default https://tapir.d-ma.be; no trailing slash needed.
TAPIR_PUBLIC_URL=
+1 -21
View File
@@ -90,24 +90,4 @@ jobs:
&& echo "Smoke test passed" \ && echo "Smoke test passed" \
|| echo "Smoke test inconclusive: $OUTPUT" || echo "Smoke test inconclusive: $OUTPUT"
# ── 3. Mirror to GitHub (deploy intentionally omitted until manifests exist) ─ # ── 3. Mirror to GitHub — skipped for now (SSH key rotation pending) ─
mirror:
name: Mirror to GitHub
needs: build
runs-on: self-hosted
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push to GitHub
run: |
mkdir -p ~/.ssh
echo '${{ secrets.GH_DEPLOY_KEY }}' > ~/.ssh/id_rsa_gh_mirror
chmod 600 ~/.ssh/id_rsa_gh_mirror
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa_gh_mirror -o IdentitiesOnly=yes" \
git push git@github.com:mathiasb/tapir.git HEAD:main
rm ~/.ssh/id_rsa_gh_mirror
echo "Mirrored to GitHub"
+25 -15
View File
@@ -79,23 +79,33 @@ Skills live in the canonical library `mathias/skills` and are wired into this re
## Current build state (start here for the first task) ## Current build state (start here for the first task)
The repo is **scaffolded and intentionally RED**: The repo is **green and shipping** — last tag `v0.4.0`. `task check` passes (fmt, vet, lint,
`go test -p 1 ./...`). Go is `1.26.1` (see `go.mod`).
- Clean Architecture skeleton exists: `internal/domain` (entities), `internal/ports` - Clean Architecture core is implemented: `internal/domain` (entities), `internal/ports`
(interfaces), `internal/usecase` (engine), `cmd/tapir` (entrypoint stub), (interfaces), `internal/usecase.Engine.ProcessNewVideo` (resolve transcript → summarize →
`internal/adapters` (empty — concrete adapters go here). deliver to sinks | skip on no-transcript). The acceptance tests in `test/acceptance/` are
- `usecase.Engine.ProcessNewVideo` returns `ErrNotImplemented`. green against it.
- `test/acceptance/summarize_new_video_test.go` translates the first two Gherkin scenarios and - Adapters present under `internal/adapters/`: `youtube` (captions-first `VideoSource`,
**fails** against the stub. `task check` is therefore red on `test`. timedtext/InnerTube acquisition per ADR-010), `summarizer` + `llm` (the copied AI router,
- **First build task:** implement `ProcessNewVideo` (resolve transcript -> summarize -> deliver to Primary→Fallback per ADR-004), `store` (Postgres, golang-migrate migrations 001006),
sinks | skip on no-transcript) to make the acceptance tests green, following the `.feature` `secrets` (file-backed `SecretStore`). The brain HTTP sink (ADR-005) is the remaining
files. Then add the AI-router `Summarizer` (copy `llm` per ADR-004), the YouTube `VideoSource` optional sink.
adapter (captions-first), and the store + brain sinks. - Stage 1 is open (ADR-012): multi-user with **DB-enforced** isolation — Postgres RLS `FORCE`d
on all user-owned tables (migration 003), two-user isolation test in
`internal/adapters/store/rls_test.go`. Registration gate, per-user YouTube web connect, and
account management (disconnect / delete, ADR-013) all shipped.
- `cmd/tapir` subcommands: `list`, `show`, `auth` (interactive host-side OAuth), `run` (batch
watch→summarize), `serve` (the HTMX+Templ web reader/writer under `internal/web`, a new
transport over the unchanged engine/ports — ADR-003). `tapir env` prints config.
- **Build/run:** `task check` is the gate; `task build` produces the binary. Local dev uses
`StubAuth` (allow-all) and a `TAPIR_DB_DSN` Postgres; the deployed service uses Dex OIDC.
**Unverified setup items** (see `docs/homelab-integration.md`, marked `confirm`): the Go version **Setup facts** (resolved — see `docs/homelab-integration.md` for the live values): LiteLLM is
in `go.mod` (1.23 — match the koala runner; estate elsewhere uses 1.26.1), the brain-mcp URL, the off-cluster at `koala:30401/v1/` with `LITELLM_MASTER_KEY` from 1Password; the summarization
exact ESO secret-ref naming, and the summarization model alias. Resolve against the live cluster model is config (`TAPIR_SUMMARIZER_MODEL`, default `koala/phi4-mini`), never hardcoded. The
before depending on them, and pin answers back into `docs/homelab-integration.md`. brain-mcp base URL and ESO ref scheme are pinned in that doc; check it before wiring rather than
re-deriving.
## Provenance (where this design came from) ## Provenance (where this design came from)
+218 -16
View File
@@ -154,6 +154,22 @@ governs advancement. Reversible: if demand appears, a new ADR opens the Future C
--- ---
## ADR-009 — Trunk-Based Development
**Status:** Accepted (2026-06-02)
**Context.** Platform-wide convention (homelab architecture review invariant; gitea-mcp #27):
commit directly to `main`, one logical change per commit, every commit deployable.
**Decision.** Tapir follows TBD. Commit directly to `main`. No feature branches or PRs for
solo/agent work; short-lived `agent/<desc>` branches only when parallel agents are active on
the repo simultaneously. CI is the quality gate, not branch protection.
**Consequences.** Consistent with the rest of the estate. Depends on the direct-to-main write
path tracked in gitea-mcp #35 (item #1).
---
## ADR-010 — Third-party caption acquisition via the timedtext/player baseUrl ## ADR-010 — Third-party caption acquisition via the timedtext/player baseUrl
**Status:** Accepted (2026-06-02) **Status:** Accepted (2026-06-02)
@@ -203,22 +219,6 @@ player/timedtext baseUrl) only. ADR-007's captions-first stance and the STT defe
--- ---
## ADR-009 — Trunk-Based Development
**Status:** Accepted (2026-06-02)
**Context.** Platform-wide convention (homelab architecture review invariant; gitea-mcp #27):
commit directly to `main`, one logical change per commit, every commit deployable.
**Decision.** Tapir follows TBD. Commit directly to `main`. No feature branches or PRs for
solo/agent work; short-lived `agent/<desc>` branches only when parallel agents are active on
the repo simultaneously. CI is the quality gate, not branch protection.
**Consequences.** Consistent with the rest of the estate. Depends on the direct-to-main write
path tracked in gitea-mcp #35 (item #1).
---
## ADR-011 — Web read-surface at Stage 0: Dex authn (single-user authz), action signal, public ingress + GitOps ## ADR-011 — Web read-surface at Stage 0: Dex authn (single-user authz), action signal, public ingress + GitOps
**Status:** Accepted (2026-06-02) **Status:** Accepted (2026-06-02)
@@ -290,6 +290,205 @@ explicit call, with isolation as the guardrail that keeps it safe.
--- ---
## ADR-013 — Account deletion is Tapir-side only; the Dex identity is left intact
**Status:** Accepted (2026-06-03)
**Context.** Stage 1 (ADR-012) added account deletion. A registered user is two things: a
`users` row (plus all their data, cascade-linked) in Tapir's Postgres, and a subject identity
in **Dex** (the homelab OIDC provider, shared across the estate — Tapir does not own it).
"Delete my account" could mean (a) erase all Tapir-side data and secrets, or (b) that plus
deprovision the Dex identity. The maintainer chose (a).
**Decision.** Deleting a Tapir account removes **only Tapir-side state**:
- The `users` row, cascading to all user-owned tables (`videos`, `transcripts`, `summaries`,
`sink_deliveries`, `video_connections`, and — via an **explicit delete**, because it has no
FK — `summary_actions`). The delete test asserts the cascade reaches every table and leaves
other users' rows untouched.
- All of that user's secrets in the SecretStore (the per-user YouTube refresh-token refs).
The **Dex identity is deliberately left intact.** Tapir does not deprovision, disable, or
modify the shared Dex directory.
**Consequences.**
- **Clean re-registration:** a deleted user who logs in again arrives as a Dex-authenticated
subject with no `users` row, so they hit the registration gate as a "new" user — no special
resurrection path needed. This is a feature of the choice, not an accident.
- **Right-to-erasure is partial.** The user's *identity* still exists in Dex after deletion.
For Future B (trusted friends) this is acceptable: Dex is the maintainer's own directory and
the identity carries no Tapir content. **But if Tapir ever moves toward Future C (real
external/public users), this is a GDPR-shaped gap** — a true "delete my account" there must
also deprovision or anonymise the Dex identity, which is a new ADR and likely a Dex-admin
integration Tapir does not currently have.
- **Blast radius stays small:** Tapir never holds write access to the shared identity provider,
consistent with the estate's blast-radius-minimisation posture (ADR-002, architecture review).
**Reversibility.** Adding Dex deprovisioning later is a superseding ADR; nothing about the
current choice blocks it. Recorded now because "deletion is partial by design" is a deliberate
semantic that future-Tapir (and any compliance review) must know was chosen, not overlooked.
---
## ADR-014 — Timedtext 429 handling: per-host backoff + honest in-flight UX, before any Whisper reconsideration
**Status:** Accepted (2026-06-03)
**Context.** ADR-010 acquires captions from the unauthenticated `timedtext` baseUrl. Live runs
show that endpoint **rate-limits per source IP (HTTP 429) under volume** — many videos fetched
in one pass from one egress IP. Stage 1 (ADR-012) made this sharper in two ways: multiple users
now drive fetches from the *same cluster egress IP*, and the v0.4.0 "Summarize" button fires an
**immediate, synchronous-feeling** fetch on click (HTMX polls `/v/{videoId}/status`), so a 429
now surfaces as a *user-facing stall* rather than a background batch hiccup. A throttle
(`TAPIR_FETCH_DELAY`) exists but is a fixed inter-fetch delay, not 429-aware, and does not
coordinate across the concurrent click-path and the `tapir run` batch path.
This ADR is **not** a decision to build Whisper. ADR-007/010 keep STT deferred *pending
measurement of the sustainable caption rate* — and that rate cannot be measured while the
client reacts badly to the 429s it already provokes. Fix the backoff and the UX first; the
clean data then tells you whether Whisper is warranted.
**Decision.**
1. **429-aware backoff at the fetch layer.** On a 429 from the timedtext/InnerTube fetch,
respect `Retry-After` when present; otherwise exponential backoff with jitter. This replaces
reliance on a fixed `TAPIR_FETCH_DELAY` alone (which stays as a floor/politeness delay).
2. **A single per-egress-IP rate gate** shared by *both* the `tapir run` batch path and the
web click path, so they cannot collectively exceed the sustainable rate. Concurrency into
the timedtext endpoint is serialised/limited at this gate regardless of how many users or
goroutines are upstream. (The 429 is per *IP*, not per user — so the gate is process-/
cluster-egress-wide, not per-`withUser`.)
3. **Honest in-flight UX (the product-shaping part).** The status poll distinguishes states
the user can understand instead of a spinner that silently stalls:
- *summarizing* — actively processing (the existing tapir spinner).
- *queued / waiting for rate limit* — fetch deferred behind the rate gate; show a calm
"queued, this can take a few minutes when busy" state, not a stuck spinner.
- *no transcript* — terminal, per ADR-010's degrade-never-error (a 429 that exhausts retries
resolves to `SourceNone`, same as any unavailable caption — it must not present as a hard
error to the user).
The spinner promising imminence is the wrong signal under rate-limiting; the UX must be able
to say "waiting" truthfully.
4. **Measurement before Whisper.** Only once (1)-(3) are in and a real sustainable
per-IP rate is observed do we revisit whether caption coverage is good enough or whether the
deferred Whisper fallback (ADR-007) is finally warranted. That reconsideration is a future
ADR, gated on this data.
**Consequences.**
- Caption fetching becomes well-behaved under multi-user load instead of self-inflicting 429s;
the endpoint is treated as the shared, rate-limited resource it is.
- The click-path UX stays honest: "waiting" reads as waiting, failure degrades to "no
transcript", never a stuck spinner or error spew.
- A future per-IP cooldown / second egress IP / proxy becomes an option the rate gate can sit
in front of without UX changes.
- **Still no Whisper** — and now there's a clean path to the *data* that decides whether it's
ever needed (`docs/homelab-integration.md` and a future ADR own that measurement).
**Open (tracked, not in this ADR's scope):** the actual sustainable rate number; whether a
dedicated egress IP / outbound proxy is worth it; CronJob-driven `tapir run` interaction with
the rate gate (the batch path moves into k3s per the deferred CronJob item).
---
## ADR-015 — Per-user credentials: envelope-encrypted in PG18, not vault-stored
**Status:** Accepted (2026-06-03)
**Context.** The Stage-0/1 SecretStore (`internal/adapters/secrets/file.go`) holds per-user
YouTube OAuth refresh tokens as a flat key-value JSON map on a PVC — explicitly a stand-in for
"op/ESO later" (ADR-002, ADR-006). infra#86 proposed migrating it to an ESO-backed store. The
decision spike (infra#88) found that framing subtly wrong: **ESO syncs vault→cluster at
deploy/refresh time; it is not a runtime write API.** Per-user tokens are written *at runtime,
per end-user* (every YouTube connect; on token rotation) — they are application state, not
configuration. The homelab 1Password SA is also read-only, so a vault-write path would require
a new write-capable SA, widening Tapir's blast radius to shared estate infra to store what is
fundamentally Tapir's own row-data. Reading the actual SecretStore confirmed the shape: a
3-method port (`Get`/`Put`/`Delete`) over opaque refs, written interactively per user.
**Decision.** Per-user credentials are stored **envelope-encrypted in PG18**, not in the vault:
1. Tokens are encrypted with a **single app-level envelope key** and stored as ciphertext in
PG18, under the Row-Level Security already enforced and tested (ADR-012). Reads/writes go
through the existing `withUser` RLS-scoped seam.
2. The **envelope key** is the only secret in 1Password — fetched via the **existing read-only
SA** (confirmed working). No new write-capable SA; no per-user vault items.
3. The `ports.SecretStore` port is unchanged (`Get`/`Put`/`Delete`). The implementation swaps
`FileStore` (PVC JSON) for a `PGStore` (encrypted rows). Every consumer — connect,
disconnect, delete-account — is untouched (the port abstraction holds, ADR-003 spirit).
4. **Infra/operator credentials** (Dex client secret, MCP-auth tokens, service tokens) stay an
**ESO/1Password** concern. This ADR governs *per-user runtime* credentials only. The two
classes use two mechanisms deliberately — because they are two different things (runtime
app-state vs deploy-time config), not as a compromise. The "one mechanism" question
(maintainer's initial preference) was answered in #88 by correctly *classifying* the
secrets rather than unifying their storage.
**Consequences.**
- Runtime credential writes are normal RLS'd DB writes — no ESO sync latency, no indirection,
no write-SA blast radius. The interactive connect→store→use flow works without a vault
round-trip.
- Keeps PG18 and keeps ADR-002 intact (Supabase was considered and rejected again in #88
adding a datastore to hold a few encrypted strings PG18 already holds).
- Adds an encrypt/decrypt seam and an **envelope-key rotation** responsibility (re-encrypt the
per-user rows under a new key). infra#89 (build) must implement and test rotation, not assume
it — this is the real engineering cost of the choice.
- The vault's involvement shrinks to one static key via the SA already trusted for reads.
- **Supersedes** the "PVC stand-in for op/ESO" intent recorded in `secrets/file.go` and
`docs/homelab-integration.md` for the *per-user* secret path (the ESO/1Password reference in
ADR-006 stands for the *infra-cred* path).
**Reversibility / falsification (from infra#88).** Revisit if: per-user tokens need
high-frequency rotation writes (weak — PG18 handles it); an estate compliance policy requires
all credentials in 1P for a single audit surface (maintainer-knowable, not currently believed
to hold — would favour the vault-write path on policy grounds); or envelope-key rotation proves
operationally worse than per-secret vault rotation (the real cost #89 must prove). If none hold,
this stands. Full reasoning + rejected candidates (write-capable SA; Supabase): the infra#88
decision doc (`infra/docs/superpowers/handoffs/`). Build + reboot-validation: infra#89.
---
## ADR-016 — Stage 0 gate revised: "useful to me OR a friend", behavioural not feedback
**Status:** Accepted (2026-06-03). Revises the Stage 0 definition in VISION.md (supersedes the
original "useful to me, specifically" gate and folds in the old Stage 1 "a trusted user returns"
test).
**Context.** The original Stage 0 gate was "the maintainer reads summaries weekly for four weeks
and acts on one." The maintainer chose to change it to include friendly users, reasoning that
early signal from friendly users is valuable. Two sub-decisions shaped the final form:
- *Me OR a friend* (not AND): either the maintainer or an onboarded friend showing use clears it.
- *Behavioural, not feedback*: the test is **return usage**, not stated approval.
**Decision.** Stage 0 passes when, over a 34 week window, **either the maintainer or at least
one onboarded friend returns to Tapir unprompted and reads/acts on summaries in ≥2 separate
weeks.** Friend feedback is gathered and valued but is **not** the gate.
**Why behavioural, not feedback (the load-bearing part).** Asked-for feedback from friendly
users is the least reliable signal in product development — politeness bias means a friend you
onboarded will tend to say encouraging things regardless of real value. The thing actually worth
knowing is whether they *come back on their own*. So the gate measures returns, not nice words.
This deliberately resists the most common way a principled gate dies: being declared "passed" on
the strength of a polite reaction.
**Honest note on what this change does.** This is a *guardrail edit made while the original gate
was unmet* (Stage 0 had barely started; build had run well ahead of use-evidence). That is
precisely the pattern that warrants scrutiny — redrawing a gate around work already done. It was
examined on that basis and proceeds because: (a) the new gate is **not softer in kind** — it
stays behavioural and sustained, merely broadening *who* can supply the signal; (b) friendly-user
signal is genuinely valuable; (c) the politeness-bias guard keeps it from collapsing into
"someone said it's nice." It is *not* a licence to treat the already-shipped Stage-1 machinery as
evidence the gate passed — use-evidence remains open.
**Consequences.**
- VISION.md Stage 0 rewritten; old Stage 1 ("a trusted user returns") folded in (it was
near-identical to the new test); hardening renumbered to Stage 1.
- New drift signal added: declaring the gate passed on polite feedback rather than return-usage.
- The 2026-07-01 check-in now asks "is anyone (me or a friend) coming back unprompted?", not
"am I using it weekly?".
**Reversibility.** A superseding ADR could tighten it back to maintainer-only or raise it to
require multiple returning users. Recorded with the full rationale (including the self-scrutiny
about editing a gate while it's unmet) so the reasoning survives, not just the new wording.
---
## Rejected alternatives ## Rejected alternatives
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
@@ -308,6 +507,9 @@ maps to the ADR that settles it.
| Audio-download + Whisper STT in the core path | ToS-grey, breakage-prone (yt-dlp), contends for koala GPU with the JEPA PoC; captions alone test the core hypothesis | ADR-007 | | Audio-download + Whisper STT in the core path | ToS-grey, breakage-prone (yt-dlp), contends for koala GPU with the JEPA PoC; captions alone test the core hypothesis | ADR-007 |
| Building multi-tenant SaaS / Google OAuth verification now | "Real users soon" was lowered to Future B; SaaS machinery before the Stage 0 self-use gate is the primary documented anti-goal | ADR-008, VISION | | Building multi-tenant SaaS / Google OAuth verification now | "Real users soon" was lowered to Future B; SaaS machinery before the Stage 0 self-use gate is the primary documented anti-goal | ADR-008, VISION |
| Delegating the S5 reuse spike to an agent swarm | A 1-hour sequential read-and-judge with a single coupled conclusion; orchestration overhead exceeds the work, and it's Diamond-1 judgment the maintainer wanted to own | (process note) | | Delegating the S5 reuse spike to an agent swarm | A 1-hour sequential read-and-judge with a single coupled conclusion; orchestration overhead exceeds the work, and it's Diamond-1 judgment the maintainer wanted to own | (process note) |
| Vault-write SA for per-user OAuth tokens (ESO as runtime write path) | ESO syncs vault→cluster at deploy time, not a runtime write API; a write-SA widens blast radius to shared infra to store app row-data | ADR-015, infra#88 |
| Supabase for per-user credential storage | Adds a second datastore for a few encrypted strings PG18 already holds; reopens ADR-002 | ADR-015, infra#88 |
| Feedback-based Stage 0 gate (friends saying it's useful) | Politeness bias makes asked-for feedback the least reliable signal; return-usage is the real test | ADR-016 |
If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one — If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one —
not a silent reversal. not a silent reversal.
+50 -27
View File
@@ -44,50 +44,69 @@ fallback — their key, their choice.
## Who it is for ## Who it is for
- **Now (the first customer):** the maintainer — one person, their own subscriptions, - **Now (the first customers):** the maintainer and a small number of known, trusted
summaries delivered to their own store and brain. friends — each with their own account, isolated data, optional BYO-AI. The maintainer is
- **Soon (Future B):** a small number of known, trusted users (friends / beta) — each with the first customer; friendly users provide the earliest real-world signal.
their own account, isolated data, optional BYO-AI.
- **Maybe (Future C, explicitly not built yet):** a public multi-tenant service. Deferred - **Maybe (Future C, explicitly not built yet):** a public multi-tenant service. Deferred
until there is evidence of sustained personal use **and** real demand. Building for C until there is evidence of sustained use **and** real demand. Building for C before that
before that evidence is a known anti-goal. evidence is a known anti-goal.
## Definition of Success ## Definition of Success
Success is staged. Each stage has a single, falsifiable headline test. We do not advance Success is staged. Each stage has a single, falsifiable headline test. We do not advance
to the next stage's ambition until the current stage's test passes. to the next stage's ambition until the current stage's test passes.
### Stage 0 — Useful to me (the gate) ### Stage 0 — Useful to me or a friend (the gate)
> **Headline test:** For four consecutive weeks, the maintainer reads Tapir-produced > **Headline test:** Over a 34 week window, *either* the maintainer *or* at least one
> summaries for their own subscriptions at least weekly, and at least once acts on a > onboarded friend returns to Tapir and reads/acts on summaries in **≥2 separate weeks**.
> summary (watches / skips / saves a video *because of* the summary). > The test is *return usage* (behavioural), not stated approval. The ideal signal is an
> **unprompted** return (organic, not because the maintainer nudged them) — but see the
> measurement note below: we currently cannot distinguish prompted from organic returns, so
> in practice we count all returns and read the result with that caveat.
- Captions-first summarization works end-to-end for the maintainer's real subscriptions. - Captions-first summarization works end-to-end for real subscriptions (the maintainer's
- Summaries land in the maintainer's store and (optionally) brain. and onboarded friends').
- Summaries land in each user's own store and (optionally) brain.
- Local-first AI produces summaries of acceptable quality without manual intervention - Local-first AI produces summaries of acceptable quality without manual intervention
most of the time. most of the time.
- **This is the gate.** Multi-user, BYO-AI-for-others, and any SaaS ambition stay deferred - **Why behavioural, not feedback.** Friend *feedback* is gathered and genuinely valuable —
until Stage 0 holds. (Ties to the 2026-07-01 self-use check-in.) but it is **not** the gate. Asked-for feedback from friendly users is the least reliable
signal in product development (politeness bias); whether they *come back* is the thing we
actually care about. So the gate measures returns, not nice words.
- **Measurement note — "unprompted" is an ideal we can't yet measure.** Whether a return was
organic or prompted by a nudge is not captured by any data Tapir holds (it's context only
the maintainer has). Rather than waive the standard, we name the gap: *unprompted* return
is the signal we genuinely want; *returns* (prompted or not) is what the data can show. A
return that needed a nudge is a weaker signal than one that didn't, and the result is read
with that in mind. If distinguishing them ever matters enough, the maintainer tracks nudges
manually or a future build records prompt events — neither is in scope now.
- **Why "me OR a friend".** This replaces the original "useful to *me*, specifically" gate
(2026-06-03 decision, recorded in DECISIONS.md ADR-016). Getting signal from friendly
users is valuable enough to count — but the bar stays behavioural so it can't be cleared
by a polite reaction. (Ties to the 2026-07-01 check-in.)
- **How it's measured.** Return usage is read from two sources: `summary_actions` (timestamped
watch/skip/save per user) answers "acted in ≥2 distinct weeks"; an append-only login-events
table (see infra/Tapir build) answers "returned/read in ≥2 distinct weeks" even without an
action click — the honest signal for a *reading* product. Login events accrue only from their
deploy date onward, so the gate window's data begins then.
- **This is the gate.** Hardening (Stage 1) and any SaaS ambition stay deferred until this
behavioural signal exists. Note: multi-user machinery was deliberately built *ahead* of
this gate (ADR-012) with isolation enforced — that was an explicit, recorded call, not a
sign the gate had passed. The gate is about *evidence of use*, which is still open.
### Stage 1 — Useful to a few (Future B) ### Stage 1 — Trustworthy at rest (hardening, Future B)
> **Headline test:** At least one trusted user other than the maintainer connects their
> own account and, within their first month, keeps using it (returns to read summaries in
> ≥2 separate weeks) without the maintainer hand-holding each summary.
- Multiple users, each with isolated accounts, credentials, and summaries.
- A new user can self-connect a YouTube/Vimeo account and get summaries with no code change.
- Optional BYO-AI works per-user.
- No cross-user data leakage — demonstrable, not assumed.
### Stage 2 — Trustworthy at rest (hardening, still Future B)
> **Headline test:** Credentials (OAuth tokens, BYO-AI keys) are encrypted at rest via the > **Headline test:** Credentials (OAuth tokens, BYO-AI keys) are encrypted at rest via the
> homelab's existing secrets convention; a documented, rehearsed recovery path exists; and > homelab's existing secrets convention; a documented, rehearsed recovery path exists; and
> a deliberate isolation test (user A cannot read user B's data) passes in CI or a > a deliberate isolation test (user A cannot read user B's data) passes in CI or a
> documented manual drill. > documented manual drill.
- Per-user data isolation is enforced and tested (delivered early via ADR-012 RLS).
- Per-user credentials are encrypted at rest (ADR-015 envelope encryption; build in infra#89).
- A new user can self-connect a YouTube/Vimeo account and get summaries with no code change.
- Optional BYO-AI works per-user.
### Non-goals (current) ### Non-goals (current)
- Public sign-up / billing / a marketing surface. - Public sign-up / billing / a marketing surface.
@@ -98,7 +117,11 @@ to the next stage's ambition until the current stage's test passes.
## How we will know we are drifting ## How we will know we are drifting
- We are building Stage 1+ machinery before the Stage 0 gate has passed. - We declare the Stage 0 gate "passed" on the strength of polite feedback rather than
behavioural return-usage (the politeness-bias trap the gate is designed to resist).
- We build Stage 1 hardening or Future C machinery while the Stage 0 use-evidence is still
absent. (Multi-user machinery already shipped ahead of the gate via ADR-012 — a recorded,
deliberate exception, not a precedent for more.)
- A user's content reaches a third-party model without that user's explicit, per-user opt-in. - A user's content reaches a third-party model without that user's explicit, per-user opt-in.
- "Brain ingestion" starts dictating the architecture instead of being one sink behind an - "Brain ingestion" starts dictating the architecture instead of being one sink behind an
interface. interface.
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/config"
)
// inviteTTL is how long a minted invite stays claimable. A week is generous for a
// human to act on an emailed link without leaving a stale capability around.
const inviteTTL = 7 * 24 * time.Hour
// inviter is the narrow store capability cmdInvite needs — minting an invitation.
// Defined here (not store) so runInvite is testable with a fake, no Postgres.
type inviter interface {
CreateInvitation(ctx context.Context, email string, ttl time.Duration) (string, error)
}
// cmdInvite mints an invitation for an email and prints the claim URL. Host-side
// only (no Dex session): the operator runs it, copies the link, and sends it.
// Usage: tapir invite <email>.
func cmdInvite(ctx context.Context, args []string) error {
if len(args) < 1 || strings.TrimSpace(args[0]) == "" {
return fmt.Errorf("usage: tapir invite <email>")
}
email := strings.TrimSpace(args[0])
cfg, err := config.Load()
if err != nil {
return err
}
if strings.TrimSpace(cfg.DBDSN) == "" {
return fmt.Errorf("missing required config: TAPIR_DB_DSN")
}
st, err := store.New(ctx, cfg.DBDSN)
if err != nil {
return err
}
defer st.Close()
return runInvite(ctx, st, os.Stdout, cfg.PublicURL, email)
}
// runInvite is the testable core: mint the token and print the absolute claim URL
// to w. Pure of config/store construction so a fake inviter exercises it.
func runInvite(ctx context.Context, inv inviter, w io.Writer, publicURL, email string) error {
token, err := inv.CreateInvitation(ctx, email, inviteTTL)
if err != nil {
return fmt.Errorf("create invitation: %w", err)
}
base := strings.TrimRight(strings.TrimSpace(publicURL), "/")
_, err = fmt.Fprintf(w, "Invite URL (valid 7 days):\n%s/invite/%s\n", base, token)
return err
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// fakeInviter records the mint call and returns a canned token.
type fakeInviter struct {
token string
err error
gotEmail string
gotTTL time.Duration
callCount int
}
func (f *fakeInviter) CreateInvitation(_ context.Context, email string, ttl time.Duration) (string, error) {
f.callCount++
f.gotEmail, f.gotTTL = email, ttl
return f.token, f.err
}
func TestRunInvitePrintsURL(t *testing.T) {
inv := &fakeInviter{token: "deadbeefcafe"}
var out strings.Builder
err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be", "new@example.com")
require.NoError(t, err)
require.Equal(t, "new@example.com", inv.gotEmail)
require.Equal(t, inviteTTL, inv.gotTTL)
got := out.String()
require.Contains(t, got, "https://tapir.d-ma.be/invite/deadbeefcafe")
require.Contains(t, got, "valid 7 days")
}
func TestRunInviteTrimsTrailingSlash(t *testing.T) {
inv := &fakeInviter{token: "tok"}
var out strings.Builder
err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be/", "x@example.com")
require.NoError(t, err)
require.Contains(t, out.String(), "https://tapir.d-ma.be/invite/tok")
require.NotContains(t, out.String(), "//invite")
}
func TestRunInvitePropagatesError(t *testing.T) {
inv := &fakeInviter{err: errors.New("db down")}
var out strings.Builder
err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be", "x@example.com")
require.Error(t, err)
require.Empty(t, out.String())
}
+74 -36
View File
@@ -22,15 +22,12 @@ import (
"os/signal" "os/signal"
"time" "time"
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm" "gitea.d-ma.be/mathias/tapir/internal/adapters/dex"
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets" "gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store" "gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/adapters/summarizer"
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
"gitea.d-ma.be/mathias/tapir/internal/auth" "gitea.d-ma.be/mathias/tapir/internal/auth"
"gitea.d-ma.be/mathias/tapir/internal/config" "gitea.d-ma.be/mathias/tapir/internal/config"
"gitea.d-ma.be/mathias/tapir/internal/runner" "gitea.d-ma.be/mathias/tapir/internal/runner"
"gitea.d-ma.be/mathias/tapir/internal/usecase"
"gitea.d-ma.be/mathias/tapir/internal/web" "gitea.d-ma.be/mathias/tapir/internal/web"
"gitea.d-ma.be/mathias/tapir/internal/web/oidc" "gitea.d-ma.be/mathias/tapir/internal/web/oidc"
) )
@@ -57,6 +54,8 @@ func main() {
err = cmdRun(ctx, log) err = cmdRun(ctx, log)
case "serve": case "serve":
err = cmdServe(ctx, log) err = cmdServe(ctx, log)
case "invite":
err = cmdInvite(ctx, os.Args[2:])
default: default:
usage() usage()
os.Exit(2) os.Exit(2)
@@ -75,6 +74,7 @@ usage:
tapir auth one-time: authorize YouTube and store a refresh token tapir auth one-time: authorize YouTube and store a refresh token
tapir run detect new videos, summarize, deliver to your store tapir run detect new videos, summarize, deliver to your store
tapir serve run the web UI (read summaries, record watch/skip/save) tapir serve run the web UI (read summaries, record watch/skip/save)
tapir invite <email> mint an invitation link for a new user (host-side)
tapir list [-limit N] list stored summaries, recent first tapir list [-limit N] list stored summaries, recent first
tapir show <video-id> show one summary in full tapir show <video-id> show one summary in full
@@ -120,34 +120,27 @@ func cmdRun(ctx context.Context, log *slog.Logger) error {
} }
defer st.Close() defer st.Close()
secretStore := secrets.NewFileStore(cfg.SecretsFile) // Same wiring the web serve path uses (buildProcessor). ValidateForRun above
src := youtube.New(youtube.Config{ // already required the engine's inputs, so a nil here is a genuine config gap.
ClientID: cfg.YTClientID, engine, err := buildProcessor(cfg, st)
ClientSecret: cfg.YTClientSecret, if err != nil {
TokenSecretRef: cfg.YTTokenRef, return err
PreferredLanguages: []string{"en"},
}, secretStore)
// Local Primary only; no BYO fallback for the demo (fallback nil).
primary := summarizer.Endpoint{
Client: llm.New(cfg.GatewayURL, cfg.GatewayKey, cfg.SummarizerModel, cfg.SummarizerTimeout),
Provider: "local",
Model: cfg.SummarizerModel,
} }
sum := summarizer.New(primary, nil) if engine == nil {
return fmt.Errorf("run: incomplete summarization config (gateway, youtube credentials, secrets file)")
engine := usecase.NewEngine(src, sum, st) }
r := runner.New(src, st, engine, cfg.UserID, log) r := runner.New(engine.Source, st, engine, cfg.UserID, log, runner.WithBackoff(cfg.FetchBackoff))
log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel, log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval) "gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval, "fetch_backoff", cfg.FetchBackoff)
return r.Loop(ctx, cfg.PollInterval) return r.Loop(ctx, cfg.PollInterval)
} }
// cmdServe runs the Stage-0 web UI: the summary reader over the existing store // cmdServe runs the Stage-1 web UI: the summary reader over the existing store
// (ADR-003 — a new transport, not new core). Auth is the StubAuth allow-all seam // (ADR-003 — a new transport, not new core). Auth (web.Auth) gates access; the
// keyed to the configured user; the Conductor swaps in oidc.DexAuth at merge — // registration gate resolves the authenticated subject to a tapir user_id and
// the only line that changes is the `authn` assignment below. // 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 { func cmdServe(ctx context.Context, log *slog.Logger) error {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
@@ -164,18 +157,17 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
defer st.Close() defer st.Close()
// Auth seam (handlers depend on web.Auth only). With Dex configured // Auth seam (handlers depend on web.Auth only). With Dex configured
// (TAPIR_OIDC_ISSUER set) serve uses real OIDC login with single-user // (TAPIR_OIDC_ISSUER set) serve uses real OIDC login — any Dex subject may
// allowlist authz (ADR-011); otherwise it falls back to the allow-all // authenticate, then registers a tapir user (ADR-012); otherwise it falls
// StubAuth for local dev — never expose StubAuth publicly. // back to the allow-all StubAuth for local dev — never expose StubAuth publicly.
var authn web.Auth var authn web.Auth
if cfg.DexConfigured() { if cfg.DexConfigured() {
authn, err = oidc.New(ctx, oidc.Config{ authn, err = oidc.New(ctx, oidc.Config{
Issuer: cfg.OIDCIssuer, Issuer: cfg.OIDCIssuer,
ClientID: cfg.DexClientID, ClientID: cfg.DexClientID,
ClientSecret: cfg.DexClientSecret, ClientSecret: cfg.DexClientSecret,
RedirectURL: cfg.OIDCRedirectURL, RedirectURL: cfg.OIDCRedirectURL,
SessionSecret: cfg.SessionSecret, SessionSecret: cfg.SessionSecret,
AllowedSubject: cfg.AllowedSubject,
}) })
if err != nil { if err != nil {
return fmt.Errorf("dex oidc: %w", err) return fmt.Errorf("dex oidc: %w", err)
@@ -186,7 +178,53 @@ 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") 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}
// Email-invite onboarding (public /invite/{token}). The store validates and
// consumes tokens; the Dex client creates the local-password account. In-cluster
// the SA token mount is present and account creation works; off-cluster (dev) it
// is nil and the submit handler degrades to a clear "deployed-only" message.
app.Invitations = st
if dexClient, err := dex.NewPasswordClient(); err == nil {
app.Dex = dexClient
log.Info("invite account creation enabled (in-cluster dex password client)")
} else if errors.Is(err, dex.ErrNotInCluster) {
log.Warn("invite account creation disabled: not in-cluster — /invite is deployed-only")
} else {
return fmt.Errorf("dex password client: %w", err)
}
// 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)
}
// Immediate summarization for the web "Summarize" button. When the engine can
// be built (gateway + YouTube credentials + secrets present), a click runs the
// summary now in the background; otherwise the button stays queue-only and the
// next `tapir run` does the work (buildProcessor returns nil — never an error).
engine, err := buildProcessor(cfg, st)
if err != nil {
return err
}
if engine != nil {
app.Processor = &engineProcessor{engine: engine, store: st}
log.Info("web immediate summarization enabled", "model", cfg.SummarizerModel)
} else {
log.Info("web summarization is queue-only (incomplete engine config)")
}
srv := &http.Server{ srv := &http.Server{
Addr: cfg.HTTPAddr, Addr: cfg.HTTPAddr,
Handler: app.Router(), Handler: app.Router(),
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"context"
"fmt"
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/adapters/summarizer"
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
"gitea.d-ma.be/mathias/tapir/internal/config"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/usecase"
)
// buildProcessor wires the summarization engine — YouTube source (captions-first),
// AI-router summarizer, store sink — shared by `tapir run` and the web
// "Summarize now" path so the wiring lives in one place. It returns (nil, nil) —
// not an error — when the config cannot support live summarization (no gateway
// URL, no YouTube client credentials, or no secrets file). That nil is the
// queue-only fallback: the web UI keeps working (the button just queues) and
// `tapir run` reports the gap via its own ValidateForRun. Missing engine config
// is never an error here.
func buildProcessor(cfg config.Config, st *store.Store) (*usecase.Engine, error) {
if cfg.GatewayURL == "" || cfg.YTClientID == "" || cfg.YTClientSecret == "" || cfg.SecretsFile == "" {
return nil, nil
}
secretStore := secrets.NewFileStore(cfg.SecretsFile)
src := youtube.New(youtube.Config{
ClientID: cfg.YTClientID,
ClientSecret: cfg.YTClientSecret,
TokenSecretRef: cfg.YTTokenRef,
PreferredLanguages: []string{"en"},
}, secretStore)
// Local Primary only; no BYO fallback for the demo (fallback nil).
primary := summarizer.Endpoint{
Client: llm.New(cfg.GatewayURL, cfg.GatewayKey, cfg.SummarizerModel, cfg.SummarizerTimeout),
Provider: "local",
Model: cfg.SummarizerModel,
}
sum := summarizer.New(primary, nil)
return usecase.NewEngine(src, sum, st), nil
}
// engineProcessor adapts the engine (which works in terms of a domain.Video) to
// the web.Processor port (which works in terms of a stored video id): it loads the
// video row, runs the engine, and — on a produced summary — clears the manual
// queue flag, mirroring the runner so the video is not re-summarized on the next
// `tapir run` and the UI drops the "Queued" chip. A skip (no transcript) leaves
// the flag set so a later run can retry.
type engineProcessor struct {
engine *usecase.Engine
store *store.Store
}
func (p *engineProcessor) ProcessVideo(ctx context.Context, userID, videoID string) error {
row, err := p.store.GetVideoRow(ctx, userID, videoID)
if err != nil {
return fmt.Errorf("load video %q: %w", videoID, err)
}
v := domain.Video{
ID: row.VideoID,
UserID: userID,
Provider: domain.Provider(row.Channel),
ProviderVideoID: row.ProviderVideoID,
Title: row.Title,
URL: row.URL,
PublishedAt: row.PublishedAt,
}
res, err := p.engine.ProcessNewVideo(ctx, v)
if err != nil {
return fmt.Errorf("process video %q: %w", videoID, err)
}
if res.Summary != nil {
if err := p.store.ClearSummarizeRequested(ctx, userID, videoID); err != nil {
return fmt.Errorf("clear summarize flag %q: %w", videoID, err)
}
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"testing"
"gitea.d-ma.be/mathias/tapir/internal/config"
)
// TestBuildProcessorNilOnIncompleteConfig asserts the queue-only fallback: when a
// required input is missing, buildProcessor returns (nil, nil) — never an error —
// so the web UI degrades to queue-only instead of failing to start.
func TestBuildProcessorNilOnIncompleteConfig(t *testing.T) {
// A complete config (the fields buildProcessor gates on). The store is nil:
// buildProcessor must not touch it on the incomplete paths, and the complete
// path only stores the pointer (no connection), so nil is fine for this test.
complete := config.Config{
GatewayURL: "http://gw/v1",
YTClientID: "id",
YTClientSecret: "secret",
SecretsFile: "/tmp/secrets.json",
}
tests := []struct {
name string
mutate func(config.Config) config.Config
wantNil bool
}{
{"complete", func(c config.Config) config.Config { return c }, false},
{"no gateway url", func(c config.Config) config.Config { c.GatewayURL = ""; return c }, true},
{"no yt client id", func(c config.Config) config.Config { c.YTClientID = ""; return c }, true},
{"no yt client secret", func(c config.Config) config.Config { c.YTClientSecret = ""; return c }, true},
{"no secrets file", func(c config.Config) config.Config { c.SecretsFile = ""; return c }, true},
{"empty config", func(config.Config) config.Config { return config.Config{} }, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
engine, err := buildProcessor(tt.mutate(complete), nil)
if err != nil {
t.Fatalf("buildProcessor returned an error, want nil: %v", err)
}
if (engine == nil) != tt.wantNil {
t.Fatalf("engine == nil is %v, want %v", engine == nil, tt.wantNil)
}
})
}
}
+65 -3
View File
@@ -45,7 +45,7 @@ adapter behind an interface (Clean Architecture ports & adapters).
```mermaid ```mermaid
graph TB graph TB
subgraph tapir["Tapir (Go)"] subgraph tapir["Tapir (Go)"]
http["HTTP server<br/>OAuth callbacks +<br/>user-facing API"] http["tapir serve<br/>(HTMX+Templ web surface:<br/>read summaries, connect,<br/>account, summarize)"]
watcher["Watcher<br/>detects new videos<br/>(WebSub + poll)"] watcher["Watcher<br/>detects new videos<br/>(WebSub + poll)"]
engine["Summarization engine<br/>(use-case core)"] engine["Summarization engine<br/>(use-case core)"]
resolver["Transcript resolver<br/>(captions-first)"] resolver["Transcript resolver<br/>(captions-first)"]
@@ -95,6 +95,66 @@ two codebases (ADR-003).
--- ---
## Web surface — `tapir serve` (Stage 1, ADR-011 → ADR-012)
A later transport added over the **unchanged** engine/ports/sinks core (ADR-003): `tapir serve`
is an HTMX+Templ reader/writer (`internal/web`) over the existing `store`. It added no business
logic to the engine — it reads the store and, for one action, kicks the existing engine. ADR-011
shipped it single-user; ADR-012 opened multi-user with DB-enforced (RLS) isolation.
```mermaid
graph TB
browser["Browser<br/>(Dex-authenticated user)"]
subgraph web["internal/web (tapir serve)"]
oidc["oidc<br/>Dex OIDC session<br/>(authenticate-only)"]
gate["registration gate<br/>new subject -> /register"]
pages["summary list + detail<br/>(read) + actions"]
connect["/oauth/youtube/callback<br/>per-user token connect"]
account["account<br/>(disconnect, delete)"]
summarize["Summarize button<br/>-> background goroutine"]
end
store[("store<br/>(Postgres, RLS per user)")]
engine["Summarization engine<br/>(unchanged core)"]
secrets["SecretStore<br/>(per-user token refs)"]
browser --> oidc
oidc --> gate
gate --> pages
pages --> store
connect --> secrets
connect --> store
account --> store
account --> secrets
summarize -->|background| engine
summarize -->|HTMX status poll| store
engine --> store
```
- **Dex OIDC session layer** (`internal/web/oidc`) — **authenticate-only** (ADR-012). It proves
*who*; authorization/isolation is the DB's job (RLS), not the session's.
- **Registration gate** — a Dex subject with no `users` row is routed to `/register`, which
creates the `users` row + the `user_identities` mapping (migration 004). Returning subjects
pass straight through.
- **Web-initiated YouTube connect** — `/oauth/youtube/connect``/oauth/youtube/callback`
persists a **per-user** refresh-token ref (`youtube/<userID>/refresh_token`) via `SecretStore`
and a `video_connections` row (ADR-006, migration 005). Distinct from the CLI `tapir auth`.
- **Account management** — `/account` offers disconnect and **delete account**. Delete removes
only Tapir-side state (cascade across the user's tables + secret refs); the shared Dex identity
is left intact (ADR-013).
- **Immediate summarization** — the web "Summarize" button (`POST /v/{id}/summarize`) fires the
engine in a **background goroutine** inside `serve`; the page HTMX-polls `/v/{id}/status`,
showing a Charmbracelet spinner while in-flight (and an honest "queued/waiting" state under
rate-limiting — ADR-014).
- **Summarization mode** — `users.auto_summarize` (migration 006). Auto: every new video is
summarized. Manual (default): new videos appear unsummarized; the button sets
`videos.summarize_requested`, which the next `tapir run` processes and clears. Both the click
path and the batch `tapir run` drive the same unchanged engine.
The engine, ports, and sink adapters are **untouched** by all of the above — the web surface only
reads the store and triggers the existing engine. Adding it changed wiring, not the core (ADR-003).
---
## Sequence — core use case: new video summarized ## Sequence — core use case: new video summarized
```mermaid ```mermaid
@@ -191,6 +251,8 @@ Gherkin features in `docs/use-cases/`).
- Audio-download + speech-to-text resolver (ADR-007) — would be an additional `VideoSource` - Audio-download + speech-to-text resolver (ADR-007) — would be an additional `VideoSource`
fallback path, drawn when built. fallback path, drawn when built.
- Multi-tenant isolation primitives (per-tenant Postgres role, NetworkPolicy, tenant label) - Per-user isolation is **live, not deferred**: Postgres RLS `FORCE`d on every user-owned table
— activate at Stage 1 (ADR-002); single-user Stage 0 doesn't exercise them. (ADR-012, migration 003), realising ADR-002's per-tenant intent at the DB layer. The coarser
multi-tenant primitives (per-namespace NetworkPolicy, Kyverno, tenant label) remain a
Stage-2 hardening item, not exercised yet.
- Public SaaS surface (sign-up, billing) — Future C, not built (ADR-008). - Public SaaS surface (sign-up, billing) — Future C, not built (ADR-008).
+87 -23
View File
@@ -23,11 +23,16 @@ only opaque references to them; the secret material lives in ESO/1Password (ADR-
## Entities ## Entities
Solid entities below are **persisted today** (migrations 001006). `AI_CREDENTIAL` and
`SUBSCRIPTION` are **planned, not yet a table** — kept in the model for intent; see the notes.
```mermaid ```mermaid
erDiagram erDiagram
USER ||--|| USER_IDENTITY : "logs in via (Dex subject)"
USER ||--o{ VIDEO_CONNECTION : has USER ||--o{ VIDEO_CONNECTION : has
USER ||--o{ AI_CREDENTIAL : has USER ||--o{ SUMMARY_ACTION : records
VIDEO_CONNECTION ||--o{ SUBSCRIPTION : exposes USER ||--o{ AI_CREDENTIAL : "has (planned)"
VIDEO_CONNECTION ||--o{ SUBSCRIPTION : "exposes (planned)"
SUBSCRIPTION ||--o{ VIDEO : "produces (per user)" SUBSCRIPTION ||--o{ VIDEO : "produces (per user)"
VIDEO ||--o| TRANSCRIPT : "has at most one" VIDEO ||--o| TRANSCRIPT : "has at most one"
VIDEO ||--o| SUMMARY : "has at most one" VIDEO ||--o| SUMMARY : "has at most one"
@@ -36,14 +41,20 @@ erDiagram
USER { USER {
uuid id PK uuid id PK
text display_name text display_name
bool auto_summarize "default false -> manual mode out of the box (migration 006)"
timestamptz created_at
}
USER_IDENTITY {
text dex_subject PK
uuid user_id FK "UNIQUE -> USER, ON DELETE CASCADE; NOT RLS-enabled"
timestamptz created_at timestamptz created_at
} }
VIDEO_CONNECTION { VIDEO_CONNECTION {
uuid id PK uuid id PK
uuid user_id FK uuid user_id FK "-> USER, ON DELETE CASCADE"
text provider "youtube | vimeo" text provider "youtube | vimeo"
text provider_account text provider_account "nullable"
text token_secret_ref "-> SecretStore, never the token" text token_ref "-> SecretStore, never the token"
text status "active | revoked | error" text status "active | revoked | error"
timestamptz connected_at timestamptz connected_at
} }
@@ -66,14 +77,15 @@ erDiagram
} }
VIDEO { VIDEO {
uuid id PK uuid id PK
uuid user_id FK uuid user_id FK "-> USER, ON DELETE CASCADE"
uuid subscription_id FK uuid subscription_id "nullable; no FK at Stage 0"
text provider text provider
text provider_video_id text provider_video_id
text title text title
int duration_s int duration_s
timestamptz published_at timestamptz published_at
text url text url
bool summarize_requested "default false -> manual-mode queue flag (migration 006)"
timestamptz seen_at timestamptz seen_at
} }
TRANSCRIPT { TRANSCRIPT {
@@ -87,7 +99,7 @@ erDiagram
SUMMARY { SUMMARY {
uuid id PK uuid id PK
uuid user_id FK uuid user_id FK
uuid video_id FK uuid video_id "no FK to videos; (user_id, video_id) UNIQUE is the dedup key"
text summary text summary
jsonb highlights jsonb highlights
jsonb takeaways jsonb takeaways
@@ -98,26 +110,53 @@ erDiagram
} }
SINK_DELIVERY { SINK_DELIVERY {
uuid id PK uuid id PK
uuid summary_id FK uuid summary_id FK "-> SUMMARY, ON DELETE CASCADE; ownership derived via this FK"
text sink "store | brain" text sink "store | brain"
text status "pending | delivered | error" text status "pending | delivered | error"
text detail "nullable; error message etc" text detail "nullable; error message etc"
timestamptz updated_at timestamptz updated_at
} }
SUMMARY_ACTION {
uuid id PK
uuid user_id FK "-> USER"
text video_id "TEXT, not FK (mirrors summaries' standalone key)"
text action "watched | skipped | saved"
timestamptz acted_at
}
``` ```
`SUMMARY_ACTION` has `UNIQUE (user_id, video_id, action)`; `VIDEO_CONNECTION` has
`UNIQUE (user_id, provider)` (one connection per provider — reconnect upserts in place).
RLS (`ENABLE` + `FORCE`) is on **every solid user-owned table above**`users`, `videos`,
`transcripts`, `summaries`, `summary_actions`, `video_connections`. `sink_deliveries` is
RLS'd via an `EXISTS` on its parent summary; `user_identities` is intentionally **not** RLS'd
(auth plumbing). See the *Isolation invariant* section for the mechanism.
## Notes per entity ## Notes per entity
- **USER** — at Stage 0 there is exactly one row. At Stage 1, identity comes via Dex; this - **USER** — one row per registered user (Stage 1, ADR-012; no longer single-row). The Tapir-side
table holds the Tapir-side profile keyed to the Dex subject. profile; the Dex identity is held separately in `USER_IDENTITY`, not on this row. `auto_summarize`
- **VIDEO_CONNECTION** — a connected YouTube/Vimeo account. `token_secret_ref` resolves to (migration 006) is the per-user mode flag: `FALSE` (default) = manual, `TRUE` = auto-summarize
the OAuth refresh token via `SecretStore`. Revocation flips `status`, doesn't delete history. every new video.
- **AI_CREDENTIAL** — optional, per provider, per user (ADR-004's Fallback). Absent for users - **USER_IDENTITY** (migration 004) — the `dex_subject → user_id` map. `dex_subject` is the PK,
who only use the local stack. One row per provider max. `user_id` a `UNIQUE` FK to `users` with `ON DELETE CASCADE`. This is the bridge resolved at login
- **SUBSCRIPTION** — a watched channel. `websub_expires` tracks the YouTube push lease so the *before* a `user_id` is known, so it is **deliberately not RLS-enabled** (it holds no user data;
watcher knows when to re-subscribe; null for poll-based (Vimeo). RLS here would deadlock the lookup that yields the id used for scoping). Account deletion cascades
the mapping away (ADR-013).
- **VIDEO_CONNECTION** (migration 005) — a connected YouTube/Vimeo account. `token_ref` resolves to
the OAuth refresh token via `SecretStore` (per-user scheme `youtube/<userID>/refresh_token`).
`UNIQUE (user_id, provider)`: one connection per provider, reconnect upserts. Revocation/disconnect
flips `status`, doesn't delete history. FORCE RLS'd.
- **AI_CREDENTIAL** — *planned, no table yet.* Optional, per provider, per user (ADR-004's Fallback).
BYO keys are currently resolved via `SecretStore` refs without a dedicated table; this entity is
modelled for when per-credential metadata is needed.
- **SUBSCRIPTION** — *planned, no table yet.* A watched channel; `websub_expires` would track the
YouTube push lease. At Stage 0/1 `videos.subscription_id` is a nullable column with **no FK** (the
subscriptions table is not part of the shipped store-sink slice — migration 001).
- **VIDEO** — one row per (user, video) — note `user_id`, reflecting the per-user-isolation - **VIDEO** — one row per (user, video) — note `user_id`, reflecting the per-user-isolation
decision. The same video seen by two users is two rows. `seen_at` is when Tapir detected it. decision. The same video seen by two users is two rows. `seen_at` is when Tapir detected it.
`summarize_requested` (migration 006) is the manual-mode queue flag: the web "Summarize" button
sets it `TRUE`; the next `tapir run` picks it up, summarizes, and clears it back to `FALSE`.
- **TRANSCRIPT** — at most one per video. `source = none` records "checked, no usable - **TRANSCRIPT** — at most one per video. `source = none` records "checked, no usable
transcript" so the watcher doesn't reprocess (ADR-007). `content` null in that case. transcript" so the watcher doesn't reprocess (ADR-007). `content` null in that case.
- **SUMMARY** — at most one per video. `fallback_used` + `ai_provider`/`ai_model` make the - **SUMMARY** — at most one per video. `fallback_used` + `ai_provider`/`ai_model` make the
@@ -125,14 +164,39 @@ erDiagram
`takeaways` as jsonb to stay schema-flexible while the output format settles. `takeaways` as jsonb to stay schema-flexible while the output format settles.
- **SINK_DELIVERY** — one row per (summary, sink) attempt. This is where "also sent to brain" - **SINK_DELIVERY** — one row per (summary, sink) attempt. This is where "also sent to brain"
lives — no brain tables, just a delivery row with `sink = brain`. Sinks fail independently; lives — no brain tables, just a delivery row with `sink = brain`. Sinks fail independently;
a failed brain delivery doesn't fail the store delivery. a failed brain delivery doesn't fail the store delivery. No own `user_id`; RLS ownership is
derived from the parent summary via `EXISTS` (migration 003).
- **SUMMARY_ACTION** (migration 002) — records the maintainer's act on a summary (watch / skip /
save) — the column that makes the Stage-0 headline metric ("acts on ≥1 summary") queryable
(ui-spec.md §5, ADR-011). `video_id` is `TEXT` and **not** FK-constrained, mirroring summaries'
standalone `(user_id, video_id)` key. `UNIQUE (user_id, video_id, action)`. FORCE RLS'd.
## Isolation invariant (Stage 1+) ## Isolation invariant (Stage 1+) — LIVE
Every user-owned table carries `user_id`. At Stage 1, this is enforced at the DB layer via a Every user-owned table carries `user_id`, and isolation is **enforced at the DB layer**, not
per-tenant Postgres role + row grants (architecture review SC7), not only in application code. only in application code. ADR-011 shipped this surface single-user (one allowlisted subject,
At Stage 0 (single user) the column exists but the enforcement is dormant. The isolation test enforcement dormant); **ADR-012 opened Stage 1 and turned enforcement on in the same slice.**
in VISION Stage 2 asserts user A cannot read user B's rows.
Enforcement is **Postgres Row-Level Security** (migration `003_rls.up.sql`):
- RLS is `ENABLE`d **and** `FORCE`d on every user-owned table — `users`, `videos`,
`transcripts`, `summaries`, `summary_actions`, `video_connections`. `FORCE` is load-bearing:
the app connects as the table **owner** (`tapir` role), and owners bypass RLS unless forced.
- Each policy keys off the per-request GUC `tapir.current_user_id`, set transaction-locally by
the store's `withUser` helper via `set_config('tapir.current_user_id', $1, true)` — it
auto-resets on commit/rollback, so it never leaks across a pooled connection.
- `current_setting('tapir.current_user_id', true)` uses `missing_ok = true`: an **unset** GUC
yields `NULL`, the predicate matches no rows, and access **denies by default**.
- `sink_deliveries` has no `user_id`; its policy derives ownership from the parent summary via
`EXISTS (SELECT 1 FROM summaries …)`.
- `user_identities` (the Dex-subject → user_id map) is **deliberately not RLS-enabled** — it is
auth plumbing read *before* a user_id is known; putting RLS there would deadlock. It holds no
user data.
The Stage-2 isolation bar is **pulled forward, not deferred**: `internal/adapters/store/rls_test.go`
runs two users against a non-superuser, non-`BYPASSRLS` role and asserts user A reads/writes zero
of user B's rows across every table. It ships green with the multi-user features (ADR-012); no
multi-user feature merges ahead of it passing.
## Job / processing state ## Job / processing state
+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 `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 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. 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) ## 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 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._ 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`.
@@ -0,0 +1,153 @@
# Spec — Landing page + documentation reconciliation
**Date:** 2026-06-03
**Status:** Ready to build
**Scope:** Two parallel workstreams — (A) a public landing page; (B) reconciling the
requirements / use-case / architecture / data-model docs against the deployed reality
(v0.4.0). These are separate concerns; do not let one worker do both, or the audit gets
done cursorily.
All work: read `CLAUDE.md` + `DECISIONS.md` first. TBD — commit directly to `main`, one
logical change per commit, conventional commits, `task check` green before every commit.
After editing any `.templ`, run `templ generate` (the repo commits both `views.templ` and the
generated `views_templ.go`).
---
## Workstream A — Public landing page
### Goal
A public landing page at `/welcome`, in the established bubbletea aesthetic, that lets a
visitor sign in (one Dex flow) and, if already logged in, jump to their Tapir page or log out.
New public transport surface only — no engine/core change (ADR-003).
### Verified facts (read from `internal/web/oidc/oidc.go` @ main — do not re-guess)
- Auth endpoints are exactly `/auth/login`, `/auth/callback`, `/auth/logout`.
- `isPublicPath(p)` = `p == "/healthz" || strings.HasPrefix(p, "/auth/")` — the single
public-route chokepoint inside `DexAuth.Middleware`.
- `DexAuth.CurrentUser(r) (web.User, bool)` reads the session cookie and does NOT redirect —
this is the "peek" the landing page uses to branch logged-in vs logged-out.
- `handleCallback` redirects to `/` on success (correct — leave as-is).
- `handleLogout` currently redirects to `loginPath` (`/auth/login`) — this is wrong for this
feature (see A3).
- There is NO separate "sign up" against Dex/OIDC: one authorization flow. Registration is
Tapir's own `/register` step (ADR-012), reached after first login for an unknown subject.
### Tasks
**A1 — make `/welcome` public.** In `oidc.go`, extend `isPublicPath`:
```go
func isPublicPath(p string) bool {
return p == "/healthz" || p == "/welcome" || strings.HasPrefix(p, "/auth/")
}
```
**A2 — unauthenticated bare-`/` → `/welcome`; deep links unchanged.** In `DexAuth.Middleware`,
the unauthenticated branch currently always calls `redirectToLogin`. Change it so that when
`r.URL.Path == "/"` an unauthenticated visitor is redirected to `/welcome`; for any other
guarded path keep `redirectToLogin` (so a shared `/v/{id}` deep link still bounces through Dex
and returns to the destination). Keep the `isPublicPath` check first (redirect-loop guard).
**A3 — logout lands on `/welcome`, not login.** In `handleLogout`, change the final redirect
from `loginPath` to `/welcome`. As written it sends the user to `/auth/login`, which
immediately starts a fresh Dex login — visibly failing to log out. This intentionally breaks
the existing logout test (oidc_test.go) which asserts redirect to `/auth/login`; update that
test to expect `/welcome`. That break is expected, not a regression.
**A4 — mount the landing handler** in `internal/web/handlers.go` `Router()`, on `root`,
OUTSIDE `Auth.Middleware`, alongside `/healthz`:
```go
root.HandleFunc("GET /welcome", a.handleWelcome)
```
`handleWelcome` peeks `a.Auth.CurrentUser(r)` and renders `WelcomePage(user, ok)`. Not behind
`Auth.Middleware` or `registrationGate`.
**A5 — `WelcomePage` templ component** in `views.templ`. Reuse the existing shared
layout/header partial and the established aesthetic (#7653FC purple rounded ╭─╮╰─╯ box, pink
tapir mascot, #0EF9B6 mint accents) — match the existing pages, do not reinvent styling.
- Logged out (`ok == false`): tapir mascot + tagline; one primary CTA **"Get Started"** →
`/auth/login`; honest sub-text: "New here? You'll set up your account right after signing in
— returning users go straight through." One button only (see verified facts: no separate
Dex sign-up; two buttons to the same URL would mislead).
- Logged in (`ok == true`): "Go to my Tapir" → `/`; "Log Out" → `/auth/logout`. May greet via
`user.Email`.
**A6 — tests** (extend `handlers_test.go` patterns). Note `StubAuth.CurrentUser` always returns
true; for the logged-out case use a fake Auth returning `(web.User{}, false)`.
- `GET /welcome`, no session → "Get Started" → `/auth/login`.
- `GET /welcome`, with session → "Go to my Tapir" + "Log Out".
- Unauthenticated `GET /` → 302 `/welcome`.
- Unauthenticated `GET /v/{id}` → still 302 `/auth/login` (deep link preserved).
- Authenticated `GET /` → still serves the list, unchanged.
- oidc: `handleLogout` → 302 `/welcome` (update the existing test).
**A out of scope:** no Dex config change, no new auth/session logic, no sign-up backend.
---
## Workstream B — Documentation reconciliation
### Why
The guardrail docs were written before Stage 1 and the web surface. Several now describe the
opposite of the deployed reality (v0.4.0). Stale guardrail docs are worse than none — a future
cold session (human or agent) trusts them. This workstream brings requirements, use cases,
architecture, and data-model back in sync with `main`. Each fix is one commit; cite the ADR or
migration that is the source of truth.
### Known drift to fix (verified this session — not exhaustive; the worker confirms against code)
**B1 — `internal/web/auth.go` comments.** The `User.Subject` doc and package doc still say
"single-user allowlist (ADR-011)" / "Stage-0". Code is multi-user (ADR-012). Update the
comments to describe the current multi-user reality; reference ADR-012.
**B2 — `docs/data-model.md` isolation status.** It says isolation enforcement is "dormant at
Stage 0". It is now LIVE: Postgres RLS, `FORCE`d on all user-owned tables, with a passing
two-user isolation test (ADR-012, migration 003). Rewrite that section to describe enforced
RLS as the current state; keep the history honest (was dormant at Stage 0, enforced from
Stage 1).
**B3 — `docs/data-model.md` schema completeness.** The doc predates migrations 002006. Add
the entities/columns that now exist: `summary_actions` (002), RLS (003), `user_identities`
(004, dex_subject→user_id), `video_connections` (005), `users.auto_summarize` +
`videos.summarize_requested` (006). The ER section should match the live schema. Cross-check
against `internal/adapters/store/migrations/*.up.sql` — those are ground truth.
**B4 — `docs/architecture/architecture.md`.** Predates the entire web surface. Update the C4
container diagram and text to include: `tapir serve` (HTMX+Templ web reader/writer), the Dex
OIDC session layer (`internal/web/oidc`), registration gate, web-initiated YouTube connect,
account management, and the immediate-processing path (web "Summarize" button → background
goroutine → status poll). The engine/ports/sinks core is unchanged (ADR-003) — show the web
surface as a new transport over the same core, not a core change.
**B5 — `docs/use-cases/*.feature`.** Add scenarios for the behaviours now live and unspecced:
register (new subject → registration → user row; returning user straight through), connect
YouTube (web OAuth), disconnect, delete-account (cascade + secret purge, Dex untouched —
ADR-013), manual-vs-auto summarize mode + the Summarize button, and the landing page
(logged-out CTA; logged-in shortcuts). Keep them as executable-style Gherkin consistent with
the existing files.
**B6 — `DECISIONS.md` ADR ordering (cosmetic).** ADR-010 sits before ADR-009/011 (append
order). Reorder to numeric while you're in the file. Pure tidy, no content change.
**B7 — requirements check.** If a requirements doc exists (e.g. `docs/ui-spec.md`, referenced
by ADR-011), reconcile it with what shipped: note where the build deviated (e.g. the spinner /
immediate processing / summarize mode were beyond the original spec) so the spec reflects
reality or explicitly records the deviation. Do not silently rewrite history — record
deviations as deviations.
### B working method
- Source of truth order: migrations + code > ADRs > prose docs. When a prose doc disagrees
with code, the code wins and the doc is corrected (unless the code is the bug — then flag it,
don't quietly doc around it).
- One logical doc per commit. Cite the ADR/migration that justifies each change in the commit
body.
- This is an audit, not a rewrite: preserve the docs' structure and the "rejected alternatives
/ history" honesty. The goal is *current and trustworthy*, not *pretty*.
---
## Coordination
A and B touch mostly different files (A: oidc.go, handlers.go, views.templ, tests; B: docs/* +
auth.go comments). The one overlap is `auth.go` (B1 edits comments) vs A (reads it) — no
conflict. Run A and B in parallel; commit independently to `main`.
If anything in B reveals that code, not docs, is wrong (e.g. an isolation gap, a migration that
doesn't match the data-model intent), STOP and surface it — that's a finding, not a doc edit.
+77
View File
@@ -0,0 +1,77 @@
# Spec — Stage 0 usage measurement (login events)
**Date:** 2026-06-03
**Status:** Ready to build · **Repo:** tapir · **Size:** small (one migration + middleware + query)
**Why:** The Stage 0 gate (VISION, ADR-016) is *return usage in ≥2 separate weeks*. `summary_actions`
captures *acts* (watch/skip/save) but not *reads* — a friend who logs in weekly and reads summaries
without clicking anything is invisible. For a **reading** product that is the most important signal.
This adds the missing data so the gate is measurable as written. Solo session, not a swarm.
Read `CLAUDE.md` + ADR-016 first. TBD, conventional commits, `task check` green before each commit.
## Scope (resist sprawl — this is NOT analytics)
A lightweight, append-only record of *when each user was active*, enough to answer
"returned/read in ≥N distinct weeks". Not page-level events, not click tracking, not a funnel.
### 1. Migration — `login_events` (append-only)
```
login_events (
id UUID PK default gen_random_uuid(),
user_id UUID NOT NULL, -- per-user; RLS like every user-owned table
seen_at TIMESTAMPTZ NOT NULL default NOW()
)
INDEX (user_id, seen_at)
```
- **RLS:** `FORCE ROW LEVEL SECURITY`, same policy/pattern as the other user-owned tables (the
`tapir.current_user_id` GUC via the `withUser` seam — match migration 003). A reporting query that
needs cross-user counts runs as the owner/maintainer outside the per-user scope, or via a dedicated
read — decide consistently with how existing admin-ish reads are done.
- Append-only: no updates, no deletes except the user-delete cascade. **Add to the delete-account
cascade** (ADR-013) — `login_events` has no FK (mirrors `summary_actions`), so `DeleteUser` needs an
explicit delete for it, and the delete test must assert it's covered. *Do not forget this* — it's the
exact footgun the last delete work caught.
### 2. Middleware — throttled stamp
- In the authenticated request path (after `CurrentUserID` resolves, inside the registration-gated
app — NOT on `/welcome`/`/healthz`/`/auth`), record one `login_events` row **per user per day**
(throttle: skip if a row exists for this user with `seen_at` ≥ start-of-today). One insert per active
day, not per request — keeps the table small and the signal clean.
- Throttle check must itself be RLS-scoped (`withUser`). Keep it cheap (indexed lookup).
### 3. Query — the gate report
Provide a query (and optionally a tiny `tapir report` CLI subcommand or an admin page — your call,
CLI is fine) answering, per user:
```sql
-- distinct active weeks from reads (login_events) AND acts (summary_actions), unioned
WITH weeks AS (
SELECT user_id, date_trunc('week', seen_at) AS wk FROM login_events
UNION
SELECT user_id, date_trunc('week', acted_at) FROM summary_actions
)
SELECT user_id, COUNT(DISTINCT wk) AS active_weeks
FROM weeks GROUP BY user_id
ORDER BY active_weeks DESC;
```
Gate passes when any user_id (maintainer or friend) reaches `active_weeks >= 2` within the window.
## Honesty caveats to carry (from VISION/ADR-016)
- **"Unprompted" is not measurable here.** login_events records *that* a user returned, not *why*. A
nudged return looks identical to an organic one. This build does not close that gap and must not
claim to — the VISION measurement note stands: count returns, read a nudged return as weaker signal.
(If prompt-tracking is ever wanted, that's a separate decision, not this build.)
- **Data accrues from deploy onward.** The gate window's read-data starts when this ships — so ship
soon (maintainer's call) rather than batching with the infra tooling session.
- **`date_trunc('week')` is ISO/timezone-sensitive** and noisy at low volume (N=3). Two visits days
apart can fall in the same or different weeks. Acceptable, but don't over-read a single-week-margin
pass/fail.
## Out of scope
Page/event analytics; prompt-vs-organic tracking; dashboards beyond the one gate query; anything
touching the engine or sinks (this is web/store only — ADR-003 holds).
## Tests
- Migration up/down; RLS on `login_events` (extend the two-user isolation test to cover it).
- Throttle: N requests same day → 1 row; next day → 2nd row.
- `DeleteUser` removes the user's `login_events` and leaves others' intact (extend the delete test).
- The gate query returns correct distinct-week counts across a seeded reads+acts fixture.
+32 -5
View File
@@ -77,8 +77,9 @@ summary_actions
- **Flow:** standard Authorization Code. Use `coreos/go-oidc` + `golang.org/x/oauth2` - **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). (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`. - 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 - On callback: verify ID token, extract `sub` (and email). **ADR-012 superseded the
`TAPIR_ALLOWED_SUBJECT` (the maintainer's Dex subject) — reject everyone else with 403. 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 - **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). + sliding refresh. Server-side session store can be in-memory at Stage 0 (single replica).
- **Middleware** guards every route except `/healthz` and `/auth/*`. - **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_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_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_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. 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 ## 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 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` / `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 2. **DNS/edge** for `tapir.d-ma.be` → the k3s ingress (piguard NPM perimeter / existing
`*.d-ma.be` pattern) + TLS cert. `*.d-ma.be` pattern) + TLS cert.
3. Confirm the **registry** host/path the gitea CI pushes to and the Flux path 3. Confirm the **registry** host/path the gitea CI pushes to and the Flux path
@@ -145,3 +147,28 @@ Gate (lane A) commits first; B/C/D follow.
`task check` green per lane; B/C/D rebase on A. Deploy (D) lands last, after the binary serves `task check` green per lane; B/C/D rebase on A. Deploy (D) lands last, after the binary serves
locally. locally.
---
## Deviations and additions (as-built)
This spec describes the **Stage-0 single-user reader** (ADR-011). What actually shipped through
v0.4.0 went further — Stage 1 (ADR-012) opened multi-user, and several UX features were added on
top. Recorded here (append-only; the spec above is left intact) so intent and reality stay
distinguishable.
| As-built feature | What it is | Why | Covered by |
|------------------|-----------|-----|------------|
| **Multi-user + RLS isolation** | Several Dex users per deployment; isolation enforced by Postgres RLS, not the single-subject allowlist of §6. | Maintainer opened Stage 1 ahead of the formal Stage-0 gate, with DB-enforced isolation as the guardrail that keeps it safe. | ADR-012; migration 003 (`6775e5f`, `f28fdc0`, `2ae66da`) |
| **Registration gate** | A Dex subject with no `users` row is routed to `/register`, which creates the `users` row + a `user_identities` mapping. (§2 listed "sign-up / user CRUD" as a non-goal.) | Explicit registration is how a multi-user surface stays honest — no just-in-time row creation. | ADR-012; `f396e01` |
| **Per-user YouTube web connect** | `/oauth/youtube/connect``/oauth/youtube/callback` stores a per-user refresh-token ref + a `video_connections` row. (The spec assumed a host-side `tapir auth` only.) | Multi-user means each user connects their own account from the browser. | ADR-006, ADR-012; migration 005 (`0c9531a`, `2aad79b`) |
| **Account management** | `/account` page with **disconnect** and **delete account**; delete removes only Tapir-side state and leaves the Dex identity intact. (§2 listed isolation/CRUD as non-goals.) | A real account needs a way out; deletion semantics are deliberately Tapir-side only. | ADR-013; `22eafcf`, `c7624d9`, `17d5e8c` |
| **Immediate web summarization** | A "Summarize" button (`POST /v/{id}/summarize`) runs the engine in a background goroutine inside `serve`; the page HTMX-polls `GET /v/{id}/status`. (§2 said "triggering runs from the browser … do NOT build".) | Reading a list you can't act on is half a product; on-demand summarize closes the loop without waiting for a batch `tapir run`. | ADR-012, ADR-014; `25215cb`, `8c6c7ca` |
| **Charmbracelet tapir spinner** | An animated in-flight indicator (charm palette) shown while a summarize is processing; an honest "queued/waiting" state under rate-limiting rather than a stuck spinner. | The spinner must tell the truth when the timedtext endpoint rate-limits (429), not imply imminence. | ADR-014; `25215cb`, `a4aeb5e` |
| **Auto/manual summarization mode** | Per-user `auto_summarize`; manual (default) lists new videos unsummarized and queues via `summarize_requested`; a mode toggle at `/account/summarize-mode`. | Control over compute/noise — only summarize what the user cares about. | migration 006 (`748d5eb`, `bdbdce7`, `3014ee0`, `a269d4a`) |
| **Public landing page** | `/welcome` mounted **outside** the auth guard; unauthenticated `/` redirects there; logout returns there (not `/auth/login`). (The spec guarded everything except `/healthz` and `/auth/*`.) | A first-time visitor needs a public "what is this / get started" page before the login wall. | `d83943c`, `0fdf2f7`, `3a27bf1`, `d208110`, `8ca374e`, `f15f57f` |
The original Stage-0 goals (read summaries, record watch/skip/save actions, Dex login, GitOps
deploy) still hold — these are additions over that base, not replacements. The architecture
stance is unchanged: every item above is web-surface or store work; the engine/ports/sinks core
was not modified (ADR-003).
+28
View File
@@ -0,0 +1,28 @@
Feature: Public landing page
As a first-time visitor
I want a public welcome page before I log in
So that I understand what Tapir is and how to get started without hitting a login wall
Scenario: An unauthenticated visit to the root is sent to the welcome page
Given I am not logged in
When I open the root path "/"
Then I am redirected to "/welcome"
Scenario: The welcome page invites an unauthenticated visitor to start
Given I am not logged in
When I open "/welcome"
Then I see a "Get Started" call to action
Scenario: An authenticated user on the welcome page sees their way in and out
Given I am logged in
When I open "/welcome"
Then I see a link to my summaries
And I see a way to log out
Scenario: Logging out returns to the welcome page
Given I am logged in
When I log out
Then I am returned to "/welcome"
# /welcome is mounted outside the auth guard so it is reachable without a session;
# the root and all data routes stay behind it (commits around the WelcomePage work).
+45
View File
@@ -0,0 +1,45 @@
Feature: Register and manage a multi-user account
As one of a handful of trusted users
I want my own account, isolated from everyone else's
So that Tapir can serve several people from one deployment without leaking data
# Stage 1 (ADR-012): Dex authenticates, Tapir authorizes per user. A Dex subject
# with no users row is a new user and must register before reaching any data.
Scenario: A new Dex subject is routed to registration
Given I am authenticated by Dex with a subject that has no Tapir account
When I open any page that requires an account
Then I am routed to the registration page
And no summaries are shown until I register
Scenario: Registering creates the account and its identity mapping
Given I am authenticated by Dex with a subject that has no Tapir account
When I complete registration
Then a user row is created for me
And a user_identities row maps my Dex subject to that user
And I am taken into the app as a registered user
Scenario: A returning subject passes straight through
Given I am authenticated by Dex with a subject that already has a Tapir account
When I open the app
Then I am not asked to register again
And I see my own summaries
Scenario: Deleting an account removes only my data and leaves other users untouched
Given I am a registered user with summaries, a connected account, and recorded actions
And another user exists with their own summaries
When I delete my account
Then all of my rows are removed across every user-owned table
And my stored secret references are removed
And the other user's data remains intact
And my Dex identity is left intact
Scenario: A deleted user can register again as a fresh account
Given I deleted my Tapir account but my Dex identity still exists
When I sign in again
Then I am routed to the registration page as a new user
And registering creates a fresh user row with none of my old data
# Isolation is DB-enforced (Postgres RLS, ADR-012, migration 003): a user can never
# read or write another user's rows even if an application WHERE clause is wrong.
# Deletion is Tapir-side only — the shared Dex directory is never modified (ADR-013).
+32
View File
@@ -0,0 +1,32 @@
Feature: Choose how new videos get summarized
As a user who wants control over compute and noise
I want to pick whether new videos are summarized automatically or on demand
So that I only spend summarization on the videos I actually care about
Background:
Given I am a registered user with a connected video account
Scenario: Auto mode summarizes every new video
Given my summarization mode is "auto"
When a subscribed channel posts a new video with captions
Then Tapir summarizes it without my asking
And the summary appears in my list
Scenario: Manual mode is the default and leaves new videos unsummarized
Given I have not changed my summarization mode
Then my mode is "manual"
When a subscribed channel posts a new video with captions
Then the video appears in my list with no summary
And nothing is summarized until I request it
Scenario: Requesting a summary in manual mode queues it for the next run
Given my summarization mode is "manual"
And a new video is in my list with no summary
When I click "Summarize" on that video
Then the video is marked as requested
And the next run summarizes it
And the request flag is cleared after it is processed
# auto_summarize is a per-user setting and summarize_requested is a per-video queue
# flag (migration 006). The web button sets the flag; `tapir run` processes both the
# auto videos and the manually queued ones, then clears the flag.
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+1
View File
@@ -10,6 +10,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.45.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
) )
+2
View File
@@ -91,6 +91,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+180
View File
@@ -0,0 +1,180 @@
// Package dex creates Dex local-password accounts by writing
// passwords.dex.coreos.com custom resources directly against the in-cluster
// Kubernetes API. This is the write side of the invite flow: a recipient sets a
// password on /invite/{token}, Tapir bcrypt-hashes it and POSTs a Password CR into
// the auth namespace, and Dex (configured with kubernetes storage) then serves
// local-password login for that email.
//
// Why the raw API and not kubectl/client-go: the deployed pod already carries a
// service-account token and the cluster CA at the well-known mount paths, so a
// single net/http POST needs no extra dependency and no shelling out. Standalone /
// dev has no such mount — NewPasswordClient returns ErrNotInCluster and the web
// handler degrades gracefully (account creation only works in the deployed env).
package dex
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"time"
)
// Sentinel errors let the web handler turn API outcomes into clear user messages.
var (
// ErrNotInCluster means the service-account token mount is absent, so there is
// no in-cluster API to talk to (local dev / tests). Construction-time only.
ErrNotInCluster = errors.New("dex: not running in-cluster (no service-account token)")
// ErrPasswordExists maps the API's 409 Conflict — a Password CR for this email
// already exists. The handler treats it as a benign "log in instead".
ErrPasswordExists = errors.New("dex: password already exists")
// ErrForbidden maps 401/403 — the tapir ServiceAccount lacks create/get on
// passwords.dex.coreos.com in the auth namespace (RBAC not applied).
ErrForbidden = errors.New("dex: forbidden — missing RBAC for passwords.dex.coreos.com")
)
// Well-known in-cluster service-account mount paths (projected by kubelet).
const (
saTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // path, not a secret
saCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
// apiServer is the in-cluster API endpoint; its TLS is validated against the
// mounted cluster CA.
apiServer = "https://kubernetes.default.svc"
// passwordsPath is the Dex Password collection in the auth namespace.
passwordsPath = "/apis/dex.coreos.com/v1/namespaces/auth/passwords"
)
// PasswordClient writes Dex Password CRs against the in-cluster API. Construct it
// with NewPasswordClient; the zero value is not usable.
type PasswordClient struct {
server string
token string
http *http.Client
}
// NewPasswordClient reads the service-account token and cluster CA from the
// well-known mount paths and returns a client that authenticates as the pod's
// ServiceAccount. It returns ErrNotInCluster when the token mount is absent (dev /
// tests / standalone), so callers can detect "no Dex available" and degrade.
func NewPasswordClient() (*PasswordClient, error) {
token, err := os.ReadFile(saTokenPath)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotInCluster
}
if err != nil {
return nil, fmt.Errorf("dex: read service-account token: %w", err)
}
caPEM, err := os.ReadFile(saCAPath)
if err != nil {
return nil, fmt.Errorf("dex: read cluster CA: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, errors.New("dex: cluster CA is not valid PEM")
}
hc := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
},
}
return newClient(apiServer, strings.TrimSpace(string(token)), hc), nil
}
// newClient is the injectable constructor shared by NewPasswordClient and tests
// (which point server at an httptest.Server and pass its TLS client).
func newClient(server, token string, hc *http.Client) *PasswordClient {
return &PasswordClient{server: server, token: token, http: hc}
}
// password is the wire form of a Dex Password CR. NOTE: Dex's kubernetes storage
// types the hash as []byte, which Kubernetes JSON-marshals as base64. So the
// `hash` field must carry the base64 encoding of the bcrypt string, NOT the raw
// bcrypt string — store the raw string and Dex's base64-decode on login yields
// garbage and every login fails. CreatePassword does that encoding.
type password struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata map[string]string `json:"metadata"`
Email string `json:"email"`
Hash string `json:"hash"`
Username string `json:"username"`
UserID string `json:"userID"`
}
// CreatePassword creates a Dex local-password account for email with the given
// bcrypt hash and Dex user id. The CR name is derived from the email so it is a
// valid, stable, idempotent Kubernetes object name. Returns ErrPasswordExists on
// 409 (the account already exists) and ErrForbidden on 401/403 (RBAC missing).
func (c *PasswordClient) CreatePassword(ctx context.Context, email, bcryptHash, userID string) error {
body, err := json.Marshal(password{
APIVersion: "dex.coreos.com/v1",
Kind: "Password",
Metadata: map[string]string{"name": passwordName(email), "namespace": "auth"},
Email: email,
// base64 of the bcrypt string — see the password type's NOTE.
Hash: base64.StdEncoding.EncodeToString([]byte(bcryptHash)),
Username: email,
UserID: userID,
})
if err != nil {
return fmt.Errorf("dex: marshal password: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.server+passwordsPath, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("dex: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("dex: create password: %w", err)
}
defer func() { _ = resp.Body.Close() }()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK:
return nil
case http.StatusConflict:
return ErrPasswordExists
case http.StatusUnauthorized, http.StatusForbidden:
return ErrForbidden
default:
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("dex: create password: unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet)))
}
}
// invalidNameChars matches anything not allowed in an RFC-1123 subdomain segment
// after the explicit @/. substitutions, so any stray character becomes '-'.
var invalidNameChars = regexp.MustCompile(`[^a-z0-9-]`)
// passwordName maps an email to a valid, deterministic Kubernetes object name:
// lowercase, '@' -> '-at-', '.' -> '-dot-', any remaining invalid char -> '-',
// with leading/trailing '-' trimmed. Deterministic so a re-invite targets the
// same CR (and so Dex's 409 is meaningful).
func passwordName(email string) string {
n := strings.ToLower(strings.TrimSpace(email))
n = strings.ReplaceAll(n, "@", "-at-")
n = strings.ReplaceAll(n, ".", "-dot-")
n = invalidNameChars.ReplaceAllString(n, "-")
n = strings.Trim(n, "-")
if n == "" {
n = "user"
}
return n
}
+104
View File
@@ -0,0 +1,104 @@
package dex
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
// newTestClient points a PasswordClient at an httptest server, using that
// server's TLS client so the in-cluster TLS path is exercised without a real CA.
func newTestClient(srv *httptest.Server) *PasswordClient {
return newClient(srv.URL, "test-token", srv.Client())
}
func TestCreatePasswordSuccess(t *testing.T) {
var gotAuth, gotPath, gotMethod string
var gotBody password
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth, gotPath, gotMethod = r.Header.Get("Authorization"), r.URL.Path, r.Method
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"kind":"Password"}`))
}))
defer srv.Close()
err := newTestClient(srv).CreatePassword(context.Background(),
"New.User@Example.com", "$2a$12$abcdefghijklmnopqrstuv", "user-uuid-1")
require.NoError(t, err)
require.Equal(t, http.MethodPost, gotMethod)
require.Equal(t, passwordsPath, gotPath)
require.Equal(t, "Bearer test-token", gotAuth)
// Email/username carry the raw address; the CR name is sanitised + lowercased.
require.Equal(t, "New.User@Example.com", gotBody.Email)
require.Equal(t, "New.User@Example.com", gotBody.Username)
require.Equal(t, "user-uuid-1", gotBody.UserID)
require.Equal(t, "new-dot-user-at-example-dot-com", gotBody.Metadata["name"])
require.Equal(t, "auth", gotBody.Metadata["namespace"])
// The hash is the BASE64 of the bcrypt string (Dex stores hash as []byte).
decoded, err := base64.StdEncoding.DecodeString(gotBody.Hash)
require.NoError(t, err)
require.Equal(t, "$2a$12$abcdefghijklmnopqrstuv", string(decoded))
}
func TestCreatePasswordConflict(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
}))
defer srv.Close()
err := newTestClient(srv).CreatePassword(context.Background(), "dup@example.com", "$2a$12$x", "u")
require.ErrorIs(t, err, ErrPasswordExists)
}
func TestCreatePasswordForbidden(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
err := newTestClient(srv).CreatePassword(context.Background(), "x@example.com", "$2a$12$x", "u")
require.ErrorIs(t, err, ErrForbidden)
}
func TestCreatePasswordUnexpectedStatus(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
err := newTestClient(srv).CreatePassword(context.Background(), "x@example.com", "$2a$12$x", "u")
require.Error(t, err)
require.NotErrorIs(t, err, ErrPasswordExists)
require.NotErrorIs(t, err, ErrForbidden)
require.Contains(t, err.Error(), "500")
}
func TestNewPasswordClientNotInCluster(t *testing.T) {
// In the test environment the SA token mount does not exist.
_, err := NewPasswordClient()
require.ErrorIs(t, err, ErrNotInCluster)
}
func TestPasswordName(t *testing.T) {
cases := map[string]string{
"Alice@Example.com": "alice-at-example-dot-com",
"a.b+c@gmail.com": "a-dot-b-c-at-gmail-dot-com",
"UPPER@DOMAIN.IO": "upper-at-domain-dot-io",
}
for in, want := range cases {
require.Equal(t, want, passwordName(in), in)
}
}
+37
View File
@@ -89,6 +89,43 @@ func (s *FileStore) Put(ref, value string) error {
return nil 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 // load reads the backing file. A missing file yields an empty map (not an
// error) for Get's caller, except Put distinguishes os.ErrNotExist. // error) for Get's caller, except Put distinguishes os.ErrNotExist.
func (s *FileStore) load() (map[string]string, error) { 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) { func TestPutMergesEntries(t *testing.T) {
path := filepath.Join(t.TempDir(), "secrets.json") path := filepath.Join(t.TempDir(), "secrets.json")
s := secrets.NewFileStore(path) 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)
}
+51 -50
View File
@@ -3,6 +3,8 @@ package store
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/jackc/pgx/v5"
) )
// allowedActions is the closed set of action verbs persisted in summary_actions. // allowedActions is the closed set of action verbs persisted in summary_actions.
@@ -39,33 +41,25 @@ func (s *Store) SetAction(ctx context.Context, userID, videoID, action string) e
return err return err
} }
tx, err := s.pool.Begin(ctx) return s.withUser(ctx, userID, func(tx pgx.Tx) error {
if err != nil { if opposite, ok := oppositeAction[action]; ok {
return fmt.Errorf("store: begin set action: %w", err) if _, err := tx.Exec(ctx,
} `DELETE FROM summary_actions
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit WHERE user_id = $1 AND video_id = $2 AND action = $3`,
userID, videoID, opposite); err != nil {
if opposite, ok := oppositeAction[action]; ok { return fmt.Errorf("store: clear opposite action: %w", err)
if _, err := tx.Exec(ctx, }
`DELETE FROM summary_actions
WHERE user_id = $1 AND video_id = $2 AND action = $3`,
userID, videoID, opposite); err != nil {
return fmt.Errorf("store: clear opposite action: %w", err)
} }
}
if _, err := tx.Exec(ctx, if _, err := tx.Exec(ctx,
`INSERT INTO summary_actions (user_id, video_id, action) `INSERT INTO summary_actions (user_id, video_id, action)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
ON CONFLICT (user_id, video_id, action) DO UPDATE SET acted_at = NOW()`, ON CONFLICT (user_id, video_id, action) DO UPDATE SET acted_at = NOW()`,
userID, videoID, action); err != nil { userID, videoID, action); err != nil {
return fmt.Errorf("store: set action: %w", err) return fmt.Errorf("store: set action: %w", err)
} }
return nil
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 // 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 { if err := validateAction(action); err != nil {
return err return err
} }
if _, err := s.pool.Exec(ctx, return s.withUser(ctx, userID, func(tx pgx.Tx) error {
`DELETE FROM summary_actions if _, err := tx.Exec(ctx,
WHERE user_id = $1 AND video_id = $2 AND action = $3`, `DELETE FROM summary_actions
userID, videoID, action); err != nil { WHERE user_id = $1 AND video_id = $2 AND action = $3`,
return fmt.Errorf("store: clear action: %w", err) userID, videoID, action); err != nil {
} return fmt.Errorf("store: clear action: %w", err)
return nil }
return nil
})
} }
// ActionsFor returns the active actions per video for the given user, keyed by // 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 { if len(videoIDs) == 0 {
return out, nil return out, nil
} }
rows, err := s.pool.Query(ctx, if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
`SELECT video_id, action FROM summary_actions rows, err := tx.Query(ctx,
WHERE user_id = $1 AND video_id = ANY($2) `SELECT video_id, action FROM summary_actions
ORDER BY video_id, action`, WHERE user_id = $1 AND video_id = ANY($2)
userID, videoIDs) ORDER BY video_id, action`,
if err != nil { userID, videoIDs)
return nil, fmt.Errorf("store: actions for: %w", err) if err != nil {
} 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)
} }
out[videoID] = append(out[videoID], action) defer rows.Close()
}
if err := rows.Err(); err != nil { for rows.Next() {
return nil, fmt.Errorf("store: iterate actions: %w", err) var videoID, action string
if err := rows.Scan(&videoID, &action); err != nil {
return fmt.Errorf("store: scan action: %w", err)
}
out[videoID] = append(out[videoID], action)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("store: iterate actions: %w", err)
}
return nil
}); err != nil {
return nil, err
} }
return out, nil 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)
}
+86
View File
@@ -0,0 +1,86 @@
package store
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Invitations are NOT routed through withUser: an invitation exists before its
// user does, so there is no user_id to scope by and no authenticated context when
// one is minted (host CLI) or claimed (the public /invite handler). The token is
// the capability — single-use, time-boxed, crypto-random. The invitations table
// is deliberately outside RLS for the same reason (see migration 009).
// CreateInvitation mints a single-use invite for email, valid for ttl, and
// returns its token. The token is 32 bytes of crypto-random entropy, hex-encoded;
// it is the only secret a recipient needs to claim the invite.
func (s *Store) CreateInvitation(ctx context.Context, email string, ttl time.Duration) (string, error) {
token, err := newInviteToken()
if err != nil {
return "", err
}
if _, err := s.pool.Exec(ctx,
`INSERT INTO invitations (email, token, expires_at)
VALUES ($1, $2, NOW() + $3::interval)`,
email, token, ttl.String()); err != nil {
return "", fmt.Errorf("store: create invitation: %w", err)
}
return token, nil
}
// PeekInvitation returns the invited email for a token that is real, unexpired,
// and unused WITHOUT consuming it — the read the /invite form does to validate the
// link before showing the password fields. Returns ErrNotFound when the token is
// missing, expired, or already used. Use ClaimInvitation to consume.
func (s *Store) PeekInvitation(ctx context.Context, token string) (string, error) {
var email string
err := s.pool.QueryRow(ctx,
`SELECT email FROM invitations
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`,
token).Scan(&email)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("store: peek invitation: %w", err)
}
return email, nil
}
// ClaimInvitation atomically consumes a valid invite and returns its email. The
// UPDATE ... WHERE used_at IS NULL AND expires_at > NOW() guarded by RETURNING
// makes the claim a single round-trip race-free check-and-set: two concurrent
// claims of the same token, only one updates a row, the other gets no rows and so
// ErrNotFound. Same ErrNotFound for missing/expired/already-used tokens.
func (s *Store) ClaimInvitation(ctx context.Context, token string) (string, error) {
var email string
err := s.pool.QueryRow(ctx,
`UPDATE invitations
SET used_at = NOW()
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()
RETURNING email`,
token).Scan(&email)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("store: claim invitation: %w", err)
}
return email, nil
}
// newInviteToken returns 32 bytes of crypto-random entropy, hex-encoded (64
// chars). Hex keeps the token URL-safe with no escaping in /invite/{token}.
func newInviteToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("store: invite token: %w", err)
}
return hex.EncodeToString(b[:]), nil
}
+110
View File
@@ -0,0 +1,110 @@
package store_test
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
// resetInvitations clears the invitations table between cases. It is not in the
// shared resetDB TRUNCATE list (invitations is not user-owned and has no FK to
// users), so the invite tests wipe it themselves.
func resetInvitations(t *testing.T, p *pgxpool.Pool) {
t.Helper()
_, err := p.Exec(context.Background(), `TRUNCATE invitations`)
require.NoError(t, err)
}
func TestCreateInvitationReturnsUsableToken(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
token, err := s.CreateInvitation(ctx, "new@example.com", time.Hour)
require.NoError(t, err)
require.Len(t, token, 64, "32 random bytes hex-encoded")
// Peek does not consume: the same token previews twice.
email, err := s.PeekInvitation(ctx, token)
require.NoError(t, err)
require.Equal(t, "new@example.com", email)
email, err = s.PeekInvitation(ctx, token)
require.NoError(t, err)
require.Equal(t, "new@example.com", email)
}
func TestCreateInvitationTokensAreUnique(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
t1, err := s.CreateInvitation(ctx, "a@example.com", time.Hour)
require.NoError(t, err)
t2, err := s.CreateInvitation(ctx, "b@example.com", time.Hour)
require.NoError(t, err)
require.NotEqual(t, t1, t2)
}
func TestClaimInvitationHappyPath(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
token, err := s.CreateInvitation(ctx, "claim@example.com", time.Hour)
require.NoError(t, err)
email, err := s.ClaimInvitation(ctx, token)
require.NoError(t, err)
require.Equal(t, "claim@example.com", email)
}
func TestClaimInvitationIsSingleUse(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
token, err := s.CreateInvitation(ctx, "once@example.com", time.Hour)
require.NoError(t, err)
_, err = s.ClaimInvitation(ctx, token)
require.NoError(t, err)
// Second claim fails — already used.
_, err = s.ClaimInvitation(ctx, token)
require.ErrorIs(t, err, store.ErrNotFound)
// And a used token no longer previews.
_, err = s.PeekInvitation(ctx, token)
require.ErrorIs(t, err, store.ErrNotFound)
}
func TestClaimInvitationExpired(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
// Negative ttl => already expired.
token, err := s.CreateInvitation(ctx, "old@example.com", -time.Minute)
require.NoError(t, err)
_, err = s.PeekInvitation(ctx, token)
require.ErrorIs(t, err, store.ErrNotFound)
_, err = s.ClaimInvitation(ctx, token)
require.ErrorIs(t, err, store.ErrNotFound)
}
func TestClaimInvitationNotFound(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
_, err := s.ClaimInvitation(ctx, "does-not-exist")
require.ErrorIs(t, err, store.ErrNotFound)
_, err = s.PeekInvitation(ctx, "does-not-exist")
require.ErrorIs(t, err, store.ErrNotFound)
}
@@ -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;
@@ -0,0 +1,2 @@
ALTER TABLE videos DROP COLUMN IF EXISTS rate_limited_at;
ALTER TABLE videos DROP COLUMN IF EXISTS transcript_status;
@@ -0,0 +1,17 @@
-- Migration 007: per-video transcript fetch status, for rate-limit backoff.
--
-- transcript_status records the outcome of the last transcript attempt:
-- NULL = not yet attempted
-- 'none' = checked, no usable transcript (permanent — SourceNone)
-- 'fetched' = transcript resolved and summarized (summary_id not null)
-- 'rate_limited'= the caption endpoint returned 429; retry after a backoff window
--
-- rate_limited_at stamps WHEN the 429 was seen, so the runner can skip re-fetching
-- a still-throttled video until NOW() - rate_limited_at exceeds TAPIR_FETCH_BACKOFF.
-- It is cleared (set NULL) whenever the status moves off 'rate_limited'.
--
-- No RLS policy changes needed: videos already has ENABLE + FORCE ROW LEVEL
-- SECURITY (migration 003) with the videos_isolation policy. New columns inherit
-- that protection automatically.
ALTER TABLE videos ADD COLUMN transcript_status TEXT;
ALTER TABLE videos ADD COLUMN rate_limited_at TIMESTAMPTZ;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS invitations;
@@ -0,0 +1,22 @@
-- Migration 009: invitations — an email-based invite to join Tapir (Stage-1
-- onboarding gate). Mathias mints one with `tapir invite <email>`; the recipient
-- visits /invite/{token}, sets a password, and Tapir creates their Dex account.
--
-- Deliberately NOT user-owned and NOT under RLS: an invitation exists BEFORE the
-- user does, so there is no user_id to scope by and no authenticated user context
-- when the invite is created (host CLI) or consumed (public /invite handler, no
-- Dex session). The token itself is the capability — a 32-byte crypto-random,
-- single-use, time-boxed secret. Hence no `user_id` FK and no ENABLE/FORCE ROW
-- LEVEL SECURITY here (unlike every user-owned table in migrations 003/005).
CREATE TABLE invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
-- Lookups are by token (both the claim and the form preview); the UNIQUE
-- constraint already creates an index, this names one explicitly for clarity.
CREATE INDEX idx_invitations_token ON invitations(token);
+234 -47
View File
@@ -25,19 +25,34 @@ var ErrNotFound = errors.New("store: summary not found")
// not part of the Stage-0 store slice yet. When that table is migrated, swap the // not part of the Stage-0 store slice yet. When that table is migrated, swap the
// JOIN source — callers already fall back gracefully on an empty Channel. // JOIN source — callers already fall back gracefully on an empty Channel.
type SummaryRow struct { type SummaryRow struct {
VideoID string VideoID string
Title string // videos.title; empty when no videos row ProviderVideoID string // videos.provider_video_id; empty when no videos row
Channel string // videos.provider for now; empty when no videos row Title string // videos.title; empty when no videos row
URL string // videos.url; empty when no videos row Channel string // videos.provider for now; empty when no videos row
PublishedAt time.Time // videos.published_at; zero when absent URL string // videos.url; empty when no videos row
Summary string PublishedAt time.Time // videos.published_at; zero when absent
Highlights []string Summary string
Takeaways []string Highlights []string
AIProvider string Takeaways []string
AIModel string AIProvider string
FallbackUsed bool AIModel string
CreatedAt time.Time FallbackUsed bool
Actions []string // current active actions for this video; nil when none 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
// TranscriptStatus mirrors videos.transcript_status (migration 007): "" (unset),
// "none", "rate_limited", or "fetched". Drives the "Retrying later" list badge.
// Only populated by ListVideos/GetVideoRow ("" on summary-only reads).
TranscriptStatus string
} }
// selectSummary is the shared projection for both reads. videos is LEFT JOINed // selectSummary is the shared projection for both reads. videos is LEFT JOINed
@@ -45,6 +60,7 @@ type SummaryRow struct {
// crosses users and a missing videos row yields nulls, not a dropped summary. // crosses users and a missing videos row yields nulls, not a dropped summary.
const selectSummary = ` const selectSummary = `
SELECT s.video_id, SELECT s.video_id,
COALESCE(v.provider_video_id, ''),
COALESCE(v.title, ''), COALESCE(v.title, ''),
COALESCE(v.provider, ''), COALESCE(v.provider, ''),
COALESCE(v.url, ''), COALESCE(v.url, ''),
@@ -66,27 +82,32 @@ func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
rows, err := s.pool.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)
}
defer rows.Close()
var out []SummaryRow var out []SummaryRow
for rows.Next() { if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
row, err := scanSummaryRow(rows) rows, err := tx.Query(ctx,
selectSummary+`
WHERE s.user_id = $1
ORDER BY s.created_at DESC
LIMIT $2`,
userID, limit)
if err != nil { if err != nil {
return nil, err return fmt.Errorf("store: list summaries: %w", err)
} }
out = append(out, row) defer rows.Close()
}
if err := rows.Err(); err != nil { for rows.Next() {
return nil, fmt.Errorf("store: iterate summaries: %w", err) row, err := scanSummaryRow(rows)
if err != nil {
return err
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("store: iterate summaries: %w", err)
}
return nil
}); err != nil {
return nil, err
} }
if err := s.attachActions(ctx, userID, out); err != nil { if err := s.attachActions(ctx, userID, out); err != nil {
return nil, err return nil, err
@@ -94,29 +115,194 @@ func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]
return out, nil return out, nil
} }
// 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,
COALESCE(v.transcript_status, '')
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 fmt.Errorf("store: get video: %w", err)
}
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
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
}
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,
&row.TranscriptStatus,
); 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 // GetSummaryByVideo returns the full summary for (userID, videoID), including
// highlights and takeaways. Returns ErrNotFound when the user has no such // highlights and takeaways. Returns ErrNotFound when the user has no such
// summary. Scoped by user_id. // summary. Scoped by user_id.
func (s *Store) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*SummaryRow, error) { func (s *Store) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*SummaryRow, error) {
rows, err := s.pool.Query(ctx, var (
selectSummary+` row SummaryRow
WHERE s.user_id = $1 AND s.video_id = $2`, found bool
userID, videoID) )
if err != nil { if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
return nil, fmt.Errorf("store: get summary: %w", err) rows, err := tx.Query(ctx,
} selectSummary+`
defer rows.Close() WHERE s.user_id = $1 AND s.video_id = $2`,
userID, videoID)
if !rows.Next() { if err != nil {
if err := rows.Err(); err != nil { return fmt.Errorf("store: get summary: %w", err)
return nil, fmt.Errorf("store: get summary: %w", err)
} }
return nil, ErrNotFound defer rows.Close()
}
row, err := scanSummaryRow(rows) if !rows.Next() {
if err != nil { 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 return nil, err
} }
if !found {
return nil, ErrNotFound
}
holder := []SummaryRow{row} holder := []SummaryRow{row}
if err := s.attachActions(ctx, userID, holder); err != nil { if err := s.attachActions(ctx, userID, holder); err != nil {
return nil, err return nil, err
@@ -135,6 +321,7 @@ func scanSummaryRow(rows pgx.Row) (SummaryRow, error) {
) )
if err := rows.Scan( if err := rows.Scan(
&row.VideoID, &row.VideoID,
&row.ProviderVideoID,
&row.Title, &row.Title,
&row.Channel, &row.Channel,
&row.URL, &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
}
+104 -64
View File
@@ -19,6 +19,7 @@ import (
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5" migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs" "github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver for migrate _ "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. // Name identifies this sink in delivery records.
func (s *Store) Name() string { return "store" } 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 // 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 // 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 // errors or duplicates. The whole write is one transaction so a summary and its
@@ -104,63 +145,57 @@ func (s *Store) Deliver(ctx context.Context, sum domain.Summary) error {
return fmt.Errorf("store: marshal takeaways: %w", err) return fmt.Errorf("store: marshal takeaways: %w", err)
} }
tx, err := s.pool.Begin(ctx) return s.withUser(ctx, sum.UserID, func(tx pgx.Tx) error {
if err != nil { // Ensure the owning user exists (FK target). The store sink receives only
return fmt.Errorf("store: begin: %w", err) // a Summary, so a minimal user row is enough at Stage 0.
} if _, err := tx.Exec(ctx,
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit `INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
sum.UserID); err != nil {
return fmt.Errorf("store: upsert user: %w", err)
}
// Ensure the owning user exists (FK target). The store sink receives only a var summaryID string
// Summary, so a minimal user row is enough at Stage 0. if err := tx.QueryRow(ctx,
if _, err := tx.Exec(ctx, `INSERT INTO summaries
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, (user_id, video_id, summary, highlights, takeaways, ai_provider, ai_model, fallback_used)
sum.UserID); err != nil { VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
return fmt.Errorf("store: upsert user: %w", err) ON CONFLICT (user_id, video_id) DO UPDATE SET
} summary = EXCLUDED.summary,
highlights = EXCLUDED.highlights,
takeaways = EXCLUDED.takeaways,
ai_provider = EXCLUDED.ai_provider,
ai_model = EXCLUDED.ai_model,
fallback_used = EXCLUDED.fallback_used
RETURNING id`,
sum.UserID, sum.VideoID, sum.Summary, highlights, takeaways,
sum.AIProvider, sum.AIModel, sum.FallbackUsed,
).Scan(&summaryID); err != nil {
return fmt.Errorf("store: upsert summary: %w", err)
}
var summaryID string if _, err := tx.Exec(ctx,
if err := tx.QueryRow(ctx, `INSERT INTO sink_deliveries (summary_id, sink, status)
`INSERT INTO summaries VALUES ($1, 'store', 'delivered')
(user_id, video_id, summary, highlights, takeaways, ai_provider, ai_model, fallback_used) ON CONFLICT (summary_id, sink) DO UPDATE SET
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) status = 'delivered',
ON CONFLICT (user_id, video_id) DO UPDATE SET detail = NULL,
summary = EXCLUDED.summary, updated_at = NOW()`,
highlights = EXCLUDED.highlights, summaryID); err != nil {
takeaways = EXCLUDED.takeaways, return fmt.Errorf("store: record delivery: %w", err)
ai_provider = EXCLUDED.ai_provider, }
ai_model = EXCLUDED.ai_model, return nil
fallback_used = EXCLUDED.fallback_used })
RETURNING id`,
sum.UserID, sum.VideoID, sum.Summary, highlights, takeaways,
sum.AIProvider, sum.AIModel, sum.FallbackUsed,
).Scan(&summaryID); err != nil {
return fmt.Errorf("store: upsert summary: %w", err)
}
if _, err := tx.Exec(ctx,
`INSERT INTO sink_deliveries (summary_id, sink, status)
VALUES ($1, 'store', 'delivered')
ON CONFLICT (summary_id, sink) DO UPDATE SET
status = 'delivered',
detail = NULL,
updated_at = NOW()`,
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). // HasSummary reports whether a summary already exists for (userID, videoID).
// This is the per-video durable dedup check. // This is the per-video durable dedup check.
func (s *Store) HasSummary(ctx context.Context, userID, videoID string) (bool, error) { func (s *Store) HasSummary(ctx context.Context, userID, videoID string) (bool, error) {
var exists bool var exists bool
if err := s.pool.QueryRow(ctx, if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
`SELECT EXISTS(SELECT 1 FROM summaries WHERE user_id = $1 AND video_id = $2)`, return tx.QueryRow(ctx,
userID, videoID).Scan(&exists); err != nil { `SELECT EXISTS(SELECT 1 FROM summaries WHERE user_id = $1 AND video_id = $2)`,
userID, videoID).Scan(&exists)
}); err != nil {
return false, fmt.Errorf("store: has summary: %w", err) return false, fmt.Errorf("store: has summary: %w", err)
} }
return exists, nil 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. The watcher uses it to skip re-summarizing across restarts. Scoped by
// user_id, so one user never sees another's videos. // user_id, so one user never sees another's videos.
func (s *Store) SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error) { func (s *Store) SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error) {
rows, err := s.pool.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)
}
defer rows.Close()
seen := make(map[string]bool) seen := make(map[string]bool)
for rows.Next() { if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
var id string rows, err := tx.Query(ctx,
if err := rows.Scan(&id); err != nil { `SELECT video_id FROM summaries WHERE user_id = $1`, userID)
return nil, fmt.Errorf("store: scan video id: %w", err) if err != nil {
return fmt.Errorf("store: seen video ids: %w", err)
} }
seen[id] = true defer rows.Close()
}
if err := rows.Err(); err != nil { for rows.Next() {
return nil, fmt.Errorf("store: iterate video ids: %w", err) var id string
if err := rows.Scan(&id); err != nil {
return fmt.Errorf("store: scan video id: %w", err)
}
seen[id] = true
}
if err := rows.Err(); err != nil {
return fmt.Errorf("store: iterate video ids: %w", err)
}
return nil
}); err != nil {
return nil, err
} }
return seen, nil 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)
}
@@ -0,0 +1,102 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// validTranscriptStatuses bounds SetTranscriptStatus input. "" clears the status
// (column NULL); the three named states mirror migration 007's documented values.
var validTranscriptStatuses = map[string]bool{
"": true,
"none": true,
"rate_limited": true,
"fetched": true,
}
// SetTranscriptStatus records the outcome of the last transcript attempt for a
// video (migration 007). When status is "rate_limited" it also stamps
// rate_limited_at = NOW() so the runner can back off; every other status clears
// that timestamp. "" unsets the status (column NULL). An unknown status is
// rejected. Scoped via withUser, so RLS confines the UPDATE to the caller's own
// video; ErrNotFound when the user has no such video.
func (s *Store) SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error {
if !validTranscriptStatuses[status] {
return fmt.Errorf("store: invalid transcript status %q", status)
}
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`UPDATE videos
SET transcript_status = NULLIF($1, ''),
rate_limited_at = CASE WHEN $1 = 'rate_limited' THEN NOW() ELSE NULL END
WHERE id = $2`,
status, videoID)
if err != nil {
return fmt.Errorf("store: set transcript status: %w", err)
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// GetTranscriptStatus returns a video's transcript_status ("" when unset/NULL).
// Returns ErrNotFound when the user has no such video. Scoped via withUser.
func (s *Store) GetTranscriptStatus(ctx context.Context, userID, videoID string) (string, error) {
var status string
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx,
`SELECT COALESCE(transcript_status, '') FROM videos WHERE id = $1`, videoID).Scan(&status)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}); err != nil {
if errors.Is(err, ErrNotFound) {
return "", ErrNotFound
}
return "", fmt.Errorf("store: get transcript status: %w", err)
}
return status, nil
}
// RateLimitedVideoIDs returns the user's videos currently in the "rate_limited"
// state, mapped to when the 429 was stamped (rate_limited_at). The run loop loads
// it once per pass (mirroring SeenVideoIDs) to skip re-fetching a video still
// inside the backoff window, saving caption requests. Scoped by user_id.
func (s *Store) RateLimitedVideoIDs(ctx context.Context, userID string) (map[string]time.Time, error) {
out := make(map[string]time.Time)
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
`SELECT id, rate_limited_at FROM videos
WHERE user_id = $1 AND transcript_status = 'rate_limited' AND rate_limited_at IS NOT NULL`,
userID)
if err != nil {
return fmt.Errorf("store: rate limited video ids: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
id string
at time.Time
)
if err := rows.Scan(&id, &at); err != nil {
return fmt.Errorf("store: scan rate limited id: %w", err)
}
out[id] = at
}
if err := rows.Err(); err != nil {
return fmt.Errorf("store: iterate rate limited ids: %w", err)
}
return nil
}); err != nil {
return nil, err
}
return out, nil
}
@@ -0,0 +1,80 @@
package store_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
func TestSetTranscriptStatus_RoundTrip(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
id, err := s.UpsertVideo(ctx, ytVideo(userA, "rt12345", "round trip"))
require.NoError(t, err)
// Unset by default.
got, err := s.GetTranscriptStatus(ctx, userA, id)
require.NoError(t, err)
require.Equal(t, "", got)
for _, status := range []string{"none", "fetched", "rate_limited", ""} {
require.NoError(t, s.SetTranscriptStatus(ctx, userA, id, status))
got, err := s.GetTranscriptStatus(ctx, userA, id)
require.NoError(t, err)
require.Equal(t, status, got)
}
}
func TestSetTranscriptStatus_RejectsInvalid(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
id, err := s.UpsertVideo(ctx, ytVideo(userA, "bad12345", "bad status"))
require.NoError(t, err)
require.Error(t, s.SetTranscriptStatus(ctx, userA, id, "bogus"))
// The rejected write left the status untouched.
got, err := s.GetTranscriptStatus(ctx, userA, id)
require.NoError(t, err)
require.Equal(t, "", got)
}
func TestSetTranscriptStatus_NotFound(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
require.ErrorIs(t, s.SetTranscriptStatus(ctx, userA, videoX, "fetched"), store.ErrNotFound)
_, err := s.GetTranscriptStatus(ctx, userA, videoX)
require.ErrorIs(t, err, store.ErrNotFound)
}
func TestRateLimitedVideoIDs_StampsAndClears(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
id, err := s.UpsertVideo(ctx, ytVideo(userA, "rl12345", "rate limited"))
require.NoError(t, err)
// Marking rate_limited stamps rate_limited_at, so the video appears.
require.NoError(t, s.SetTranscriptStatus(ctx, userA, id, "rate_limited"))
rl, err := s.RateLimitedVideoIDs(ctx, userA)
require.NoError(t, err)
require.Contains(t, rl, id)
require.False(t, rl[id].IsZero(), "rate_limited_at must be stamped")
// Moving off rate_limited clears the timestamp, so it drops out.
require.NoError(t, s.SetTranscriptStatus(ctx, userA, id, "fetched"))
rl, err = s.RateLimitedVideoIDs(ctx, userA)
require.NoError(t, err)
require.NotContains(t, rl, id)
}
+24 -27
View File
@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/jackc/pgx/v5"
"gitea.d-ma.be/mathias/tapir/internal/domain" "gitea.d-ma.be/mathias/tapir/internal/domain"
) )
@@ -29,40 +31,35 @@ func (s *Store) UpsertVideo(ctx context.Context, v domain.Video) (string, error)
return "", fmt.Errorf("store: upsert video: empty provider video id") 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) provider := string(v.Provider)
if provider == "" { if provider == "" {
provider = string(domain.ProviderYouTube) provider = string(domain.ProviderYouTube)
} }
var id string var id string
if err := tx.QueryRow(ctx, if err := s.withUser(ctx, v.UserID, func(tx pgx.Tx) error {
`INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at) // Ensure the owning user exists (FK target) — same as the Deliver path.
VALUES ($1, $2, $3, $4, $5, $6) if _, err := tx.Exec(ctx,
ON CONFLICT (user_id, provider, provider_video_id) DO UPDATE SET `INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
title = EXCLUDED.title, v.UserID); err != nil {
url = EXCLUDED.url, return fmt.Errorf("store: upsert user: %w", err)
published_at = EXCLUDED.published_at }
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)
}
if err := tx.Commit(ctx); err != nil { if err := tx.QueryRow(ctx,
return "", fmt.Errorf("store: commit: %w", err) `INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, provider, provider_video_id) DO UPDATE SET
title = EXCLUDED.title,
url = EXCLUDED.url,
published_at = EXCLUDED.published_at
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 nil
}); err != nil {
return "", err
} }
return id, nil return id, nil
} }
+6
View File
@@ -60,6 +60,12 @@ func (a *Adapter) FetchTranscript(ctx context.Context, v domain.Video) (domain.T
if err != nil { if err != nil {
return domain.Transcript{}, fmt.Errorf("download caption track for %q: %w", v.ProviderVideoID, err) return domain.Transcript{}, fmt.Errorf("download caption track for %q: %w", v.ProviderVideoID, err)
} }
if status == http.StatusTooManyRequests {
// 429 means the IP is rate-limited; record for retry, not a permanent
// absence. Degrade gracefully (no error, no text) like SourceNone, but
// flag it distinctly so the runner backs off and retries (ADR-007/010).
return domain.Transcript{VideoID: v.ID, UserID: v.UserID, Source: domain.SourceRateLimited}, nil
}
if status != http.StatusOK { if status != http.StatusOK {
// Owner-only 403, region/age gate, or transient unavailability: not an error. // Owner-only 403, region/age gate, or transient unavailability: not an error.
return noTranscript(v), nil return noTranscript(v), nil
+31
View File
@@ -388,6 +388,37 @@ func TestFetchTranscriptBaseURLForbiddenDegrades(t *testing.T) {
} }
} }
// A 429 on the baseUrl fetch is the IP being rate-limited, NOT a permanent
// absence of captions: it returns SourceRateLimited (no error, no text) so the
// runner can record it and retry after a backoff window rather than recording a
// false "no transcript".
func TestFetchTranscriptRateLimitedReturnsSourceRateLimited(t *testing.T) {
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/youtubei/v1/player":
base := "http://" + r.Host
_, _ = w.Write([]byte(`{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[` +
`{"baseUrl":"` + base + `/api/timedtext?lang=en","languageCode":"en"}]}}}`))
case "/api/timedtext":
w.WriteHeader(http.StatusTooManyRequests)
}
})
tr, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"})
if err != nil {
t.Fatalf("429 on baseUrl must degrade, not error: %v", err)
}
if tr.Source != domain.SourceRateLimited {
t.Fatalf("expected SourceRateLimited on 429, got %q", tr.Source)
}
if tr.HasText() {
t.Error("expected HasText() false for SourceRateLimited")
}
if tr.Content != "" {
t.Errorf("expected empty content on 429, got %q", tr.Content)
}
}
// An empty baseUrl on the selected track degrades to SourceNone, never an error. // An empty baseUrl on the selected track degrades to SourceNone, never an error.
func TestFetchTranscriptEmptyBaseURLDegrades(t *testing.T) { func TestFetchTranscriptEmptyBaseURLDegrades(t *testing.T) {
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
+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 // 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 // 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 // (e.g. consent was not forced with offline access), since without one the
+52 -27
View File
@@ -42,6 +42,11 @@ type Config struct {
// YTTokenRef is the opaque SecretStore reference under which the YouTube // YTTokenRef is the opaque SecretStore reference under which the YouTube
// refresh token is persisted/resolved. Not the token itself. // refresh token is persisted/resolved. Not the token itself.
YTTokenRef string 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 // SecretsFile is the path to the local file-backed SecretStore (0600). A
// Stage-0 stand-in for op/ESO, swappable behind the SecretStore port. // Stage-0 stand-in for op/ESO, swappable behind the SecretStore port.
@@ -54,18 +59,28 @@ type Config struct {
// PollInterval, when > 0, makes `run` loop on that cadence; 0 means run once. // PollInterval, when > 0, makes `run` loop on that cadence; 0 means run once.
PollInterval time.Duration PollInterval time.Duration
// FetchBackoff is how long the run loop waits before re-fetching a transcript
// that previously returned HTTP 429 (rate_limited). Inside the window the video
// is skipped without hitting the caption endpoint, saving requests; after it
// expires the video is retried. Zero means "always retry" (no backoff).
FetchBackoff time.Duration
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI). // HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
HTTPAddr string HTTPAddr string
// Dex OIDC (web login, ADR-011). When OIDCIssuer is empty, `serve` falls back // PublicURL is the externally-reachable base URL of the deployed service,
// to the allow-all StubAuth (local dev). When set, serve uses Dex with // e.g. "https://tapir.d-ma.be". Used to build absolute links handed to humans
// single-user allowlist authz. // (the `tapir invite` URL). No trailing slash is assumed — callers trim it.
PublicURL string
// 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 OIDCIssuer string
DexClientID string DexClientID string
DexClientSecret string DexClientSecret string
OIDCRedirectURL string OIDCRedirectURL string
SessionSecret string SessionSecret string
AllowedSubject string
} }
// DexConfigured reports whether Dex OIDC login is wired (issuer present). When // DexConfigured reports whether Dex OIDC login is wired (issuer present). When
@@ -74,12 +89,15 @@ func (c Config) DexConfigured() bool { return strings.TrimSpace(c.OIDCIssuer) !=
// Defaults (see docs/homelab-integration.md). All overridable via env. // Defaults (see docs/homelab-integration.md). All overridable via env.
const ( const (
defaultGatewayURL = "http://koala:30401/v1" defaultGatewayURL = "http://koala:30401/v1"
defaultSummarizerModel = "koala/phi4-mini" defaultSummarizerModel = "koala/phi4-mini"
defaultSummarizerTimeout = 5 * time.Minute defaultSummarizerTimeout = 5 * time.Minute
defaultYTTokenRef = "youtube/refresh_token" defaultYTTokenRef = "youtube/refresh_token"
defaultOAuthRedirectAddr = "localhost:8080" defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
defaultHTTPAddr = ":8080" defaultOAuthRedirectAddr = "localhost:8080"
defaultHTTPAddr = ":8080"
defaultFetchBackoff = time.Hour
defaultPublicURL = "https://tapir.d-ma.be"
) )
// Load reads the environment into a Config, applying defaults. It does not // Load reads the environment into a Config, applying defaults. It does not
@@ -88,23 +106,24 @@ const (
// it needs. // it needs.
func Load() (Config, error) { func Load() (Config, error) {
c := Config{ c := Config{
UserID: os.Getenv("TAPIR_USER_ID"), UserID: os.Getenv("TAPIR_USER_ID"),
GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL), GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL),
GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"), GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"),
SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel), SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel),
DBDSN: os.Getenv("TAPIR_DB_DSN"), DBDSN: os.Getenv("TAPIR_DB_DSN"),
YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"), YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"),
YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"), YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"),
YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef), YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef),
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()), YTConnectRedirectURL: envOr("TAPIR_YT_CONNECT_REDIRECT_URL", defaultYTConnectRedirectURL),
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr), SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr), OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"), HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"), PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL),
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"), OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"), DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"), DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
AllowedSubject: os.Getenv("TAPIR_ALLOWED_SUBJECT"), OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"),
SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"),
} }
timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout) timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout)
@@ -119,6 +138,12 @@ func Load() (Config, error) {
} }
c.PollInterval = interval c.PollInterval = interval
backoff, err := durationOr("TAPIR_FETCH_BACKOFF", defaultFetchBackoff)
if err != nil {
return Config{}, err
}
c.FetchBackoff = backoff
return c, nil return c, nil
} }
+7
View File
@@ -45,6 +45,9 @@ func TestLoad_AppliesDefaults(t *testing.T) {
if c.PollInterval != 0 { if c.PollInterval != 0 {
t.Errorf("PollInterval = %v, want 0 (run once)", c.PollInterval) t.Errorf("PollInterval = %v, want 0 (run once)", c.PollInterval)
} }
if c.FetchBackoff != defaultFetchBackoff {
t.Errorf("FetchBackoff = %v, want default %v", c.FetchBackoff, defaultFetchBackoff)
}
} }
func TestLoad_ParsesValues(t *testing.T) { func TestLoad_ParsesValues(t *testing.T) {
@@ -56,6 +59,7 @@ func TestLoad_ParsesValues(t *testing.T) {
"TAPIR_SUMMARIZER_TIMEOUT": "90s", "TAPIR_SUMMARIZER_TIMEOUT": "90s",
"TAPIR_DB_DSN": "postgres://x", "TAPIR_DB_DSN": "postgres://x",
"TAPIR_POLL_INTERVAL": "10m", "TAPIR_POLL_INTERVAL": "10m",
"TAPIR_FETCH_BACKOFF": "30m",
}) })
c, err := Load() c, err := Load()
@@ -77,6 +81,9 @@ func TestLoad_ParsesValues(t *testing.T) {
if c.PollInterval != 10*time.Minute { if c.PollInterval != 10*time.Minute {
t.Errorf("PollInterval = %v, want 10m", c.PollInterval) t.Errorf("PollInterval = %v, want 10m", c.PollInterval)
} }
if c.FetchBackoff != 30*time.Minute {
t.Errorf("FetchBackoff = %v, want 30m", c.FetchBackoff)
}
} }
func TestLoad_RejectsBadDuration(t *testing.T) { func TestLoad_RejectsBadDuration(t *testing.T) {
+6
View File
@@ -18,6 +18,12 @@ type TranscriptSource string
const ( const (
SourceCaptions TranscriptSource = "captions" SourceCaptions TranscriptSource = "captions"
SourceNone TranscriptSource = "none" SourceNone TranscriptSource = "none"
// SourceRateLimited records that the caption endpoint returned HTTP 429.
// Unlike SourceNone (a permanent absence), this is a transient "retry later":
// the IP is rate-limited, not the video caption-less. It carries no text
// (HasText is false), so the engine degrades the same as SourceNone, but the
// runner persists it distinctly to retry after a backoff window.
SourceRateLimited TranscriptSource = "rate_limited"
) )
// User is the Tapir-side profile. At Stage 0 there is exactly one. // User is the Tapir-side profile. At Stage 0 there is exactly one.
+119 -13
View File
@@ -23,10 +23,21 @@ import (
) )
// VideoStore is the durable persistence the run loop needs: assign a stable id + // 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 { type VideoStore interface {
UpsertVideo(ctx context.Context, v domain.Video) (string, error) UpsertVideo(ctx context.Context, v domain.Video) (string, error)
SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, 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
// RateLimitedVideoIDs maps the user's still-throttled videos to when they were
// rate-limited, so the loop can back off without re-hitting the caption endpoint.
RateLimitedVideoIDs(ctx context.Context, userID string) (map[string]time.Time, error)
// SetTranscriptStatus records the outcome of a transcript attempt: "none",
// "rate_limited" (stamps the backoff clock), or "fetched".
SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error
} }
// Processor runs the core use case for a single video. *usecase.Engine // Processor runs the core use case for a single video. *usecase.Engine
@@ -38,28 +49,51 @@ type Processor interface {
// Runner walks a user's subscriptions, persists each candidate video, skips the // Runner walks a user's subscriptions, persists each candidate video, skips the
// ones already summarized (durably), and processes the rest through the engine. // ones already summarized (durably), and processes the rest through the engine.
type Runner struct { type Runner struct {
src ports.VideoSource src ports.VideoSource
store VideoStore store VideoStore
engine Processor engine Processor
userID string userID string
log *slog.Logger log *slog.Logger
backoff time.Duration // rate-limit retry window; 0 = always retry
now func() time.Time // injectable clock (tests); defaults to time.Now
} }
// Option configures a Runner at construction. Variadic so existing call sites
// stay valid as new knobs (backoff, clock) are added.
type Option func(*Runner)
// WithBackoff sets the rate-limit retry window. A video that returned HTTP 429 is
// skipped (no caption fetch) until this much time has passed; 0 = always retry.
func WithBackoff(d time.Duration) Option { return func(r *Runner) { r.backoff = d } }
// WithClock overrides the clock used for backoff comparisons. Tests inject a
// fixed time; production leaves the time.Now default.
func WithClock(now func() time.Time) Option { return func(r *Runner) { r.now = now } }
// New builds a Runner. A nil logger falls back to slog.Default. // New builds a Runner. A nil logger falls back to slog.Default.
func New(src ports.VideoSource, store VideoStore, engine Processor, userID string, log *slog.Logger) *Runner { func New(src ports.VideoSource, store VideoStore, engine Processor, userID string, log *slog.Logger, opts ...Option) *Runner {
if log == nil { if log == nil {
log = slog.Default() log = slog.Default()
} }
return &Runner{src: src, store: store, engine: engine, userID: userID, log: log} r := &Runner{src: src, store: store, engine: engine, userID: userID, log: log, now: time.Now}
for _, opt := range opts {
opt(r)
}
if r.now == nil {
r.now = time.Now
}
return r
} }
// Stats summarizes one RunOnce pass. // Stats summarizes one RunOnce pass.
type Stats struct { type Stats struct {
Candidates int Candidates int
Summarized int Summarized int
SkippedSeen int SkippedSeen int
SkippedNoText int SkippedNoText int
Errors int SkippedManual int // discovered but not queued, in manual mode
SkippedRateLimited int // 429'd previously and still inside the backoff window
Errors int
} }
// RunOnce performs a single pass over the user's subscriptions. Per-item errors // RunOnce performs a single pass over the user's subscriptions. Per-item errors
@@ -82,6 +116,34 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
return stats, fmt.Errorf("runner: load seen videos: %w", err) 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)
}
}
// Rate-limit backoff: videos that 429'd on a prior pass, mapped to when. Inside
// the backoff window they are skipped before any caption fetch, so a throttled
// IP is not hammered. Loaded once per pass (like seen/requested). Disabled when
// backoff <= 0 ("always retry").
var rateLimited map[string]time.Time
if r.backoff > 0 {
rateLimited, err = r.store.RateLimitedVideoIDs(ctx, r.userID)
if err != nil {
return stats, fmt.Errorf("runner: load rate-limited videos: %w", err)
}
}
subs, err := r.src.ListSubscriptions(ctx, r.userID) subs, err := r.src.ListSubscriptions(ctx, r.userID)
if err != nil { if err != nil {
return stats, fmt.Errorf("runner: list subscriptions: %w", err) return stats, fmt.Errorf("runner: list subscriptions: %w", err)
@@ -112,6 +174,23 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
} }
seen[id] = true // also guard against the same video within this pass 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
}
// Still inside the rate-limit backoff window: skip without fetching, so
// we don't re-hit a caption endpoint that just 429'd us. After the window
// expires the video falls through and is retried normally.
if at, ok := rateLimited[id]; ok && r.now().Sub(at) < r.backoff {
stats.SkippedRateLimited++
r.log.Info("skipped video (rate-limited, backing off)", "video", v.ProviderVideoID, "title", v.Title)
continue
}
if fetchDelay > 0 { if fetchDelay > 0 {
time.Sleep(fetchDelay) time.Sleep(fetchDelay)
} }
@@ -122,11 +201,37 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
continue continue
} }
switch { switch {
case res.Skipped && res.TranscriptSource == string(domain.SourceRateLimited):
// Fresh 429 this pass: persist rate_limited (stamps the backoff clock)
// so the next pass skips it until the window expires.
stats.SkippedRateLimited++
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "rate_limited"); err != nil {
errs = append(errs, fmt.Errorf("set rate_limited status %q: %w", v.ProviderVideoID, err))
stats.Errors++
}
r.log.Info("skipped video (rate-limited)", "video", v.ProviderVideoID, "title", v.Title)
case res.Skipped: case res.Skipped:
stats.SkippedNoText++ stats.SkippedNoText++
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "none"); err != nil {
errs = append(errs, fmt.Errorf("set none status %q: %w", v.ProviderVideoID, err))
stats.Errors++
}
r.log.Info("skipped video (no transcript)", "video", v.ProviderVideoID, "title", v.Title) r.log.Info("skipped video (no transcript)", "video", v.ProviderVideoID, "title", v.Title)
case res.Summary != nil: case res.Summary != nil:
stats.Summarized++ stats.Summarized++
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "fetched"); err != nil {
errs = append(errs, fmt.Errorf("set fetched status %q: %w", v.ProviderVideoID, err))
stats.Errors++
}
// 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, r.log.Info("summarized video", "video", v.ProviderVideoID, "title", v.Title,
"provider", res.Summary.AIProvider, "model", res.Summary.AIModel) "provider", res.Summary.AIProvider, "model", res.Summary.AIModel)
} }
@@ -145,6 +250,7 @@ func (r *Runner) Loop(ctx context.Context, interval time.Duration) error {
r.log.Info("run pass complete", r.log.Info("run pass complete",
"candidates", stats.Candidates, "summarized", stats.Summarized, "candidates", stats.Candidates, "summarized", stats.Summarized,
"skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText, "skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
"skipped_manual", stats.SkippedManual, "skipped_rate_limited", stats.SkippedRateLimited,
"errors", stats.Errors) "errors", stats.Errors)
if err != nil { if err != nil {
r.log.Warn("run pass had errors", "err", err) r.log.Warn("run pass had errors", "err", err)
+143 -6
View File
@@ -5,6 +5,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -40,9 +41,16 @@ func (f *fakeSource) FetchTranscript(_ context.Context, v domain.Video) (domain.
// fakeStore assigns deterministic ids ("id-"+provider video id) so a pre-seeded // fakeStore assigns deterministic ids ("id-"+provider video id) so a pre-seeded
// seen set lines up with UpsertVideo output, modelling cross-restart dedup. // 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 { type fakeStore struct {
seen map[string]bool seen map[string]bool
upserted []domain.Video upserted []domain.Video
auto bool
requested map[string]bool
cleared []string
rateLimited map[string]time.Time // id -> when 429'd (seeds the backoff window)
statuses map[string]string // id -> last SetTranscriptStatus value
} }
func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) { func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) {
@@ -58,6 +66,39 @@ func (f *fakeStore) SeenVideoIDs(_ context.Context, _ string) (map[string]bool,
return cp, nil 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
}
func (f *fakeStore) RateLimitedVideoIDs(_ context.Context, _ string) (map[string]time.Time, error) {
cp := make(map[string]time.Time, len(f.rateLimited))
for k, v := range f.rateLimited {
cp[k] = v
}
return cp, nil
}
func (f *fakeStore) SetTranscriptStatus(_ context.Context, _, videoID, status string) error {
if f.statuses == nil {
f.statuses = map[string]string{}
}
f.statuses[videoID] = status
return nil
}
type fakeSummarizer struct{} type fakeSummarizer struct{}
func (fakeSummarizer) Summarize(_ context.Context, v domain.Video, _ domain.Transcript) (domain.Summary, error) { func (fakeSummarizer) Summarize(_ context.Context, v domain.Video, _ domain.Transcript) (domain.Summary, error) {
@@ -91,7 +132,7 @@ func TestRunOnce_SummarizesNewVideos(t *testing.T) {
subs: []domain.Subscription{sub("chan1", "Channel One")}, subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}}, 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{} sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink) eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger()) r := runner.New(src, st, eng, testUser, quietLogger())
@@ -114,7 +155,7 @@ func TestRunOnce_SkipsAlreadySummarized(t *testing.T) {
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}}, videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
} }
// v1 was summarized in a prior run (durable seen set). // 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{} sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink) eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger()) r := runner.New(src, st, eng, testUser, quietLogger())
@@ -133,7 +174,7 @@ func TestRunOnce_SkipsVideosWithoutTranscript(t *testing.T) {
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}}, videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
transcripts: map[string]domain.Transcript{"v1": {Source: domain.SourceNone}}, 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{} sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink) eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger()) r := runner.New(src, st, eng, testUser, quietLogger())
@@ -145,13 +186,109 @@ func TestRunOnce_SkipsVideosWithoutTranscript(t *testing.T) {
require.Empty(t, sink.delivered, "no summary delivered when there is no transcript") 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")
}
// noFetchSource fails the test if a transcript fetch happens — used to prove the
// runner skips a rate-limited video before touching the caption endpoint.
type noFetchSource struct{ *fakeSource }
func (noFetchSource) FetchTranscript(context.Context, domain.Video) (domain.Transcript, error) {
panic("FetchTranscript must not be called for a rate-limited video within the backoff window")
}
func TestRunOnce_SkipsRateLimitedWithinBackoff(t *testing.T) {
base := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
}
// v1 was rate-limited 5m ago; backoff is 1h, so it is still inside the window.
st := &fakeStore{
seen: map[string]bool{},
auto: true,
rateLimited: map[string]time.Time{"id-v1": base.Add(-5 * time.Minute)},
}
eng := usecase.NewEngine(noFetchSource{src}, fakeSummarizer{}, &recordingSink{})
r := runner.New(noFetchSource{src}, st, eng, testUser, quietLogger(),
runner.WithBackoff(time.Hour), runner.WithClock(func() time.Time { return base }))
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 1, stats.SkippedRateLimited, "still throttled -> skipped")
require.Equal(t, 0, stats.Summarized)
require.Empty(t, st.statuses, "no status write: the engine was never invoked")
}
func TestRunOnce_RetriesRateLimitedAfterBackoff(t *testing.T) {
base := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
}
// v1 was rate-limited 2h ago; backoff is 1h, so the window has expired.
st := &fakeStore{
seen: map[string]bool{},
auto: true,
rateLimited: map[string]time.Time{"id-v1": base.Add(-2 * time.Hour)},
}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger(),
runner.WithBackoff(time.Hour), runner.WithClock(func() time.Time { return base }))
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 0, stats.SkippedRateLimited, "window expired -> not skipped")
require.Equal(t, 1, stats.Summarized, "the video is retried and summarized")
require.Len(t, sink.delivered, 1)
require.Equal(t, "fetched", st.statuses["id-v1"], "status advances to fetched on success")
}
func TestRunOnce_UpsertsEveryCandidate(t *testing.T) { func TestRunOnce_UpsertsEveryCandidate(t *testing.T) {
src := &fakeSource{ src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")}, subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}}, 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. // 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{}) eng := usecase.NewEngine(src, fakeSummarizer{}, &recordingSink{})
r := runner.New(src, st, eng, testUser, quietLogger()) r := runner.New(src, st, eng, testUser, quietLogger())
+11 -4
View File
@@ -44,8 +44,13 @@ func NewEngine(src ports.VideoSource, ai ports.Summarizer, sinks ...ports.Sink)
type ProcessResult struct { type ProcessResult struct {
Video domain.Video Video domain.Video
Skipped bool Skipped bool
Reason string // set when Skipped (e.g. "no transcript") Reason string // set when Skipped (e.g. "no transcript")
Summary *domain.Summary // nil when Skipped // TranscriptSource is how the transcript resolved (or that there was none):
// the domain.TranscriptSource value as a string. The runner reads it to tell a
// permanent absence (SourceNone) from a transient 429 (SourceRateLimited) and
// persist the right transcript_status. Empty when a fetch error short-circuits.
TranscriptSource string
Summary *domain.Summary // nil when Skipped
} }
// ProcessNewVideo runs the core use case for a single video: // ProcessNewVideo runs the core use case for a single video:
@@ -59,7 +64,9 @@ func (e *Engine) ProcessNewVideo(ctx context.Context, v domain.Video) (ProcessRe
if !t.HasText() { if !t.HasText() {
// No usable transcript: record the skip, produce no summary, deliver nothing // No usable transcript: record the skip, produce no summary, deliver nothing
// (captions-first, ADR-007; the watcher uses this to avoid reprocessing). // (captions-first, ADR-007; the watcher uses this to avoid reprocessing).
return ProcessResult{Video: v, Skipped: true, Reason: "no transcript"}, nil // Surface the source so the runner separates SourceNone (permanent) from
// SourceRateLimited (retry after a backoff window).
return ProcessResult{Video: v, Skipped: true, Reason: "no transcript", TranscriptSource: string(t.Source)}, nil
} }
sum, err := e.AI.Summarize(ctx, v, t) sum, err := e.AI.Summarize(ctx, v, t)
@@ -76,7 +83,7 @@ func (e *Engine) ProcessNewVideo(ctx context.Context, v domain.Video) (ProcessRe
} }
} }
return ProcessResult{Video: v, Summary: &sum}, errors.Join(errs...) return ProcessResult{Video: v, Summary: &sum, TranscriptSource: string(t.Source)}, errors.Join(errs...)
} }
// ProcessNewVideos walks a user's subscriptions and processes each newly seen // ProcessNewVideos walks a user's subscriptions and processes each newly seen
+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)
}
}
+7 -5
View File
@@ -1,6 +1,7 @@
// Package web is the Stage-0 HTTP read/write surface (ADR-011, docs/ui-spec.md). // Package web is the multi-user HTTP read/write surface (ADR-012, docs/ui-spec.md).
// It serves the summary reader over the existing store; the engine and ports are // It serves the summary reader over the existing store; the engine and ports are
// untouched (ADR-003). // untouched (ADR-003). ADR-011 shipped this as a single-user Stage-0 reader; ADR-012
// opened Stage 1 — multiple Dex-authenticated users with DB-enforced (RLS) isolation.
// //
// This file defines the auth SEAM so the Dex session layer (internal/web/oidc) // This file defines the auth SEAM so the Dex session layer (internal/web/oidc)
// and the page/handler layer can be built independently: handlers depend only on // and the page/handler layer can be built independently: handlers depend only on
@@ -10,9 +11,10 @@ package web
import "net/http" import "net/http"
// User is the authenticated principal. Subject is the Dex subject used for the // User is the authenticated principal. Subject is the Dex subject — the key for the
// single-user allowlist (ADR-011); store operations key off the configured // user_identities lookup (ADR-012) that resolves to a tapir user_id (UUID); store
// tapir user_id (UUID), not this subject. // operations scope every row by that id, not by this subject. A subject with no
// users row is routed through the registration gate (see registration.go).
type User struct { type User struct {
Subject string Subject string
Email string Email string
+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")
}
+14
View File
@@ -0,0 +1,14 @@
package web
import "net/http"
// Test-only handles to the unexported invite handlers so the external web_test
// package can mount them on an httptest mux (and get PathValue routing) without
// standing up the full Router + auth stack. export_test.go compiles only under
// `go test`, so these never widen the package's real API.
func (a *App) HandleInviteFormForTest(w http.ResponseWriter, r *http.Request) {
a.handleInviteForm(w, r)
}
func (a *App) HandleInviteSubmitForTest(w http.ResponseWriter, r *http.Request) {
a.handleInviteSubmit(w, r)
}
+55
View File
@@ -0,0 +1,55 @@
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"
flashAccountCreated = "account_created"
)
// 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)
}
}
}
+250 -19
View File
@@ -16,22 +16,66 @@ import (
// the concrete *store.Store). *store.Store satisfies it; tests can substitute a // the concrete *store.Store). *store.Store satisfies it; tests can substitute a
// fake without a database. // fake without a database.
type Store interface { 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) 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) ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
SetAction(ctx context.Context, userID, videoID, action string) error SetAction(ctx context.Context, userID, videoID, action string) error
ClearAction(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 // SecretRemover deletes secret material by its opaque ref. *secrets.FileStore
// implementation. UserID is the single configured tapir user every store // satisfies it; account tests use a fake. The account handlers depend only on
// operation runs as (ADR-011 — Auth only gates access; it does not select the // this narrow capability (not the read-side ports.SecretStore), mirroring how the
// store identity). // 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 { type App struct {
Store Store Store Store
Auth Auth Identity Identity
UserID string Auth Auth
Log *slog.Logger 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
// Processor, when non-nil, summarizes a queued video immediately in a
// background goroutine (the "Summarize" button kicks it off). Nil = queue-only:
// the button flips the DB flag and the next `tapir run` does the work.
Processor Processor
// Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet
// Invitations validates and consumes email-invite tokens for the public
// /invite/{token} flow. Nil = the invite routes report "invalid" (the flow is
// effectively off). *store.Store satisfies it.
Invitations InvitationStore
// Dex creates the Dex local-password account when an invite is claimed. Nil =
// not in-cluster (dev): the submit handler degrades to a clear "deployed-only"
// message instead of creating an account. *dex.PasswordClient satisfies it.
Dex DexPasswordCreator
} }
func (a *App) logger() *slog.Logger { func (a *App) logger() *slog.Logger {
@@ -47,18 +91,55 @@ func (a *App) logger() *slog.Logger {
func (a *App) Router() http.Handler { func (a *App) Router() http.Handler {
root := http.NewServeMux() root := http.NewServeMux()
root.HandleFunc("GET /healthz", a.handleHealthz) root.HandleFunc("GET /healthz", a.handleHealthz)
root.HandleFunc("GET /welcome", a.handleWelcome)
root.Handle("GET /static/", staticHandler()) root.Handle("GET /static/", staticHandler())
root.Handle("/auth/", a.Auth.Routes()) root.Handle("/auth/", a.Auth.Routes())
// Email invitation claim (public — the visitor has no Dex session yet, so this
// sits OUTSIDE Auth.Middleware). The token in the path is the capability.
root.HandleFunc("GET /invite/{token}", a.handleInviteForm)
root.HandleFunc("POST /invite/{token}", a.handleInviteSubmit)
app := http.NewServeMux() app := http.NewServeMux()
app.HandleFunc("GET /{$}", a.handleList) app.HandleFunc("GET /{$}", a.handleList)
app.HandleFunc("GET /v/{videoId}", a.handleDetail) app.HandleFunc("GET /v/{videoId}", a.handleDetail)
app.HandleFunc("POST /v/{videoId}/action", a.handleAction) app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
app.HandleFunc("GET /v/{videoId}/status", a.handleStatus)
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 return root
} }
// handleWelcome renders the public landing page (/welcome). It is mounted outside
// Auth.Middleware, so it must not assume a session: CurrentUser peeks the cookie
// without redirecting and the page renders the logged-out or logged-in variant
// accordingly.
func (a *App) handleWelcome(w http.ResponseWriter, r *http.Request) {
user, ok := a.Auth.CurrentUser(r)
a.render(w, r, WelcomePage(user, ok))
}
// handleHealthz is the unauthenticated liveness/readiness probe. // handleHealthz is the unauthenticated liveness/readiness probe.
func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) { func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
@@ -69,6 +150,10 @@ func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
// query string. An HTMX request gets only the table fragment so the filter form // 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. // can swap #summary-list in place; a plain request gets the full page.
func (a *App) handleList(w http.ResponseWriter, r *http.Request) { func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
q := r.URL.Query() q := r.URL.Query()
f := Filter{ f := Filter{
Channel: q.Get("channel"), Channel: q.Get("channel"),
@@ -76,24 +161,41 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
To: q.Get("to"), 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 { if err != nil {
a.serverError(w, r, "list summaries", err) a.serverError(w, r, "list videos", err)
return return
} }
rows = f.apply(rows) rows = f.apply(rows)
// hasConnected drives the empty state: a fresh account with a connection but
// no `tapir run` yet has zero rows, and we want it to read "connected, run
// tapir" rather than "nothing here". Only needed when the list is empty.
hasConnected := false
if len(rows) == 0 {
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
if err != nil {
a.serverError(w, r, "connections for user", err)
return
}
hasConnected = len(conns) > 0
}
if isHTMX(r) { if isHTMX(r) {
a.render(w, r, summaryList(rows)) a.render(w, r, summaryList(rows, hasConnected))
return return
} }
a.render(w, r, ListPage(rows, f)) a.render(w, r, ListPage(rows, f, takeFlash(w, r), hasConnected))
} }
// handleDetail renders one summary in full (highlights, takeaways, action group). // handleDetail renders one summary in full (highlights, takeaways, action group).
func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) { func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
videoID := r.PathValue("videoId") 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) { if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -110,6 +212,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 refreshed button-group fragment for HTMX; without JS it redirects back to
// the detail page (POST→redirect→GET). // the detail page (POST→redirect→GET).
func (a *App) handleAction(w http.ResponseWriter, r *http.Request) { func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
videoID := r.PathValue("videoId") videoID := r.PathValue("videoId")
action := r.FormValue("action") action := r.FormValue("action")
if !isActionVerb(action) { if !isActionVerb(action) {
@@ -117,23 +223,23 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
a.serverError(w, r, "read actions", err) a.serverError(w, r, "read actions", err)
return return
} }
if actionSet(current[videoID])[action] { 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 { } else {
err = a.Store.SetAction(r.Context(), a.UserID, videoID, action) err = a.Store.SetAction(r.Context(), userID, videoID, action)
} }
if err != nil { if err != nil {
a.serverError(w, r, "toggle action", err) a.serverError(w, r, "toggle action", err)
return 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 { if err != nil {
a.serverError(w, r, "read actions", err) a.serverError(w, r, "read actions", err)
return return
@@ -146,10 +252,135 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther) http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
} }
// handleRequestSummarize handles the "Summarize" button. It always flips the DB
// flag (summarize_requested) so the work is durable. With a Processor wired it
// then summarizes immediately in the background and answers with the animated
// processing card that polls /status until done; without one (queue-only) it
// answers with the "Queued" card — the next `tapir run` does the work. Without
// JS it redirects back to the list (POST→redirect→GET).
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
}
if a.Processor != nil {
a.startProcessing(userID, videoID)
a.render(w, r, processingCard(*row))
return
}
a.render(w, r, VideoCard(*row))
}
// startProcessing marks a video in-flight and summarizes it in the background.
// The goroutine uses a detached context — not the request's, which is cancelled
// when the handler returns — and clears the in-flight mark on completion. On
// error the DB flag stays set, so the video remains queued for the next
// `tapir run`; a successful Processor.ProcessVideo clears it itself.
func (a *App) startProcessing(userID, videoID string) {
key := processingKey(userID, videoID)
a.Processing.Add(key)
go func() {
defer a.Processing.Remove(key)
if err := a.Processor.ProcessVideo(context.Background(), userID, videoID); err != nil {
a.logger().Error("background summarize", "user", userID, "video", videoID, "err", err)
}
}()
}
// handleStatus is the HTMX poll target for an in-flight summarization. It returns
// the card in its current state: the full summary card once the summary exists,
// otherwise the animated processing card while still in-flight (which keeps
// polling), or the queued/button card when neither holds. VideoCard carries no
// polling attributes, so HTMX stops polling once it swaps in.
func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
videoID := r.PathValue("videoId")
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
a.serverError(w, r, "get video", err)
return
}
if row.Summarized || !a.Processing.Has(processingKey(userID, videoID)) {
a.render(w, r, VideoCard(*row))
return
}
a.render(w, r, processingCard(*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: // 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. // 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) { 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.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := c.Render(r.Context(), w); err != nil { if err := c.Render(r.Context(), w); err != nil {
a.logger().Error("render", "path", r.URL.Path, "err", err) 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" userID = "11111111-1111-1111-1111-111111111111"
videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
videoY = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" 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 { func newStore(t *testing.T) *store.Store {
@@ -63,21 +66,48 @@ func rawPool(t *testing.T) *pgxpool.Pool {
return p 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() t.Helper()
_, err := p.Exec(context.Background(), _, err := p.Exec(context.Background(),
`TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`) `TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
require.NoError(t, err) require.NoError(t, err)
} }
// newApp builds the App under test: the real store, StubAuth (allow-all) keyed to // resetDB truncates, then seeds the StubAuth identity (stubSubject → userID) so
// the configured user. This is exactly cmd/tapir's serve wiring minus Dex. // 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 { func newApp(t *testing.T) *web.App {
t.Helper() 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{ return &web.App{
Store: newStore(t), Store: s,
Auth: web.StubAuth{U: web.User{Subject: userID}}, Identity: s,
UserID: userID, Auth: web.StubAuth{U: web.User{Subject: subject}},
} }
} }
@@ -155,8 +185,10 @@ func TestListRendersRowsAndActionState(t *testing.T) {
func TestListHTMXReturnsFragment(t *testing.T) { func TestListHTMXReturnsFragment(t *testing.T) {
ctx := context.Background() ctx := context.Background()
app := newApp(t) app := newApp(t)
resetDB(t, rawPool(t)) p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "body x")) 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 := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("HX-Request", "true") req.Header.Set("HX-Request", "true")
@@ -260,6 +292,103 @@ func TestActionRejectsUnknownVerb(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rec.Code) 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. // deliver stores a summary through the App's store under test.
func deliver(ctx context.Context, app *web.App, videoID, text string) error { func deliver(ctx context.Context, app *web.App, videoID, text string) error {
return app.Store.(*store.Store).Deliver(ctx, summary(videoID, text)) return app.Store.(*store.Store).Deliver(ctx, summary(videoID, text))
+164
View File
@@ -0,0 +1,164 @@
package web
import (
"context"
"crypto/rand"
"errors"
"fmt"
"net/http"
"golang.org/x/crypto/bcrypt"
"gitea.d-ma.be/mathias/tapir/internal/adapters/dex"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
// InvitationStore is the narrow store surface the public invite flow needs:
// PeekInvitation validates a token without consuming it (the GET form preview);
// ClaimInvitation consumes it atomically (the POST). *store.Store satisfies it.
// Deliberately separate from Store (the user-scoped surface) — invites run with no
// authenticated user (the user does not exist yet).
type InvitationStore interface {
PeekInvitation(ctx context.Context, token string) (email string, err error)
ClaimInvitation(ctx context.Context, token string) (email string, err error)
}
// DexPasswordCreator creates a Dex local-password account from a bcrypt hash.
// *dex.PasswordClient satisfies it; tests substitute a fake. A nil App.Dex means
// the process is not in-cluster (dev) and account creation is unavailable.
type DexPasswordCreator interface {
CreatePassword(ctx context.Context, email, bcryptHash, userID string) error
}
// bcryptCost is the work factor for hashing invite passwords. 12 is a sensible
// 2020s default — noticeably slow to brute-force, fast enough for a single login.
const bcryptCost = 12
// minPasswordLen is the floor for an invite password. Length beats composition
// rules; 8 is the practical minimum we accept.
const minPasswordLen = 8
// handleInviteForm renders the set-password form for a valid invite token, or a
// clear "expired / already used" page otherwise. It only previews the token
// (PeekInvitation) — the token is consumed on submit, not on view, so a refresh
// or a link-preview fetch never burns the invite.
func (a *App) handleInviteForm(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
if a.Invitations == nil {
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
return
}
email, err := a.Invitations.PeekInvitation(r.Context(), token)
if errors.Is(err, store.ErrNotFound) {
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
return
}
if err != nil {
a.serverError(w, r, "peek invitation", err)
return
}
a.render(w, r, InvitePage(email, token, ""))
}
// handleInviteSubmit validates the chosen password, consumes the invite, and
// creates the Dex local-password account. Order matters (see inline): password is
// validated first (no token burned on a typo), then the invite is claimed exactly
// once, then the Dex account is created. On success the visitor is sent to the Dex
// login to sign in with the email + new password.
func (a *App) handleInviteSubmit(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
if a.Invitations == nil {
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
password := r.FormValue("password")
confirm := r.FormValue("password_confirm")
// 1. Validate before consuming the token, so a mismatch/typo is retryable.
if len(password) < minPasswordLen {
a.reshowInvite(w, r, token, "Password must be at least 8 characters.")
return
}
if password != confirm {
a.reshowInvite(w, r, token, "Passwords do not match.")
return
}
// Off-cluster (dev): we cannot create a Dex account. Degrade clearly WITHOUT
// consuming the invite, so it still works once deployed.
if a.Dex == nil {
a.render(w, r, InviteNoticePage("Account creation only works in the deployed environment.", false))
return
}
// 2. Consume the invite exactly once. If the token vanished between GET and
// POST (expired, replay, concurrent claim) this is where it surfaces.
email, err := a.Invitations.ClaimInvitation(r.Context(), token)
if errors.Is(err, store.ErrNotFound) {
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
return
}
if err != nil {
a.serverError(w, r, "claim invitation", err)
return
}
// 3. Hash the password (cost 12). The Dex client base64-encodes it for the CR.
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
a.serverError(w, r, "hash password", err)
return
}
// 4. Create the Dex local-password account.
userID, err := newID()
if err != nil {
a.serverError(w, r, "new user id", err)
return
}
switch err := a.Dex.CreatePassword(r.Context(), email, string(hash), userID); {
case err == nil:
// 5. Off to the Dex login — a flash surfaces on the first page after login.
setFlash(w, flashAccountCreated)
http.Redirect(w, r, loginPath, http.StatusSeeOther)
case errors.Is(err, dex.ErrPasswordExists):
a.render(w, r, InviteNoticePage("An account with this email already exists. Try logging in.", true))
case errors.Is(err, dex.ErrForbidden):
a.render(w, r, InviteNoticePage("Unable to create your Dex account — please contact the administrator.", false))
default:
a.serverError(w, r, "create dex password", err)
}
}
// reshowInvite re-renders the password form with a validation message, re-fetching
// the email from the (still-unconsumed) token. A token that became invalid in the
// meantime falls back to the expired/used page.
func (a *App) reshowInvite(w http.ResponseWriter, r *http.Request, token, errMsg string) {
email, err := a.Invitations.PeekInvitation(r.Context(), token)
if errors.Is(err, store.ErrNotFound) {
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
return
}
if err != nil {
a.serverError(w, r, "peek invitation", err)
return
}
a.renderStatus(w, r, http.StatusBadRequest, InvitePage(email, token, errMsg))
}
// newID returns a fresh random RFC-4122 v4 UUID for the Dex userID field
// (crypto/rand, no new dependency). Kept local rather than coupling web to the
// store package's unexported generator.
func newID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("web: new id: %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
}
+185
View File
@@ -0,0 +1,185 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"gitea.d-ma.be/mathias/tapir/internal/adapters/dex"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/web"
)
// fakeDex captures the CreatePassword call and returns a canned error.
type fakeDex struct {
called bool
email, hash, userID string
err error
}
func (f *fakeDex) CreatePassword(_ context.Context, email, hash, userID string) error {
f.called = true
f.email, f.hash, f.userID = email, hash, userID
return f.err
}
func resetInvites(t *testing.T, p *pgxpool.Pool) {
t.Helper()
_, err := p.Exec(context.Background(), `TRUNCATE invitations`)
require.NoError(t, err)
}
// inviteMux mounts only the two public invite routes against app, so PathValue
// ("token") is populated exactly as in production without the full Router/auth.
func inviteMux(app *web.App) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /invite/{token}", app.HandleInviteFormForTest)
mux.HandleFunc("POST /invite/{token}", app.HandleInviteSubmitForTest)
return mux
}
func newInvite(t *testing.T, st *store.Store, email string, ttl time.Duration) string {
t.Helper()
token, err := st.CreateInvitation(context.Background(), email, ttl)
require.NoError(t, err)
return token
}
func TestInviteFormValidToken(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "invitee@example.com", time.Hour)
app := &web.App{Invitations: st, Dex: &fakeDex{}}
rr := httptest.NewRecorder()
inviteMux(app).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/invite/"+token, nil))
require.Equal(t, http.StatusOK, rr.Code)
body := rr.Body.String()
require.Contains(t, body, "invitee@example.com")
require.Contains(t, body, "Create my account")
}
func TestInviteFormInvalidToken(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
app := &web.App{Invitations: st, Dex: &fakeDex{}}
rr := httptest.NewRecorder()
inviteMux(app).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/invite/nope", nil))
require.Equal(t, http.StatusOK, rr.Code)
require.Contains(t, rr.Body.String(), "no longer valid")
}
func postInvite(app *web.App, token string, form url.Values) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/invite/"+token, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
inviteMux(app).ServeHTTP(rr, req)
return rr
}
func TestInviteSubmitPasswordMismatch(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "a@example.com", time.Hour)
fd := &fakeDex{}
app := &web.App{Invitations: st, Dex: fd}
rr := postInvite(app, token, url.Values{"password": {"longenough1"}, "password_confirm": {"different1"}})
require.Equal(t, http.StatusBadRequest, rr.Code)
require.Contains(t, rr.Body.String(), "do not match")
require.False(t, fd.called)
// Token not consumed — still claimable.
_, err := st.PeekInvitation(context.Background(), token)
require.NoError(t, err)
}
func TestInviteSubmitShortPassword(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "a@example.com", time.Hour)
fd := &fakeDex{}
app := &web.App{Invitations: st, Dex: fd}
rr := postInvite(app, token, url.Values{"password": {"short"}, "password_confirm": {"short"}})
require.Equal(t, http.StatusBadRequest, rr.Code)
require.Contains(t, rr.Body.String(), "at least 8")
require.False(t, fd.called)
}
func TestInviteSubmitValidCreatesAccount(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "new@example.com", time.Hour)
fd := &fakeDex{}
app := &web.App{Invitations: st, Dex: fd}
rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}})
require.Equal(t, http.StatusSeeOther, rr.Code)
require.Equal(t, "/auth/login", rr.Header().Get("Location"))
require.True(t, fd.called)
require.Equal(t, "new@example.com", fd.email)
require.NotEmpty(t, fd.userID)
// The handler hands Dex a real bcrypt hash of the chosen password.
require.NoError(t, bcrypt.CompareHashAndPassword([]byte(fd.hash), []byte("correcthorse")))
// Flash queued for the post-login page.
require.Contains(t, rr.Header().Get("Set-Cookie"), "tapir_flash=account_created")
// Token consumed — a second claim fails.
_, err := st.ClaimInvitation(context.Background(), token)
require.ErrorIs(t, err, store.ErrNotFound)
}
func TestInviteSubmitDevModeNoDex(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "dev@example.com", time.Hour)
app := &web.App{Invitations: st, Dex: nil} // not in-cluster
rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}})
require.Equal(t, http.StatusOK, rr.Code)
require.Contains(t, rr.Body.String(), "deployed environment")
// Token preserved so it still works once deployed.
_, err := st.PeekInvitation(context.Background(), token)
require.NoError(t, err)
}
func TestInviteSubmitPasswordExists(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "dup@example.com", time.Hour)
app := &web.App{Invitations: st, Dex: &fakeDex{err: dex.ErrPasswordExists}}
rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}})
require.Equal(t, http.StatusOK, rr.Code)
require.Contains(t, rr.Body.String(), "already exists")
}
func TestInviteSubmitForbidden(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "x@example.com", time.Hour)
app := &web.App{Invitations: st, Dex: &fakeDex{err: dex.ErrForbidden}}
rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}})
require.Equal(t, http.StatusOK, rr.Code)
require.Contains(t, rr.Body.String(), "administrator")
}
+35 -28
View File
@@ -4,11 +4,13 @@
// interface, so swapping the stub for Dex is a wiring choice in cmd/tapir, not // interface, so swapping the stub for Dex is a wiring choice in cmd/tapir, not
// a code change (ADR-003). // a code change (ADR-003).
// //
// Authentication is real (Dex OIDC); authorization is single-user — the ID // Authentication is real (Dex OIDC) and is the only gate: any Dex-authenticated
// token's subject must equal Config.AllowedSubject or the request is refused // subject may sign in (ADR-012 dropped ADR-011's single-subject allowlist).
// with 403. Sessions are server-side (in-memory, fine for the single Stage-0 // Authorization/registration is layered on top in internal/web (an authenticated
// replica) addressed by an HMAC-signed (HS256) HttpOnly Secure SameSite=Lax // subject with no tapir user is routed to registration). Sessions are server-side
// cookie with a short TTL and sliding refresh. Tokens are never logged. // (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 // 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. // 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 // Config is the OIDC + session configuration. cmd/tapir maps these from
// TAPIR_OIDC_*/TAPIR_DEX_*/TAPIR_SESSION_SECRET/TAPIR_ALLOWED_SUBJECT; this // TAPIR_OIDC_*/TAPIR_DEX_*/TAPIR_SESSION_SECRET; this package takes the resolved
// package takes the resolved struct. // struct.
type Config struct { type Config struct {
// Issuer is the Dex issuer URL, e.g. https://auth.d-ma.be. Discovery // Issuer is the Dex issuer URL, e.g. https://auth.d-ma.be. Discovery
// (.well-known/openid-configuration) runs against it in New. // (.well-known/openid-configuration) runs against it in New.
@@ -42,9 +44,6 @@ type Config struct {
RedirectURL string RedirectURL string
// SessionSecret keys the HS256 session-cookie signature. Never logged. // SessionSecret keys the HS256 session-cookie signature. Never logged.
SessionSecret string 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 ( const (
@@ -102,12 +101,11 @@ func WithInsecureCookies() Option {
// discovery request only. // discovery request only.
func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) { func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
for name, val := range map[string]string{ for name, val := range map[string]string{
"issuer": cfg.Issuer, "issuer": cfg.Issuer,
"client id": cfg.ClientID, "client id": cfg.ClientID,
"client secret": cfg.ClientSecret, "client secret": cfg.ClientSecret,
"redirect url": cfg.RedirectURL, "redirect url": cfg.RedirectURL,
"session secret": cfg.SessionSecret, "session secret": cfg.SessionSecret,
"allowed subject": cfg.AllowedSubject,
} { } {
if strings.TrimSpace(val) == "" { if strings.TrimSpace(val) == "" {
return nil, fmt.Errorf("oidc: missing %s", name) return nil, fmt.Errorf("oidc: missing %s", name)
@@ -163,11 +161,11 @@ func (d *DexAuth) Middleware(h http.Handler) http.Handler {
} }
sid, ok := d.sessionID(r) sid, ok := d.sessionID(r)
if !ok { if !ok {
d.redirectToLogin(w, r) d.redirectUnauthenticated(w, r)
return return
} }
if _, ok := d.sessions.get(sid, d.now()); !ok { if _, ok := d.sessions.get(sid, d.now()); !ok {
d.redirectToLogin(w, r) d.redirectUnauthenticated(w, r)
return return
} }
d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh
@@ -242,14 +240,9 @@ func (d *DexAuth) handleCallback(w http.ResponseWriter, r *http.Request) {
return return
} }
// Single-user authz: only the allowlisted subject may sign in. On mismatch // Authentication is the only gate (ADR-012): any Dex-authenticated subject may
// we echo the caller's own subject (an opaque id, not a secret) so the // establish a session. Whether that subject has a tapir user — and routing to
// maintainer can bootstrap TAPIR_ALLOWED_SUBJECT on first login. // registration if not — is decided downstream in internal/web, not here.
if idToken.Subject != d.cfg.AllowedSubject {
http.Error(w, "forbidden — not the allowlisted subject. your subject is: "+idToken.Subject, http.StatusForbidden)
return
}
var claims struct { var claims struct {
Email string `json:"email"` Email string `json:"email"`
} }
@@ -273,7 +266,21 @@ func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
d.sessions.delete(sid) d.sessions.delete(sid)
} }
d.clearSessionCookie(w) d.clearSessionCookie(w)
http.Redirect(w, r, loginPath, http.StatusFound) // Land on the public landing page, not the login endpoint: a just-logged-out
// visitor should see /welcome, not be bounced straight back into a Dex login.
http.Redirect(w, r, "/welcome", http.StatusFound)
}
// redirectUnauthenticated sends an unauthenticated visitor somewhere useful: the
// bare root goes to the public landing page (/welcome), any deeper guarded path
// goes to login so the post-login round-trip can return them to it. isPublicPath
// has already let /welcome and /auth/* through, so this never loops.
func (d *DexAuth) redirectUnauthenticated(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/welcome", http.StatusFound)
return
}
d.redirectToLogin(w, r)
} }
func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) { func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) {
@@ -312,5 +319,5 @@ func (d *DexAuth) clearSessionCookie(w http.ResponseWriter) {
} }
func isPublicPath(p string) bool { func isPublicPath(p string) bool {
return p == "/healthz" || strings.HasPrefix(p, "/auth/") return p == "/healthz" || p == "/welcome" || strings.HasPrefix(p, "/auth/")
} }
+37 -23
View File
@@ -20,7 +20,7 @@ import (
const ( const (
testClientID = "tapir-web" testClientID = "tapir-web"
allowedSub = "allowed-subject-123" testSubject = "dex-subject-123"
) )
// fakeIssuer is an httptest-backed OIDC provider: it serves a discovery // fakeIssuer is an httptest-backed OIDC provider: it serves a discovery
@@ -115,12 +115,11 @@ func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
func newAuth(t *testing.T, f *fakeIssuer) *oidc.DexAuth { func newAuth(t *testing.T, f *fakeIssuer) *oidc.DexAuth {
t.Helper() t.Helper()
auth, err := oidc.New(context.Background(), oidc.Config{ auth, err := oidc.New(context.Background(), oidc.Config{
Issuer: f.server.URL, Issuer: f.server.URL,
ClientID: testClientID, ClientID: testClientID,
ClientSecret: "test-client-secret", ClientSecret: "test-client-secret",
RedirectURL: "http://tapir.test/auth/callback", RedirectURL: "http://tapir.test/auth/callback",
SessionSecret: "test-session-secret-please-change", SessionSecret: "test-session-secret-please-change",
AllowedSubject: allowedSub,
}, oidc.WithInsecureCookies()) }, oidc.WithInsecureCookies())
require.NoError(t, err) require.NoError(t, err)
return auth return auth
@@ -140,12 +139,12 @@ func login(t *testing.T, auth *oidc.DexAuth) (state, nonce string) {
return q.Get("state"), q.Get("nonce") return q.Get("state"), q.Get("nonce")
} }
// authenticate completes a full login+callback for the allowlisted subject and // authenticate completes a full login+callback for the test subject and returns
// returns the resulting session cookie. // the resulting session cookie.
func authenticate(t *testing.T, auth *oidc.DexAuth, f *fakeIssuer) *http.Cookie { func authenticate(t *testing.T, auth *oidc.DexAuth, f *fakeIssuer) *http.Cookie {
t.Helper() t.Helper()
state, nonce := login(t, auth) 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() rec := httptest.NewRecorder()
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
@@ -189,7 +188,7 @@ func TestLoginRedirectsToAuthorize(t *testing.T) {
require.Contains(t, q.Get("scope"), "openid") require.Contains(t, q.Get("scope"), "openid")
} }
func TestCallbackAllowedSubjectSetsSession(t *testing.T) { func TestCallbackSetsSession(t *testing.T) {
f := newFakeIssuer(t) f := newFakeIssuer(t)
auth := newAuth(t, f) auth := newAuth(t, f)
@@ -199,26 +198,35 @@ func TestCallbackAllowedSubjectSetsSession(t *testing.T) {
req.AddCookie(cookie) req.AddCookie(cookie)
user, ok := auth.CurrentUser(req) user, ok := auth.CurrentUser(req)
require.True(t, ok) 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.Equal(t, "maintainer@d-ma.be", user.Email)
require.True(t, cookie.HttpOnly) require.True(t, cookie.HttpOnly)
require.Equal(t, http.SameSiteLaxMode, cookie.SameSite) 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) f := newFakeIssuer(t)
auth := newAuth(t, f) auth := newAuth(t, f)
state, nonce := login(t, auth) 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() rec := httptest.NewRecorder()
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
"/auth/callback?code=valid-code&state="+state, nil)) "/auth/callback?code=valid-code&state="+state, nil))
require.Equal(t, http.StatusForbidden, rec.Code) require.Equal(t, http.StatusFound, rec.Code)
require.Empty(t, rec.Result().Cookies(), "no session for a rejected subject") 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) { func TestCallbackUnknownStateRejected(t *testing.T) {
@@ -240,9 +248,15 @@ func TestMiddlewareRedirectsUnauthenticated(t *testing.T) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
// The bare root sends an unauthenticated visitor to the public landing page.
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusFound, rec.Code)
require.Equal(t, "/welcome", rec.Header().Get("Location"))
// A deeper guarded path goes to login so the post-login round-trip returns there.
rec = httptest.NewRecorder()
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v/some-id", nil))
require.Equal(t, http.StatusFound, rec.Code) require.Equal(t, http.StatusFound, rec.Code)
require.Equal(t, "/auth/login", rec.Header().Get("Location")) require.Equal(t, "/auth/login", rec.Header().Get("Location"))
} }
@@ -272,7 +286,7 @@ func TestMiddlewarePublicPathsBypassAuth(t *testing.T) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
for _, path := range []string{"/healthz", "/auth/login"} { for _, path := range []string{"/healthz", "/welcome", "/auth/login"} {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
require.Equal(t, http.StatusOK, rec.Code, "expected %s to bypass auth", path) require.Equal(t, http.StatusOK, rec.Code, "expected %s to bypass auth", path)
@@ -290,6 +304,7 @@ func TestLogoutClearsSession(t *testing.T) {
auth.Routes().ServeHTTP(rec, req) auth.Routes().ServeHTTP(rec, req)
require.Equal(t, http.StatusFound, rec.Code) require.Equal(t, http.StatusFound, rec.Code)
require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page")
cleared := sessionCookie(t, rec.Result()) cleared := sessionCookie(t, rec.Result())
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie") require.Less(t, cleared.MaxAge, 0, "logout expires the cookie")
@@ -304,12 +319,11 @@ func TestExpiredSessionRejected(t *testing.T) {
f := newFakeIssuer(t) f := newFakeIssuer(t)
clock := time.Now() clock := time.Now()
auth, err := oidc.New(context.Background(), oidc.Config{ auth, err := oidc.New(context.Background(), oidc.Config{
Issuer: f.server.URL, Issuer: f.server.URL,
ClientID: testClientID, ClientID: testClientID,
ClientSecret: "test-client-secret", ClientSecret: "test-client-secret",
RedirectURL: "http://tapir.test/auth/callback", RedirectURL: "http://tapir.test/auth/callback",
SessionSecret: "test-session-secret-please-change", SessionSecret: "test-session-secret-please-change",
AllowedSubject: allowedSub,
}, oidc.WithInsecureCookies(), }, oidc.WithInsecureCookies(),
oidc.WithSessionTTL(time.Minute), oidc.WithSessionTTL(time.Minute),
oidc.WithClock(func() time.Time { return clock })) oidc.WithClock(func() time.Time { return clock }))
+42
View File
@@ -0,0 +1,42 @@
package web
import (
"context"
"sync"
)
// Processor runs the core summarization use case for a single already-discovered
// video — resolve its transcript, summarize, deliver to the store. *usecase.Engine
// wrapped with the store satisfies it (wired in cmd/tapir). Optional on App: a nil
// Processor means queue-only — the "Summarize" button only flips the DB flag and
// the next `tapir run` does the work.
type Processor interface {
ProcessVideo(ctx context.Context, userID, videoID string) error
}
// ProcessingSet tracks the (user, video) ids currently being summarized in-process
// so the status endpoint can show the animation until the summary lands. It is
// ephemeral (single-instance Stage-1): a restart drops it, and the DB holds the
// durable state — the summary is present, or summarize_requested is still set so
// `tapir run` retries. The zero value is ready to use; methods are concurrency-safe.
type ProcessingSet struct {
m sync.Map
}
// Add marks a key in-flight.
func (p *ProcessingSet) Add(key string) { p.m.Store(key, struct{}{}) }
// Remove clears a key once its summarization finishes (success or failure).
func (p *ProcessingSet) Remove(key string) { p.m.Delete(key) }
// Has reports whether a key is currently in-flight.
func (p *ProcessingSet) Has(key string) bool {
_, ok := p.m.Load(key)
return ok
}
// processingKey scopes the in-flight key by user so one user's summarization is
// never confused with another's for the same video id.
func processingKey(userID, videoID string) string {
return userID + "|" + videoID
}
+131
View File
@@ -0,0 +1,131 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/web"
)
// fakeProcessor records ProcessVideo calls. With block set it parks until the
// channel is closed, so a test can observe the handler return before the
// background work finishes (proving it ran in a goroutine).
type fakeProcessor struct {
block chan struct{}
done chan struct{}
calls []string
}
func (f *fakeProcessor) ProcessVideo(_ context.Context, _, videoID string) error {
if f.block != nil {
<-f.block
}
f.calls = append(f.calls, videoID)
if f.done != nil {
close(f.done)
}
return nil
}
func TestRequestSummarizeImmediateProcessing(t *testing.T) {
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
fp := &fakeProcessor{block: make(chan struct{}), done: make(chan struct{})}
app.Processor = fp
rec := postSummarize(t, app, videoX, true)
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
// The processing card came back while ProcessVideo is still parked on block:
// the work runs in a goroutine, the handler did not wait for it.
require.Contains(t, html, "Summarizing", "processing card returned")
require.Contains(t, html, "╭", "charm box rendered")
require.Contains(t, html, "▓", "tapir body block chars rendered")
require.Contains(t, html, "∩", "wiggling snout frame rendered")
require.Contains(t, html, web.CharmPurple, "charm palette applied to the border")
require.Contains(t, html, "/v/"+videoX+"/status", "card polls the status endpoint")
require.Contains(t, html, `hx-trigger="every 2s"`, "card auto-polls every 2s")
require.NotContains(t, html, "Queued", "not the queue-only card")
close(fp.block)
select {
case <-fp.done:
case <-time.After(2 * time.Second):
t.Fatal("ProcessVideo was not called in the background")
}
require.Equal(t, []string{videoX}, fp.calls)
}
func TestStatusProcessingThenDone(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{})
// Park ProcessVideo so the video stays in-flight while we poll status.
fp := &fakeProcessor{block: make(chan struct{})}
app.Processor = fp
require.Equal(t, http.StatusOK, postSummarize(t, app, videoX, true).Code)
// Processing: status returns the animation card, still polling.
rec := getStatus(t, app, videoX)
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Summarizing", "in-flight → animation card")
require.Contains(t, html, `hx-trigger="every 2s"`, "still polling")
close(fp.block)
// Done: once a summary exists, status returns the summary card with no poll.
require.NoError(t, deliver(ctx, app, videoX, "the summary body"))
rec = getStatus(t, app, videoX)
require.Equal(t, http.StatusOK, rec.Code)
html = body(t, rec)
require.NotContains(t, html, "Summarizing", "done → no animation")
require.NotContains(t, html, "every 2s", "done card does not poll (polling stops)")
require.Contains(t, html, "/v/"+videoX+"\"", "links to the detail page")
}
func TestStatusQueuedWhenNotInFlight(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{})
// Flag set but nothing in-flight (e.g. queue-only, or after a restart).
require.NoError(t, app.Store.RequestSummarize(ctx, userID, videoX))
rec := getStatus(t, app, videoX)
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Queued", "queued chip card")
require.NotContains(t, html, "Summarizing", "not processing")
require.NotContains(t, html, "every 2s", "queued card does not poll")
}
func TestStatusNotFound(t *testing.T) {
app := newApp(t)
resetDB(t, rawPool(t))
rec := getStatus(t, app, videoX)
require.Equal(t, http.StatusNotFound, rec.Code)
}
func getStatus(t *testing.T, app *web.App, videoID string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/v/"+videoID+"/status", nil)
req.Header.Set("HX-Request", "true")
rec := httptest.NewRecorder()
app.Router().ServeHTTP(rec, req)
return rec
}
+27
View File
@@ -0,0 +1,27 @@
package web
import "testing"
func TestProcessingSetAddHasRemove(t *testing.T) {
var s ProcessingSet // zero value is usable
key := processingKey("user-1", "video-1")
if s.Has(key) {
t.Fatal("fresh set must not report a key as in-flight")
}
s.Add(key)
if !s.Has(key) {
t.Fatal("Add must mark the key in-flight")
}
// A different user with the same video id is a distinct key.
if s.Has(processingKey("user-2", "video-1")) {
t.Fatal("keys must be scoped by user")
}
s.Remove(key)
if s.Has(key) {
t.Fatal("Remove must clear the key")
}
}
+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/")
}
+55
View File
@@ -0,0 +1,55 @@
package web
import (
"context"
"strings"
"testing"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
func renderVideoCard(t *testing.T, r store.SummaryRow) string {
t.Helper()
var sb strings.Builder
if err := VideoCard(r).Render(context.Background(), &sb); err != nil {
t.Fatalf("render VideoCard: %v", err)
}
return sb.String()
}
// A rate-limited, unsummarized video shows the passive "Retrying later" badge and
// hides the Summarize button — the user can't fix it, retry is automatic.
func TestVideoCard_RateLimitedShowsRetryingBadge(t *testing.T) {
html := renderVideoCard(t, store.SummaryRow{
VideoID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
Title: "Throttled Video",
Summarized: false,
TranscriptStatus: "rate_limited",
})
if !strings.Contains(html, "Retrying later") {
t.Errorf("expected a 'Retrying later' badge, got:\n%s", html)
}
if !strings.Contains(html, "chip-retry") {
t.Errorf("expected the passive chip-retry styling, got:\n%s", html)
}
if strings.Contains(html, ">Summarize<") {
t.Errorf("the Summarize button must be hidden for a rate-limited video, got:\n%s", html)
}
}
// An ordinary unsummarized video still offers the Summarize button.
func TestVideoCard_UnsummarizedShowsSummarize(t *testing.T) {
html := renderVideoCard(t, store.SummaryRow{
VideoID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
Title: "Fresh Video",
Summarized: false,
})
if !strings.Contains(html, ">Summarize<") {
t.Errorf("expected a Summarize button, got:\n%s", html)
}
if strings.Contains(html, "Retrying later") {
t.Errorf("no retry badge for a non-rate-limited video, got:\n%s", html)
}
}
+376 -1
View File
@@ -1,14 +1,31 @@
package web package web
import ( import (
"regexp"
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/a-h/templ" "github.com/a-h/templ"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store" "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 // 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 // group. It mirrors the store's allowed actions (store/actions.go); order here is
// the display order, not the store's. // the display order, not the store's.
@@ -100,6 +117,51 @@ func detailMeta(r store.SummaryRow) string {
return strings.Join(parts, " · ") 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. // videoURL builds the internal detail-page path for a video id.
func videoURL(videoID string) templ.SafeURL { func videoURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID) return templ.SafeURL("/v/" + videoID)
@@ -110,11 +172,222 @@ func actionURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/action") 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")
}
// statusURL builds the processing-status poll path (GET) for a video id — the
// HTMX poll target while an immediate summarization is in flight.
func statusURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/status")
}
// inviteURL builds the claim path (POST) for an invite token.
func inviteURL(token string) templ.SafeURL {
return templ.SafeURL("/invite/" + token)
}
// Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
// so the inline span colours and the CSS track/fill share one source of truth.
const (
CharmPurple = "#7653FC" // box border
CharmPink = "#FF6E9C" // tapir body
CharmMint = "#0EF9B6" // snout, eyes, progress fill
CharmCream = "#FFFDF5" // bright text
CharmDim = "#6C6C6C" // dim text
charmTrack = "#2D2D2D" // empty progress track (internal: dark char colour)
)
// tapirInteriorW is the fixed inner width of the Charm box, in monospace cells.
const tapirInteriorW = 34
// tapirBarFill is the mint progress fill (27 cells), revealed left→right by the
// CSS width/clip animation over the dim track drawn in each frame.
const tapirBarFill = "███████████████████████████"
// tapirRun is one coloured (or uncoloured) text segment of a box row.
type tapirRun struct {
s string
color string // "" = no span (plain text)
}
func tapirSpan(color, s string) string {
if color == "" {
return s
}
return `<span style="color:` + color + `">` + s + `</span>`
}
// tapirLine renders one interior box row: concatenate the coloured runs, pad with
// spaces to the fixed interior width, then flank with the purple side borders.
// Padding is computed from the runs' rune counts, so every row's right border
// lines up no matter how many runs it has (assuming 1-cell monospace glyphs).
func tapirLine(runs ...tapirRun) string {
var b strings.Builder
width := 0
for _, r := range runs {
b.WriteString(tapirSpan(r.color, r.s))
width += utf8.RuneCountInString(r.s)
}
if width < tapirInteriorW {
b.WriteString(strings.Repeat(" ", tapirInteriorW-width))
}
bar := tapirSpan(CharmPurple, "│")
return bar + b.String() + bar
}
// tapirFrameHTML builds one animation frame: a rounded Charm box around a colored
// ASCII tapir, a dim progress track, and labels. snout is the wiggling nose glyph
// that differs between the three frames. Returned as raw HTML (coloured spans),
// emitted verbatim by the template via templ.Raw.
func tapirFrameHTML(snout string) string {
top := tapirSpan(CharmPurple, "╭"+strings.Repeat("─", tapirInteriorW)+"╮")
bottom := tapirSpan(CharmPurple, "╰"+strings.Repeat("─", tapirInteriorW)+"╯")
lines := []string{
top,
tapirLine(tapirRun{s: " "}, tapirRun{s: "◆", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "tapir", color: CharmCream}),
tapirLine(),
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄▄▄▄▄", color: CharmPink}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄█▓▓▓▓█▄", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: snout, color: CharmMint}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "█▓(", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "◕ ◕", color: CharmMint}, tapirRun{s: ")▓█", color: CharmPink}, tapirRun{s: "──┘", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "< thinking...", color: CharmDim}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀█▓▓▓▓█▀", color: CharmPink}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "██▄▄██", color: CharmPink}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}),
tapirLine(),
tapirLine(tapirRun{s: " "}, tapirRun{s: "[", color: CharmDim}, tapirRun{s: strings.Repeat("░", 27), color: charmTrack}, tapirRun{s: "]", color: CharmDim}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "summarizing", color: CharmDim}),
bottom,
}
return strings.Join(lines, "\n")
}
// The three frames differ only in the snout glyph (∩ → → ~), cross-faded by CSS
// to read as a tapir wiggling its nose while it thinks.
var (
tapirFrameHTML1 = tapirFrameHTML("∩")
tapirFrameHTML2 = tapirFrameHTML("")
tapirFrameHTML3 = tapirFrameHTML("~")
)
// welcomeHeroHTML is the static Charm-box tapir mascot on the public landing
// page — the same rounded purple box / pink tapir / mint accents as the spinner,
// but a single still frame with a friendly tagline instead of the animation.
// Built from the shared tapirLine helpers so the aesthetic stays in one place.
func welcomeHeroHTML() string {
top := tapirSpan(CharmPurple, "╭"+strings.Repeat("─", tapirInteriorW)+"╮")
bottom := tapirSpan(CharmPurple, "╰"+strings.Repeat("─", tapirInteriorW)+"╯")
lines := []string{
top,
tapirLine(tapirRun{s: " "}, tapirRun{s: "◆", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "tapir", color: CharmCream}),
tapirLine(),
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄▄▄▄▄", color: CharmPink}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄█▓▓▓▓█▄", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "∩", color: CharmMint}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "█▓(", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "◕ ◕", color: CharmMint}, tapirRun{s: ")▓█", color: CharmPink}, tapirRun{s: "──┘", color: CharmMint}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀█▓▓▓▓█▀", color: CharmPink}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "██▄▄██", color: CharmPink}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}),
tapirLine(),
tapirLine(tapirRun{s: " "}, tapirRun{s: "watch less, know more", color: CharmMint}),
bottom,
}
return strings.Join(lines, "\n")
}
var welcomeHero = welcomeHeroHTML()
// 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. // externalURL passes a stored source URL through templ's URL sanitiser.
func externalURL(u string) templ.SafeURL { func externalURL(u string) templ.SafeURL {
return templ.URL(u) 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."},
flashAccountCreated: {"success", "Account created — log in with your email and password."},
}
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". // 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 // Dates are kept as the raw YYYY-MM-DD strings so the form re-renders the user's
// input verbatim; parsing happens in matchFilter. // input verbatim; parsing happens in matchFilter.
@@ -200,8 +473,9 @@ body { font: 15px/1.6 system-ui, -apple-system, sans-serif; margin: 0; color: va
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover, a:focus-visible { text-decoration: underline; } a:hover, a:focus-visible { text-decoration: underline; }
a:visited { color: var(--accent); } 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); } .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); } main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.muted { color: var(--muted); } .muted { color: var(--muted); }
@@ -211,6 +485,10 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.filters input { font: inherit; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); min-width: 9rem; } .filters input { font: inherit; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); min-width: 9rem; }
.filters input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); } .filters input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
.btn { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid var(--accent); border-radius: var(--radius); background: var(--accent); color: var(--accent-fg); cursor: pointer; } .btn { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid var(--accent); border-radius: var(--radius); background: var(--accent); color: var(--accent-fg); cursor: pointer; }
/* anchors styled as buttons: the generic a{} / a:visited{} colour rules outrank
.btn on <a>, painting the label accent-on-accent (invisible). Restore the
button foreground for anchor buttons, visited included. */
a.btn, a.btn:visited { color: var(--accent-fg); }
.btn:hover { filter: brightness(1.05); } .btn:hover { filter: brightness(1.05); }
.btn:active { transform: translateY(1px); } .btn:active { transform: translateY(1px); }
@@ -219,15 +497,66 @@ 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 { 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-title { font-size: 1.1rem; font-weight: 600; line-height: 1.3; }
.card-meta { color: var(--muted); font-size: .85rem; } .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); } .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; } .chip { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: .72rem; font-weight: 600; }
/* passive "retrying later" chip: dim/grey (CharmDim), not the accent — it is a
status, not an action the user can take. */
.chip-retry { background: rgba(108, 108, 108, .16); color: #6c6c6c; }
.card-state { color: var(--muted); font-size: .8rem; } .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; } .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; }
/* summarizing animation — a Charmbracelet-style TUI panel rendered in the
browser: a dark terminal card, a rounded purple box around a pink ASCII tapir,
and a lipgloss-style progress bar. Three frames are stacked and cross-faded by
a stepped keyframe (staggered delays) so the snout appears to wiggle; the
progress fill grows independently via a clip animation over the dim track. */
.card-processing { border-style: dashed; }
.tapir-charm { position: relative; display: inline-block; background: #0d0d12; border-radius: 10px; padding: .8em 1em; margin: var(--s2) 0; font: .82rem/1.15 ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace; box-shadow: 0 2px 14px rgba(118, 83, 252, .25); }
.tapir-charm pre { margin: 0; white-space: pre; opacity: 0; animation: tapir-cycle 1.2s steps(1, end) infinite; }
.tapir-charm .tapir-f1 { position: relative; animation-delay: 0s; }
.tapir-charm .tapir-f2 { position: absolute; top: .8em; left: 1em; animation-delay: .4s; }
.tapir-charm .tapir-f3 { position: absolute; top: .8em; left: 1em; animation-delay: .8s; }
@keyframes tapir-cycle { 0%, 33.32% { opacity: 1; } 33.33%, 100% { opacity: 0; } }
/* progress fill: 27 mint cells overlaying the dim track at box row 10, col 3,
revealed left→right over 8s, looping. */
.tapir-bar { position: absolute; top: calc(.8em + 11.5em); left: calc(1em + 3ch); height: 1.15em; line-height: 1.15; overflow: hidden; }
.tapir-bar-fill { animation: tapir-fill 8s linear infinite; text-shadow: 0 0 6px rgba(14, 249, 182, .7); }
@keyframes tapir-fill { 0% { clip-path: inset(0 100% 0 0); } 100% { clip-path: inset(0 0 0 0); } }
.tapir-label { color: var(--muted); font-size: .9rem; margin: 0; }
@media (prefers-reduced-motion: reduce) {
.tapir-charm pre { animation: none; }
.tapir-charm .tapir-f2, .tapir-charm .tapir-f3 { display: none; }
.tapir-charm .tapir-f1 { opacity: 1; }
.tapir-bar-fill { animation: none; clip-path: inset(0 35% 0 0); }
}
/* 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 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 { 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 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; } .empty code { background: var(--accent-weak); color: var(--accent); padding: .1rem .35rem; border-radius: .3rem; }
.empty p { margin: var(--s3) 0 0; }
/* connected-but-empty: a distinct accent callout, not a muted blank state, so a
fresh account knows the next step is to run tapir, not "something is broken". */
.empty-connected { border-style: solid; border-color: var(--accent); background: var(--accent-weak); color: var(--fg); }
.empty-connected strong { color: var(--accent); }
/* 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 loading feedback */
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; } .htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
@@ -238,6 +567,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 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 .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 .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 { 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 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; } .detail .body { white-space: pre-wrap; line-height: 1.7; margin: 0; }
@@ -252,6 +583,50 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.actions .action:active { transform: translateY(1px); } .actions .action:active { transform: translateY(1px); }
.actions .action.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); } .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; }
}
/* public landing page (/welcome) — the Charm-box mascot hero plus the sign-in CTA */
.welcome { text-align: center; padding: var(--s5) var(--s3); display: flex; flex-direction: column; align-items: center; gap: var(--s4); }
.welcome-hero { background: #0d0d12; border-radius: 10px; padding: .9em 1.1em; display: inline-block; box-shadow: 0 2px 14px rgba(118, 83, 252, .25); }
.welcome-hero pre { margin: 0; white-space: pre; font: .82rem/1.15 ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace; }
.welcome-title { font-size: 1.9rem; line-height: 1.2; margin: 0; }
.welcome-tagline { color: var(--muted); font-size: 1.05rem; line-height: 1.5; margin: 0; max-width: 32rem; }
.welcome-cta { display: flex; gap: var(--s3); flex-wrap: wrap; justify-content: center; align-items: center; }
.welcome-sub { color: var(--muted); font-size: .9rem; margin: 0; }
.btn-lg { padding: .6rem 1.6rem; font-size: 1.05rem; }
@media (max-width: 640px) { @media (max-width: 640px) {
main { padding: var(--s3) var(--s2); } main { padding: var(--s3) var(--s2); }
.filters { gap: var(--s2); } .filters { gap: var(--s2); }
+50
View File
@@ -0,0 +1,50 @@
package web
import (
"regexp"
"strings"
"testing"
"unicode/utf8"
)
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)
}
})
}
}
// TestTapirFrameRowsAligned asserts every box row has the same cell width once
// the inline-colour spans are stripped, so the rounded border lines up on every
// line (the panel only looks right if the right │ is flush across all rows).
func TestTapirFrameRowsAligned(t *testing.T) {
stripSpan := regexp.MustCompile(`</?span[^>]*>`)
for name, frame := range map[string]string{"f1": tapirFrameHTML1, "f2": tapirFrameHTML2, "f3": tapirFrameHTML3} {
plain := stripSpan.ReplaceAllString(frame, "")
want := tapirInteriorW + 2 // both purple side borders
for i, line := range strings.Split(plain, "\n") {
if got := utf8.RuneCountInString(line); got != want {
t.Errorf("%s line %d width = %d, want %d: %q", name, i, got, want, line)
}
}
}
}
+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)
}
})
}
}
+354 -30
View File
@@ -20,7 +20,10 @@ templ Layout(title string) {
@templ.Raw(styleTag) @templ.Raw(styleTag)
</head> </head>
<body> <body>
<header><a href="/" class="brand">Tapir</a></header> <header>
<a href="/" class="brand">Tapir</a>
<nav class="nav"><a href="/account">Account</a><a href="/auth/logout">Log out</a></nav>
</header>
<main> <main>
{ children... } { children... }
</main> </main>
@@ -28,13 +31,60 @@ templ Layout(title string) {
</html> </html>
} }
// WelcomePage is the public landing page (served at /welcome, outside the auth
// guard — ADR-012). Logged out: the tapir mascot, a one-line tagline, and a
// single "Get Started" CTA into the shared Dex flow (sign-in and sign-up are the
// same URL). Logged in: a greeting plus links back into the app and to log out.
templ WelcomePage(user User, loggedIn bool) {
@Layout("Tapir — Watch less, know more") {
<section class="welcome">
<div class="welcome-hero">
<pre aria-hidden="true">@templ.Raw(welcomeHero)</pre>
</div>
if loggedIn {
<h1 class="welcome-title">Welcome back</h1>
if user.Email != "" {
<p class="welcome-tagline">Signed in as { user.Email }.</p>
}
<div class="welcome-cta">
<a class="btn btn-lg" href="/">Go to my Tapir</a>
<a class="btn-secondary" href="/auth/logout">Log Out</a>
</div>
} else {
<h1 class="welcome-title">Watch less, know more</h1>
<p class="welcome-tagline">
Tapir summarizes the videos your subscriptions publish, so you can
skim the gist and decide what is worth your time.
</p>
<div class="welcome-cta">
<a class="btn btn-lg" href="/auth/login">Get Started</a>
</div>
<p class="welcome-sub">Access is by invitation. If you have an invite link, it will set up your account automatically. Returning users with credentials can log in above.</p>
}
</section>
}
}
// 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 // 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. // #summary-list region; a non-HTMX request renders the whole page. flash carries
templ ListPage(rows []store.SummaryRow, f Filter) { // a one-shot notification (e.g. "connected", "registered") surfaced on arrival
// after a POST→redirect.
templ ListPage(rows []store.SummaryRow, f Filter, flash string, hasConnected bool) {
@Layout("Tapir — Summaries") { @Layout("Tapir — Summaries") {
@flashBanner(flash)
@filterForm(f) @filterForm(f)
<div id="summary-list"> <div id="summary-list">
@summaryList(rows) @summaryList(rows, hasConnected)
</div> </div>
} }
} }
@@ -57,41 +107,119 @@ templ filterForm(f Filter) {
</form> </form>
} }
// summaryList is the swappable list fragment: one card per summary (title link, // summaryList is the swappable list fragment: one card per video (summarized or
// channel · date meta, provider chip, fallback badge, action state). Cards // not). Cards reflow to a single column on mobile; an empty list shows a friendly
// reflow to a single column on mobile; an empty list shows a friendly first-run // first-run state instead of a blank table.
// state instead of a blank table. templ summaryList(rows []store.SummaryRow, hasConnected bool) {
templ summaryList(rows []store.SummaryRow) {
if len(rows) == 0 { if len(rows) == 0 {
<div class="empty"> if hasConnected {
<strong>No summaries yet</strong> <div class="empty empty-connected">
<span>Summaries appear here as your subscriptions are processed run <code>tapir run</code> to fetch and summarize new videos.</span> <strong>Your YouTube account is connected!</strong>
</div> <span>Run <code>tapir run</code> to discover your subscriptions. Videos will appear here once discovered. In manual mode, each new video gets a Summarize button.</span>
</div>
} else {
<div class="empty">
<strong>No videos yet</strong>
<span>Connect your YouTube account to get started.</span>
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
</div>
}
} else { } else {
<ul class="cards"> <ul class="cards">
for _, r := range rows { for _, r := range rows {
<li class="card"> @VideoCard(r)
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
if cardMeta(r) != "" {
<div class="card-meta">{ cardMeta(r) }</div>
}
<div class="card-foot">
if r.AIProvider != "" {
<span class="chip">{ r.AIProvider }</span>
}
if r.FallbackUsed {
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
}
if len(r.Actions) > 0 {
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
}
</div>
</li>
} }
</ul> </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>
}
if r.FallbackUsed {
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
}
if len(r.Actions) > 0 {
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
}
} else if r.TranscriptStatus == "rate_limited" {
<span class="chip chip-retry" title="Caption fetch was rate-limited; tapir will retry automatically."> Retrying later</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>
}
// TapirSpinner is the summarizing animation: a Charmbracelet-style TUI panel —
// three richly coloured ASCII tapir frames (inline span colours, snout wiggling
// ∩→∪→~) cross-faded by CSS, plus a lipgloss-style progress bar whose mint fill
// grows over the dim track. The panel is aria-hidden (decorative); the
// "Summarizing…" label below carries the meaning for assistive tech.
templ TapirSpinner() {
<div class="tapir-charm" aria-hidden="true">
<pre class="tapir-f1">@templ.Raw(tapirFrameHTML1)</pre>
<pre class="tapir-f2">@templ.Raw(tapirFrameHTML2)</pre>
<pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre>
<div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div>
</div>
<p class="tapir-label" role="status" aria-live="polite"><em>Summarizing…</em></p>
}
// processingCard is the in-flight summarization card. It replaces the Summarize
// button card and polls /v/{id}/status every 2s, swapping itself (outerHTML, same
// id as VideoCard) for whatever state comes back: it keeps polling while still
// processing, and the summary/queued card it is eventually replaced by carries no
// poll, so polling stops on its own when the fragment changes.
templ processingCard(r store.SummaryRow) {
<li
class="card card-processing"
id={ "video-" + r.VideoID }
hx-get={ string(statusURL(r.VideoID)) }
hx-trigger="every 2s"
hx-swap="outerHTML"
>
<div class="card-title">{ displayTitle(r) }</div>
if cardMeta(r) != "" {
<div class="card-meta">{ cardMeta(r) }</div>
}
@TapirSpinner()
</li>
}
// DetailPage is the full summary view: text, highlights, takeaways, metadata, // DetailPage is the full summary view: text, highlights, takeaways, metadata,
// and the action button group. // and the action button group.
templ DetailPage(r store.SummaryRow) { templ DetailPage(r store.SummaryRow) {
@@ -106,6 +234,18 @@ templ DetailPage(r store.SummaryRow) {
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span> <span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
} }
</p> </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 != "" { if r.URL != "" {
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source </a></p> <p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source </a></p>
} }
@@ -138,6 +278,190 @@ 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>
}
}
// InvitePage is the public set-password form an invited user reaches via their
// emailed /invite/{token} link. The email is shown read-only (it is fixed by the
// invite, not chosen here); the visitor sets a password to create their account.
// errMsg, when set, reports a validation problem on the prior submit. No auth
// chrome (header nav) is appropriate — the visitor has no session yet — but the
// shared Layout keeps the look consistent.
templ InvitePage(email, token, errMsg string) {
@Layout("Tapir — Set your password") {
<article class="register">
<h1>Set up your Tapir account</h1>
<p class="meta">Invitation for { email }.</p>
<p>Choose a password to finish creating your account. You'll then log in with this email and password.</p>
if errMsg != "" {
<p class="error" role="alert">{ errMsg }</p>
}
<form method="post" action={ inviteURL(token) } class="register-form">
<label>
Email
<input type="email" name="email" value={ email } readonly/>
</label>
<label>
Password
<input type="password" name="password" minlength="8" required autofocus autocomplete="new-password"/>
</label>
<label>
Confirm password
<input type="password" name="password_confirm" minlength="8" required autocomplete="new-password"/>
</label>
<button type="submit" class="btn">Create my account</button>
</form>
</article>
}
}
// InviteInvalidPage is shown when an invite token is missing, expired, or already
// used — a dead-end with no form, so a stale or replayed link reads clearly.
templ InviteInvalidPage() {
@Layout("Tapir — Invitation") {
<article class="register">
<h1>This invite link is no longer valid</h1>
<p>This invitation has expired or has already been used. Ask for a fresh invite link, or log in if you already have an account.</p>
<p><a class="btn" href="/auth/login">Log in</a></p>
</article>
}
}
// InviteNoticePage is a terminal message after a submit that neither succeeded nor
// is a retryable validation error (account already exists, RBAC missing, or the
// dev "deployed-only" degrade). showLogin adds a log-in CTA where that is the
// natural next step.
templ InviteNoticePage(message string, showLogin bool) {
@Layout("Tapir — Invitation") {
<article class="register">
<h1>Invitation</h1>
<p>{ message }</p>
if showLogin {
<p><a class="btn" href="/auth/login">Log in</a></p>
}
</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. // ActionButtons is the toggle group fragment returned by POST /v/{id}/action.
// Each button submits its verb; HTMX swaps this element in place (outerHTML), // 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 // and without JS the form POSTs and the handler redirects back to the detail
+1408 -228
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
package web_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/web"
)
// fakeAuth is a configurable web.Auth for the landing-page tests: it reports a
// fixed (user, ok) from CurrentUser and, when logged out, replicates DexAuth's
// redirect split in Middleware — bare root → /welcome, deeper paths → login.
// StubAuth can't express the logged-out case (it allows everything), so the
// welcome routing needs this.
type fakeAuth struct {
user web.User
ok bool
}
func (f fakeAuth) CurrentUser(*http.Request) (web.User, bool) { return f.user, f.ok }
func (f fakeAuth) Routes() http.Handler { return http.NewServeMux() }
func (f fakeAuth) Middleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if f.ok {
h.ServeHTTP(w, r)
return
}
if r.URL.Path == "/" {
http.Redirect(w, r, "/welcome", http.StatusFound)
return
}
http.Redirect(w, r, "/auth/login", http.StatusFound)
})
}
// appWithAuth builds an App with a given Auth but no store wiring — enough for
// the /welcome page (which never touches the store) and the unauthenticated
// redirect paths (which never reach a handler).
func appWithAuth(auth web.Auth) *web.App {
return &web.App{Auth: auth}
}
func TestWelcomeLoggedOut(t *testing.T) {
app := appWithAuth(fakeAuth{ok: false})
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/welcome", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Get Started", "logged-out CTA present")
require.Contains(t, html, `href="/auth/login"`, "CTA links into the Dex flow")
require.NotContains(t, html, "Go to my Tapir", "no logged-in controls")
}
func TestWelcomeLoggedIn(t *testing.T) {
app := appWithAuth(fakeAuth{user: web.User{Subject: "s", Email: "me@d-ma.be"}, ok: true})
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/welcome", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Go to my Tapir", "logged-in CTA present")
require.Contains(t, html, `href="/"`, "links back into the app")
require.Contains(t, html, "me@d-ma.be", "greets by email")
require.NotContains(t, html, "Get Started", "no logged-out CTA")
}
func TestUnauthenticatedRootRedirectsToWelcome(t *testing.T) {
app := appWithAuth(fakeAuth{ok: false})
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusFound, rec.Code)
require.Equal(t, "/welcome", rec.Header().Get("Location"))
}
func TestUnauthenticatedDeepLinkRedirectsToLogin(t *testing.T) {
app := appWithAuth(fakeAuth{ok: false})
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/v/some-id", nil))
require.Equal(t, http.StatusFound, rec.Code)
require.Equal(t, "/auth/login", rec.Header().Get("Location"))
}
func TestAuthenticatedRootRendersList(t *testing.T) {
app := newApp(t)
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", "authenticated root still renders the list page")
}