Commit Graph
17 Commits
Author SHA1 Message Date
mathiasandClaude Opus 4.8 1e81965519 feat(store): TranscriptStatus read field + status setter/loader
Surfaces videos.transcript_status (migration 007) on SummaryRow and adds
SetTranscriptStatus / GetTranscriptStatus / RateLimitedVideoIDs.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:52:48 +02:00
mathiasandClaude Opus 4.8 689500c85e feat(store): migration 007 — per-video transcript status
CI / Lint / Test / Vet (push) Successful in 20s
CI / Build & Import (push) Successful in 10s
Adds videos.transcript_status (NULL|none|fetched|rate_limited) and
videos.rate_limited_at, so the runner can record a 429 and skip re-fetching a
still-throttled video until a backoff window elapses. Columns inherit the
existing videos RLS policy (migration 003); no policy change needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:45:29 +02:00
mathiasandClaude Opus 4.8 bdbdce7de1 feat(store): summarization-mode methods + all-videos read
Add the store surface for summarization mode:
- SetAutoSummarize / GetAutoSummarize — per-user auto/manual toggle; an absent
  user reads as manual (the safe default).
- RequestSummarize — queue one video (summarize_requested=TRUE); ErrNotFound
  when the video is absent or not owned (RLS hides another user's row).
- RequestedVideoIDs / ClearSummarizeRequested — the run-loop side: load the
  queued set per pass (mirrors SeenVideoIDs), clear after summarizing.
- ListVideos / GetVideoRow — drive from the videos table LEFT JOIN summaries so
  discovered-but-unsummarized videos appear with empty summary fields. SummaryRow
  gains additive Summarized + SummarizeRequested fields; the summary-only reads
  are untouched.

RLS proof: rls_test.go gains a cross-user "queue B's video" write asserting it
touches zero rows (store-level scoping can't prove this — the test pool is a
superuser that bypasses RLS, same caveat documented there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:26:52 +02:00
mathiasandClaude Opus 4.8 748d5eb0bd feat(store): migration 006 — per-user auto_summarize + per-video summarize_requested
Add the schema for the summarization-mode feature: a per-user auto/manual
toggle (users.auto_summarize, default FALSE = manual) and a per-video manual
queue flag (videos.summarize_requested, default FALSE).

Both columns land on tables that already have ENABLE + FORCE ROW LEVEL
SECURITY (migration 003), so they inherit per-user isolation automatically —
no policy changes needed. auto_summarize is per-user, not global, per ADR-012.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:26:43 +02:00
mathiasandClaude Opus 4.8 c7624d97fe feat(store): DeleteUser + DisplayName for account management
DeleteUser permanently removes a user and all owned data, scoped via
withUser. The users-row ON DELETE CASCADE reaches videos, transcripts,
summaries → sink_deliveries, video_connections, and the user_identities
map (cascades bypass RLS, so a scoped connection still wipes child rows).
summary_actions carries user_id but has NO FK to users (migration 002),
so it is deleted explicitly in the same scoped transaction. Idempotent.

Tapir-side only (decision 2026-06-03): Dex identity is left untouched;
secrets live in the SecretStore and are removed by the account handler.

DisplayName returns the registered name for the account page.

Test proves deletion removes every row for the target user across all
isolated tables (incl. video_connections AND user_identities) and leaves
another user's rows fully intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:49:50 +02:00
mathiasandClaude Opus 4.8 0c9531a9b8 feat(store): video_connections table + RLS + connection store methods
Add the video_connections table (data-model VIDEO_CONNECTION) with FORCE
row-level security keyed off tapir.current_user_id, identical to migration
003's per-user isolation pattern — connections are user-owned data and must
be isolated at the DB layer, not only by application WHERE clauses.

Store methods (UpsertConnection / ConnectionsForUser / DeleteConnection) all
route through withUser so RLS scopes every access. UpsertConnection is
idempotent on (user_id, provider). The OAuth refresh token never lives here;
token_ref is the opaque SecretStore reference.

Extend the RLS isolation proof to cover video_connections: seeded per user,
included in the deny-all + scoped-read assertions, and added to the
cross-user write-invisibility and survivor checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:12:34 +02:00
mathiasandClaude Opus 4.8 f396e01243 feat(store): user_identities map + UserBySubject/RegisterUser (ADR-012)
Add the Dex-subject -> tapir-user_id bridge for multi-user Stage 1.

Migration 004 creates user_identities (dex_subject PK, user_id UNIQUE FK
ON DELETE CASCADE). It is intentionally NOT RLS-enabled: it holds no user
data and must be readable BEFORE a user_id is known (the lookup is what
yields the id used to set tapir.current_user_id). RLS here would be a
chicken-and-egg deadlock; data isolation stays on the user-owned tables.

UserBySubject resolves subject -> user_id as a plain pool query (pre-scope,
no withUser). RegisterUser generates the UUID app-side (stdlib crypto/rand,
no new dep) so the forced-RLS WITH CHECK (id = GUC) passes, then inserts the
users row via withUser(newID) and the identity row in the same transaction.
Re-registration of a subject errors with ErrSubjectRegistered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:53:34 +02:00
mathias 9bff59037f merge: video embed on detail page (Worker R2)
CI / Lint / Test / Vet (push) Successful in 13s
CI / Build & Import (push) Successful in 11s
CI / Mirror to GitHub (push) Failing after 2s
# Conflicts:
#	internal/web/views_templ.go
2026-06-03 15:38:31 +02:00
mathiasandClaude Opus 4.8 f28fdc0292 test(store): prove RLS isolation as a non-superuser role
The isolation proof for ADR-012. embedded-postgres's default user is a
SUPERUSER, which bypasses RLS regardless of FORCE — a test run as it would be
fake-green. So this test creates a dedicated non-superuser role ("app", mirroring
the prod owner tapir), grants it DML, asserts rolsuper is false, and runs every
scoped query as that role.

Assertions: (1) deny-all — with no GUC set, every isolated table returns zero
rows, proving the enforcement path is live, not bypassed; (2) a connection scoped
to user A sees exactly its own one row in every table (and B likewise); (3)
cross-user UPDATE/DELETE aimed at B's rows touches zero rows; (4) B's rows survive
unchanged, verified via the superuser pool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:15:58 +02:00
mathiasandClaude Opus 4.8 7b139c2cd7 feat(store): route all DB access through withUser for structural scoping
Add Store.withUser(ctx, userID, fn) — a single choke point that BEGINs a tx,
sets the transaction-local GUC tapir.current_user_id via
set_config(..., true), runs fn, and commits. set_config is used over SET LOCAL
because it is parameterizable; the local flag means the value auto-resets on
commit/rollback so a pooled connection never leaks one request's user into the
next.

Route all 9 DB-touching methods through it (Deliver, HasSummary, SeenVideoIDs,
ListSummaries, GetSummaryByVideo, SetAction, ClearAction, ActionsFor, and
UpsertVideo; attachActions flows via ActionsFor). Scoping is now structural —
not a per-query opt-in someone can forget — and arms the migration-003 RLS
policies. Method signatures and existing WHERE clauses are unchanged (defence in
depth; superuser DSNs in existing tests bypass RLS so behaviour is preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:15:52 +02:00
mathiasandClaude Opus 4.8 6775e5f53d feat(store): migration 003 — enforce per-user isolation via forced RLS
Enable AND FORCE row-level security on every user-owned table (users, videos,
transcripts, summaries, summary_actions, sink_deliveries) per ADR-012. Each
policy keys off the per-request GUC tapir.current_user_id; an unset GUC yields
NULL → deny-all (the safe default).

FORCE is load-bearing: the app connects as the table owner (tapir), and owners
bypass RLS unless forced. Without FORCE the policies are dead for the prod user.

sink_deliveries has no user_id; its policy derives ownership from the summary it
belongs to via EXISTS against the GUC, so it is self-contained rather than
silently depending on summaries' own RLS being applied to a subquery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:15:44 +02:00
mathias 897a21a1d6 feat(store): expose provider_video_id on SummaryRow
Read-only addition (column + field + scan) so the web layer can build a
video embed URL. No write-path or restructuring.
2026-06-03 15:10:29 +02:00
mathiasandClaude Opus 4.8 a032c324f6 feat(store): persist summary actions (watch/skip/save) — Stage-0 metric
CI / Lint / Test / Vet (push) Successful in 6s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
The summary_actions table records the maintainer's act on a summary, the
column that makes the Stage-0 headline test ("acts on >=1 summary") queryable
(ui-spec.md §5, ADR-011). This is the gate lanes B/C build on.

- Migration 002: summary_actions (id, user_id, video_id TEXT, action, acted_at)
  with a CHECK on action IN ('watched','skipped','saved') and a UNIQUE
  (user_id, video_id, action). Per-user isolation: every row carries user_id.
- New actions.go: SetAction (idempotent, atomic watched<->skipped mutual
  exclusion in one tx; saved independent), ClearAction, ActionsFor for the list
  view, plus Go-side action validation.
- reads.go: additive SummaryRow.Actions, populated by composing ActionsFor
  (Go-side, not a SQL join — summaries.video_id is UUID, actions.video_id TEXT).
- embedded-postgres tests: set/clear, mutual exclusion, saved coexistence,
  idempotency, invalid rejection, user scoping, read-view surfacing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:36:55 +02:00
mathias a38002df80 merge: demo wiring — tapir auth/run + config (Worker F, agent/demo-wiring)
CI / Lint / Test / Vet (push) Successful in 6s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
# Conflicts:
#	cmd/tapir/main.go
2026-06-02 21:29:48 +02:00
mathiasandClaude Opus 4.8 6beb779df6 feat(store): UpsertVideo to persist video metadata
The Sink port carries only a Summary, so video title/url/published_at would
never reach the store. UpsertVideo (new file, store.go untouched) persists them
and returns the durable videos.id UUID, idempotent on
(user_id, provider, provider_video_id). The run loop uses that id as v.ID, so
it equals summaries.video_id and SeenVideoIDs dedup survives restarts.
subscription_id stays NULL: the YouTube resource id is not a UUID (Stage 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:00:04 +02:00
mathiasandClaude Opus 4.8 c153ff35ce feat(store): add read methods for stored summaries
ListSummaries (recent-first, user-scoped, limit) and GetSummaryByVideo
LEFT JOIN videos for title/url/published_at, null-safe when no videos
row exists. Channel mirrors provider for now — channel_title lives on
the not-yet-migrated subscriptions table (data-model.md). New file so it
does not collide with Worker F's concurrent edits to store.go.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:58:04 +02:00
mathiasandClaude Opus 4.8 2695b5d91e feat(adapters): add Postgres store sink with durable dedup
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
Implements ports.Sink over Postgres (pgx/v5 + pgxpool, DSN from env per
estate convention). This is the primary sink (ADR-003) and the source of
the engine's durable, cross-restart dedup — the in-engine processed map is
process-lifetime only.

- Migrations (golang-migrate, NNN_name.up/down.sql per estate convention,
  applied from an embedded FS on New): users, videos, transcripts,
  summaries, sink_deliveries. Every user-owned table carries user_id
  (Stage-0 per-user isolation promise, data-model.md). summaries has
  UNIQUE(user_id, video_id) — at most one summary per video; highlights /
  takeaways are jsonb.
- Deliver upserts the summary idempotently on (user_id, video_id)
  (ON CONFLICT DO UPDATE) inside one tx with its sink_delivery row. Re-
  delivering the same summary updates in place, never duplicates or errors.
- Dedup reads (store methods, not a new port): HasSummary(ctx,userID,
  videoID) and SeenVideoIDs(ctx,userID) — both user_id-scoped, so one
  user never sees another's videos.

summaries.video_id is intentionally not FK-constrained to videos at Stage 0:
the sink receives only a Summary, so the dedup key stands alone; video-row
persistence is the engine/source's concern, deferred.

Tested against a real in-process Postgres via embedded-postgres (real SQL:
constraints, ON CONFLICT, jsonb, user_id scoping) — no docker, no live
cluster, no creds, fully offline.

Deps: golang-migrate/migrate/v4 and jackc/pgx/v5 (runtime),
fergusstrange/embedded-postgres + stretchr/testify (test-only). go mod tidy
raised the go directive to 1.25.0 (minimum required by the dep graph;
estate elsewhere already runs 1.26.1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:04:04 +02:00