Commit Graph
243 Commits
Author SHA1 Message Date
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
mathias fa57ee0532 docs(homelab): Stage-1 facts — RLS non-super DSN, per-user token PVC, web connect URI
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Failing after 2s
Per the docs-currency gate (ADR-012 shipped): record the Stage-1 deployment
facts that future sessions would otherwise rediscover the hard way.
2026-06-03 18:28:29 +02:00
mathiasandClaude Opus 4.8 22eafcf43f feat(web): account page with disconnect + delete-account
CI / Lint / Test / Vet (push) Successful in 17s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Has been skipped
GET /account shows the registered display name, the signed-in email, the
user's connected video accounts (status + when), a Connect-YouTube link
when none is connected, and the disconnect / delete controls. Linked from
the header nav.

POST /account/disconnect/{provider}: deletes the OAuth token from the
SecretStore (resolved from the connection's own token_ref, provider-
agnostic) and the connection row. Does NOT delete the account.

POST /account/delete: confirm-before-destroy (a <details> disclosure gates
the destructive submit — works without JS). Captures token refs, calls
store.DeleteUser (cascades all rows), purges every secret, then routes to
/auth/logout to clear the session. Tapir-side only — Dex is left untouched
(decision 2026-06-03).

Account handlers depend on a narrow SecretRemover (Delete) and the extended
Store port; cmd/tapir serve shares one file-backed SecretStore between the
connect flow and account management.

Tests: account page renders connections + name + Connect link; disconnect
removes token (fake records Delete) + row and keeps the account; delete
wipes users/summaries/connections/identities and purges the token, then
redirects to logout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.2.0
2026-06-03 16:55:02 +02:00
mathiasandClaude Opus 4.8 2fe4833434 feat(web): reusable flash/notification banner (PRG)
Add a one-shot flash component used across the app — connect success,
disconnect, account delete, and registration — instead of per-page ad-hoc
markup. setFlash queues a short-lived HttpOnly+SameSite cookie carrying an
opaque code; takeFlash consumes it on the next full-page render (not on
HTMX fragments). flashBanner maps the code to a styled, role=status banner;
the message text lives server-side in flashMessages so the cookie never
carries free text and a forged/unknown code renders nothing.

Wire it into the list page (the PRG landing spot for connect/registration)
and set it on registration and connect-callback success. Styled with the
existing design-system tokens; header gains an Account nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:52:03 +02:00
mathiasandClaude Opus 4.8 17d5e8c393 feat(secrets): FileStore.Delete to purge a user's OAuth tokens
Account disconnect/delete needs to remove the per-user YouTube refresh
token from the SecretStore. Add Delete(ref) on the file-backed store,
mirroring Put: atomic temp-file+rename, 0600, no-op on an absent ref.

Kept off the read-only ports.SecretStore (Get) — write/delete follow the
existing auth.TokenWriter convention of narrow capability interfaces, so
the youtube adapter's read-only dependency is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:50:20 +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 2aad79b2a8 feat(web): web-initiated YouTube OAuth connect flow (ADR-006)
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Failing after 3s
Add GET /oauth/youtube/connect and /oauth/youtube/callback, mounted inside
the login + registration guard so CurrentUserID is always set and every
connection binds to the authenticated tapir user.

- connect: generate a per-user CSRF state (single-use, short TTL, bound to
  the user), redirect to Google consent with access_type=offline and
  prompt=consent so a refresh token comes back.
- callback: verify the state belongs to this user, exchange the code via the
  existing auth.Exchange, persist the refresh token under a PER-USER ref
  (web.YouTubeTokenRef = "youtube/<userID>/refresh_token") so tenants never
  collide, then UpsertConnection (provider=youtube, status=active). Any
  failure renders a clean error page and leaves no half-written state.

