Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:45:29 +02:00
mathias 477701fea2 docs: revise Stage 0 gate to "useful to me or a friend" (behavioural)
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 10s
Replaces the original "useful to me, specifically" gate with "me OR a friend
returns unprompted in >=2 separate weeks" — friendly-user signal counts, but the
test stays behavioural (return usage) not feedback-based, to resist politeness
bias. Folds the old Stage 1 ("a trusted user returns") into the new Stage 0 (they
were near-identical), renumbers hardening to Stage 1, and updates the drift
signals (the gate can be softened by mistaking polite feedback for evidence;
multi-user shipping ahead of the gate was a recorded exception per ADR-012, not a
precedent). Rationale recorded in ADR-016.
2026-06-03 20:44:19 +00:00
mathias 17fad140a6 docs: add ADR-015 — per-user credentials envelope-encrypted in PG18
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
Records the infra#88 spike decision: per-user OAuth tokens are runtime app-state,
not config, so they live envelope-encrypted in PG18 under RLS (key from 1P via the
existing read-only SA) rather than in the vault. Infra creds stay ESO/1Password —
two mechanisms because they're two different things. Includes the falsification
conditions (frequent rotation; estate audit policy; key-rotation cost) so the
choice is earned not assumed. Adds the two rejected candidates (vault-write SA;
Supabase) to the rejected-alternatives table. Full reasoning in the #88 decision
doc; build + reboot-validation in #89.
2026-06-03 20:29:50 +00:00
40 changed files with 2362 additions and 293 deletions
+10
View File
@@ -46,3 +46,13 @@ TAPIR_SECRETS_FILE=
# --- run loop ------------------------------------------------------------- # --- run loop -------------------------------------------------------------
# Empty/0 = single pass. Set (e.g. 15m) to poll on that cadence. # Empty/0 = single pass. Set (e.g. 15m) to poll on that cadence.
TAPIR_POLL_INTERVAL= TAPIR_POLL_INTERVAL=
# How long to wait before re-fetching a transcript that returned HTTP 429
# (rate_limited). Inside the window the video is skipped without hitting the
# caption endpoint; after it expires the video is retried. 0 = always retry.
# Go duration; default 1h.
TAPIR_FETCH_BACKOFF=
# --- invitations (tapir invite) -------------------------------------------
# Public base URL used to build the invite link `tapir invite <email>` prints.
# Default https://tapir.d-ma.be; no trailing slash needed.
TAPIR_PUBLIC_URL=
+104
View File
@@ -388,6 +388,107 @@ the rate gate (the batch path moves into k3s per the deferred CronJob item).
--- ---
## ADR-015 — Per-user credentials: envelope-encrypted in PG18, not vault-stored
**Status:** Accepted (2026-06-03)
**Context.** The Stage-0/1 SecretStore (`internal/adapters/secrets/file.go`) holds per-user
YouTube OAuth refresh tokens as a flat key-value JSON map on a PVC — explicitly a stand-in for
"op/ESO later" (ADR-002, ADR-006). infra#86 proposed migrating it to an ESO-backed store. The
decision spike (infra#88) found that framing subtly wrong: **ESO syncs vault→cluster at
deploy/refresh time; it is not a runtime write API.** Per-user tokens are written *at runtime,
per end-user* (every YouTube connect; on token rotation) — they are application state, not
configuration. The homelab 1Password SA is also read-only, so a vault-write path would require
a new write-capable SA, widening Tapir's blast radius to shared estate infra to store what is
fundamentally Tapir's own row-data. Reading the actual SecretStore confirmed the shape: a
3-method port (`Get`/`Put`/`Delete`) over opaque refs, written interactively per user.
**Decision.** Per-user credentials are stored **envelope-encrypted in PG18**, not in the vault:
1. Tokens are encrypted with a **single app-level envelope key** and stored as ciphertext in
PG18, under the Row-Level Security already enforced and tested (ADR-012). Reads/writes go
through the existing `withUser` RLS-scoped seam.
2. The **envelope key** is the only secret in 1Password — fetched via the **existing read-only
SA** (confirmed working). No new write-capable SA; no per-user vault items.
3. The `ports.SecretStore` port is unchanged (`Get`/`Put`/`Delete`). The implementation swaps
`FileStore` (PVC JSON) for a `PGStore` (encrypted rows). Every consumer — connect,
disconnect, delete-account — is untouched (the port abstraction holds, ADR-003 spirit).
4. **Infra/operator credentials** (Dex client secret, MCP-auth tokens, service tokens) stay an
**ESO/1Password** concern. This ADR governs *per-user runtime* credentials only. The two
classes use two mechanisms deliberately — because they are two different things (runtime
app-state vs deploy-time config), not as a compromise. The "one mechanism" question
(maintainer's initial preference) was answered in #88 by correctly *classifying* the
secrets rather than unifying their storage.
**Consequences.**
- Runtime credential writes are normal RLS'd DB writes — no ESO sync latency, no indirection,
no write-SA blast radius. The interactive connect→store→use flow works without a vault
round-trip.
- Keeps PG18 and keeps ADR-002 intact (Supabase was considered and rejected again in #88
adding a datastore to hold a few encrypted strings PG18 already holds).
- Adds an encrypt/decrypt seam and an **envelope-key rotation** responsibility (re-encrypt the
per-user rows under a new key). infra#89 (build) must implement and test rotation, not assume
it — this is the real engineering cost of the choice.
- The vault's involvement shrinks to one static key via the SA already trusted for reads.
- **Supersedes** the "PVC stand-in for op/ESO" intent recorded in `secrets/file.go` and
`docs/homelab-integration.md` for the *per-user* secret path (the ESO/1Password reference in
ADR-006 stands for the *infra-cred* path).
**Reversibility / falsification (from infra#88).** Revisit if: per-user tokens need
high-frequency rotation writes (weak — PG18 handles it); an estate compliance policy requires
all credentials in 1P for a single audit surface (maintainer-knowable, not currently believed
to hold — would favour the vault-write path on policy grounds); or envelope-key rotation proves
operationally worse than per-secret vault rotation (the real cost #89 must prove). If none hold,
this stands. Full reasoning + rejected candidates (write-capable SA; Supabase): the infra#88
decision doc (`infra/docs/superpowers/handoffs/`). Build + reboot-validation: infra#89.
---
## ADR-016 — Stage 0 gate revised: "useful to me OR a friend", behavioural not feedback
**Status:** Accepted (2026-06-03). Revises the Stage 0 definition in VISION.md (supersedes the
original "useful to me, specifically" gate and folds in the old Stage 1 "a trusted user returns"
test).
**Context.** The original Stage 0 gate was "the maintainer reads summaries weekly for four weeks
and acts on one." The maintainer chose to change it to include friendly users, reasoning that
early signal from friendly users is valuable. Two sub-decisions shaped the final form:
- *Me OR a friend* (not AND): either the maintainer or an onboarded friend showing use clears it.
- *Behavioural, not feedback*: the test is **return usage**, not stated approval.
**Decision.** Stage 0 passes when, over a 34 week window, **either the maintainer or at least
one onboarded friend returns to Tapir unprompted and reads/acts on summaries in ≥2 separate
weeks.** Friend feedback is gathered and valued but is **not** the gate.
**Why behavioural, not feedback (the load-bearing part).** Asked-for feedback from friendly
users is the least reliable signal in product development — politeness bias means a friend you
onboarded will tend to say encouraging things regardless of real value. The thing actually worth
knowing is whether they *come back on their own*. So the gate measures returns, not nice words.
This deliberately resists the most common way a principled gate dies: being declared "passed" on
the strength of a polite reaction.
**Honest note on what this change does.** This is a *guardrail edit made while the original gate
was unmet* (Stage 0 had barely started; build had run well ahead of use-evidence). That is
precisely the pattern that warrants scrutiny — redrawing a gate around work already done. It was
examined on that basis and proceeds because: (a) the new gate is **not softer in kind** — it
stays behavioural and sustained, merely broadening *who* can supply the signal; (b) friendly-user
signal is genuinely valuable; (c) the politeness-bias guard keeps it from collapsing into
"someone said it's nice." It is *not* a licence to treat the already-shipped Stage-1 machinery as
evidence the gate passed — use-evidence remains open.
**Consequences.**
- VISION.md Stage 0 rewritten; old Stage 1 ("a trusted user returns") folded in (it was
near-identical to the new test); hardening renumbered to Stage 1.
- New drift signal added: declaring the gate passed on polite feedback rather than return-usage.
- The 2026-07-01 check-in now asks "is anyone (me or a friend) coming back unprompted?", not
"am I using it weekly?".
**Reversibility.** A superseding ADR could tighten it back to maintainer-only or raise it to
require multiple returning users. Recorded with the full rationale (including the self-scrutiny
about editing a gate while it's unmet) so the reasoning survives, not just the new wording.
---
## Rejected alternatives ## Rejected alternatives
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
@@ -406,6 +507,9 @@ maps to the ADR that settles it.
| Audio-download + Whisper STT in the core path | ToS-grey, breakage-prone (yt-dlp), contends for koala GPU with the JEPA PoC; captions alone test the core hypothesis | ADR-007 | | Audio-download + Whisper STT in the core path | ToS-grey, breakage-prone (yt-dlp), contends for koala GPU with the JEPA PoC; captions alone test the core hypothesis | ADR-007 |
| Building multi-tenant SaaS / Google OAuth verification now | "Real users soon" was lowered to Future B; SaaS machinery before the Stage 0 self-use gate is the primary documented anti-goal | ADR-008, VISION | | Building multi-tenant SaaS / Google OAuth verification now | "Real users soon" was lowered to Future B; SaaS machinery before the Stage 0 self-use gate is the primary documented anti-goal | ADR-008, VISION |
| Delegating the S5 reuse spike to an agent swarm | A 1-hour sequential read-and-judge with a single coupled conclusion; orchestration overhead exceeds the work, and it's Diamond-1 judgment the maintainer wanted to own | (process note) | | Delegating the S5 reuse spike to an agent swarm | A 1-hour sequential read-and-judge with a single coupled conclusion; orchestration overhead exceeds the work, and it's Diamond-1 judgment the maintainer wanted to own | (process note) |
| Vault-write SA for per-user OAuth tokens (ESO as runtime write path) | ESO syncs vault→cluster at deploy time, not a runtime write API; a write-SA widens blast radius to shared infra to store app row-data | ADR-015, infra#88 |
| Supabase for per-user credential storage | Adds a second datastore for a few encrypted strings PG18 already holds; reopens ADR-002 | ADR-015, infra#88 |
| Feedback-based Stage 0 gate (friends saying it's useful) | Politeness bias makes asked-for feedback the least reliable signal; return-usage is the real test | ADR-016 |
If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one — If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one —
not a silent reversal. not a silent reversal.
+50 -27
View File
@@ -44,50 +44,69 @@ fallback — their key, their choice.
## Who it is for ## Who it is for
- **Now (the first customer):** the maintainer — one person, their own subscriptions, - **Now (the first customers):** the maintainer and a small number of known, trusted
summaries delivered to their own store and brain. friends — each with their own account, isolated data, optional BYO-AI. The maintainer is
- **Soon (Future B):** a small number of known, trusted users (friends / beta) — each with the first customer; friendly users provide the earliest real-world signal.
their own account, isolated data, optional BYO-AI.
- **Maybe (Future C, explicitly not built yet):** a public multi-tenant service. Deferred - **Maybe (Future C, explicitly not built yet):** a public multi-tenant service. Deferred
until there is evidence of sustained personal use **and** real demand. Building for C until there is evidence of sustained use **and** real demand. Building for C before that
before that evidence is a known anti-goal. evidence is a known anti-goal.
## Definition of Success ## Definition of Success
Success is staged. Each stage has a single, falsifiable headline test. We do not advance Success is staged. Each stage has a single, falsifiable headline test. We do not advance
to the next stage's ambition until the current stage's test passes. to the next stage's ambition until the current stage's test passes.
### Stage 0 — Useful to me (the gate) ### Stage 0 — Useful to me or a friend (the gate)
> **Headline test:** For four consecutive weeks, the maintainer reads Tapir-produced > **Headline test:** Over a 34 week window, *either* the maintainer *or* at least one
> summaries for their own subscriptions at least weekly, and at least once acts on a > onboarded friend returns to Tapir and reads/acts on summaries in **≥2 separate weeks**.
> summary (watches / skips / saves a video *because of* the summary). > The test is *return usage* (behavioural), not stated approval. The ideal signal is an
> **unprompted** return (organic, not because the maintainer nudged them) — but see the
> measurement note below: we currently cannot distinguish prompted from organic returns, so
> in practice we count all returns and read the result with that caveat.
- Captions-first summarization works end-to-end for the maintainer's real subscriptions. - Captions-first summarization works end-to-end for real subscriptions (the maintainer's
- Summaries land in the maintainer's store and (optionally) brain. and onboarded friends').
- Summaries land in each user's own store and (optionally) brain.
- Local-first AI produces summaries of acceptable quality without manual intervention - Local-first AI produces summaries of acceptable quality without manual intervention
most of the time. most of the time.
- **This is the gate.** Multi-user, BYO-AI-for-others, and any SaaS ambition stay deferred - **Why behavioural, not feedback.** Friend *feedback* is gathered and genuinely valuable —
until Stage 0 holds. (Ties to the 2026-07-01 self-use check-in.) but it is **not** the gate. Asked-for feedback from friendly users is the least reliable
signal in product development (politeness bias); whether they *come back* is the thing we
actually care about. So the gate measures returns, not nice words.
- **Measurement note — "unprompted" is an ideal we can't yet measure.** Whether a return was
organic or prompted by a nudge is not captured by any data Tapir holds (it's context only
the maintainer has). Rather than waive the standard, we name the gap: *unprompted* return
is the signal we genuinely want; *returns* (prompted or not) is what the data can show. A
return that needed a nudge is a weaker signal than one that didn't, and the result is read
with that in mind. If distinguishing them ever matters enough, the maintainer tracks nudges
manually or a future build records prompt events — neither is in scope now.
- **Why "me OR a friend".** This replaces the original "useful to *me*, specifically" gate
(2026-06-03 decision, recorded in DECISIONS.md ADR-016). Getting signal from friendly
users is valuable enough to count — but the bar stays behavioural so it can't be cleared
by a polite reaction. (Ties to the 2026-07-01 check-in.)
- **How it's measured.** Return usage is read from two sources: `summary_actions` (timestamped
watch/skip/save per user) answers "acted in ≥2 distinct weeks"; an append-only login-events
table (see infra/Tapir build) answers "returned/read in ≥2 distinct weeks" even without an
action click — the honest signal for a *reading* product. Login events accrue only from their
deploy date onward, so the gate window's data begins then.
- **This is the gate.** Hardening (Stage 1) and any SaaS ambition stay deferred until this
behavioural signal exists. Note: multi-user machinery was deliberately built *ahead* of
this gate (ADR-012) with isolation enforced — that was an explicit, recorded call, not a
sign the gate had passed. The gate is about *evidence of use*, which is still open.
### Stage 1 — Useful to a few (Future B) ### Stage 1 — Trustworthy at rest (hardening, Future B)
> **Headline test:** At least one trusted user other than the maintainer connects their
> own account and, within their first month, keeps using it (returns to read summaries in
> ≥2 separate weeks) without the maintainer hand-holding each summary.
- Multiple users, each with isolated accounts, credentials, and summaries.
- A new user can self-connect a YouTube/Vimeo account and get summaries with no code change.
- Optional BYO-AI works per-user.
- No cross-user data leakage — demonstrable, not assumed.
### Stage 2 — Trustworthy at rest (hardening, still Future B)
> **Headline test:** Credentials (OAuth tokens, BYO-AI keys) are encrypted at rest via the > **Headline test:** Credentials (OAuth tokens, BYO-AI keys) are encrypted at rest via the
> homelab's existing secrets convention; a documented, rehearsed recovery path exists; and > homelab's existing secrets convention; a documented, rehearsed recovery path exists; and
> a deliberate isolation test (user A cannot read user B's data) passes in CI or a > a deliberate isolation test (user A cannot read user B's data) passes in CI or a
> documented manual drill. > documented manual drill.
- Per-user data isolation is enforced and tested (delivered early via ADR-012 RLS).
- Per-user credentials are encrypted at rest (ADR-015 envelope encryption; build in infra#89).
- A new user can self-connect a YouTube/Vimeo account and get summaries with no code change.
- Optional BYO-AI works per-user.
### Non-goals (current) ### Non-goals (current)
- Public sign-up / billing / a marketing surface. - Public sign-up / billing / a marketing surface.
@@ -98,7 +117,11 @@ to the next stage's ambition until the current stage's test passes.
## How we will know we are drifting ## How we will know we are drifting
- We are building Stage 1+ machinery before the Stage 0 gate has passed. - We declare the Stage 0 gate "passed" on the strength of polite feedback rather than
behavioural return-usage (the politeness-bias trap the gate is designed to resist).
- We build Stage 1 hardening or Future C machinery while the Stage 0 use-evidence is still
absent. (Multi-user machinery already shipped ahead of the gate via ADR-012 — a recorded,
deliberate exception, not a precedent for more.)
- A user's content reaches a third-party model without that user's explicit, per-user opt-in. - A user's content reaches a third-party model without that user's explicit, per-user opt-in.
- "Brain ingestion" starts dictating the architecture instead of being one sink behind an - "Brain ingestion" starts dictating the architecture instead of being one sink behind an
interface. interface.
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/config"
)
// inviteTTL is how long a minted invite stays claimable. A week is generous for a
// human to act on an emailed link without leaving a stale capability around.
const inviteTTL = 7 * 24 * time.Hour
// inviter is the narrow store capability cmdInvite needs — minting an invitation.
// Defined here (not store) so runInvite is testable with a fake, no Postgres.
type inviter interface {
CreateInvitation(ctx context.Context, email string, ttl time.Duration) (string, error)
}
// cmdInvite mints an invitation for an email and prints the claim URL. Host-side
// only (no Dex session): the operator runs it, copies the link, and sends it.
// Usage: tapir invite <email>.
func cmdInvite(ctx context.Context, args []string) error {
if len(args) < 1 || strings.TrimSpace(args[0]) == "" {
return fmt.Errorf("usage: tapir invite <email>")
}
email := strings.TrimSpace(args[0])
cfg, err := config.Load()
if err != nil {
return err
}
if strings.TrimSpace(cfg.DBDSN) == "" {
return fmt.Errorf("missing required config: TAPIR_DB_DSN")
}
st, err := store.New(ctx, cfg.DBDSN)
if err != nil {
return err
}
defer st.Close()
return runInvite(ctx, st, os.Stdout, cfg.PublicURL, email)
}
// runInvite is the testable core: mint the token and print the absolute claim URL
// to w. Pure of config/store construction so a fake inviter exercises it.
func runInvite(ctx context.Context, inv inviter, w io.Writer, publicURL, email string) error {
token, err := inv.CreateInvitation(ctx, email, inviteTTL)
if err != nil {
return fmt.Errorf("create invitation: %w", err)
}
base := strings.TrimRight(strings.TrimSpace(publicURL), "/")
_, err = fmt.Fprintf(w, "Invite URL (valid 7 days):\n%s/invite/%s\n", base, token)
return err
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// fakeInviter records the mint call and returns a canned token.
type fakeInviter struct {
token string
err error
gotEmail string
gotTTL time.Duration
callCount int
}
func (f *fakeInviter) CreateInvitation(_ context.Context, email string, ttl time.Duration) (string, error) {
f.callCount++
f.gotEmail, f.gotTTL = email, ttl
return f.token, f.err
}
func TestRunInvitePrintsURL(t *testing.T) {
inv := &fakeInviter{token: "deadbeefcafe"}
var out strings.Builder
err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be", "new@example.com")
require.NoError(t, err)
require.Equal(t, "new@example.com", inv.gotEmail)
require.Equal(t, inviteTTL, inv.gotTTL)
got := out.String()
require.Contains(t, got, "https://tapir.d-ma.be/invite/deadbeefcafe")
require.Contains(t, got, "valid 7 days")
}
func TestRunInviteTrimsTrailingSlash(t *testing.T) {
inv := &fakeInviter{token: "tok"}
var out strings.Builder
err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be/", "x@example.com")
require.NoError(t, err)
require.Contains(t, out.String(), "https://tapir.d-ma.be/invite/tok")
require.NotContains(t, out.String(), "//invite")
}
func TestRunInvitePropagatesError(t *testing.T) {
inv := &fakeInviter{err: errors.New("db down")}
var out strings.Builder
err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be", "x@example.com")
require.Error(t, err)
require.Empty(t, out.String())
}
+20 -2
View File
@@ -22,6 +22,7 @@ import (
"os/signal" "os/signal"
"time" "time"
"gitea.d-ma.be/mathias/tapir/internal/adapters/dex"
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets" "gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store" "gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/auth" "gitea.d-ma.be/mathias/tapir/internal/auth"
@@ -53,6 +54,8 @@ func main() {
err = cmdRun(ctx, log) err = cmdRun(ctx, log)
case "serve": case "serve":
err = cmdServe(ctx, log) err = cmdServe(ctx, log)
case "invite":
err = cmdInvite(ctx, os.Args[2:])
default: default:
usage() usage()
os.Exit(2) os.Exit(2)
@@ -71,6 +74,7 @@ usage:
tapir auth one-time: authorize YouTube and store a refresh token tapir auth one-time: authorize YouTube and store a refresh token
tapir run detect new videos, summarize, deliver to your store tapir run detect new videos, summarize, deliver to your store
tapir serve run the web UI (read summaries, record watch/skip/save) tapir serve run the web UI (read summaries, record watch/skip/save)
tapir invite <email> mint an invitation link for a new user (host-side)
tapir list [-limit N] list stored summaries, recent first tapir list [-limit N] list stored summaries, recent first
tapir show <video-id> show one summary in full tapir show <video-id> show one summary in full
@@ -125,10 +129,10 @@ func cmdRun(ctx context.Context, log *slog.Logger) error {
if engine == nil { if engine == nil {
return fmt.Errorf("run: incomplete summarization config (gateway, youtube credentials, secrets file)") 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, log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval) "gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval, "fetch_backoff", cfg.FetchBackoff)
return r.Loop(ctx, cfg.PollInterval) return r.Loop(ctx, cfg.PollInterval)
} }
@@ -179,6 +183,20 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
secretStore := secrets.NewFileStore(cfg.SecretsFile) secretStore := secrets.NewFileStore(cfg.SecretsFile)
app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log} app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log}
// Email-invite onboarding (public /invite/{token}). The store validates and
// consumes tokens; the Dex client creates the local-password account. In-cluster
// the SA token mount is present and account creation works; off-cluster (dev) it
// is nil and the submit handler degrades to a clear "deployed-only" message.
app.Invitations = st
if dexClient, err := dex.NewPasswordClient(); err == nil {
app.Dex = dexClient
log.Info("invite account creation enabled (in-cluster dex password client)")
} else if errors.Is(err, dex.ErrNotInCluster) {
log.Warn("invite account creation disabled: not in-cluster — /invite is deployed-only")
} else {
return fmt.Errorf("dex password client: %w", err)
}
// Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client // Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client
// credentials are present; the refresh token persists through the SecretStore // credentials are present; the refresh token persists through the SecretStore
// under a per-user ref (web.YouTubeTokenRef). Live connect also needs the // under a per-user ref (web.YouTubeTokenRef). Live connect also needs the
+77
View File
@@ -0,0 +1,77 @@
# Spec — Stage 0 usage measurement (login events)
**Date:** 2026-06-03
**Status:** Ready to build · **Repo:** tapir · **Size:** small (one migration + middleware + query)
**Why:** The Stage 0 gate (VISION, ADR-016) is *return usage in ≥2 separate weeks*. `summary_actions`
captures *acts* (watch/skip/save) but not *reads* — a friend who logs in weekly and reads summaries
without clicking anything is invisible. For a **reading** product that is the most important signal.
This adds the missing data so the gate is measurable as written. Solo session, not a swarm.
Read `CLAUDE.md` + ADR-016 first. TBD, conventional commits, `task check` green before each commit.
## Scope (resist sprawl — this is NOT analytics)
A lightweight, append-only record of *when each user was active*, enough to answer
"returned/read in ≥N distinct weeks". Not page-level events, not click tracking, not a funnel.
### 1. Migration — `login_events` (append-only)
```
login_events (
id UUID PK default gen_random_uuid(),
user_id UUID NOT NULL, -- per-user; RLS like every user-owned table
seen_at TIMESTAMPTZ NOT NULL default NOW()
)
INDEX (user_id, seen_at)
```
- **RLS:** `FORCE ROW LEVEL SECURITY`, same policy/pattern as the other user-owned tables (the
`tapir.current_user_id` GUC via the `withUser` seam — match migration 003). A reporting query that
needs cross-user counts runs as the owner/maintainer outside the per-user scope, or via a dedicated
read — decide consistently with how existing admin-ish reads are done.
- Append-only: no updates, no deletes except the user-delete cascade. **Add to the delete-account
cascade** (ADR-013) — `login_events` has no FK (mirrors `summary_actions`), so `DeleteUser` needs an
explicit delete for it, and the delete test must assert it's covered. *Do not forget this* — it's the
exact footgun the last delete work caught.
### 2. Middleware — throttled stamp
- In the authenticated request path (after `CurrentUserID` resolves, inside the registration-gated
app — NOT on `/welcome`/`/healthz`/`/auth`), record one `login_events` row **per user per day**
(throttle: skip if a row exists for this user with `seen_at` ≥ start-of-today). One insert per active
day, not per request — keeps the table small and the signal clean.
- Throttle check must itself be RLS-scoped (`withUser`). Keep it cheap (indexed lookup).
### 3. Query — the gate report
Provide a query (and optionally a tiny `tapir report` CLI subcommand or an admin page — your call,
CLI is fine) answering, per user:
```sql
-- distinct active weeks from reads (login_events) AND acts (summary_actions), unioned
WITH weeks AS (
SELECT user_id, date_trunc('week', seen_at) AS wk FROM login_events
UNION
SELECT user_id, date_trunc('week', acted_at) FROM summary_actions
)
SELECT user_id, COUNT(DISTINCT wk) AS active_weeks
FROM weeks GROUP BY user_id
ORDER BY active_weeks DESC;
```
Gate passes when any user_id (maintainer or friend) reaches `active_weeks >= 2` within the window.
## Honesty caveats to carry (from VISION/ADR-016)
- **"Unprompted" is not measurable here.** login_events records *that* a user returned, not *why*. A
nudged return looks identical to an organic one. This build does not close that gap and must not
claim to — the VISION measurement note stands: count returns, read a nudged return as weaker signal.
(If prompt-tracking is ever wanted, that's a separate decision, not this build.)
- **Data accrues from deploy onward.** The gate window's read-data starts when this ships — so ship
soon (maintainer's call) rather than batching with the infra tooling session.
- **`date_trunc('week')` is ISO/timezone-sensitive** and noisy at low volume (N=3). Two visits days
apart can fall in the same or different weeks. Acceptable, but don't over-read a single-week-margin
pass/fail.
## Out of scope
Page/event analytics; prompt-vs-organic tracking; dashboards beyond the one gate query; anything
touching the engine or sinks (this is web/store only — ADR-003 holds).
## Tests
- Migration up/down; RLS on `login_events` (extend the two-user isolation test to cover it).
- Throttle: N requests same day → 1 row; next day → 2nd row.
- `DeleteUser` removes the user's `login_events` and leaves others' intact (extend the delete test).
- The gate query returns correct distinct-week counts across a seeded reads+acts fixture.
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+1
View File
@@ -10,6 +10,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.45.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
) )
+2
View File
@@ -91,6 +91,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+180
View File
@@ -0,0 +1,180 @@
// Package dex creates Dex local-password accounts by writing
// passwords.dex.coreos.com custom resources directly against the in-cluster
// Kubernetes API. This is the write side of the invite flow: a recipient sets a
// password on /invite/{token}, Tapir bcrypt-hashes it and POSTs a Password CR into
// the auth namespace, and Dex (configured with kubernetes storage) then serves
// local-password login for that email.
//
// Why the raw API and not kubectl/client-go: the deployed pod already carries a
// service-account token and the cluster CA at the well-known mount paths, so a
// single net/http POST needs no extra dependency and no shelling out. Standalone /
// dev has no such mount — NewPasswordClient returns ErrNotInCluster and the web
// handler degrades gracefully (account creation only works in the deployed env).
package dex
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"time"
)
// Sentinel errors let the web handler turn API outcomes into clear user messages.
var (
// ErrNotInCluster means the service-account token mount is absent, so there is
// no in-cluster API to talk to (local dev / tests). Construction-time only.
ErrNotInCluster = errors.New("dex: not running in-cluster (no service-account token)")
// ErrPasswordExists maps the API's 409 Conflict — a Password CR for this email
// already exists. The handler treats it as a benign "log in instead".
ErrPasswordExists = errors.New("dex: password already exists")
// ErrForbidden maps 401/403 — the tapir ServiceAccount lacks create/get on
// passwords.dex.coreos.com in the auth namespace (RBAC not applied).
ErrForbidden = errors.New("dex: forbidden — missing RBAC for passwords.dex.coreos.com")
)
// Well-known in-cluster service-account mount paths (projected by kubelet).
const (
saTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // path, not a secret
saCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
// apiServer is the in-cluster API endpoint; its TLS is validated against the
// mounted cluster CA.
apiServer = "https://kubernetes.default.svc"
// passwordsPath is the Dex Password collection in the auth namespace.
passwordsPath = "/apis/dex.coreos.com/v1/namespaces/auth/passwords"
)
// PasswordClient writes Dex Password CRs against the in-cluster API. Construct it
// with NewPasswordClient; the zero value is not usable.
type PasswordClient struct {
server string
token string
http *http.Client
}
// NewPasswordClient reads the service-account token and cluster CA from the
// well-known mount paths and returns a client that authenticates as the pod's
// ServiceAccount. It returns ErrNotInCluster when the token mount is absent (dev /
// tests / standalone), so callers can detect "no Dex available" and degrade.
func NewPasswordClient() (*PasswordClient, error) {
token, err := os.ReadFile(saTokenPath)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotInCluster
}
if err != nil {
return nil, fmt.Errorf("dex: read service-account token: %w", err)
}
caPEM, err := os.ReadFile(saCAPath)
if err != nil {
return nil, fmt.Errorf("dex: read cluster CA: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, errors.New("dex: cluster CA is not valid PEM")
}
hc := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
},
}
return newClient(apiServer, strings.TrimSpace(string(token)), hc), nil
}
// newClient is the injectable constructor shared by NewPasswordClient and tests
// (which point server at an httptest.Server and pass its TLS client).
func newClient(server, token string, hc *http.Client) *PasswordClient {
return &PasswordClient{server: server, token: token, http: hc}
}
// password is the wire form of a Dex Password CR. NOTE: Dex's kubernetes storage
// types the hash as []byte, which Kubernetes JSON-marshals as base64. So the
// `hash` field must carry the base64 encoding of the bcrypt string, NOT the raw
// bcrypt string — store the raw string and Dex's base64-decode on login yields
// garbage and every login fails. CreatePassword does that encoding.
type password struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata map[string]string `json:"metadata"`
Email string `json:"email"`
Hash string `json:"hash"`
Username string `json:"username"`
UserID string `json:"userID"`
}
// CreatePassword creates a Dex local-password account for email with the given
// bcrypt hash and Dex user id. The CR name is derived from the email so it is a
// valid, stable, idempotent Kubernetes object name. Returns ErrPasswordExists on
// 409 (the account already exists) and ErrForbidden on 401/403 (RBAC missing).
func (c *PasswordClient) CreatePassword(ctx context.Context, email, bcryptHash, userID string) error {
body, err := json.Marshal(password{
APIVersion: "dex.coreos.com/v1",
Kind: "Password",
Metadata: map[string]string{"name": passwordName(email), "namespace": "auth"},
Email: email,
// base64 of the bcrypt string — see the password type's NOTE.
Hash: base64.StdEncoding.EncodeToString([]byte(bcryptHash)),
Username: email,
UserID: userID,
})
if err != nil {
return fmt.Errorf("dex: marshal password: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.server+passwordsPath, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("dex: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("dex: create password: %w", err)
}
defer func() { _ = resp.Body.Close() }()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK:
return nil
case http.StatusConflict:
return ErrPasswordExists
case http.StatusUnauthorized, http.StatusForbidden:
return ErrForbidden
default:
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("dex: create password: unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet)))
}
}
// invalidNameChars matches anything not allowed in an RFC-1123 subdomain segment
// after the explicit @/. substitutions, so any stray character becomes '-'.
var invalidNameChars = regexp.MustCompile(`[^a-z0-9-]`)
// passwordName maps an email to a valid, deterministic Kubernetes object name:
// lowercase, '@' -> '-at-', '.' -> '-dot-', any remaining invalid char -> '-',
// with leading/trailing '-' trimmed. Deterministic so a re-invite targets the
// same CR (and so Dex's 409 is meaningful).
func passwordName(email string) string {
n := strings.ToLower(strings.TrimSpace(email))
n = strings.ReplaceAll(n, "@", "-at-")
n = strings.ReplaceAll(n, ".", "-dot-")
n = invalidNameChars.ReplaceAllString(n, "-")
n = strings.Trim(n, "-")
if n == "" {
n = "user"
}
return n
}
+104
View File
@@ -0,0 +1,104 @@
package dex
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
// newTestClient points a PasswordClient at an httptest server, using that
// server's TLS client so the in-cluster TLS path is exercised without a real CA.
func newTestClient(srv *httptest.Server) *PasswordClient {
return newClient(srv.URL, "test-token", srv.Client())
}
func TestCreatePasswordSuccess(t *testing.T) {
var gotAuth, gotPath, gotMethod string
var gotBody password
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth, gotPath, gotMethod = r.Header.Get("Authorization"), r.URL.Path, r.Method
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"kind":"Password"}`))
}))
defer srv.Close()
err := newTestClient(srv).CreatePassword(context.Background(),
"New.User@Example.com", "$2a$12$abcdefghijklmnopqrstuv", "user-uuid-1")
require.NoError(t, err)
require.Equal(t, http.MethodPost, gotMethod)
require.Equal(t, passwordsPath, gotPath)
require.Equal(t, "Bearer test-token", gotAuth)
// Email/username carry the raw address; the CR name is sanitised + lowercased.
require.Equal(t, "New.User@Example.com", gotBody.Email)
require.Equal(t, "New.User@Example.com", gotBody.Username)
require.Equal(t, "user-uuid-1", gotBody.UserID)
require.Equal(t, "new-dot-user-at-example-dot-com", gotBody.Metadata["name"])
require.Equal(t, "auth", gotBody.Metadata["namespace"])
// The hash is the BASE64 of the bcrypt string (Dex stores hash as []byte).
decoded, err := base64.StdEncoding.DecodeString(gotBody.Hash)
require.NoError(t, err)
require.Equal(t, "$2a$12$abcdefghijklmnopqrstuv", string(decoded))
}
func TestCreatePasswordConflict(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
}))
defer srv.Close()
err := newTestClient(srv).CreatePassword(context.Background(), "dup@example.com", "$2a$12$x", "u")
require.ErrorIs(t, err, ErrPasswordExists)
}
func TestCreatePasswordForbidden(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
err := newTestClient(srv).CreatePassword(context.Background(), "x@example.com", "$2a$12$x", "u")
require.ErrorIs(t, err, ErrForbidden)
}
func TestCreatePasswordUnexpectedStatus(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
err := newTestClient(srv).CreatePassword(context.Background(), "x@example.com", "$2a$12$x", "u")
require.Error(t, err)
require.NotErrorIs(t, err, ErrPasswordExists)
require.NotErrorIs(t, err, ErrForbidden)
require.Contains(t, err.Error(), "500")
}
func TestNewPasswordClientNotInCluster(t *testing.T) {
// In the test environment the SA token mount does not exist.
_, err := NewPasswordClient()
require.ErrorIs(t, err, ErrNotInCluster)
}
func TestPasswordName(t *testing.T) {
cases := map[string]string{
"Alice@Example.com": "alice-at-example-dot-com",
"a.b+c@gmail.com": "a-dot-b-c-at-gmail-dot-com",
"UPPER@DOMAIN.IO": "upper-at-domain-dot-io",
}
for in, want := range cases {
require.Equal(t, want, passwordName(in), in)
}
}
+86
View File
@@ -0,0 +1,86 @@
package store
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Invitations are NOT routed through withUser: an invitation exists before its
// user does, so there is no user_id to scope by and no authenticated context when
// one is minted (host CLI) or claimed (the public /invite handler). The token is
// the capability — single-use, time-boxed, crypto-random. The invitations table
// is deliberately outside RLS for the same reason (see migration 009).
// CreateInvitation mints a single-use invite for email, valid for ttl, and
// returns its token. The token is 32 bytes of crypto-random entropy, hex-encoded;
// it is the only secret a recipient needs to claim the invite.
func (s *Store) CreateInvitation(ctx context.Context, email string, ttl time.Duration) (string, error) {
token, err := newInviteToken()
if err != nil {
return "", err
}
if _, err := s.pool.Exec(ctx,
`INSERT INTO invitations (email, token, expires_at)
VALUES ($1, $2, NOW() + $3::interval)`,
email, token, ttl.String()); err != nil {
return "", fmt.Errorf("store: create invitation: %w", err)
}
return token, nil
}
// PeekInvitation returns the invited email for a token that is real, unexpired,
// and unused WITHOUT consuming it — the read the /invite form does to validate the
// link before showing the password fields. Returns ErrNotFound when the token is
// missing, expired, or already used. Use ClaimInvitation to consume.
func (s *Store) PeekInvitation(ctx context.Context, token string) (string, error) {
var email string
err := s.pool.QueryRow(ctx,
`SELECT email FROM invitations
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`,
token).Scan(&email)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("store: peek invitation: %w", err)
}
return email, nil
}
// ClaimInvitation atomically consumes a valid invite and returns its email. The
// UPDATE ... WHERE used_at IS NULL AND expires_at > NOW() guarded by RETURNING
// makes the claim a single round-trip race-free check-and-set: two concurrent
// claims of the same token, only one updates a row, the other gets no rows and so
// ErrNotFound. Same ErrNotFound for missing/expired/already-used tokens.
func (s *Store) ClaimInvitation(ctx context.Context, token string) (string, error) {
var email string
err := s.pool.QueryRow(ctx,
`UPDATE invitations
SET used_at = NOW()
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()
RETURNING email`,
token).Scan(&email)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("store: claim invitation: %w", err)
}
return email, nil
}
// newInviteToken returns 32 bytes of crypto-random entropy, hex-encoded (64
// chars). Hex keeps the token URL-safe with no escaping in /invite/{token}.
func newInviteToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("store: invite token: %w", err)
}
return hex.EncodeToString(b[:]), nil
}
+110
View File
@@ -0,0 +1,110 @@
package store_test
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
// resetInvitations clears the invitations table between cases. It is not in the
// shared resetDB TRUNCATE list (invitations is not user-owned and has no FK to
// users), so the invite tests wipe it themselves.
func resetInvitations(t *testing.T, p *pgxpool.Pool) {
t.Helper()
_, err := p.Exec(context.Background(), `TRUNCATE invitations`)
require.NoError(t, err)
}
func TestCreateInvitationReturnsUsableToken(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
token, err := s.CreateInvitation(ctx, "new@example.com", time.Hour)
require.NoError(t, err)
require.Len(t, token, 64, "32 random bytes hex-encoded")
// Peek does not consume: the same token previews twice.
email, err := s.PeekInvitation(ctx, token)
require.NoError(t, err)
require.Equal(t, "new@example.com", email)
email, err = s.PeekInvitation(ctx, token)
require.NoError(t, err)
require.Equal(t, "new@example.com", email)
}
func TestCreateInvitationTokensAreUnique(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
t1, err := s.CreateInvitation(ctx, "a@example.com", time.Hour)
require.NoError(t, err)
t2, err := s.CreateInvitation(ctx, "b@example.com", time.Hour)
require.NoError(t, err)
require.NotEqual(t, t1, t2)
}
func TestClaimInvitationHappyPath(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
token, err := s.CreateInvitation(ctx, "claim@example.com", time.Hour)
require.NoError(t, err)
email, err := s.ClaimInvitation(ctx, token)
require.NoError(t, err)
require.Equal(t, "claim@example.com", email)
}
func TestClaimInvitationIsSingleUse(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
token, err := s.CreateInvitation(ctx, "once@example.com", time.Hour)
require.NoError(t, err)
_, err = s.ClaimInvitation(ctx, token)
require.NoError(t, err)
// Second claim fails — already used.
_, err = s.ClaimInvitation(ctx, token)
require.ErrorIs(t, err, store.ErrNotFound)
// And a used token no longer previews.
_, err = s.PeekInvitation(ctx, token)
require.ErrorIs(t, err, store.ErrNotFound)
}
func TestClaimInvitationExpired(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
// Negative ttl => already expired.
token, err := s.CreateInvitation(ctx, "old@example.com", -time.Minute)
require.NoError(t, err)
_, err = s.PeekInvitation(ctx, token)
require.ErrorIs(t, err, store.ErrNotFound)
_, err = s.ClaimInvitation(ctx, token)
require.ErrorIs(t, err, store.ErrNotFound)
}
func TestClaimInvitationNotFound(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
_, err := s.ClaimInvitation(ctx, "does-not-exist")
require.ErrorIs(t, err, store.ErrNotFound)
_, err = s.PeekInvitation(ctx, "does-not-exist")
require.ErrorIs(t, err, store.ErrNotFound)
}
@@ -0,0 +1,2 @@
ALTER TABLE videos DROP COLUMN IF EXISTS rate_limited_at;
ALTER TABLE videos DROP COLUMN IF EXISTS transcript_status;
@@ -0,0 +1,17 @@
-- Migration 007: per-video transcript fetch status, for rate-limit backoff.
--
-- transcript_status records the outcome of the last transcript attempt:
-- NULL = not yet attempted
-- 'none' = checked, no usable transcript (permanent — SourceNone)
-- 'fetched' = transcript resolved and summarized (summary_id not null)
-- 'rate_limited'= the caption endpoint returned 429; retry after a backoff window
--
-- rate_limited_at stamps WHEN the 429 was seen, so the runner can skip re-fetching
-- a still-throttled video until NOW() - rate_limited_at exceeds TAPIR_FETCH_BACKOFF.
-- It is cleared (set NULL) whenever the status moves off 'rate_limited'.
--
-- No RLS policy changes needed: videos already has ENABLE + FORCE ROW LEVEL
-- SECURITY (migration 003) with the videos_isolation policy. New columns inherit
-- that protection automatically.
ALTER TABLE videos ADD COLUMN transcript_status TEXT;
ALTER TABLE videos ADD COLUMN rate_limited_at TIMESTAMPTZ;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS invitations;
@@ -0,0 +1,22 @@
-- Migration 009: invitations — an email-based invite to join Tapir (Stage-1
-- onboarding gate). Mathias mints one with `tapir invite <email>`; the recipient
-- visits /invite/{token}, sets a password, and Tapir creates their Dex account.
--
-- Deliberately NOT user-owned and NOT under RLS: an invitation exists BEFORE the
-- user does, so there is no user_id to scope by and no authenticated user context
-- when the invite is created (host CLI) or consumed (public /invite handler, no
-- Dex session). The token itself is the capability — a 32-byte crypto-random,
-- single-use, time-boxed secret. Hence no `user_id` FK and no ENABLE/FORCE ROW
-- LEVEL SECURITY here (unlike every user-owned table in migrations 003/005).
CREATE TABLE invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
-- Lookups are by token (both the claim and the form preview); the UNIQUE
-- constraint already creates an index, this names one explicitly for clarity.
CREATE INDEX idx_invitations_token ON invitations(token);
+7 -1
View File
@@ -49,6 +49,10 @@ type SummaryRow struct {
// set by the web "Summarize" button and cleared by the next `tapir run`. Only // set by the web "Summarize" button and cleared by the next `tapir run`. Only
// populated by ListVideos/GetVideoRow (summary-only reads leave it false). // populated by ListVideos/GetVideoRow (summary-only reads leave it false).
SummarizeRequested bool SummarizeRequested bool
// TranscriptStatus mirrors videos.transcript_status (migration 007): "" (unset),
// "none", "rate_limited", or "fetched". Drives the "Retrying later" list badge.
// Only populated by ListVideos/GetVideoRow ("" on summary-only reads).
TranscriptStatus string
} }
// selectSummary is the shared projection for both reads. videos is LEFT JOINed // selectSummary is the shared projection for both reads. videos is LEFT JOINed
@@ -132,7 +136,8 @@ const selectVideo = `
COALESCE(s.fallback_used, FALSE), COALESCE(s.fallback_used, FALSE),
COALESCE(s.created_at, v.seen_at), COALESCE(s.created_at, v.seen_at),
(s.id IS NOT NULL) AS summarized, (s.id IS NOT NULL) AS summarized,
v.summarize_requested v.summarize_requested,
COALESCE(v.transcript_status, '')
FROM videos v FROM videos v
LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id` LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id`
@@ -245,6 +250,7 @@ func scanVideoRow(rows pgx.Row) (SummaryRow, error) {
&row.CreatedAt, &row.CreatedAt,
&row.Summarized, &row.Summarized,
&row.SummarizeRequested, &row.SummarizeRequested,
&row.TranscriptStatus,
); err != nil { ); err != nil {
return SummaryRow{}, fmt.Errorf("store: scan video: %w", err) return SummaryRow{}, fmt.Errorf("store: scan video: %w", err)
} }
@@ -0,0 +1,102 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// validTranscriptStatuses bounds SetTranscriptStatus input. "" clears the status
// (column NULL); the three named states mirror migration 007's documented values.
var validTranscriptStatuses = map[string]bool{
"": true,
"none": true,
"rate_limited": true,
"fetched": true,
}
// SetTranscriptStatus records the outcome of the last transcript attempt for a
// video (migration 007). When status is "rate_limited" it also stamps
// rate_limited_at = NOW() so the runner can back off; every other status clears
// that timestamp. "" unsets the status (column NULL). An unknown status is
// rejected. Scoped via withUser, so RLS confines the UPDATE to the caller's own
// video; ErrNotFound when the user has no such video.
func (s *Store) SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error {
if !validTranscriptStatuses[status] {
return fmt.Errorf("store: invalid transcript status %q", status)
}
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`UPDATE videos
SET transcript_status = NULLIF($1, ''),
rate_limited_at = CASE WHEN $1 = 'rate_limited' THEN NOW() ELSE NULL END
WHERE id = $2`,
status, videoID)
if err != nil {
return fmt.Errorf("store: set transcript status: %w", err)
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// GetTranscriptStatus returns a video's transcript_status ("" when unset/NULL).
// Returns ErrNotFound when the user has no such video. Scoped via withUser.
func (s *Store) GetTranscriptStatus(ctx context.Context, userID, videoID string) (string, error) {
var status string
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx,
`SELECT COALESCE(transcript_status, '') FROM videos WHERE id = $1`, videoID).Scan(&status)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}); err != nil {
if errors.Is(err, ErrNotFound) {
return "", ErrNotFound
}
return "", fmt.Errorf("store: get transcript status: %w", err)
}
return status, nil
}
// RateLimitedVideoIDs returns the user's videos currently in the "rate_limited"
// state, mapped to when the 429 was stamped (rate_limited_at). The run loop loads
// it once per pass (mirroring SeenVideoIDs) to skip re-fetching a video still
// inside the backoff window, saving caption requests. Scoped by user_id.
func (s *Store) RateLimitedVideoIDs(ctx context.Context, userID string) (map[string]time.Time, error) {
out := make(map[string]time.Time)
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
`SELECT id, rate_limited_at FROM videos
WHERE user_id = $1 AND transcript_status = 'rate_limited' AND rate_limited_at IS NOT NULL`,
userID)
if err != nil {
return fmt.Errorf("store: rate limited video ids: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
id string
at time.Time
)
if err := rows.Scan(&id, &at); err != nil {
return fmt.Errorf("store: scan rate limited id: %w", err)
}
out[id] = at
}
if err := rows.Err(); err != nil {
return fmt.Errorf("store: iterate rate limited ids: %w", err)
}
return nil
}); err != nil {
return nil, err
}
return out, nil
}
@@ -0,0 +1,80 @@
package store_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
func TestSetTranscriptStatus_RoundTrip(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
id, err := s.UpsertVideo(ctx, ytVideo(userA, "rt12345", "round trip"))
require.NoError(t, err)
// Unset by default.
got, err := s.GetTranscriptStatus(ctx, userA, id)
require.NoError(t, err)
require.Equal(t, "", got)
for _, status := range []string{"none", "fetched", "rate_limited", ""} {
require.NoError(t, s.SetTranscriptStatus(ctx, userA, id, status))
got, err := s.GetTranscriptStatus(ctx, userA, id)
require.NoError(t, err)
require.Equal(t, status, got)
}
}
func TestSetTranscriptStatus_RejectsInvalid(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
id, err := s.UpsertVideo(ctx, ytVideo(userA, "bad12345", "bad status"))
require.NoError(t, err)
require.Error(t, s.SetTranscriptStatus(ctx, userA, id, "bogus"))
// The rejected write left the status untouched.
got, err := s.GetTranscriptStatus(ctx, userA, id)
require.NoError(t, err)
require.Equal(t, "", got)
}
func TestSetTranscriptStatus_NotFound(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
require.ErrorIs(t, s.SetTranscriptStatus(ctx, userA, videoX, "fetched"), store.ErrNotFound)
_, err := s.GetTranscriptStatus(ctx, userA, videoX)
require.ErrorIs(t, err, store.ErrNotFound)
}
func TestRateLimitedVideoIDs_StampsAndClears(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
id, err := s.UpsertVideo(ctx, ytVideo(userA, "rl12345", "rate limited"))
require.NoError(t, err)
// Marking rate_limited stamps rate_limited_at, so the video appears.
require.NoError(t, s.SetTranscriptStatus(ctx, userA, id, "rate_limited"))
rl, err := s.RateLimitedVideoIDs(ctx, userA)
require.NoError(t, err)
require.Contains(t, rl, id)
require.False(t, rl[id].IsZero(), "rate_limited_at must be stamped")
// Moving off rate_limited clears the timestamp, so it drops out.
require.NoError(t, s.SetTranscriptStatus(ctx, userA, id, "fetched"))
rl, err = s.RateLimitedVideoIDs(ctx, userA)
require.NoError(t, err)
require.NotContains(t, rl, id)
}
+6
View File
@@ -60,6 +60,12 @@ func (a *Adapter) FetchTranscript(ctx context.Context, v domain.Video) (domain.T
if err != nil { if err != nil {
return domain.Transcript{}, fmt.Errorf("download caption track for %q: %w", v.ProviderVideoID, err) return domain.Transcript{}, fmt.Errorf("download caption track for %q: %w", v.ProviderVideoID, err)
} }
if status == http.StatusTooManyRequests {
// 429 means the IP is rate-limited; record for retry, not a permanent
// absence. Degrade gracefully (no error, no text) like SourceNone, but
// flag it distinctly so the runner backs off and retries (ADR-007/010).
return domain.Transcript{VideoID: v.ID, UserID: v.UserID, Source: domain.SourceRateLimited}, nil
}
if status != http.StatusOK { if status != http.StatusOK {
// Owner-only 403, region/age gate, or transient unavailability: not an error. // Owner-only 403, region/age gate, or transient unavailability: not an error.
return noTranscript(v), nil return noTranscript(v), nil
+31
View File
@@ -388,6 +388,37 @@ func TestFetchTranscriptBaseURLForbiddenDegrades(t *testing.T) {
} }
} }
// A 429 on the baseUrl fetch is the IP being rate-limited, NOT a permanent
// absence of captions: it returns SourceRateLimited (no error, no text) so the
// runner can record it and retry after a backoff window rather than recording a
// false "no transcript".
func TestFetchTranscriptRateLimitedReturnsSourceRateLimited(t *testing.T) {
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/youtubei/v1/player":
base := "http://" + r.Host
_, _ = w.Write([]byte(`{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[` +
`{"baseUrl":"` + base + `/api/timedtext?lang=en","languageCode":"en"}]}}}`))
case "/api/timedtext":
w.WriteHeader(http.StatusTooManyRequests)
}
})
tr, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"})
if err != nil {
t.Fatalf("429 on baseUrl must degrade, not error: %v", err)
}
if tr.Source != domain.SourceRateLimited {
t.Fatalf("expected SourceRateLimited on 429, got %q", tr.Source)
}
if tr.HasText() {
t.Error("expected HasText() false for SourceRateLimited")
}
if tr.Content != "" {
t.Errorf("expected empty content on 429, got %q", tr.Content)
}
}
// An empty baseUrl on the selected track degrades to SourceNone, never an error. // An empty baseUrl on the selected track degrades to SourceNone, never an error.
func TestFetchTranscriptEmptyBaseURLDegrades(t *testing.T) { func TestFetchTranscriptEmptyBaseURLDegrades(t *testing.T) {
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
+20
View File
@@ -59,9 +59,20 @@ type Config struct {
// PollInterval, when > 0, makes `run` loop on that cadence; 0 means run once. // PollInterval, when > 0, makes `run` loop on that cadence; 0 means run once.
PollInterval time.Duration PollInterval time.Duration
// FetchBackoff is how long the run loop waits before re-fetching a transcript
// that previously returned HTTP 429 (rate_limited). Inside the window the video
// is skipped without hitting the caption endpoint, saving requests; after it
// expires the video is retried. Zero means "always retry" (no backoff).
FetchBackoff time.Duration
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI). // HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
HTTPAddr string HTTPAddr string
// PublicURL is the externally-reachable base URL of the deployed service,
// e.g. "https://tapir.d-ma.be". Used to build absolute links handed to humans
// (the `tapir invite` URL). No trailing slash is assumed — callers trim it.
PublicURL string
// Dex OIDC (web login, ADR-011/012). When OIDCIssuer is empty, `serve` falls // Dex OIDC (web login, ADR-011/012). When OIDCIssuer is empty, `serve` falls
// back to the allow-all StubAuth (local dev). When set, serve uses Dex: any // back to the allow-all StubAuth (local dev). When set, serve uses Dex: any
// Dex-authenticated subject may sign in, then registers a tapir user (ADR-012). // Dex-authenticated subject may sign in, then registers a tapir user (ADR-012).
@@ -85,6 +96,8 @@ const (
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback" defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
defaultOAuthRedirectAddr = "localhost:8080" defaultOAuthRedirectAddr = "localhost:8080"
defaultHTTPAddr = ":8080" defaultHTTPAddr = ":8080"
defaultFetchBackoff = time.Hour
defaultPublicURL = "https://tapir.d-ma.be"
) )
// Load reads the environment into a Config, applying defaults. It does not // Load reads the environment into a Config, applying defaults. It does not
@@ -105,6 +118,7 @@ func Load() (Config, error) {
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()), SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr), OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr), HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL),
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"), OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"), DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"), DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
@@ -124,6 +138,12 @@ func Load() (Config, error) {
} }
c.PollInterval = interval c.PollInterval = interval
backoff, err := durationOr("TAPIR_FETCH_BACKOFF", defaultFetchBackoff)
if err != nil {
return Config{}, err
}
c.FetchBackoff = backoff
return c, nil return c, nil
} }
+7
View File
@@ -45,6 +45,9 @@ func TestLoad_AppliesDefaults(t *testing.T) {
if c.PollInterval != 0 { if c.PollInterval != 0 {
t.Errorf("PollInterval = %v, want 0 (run once)", c.PollInterval) t.Errorf("PollInterval = %v, want 0 (run once)", c.PollInterval)
} }
if c.FetchBackoff != defaultFetchBackoff {
t.Errorf("FetchBackoff = %v, want default %v", c.FetchBackoff, defaultFetchBackoff)
}
} }
func TestLoad_ParsesValues(t *testing.T) { func TestLoad_ParsesValues(t *testing.T) {
@@ -56,6 +59,7 @@ func TestLoad_ParsesValues(t *testing.T) {
"TAPIR_SUMMARIZER_TIMEOUT": "90s", "TAPIR_SUMMARIZER_TIMEOUT": "90s",
"TAPIR_DB_DSN": "postgres://x", "TAPIR_DB_DSN": "postgres://x",
"TAPIR_POLL_INTERVAL": "10m", "TAPIR_POLL_INTERVAL": "10m",
"TAPIR_FETCH_BACKOFF": "30m",
}) })
c, err := Load() c, err := Load()
@@ -77,6 +81,9 @@ func TestLoad_ParsesValues(t *testing.T) {
if c.PollInterval != 10*time.Minute { if c.PollInterval != 10*time.Minute {
t.Errorf("PollInterval = %v, want 10m", c.PollInterval) t.Errorf("PollInterval = %v, want 10m", c.PollInterval)
} }
if c.FetchBackoff != 30*time.Minute {
t.Errorf("FetchBackoff = %v, want 30m", c.FetchBackoff)
}
} }
func TestLoad_RejectsBadDuration(t *testing.T) { func TestLoad_RejectsBadDuration(t *testing.T) {
+6
View File
@@ -18,6 +18,12 @@ type TranscriptSource string
const ( const (
SourceCaptions TranscriptSource = "captions" SourceCaptions TranscriptSource = "captions"
SourceNone TranscriptSource = "none" SourceNone TranscriptSource = "none"
// SourceRateLimited records that the caption endpoint returned HTTP 429.
// Unlike SourceNone (a permanent absence), this is a transient "retry later":
// the IP is rate-limited, not the video caption-less. It carries no text
// (HasText is false), so the engine degrades the same as SourceNone, but the
// runner persists it distinctly to retry after a backoff window.
SourceRateLimited TranscriptSource = "rate_limited"
) )
// User is the Tapir-side profile. At Stage 0 there is exactly one. // User is the Tapir-side profile. At Stage 0 there is exactly one.
+81 -14
View File
@@ -32,6 +32,12 @@ type VideoStore interface {
GetAutoSummarize(ctx context.Context, userID string) (bool, error) GetAutoSummarize(ctx context.Context, userID string) (bool, error)
RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error) RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error)
ClearSummarizeRequested(ctx context.Context, userID, videoID string) error ClearSummarizeRequested(ctx context.Context, userID, videoID string) error
// RateLimitedVideoIDs maps the user's still-throttled videos to when they were
// rate-limited, so the loop can back off without re-hitting the caption endpoint.
RateLimitedVideoIDs(ctx context.Context, userID string) (map[string]time.Time, error)
// SetTranscriptStatus records the outcome of a transcript attempt: "none",
// "rate_limited" (stamps the backoff clock), or "fetched".
SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error
} }
// Processor runs the core use case for a single video. *usecase.Engine // Processor runs the core use case for a single video. *usecase.Engine
@@ -43,29 +49,51 @@ type Processor interface {
// Runner walks a user's subscriptions, persists each candidate video, skips the // Runner walks a user's subscriptions, persists each candidate video, skips the
// ones already summarized (durably), and processes the rest through the engine. // ones already summarized (durably), and processes the rest through the engine.
type Runner struct { type Runner struct {
src ports.VideoSource src ports.VideoSource
store VideoStore store VideoStore
engine Processor engine Processor
userID string userID string
log *slog.Logger log *slog.Logger
backoff time.Duration // rate-limit retry window; 0 = always retry
now func() time.Time // injectable clock (tests); defaults to time.Now
} }
// Option configures a Runner at construction. Variadic so existing call sites
// stay valid as new knobs (backoff, clock) are added.
type Option func(*Runner)
// WithBackoff sets the rate-limit retry window. A video that returned HTTP 429 is
// skipped (no caption fetch) until this much time has passed; 0 = always retry.
func WithBackoff(d time.Duration) Option { return func(r *Runner) { r.backoff = d } }
// WithClock overrides the clock used for backoff comparisons. Tests inject a
// fixed time; production leaves the time.Now default.
func WithClock(now func() time.Time) Option { return func(r *Runner) { r.now = now } }
// New builds a Runner. A nil logger falls back to slog.Default. // New builds a Runner. A nil logger falls back to slog.Default.
func New(src ports.VideoSource, store VideoStore, engine Processor, userID string, log *slog.Logger) *Runner { func New(src ports.VideoSource, store VideoStore, engine Processor, userID string, log *slog.Logger, opts ...Option) *Runner {
if log == nil { if log == nil {
log = slog.Default() log = slog.Default()
} }
return &Runner{src: src, store: store, engine: engine, userID: userID, log: log} r := &Runner{src: src, store: store, engine: engine, userID: userID, log: log, now: time.Now}
for _, opt := range opts {
opt(r)
}
if r.now == nil {
r.now = time.Now
}
return r
} }
// Stats summarizes one RunOnce pass. // Stats summarizes one RunOnce pass.
type Stats struct { type Stats struct {
Candidates int Candidates int
Summarized int Summarized int
SkippedSeen int SkippedSeen int
SkippedNoText int SkippedNoText int
SkippedManual int // discovered but not queued, in manual mode SkippedManual int // discovered but not queued, in manual mode
Errors int SkippedRateLimited int // 429'd previously and still inside the backoff window
Errors int
} }
// RunOnce performs a single pass over the user's subscriptions. Per-item errors // RunOnce performs a single pass over the user's subscriptions. Per-item errors
@@ -104,6 +132,18 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
} }
} }
// Rate-limit backoff: videos that 429'd on a prior pass, mapped to when. Inside
// the backoff window they are skipped before any caption fetch, so a throttled
// IP is not hammered. Loaded once per pass (like seen/requested). Disabled when
// backoff <= 0 ("always retry").
var rateLimited map[string]time.Time
if r.backoff > 0 {
rateLimited, err = r.store.RateLimitedVideoIDs(ctx, r.userID)
if err != nil {
return stats, fmt.Errorf("runner: load rate-limited videos: %w", err)
}
}
subs, err := r.src.ListSubscriptions(ctx, r.userID) subs, err := r.src.ListSubscriptions(ctx, r.userID)
if err != nil { if err != nil {
return stats, fmt.Errorf("runner: list subscriptions: %w", err) return stats, fmt.Errorf("runner: list subscriptions: %w", err)
@@ -142,6 +182,15 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
continue continue
} }
// Still inside the rate-limit backoff window: skip without fetching, so
// we don't re-hit a caption endpoint that just 429'd us. After the window
// expires the video falls through and is retried normally.
if at, ok := rateLimited[id]; ok && r.now().Sub(at) < r.backoff {
stats.SkippedRateLimited++
r.log.Info("skipped video (rate-limited, backing off)", "video", v.ProviderVideoID, "title", v.Title)
continue
}
if fetchDelay > 0 { if fetchDelay > 0 {
time.Sleep(fetchDelay) time.Sleep(fetchDelay)
} }
@@ -152,11 +201,28 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
continue continue
} }
switch { switch {
case res.Skipped && res.TranscriptSource == string(domain.SourceRateLimited):
// Fresh 429 this pass: persist rate_limited (stamps the backoff clock)
// so the next pass skips it until the window expires.
stats.SkippedRateLimited++
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "rate_limited"); err != nil {
errs = append(errs, fmt.Errorf("set rate_limited status %q: %w", v.ProviderVideoID, err))
stats.Errors++
}
r.log.Info("skipped video (rate-limited)", "video", v.ProviderVideoID, "title", v.Title)
case res.Skipped: case res.Skipped:
stats.SkippedNoText++ stats.SkippedNoText++
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "none"); err != nil {
errs = append(errs, fmt.Errorf("set none status %q: %w", v.ProviderVideoID, err))
stats.Errors++
}
r.log.Info("skipped video (no transcript)", "video", v.ProviderVideoID, "title", v.Title) r.log.Info("skipped video (no transcript)", "video", v.ProviderVideoID, "title", v.Title)
case res.Summary != nil: case res.Summary != nil:
stats.Summarized++ stats.Summarized++
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "fetched"); err != nil {
errs = append(errs, fmt.Errorf("set fetched status %q: %w", v.ProviderVideoID, err))
stats.Errors++
}
// In manual mode the video was processed because it was queued; // In manual mode the video was processed because it was queued;
// clear the flag so it is not re-summarized and the UI drops the // clear the flag so it is not re-summarized and the UI drops the
// "Queued" chip. (Auto mode never sets the flag.) // "Queued" chip. (Auto mode never sets the flag.)
@@ -184,7 +250,8 @@ func (r *Runner) Loop(ctx context.Context, interval time.Duration) error {
r.log.Info("run pass complete", r.log.Info("run pass complete",
"candidates", stats.Candidates, "summarized", stats.Summarized, "candidates", stats.Candidates, "summarized", stats.Summarized,
"skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText, "skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
"skipped_manual", stats.SkippedManual, "errors", stats.Errors) "skipped_manual", stats.SkippedManual, "skipped_rate_limited", stats.SkippedRateLimited,
"errors", stats.Errors)
if err != nil { if err != nil {
r.log.Warn("run pass had errors", "err", err) r.log.Warn("run pass had errors", "err", err)
} }
+80 -5
View File
@@ -5,6 +5,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -43,11 +44,13 @@ func (f *fakeSource) FetchTranscript(_ context.Context, v domain.Video) (domain.
// auto controls the summarization mode; requested is the manual-mode queue keyed // auto controls the summarization mode; requested is the manual-mode queue keyed
// by store id; cleared records the ids whose queue flag the runner reset. // by store id; cleared records the ids whose queue flag the runner reset.
type fakeStore struct { type fakeStore struct {
seen map[string]bool seen map[string]bool
upserted []domain.Video upserted []domain.Video
auto bool auto bool
requested map[string]bool requested map[string]bool
cleared []string cleared []string
rateLimited map[string]time.Time // id -> when 429'd (seeds the backoff window)
statuses map[string]string // id -> last SetTranscriptStatus value
} }
func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) { func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) {
@@ -80,6 +83,22 @@ func (f *fakeStore) ClearSummarizeRequested(_ context.Context, _, videoID string
return nil return nil
} }
func (f *fakeStore) RateLimitedVideoIDs(_ context.Context, _ string) (map[string]time.Time, error) {
cp := make(map[string]time.Time, len(f.rateLimited))
for k, v := range f.rateLimited {
cp[k] = v
}
return cp, nil
}
func (f *fakeStore) SetTranscriptStatus(_ context.Context, _, videoID, status string) error {
if f.statuses == nil {
f.statuses = map[string]string{}
}
f.statuses[videoID] = status
return nil
}
type fakeSummarizer struct{} type fakeSummarizer struct{}
func (fakeSummarizer) Summarize(_ context.Context, v domain.Video, _ domain.Transcript) (domain.Summary, error) { func (fakeSummarizer) Summarize(_ context.Context, v domain.Video, _ domain.Transcript) (domain.Summary, error) {
@@ -207,6 +226,62 @@ func TestRunOnce_ManualMode_ProcessesRequested(t *testing.T) {
require.Equal(t, []string{"id-v1"}, st.cleared, "the queue flag is cleared after summarizing") require.Equal(t, []string{"id-v1"}, st.cleared, "the queue flag is cleared after summarizing")
} }
// noFetchSource fails the test if a transcript fetch happens — used to prove the
// runner skips a rate-limited video before touching the caption endpoint.
type noFetchSource struct{ *fakeSource }
func (noFetchSource) FetchTranscript(context.Context, domain.Video) (domain.Transcript, error) {
panic("FetchTranscript must not be called for a rate-limited video within the backoff window")
}
func TestRunOnce_SkipsRateLimitedWithinBackoff(t *testing.T) {
base := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
}
// v1 was rate-limited 5m ago; backoff is 1h, so it is still inside the window.
st := &fakeStore{
seen: map[string]bool{},
auto: true,
rateLimited: map[string]time.Time{"id-v1": base.Add(-5 * time.Minute)},
}
eng := usecase.NewEngine(noFetchSource{src}, fakeSummarizer{}, &recordingSink{})
r := runner.New(noFetchSource{src}, st, eng, testUser, quietLogger(),
runner.WithBackoff(time.Hour), runner.WithClock(func() time.Time { return base }))
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 1, stats.SkippedRateLimited, "still throttled -> skipped")
require.Equal(t, 0, stats.Summarized)
require.Empty(t, st.statuses, "no status write: the engine was never invoked")
}
func TestRunOnce_RetriesRateLimitedAfterBackoff(t *testing.T) {
base := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
}
// v1 was rate-limited 2h ago; backoff is 1h, so the window has expired.
st := &fakeStore{
seen: map[string]bool{},
auto: true,
rateLimited: map[string]time.Time{"id-v1": base.Add(-2 * time.Hour)},
}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger(),
runner.WithBackoff(time.Hour), runner.WithClock(func() time.Time { return base }))
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 0, stats.SkippedRateLimited, "window expired -> not skipped")
require.Equal(t, 1, stats.Summarized, "the video is retried and summarized")
require.Len(t, sink.delivered, 1)
require.Equal(t, "fetched", st.statuses["id-v1"], "status advances to fetched on success")
}
func TestRunOnce_UpsertsEveryCandidate(t *testing.T) { func TestRunOnce_UpsertsEveryCandidate(t *testing.T) {
src := &fakeSource{ src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")}, subs: []domain.Subscription{sub("chan1", "Channel One")},
+11 -4
View File
@@ -44,8 +44,13 @@ func NewEngine(src ports.VideoSource, ai ports.Summarizer, sinks ...ports.Sink)
type ProcessResult struct { type ProcessResult struct {
Video domain.Video Video domain.Video
Skipped bool Skipped bool
Reason string // set when Skipped (e.g. "no transcript") Reason string // set when Skipped (e.g. "no transcript")
Summary *domain.Summary // nil when Skipped // TranscriptSource is how the transcript resolved (or that there was none):
// the domain.TranscriptSource value as a string. The runner reads it to tell a
// permanent absence (SourceNone) from a transient 429 (SourceRateLimited) and
// persist the right transcript_status. Empty when a fetch error short-circuits.
TranscriptSource string
Summary *domain.Summary // nil when Skipped
} }
// ProcessNewVideo runs the core use case for a single video: // ProcessNewVideo runs the core use case for a single video:
@@ -59,7 +64,9 @@ func (e *Engine) ProcessNewVideo(ctx context.Context, v domain.Video) (ProcessRe
if !t.HasText() { if !t.HasText() {
// No usable transcript: record the skip, produce no summary, deliver nothing // No usable transcript: record the skip, produce no summary, deliver nothing
// (captions-first, ADR-007; the watcher uses this to avoid reprocessing). // (captions-first, ADR-007; the watcher uses this to avoid reprocessing).
return ProcessResult{Video: v, Skipped: true, Reason: "no transcript"}, nil // Surface the source so the runner separates SourceNone (permanent) from
// SourceRateLimited (retry after a backoff window).
return ProcessResult{Video: v, Skipped: true, Reason: "no transcript", TranscriptSource: string(t.Source)}, nil
} }
sum, err := e.AI.Summarize(ctx, v, t) sum, err := e.AI.Summarize(ctx, v, t)
@@ -76,7 +83,7 @@ func (e *Engine) ProcessNewVideo(ctx context.Context, v domain.Video) (ProcessRe
} }
} }
return ProcessResult{Video: v, Summary: &sum}, errors.Join(errs...) return ProcessResult{Video: v, Summary: &sum, TranscriptSource: string(t.Source)}, errors.Join(errs...)
} }
// ProcessNewVideos walks a user's subscriptions and processes each newly seen // ProcessNewVideos walks a user's subscriptions and processes each newly seen
+14
View File
@@ -0,0 +1,14 @@
package web
import "net/http"
// Test-only handles to the unexported invite handlers so the external web_test
// package can mount them on an httptest mux (and get PathValue routing) without
// standing up the full Router + auth stack. export_test.go compiles only under
// `go test`, so these never widen the package's real API.
func (a *App) HandleInviteFormForTest(w http.ResponseWriter, r *http.Request) {
a.handleInviteForm(w, r)
}
func (a *App) HandleInviteSubmitForTest(w http.ResponseWriter, r *http.Request) {
a.handleInviteSubmit(w, r)
}
+6 -5
View File
@@ -11,11 +11,12 @@ const flashCookie = "tapir_flash"
// Flash codes. Kept small and stable — the message + severity live in // Flash codes. Kept small and stable — the message + severity live in
// flashMessages (view.go), not here, so the cookie never carries free text. // flashMessages (view.go), not here, so the cookie never carries free text.
const ( const (
flashConnected = "connected" flashConnected = "connected"
flashConnectFailed = "connect_failed" flashConnectFailed = "connect_failed"
flashDisconnected = "disconnected" flashDisconnected = "disconnected"
flashDeleted = "deleted" flashDeleted = "deleted"
flashRegistered = "registered" flashRegistered = "registered"
flashAccountCreated = "account_created"
) )
// flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to // flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to
+28 -2
View File
@@ -68,6 +68,14 @@ type App struct {
// Processing tracks in-flight immediate summarizations so the status endpoint // Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use. // shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet Processing ProcessingSet
// Invitations validates and consumes email-invite tokens for the public
// /invite/{token} flow. Nil = the invite routes report "invalid" (the flow is
// effectively off). *store.Store satisfies it.
Invitations InvitationStore
// Dex creates the Dex local-password account when an invite is claimed. Nil =
// not in-cluster (dev): the submit handler degrades to a clear "deployed-only"
// message instead of creating an account. *dex.PasswordClient satisfies it.
Dex DexPasswordCreator
} }
func (a *App) logger() *slog.Logger { func (a *App) logger() *slog.Logger {
@@ -87,6 +95,11 @@ func (a *App) Router() http.Handler {
root.Handle("GET /static/", staticHandler()) root.Handle("GET /static/", staticHandler())
root.Handle("/auth/", a.Auth.Routes()) root.Handle("/auth/", a.Auth.Routes())
// Email invitation claim (public — the visitor has no Dex session yet, so this
// sits OUTSIDE Auth.Middleware). The token in the path is the capability.
root.HandleFunc("GET /invite/{token}", a.handleInviteForm)
root.HandleFunc("POST /invite/{token}", a.handleInviteSubmit)
app := http.NewServeMux() app := http.NewServeMux()
app.HandleFunc("GET /{$}", a.handleList) app.HandleFunc("GET /{$}", a.handleList)
app.HandleFunc("GET /v/{videoId}", a.handleDetail) app.HandleFunc("GET /v/{videoId}", a.handleDetail)
@@ -155,11 +168,24 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
} }
rows = f.apply(rows) rows = f.apply(rows)
// hasConnected drives the empty state: a fresh account with a connection but
// no `tapir run` yet has zero rows, and we want it to read "connected, run
// tapir" rather than "nothing here". Only needed when the list is empty.
hasConnected := false
if len(rows) == 0 {
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
if err != nil {
a.serverError(w, r, "connections for user", err)
return
}
hasConnected = len(conns) > 0
}
if isHTMX(r) { if isHTMX(r) {
a.render(w, r, summaryList(rows)) a.render(w, r, summaryList(rows, hasConnected))
return return
} }
a.render(w, r, ListPage(rows, f, takeFlash(w, r))) a.render(w, r, ListPage(rows, f, takeFlash(w, r), hasConnected))
} }
// handleDetail renders one summary in full (highlights, takeaways, action group). // handleDetail renders one summary in full (highlights, takeaways, action group).
+164
View File
@@ -0,0 +1,164 @@
package web
import (
"context"
"crypto/rand"
"errors"
"fmt"
"net/http"
"golang.org/x/crypto/bcrypt"
"gitea.d-ma.be/mathias/tapir/internal/adapters/dex"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
// InvitationStore is the narrow store surface the public invite flow needs:
// PeekInvitation validates a token without consuming it (the GET form preview);
// ClaimInvitation consumes it atomically (the POST). *store.Store satisfies it.
// Deliberately separate from Store (the user-scoped surface) — invites run with no
// authenticated user (the user does not exist yet).
type InvitationStore interface {
PeekInvitation(ctx context.Context, token string) (email string, err error)
ClaimInvitation(ctx context.Context, token string) (email string, err error)
}
// DexPasswordCreator creates a Dex local-password account from a bcrypt hash.
// *dex.PasswordClient satisfies it; tests substitute a fake. A nil App.Dex means
// the process is not in-cluster (dev) and account creation is unavailable.
type DexPasswordCreator interface {
CreatePassword(ctx context.Context, email, bcryptHash, userID string) error
}
// bcryptCost is the work factor for hashing invite passwords. 12 is a sensible
// 2020s default — noticeably slow to brute-force, fast enough for a single login.
const bcryptCost = 12
// minPasswordLen is the floor for an invite password. Length beats composition
// rules; 8 is the practical minimum we accept.
const minPasswordLen = 8
// handleInviteForm renders the set-password form for a valid invite token, or a
// clear "expired / already used" page otherwise. It only previews the token
// (PeekInvitation) — the token is consumed on submit, not on view, so a refresh
// or a link-preview fetch never burns the invite.
func (a *App) handleInviteForm(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
if a.Invitations == nil {
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
return
}
email, err := a.Invitations.PeekInvitation(r.Context(), token)
if errors.Is(err, store.ErrNotFound) {
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
return
}
if err != nil {
a.serverError(w, r, "peek invitation", err)
return
}
a.render(w, r, InvitePage(email, token, ""))
}
// handleInviteSubmit validates the chosen password, consumes the invite, and
// creates the Dex local-password account. Order matters (see inline): password is
// validated first (no token burned on a typo), then the invite is claimed exactly
// once, then the Dex account is created. On success the visitor is sent to the Dex
// login to sign in with the email + new password.
func (a *App) handleInviteSubmit(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
if a.Invitations == nil {
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
password := r.FormValue("password")
confirm := r.FormValue("password_confirm")
// 1. Validate before consuming the token, so a mismatch/typo is retryable.
if len(password) < minPasswordLen {
a.reshowInvite(w, r, token, "Password must be at least 8 characters.")
return
}
if password != confirm {
a.reshowInvite(w, r, token, "Passwords do not match.")
return
}
// Off-cluster (dev): we cannot create a Dex account. Degrade clearly WITHOUT
// consuming the invite, so it still works once deployed.
if a.Dex == nil {
a.render(w, r, InviteNoticePage("Account creation only works in the deployed environment.", false))
return
}
// 2. Consume the invite exactly once. If the token vanished between GET and
// POST (expired, replay, concurrent claim) this is where it surfaces.
email, err := a.Invitations.ClaimInvitation(r.Context(), token)
if errors.Is(err, store.ErrNotFound) {
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
return
}
if err != nil {
a.serverError(w, r, "claim invitation", err)
return
}
// 3. Hash the password (cost 12). The Dex client base64-encodes it for the CR.
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
a.serverError(w, r, "hash password", err)
return
}
// 4. Create the Dex local-password account.
userID, err := newID()
if err != nil {
a.serverError(w, r, "new user id", err)
return
}
switch err := a.Dex.CreatePassword(r.Context(), email, string(hash), userID); {
case err == nil:
// 5. Off to the Dex login — a flash surfaces on the first page after login.
setFlash(w, flashAccountCreated)
http.Redirect(w, r, loginPath, http.StatusSeeOther)
case errors.Is(err, dex.ErrPasswordExists):
a.render(w, r, InviteNoticePage("An account with this email already exists. Try logging in.", true))
case errors.Is(err, dex.ErrForbidden):
a.render(w, r, InviteNoticePage("Unable to create your Dex account — please contact the administrator.", false))
default:
a.serverError(w, r, "create dex password", err)
}
}
// reshowInvite re-renders the password form with a validation message, re-fetching
// the email from the (still-unconsumed) token. A token that became invalid in the
// meantime falls back to the expired/used page.
func (a *App) reshowInvite(w http.ResponseWriter, r *http.Request, token, errMsg string) {
email, err := a.Invitations.PeekInvitation(r.Context(), token)
if errors.Is(err, store.ErrNotFound) {
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
return
}
if err != nil {
a.serverError(w, r, "peek invitation", err)
return
}
a.renderStatus(w, r, http.StatusBadRequest, InvitePage(email, token, errMsg))
}
// newID returns a fresh random RFC-4122 v4 UUID for the Dex userID field
// (crypto/rand, no new dependency). Kept local rather than coupling web to the
// store package's unexported generator.
func newID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("web: new id: %w", err)
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
+185
View File
@@ -0,0 +1,185 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"gitea.d-ma.be/mathias/tapir/internal/adapters/dex"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/web"
)
// fakeDex captures the CreatePassword call and returns a canned error.
type fakeDex struct {
called bool
email, hash, userID string
err error
}
func (f *fakeDex) CreatePassword(_ context.Context, email, hash, userID string) error {
f.called = true
f.email, f.hash, f.userID = email, hash, userID
return f.err
}
func resetInvites(t *testing.T, p *pgxpool.Pool) {
t.Helper()
_, err := p.Exec(context.Background(), `TRUNCATE invitations`)
require.NoError(t, err)
}
// inviteMux mounts only the two public invite routes against app, so PathValue
// ("token") is populated exactly as in production without the full Router/auth.
func inviteMux(app *web.App) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /invite/{token}", app.HandleInviteFormForTest)
mux.HandleFunc("POST /invite/{token}", app.HandleInviteSubmitForTest)
return mux
}
func newInvite(t *testing.T, st *store.Store, email string, ttl time.Duration) string {
t.Helper()
token, err := st.CreateInvitation(context.Background(), email, ttl)
require.NoError(t, err)
return token
}
func TestInviteFormValidToken(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "invitee@example.com", time.Hour)
app := &web.App{Invitations: st, Dex: &fakeDex{}}
rr := httptest.NewRecorder()
inviteMux(app).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/invite/"+token, nil))
require.Equal(t, http.StatusOK, rr.Code)
body := rr.Body.String()
require.Contains(t, body, "invitee@example.com")
require.Contains(t, body, "Create my account")
}
func TestInviteFormInvalidToken(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
app := &web.App{Invitations: st, Dex: &fakeDex{}}
rr := httptest.NewRecorder()
inviteMux(app).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/invite/nope", nil))
require.Equal(t, http.StatusOK, rr.Code)
require.Contains(t, rr.Body.String(), "no longer valid")
}
func postInvite(app *web.App, token string, form url.Values) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/invite/"+token, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
inviteMux(app).ServeHTTP(rr, req)
return rr
}
func TestInviteSubmitPasswordMismatch(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "a@example.com", time.Hour)
fd := &fakeDex{}
app := &web.App{Invitations: st, Dex: fd}
rr := postInvite(app, token, url.Values{"password": {"longenough1"}, "password_confirm": {"different1"}})
require.Equal(t, http.StatusBadRequest, rr.Code)
require.Contains(t, rr.Body.String(), "do not match")
require.False(t, fd.called)
// Token not consumed — still claimable.
_, err := st.PeekInvitation(context.Background(), token)
require.NoError(t, err)
}
func TestInviteSubmitShortPassword(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "a@example.com", time.Hour)
fd := &fakeDex{}
app := &web.App{Invitations: st, Dex: fd}
rr := postInvite(app, token, url.Values{"password": {"short"}, "password_confirm": {"short"}})
require.Equal(t, http.StatusBadRequest, rr.Code)
require.Contains(t, rr.Body.String(), "at least 8")
require.False(t, fd.called)
}
func TestInviteSubmitValidCreatesAccount(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "new@example.com", time.Hour)
fd := &fakeDex{}
app := &web.App{Invitations: st, Dex: fd}
rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}})
require.Equal(t, http.StatusSeeOther, rr.Code)
require.Equal(t, "/auth/login", rr.Header().Get("Location"))
require.True(t, fd.called)
require.Equal(t, "new@example.com", fd.email)
require.NotEmpty(t, fd.userID)
// The handler hands Dex a real bcrypt hash of the chosen password.
require.NoError(t, bcrypt.CompareHashAndPassword([]byte(fd.hash), []byte("correcthorse")))
// Flash queued for the post-login page.
require.Contains(t, rr.Header().Get("Set-Cookie"), "tapir_flash=account_created")
// Token consumed — a second claim fails.
_, err := st.ClaimInvitation(context.Background(), token)
require.ErrorIs(t, err, store.ErrNotFound)
}
func TestInviteSubmitDevModeNoDex(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "dev@example.com", time.Hour)
app := &web.App{Invitations: st, Dex: nil} // not in-cluster
rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}})
require.Equal(t, http.StatusOK, rr.Code)
require.Contains(t, rr.Body.String(), "deployed environment")
// Token preserved so it still works once deployed.
_, err := st.PeekInvitation(context.Background(), token)
require.NoError(t, err)
}
func TestInviteSubmitPasswordExists(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "dup@example.com", time.Hour)
app := &web.App{Invitations: st, Dex: &fakeDex{err: dex.ErrPasswordExists}}
rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}})
require.Equal(t, http.StatusOK, rr.Code)
require.Contains(t, rr.Body.String(), "already exists")
}
func TestInviteSubmitForbidden(t *testing.T) {
st, p := newStore(t), rawPool(t)
resetInvites(t, p)
token := newInvite(t, st, "x@example.com", time.Hour)
app := &web.App{Invitations: st, Dex: &fakeDex{err: dex.ErrForbidden}}
rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}})
require.Equal(t, http.StatusOK, rr.Code)
require.Contains(t, rr.Body.String(), "administrator")
}
+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)
}
}
+23 -5
View File
@@ -183,6 +183,11 @@ func statusURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/status") return templ.SafeURL("/v/" + videoID + "/status")
} }
// inviteURL builds the claim path (POST) for an invite token.
func inviteURL(token string) templ.SafeURL {
return templ.SafeURL("/invite/" + token)
}
// Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) — // Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts // a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
// so the inline span colours and the CSS track/fill share one source of truth. // so the inline span colours and the CSS track/fill share one source of truth.
@@ -333,11 +338,12 @@ type flashView struct {
// flashMessages maps each flash code to its banner. An unknown code renders no // flashMessages maps each flash code to its banner. An unknown code renders no
// banner (flashFor returns ok=false), so a forged cookie value is inert. // banner (flashFor returns ok=false), so a forged cookie value is inert.
var flashMessages = map[string]flashView{ var flashMessages = map[string]flashView{
flashConnected: {"success", "YouTube account connected."}, flashConnected: {"success", "YouTube account connected."},
flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."}, flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."},
flashDisconnected: {"success", "Account disconnected."}, flashDisconnected: {"success", "Account disconnected."},
flashDeleted: {"success", "Your account and all its data were deleted."}, flashDeleted: {"success", "Your account and all its data were deleted."},
flashRegistered: {"success", "Welcome to Tapir — your account is ready."}, flashRegistered: {"success", "Welcome to Tapir — your account is ready."},
flashAccountCreated: {"success", "Account created — log in with your email and password."},
} }
func flashFor(code string) (flashView, bool) { func flashFor(code string) (flashView, bool) {
@@ -479,6 +485,10 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.filters input { font: inherit; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); min-width: 9rem; } .filters input { font: inherit; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); min-width: 9rem; }
.filters input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); } .filters input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
.btn { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid var(--accent); border-radius: var(--radius); background: var(--accent); color: var(--accent-fg); cursor: pointer; } .btn { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid var(--accent); border-radius: var(--radius); background: var(--accent); color: var(--accent-fg); cursor: pointer; }
/* anchors styled as buttons: the generic a{} / a:visited{} colour rules outrank
.btn on <a>, painting the label accent-on-accent (invisible). Restore the
button foreground for anchor buttons, visited included. */
a.btn, a.btn:visited { color: var(--accent-fg); }
.btn:hover { filter: brightness(1.05); } .btn:hover { filter: brightness(1.05); }
.btn:active { transform: translateY(1px); } .btn:active { transform: translateY(1px); }
@@ -490,6 +500,9 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.card-preview { color: var(--muted); font-size: .9rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 1; line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; } .card-preview { color: var(--muted); font-size: .9rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 1; line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.card-foot { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; margin-top: var(--s1); } .card-foot { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; margin-top: var(--s1); }
.chip { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: .72rem; font-weight: 600; } .chip { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: .72rem; font-weight: 600; }
/* passive "retrying later" chip: dim/grey (CharmDim), not the accent — it is a
status, not an action the user can take. */
.chip-retry { background: rgba(108, 108, 108, .16); color: #6c6c6c; }
.card-state { color: var(--muted); font-size: .8rem; } .card-state { color: var(--muted); font-size: .8rem; }
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; } .badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
@@ -531,6 +544,11 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); } .empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); }
.empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); } .empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); }
.empty code { background: var(--accent-weak); color: var(--accent); padding: .1rem .35rem; border-radius: .3rem; } .empty code { background: var(--accent-weak); color: var(--accent); padding: .1rem .35rem; border-radius: .3rem; }
.empty p { margin: var(--s3) 0 0; }
/* connected-but-empty: a distinct accent callout, not a muted blank state, so a
fresh account knows the next step is to run tapir, not "something is broken". */
.empty-connected { border-style: solid; border-color: var(--accent); background: var(--accent-weak); color: var(--fg); }
.empty-connected strong { color: var(--accent); }
/* flash / notification banner */ /* flash / notification banner */
.flash { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); } .flash { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); }
+81 -9
View File
@@ -22,7 +22,7 @@ templ Layout(title string) {
<body> <body>
<header> <header>
<a href="/" class="brand">Tapir</a> <a href="/" class="brand">Tapir</a>
<nav class="nav"><a href="/account">Account</a></nav> <nav class="nav"><a href="/account">Account</a><a href="/auth/logout">Log out</a></nav>
</header> </header>
<main> <main>
{ children... } { children... }
@@ -59,7 +59,7 @@ templ WelcomePage(user User, loggedIn bool) {
<div class="welcome-cta"> <div class="welcome-cta">
<a class="btn btn-lg" href="/auth/login">Get Started</a> <a class="btn btn-lg" href="/auth/login">Get Started</a>
</div> </div>
<p class="welcome-sub">Already have an account? You'll go straight through.</p> <p class="welcome-sub">Access is by invitation. If you have an invite link, it will set up your account automatically. Returning users with credentials can log in above.</p>
} }
</section> </section>
} }
@@ -79,12 +79,12 @@ templ flashBanner(code string) {
// #summary-list region; a non-HTMX request renders the whole page. flash carries // #summary-list region; a non-HTMX request renders the whole page. flash carries
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival // a one-shot notification (e.g. "connected", "registered") surfaced on arrival
// after a POST→redirect. // after a POST→redirect.
templ ListPage(rows []store.SummaryRow, f Filter, flash string) { templ ListPage(rows []store.SummaryRow, f Filter, flash string, hasConnected bool) {
@Layout("Tapir — Summaries") { @Layout("Tapir — Summaries") {
@flashBanner(flash) @flashBanner(flash)
@filterForm(f) @filterForm(f)
<div id="summary-list"> <div id="summary-list">
@summaryList(rows) @summaryList(rows, hasConnected)
</div> </div>
} }
} }
@@ -110,12 +110,20 @@ templ filterForm(f Filter) {
// summaryList is the swappable list fragment: one card per video (summarized or // summaryList is the swappable list fragment: one card per video (summarized or
// not). Cards reflow to a single column on mobile; an empty list shows a friendly // not). Cards reflow to a single column on mobile; an empty list shows a friendly
// first-run state instead of a blank table. // first-run state instead of a blank table.
templ summaryList(rows []store.SummaryRow) { templ summaryList(rows []store.SummaryRow, hasConnected bool) {
if len(rows) == 0 { if len(rows) == 0 {
<div class="empty"> if hasConnected {
<strong>No videos yet</strong> <div class="empty empty-connected">
<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> <strong>Your YouTube account is connected!</strong>
</div> <span>Run <code>tapir run</code> to discover your subscriptions. Videos will appear here once discovered. In manual mode, each new video gets a Summarize button.</span>
</div>
} else {
<div class="empty">
<strong>No videos yet</strong>
<span>Connect your YouTube account to get started.</span>
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
</div>
}
} else { } else {
<ul class="cards"> <ul class="cards">
for _, r := range rows { for _, r := range rows {
@@ -156,6 +164,8 @@ templ VideoCard(r store.SummaryRow) {
if len(r.Actions) > 0 { if len(r.Actions) > 0 {
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span> <span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
} }
} else if r.TranscriptStatus == "rate_limited" {
<span class="chip chip-retry" title="Caption fetch was rate-limited; tapir will retry automatically."> Retrying later</span>
} else if r.SummarizeRequested { } else if r.SummarizeRequested {
<span class="chip">Queued</span> <span class="chip">Queued</span>
<span class="card-state muted">waiting for the next run</span> <span class="card-state muted">waiting for the next run</span>
@@ -297,6 +307,68 @@ templ RegisterPage(email, errMsg string) {
} }
} }
// InvitePage is the public set-password form an invited user reaches via their
// emailed /invite/{token} link. The email is shown read-only (it is fixed by the
// invite, not chosen here); the visitor sets a password to create their account.
// errMsg, when set, reports a validation problem on the prior submit. No auth
// chrome (header nav) is appropriate — the visitor has no session yet — but the
// shared Layout keeps the look consistent.
templ InvitePage(email, token, errMsg string) {
@Layout("Tapir — Set your password") {
<article class="register">
<h1>Set up your Tapir account</h1>
<p class="meta">Invitation for { email }.</p>
<p>Choose a password to finish creating your account. You'll then log in with this email and password.</p>
if errMsg != "" {
<p class="error" role="alert">{ errMsg }</p>
}
<form method="post" action={ inviteURL(token) } class="register-form">
<label>
Email
<input type="email" name="email" value={ email } readonly/>
</label>
<label>
Password
<input type="password" name="password" minlength="8" required autofocus autocomplete="new-password"/>
</label>
<label>
Confirm password
<input type="password" name="password_confirm" minlength="8" required autocomplete="new-password"/>
</label>
<button type="submit" class="btn">Create my account</button>
</form>
</article>
}
}
// InviteInvalidPage is shown when an invite token is missing, expired, or already
// used — a dead-end with no form, so a stale or replayed link reads clearly.
templ InviteInvalidPage() {
@Layout("Tapir — Invitation") {
<article class="register">
<h1>This invite link is no longer valid</h1>
<p>This invitation has expired or has already been used. Ask for a fresh invite link, or log in if you already have an account.</p>
<p><a class="btn" href="/auth/login">Log in</a></p>
</article>
}
}
// InviteNoticePage is a terminal message after a submit that neither succeeded nor
// is a retryable validation error (account already exists, RBAC missing, or the
// dev "deployed-only" degrade). showLogin adds a log-in CTA where that is the
// natural next step.
templ InviteNoticePage(message string, showLogin bool) {
@Layout("Tapir — Invitation") {
<article class="register">
<h1>Invitation</h1>
<p>{ message }</p>
if showLogin {
<p><a class="btn" href="/auth/login">Log in</a></p>
}
</article>
}
}
// AccountPage is the account-management view: the registered display name and // AccountPage is the account-management view: the registered display name and
// signed-in email, the user's connected video accounts (each with a Disconnect // signed-in email, the user's connected video accounts (each with a Disconnect
// control), a Connect-YouTube link when none is connected, and the delete-account // control), a Connect-YouTube link when none is connected, and the delete-account
File diff suppressed because it is too large Load Diff