The registration gate, once it resolves the authenticated subject to a tapir
user_id, calls StampLogin (store-throttled to one row per user per day). Best-
effort: a stamp failure is logged and swallowed so it never breaks the request.
This is what makes the read-side Stage-0 usage signal actually accrue.
Tests cover the happy-path stamp, the same-day throttle, and that an
unregistered subject (redirected to /register) is never stamped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage-0 usage measurement (VISION/ADR-016): summary_actions captures acts
(watch/skip/save) but not reads. A reader who logs in weekly and clicks
nothing is invisible — for a reading product that return is the signal the
gate ("usage in >=2 distinct weeks") is defined on. login_events records
THAT a user was active, append-only, one row per user per active day.
Per-user isolation via the same GUC-keyed FORCE RLS policy as migration 003.
No FK to users (mirrors summary_actions) — the cascade footgun is handled by
DeleteUser in a later commit. Adds an up/down reversibility test and registers
the table in both truncate helpers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Logged-out visitors on /welcome and /invite should not see Account or
Log out. Split Layout into Layout (authenticated, full nav) and
PublicLayout (public, brand-only header). WelcomePage + InvitePage
variants now use PublicLayout.
The Stage-1 onboarding path: an invited user opens their emailed link,
sets a password, and Tapir creates their Dex local-password account so
they can log in. Mounted on root OUTSIDE Auth.Middleware — the visitor
has no Dex session yet; the token in the path is the capability.
handleInviteForm previews the token (no consume) and shows the form, or
a clear "expired / already used" page. handleInviteSubmit validates the
password BEFORE consuming the token (a typo is retryable), then claims
the invite exactly once, bcrypt-hashes (cost 12), and creates the Dex
account — mapping ErrPasswordExists -> "log in instead" and ErrForbidden
-> "contact the administrator". Off-cluster (App.Dex nil) it degrades to
a "deployed-only" message without burning the token. On success it sets
an account_created flash and redirects to /auth/login.
Welcome sub-text now states access is invite-only. Handlers depend on
narrow ports (InvitationStore, DexPasswordCreator) so tests use fakes;
cmdServe wires the store + an in-cluster dex.PasswordClient.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A discovered-but-unsummarized video whose caption fetch was rate-limited now
shows a passive ⏳ "Retrying later" chip (dim CharmDim styling, not the accent)
instead of the Summarize button — the user cannot fix a 429, the runner retries
automatically once the backoff window expires. Regenerated views_templ.go.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Logout was only reachable from /welcome. Users who are logged in had no way
to sign out from any app page (list, detail, account). Added to the shared
nav alongside Account.
Videos rows are created by `tapir run`, not when a YouTube account is
connected, so a freshly-connected account correctly shows an empty list —
but the old empty state ("No videos yet") gave no clue why or what to do.
Split it on whether the user has any connection:
- connected, no videos: a distinct accent callout telling them to run
`tapir run` to discover subscriptions.
- not connected: a prompt with a Connect YouTube button.
handleList fetches connections only when the list is empty. Includes
web-shot captures of all three states under docs/ux-review/fixes/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
429 from the caption endpoint means the IP is rate-limited (retry later),
not that the video has no captions. Distinguishing it from SourceNone is the
prerequisite for the runner's backoff/retry logic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Get Started" button drops users straight into the shared Dex flow,
which has no separate "register" option — registration completes
automatically after first login. Users new to Tapir had no signal that
signing in is also how they sign up. Reword the sub-text to say so
explicitly, keeping the single Dex CTA (sign-in and sign-up are one flow).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The .btn class sets color:var(--accent-fg), but the generic a{} and
a:visited{} rules outrank it on <a> elements, so anchor buttons (the
landing "Get Started" CTA, "Connect YouTube") rendered their label
accent-on-accent — invisible. Add a.btn / a.btn:visited to restore the
button foreground.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Package comment said "Stage-0 ... (ADR-011)" and User.Subject said
"single-user allowlist (ADR-011)". Both stale: ADR-012 opened Stage 1
(multi-user, RLS-enforced isolation). Subject is now the user_identities
lookup key (migration 004) resolving to a per-user UUID; an unknown
subject hits the registration gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a configurable fakeAuth (StubAuth can't express the logged-out case)
and assert: /welcome renders the logged-out CTA with no session and the
logged-in controls with one, unauthenticated / redirects to /welcome,
unauthenticated deep links redirect to /auth/login, and an authenticated
root still renders the list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire GET /welcome on the root mux alongside /healthz, outside
Auth.Middleware. handleWelcome peeks the session via CurrentUser (no
redirect) and renders the logged-out or logged-in WelcomePage variant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the public landing page: a static Charm-box tapir mascot (reusing the
shared tapirLine helpers and charm palette), a tagline, and a single
'Get Started' CTA into the shared Dex flow — sign-in and sign-up are the
same URL (ADR-012). Logged-in visitors get a greeting plus links back into
the app and to log out. Regenerated views_templ.go committed alongside.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Logout was bouncing the just-logged-out visitor straight back into a Dex
login. Land them on the public /welcome page instead — an intentional UX
fix. Cookie clearing and server-side session deletion are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An unauthenticated visit to / now lands on the public /welcome page
instead of bouncing straight to Dex. Deeper guarded paths still redirect
to /auth/login so the post-login round-trip returns the visitor to the
page they asked for. isPublicPath runs first, so no redirect loop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The landing page must render without a session. Add /welcome to
isPublicPath so the auth middleware lets it through (alongside /healthz
and /auth/*), and assert the bypass in the public-paths test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the plain ASCII spinner with a Charmbracelet-style TUI panel rendered in
the browser: a dark terminal card holding a rounded purple box (╭─╮╰─╯│) around a
pink block-char tapir (▄▓▀█) with mint eyes (◕ ◕) and a snout that wiggles ∩→∪→~
across three cross-faded frames, plus a lipgloss-style progress bar whose mint
fill grows over a dim track via an 8s CSS clip animation.
Colours come from named palette consts (CharmPurple/Pink/Mint/Cream/Dim) applied
as inline span styles, so the box and CSS share one source of truth. Frames are
built by a small run/line helper that pads each row to a fixed interior width —
an internal test asserts every box row is the same cell width so the border stays
flush. Honours prefers-reduced-motion (static frame + partial bar).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking "Summarize" now runs the summary in the background (when a Processor is
wired) instead of only queuing it. The handler flips the DB flag, kicks off the
work on a detached context, and returns an animated "processing" card that polls
GET /v/{id}/status every 2s via HTMX. Status returns the summary card once it
lands (no poll → polling stops), the animation while in-flight, or the Queued
card otherwise. Queue-only behaviour is unchanged when no Processor is set.
The spinner is a CSS-only cross-fade of three ASCII tapir frames — no JS, honours
prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the seam for immediate web-triggered summarization. Processor is the
optional single-video summarize port (nil = queue-only, unchanged behaviour);
ProcessingSet is an ephemeral, concurrency-safe set of in-flight (user,video)
ids so a status endpoint can show progress until the summary lands. State is
deliberately in-memory only — the DB holds the durable truth across restarts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The list now shows ALL videos (ListVideos), not just summaries. Summarized cards
are unchanged; discovered-but-unsummarized videos render with a muted "pending"
treatment and either a "Summarize" button or a "Queued" chip.
- POST /v/{videoId}/summarize queues a video (RequestSummarize) and returns the
refreshed card — it does NOT run the engine inline; `tapir run` is the single
summarization driver, which picks up the flag on its next pass.
- Account page gains an Automatic/Manual toggle (POST /account/summarize-mode →
SetAutoSummarize), shown as the current mode with a one-click switch.
- Both new POSTs degrade without JS (redirect back); HTMX swaps the fragment.
VideoCard and summarizeModeControl are extracted templ fragments reused as the
HTMX swap targets. views_templ.go regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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).
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.
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.
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.
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.
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>
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>
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>
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>