Reuses auth.Exchange and adds auth.AuthCodeURL (offline + consent) rather
than the CLI's listener/terminal flow (ADR-006: web flow, not CLI). The
ConnectHandler depends on a narrow web.Connections port, not the concrete
store. Wired in cmdServe only when YT client credentials are present;
TAPIR_YT_CONNECT_REDIRECT_URL configures the callback URL. Per-user token-ref
scheme documented in docs/homelab-integration.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:12:42 +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 7b4960e417 feat(web): registration gate + per-request user-id seam (ADR-012)
CI / Lint / Test / Vet (push) Successful in 9s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Failing after 3s
Multi-user web surface. Two layered middlewares: Auth.Middleware (Dex
session required) wraps registrationGate, which resolves the authenticated
subject -> tapir user_id once per request via the new web.Identity port and
stashes it. A subject with no tapir user is redirected to GET /register
(display name + accept-terms); POST /register calls RegisterUser then
redirects to /. /register is inside the auth guard but exempt from the gate
(/auth/* and /healthz too).

Current-user seam: CurrentUserID(r) (string, bool) returns the resolved id
from the request context. The list/detail/action handlers now scope by it,
replacing the single configured App.UserID (removed). App gains an Identity
field; *store.Store satisfies both Store and Identity. cmd/tapir wires
Identity: st and drops UserID.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:00:30 +02:00
mathiasandClaude Opus 4.8 e62df0027d refactor(oidc): drop single-subject allowlist, authenticate-only (ADR-012)
ADR-011's single-user authz (ID-token subject must equal AllowedSubject,
else 403) is replaced by ADR-012's model: Dex authentication is the only
gate — any Dex-authenticated subject may establish a session. Whether that
subject has a tapir user, and routing to registration if not, is decided
downstream in internal/web (next commit).

Removals (noted): oidc.Config.AllowedSubject + its required-field check + the
callback 403 branch; config.Config.AllowedSubject + TAPIR_ALLOWED_SUBJECT env
wiring; the AllowedSubject arg in cmdServe. ui-spec.md updated to reflect the
supersession. Sessions, cookie signing, login/callback/logout unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:56:13 +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
mathias 6d9f3c49ed merge: summary preview in list (Worker R1) 2026-06-03 15:37:42 +02:00
mathias 2ae66da0e0 merge: RLS isolation foundation — forced RLS + withUser scoping + isolation test (Worker I, ADR-012) 2026-06-03 15:37:24 +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 8210f927ee feat(web): summary preview under each list card
Render a muted one-line lede beneath the title/meta of each summary
card via previewText(r.Summary, 160). New .card-preview token clamps to
one line. Regenerated views_templ.go committed (CI has no templ binary).
2026-06-03 15:11:42 +02:00
mathias dc4b06baf4 feat(web): embed video on detail page via nocookie iframe
Responsive 16:9 youtube-nocookie iframe rendered when the id is valid;
omitted (graceful) otherwise so summary/highlights/takeaways still show.
Regenerated views_templ.go.
2026-06-03 15:11:13 +02:00
mathias 23fa5427b7 feat(web): previewText truncation helper for summary cards
One-line lede for list cards: collapses whitespace, prefers the first
sentence within budget, else word-boundary truncation with an ellipsis.
Pure and rune-based (multibyte-safe). Table-driven tests cover empty,
short, first-sentence, word-boundary, and multibyte cases.
2026-06-03 15:10:50 +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
mathias b2d1909b13 feat(web): embedURL helper for privacy-friendly nocookie embeds
Validates an 11-char YouTube id and returns the youtube-nocookie embed
URL, or ("", false) so callers omit a broken iframe. Table-driven test.
2026-06-03 15:10:06 +02:00
mathias aa3f1631a6 docs(adr): ADR-012 — open Stage 1 (multi-user) with enforced RLS isolation
CI / Lint / Test / Vet (push) Successful in 8s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Has been skipped
Maintainer's call to open Stage 1 ahead of the Stage-0 gate. Non-negotiable:
multi-user ships WITH DB-enforced isolation (Postgres RLS, FORCE'd on the
owner role, per-request tapir.current_user_id) and a passing two-user isolation
test in the same slice — the VISION Stage-2 bar pulled forward, not deferred.
Replaces ADR-011's allowlist-of-one with per-subject users rows; adds
video_connections + subscriptions.
v0.1.0
2026-06-03 15:06:23 +02:00
mathias 598ba5d34f feat(runner): TAPIR_FETCH_DELAY to throttle transcript fetches
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 9s
CI / Mirror to GitHub (push) Failing after 3s
Diagnosis of a live run: caption tracks resolve fine (player + watch-page scrape),
but the timedtext baseUrl fetch returns 429 under back-to-back volume — YouTube
rate-limits the unauthenticated caption-download endpoint per IP. A per-video
delay spaces the fetches. (Follow-up: treat 429 distinctly from genuine
no-caption instead of silently degrading to SourceNone; consider Whisper if the
endpoint stays hostile at any sustainable rate.)
2026-06-03 12:20:12 +02:00
mathias 7589c09201 fix(youtube): use a browser UA for the watch-page caption scrape
CI / Lint / Test / Vet (push) Successful in 9s
CI / Build & Import (push) Successful in 9s
CI / Mirror to GitHub (push) Failing after 3s
The ANDROID InnerTube player now returns 0 captionTracks (PoToken-gated), so the
watch-page scrape is the real path — but it sent an Android *app* User-Agent,
which makes YouTube serve a page WITHOUT ytInitialPlayerResponse, so captions
came back empty. Result: 87% of a live run skipped as 'no transcript' despite
the videos having captions. A desktop-browser UA returns the player JSON with
captionTracks. Confirmed live: app-UA=0 tracks, browser-UA=1.
2026-06-03 09:35:36 +02:00
mathias c9863e9633 feat(oidc): echo caller subject in the 403 to bootstrap the allowlist
CI / Lint / Test / Vet (push) Successful in 9s
CI / Build & Import (push) Successful in 9s
CI / Mirror to GitHub (push) Failing after 2s
First-login chicken-egg: TAPIR_ALLOWED_SUBJECT can't be known until the user
logs in once, but the allowlist gates login. Echo the (non-secret, opaque)
subject in the forbidden response so the maintainer can read it in the browser,
set the 1P item, and lock the allowlist.
2026-06-03 08:53:06 +02:00
mathias c33cba3555 fix(docker): bump build image to golang:1.26 to match go.mod 1.26.1
CI / Lint / Test / Vet (push) Successful in 9s
CI / Build & Import (push) Successful in 9s
CI / Mirror to GitHub (push) Failing after 2s
go.mod requires >=1.26.1 but the Dockerfile pinned golang:1.25 -> 'go.mod
requires go >= 1.26.1 (running go 1.25.11)'. Also revert the ci.yml XDG hack:
the real rootless-buildah fix is a user ~/.config/containers/storage.conf (vfs +
writable runroot), which fixes plain buildah for every repo without workflow
changes.
2026-06-03 08:44:41 +02:00
mathias 86f8929c15 fix(ci): give rootless buildah a writable XDG_RUNTIME_DIR in build job
CI / Lint / Test / Vet (push) Successful in 9s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
The act_runner is a systemd service with no login session → XDG_RUNTIME_DIR
unset → rootless buildah uses root-owned /run/containers and fails 'mkdir
/run/containers: permission denied'. Set XDG_RUNTIME_DIR to a per-job mktemp
dir so its runroot is writable. (check + on:/go-version fixes already landed;
this unblocks the image build → registry.)
2026-06-03 08:23:40 +02:00
mathias 3a1eb3d3da fix(test): serialize packages (-p 1) to stop embedded-postgres data-dir race
CI / Lint / Test / Vet (push) Successful in 9s
CI / Build & Import (push) Failing after 0s
CI / Mirror to GitHub (push) Has been skipped
CI check failed in internal/adapters/store: parallel 'go test ./...' starts
multiple embedded-postgres instances against the shared ~/.embedded-postgres-go
data dir → 'another postgres running in data directory'. -p 1 runs one package
at a time, so only one embedded-postgres is live. (Locally flaky, deterministic
in CI's fresh env.)
2026-06-03 08:14:44 +02:00
mathias 551c0b1457 fix(ci): align go directive to estate 1.26.1 so setup-go uses host Go
CI / Lint / Test / Vet (push) Failing after 12s
CI / Build & Import (push) Has been skipped
CI / Mirror to GitHub (push) Has been skipped
CI check failed because go.mod declared 'go 1.25.0' — the only repo not on the
host's 1.26.x line. actions/setup-go then tried to download 1.25.0 on the
self-hosted runner (fails), while cobalt (1.26.1) and template-go-agent (1.26)
serve from the host/cache. Deps need >=1.25, so 1.26.1 satisfies them and
matches the estate.
2026-06-03 07:58:57 +02:00
mathias 7ee0684b81 fix(ci): quote "on" key so gitea parses workflow triggers
CI / Lint / Test / Vet (push) Failing after 9s
CI / Build & Import (push) Has been skipped
CI / Mirror to GitHub (push) Has been skipped
Bare 'on:' is a YAML boolean (Norway problem) — parsed as the key True, not
the string 'on'. Gitea's workflow loader then materialised 0 jobs and every run
failed instantly. Quoting "on": fixes dispatch.
2026-06-03 07:42:30 +02:00
mathias 0f0d72ca8c docs(ux): after-screenshots of the polished Stage-0 reader
CI / Lint / Test / Vet (push) Failing after 7s
CI / Build & Import (push) Has been skipped
CI / Mirror to GitHub (push) Has been skipped
Light + dark list, mobile (375px) card reflow, and the reader detail page in
both schemes — captured via web-shot against the seeded serve. Visible evidence
that the Top 5 UX fixes landed.
2026-06-03 00:20:22 +02:00
mathias d9b7107ccb feat(web): sleek Stage-0 reader — design system, card list, reader detail
Implements the Top 5 fixes from the Stage-0 UX review (docs/ux-review/UX-REVIEW.md):

1. Dark mode: full light+dark custom-property palette (bg/fg/muted/line/accent/
   card) under :root + @media (prefers-color-scheme: dark), applied to body.
   color-scheme: light dark is now actually honoured — summary text was invisible
   on a dark canvas before.
2. Table -> responsive card list: one card per summary (title link, channel·date
   meta, provider chip, fallback badge, action state). Single-column reflow at
   375px, no horizontal crush.
3. Minimal design system: 4/8px spacing scale, one accent, styled accent links
   (underline-on-hover), real buttons with active/pressed state, consistent
   radius and dividers — applied across list + detail.
4. Detail page as a reader: prose capped at 38rem, title->meta->summary->
   highlights->takeaways hierarchy with section rules, 1.7 line-height. Meta is
   built from non-empty parts (detailMeta) so the no-video edge case no longer
   renders a stray "· — ·".
5. Contrast + a11y: muted bumped to #595959 (~7:1, clears WCAG AA), fallback
   badge gets vertical padding + aria-label/title, friendly first-run empty
   state, hx-indicator on the filter form.

Tests updated for the card markup (table -> cards); missing date is now omitted
rather than em-dashed. task check green; HTMX action toggles verified working.
2026-06-03 00:20:22 +02:00
mathiasandClaude Opus 4.8 fa9f101abe feat(deploy): add Dockerfile and vendor htmx for deployable image
Stage-0 web UI needs a self-contained container image. Two changes:

- Dockerfile: multi-stage build (golang:1.25 builder, CGO off + static
  link, -trimpath -s -w) into distroless static nonroot. The committed
  templ output and vendored asset mean a plain `go build` suffices — no
  codegen or CDN at build/run time. Existing .gitea CI already builds and
  pushes localhost:5000/tapir:<sha> + mirrors to GitHub (deploy patch
  intentionally omitted — cutover is held), so it only needed this file.

- Vendor htmx 1.9.12 locally (internal/web/static/, embed.FS, served at
  /static/ outside the auth guard) and point Layout at /static/htmx.min.js
  instead of unpkg. The deployed UI must not depend on an external CDN
  being reachable from the cluster.

task check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:08:35 +02:00
mathiasandClaude Opus 4.8 ae239c02c2 docs(ux): Stage-0 reader UX review with screenshots
Reviewer pass against running `tapir serve` UI, seeded with 11
representative summaries (5 channels, rich+sparse, local+fallback,
varied action states). Captured via Playwright on koala k3s across
desktop/mobile and light/dark color schemes.

Two blocking findings: dark mode is unreadable (summary text near-black
on a dark canvas — `color-scheme: light dark` declared but `--fg`
hardcoded and no body background), and the list is a 6-column table that
does not reflow on mobile. Plus a sub-4.5:1 muted color, an unstyled
"admin table" surface, and small correctness nits (stray `· — ·` meta
join, cramped fallback badge). HTMX action toggles verified working.

Includes UX-REVIEW.md (severity-tagged findings + Top-5 sleek list) and
16 screenshots. No code changed — drives the next UI iteration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:06:54 +02:00
mathiasandClaude Opus 4.8 3364cf7ac3 feat(serve): wire Dex OIDC into serve when configured, else StubAuth
CI / Lint / Test / Vet (push) Failing after 8s
CI / Build & Import (push) Has been skipped
CI / Mirror to GitHub (push) Has been skipped
serve now uses oidc.DexAuth (single-user allowlist authz, ADR-011) when
TAPIR_OIDC_ISSUER is set, falling back to allow-all StubAuth for local dev.
Adds the Dex config fields (TAPIR_OIDC_ISSUER/DEX_CLIENT_ID/SECRET/
OIDC_REDIRECT_URL/SESSION_SECRET/ALLOWED_SUBJECT) + Config.DexConfigured().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:05:25 +02:00
mathias 7d525eaaa3 merge: Templ+HTMX reader pages + tapir serve (Worker C, agent/ui-pages)
CI / Lint / Test / Vet (push) Failing after 7s
CI / Build & Import (push) Has been skipped
CI / Mirror to GitHub (push) Has been skipped
# Conflicts:
#	go.mod
2026-06-02 23:57:23 +02:00
mathias d744c049d4 merge: Dex OIDC session (Worker B, agent/ui-dex-auth) 2026-06-02 23:56:14 +02:00
mathiasandClaude Opus 4.8 71689ced60 feat(web): Stage-0 reader UI — Templ+HTMX pages + tapir serve
Add the lane-C reader surface: list, detail, and an action button-group
fragment over the lane-A store reads/actions, behind the web.Auth seam.

- Templ components (base layout, list+filters, detail, ActionButtons) with
  committed *_templ.go so go build/task check work without the templ binary;
  `task generate` regenerates. Filters and action toggles are HTMX-swapped and
  degrade to plain form GET/POST (POST→303→GET) without JS.
- Handlers (internal/web): GET / (channel+date filters, in-memory),
  GET /v/{videoId}, POST /v/{videoId}/action (re-click clears, else SetAction;
  store enforces watched↔skipped exclusion), GET /healthz (no auth). Store ops
  run as the configured UserID; Auth only gates.
- `tapir serve` wires store + StubAuth{Subject: cfg.UserID} + http.Server on
  TAPIR_HTTP_ADDR (default :8080), graceful shutdown on signal. Handlers depend
  only on web.Auth — Conductor swaps StubAuth → oidc.DexAuth at merge (one line
  in cmdServe).
- Handler tests: real store (embedded-postgres) + StubAuth — list rows+state,
  HTMX fragment vs full page, channel filter, detail highlights/takeaways,
  404, action toggle+clear, no-JS redirect, bad-verb 400.

New dep: github.com/a-h/templ — the house default for typed server-rendered
HTML (CLAUDE.md stack, ui-spec.md §3). Generated code is committed so the
templ binary is build-time-optional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:47:47 +02:00
mathiasandClaude Opus 4.8 57c06e5a12 feat(web): implement Dex OIDC auth (web.Auth) with single-user allowlist
Adds internal/web/oidc.DexAuth, the production web.Auth impl behind the seam
(ADR-011, docs/ui-spec.md §6). Standard Authorization Code flow against Dex:

- Routes() mounts /auth/login (state+nonce, redirect to authorize),
  /auth/callback (code exchange, ID-token verify, nonce check, allowlist:
  sub must equal Config.AllowedSubject else 403, set session, redirect /),
  /auth/logout (clear session).
- Middleware redirects unauthenticated requests to /auth/login, slides the
  session expiry on each authenticated request; /healthz and /auth/* bypass.
- CurrentUser resolves the principal from the session cookie.
- Sessions: server-side in-memory store (single Stage-0 replica) keyed by an
  HMAC-SHA256 (HS256) signed, HttpOnly, Secure, SameSite=Lax cookie with a
  short TTL + sliding refresh. State->nonce pending map is one-time + expiring
  (replay/CSRF defense). Tokens are never logged.

Constructor New(ctx, Config, ...Option); the six-field Config (Issuer,
ClientID, ClientSecret, RedirectURL, SessionSecret, AllowedSubject) is what
cmd/tapir wires from TAPIR_OIDC_*/TAPIR_DEX_*/TAPIR_SESSION_SECRET/
TAPIR_ALLOWED_SUBJECT. Options (clock, TTL, insecure cookies) are test-only.

Tests use a fake OIDC issuer via httptest (discovery + JWKS + token endpoint
signing an RS256 ID token) — no live Dex: login 302s to authorize; callback
for the allowlisted sub sets a session and 302s to /; non-allowlisted sub 403;
middleware redirects unauthenticated and passes authenticated; logout clears;
plus expiry, tampered-cookie, and unknown-state cases.

Deps (per ADR-006 / ui-spec §6): adds github.com/coreos/go-oidc/v3 — the
homelab-standard OIDC lib, small, handles discovery + JWKS + ID-token
verification; pairs with the already-present golang.org/x/oauth2. go-jose/v4
(transitive via go-oidc) is used directly only in tests to sign the fake
issuer's tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:46:20 +02:00
mathiasandClaude Opus 4.8 e38fa792ee feat(web): add Auth seam (interface + StubAuth) for parallel UI build
CI / Lint / Test / Vet (push) Successful in 5s
CI / Build & Import (push) Failing after 0s
CI / Mirror to GitHub (push) Has been skipped
Lets the Dex session layer (lane B) and page handlers (lane C) build independently:
handlers depend only on web.Auth; oidc.DexAuth (B) and StubAuth (dev) implement it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:39:04 +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
mathiasandClaude Opus 4.8 13663c220b docs(ui): add Stage-0 Web UI spec + ADR-011 (Dex authn, action signal, tapir.d-ma.be GitOps)
CI / Build & Import (push) Failing after 0s
CI / Mirror to GitHub (push) Has been skipped
CI / Lint / Test / Vet (push) Successful in 6s
Reader + watch/skip/save (instruments the Stage-0 'acts on a summary' metric),
HTMX+Templ over the existing store, Dex OIDC login with single-user allowlist
authz (authn now, tenancy deferred), deployed at tapir.d-ma.be via Flux GitOps
with ESO secrets and in-cluster postgres18. Build decomposed into 4 gated lanes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:30:41 +02:00
mathiasandClaude Opus 4.8 af1c163a11 fix(youtube): discover via uploads playlist, not search.list (100x cheaper)
CI / Lint / Test / Vet (push) Successful in 5s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
NewVideos called search.list at 100 quota units/call. With ~143 channels one
discovery pass = 14,300 units > the 10,000/day YouTube Data API cap, exhausting
the whole day in a single loop (live quotaExceeded).

Switch to playlistItems.list (1 unit/call) against the channel's uploads
playlist. For a standard channel id UCxxxx the uploads playlist is UUxxxx,
derived at zero API cost (uploadsPlaylistID). Non-standard ids fall back to
channels.list (1 unit) to read contentDetails.relatedPlaylists.uploads. Newest-
first ordering and MaxVideosPerSubscription cap preserved.

Side effect: removing search.list also removes the accountDelegationForbidden
error that endpoint threw for one channel — no separate hardening needed.

New per-pass quota: /subscriptions (1) + ~1/channel discovery (143) + any
channels.list fallbacks ≈ 145 units/day, well under 10k. Caption fetch (ADR-010
timedtext) uses no Data API quota.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:15:27 +02:00
mathiasandClaude Opus 4.8 92da25d01e docs(research): Vimeo + Whisper transcript feasibility findings
CI / Lint / Test / Vet (push) Successful in 6s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
Vimeo texttracks API is owner-only (worse than YouTube, no public timedtext) → defer.
Whisper viable as a no-caption fallback; berget/whisper-large-v3 already on the gateway
(cloud), local options on iguana/koala carry setup+GPU-contention cost (ADR-007). Both
investigated inline (session sub-agents are network-sandboxed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:14:03 +02:00
mathiasandClaude Opus 4.8 8b14ef4add feat(youtube): acquire captions via player/timedtext baseUrl (ADR-010)
CI / Lint / Test / Vet (push) Successful in 6s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
The Data API captions.download endpoint is owner-only: every subscription
video the user does not own returned HTTP 403, producing 0 summaries and a
~150-line error spew in the first live Stage-0 run. Captions-first (ADR-007)
is sound; only the acquisition mechanism was wrong.

FetchTranscript now resolves caption tracks from the InnerTube player
response (ANDROID client, unauthenticated) and GETs the chosen track's
timedtext baseUrl with a plain http.Client — no OAuth token, which can break
the endpoint. The srv3 XML, json3, and legacy <transcript> formats all parse;
non-asr tracks in a preferred language win. Watch-page ytInitialPlayerResponse
scrape is the fallback when InnerTube returns no tracks.

Degrade, don't error (explicit quick-fix): no captionTracks, empty baseUrl, a
non-200 fetch, or an unparseable body yield Source=none, not an error. Only
genuine transport faults error — this kills the spew. OAuth stays on
ListSubscriptions/NewVideos (Data API); only transcript fetch goes unauthed.

Validated live from koala: the ANDROID client returned working baseUrls and
real transcript text for public videos the run identity does not own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:08:13 +02:00
mathiasandClaude Opus 4.8 fa16a62e6d docs(readme): add headless-on-koala runbook (op service account + SSH-tunnelled auth)
CI / Lint / Test / Vet (push) Successful in 6s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
tapir auth binds localhost:8080 on koala and prints the consent URL (no browser
auto-open), so it works headless via 'ssh -L 8080:localhost:8080 koala'. run/list/
show are already non-interactive; document the 'op run --env-file' invocation with
a service-account token so secrets resolve without an interactive signin. Also
correct the stale 'Pre-code' status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:11:57 +02:00
mathiasandClaude Opus 4.8 f6539dc88f docs(config): add .env.example the README runbook references
CI / Lint / Test / Vet (push) Successful in 6s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
Worker F's README told users to 'cp .env.example .env' but a blanket .env.*
gitignore rule silently dropped it. Un-ignore .env.example (real .env stays
ignored) and generate the template from internal/config: every TAPIR_* var,
which command needs it, accurate defaults, op/port-forward notes for demo time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:03:17 +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
mathias 17df43da53 merge: CLI reader — tapir list/show (Worker E, agent/cli-reader) 2026-06-02 21:28:54 +02:00