Compare commits

...
32 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
38 changed files with 2048 additions and 621 deletions
+5
View File
@@ -46,3 +46,8 @@ TAPIR_SECRETS_FILE=
# --- run loop -------------------------------------------------------------
# Empty/0 = single pass. Set (e.g. 15m) to poll on that cadence.
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 inconclusive: $OUTPUT"
# ── 3. Mirror to GitHub (deploy intentionally omitted until manifests exist) ─
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"
# ── 3. Mirror to GitHub — skipped for now (SSH key rotation pending) ─
+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)
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`
(interfaces), `internal/usecase` (engine), `cmd/tapir` (entrypoint stub),
`internal/adapters` (empty — concrete adapters go here).
- `usecase.Engine.ProcessNewVideo` returns `ErrNotImplemented`.
- `test/acceptance/summarize_new_video_test.go` translates the first two Gherkin scenarios and
**fails** against the stub. `task check` is therefore red on `test`.
- **First build task:** implement `ProcessNewVideo` (resolve transcript -> summarize -> deliver to
sinks | skip on no-transcript) to make the acceptance tests green, following the `.feature`
files. Then add the AI-router `Summarizer` (copy `llm` per ADR-004), the YouTube `VideoSource`
adapter (captions-first), and the store + brain sinks.
- Clean Architecture core is implemented: `internal/domain` (entities), `internal/ports`
(interfaces), `internal/usecase.Engine.ProcessNewVideo` (resolve transcript → summarize →
deliver to sinks | skip on no-transcript). The acceptance tests in `test/acceptance/` are
green against it.
- Adapters present under `internal/adapters/`: `youtube` (captions-first `VideoSource`,
timedtext/InnerTube acquisition per ADR-010), `summarizer` + `llm` (the copied AI router,
Primary→Fallback per ADR-004), `store` (Postgres, golang-migrate migrations 001006),
`secrets` (file-backed `SecretStore`). The brain HTTP sink (ADR-005) is the remaining
optional sink.
- 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
in `go.mod` (1.23 — match the koala runner; estate elsewhere uses 1.26.1), the brain-mcp URL, the
exact ESO secret-ref naming, and the summarization model alias. Resolve against the live cluster
before depending on them, and pin answers back into `docs/homelab-integration.md`.
**Setup facts** (resolved — see `docs/homelab-integration.md` for the live values): LiteLLM is
off-cluster at `koala:30401/v1/` with `LITELLM_MASTER_KEY` from 1Password; the summarization
model is config (`TAPIR_SUMMARIZER_MODEL`, default `koala/phi4-mini`), never hardcoded. The
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)
+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
**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
**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
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 |
| 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) |
| 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 —
not a silent reversal.
+35 -27
View File
@@ -44,50 +44,54 @@ fallback — their key, their choice.
## Who it is for
- **Now (the first customer):** the maintainer — one person, their own subscriptions,
summaries delivered to their own store and brain.
- **Soon (Future B):** a small number of known, trusted users (friends / beta) — each with
their own account, isolated data, optional BYO-AI.
- **Now (the first customers):** the maintainer and a small number of known, trusted
friends — each with their own account, isolated data, optional BYO-AI. The maintainer is
the first customer; friendly users provide the earliest real-world signal.
- **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
before that evidence is a known anti-goal.
until there is evidence of sustained use **and** real demand. Building for C before that
evidence is a known anti-goal.
## Definition of Success
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.
### 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
> summaries for their own subscriptions at least weekly, and at least once acts on a
> summary (watches / skips / saves a video *because of* the summary).
> **Headline test:** 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**. The test is *return usage* (behavioural), not stated approval.
- Captions-first summarization works end-to-end for the maintainer's real subscriptions.
- Summaries land in the maintainer's store and (optionally) brain.
- Captions-first summarization works end-to-end for real subscriptions (the maintainer's
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
most of the time.
- **This is the gate.** Multi-user, BYO-AI-for-others, and any SaaS ambition stay deferred
until Stage 0 holds. (Ties to the 2026-07-01 self-use check-in.)
- **Why behavioural, not feedback.** Friend *feedback* is gathered and genuinely valuable —
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)
> **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)
### Stage 1 — Trustworthy at rest (hardening, Future B)
> **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
> a deliberate isolation test (user A cannot read user B's data) passes in CI or a
> 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)
- 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
- 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.
- "Brain ingestion" starts dictating the architecture instead of being one sink behind an
interface.
+2 -2
View File
@@ -125,10 +125,10 @@ func cmdRun(ctx context.Context, log *slog.Logger) error {
if engine == nil {
return fmt.Errorf("run: incomplete summarization config (gateway, youtube credentials, secrets file)")
}
r := runner.New(engine.Source, 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,
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval)
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval, "fetch_backoff", cfg.FetchBackoff)
return r.Loop(ctx, cfg.PollInterval)
}
+65 -3
View File
@@ -45,7 +45,7 @@ adapter behind an interface (Clean Architecture ports & adapters).
```mermaid
graph TB
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)"]
engine["Summarization engine<br/>(use-case core)"]
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
```mermaid
@@ -191,6 +251,8 @@ Gherkin features in `docs/use-cases/`).
- Audio-download + speech-to-text resolver (ADR-007) — would be an additional `VideoSource`
fallback path, drawn when built.
- Multi-tenant isolation primitives (per-tenant Postgres role, NetworkPolicy, tenant label)
— activate at Stage 1 (ADR-002); single-user Stage 0 doesn't exercise them.
- Per-user isolation is **live, not deferred**: Postgres RLS `FORCE`d on every user-owned table
(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).
+87 -23
View File
@@ -23,11 +23,16 @@ only opaque references to them; the secret material lives in ESO/1Password (ADR-
## 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
erDiagram
USER ||--|| USER_IDENTITY : "logs in via (Dex subject)"
USER ||--o{ VIDEO_CONNECTION : has
USER ||--o{ AI_CREDENTIAL : has
VIDEO_CONNECTION ||--o{ SUBSCRIPTION : exposes
USER ||--o{ SUMMARY_ACTION : records
USER ||--o{ AI_CREDENTIAL : "has (planned)"
VIDEO_CONNECTION ||--o{ SUBSCRIPTION : "exposes (planned)"
SUBSCRIPTION ||--o{ VIDEO : "produces (per user)"
VIDEO ||--o| TRANSCRIPT : "has at most one"
VIDEO ||--o| SUMMARY : "has at most one"
@@ -36,14 +41,20 @@ erDiagram
USER {
uuid id PK
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
}
VIDEO_CONNECTION {
uuid id PK
uuid user_id FK
uuid user_id FK "-> USER, ON DELETE CASCADE"
text provider "youtube | vimeo"
text provider_account
text token_secret_ref "-> SecretStore, never the token"
text provider_account "nullable"
text token_ref "-> SecretStore, never the token"
text status "active | revoked | error"
timestamptz connected_at
}
@@ -66,14 +77,15 @@ erDiagram
}
VIDEO {
uuid id PK
uuid user_id FK
uuid subscription_id FK
uuid user_id FK "-> USER, ON DELETE CASCADE"
uuid subscription_id "nullable; no FK at Stage 0"
text provider
text provider_video_id
text title
int duration_s
timestamptz published_at
text url
bool summarize_requested "default false -> manual-mode queue flag (migration 006)"
timestamptz seen_at
}
TRANSCRIPT {
@@ -87,7 +99,7 @@ erDiagram
SUMMARY {
uuid id PK
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
jsonb highlights
jsonb takeaways
@@ -98,26 +110,53 @@ erDiagram
}
SINK_DELIVERY {
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 status "pending | delivered | error"
text detail "nullable; error message etc"
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
- **USER** — at Stage 0 there is exactly one row. At Stage 1, identity comes via Dex; this
table holds the Tapir-side profile keyed to the Dex subject.
- **VIDEO_CONNECTION** — a connected YouTube/Vimeo account. `token_secret_ref` resolves to
the OAuth refresh token via `SecretStore`. Revocation flips `status`, doesn't delete history.
- **AI_CREDENTIAL** — optional, per provider, per user (ADR-004's Fallback). Absent for users
who only use the local stack. One row per provider max.
- **SUBSCRIPTION** — a watched channel. `websub_expires` tracks the YouTube push lease so the
watcher knows when to re-subscribe; null for poll-based (Vimeo).
- **USER** — one row per registered user (Stage 1, ADR-012; no longer single-row). The Tapir-side
profile; the Dex identity is held separately in `USER_IDENTITY`, not on this row. `auto_summarize`
(migration 006) is the per-user mode flag: `FALSE` (default) = manual, `TRUE` = auto-summarize
every new video.
- **USER_IDENTITY** (migration 004) — the `dex_subject → user_id` map. `dex_subject` is the PK,
`user_id` a `UNIQUE` FK to `users` with `ON DELETE CASCADE`. This is the bridge resolved at login
*before* a `user_id` is known, so it is **deliberately not RLS-enabled** (it holds no user data;
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
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" 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
@@ -125,14 +164,39 @@ erDiagram
`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"
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
per-tenant Postgres role + row grants (architecture review SC7), not only in application code.
At Stage 0 (single user) the column exists but the enforcement is dormant. The isolation test
in VISION Stage 2 asserts user A cannot read user B's rows.
Every user-owned table carries `user_id`, and isolation is **enforced at the DB layer**, not
only in application code. ADR-011 shipped this surface single-user (one allowlisted subject,
enforcement dormant); **ADR-012 opened Stage 1 and turned enforcement on in the same slice.**
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
@@ -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
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
// populated by ListVideos/GetVideoRow (summary-only reads leave it false).
SummarizeRequested bool
// TranscriptStatus mirrors videos.transcript_status (migration 007): "" (unset),
// "none", "rate_limited", or "fetched". Drives the "Retrying later" list badge.
// Only populated by ListVideos/GetVideoRow ("" on summary-only reads).
TranscriptStatus string
}
// selectSummary is the shared projection for both reads. videos is LEFT JOINed
@@ -132,7 +136,8 @@ const selectVideo = `
COALESCE(s.fallback_used, FALSE),
COALESCE(s.created_at, v.seen_at),
(s.id IS NOT NULL) AS summarized,
v.summarize_requested
v.summarize_requested,
COALESCE(v.transcript_status, '')
FROM videos v
LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id`
@@ -245,6 +250,7 @@ func scanVideoRow(rows pgx.Row) (SummaryRow, error) {
&row.CreatedAt,
&row.Summarized,
&row.SummarizeRequested,
&row.TranscriptStatus,
); err != nil {
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 {
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 {
// Owner-only 403, region/age gate, or transient unavailability: not an error.
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.
func TestFetchTranscriptEmptyBaseURLDegrades(t *testing.T) {
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 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 string
@@ -85,6 +91,7 @@ const (
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
defaultOAuthRedirectAddr = "localhost:8080"
defaultHTTPAddr = ":8080"
defaultFetchBackoff = time.Hour
)
// Load reads the environment into a Config, applying defaults. It does not
@@ -124,6 +131,12 @@ func Load() (Config, error) {
}
c.PollInterval = interval
backoff, err := durationOr("TAPIR_FETCH_BACKOFF", defaultFetchBackoff)
if err != nil {
return Config{}, err
}
c.FetchBackoff = backoff
return c, nil
}
+7
View File
@@ -45,6 +45,9 @@ func TestLoad_AppliesDefaults(t *testing.T) {
if c.PollInterval != 0 {
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) {
@@ -56,6 +59,7 @@ func TestLoad_ParsesValues(t *testing.T) {
"TAPIR_SUMMARIZER_TIMEOUT": "90s",
"TAPIR_DB_DSN": "postgres://x",
"TAPIR_POLL_INTERVAL": "10m",
"TAPIR_FETCH_BACKOFF": "30m",
})
c, err := Load()
@@ -77,6 +81,9 @@ func TestLoad_ParsesValues(t *testing.T) {
if c.PollInterval != 10*time.Minute {
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) {
+6
View File
@@ -18,6 +18,12 @@ type TranscriptSource string
const (
SourceCaptions TranscriptSource = "captions"
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.
+70 -3
View File
@@ -32,6 +32,12 @@ type VideoStore interface {
GetAutoSummarize(ctx context.Context, userID string) (bool, error)
RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error)
ClearSummarizeRequested(ctx context.Context, userID, videoID string) error
// RateLimitedVideoIDs maps the user's still-throttled videos to when they were
// rate-limited, so the loop can back off without re-hitting the caption endpoint.
RateLimitedVideoIDs(ctx context.Context, userID string) (map[string]time.Time, error)
// SetTranscriptStatus records the outcome of a transcript attempt: "none",
// "rate_limited" (stamps the backoff clock), or "fetched".
SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error
}
// Processor runs the core use case for a single video. *usecase.Engine
@@ -48,14 +54,35 @@ type Runner struct {
engine Processor
userID string
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.
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 {
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.
@@ -65,6 +92,7 @@ type Stats struct {
SkippedSeen int
SkippedNoText int
SkippedManual int // discovered but not queued, in manual mode
SkippedRateLimited int // 429'd previously and still inside the backoff window
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)
if err != nil {
return stats, fmt.Errorf("runner: list subscriptions: %w", err)
@@ -142,6 +182,15 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
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 {
time.Sleep(fetchDelay)
}
@@ -152,11 +201,28 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
continue
}
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:
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)
case res.Summary != nil:
stats.Summarized++
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "fetched"); err != nil {
errs = append(errs, fmt.Errorf("set fetched status %q: %w", v.ProviderVideoID, err))
stats.Errors++
}
// In manual mode the video was processed because it was queued;
// clear the flag so it is not re-summarized and the UI drops the
// "Queued" chip. (Auto mode never sets the flag.)
@@ -184,7 +250,8 @@ func (r *Runner) Loop(ctx context.Context, interval time.Duration) error {
r.log.Info("run pass complete",
"candidates", stats.Candidates, "summarized", stats.Summarized,
"skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
"skipped_manual", stats.SkippedManual, "errors", stats.Errors)
"skipped_manual", stats.SkippedManual, "skipped_rate_limited", stats.SkippedRateLimited,
"errors", stats.Errors)
if err != nil {
r.log.Warn("run pass had errors", "err", err)
}
+75
View File
@@ -5,6 +5,7 @@ import (
"io"
"log/slog"
"testing"
"time"
"github.com/stretchr/testify/require"
@@ -48,6 +49,8 @@ type fakeStore struct {
auto bool
requested map[string]bool
cleared []string
rateLimited map[string]time.Time // id -> when 429'd (seeds the backoff window)
statuses map[string]string // id -> last SetTranscriptStatus value
}
func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) {
@@ -80,6 +83,22 @@ func (f *fakeStore) ClearSummarizeRequested(_ context.Context, _, videoID string
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{}
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")
}
// 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) {
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
+9 -2
View File
@@ -45,6 +45,11 @@ type ProcessResult struct {
Video domain.Video
Skipped bool
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
}
@@ -59,7 +64,9 @@ func (e *Engine) ProcessNewVideo(ctx context.Context, v domain.Video) (ProcessRe
if !t.HasText() {
// No usable transcript: record the skip, produce no summary, deliver nothing
// (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)
@@ -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
+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
// 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)
// and the page/handler layer can be built independently: handlers depend only on
@@ -10,9 +11,10 @@ package web
import "net/http"
// User is the authenticated principal. Subject is the Dex subject used for the
// single-user allowlist (ADR-011); store operations key off the configured
// tapir user_id (UUID), not this subject.
// User is the authenticated principal. Subject is the Dex subject — the key for the
// user_identities lookup (ADR-012) that resolves to a tapir user_id (UUID); store
// 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 {
Subject string
Email string
+26 -3
View File
@@ -83,6 +83,7 @@ func (a *App) logger() *slog.Logger {
func (a *App) Router() http.Handler {
root := http.NewServeMux()
root.HandleFunc("GET /healthz", a.handleHealthz)
root.HandleFunc("GET /welcome", a.handleWelcome)
root.Handle("GET /static/", staticHandler())
root.Handle("/auth/", a.Auth.Routes())
@@ -117,6 +118,15 @@ func (a *App) Router() http.Handler {
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.
func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
@@ -145,11 +155,24 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
}
rows = f.apply(rows)
if isHTMX(r) {
a.render(w, r, summaryList(rows))
// hasConnected drives the empty state: a fresh account with a connection but
// no `tapir run` yet has zero rows, and we want it to read "connected, run
// tapir" rather than "nothing here". Only needed when the list is empty.
hasConnected := false
if len(rows) == 0 {
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
if err != nil {
a.serverError(w, r, "connections for user", err)
return
}
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).
+18 -4
View File
@@ -161,11 +161,11 @@ func (d *DexAuth) Middleware(h http.Handler) http.Handler {
}
sid, ok := d.sessionID(r)
if !ok {
d.redirectToLogin(w, r)
d.redirectUnauthenticated(w, r)
return
}
if _, ok := d.sessions.get(sid, d.now()); !ok {
d.redirectToLogin(w, r)
d.redirectUnauthenticated(w, r)
return
}
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.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) {
@@ -305,5 +319,5 @@ func (d *DexAuth) clearSessionCookie(w http.ResponseWriter) {
}
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)
}))
// The bare root sends an unauthenticated visitor to the public landing page.
rec := httptest.NewRecorder()
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, "/auth/login", rec.Header().Get("Location"))
}
@@ -280,7 +286,7 @@ func TestMiddlewarePublicPathsBypassAuth(t *testing.T) {
w.WriteHeader(http.StatusOK)
}))
for _, path := range []string{"/healthz", "/auth/login"} {
for _, path := range []string{"/healthz", "/welcome", "/auth/login"} {
rec := httptest.NewRecorder()
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
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)
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())
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie")
+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)
}
}
+48
View File
@@ -266,6 +266,32 @@ var (
tapirFrameHTML3 = tapirFrameHTML("~")
)
// welcomeHeroHTML is the static Charm-box tapir mascot on the public landing
// page — the same rounded purple box / pink tapir / mint accents as the spinner,
// but a single still frame with a friendly tagline instead of the animation.
// Built from the shared tapirLine helpers so the aesthetic stays in one place.
func welcomeHeroHTML() string {
top := tapirSpan(CharmPurple, "╭"+strings.Repeat("─", tapirInteriorW)+"╮")
bottom := tapirSpan(CharmPurple, "╰"+strings.Repeat("─", tapirInteriorW)+"╯")
lines := []string{
top,
tapirLine(tapirRun{s: " "}, tapirRun{s: "◆", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "tapir", color: CharmCream}),
tapirLine(),
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄▄▄▄▄", color: CharmPink}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄█▓▓▓▓█▄", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "∩", color: CharmMint}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "█▓(", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "◕ ◕", color: CharmMint}, tapirRun{s: ")▓█", color: CharmPink}, tapirRun{s: "──┘", color: CharmMint}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀█▓▓▓▓█▀", color: CharmPink}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "██▄▄██", color: CharmPink}),
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}),
tapirLine(),
tapirLine(tapirRun{s: " "}, tapirRun{s: "watch less, know more", color: CharmMint}),
bottom,
}
return strings.Join(lines, "\n")
}
var welcomeHero = welcomeHeroHTML()
// summarizeModeLabel names the current mode for display.
func summarizeModeLabel(auto bool) string {
if auto {
@@ -453,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: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; }
/* 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:active { transform: translateY(1px); }
@@ -464,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-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; }
/* 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; }
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
@@ -505,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 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 p { margin: var(--s3) 0 0; }
/* connected-but-empty: a distinct accent callout, not a muted blank state, so a
fresh account knows the next step is to run tapir, not "something is broken". */
.empty-connected { border-style: solid; border-color: var(--accent); background: var(--accent-weak); color: var(--fg); }
.empty-connected strong { color: var(--accent); }
/* flash / notification banner */
.flash { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); }
@@ -573,6 +611,16 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.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) {
main { padding: var(--s3) var(--s2); }
.filters { gap: var(--s2); }
+49 -5
View File
@@ -22,7 +22,7 @@ templ Layout(title string) {
<body>
<header>
<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>
<main>
{ children... }
@@ -31,6 +31,40 @@ templ Layout(title string) {
</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/
// failure, disconnect, delete, registration). An empty or unknown code renders
// 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
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival
// 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") {
@flashBanner(flash)
@filterForm(f)
<div id="summary-list">
@summaryList(rows)
@summaryList(rows, hasConnected)
</div>
}
}
@@ -76,12 +110,20 @@ templ filterForm(f Filter) {
// summaryList is the swappable list fragment: one card per video (summarized or
// not). Cards reflow to a single column on mobile; an empty list shows a friendly
// first-run state instead of a blank table.
templ summaryList(rows []store.SummaryRow) {
templ summaryList(rows []store.SummaryRow, hasConnected bool) {
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">
<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>
}
} else {
<ul class="cards">
for _, r := range rows {
@@ -122,6 +164,8 @@ templ VideoCard(r store.SummaryRow) {
if len(r.Actions) > 0 {
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
}
} else if r.TranscriptStatus == "rate_limited" {
<span class="chip chip-retry" title="Caption fetch was rate-limited; tapir will retry automatically."> Retrying later</span>
} else if r.SummarizeRequested {
<span class="chip">Queued</span>
<span class="card-state muted">waiting for the next run</span>
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")
}