Commit Graph
184 Commits
Author SHA1 Message Date
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
mathiasandClaude Opus 4.8 61796db16b feat(cmd): wire auth/run dispatcher + demo docs
main.go dispatches `tapir auth` (interactive OAuth → persist refresh token via
SecretStore) and `tapir run` (wire YouTube source + local summarizer + store
sink, build engine, run the dedup-aware loop). Config-driven so live creds plug
in at demo time; SIGINT stops the loop cleanly. Block kept minimal so Worker E's
list/show cases union cleanly at merge.

Add .env.example documenting every TAPIR_* var and a README demo runbook. Pin
the summarizer alias-as-config decision and record the max_tokens fix in
docs/homelab-integration.md (clears two `confirm` items).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:03:21 +02:00
mathiasandClaude Opus 4.8 5645c2c012 feat(runner): end-to-end run loop with durable dedup
RunOnce walks the user's subscriptions, upserts each candidate video (assigning
its durable store id), skips videos already summarized via the store's
SeenVideoIDs (cross-restart dedup the engine's in-memory map can't provide),
and processes the rest through the engine. Loop adds an optional poll cadence;
per-item errors are collected, not fatal. Tested with fakes — no live deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:01:23 +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 460f4bb1de feat(auth): interactive YouTube OAuth code flow
`tapir auth` mints a refresh token for the single Stage-0 user: bind a local
redirect listener, print the consent URL (offline access + forced consent so
Google returns a refresh token), validate the state param, exchange the code,
and persist the refresh token through the SecretStore port. Written fresh on
x/oauth2 (ADR-006). Token is never logged or returned. Tests cover exchange,
missing-refresh-token rejection, and the full listener flow with httptest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:59:16 +02:00
mathiasandClaude Opus 4.8 d3498898c0 feat(cli): add read-only list and show subcommands
tapir list — table of stored summaries (date, title|id, channel, AI,
fallback), recent-first. tapir show <video-id> — full summary with
highlights and takeaways. DSN + user id from TAPIR_DB_DSN/TAPIR_USER_ID,
never hardcoded. main.go gains a minimal os.Args[1] dispatcher kept flat
so Worker F's auth/run cases union cleanly at merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:58:09 +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 c93b433aaf feat(secrets): file-backed SecretStore for Stage-0
Implements ports.SecretStore over a 0600 JSON file as a stand-in for op/ESO so
the demo runs without live op. Put persists atomically (temp + rename) and
merges; Get returns ErrNotFound for unknown refs so a missing token fails loud.
Behind the port, so swapping to op/ESO later is wiring, not code (ADR-002).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:57:44 +02:00
mathiasandClaude Opus 4.8 c424d88c95 feat(config): typed env-driven configuration
Parse TAPIR_* env into a typed Config with homelab defaults (gateway URL,
summarizer model, token ref, redirect addr). Secrets (gateway key, OAuth
client secret) come from env only; the refresh token never lives here — it is
addressed by an opaque ref behind the SecretStore port. Per-command validation
(ValidateForAuth/ValidateForRun) so each command demands only what it needs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:57:07 +02:00
mathiasandClaude Opus 4.8 c40b46b661 fix(llm): send generous max_tokens on every request
The copied OpenAI-compatible client sent no max_tokens. Thinking models
(qwen3, deepseek-r1) spend their budget on the reasoning trace and return
EMPTY content when max_tokens is unset, which the summarizer treats as an
error. ADR-004 says change Tapir's copy rather than the hyperguild upstream,
so set a generous default (8192) leaving room for both reasoning and output.

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