Compare commits

...
36 Commits
Author SHA1 Message Date
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
44 changed files with 2711 additions and 531 deletions
+5
View File
@@ -46,3 +46,8 @@ 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=
+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.
+35 -27
View File
@@ -44,50 +44,54 @@ 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 **unprompted** and reads/acts on summaries in **≥2
> summary (watches / skips / saves a video *because of* the summary). > separate weeks**. The test is *return usage* (behavioural), not stated approval.
- 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 on their own* is
the thing we actually care about. So the gate measures returns, not nice words.
- **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.)
- **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 +102,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.
+25 -22
View File
@@ -22,15 +22,11 @@ import (
"os/signal" "os/signal"
"time" "time"
"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/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"
) )
@@ -120,27 +116,19 @@ 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)
} }
@@ -204,6 +192,21 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL) 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
@@ -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.
+25
View File
@@ -147,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

@@ -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;
+7 -1
View File
@@ -49,6 +49,10 @@ type SummaryRow struct {
// set by the web "Summarize" button and cleared by the next `tapir run`. Only // set by the web "Summarize" button and cleared by the next `tapir run`. Only
// populated by ListVideos/GetVideoRow (summary-only reads leave it false). // populated by ListVideos/GetVideoRow (summary-only reads leave it false).
SummarizeRequested bool 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
@@ -132,7 +136,8 @@ const selectVideo = `
COALESCE(s.fallback_used, FALSE), COALESCE(s.fallback_used, FALSE),
COALESCE(s.created_at, v.seen_at), COALESCE(s.created_at, v.seen_at),
(s.id IS NOT NULL) AS summarized, (s.id IS NOT NULL) AS summarized,
v.summarize_requested v.summarize_requested,
COALESCE(v.transcript_status, '')
FROM videos v FROM videos v
LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id` LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id`
@@ -245,6 +250,7 @@ func scanVideoRow(rows pgx.Row) (SummaryRow, error) {
&row.CreatedAt, &row.CreatedAt,
&row.Summarized, &row.Summarized,
&row.SummarizeRequested, &row.SummarizeRequested,
&row.TranscriptStatus,
); err != nil { ); err != nil {
return SummaryRow{}, fmt.Errorf("store: scan video: %w", err) return SummaryRow{}, fmt.Errorf("store: scan video: %w", err)
} }
@@ -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)
}
+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) {
+13
View File
@@ -59,6 +59,12 @@ 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
@@ -85,6 +91,7 @@ const (
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback" defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
defaultOAuthRedirectAddr = "localhost:8080" defaultOAuthRedirectAddr = "localhost:8080"
defaultHTTPAddr = ":8080" defaultHTTPAddr = ":8080"
defaultFetchBackoff = time.Hour
) )
// Load reads the environment into a Config, applying defaults. It does not // Load reads the environment into a Config, applying defaults. It does not
@@ -124,6 +131,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.
+70 -3
View File
@@ -32,6 +32,12 @@ type VideoStore interface {
GetAutoSummarize(ctx context.Context, userID string) (bool, error) GetAutoSummarize(ctx context.Context, userID string) (bool, error)
RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error) RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error)
ClearSummarizeRequested(ctx context.Context, userID, videoID string) 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
@@ -48,14 +54,35 @@ type Runner struct {
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.
@@ -65,6 +92,7 @@ type Stats struct {
SkippedSeen int SkippedSeen int
SkippedNoText int SkippedNoText int
SkippedManual int // discovered but not queued, in manual mode SkippedManual int // discovered but not queued, in manual mode
SkippedRateLimited int // 429'd previously and still inside the backoff window
Errors int Errors int
} }
@@ -104,6 +132,18 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
} }
} }
// 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)
@@ -142,6 +182,15 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
continue 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)
} }
@@ -152,11 +201,28 @@ 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; // 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 // clear the flag so it is not re-summarized and the UI drops the
// "Queued" chip. (Auto mode never sets the flag.) // "Queued" chip. (Auto mode never sets the flag.)
@@ -184,7 +250,8 @@ 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, "errors", stats.Errors) "skipped_manual", stats.SkippedManual, "skipped_rate_limited", stats.SkippedRateLimited,
"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)
} }
+75
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"
@@ -48,6 +49,8 @@ type fakeStore struct {
auto bool auto bool
requested map[string]bool requested map[string]bool
cleared []string 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) {
@@ -80,6 +83,22 @@ func (f *fakeStore) ClearSummarizeRequested(_ context.Context, _, videoID string
return nil 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) {
@@ -207,6 +226,62 @@ func TestRunOnce_ManualMode_ProcessesRequested(t *testing.T) {
require.Equal(t, []string{"id-v1"}, st.cleared, "the queue flag is cleared after summarizing") 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")},
+9 -2
View File
@@ -45,6 +45,11 @@ 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")
// 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 Summary *domain.Summary // nil when Skipped
} }
@@ -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
+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
+91 -7
View File
@@ -61,6 +61,13 @@ type App struct {
// Secrets removes a user's OAuth tokens on disconnect / delete-account. The // Secrets removes a user's OAuth tokens on disconnect / delete-account. The
// account routes require it; cmd/tapir wires the file-backed store. // account routes require it; cmd/tapir wires the file-backed store.
Secrets SecretRemover 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
} }
func (a *App) logger() *slog.Logger { func (a *App) logger() *slog.Logger {
@@ -76,6 +83,7 @@ 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())
@@ -84,6 +92,7 @@ func (a *App) Router() http.Handler {
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("POST /v/{videoId}/summarize", a.handleRequestSummarize)
app.HandleFunc("GET /v/{videoId}/status", a.handleStatus)
app.HandleFunc("GET /register", a.handleRegisterForm) app.HandleFunc("GET /register", a.handleRegisterForm)
app.HandleFunc("POST /register", a.handleRegister) app.HandleFunc("POST /register", a.handleRegister)
@@ -109,6 +118,15 @@ func (a *App) Router() http.Handler {
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")
@@ -137,11 +155,24 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
} }
rows = f.apply(rows) rows = f.apply(rows)
if isHTMX(r) { // hasConnected drives the empty state: a fresh account with a connection but
a.render(w, r, summaryList(rows)) // 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 return
} }
a.render(w, r, ListPage(rows, f, takeFlash(w, r))) hasConnected = len(conns) > 0
}
if isHTMX(r) {
a.render(w, r, summaryList(rows, hasConnected))
return
}
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).
@@ -208,10 +239,12 @@ 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 queues a video for manual summarization. It does NOT run // handleRequestSummarize handles the "Summarize" button. It always flips the DB
// the engine inline — it only flips summarize_requested; the next `tapir run` // flag (summarize_requested) so the work is durable. With a Processor wired it
// picks it up (the single summarization driver). For HTMX it returns the refreshed // then summarizes immediately in the background and answers with the animated
// card (now showing "Queued"); without JS it redirects back to the list. // 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) { func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r) userID, ok := a.currentUserID(w, r)
if !ok { if !ok {
@@ -238,9 +271,60 @@ func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
a.serverError(w, r, "get video", err) a.serverError(w, r, "get video", err)
return return
} }
if a.Processor != nil {
a.startProcessing(userID, videoID)
a.render(w, r, processingCard(*row))
return
}
a.render(w, r, VideoCard(*row)) 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 // handleSummarizeMode toggles the user's auto/manual summarization mode. The form
// submits the desired new value (enabled=true|false). For HTMX it returns the // 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. // refreshed mode control; without JS it redirects back to the account page.
+18 -4
View File
@@ -161,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
@@ -266,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) {
@@ -305,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/")
} }
+8 -1
View File
@@ -248,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"))
} }
@@ -280,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)
@@ -298,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")
+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")
}
}
+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)
}
}
+163
View File
@@ -4,6 +4,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/a-h/templ" "github.com/a-h/templ"
@@ -176,6 +177,121 @@ func summarizeURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/summarize") 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")
}
// 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. // summarizeModeLabel names the current mode for display.
func summarizeModeLabel(auto bool) string { func summarizeModeLabel(auto bool) string {
if auto { if auto {
@@ -363,6 +479,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); }
@@ -374,6 +494,9 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.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-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; }
@@ -381,6 +504,31 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.card-pending { border-style: dashed; } .card-pending { border-style: dashed; }
.card-pending .card-title { color: var(--muted); font-weight: 600; } .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 */ /* summarization mode toggle on the account page */
.summarize-mode { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; } .summarize-mode { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; }
.summarize-mode p { margin: 0; } .summarize-mode p { margin: 0; }
@@ -390,6 +538,11 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.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 / 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 { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); }
@@ -458,6 +611,16 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.confirm-delete > summary:hover { background: #3a1714; } .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); }
+22 -1
View File
@@ -1,6 +1,11 @@
package web package web
import "testing" import (
"regexp"
"strings"
"testing"
"unicode/utf8"
)
func TestEmbedURL(t *testing.T) { func TestEmbedURL(t *testing.T) {
tests := []struct { tests := []struct {
@@ -27,3 +32,19 @@ func TestEmbedURL(t *testing.T) {
}) })
} }
} }
// 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)
}
}
}
}
+85 -5
View File
@@ -22,7 +22,7 @@ templ Layout(title string) {
<body> <body>
<header> <header>
<a href="/" class="brand">Tapir</a> <a href="/" class="brand">Tapir</a>
<nav class="nav"><a href="/account">Account</a></nav> <nav class="nav"><a href="/account">Account</a><a href="/auth/logout">Log out</a></nav>
</header> </header>
<main> <main>
{ children... } { children... }
@@ -31,6 +31,40 @@ 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">New to Tapir? Just sign in you'll complete a quick setup right after. Already have an account? You'll go straight through.</p>
}
</section>
}
}
// flashBanner renders a one-shot notification for a flash code (connect success/ // flashBanner renders a one-shot notification for a flash code (connect success/
// failure, disconnect, delete, registration). An empty or unknown code renders // failure, disconnect, delete, registration). An empty or unknown code renders
// nothing, so it is safe to drop into any page unconditionally. Reused across the // nothing, so it is safe to drop into any page unconditionally. Reused across the
@@ -45,12 +79,12 @@ templ flashBanner(code string) {
// #summary-list region; a non-HTMX request renders the whole page. flash carries // #summary-list region; a non-HTMX request renders the whole page. flash carries
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival // a one-shot notification (e.g. "connected", "registered") surfaced on arrival
// after a POST→redirect. // after a POST→redirect.
templ ListPage(rows []store.SummaryRow, f Filter, flash string) { templ ListPage(rows []store.SummaryRow, f Filter, flash string, hasConnected bool) {
@Layout("Tapir — Summaries") { @Layout("Tapir — Summaries") {
@flashBanner(flash) @flashBanner(flash)
@filterForm(f) @filterForm(f)
<div id="summary-list"> <div id="summary-list">
@summaryList(rows) @summaryList(rows, hasConnected)
</div> </div>
} }
} }
@@ -76,12 +110,20 @@ templ filterForm(f Filter) {
// summaryList is the swappable list fragment: one card per video (summarized or // summaryList is the swappable list fragment: one card per video (summarized or
// not). Cards reflow to a single column on mobile; an empty list shows a friendly // not). Cards reflow to a single column on mobile; an empty list shows a friendly
// first-run state instead of a blank table. // first-run state instead of a blank table.
templ summaryList(rows []store.SummaryRow) { templ summaryList(rows []store.SummaryRow, hasConnected bool) {
if len(rows) == 0 { if len(rows) == 0 {
if hasConnected {
<div class="empty empty-connected">
<strong>Your YouTube account is connected!</strong>
<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"> <div class="empty">
<strong>No videos yet</strong> <strong>No videos yet</strong>
<span>Videos appear here as your subscriptions are processed run <code>tapir run</code> to fetch them. In manual mode, use the Summarize button to queue one.</span> <span>Connect your YouTube account to get started.</span>
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
</div> </div>
}
} else { } else {
<ul class="cards"> <ul class="cards">
for _, r := range rows { for _, r := range rows {
@@ -122,6 +164,8 @@ templ VideoCard(r store.SummaryRow) {
if len(r.Actions) > 0 { if len(r.Actions) > 0 {
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span> <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 { } else if r.SummarizeRequested {
<span class="chip">Queued</span> <span class="chip">Queued</span>
<span class="card-state muted">waiting for the next run</span> <span class="card-state muted">waiting for the next run</span>
@@ -140,6 +184,42 @@ templ VideoCard(r store.SummaryRow) {
</li> </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) {
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")
}