Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
943554a96c | ||
|
|
40a614c8d4 | ||
|
|
ce2fc62ef8 | ||
|
|
0ceacc8230 | ||
|
|
1e81965519 | ||
|
|
f50c072d65 | ||
|
|
e6f508824b | ||
|
|
689500c85e | ||
|
|
4678d473b8 | ||
|
|
c63b2de66d | ||
|
|
27aa319f1d | ||
|
|
61d4d5bc4a | ||
|
|
483730cd03 | ||
|
|
477701fea2 | ||
|
|
17fad140a6 | ||
|
|
eb24a24b9c | ||
|
|
21e6ddd61e | ||
|
|
152aab7a4a | ||
|
|
1018dc0df9 | ||
|
|
74f4fd7f2a | ||
|
|
0cc441d6ce | ||
|
|
8415a97d15 | ||
|
|
fb425cbf9a | ||
|
|
e77edf58ef | ||
|
|
f15f57f9ed | ||
|
|
d208110002 | ||
|
|
3a27bf1126 | ||
|
|
8ca374e657 | ||
|
|
0fdf2f7218 | ||
|
|
d83943c86a | ||
|
|
6b817f11b9 | ||
|
|
672a0c8580 | ||
|
|
a4aeb5efcd | ||
|
|
8c6c7ca947 | ||
|
|
25215cbcbd | ||
|
|
404f74c55c | ||
|
|
3014ee0d60 | ||
|
|
a269d4a200 | ||
|
|
bdbdce7de1 | ||
|
|
748d5eb0bd | ||
|
|
fa57ee0532 | ||
|
|
22eafcf43f | ||
|
|
2fe4833434 | ||
|
|
17d5e8c393 | ||
|
|
c7624d97fe | ||
|
|
2aad79b2a8 | ||
|
|
0c9531a9b8 | ||
|
|
7b4960e417 | ||
|
|
e62df0027d | ||
|
|
f396e01243 | ||
|
|
9bff59037f | ||
|
|
6d9f3c49ed | ||
|
|
2ae66da0e0 | ||
|
|
f28fdc0292 | ||
|
|
7b139c2cd7 | ||
|
|
6775e5f53d | ||
|
|
8210f927ee | ||
|
|
dc4b06baf4 | ||
|
|
23fa5427b7 | ||
|
|
897a21a1d6 | ||
|
|
b2d1909b13 |
@@ -46,3 +46,8 @@ TAPIR_SECRETS_FILE=
|
|||||||
# --- run loop -------------------------------------------------------------
|
# --- run loop -------------------------------------------------------------
|
||||||
# Empty/0 = single pass. Set (e.g. 15m) to poll on that cadence.
|
# Empty/0 = single pass. Set (e.g. 15m) to poll on that cadence.
|
||||||
TAPIR_POLL_INTERVAL=
|
TAPIR_POLL_INTERVAL=
|
||||||
|
# How long to wait before re-fetching a transcript that returned HTTP 429
|
||||||
|
# (rate_limited). Inside the window the video is skipped without hitting the
|
||||||
|
# caption endpoint; after it expires the video is retried. 0 = always retry.
|
||||||
|
# Go duration; default 1h.
|
||||||
|
TAPIR_FETCH_BACKOFF=
|
||||||
|
|||||||
+1
-21
@@ -90,24 +90,4 @@ jobs:
|
|||||||
&& echo "Smoke test passed" \
|
&& echo "Smoke test passed" \
|
||||||
|| echo "Smoke test inconclusive: $OUTPUT"
|
|| echo "Smoke test inconclusive: $OUTPUT"
|
||||||
|
|
||||||
# ── 3. Mirror to GitHub (deploy intentionally omitted until manifests exist) ─
|
# ── 3. Mirror to GitHub — skipped for now (SSH key rotation pending) ─
|
||||||
mirror:
|
|
||||||
name: Mirror to GitHub
|
|
||||||
needs: build
|
|
||||||
runs-on: self-hosted
|
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Push to GitHub
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo '${{ secrets.GH_DEPLOY_KEY }}' > ~/.ssh/id_rsa_gh_mirror
|
|
||||||
chmod 600 ~/.ssh/id_rsa_gh_mirror
|
|
||||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa_gh_mirror -o IdentitiesOnly=yes" \
|
|
||||||
git push git@github.com:mathiasb/tapir.git HEAD:main
|
|
||||||
rm ~/.ssh/id_rsa_gh_mirror
|
|
||||||
echo "Mirrored to GitHub"
|
|
||||||
|
|||||||
@@ -79,23 +79,33 @@ Skills live in the canonical library `mathias/skills` and are wired into this re
|
|||||||
|
|
||||||
## Current build state (start here for the first task)
|
## Current build state (start here for the first task)
|
||||||
|
|
||||||
The repo is **scaffolded and intentionally RED**:
|
The repo is **green and shipping** — last tag `v0.4.0`. `task check` passes (fmt, vet, lint,
|
||||||
|
`go test -p 1 ./...`). Go is `1.26.1` (see `go.mod`).
|
||||||
|
|
||||||
- Clean Architecture skeleton exists: `internal/domain` (entities), `internal/ports`
|
- Clean Architecture core is implemented: `internal/domain` (entities), `internal/ports`
|
||||||
(interfaces), `internal/usecase` (engine), `cmd/tapir` (entrypoint stub),
|
(interfaces), `internal/usecase.Engine.ProcessNewVideo` (resolve transcript → summarize →
|
||||||
`internal/adapters` (empty — concrete adapters go here).
|
deliver to sinks | skip on no-transcript). The acceptance tests in `test/acceptance/` are
|
||||||
- `usecase.Engine.ProcessNewVideo` returns `ErrNotImplemented`.
|
green against it.
|
||||||
- `test/acceptance/summarize_new_video_test.go` translates the first two Gherkin scenarios and
|
- Adapters present under `internal/adapters/`: `youtube` (captions-first `VideoSource`,
|
||||||
**fails** against the stub. `task check` is therefore red on `test`.
|
timedtext/InnerTube acquisition per ADR-010), `summarizer` + `llm` (the copied AI router,
|
||||||
- **First build task:** implement `ProcessNewVideo` (resolve transcript -> summarize -> deliver to
|
Primary→Fallback per ADR-004), `store` (Postgres, golang-migrate migrations 001–006),
|
||||||
sinks | skip on no-transcript) to make the acceptance tests green, following the `.feature`
|
`secrets` (file-backed `SecretStore`). The brain HTTP sink (ADR-005) is the remaining
|
||||||
files. Then add the AI-router `Summarizer` (copy `llm` per ADR-004), the YouTube `VideoSource`
|
optional sink.
|
||||||
adapter (captions-first), and the store + brain sinks.
|
- Stage 1 is open (ADR-012): multi-user with **DB-enforced** isolation — Postgres RLS `FORCE`d
|
||||||
|
on all user-owned tables (migration 003), two-user isolation test in
|
||||||
|
`internal/adapters/store/rls_test.go`. Registration gate, per-user YouTube web connect, and
|
||||||
|
account management (disconnect / delete, ADR-013) all shipped.
|
||||||
|
- `cmd/tapir` subcommands: `list`, `show`, `auth` (interactive host-side OAuth), `run` (batch
|
||||||
|
watch→summarize), `serve` (the HTMX+Templ web reader/writer under `internal/web`, a new
|
||||||
|
transport over the unchanged engine/ports — ADR-003). `tapir env` prints config.
|
||||||
|
- **Build/run:** `task check` is the gate; `task build` produces the binary. Local dev uses
|
||||||
|
`StubAuth` (allow-all) and a `TAPIR_DB_DSN` Postgres; the deployed service uses Dex OIDC.
|
||||||
|
|
||||||
**Unverified setup items** (see `docs/homelab-integration.md`, marked `confirm`): the Go version
|
**Setup facts** (resolved — see `docs/homelab-integration.md` for the live values): LiteLLM is
|
||||||
in `go.mod` (1.23 — match the koala runner; estate elsewhere uses 1.26.1), the brain-mcp URL, the
|
off-cluster at `koala:30401/v1/` with `LITELLM_MASTER_KEY` from 1Password; the summarization
|
||||||
exact ESO secret-ref naming, and the summarization model alias. Resolve against the live cluster
|
model is config (`TAPIR_SUMMARIZER_MODEL`, default `koala/phi4-mini`), never hardcoded. The
|
||||||
before depending on them, and pin answers back into `docs/homelab-integration.md`.
|
brain-mcp base URL and ESO ref scheme are pinned in that doc; check it before wiring rather than
|
||||||
|
re-deriving.
|
||||||
|
|
||||||
## Provenance (where this design came from)
|
## Provenance (where this design came from)
|
||||||
|
|
||||||
|
|||||||
+218
-16
@@ -154,6 +154,22 @@ governs advancement. Reversible: if demand appears, a new ADR opens the Future C
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-009 — Trunk-Based Development
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-06-02)
|
||||||
|
|
||||||
|
**Context.** Platform-wide convention (homelab architecture review invariant; gitea-mcp #27):
|
||||||
|
commit directly to `main`, one logical change per commit, every commit deployable.
|
||||||
|
|
||||||
|
**Decision.** Tapir follows TBD. Commit directly to `main`. No feature branches or PRs for
|
||||||
|
solo/agent work; short-lived `agent/<desc>` branches only when parallel agents are active on
|
||||||
|
the repo simultaneously. CI is the quality gate, not branch protection.
|
||||||
|
|
||||||
|
**Consequences.** Consistent with the rest of the estate. Depends on the direct-to-main write
|
||||||
|
path tracked in gitea-mcp #35 (item #1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ADR-010 — Third-party caption acquisition via the timedtext/player baseUrl
|
## ADR-010 — Third-party caption acquisition via the timedtext/player baseUrl
|
||||||
|
|
||||||
**Status:** Accepted (2026-06-02)
|
**Status:** Accepted (2026-06-02)
|
||||||
@@ -203,22 +219,6 @@ player/timedtext baseUrl) only. ADR-007's captions-first stance and the STT defe
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADR-009 — Trunk-Based Development
|
|
||||||
|
|
||||||
**Status:** Accepted (2026-06-02)
|
|
||||||
|
|
||||||
**Context.** Platform-wide convention (homelab architecture review invariant; gitea-mcp #27):
|
|
||||||
commit directly to `main`, one logical change per commit, every commit deployable.
|
|
||||||
|
|
||||||
**Decision.** Tapir follows TBD. Commit directly to `main`. No feature branches or PRs for
|
|
||||||
solo/agent work; short-lived `agent/<desc>` branches only when parallel agents are active on
|
|
||||||
the repo simultaneously. CI is the quality gate, not branch protection.
|
|
||||||
|
|
||||||
**Consequences.** Consistent with the rest of the estate. Depends on the direct-to-main write
|
|
||||||
path tracked in gitea-mcp #35 (item #1).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-011 — Web read-surface at Stage 0: Dex authn (single-user authz), action signal, public ingress + GitOps
|
## ADR-011 — Web read-surface at Stage 0: Dex authn (single-user authz), action signal, public ingress + GitOps
|
||||||
|
|
||||||
**Status:** Accepted (2026-06-02)
|
**Status:** Accepted (2026-06-02)
|
||||||
@@ -290,6 +290,205 @@ explicit call, with isolation as the guardrail that keeps it safe.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-013 — Account deletion is Tapir-side only; the Dex identity is left intact
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-06-03)
|
||||||
|
|
||||||
|
**Context.** Stage 1 (ADR-012) added account deletion. A registered user is two things: a
|
||||||
|
`users` row (plus all their data, cascade-linked) in Tapir's Postgres, and a subject identity
|
||||||
|
in **Dex** (the homelab OIDC provider, shared across the estate — Tapir does not own it).
|
||||||
|
"Delete my account" could mean (a) erase all Tapir-side data and secrets, or (b) that plus
|
||||||
|
deprovision the Dex identity. The maintainer chose (a).
|
||||||
|
|
||||||
|
**Decision.** Deleting a Tapir account removes **only Tapir-side state**:
|
||||||
|
- The `users` row, cascading to all user-owned tables (`videos`, `transcripts`, `summaries`,
|
||||||
|
`sink_deliveries`, `video_connections`, and — via an **explicit delete**, because it has no
|
||||||
|
FK — `summary_actions`). The delete test asserts the cascade reaches every table and leaves
|
||||||
|
other users' rows untouched.
|
||||||
|
- All of that user's secrets in the SecretStore (the per-user YouTube refresh-token refs).
|
||||||
|
|
||||||
|
The **Dex identity is deliberately left intact.** Tapir does not deprovision, disable, or
|
||||||
|
modify the shared Dex directory.
|
||||||
|
|
||||||
|
**Consequences.**
|
||||||
|
- **Clean re-registration:** a deleted user who logs in again arrives as a Dex-authenticated
|
||||||
|
subject with no `users` row, so they hit the registration gate as a "new" user — no special
|
||||||
|
resurrection path needed. This is a feature of the choice, not an accident.
|
||||||
|
- **Right-to-erasure is partial.** The user's *identity* still exists in Dex after deletion.
|
||||||
|
For Future B (trusted friends) this is acceptable: Dex is the maintainer's own directory and
|
||||||
|
the identity carries no Tapir content. **But if Tapir ever moves toward Future C (real
|
||||||
|
external/public users), this is a GDPR-shaped gap** — a true "delete my account" there must
|
||||||
|
also deprovision or anonymise the Dex identity, which is a new ADR and likely a Dex-admin
|
||||||
|
integration Tapir does not currently have.
|
||||||
|
- **Blast radius stays small:** Tapir never holds write access to the shared identity provider,
|
||||||
|
consistent with the estate's blast-radius-minimisation posture (ADR-002, architecture review).
|
||||||
|
|
||||||
|
**Reversibility.** Adding Dex deprovisioning later is a superseding ADR; nothing about the
|
||||||
|
current choice blocks it. Recorded now because "deletion is partial by design" is a deliberate
|
||||||
|
semantic that future-Tapir (and any compliance review) must know was chosen, not overlooked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-014 — Timedtext 429 handling: per-host backoff + honest in-flight UX, before any Whisper reconsideration
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-06-03)
|
||||||
|
|
||||||
|
**Context.** ADR-010 acquires captions from the unauthenticated `timedtext` baseUrl. Live runs
|
||||||
|
show that endpoint **rate-limits per source IP (HTTP 429) under volume** — many videos fetched
|
||||||
|
in one pass from one egress IP. Stage 1 (ADR-012) made this sharper in two ways: multiple users
|
||||||
|
now drive fetches from the *same cluster egress IP*, and the v0.4.0 "Summarize" button fires an
|
||||||
|
**immediate, synchronous-feeling** fetch on click (HTMX polls `/v/{videoId}/status`), so a 429
|
||||||
|
now surfaces as a *user-facing stall* rather than a background batch hiccup. A throttle
|
||||||
|
(`TAPIR_FETCH_DELAY`) exists but is a fixed inter-fetch delay, not 429-aware, and does not
|
||||||
|
coordinate across the concurrent click-path and the `tapir run` batch path.
|
||||||
|
|
||||||
|
This ADR is **not** a decision to build Whisper. ADR-007/010 keep STT deferred *pending
|
||||||
|
measurement of the sustainable caption rate* — and that rate cannot be measured while the
|
||||||
|
client reacts badly to the 429s it already provokes. Fix the backoff and the UX first; the
|
||||||
|
clean data then tells you whether Whisper is warranted.
|
||||||
|
|
||||||
|
**Decision.**
|
||||||
|
|
||||||
|
1. **429-aware backoff at the fetch layer.** On a 429 from the timedtext/InnerTube fetch,
|
||||||
|
respect `Retry-After` when present; otherwise exponential backoff with jitter. This replaces
|
||||||
|
reliance on a fixed `TAPIR_FETCH_DELAY` alone (which stays as a floor/politeness delay).
|
||||||
|
2. **A single per-egress-IP rate gate** shared by *both* the `tapir run` batch path and the
|
||||||
|
web click path, so they cannot collectively exceed the sustainable rate. Concurrency into
|
||||||
|
the timedtext endpoint is serialised/limited at this gate regardless of how many users or
|
||||||
|
goroutines are upstream. (The 429 is per *IP*, not per user — so the gate is process-/
|
||||||
|
cluster-egress-wide, not per-`withUser`.)
|
||||||
|
3. **Honest in-flight UX (the product-shaping part).** The status poll distinguishes states
|
||||||
|
the user can understand instead of a spinner that silently stalls:
|
||||||
|
- *summarizing* — actively processing (the existing tapir spinner).
|
||||||
|
- *queued / waiting for rate limit* — fetch deferred behind the rate gate; show a calm
|
||||||
|
"queued, this can take a few minutes when busy" state, not a stuck spinner.
|
||||||
|
- *no transcript* — terminal, per ADR-010's degrade-never-error (a 429 that exhausts retries
|
||||||
|
resolves to `SourceNone`, same as any unavailable caption — it must not present as a hard
|
||||||
|
error to the user).
|
||||||
|
The spinner promising imminence is the wrong signal under rate-limiting; the UX must be able
|
||||||
|
to say "waiting" truthfully.
|
||||||
|
4. **Measurement before Whisper.** Only once (1)-(3) are in and a real sustainable
|
||||||
|
per-IP rate is observed do we revisit whether caption coverage is good enough or whether the
|
||||||
|
deferred Whisper fallback (ADR-007) is finally warranted. That reconsideration is a future
|
||||||
|
ADR, gated on this data.
|
||||||
|
|
||||||
|
**Consequences.**
|
||||||
|
- Caption fetching becomes well-behaved under multi-user load instead of self-inflicting 429s;
|
||||||
|
the endpoint is treated as the shared, rate-limited resource it is.
|
||||||
|
- The click-path UX stays honest: "waiting" reads as waiting, failure degrades to "no
|
||||||
|
transcript", never a stuck spinner or error spew.
|
||||||
|
- A future per-IP cooldown / second egress IP / proxy becomes an option the rate gate can sit
|
||||||
|
in front of without UX changes.
|
||||||
|
- **Still no Whisper** — and now there's a clean path to the *data* that decides whether it's
|
||||||
|
ever needed (`docs/homelab-integration.md` and a future ADR own that measurement).
|
||||||
|
|
||||||
|
**Open (tracked, not in this ADR's scope):** the actual sustainable rate number; whether a
|
||||||
|
dedicated egress IP / outbound proxy is worth it; CronJob-driven `tapir run` interaction with
|
||||||
|
the rate gate (the batch path moves into k3s per the deferred CronJob item).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-015 — Per-user credentials: envelope-encrypted in PG18, not vault-stored
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-06-03)
|
||||||
|
|
||||||
|
**Context.** The Stage-0/1 SecretStore (`internal/adapters/secrets/file.go`) holds per-user
|
||||||
|
YouTube OAuth refresh tokens as a flat key-value JSON map on a PVC — explicitly a stand-in for
|
||||||
|
"op/ESO later" (ADR-002, ADR-006). infra#86 proposed migrating it to an ESO-backed store. The
|
||||||
|
decision spike (infra#88) found that framing subtly wrong: **ESO syncs vault→cluster at
|
||||||
|
deploy/refresh time; it is not a runtime write API.** Per-user tokens are written *at runtime,
|
||||||
|
per end-user* (every YouTube connect; on token rotation) — they are application state, not
|
||||||
|
configuration. The homelab 1Password SA is also read-only, so a vault-write path would require
|
||||||
|
a new write-capable SA, widening Tapir's blast radius to shared estate infra to store what is
|
||||||
|
fundamentally Tapir's own row-data. Reading the actual SecretStore confirmed the shape: a
|
||||||
|
3-method port (`Get`/`Put`/`Delete`) over opaque refs, written interactively per user.
|
||||||
|
|
||||||
|
**Decision.** Per-user credentials are stored **envelope-encrypted in PG18**, not in the vault:
|
||||||
|
|
||||||
|
1. Tokens are encrypted with a **single app-level envelope key** and stored as ciphertext in
|
||||||
|
PG18, under the Row-Level Security already enforced and tested (ADR-012). Reads/writes go
|
||||||
|
through the existing `withUser` RLS-scoped seam.
|
||||||
|
2. The **envelope key** is the only secret in 1Password — fetched via the **existing read-only
|
||||||
|
SA** (confirmed working). No new write-capable SA; no per-user vault items.
|
||||||
|
3. The `ports.SecretStore` port is unchanged (`Get`/`Put`/`Delete`). The implementation swaps
|
||||||
|
`FileStore` (PVC JSON) for a `PGStore` (encrypted rows). Every consumer — connect,
|
||||||
|
disconnect, delete-account — is untouched (the port abstraction holds, ADR-003 spirit).
|
||||||
|
4. **Infra/operator credentials** (Dex client secret, MCP-auth tokens, service tokens) stay an
|
||||||
|
**ESO/1Password** concern. This ADR governs *per-user runtime* credentials only. The two
|
||||||
|
classes use two mechanisms deliberately — because they are two different things (runtime
|
||||||
|
app-state vs deploy-time config), not as a compromise. The "one mechanism" question
|
||||||
|
(maintainer's initial preference) was answered in #88 by correctly *classifying* the
|
||||||
|
secrets rather than unifying their storage.
|
||||||
|
|
||||||
|
**Consequences.**
|
||||||
|
- Runtime credential writes are normal RLS'd DB writes — no ESO sync latency, no indirection,
|
||||||
|
no write-SA blast radius. The interactive connect→store→use flow works without a vault
|
||||||
|
round-trip.
|
||||||
|
- Keeps PG18 and keeps ADR-002 intact (Supabase was considered and rejected again in #88 —
|
||||||
|
adding a datastore to hold a few encrypted strings PG18 already holds).
|
||||||
|
- Adds an encrypt/decrypt seam and an **envelope-key rotation** responsibility (re-encrypt the
|
||||||
|
per-user rows under a new key). infra#89 (build) must implement and test rotation, not assume
|
||||||
|
it — this is the real engineering cost of the choice.
|
||||||
|
- The vault's involvement shrinks to one static key via the SA already trusted for reads.
|
||||||
|
- **Supersedes** the "PVC stand-in for op/ESO" intent recorded in `secrets/file.go` and
|
||||||
|
`docs/homelab-integration.md` for the *per-user* secret path (the ESO/1Password reference in
|
||||||
|
ADR-006 stands for the *infra-cred* path).
|
||||||
|
|
||||||
|
**Reversibility / falsification (from infra#88).** Revisit if: per-user tokens need
|
||||||
|
high-frequency rotation writes (weak — PG18 handles it); an estate compliance policy requires
|
||||||
|
all credentials in 1P for a single audit surface (maintainer-knowable, not currently believed
|
||||||
|
to hold — would favour the vault-write path on policy grounds); or envelope-key rotation proves
|
||||||
|
operationally worse than per-secret vault rotation (the real cost #89 must prove). If none hold,
|
||||||
|
this stands. Full reasoning + rejected candidates (write-capable SA; Supabase): the infra#88
|
||||||
|
decision doc (`infra/docs/superpowers/handoffs/`). Build + reboot-validation: infra#89.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-016 — Stage 0 gate revised: "useful to me OR a friend", behavioural not feedback
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-06-03). Revises the Stage 0 definition in VISION.md (supersedes the
|
||||||
|
original "useful to me, specifically" gate and folds in the old Stage 1 "a trusted user returns"
|
||||||
|
test).
|
||||||
|
|
||||||
|
**Context.** The original Stage 0 gate was "the maintainer reads summaries weekly for four weeks
|
||||||
|
and acts on one." The maintainer chose to change it to include friendly users, reasoning that
|
||||||
|
early signal from friendly users is valuable. Two sub-decisions shaped the final form:
|
||||||
|
- *Me OR a friend* (not AND): either the maintainer or an onboarded friend showing use clears it.
|
||||||
|
- *Behavioural, not feedback*: the test is **return usage**, not stated approval.
|
||||||
|
|
||||||
|
**Decision.** Stage 0 passes when, over a 3–4 week window, **either the maintainer or at least
|
||||||
|
one onboarded friend returns to Tapir unprompted and reads/acts on summaries in ≥2 separate
|
||||||
|
weeks.** Friend feedback is gathered and valued but is **not** the gate.
|
||||||
|
|
||||||
|
**Why behavioural, not feedback (the load-bearing part).** Asked-for feedback from friendly
|
||||||
|
users is the least reliable signal in product development — politeness bias means a friend you
|
||||||
|
onboarded will tend to say encouraging things regardless of real value. The thing actually worth
|
||||||
|
knowing is whether they *come back on their own*. So the gate measures returns, not nice words.
|
||||||
|
This deliberately resists the most common way a principled gate dies: being declared "passed" on
|
||||||
|
the strength of a polite reaction.
|
||||||
|
|
||||||
|
**Honest note on what this change does.** This is a *guardrail edit made while the original gate
|
||||||
|
was unmet* (Stage 0 had barely started; build had run well ahead of use-evidence). That is
|
||||||
|
precisely the pattern that warrants scrutiny — redrawing a gate around work already done. It was
|
||||||
|
examined on that basis and proceeds because: (a) the new gate is **not softer in kind** — it
|
||||||
|
stays behavioural and sustained, merely broadening *who* can supply the signal; (b) friendly-user
|
||||||
|
signal is genuinely valuable; (c) the politeness-bias guard keeps it from collapsing into
|
||||||
|
"someone said it's nice." It is *not* a licence to treat the already-shipped Stage-1 machinery as
|
||||||
|
evidence the gate passed — use-evidence remains open.
|
||||||
|
|
||||||
|
**Consequences.**
|
||||||
|
- VISION.md Stage 0 rewritten; old Stage 1 ("a trusted user returns") folded in (it was
|
||||||
|
near-identical to the new test); hardening renumbered to Stage 1.
|
||||||
|
- New drift signal added: declaring the gate passed on polite feedback rather than return-usage.
|
||||||
|
- The 2026-07-01 check-in now asks "is anyone (me or a friend) coming back unprompted?", not
|
||||||
|
"am I using it weekly?".
|
||||||
|
|
||||||
|
**Reversibility.** A superseding ADR could tighten it back to maintainer-only or raise it to
|
||||||
|
require multiple returning users. Recorded with the full rationale (including the self-scrutiny
|
||||||
|
about editing a gate while it's unmet) so the reasoning survives, not just the new wording.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Rejected alternatives
|
## Rejected alternatives
|
||||||
|
|
||||||
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
||||||
@@ -308,6 +507,9 @@ maps to the ADR that settles it.
|
|||||||
| Audio-download + Whisper STT in the core path | ToS-grey, breakage-prone (yt-dlp), contends for koala GPU with the JEPA PoC; captions alone test the core hypothesis | ADR-007 |
|
| Audio-download + Whisper STT in the core path | ToS-grey, breakage-prone (yt-dlp), contends for koala GPU with the JEPA PoC; captions alone test the core hypothesis | ADR-007 |
|
||||||
| Building multi-tenant SaaS / Google OAuth verification now | "Real users soon" was lowered to Future B; SaaS machinery before the Stage 0 self-use gate is the primary documented anti-goal | ADR-008, VISION |
|
| Building multi-tenant SaaS / Google OAuth verification now | "Real users soon" was lowered to Future B; SaaS machinery before the Stage 0 self-use gate is the primary documented anti-goal | ADR-008, VISION |
|
||||||
| Delegating the S5 reuse spike to an agent swarm | A 1-hour sequential read-and-judge with a single coupled conclusion; orchestration overhead exceeds the work, and it's Diamond-1 judgment the maintainer wanted to own | (process note) |
|
| Delegating the S5 reuse spike to an agent swarm | A 1-hour sequential read-and-judge with a single coupled conclusion; orchestration overhead exceeds the work, and it's Diamond-1 judgment the maintainer wanted to own | (process note) |
|
||||||
|
| Vault-write SA for per-user OAuth tokens (ESO as runtime write path) | ESO syncs vault→cluster at deploy time, not a runtime write API; a write-SA widens blast radius to shared infra to store app row-data | ADR-015, infra#88 |
|
||||||
|
| Supabase for per-user credential storage | Adds a second datastore for a few encrypted strings PG18 already holds; reopens ADR-002 | ADR-015, infra#88 |
|
||||||
|
| Feedback-based Stage 0 gate (friends saying it's useful) | Politeness bias makes asked-for feedback the least reliable signal; return-usage is the real test | ADR-016 |
|
||||||
|
|
||||||
If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one —
|
If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one —
|
||||||
not a silent reversal.
|
not a silent reversal.
|
||||||
|
|||||||
@@ -44,50 +44,54 @@ fallback — their key, their choice.
|
|||||||
|
|
||||||
## Who it is for
|
## Who it is for
|
||||||
|
|
||||||
- **Now (the first customer):** the maintainer — one person, their own subscriptions,
|
- **Now (the first customers):** the maintainer and a small number of known, trusted
|
||||||
summaries delivered to their own store and brain.
|
friends — each with their own account, isolated data, optional BYO-AI. The maintainer is
|
||||||
- **Soon (Future B):** a small number of known, trusted users (friends / beta) — each with
|
the first customer; friendly users provide the earliest real-world signal.
|
||||||
their own account, isolated data, optional BYO-AI.
|
|
||||||
- **Maybe (Future C, explicitly not built yet):** a public multi-tenant service. Deferred
|
- **Maybe (Future C, explicitly not built yet):** a public multi-tenant service. Deferred
|
||||||
until there is evidence of sustained personal use **and** real demand. Building for C
|
until there is evidence of sustained use **and** real demand. Building for C before that
|
||||||
before that evidence is a known anti-goal.
|
evidence is a known anti-goal.
|
||||||
|
|
||||||
## Definition of Success
|
## Definition of Success
|
||||||
|
|
||||||
Success is staged. Each stage has a single, falsifiable headline test. We do not advance
|
Success is staged. Each stage has a single, falsifiable headline test. We do not advance
|
||||||
to the next stage's ambition until the current stage's test passes.
|
to the next stage's ambition until the current stage's test passes.
|
||||||
|
|
||||||
### Stage 0 — Useful to me (the gate)
|
### Stage 0 — Useful to me or a friend (the gate)
|
||||||
|
|
||||||
> **Headline test:** For four consecutive weeks, the maintainer reads Tapir-produced
|
> **Headline test:** Over a 3–4 week window, *either* the maintainer *or* at least one
|
||||||
> summaries for their own subscriptions at least weekly, and at least once acts on a
|
> onboarded friend returns to Tapir **unprompted** and reads/acts on summaries in **≥2
|
||||||
> summary (watches / skips / saves a video *because of* the summary).
|
> separate weeks**. The test is *return usage* (behavioural), not stated approval.
|
||||||
|
|
||||||
- Captions-first summarization works end-to-end for the maintainer's real subscriptions.
|
- Captions-first summarization works end-to-end for real subscriptions (the maintainer's
|
||||||
- Summaries land in the maintainer's store and (optionally) brain.
|
and onboarded friends').
|
||||||
|
- Summaries land in each user's own store and (optionally) brain.
|
||||||
- Local-first AI produces summaries of acceptable quality without manual intervention
|
- Local-first AI produces summaries of acceptable quality without manual intervention
|
||||||
most of the time.
|
most of the time.
|
||||||
- **This is the gate.** Multi-user, BYO-AI-for-others, and any SaaS ambition stay deferred
|
- **Why behavioural, not feedback.** Friend *feedback* is gathered and genuinely valuable —
|
||||||
until Stage 0 holds. (Ties to the 2026-07-01 self-use check-in.)
|
but it is **not** the gate. Asked-for feedback from friendly users is the least reliable
|
||||||
|
signal in product development (politeness bias); whether they *come back on their own* is
|
||||||
|
the thing we actually care about. So the gate measures returns, not nice words.
|
||||||
|
- **Why "me OR a friend".** This replaces the original "useful to *me*, specifically" gate
|
||||||
|
(2026-06-03 decision, recorded in DECISIONS.md ADR-016). Getting signal from friendly
|
||||||
|
users is valuable enough to count — but the bar stays behavioural so it can't be cleared
|
||||||
|
by a polite reaction. (Ties to the 2026-07-01 check-in.)
|
||||||
|
- **This is the gate.** Hardening (Stage 1) and any SaaS ambition stay deferred until this
|
||||||
|
behavioural signal exists. Note: multi-user machinery was deliberately built *ahead* of
|
||||||
|
this gate (ADR-012) with isolation enforced — that was an explicit, recorded call, not a
|
||||||
|
sign the gate had passed. The gate is about *evidence of use*, which is still open.
|
||||||
|
|
||||||
### Stage 1 — Useful to a few (Future B)
|
### Stage 1 — Trustworthy at rest (hardening, Future B)
|
||||||
|
|
||||||
> **Headline test:** At least one trusted user other than the maintainer connects their
|
|
||||||
> own account and, within their first month, keeps using it (returns to read summaries in
|
|
||||||
> ≥2 separate weeks) without the maintainer hand-holding each summary.
|
|
||||||
|
|
||||||
- Multiple users, each with isolated accounts, credentials, and summaries.
|
|
||||||
- A new user can self-connect a YouTube/Vimeo account and get summaries with no code change.
|
|
||||||
- Optional BYO-AI works per-user.
|
|
||||||
- No cross-user data leakage — demonstrable, not assumed.
|
|
||||||
|
|
||||||
### Stage 2 — Trustworthy at rest (hardening, still Future B)
|
|
||||||
|
|
||||||
> **Headline test:** Credentials (OAuth tokens, BYO-AI keys) are encrypted at rest via the
|
> **Headline test:** Credentials (OAuth tokens, BYO-AI keys) are encrypted at rest via the
|
||||||
> homelab's existing secrets convention; a documented, rehearsed recovery path exists; and
|
> homelab's existing secrets convention; a documented, rehearsed recovery path exists; and
|
||||||
> a deliberate isolation test (user A cannot read user B's data) passes in CI or a
|
> a deliberate isolation test (user A cannot read user B's data) passes in CI or a
|
||||||
> documented manual drill.
|
> documented manual drill.
|
||||||
|
|
||||||
|
- Per-user data isolation is enforced and tested (delivered early via ADR-012 RLS).
|
||||||
|
- Per-user credentials are encrypted at rest (ADR-015 envelope encryption; build in infra#89).
|
||||||
|
- A new user can self-connect a YouTube/Vimeo account and get summaries with no code change.
|
||||||
|
- Optional BYO-AI works per-user.
|
||||||
|
|
||||||
### Non-goals (current)
|
### Non-goals (current)
|
||||||
|
|
||||||
- Public sign-up / billing / a marketing surface.
|
- Public sign-up / billing / a marketing surface.
|
||||||
@@ -98,7 +102,11 @@ to the next stage's ambition until the current stage's test passes.
|
|||||||
|
|
||||||
## How we will know we are drifting
|
## How we will know we are drifting
|
||||||
|
|
||||||
- We are building Stage 1+ machinery before the Stage 0 gate has passed.
|
- We declare the Stage 0 gate "passed" on the strength of polite feedback rather than
|
||||||
|
behavioural return-usage (the politeness-bias trap the gate is designed to resist).
|
||||||
|
- We build Stage 1 hardening or Future C machinery while the Stage 0 use-evidence is still
|
||||||
|
absent. (Multi-user machinery already shipped ahead of the gate via ADR-012 — a recorded,
|
||||||
|
deliberate exception, not a precedent for more.)
|
||||||
- A user's content reaches a third-party model without that user's explicit, per-user opt-in.
|
- A user's content reaches a third-party model without that user's explicit, per-user opt-in.
|
||||||
- "Brain ingestion" starts dictating the architecture instead of being one sink behind an
|
- "Brain ingestion" starts dictating the architecture instead of being one sink behind an
|
||||||
interface.
|
interface.
|
||||||
|
|||||||
+51
-31
@@ -22,15 +22,11 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/summarizer"
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
|
"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
|
||||||
)
|
)
|
||||||
@@ -120,34 +116,27 @@ func cmdRun(ctx context.Context, log *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
// Same wiring the web serve path uses (buildProcessor). ValidateForRun above
|
||||||
src := youtube.New(youtube.Config{
|
// already required the engine's inputs, so a nil here is a genuine config gap.
|
||||||
ClientID: cfg.YTClientID,
|
engine, err := buildProcessor(cfg, st)
|
||||||
ClientSecret: cfg.YTClientSecret,
|
if err != nil {
|
||||||
TokenSecretRef: cfg.YTTokenRef,
|
return err
|
||||||
PreferredLanguages: []string{"en"},
|
|
||||||
}, secretStore)
|
|
||||||
|
|
||||||
// Local Primary only; no BYO fallback for the demo (fallback nil).
|
|
||||||
primary := summarizer.Endpoint{
|
|
||||||
Client: llm.New(cfg.GatewayURL, cfg.GatewayKey, cfg.SummarizerModel, cfg.SummarizerTimeout),
|
|
||||||
Provider: "local",
|
|
||||||
Model: cfg.SummarizerModel,
|
|
||||||
}
|
}
|
||||||
sum := summarizer.New(primary, nil)
|
if engine == nil {
|
||||||
|
return fmt.Errorf("run: incomplete summarization config (gateway, youtube credentials, secrets file)")
|
||||||
engine := usecase.NewEngine(src, sum, st)
|
}
|
||||||
r := runner.New(src, st, engine, cfg.UserID, log)
|
r := runner.New(engine.Source, st, engine, cfg.UserID, log, runner.WithBackoff(cfg.FetchBackoff))
|
||||||
|
|
||||||
log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
|
log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
|
||||||
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval)
|
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval, "fetch_backoff", cfg.FetchBackoff)
|
||||||
return r.Loop(ctx, cfg.PollInterval)
|
return r.Loop(ctx, cfg.PollInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cmdServe runs the Stage-0 web UI: the summary reader over the existing store
|
// cmdServe runs the Stage-1 web UI: the summary reader over the existing store
|
||||||
// (ADR-003 — a new transport, not new core). Auth is the StubAuth allow-all seam
|
// (ADR-003 — a new transport, not new core). Auth (web.Auth) gates access; the
|
||||||
// keyed to the configured user; the Conductor swaps in oidc.DexAuth at merge —
|
// registration gate resolves the authenticated subject to a tapir user_id and
|
||||||
// the only line that changes is the `authn` assignment below.
|
// scopes every store access by it (ADR-012). With Dex configured, real OIDC login
|
||||||
|
// is used; otherwise StubAuth (dev only). The store doubles as the Identity port.
|
||||||
func cmdServe(ctx context.Context, log *slog.Logger) error {
|
func cmdServe(ctx context.Context, log *slog.Logger) error {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -164,9 +153,9 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
|||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
// Auth seam (handlers depend on web.Auth only). With Dex configured
|
// Auth seam (handlers depend on web.Auth only). With Dex configured
|
||||||
// (TAPIR_OIDC_ISSUER set) serve uses real OIDC login with single-user
|
// (TAPIR_OIDC_ISSUER set) serve uses real OIDC login — any Dex subject may
|
||||||
// allowlist authz (ADR-011); otherwise it falls back to the allow-all
|
// authenticate, then registers a tapir user (ADR-012); otherwise it falls
|
||||||
// StubAuth for local dev — never expose StubAuth publicly.
|
// back to the allow-all StubAuth for local dev — never expose StubAuth publicly.
|
||||||
var authn web.Auth
|
var authn web.Auth
|
||||||
if cfg.DexConfigured() {
|
if cfg.DexConfigured() {
|
||||||
authn, err = oidc.New(ctx, oidc.Config{
|
authn, err = oidc.New(ctx, oidc.Config{
|
||||||
@@ -175,7 +164,6 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
|||||||
ClientSecret: cfg.DexClientSecret,
|
ClientSecret: cfg.DexClientSecret,
|
||||||
RedirectURL: cfg.OIDCRedirectURL,
|
RedirectURL: cfg.OIDCRedirectURL,
|
||||||
SessionSecret: cfg.SessionSecret,
|
SessionSecret: cfg.SessionSecret,
|
||||||
AllowedSubject: cfg.AllowedSubject,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("dex oidc: %w", err)
|
return fmt.Errorf("dex oidc: %w", err)
|
||||||
@@ -186,7 +174,39 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
|||||||
log.Warn("web auth: STUB allow-all (no TAPIR_OIDC_ISSUER) — local dev only, do not expose")
|
log.Warn("web auth: STUB allow-all (no TAPIR_OIDC_ISSUER) — local dev only, do not expose")
|
||||||
}
|
}
|
||||||
|
|
||||||
app := &web.App{Store: st, Auth: authn, UserID: cfg.UserID, Log: log}
|
// The file-backed SecretStore is shared by the connect flow (writes tokens)
|
||||||
|
// and account management (deletes them on disconnect / delete-account).
|
||||||
|
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
||||||
|
app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log}
|
||||||
|
|
||||||
|
// Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client
|
||||||
|
// credentials are present; the refresh token persists through the SecretStore
|
||||||
|
// under a per-user ref (web.YouTubeTokenRef). Live connect also needs the
|
||||||
|
// callback URL registered in the Google OAuth client's authorized redirects.
|
||||||
|
if cfg.YTClientID != "" && cfg.YTClientSecret != "" {
|
||||||
|
app.Connect = web.NewConnectHandler(auth.Config{
|
||||||
|
ClientID: cfg.YTClientID,
|
||||||
|
ClientSecret: cfg.YTClientSecret,
|
||||||
|
RedirectURL: cfg.YTConnectRedirectURL,
|
||||||
|
}, secretStore, st, log)
|
||||||
|
log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediate summarization for the web "Summarize" button. When the engine can
|
||||||
|
// be built (gateway + YouTube credentials + secrets present), a click runs the
|
||||||
|
// summary now in the background; otherwise the button stays queue-only and the
|
||||||
|
// next `tapir run` does the work (buildProcessor returns nil — never an error).
|
||||||
|
engine, err := buildProcessor(cfg, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if engine != nil {
|
||||||
|
app.Processor = &engineProcessor{engine: engine, store: st}
|
||||||
|
log.Info("web immediate summarization enabled", "model", cfg.SummarizerModel)
|
||||||
|
} else {
|
||||||
|
log.Info("web summarization is queue-only (incomplete engine config)")
|
||||||
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: app.Router(),
|
Handler: app.Router(),
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/summarizer"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildProcessor wires the summarization engine — YouTube source (captions-first),
|
||||||
|
// AI-router summarizer, store sink — shared by `tapir run` and the web
|
||||||
|
// "Summarize now" path so the wiring lives in one place. It returns (nil, nil) —
|
||||||
|
// not an error — when the config cannot support live summarization (no gateway
|
||||||
|
// URL, no YouTube client credentials, or no secrets file). That nil is the
|
||||||
|
// queue-only fallback: the web UI keeps working (the button just queues) and
|
||||||
|
// `tapir run` reports the gap via its own ValidateForRun. Missing engine config
|
||||||
|
// is never an error here.
|
||||||
|
func buildProcessor(cfg config.Config, st *store.Store) (*usecase.Engine, error) {
|
||||||
|
if cfg.GatewayURL == "" || cfg.YTClientID == "" || cfg.YTClientSecret == "" || cfg.SecretsFile == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
||||||
|
src := youtube.New(youtube.Config{
|
||||||
|
ClientID: cfg.YTClientID,
|
||||||
|
ClientSecret: cfg.YTClientSecret,
|
||||||
|
TokenSecretRef: cfg.YTTokenRef,
|
||||||
|
PreferredLanguages: []string{"en"},
|
||||||
|
}, secretStore)
|
||||||
|
|
||||||
|
// Local Primary only; no BYO fallback for the demo (fallback nil).
|
||||||
|
primary := summarizer.Endpoint{
|
||||||
|
Client: llm.New(cfg.GatewayURL, cfg.GatewayKey, cfg.SummarizerModel, cfg.SummarizerTimeout),
|
||||||
|
Provider: "local",
|
||||||
|
Model: cfg.SummarizerModel,
|
||||||
|
}
|
||||||
|
sum := summarizer.New(primary, nil)
|
||||||
|
|
||||||
|
return usecase.NewEngine(src, sum, st), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// engineProcessor adapts the engine (which works in terms of a domain.Video) to
|
||||||
|
// the web.Processor port (which works in terms of a stored video id): it loads the
|
||||||
|
// video row, runs the engine, and — on a produced summary — clears the manual
|
||||||
|
// queue flag, mirroring the runner so the video is not re-summarized on the next
|
||||||
|
// `tapir run` and the UI drops the "Queued" chip. A skip (no transcript) leaves
|
||||||
|
// the flag set so a later run can retry.
|
||||||
|
type engineProcessor struct {
|
||||||
|
engine *usecase.Engine
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *engineProcessor) ProcessVideo(ctx context.Context, userID, videoID string) error {
|
||||||
|
row, err := p.store.GetVideoRow(ctx, userID, videoID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load video %q: %w", videoID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := domain.Video{
|
||||||
|
ID: row.VideoID,
|
||||||
|
UserID: userID,
|
||||||
|
Provider: domain.Provider(row.Channel),
|
||||||
|
ProviderVideoID: row.ProviderVideoID,
|
||||||
|
Title: row.Title,
|
||||||
|
URL: row.URL,
|
||||||
|
PublishedAt: row.PublishedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := p.engine.ProcessNewVideo(ctx, v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("process video %q: %w", videoID, err)
|
||||||
|
}
|
||||||
|
if res.Summary != nil {
|
||||||
|
if err := p.store.ClearSummarizeRequested(ctx, userID, videoID); err != nil {
|
||||||
|
return fmt.Errorf("clear summarize flag %q: %w", videoID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBuildProcessorNilOnIncompleteConfig asserts the queue-only fallback: when a
|
||||||
|
// required input is missing, buildProcessor returns (nil, nil) — never an error —
|
||||||
|
// so the web UI degrades to queue-only instead of failing to start.
|
||||||
|
func TestBuildProcessorNilOnIncompleteConfig(t *testing.T) {
|
||||||
|
// A complete config (the fields buildProcessor gates on). The store is nil:
|
||||||
|
// buildProcessor must not touch it on the incomplete paths, and the complete
|
||||||
|
// path only stores the pointer (no connection), so nil is fine for this test.
|
||||||
|
complete := config.Config{
|
||||||
|
GatewayURL: "http://gw/v1",
|
||||||
|
YTClientID: "id",
|
||||||
|
YTClientSecret: "secret",
|
||||||
|
SecretsFile: "/tmp/secrets.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(config.Config) config.Config
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{"complete", func(c config.Config) config.Config { return c }, false},
|
||||||
|
{"no gateway url", func(c config.Config) config.Config { c.GatewayURL = ""; return c }, true},
|
||||||
|
{"no yt client id", func(c config.Config) config.Config { c.YTClientID = ""; return c }, true},
|
||||||
|
{"no yt client secret", func(c config.Config) config.Config { c.YTClientSecret = ""; return c }, true},
|
||||||
|
{"no secrets file", func(c config.Config) config.Config { c.SecretsFile = ""; return c }, true},
|
||||||
|
{"empty config", func(config.Config) config.Config { return config.Config{} }, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
engine, err := buildProcessor(tt.mutate(complete), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildProcessor returned an error, want nil: %v", err)
|
||||||
|
}
|
||||||
|
if (engine == nil) != tt.wantNil {
|
||||||
|
t.Fatalf("engine == nil is %v, want %v", engine == nil, tt.wantNil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ adapter behind an interface (Clean Architecture ports & adapters).
|
|||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
subgraph tapir["Tapir (Go)"]
|
subgraph tapir["Tapir (Go)"]
|
||||||
http["HTTP server<br/>OAuth callbacks +<br/>user-facing API"]
|
http["tapir serve<br/>(HTMX+Templ web surface:<br/>read summaries, connect,<br/>account, summarize)"]
|
||||||
watcher["Watcher<br/>detects new videos<br/>(WebSub + poll)"]
|
watcher["Watcher<br/>detects new videos<br/>(WebSub + poll)"]
|
||||||
engine["Summarization engine<br/>(use-case core)"]
|
engine["Summarization engine<br/>(use-case core)"]
|
||||||
resolver["Transcript resolver<br/>(captions-first)"]
|
resolver["Transcript resolver<br/>(captions-first)"]
|
||||||
@@ -95,6 +95,66 @@ two codebases (ADR-003).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Web surface — `tapir serve` (Stage 1, ADR-011 → ADR-012)
|
||||||
|
|
||||||
|
A later transport added over the **unchanged** engine/ports/sinks core (ADR-003): `tapir serve`
|
||||||
|
is an HTMX+Templ reader/writer (`internal/web`) over the existing `store`. It added no business
|
||||||
|
logic to the engine — it reads the store and, for one action, kicks the existing engine. ADR-011
|
||||||
|
shipped it single-user; ADR-012 opened multi-user with DB-enforced (RLS) isolation.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
browser["Browser<br/>(Dex-authenticated user)"]
|
||||||
|
subgraph web["internal/web (tapir serve)"]
|
||||||
|
oidc["oidc<br/>Dex OIDC session<br/>(authenticate-only)"]
|
||||||
|
gate["registration gate<br/>new subject -> /register"]
|
||||||
|
pages["summary list + detail<br/>(read) + actions"]
|
||||||
|
connect["/oauth/youtube/callback<br/>per-user token connect"]
|
||||||
|
account["account<br/>(disconnect, delete)"]
|
||||||
|
summarize["Summarize button<br/>-> background goroutine"]
|
||||||
|
end
|
||||||
|
store[("store<br/>(Postgres, RLS per user)")]
|
||||||
|
engine["Summarization engine<br/>(unchanged core)"]
|
||||||
|
secrets["SecretStore<br/>(per-user token refs)"]
|
||||||
|
|
||||||
|
browser --> oidc
|
||||||
|
oidc --> gate
|
||||||
|
gate --> pages
|
||||||
|
pages --> store
|
||||||
|
connect --> secrets
|
||||||
|
connect --> store
|
||||||
|
account --> store
|
||||||
|
account --> secrets
|
||||||
|
summarize -->|background| engine
|
||||||
|
summarize -->|HTMX status poll| store
|
||||||
|
engine --> store
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Dex OIDC session layer** (`internal/web/oidc`) — **authenticate-only** (ADR-012). It proves
|
||||||
|
*who*; authorization/isolation is the DB's job (RLS), not the session's.
|
||||||
|
- **Registration gate** — a Dex subject with no `users` row is routed to `/register`, which
|
||||||
|
creates the `users` row + the `user_identities` mapping (migration 004). Returning subjects
|
||||||
|
pass straight through.
|
||||||
|
- **Web-initiated YouTube connect** — `/oauth/youtube/connect` → `/oauth/youtube/callback`
|
||||||
|
persists a **per-user** refresh-token ref (`youtube/<userID>/refresh_token`) via `SecretStore`
|
||||||
|
and a `video_connections` row (ADR-006, migration 005). Distinct from the CLI `tapir auth`.
|
||||||
|
- **Account management** — `/account` offers disconnect and **delete account**. Delete removes
|
||||||
|
only Tapir-side state (cascade across the user's tables + secret refs); the shared Dex identity
|
||||||
|
is left intact (ADR-013).
|
||||||
|
- **Immediate summarization** — the web "Summarize" button (`POST /v/{id}/summarize`) fires the
|
||||||
|
engine in a **background goroutine** inside `serve`; the page HTMX-polls `/v/{id}/status`,
|
||||||
|
showing a Charmbracelet spinner while in-flight (and an honest "queued/waiting" state under
|
||||||
|
rate-limiting — ADR-014).
|
||||||
|
- **Summarization mode** — `users.auto_summarize` (migration 006). Auto: every new video is
|
||||||
|
summarized. Manual (default): new videos appear unsummarized; the button sets
|
||||||
|
`videos.summarize_requested`, which the next `tapir run` processes and clears. Both the click
|
||||||
|
path and the batch `tapir run` drive the same unchanged engine.
|
||||||
|
|
||||||
|
The engine, ports, and sink adapters are **untouched** by all of the above — the web surface only
|
||||||
|
reads the store and triggers the existing engine. Adding it changed wiring, not the core (ADR-003).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Sequence — core use case: new video summarized
|
## Sequence — core use case: new video summarized
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -191,6 +251,8 @@ Gherkin features in `docs/use-cases/`).
|
|||||||
|
|
||||||
- Audio-download + speech-to-text resolver (ADR-007) — would be an additional `VideoSource`
|
- Audio-download + speech-to-text resolver (ADR-007) — would be an additional `VideoSource`
|
||||||
fallback path, drawn when built.
|
fallback path, drawn when built.
|
||||||
- Multi-tenant isolation primitives (per-tenant Postgres role, NetworkPolicy, tenant label)
|
- Per-user isolation is **live, not deferred**: Postgres RLS `FORCE`d on every user-owned table
|
||||||
— activate at Stage 1 (ADR-002); single-user Stage 0 doesn't exercise them.
|
(ADR-012, migration 003), realising ADR-002's per-tenant intent at the DB layer. The coarser
|
||||||
|
multi-tenant primitives (per-namespace NetworkPolicy, Kyverno, tenant label) remain a
|
||||||
|
Stage-2 hardening item, not exercised yet.
|
||||||
- Public SaaS surface (sign-up, billing) — Future C, not built (ADR-008).
|
- Public SaaS surface (sign-up, billing) — Future C, not built (ADR-008).
|
||||||
|
|||||||
+87
-23
@@ -23,11 +23,16 @@ only opaque references to them; the secret material lives in ESO/1Password (ADR-
|
|||||||
|
|
||||||
## Entities
|
## Entities
|
||||||
|
|
||||||
|
Solid entities below are **persisted today** (migrations 001–006). `AI_CREDENTIAL` and
|
||||||
|
`SUBSCRIPTION` are **planned, not yet a table** — kept in the model for intent; see the notes.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
erDiagram
|
erDiagram
|
||||||
|
USER ||--|| USER_IDENTITY : "logs in via (Dex subject)"
|
||||||
USER ||--o{ VIDEO_CONNECTION : has
|
USER ||--o{ VIDEO_CONNECTION : has
|
||||||
USER ||--o{ AI_CREDENTIAL : has
|
USER ||--o{ SUMMARY_ACTION : records
|
||||||
VIDEO_CONNECTION ||--o{ SUBSCRIPTION : exposes
|
USER ||--o{ AI_CREDENTIAL : "has (planned)"
|
||||||
|
VIDEO_CONNECTION ||--o{ SUBSCRIPTION : "exposes (planned)"
|
||||||
SUBSCRIPTION ||--o{ VIDEO : "produces (per user)"
|
SUBSCRIPTION ||--o{ VIDEO : "produces (per user)"
|
||||||
VIDEO ||--o| TRANSCRIPT : "has at most one"
|
VIDEO ||--o| TRANSCRIPT : "has at most one"
|
||||||
VIDEO ||--o| SUMMARY : "has at most one"
|
VIDEO ||--o| SUMMARY : "has at most one"
|
||||||
@@ -36,14 +41,20 @@ erDiagram
|
|||||||
USER {
|
USER {
|
||||||
uuid id PK
|
uuid id PK
|
||||||
text display_name
|
text display_name
|
||||||
|
bool auto_summarize "default false -> manual mode out of the box (migration 006)"
|
||||||
|
timestamptz created_at
|
||||||
|
}
|
||||||
|
USER_IDENTITY {
|
||||||
|
text dex_subject PK
|
||||||
|
uuid user_id FK "UNIQUE -> USER, ON DELETE CASCADE; NOT RLS-enabled"
|
||||||
timestamptz created_at
|
timestamptz created_at
|
||||||
}
|
}
|
||||||
VIDEO_CONNECTION {
|
VIDEO_CONNECTION {
|
||||||
uuid id PK
|
uuid id PK
|
||||||
uuid user_id FK
|
uuid user_id FK "-> USER, ON DELETE CASCADE"
|
||||||
text provider "youtube | vimeo"
|
text provider "youtube | vimeo"
|
||||||
text provider_account
|
text provider_account "nullable"
|
||||||
text token_secret_ref "-> SecretStore, never the token"
|
text token_ref "-> SecretStore, never the token"
|
||||||
text status "active | revoked | error"
|
text status "active | revoked | error"
|
||||||
timestamptz connected_at
|
timestamptz connected_at
|
||||||
}
|
}
|
||||||
@@ -66,14 +77,15 @@ erDiagram
|
|||||||
}
|
}
|
||||||
VIDEO {
|
VIDEO {
|
||||||
uuid id PK
|
uuid id PK
|
||||||
uuid user_id FK
|
uuid user_id FK "-> USER, ON DELETE CASCADE"
|
||||||
uuid subscription_id FK
|
uuid subscription_id "nullable; no FK at Stage 0"
|
||||||
text provider
|
text provider
|
||||||
text provider_video_id
|
text provider_video_id
|
||||||
text title
|
text title
|
||||||
int duration_s
|
int duration_s
|
||||||
timestamptz published_at
|
timestamptz published_at
|
||||||
text url
|
text url
|
||||||
|
bool summarize_requested "default false -> manual-mode queue flag (migration 006)"
|
||||||
timestamptz seen_at
|
timestamptz seen_at
|
||||||
}
|
}
|
||||||
TRANSCRIPT {
|
TRANSCRIPT {
|
||||||
@@ -87,7 +99,7 @@ erDiagram
|
|||||||
SUMMARY {
|
SUMMARY {
|
||||||
uuid id PK
|
uuid id PK
|
||||||
uuid user_id FK
|
uuid user_id FK
|
||||||
uuid video_id FK
|
uuid video_id "no FK to videos; (user_id, video_id) UNIQUE is the dedup key"
|
||||||
text summary
|
text summary
|
||||||
jsonb highlights
|
jsonb highlights
|
||||||
jsonb takeaways
|
jsonb takeaways
|
||||||
@@ -98,26 +110,53 @@ erDiagram
|
|||||||
}
|
}
|
||||||
SINK_DELIVERY {
|
SINK_DELIVERY {
|
||||||
uuid id PK
|
uuid id PK
|
||||||
uuid summary_id FK
|
uuid summary_id FK "-> SUMMARY, ON DELETE CASCADE; ownership derived via this FK"
|
||||||
text sink "store | brain"
|
text sink "store | brain"
|
||||||
text status "pending | delivered | error"
|
text status "pending | delivered | error"
|
||||||
text detail "nullable; error message etc"
|
text detail "nullable; error message etc"
|
||||||
timestamptz updated_at
|
timestamptz updated_at
|
||||||
}
|
}
|
||||||
|
SUMMARY_ACTION {
|
||||||
|
uuid id PK
|
||||||
|
uuid user_id FK "-> USER"
|
||||||
|
text video_id "TEXT, not FK (mirrors summaries' standalone key)"
|
||||||
|
text action "watched | skipped | saved"
|
||||||
|
timestamptz acted_at
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`SUMMARY_ACTION` has `UNIQUE (user_id, video_id, action)`; `VIDEO_CONNECTION` has
|
||||||
|
`UNIQUE (user_id, provider)` (one connection per provider — reconnect upserts in place).
|
||||||
|
RLS (`ENABLE` + `FORCE`) is on **every solid user-owned table above** — `users`, `videos`,
|
||||||
|
`transcripts`, `summaries`, `summary_actions`, `video_connections`. `sink_deliveries` is
|
||||||
|
RLS'd via an `EXISTS` on its parent summary; `user_identities` is intentionally **not** RLS'd
|
||||||
|
(auth plumbing). See the *Isolation invariant* section for the mechanism.
|
||||||
|
|
||||||
## Notes per entity
|
## Notes per entity
|
||||||
|
|
||||||
- **USER** — at Stage 0 there is exactly one row. At Stage 1, identity comes via Dex; this
|
- **USER** — one row per registered user (Stage 1, ADR-012; no longer single-row). The Tapir-side
|
||||||
table holds the Tapir-side profile keyed to the Dex subject.
|
profile; the Dex identity is held separately in `USER_IDENTITY`, not on this row. `auto_summarize`
|
||||||
- **VIDEO_CONNECTION** — a connected YouTube/Vimeo account. `token_secret_ref` resolves to
|
(migration 006) is the per-user mode flag: `FALSE` (default) = manual, `TRUE` = auto-summarize
|
||||||
the OAuth refresh token via `SecretStore`. Revocation flips `status`, doesn't delete history.
|
every new video.
|
||||||
- **AI_CREDENTIAL** — optional, per provider, per user (ADR-004's Fallback). Absent for users
|
- **USER_IDENTITY** (migration 004) — the `dex_subject → user_id` map. `dex_subject` is the PK,
|
||||||
who only use the local stack. One row per provider max.
|
`user_id` a `UNIQUE` FK to `users` with `ON DELETE CASCADE`. This is the bridge resolved at login
|
||||||
- **SUBSCRIPTION** — a watched channel. `websub_expires` tracks the YouTube push lease so the
|
*before* a `user_id` is known, so it is **deliberately not RLS-enabled** (it holds no user data;
|
||||||
watcher knows when to re-subscribe; null for poll-based (Vimeo).
|
RLS here would deadlock the lookup that yields the id used for scoping). Account deletion cascades
|
||||||
|
the mapping away (ADR-013).
|
||||||
|
- **VIDEO_CONNECTION** (migration 005) — a connected YouTube/Vimeo account. `token_ref` resolves to
|
||||||
|
the OAuth refresh token via `SecretStore` (per-user scheme `youtube/<userID>/refresh_token`).
|
||||||
|
`UNIQUE (user_id, provider)`: one connection per provider, reconnect upserts. Revocation/disconnect
|
||||||
|
flips `status`, doesn't delete history. FORCE RLS'd.
|
||||||
|
- **AI_CREDENTIAL** — *planned, no table yet.* Optional, per provider, per user (ADR-004's Fallback).
|
||||||
|
BYO keys are currently resolved via `SecretStore` refs without a dedicated table; this entity is
|
||||||
|
modelled for when per-credential metadata is needed.
|
||||||
|
- **SUBSCRIPTION** — *planned, no table yet.* A watched channel; `websub_expires` would track the
|
||||||
|
YouTube push lease. At Stage 0/1 `videos.subscription_id` is a nullable column with **no FK** (the
|
||||||
|
subscriptions table is not part of the shipped store-sink slice — migration 001).
|
||||||
- **VIDEO** — one row per (user, video) — note `user_id`, reflecting the per-user-isolation
|
- **VIDEO** — one row per (user, video) — note `user_id`, reflecting the per-user-isolation
|
||||||
decision. The same video seen by two users is two rows. `seen_at` is when Tapir detected it.
|
decision. The same video seen by two users is two rows. `seen_at` is when Tapir detected it.
|
||||||
|
`summarize_requested` (migration 006) is the manual-mode queue flag: the web "Summarize" button
|
||||||
|
sets it `TRUE`; the next `tapir run` picks it up, summarizes, and clears it back to `FALSE`.
|
||||||
- **TRANSCRIPT** — at most one per video. `source = none` records "checked, no usable
|
- **TRANSCRIPT** — at most one per video. `source = none` records "checked, no usable
|
||||||
transcript" so the watcher doesn't reprocess (ADR-007). `content` null in that case.
|
transcript" so the watcher doesn't reprocess (ADR-007). `content` null in that case.
|
||||||
- **SUMMARY** — at most one per video. `fallback_used` + `ai_provider`/`ai_model` make the
|
- **SUMMARY** — at most one per video. `fallback_used` + `ai_provider`/`ai_model` make the
|
||||||
@@ -125,14 +164,39 @@ erDiagram
|
|||||||
`takeaways` as jsonb to stay schema-flexible while the output format settles.
|
`takeaways` as jsonb to stay schema-flexible while the output format settles.
|
||||||
- **SINK_DELIVERY** — one row per (summary, sink) attempt. This is where "also sent to brain"
|
- **SINK_DELIVERY** — one row per (summary, sink) attempt. This is where "also sent to brain"
|
||||||
lives — no brain tables, just a delivery row with `sink = brain`. Sinks fail independently;
|
lives — no brain tables, just a delivery row with `sink = brain`. Sinks fail independently;
|
||||||
a failed brain delivery doesn't fail the store delivery.
|
a failed brain delivery doesn't fail the store delivery. No own `user_id`; RLS ownership is
|
||||||
|
derived from the parent summary via `EXISTS` (migration 003).
|
||||||
|
- **SUMMARY_ACTION** (migration 002) — records the maintainer's act on a summary (watch / skip /
|
||||||
|
save) — the column that makes the Stage-0 headline metric ("acts on ≥1 summary") queryable
|
||||||
|
(ui-spec.md §5, ADR-011). `video_id` is `TEXT` and **not** FK-constrained, mirroring summaries'
|
||||||
|
standalone `(user_id, video_id)` key. `UNIQUE (user_id, video_id, action)`. FORCE RLS'd.
|
||||||
|
|
||||||
## Isolation invariant (Stage 1+)
|
## Isolation invariant (Stage 1+) — LIVE
|
||||||
|
|
||||||
Every user-owned table carries `user_id`. At Stage 1, this is enforced at the DB layer via a
|
Every user-owned table carries `user_id`, and isolation is **enforced at the DB layer**, not
|
||||||
per-tenant Postgres role + row grants (architecture review SC7), not only in application code.
|
only in application code. ADR-011 shipped this surface single-user (one allowlisted subject,
|
||||||
At Stage 0 (single user) the column exists but the enforcement is dormant. The isolation test
|
enforcement dormant); **ADR-012 opened Stage 1 and turned enforcement on in the same slice.**
|
||||||
in VISION Stage 2 asserts user A cannot read user B's rows.
|
|
||||||
|
Enforcement is **Postgres Row-Level Security** (migration `003_rls.up.sql`):
|
||||||
|
|
||||||
|
- RLS is `ENABLE`d **and** `FORCE`d on every user-owned table — `users`, `videos`,
|
||||||
|
`transcripts`, `summaries`, `summary_actions`, `video_connections`. `FORCE` is load-bearing:
|
||||||
|
the app connects as the table **owner** (`tapir` role), and owners bypass RLS unless forced.
|
||||||
|
- Each policy keys off the per-request GUC `tapir.current_user_id`, set transaction-locally by
|
||||||
|
the store's `withUser` helper via `set_config('tapir.current_user_id', $1, true)` — it
|
||||||
|
auto-resets on commit/rollback, so it never leaks across a pooled connection.
|
||||||
|
- `current_setting('tapir.current_user_id', true)` uses `missing_ok = true`: an **unset** GUC
|
||||||
|
yields `NULL`, the predicate matches no rows, and access **denies by default**.
|
||||||
|
- `sink_deliveries` has no `user_id`; its policy derives ownership from the parent summary via
|
||||||
|
`EXISTS (SELECT 1 FROM summaries …)`.
|
||||||
|
- `user_identities` (the Dex-subject → user_id map) is **deliberately not RLS-enabled** — it is
|
||||||
|
auth plumbing read *before* a user_id is known; putting RLS there would deadlock. It holds no
|
||||||
|
user data.
|
||||||
|
|
||||||
|
The Stage-2 isolation bar is **pulled forward, not deferred**: `internal/adapters/store/rls_test.go`
|
||||||
|
runs two users against a non-superuser, non-`BYPASSRLS` role and asserts user A reads/writes zero
|
||||||
|
of user B's rows across every table. It ships green with the multi-user features (ADR-012); no
|
||||||
|
multi-user feature merges ahead of it passing.
|
||||||
|
|
||||||
## Job / processing state
|
## Job / processing state
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ This maps directly onto the copied `llm` package: `Client` is the OpenAI-compati
|
|||||||
`SecretStore` port (`youtube.New(cfg, secrets)`). Pinning the actual vault-item name only
|
`SecretStore` port (`youtube.New(cfg, secrets)`). Pinning the actual vault-item name only
|
||||||
changes wiring/config, not the adapter — so this `confirm` does not block the adapter. Decide
|
changes wiring/config, not the adapter — so this `confirm` does not block the adapter. Decide
|
||||||
the name when wiring the live connection and record it here.
|
the name when wiring the live connection and record it here.
|
||||||
|
- **Per-user token-ref scheme (Stage 1 web connect):** the web connect flow
|
||||||
|
(`/oauth/youtube/connect` → `/oauth/youtube/callback`) persists each user's refresh token
|
||||||
|
under a **per-user ref `youtube/<userID>/refresh_token`** (`web.YouTubeTokenRef`), not the
|
||||||
|
Stage-0 single `youtube/refresh_token`. This is what keeps tokens isolated across tenants
|
||||||
|
behind the `SecretStore` port; the `video_connections` row stores only this opaque
|
||||||
|
`token_ref`, never the token. The connect callback URL is
|
||||||
|
`TAPIR_YT_CONNECT_REDIRECT_URL` (default `https://tapir.d-ma.be/oauth/youtube/callback`) and
|
||||||
|
must be in the Google OAuth client's authorized redirects for live connect.
|
||||||
|
|
||||||
## Hosts (for reference)
|
## Hosts (for reference)
|
||||||
|
|
||||||
@@ -154,3 +162,32 @@ allow per-provider when a user connects one.
|
|||||||
|
|
||||||
_Snapshot date 2026-06-02. Items marked **confirm** were not verified to a pinned source at
|
_Snapshot date 2026-06-02. Items marked **confirm** were not verified to a pinned source at
|
||||||
snapshot time — check brain or the live cluster before depending on them._
|
snapshot time — check brain or the live cluster before depending on them._
|
||||||
|
|
||||||
|
## Stage 1 — multi-user facts (verified 2026-06-03)
|
||||||
|
|
||||||
|
### Postgres RLS (ADR-012)
|
||||||
|
- **The deployed DSN MUST connect as a non-superuser, non-BYPASSRLS role.** The
|
||||||
|
app uses the `tapir` role (table owner, non-superuser). `FORCE ROW LEVEL
|
||||||
|
SECURITY` is applied on all user-owned tables; a superuser DSN silently bypasses
|
||||||
|
FORCE and isolation is dead in prod. Verify: `SELECT rolsuper FROM pg_roles
|
||||||
|
WHERE rolname = 'tapir'` must return `f`.
|
||||||
|
- Scoping is via `set_config('tapir.current_user_id', $userID, true)` (transaction-
|
||||||
|
local, auto-resets on commit — never leaks across a pooled connection).
|
||||||
|
|
||||||
|
### Per-user YouTube token persistence
|
||||||
|
- Stage-1 uses the **file-backed SecretStore** at `TAPIR_SECRETS_FILE=/data/secrets.json`
|
||||||
|
mounted from a **PVC** (`tapir-secrets`, 64Mi, RWO). Tokens survive pod restarts.
|
||||||
|
Upgrading to an ESO-backed per-user SecretStore is backlog (infra#86).
|
||||||
|
- Per-user token ref scheme: `youtube/<userID>/refresh_token` (Worker C, ADR-006).
|
||||||
|
The Stage-0 single ref `youtube/refresh_token` is no longer used by `serve`; it
|
||||||
|
remains valid for the CLI `tapir run` (single-user, host-side).
|
||||||
|
|
||||||
|
### Web YouTube connect
|
||||||
|
- Redirect URI (registered in Google OAuth client, type Web): `https://tapir.d-ma.be/oauth/youtube/callback`.
|
||||||
|
- Config env: `TAPIR_YT_CONNECT_REDIRECT_URL=https://tapir.d-ma.be/oauth/youtube/callback`.
|
||||||
|
`TAPIR_YT_CLIENT_ID` / `TAPIR_YT_CLIENT_SECRET` from the Web client (not the Desktop client used for the CLI).
|
||||||
|
|
||||||
|
### Identity resolution
|
||||||
|
- `user_identities(dex_subject → user_id)` table is **intentionally NOT RLS-enabled**
|
||||||
|
(it's auth plumbing, holds no user data; data isolation is on the user-owned tables).
|
||||||
|
All data access after subject resolution goes through `withUser`.
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Spec — Landing page + documentation reconciliation
|
||||||
|
|
||||||
|
**Date:** 2026-06-03
|
||||||
|
**Status:** Ready to build
|
||||||
|
**Scope:** Two parallel workstreams — (A) a public landing page; (B) reconciling the
|
||||||
|
requirements / use-case / architecture / data-model docs against the deployed reality
|
||||||
|
(v0.4.0). These are separate concerns; do not let one worker do both, or the audit gets
|
||||||
|
done cursorily.
|
||||||
|
|
||||||
|
All work: read `CLAUDE.md` + `DECISIONS.md` first. TBD — commit directly to `main`, one
|
||||||
|
logical change per commit, conventional commits, `task check` green before every commit.
|
||||||
|
After editing any `.templ`, run `templ generate` (the repo commits both `views.templ` and the
|
||||||
|
generated `views_templ.go`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream A — Public landing page
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
A public landing page at `/welcome`, in the established bubbletea aesthetic, that lets a
|
||||||
|
visitor sign in (one Dex flow) and, if already logged in, jump to their Tapir page or log out.
|
||||||
|
New public transport surface only — no engine/core change (ADR-003).
|
||||||
|
|
||||||
|
### Verified facts (read from `internal/web/oidc/oidc.go` @ main — do not re-guess)
|
||||||
|
- Auth endpoints are exactly `/auth/login`, `/auth/callback`, `/auth/logout`.
|
||||||
|
- `isPublicPath(p)` = `p == "/healthz" || strings.HasPrefix(p, "/auth/")` — the single
|
||||||
|
public-route chokepoint inside `DexAuth.Middleware`.
|
||||||
|
- `DexAuth.CurrentUser(r) (web.User, bool)` reads the session cookie and does NOT redirect —
|
||||||
|
this is the "peek" the landing page uses to branch logged-in vs logged-out.
|
||||||
|
- `handleCallback` redirects to `/` on success (correct — leave as-is).
|
||||||
|
- `handleLogout` currently redirects to `loginPath` (`/auth/login`) — this is wrong for this
|
||||||
|
feature (see A3).
|
||||||
|
- There is NO separate "sign up" against Dex/OIDC: one authorization flow. Registration is
|
||||||
|
Tapir's own `/register` step (ADR-012), reached after first login for an unknown subject.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
**A1 — make `/welcome` public.** In `oidc.go`, extend `isPublicPath`:
|
||||||
|
```go
|
||||||
|
func isPublicPath(p string) bool {
|
||||||
|
return p == "/healthz" || p == "/welcome" || strings.HasPrefix(p, "/auth/")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**A2 — unauthenticated bare-`/` → `/welcome`; deep links unchanged.** In `DexAuth.Middleware`,
|
||||||
|
the unauthenticated branch currently always calls `redirectToLogin`. Change it so that when
|
||||||
|
`r.URL.Path == "/"` an unauthenticated visitor is redirected to `/welcome`; for any other
|
||||||
|
guarded path keep `redirectToLogin` (so a shared `/v/{id}` deep link still bounces through Dex
|
||||||
|
and returns to the destination). Keep the `isPublicPath` check first (redirect-loop guard).
|
||||||
|
|
||||||
|
**A3 — logout lands on `/welcome`, not login.** In `handleLogout`, change the final redirect
|
||||||
|
from `loginPath` to `/welcome`. As written it sends the user to `/auth/login`, which
|
||||||
|
immediately starts a fresh Dex login — visibly failing to log out. This intentionally breaks
|
||||||
|
the existing logout test (oidc_test.go) which asserts redirect to `/auth/login`; update that
|
||||||
|
test to expect `/welcome`. That break is expected, not a regression.
|
||||||
|
|
||||||
|
**A4 — mount the landing handler** in `internal/web/handlers.go` `Router()`, on `root`,
|
||||||
|
OUTSIDE `Auth.Middleware`, alongside `/healthz`:
|
||||||
|
```go
|
||||||
|
root.HandleFunc("GET /welcome", a.handleWelcome)
|
||||||
|
```
|
||||||
|
`handleWelcome` peeks `a.Auth.CurrentUser(r)` and renders `WelcomePage(user, ok)`. Not behind
|
||||||
|
`Auth.Middleware` or `registrationGate`.
|
||||||
|
|
||||||
|
**A5 — `WelcomePage` templ component** in `views.templ`. Reuse the existing shared
|
||||||
|
layout/header partial and the established aesthetic (#7653FC purple rounded ╭─╮╰─╯ box, pink
|
||||||
|
tapir mascot, #0EF9B6 mint accents) — match the existing pages, do not reinvent styling.
|
||||||
|
- Logged out (`ok == false`): tapir mascot + tagline; one primary CTA **"Get Started"** →
|
||||||
|
`/auth/login`; honest sub-text: "New here? You'll set up your account right after signing in
|
||||||
|
— returning users go straight through." One button only (see verified facts: no separate
|
||||||
|
Dex sign-up; two buttons to the same URL would mislead).
|
||||||
|
- Logged in (`ok == true`): "Go to my Tapir" → `/`; "Log Out" → `/auth/logout`. May greet via
|
||||||
|
`user.Email`.
|
||||||
|
|
||||||
|
**A6 — tests** (extend `handlers_test.go` patterns). Note `StubAuth.CurrentUser` always returns
|
||||||
|
true; for the logged-out case use a fake Auth returning `(web.User{}, false)`.
|
||||||
|
- `GET /welcome`, no session → "Get Started" → `/auth/login`.
|
||||||
|
- `GET /welcome`, with session → "Go to my Tapir" + "Log Out".
|
||||||
|
- Unauthenticated `GET /` → 302 `/welcome`.
|
||||||
|
- Unauthenticated `GET /v/{id}` → still 302 `/auth/login` (deep link preserved).
|
||||||
|
- Authenticated `GET /` → still serves the list, unchanged.
|
||||||
|
- oidc: `handleLogout` → 302 `/welcome` (update the existing test).
|
||||||
|
|
||||||
|
**A out of scope:** no Dex config change, no new auth/session logic, no sign-up backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream B — Documentation reconciliation
|
||||||
|
|
||||||
|
### Why
|
||||||
|
The guardrail docs were written before Stage 1 and the web surface. Several now describe the
|
||||||
|
opposite of the deployed reality (v0.4.0). Stale guardrail docs are worse than none — a future
|
||||||
|
cold session (human or agent) trusts them. This workstream brings requirements, use cases,
|
||||||
|
architecture, and data-model back in sync with `main`. Each fix is one commit; cite the ADR or
|
||||||
|
migration that is the source of truth.
|
||||||
|
|
||||||
|
### Known drift to fix (verified this session — not exhaustive; the worker confirms against code)
|
||||||
|
**B1 — `internal/web/auth.go` comments.** The `User.Subject` doc and package doc still say
|
||||||
|
"single-user allowlist (ADR-011)" / "Stage-0". Code is multi-user (ADR-012). Update the
|
||||||
|
comments to describe the current multi-user reality; reference ADR-012.
|
||||||
|
|
||||||
|
**B2 — `docs/data-model.md` isolation status.** It says isolation enforcement is "dormant at
|
||||||
|
Stage 0". It is now LIVE: Postgres RLS, `FORCE`d on all user-owned tables, with a passing
|
||||||
|
two-user isolation test (ADR-012, migration 003). Rewrite that section to describe enforced
|
||||||
|
RLS as the current state; keep the history honest (was dormant at Stage 0, enforced from
|
||||||
|
Stage 1).
|
||||||
|
|
||||||
|
**B3 — `docs/data-model.md` schema completeness.** The doc predates migrations 002–006. Add
|
||||||
|
the entities/columns that now exist: `summary_actions` (002), RLS (003), `user_identities`
|
||||||
|
(004, dex_subject→user_id), `video_connections` (005), `users.auto_summarize` +
|
||||||
|
`videos.summarize_requested` (006). The ER section should match the live schema. Cross-check
|
||||||
|
against `internal/adapters/store/migrations/*.up.sql` — those are ground truth.
|
||||||
|
|
||||||
|
**B4 — `docs/architecture/architecture.md`.** Predates the entire web surface. Update the C4
|
||||||
|
container diagram and text to include: `tapir serve` (HTMX+Templ web reader/writer), the Dex
|
||||||
|
OIDC session layer (`internal/web/oidc`), registration gate, web-initiated YouTube connect,
|
||||||
|
account management, and the immediate-processing path (web "Summarize" button → background
|
||||||
|
goroutine → status poll). The engine/ports/sinks core is unchanged (ADR-003) — show the web
|
||||||
|
surface as a new transport over the same core, not a core change.
|
||||||
|
|
||||||
|
**B5 — `docs/use-cases/*.feature`.** Add scenarios for the behaviours now live and unspecced:
|
||||||
|
register (new subject → registration → user row; returning user straight through), connect
|
||||||
|
YouTube (web OAuth), disconnect, delete-account (cascade + secret purge, Dex untouched —
|
||||||
|
ADR-013), manual-vs-auto summarize mode + the Summarize button, and the landing page
|
||||||
|
(logged-out CTA; logged-in shortcuts). Keep them as executable-style Gherkin consistent with
|
||||||
|
the existing files.
|
||||||
|
|
||||||
|
**B6 — `DECISIONS.md` ADR ordering (cosmetic).** ADR-010 sits before ADR-009/011 (append
|
||||||
|
order). Reorder to numeric while you're in the file. Pure tidy, no content change.
|
||||||
|
|
||||||
|
**B7 — requirements check.** If a requirements doc exists (e.g. `docs/ui-spec.md`, referenced
|
||||||
|
by ADR-011), reconcile it with what shipped: note where the build deviated (e.g. the spinner /
|
||||||
|
immediate processing / summarize mode were beyond the original spec) so the spec reflects
|
||||||
|
reality or explicitly records the deviation. Do not silently rewrite history — record
|
||||||
|
deviations as deviations.
|
||||||
|
|
||||||
|
### B working method
|
||||||
|
- Source of truth order: migrations + code > ADRs > prose docs. When a prose doc disagrees
|
||||||
|
with code, the code wins and the doc is corrected (unless the code is the bug — then flag it,
|
||||||
|
don't quietly doc around it).
|
||||||
|
- One logical doc per commit. Cite the ADR/migration that justifies each change in the commit
|
||||||
|
body.
|
||||||
|
- This is an audit, not a rewrite: preserve the docs' structure and the "rejected alternatives
|
||||||
|
/ history" honesty. The goal is *current and trustworthy*, not *pretty*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coordination
|
||||||
|
A and B touch mostly different files (A: oidc.go, handlers.go, views.templ, tests; B: docs/* +
|
||||||
|
auth.go comments). The one overlap is `auth.go` (B1 edits comments) vs A (reads it) — no
|
||||||
|
conflict. Run A and B in parallel; commit independently to `main`.
|
||||||
|
|
||||||
|
If anything in B reveals that code, not docs, is wrong (e.g. an isolation gap, a migration that
|
||||||
|
doesn't match the data-model intent), STOP and surface it — that's a finding, not a doc edit.
|
||||||
+32
-5
@@ -77,8 +77,9 @@ summary_actions
|
|||||||
- **Flow:** standard Authorization Code. Use `coreos/go-oidc` + `golang.org/x/oauth2`
|
- **Flow:** standard Authorization Code. Use `coreos/go-oidc` + `golang.org/x/oauth2`
|
||||||
(justify the deps in the commit; both are the homelab-standard OIDC libs and small).
|
(justify the deps in the commit; both are the homelab-standard OIDC libs and small).
|
||||||
- Discover issuer `https://auth.d-ma.be` (`TAPIR_OIDC_ISSUER`); scopes `openid profile email`.
|
- Discover issuer `https://auth.d-ma.be` (`TAPIR_OIDC_ISSUER`); scopes `openid profile email`.
|
||||||
- On callback: verify ID token, extract `sub` (and email); **allowlist check** against
|
- On callback: verify ID token, extract `sub` (and email). **ADR-012 superseded the
|
||||||
`TAPIR_ALLOWED_SUBJECT` (the maintainer's Dex subject) — reject everyone else with 403.
|
ADR-011 single-subject allowlist:** any Dex-authenticated subject may sign in; a subject
|
||||||
|
with no tapir user is routed to explicit registration (see `internal/web` registration gate).
|
||||||
- **Session:** signed, httpOnly, Secure cookie (HS256 with `TAPIR_SESSION_SECRET`); short TTL
|
- **Session:** signed, httpOnly, Secure cookie (HS256 with `TAPIR_SESSION_SECRET`); short TTL
|
||||||
+ sliding refresh. Server-side session store can be in-memory at Stage 0 (single replica).
|
+ sliding refresh. Server-side session store can be in-memory at Stage 0 (single replica).
|
||||||
- **Middleware** guards every route except `/healthz` and `/auth/*`.
|
- **Middleware** guards every route except `/healthz` and `/auth/*`.
|
||||||
@@ -89,8 +90,9 @@ summary_actions
|
|||||||
|
|
||||||
`TAPIR_HTTP_ADDR` (`:8080`), `TAPIR_PUBLIC_URL` (`https://tapir.d-ma.be`),
|
`TAPIR_HTTP_ADDR` (`:8080`), `TAPIR_PUBLIC_URL` (`https://tapir.d-ma.be`),
|
||||||
`TAPIR_OIDC_ISSUER` (`https://auth.d-ma.be`), `TAPIR_DEX_CLIENT_ID`, `TAPIR_DEX_CLIENT_SECRET`,
|
`TAPIR_OIDC_ISSUER` (`https://auth.d-ma.be`), `TAPIR_DEX_CLIENT_ID`, `TAPIR_DEX_CLIENT_SECRET`,
|
||||||
`TAPIR_OIDC_REDIRECT_URL` (`https://tapir.d-ma.be/auth/callback`), `TAPIR_SESSION_SECRET`,
|
`TAPIR_OIDC_REDIRECT_URL` (`https://tapir.d-ma.be/auth/callback`), `TAPIR_SESSION_SECRET`.
|
||||||
`TAPIR_ALLOWED_SUBJECT`. Reuses existing `TAPIR_DB_DSN`, `TAPIR_USER_ID`. No secrets committed.
|
Reuses existing `TAPIR_DB_DSN`, `TAPIR_USER_ID` (the StubAuth dev subject only). No secrets
|
||||||
|
committed. (`TAPIR_ALLOWED_SUBJECT` was removed by ADR-012.)
|
||||||
|
|
||||||
## 8. Deployment — k3s + Flux GitOps
|
## 8. Deployment — k3s + Flux GitOps
|
||||||
|
|
||||||
@@ -117,7 +119,7 @@ summary_actions
|
|||||||
|
|
||||||
1. **Register a Dex static client** `tapir-web` in the Dex config (in `infra`) with redirect
|
1. **Register a Dex static client** `tapir-web` in the Dex config (in `infra`) with redirect
|
||||||
`https://tapir.d-ma.be/auth/callback`; client id/secret → 1P `TAPIR_DEX_CLIENT_ID` /
|
`https://tapir.d-ma.be/auth/callback`; client id/secret → 1P `TAPIR_DEX_CLIENT_ID` /
|
||||||
`TAPIR_DEX_CLIENT_SECRET`. Capture your Dex `sub` for `TAPIR_ALLOWED_SUBJECT`.
|
`TAPIR_DEX_CLIENT_SECRET`. (No allowlist subject to capture — ADR-012 dropped it.)
|
||||||
2. **DNS/edge** for `tapir.d-ma.be` → the k3s ingress (piguard NPM perimeter / existing
|
2. **DNS/edge** for `tapir.d-ma.be` → the k3s ingress (piguard NPM perimeter / existing
|
||||||
`*.d-ma.be` pattern) + TLS cert.
|
`*.d-ma.be` pattern) + TLS cert.
|
||||||
3. Confirm the **registry** host/path the gitea CI pushes to and the Flux path
|
3. Confirm the **registry** host/path the gitea CI pushes to and the Flux path
|
||||||
@@ -145,3 +147,28 @@ Gate (lane A) commits first; B/C/D follow.
|
|||||||
|
|
||||||
`task check` green per lane; B/C/D rebase on A. Deploy (D) lands last, after the binary serves
|
`task check` green per lane; B/C/D rebase on A. Deploy (D) lands last, after the binary serves
|
||||||
locally.
|
locally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deviations and additions (as-built)
|
||||||
|
|
||||||
|
This spec describes the **Stage-0 single-user reader** (ADR-011). What actually shipped through
|
||||||
|
v0.4.0 went further — Stage 1 (ADR-012) opened multi-user, and several UX features were added on
|
||||||
|
top. Recorded here (append-only; the spec above is left intact) so intent and reality stay
|
||||||
|
distinguishable.
|
||||||
|
|
||||||
|
| As-built feature | What it is | Why | Covered by |
|
||||||
|
|------------------|-----------|-----|------------|
|
||||||
|
| **Multi-user + RLS isolation** | Several Dex users per deployment; isolation enforced by Postgres RLS, not the single-subject allowlist of §6. | Maintainer opened Stage 1 ahead of the formal Stage-0 gate, with DB-enforced isolation as the guardrail that keeps it safe. | ADR-012; migration 003 (`6775e5f`, `f28fdc0`, `2ae66da`) |
|
||||||
|
| **Registration gate** | A Dex subject with no `users` row is routed to `/register`, which creates the `users` row + a `user_identities` mapping. (§2 listed "sign-up / user CRUD" as a non-goal.) | Explicit registration is how a multi-user surface stays honest — no just-in-time row creation. | ADR-012; `f396e01` |
|
||||||
|
| **Per-user YouTube web connect** | `/oauth/youtube/connect` → `/oauth/youtube/callback` stores a per-user refresh-token ref + a `video_connections` row. (The spec assumed a host-side `tapir auth` only.) | Multi-user means each user connects their own account from the browser. | ADR-006, ADR-012; migration 005 (`0c9531a`, `2aad79b`) |
|
||||||
|
| **Account management** | `/account` page with **disconnect** and **delete account**; delete removes only Tapir-side state and leaves the Dex identity intact. (§2 listed isolation/CRUD as non-goals.) | A real account needs a way out; deletion semantics are deliberately Tapir-side only. | ADR-013; `22eafcf`, `c7624d9`, `17d5e8c` |
|
||||||
|
| **Immediate web summarization** | A "Summarize" button (`POST /v/{id}/summarize`) runs the engine in a background goroutine inside `serve`; the page HTMX-polls `GET /v/{id}/status`. (§2 said "triggering runs from the browser … do NOT build".) | Reading a list you can't act on is half a product; on-demand summarize closes the loop without waiting for a batch `tapir run`. | ADR-012, ADR-014; `25215cb`, `8c6c7ca` |
|
||||||
|
| **Charmbracelet tapir spinner** | An animated in-flight indicator (charm palette) shown while a summarize is processing; an honest "queued/waiting" state under rate-limiting rather than a stuck spinner. | The spinner must tell the truth when the timedtext endpoint rate-limits (429), not imply imminence. | ADR-014; `25215cb`, `a4aeb5e` |
|
||||||
|
| **Auto/manual summarization mode** | Per-user `auto_summarize`; manual (default) lists new videos unsummarized and queues via `summarize_requested`; a mode toggle at `/account/summarize-mode`. | Control over compute/noise — only summarize what the user cares about. | migration 006 (`748d5eb`, `bdbdce7`, `3014ee0`, `a269d4a`) |
|
||||||
|
| **Public landing page** | `/welcome` mounted **outside** the auth guard; unauthenticated `/` redirects there; logout returns there (not `/auth/login`). (The spec guarded everything except `/healthz` and `/auth/*`.) | A first-time visitor needs a public "what is this / get started" page before the login wall. | `d83943c`, `0fdf2f7`, `3a27bf1`, `d208110`, `8ca374e`, `f15f57f` |
|
||||||
|
|
||||||
|
The original Stage-0 goals (read summaries, record watch/skip/save actions, Dex login, GitOps
|
||||||
|
deploy) still hold — these are additions over that base, not replacements. The architecture
|
||||||
|
stance is unchanged: every item above is web-surface or store work; the engine/ports/sinks core
|
||||||
|
was not modified (ADR-003).
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
Feature: Public landing page
|
||||||
|
As a first-time visitor
|
||||||
|
I want a public welcome page before I log in
|
||||||
|
So that I understand what Tapir is and how to get started without hitting a login wall
|
||||||
|
|
||||||
|
Scenario: An unauthenticated visit to the root is sent to the welcome page
|
||||||
|
Given I am not logged in
|
||||||
|
When I open the root path "/"
|
||||||
|
Then I am redirected to "/welcome"
|
||||||
|
|
||||||
|
Scenario: The welcome page invites an unauthenticated visitor to start
|
||||||
|
Given I am not logged in
|
||||||
|
When I open "/welcome"
|
||||||
|
Then I see a "Get Started" call to action
|
||||||
|
|
||||||
|
Scenario: An authenticated user on the welcome page sees their way in and out
|
||||||
|
Given I am logged in
|
||||||
|
When I open "/welcome"
|
||||||
|
Then I see a link to my summaries
|
||||||
|
And I see a way to log out
|
||||||
|
|
||||||
|
Scenario: Logging out returns to the welcome page
|
||||||
|
Given I am logged in
|
||||||
|
When I log out
|
||||||
|
Then I am returned to "/welcome"
|
||||||
|
|
||||||
|
# /welcome is mounted outside the auth guard so it is reachable without a session;
|
||||||
|
# the root and all data routes stay behind it (commits around the WelcomePage work).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
Feature: Register and manage a multi-user account
|
||||||
|
As one of a handful of trusted users
|
||||||
|
I want my own account, isolated from everyone else's
|
||||||
|
So that Tapir can serve several people from one deployment without leaking data
|
||||||
|
|
||||||
|
# Stage 1 (ADR-012): Dex authenticates, Tapir authorizes per user. A Dex subject
|
||||||
|
# with no users row is a new user and must register before reaching any data.
|
||||||
|
|
||||||
|
Scenario: A new Dex subject is routed to registration
|
||||||
|
Given I am authenticated by Dex with a subject that has no Tapir account
|
||||||
|
When I open any page that requires an account
|
||||||
|
Then I am routed to the registration page
|
||||||
|
And no summaries are shown until I register
|
||||||
|
|
||||||
|
Scenario: Registering creates the account and its identity mapping
|
||||||
|
Given I am authenticated by Dex with a subject that has no Tapir account
|
||||||
|
When I complete registration
|
||||||
|
Then a user row is created for me
|
||||||
|
And a user_identities row maps my Dex subject to that user
|
||||||
|
And I am taken into the app as a registered user
|
||||||
|
|
||||||
|
Scenario: A returning subject passes straight through
|
||||||
|
Given I am authenticated by Dex with a subject that already has a Tapir account
|
||||||
|
When I open the app
|
||||||
|
Then I am not asked to register again
|
||||||
|
And I see my own summaries
|
||||||
|
|
||||||
|
Scenario: Deleting an account removes only my data and leaves other users untouched
|
||||||
|
Given I am a registered user with summaries, a connected account, and recorded actions
|
||||||
|
And another user exists with their own summaries
|
||||||
|
When I delete my account
|
||||||
|
Then all of my rows are removed across every user-owned table
|
||||||
|
And my stored secret references are removed
|
||||||
|
And the other user's data remains intact
|
||||||
|
And my Dex identity is left intact
|
||||||
|
|
||||||
|
Scenario: A deleted user can register again as a fresh account
|
||||||
|
Given I deleted my Tapir account but my Dex identity still exists
|
||||||
|
When I sign in again
|
||||||
|
Then I am routed to the registration page as a new user
|
||||||
|
And registering creates a fresh user row with none of my old data
|
||||||
|
|
||||||
|
# Isolation is DB-enforced (Postgres RLS, ADR-012, migration 003): a user can never
|
||||||
|
# read or write another user's rows even if an application WHERE clause is wrong.
|
||||||
|
# Deletion is Tapir-side only — the shared Dex directory is never modified (ADR-013).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
Feature: Choose how new videos get summarized
|
||||||
|
As a user who wants control over compute and noise
|
||||||
|
I want to pick whether new videos are summarized automatically or on demand
|
||||||
|
So that I only spend summarization on the videos I actually care about
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am a registered user with a connected video account
|
||||||
|
|
||||||
|
Scenario: Auto mode summarizes every new video
|
||||||
|
Given my summarization mode is "auto"
|
||||||
|
When a subscribed channel posts a new video with captions
|
||||||
|
Then Tapir summarizes it without my asking
|
||||||
|
And the summary appears in my list
|
||||||
|
|
||||||
|
Scenario: Manual mode is the default and leaves new videos unsummarized
|
||||||
|
Given I have not changed my summarization mode
|
||||||
|
Then my mode is "manual"
|
||||||
|
When a subscribed channel posts a new video with captions
|
||||||
|
Then the video appears in my list with no summary
|
||||||
|
And nothing is summarized until I request it
|
||||||
|
|
||||||
|
Scenario: Requesting a summary in manual mode queues it for the next run
|
||||||
|
Given my summarization mode is "manual"
|
||||||
|
And a new video is in my list with no summary
|
||||||
|
When I click "Summarize" on that video
|
||||||
|
Then the video is marked as requested
|
||||||
|
And the next run summarizes it
|
||||||
|
And the request flag is cleared after it is processed
|
||||||
|
|
||||||
|
# auto_summarize is a per-user setting and summarize_requested is a per-video queue
|
||||||
|
# flag (migration 006). The web button sets the flag; `tapir run` processes both the
|
||||||
|
# auto videos and the manually queued ones, then clears the flag.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -89,6 +89,43 @@ func (s *FileStore) Put(ref, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes the secret stored under ref, persisting the file atomically
|
||||||
|
// (temp file + rename) with 0600 permissions. Deleting an absent ref — or one in
|
||||||
|
// a file that does not exist yet — is a no-op, not an error. Used by account
|
||||||
|
// management (disconnect / delete-account) to purge a user's OAuth tokens.
|
||||||
|
func (s *FileStore) Delete(ref string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
m, err := s.load()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil // nothing to delete
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, ok := m[ref]; !ok {
|
||||||
|
return nil // already absent
|
||||||
|
}
|
||||||
|
delete(m, ref)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||||
|
return fmt.Errorf("secrets: create dir: %w", err)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("secrets: marshal: %w", err)
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("secrets: write temp: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, s.path); err != nil {
|
||||||
|
return fmt.Errorf("secrets: rename: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// load reads the backing file. A missing file yields an empty map (not an
|
// load reads the backing file. A missing file yields an empty map (not an
|
||||||
// error) for Get's caller, except Put distinguishes os.ErrNotExist.
|
// error) for Get's caller, except Put distinguishes os.ErrNotExist.
|
||||||
func (s *FileStore) load() (map[string]string, error) {
|
func (s *FileStore) load() (map[string]string, error) {
|
||||||
|
|||||||
@@ -49,6 +49,40 @@ func TestPutIsOwnerOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteRemovesRefAndLeavesOthers(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "secrets.json")
|
||||||
|
s := secrets.NewFileStore(path)
|
||||||
|
if err := s.Put("youtube/u1/refresh_token", "rt-1"); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Put("youtube/u2/refresh_token", "rt-2"); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Delete("youtube/u1/refresh_token"); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deleted ref is gone (persisted: re-open from disk)...
|
||||||
|
s2 := secrets.NewFileStore(path)
|
||||||
|
if _, err := s2.Get(context.Background(), "youtube/u1/refresh_token"); !errors.Is(err, secrets.ErrNotFound) {
|
||||||
|
t.Errorf("Get deleted ref: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
// ...and the other user's secret survives.
|
||||||
|
if got, err := s2.Get(context.Background(), "youtube/u2/refresh_token"); err != nil || got != "rt-2" {
|
||||||
|
t.Errorf("Get surviving ref = (%q, %v), want (%q, nil)", got, err, "rt-2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAbsentRefIsNoop(t *testing.T) {
|
||||||
|
// Deleting an unknown ref — or from a file that does not exist yet — is a
|
||||||
|
// no-op, not an error (mirrors store.DeleteConnection semantics).
|
||||||
|
s := secrets.NewFileStore(filepath.Join(t.TempDir(), "secrets.json"))
|
||||||
|
if err := s.Delete("missing"); err != nil {
|
||||||
|
t.Errorf("Delete absent ref: %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPutMergesEntries(t *testing.T) {
|
func TestPutMergesEntries(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "secrets.json")
|
path := filepath.Join(t.TempDir(), "secrets.json")
|
||||||
s := secrets.NewFileStore(path)
|
s := secrets.NewFileStore(path)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeleteUser permanently removes a user and all of their data. It runs through
|
||||||
|
// withUser so RLS confines every statement to the calling user's own rows.
|
||||||
|
//
|
||||||
|
// Deleting the users row cascades (ON DELETE CASCADE) to videos, transcripts,
|
||||||
|
// summaries (→ sink_deliveries), video_connections, and the user_identities map
|
||||||
|
// — referential-integrity cascades bypass RLS, so a user's child rows are removed
|
||||||
|
// even though the deleting connection is scoped. summary_actions is the exception:
|
||||||
|
// it carries a user_id but has NO foreign key to users (migration 002), so the
|
||||||
|
// cascade does not reach it; it is deleted explicitly in the same scoped
|
||||||
|
// transaction. Deleting an absent user is a no-op (idempotent).
|
||||||
|
//
|
||||||
|
// This is tapir-side only (decision 2026-06-03): it removes all tapir data; the
|
||||||
|
// Dex login identity is left untouched — a later login simply re-enters
|
||||||
|
// registration. The user's secrets (OAuth tokens) live in the SecretStore, not
|
||||||
|
// the DB, and are removed by the caller (the account handler).
|
||||||
|
func (s *Store) DeleteUser(ctx context.Context, userID string) error {
|
||||||
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`DELETE FROM summary_actions WHERE user_id = $1`, userID); err != nil {
|
||||||
|
return fmt.Errorf("store: delete summary_actions: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`DELETE FROM users WHERE id = $1`, userID); err != nil {
|
||||||
|
return fmt.Errorf("store: delete user: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayName returns the user's registered display name (empty if unset). Scoped
|
||||||
|
// by user_id via withUser, like every read in this package.
|
||||||
|
func (s *Store) DisplayName(ctx context.Context, userID string) (string, error) {
|
||||||
|
var name string
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
return tx.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(display_name, '') FROM users WHERE id = $1`, userID).Scan(&name)
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Errorf("store: display name: %w", err)
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedIdentity inserts the un-RLS'd dex_subject → user_id mapping for a user, so
|
||||||
|
// the cascade-on-delete to user_identities can be asserted.
|
||||||
|
func seedIdentity(t *testing.T, p *pgxpool.Pool, subject, userID string) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := p.Exec(context.Background(),
|
||||||
|
`INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// countFor counts rows owned by userID in table. The users table is keyed on its
|
||||||
|
// own id; every other isolated table on user_id.
|
||||||
|
func countFor(t *testing.T, p *pgxpool.Pool, table, userID string) int {
|
||||||
|
t.Helper()
|
||||||
|
col := "user_id"
|
||||||
|
if table == "users" {
|
||||||
|
col = "id"
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
require.NoError(t, p.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM `+table+` WHERE `+col+` = $1`, userID).Scan(&n))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func countDeliveries(t *testing.T, p *pgxpool.Pool, summaryID string) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
require.NoError(t, p.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM sink_deliveries WHERE summary_id = $1`, summaryID).Scan(&n))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func countIdentities(t *testing.T, p *pgxpool.Pool, userID string) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
require.NoError(t, p.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM user_identities WHERE user_id = $1`, userID).Scan(&n))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteUserRemovesAllRowsForUserOnly is the account-deletion isolation proof
|
||||||
|
// (Worker N+M): DeleteUser wipes every row owned by the target user — across the
|
||||||
|
// cascade-linked tables, the user_identities map (ON DELETE CASCADE), AND
|
||||||
|
// summary_actions (which has NO FK to users, so the users-row cascade does not
|
||||||
|
// reach it and DeleteUser must delete it explicitly) — while leaving another
|
||||||
|
// user's rows completely intact.
|
||||||
|
func TestDeleteUserRemovesAllRowsForUserOnly(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
newStore(t) // apply migrations
|
||||||
|
super := rawPool(t)
|
||||||
|
resetDB(t, super)
|
||||||
|
|
||||||
|
a := seedUser(t, super, userA)
|
||||||
|
b := seedUser(t, super, userB)
|
||||||
|
seedIdentity(t, super, "subject-a", userA)
|
||||||
|
seedIdentity(t, super, "subject-b", userB)
|
||||||
|
|
||||||
|
s := newStore(t)
|
||||||
|
require.NoError(t, s.DeleteUser(ctx, userA))
|
||||||
|
|
||||||
|
// Every user-keyed isolated table: zero rows for A, exactly one for B.
|
||||||
|
for _, table := range userIsolatedTables {
|
||||||
|
require.Equal(t, 0, countFor(t, super, table, userA),
|
||||||
|
"A's %s rows must be deleted", table)
|
||||||
|
require.Equal(t, 1, countFor(t, super, table, userB),
|
||||||
|
"B's %s rows must survive A's deletion", table)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sink_deliveries is keyed by summary, not user_id (cascade from summaries).
|
||||||
|
require.Equal(t, 0, countDeliveries(t, super, a.summaryID), "A's deliveries must cascade-delete")
|
||||||
|
require.Equal(t, 1, countDeliveries(t, super, b.summaryID), "B's deliveries must survive")
|
||||||
|
|
||||||
|
// The cascade must reach user_identities (explicitly asserted per the mission).
|
||||||
|
require.Equal(t, 0, countIdentities(t, super, userA), "A's identity mapping must cascade-delete")
|
||||||
|
require.Equal(t, 1, countIdentities(t, super, userB), "B's identity mapping must survive")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteUserIsIdempotent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
// Deleting an absent user is a no-op, not an error.
|
||||||
|
require.NoError(t, s.DeleteUser(ctx, userA))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayNameReturnsRegisteredName(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
_, err := p.Exec(ctx, `INSERT INTO users (id, display_name) VALUES ($1, $2)`, userA, "Ada")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
name, err := s.DisplayName(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Ada", name)
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// allowedActions is the closed set of action verbs persisted in summary_actions.
|
// allowedActions is the closed set of action verbs persisted in summary_actions.
|
||||||
@@ -39,12 +41,7 @@ func (s *Store) SetAction(ctx context.Context, userID, videoID, action string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("store: begin set action: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
|
|
||||||
|
|
||||||
if opposite, ok := oppositeAction[action]; ok {
|
if opposite, ok := oppositeAction[action]; ok {
|
||||||
if _, err := tx.Exec(ctx,
|
if _, err := tx.Exec(ctx,
|
||||||
`DELETE FROM summary_actions
|
`DELETE FROM summary_actions
|
||||||
@@ -61,11 +58,8 @@ func (s *Store) SetAction(ctx context.Context, userID, videoID, action string) e
|
|||||||
userID, videoID, action); err != nil {
|
userID, videoID, action); err != nil {
|
||||||
return fmt.Errorf("store: set action: %w", err)
|
return fmt.Errorf("store: set action: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return fmt.Errorf("store: commit set action: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearAction removes an action for (user, video). Clearing an action that is
|
// ClearAction removes an action for (user, video). Clearing an action that is
|
||||||
@@ -74,13 +68,15 @@ func (s *Store) ClearAction(ctx context.Context, userID, videoID, action string)
|
|||||||
if err := validateAction(action); err != nil {
|
if err := validateAction(action); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := s.pool.Exec(ctx,
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
`DELETE FROM summary_actions
|
`DELETE FROM summary_actions
|
||||||
WHERE user_id = $1 AND video_id = $2 AND action = $3`,
|
WHERE user_id = $1 AND video_id = $2 AND action = $3`,
|
||||||
userID, videoID, action); err != nil {
|
userID, videoID, action); err != nil {
|
||||||
return fmt.Errorf("store: clear action: %w", err)
|
return fmt.Errorf("store: clear action: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionsFor returns the active actions per video for the given user, keyed by
|
// ActionsFor returns the active actions per video for the given user, keyed by
|
||||||
@@ -91,25 +87,30 @@ func (s *Store) ActionsFor(ctx context.Context, userID string, videoIDs []string
|
|||||||
if len(videoIDs) == 0 {
|
if len(videoIDs) == 0 {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx,
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
`SELECT video_id, action FROM summary_actions
|
`SELECT video_id, action FROM summary_actions
|
||||||
WHERE user_id = $1 AND video_id = ANY($2)
|
WHERE user_id = $1 AND video_id = ANY($2)
|
||||||
ORDER BY video_id, action`,
|
ORDER BY video_id, action`,
|
||||||
userID, videoIDs)
|
userID, videoIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: actions for: %w", err)
|
return fmt.Errorf("store: actions for: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var videoID, action string
|
var videoID, action string
|
||||||
if err := rows.Scan(&videoID, &action); err != nil {
|
if err := rows.Scan(&videoID, &action); err != nil {
|
||||||
return nil, fmt.Errorf("store: scan action: %w", err)
|
return fmt.Errorf("store: scan action: %w", err)
|
||||||
}
|
}
|
||||||
out[videoID] = append(out[videoID], action)
|
out[videoID] = append(out[videoID], action)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("store: iterate actions: %w", err)
|
return fmt.Errorf("store: iterate actions: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Connection is one connected video account (data-model.md VIDEO_CONNECTION).
|
||||||
|
// TokenRef is the opaque SecretStore reference that resolves to the OAuth refresh
|
||||||
|
// token — never the token itself. ConnectedAt is set by the DB and is read-only
|
||||||
|
// on writes (UpsertConnection ignores it).
|
||||||
|
type Connection struct {
|
||||||
|
Provider string
|
||||||
|
ProviderAccount string
|
||||||
|
TokenRef string
|
||||||
|
Status string
|
||||||
|
ConnectedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertConnection records (or refreshes) the user's connection to a provider,
|
||||||
|
// keyed on (user_id, provider): re-connecting the same provider overwrites the
|
||||||
|
// token_ref/status/account and bumps connected_at, never duplicating. Like every
|
||||||
|
// access in this package it routes through withUser, so RLS scopes the write to
|
||||||
|
// the calling user — a connection can only be written for the current user.
|
||||||
|
func (s *Store) UpsertConnection(ctx context.Context, userID string, c Connection) error {
|
||||||
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO video_connections
|
||||||
|
(user_id, provider, provider_account, token_ref, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (user_id, provider) DO UPDATE SET
|
||||||
|
provider_account = EXCLUDED.provider_account,
|
||||||
|
token_ref = EXCLUDED.token_ref,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
connected_at = now()`,
|
||||||
|
userID, c.Provider, nullIfEmpty(c.ProviderAccount), c.TokenRef, c.Status,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("store: upsert connection: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionsForUser returns the user's connections, most-recently-connected
|
||||||
|
// first. Scoped by user_id via withUser: one user never sees another's.
|
||||||
|
func (s *Store) ConnectionsForUser(ctx context.Context, userID string) ([]Connection, error) {
|
||||||
|
var out []Connection
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
|
`SELECT provider, COALESCE(provider_account, ''), token_ref, status, connected_at
|
||||||
|
FROM video_connections
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY connected_at DESC, provider`,
|
||||||
|
userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: connections for user: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var c Connection
|
||||||
|
if err := rows.Scan(&c.Provider, &c.ProviderAccount, &c.TokenRef, &c.Status, &c.ConnectedAt); err != nil {
|
||||||
|
return fmt.Errorf("store: scan connection: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("store: iterate connections: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteConnection removes the user's connection to a provider. Deleting an
|
||||||
|
// absent connection is a no-op (no error).
|
||||||
|
func (s *Store) DeleteConnection(ctx context.Context, userID, provider string) error {
|
||||||
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`DELETE FROM video_connections WHERE user_id = $1 AND provider = $2`,
|
||||||
|
userID, provider); err != nil {
|
||||||
|
return fmt.Errorf("store: delete connection: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullIfEmpty maps "" to a SQL NULL so an unknown provider_account is stored as
|
||||||
|
// NULL (the column is nullable) rather than an empty string.
|
||||||
|
func nullIfEmpty(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedUserRow inserts a bare users row (FK target for a connection) as the
|
||||||
|
// superuser pool, which bypasses RLS.
|
||||||
|
func seedUserRow(t *testing.T, userID string) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := rawPool(t).Exec(context.Background(),
|
||||||
|
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertConnectionInsertsRow(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedUserRow(t, userA)
|
||||||
|
|
||||||
|
require.NoError(t, s.UpsertConnection(ctx, userA, store.Connection{
|
||||||
|
Provider: "youtube",
|
||||||
|
ProviderAccount: "chan@example.com",
|
||||||
|
TokenRef: "youtube/" + userA + "/refresh_token",
|
||||||
|
Status: "active",
|
||||||
|
}))
|
||||||
|
|
||||||
|
conns, err := s.ConnectionsForUser(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, conns, 1)
|
||||||
|
require.Equal(t, "youtube", conns[0].Provider)
|
||||||
|
require.Equal(t, "chan@example.com", conns[0].ProviderAccount)
|
||||||
|
require.Equal(t, "youtube/"+userA+"/refresh_token", conns[0].TokenRef)
|
||||||
|
require.Equal(t, "active", conns[0].Status)
|
||||||
|
require.False(t, conns[0].ConnectedAt.IsZero(), "connected_at set by the DB default")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertConnectionIsIdempotentOnUserProvider(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedUserRow(t, userA)
|
||||||
|
|
||||||
|
require.NoError(t, s.UpsertConnection(ctx, userA, store.Connection{
|
||||||
|
Provider: "youtube", TokenRef: "ref-1", Status: "active",
|
||||||
|
}))
|
||||||
|
// Re-connect the same provider: must update in place, not duplicate.
|
||||||
|
require.NoError(t, s.UpsertConnection(ctx, userA, store.Connection{
|
||||||
|
Provider: "youtube", TokenRef: "ref-2", Status: "revoked",
|
||||||
|
}))
|
||||||
|
|
||||||
|
var count int
|
||||||
|
require.NoError(t, p.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM video_connections WHERE user_id = $1 AND provider = 'youtube'`,
|
||||||
|
userA).Scan(&count))
|
||||||
|
require.Equal(t, 1, count, "second connect must update, not duplicate")
|
||||||
|
|
||||||
|
conns, err := s.ConnectionsForUser(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, conns, 1)
|
||||||
|
require.Equal(t, "ref-2", conns[0].TokenRef, "token_ref overwritten")
|
||||||
|
require.Equal(t, "revoked", conns[0].Status, "status overwritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteConnection(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
seedUserRow(t, userA)
|
||||||
|
|
||||||
|
require.NoError(t, s.UpsertConnection(ctx, userA, store.Connection{
|
||||||
|
Provider: "youtube", TokenRef: "ref", Status: "active",
|
||||||
|
}))
|
||||||
|
require.NoError(t, s.DeleteConnection(ctx, userA, "youtube"))
|
||||||
|
|
||||||
|
conns, err := s.ConnectionsForUser(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, conns)
|
||||||
|
|
||||||
|
// Deleting an absent connection is a no-op, not an error.
|
||||||
|
require.NoError(t, s.DeleteConnection(ctx, userA, "youtube"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectionsForUserIsScoped(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
seedUserRow(t, userA)
|
||||||
|
seedUserRow(t, userB)
|
||||||
|
|
||||||
|
require.NoError(t, s.UpsertConnection(ctx, userA, store.Connection{
|
||||||
|
Provider: "youtube", TokenRef: "a-ref", Status: "active",
|
||||||
|
}))
|
||||||
|
|
||||||
|
// User B must not see user A's connection.
|
||||||
|
connsB, err := s.ConnectionsForUser(ctx, userB)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, connsB, "user B must not see user A's connections")
|
||||||
|
|
||||||
|
connsA, err := s.ConnectionsForUser(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, connsA, 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrSubjectRegistered is returned by RegisterUser when the Dex subject already
|
||||||
|
// maps to a tapir user. Registration is explicit and once-per-subject (ADR-012).
|
||||||
|
var ErrSubjectRegistered = errors.New("store: subject already registered")
|
||||||
|
|
||||||
|
// UserBySubject resolves a Dex subject to its tapir user_id via the un-RLS'd
|
||||||
|
// user_identities map. It runs as a plain pool query WITHOUT withUser: this is
|
||||||
|
// the pre-scope lookup whose result becomes the GUC for every subsequent
|
||||||
|
// user-scoped access, so it cannot itself depend on that GUC being set. found is
|
||||||
|
// false (no error) when the subject has no mapping yet — the caller routes such
|
||||||
|
// requests to registration.
|
||||||
|
func (s *Store) UserBySubject(ctx context.Context, subject string) (userID string, found bool, err error) {
|
||||||
|
err = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT user_id FROM user_identities WHERE dex_subject = $1`, subject).Scan(&userID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("store: user by subject: %w", err)
|
||||||
|
}
|
||||||
|
return userID, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterUser creates the tapir user for a Dex subject and the identity mapping
|
||||||
|
// that points to it, returning the new user_id. It errors with
|
||||||
|
// ErrSubjectRegistered if the subject already maps.
|
||||||
|
//
|
||||||
|
// Bootstrapping note (generate-uuid-then-scope): the users table is FORCE'd RLS
|
||||||
|
// with a WITH CHECK that defaults to the USING predicate id =
|
||||||
|
// current_setting('tapir.current_user_id') (migration 003). A users row can
|
||||||
|
// therefore only be inserted while the connection is ALREADY scoped to that
|
||||||
|
// row's own id — a chicken-and-egg if the id were DB-generated. So we generate
|
||||||
|
// the UUID app-side, scope to it via withUser(newID, ...), and insert the users
|
||||||
|
// row inside that scope so the WITH CHECK passes. The user_identities row is
|
||||||
|
// un-RLS'd auth plumbing; it is written in the SAME transaction so a user and
|
||||||
|
// its mapping are always consistent.
|
||||||
|
func (s *Store) RegisterUser(ctx context.Context, subject, displayName string) (userID string, err error) {
|
||||||
|
newID, err := newUUIDv4()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("store: register user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast, clear rejection of a re-registration. The dex_subject PRIMARY KEY is
|
||||||
|
// the authoritative guard (a concurrent insert would still violate it); this
|
||||||
|
// check just turns the common case into a meaningful error instead of a raw
|
||||||
|
// constraint violation.
|
||||||
|
if _, found, err := s.UserBySubject(ctx, subject); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if found {
|
||||||
|
return "", fmt.Errorf("%w: %q", ErrSubjectRegistered, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.withUser(ctx, newID, func(tx pgx.Tx) error {
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO users (id, display_name) VALUES ($1, $2)`, newID, displayName); err != nil {
|
||||||
|
return fmt.Errorf("store: insert user: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`,
|
||||||
|
subject, newID); err != nil {
|
||||||
|
return fmt.Errorf("store: insert identity: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return newID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newUUIDv4 returns a random RFC-4122 v4 UUID string. Generated app-side (stdlib
|
||||||
|
// crypto/rand, no new dependency) so the id is known before the row is scoped and
|
||||||
|
// inserted — see RegisterUser's bootstrapping note.
|
||||||
|
func newUUIDv4() (string, error) {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", fmt.Errorf("generate uuid: %w", err)
|
||||||
|
}
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
subjectA = "dex|alice-123"
|
||||||
|
subjectB = "dex|bob-456"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserBySubjectUnknownReturnsNotFound(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
id, found, err := s.UserBySubject(ctx, subjectA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, found)
|
||||||
|
require.Empty(t, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterUserCreatesUserAndIdentity(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
|
||||||
|
id, err := s.RegisterUser(ctx, subjectA, "Alice")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, id)
|
||||||
|
|
||||||
|
// Exactly one users row with the returned id and the given display name.
|
||||||
|
var users int
|
||||||
|
var name string
|
||||||
|
require.NoError(t, p.QueryRow(ctx,
|
||||||
|
`SELECT count(*), coalesce(max(display_name), '') FROM users WHERE id = $1`, id).
|
||||||
|
Scan(&users, &name))
|
||||||
|
require.Equal(t, 1, users)
|
||||||
|
require.Equal(t, "Alice", name)
|
||||||
|
|
||||||
|
// Exactly one identity row mapping the subject to that id.
|
||||||
|
var idents int
|
||||||
|
require.NoError(t, p.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM user_identities WHERE dex_subject = $1 AND user_id = $2`,
|
||||||
|
subjectA, id).Scan(&idents))
|
||||||
|
require.Equal(t, 1, idents)
|
||||||
|
|
||||||
|
// And it now resolves straight through.
|
||||||
|
got, found, err := s.UserBySubject(ctx, subjectA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, found)
|
||||||
|
require.Equal(t, id, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterUserRejectsDuplicateSubject(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
|
||||||
|
first, err := s.RegisterUser(ctx, subjectA, "Alice")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = s.RegisterUser(ctx, subjectA, "Alice Again")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.True(t, errors.Is(err, store.ErrSubjectRegistered))
|
||||||
|
|
||||||
|
// No second user was created; the original mapping is intact.
|
||||||
|
var users, idents int
|
||||||
|
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users))
|
||||||
|
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM user_identities`).Scan(&idents))
|
||||||
|
require.Equal(t, 1, users)
|
||||||
|
require.Equal(t, 1, idents)
|
||||||
|
|
||||||
|
got, found, err := s.UserBySubject(ctx, subjectA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, found)
|
||||||
|
require.Equal(t, first, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterUserDistinctSubjectsGetDistinctUsers(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
|
||||||
|
idA, err := s.RegisterUser(ctx, subjectA, "Alice")
|
||||||
|
require.NoError(t, err)
|
||||||
|
idB, err := s.RegisterUser(ctx, subjectB, "Bob")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, idA, idB)
|
||||||
|
|
||||||
|
var users int
|
||||||
|
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users))
|
||||||
|
require.Equal(t, 2, users)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
DROP POLICY IF EXISTS sink_deliveries_isolation ON sink_deliveries;
|
||||||
|
ALTER TABLE sink_deliveries NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE sink_deliveries DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS summary_actions_isolation ON summary_actions;
|
||||||
|
ALTER TABLE summary_actions NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE summary_actions DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS summaries_isolation ON summaries;
|
||||||
|
ALTER TABLE summaries NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE summaries DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS transcripts_isolation ON transcripts;
|
||||||
|
ALTER TABLE transcripts NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE transcripts DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS videos_isolation ON videos;
|
||||||
|
ALTER TABLE videos NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE videos DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS users_isolation ON users;
|
||||||
|
ALTER TABLE users NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE users DISABLE ROW LEVEL SECURITY;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- Migration 003: enforce per-user isolation at the DB layer via row-level
|
||||||
|
-- security (ADR-012, data-model.md "Isolation invariant"). Stage 1 ships
|
||||||
|
-- multi-user WITH this enforcement; it is the proof that user A cannot read or
|
||||||
|
-- write user B's rows even if application-level WHERE clauses are wrong.
|
||||||
|
--
|
||||||
|
-- How it works:
|
||||||
|
-- * Every policy keys off the per-request GUC tapir.current_user_id, set by the
|
||||||
|
-- store's withUser helper via set_config('tapir.current_user_id', $1, true)
|
||||||
|
-- (transaction-local — auto-reset on commit/rollback, never leaks across a
|
||||||
|
-- pooled connection's requests).
|
||||||
|
-- * current_setting('tapir.current_user_id', true) uses missing_ok = true: an
|
||||||
|
-- UNSET GUC yields NULL, so the predicate is NULL → no rows match → deny-all.
|
||||||
|
-- That is the safe default and is asserted in rls_test.go.
|
||||||
|
-- * FORCE ROW LEVEL SECURITY: the app connects as the table OWNER (tapir), and
|
||||||
|
-- owners BYPASS RLS unless forced. Without FORCE the policies below are dead
|
||||||
|
-- for the production user. FORCE makes the owner subject to them. (A superuser
|
||||||
|
-- DSN still bypasses RLS regardless — the test connects as a non-superuser,
|
||||||
|
-- non-BYPASSRLS role so the enforcement is real, not theatre.)
|
||||||
|
|
||||||
|
-- users: the row's own id IS the user_id for this table.
|
||||||
|
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE users FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY users_isolation ON users
|
||||||
|
FOR ALL
|
||||||
|
USING (id = current_setting('tapir.current_user_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE videos ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE videos FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY videos_isolation ON videos
|
||||||
|
FOR ALL
|
||||||
|
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE transcripts ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE transcripts FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY transcripts_isolation ON transcripts
|
||||||
|
FOR ALL
|
||||||
|
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE summaries ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE summaries FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY summaries_isolation ON summaries
|
||||||
|
FOR ALL
|
||||||
|
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE summary_actions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE summary_actions FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY summary_actions_isolation ON summary_actions
|
||||||
|
FOR ALL
|
||||||
|
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
|
||||||
|
|
||||||
|
-- sink_deliveries has NO user_id of its own; ownership is derived from the
|
||||||
|
-- summary it belongs to. We key the policy directly off the GUC via EXISTS
|
||||||
|
-- (rather than `summary_id IN (SELECT id FROM summaries)`) so it is self-contained
|
||||||
|
-- and does not silently depend on summaries' own RLS being applied to the
|
||||||
|
-- subquery. The WITH CHECK clause (defaulting to USING under FOR ALL) means a
|
||||||
|
-- delivery row can only be inserted/updated when its summary is owned by the
|
||||||
|
-- current user.
|
||||||
|
ALTER TABLE sink_deliveries ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE sink_deliveries FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY sink_deliveries_isolation ON sink_deliveries
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM summaries s
|
||||||
|
WHERE s.id = sink_deliveries.summary_id
|
||||||
|
AND s.user_id = current_setting('tapir.current_user_id', true)::uuid
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS user_identities;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Migration 004: the Dex-subject → tapir-user map (ADR-012 Stage 1, multi-user).
|
||||||
|
-- A Dex-authenticated subject is the login identity; the tapir user_id (UUID) is
|
||||||
|
-- what every user-owned, force-RLS table keys off. This table is the bridge:
|
||||||
|
-- resolve subject → user_id here (auth plumbing, pre-scope), THEN scope all data
|
||||||
|
-- access by that id via the store's withUser helper.
|
||||||
|
--
|
||||||
|
-- INTENTIONALLY NOT RLS-ENABLED. The forced-RLS isolation (migration 003) guards
|
||||||
|
-- the user-OWNED data tables. user_identities holds no user data — only an opaque
|
||||||
|
-- (dex_subject ↔ user_id) pair — and must be readable BEFORE a user_id is known
|
||||||
|
-- (that lookup is what yields the id used to set tapir.current_user_id). Putting
|
||||||
|
-- RLS here would be a chicken-and-egg deadlock (you'd need the GUC to read the row
|
||||||
|
-- that tells you the GUC). Data isolation lives on the user-owned tables, not here.
|
||||||
|
CREATE TABLE user_identities (
|
||||||
|
dex_subject TEXT PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE user_identities IS
|
||||||
|
'Dex subject -> tapir user_id map. Auth plumbing, deliberately NOT RLS-enabled '
|
||||||
|
'(no user data; must be read pre-scope to resolve the id used for RLS). '
|
||||||
|
'ON DELETE CASCADE so deleting a user cleans up its identity mapping.';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS video_connections;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Migration 005: video_connections — a user's connected video account
|
||||||
|
-- (data-model.md VIDEO_CONNECTION). The OAuth refresh token never lives here;
|
||||||
|
-- token_ref is the opaque SecretStore reference that resolves to it. Revocation
|
||||||
|
-- flips status, it does not delete the row (history is kept).
|
||||||
|
--
|
||||||
|
-- One connection per (user, provider): re-connecting the same provider upserts
|
||||||
|
-- in place (the connect flow's ON CONFLICT (user_id, provider) target).
|
||||||
|
CREATE TABLE video_connections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_account TEXT,
|
||||||
|
token_ref TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT video_connections_user_provider_unique UNIQUE (user_id, provider)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_video_connections_user_id ON video_connections(user_id);
|
||||||
|
|
||||||
|
-- Per-user isolation, identical to migration 003's pattern: this is user-owned
|
||||||
|
-- data, so user A must never read or write user B's connections even with a wrong
|
||||||
|
-- application-level WHERE. ENABLE + FORCE so the table owner (tapir) is subject to
|
||||||
|
-- the policy too; the policy keys off the per-request GUC tapir.current_user_id
|
||||||
|
-- set by the store's withUser helper. An unset GUC yields NULL -> deny-all.
|
||||||
|
ALTER TABLE video_connections ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE video_connections FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY video_connections_isolation ON video_connections
|
||||||
|
FOR ALL
|
||||||
|
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE videos DROP COLUMN IF EXISTS summarize_requested;
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS auto_summarize;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Migration 006: summarization mode (per-user auto/manual + per-video queue).
|
||||||
|
--
|
||||||
|
-- auto_summarize is a per-user setting (not a global one): multi-user ready per
|
||||||
|
-- ADR-012. FALSE default makes MANUAL the out-of-the-box behavior — `tapir run`
|
||||||
|
-- discovers new videos but only summarizes the ones the user explicitly queued.
|
||||||
|
--
|
||||||
|
-- summarize_requested is the per-video manual queue flag. The web "Summarize"
|
||||||
|
-- button sets it TRUE; the next `tapir run` picks it up, summarizes, and clears
|
||||||
|
-- it back to FALSE. In auto mode it is unused.
|
||||||
|
--
|
||||||
|
-- No RLS policy changes needed: both columns are added to tables that already
|
||||||
|
-- carry user_id and have ENABLE + FORCE ROW LEVEL SECURITY (migration 003). A new
|
||||||
|
-- column on an RLS-protected table inherits that protection automatically — the
|
||||||
|
-- existing users_isolation / videos_isolation policies gate every row, so these
|
||||||
|
-- columns are only ever readable/writable for the row's own user.
|
||||||
|
ALTER TABLE users ADD COLUMN auto_summarize BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE videos ADD COLUMN summarize_requested BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -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;
|
||||||
@@ -26,6 +26,7 @@ var ErrNotFound = errors.New("store: summary not found")
|
|||||||
// JOIN source — callers already fall back gracefully on an empty Channel.
|
// JOIN source — callers already fall back gracefully on an empty Channel.
|
||||||
type SummaryRow struct {
|
type SummaryRow struct {
|
||||||
VideoID string
|
VideoID string
|
||||||
|
ProviderVideoID string // videos.provider_video_id; empty when no videos row
|
||||||
Title string // videos.title; empty when no videos row
|
Title string // videos.title; empty when no videos row
|
||||||
Channel string // videos.provider for now; empty when no videos row
|
Channel string // videos.provider for now; empty when no videos row
|
||||||
URL string // videos.url; empty when no videos row
|
URL string // videos.url; empty when no videos row
|
||||||
@@ -38,6 +39,20 @@ type SummaryRow struct {
|
|||||||
FallbackUsed bool
|
FallbackUsed bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Actions []string // current active actions for this video; nil when none
|
Actions []string // current active actions for this video; nil when none
|
||||||
|
|
||||||
|
// Summarized reports whether a summary exists for this video. The summary-only
|
||||||
|
// reads (ListSummaries/GetSummaryByVideo) always yield true; the all-videos
|
||||||
|
// read (ListVideos) yields false for a discovered-but-unsummarized video, whose
|
||||||
|
// Summary/Highlights/AIProvider fields are then empty.
|
||||||
|
Summarized bool
|
||||||
|
// SummarizeRequested reflects videos.summarize_requested: the manual queue flag
|
||||||
|
// set by the web "Summarize" button and cleared by the next `tapir run`. Only
|
||||||
|
// populated by ListVideos/GetVideoRow (summary-only reads leave it false).
|
||||||
|
SummarizeRequested bool
|
||||||
|
// TranscriptStatus mirrors videos.transcript_status (migration 007): "" (unset),
|
||||||
|
// "none", "rate_limited", or "fetched". Drives the "Retrying later" list badge.
|
||||||
|
// Only populated by ListVideos/GetVideoRow ("" on summary-only reads).
|
||||||
|
TranscriptStatus string
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectSummary is the shared projection for both reads. videos is LEFT JOINed
|
// selectSummary is the shared projection for both reads. videos is LEFT JOINed
|
||||||
@@ -45,6 +60,7 @@ type SummaryRow struct {
|
|||||||
// crosses users and a missing videos row yields nulls, not a dropped summary.
|
// crosses users and a missing videos row yields nulls, not a dropped summary.
|
||||||
const selectSummary = `
|
const selectSummary = `
|
||||||
SELECT s.video_id,
|
SELECT s.video_id,
|
||||||
|
COALESCE(v.provider_video_id, ''),
|
||||||
COALESCE(v.title, ''),
|
COALESCE(v.title, ''),
|
||||||
COALESCE(v.provider, ''),
|
COALESCE(v.provider, ''),
|
||||||
COALESCE(v.url, ''),
|
COALESCE(v.url, ''),
|
||||||
@@ -66,27 +82,32 @@ func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]
|
|||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx,
|
var out []SummaryRow
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
selectSummary+`
|
selectSummary+`
|
||||||
WHERE s.user_id = $1
|
WHERE s.user_id = $1
|
||||||
ORDER BY s.created_at DESC
|
ORDER BY s.created_at DESC
|
||||||
LIMIT $2`,
|
LIMIT $2`,
|
||||||
userID, limit)
|
userID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: list summaries: %w", err)
|
return fmt.Errorf("store: list summaries: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var out []SummaryRow
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
row, err := scanSummaryRow(rows)
|
row, err := scanSummaryRow(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
out = append(out, row)
|
out = append(out, row)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("store: iterate summaries: %w", err)
|
return fmt.Errorf("store: iterate summaries: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.attachActions(ctx, userID, out); err != nil {
|
if err := s.attachActions(ctx, userID, out); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -94,29 +115,194 @@ func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSummaryByVideo returns the full summary for (userID, videoID), including
|
// selectVideo is the all-videos projection: it drives from the videos table and
|
||||||
// highlights and takeaways. Returns ErrNotFound when the user has no such
|
// LEFT JOINs the (at most one) summary, so a discovered-but-unsummarized video
|
||||||
// summary. Scoped by user_id.
|
// still appears with empty summary fields. The column order mirrors selectSummary
|
||||||
func (s *Store) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*SummaryRow, error) {
|
// for the shared fields, then appends summarized + summarize_requested. created_at
|
||||||
rows, err := s.pool.Query(ctx,
|
// falls back to the video's seen_at when there is no summary, so the read-side row
|
||||||
selectSummary+`
|
// always carries a sortable timestamp.
|
||||||
WHERE s.user_id = $1 AND s.video_id = $2`,
|
const selectVideo = `
|
||||||
|
SELECT v.id,
|
||||||
|
v.provider_video_id,
|
||||||
|
COALESCE(v.title, ''),
|
||||||
|
v.provider,
|
||||||
|
COALESCE(v.url, ''),
|
||||||
|
v.published_at,
|
||||||
|
COALESCE(s.summary, ''),
|
||||||
|
s.highlights,
|
||||||
|
s.takeaways,
|
||||||
|
COALESCE(s.ai_provider, ''),
|
||||||
|
COALESCE(s.ai_model, ''),
|
||||||
|
COALESCE(s.fallback_used, FALSE),
|
||||||
|
COALESCE(s.created_at, v.seen_at),
|
||||||
|
(s.id IS NOT NULL) AS summarized,
|
||||||
|
v.summarize_requested,
|
||||||
|
COALESCE(v.transcript_status, '')
|
||||||
|
FROM videos v
|
||||||
|
LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id`
|
||||||
|
|
||||||
|
// ListVideos returns ALL of the user's videos — summarized and not — most recent
|
||||||
|
// first by seen_at, capped at limit (non-positive defaults to 50). Unsummarized
|
||||||
|
// videos come back with Summarized=false and empty summary fields, so the list
|
||||||
|
// view can render them with a "Summarize" affordance. Scoped by user_id.
|
||||||
|
func (s *Store) ListVideos(ctx context.Context, userID string, limit int) ([]SummaryRow, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
var out []SummaryRow
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
|
selectVideo+`
|
||||||
|
WHERE v.user_id = $1
|
||||||
|
ORDER BY v.seen_at DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
userID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: list videos: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
row, err := scanVideoRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("store: iterate videos: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.attachActions(ctx, userID, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVideoRow returns a single video row (summarized or not) for (userID,
|
||||||
|
// videoID), used to re-render one card after queuing it. Returns ErrNotFound when
|
||||||
|
// the user has no such video. Scoped by user_id.
|
||||||
|
func (s *Store) GetVideoRow(ctx context.Context, userID, videoID string) (*SummaryRow, error) {
|
||||||
|
var (
|
||||||
|
row SummaryRow
|
||||||
|
found bool
|
||||||
|
)
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
|
selectVideo+`
|
||||||
|
WHERE v.user_id = $1 AND v.id = $2`,
|
||||||
userID, videoID)
|
userID, videoID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: get summary: %w", err)
|
return fmt.Errorf("store: get video: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("store: get summary: %w", err)
|
return fmt.Errorf("store: get video: %w", err)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
row, err = scanVideoRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
row, err := scanSummaryRow(rows)
|
holder := []SummaryRow{row}
|
||||||
if err != nil {
|
if err := s.attachActions(ctx, userID, holder); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return &holder[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanVideoRow reads one row in the selectVideo column order. published_at is
|
||||||
|
// nullable so it scans through a pointer.
|
||||||
|
func scanVideoRow(rows pgx.Row) (SummaryRow, error) {
|
||||||
|
var (
|
||||||
|
row SummaryRow
|
||||||
|
highlights []byte
|
||||||
|
takeaways []byte
|
||||||
|
publishedAt *time.Time
|
||||||
|
)
|
||||||
|
if err := rows.Scan(
|
||||||
|
&row.VideoID,
|
||||||
|
&row.ProviderVideoID,
|
||||||
|
&row.Title,
|
||||||
|
&row.Channel,
|
||||||
|
&row.URL,
|
||||||
|
&publishedAt,
|
||||||
|
&row.Summary,
|
||||||
|
&highlights,
|
||||||
|
&takeaways,
|
||||||
|
&row.AIProvider,
|
||||||
|
&row.AIModel,
|
||||||
|
&row.FallbackUsed,
|
||||||
|
&row.CreatedAt,
|
||||||
|
&row.Summarized,
|
||||||
|
&row.SummarizeRequested,
|
||||||
|
&row.TranscriptStatus,
|
||||||
|
); err != nil {
|
||||||
|
return SummaryRow{}, fmt.Errorf("store: scan video: %w", err)
|
||||||
|
}
|
||||||
|
if publishedAt != nil {
|
||||||
|
row.PublishedAt = *publishedAt
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if row.Highlights, err = unmarshalList(highlights); err != nil {
|
||||||
|
return SummaryRow{}, fmt.Errorf("store: unmarshal highlights: %w", err)
|
||||||
|
}
|
||||||
|
if row.Takeaways, err = unmarshalList(takeaways); err != nil {
|
||||||
|
return SummaryRow{}, fmt.Errorf("store: unmarshal takeaways: %w", err)
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSummaryByVideo returns the full summary for (userID, videoID), including
|
||||||
|
// highlights and takeaways. Returns ErrNotFound when the user has no such
|
||||||
|
// summary. Scoped by user_id.
|
||||||
|
func (s *Store) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*SummaryRow, error) {
|
||||||
|
var (
|
||||||
|
row SummaryRow
|
||||||
|
found bool
|
||||||
|
)
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
|
selectSummary+`
|
||||||
|
WHERE s.user_id = $1 AND s.video_id = $2`,
|
||||||
|
userID, videoID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: get summary: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
if !rows.Next() {
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("store: get summary: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
row, err = scanSummaryRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
holder := []SummaryRow{row}
|
holder := []SummaryRow{row}
|
||||||
if err := s.attachActions(ctx, userID, holder); err != nil {
|
if err := s.attachActions(ctx, userID, holder); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -135,6 +321,7 @@ func scanSummaryRow(rows pgx.Row) (SummaryRow, error) {
|
|||||||
)
|
)
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&row.VideoID,
|
&row.VideoID,
|
||||||
|
&row.ProviderVideoID,
|
||||||
&row.Title,
|
&row.Title,
|
||||||
&row.Channel,
|
&row.Channel,
|
||||||
&row.URL,
|
&row.URL,
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is the isolation proof for ADR-012: per-user isolation is enforced by the
|
||||||
|
// database (migration 003 RLS policies), not merely by application WHERE clauses.
|
||||||
|
//
|
||||||
|
// CRITICAL: embedded-postgres's default user (postgres) is a SUPERUSER, which
|
||||||
|
// BYPASSES RLS regardless of FORCE ROW LEVEL SECURITY. A test that ran scoped
|
||||||
|
// queries as postgres would be fake-green — it would pass even with the policies
|
||||||
|
// removed. So this test creates a dedicated NON-SUPERUSER, non-BYPASSRLS role
|
||||||
|
// ("app", mirroring the production table-owner role tapir which FORCE subjects to
|
||||||
|
// RLS) and runs every scoped query as that role. The deny-all sanity check below
|
||||||
|
// (no GUC set → zero rows) proves the enforcement path is live, not bypassed.
|
||||||
|
|
||||||
|
// userIsolatedTables are the tables that carry a user_id and whose policy keys
|
||||||
|
// directly off the tapir.current_user_id GUC.
|
||||||
|
var userIsolatedTables = []string{
|
||||||
|
"users", "videos", "transcripts", "summaries", "summary_actions", "video_connections",
|
||||||
|
}
|
||||||
|
|
||||||
|
// allIsolatedTables adds sink_deliveries, whose ownership is derived from its
|
||||||
|
// summary (no user_id column of its own).
|
||||||
|
var allIsolatedTables = append(append([]string{}, userIsolatedTables...), "sink_deliveries")
|
||||||
|
|
||||||
|
// seeded captures the DB-generated ids for one user's row chain.
|
||||||
|
type seeded struct {
|
||||||
|
userID string
|
||||||
|
videoID string // videos.id (UUID), reused as summaries.video_id
|
||||||
|
summaryID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedUser inserts one full chain (user → video → transcript → summary →
|
||||||
|
// action → delivery) as the superuser pool, which bypasses RLS so both users'
|
||||||
|
// data lands regardless of the GUC.
|
||||||
|
func seedUser(t *testing.T, p *pgxpool.Pool, userID string) seeded {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := p.Exec(ctx, `INSERT INTO users (id) VALUES ($1)`, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var videoID string
|
||||||
|
require.NoError(t, p.QueryRow(ctx,
|
||||||
|
`INSERT INTO videos (user_id, provider, provider_video_id, title)
|
||||||
|
VALUES ($1, 'youtube', $2, 'title') RETURNING id`,
|
||||||
|
userID, "vid-"+userID).Scan(&videoID))
|
||||||
|
|
||||||
|
_, err = p.Exec(ctx,
|
||||||
|
`INSERT INTO transcripts (video_id, user_id, source, content)
|
||||||
|
VALUES ($1, $2, 'captions', 'words')`, videoID, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var summaryID string
|
||||||
|
require.NoError(t, p.QueryRow(ctx,
|
||||||
|
`INSERT INTO summaries (user_id, video_id, summary) VALUES ($1, $2, 'sum')
|
||||||
|
RETURNING id`, userID, videoID).Scan(&summaryID))
|
||||||
|
|
||||||
|
_, err = p.Exec(ctx,
|
||||||
|
`INSERT INTO summary_actions (user_id, video_id, action)
|
||||||
|
VALUES ($1, $2, 'watched')`, userID, videoID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = p.Exec(ctx,
|
||||||
|
`INSERT INTO sink_deliveries (summary_id, sink, status)
|
||||||
|
VALUES ($1, 'store', 'delivered')`, summaryID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = p.Exec(ctx,
|
||||||
|
`INSERT INTO video_connections (user_id, provider, token_ref, status)
|
||||||
|
VALUES ($1, 'youtube', $2, 'active')`, userID, "youtube/"+userID+"/refresh_token")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return seeded{userID: userID, videoID: videoID, summaryID: summaryID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// appPool creates a non-superuser role with DML grants and returns a pool
|
||||||
|
// connected AS that role, so RLS is actually enforced for it.
|
||||||
|
func appPool(t *testing.T, super *pgxpool.Pool) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Idempotent across test runs (schema/role persist for the TestMain PG).
|
||||||
|
_, _ = super.Exec(ctx, `DROP ROLE IF EXISTS app`)
|
||||||
|
_, err := super.Exec(ctx, `CREATE ROLE app LOGIN PASSWORD 'app'`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = super.Exec(ctx, `GRANT USAGE ON SCHEMA public TO app`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = super.Exec(ctx,
|
||||||
|
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
appDSN := strings.Replace(dsn, "postgres:postgres@", "app:app@", 1)
|
||||||
|
p, err := pgxpool.New(ctx, appDSN)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(p.Close)
|
||||||
|
|
||||||
|
// Sanity: the app role must NOT be a superuser / must not bypass RLS, else
|
||||||
|
// this whole test is theatre.
|
||||||
|
var isSuper bool
|
||||||
|
require.NoError(t, p.QueryRow(ctx,
|
||||||
|
`SELECT rolsuper FROM pg_roles WHERE rolname = current_user`).Scan(&isSuper))
|
||||||
|
require.False(t, isSuper, "app role must be non-superuser or RLS is bypassed")
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopedCount counts rows in table as the app role, optionally scoped to a user
|
||||||
|
// via the transaction-local GUC. An empty scope sets no GUC (deny-all path).
|
||||||
|
func scopedCount(t *testing.T, p *pgxpool.Pool, scope, table string) int {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
tx, err := p.Begin(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck
|
||||||
|
|
||||||
|
if scope != "" {
|
||||||
|
_, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, scope)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
require.NoError(t, tx.QueryRow(ctx, `SELECT count(*) FROM `+table).Scan(&n))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopedRowsAffected runs a write as the app role scoped to scope and returns the
|
||||||
|
// rows affected, so we can assert a cross-user write touches zero rows.
|
||||||
|
func scopedRowsAffected(t *testing.T, p *pgxpool.Pool, scope, sql string, args ...any) int64 {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
tx, err := p.Begin(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, scope)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ct, err := tx.Exec(ctx, sql, args...)
|
||||||
|
require.NoError(t, err) // RLS hides the rows; it is NOT a permission error
|
||||||
|
require.NoError(t, tx.Commit(ctx))
|
||||||
|
return ct.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRLSEnforcesPerUserIsolation(t *testing.T) {
|
||||||
|
newStore(t) // apply migrations (incl. 003 RLS) as superuser
|
||||||
|
super := rawPool(t)
|
||||||
|
resetDB(t, super)
|
||||||
|
|
||||||
|
a := seedUser(t, super, userA)
|
||||||
|
b := seedUser(t, super, userB)
|
||||||
|
app := appPool(t, super)
|
||||||
|
|
||||||
|
// 1. Deny-all: with NO GUC set, every isolated table returns zero rows. This
|
||||||
|
// proves RLS is actually ON (a bypassed/superuser path would see all rows).
|
||||||
|
for _, table := range allIsolatedTables {
|
||||||
|
require.Equal(t, 0, scopedCount(t, app, "", table),
|
||||||
|
"unset tapir.current_user_id must yield deny-all on %s", table)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scoped reads: A sees exactly its own one row per table; likewise B. A
|
||||||
|
// seeing B's row (or vice versa) would mean isolation is broken.
|
||||||
|
for _, table := range allIsolatedTables {
|
||||||
|
require.Equal(t, 1, scopedCount(t, app, userA, table),
|
||||||
|
"user A scoped read must see exactly its own row in %s", table)
|
||||||
|
require.Equal(t, 1, scopedCount(t, app, userB, table),
|
||||||
|
"user B scoped read must see exactly its own row in %s", table)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Cross-user writes are invisible: scoped to A, an UPDATE/DELETE aimed at
|
||||||
|
// B's rows affects zero rows (RLS hides them from the write, too).
|
||||||
|
writes := []struct {
|
||||||
|
name string
|
||||||
|
sql string
|
||||||
|
arg any // identifies B's row(s)
|
||||||
|
}{
|
||||||
|
{"update users", `UPDATE users SET display_name = 'hacked' WHERE id = $1`, b.userID},
|
||||||
|
{"update videos", `UPDATE videos SET title = 'hacked' WHERE user_id = $1`, b.userID},
|
||||||
|
{"queue videos summarize", `UPDATE videos SET summarize_requested = TRUE WHERE id = $1`, b.videoID},
|
||||||
|
{"update transcripts", `UPDATE transcripts SET content = 'hacked' WHERE user_id = $1`, b.userID},
|
||||||
|
{"update summaries", `UPDATE summaries SET summary = 'hacked' WHERE user_id = $1`, b.userID},
|
||||||
|
{"update summary_actions", `UPDATE summary_actions SET action = 'skipped' WHERE user_id = $1`, b.userID},
|
||||||
|
{"update sink_deliveries", `UPDATE sink_deliveries SET status = 'hacked' WHERE summary_id = $1`, b.summaryID},
|
||||||
|
{"update video_connections", `UPDATE video_connections SET token_ref = 'hacked' WHERE user_id = $1`, b.userID},
|
||||||
|
{"delete summaries", `DELETE FROM summaries WHERE user_id = $1`, b.userID},
|
||||||
|
{"delete summary_actions", `DELETE FROM summary_actions WHERE user_id = $1`, b.userID},
|
||||||
|
{"delete sink_deliveries", `DELETE FROM sink_deliveries WHERE summary_id = $1`, b.summaryID},
|
||||||
|
{"delete video_connections", `DELETE FROM video_connections WHERE user_id = $1`, b.userID},
|
||||||
|
}
|
||||||
|
for _, w := range writes {
|
||||||
|
require.Equal(t, int64(0), scopedRowsAffected(t, app, userA, w.sql, w.arg),
|
||||||
|
"user A scoped %s must touch zero of user B's rows", w.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. B's rows survived unchanged (the writes above neither modified nor
|
||||||
|
// deleted them), verified via the superuser pool which bypasses RLS.
|
||||||
|
ctx := context.Background()
|
||||||
|
var bSummary string
|
||||||
|
require.NoError(t, super.QueryRow(ctx,
|
||||||
|
`SELECT summary FROM summaries WHERE user_id = $1`, b.userID).Scan(&bSummary))
|
||||||
|
require.Equal(t, "sum", bSummary, "B's summary must be untouched by A's writes")
|
||||||
|
|
||||||
|
var bSummaries, bActions, bDeliveries, bConnections int
|
||||||
|
require.NoError(t, super.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM summaries WHERE user_id = $1`, b.userID).Scan(&bSummaries))
|
||||||
|
require.NoError(t, super.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM summary_actions WHERE user_id = $1`, b.userID).Scan(&bActions))
|
||||||
|
require.NoError(t, super.QueryRow(ctx,
|
||||||
|
fmt.Sprintf(`SELECT count(*) FROM sink_deliveries WHERE summary_id = '%s'`, b.summaryID)).Scan(&bDeliveries))
|
||||||
|
require.NoError(t, super.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM video_connections WHERE user_id = $1 AND token_ref <> 'hacked'`, b.userID).Scan(&bConnections))
|
||||||
|
require.Equal(t, 1, bSummaries, "A's DELETE must not have removed B's summary")
|
||||||
|
require.Equal(t, 1, bActions, "A's DELETE must not have removed B's action")
|
||||||
|
require.Equal(t, 1, bDeliveries, "A's DELETE must not have removed B's delivery")
|
||||||
|
require.Equal(t, 1, bConnections, "A's writes must not have touched B's connection")
|
||||||
|
|
||||||
|
var bRequested bool
|
||||||
|
require.NoError(t, super.QueryRow(ctx,
|
||||||
|
`SELECT summarize_requested FROM videos WHERE user_id = $1`, b.userID).Scan(&bRequested))
|
||||||
|
require.False(t, bRequested, "A scoped must not have queued B's video for summarization")
|
||||||
|
|
||||||
|
_ = a // a's ids are seeded for the symmetric read assertions above
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver for migrate
|
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver for migrate
|
||||||
@@ -90,6 +91,46 @@ func (s *Store) Close() {
|
|||||||
// Name identifies this sink in delivery records.
|
// Name identifies this sink in delivery records.
|
||||||
func (s *Store) Name() string { return "store" }
|
func (s *Store) Name() string { return "store" }
|
||||||
|
|
||||||
|
// withUser is the single choke point through which EVERY DB access in this
|
||||||
|
// package flows, so per-user isolation is structural — not a per-query opt-in
|
||||||
|
// someone can forget. It:
|
||||||
|
//
|
||||||
|
// - BEGINs a transaction,
|
||||||
|
// - sets the per-request GUC tapir.current_user_id via
|
||||||
|
// set_config('tapir.current_user_id', $1, true). The set_config form is used
|
||||||
|
// instead of `SET LOCAL` because it is parameterizable (SET cannot bind a
|
||||||
|
// value through the driver); the third arg true = local = transaction-scoped,
|
||||||
|
// so it auto-resets on commit/rollback and a pooled connection never leaks one
|
||||||
|
// request's user into the next,
|
||||||
|
// - runs fn against that transaction,
|
||||||
|
// - COMMITs (or ROLLBACKs on error).
|
||||||
|
//
|
||||||
|
// The migration-003 RLS policies key off this GUC: a row is visible/writable only
|
||||||
|
// when its owner = current_setting('tapir.current_user_id'). RLS enforces only
|
||||||
|
// when the app connects as a non-superuser, non-BYPASSRLS role (in production the
|
||||||
|
// table owner tapir, made subject via FORCE ROW LEVEL SECURITY). A superuser DSN
|
||||||
|
// bypasses RLS regardless — see rls_test.go, which connects as a dedicated
|
||||||
|
// non-superuser role to prove the enforcement is real.
|
||||||
|
func (s *Store) withUser(ctx context.Context, userID string, fn func(pgx.Tx) error) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: begin: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`SELECT set_config('tapir.current_user_id', $1, true)`, userID); err != nil {
|
||||||
|
return fmt.Errorf("store: scope user: %w", err)
|
||||||
|
}
|
||||||
|
if err := fn(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("store: commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Deliver upserts the summary idempotently on (user_id, video_id) and records the
|
// Deliver upserts the summary idempotently on (user_id, video_id) and records the
|
||||||
// store delivery. Re-delivering the same summary updates in place — it never
|
// store delivery. Re-delivering the same summary updates in place — it never
|
||||||
// errors or duplicates. The whole write is one transaction so a summary and its
|
// errors or duplicates. The whole write is one transaction so a summary and its
|
||||||
@@ -104,14 +145,9 @@ func (s *Store) Deliver(ctx context.Context, sum domain.Summary) error {
|
|||||||
return fmt.Errorf("store: marshal takeaways: %w", err)
|
return fmt.Errorf("store: marshal takeaways: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
return s.withUser(ctx, sum.UserID, func(tx pgx.Tx) error {
|
||||||
if err != nil {
|
// Ensure the owning user exists (FK target). The store sink receives only
|
||||||
return fmt.Errorf("store: begin: %w", err)
|
// a Summary, so a minimal user row is enough at Stage 0.
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
|
|
||||||
|
|
||||||
// Ensure the owning user exists (FK target). The store sink receives only a
|
|
||||||
// Summary, so a minimal user row is enough at Stage 0.
|
|
||||||
if _, err := tx.Exec(ctx,
|
if _, err := tx.Exec(ctx,
|
||||||
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
||||||
sum.UserID); err != nil {
|
sum.UserID); err != nil {
|
||||||
@@ -147,20 +183,19 @@ func (s *Store) Deliver(ctx context.Context, sum domain.Summary) error {
|
|||||||
summaryID); err != nil {
|
summaryID); err != nil {
|
||||||
return fmt.Errorf("store: record delivery: %w", err)
|
return fmt.Errorf("store: record delivery: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return fmt.Errorf("store: commit: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasSummary reports whether a summary already exists for (userID, videoID).
|
// HasSummary reports whether a summary already exists for (userID, videoID).
|
||||||
// This is the per-video durable dedup check.
|
// This is the per-video durable dedup check.
|
||||||
func (s *Store) HasSummary(ctx context.Context, userID, videoID string) (bool, error) {
|
func (s *Store) HasSummary(ctx context.Context, userID, videoID string) (bool, error) {
|
||||||
var exists bool
|
var exists bool
|
||||||
if err := s.pool.QueryRow(ctx,
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
return tx.QueryRow(ctx,
|
||||||
`SELECT EXISTS(SELECT 1 FROM summaries WHERE user_id = $1 AND video_id = $2)`,
|
`SELECT EXISTS(SELECT 1 FROM summaries WHERE user_id = $1 AND video_id = $2)`,
|
||||||
userID, videoID).Scan(&exists); err != nil {
|
userID, videoID).Scan(&exists)
|
||||||
|
}); err != nil {
|
||||||
return false, fmt.Errorf("store: has summary: %w", err)
|
return false, fmt.Errorf("store: has summary: %w", err)
|
||||||
}
|
}
|
||||||
return exists, nil
|
return exists, nil
|
||||||
@@ -170,23 +205,28 @@ func (s *Store) HasSummary(ctx context.Context, userID, videoID string) (bool, e
|
|||||||
// user. The watcher uses it to skip re-summarizing across restarts. Scoped by
|
// user. The watcher uses it to skip re-summarizing across restarts. Scoped by
|
||||||
// user_id, so one user never sees another's videos.
|
// user_id, so one user never sees another's videos.
|
||||||
func (s *Store) SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
func (s *Store) SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
||||||
rows, err := s.pool.Query(ctx,
|
seen := make(map[string]bool)
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
`SELECT video_id FROM summaries WHERE user_id = $1`, userID)
|
`SELECT video_id FROM summaries WHERE user_id = $1`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: seen video ids: %w", err)
|
return fmt.Errorf("store: seen video ids: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id string
|
var id string
|
||||||
if err := rows.Scan(&id); err != nil {
|
if err := rows.Scan(&id); err != nil {
|
||||||
return nil, fmt.Errorf("store: scan video id: %w", err)
|
return fmt.Errorf("store: scan video id: %w", err)
|
||||||
}
|
}
|
||||||
seen[id] = true
|
seen[id] = true
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("store: iterate video ids: %w", err)
|
return fmt.Errorf("store: iterate video ids: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return seen, nil
|
return seen, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetAutoSummarize sets the user's auto/manual summarization mode. TRUE =
|
||||||
|
// automatic (every new video is summarized by `tapir run`); FALSE = manual (the
|
||||||
|
// user queues videos individually). Per-user, not global (ADR-012). Scoped via
|
||||||
|
// withUser, so RLS confines the UPDATE to the calling user's own row.
|
||||||
|
func (s *Store) SetAutoSummarize(ctx context.Context, userID string, enabled bool) error {
|
||||||
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
// Ensure the row exists (FK/identity target) before the UPDATE — mirrors
|
||||||
|
// the Deliver/UpsertVideo paths, so toggling mode works even before the
|
||||||
|
// first summary lands.
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
||||||
|
userID); err != nil {
|
||||||
|
return fmt.Errorf("store: upsert user: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE users SET auto_summarize = $1 WHERE id = $2`, enabled, userID); err != nil {
|
||||||
|
return fmt.Errorf("store: set auto summarize: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAutoSummarize reports the user's summarization mode (TRUE = automatic). An
|
||||||
|
// absent user row reads as FALSE (manual), the safe default. Scoped via withUser.
|
||||||
|
func (s *Store) GetAutoSummarize(ctx context.Context, userID string) (bool, error) {
|
||||||
|
var enabled bool
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
err := tx.QueryRow(ctx,
|
||||||
|
`SELECT auto_summarize FROM users WHERE id = $1`, userID).Scan(&enabled)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
enabled = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return false, fmt.Errorf("store: get auto summarize: %w", err)
|
||||||
|
}
|
||||||
|
return enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestSummarize queues a single video for manual summarization by setting its
|
||||||
|
// summarize_requested flag. The next `tapir run` picks it up and clears the flag.
|
||||||
|
// Returns ErrNotFound when the video does not exist or is not owned by the user
|
||||||
|
// (RLS hides another user's row, so the UPDATE matches zero rows). Scoped via
|
||||||
|
// withUser.
|
||||||
|
func (s *Store) RequestSummarize(ctx context.Context, userID, videoID string) error {
|
||||||
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
ct, err := tx.Exec(ctx,
|
||||||
|
`UPDATE videos SET summarize_requested = TRUE WHERE id = $1`, videoID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: request summarize: %w", err)
|
||||||
|
}
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestedVideoIDs returns the set of the user's video ids currently flagged for
|
||||||
|
// manual summarization. The run loop loads it once per pass (mirroring
|
||||||
|
// SeenVideoIDs) to decide which discovered videos to process in manual mode.
|
||||||
|
// Scoped by user_id.
|
||||||
|
func (s *Store) RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
||||||
|
requested := make(map[string]bool)
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
|
`SELECT id FROM videos WHERE user_id = $1 AND summarize_requested = TRUE`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: requested video ids: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return fmt.Errorf("store: scan requested id: %w", err)
|
||||||
|
}
|
||||||
|
requested[id] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("store: iterate requested ids: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return requested, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSummarizeRequested resets a video's manual queue flag, called by the run
|
||||||
|
// loop after a queued video is successfully summarized so it is not re-processed
|
||||||
|
// and the list view drops the "Queued" chip. Scoped via withUser.
|
||||||
|
func (s *Store) ClearSummarizeRequested(ctx context.Context, userID, videoID string) error {
|
||||||
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE videos SET summarize_requested = FALSE WHERE id = $1`, videoID); err != nil {
|
||||||
|
return fmt.Errorf("store: clear summarize requested: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedBareVideo inserts a videos row with no summary, so the all-videos read and
|
||||||
|
// the manual-queue flag can be exercised without a delivered summary.
|
||||||
|
func seedBareVideo(t *testing.T, p *pgxpool.Pool, userID, videoID, title string) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := p.Exec(context.Background(),
|
||||||
|
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = p.Exec(context.Background(),
|
||||||
|
`INSERT INTO videos (id, user_id, provider, provider_video_id, title)
|
||||||
|
VALUES ($1, $2, 'youtube', $3, $4)`,
|
||||||
|
videoID, userID, "pv-"+videoID[:8], title)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoSummarizeRoundTripDefaultsFalse(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
// Unknown / fresh user defaults to manual (false).
|
||||||
|
got, err := s.GetAutoSummarize(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, got, "default mode is manual")
|
||||||
|
|
||||||
|
require.NoError(t, s.SetAutoSummarize(ctx, userA, true))
|
||||||
|
got, err = s.GetAutoSummarize(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, got, "set to automatic round-trips")
|
||||||
|
|
||||||
|
require.NoError(t, s.SetAutoSummarize(ctx, userA, false))
|
||||||
|
got, err = s.GetAutoSummarize(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, got, "set back to manual round-trips")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeSetsFlag(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedBareVideo(t, p, userA, videoX, "X Title")
|
||||||
|
|
||||||
|
require.NoError(t, s.RequestSummarize(ctx, userA, videoX))
|
||||||
|
|
||||||
|
requested, err := s.RequestedVideoIDs(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, map[string]bool{videoX: true}, requested)
|
||||||
|
|
||||||
|
// Clearing drops it from the requested set.
|
||||||
|
require.NoError(t, s.ClearSummarizeRequested(ctx, userA, videoX))
|
||||||
|
requested, err = s.RequestedVideoIDs(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeMissingVideo(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
err := s.RequestSummarize(ctx, userA, videoX)
|
||||||
|
require.ErrorIs(t, err, store.ErrNotFound, "queuing a non-existent video reports not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListVideosReturnsSummarizedAndUnsummarized(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
|
||||||
|
// videoX: discovered AND summarized. videoY: discovered, not yet summarized.
|
||||||
|
seedBareVideo(t, p, userA, videoX, "Summarized One")
|
||||||
|
seedBareVideo(t, p, userA, videoY, "Pending One")
|
||||||
|
require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "body x")))
|
||||||
|
require.NoError(t, s.RequestSummarize(ctx, userA, videoY))
|
||||||
|
|
||||||
|
rows, err := s.ListVideos(ctx, userA, 50)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 2, "both summarized and unsummarized videos are listed")
|
||||||
|
|
||||||
|
byID := map[string]store.SummaryRow{}
|
||||||
|
for _, r := range rows {
|
||||||
|
byID[r.VideoID] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
require.True(t, byID[videoX].Summarized)
|
||||||
|
require.Equal(t, "body x", byID[videoX].Summary)
|
||||||
|
require.False(t, byID[videoX].SummarizeRequested)
|
||||||
|
|
||||||
|
require.False(t, byID[videoY].Summarized, "no summary -> Summarized false")
|
||||||
|
require.Empty(t, byID[videoY].Summary, "unsummarized row has empty summary")
|
||||||
|
require.True(t, byID[videoY].SummarizeRequested, "queued video carries the flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListVideosIsUserScoped(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedBareVideo(t, p, userA, videoX, "A only")
|
||||||
|
|
||||||
|
rows, err := s.ListVideos(ctx, userB, 50)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, rows, "user B must not see user A's videos")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVideoRowNotFound(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
_, err := s.GetVideoRow(ctx, userA, videoX)
|
||||||
|
require.ErrorIs(t, err, store.ErrNotFound)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validTranscriptStatuses bounds SetTranscriptStatus input. "" clears the status
|
||||||
|
// (column NULL); the three named states mirror migration 007's documented values.
|
||||||
|
var validTranscriptStatuses = map[string]bool{
|
||||||
|
"": true,
|
||||||
|
"none": true,
|
||||||
|
"rate_limited": true,
|
||||||
|
"fetched": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTranscriptStatus records the outcome of the last transcript attempt for a
|
||||||
|
// video (migration 007). When status is "rate_limited" it also stamps
|
||||||
|
// rate_limited_at = NOW() so the runner can back off; every other status clears
|
||||||
|
// that timestamp. "" unsets the status (column NULL). An unknown status is
|
||||||
|
// rejected. Scoped via withUser, so RLS confines the UPDATE to the caller's own
|
||||||
|
// video; ErrNotFound when the user has no such video.
|
||||||
|
func (s *Store) SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error {
|
||||||
|
if !validTranscriptStatuses[status] {
|
||||||
|
return fmt.Errorf("store: invalid transcript status %q", status)
|
||||||
|
}
|
||||||
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
ct, err := tx.Exec(ctx,
|
||||||
|
`UPDATE videos
|
||||||
|
SET transcript_status = NULLIF($1, ''),
|
||||||
|
rate_limited_at = CASE WHEN $1 = 'rate_limited' THEN NOW() ELSE NULL END
|
||||||
|
WHERE id = $2`,
|
||||||
|
status, videoID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: set transcript status: %w", err)
|
||||||
|
}
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTranscriptStatus returns a video's transcript_status ("" when unset/NULL).
|
||||||
|
// Returns ErrNotFound when the user has no such video. Scoped via withUser.
|
||||||
|
func (s *Store) GetTranscriptStatus(ctx context.Context, userID, videoID string) (string, error) {
|
||||||
|
var status string
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
err := tx.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(transcript_status, '') FROM videos WHERE id = $1`, videoID).Scan(&status)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("store: get transcript status: %w", err)
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitedVideoIDs returns the user's videos currently in the "rate_limited"
|
||||||
|
// state, mapped to when the 429 was stamped (rate_limited_at). The run loop loads
|
||||||
|
// it once per pass (mirroring SeenVideoIDs) to skip re-fetching a video still
|
||||||
|
// inside the backoff window, saving caption requests. Scoped by user_id.
|
||||||
|
func (s *Store) RateLimitedVideoIDs(ctx context.Context, userID string) (map[string]time.Time, error) {
|
||||||
|
out := make(map[string]time.Time)
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
|
`SELECT id, rate_limited_at FROM videos
|
||||||
|
WHERE user_id = $1 AND transcript_status = 'rate_limited' AND rate_limited_at IS NOT NULL`,
|
||||||
|
userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: rate limited video ids: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id string
|
||||||
|
at time.Time
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&id, &at); err != nil {
|
||||||
|
return fmt.Errorf("store: scan rate limited id: %w", err)
|
||||||
|
}
|
||||||
|
out[id] = at
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("store: iterate rate limited ids: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetTranscriptStatus_RoundTrip(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
id, err := s.UpsertVideo(ctx, ytVideo(userA, "rt12345", "round trip"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Unset by default.
|
||||||
|
got, err := s.GetTranscriptStatus(ctx, userA, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "", got)
|
||||||
|
|
||||||
|
for _, status := range []string{"none", "fetched", "rate_limited", ""} {
|
||||||
|
require.NoError(t, s.SetTranscriptStatus(ctx, userA, id, status))
|
||||||
|
got, err := s.GetTranscriptStatus(ctx, userA, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, status, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetTranscriptStatus_RejectsInvalid(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
id, err := s.UpsertVideo(ctx, ytVideo(userA, "bad12345", "bad status"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Error(t, s.SetTranscriptStatus(ctx, userA, id, "bogus"))
|
||||||
|
|
||||||
|
// The rejected write left the status untouched.
|
||||||
|
got, err := s.GetTranscriptStatus(ctx, userA, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetTranscriptStatus_NotFound(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
require.ErrorIs(t, s.SetTranscriptStatus(ctx, userA, videoX, "fetched"), store.ErrNotFound)
|
||||||
|
|
||||||
|
_, err := s.GetTranscriptStatus(ctx, userA, videoX)
|
||||||
|
require.ErrorIs(t, err, store.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitedVideoIDs_StampsAndClears(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
id, err := s.UpsertVideo(ctx, ytVideo(userA, "rl12345", "rate limited"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Marking rate_limited stamps rate_limited_at, so the video appears.
|
||||||
|
require.NoError(t, s.SetTranscriptStatus(ctx, userA, id, "rate_limited"))
|
||||||
|
rl, err := s.RateLimitedVideoIDs(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, rl, id)
|
||||||
|
require.False(t, rl[id].IsZero(), "rate_limited_at must be stamped")
|
||||||
|
|
||||||
|
// Moving off rate_limited clears the timestamp, so it drops out.
|
||||||
|
require.NoError(t, s.SetTranscriptStatus(ctx, userA, id, "fetched"))
|
||||||
|
rl, err = s.RateLimitedVideoIDs(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, rl, id)
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,25 +31,20 @@ func (s *Store) UpsertVideo(ctx context.Context, v domain.Video) (string, error)
|
|||||||
return "", fmt.Errorf("store: upsert video: empty provider video id")
|
return "", fmt.Errorf("store: upsert video: empty provider video id")
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("store: begin: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
|
|
||||||
|
|
||||||
// Ensure the owning user exists (FK target) — same as the Deliver path.
|
|
||||||
if _, err := tx.Exec(ctx,
|
|
||||||
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
|
||||||
v.UserID); err != nil {
|
|
||||||
return "", fmt.Errorf("store: upsert user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
provider := string(v.Provider)
|
provider := string(v.Provider)
|
||||||
if provider == "" {
|
if provider == "" {
|
||||||
provider = string(domain.ProviderYouTube)
|
provider = string(domain.ProviderYouTube)
|
||||||
}
|
}
|
||||||
|
|
||||||
var id string
|
var id string
|
||||||
|
if err := s.withUser(ctx, v.UserID, func(tx pgx.Tx) error {
|
||||||
|
// Ensure the owning user exists (FK target) — same as the Deliver path.
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
||||||
|
v.UserID); err != nil {
|
||||||
|
return fmt.Errorf("store: upsert user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.QueryRow(ctx,
|
if err := tx.QueryRow(ctx,
|
||||||
`INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at)
|
`INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
@@ -58,11 +55,11 @@ func (s *Store) UpsertVideo(ctx context.Context, v domain.Video) (string, error)
|
|||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
v.UserID, provider, v.ProviderVideoID, v.Title, v.URL, nullTime(v.PublishedAt),
|
v.UserID, provider, v.ProviderVideoID, v.Title, v.URL, nullTime(v.PublishedAt),
|
||||||
).Scan(&id); err != nil {
|
).Scan(&id); err != nil {
|
||||||
return "", fmt.Errorf("store: upsert video: %w", err)
|
return fmt.Errorf("store: upsert video: %w", err)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
if err := tx.Commit(ctx); err != nil {
|
}); err != nil {
|
||||||
return "", fmt.Errorf("store: commit: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ func oauthConfig(c Config) *oauth2.Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthCodeURL builds the provider consent URL the web connect flow redirects to
|
||||||
|
// (internal/web). It reuses oauthConfig and pins access_type=offline + prompt=
|
||||||
|
// consent so Google returns a refresh token even on a repeat authorization —
|
||||||
|
// without one, Exchange would reject the result. state is the per-request CSRF
|
||||||
|
// token the caller binds to the user and verifies on the callback.
|
||||||
|
func AuthCodeURL(c Config, state string) string {
|
||||||
|
return oauthConfig(c).AuthCodeURL(state,
|
||||||
|
oauth2.AccessTypeOffline,
|
||||||
|
oauth2.SetAuthURLParam("prompt", "consent"))
|
||||||
|
}
|
||||||
|
|
||||||
// Exchange swaps an authorization code for a token and persists the refresh
|
// Exchange swaps an authorization code for a token and persists the refresh
|
||||||
// token through the writer. It errors if the provider returned no refresh token
|
// token through the writer. It errors if the provider returned no refresh token
|
||||||
// (e.g. consent was not forced with offline access), since without one the
|
// (e.g. consent was not forced with offline access), since without one the
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ type Config struct {
|
|||||||
// YTTokenRef is the opaque SecretStore reference under which the YouTube
|
// YTTokenRef is the opaque SecretStore reference under which the YouTube
|
||||||
// refresh token is persisted/resolved. Not the token itself.
|
// refresh token is persisted/resolved. Not the token itself.
|
||||||
YTTokenRef string
|
YTTokenRef string
|
||||||
|
// YTConnectRedirectURL is the public callback URL the web connect flow
|
||||||
|
// registers with Google, e.g. "https://tapir.d-ma.be/oauth/youtube/callback".
|
||||||
|
// Must be in the OAuth client's authorized redirects. Distinct from the CLI
|
||||||
|
// auth command's localhost listener and from the Dex OIDC redirect.
|
||||||
|
YTConnectRedirectURL string
|
||||||
|
|
||||||
// SecretsFile is the path to the local file-backed SecretStore (0600). A
|
// SecretsFile is the path to the local file-backed SecretStore (0600). A
|
||||||
// Stage-0 stand-in for op/ESO, swappable behind the SecretStore port.
|
// Stage-0 stand-in for op/ESO, swappable behind the SecretStore port.
|
||||||
@@ -54,18 +59,23 @@ type Config struct {
|
|||||||
// PollInterval, when > 0, makes `run` loop on that cadence; 0 means run once.
|
// PollInterval, when > 0, makes `run` loop on that cadence; 0 means run once.
|
||||||
PollInterval time.Duration
|
PollInterval time.Duration
|
||||||
|
|
||||||
|
// FetchBackoff is how long the run loop waits before re-fetching a transcript
|
||||||
|
// that previously returned HTTP 429 (rate_limited). Inside the window the video
|
||||||
|
// is skipped without hitting the caption endpoint, saving requests; after it
|
||||||
|
// expires the video is retried. Zero means "always retry" (no backoff).
|
||||||
|
FetchBackoff time.Duration
|
||||||
|
|
||||||
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
|
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
|
||||||
HTTPAddr string
|
HTTPAddr string
|
||||||
|
|
||||||
// Dex OIDC (web login, ADR-011). When OIDCIssuer is empty, `serve` falls back
|
// Dex OIDC (web login, ADR-011/012). When OIDCIssuer is empty, `serve` falls
|
||||||
// to the allow-all StubAuth (local dev). When set, serve uses Dex with
|
// back to the allow-all StubAuth (local dev). When set, serve uses Dex: any
|
||||||
// single-user allowlist authz.
|
// Dex-authenticated subject may sign in, then registers a tapir user (ADR-012).
|
||||||
OIDCIssuer string
|
OIDCIssuer string
|
||||||
DexClientID string
|
DexClientID string
|
||||||
DexClientSecret string
|
DexClientSecret string
|
||||||
OIDCRedirectURL string
|
OIDCRedirectURL string
|
||||||
SessionSecret string
|
SessionSecret string
|
||||||
AllowedSubject string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DexConfigured reports whether Dex OIDC login is wired (issuer present). When
|
// DexConfigured reports whether Dex OIDC login is wired (issuer present). When
|
||||||
@@ -78,8 +88,10 @@ const (
|
|||||||
defaultSummarizerModel = "koala/phi4-mini"
|
defaultSummarizerModel = "koala/phi4-mini"
|
||||||
defaultSummarizerTimeout = 5 * time.Minute
|
defaultSummarizerTimeout = 5 * time.Minute
|
||||||
defaultYTTokenRef = "youtube/refresh_token"
|
defaultYTTokenRef = "youtube/refresh_token"
|
||||||
|
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
|
||||||
defaultOAuthRedirectAddr = "localhost:8080"
|
defaultOAuthRedirectAddr = "localhost:8080"
|
||||||
defaultHTTPAddr = ":8080"
|
defaultHTTPAddr = ":8080"
|
||||||
|
defaultFetchBackoff = time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load reads the environment into a Config, applying defaults. It does not
|
// Load reads the environment into a Config, applying defaults. It does not
|
||||||
@@ -96,6 +108,7 @@ func Load() (Config, error) {
|
|||||||
YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"),
|
YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"),
|
||||||
YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"),
|
YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"),
|
||||||
YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef),
|
YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef),
|
||||||
|
YTConnectRedirectURL: envOr("TAPIR_YT_CONNECT_REDIRECT_URL", defaultYTConnectRedirectURL),
|
||||||
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),
|
||||||
@@ -104,7 +117,6 @@ func Load() (Config, error) {
|
|||||||
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
|
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
|
||||||
OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"),
|
OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"),
|
||||||
SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"),
|
SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"),
|
||||||
AllowedSubject: os.Getenv("TAPIR_ALLOWED_SUBJECT"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout)
|
timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout)
|
||||||
@@ -119,6 +131,12 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
c.PollInterval = interval
|
c.PollInterval = interval
|
||||||
|
|
||||||
|
backoff, err := durationOr("TAPIR_FETCH_BACKOFF", defaultFetchBackoff)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
c.FetchBackoff = backoff
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+109
-3
@@ -23,10 +23,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// VideoStore is the durable persistence the run loop needs: assign a stable id +
|
// VideoStore is the durable persistence the run loop needs: assign a stable id +
|
||||||
// metadata, and read the already-summarized set. *store.Store satisfies it.
|
// metadata, read the already-summarized set, and (for manual summarization mode)
|
||||||
|
// read the user's mode + queued videos and clear a video's queue flag once it has
|
||||||
|
// been summarized. *store.Store satisfies it.
|
||||||
type VideoStore interface {
|
type VideoStore interface {
|
||||||
UpsertVideo(ctx context.Context, v domain.Video) (string, error)
|
UpsertVideo(ctx context.Context, v domain.Video) (string, error)
|
||||||
SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error)
|
SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error)
|
||||||
|
GetAutoSummarize(ctx context.Context, userID string) (bool, error)
|
||||||
|
RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error)
|
||||||
|
ClearSummarizeRequested(ctx context.Context, userID, videoID string) error
|
||||||
|
// RateLimitedVideoIDs maps the user's still-throttled videos to when they were
|
||||||
|
// rate-limited, so the loop can back off without re-hitting the caption endpoint.
|
||||||
|
RateLimitedVideoIDs(ctx context.Context, userID string) (map[string]time.Time, error)
|
||||||
|
// SetTranscriptStatus records the outcome of a transcript attempt: "none",
|
||||||
|
// "rate_limited" (stamps the backoff clock), or "fetched".
|
||||||
|
SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Processor runs the core use case for a single video. *usecase.Engine
|
// Processor runs the core use case for a single video. *usecase.Engine
|
||||||
@@ -43,14 +54,35 @@ type Runner struct {
|
|||||||
engine Processor
|
engine Processor
|
||||||
userID string
|
userID string
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
backoff time.Duration // rate-limit retry window; 0 = always retry
|
||||||
|
now func() time.Time // injectable clock (tests); defaults to time.Now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Option configures a Runner at construction. Variadic so existing call sites
|
||||||
|
// stay valid as new knobs (backoff, clock) are added.
|
||||||
|
type Option func(*Runner)
|
||||||
|
|
||||||
|
// WithBackoff sets the rate-limit retry window. A video that returned HTTP 429 is
|
||||||
|
// skipped (no caption fetch) until this much time has passed; 0 = always retry.
|
||||||
|
func WithBackoff(d time.Duration) Option { return func(r *Runner) { r.backoff = d } }
|
||||||
|
|
||||||
|
// WithClock overrides the clock used for backoff comparisons. Tests inject a
|
||||||
|
// fixed time; production leaves the time.Now default.
|
||||||
|
func WithClock(now func() time.Time) Option { return func(r *Runner) { r.now = now } }
|
||||||
|
|
||||||
// New builds a Runner. A nil logger falls back to slog.Default.
|
// New builds a Runner. A nil logger falls back to slog.Default.
|
||||||
func New(src ports.VideoSource, store VideoStore, engine Processor, userID string, log *slog.Logger) *Runner {
|
func New(src ports.VideoSource, store VideoStore, engine Processor, userID string, log *slog.Logger, opts ...Option) *Runner {
|
||||||
if log == nil {
|
if log == nil {
|
||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
}
|
}
|
||||||
return &Runner{src: src, store: store, engine: engine, userID: userID, log: log}
|
r := &Runner{src: src, store: store, engine: engine, userID: userID, log: log, now: time.Now}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(r)
|
||||||
|
}
|
||||||
|
if r.now == nil {
|
||||||
|
r.now = time.Now
|
||||||
|
}
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats summarizes one RunOnce pass.
|
// Stats summarizes one RunOnce pass.
|
||||||
@@ -59,6 +91,8 @@ type Stats struct {
|
|||||||
Summarized int
|
Summarized int
|
||||||
SkippedSeen int
|
SkippedSeen int
|
||||||
SkippedNoText int
|
SkippedNoText int
|
||||||
|
SkippedManual int // discovered but not queued, in manual mode
|
||||||
|
SkippedRateLimited int // 429'd previously and still inside the backoff window
|
||||||
Errors int
|
Errors int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +116,34 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
|||||||
return stats, fmt.Errorf("runner: load seen videos: %w", err)
|
return stats, fmt.Errorf("runner: load seen videos: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Summarization mode (per-user, ADR-012). Auto = summarize every unseen video
|
||||||
|
// (the original behavior). Manual = still discover/persist videos so the user
|
||||||
|
// sees them, but only summarize the ones explicitly queued via the web UI
|
||||||
|
// (summarize_requested). The queued set is loaded once per pass, like seen.
|
||||||
|
auto, err := r.store.GetAutoSummarize(ctx, r.userID)
|
||||||
|
if err != nil {
|
||||||
|
return stats, fmt.Errorf("runner: load summarize mode: %w", err)
|
||||||
|
}
|
||||||
|
var requested map[string]bool
|
||||||
|
if !auto {
|
||||||
|
requested, err = r.store.RequestedVideoIDs(ctx, r.userID)
|
||||||
|
if err != nil {
|
||||||
|
return stats, fmt.Errorf("runner: load requested videos: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limit backoff: videos that 429'd on a prior pass, mapped to when. Inside
|
||||||
|
// the backoff window they are skipped before any caption fetch, so a throttled
|
||||||
|
// IP is not hammered. Loaded once per pass (like seen/requested). Disabled when
|
||||||
|
// backoff <= 0 ("always retry").
|
||||||
|
var rateLimited map[string]time.Time
|
||||||
|
if r.backoff > 0 {
|
||||||
|
rateLimited, err = r.store.RateLimitedVideoIDs(ctx, r.userID)
|
||||||
|
if err != nil {
|
||||||
|
return stats, fmt.Errorf("runner: load rate-limited videos: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
subs, err := r.src.ListSubscriptions(ctx, r.userID)
|
subs, err := r.src.ListSubscriptions(ctx, r.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return stats, fmt.Errorf("runner: list subscriptions: %w", err)
|
return stats, fmt.Errorf("runner: list subscriptions: %w", err)
|
||||||
@@ -112,6 +174,23 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
|||||||
}
|
}
|
||||||
seen[id] = true // also guard against the same video within this pass
|
seen[id] = true // also guard against the same video within this pass
|
||||||
|
|
||||||
|
// Manual mode: skip summarization for videos the user has not queued.
|
||||||
|
// Discovery already happened (UpsertVideo above), so the new video is
|
||||||
|
// visible in the list; it just isn't summarized until requested.
|
||||||
|
if !auto && !requested[id] {
|
||||||
|
stats.SkippedManual++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still inside the rate-limit backoff window: skip without fetching, so
|
||||||
|
// we don't re-hit a caption endpoint that just 429'd us. After the window
|
||||||
|
// expires the video falls through and is retried normally.
|
||||||
|
if at, ok := rateLimited[id]; ok && r.now().Sub(at) < r.backoff {
|
||||||
|
stats.SkippedRateLimited++
|
||||||
|
r.log.Info("skipped video (rate-limited, backing off)", "video", v.ProviderVideoID, "title", v.Title)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if fetchDelay > 0 {
|
if fetchDelay > 0 {
|
||||||
time.Sleep(fetchDelay)
|
time.Sleep(fetchDelay)
|
||||||
}
|
}
|
||||||
@@ -122,11 +201,37 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
|
case res.Skipped && res.TranscriptSource == string(domain.SourceRateLimited):
|
||||||
|
// Fresh 429 this pass: persist rate_limited (stamps the backoff clock)
|
||||||
|
// so the next pass skips it until the window expires.
|
||||||
|
stats.SkippedRateLimited++
|
||||||
|
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "rate_limited"); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("set rate_limited status %q: %w", v.ProviderVideoID, err))
|
||||||
|
stats.Errors++
|
||||||
|
}
|
||||||
|
r.log.Info("skipped video (rate-limited)", "video", v.ProviderVideoID, "title", v.Title)
|
||||||
case res.Skipped:
|
case res.Skipped:
|
||||||
stats.SkippedNoText++
|
stats.SkippedNoText++
|
||||||
|
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "none"); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("set none status %q: %w", v.ProviderVideoID, err))
|
||||||
|
stats.Errors++
|
||||||
|
}
|
||||||
r.log.Info("skipped video (no transcript)", "video", v.ProviderVideoID, "title", v.Title)
|
r.log.Info("skipped video (no transcript)", "video", v.ProviderVideoID, "title", v.Title)
|
||||||
case res.Summary != nil:
|
case res.Summary != nil:
|
||||||
stats.Summarized++
|
stats.Summarized++
|
||||||
|
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "fetched"); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("set fetched status %q: %w", v.ProviderVideoID, err))
|
||||||
|
stats.Errors++
|
||||||
|
}
|
||||||
|
// In manual mode the video was processed because it was queued;
|
||||||
|
// clear the flag so it is not re-summarized and the UI drops the
|
||||||
|
// "Queued" chip. (Auto mode never sets the flag.)
|
||||||
|
if !auto {
|
||||||
|
if err := r.store.ClearSummarizeRequested(ctx, r.userID, id); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("clear summarize flag %q: %w", v.ProviderVideoID, err))
|
||||||
|
stats.Errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
r.log.Info("summarized video", "video", v.ProviderVideoID, "title", v.Title,
|
r.log.Info("summarized video", "video", v.ProviderVideoID, "title", v.Title,
|
||||||
"provider", res.Summary.AIProvider, "model", res.Summary.AIModel)
|
"provider", res.Summary.AIProvider, "model", res.Summary.AIModel)
|
||||||
}
|
}
|
||||||
@@ -145,6 +250,7 @@ func (r *Runner) Loop(ctx context.Context, interval time.Duration) error {
|
|||||||
r.log.Info("run pass complete",
|
r.log.Info("run pass complete",
|
||||||
"candidates", stats.Candidates, "summarized", stats.Summarized,
|
"candidates", stats.Candidates, "summarized", stats.Summarized,
|
||||||
"skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
|
"skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
|
||||||
|
"skipped_manual", stats.SkippedManual, "skipped_rate_limited", stats.SkippedRateLimited,
|
||||||
"errors", stats.Errors)
|
"errors", stats.Errors)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Warn("run pass had errors", "err", err)
|
r.log.Warn("run pass had errors", "err", err)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@@ -40,9 +41,16 @@ func (f *fakeSource) FetchTranscript(_ context.Context, v domain.Video) (domain.
|
|||||||
|
|
||||||
// fakeStore assigns deterministic ids ("id-"+provider video id) so a pre-seeded
|
// fakeStore assigns deterministic ids ("id-"+provider video id) so a pre-seeded
|
||||||
// seen set lines up with UpsertVideo output, modelling cross-restart dedup.
|
// seen set lines up with UpsertVideo output, modelling cross-restart dedup.
|
||||||
|
// auto controls the summarization mode; requested is the manual-mode queue keyed
|
||||||
|
// by store id; cleared records the ids whose queue flag the runner reset.
|
||||||
type fakeStore struct {
|
type fakeStore struct {
|
||||||
seen map[string]bool
|
seen map[string]bool
|
||||||
upserted []domain.Video
|
upserted []domain.Video
|
||||||
|
auto bool
|
||||||
|
requested map[string]bool
|
||||||
|
cleared []string
|
||||||
|
rateLimited map[string]time.Time // id -> when 429'd (seeds the backoff window)
|
||||||
|
statuses map[string]string // id -> last SetTranscriptStatus value
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) {
|
func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) {
|
||||||
@@ -58,6 +66,39 @@ func (f *fakeStore) SeenVideoIDs(_ context.Context, _ string) (map[string]bool,
|
|||||||
return cp, nil
|
return cp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetAutoSummarize(_ context.Context, _ string) (bool, error) {
|
||||||
|
return f.auto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) RequestedVideoIDs(_ context.Context, _ string) (map[string]bool, error) {
|
||||||
|
cp := make(map[string]bool, len(f.requested))
|
||||||
|
for k, v := range f.requested {
|
||||||
|
cp[k] = v
|
||||||
|
}
|
||||||
|
return cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ClearSummarizeRequested(_ context.Context, _, videoID string) error {
|
||||||
|
f.cleared = append(f.cleared, videoID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) RateLimitedVideoIDs(_ context.Context, _ string) (map[string]time.Time, error) {
|
||||||
|
cp := make(map[string]time.Time, len(f.rateLimited))
|
||||||
|
for k, v := range f.rateLimited {
|
||||||
|
cp[k] = v
|
||||||
|
}
|
||||||
|
return cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetTranscriptStatus(_ context.Context, _, videoID, status string) error {
|
||||||
|
if f.statuses == nil {
|
||||||
|
f.statuses = map[string]string{}
|
||||||
|
}
|
||||||
|
f.statuses[videoID] = status
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type fakeSummarizer struct{}
|
type fakeSummarizer struct{}
|
||||||
|
|
||||||
func (fakeSummarizer) Summarize(_ context.Context, v domain.Video, _ domain.Transcript) (domain.Summary, error) {
|
func (fakeSummarizer) Summarize(_ context.Context, v domain.Video, _ domain.Transcript) (domain.Summary, error) {
|
||||||
@@ -91,7 +132,7 @@ func TestRunOnce_SummarizesNewVideos(t *testing.T) {
|
|||||||
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
||||||
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
|
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
|
||||||
}
|
}
|
||||||
st := &fakeStore{seen: map[string]bool{}}
|
st := &fakeStore{seen: map[string]bool{}, auto: true}
|
||||||
sink := &recordingSink{}
|
sink := &recordingSink{}
|
||||||
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
||||||
r := runner.New(src, st, eng, testUser, quietLogger())
|
r := runner.New(src, st, eng, testUser, quietLogger())
|
||||||
@@ -114,7 +155,7 @@ func TestRunOnce_SkipsAlreadySummarized(t *testing.T) {
|
|||||||
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
|
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
|
||||||
}
|
}
|
||||||
// v1 was summarized in a prior run (durable seen set).
|
// v1 was summarized in a prior run (durable seen set).
|
||||||
st := &fakeStore{seen: map[string]bool{"id-v1": true}}
|
st := &fakeStore{seen: map[string]bool{"id-v1": true}, auto: true}
|
||||||
sink := &recordingSink{}
|
sink := &recordingSink{}
|
||||||
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
||||||
r := runner.New(src, st, eng, testUser, quietLogger())
|
r := runner.New(src, st, eng, testUser, quietLogger())
|
||||||
@@ -133,7 +174,7 @@ func TestRunOnce_SkipsVideosWithoutTranscript(t *testing.T) {
|
|||||||
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
|
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
|
||||||
transcripts: map[string]domain.Transcript{"v1": {Source: domain.SourceNone}},
|
transcripts: map[string]domain.Transcript{"v1": {Source: domain.SourceNone}},
|
||||||
}
|
}
|
||||||
st := &fakeStore{seen: map[string]bool{}}
|
st := &fakeStore{seen: map[string]bool{}, auto: true}
|
||||||
sink := &recordingSink{}
|
sink := &recordingSink{}
|
||||||
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
||||||
r := runner.New(src, st, eng, testUser, quietLogger())
|
r := runner.New(src, st, eng, testUser, quietLogger())
|
||||||
@@ -145,13 +186,109 @@ func TestRunOnce_SkipsVideosWithoutTranscript(t *testing.T) {
|
|||||||
require.Empty(t, sink.delivered, "no summary delivered when there is no transcript")
|
require.Empty(t, sink.delivered, "no summary delivered when there is no transcript")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunOnce_ManualMode_SkipsUnrequested(t *testing.T) {
|
||||||
|
src := &fakeSource{
|
||||||
|
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
||||||
|
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
|
||||||
|
}
|
||||||
|
// Manual mode, nothing queued: discover (upsert) but summarize nothing.
|
||||||
|
st := &fakeStore{seen: map[string]bool{}, auto: false, requested: map[string]bool{}}
|
||||||
|
sink := &recordingSink{}
|
||||||
|
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
||||||
|
r := runner.New(src, st, eng, testUser, quietLogger())
|
||||||
|
|
||||||
|
stats, err := r.RunOnce(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 2, stats.Candidates)
|
||||||
|
require.Equal(t, 2, stats.SkippedManual, "manual mode skips unqueued videos")
|
||||||
|
require.Equal(t, 0, stats.Summarized)
|
||||||
|
require.Empty(t, sink.delivered, "no summary in manual mode without a request")
|
||||||
|
require.Len(t, st.upserted, 2, "discovery still persists every candidate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunOnce_ManualMode_ProcessesRequested(t *testing.T) {
|
||||||
|
src := &fakeSource{
|
||||||
|
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
||||||
|
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
|
||||||
|
}
|
||||||
|
// Manual mode, v1 queued (by store id). Only v1 is summarized; its flag clears.
|
||||||
|
st := &fakeStore{seen: map[string]bool{}, auto: false, requested: map[string]bool{"id-v1": true}}
|
||||||
|
sink := &recordingSink{}
|
||||||
|
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
||||||
|
r := runner.New(src, st, eng, testUser, quietLogger())
|
||||||
|
|
||||||
|
stats, err := r.RunOnce(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, stats.Summarized, "only the queued video is summarized")
|
||||||
|
require.Equal(t, 1, stats.SkippedManual, "the unqueued video is skipped")
|
||||||
|
require.Len(t, sink.delivered, 1)
|
||||||
|
require.Equal(t, "id-v1", sink.delivered[0].VideoID)
|
||||||
|
require.Equal(t, []string{"id-v1"}, st.cleared, "the queue flag is cleared after summarizing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// noFetchSource fails the test if a transcript fetch happens — used to prove the
|
||||||
|
// runner skips a rate-limited video before touching the caption endpoint.
|
||||||
|
type noFetchSource struct{ *fakeSource }
|
||||||
|
|
||||||
|
func (noFetchSource) FetchTranscript(context.Context, domain.Video) (domain.Transcript, error) {
|
||||||
|
panic("FetchTranscript must not be called for a rate-limited video within the backoff window")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunOnce_SkipsRateLimitedWithinBackoff(t *testing.T) {
|
||||||
|
base := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
src := &fakeSource{
|
||||||
|
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
||||||
|
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
|
||||||
|
}
|
||||||
|
// v1 was rate-limited 5m ago; backoff is 1h, so it is still inside the window.
|
||||||
|
st := &fakeStore{
|
||||||
|
seen: map[string]bool{},
|
||||||
|
auto: true,
|
||||||
|
rateLimited: map[string]time.Time{"id-v1": base.Add(-5 * time.Minute)},
|
||||||
|
}
|
||||||
|
eng := usecase.NewEngine(noFetchSource{src}, fakeSummarizer{}, &recordingSink{})
|
||||||
|
r := runner.New(noFetchSource{src}, st, eng, testUser, quietLogger(),
|
||||||
|
runner.WithBackoff(time.Hour), runner.WithClock(func() time.Time { return base }))
|
||||||
|
|
||||||
|
stats, err := r.RunOnce(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, stats.SkippedRateLimited, "still throttled -> skipped")
|
||||||
|
require.Equal(t, 0, stats.Summarized)
|
||||||
|
require.Empty(t, st.statuses, "no status write: the engine was never invoked")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunOnce_RetriesRateLimitedAfterBackoff(t *testing.T) {
|
||||||
|
base := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
src := &fakeSource{
|
||||||
|
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
||||||
|
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
|
||||||
|
}
|
||||||
|
// v1 was rate-limited 2h ago; backoff is 1h, so the window has expired.
|
||||||
|
st := &fakeStore{
|
||||||
|
seen: map[string]bool{},
|
||||||
|
auto: true,
|
||||||
|
rateLimited: map[string]time.Time{"id-v1": base.Add(-2 * time.Hour)},
|
||||||
|
}
|
||||||
|
sink := &recordingSink{}
|
||||||
|
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
||||||
|
r := runner.New(src, st, eng, testUser, quietLogger(),
|
||||||
|
runner.WithBackoff(time.Hour), runner.WithClock(func() time.Time { return base }))
|
||||||
|
|
||||||
|
stats, err := r.RunOnce(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, stats.SkippedRateLimited, "window expired -> not skipped")
|
||||||
|
require.Equal(t, 1, stats.Summarized, "the video is retried and summarized")
|
||||||
|
require.Len(t, sink.delivered, 1)
|
||||||
|
require.Equal(t, "fetched", st.statuses["id-v1"], "status advances to fetched on success")
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunOnce_UpsertsEveryCandidate(t *testing.T) {
|
func TestRunOnce_UpsertsEveryCandidate(t *testing.T) {
|
||||||
src := &fakeSource{
|
src := &fakeSource{
|
||||||
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
||||||
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
|
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
|
||||||
}
|
}
|
||||||
// Even an already-seen video gets upserted so its metadata stays fresh.
|
// Even an already-seen video gets upserted so its metadata stays fresh.
|
||||||
st := &fakeStore{seen: map[string]bool{"id-v1": true}}
|
st := &fakeStore{seen: map[string]bool{"id-v1": true}, auto: true}
|
||||||
eng := usecase.NewEngine(src, fakeSummarizer{}, &recordingSink{})
|
eng := usecase.NewEngine(src, fakeSummarizer{}, &recordingSink{})
|
||||||
r := runner.New(src, st, eng, testUser, quietLogger())
|
r := runner.New(src, st, eng, testUser, quietLogger())
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ type ProcessResult struct {
|
|||||||
Video domain.Video
|
Video domain.Video
|
||||||
Skipped bool
|
Skipped bool
|
||||||
Reason string // set when Skipped (e.g. "no transcript")
|
Reason string // set when Skipped (e.g. "no transcript")
|
||||||
|
// TranscriptSource is how the transcript resolved (or that there was none):
|
||||||
|
// the domain.TranscriptSource value as a string. The runner reads it to tell a
|
||||||
|
// permanent absence (SourceNone) from a transient 429 (SourceRateLimited) and
|
||||||
|
// persist the right transcript_status. Empty when a fetch error short-circuits.
|
||||||
|
TranscriptSource string
|
||||||
Summary *domain.Summary // nil when Skipped
|
Summary *domain.Summary // nil when Skipped
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +64,9 @@ func (e *Engine) ProcessNewVideo(ctx context.Context, v domain.Video) (ProcessRe
|
|||||||
if !t.HasText() {
|
if !t.HasText() {
|
||||||
// No usable transcript: record the skip, produce no summary, deliver nothing
|
// No usable transcript: record the skip, produce no summary, deliver nothing
|
||||||
// (captions-first, ADR-007; the watcher uses this to avoid reprocessing).
|
// (captions-first, ADR-007; the watcher uses this to avoid reprocessing).
|
||||||
return ProcessResult{Video: v, Skipped: true, Reason: "no transcript"}, nil
|
// Surface the source so the runner separates SourceNone (permanent) from
|
||||||
|
// SourceRateLimited (retry after a backoff window).
|
||||||
|
return ProcessResult{Video: v, Skipped: true, Reason: "no transcript", TranscriptSource: string(t.Source)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
sum, err := e.AI.Summarize(ctx, v, t)
|
sum, err := e.AI.Summarize(ctx, v, t)
|
||||||
@@ -76,7 +83,7 @@ func (e *Engine) ProcessNewVideo(ctx context.Context, v domain.Video) (ProcessRe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProcessResult{Video: v, Summary: &sum}, errors.Join(errs...)
|
return ProcessResult{Video: v, Summary: &sum, TranscriptSource: string(t.Source)}, errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessNewVideos walks a user's subscriptions and processes each newly seen
|
// ProcessNewVideos walks a user's subscriptions and processes each newly seen
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleAccount renders the account page: the user's display name, the
|
||||||
|
// authenticated email, their connected video accounts (with a Connect link when
|
||||||
|
// YouTube is not connected), and the disconnect / delete-account controls.
|
||||||
|
func (a *App) handleAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "list connections", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, err := a.Store.DisplayName(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "display name", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var email string
|
||||||
|
if u, ok := a.Auth.CurrentUser(r); ok {
|
||||||
|
email = u.Email
|
||||||
|
}
|
||||||
|
auto, err := a.Store.GetAutoSummarize(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "summarize mode", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, AccountPage(name, email, conns, auto, takeFlash(w, r)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDisconnect removes a provider connection: it deletes the OAuth token from
|
||||||
|
// the SecretStore (resolved from the connection's own token_ref) and the
|
||||||
|
// connection row. It does NOT delete the account. Redirects back to /account with
|
||||||
|
// a flash. Disconnecting an absent provider is a no-op (idempotent).
|
||||||
|
func (a *App) handleDisconnect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
provider := r.PathValue("provider")
|
||||||
|
if provider == "" {
|
||||||
|
http.Error(w, "missing provider", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "list connections", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Remove the token before the row, using the connection's own ref so this is
|
||||||
|
// provider-agnostic. A SecretStore failure is logged, not fatal — the row
|
||||||
|
// removal below still revokes access from Tapir's side.
|
||||||
|
if ref := tokenRefFor(conns, provider); ref != "" && a.Secrets != nil {
|
||||||
|
if err := a.Secrets.Delete(ref); err != nil {
|
||||||
|
a.logger().Error("disconnect: delete token", "provider", provider, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.Store.DeleteConnection(r.Context(), userID, provider); err != nil {
|
||||||
|
a.serverError(w, r, "delete connection", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlash(w, flashDisconnected)
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteAccount permanently deletes the user: it removes all tapir data
|
||||||
|
// (DeleteUser cascades the rows) and every one of the user's secrets, then logs
|
||||||
|
// the user out. Tapir-side only (decision 2026-06-03) — the Dex identity is left
|
||||||
|
// untouched, so a later login simply re-enters registration.
|
||||||
|
func (a *App) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the secret refs BEFORE the rows are deleted — DeleteUser cascades
|
||||||
|
// the video_connections away.
|
||||||
|
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "list connections", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Store.DeleteUser(r.Context(), userID); err != nil {
|
||||||
|
a.serverError(w, r, "delete user", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Best-effort secret cleanup: the account is already gone, so a SecretStore
|
||||||
|
// failure is logged, never resurrects the account.
|
||||||
|
if a.Secrets != nil {
|
||||||
|
for _, c := range conns {
|
||||||
|
if c.TokenRef == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := a.Secrets.Delete(c.TokenRef); err != nil {
|
||||||
|
a.logger().Error("delete account: delete token", "ref", c.TokenRef, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the session by routing through the auth logout endpoint, then land on
|
||||||
|
// the list page with a flash (StubAuth logout is a no-op; Dex clears the
|
||||||
|
// session cookie and redirects to login).
|
||||||
|
setFlash(w, flashDeleted)
|
||||||
|
http.Redirect(w, r, "/auth/logout", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenRefFor returns the SecretStore ref for the user's connection to provider,
|
||||||
|
// or "" if there is none.
|
||||||
|
func tokenRefFor(conns []store.Connection, provider string) string {
|
||||||
|
for _, c := range conns {
|
||||||
|
if c.Provider == provider {
|
||||||
|
return c.TokenRef
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeSecrets is a SecretRemover that records the refs it was asked to delete, so
|
||||||
|
// account tests can assert the OAuth token cleanup without a real secret file.
|
||||||
|
type fakeSecrets struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
deleted []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSecrets) Delete(ref string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.deleted = append(f.deleted, ref)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSecrets) deletedRefs() []string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]string(nil), f.deleted...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAccountApp builds the App as the registered stub user with a recording fake
|
||||||
|
// SecretStore, so disconnect/delete can be exercised end-to-end.
|
||||||
|
func newAccountApp(t *testing.T) (*web.App, *fakeSecrets) {
|
||||||
|
t.Helper()
|
||||||
|
s := newStore(t)
|
||||||
|
fs := &fakeSecrets{}
|
||||||
|
return &web.App{
|
||||||
|
Store: s,
|
||||||
|
Identity: s,
|
||||||
|
Auth: web.StubAuth{U: web.User{Subject: stubSubject, Email: "ada@example.com"}},
|
||||||
|
Secrets: fs,
|
||||||
|
}, fs
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedConnection(t *testing.T, app *web.App, provider, account, ref string) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, app.Store.(*store.Store).UpsertConnection(context.Background(), userID, store.Connection{
|
||||||
|
Provider: provider,
|
||||||
|
ProviderAccount: account,
|
||||||
|
TokenRef: ref,
|
||||||
|
Status: "active",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountPageShowsConnectionAndName(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app, _ := newAccountApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
_, err := p.Exec(ctx, `UPDATE users SET display_name = $1 WHERE id = $2`, "Ada", userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
seedConnection(t, app, "youtube", "ada@channel", web.YouTubeTokenRef(userID))
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
|
||||||
|
require.Contains(t, html, "Ada", "display name shown")
|
||||||
|
require.Contains(t, html, "ada@example.com", "signed-in email shown")
|
||||||
|
require.Contains(t, html, "ada@channel", "connected account shown")
|
||||||
|
require.Contains(t, html, "YouTube")
|
||||||
|
require.Contains(t, html, "/account/disconnect/youtube", "a Disconnect control is present")
|
||||||
|
require.NotContains(t, html, "/oauth/youtube/connect", "no Connect link while already connected")
|
||||||
|
// Confirm-before-destroy: the delete is behind a disclosure, not a bare button.
|
||||||
|
require.Contains(t, html, "/account/delete")
|
||||||
|
require.Contains(t, html, "<details", "delete is gated behind a confirm step")
|
||||||
|
require.Contains(t, html, "cannot be undone")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountPageShowsConnectLinkWhenNotConnected(t *testing.T) {
|
||||||
|
app, _ := newAccountApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
|
||||||
|
require.Contains(t, html, "/oauth/youtube/connect", "Connect link offered when not connected")
|
||||||
|
require.Contains(t, html, "Connect YouTube")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisconnectRemovesTokenAndConnectionKeepsAccount(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app, fs := newAccountApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
ref := web.YouTubeTokenRef(userID)
|
||||||
|
seedConnection(t, app, "youtube", "ada@channel", ref)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/account/disconnect/youtube", nil)
|
||||||
|
rec := do(t, app, req)
|
||||||
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||||
|
require.Equal(t, "/account", rec.Header().Get("Location"))
|
||||||
|
|
||||||
|
// Token purged from the SecretStore.
|
||||||
|
require.Contains(t, fs.deletedRefs(), ref, "the per-user YouTube token must be deleted")
|
||||||
|
|
||||||
|
// Connection row gone...
|
||||||
|
conns, err := app.Store.(*store.Store).ConnectionsForUser(ctx, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, conns, "the connection row must be removed")
|
||||||
|
|
||||||
|
// ...but the account itself survives (disconnect is not delete).
|
||||||
|
name, err := app.Store.(*store.Store).DisplayName(ctx, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_ = name
|
||||||
|
var users int
|
||||||
|
require.NoError(t, rawPool(t).QueryRow(ctx, `SELECT count(*) FROM users WHERE id = $1`, userID).Scan(&users))
|
||||||
|
require.Equal(t, 1, users, "disconnect must not delete the account")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAccountWipesDataAndSecretsAndLogsOut(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app, fs := newAccountApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
ref := web.YouTubeTokenRef(userID)
|
||||||
|
seedConnection(t, app, "youtube", "ada@channel", ref)
|
||||||
|
require.NoError(t, deliver(ctx, app, videoX, "a summary")) // some user data to wipe
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodPost, "/account/delete", nil))
|
||||||
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||||
|
require.Equal(t, "/auth/logout", rec.Header().Get("Location"), "delete logs the user out")
|
||||||
|
|
||||||
|
// The user's secrets were removed (the per-user YouTube token).
|
||||||
|
require.Contains(t, fs.deletedRefs(), ref, "delete must purge the user's OAuth tokens")
|
||||||
|
|
||||||
|
// The account and its data are gone.
|
||||||
|
for _, q := range []string{
|
||||||
|
`SELECT count(*) FROM users WHERE id = $1`,
|
||||||
|
`SELECT count(*) FROM summaries WHERE user_id = $1`,
|
||||||
|
`SELECT count(*) FROM video_connections WHERE user_id = $1`,
|
||||||
|
`SELECT count(*) FROM user_identities WHERE user_id = $1`,
|
||||||
|
} {
|
||||||
|
var n int
|
||||||
|
require.NoError(t, p.QueryRow(ctx, q, userID).Scan(&n))
|
||||||
|
require.Equal(t, 0, n, "delete must remove all rows: %s", q)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Package web is the Stage-0 HTTP read/write surface (ADR-011, docs/ui-spec.md).
|
// Package web is the multi-user HTTP read/write surface (ADR-012, docs/ui-spec.md).
|
||||||
// It serves the summary reader over the existing store; the engine and ports are
|
// It serves the summary reader over the existing store; the engine and ports are
|
||||||
// untouched (ADR-003).
|
// untouched (ADR-003). ADR-011 shipped this as a single-user Stage-0 reader; ADR-012
|
||||||
|
// opened Stage 1 — multiple Dex-authenticated users with DB-enforced (RLS) isolation.
|
||||||
//
|
//
|
||||||
// This file defines the auth SEAM so the Dex session layer (internal/web/oidc)
|
// This file defines the auth SEAM so the Dex session layer (internal/web/oidc)
|
||||||
// and the page/handler layer can be built independently: handlers depend only on
|
// and the page/handler layer can be built independently: handlers depend only on
|
||||||
@@ -10,9 +11,10 @@ package web
|
|||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
// User is the authenticated principal. Subject is the Dex subject used for the
|
// User is the authenticated principal. Subject is the Dex subject — the key for the
|
||||||
// single-user allowlist (ADR-011); store operations key off the configured
|
// user_identities lookup (ADR-012) that resolves to a tapir user_id (UUID); store
|
||||||
// tapir user_id (UUID), not this subject.
|
// operations scope every row by that id, not by this subject. A subject with no
|
||||||
|
// users row is routed through the registration gate (see registration.go).
|
||||||
type User struct {
|
type User struct {
|
||||||
Subject string
|
Subject string
|
||||||
Email string
|
Email string
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Connections is the narrow write port the connect flow depends on (Clean
|
||||||
|
// Architecture: the handler depends on this interface, not the concrete store).
|
||||||
|
// *store.Store satisfies it; tests substitute a fake.
|
||||||
|
type Connections interface {
|
||||||
|
UpsertConnection(ctx context.Context, userID string, c store.Connection) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectStateTTL bounds how long a generated CSRF state is valid between the
|
||||||
|
// connect redirect and the provider callback.
|
||||||
|
const connectStateTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// ConnectHandler runs the web-initiated YouTube OAuth connect flow. It is mounted
|
||||||
|
// INSIDE the login + registration guard (Router), so CurrentUserID is always set
|
||||||
|
// — every connection is bound to the authenticated tapir user. It reuses
|
||||||
|
// auth.AuthCodeURL / auth.Exchange (ADR-006: the web flow, not the CLI listener).
|
||||||
|
//
|
||||||
|
// The minted refresh token is persisted under a PER-USER SecretStore ref
|
||||||
|
// (YouTubeTokenRef) so tenants never share or overwrite each other's token.
|
||||||
|
type ConnectHandler struct {
|
||||||
|
// OAuth carries the registered client id/secret, the callback RedirectURL,
|
||||||
|
// and (in tests) the Endpoint override. TokenRef is set per-user per request,
|
||||||
|
// not here.
|
||||||
|
OAuth auth.Config
|
||||||
|
Secrets auth.TokenWriter // persists the refresh token (secrets.FileStore)
|
||||||
|
Conns Connections
|
||||||
|
Log *slog.Logger
|
||||||
|
|
||||||
|
states *connectStateStore
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConnectHandler wires the connect flow. now defaults to time.Now; the CSRF
|
||||||
|
// state store is in-memory (single-instance Stage 1).
|
||||||
|
func NewConnectHandler(oauth auth.Config, secrets auth.TokenWriter, conns Connections, log *slog.Logger) *ConnectHandler {
|
||||||
|
return &ConnectHandler{
|
||||||
|
OAuth: oauth,
|
||||||
|
Secrets: secrets,
|
||||||
|
Conns: conns,
|
||||||
|
Log: log,
|
||||||
|
states: newConnectStateStore(),
|
||||||
|
now: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTubeTokenRef is the per-user SecretStore reference under which a user's
|
||||||
|
// YouTube OAuth refresh token is persisted: "youtube/<userID>/refresh_token".
|
||||||
|
// Per-user (not the Stage-0 single "youtube/refresh_token") so connections never
|
||||||
|
// collide across tenants.
|
||||||
|
func YouTubeTokenRef(userID string) string {
|
||||||
|
return "youtube/" + userID + "/refresh_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConnect generates a per-user CSRF state, stores it bound to the user with
|
||||||
|
// a short TTL, and redirects to Google's consent screen (offline + prompt=consent
|
||||||
|
// so a refresh token comes back).
|
||||||
|
func (h *ConnectHandler) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := CurrentUserID(r)
|
||||||
|
if !ok {
|
||||||
|
h.serverError(w, r, "current user", errNoCurrentUser)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state, err := randomState()
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "generate state", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.states.put(state, userID, h.now().Add(connectStateTTL))
|
||||||
|
http.Redirect(w, r, auth.AuthCodeURL(h.OAuth, state), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCallback verifies the CSRF state (present, unexpired, bound to THIS user),
|
||||||
|
// exchanges the code for a refresh token under the per-user ref, and records the
|
||||||
|
// connection. Any failure renders a clean error page and leaves no half-written
|
||||||
|
// state (Exchange persists nothing without a refresh token; the connection row is
|
||||||
|
// only written after a successful exchange).
|
||||||
|
func (h *ConnectHandler) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := CurrentUserID(r)
|
||||||
|
if !ok {
|
||||||
|
h.serverError(w, r, "current user", errNoCurrentUser)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
if e := q.Get("error"); e != "" {
|
||||||
|
h.failure(w, http.StatusBadRequest, "Authorization was declined.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
boundUser, ok := h.states.take(q.Get("state"), h.now())
|
||||||
|
if !ok || boundUser != userID {
|
||||||
|
// Missing, unknown, expired, or another user's state — reject as CSRF.
|
||||||
|
h.failure(w, http.StatusBadRequest, "Invalid or expired authorization state. Please try connecting again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := q.Get("code")
|
||||||
|
if code == "" {
|
||||||
|
h.failure(w, http.StatusBadRequest, "Authorization returned no code.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthCfg := h.OAuth
|
||||||
|
oauthCfg.TokenRef = YouTubeTokenRef(userID)
|
||||||
|
if err := auth.Exchange(r.Context(), oauthCfg, h.Secrets, code); err != nil {
|
||||||
|
h.logger().Error("connect: exchange code", "err", err)
|
||||||
|
h.failure(w, http.StatusBadGateway, "Could not complete authorization with YouTube. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.Conns.UpsertConnection(r.Context(), userID, store.Connection{
|
||||||
|
Provider: "youtube",
|
||||||
|
TokenRef: oauthCfg.TokenRef,
|
||||||
|
Status: "active",
|
||||||
|
}); err != nil {
|
||||||
|
h.logger().Error("connect: upsert connection", "err", err)
|
||||||
|
h.failure(w, http.StatusInternalServerError, "Authorized, but could not save the connection. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlash(w, flashConnected)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConnectHandler) logger() *slog.Logger {
|
||||||
|
if h.Log != nil {
|
||||||
|
return h.Log
|
||||||
|
}
|
||||||
|
return slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConnectHandler) serverError(w http.ResponseWriter, r *http.Request, op string, err error) {
|
||||||
|
h.logger().Error("connect handler error", "op", op, "path", r.URL.Path, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failure renders a minimal, self-contained error page with a link back. No
|
||||||
|
// templ dependency so it can render even if a connection is half-set-up upstream.
|
||||||
|
func (h *ConnectHandler) failure(w http.ResponseWriter, status int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = io.WriteString(w, `<!doctype html><html lang="en"><head><meta charset="utf-8">`+
|
||||||
|
`<title>Connection failed</title></head><body>`+
|
||||||
|
`<h1>Could not connect your YouTube account</h1>`+
|
||||||
|
`<p>`+template.HTMLEscapeString(msg)+`</p>`+
|
||||||
|
`<p><a href="/">Back to Tapir</a></p></body></html>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomState returns a 128-bit hex CSRF token.
|
||||||
|
func randomState() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("web: generate state: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectStateEntry binds a CSRF state to the user who initiated the connect and
|
||||||
|
// when it expires.
|
||||||
|
type connectStateEntry struct {
|
||||||
|
userID string
|
||||||
|
expiry time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectStateStore maps a CSRF state to its bound user between the connect
|
||||||
|
// redirect and the callback. Entries are one-time (take deletes) and short-lived,
|
||||||
|
// defeating replay and CSRF on the callback.
|
||||||
|
type connectStateStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]connectStateEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConnectStateStore() *connectStateStore {
|
||||||
|
return &connectStateStore{m: make(map[string]connectStateEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *connectStateStore) put(state, userID string, expiry time.Time) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.m[state] = connectStateEntry{userID: userID, expiry: expiry}
|
||||||
|
}
|
||||||
|
|
||||||
|
// take consumes the user bound to state, returning ok=false if state is empty,
|
||||||
|
// unknown, or expired.
|
||||||
|
func (s *connectStateStore) take(state string, now time.Time) (string, bool) {
|
||||||
|
if state == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
e, ok := s.m[state]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
delete(s.m, state)
|
||||||
|
if !now.Before(e.expiry) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return e.userID, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
|
||||||
|
type fakeWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ref, val string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *fakeWriter) Put(ref, value string) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
w.ref, w.val, w.calls = ref, value, w.calls+1
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeConns captures UpsertConnection calls without a database.
|
||||||
|
type fakeConns struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls int
|
||||||
|
userID string
|
||||||
|
conn store.Connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeConns) UpsertConnection(_ context.Context, userID string, conn store.Connection) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.calls, c.userID, c.conn = c.calls+1, userID, conn
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenServer fakes Google's token endpoint, returning body for any POST.
|
||||||
|
func tokenServer(t *testing.T, body string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// newConnectApp builds a registered-stub-user App with a wired ConnectHandler.
|
||||||
|
// The OAuth endpoint points at srvURL so Exchange never contacts live Google.
|
||||||
|
func newConnectApp(t *testing.T, srvURL string, secrets auth.TokenWriter, conns web.Connections) *web.App {
|
||||||
|
t.Helper()
|
||||||
|
s := newStore(t) // applies migrations
|
||||||
|
resetDB(t, rawPool(t)) // seeds stubSubject -> userID so the gate resolves a user
|
||||||
|
connect := web.NewConnectHandler(auth.Config{
|
||||||
|
ClientID: "cid",
|
||||||
|
ClientSecret: "csecret",
|
||||||
|
RedirectURL: "https://tapir.d-ma.be/oauth/youtube/callback",
|
||||||
|
Endpoint: oauth2.Endpoint{AuthURL: srvURL + "/auth", TokenURL: srvURL + "/token"},
|
||||||
|
}, secrets, conns, nil)
|
||||||
|
return &web.App{
|
||||||
|
Store: s,
|
||||||
|
Identity: s,
|
||||||
|
Auth: web.StubAuth{U: web.User{Subject: stubSubject}},
|
||||||
|
Connect: connect,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectState drives GET /oauth/youtube/connect and returns the CSRF state from
|
||||||
|
// the consent redirect, so the callback test can present a valid state.
|
||||||
|
func connectState(t *testing.T, app *web.App) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/oauth/youtube/connect", nil))
|
||||||
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
loc := rec.Header().Get("Location")
|
||||||
|
u, err := url.Parse(loc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
q := u.Query()
|
||||||
|
require.Equal(t, "offline", q.Get("access_type"), "must request offline access for a refresh token")
|
||||||
|
require.Equal(t, "consent", q.Get("prompt"), "must force consent for a refresh token")
|
||||||
|
state := q.Get("state")
|
||||||
|
require.NotEmpty(t, state, "consent URL must carry a CSRF state")
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectRedirectsToConsent(t *testing.T) {
|
||||||
|
srv := tokenServer(t, `{}`)
|
||||||
|
app := newConnectApp(t, srv.URL, &fakeWriter{}, &fakeConns{})
|
||||||
|
_ = connectState(t, app) // assertions live in the helper
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallbackExchangesAndRecordsConnection(t *testing.T) {
|
||||||
|
srv := tokenServer(t,
|
||||||
|
`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
|
||||||
|
w := &fakeWriter{}
|
||||||
|
conns := &fakeConns{}
|
||||||
|
app := newConnectApp(t, srv.URL, w, conns)
|
||||||
|
|
||||||
|
state := connectState(t, app)
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet,
|
||||||
|
"/oauth/youtube/callback?state="+state+"&code=the-code", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||||
|
require.Equal(t, "/", rec.Header().Get("Location"))
|
||||||
|
|
||||||
|
// Token persisted under the per-user ref.
|
||||||
|
wantRef := web.YouTubeTokenRef(userID)
|
||||||
|
require.Equal(t, wantRef, w.ref, "refresh token stored under the per-user ref")
|
||||||
|
require.Equal(t, "rt-secret", w.val)
|
||||||
|
|
||||||
|
// Connection recorded for the authenticated user.
|
||||||
|
require.Equal(t, 1, conns.calls)
|
||||||
|
require.Equal(t, userID, conns.userID)
|
||||||
|
require.Equal(t, "youtube", conns.conn.Provider)
|
||||||
|
require.Equal(t, "active", conns.conn.Status)
|
||||||
|
require.Equal(t, wantRef, conns.conn.TokenRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallbackRejectsMissingState(t *testing.T) {
|
||||||
|
srv := tokenServer(t,
|
||||||
|
`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
||||||
|
w := &fakeWriter{}
|
||||||
|
conns := &fakeConns{}
|
||||||
|
app := newConnectApp(t, srv.URL, w, conns)
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet,
|
||||||
|
"/oauth/youtube/callback?code=the-code", nil)) // no state
|
||||||
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
require.Equal(t, 0, w.calls, "nothing persisted on missing state")
|
||||||
|
require.Equal(t, 0, conns.calls, "no connection recorded on missing state")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallbackRejectsUnknownState(t *testing.T) {
|
||||||
|
srv := tokenServer(t,
|
||||||
|
`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
||||||
|
w := &fakeWriter{}
|
||||||
|
conns := &fakeConns{}
|
||||||
|
app := newConnectApp(t, srv.URL, w, conns)
|
||||||
|
|
||||||
|
// A state never issued by connect must be rejected (CSRF).
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet,
|
||||||
|
"/oauth/youtube/callback?state=deadbeef&code=the-code", nil))
|
||||||
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
require.Equal(t, 0, w.calls)
|
||||||
|
require.Equal(t, 0, conns.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallbackStateIsSingleUse(t *testing.T) {
|
||||||
|
srv := tokenServer(t,
|
||||||
|
`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
|
||||||
|
w := &fakeWriter{}
|
||||||
|
conns := &fakeConns{}
|
||||||
|
app := newConnectApp(t, srv.URL, w, conns)
|
||||||
|
|
||||||
|
state := connectState(t, app)
|
||||||
|
url := "/oauth/youtube/callback?state=" + state + "&code=the-code"
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, url, nil))
|
||||||
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||||
|
|
||||||
|
// Replaying the same state must fail — it was consumed.
|
||||||
|
rec = do(t, app, httptest.NewRequest(http.MethodGet, url, nil))
|
||||||
|
require.Equal(t, http.StatusBadRequest, rec.Code, "state is single-use")
|
||||||
|
require.Equal(t, 1, conns.calls, "replay must not record a second connection")
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// flashCookie carries a one-shot notification code between a POST→redirect and
|
||||||
|
// the next rendered page (PRG pattern). The value is a non-sensitive code (not
|
||||||
|
// user data), so it is not signed; HttpOnly + SameSite=Lax + a short MaxAge bound
|
||||||
|
// it. The flashBanner component maps the code to a styled message.
|
||||||
|
const flashCookie = "tapir_flash"
|
||||||
|
|
||||||
|
// Flash codes. Kept small and stable — the message + severity live in
|
||||||
|
// flashMessages (view.go), not here, so the cookie never carries free text.
|
||||||
|
const (
|
||||||
|
flashConnected = "connected"
|
||||||
|
flashConnectFailed = "connect_failed"
|
||||||
|
flashDisconnected = "disconnected"
|
||||||
|
flashDeleted = "deleted"
|
||||||
|
flashRegistered = "registered"
|
||||||
|
)
|
||||||
|
|
||||||
|
// flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to
|
||||||
|
// survive the redirect, short enough that a stale banner never reappears.
|
||||||
|
const flashMaxAge = 60
|
||||||
|
|
||||||
|
// setFlash queues a one-shot notification surfaced by the next full page render.
|
||||||
|
func setFlash(w http.ResponseWriter, code string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: flashCookie,
|
||||||
|
Value: code,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: flashMaxAge,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeFlash returns the pending flash code (if any) and clears the cookie so the
|
||||||
|
// banner shows exactly once. Call it only on full-page renders, not HTMX
|
||||||
|
// fragments, so a fragment swap never consumes a flash meant for the next page.
|
||||||
|
func takeFlash(w http.ResponseWriter, r *http.Request) string {
|
||||||
|
c, err := r.Cookie(flashCookie)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: flashCookie,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFlashBannerRendersEachKind proves the reusable notification component
|
||||||
|
// renders a banner with the right message and severity class for every flash
|
||||||
|
// code, and renders nothing for an empty or unknown (e.g. forged) code.
|
||||||
|
func TestFlashBannerRendersEachKind(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
code string
|
||||||
|
wantText string
|
||||||
|
wantKind string
|
||||||
|
}{
|
||||||
|
{flashConnected, "YouTube account connected", "flash-success"},
|
||||||
|
{flashConnectFailed, "Could not connect", "flash-error"},
|
||||||
|
{flashDisconnected, "Account disconnected", "flash-success"},
|
||||||
|
{flashDeleted, "account and all its data were deleted", "flash-success"},
|
||||||
|
{flashRegistered, "Welcome to Tapir", "flash-success"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.code, func(t *testing.T) {
|
||||||
|
var sb strings.Builder
|
||||||
|
if err := flashBanner(tc.code).Render(context.Background(), &sb); err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
got := sb.String()
|
||||||
|
if !strings.Contains(got, tc.wantText) {
|
||||||
|
t.Errorf("banner %q = %q, want it to contain %q", tc.code, got, tc.wantText)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, tc.wantKind) {
|
||||||
|
t.Errorf("banner %q = %q, want severity class %q", tc.code, got, tc.wantKind)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `role="status"`) {
|
||||||
|
t.Errorf("banner %q must carry role=status for assistive tech, got %q", tc.code, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlashBannerRendersNothingForUnknownCode(t *testing.T) {
|
||||||
|
for _, code := range []string{"", "bogus", "<script>"} {
|
||||||
|
var sb strings.Builder
|
||||||
|
if err := flashBanner(code).Render(context.Background(), &sb); err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(sb.String()); got != "" {
|
||||||
|
t.Errorf("flashBanner(%q) = %q, want empty (no banner)", code, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+235
-17
@@ -16,22 +16,58 @@ import (
|
|||||||
// the concrete *store.Store). *store.Store satisfies it; tests can substitute a
|
// the concrete *store.Store). *store.Store satisfies it; tests can substitute a
|
||||||
// fake without a database.
|
// fake without a database.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
ListSummaries(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
|
ListVideos(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
|
||||||
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
||||||
|
GetVideoRow(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
||||||
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
|
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
|
||||||
SetAction(ctx context.Context, userID, videoID, action string) error
|
SetAction(ctx context.Context, userID, videoID, action string) error
|
||||||
ClearAction(ctx context.Context, userID, videoID, action string) error
|
ClearAction(ctx context.Context, userID, videoID, action string) error
|
||||||
|
|
||||||
|
// Summarization mode: the per-user auto/manual toggle and the per-video
|
||||||
|
// manual queue (the "Summarize" button). The runner consumes the queue.
|
||||||
|
GetAutoSummarize(ctx context.Context, userID string) (bool, error)
|
||||||
|
SetAutoSummarize(ctx context.Context, userID string, enabled bool) error
|
||||||
|
RequestSummarize(ctx context.Context, userID, videoID string) error
|
||||||
|
|
||||||
|
// Account management (the /account page, disconnect, delete-account).
|
||||||
|
ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
|
||||||
|
DeleteConnection(ctx context.Context, userID, provider string) error
|
||||||
|
DeleteUser(ctx context.Context, userID string) error
|
||||||
|
DisplayName(ctx context.Context, userID string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// App is the Stage-0 web surface: handlers over the store, gated by an Auth
|
// SecretRemover deletes secret material by its opaque ref. *secrets.FileStore
|
||||||
// implementation. UserID is the single configured tapir user every store
|
// satisfies it; account tests use a fake. The account handlers depend only on
|
||||||
// operation runs as (ADR-011 — Auth only gates access; it does not select the
|
// this narrow capability (not the read-side ports.SecretStore), mirroring how the
|
||||||
// store identity).
|
// connect flow depends on auth.TokenWriter for the write side.
|
||||||
|
type SecretRemover interface {
|
||||||
|
Delete(ref string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// App is the Stage-1 web surface: handlers over the store, gated by an Auth
|
||||||
|
// implementation (authentication) and a registration gate (which resolves the
|
||||||
|
// authenticated subject to its tapir user_id and stashes it per request). Every
|
||||||
|
// data handler scopes by that resolved id — CurrentUserID(r) — not by a single
|
||||||
|
// configured user (ADR-012, multi-user with enforced isolation).
|
||||||
type App struct {
|
type App struct {
|
||||||
Store Store
|
Store Store
|
||||||
|
Identity Identity
|
||||||
Auth Auth
|
Auth Auth
|
||||||
UserID string
|
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
|
// Connect runs the web-initiated YouTube OAuth connect flow. Optional: when
|
||||||
|
// nil (e.g. dev without YouTube client credentials), the /oauth/youtube/*
|
||||||
|
// routes are not mounted.
|
||||||
|
Connect *ConnectHandler
|
||||||
|
// Secrets removes a user's OAuth tokens on disconnect / delete-account. The
|
||||||
|
// account routes require it; cmd/tapir wires the file-backed store.
|
||||||
|
Secrets SecretRemover
|
||||||
|
// Processor, when non-nil, summarizes a queued video immediately in a
|
||||||
|
// background goroutine (the "Summarize" button kicks it off). Nil = queue-only:
|
||||||
|
// the button flips the DB flag and the next `tapir run` does the work.
|
||||||
|
Processor Processor
|
||||||
|
// Processing tracks in-flight immediate summarizations so the status endpoint
|
||||||
|
// shows the animation until the summary lands. The zero value is ready to use.
|
||||||
|
Processing ProcessingSet
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) logger() *slog.Logger {
|
func (a *App) logger() *slog.Logger {
|
||||||
@@ -47,6 +83,7 @@ func (a *App) logger() *slog.Logger {
|
|||||||
func (a *App) Router() http.Handler {
|
func (a *App) Router() http.Handler {
|
||||||
root := http.NewServeMux()
|
root := http.NewServeMux()
|
||||||
root.HandleFunc("GET /healthz", a.handleHealthz)
|
root.HandleFunc("GET /healthz", a.handleHealthz)
|
||||||
|
root.HandleFunc("GET /welcome", a.handleWelcome)
|
||||||
root.Handle("GET /static/", staticHandler())
|
root.Handle("GET /static/", staticHandler())
|
||||||
root.Handle("/auth/", a.Auth.Routes())
|
root.Handle("/auth/", a.Auth.Routes())
|
||||||
|
|
||||||
@@ -54,11 +91,42 @@ func (a *App) Router() http.Handler {
|
|||||||
app.HandleFunc("GET /{$}", a.handleList)
|
app.HandleFunc("GET /{$}", a.handleList)
|
||||||
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
||||||
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
||||||
|
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
||||||
|
app.HandleFunc("GET /v/{videoId}/status", a.handleStatus)
|
||||||
|
app.HandleFunc("GET /register", a.handleRegisterForm)
|
||||||
|
app.HandleFunc("POST /register", a.handleRegister)
|
||||||
|
|
||||||
root.Handle("/", a.Auth.Middleware(app))
|
// Account management: view connections, disconnect a provider, delete the
|
||||||
|
// account. Gated like every app route, so CurrentUserID is set.
|
||||||
|
app.HandleFunc("GET /account", a.handleAccount)
|
||||||
|
app.HandleFunc("POST /account/disconnect/{provider}", a.handleDisconnect)
|
||||||
|
app.HandleFunc("POST /account/delete", a.handleDeleteAccount)
|
||||||
|
app.HandleFunc("POST /account/summarize-mode", a.handleSummarizeMode)
|
||||||
|
|
||||||
|
// Web-initiated YouTube connect (ADR-006). Gated like every app route, so
|
||||||
|
// CurrentUserID is set and the connection binds to the authenticated user.
|
||||||
|
if a.Connect != nil {
|
||||||
|
app.HandleFunc("GET /oauth/youtube/connect", a.Connect.handleConnect)
|
||||||
|
app.HandleFunc("GET /oauth/youtube/callback", a.Connect.handleCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two layers: Auth.Middleware requires a Dex session (you must be logged in);
|
||||||
|
// registrationGate requires a tapir user (else → /register) and stashes the
|
||||||
|
// resolved user_id. /register lives inside the auth guard but is exempt from
|
||||||
|
// the registration gate (you must be able to reach it before you have a user).
|
||||||
|
root.Handle("/", a.Auth.Middleware(a.registrationGate(app)))
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleWelcome renders the public landing page (/welcome). It is mounted outside
|
||||||
|
// Auth.Middleware, so it must not assume a session: CurrentUser peeks the cookie
|
||||||
|
// without redirecting and the page renders the logged-out or logged-in variant
|
||||||
|
// accordingly.
|
||||||
|
func (a *App) handleWelcome(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.Auth.CurrentUser(r)
|
||||||
|
a.render(w, r, WelcomePage(user, ok))
|
||||||
|
}
|
||||||
|
|
||||||
// handleHealthz is the unauthenticated liveness/readiness probe.
|
// handleHealthz is the unauthenticated liveness/readiness probe.
|
||||||
func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
@@ -69,6 +137,10 @@ func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|||||||
// query string. An HTMX request gets only the table fragment so the filter form
|
// query string. An HTMX request gets only the table fragment so the filter form
|
||||||
// can swap #summary-list in place; a plain request gets the full page.
|
// can swap #summary-list in place; a plain request gets the full page.
|
||||||
func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
f := Filter{
|
f := Filter{
|
||||||
Channel: q.Get("channel"),
|
Channel: q.Get("channel"),
|
||||||
@@ -76,24 +148,41 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
|||||||
To: q.Get("to"),
|
To: q.Get("to"),
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := a.Store.ListSummaries(r.Context(), a.UserID, 0)
|
rows, err := a.Store.ListVideos(r.Context(), userID, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.serverError(w, r, "list summaries", err)
|
a.serverError(w, r, "list videos", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows = f.apply(rows)
|
rows = f.apply(rows)
|
||||||
|
|
||||||
if isHTMX(r) {
|
// hasConnected drives the empty state: a fresh account with a connection but
|
||||||
a.render(w, r, summaryList(rows))
|
// no `tapir run` yet has zero rows, and we want it to read "connected, run
|
||||||
|
// tapir" rather than "nothing here". Only needed when the list is empty.
|
||||||
|
hasConnected := false
|
||||||
|
if len(rows) == 0 {
|
||||||
|
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "connections for user", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.render(w, r, ListPage(rows, f))
|
hasConnected = len(conns) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if isHTMX(r) {
|
||||||
|
a.render(w, r, summaryList(rows, hasConnected))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, ListPage(rows, f, takeFlash(w, r), hasConnected))
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDetail renders one summary in full (highlights, takeaways, action group).
|
// handleDetail renders one summary in full (highlights, takeaways, action group).
|
||||||
func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
videoID := r.PathValue("videoId")
|
videoID := r.PathValue("videoId")
|
||||||
row, err := a.Store.GetSummaryByVideo(r.Context(), a.UserID, videoID)
|
row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID)
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -110,6 +199,10 @@ func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
// the refreshed button-group fragment for HTMX; without JS it redirects back to
|
// the refreshed button-group fragment for HTMX; without JS it redirects back to
|
||||||
// the detail page (POST→redirect→GET).
|
// the detail page (POST→redirect→GET).
|
||||||
func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
videoID := r.PathValue("videoId")
|
videoID := r.PathValue("videoId")
|
||||||
action := r.FormValue("action")
|
action := r.FormValue("action")
|
||||||
if !isActionVerb(action) {
|
if !isActionVerb(action) {
|
||||||
@@ -117,23 +210,23 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
current, err := a.Store.ActionsFor(r.Context(), a.UserID, []string{videoID})
|
current, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.serverError(w, r, "read actions", err)
|
a.serverError(w, r, "read actions", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if actionSet(current[videoID])[action] {
|
if actionSet(current[videoID])[action] {
|
||||||
err = a.Store.ClearAction(r.Context(), a.UserID, videoID, action)
|
err = a.Store.ClearAction(r.Context(), userID, videoID, action)
|
||||||
} else {
|
} else {
|
||||||
err = a.Store.SetAction(r.Context(), a.UserID, videoID, action)
|
err = a.Store.SetAction(r.Context(), userID, videoID, action)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.serverError(w, r, "toggle action", err)
|
a.serverError(w, r, "toggle action", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, err := a.Store.ActionsFor(r.Context(), a.UserID, []string{videoID})
|
updated, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.serverError(w, r, "read actions", err)
|
a.serverError(w, r, "read actions", err)
|
||||||
return
|
return
|
||||||
@@ -146,10 +239,135 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
|
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRequestSummarize handles the "Summarize" button. It always flips the DB
|
||||||
|
// flag (summarize_requested) so the work is durable. With a Processor wired it
|
||||||
|
// then summarizes immediately in the background and answers with the animated
|
||||||
|
// processing card that polls /status until done; without one (queue-only) it
|
||||||
|
// answers with the "Queued" card — the next `tapir run` does the work. Without
|
||||||
|
// JS it redirects back to the list (POST→redirect→GET).
|
||||||
|
func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoID := r.PathValue("videoId")
|
||||||
|
|
||||||
|
err := a.Store.RequestSummarize(r.Context(), userID, videoID)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "request summarize", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isHTMX(r) {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "get video", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Processor != nil {
|
||||||
|
a.startProcessing(userID, videoID)
|
||||||
|
a.render(w, r, processingCard(*row))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, VideoCard(*row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// startProcessing marks a video in-flight and summarizes it in the background.
|
||||||
|
// The goroutine uses a detached context — not the request's, which is cancelled
|
||||||
|
// when the handler returns — and clears the in-flight mark on completion. On
|
||||||
|
// error the DB flag stays set, so the video remains queued for the next
|
||||||
|
// `tapir run`; a successful Processor.ProcessVideo clears it itself.
|
||||||
|
func (a *App) startProcessing(userID, videoID string) {
|
||||||
|
key := processingKey(userID, videoID)
|
||||||
|
a.Processing.Add(key)
|
||||||
|
go func() {
|
||||||
|
defer a.Processing.Remove(key)
|
||||||
|
if err := a.Processor.ProcessVideo(context.Background(), userID, videoID); err != nil {
|
||||||
|
a.logger().Error("background summarize", "user", userID, "video", videoID, "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatus is the HTMX poll target for an in-flight summarization. It returns
|
||||||
|
// the card in its current state: the full summary card once the summary exists,
|
||||||
|
// otherwise the animated processing card while still in-flight (which keeps
|
||||||
|
// polling), or the queued/button card when neither holds. VideoCard carries no
|
||||||
|
// polling attributes, so HTMX stops polling once it swaps in.
|
||||||
|
func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoID := r.PathValue("videoId")
|
||||||
|
|
||||||
|
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "get video", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Summarized || !a.Processing.Has(processingKey(userID, videoID)) {
|
||||||
|
a.render(w, r, VideoCard(*row))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, processingCard(*row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
|
||||||
|
// submits the desired new value (enabled=true|false). For HTMX it returns the
|
||||||
|
// refreshed mode control; without JS it redirects back to the account page.
|
||||||
|
func (a *App) handleSummarizeMode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enabled := r.FormValue("enabled") == "true"
|
||||||
|
if err := a.Store.SetAutoSummarize(r.Context(), userID, enabled); err != nil {
|
||||||
|
a.serverError(w, r, "set summarize mode", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isHTMX(r) {
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, summarizeModeControl(enabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentUserID returns the tapir user_id the registration gate resolved for this
|
||||||
|
// request. Behind the gate it is always present; a miss means a handler was
|
||||||
|
// reached without scoping (a wiring bug), so it answers 500 and reports false.
|
||||||
|
func (a *App) currentUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
|
id, ok := CurrentUserID(r)
|
||||||
|
if !ok {
|
||||||
|
a.serverError(w, r, "current user", errNoCurrentUser)
|
||||||
|
}
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
|
|
||||||
// render writes a templ component as HTML. A render error is logged, not retried:
|
// render writes a templ component as HTML. A render error is logged, not retried:
|
||||||
// headers may already be flushed, so there is nothing useful to send the client.
|
// headers may already be flushed, so there is nothing useful to send the client.
|
||||||
func (a *App) render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
func (a *App) render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
||||||
|
a.renderStatus(w, r, http.StatusOK, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderStatus writes a templ component as HTML with an explicit status code (the
|
||||||
|
// Content-Type must be set before WriteHeader, so this is the single place that
|
||||||
|
// orders them correctly).
|
||||||
|
func (a *App) renderStatus(w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
if err := c.Render(r.Context(), w); err != nil {
|
if err := c.Render(r.Context(), w); err != nil {
|
||||||
a.logger().Error("render", "path", r.URL.Path, "err", err)
|
a.logger().Error("render", "path", r.URL.Path, "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ const (
|
|||||||
userID = "11111111-1111-1111-1111-111111111111"
|
userID = "11111111-1111-1111-1111-111111111111"
|
||||||
videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||||
videoY = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
videoY = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||||
|
// stubSubject is the StubAuth Dex subject the registration gate resolves to
|
||||||
|
// the fixed userID (mapping seeded by resetDB).
|
||||||
|
stubSubject = "stub-subject-xyz"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newStore(t *testing.T) *store.Store {
|
func newStore(t *testing.T) *store.Store {
|
||||||
@@ -63,21 +66,48 @@ func rawPool(t *testing.T) *pgxpool.Pool {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetDB(t *testing.T, p *pgxpool.Pool) {
|
// truncateAll wipes every table to a pristine state (user_identities is cleared
|
||||||
|
// via the ON DELETE CASCADE from users). Registration tests use this directly so
|
||||||
|
// no subject is pre-registered.
|
||||||
|
func truncateAll(t *testing.T, p *pgxpool.Pool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, err := p.Exec(context.Background(),
|
_, err := p.Exec(context.Background(),
|
||||||
`TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
|
`TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newApp builds the App under test: the real store, StubAuth (allow-all) keyed to
|
// resetDB truncates, then seeds the StubAuth identity (stubSubject → userID) so
|
||||||
// the configured user. This is exactly cmd/tapir's serve wiring minus Dex.
|
// the registration gate resolves the stub user and the existing handler tests can
|
||||||
|
// keep seeding and scoping by the fixed userID.
|
||||||
|
func resetDB(t *testing.T, p *pgxpool.Pool) {
|
||||||
|
t.Helper()
|
||||||
|
truncateAll(t, p)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := p.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = p.Exec(ctx,
|
||||||
|
`INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (dex_subject) DO NOTHING`, stubSubject, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newApp builds the App under test as the registered stub user (subject
|
||||||
|
// stubSubject, resolved to userID by resetDB). This is cmd/tapir's serve wiring
|
||||||
|
// minus Dex: the store is both the Store and the Identity port.
|
||||||
func newApp(t *testing.T) *web.App {
|
func newApp(t *testing.T) *web.App {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
return newAppAs(t, stubSubject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAppAs builds the App under test with a specific StubAuth Dex subject, so
|
||||||
|
// registration-gate tests can drive registered vs unregistered subjects.
|
||||||
|
func newAppAs(t *testing.T, subject string) *web.App {
|
||||||
|
t.Helper()
|
||||||
|
s := newStore(t)
|
||||||
return &web.App{
|
return &web.App{
|
||||||
Store: newStore(t),
|
Store: s,
|
||||||
Auth: web.StubAuth{U: web.User{Subject: userID}},
|
Identity: s,
|
||||||
UserID: userID,
|
Auth: web.StubAuth{U: web.User{Subject: subject}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,8 +185,10 @@ func TestListRendersRowsAndActionState(t *testing.T) {
|
|||||||
func TestListHTMXReturnsFragment(t *testing.T) {
|
func TestListHTMXReturnsFragment(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
app := newApp(t)
|
app := newApp(t)
|
||||||
resetDB(t, rawPool(t))
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
require.NoError(t, deliver(ctx, app, videoX, "body x"))
|
require.NoError(t, deliver(ctx, app, videoX, "body x"))
|
||||||
|
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
req.Header.Set("HX-Request", "true")
|
req.Header.Set("HX-Request", "true")
|
||||||
@@ -260,6 +292,103 @@ func TestActionRejectsUnknownVerb(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListShowsSummarizeButtonForUnsummarized(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
// A discovered-but-unsummarized video (no summary delivered).
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
|
||||||
|
require.Contains(t, html, "Pending Title", "unsummarized videos are listed too")
|
||||||
|
require.Contains(t, html, "Summarize", "a Summarize button is offered")
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"/summarize", "button posts to the queue endpoint")
|
||||||
|
require.Contains(t, html, "card-pending", "muted pending treatment")
|
||||||
|
require.NotContains(t, html, "Queued", "not queued yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeQueuesAndRendersCard(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
rec := postSummarize(t, app, videoX, true)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Queued", "card now shows the queued state")
|
||||||
|
require.NotContains(t, html, ">Summarize<", "the Summarize button is gone once queued")
|
||||||
|
|
||||||
|
// The flag is persisted, so the next run picks it up.
|
||||||
|
row, err := app.Store.GetVideoRow(ctx, userID, videoX)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, row.SummarizeRequested)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeNonHTMXRedirects(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
rec := postSummarize(t, app, videoX, false)
|
||||||
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||||
|
require.Equal(t, "/", rec.Header().Get("Location"))
|
||||||
|
|
||||||
|
row, err := app.Store.GetVideoRow(ctx, userID, videoX)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, row.SummarizeRequested, "queued on the no-JS path too")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeNotFound(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
rec := postSummarize(t, app, videoX, true)
|
||||||
|
require.Equal(t, http.StatusNotFound, rec.Code, "queuing an unknown video is a 404")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizeModeToggle(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
// Account page defaults to manual.
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Manual", "default mode shown")
|
||||||
|
require.Contains(t, html, "Switch to automatic")
|
||||||
|
|
||||||
|
// Toggle to automatic via HTMX returns the refreshed control.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/account/summarize-mode",
|
||||||
|
strings.NewReader("enabled=true"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
rec = do(t, app, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html = body(t, rec)
|
||||||
|
require.Contains(t, html, "Automatic")
|
||||||
|
require.Contains(t, html, "Switch to manual")
|
||||||
|
|
||||||
|
got, err := app.Store.GetAutoSummarize(ctx, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, got, "mode persisted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func postSummarize(t *testing.T, app *web.App, videoID string, htmx bool) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v/"+videoID+"/summarize", nil)
|
||||||
|
if htmx {
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
}
|
||||||
|
return do(t, app, req)
|
||||||
|
}
|
||||||
|
|
||||||
// deliver stores a summary through the App's store under test.
|
// deliver stores a summary through the App's store under test.
|
||||||
func deliver(ctx context.Context, app *web.App, videoID, text string) error {
|
func deliver(ctx context.Context, app *web.App, videoID, text string) error {
|
||||||
return app.Store.(*store.Store).Deliver(ctx, summary(videoID, text))
|
return app.Store.(*store.Store).Deliver(ctx, summary(videoID, text))
|
||||||
|
|||||||
+30
-23
@@ -4,11 +4,13 @@
|
|||||||
// interface, so swapping the stub for Dex is a wiring choice in cmd/tapir, not
|
// interface, so swapping the stub for Dex is a wiring choice in cmd/tapir, not
|
||||||
// a code change (ADR-003).
|
// a code change (ADR-003).
|
||||||
//
|
//
|
||||||
// Authentication is real (Dex OIDC); authorization is single-user — the ID
|
// Authentication is real (Dex OIDC) and is the only gate: any Dex-authenticated
|
||||||
// token's subject must equal Config.AllowedSubject or the request is refused
|
// subject may sign in (ADR-012 dropped ADR-011's single-subject allowlist).
|
||||||
// with 403. Sessions are server-side (in-memory, fine for the single Stage-0
|
// Authorization/registration is layered on top in internal/web (an authenticated
|
||||||
// replica) addressed by an HMAC-signed (HS256) HttpOnly Secure SameSite=Lax
|
// subject with no tapir user is routed to registration). Sessions are server-side
|
||||||
// cookie with a short TTL and sliding refresh. Tokens are never logged.
|
// (in-memory, fine for the single Stage-1 replica) addressed by an HMAC-signed
|
||||||
|
// (HS256) HttpOnly Secure SameSite=Lax cookie with a short TTL and sliding
|
||||||
|
// refresh. Tokens are never logged.
|
||||||
//
|
//
|
||||||
// This is mcp-chassis's cousin but NOT the same code: mcp-chassis validates
|
// This is mcp-chassis's cousin but NOT the same code: mcp-chassis validates
|
||||||
// inbound Bearer JWTs for MCP APIs; this is a browser session login.
|
// inbound Bearer JWTs for MCP APIs; this is a browser session login.
|
||||||
@@ -28,8 +30,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Config is the OIDC + session configuration. cmd/tapir maps these from
|
// Config is the OIDC + session configuration. cmd/tapir maps these from
|
||||||
// TAPIR_OIDC_*/TAPIR_DEX_*/TAPIR_SESSION_SECRET/TAPIR_ALLOWED_SUBJECT; this
|
// TAPIR_OIDC_*/TAPIR_DEX_*/TAPIR_SESSION_SECRET; this package takes the resolved
|
||||||
// package takes the resolved struct.
|
// struct.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Issuer is the Dex issuer URL, e.g. https://auth.d-ma.be. Discovery
|
// Issuer is the Dex issuer URL, e.g. https://auth.d-ma.be. Discovery
|
||||||
// (.well-known/openid-configuration) runs against it in New.
|
// (.well-known/openid-configuration) runs against it in New.
|
||||||
@@ -42,9 +44,6 @@ type Config struct {
|
|||||||
RedirectURL string
|
RedirectURL string
|
||||||
// SessionSecret keys the HS256 session-cookie signature. Never logged.
|
// SessionSecret keys the HS256 session-cookie signature. Never logged.
|
||||||
SessionSecret string
|
SessionSecret string
|
||||||
// AllowedSubject is the single Dex subject permitted to sign in. Everyone
|
|
||||||
// else is refused 403 (single-user authz, ADR-011).
|
|
||||||
AllowedSubject string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -107,7 +106,6 @@ func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
|
|||||||
"client secret": cfg.ClientSecret,
|
"client secret": cfg.ClientSecret,
|
||||||
"redirect url": cfg.RedirectURL,
|
"redirect url": cfg.RedirectURL,
|
||||||
"session secret": cfg.SessionSecret,
|
"session secret": cfg.SessionSecret,
|
||||||
"allowed subject": cfg.AllowedSubject,
|
|
||||||
} {
|
} {
|
||||||
if strings.TrimSpace(val) == "" {
|
if strings.TrimSpace(val) == "" {
|
||||||
return nil, fmt.Errorf("oidc: missing %s", name)
|
return nil, fmt.Errorf("oidc: missing %s", name)
|
||||||
@@ -163,11 +161,11 @@ func (d *DexAuth) Middleware(h http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
sid, ok := d.sessionID(r)
|
sid, ok := d.sessionID(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
d.redirectToLogin(w, r)
|
d.redirectUnauthenticated(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := d.sessions.get(sid, d.now()); !ok {
|
if _, ok := d.sessions.get(sid, d.now()); !ok {
|
||||||
d.redirectToLogin(w, r)
|
d.redirectUnauthenticated(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh
|
d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh
|
||||||
@@ -242,14 +240,9 @@ func (d *DexAuth) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-user authz: only the allowlisted subject may sign in. On mismatch
|
// Authentication is the only gate (ADR-012): any Dex-authenticated subject may
|
||||||
// we echo the caller's own subject (an opaque id, not a secret) so the
|
// establish a session. Whether that subject has a tapir user — and routing to
|
||||||
// maintainer can bootstrap TAPIR_ALLOWED_SUBJECT on first login.
|
// registration if not — is decided downstream in internal/web, not here.
|
||||||
if idToken.Subject != d.cfg.AllowedSubject {
|
|
||||||
http.Error(w, "forbidden — not the allowlisted subject. your subject is: "+idToken.Subject, http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var claims struct {
|
var claims struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
@@ -273,7 +266,21 @@ func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
d.sessions.delete(sid)
|
d.sessions.delete(sid)
|
||||||
}
|
}
|
||||||
d.clearSessionCookie(w)
|
d.clearSessionCookie(w)
|
||||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
// Land on the public landing page, not the login endpoint: a just-logged-out
|
||||||
|
// visitor should see /welcome, not be bounced straight back into a Dex login.
|
||||||
|
http.Redirect(w, r, "/welcome", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectUnauthenticated sends an unauthenticated visitor somewhere useful: the
|
||||||
|
// bare root goes to the public landing page (/welcome), any deeper guarded path
|
||||||
|
// goes to login so the post-login round-trip can return them to it. isPublicPath
|
||||||
|
// has already let /welcome and /auth/* through, so this never loops.
|
||||||
|
func (d *DexAuth) redirectUnauthenticated(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
http.Redirect(w, r, "/welcome", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.redirectToLogin(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -312,5 +319,5 @@ func (d *DexAuth) clearSessionCookie(w http.ResponseWriter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isPublicPath(p string) bool {
|
func isPublicPath(p string) bool {
|
||||||
return p == "/healthz" || strings.HasPrefix(p, "/auth/")
|
return p == "/healthz" || p == "/welcome" || strings.HasPrefix(p, "/auth/")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
testClientID = "tapir-web"
|
testClientID = "tapir-web"
|
||||||
allowedSub = "allowed-subject-123"
|
testSubject = "dex-subject-123"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeIssuer is an httptest-backed OIDC provider: it serves a discovery
|
// fakeIssuer is an httptest-backed OIDC provider: it serves a discovery
|
||||||
@@ -120,7 +120,6 @@ func newAuth(t *testing.T, f *fakeIssuer) *oidc.DexAuth {
|
|||||||
ClientSecret: "test-client-secret",
|
ClientSecret: "test-client-secret",
|
||||||
RedirectURL: "http://tapir.test/auth/callback",
|
RedirectURL: "http://tapir.test/auth/callback",
|
||||||
SessionSecret: "test-session-secret-please-change",
|
SessionSecret: "test-session-secret-please-change",
|
||||||
AllowedSubject: allowedSub,
|
|
||||||
}, oidc.WithInsecureCookies())
|
}, oidc.WithInsecureCookies())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return auth
|
return auth
|
||||||
@@ -140,12 +139,12 @@ func login(t *testing.T, auth *oidc.DexAuth) (state, nonce string) {
|
|||||||
return q.Get("state"), q.Get("nonce")
|
return q.Get("state"), q.Get("nonce")
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticate completes a full login+callback for the allowlisted subject and
|
// authenticate completes a full login+callback for the test subject and returns
|
||||||
// returns the resulting session cookie.
|
// the resulting session cookie.
|
||||||
func authenticate(t *testing.T, auth *oidc.DexAuth, f *fakeIssuer) *http.Cookie {
|
func authenticate(t *testing.T, auth *oidc.DexAuth, f *fakeIssuer) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
state, nonce := login(t, auth)
|
state, nonce := login(t, auth)
|
||||||
f.sub, f.email, f.nonce = allowedSub, "maintainer@d-ma.be", nonce
|
f.sub, f.email, f.nonce = testSubject, "maintainer@d-ma.be", nonce
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
|
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
|
||||||
@@ -189,7 +188,7 @@ func TestLoginRedirectsToAuthorize(t *testing.T) {
|
|||||||
require.Contains(t, q.Get("scope"), "openid")
|
require.Contains(t, q.Get("scope"), "openid")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCallbackAllowedSubjectSetsSession(t *testing.T) {
|
func TestCallbackSetsSession(t *testing.T) {
|
||||||
f := newFakeIssuer(t)
|
f := newFakeIssuer(t)
|
||||||
auth := newAuth(t, f)
|
auth := newAuth(t, f)
|
||||||
|
|
||||||
@@ -199,26 +198,35 @@ func TestCallbackAllowedSubjectSetsSession(t *testing.T) {
|
|||||||
req.AddCookie(cookie)
|
req.AddCookie(cookie)
|
||||||
user, ok := auth.CurrentUser(req)
|
user, ok := auth.CurrentUser(req)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
require.Equal(t, allowedSub, user.Subject)
|
require.Equal(t, testSubject, user.Subject)
|
||||||
require.Equal(t, "maintainer@d-ma.be", user.Email)
|
require.Equal(t, "maintainer@d-ma.be", user.Email)
|
||||||
|
|
||||||
require.True(t, cookie.HttpOnly)
|
require.True(t, cookie.HttpOnly)
|
||||||
require.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
require.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCallbackNonAllowedSubjectForbidden(t *testing.T) {
|
// TestCallbackAnySubjectAuthenticates proves the single-subject allowlist is gone
|
||||||
|
// (ADR-012): a subject other than any prior allowlist still gets a session.
|
||||||
|
func TestCallbackAnySubjectAuthenticates(t *testing.T) {
|
||||||
f := newFakeIssuer(t)
|
f := newFakeIssuer(t)
|
||||||
auth := newAuth(t, f)
|
auth := newAuth(t, f)
|
||||||
|
|
||||||
state, nonce := login(t, auth)
|
state, nonce := login(t, auth)
|
||||||
f.sub, f.email, f.nonce = "intruder-999", "intruder@elsewhere.test", nonce
|
f.sub, f.email, f.nonce = "some-other-subject-999", "other@elsewhere.test", nonce
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
|
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
|
||||||
"/auth/callback?code=valid-code&state="+state, nil))
|
"/auth/callback?code=valid-code&state="+state, nil))
|
||||||
|
|
||||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
require.Empty(t, rec.Result().Cookies(), "no session for a rejected subject")
|
require.Equal(t, "/", rec.Header().Get("Location"))
|
||||||
|
|
||||||
|
cookie := sessionCookie(t, rec.Result())
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
user, ok := auth.CurrentUser(req)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "some-other-subject-999", user.Subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCallbackUnknownStateRejected(t *testing.T) {
|
func TestCallbackUnknownStateRejected(t *testing.T) {
|
||||||
@@ -240,9 +248,15 @@ func TestMiddlewareRedirectsUnauthenticated(t *testing.T) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// The bare root sends an unauthenticated visitor to the public landing page.
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
require.Equal(t, "/welcome", rec.Header().Get("Location"))
|
||||||
|
|
||||||
|
// A deeper guarded path goes to login so the post-login round-trip returns there.
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v/some-id", nil))
|
||||||
require.Equal(t, http.StatusFound, rec.Code)
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
require.Equal(t, "/auth/login", rec.Header().Get("Location"))
|
require.Equal(t, "/auth/login", rec.Header().Get("Location"))
|
||||||
}
|
}
|
||||||
@@ -272,7 +286,7 @@ func TestMiddlewarePublicPathsBypassAuth(t *testing.T) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
for _, path := range []string{"/healthz", "/auth/login"} {
|
for _, path := range []string{"/healthz", "/welcome", "/auth/login"} {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
require.Equal(t, http.StatusOK, rec.Code, "expected %s to bypass auth", path)
|
require.Equal(t, http.StatusOK, rec.Code, "expected %s to bypass auth", path)
|
||||||
@@ -290,6 +304,7 @@ func TestLogoutClearsSession(t *testing.T) {
|
|||||||
auth.Routes().ServeHTTP(rec, req)
|
auth.Routes().ServeHTTP(rec, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusFound, rec.Code)
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page")
|
||||||
cleared := sessionCookie(t, rec.Result())
|
cleared := sessionCookie(t, rec.Result())
|
||||||
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie")
|
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie")
|
||||||
|
|
||||||
@@ -309,7 +324,6 @@ func TestExpiredSessionRejected(t *testing.T) {
|
|||||||
ClientSecret: "test-client-secret",
|
ClientSecret: "test-client-secret",
|
||||||
RedirectURL: "http://tapir.test/auth/callback",
|
RedirectURL: "http://tapir.test/auth/callback",
|
||||||
SessionSecret: "test-session-secret-please-change",
|
SessionSecret: "test-session-secret-please-change",
|
||||||
AllowedSubject: allowedSub,
|
|
||||||
}, oidc.WithInsecureCookies(),
|
}, oidc.WithInsecureCookies(),
|
||||||
oidc.WithSessionTTL(time.Minute),
|
oidc.WithSessionTTL(time.Minute),
|
||||||
oidc.WithClock(func() time.Time { return clock }))
|
oidc.WithClock(func() time.Time { return clock }))
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Processor runs the core summarization use case for a single already-discovered
|
||||||
|
// video — resolve its transcript, summarize, deliver to the store. *usecase.Engine
|
||||||
|
// wrapped with the store satisfies it (wired in cmd/tapir). Optional on App: a nil
|
||||||
|
// Processor means queue-only — the "Summarize" button only flips the DB flag and
|
||||||
|
// the next `tapir run` does the work.
|
||||||
|
type Processor interface {
|
||||||
|
ProcessVideo(ctx context.Context, userID, videoID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessingSet tracks the (user, video) ids currently being summarized in-process
|
||||||
|
// so the status endpoint can show the animation until the summary lands. It is
|
||||||
|
// ephemeral (single-instance Stage-1): a restart drops it, and the DB holds the
|
||||||
|
// durable state — the summary is present, or summarize_requested is still set so
|
||||||
|
// `tapir run` retries. The zero value is ready to use; methods are concurrency-safe.
|
||||||
|
type ProcessingSet struct {
|
||||||
|
m sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add marks a key in-flight.
|
||||||
|
func (p *ProcessingSet) Add(key string) { p.m.Store(key, struct{}{}) }
|
||||||
|
|
||||||
|
// Remove clears a key once its summarization finishes (success or failure).
|
||||||
|
func (p *ProcessingSet) Remove(key string) { p.m.Delete(key) }
|
||||||
|
|
||||||
|
// Has reports whether a key is currently in-flight.
|
||||||
|
func (p *ProcessingSet) Has(key string) bool {
|
||||||
|
_, ok := p.m.Load(key)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// processingKey scopes the in-flight key by user so one user's summarization is
|
||||||
|
// never confused with another's for the same video id.
|
||||||
|
func processingKey(userID, videoID string) string {
|
||||||
|
return userID + "|" + videoID
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeProcessor records ProcessVideo calls. With block set it parks until the
|
||||||
|
// channel is closed, so a test can observe the handler return before the
|
||||||
|
// background work finishes (proving it ran in a goroutine).
|
||||||
|
type fakeProcessor struct {
|
||||||
|
block chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeProcessor) ProcessVideo(_ context.Context, _, videoID string) error {
|
||||||
|
if f.block != nil {
|
||||||
|
<-f.block
|
||||||
|
}
|
||||||
|
f.calls = append(f.calls, videoID)
|
||||||
|
if f.done != nil {
|
||||||
|
close(f.done)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeImmediateProcessing(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
fp := &fakeProcessor{block: make(chan struct{}), done: make(chan struct{})}
|
||||||
|
app.Processor = fp
|
||||||
|
|
||||||
|
rec := postSummarize(t, app, videoX, true)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
|
||||||
|
// The processing card came back while ProcessVideo is still parked on block:
|
||||||
|
// the work runs in a goroutine, the handler did not wait for it.
|
||||||
|
require.Contains(t, html, "Summarizing", "processing card returned")
|
||||||
|
require.Contains(t, html, "╭", "charm box rendered")
|
||||||
|
require.Contains(t, html, "▓", "tapir body block chars rendered")
|
||||||
|
require.Contains(t, html, "∩", "wiggling snout frame rendered")
|
||||||
|
require.Contains(t, html, web.CharmPurple, "charm palette applied to the border")
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"/status", "card polls the status endpoint")
|
||||||
|
require.Contains(t, html, `hx-trigger="every 2s"`, "card auto-polls every 2s")
|
||||||
|
require.NotContains(t, html, "Queued", "not the queue-only card")
|
||||||
|
|
||||||
|
close(fp.block)
|
||||||
|
select {
|
||||||
|
case <-fp.done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("ProcessVideo was not called in the background")
|
||||||
|
}
|
||||||
|
require.Equal(t, []string{videoX}, fp.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusProcessingThenDone(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
// Park ProcessVideo so the video stays in-flight while we poll status.
|
||||||
|
fp := &fakeProcessor{block: make(chan struct{})}
|
||||||
|
app.Processor = fp
|
||||||
|
require.Equal(t, http.StatusOK, postSummarize(t, app, videoX, true).Code)
|
||||||
|
|
||||||
|
// Processing: status returns the animation card, still polling.
|
||||||
|
rec := getStatus(t, app, videoX)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Summarizing", "in-flight → animation card")
|
||||||
|
require.Contains(t, html, `hx-trigger="every 2s"`, "still polling")
|
||||||
|
|
||||||
|
close(fp.block)
|
||||||
|
|
||||||
|
// Done: once a summary exists, status returns the summary card with no poll.
|
||||||
|
require.NoError(t, deliver(ctx, app, videoX, "the summary body"))
|
||||||
|
rec = getStatus(t, app, videoX)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html = body(t, rec)
|
||||||
|
require.NotContains(t, html, "Summarizing", "done → no animation")
|
||||||
|
require.NotContains(t, html, "every 2s", "done card does not poll (polling stops)")
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"\"", "links to the detail page")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusQueuedWhenNotInFlight(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
// Flag set but nothing in-flight (e.g. queue-only, or after a restart).
|
||||||
|
require.NoError(t, app.Store.RequestSummarize(ctx, userID, videoX))
|
||||||
|
|
||||||
|
rec := getStatus(t, app, videoX)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Queued", "queued chip card")
|
||||||
|
require.NotContains(t, html, "Summarizing", "not processing")
|
||||||
|
require.NotContains(t, html, "every 2s", "queued card does not poll")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusNotFound(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
rec := getStatus(t, app, videoX)
|
||||||
|
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStatus(t *testing.T, app *web.App, videoID string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v/"+videoID+"/status", nil)
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.Router().ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestProcessingSetAddHasRemove(t *testing.T) {
|
||||||
|
var s ProcessingSet // zero value is usable
|
||||||
|
|
||||||
|
key := processingKey("user-1", "video-1")
|
||||||
|
if s.Has(key) {
|
||||||
|
t.Fatal("fresh set must not report a key as in-flight")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Add(key)
|
||||||
|
if !s.Has(key) {
|
||||||
|
t.Fatal("Add must mark the key in-flight")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different user with the same video id is a distinct key.
|
||||||
|
if s.Has(processingKey("user-2", "video-1")) {
|
||||||
|
t.Fatal("keys must be scoped by user")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Remove(key)
|
||||||
|
if s.Has(key) {
|
||||||
|
t.Fatal("Remove must clear the key")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnregisteredSubjectRedirectedToRegister(t *testing.T) {
|
||||||
|
app := newAppAs(t, "unregistered-sub")
|
||||||
|
truncateAll(t, rawPool(t))
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
require.Equal(t, "/register", rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterPageReachableWhenUnregistered(t *testing.T) {
|
||||||
|
app := newAppAs(t, "unregistered-sub")
|
||||||
|
truncateAll(t, rawPool(t))
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/register", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "/register is exempt from the gate")
|
||||||
|
require.Contains(t, body(t, rec), "Complete your registration")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredSubjectPassesThrough(t *testing.T) {
|
||||||
|
app := newApp(t) // stubSubject
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.Contains(t, body(t, rec), "<html", "registered subject gets the app, not a redirect")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterCreatesExactlyOneUserAndIdentity(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
const sub = "brand-new-subject"
|
||||||
|
app := newAppAs(t, sub)
|
||||||
|
p := rawPool(t)
|
||||||
|
truncateAll(t, p)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/register",
|
||||||
|
strings.NewReader("display_name=Newbie&accept_terms=yes"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := do(t, app, req)
|
||||||
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||||
|
require.Equal(t, "/", rec.Header().Get("Location"))
|
||||||
|
|
||||||
|
// Exactly one identity row for the subject, and its user exists.
|
||||||
|
var idents int
|
||||||
|
var newID string
|
||||||
|
require.NoError(t, p.QueryRow(ctx,
|
||||||
|
`SELECT count(*), coalesce(max(user_id::text), '') FROM user_identities WHERE dex_subject = $1`,
|
||||||
|
sub).Scan(&idents, &newID))
|
||||||
|
require.Equal(t, 1, idents)
|
||||||
|
|
||||||
|
var users int
|
||||||
|
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users WHERE id = $1`, newID).Scan(&users))
|
||||||
|
require.Equal(t, 1, users)
|
||||||
|
|
||||||
|
// Returning subject resolves straight through — no second user created.
|
||||||
|
rec = do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
|
var totalUsers, totalIdents int
|
||||||
|
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&totalUsers))
|
||||||
|
require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM user_identities`).Scan(&totalIdents))
|
||||||
|
require.Equal(t, 1, totalUsers, "a second request must not register again")
|
||||||
|
require.Equal(t, 1, totalIdents)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRejectsMissingFields(t *testing.T) {
|
||||||
|
app := newAppAs(t, "incomplete-subject")
|
||||||
|
truncateAll(t, rawPool(t))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/register",
|
||||||
|
strings.NewReader("display_name=&accept_terms=")) // both missing
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := do(t, app, req)
|
||||||
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Identity is the narrow port the web layer uses to resolve a Dex subject to a
|
||||||
|
// tapir user and to register new ones (ADR-012). *store.Store satisfies it; tests
|
||||||
|
// can substitute a fake. It is deliberately separate from Store: identity
|
||||||
|
// resolution runs pre-scope (un-RLS'd map), whereas Store runs user-scoped.
|
||||||
|
type Identity interface {
|
||||||
|
UserBySubject(ctx context.Context, subject string) (userID string, found bool, err error)
|
||||||
|
RegisterUser(ctx context.Context, subject, displayName string) (userID string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errNoCurrentUser indicates a scoped handler ran without a resolved user_id —
|
||||||
|
// only possible if it was reached outside the registration gate (a wiring bug).
|
||||||
|
var errNoCurrentUser = errors.New("web: no current user in request context")
|
||||||
|
|
||||||
|
// userIDCtxKey types the per-request resolved tapir user_id stored by the
|
||||||
|
// registration gate. Unexported so only this package can set it.
|
||||||
|
type userIDCtxKey struct{}
|
||||||
|
|
||||||
|
func withUserID(ctx context.Context, id string) context.Context {
|
||||||
|
return context.WithValue(ctx, userIDCtxKey{}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentUserID returns the tapir user_id (UUID) the registration gate resolved
|
||||||
|
// for the request from the authenticated Dex subject. ok is false for requests
|
||||||
|
// that never passed the gate (e.g. /register, /auth/*). This is the seam handlers
|
||||||
|
// — and downstream features (per-user YouTube connect, account management) —
|
||||||
|
// scope every store access by.
|
||||||
|
func CurrentUserID(r *http.Request) (string, bool) {
|
||||||
|
id, ok := r.Context().Value(userIDCtxKey{}).(string)
|
||||||
|
return id, ok && id != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// registrationGate sits inside Auth.Middleware. For a gated request it resolves
|
||||||
|
// the authenticated subject → tapir user_id once and stashes it for handlers; a
|
||||||
|
// subject with no tapir user is redirected to /register. Exempt paths pass
|
||||||
|
// straight through (/register so an unregistered user can reach the form; /auth/*
|
||||||
|
// and /healthz are already public but listed for safety).
|
||||||
|
func (a *App) registrationGate(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if isRegistrationExempt(r.URL.Path) {
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, ok := a.Auth.CurrentUser(r)
|
||||||
|
if !ok {
|
||||||
|
// Auth.Middleware should have caught this; redirect defensively.
|
||||||
|
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, found, err := a.Identity.UserBySubject(r.Context(), user.Subject)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "resolve identity", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
http.Redirect(w, r, registerPath, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r.WithContext(withUserID(r.Context(), userID)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegisterForm renders the registration form for an authenticated, not-yet-
|
||||||
|
// registered subject. An already-registered subject is sent to the app root.
|
||||||
|
func (a *App) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.Auth.CurrentUser(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, found, err := a.Identity.UserBySubject(r.Context(), user.Subject); err != nil {
|
||||||
|
a.serverError(w, r, "resolve identity", err)
|
||||||
|
return
|
||||||
|
} else if found {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, RegisterPage(user.Email, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegister creates the tapir user for the authenticated subject from the
|
||||||
|
// submitted display name (terms must be accepted), then redirects to the app
|
||||||
|
// root. A double-submit by an already-registered subject is idempotent.
|
||||||
|
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.Auth.CurrentUser(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, found, err := a.Identity.UserBySubject(r.Context(), user.Subject); err != nil {
|
||||||
|
a.serverError(w, r, "resolve identity", err)
|
||||||
|
return
|
||||||
|
} else if found {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
displayName := strings.TrimSpace(r.FormValue("display_name"))
|
||||||
|
accepted := r.FormValue("accept_terms") != ""
|
||||||
|
if displayName == "" || !accepted {
|
||||||
|
a.renderStatus(w, r, http.StatusBadRequest,
|
||||||
|
RegisterPage(user.Email, "Enter a display name and accept the terms to continue."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.Identity.RegisterUser(r.Context(), user.Subject, displayName); err != nil {
|
||||||
|
a.serverError(w, r, "register user", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFlash(w, flashRegistered)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
registerPath = "/register"
|
||||||
|
loginPath = "/auth/login"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isRegistrationExempt(p string) bool {
|
||||||
|
return p == registerPath || p == "/healthz" || strings.HasPrefix(p, "/auth/")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+370
-1
@@ -1,14 +1,31 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// youtubeIDRe matches a canonical 11-char YouTube video id (the provider's
|
||||||
|
// base64url alphabet). Anything else is rejected so we never emit a broken
|
||||||
|
// embed src.
|
||||||
|
var youtubeIDRe = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`)
|
||||||
|
|
||||||
|
// embedURL builds a privacy-friendly nocookie embed URL for a YouTube video id.
|
||||||
|
// It returns ("", false) for any id that isn't a valid 11-char YouTube id, so
|
||||||
|
// the caller can omit the embed instead of rendering a broken iframe.
|
||||||
|
func embedURL(providerVideoID string) (string, bool) {
|
||||||
|
if !youtubeIDRe.MatchString(providerVideoID) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return "https://www.youtube-nocookie.com/embed/" + providerVideoID, true
|
||||||
|
}
|
||||||
|
|
||||||
// actionVerbs is the fixed, ordered set of action toggles rendered in the button
|
// actionVerbs is the fixed, ordered set of action toggles rendered in the button
|
||||||
// group. It mirrors the store's allowed actions (store/actions.go); order here is
|
// group. It mirrors the store's allowed actions (store/actions.go); order here is
|
||||||
// the display order, not the store's.
|
// the display order, not the store's.
|
||||||
@@ -100,6 +117,51 @@ func detailMeta(r store.SummaryRow) string {
|
|||||||
return strings.Join(parts, " · ")
|
return strings.Join(parts, " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// previewText renders a one-line lede for a summary card: it collapses internal
|
||||||
|
// whitespace, then returns the first sentence when one ends within max runes,
|
||||||
|
// otherwise truncates at max runes on a word boundary (never mid-word) and
|
||||||
|
// appends an ellipsis. Empty/short input is returned unchanged (no ellipsis).
|
||||||
|
// Pure and multibyte-safe — all length work is on runes, not bytes.
|
||||||
|
func previewText(s string, max int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s = strings.Join(strings.Fields(s), " ")
|
||||||
|
runes := []rune(s)
|
||||||
|
|
||||||
|
// Prefer the first sentence when it terminates within the budget.
|
||||||
|
if end := firstSentenceEnd(runes); end > 0 && end <= max {
|
||||||
|
return string(runes[:end])
|
||||||
|
}
|
||||||
|
if len(runes) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate at max runes, then back off to the last word boundary so no
|
||||||
|
// partial word is emitted. Space is single-byte, so the byte-index slice
|
||||||
|
// lands cleanly on a rune boundary.
|
||||||
|
cut := string(runes[:max])
|
||||||
|
if i := strings.LastIndexByte(cut, ' '); i > 0 {
|
||||||
|
cut = cut[:i]
|
||||||
|
}
|
||||||
|
return strings.TrimRight(cut, " ") + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstSentenceEnd returns the rune index just past the first sentence
|
||||||
|
// terminator (. ! ?) that is followed by whitespace or the end of input, or 0
|
||||||
|
// when there is none.
|
||||||
|
func firstSentenceEnd(runes []rune) int {
|
||||||
|
for i, r := range runes {
|
||||||
|
if r == '.' || r == '!' || r == '?' {
|
||||||
|
if i+1 == len(runes) || runes[i+1] == ' ' {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// videoURL builds the internal detail-page path for a video id.
|
// videoURL builds the internal detail-page path for a video id.
|
||||||
func videoURL(videoID string) templ.SafeURL {
|
func videoURL(videoID string) templ.SafeURL {
|
||||||
return templ.SafeURL("/v/" + videoID)
|
return templ.SafeURL("/v/" + videoID)
|
||||||
@@ -110,11 +172,216 @@ func actionURL(videoID string) templ.SafeURL {
|
|||||||
return templ.SafeURL("/v/" + videoID + "/action")
|
return templ.SafeURL("/v/" + videoID + "/action")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// summarizeURL builds the manual-queue POST path for a video id.
|
||||||
|
func summarizeURL(videoID string) templ.SafeURL {
|
||||||
|
return templ.SafeURL("/v/" + videoID + "/summarize")
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusURL builds the processing-status poll path (GET) for a video id — the
|
||||||
|
// HTMX poll target while an immediate summarization is in flight.
|
||||||
|
func statusURL(videoID string) templ.SafeURL {
|
||||||
|
return templ.SafeURL("/v/" + videoID + "/status")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
|
||||||
|
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
|
||||||
|
// so the inline span colours and the CSS track/fill share one source of truth.
|
||||||
|
const (
|
||||||
|
CharmPurple = "#7653FC" // box border
|
||||||
|
CharmPink = "#FF6E9C" // tapir body
|
||||||
|
CharmMint = "#0EF9B6" // snout, eyes, progress fill
|
||||||
|
CharmCream = "#FFFDF5" // bright text
|
||||||
|
CharmDim = "#6C6C6C" // dim text
|
||||||
|
charmTrack = "#2D2D2D" // empty progress track (internal: dark char colour)
|
||||||
|
)
|
||||||
|
|
||||||
|
// tapirInteriorW is the fixed inner width of the Charm box, in monospace cells.
|
||||||
|
const tapirInteriorW = 34
|
||||||
|
|
||||||
|
// tapirBarFill is the mint progress fill (27 cells), revealed left→right by the
|
||||||
|
// CSS width/clip animation over the dim track drawn in each frame.
|
||||||
|
const tapirBarFill = "███████████████████████████"
|
||||||
|
|
||||||
|
// tapirRun is one coloured (or uncoloured) text segment of a box row.
|
||||||
|
type tapirRun struct {
|
||||||
|
s string
|
||||||
|
color string // "" = no span (plain text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tapirSpan(color, s string) string {
|
||||||
|
if color == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return `<span style="color:` + color + `">` + s + `</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tapirLine renders one interior box row: concatenate the coloured runs, pad with
|
||||||
|
// spaces to the fixed interior width, then flank with the purple side borders.
|
||||||
|
// Padding is computed from the runs' rune counts, so every row's right border
|
||||||
|
// lines up no matter how many runs it has (assuming 1-cell monospace glyphs).
|
||||||
|
func tapirLine(runs ...tapirRun) string {
|
||||||
|
var b strings.Builder
|
||||||
|
width := 0
|
||||||
|
for _, r := range runs {
|
||||||
|
b.WriteString(tapirSpan(r.color, r.s))
|
||||||
|
width += utf8.RuneCountInString(r.s)
|
||||||
|
}
|
||||||
|
if width < tapirInteriorW {
|
||||||
|
b.WriteString(strings.Repeat(" ", tapirInteriorW-width))
|
||||||
|
}
|
||||||
|
bar := tapirSpan(CharmPurple, "│")
|
||||||
|
return bar + b.String() + bar
|
||||||
|
}
|
||||||
|
|
||||||
|
// tapirFrameHTML builds one animation frame: a rounded Charm box around a colored
|
||||||
|
// ASCII tapir, a dim progress track, and labels. snout is the wiggling nose glyph
|
||||||
|
// that differs between the three frames. Returned as raw HTML (coloured spans),
|
||||||
|
// emitted verbatim by the template via templ.Raw.
|
||||||
|
func tapirFrameHTML(snout string) string {
|
||||||
|
top := tapirSpan(CharmPurple, "╭"+strings.Repeat("─", tapirInteriorW)+"╮")
|
||||||
|
bottom := tapirSpan(CharmPurple, "╰"+strings.Repeat("─", tapirInteriorW)+"╯")
|
||||||
|
lines := []string{
|
||||||
|
top,
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "◆", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "tapir", color: CharmCream}),
|
||||||
|
tapirLine(),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄▄▄▄▄", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄█▓▓▓▓█▄", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: snout, color: CharmMint}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "█▓(", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "◕ ◕", color: CharmMint}, tapirRun{s: ")▓█", color: CharmPink}, tapirRun{s: "──┘", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "< thinking...", color: CharmDim}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀█▓▓▓▓█▀", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "██▄▄██", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}),
|
||||||
|
tapirLine(),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "[", color: CharmDim}, tapirRun{s: strings.Repeat("░", 27), color: charmTrack}, tapirRun{s: "]", color: CharmDim}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "summarizing", color: CharmDim}),
|
||||||
|
bottom,
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three frames differ only in the snout glyph (∩ → ∪ → ~), cross-faded by CSS
|
||||||
|
// to read as a tapir wiggling its nose while it thinks.
|
||||||
|
var (
|
||||||
|
tapirFrameHTML1 = tapirFrameHTML("∩")
|
||||||
|
tapirFrameHTML2 = tapirFrameHTML("∪")
|
||||||
|
tapirFrameHTML3 = tapirFrameHTML("~")
|
||||||
|
)
|
||||||
|
|
||||||
|
// welcomeHeroHTML is the static Charm-box tapir mascot on the public landing
|
||||||
|
// page — the same rounded purple box / pink tapir / mint accents as the spinner,
|
||||||
|
// but a single still frame with a friendly tagline instead of the animation.
|
||||||
|
// Built from the shared tapirLine helpers so the aesthetic stays in one place.
|
||||||
|
func welcomeHeroHTML() string {
|
||||||
|
top := tapirSpan(CharmPurple, "╭"+strings.Repeat("─", tapirInteriorW)+"╮")
|
||||||
|
bottom := tapirSpan(CharmPurple, "╰"+strings.Repeat("─", tapirInteriorW)+"╯")
|
||||||
|
lines := []string{
|
||||||
|
top,
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "◆", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "tapir", color: CharmCream}),
|
||||||
|
tapirLine(),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄▄▄▄▄", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄█▓▓▓▓█▄", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "∩", color: CharmMint}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "█▓(", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "◕ ◕", color: CharmMint}, tapirRun{s: ")▓█", color: CharmPink}, tapirRun{s: "──┘", color: CharmMint}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀█▓▓▓▓█▀", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "██▄▄██", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}),
|
||||||
|
tapirLine(),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "watch less, know more", color: CharmMint}),
|
||||||
|
bottom,
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
var welcomeHero = welcomeHeroHTML()
|
||||||
|
|
||||||
|
// summarizeModeLabel names the current mode for display.
|
||||||
|
func summarizeModeLabel(auto bool) string {
|
||||||
|
if auto {
|
||||||
|
return "Automatic"
|
||||||
|
}
|
||||||
|
return "Manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeModeToggleLabel is the caption on the toggle button — it names the mode
|
||||||
|
// the click switches TO (the opposite of the current one).
|
||||||
|
func summarizeModeToggleLabel(auto bool) string {
|
||||||
|
if auto {
|
||||||
|
return "Switch to manual"
|
||||||
|
}
|
||||||
|
return "Switch to automatic"
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolStr renders a bool as the "enabled" form value the toggle submits.
|
||||||
|
func boolStr(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
// externalURL passes a stored source URL through templ's URL sanitiser.
|
// externalURL passes a stored source URL through templ's URL sanitiser.
|
||||||
func externalURL(u string) templ.SafeURL {
|
func externalURL(u string) templ.SafeURL {
|
||||||
return templ.URL(u)
|
return templ.URL(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// flashView is the rendered form of a flash code: a severity (drives the banner
|
||||||
|
// colour) and the human message. Keeping the text here — not in the cookie —
|
||||||
|
// means the cookie only ever carries an opaque, validated code.
|
||||||
|
type flashView struct {
|
||||||
|
Kind string // "success" | "error"
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// flashMessages maps each flash code to its banner. An unknown code renders no
|
||||||
|
// banner (flashFor returns ok=false), so a forged cookie value is inert.
|
||||||
|
var flashMessages = map[string]flashView{
|
||||||
|
flashConnected: {"success", "YouTube account connected."},
|
||||||
|
flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."},
|
||||||
|
flashDisconnected: {"success", "Account disconnected."},
|
||||||
|
flashDeleted: {"success", "Your account and all its data were deleted."},
|
||||||
|
flashRegistered: {"success", "Welcome to Tapir — your account is ready."},
|
||||||
|
}
|
||||||
|
|
||||||
|
func flashFor(code string) (flashView, bool) {
|
||||||
|
f, ok := flashMessages[code]
|
||||||
|
return f, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// providerLabels maps a provider key to its display name for the account page.
|
||||||
|
var providerLabels = map[string]string{
|
||||||
|
"youtube": "YouTube",
|
||||||
|
"vimeo": "Vimeo",
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerLabel(p string) string {
|
||||||
|
if l, ok := providerLabels[p]; ok {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayNameOr falls back to a placeholder when the user has no display name set.
|
||||||
|
func displayNameOr(name string) string {
|
||||||
|
if name == "" {
|
||||||
|
return "(not set)"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasYouTube reports whether the user already has a YouTube connection, so the
|
||||||
|
// account page hides the Connect link when one exists.
|
||||||
|
func hasYouTube(conns []store.Connection) bool {
|
||||||
|
for _, c := range conns {
|
||||||
|
if c.Provider == "youtube" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// disconnectURL builds the disconnect POST path for a provider.
|
||||||
|
func disconnectURL(provider string) templ.SafeURL {
|
||||||
|
return templ.SafeURL("/account/disconnect/" + provider)
|
||||||
|
}
|
||||||
|
|
||||||
// Filter holds the list-view query parameters. Empty fields mean "no constraint".
|
// Filter holds the list-view query parameters. Empty fields mean "no constraint".
|
||||||
// Dates are kept as the raw YYYY-MM-DD strings so the form re-renders the user's
|
// Dates are kept as the raw YYYY-MM-DD strings so the form re-renders the user's
|
||||||
// input verbatim; parsing happens in matchFilter.
|
// input verbatim; parsing happens in matchFilter.
|
||||||
@@ -200,8 +467,9 @@ body { font: 15px/1.6 system-ui, -apple-system, sans-serif; margin: 0; color: va
|
|||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover, a:focus-visible { text-decoration: underline; }
|
a:hover, a:focus-visible { text-decoration: underline; }
|
||||||
a:visited { color: var(--accent); }
|
a:visited { color: var(--accent); }
|
||||||
header { padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line); background: var(--card); }
|
header { padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line); background: var(--card); display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
|
||||||
.brand { font-weight: 700; font-size: 1.05rem; color: var(--accent); }
|
.brand { font-weight: 700; font-size: 1.05rem; color: var(--accent); }
|
||||||
|
.nav { display: flex; gap: var(--s3); font-size: .9rem; }
|
||||||
main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
@@ -211,6 +479,10 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
|||||||
.filters input { font: inherit; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); min-width: 9rem; }
|
.filters input { font: inherit; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); min-width: 9rem; }
|
||||||
.filters input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
|
.filters input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
|
||||||
.btn { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid var(--accent); border-radius: var(--radius); background: var(--accent); color: var(--accent-fg); cursor: pointer; }
|
.btn { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid var(--accent); border-radius: var(--radius); background: var(--accent); color: var(--accent-fg); cursor: pointer; }
|
||||||
|
/* anchors styled as buttons: the generic a{} / a:visited{} colour rules outrank
|
||||||
|
.btn on <a>, painting the label accent-on-accent (invisible). Restore the
|
||||||
|
button foreground for anchor buttons, visited included. */
|
||||||
|
a.btn, a.btn:visited { color: var(--accent-fg); }
|
||||||
.btn:hover { filter: brightness(1.05); }
|
.btn:hover { filter: brightness(1.05); }
|
||||||
.btn:active { transform: translateY(1px); }
|
.btn:active { transform: translateY(1px); }
|
||||||
|
|
||||||
@@ -219,15 +491,66 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
|||||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--s3) var(--s4); display: flex; flex-direction: column; gap: var(--s2); }
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--s3) var(--s4); display: flex; flex-direction: column; gap: var(--s2); }
|
||||||
.card-title { font-size: 1.1rem; font-weight: 600; line-height: 1.3; }
|
.card-title { font-size: 1.1rem; font-weight: 600; line-height: 1.3; }
|
||||||
.card-meta { color: var(--muted); font-size: .85rem; }
|
.card-meta { color: var(--muted); font-size: .85rem; }
|
||||||
|
.card-preview { color: var(--muted); font-size: .9rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 1; line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
.card-foot { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; margin-top: var(--s1); }
|
.card-foot { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; margin-top: var(--s1); }
|
||||||
.chip { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: .72rem; font-weight: 600; }
|
.chip { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: .72rem; font-weight: 600; }
|
||||||
|
/* passive "retrying later" chip: dim/grey (CharmDim), not the accent — it is a
|
||||||
|
status, not an action the user can take. */
|
||||||
|
.chip-retry { background: rgba(108, 108, 108, .16); color: #6c6c6c; }
|
||||||
.card-state { color: var(--muted); font-size: .8rem; }
|
.card-state { color: var(--muted); font-size: .8rem; }
|
||||||
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
|
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
|
||||||
|
|
||||||
|
/* pending (discovered-but-unsummarized) card: muted until summarized */
|
||||||
|
.card-pending { border-style: dashed; }
|
||||||
|
.card-pending .card-title { color: var(--muted); font-weight: 600; }
|
||||||
|
|
||||||
|
/* summarizing animation — a Charmbracelet-style TUI panel rendered in the
|
||||||
|
browser: a dark terminal card, a rounded purple box around a pink ASCII tapir,
|
||||||
|
and a lipgloss-style progress bar. Three frames are stacked and cross-faded by
|
||||||
|
a stepped keyframe (staggered delays) so the snout appears to wiggle; the
|
||||||
|
progress fill grows independently via a clip animation over the dim track. */
|
||||||
|
.card-processing { border-style: dashed; }
|
||||||
|
.tapir-charm { position: relative; display: inline-block; background: #0d0d12; border-radius: 10px; padding: .8em 1em; margin: var(--s2) 0; font: .82rem/1.15 ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace; box-shadow: 0 2px 14px rgba(118, 83, 252, .25); }
|
||||||
|
.tapir-charm pre { margin: 0; white-space: pre; opacity: 0; animation: tapir-cycle 1.2s steps(1, end) infinite; }
|
||||||
|
.tapir-charm .tapir-f1 { position: relative; animation-delay: 0s; }
|
||||||
|
.tapir-charm .tapir-f2 { position: absolute; top: .8em; left: 1em; animation-delay: .4s; }
|
||||||
|
.tapir-charm .tapir-f3 { position: absolute; top: .8em; left: 1em; animation-delay: .8s; }
|
||||||
|
@keyframes tapir-cycle { 0%, 33.32% { opacity: 1; } 33.33%, 100% { opacity: 0; } }
|
||||||
|
/* progress fill: 27 mint cells overlaying the dim track at box row 10, col 3,
|
||||||
|
revealed left→right over 8s, looping. */
|
||||||
|
.tapir-bar { position: absolute; top: calc(.8em + 11.5em); left: calc(1em + 3ch); height: 1.15em; line-height: 1.15; overflow: hidden; }
|
||||||
|
.tapir-bar-fill { animation: tapir-fill 8s linear infinite; text-shadow: 0 0 6px rgba(14, 249, 182, .7); }
|
||||||
|
@keyframes tapir-fill { 0% { clip-path: inset(0 100% 0 0); } 100% { clip-path: inset(0 0 0 0); } }
|
||||||
|
.tapir-label { color: var(--muted); font-size: .9rem; margin: 0; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tapir-charm pre { animation: none; }
|
||||||
|
.tapir-charm .tapir-f2, .tapir-charm .tapir-f3 { display: none; }
|
||||||
|
.tapir-charm .tapir-f1 { opacity: 1; }
|
||||||
|
.tapir-bar-fill { animation: none; clip-path: inset(0 35% 0 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* summarization mode toggle on the account page */
|
||||||
|
.summarize-mode { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; }
|
||||||
|
.summarize-mode p { margin: 0; }
|
||||||
|
.summarize-mode form { margin: 0; }
|
||||||
|
|
||||||
/* empty state */
|
/* empty state */
|
||||||
.empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); }
|
.empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); }
|
||||||
.empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); }
|
.empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); }
|
||||||
.empty code { background: var(--accent-weak); color: var(--accent); padding: .1rem .35rem; border-radius: .3rem; }
|
.empty code { background: var(--accent-weak); color: var(--accent); padding: .1rem .35rem; border-radius: .3rem; }
|
||||||
|
.empty p { margin: var(--s3) 0 0; }
|
||||||
|
/* connected-but-empty: a distinct accent callout, not a muted blank state, so a
|
||||||
|
fresh account knows the next step is to run tapir, not "something is broken". */
|
||||||
|
.empty-connected { border-style: solid; border-color: var(--accent); background: var(--accent-weak); color: var(--fg); }
|
||||||
|
.empty-connected strong { color: var(--accent); }
|
||||||
|
|
||||||
|
/* flash / notification banner */
|
||||||
|
.flash { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); }
|
||||||
|
.flash-success { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); }
|
||||||
|
.flash-error { background: #fce8e6; color: #8a1c10; border-color: #d9534f; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.flash-error { background: #3a1714; color: #f3b5ae; border-color: #a6362e; }
|
||||||
|
}
|
||||||
|
|
||||||
/* htmx loading feedback */
|
/* htmx loading feedback */
|
||||||
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
|
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
|
||||||
@@ -238,6 +561,8 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
|||||||
.detail h1 { font-size: 1.7rem; line-height: 1.25; margin: 0 0 var(--s2); }
|
.detail h1 { font-size: 1.7rem; line-height: 1.25; margin: 0 0 var(--s2); }
|
||||||
.detail .meta { color: var(--muted); font-size: .9rem; margin: 0 0 var(--s2); display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
|
.detail .meta { color: var(--muted); font-size: .9rem; margin: 0 0 var(--s2); display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
|
||||||
.detail .source { margin: 0 0 var(--s4); font-size: .9rem; }
|
.detail .source { margin: 0 0 var(--s4); font-size: .9rem; }
|
||||||
|
.detail .embed { margin: 0 0 var(--s4); aspect-ratio: 16 / 9; border-radius: var(--radius); overflow: hidden; background: #000; border: 1px solid var(--line); }
|
||||||
|
.detail .embed iframe { display: block; width: 100%; height: 100%; border: 0; }
|
||||||
.detail section { margin-top: var(--s4); }
|
.detail section { margin-top: var(--s4); }
|
||||||
.detail section h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-top: 1px solid var(--line); padding-top: var(--s3); margin: 0 0 var(--s2); }
|
.detail section h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-top: 1px solid var(--line); padding-top: var(--s3); margin: 0 0 var(--s2); }
|
||||||
.detail .body { white-space: pre-wrap; line-height: 1.7; margin: 0; }
|
.detail .body { white-space: pre-wrap; line-height: 1.7; margin: 0; }
|
||||||
@@ -252,6 +577,50 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
|||||||
.actions .action:active { transform: translateY(1px); }
|
.actions .action:active { transform: translateY(1px); }
|
||||||
.actions .action.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
.actions .action.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* account page */
|
||||||
|
.account { max-width: 40rem; }
|
||||||
|
.account h1 { font-size: 1.7rem; margin: 0 0 var(--s4); }
|
||||||
|
.account section { margin-top: var(--s5); }
|
||||||
|
.account section h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-top: 1px solid var(--line); padding-top: var(--s3); margin: 0 0 var(--s3); }
|
||||||
|
.account-meta { display: grid; grid-template-columns: max-content 1fr; gap: var(--s1) var(--s3); margin: 0; }
|
||||||
|
.account-meta dt { color: var(--muted); font-size: .85rem; }
|
||||||
|
.account-meta dd { margin: 0; }
|
||||||
|
.conn-list { list-style: none; margin: 0 0 var(--s3); padding: 0; display: grid; gap: var(--s2); }
|
||||||
|
.conn { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--s3); display: flex; flex-direction: column; gap: var(--s1); }
|
||||||
|
.conn-main { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
|
||||||
|
.conn-provider { font-weight: 600; }
|
||||||
|
.conn-meta { font-size: .8rem; }
|
||||||
|
.conn form { margin-top: var(--s1); }
|
||||||
|
.btn-secondary { font: inherit; font-weight: 600; padding: .4rem .9rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); cursor: pointer; }
|
||||||
|
.btn-secondary:hover { border-color: var(--accent); }
|
||||||
|
.btn-secondary:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
|
||||||
|
/* delete danger zone — destructive action behind a confirm disclosure */
|
||||||
|
.danger-zone h2 { border-top-color: #d9534f; }
|
||||||
|
.confirm-delete > summary { display: inline-block; list-style: none; cursor: pointer; font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid #d9534f; border-radius: var(--radius); background: transparent; color: #c0392b; }
|
||||||
|
.confirm-delete > summary::-webkit-details-marker { display: none; }
|
||||||
|
.confirm-delete > summary:hover { background: #fce8e6; }
|
||||||
|
.confirm-delete[open] > summary { margin-bottom: var(--s3); }
|
||||||
|
.confirm-body { border: 1px solid #d9534f; border-radius: var(--radius); padding: var(--s3); background: #fce8e6; color: #8a1c10; }
|
||||||
|
.btn-danger { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid #d9534f; border-radius: var(--radius); background: #d9534f; color: #fff; cursor: pointer; }
|
||||||
|
.btn-danger:hover { filter: brightness(1.05); }
|
||||||
|
.btn-danger:focus-visible { outline: 2px solid #d9534f; outline-offset: 1px; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.confirm-body { background: #3a1714; color: #f3b5ae; }
|
||||||
|
.confirm-delete > summary { color: #f3b5ae; }
|
||||||
|
.confirm-delete > summary:hover { background: #3a1714; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* public landing page (/welcome) — the Charm-box mascot hero plus the sign-in CTA */
|
||||||
|
.welcome { text-align: center; padding: var(--s5) var(--s3); display: flex; flex-direction: column; align-items: center; gap: var(--s4); }
|
||||||
|
.welcome-hero { background: #0d0d12; border-radius: 10px; padding: .9em 1.1em; display: inline-block; box-shadow: 0 2px 14px rgba(118, 83, 252, .25); }
|
||||||
|
.welcome-hero pre { margin: 0; white-space: pre; font: .82rem/1.15 ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace; }
|
||||||
|
.welcome-title { font-size: 1.9rem; line-height: 1.2; margin: 0; }
|
||||||
|
.welcome-tagline { color: var(--muted); font-size: 1.05rem; line-height: 1.5; margin: 0; max-width: 32rem; }
|
||||||
|
.welcome-cta { display: flex; gap: var(--s3); flex-wrap: wrap; justify-content: center; align-items: center; }
|
||||||
|
.welcome-sub { color: var(--muted); font-size: .9rem; margin: 0; }
|
||||||
|
.btn-lg { padding: .6rem 1.6rem; font-size: 1.05rem; }
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
main { padding: var(--s3) var(--s2); }
|
main { padding: var(--s3) var(--s2); }
|
||||||
.filters { gap: var(--s2); }
|
.filters { gap: var(--s2); }
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmbedURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
id string
|
||||||
|
wantURL string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"valid 11-char id", "dQw4w9WgXcQ", "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ", true},
|
||||||
|
{"valid with dash and underscore", "a_b-cD12345", "https://www.youtube-nocookie.com/embed/a_b-cD12345", true},
|
||||||
|
{"empty", "", "", false},
|
||||||
|
{"too short", "abc", "", false},
|
||||||
|
{"too long", "dQw4w9WgXcQX", "", false},
|
||||||
|
{"invalid char", "dQw4w9WgXc!", "", false},
|
||||||
|
{"space", "dQw4w9WgX Q", "", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotURL, gotOK := embedURL(tt.id)
|
||||||
|
if gotURL != tt.wantURL || gotOK != tt.wantOK {
|
||||||
|
t.Errorf("embedURL(%q) = (%q, %v), want (%q, %v)",
|
||||||
|
tt.id, gotURL, gotOK, tt.wantURL, tt.wantOK)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTapirFrameRowsAligned asserts every box row has the same cell width once
|
||||||
|
// the inline-colour spans are stripped, so the rounded border lines up on every
|
||||||
|
// line (the panel only looks right if the right │ is flush across all rows).
|
||||||
|
func TestTapirFrameRowsAligned(t *testing.T) {
|
||||||
|
stripSpan := regexp.MustCompile(`</?span[^>]*>`)
|
||||||
|
for name, frame := range map[string]string{"f1": tapirFrameHTML1, "f2": tapirFrameHTML2, "f3": tapirFrameHTML3} {
|
||||||
|
plain := stripSpan.ReplaceAllString(frame, "")
|
||||||
|
want := tapirInteriorW + 2 // both purple side borders
|
||||||
|
for i, line := range strings.Split(plain, "\n") {
|
||||||
|
if got := utf8.RuneCountInString(line); got != want {
|
||||||
|
t.Errorf("%s line %d width = %d, want %d: %q", name, i, got, want, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPreviewText(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
max int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
in: "",
|
||||||
|
max: 160,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace-only input",
|
||||||
|
in: " \n\t ",
|
||||||
|
max: 160,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "short string unchanged",
|
||||||
|
in: "A tidy little summary",
|
||||||
|
max: 160,
|
||||||
|
want: "A tidy little summary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "short single sentence unchanged",
|
||||||
|
in: "Hello there.",
|
||||||
|
max: 160,
|
||||||
|
want: "Hello there.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "first sentence taken when more follows",
|
||||||
|
in: "First sentence. Second sentence that we drop.",
|
||||||
|
max: 160,
|
||||||
|
want: "First sentence.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "first sentence with question mark",
|
||||||
|
in: "What is this? It is a preview.",
|
||||||
|
max: 160,
|
||||||
|
want: "What is this?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "long string truncated on word boundary with ellipsis",
|
||||||
|
// 5 ten-char words past the limit; max cuts mid "ones".
|
||||||
|
in: "alpha bravo charlie delta echo foxtrot golf hotel india juliet",
|
||||||
|
max: 30,
|
||||||
|
// runes[:30] = "alpha bravo charlie delta echo"; ends exactly on a
|
||||||
|
// word so the next char would be a space — backs off to last space.
|
||||||
|
want: "alpha bravo charlie delta…",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no mid-word cut",
|
||||||
|
in: "internationalization frameworks everywhere today",
|
||||||
|
max: 25,
|
||||||
|
// runes[:25] = "internationalization fram" — back off to the space
|
||||||
|
// after the first word; never emit a partial word.
|
||||||
|
want: "internationalization…",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multibyte safe truncation",
|
||||||
|
// Accented + emoji runes; cutting on rune indices must not split a
|
||||||
|
// multibyte sequence.
|
||||||
|
in: "café déjà vû señor naïve résumé piñata fiancé",
|
||||||
|
max: 20,
|
||||||
|
want: "café déjà vû señor…",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multibyte short unchanged",
|
||||||
|
in: "café señor",
|
||||||
|
max: 160,
|
||||||
|
want: "café señor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "collapses internal whitespace",
|
||||||
|
in: "line one\n\n line two\tline three",
|
||||||
|
max: 160,
|
||||||
|
want: "line one line two line three",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentence beyond max falls back to char truncation",
|
||||||
|
in: "alpha bravo charlie delta echo foxtrot golf. short.",
|
||||||
|
max: 20,
|
||||||
|
want: "alpha bravo charlie…",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := previewText(tt.in, tt.max)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("previewText(%q, %d) = %q, want %q", tt.in, tt.max, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+277
-15
@@ -20,7 +20,10 @@ templ Layout(title string) {
|
|||||||
@templ.Raw(styleTag)
|
@templ.Raw(styleTag)
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header><a href="/" class="brand">Tapir</a></header>
|
<header>
|
||||||
|
<a href="/" class="brand">Tapir</a>
|
||||||
|
<nav class="nav"><a href="/account">Account</a><a href="/auth/logout">Log out</a></nav>
|
||||||
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{ children... }
|
{ children... }
|
||||||
</main>
|
</main>
|
||||||
@@ -28,13 +31,60 @@ templ Layout(title string) {
|
|||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WelcomePage is the public landing page (served at /welcome, outside the auth
|
||||||
|
// guard — ADR-012). Logged out: the tapir mascot, a one-line tagline, and a
|
||||||
|
// single "Get Started" CTA into the shared Dex flow (sign-in and sign-up are the
|
||||||
|
// same URL). Logged in: a greeting plus links back into the app and to log out.
|
||||||
|
templ WelcomePage(user User, loggedIn bool) {
|
||||||
|
@Layout("Tapir — Watch less, know more") {
|
||||||
|
<section class="welcome">
|
||||||
|
<div class="welcome-hero">
|
||||||
|
<pre aria-hidden="true">@templ.Raw(welcomeHero)</pre>
|
||||||
|
</div>
|
||||||
|
if loggedIn {
|
||||||
|
<h1 class="welcome-title">Welcome back</h1>
|
||||||
|
if user.Email != "" {
|
||||||
|
<p class="welcome-tagline">Signed in as { user.Email }.</p>
|
||||||
|
}
|
||||||
|
<div class="welcome-cta">
|
||||||
|
<a class="btn btn-lg" href="/">Go to my Tapir</a>
|
||||||
|
<a class="btn-secondary" href="/auth/logout">Log Out</a>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<h1 class="welcome-title">Watch less, know more</h1>
|
||||||
|
<p class="welcome-tagline">
|
||||||
|
Tapir summarizes the videos your subscriptions publish, so you can
|
||||||
|
skim the gist and decide what is worth your time.
|
||||||
|
</p>
|
||||||
|
<div class="welcome-cta">
|
||||||
|
<a class="btn btn-lg" href="/auth/login">Get Started</a>
|
||||||
|
</div>
|
||||||
|
<p class="welcome-sub">New to Tapir? Just sign in — you'll complete a quick setup right after. Already have an account? You'll go straight through.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flashBanner renders a one-shot notification for a flash code (connect success/
|
||||||
|
// failure, disconnect, delete, registration). An empty or unknown code renders
|
||||||
|
// nothing, so it is safe to drop into any page unconditionally. Reused across the
|
||||||
|
// app — not per-page ad-hoc markup.
|
||||||
|
templ flashBanner(code string) {
|
||||||
|
if f, ok := flashFor(code); ok {
|
||||||
|
<div class={ "flash", "flash-" + f.Kind } role="status" aria-live="polite">{ f.Message }</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ListPage is the full summary list with the filter form. HTMX swaps only the
|
// ListPage is the full summary list with the filter form. HTMX swaps only the
|
||||||
// #summary-list region; a non-HTMX request renders the whole page.
|
// #summary-list region; a non-HTMX request renders the whole page. flash carries
|
||||||
templ ListPage(rows []store.SummaryRow, f Filter) {
|
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival
|
||||||
|
// after a POST→redirect.
|
||||||
|
templ ListPage(rows []store.SummaryRow, f Filter, flash string, hasConnected bool) {
|
||||||
@Layout("Tapir — Summaries") {
|
@Layout("Tapir — Summaries") {
|
||||||
|
@flashBanner(flash)
|
||||||
@filterForm(f)
|
@filterForm(f)
|
||||||
<div id="summary-list">
|
<div id="summary-list">
|
||||||
@summaryList(rows)
|
@summaryList(rows, hasConnected)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,25 +107,54 @@ templ filterForm(f Filter) {
|
|||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
// summaryList is the swappable list fragment: one card per summary (title link,
|
// summaryList is the swappable list fragment: one card per video (summarized or
|
||||||
// channel · date meta, provider chip, fallback badge, action state). Cards
|
// not). Cards reflow to a single column on mobile; an empty list shows a friendly
|
||||||
// reflow to a single column on mobile; an empty list shows a friendly first-run
|
// first-run state instead of a blank table.
|
||||||
// state instead of a blank table.
|
templ summaryList(rows []store.SummaryRow, hasConnected bool) {
|
||||||
templ summaryList(rows []store.SummaryRow) {
|
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
<div class="empty">
|
if hasConnected {
|
||||||
<strong>No summaries yet</strong>
|
<div class="empty empty-connected">
|
||||||
<span>Summaries appear here as your subscriptions are processed — run <code>tapir run</code> to fetch and summarize new videos.</span>
|
<strong>Your YouTube account is connected!</strong>
|
||||||
|
<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>
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="empty">
|
||||||
|
<strong>No videos yet</strong>
|
||||||
|
<span>Connect your YouTube account to get started.</span>
|
||||||
|
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
<ul class="cards">
|
<ul class="cards">
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
<li class="card">
|
@VideoCard(r)
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VideoCard is one list card, also returned standalone by POST /v/{id}/summarize
|
||||||
|
// (HTMX swaps it in place via outerHTML). A summarized video links to its detail
|
||||||
|
// page and shows its provider chip / fallback badge / action state. An
|
||||||
|
// unsummarized video gets a muted "pending" treatment and either a "Summarize"
|
||||||
|
// button (to queue it) or a "Queued" chip when already requested.
|
||||||
|
templ VideoCard(r store.SummaryRow) {
|
||||||
|
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
|
||||||
|
if r.Summarized {
|
||||||
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
|
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
|
||||||
|
} else {
|
||||||
|
<div class="card-title">{ displayTitle(r) }</div>
|
||||||
|
}
|
||||||
if cardMeta(r) != "" {
|
if cardMeta(r) != "" {
|
||||||
<div class="card-meta">{ cardMeta(r) }</div>
|
<div class="card-meta">{ cardMeta(r) }</div>
|
||||||
}
|
}
|
||||||
|
if r.Summarized {
|
||||||
|
if p := previewText(r.Summary, 160); p != "" {
|
||||||
|
<div class="card-preview">{ p }</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
<div class="card-foot">
|
<div class="card-foot">
|
||||||
|
if r.Summarized {
|
||||||
if r.AIProvider != "" {
|
if r.AIProvider != "" {
|
||||||
<span class="chip">{ r.AIProvider }</span>
|
<span class="chip">{ r.AIProvider }</span>
|
||||||
}
|
}
|
||||||
@@ -85,11 +164,60 @@ templ summaryList(rows []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 {
|
||||||
|
<span class="chip">Queued</span>
|
||||||
|
<span class="card-state muted">waiting for the next run</span>
|
||||||
|
} else {
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action={ summarizeURL(r.VideoID) }
|
||||||
|
hx-post={ string(summarizeURL(r.VideoID)) }
|
||||||
|
hx-target={ "#video-" + r.VideoID }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<button type="submit" class="btn-secondary">Summarize</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
// TapirSpinner is the summarizing animation: a Charmbracelet-style TUI panel —
|
||||||
|
// three richly coloured ASCII tapir frames (inline span colours, snout wiggling
|
||||||
|
// ∩→∪→~) cross-faded by CSS, plus a lipgloss-style progress bar whose mint fill
|
||||||
|
// grows over the dim track. The panel is aria-hidden (decorative); the
|
||||||
|
// "Summarizing…" label below carries the meaning for assistive tech.
|
||||||
|
templ TapirSpinner() {
|
||||||
|
<div class="tapir-charm" aria-hidden="true">
|
||||||
|
<pre class="tapir-f1">@templ.Raw(tapirFrameHTML1)</pre>
|
||||||
|
<pre class="tapir-f2">@templ.Raw(tapirFrameHTML2)</pre>
|
||||||
|
<pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre>
|
||||||
|
<div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div>
|
||||||
|
</div>
|
||||||
|
<p class="tapir-label" role="status" aria-live="polite"><em>Summarizing…</em></p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// processingCard is the in-flight summarization card. It replaces the Summarize
|
||||||
|
// button card and polls /v/{id}/status every 2s, swapping itself (outerHTML, same
|
||||||
|
// id as VideoCard) for whatever state comes back: it keeps polling while still
|
||||||
|
// processing, and the summary/queued card it is eventually replaced by carries no
|
||||||
|
// poll, so polling stops on its own when the fragment changes.
|
||||||
|
templ processingCard(r store.SummaryRow) {
|
||||||
|
<li
|
||||||
|
class="card card-processing"
|
||||||
|
id={ "video-" + r.VideoID }
|
||||||
|
hx-get={ string(statusURL(r.VideoID)) }
|
||||||
|
hx-trigger="every 2s"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<div class="card-title">{ displayTitle(r) }</div>
|
||||||
|
if cardMeta(r) != "" {
|
||||||
|
<div class="card-meta">{ cardMeta(r) }</div>
|
||||||
}
|
}
|
||||||
</ul>
|
@TapirSpinner()
|
||||||
}
|
</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
||||||
@@ -106,6 +234,18 @@ templ DetailPage(r store.SummaryRow) {
|
|||||||
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
if url, ok := embedURL(r.ProviderVideoID); ok {
|
||||||
|
<div class="embed">
|
||||||
|
<iframe
|
||||||
|
src={ url }
|
||||||
|
title={ displayTitle(r) }
|
||||||
|
loading="lazy"
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
if r.URL != "" {
|
if r.URL != "" {
|
||||||
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source ↗</a></p>
|
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source ↗</a></p>
|
||||||
}
|
}
|
||||||
@@ -138,6 +278,128 @@ templ DetailPage(r store.SummaryRow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterPage is the explicit registration step (ADR-012): an authenticated Dex
|
||||||
|
// subject with no tapir user picks a display name and accepts the terms to create
|
||||||
|
// their account. errMsg, when set, reports a validation problem on the prior POST.
|
||||||
|
templ RegisterPage(email, errMsg string) {
|
||||||
|
@Layout("Tapir — Register") {
|
||||||
|
<article class="register">
|
||||||
|
<h1>Complete your registration</h1>
|
||||||
|
if email != "" {
|
||||||
|
<p class="meta">Signed in as { email }.</p>
|
||||||
|
}
|
||||||
|
<p>Choose a display name to finish setting up your Tapir account.</p>
|
||||||
|
if errMsg != "" {
|
||||||
|
<p class="error" role="alert">{ errMsg }</p>
|
||||||
|
}
|
||||||
|
<form method="post" action="/register" class="register-form">
|
||||||
|
<label>
|
||||||
|
Display name
|
||||||
|
<input type="text" name="display_name" required autofocus/>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="accept_terms" value="yes" required/>
|
||||||
|
I accept the terms of use
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn">Register</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountPage is the account-management view: the registered display name and
|
||||||
|
// signed-in email, the user's connected video accounts (each with a Disconnect
|
||||||
|
// control), a Connect-YouTube link when none is connected, and the delete-account
|
||||||
|
// danger zone. flash surfaces a one-shot notification (disconnect/connect).
|
||||||
|
templ AccountPage(displayName, email string, conns []store.Connection, autoSummarize bool, flash string) {
|
||||||
|
@Layout("Tapir — Account") {
|
||||||
|
@flashBanner(flash)
|
||||||
|
<article class="account">
|
||||||
|
<h1>Account</h1>
|
||||||
|
<dl class="account-meta">
|
||||||
|
<dt>Display name</dt>
|
||||||
|
<dd>{ displayNameOr(displayName) }</dd>
|
||||||
|
if email != "" {
|
||||||
|
<dt>Signed in as</dt>
|
||||||
|
<dd>{ email }</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
<section>
|
||||||
|
<h2>Summarization</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Automatic summarizes every new video as it is discovered. Manual lets you
|
||||||
|
pick which videos to summarize — new videos appear in your list with a
|
||||||
|
Summarize button.
|
||||||
|
</p>
|
||||||
|
@summarizeModeControl(autoSummarize)
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Connected accounts</h2>
|
||||||
|
if len(conns) == 0 {
|
||||||
|
<p class="muted">No connected video accounts yet.</p>
|
||||||
|
} else {
|
||||||
|
<ul class="conn-list">
|
||||||
|
for _, c := range conns {
|
||||||
|
<li class="conn">
|
||||||
|
<div class="conn-main">
|
||||||
|
<span class="conn-provider">{ providerLabel(c.Provider) }</span>
|
||||||
|
if c.ProviderAccount != "" {
|
||||||
|
<span class="muted">{ c.ProviderAccount }</span>
|
||||||
|
}
|
||||||
|
<span class="chip">{ c.Status }</span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-meta muted">connected { c.ConnectedAt.Format("2006-01-02") }</div>
|
||||||
|
<form method="post" action={ disconnectURL(c.Provider) }>
|
||||||
|
<button type="submit" class="btn-secondary">Disconnect</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
if !hasYouTube(conns) {
|
||||||
|
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
<section class="danger-zone">
|
||||||
|
<h2>Delete account</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Permanently remove your Tapir account and all of its data — summaries,
|
||||||
|
watch/skip/save actions, and connected accounts. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<details class="confirm-delete">
|
||||||
|
<summary class="btn-danger">Delete account…</summary>
|
||||||
|
<div class="confirm-body">
|
||||||
|
<p>This permanently deletes your account and all data. Are you sure?</p>
|
||||||
|
<form method="post" action="/account/delete">
|
||||||
|
<button type="submit" class="btn-danger">Yes, permanently delete my account</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeModeControl is the auto/manual toggle, also returned standalone by
|
||||||
|
// POST /account/summarize-mode (HTMX swaps it via outerHTML). The hidden field
|
||||||
|
// submits the desired NEW value, so a single submit flips the mode; without JS the
|
||||||
|
// form posts and the handler redirects back to /account.
|
||||||
|
templ summarizeModeControl(auto bool) {
|
||||||
|
<div id="summarize-mode" class="summarize-mode">
|
||||||
|
<p>Current mode: <strong>{ summarizeModeLabel(auto) }</strong></p>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/account/summarize-mode"
|
||||||
|
hx-post="/account/summarize-mode"
|
||||||
|
hx-target="#summarize-mode"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="enabled" value={ boolStr(!auto) }/>
|
||||||
|
<button type="submit" class="btn-secondary">{ summarizeModeToggleLabel(auto) }</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
// ActionButtons is the toggle group fragment returned by POST /v/{id}/action.
|
// ActionButtons is the toggle group fragment returned by POST /v/{id}/action.
|
||||||
// Each button submits its verb; HTMX swaps this element in place (outerHTML),
|
// Each button submits its verb; HTMX swaps this element in place (outerHTML),
|
||||||
// and without JS the form POSTs and the handler redirects back to the detail
|
// and without JS the form POSTs and the handler redirects back to the detail
|
||||||
|
|||||||
+1141
-199
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeAuth is a configurable web.Auth for the landing-page tests: it reports a
|
||||||
|
// fixed (user, ok) from CurrentUser and, when logged out, replicates DexAuth's
|
||||||
|
// redirect split in Middleware — bare root → /welcome, deeper paths → login.
|
||||||
|
// StubAuth can't express the logged-out case (it allows everything), so the
|
||||||
|
// welcome routing needs this.
|
||||||
|
type fakeAuth struct {
|
||||||
|
user web.User
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeAuth) CurrentUser(*http.Request) (web.User, bool) { return f.user, f.ok }
|
||||||
|
func (f fakeAuth) Routes() http.Handler { return http.NewServeMux() }
|
||||||
|
|
||||||
|
func (f fakeAuth) Middleware(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if f.ok {
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
http.Redirect(w, r, "/welcome", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// appWithAuth builds an App with a given Auth but no store wiring — enough for
|
||||||
|
// the /welcome page (which never touches the store) and the unauthenticated
|
||||||
|
// redirect paths (which never reach a handler).
|
||||||
|
func appWithAuth(auth web.Auth) *web.App {
|
||||||
|
return &web.App{Auth: auth}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWelcomeLoggedOut(t *testing.T) {
|
||||||
|
app := appWithAuth(fakeAuth{ok: false})
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/welcome", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Get Started", "logged-out CTA present")
|
||||||
|
require.Contains(t, html, `href="/auth/login"`, "CTA links into the Dex flow")
|
||||||
|
require.NotContains(t, html, "Go to my Tapir", "no logged-in controls")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWelcomeLoggedIn(t *testing.T) {
|
||||||
|
app := appWithAuth(fakeAuth{user: web.User{Subject: "s", Email: "me@d-ma.be"}, ok: true})
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/welcome", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Go to my Tapir", "logged-in CTA present")
|
||||||
|
require.Contains(t, html, `href="/"`, "links back into the app")
|
||||||
|
require.Contains(t, html, "me@d-ma.be", "greets by email")
|
||||||
|
require.NotContains(t, html, "Get Started", "no logged-out CTA")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnauthenticatedRootRedirectsToWelcome(t *testing.T) {
|
||||||
|
app := appWithAuth(fakeAuth{ok: false})
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
require.Equal(t, "/welcome", rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnauthenticatedDeepLinkRedirectsToLogin(t *testing.T) {
|
||||||
|
app := appWithAuth(fakeAuth{ok: false})
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/v/some-id", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
require.Equal(t, "/auth/login", rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticatedRootRendersList(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.Contains(t, body(t, rec), "<html", "authenticated root still renders the list page")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user