Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72bf8a5553 | ||
|
|
dece5dec44 | ||
|
|
893886a60a | ||
|
|
e44485df16 | ||
|
|
8b7ef07ba3 | ||
|
|
1cf58768ed | ||
|
|
f45ba35e25 | ||
|
|
943554a96c | ||
|
|
40a614c8d4 | ||
|
|
ce2fc62ef8 | ||
|
|
0ceacc8230 | ||
|
|
1e81965519 | ||
|
|
f50c072d65 | ||
|
|
e6f508824b | ||
|
|
689500c85e | ||
|
|
4678d473b8 | ||
|
|
c63b2de66d | ||
|
|
27aa319f1d | ||
|
|
61d4d5bc4a | ||
|
|
483730cd03 | ||
|
|
477701fea2 | ||
|
|
17fad140a6 |
@@ -46,3 +46,13 @@ TAPIR_SECRETS_FILE=
|
||||
# --- run loop -------------------------------------------------------------
|
||||
# Empty/0 = single pass. Set (e.g. 15m) to poll on that cadence.
|
||||
TAPIR_POLL_INTERVAL=
|
||||
# How long to wait before re-fetching a transcript that returned HTTP 429
|
||||
# (rate_limited). Inside the window the video is skipped without hitting the
|
||||
# caption endpoint; after it expires the video is retried. 0 = always retry.
|
||||
# Go duration; default 1h.
|
||||
TAPIR_FETCH_BACKOFF=
|
||||
|
||||
# --- invitations (tapir invite) -------------------------------------------
|
||||
# Public base URL used to build the invite link `tapir invite <email>` prints.
|
||||
# Default https://tapir.d-ma.be; no trailing slash needed.
|
||||
TAPIR_PUBLIC_URL=
|
||||
|
||||
+104
@@ -388,6 +388,107 @@ the rate gate (the batch path moves into k3s per the deferred CronJob item).
|
||||
|
||||
---
|
||||
|
||||
## ADR-015 — Per-user credentials: envelope-encrypted in PG18, not vault-stored
|
||||
|
||||
**Status:** Accepted (2026-06-03)
|
||||
|
||||
**Context.** The Stage-0/1 SecretStore (`internal/adapters/secrets/file.go`) holds per-user
|
||||
YouTube OAuth refresh tokens as a flat key-value JSON map on a PVC — explicitly a stand-in for
|
||||
"op/ESO later" (ADR-002, ADR-006). infra#86 proposed migrating it to an ESO-backed store. The
|
||||
decision spike (infra#88) found that framing subtly wrong: **ESO syncs vault→cluster at
|
||||
deploy/refresh time; it is not a runtime write API.** Per-user tokens are written *at runtime,
|
||||
per end-user* (every YouTube connect; on token rotation) — they are application state, not
|
||||
configuration. The homelab 1Password SA is also read-only, so a vault-write path would require
|
||||
a new write-capable SA, widening Tapir's blast radius to shared estate infra to store what is
|
||||
fundamentally Tapir's own row-data. Reading the actual SecretStore confirmed the shape: a
|
||||
3-method port (`Get`/`Put`/`Delete`) over opaque refs, written interactively per user.
|
||||
|
||||
**Decision.** Per-user credentials are stored **envelope-encrypted in PG18**, not in the vault:
|
||||
|
||||
1. Tokens are encrypted with a **single app-level envelope key** and stored as ciphertext in
|
||||
PG18, under the Row-Level Security already enforced and tested (ADR-012). Reads/writes go
|
||||
through the existing `withUser` RLS-scoped seam.
|
||||
2. The **envelope key** is the only secret in 1Password — fetched via the **existing read-only
|
||||
SA** (confirmed working). No new write-capable SA; no per-user vault items.
|
||||
3. The `ports.SecretStore` port is unchanged (`Get`/`Put`/`Delete`). The implementation swaps
|
||||
`FileStore` (PVC JSON) for a `PGStore` (encrypted rows). Every consumer — connect,
|
||||
disconnect, delete-account — is untouched (the port abstraction holds, ADR-003 spirit).
|
||||
4. **Infra/operator credentials** (Dex client secret, MCP-auth tokens, service tokens) stay an
|
||||
**ESO/1Password** concern. This ADR governs *per-user runtime* credentials only. The two
|
||||
classes use two mechanisms deliberately — because they are two different things (runtime
|
||||
app-state vs deploy-time config), not as a compromise. The "one mechanism" question
|
||||
(maintainer's initial preference) was answered in #88 by correctly *classifying* the
|
||||
secrets rather than unifying their storage.
|
||||
|
||||
**Consequences.**
|
||||
- Runtime credential writes are normal RLS'd DB writes — no ESO sync latency, no indirection,
|
||||
no write-SA blast radius. The interactive connect→store→use flow works without a vault
|
||||
round-trip.
|
||||
- Keeps PG18 and keeps ADR-002 intact (Supabase was considered and rejected again in #88 —
|
||||
adding a datastore to hold a few encrypted strings PG18 already holds).
|
||||
- Adds an encrypt/decrypt seam and an **envelope-key rotation** responsibility (re-encrypt the
|
||||
per-user rows under a new key). infra#89 (build) must implement and test rotation, not assume
|
||||
it — this is the real engineering cost of the choice.
|
||||
- The vault's involvement shrinks to one static key via the SA already trusted for reads.
|
||||
- **Supersedes** the "PVC stand-in for op/ESO" intent recorded in `secrets/file.go` and
|
||||
`docs/homelab-integration.md` for the *per-user* secret path (the ESO/1Password reference in
|
||||
ADR-006 stands for the *infra-cred* path).
|
||||
|
||||
**Reversibility / falsification (from infra#88).** Revisit if: per-user tokens need
|
||||
high-frequency rotation writes (weak — PG18 handles it); an estate compliance policy requires
|
||||
all credentials in 1P for a single audit surface (maintainer-knowable, not currently believed
|
||||
to hold — would favour the vault-write path on policy grounds); or envelope-key rotation proves
|
||||
operationally worse than per-secret vault rotation (the real cost #89 must prove). If none hold,
|
||||
this stands. Full reasoning + rejected candidates (write-capable SA; Supabase): the infra#88
|
||||
decision doc (`infra/docs/superpowers/handoffs/`). Build + reboot-validation: infra#89.
|
||||
|
||||
---
|
||||
|
||||
## ADR-016 — Stage 0 gate revised: "useful to me OR a friend", behavioural not feedback
|
||||
|
||||
**Status:** Accepted (2026-06-03). Revises the Stage 0 definition in VISION.md (supersedes the
|
||||
original "useful to me, specifically" gate and folds in the old Stage 1 "a trusted user returns"
|
||||
test).
|
||||
|
||||
**Context.** The original Stage 0 gate was "the maintainer reads summaries weekly for four weeks
|
||||
and acts on one." The maintainer chose to change it to include friendly users, reasoning that
|
||||
early signal from friendly users is valuable. Two sub-decisions shaped the final form:
|
||||
- *Me OR a friend* (not AND): either the maintainer or an onboarded friend showing use clears it.
|
||||
- *Behavioural, not feedback*: the test is **return usage**, not stated approval.
|
||||
|
||||
**Decision.** Stage 0 passes when, over a 3–4 week window, **either the maintainer or at least
|
||||
one onboarded friend returns to Tapir unprompted and reads/acts on summaries in ≥2 separate
|
||||
weeks.** Friend feedback is gathered and valued but is **not** the gate.
|
||||
|
||||
**Why behavioural, not feedback (the load-bearing part).** Asked-for feedback from friendly
|
||||
users is the least reliable signal in product development — politeness bias means a friend you
|
||||
onboarded will tend to say encouraging things regardless of real value. The thing actually worth
|
||||
knowing is whether they *come back on their own*. So the gate measures returns, not nice words.
|
||||
This deliberately resists the most common way a principled gate dies: being declared "passed" on
|
||||
the strength of a polite reaction.
|
||||
|
||||
**Honest note on what this change does.** This is a *guardrail edit made while the original gate
|
||||
was unmet* (Stage 0 had barely started; build had run well ahead of use-evidence). That is
|
||||
precisely the pattern that warrants scrutiny — redrawing a gate around work already done. It was
|
||||
examined on that basis and proceeds because: (a) the new gate is **not softer in kind** — it
|
||||
stays behavioural and sustained, merely broadening *who* can supply the signal; (b) friendly-user
|
||||
signal is genuinely valuable; (c) the politeness-bias guard keeps it from collapsing into
|
||||
"someone said it's nice." It is *not* a licence to treat the already-shipped Stage-1 machinery as
|
||||
evidence the gate passed — use-evidence remains open.
|
||||
|
||||
**Consequences.**
|
||||
- VISION.md Stage 0 rewritten; old Stage 1 ("a trusted user returns") folded in (it was
|
||||
near-identical to the new test); hardening renumbered to Stage 1.
|
||||
- New drift signal added: declaring the gate passed on polite feedback rather than return-usage.
|
||||
- The 2026-07-01 check-in now asks "is anyone (me or a friend) coming back unprompted?", not
|
||||
"am I using it weekly?".
|
||||
|
||||
**Reversibility.** A superseding ADR could tighten it back to maintainer-only or raise it to
|
||||
require multiple returning users. Recorded with the full rationale (including the self-scrutiny
|
||||
about editing a gate while it's unmet) so the reasoning survives, not just the new wording.
|
||||
|
||||
---
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
||||
@@ -406,6 +507,9 @@ maps to the ADR that settles it.
|
||||
| Audio-download + Whisper STT in the core path | ToS-grey, breakage-prone (yt-dlp), contends for koala GPU with the JEPA PoC; captions alone test the core hypothesis | ADR-007 |
|
||||
| Building multi-tenant SaaS / Google OAuth verification now | "Real users soon" was lowered to Future B; SaaS machinery before the Stage 0 self-use gate is the primary documented anti-goal | ADR-008, VISION |
|
||||
| Delegating the S5 reuse spike to an agent swarm | A 1-hour sequential read-and-judge with a single coupled conclusion; orchestration overhead exceeds the work, and it's Diamond-1 judgment the maintainer wanted to own | (process note) |
|
||||
| Vault-write SA for per-user OAuth tokens (ESO as runtime write path) | ESO syncs vault→cluster at deploy time, not a runtime write API; a write-SA widens blast radius to shared infra to store app row-data | ADR-015, infra#88 |
|
||||
| Supabase for per-user credential storage | Adds a second datastore for a few encrypted strings PG18 already holds; reopens ADR-002 | ADR-015, infra#88 |
|
||||
| Feedback-based Stage 0 gate (friends saying it's useful) | Politeness bias makes asked-for feedback the least reliable signal; return-usage is the real test | ADR-016 |
|
||||
|
||||
If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one —
|
||||
not a silent reversal.
|
||||
|
||||
@@ -44,50 +44,69 @@ fallback — their key, their choice.
|
||||
|
||||
## Who it is for
|
||||
|
||||
- **Now (the first customer):** the maintainer — one person, their own subscriptions,
|
||||
summaries delivered to their own store and brain.
|
||||
- **Soon (Future B):** a small number of known, trusted users (friends / beta) — each with
|
||||
their own account, isolated data, optional BYO-AI.
|
||||
- **Now (the first customers):** the maintainer and a small number of known, trusted
|
||||
friends — each with their own account, isolated data, optional BYO-AI. The maintainer is
|
||||
the first customer; friendly users provide the earliest real-world signal.
|
||||
- **Maybe (Future C, explicitly not built yet):** a public multi-tenant service. Deferred
|
||||
until there is evidence of sustained personal use **and** real demand. Building for C
|
||||
before that evidence is a known anti-goal.
|
||||
until there is evidence of sustained use **and** real demand. Building for C before that
|
||||
evidence is a known anti-goal.
|
||||
|
||||
## Definition of Success
|
||||
|
||||
Success is staged. Each stage has a single, falsifiable headline test. We do not advance
|
||||
to the next stage's ambition until the current stage's test passes.
|
||||
|
||||
### Stage 0 — Useful to me (the gate)
|
||||
### Stage 0 — Useful to me or a friend (the gate)
|
||||
|
||||
> **Headline test:** For four consecutive weeks, the maintainer reads Tapir-produced
|
||||
> summaries for their own subscriptions at least weekly, and at least once acts on a
|
||||
> summary (watches / skips / saves a video *because of* the summary).
|
||||
> **Headline test:** Over a 3–4 week window, *either* the maintainer *or* at least one
|
||||
> onboarded friend returns to Tapir and reads/acts on summaries in **≥2 separate weeks**.
|
||||
> The test is *return usage* (behavioural), not stated approval. The ideal signal is an
|
||||
> **unprompted** return (organic, not because the maintainer nudged them) — but see the
|
||||
> measurement note below: we currently cannot distinguish prompted from organic returns, so
|
||||
> in practice we count all returns and read the result with that caveat.
|
||||
|
||||
- Captions-first summarization works end-to-end for the maintainer's real subscriptions.
|
||||
- Summaries land in the maintainer's store and (optionally) brain.
|
||||
- Captions-first summarization works end-to-end for real subscriptions (the maintainer's
|
||||
and onboarded friends').
|
||||
- Summaries land in each user's own store and (optionally) brain.
|
||||
- Local-first AI produces summaries of acceptable quality without manual intervention
|
||||
most of the time.
|
||||
- **This is the gate.** Multi-user, BYO-AI-for-others, and any SaaS ambition stay deferred
|
||||
until Stage 0 holds. (Ties to the 2026-07-01 self-use check-in.)
|
||||
- **Why behavioural, not feedback.** Friend *feedback* is gathered and genuinely valuable —
|
||||
but it is **not** the gate. Asked-for feedback from friendly users is the least reliable
|
||||
signal in product development (politeness bias); whether they *come back* is the thing we
|
||||
actually care about. So the gate measures returns, not nice words.
|
||||
- **Measurement note — "unprompted" is an ideal we can't yet measure.** Whether a return was
|
||||
organic or prompted by a nudge is not captured by any data Tapir holds (it's context only
|
||||
the maintainer has). Rather than waive the standard, we name the gap: *unprompted* return
|
||||
is the signal we genuinely want; *returns* (prompted or not) is what the data can show. A
|
||||
return that needed a nudge is a weaker signal than one that didn't, and the result is read
|
||||
with that in mind. If distinguishing them ever matters enough, the maintainer tracks nudges
|
||||
manually or a future build records prompt events — neither is in scope now.
|
||||
- **Why "me OR a friend".** This replaces the original "useful to *me*, specifically" gate
|
||||
(2026-06-03 decision, recorded in DECISIONS.md ADR-016). Getting signal from friendly
|
||||
users is valuable enough to count — but the bar stays behavioural so it can't be cleared
|
||||
by a polite reaction. (Ties to the 2026-07-01 check-in.)
|
||||
- **How it's measured.** Return usage is read from two sources: `summary_actions` (timestamped
|
||||
watch/skip/save per user) answers "acted in ≥2 distinct weeks"; an append-only login-events
|
||||
table (see infra/Tapir build) answers "returned/read in ≥2 distinct weeks" even without an
|
||||
action click — the honest signal for a *reading* product. Login events accrue only from their
|
||||
deploy date onward, so the gate window's data begins then.
|
||||
- **This is the gate.** Hardening (Stage 1) and any SaaS ambition stay deferred until this
|
||||
behavioural signal exists. Note: multi-user machinery was deliberately built *ahead* of
|
||||
this gate (ADR-012) with isolation enforced — that was an explicit, recorded call, not a
|
||||
sign the gate had passed. The gate is about *evidence of use*, which is still open.
|
||||
|
||||
### Stage 1 — Useful to a few (Future B)
|
||||
|
||||
> **Headline test:** At least one trusted user other than the maintainer connects their
|
||||
> own account and, within their first month, keeps using it (returns to read summaries in
|
||||
> ≥2 separate weeks) without the maintainer hand-holding each summary.
|
||||
|
||||
- Multiple users, each with isolated accounts, credentials, and summaries.
|
||||
- A new user can self-connect a YouTube/Vimeo account and get summaries with no code change.
|
||||
- Optional BYO-AI works per-user.
|
||||
- No cross-user data leakage — demonstrable, not assumed.
|
||||
|
||||
### Stage 2 — Trustworthy at rest (hardening, still Future B)
|
||||
### Stage 1 — Trustworthy at rest (hardening, Future B)
|
||||
|
||||
> **Headline test:** Credentials (OAuth tokens, BYO-AI keys) are encrypted at rest via the
|
||||
> homelab's existing secrets convention; a documented, rehearsed recovery path exists; and
|
||||
> a deliberate isolation test (user A cannot read user B's data) passes in CI or a
|
||||
> documented manual drill.
|
||||
|
||||
- Per-user data isolation is enforced and tested (delivered early via ADR-012 RLS).
|
||||
- Per-user credentials are encrypted at rest (ADR-015 envelope encryption; build in infra#89).
|
||||
- A new user can self-connect a YouTube/Vimeo account and get summaries with no code change.
|
||||
- Optional BYO-AI works per-user.
|
||||
|
||||
### Non-goals (current)
|
||||
|
||||
- Public sign-up / billing / a marketing surface.
|
||||
@@ -98,7 +117,11 @@ to the next stage's ambition until the current stage's test passes.
|
||||
|
||||
## How we will know we are drifting
|
||||
|
||||
- We are building Stage 1+ machinery before the Stage 0 gate has passed.
|
||||
- We declare the Stage 0 gate "passed" on the strength of polite feedback rather than
|
||||
behavioural return-usage (the politeness-bias trap the gate is designed to resist).
|
||||
- We build Stage 1 hardening or Future C machinery while the Stage 0 use-evidence is still
|
||||
absent. (Multi-user machinery already shipped ahead of the gate via ADR-012 — a recorded,
|
||||
deliberate exception, not a precedent for more.)
|
||||
- A user's content reaches a third-party model without that user's explicit, per-user opt-in.
|
||||
- "Brain ingestion" starts dictating the architecture instead of being one sink behind an
|
||||
interface.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||
)
|
||||
|
||||
// inviteTTL is how long a minted invite stays claimable. A week is generous for a
|
||||
// human to act on an emailed link without leaving a stale capability around.
|
||||
const inviteTTL = 7 * 24 * time.Hour
|
||||
|
||||
// inviter is the narrow store capability cmdInvite needs — minting an invitation.
|
||||
// Defined here (not store) so runInvite is testable with a fake, no Postgres.
|
||||
type inviter interface {
|
||||
CreateInvitation(ctx context.Context, email string, ttl time.Duration) (string, error)
|
||||
}
|
||||
|
||||
// cmdInvite mints an invitation for an email and prints the claim URL. Host-side
|
||||
// only (no Dex session): the operator runs it, copies the link, and sends it.
|
||||
// Usage: tapir invite <email>.
|
||||
func cmdInvite(ctx context.Context, args []string) error {
|
||||
if len(args) < 1 || strings.TrimSpace(args[0]) == "" {
|
||||
return fmt.Errorf("usage: tapir invite <email>")
|
||||
}
|
||||
email := strings.TrimSpace(args[0])
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.DBDSN) == "" {
|
||||
return fmt.Errorf("missing required config: TAPIR_DB_DSN")
|
||||
}
|
||||
|
||||
st, err := store.New(ctx, cfg.DBDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
return runInvite(ctx, st, os.Stdout, cfg.PublicURL, email)
|
||||
}
|
||||
|
||||
// runInvite is the testable core: mint the token and print the absolute claim URL
|
||||
// to w. Pure of config/store construction so a fake inviter exercises it.
|
||||
func runInvite(ctx context.Context, inv inviter, w io.Writer, publicURL, email string) error {
|
||||
token, err := inv.CreateInvitation(ctx, email, inviteTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create invitation: %w", err)
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(publicURL), "/")
|
||||
_, err = fmt.Fprintf(w, "Invite URL (valid 7 days):\n%s/invite/%s\n", base, token)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeInviter records the mint call and returns a canned token.
|
||||
type fakeInviter struct {
|
||||
token string
|
||||
err error
|
||||
gotEmail string
|
||||
gotTTL time.Duration
|
||||
callCount int
|
||||
}
|
||||
|
||||
func (f *fakeInviter) CreateInvitation(_ context.Context, email string, ttl time.Duration) (string, error) {
|
||||
f.callCount++
|
||||
f.gotEmail, f.gotTTL = email, ttl
|
||||
return f.token, f.err
|
||||
}
|
||||
|
||||
func TestRunInvitePrintsURL(t *testing.T) {
|
||||
inv := &fakeInviter{token: "deadbeefcafe"}
|
||||
var out strings.Builder
|
||||
|
||||
err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be", "new@example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "new@example.com", inv.gotEmail)
|
||||
require.Equal(t, inviteTTL, inv.gotTTL)
|
||||
got := out.String()
|
||||
require.Contains(t, got, "https://tapir.d-ma.be/invite/deadbeefcafe")
|
||||
require.Contains(t, got, "valid 7 days")
|
||||
}
|
||||
|
||||
func TestRunInviteTrimsTrailingSlash(t *testing.T) {
|
||||
inv := &fakeInviter{token: "tok"}
|
||||
var out strings.Builder
|
||||
|
||||
err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be/", "x@example.com")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, out.String(), "https://tapir.d-ma.be/invite/tok")
|
||||
require.NotContains(t, out.String(), "//invite")
|
||||
}
|
||||
|
||||
func TestRunInvitePropagatesError(t *testing.T) {
|
||||
inv := &fakeInviter{err: errors.New("db down")}
|
||||
var out strings.Builder
|
||||
|
||||
err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be", "x@example.com")
|
||||
require.Error(t, err)
|
||||
require.Empty(t, out.String())
|
||||
}
|
||||
+20
-2
@@ -22,6 +22,7 @@ import (
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/dex"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
||||
@@ -53,6 +54,8 @@ func main() {
|
||||
err = cmdRun(ctx, log)
|
||||
case "serve":
|
||||
err = cmdServe(ctx, log)
|
||||
case "invite":
|
||||
err = cmdInvite(ctx, os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
@@ -71,6 +74,7 @@ usage:
|
||||
tapir auth one-time: authorize YouTube and store a refresh token
|
||||
tapir run detect new videos, summarize, deliver to your store
|
||||
tapir serve run the web UI (read summaries, record watch/skip/save)
|
||||
tapir invite <email> mint an invitation link for a new user (host-side)
|
||||
tapir list [-limit N] list stored summaries, recent first
|
||||
tapir show <video-id> show one summary in full
|
||||
|
||||
@@ -125,10 +129,10 @@ func cmdRun(ctx context.Context, log *slog.Logger) error {
|
||||
if engine == nil {
|
||||
return fmt.Errorf("run: incomplete summarization config (gateway, youtube credentials, secrets file)")
|
||||
}
|
||||
r := runner.New(engine.Source, st, engine, cfg.UserID, log)
|
||||
r := runner.New(engine.Source, st, engine, cfg.UserID, log, runner.WithBackoff(cfg.FetchBackoff))
|
||||
|
||||
log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
|
||||
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval)
|
||||
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval, "fetch_backoff", cfg.FetchBackoff)
|
||||
return r.Loop(ctx, cfg.PollInterval)
|
||||
}
|
||||
|
||||
@@ -179,6 +183,20 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
||||
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
||||
app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log}
|
||||
|
||||
// Email-invite onboarding (public /invite/{token}). The store validates and
|
||||
// consumes tokens; the Dex client creates the local-password account. In-cluster
|
||||
// the SA token mount is present and account creation works; off-cluster (dev) it
|
||||
// is nil and the submit handler degrades to a clear "deployed-only" message.
|
||||
app.Invitations = st
|
||||
if dexClient, err := dex.NewPasswordClient(); err == nil {
|
||||
app.Dex = dexClient
|
||||
log.Info("invite account creation enabled (in-cluster dex password client)")
|
||||
} else if errors.Is(err, dex.ErrNotInCluster) {
|
||||
log.Warn("invite account creation disabled: not in-cluster — /invite is deployed-only")
|
||||
} else {
|
||||
return fmt.Errorf("dex password client: %w", err)
|
||||
}
|
||||
|
||||
// Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client
|
||||
// credentials are present; the refresh token persists through the SecretStore
|
||||
// under a per-user ref (web.YouTubeTokenRef). Live connect also needs the
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Spec — Stage 0 usage measurement (login events)
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Status:** Ready to build · **Repo:** tapir · **Size:** small (one migration + middleware + query)
|
||||
**Why:** The Stage 0 gate (VISION, ADR-016) is *return usage in ≥2 separate weeks*. `summary_actions`
|
||||
captures *acts* (watch/skip/save) but not *reads* — a friend who logs in weekly and reads summaries
|
||||
without clicking anything is invisible. For a **reading** product that is the most important signal.
|
||||
This adds the missing data so the gate is measurable as written. Solo session, not a swarm.
|
||||
|
||||
Read `CLAUDE.md` + ADR-016 first. TBD, conventional commits, `task check` green before each commit.
|
||||
|
||||
## Scope (resist sprawl — this is NOT analytics)
|
||||
|
||||
A lightweight, append-only record of *when each user was active*, enough to answer
|
||||
"returned/read in ≥N distinct weeks". Not page-level events, not click tracking, not a funnel.
|
||||
|
||||
### 1. Migration — `login_events` (append-only)
|
||||
```
|
||||
login_events (
|
||||
id UUID PK default gen_random_uuid(),
|
||||
user_id UUID NOT NULL, -- per-user; RLS like every user-owned table
|
||||
seen_at TIMESTAMPTZ NOT NULL default NOW()
|
||||
)
|
||||
INDEX (user_id, seen_at)
|
||||
```
|
||||
- **RLS:** `FORCE ROW LEVEL SECURITY`, same policy/pattern as the other user-owned tables (the
|
||||
`tapir.current_user_id` GUC via the `withUser` seam — match migration 003). A reporting query that
|
||||
needs cross-user counts runs as the owner/maintainer outside the per-user scope, or via a dedicated
|
||||
read — decide consistently with how existing admin-ish reads are done.
|
||||
- Append-only: no updates, no deletes except the user-delete cascade. **Add to the delete-account
|
||||
cascade** (ADR-013) — `login_events` has no FK (mirrors `summary_actions`), so `DeleteUser` needs an
|
||||
explicit delete for it, and the delete test must assert it's covered. *Do not forget this* — it's the
|
||||
exact footgun the last delete work caught.
|
||||
|
||||
### 2. Middleware — throttled stamp
|
||||
- In the authenticated request path (after `CurrentUserID` resolves, inside the registration-gated
|
||||
app — NOT on `/welcome`/`/healthz`/`/auth`), record one `login_events` row **per user per day**
|
||||
(throttle: skip if a row exists for this user with `seen_at` ≥ start-of-today). One insert per active
|
||||
day, not per request — keeps the table small and the signal clean.
|
||||
- Throttle check must itself be RLS-scoped (`withUser`). Keep it cheap (indexed lookup).
|
||||
|
||||
### 3. Query — the gate report
|
||||
Provide a query (and optionally a tiny `tapir report` CLI subcommand or an admin page — your call,
|
||||
CLI is fine) answering, per user:
|
||||
```sql
|
||||
-- distinct active weeks from reads (login_events) AND acts (summary_actions), unioned
|
||||
WITH weeks AS (
|
||||
SELECT user_id, date_trunc('week', seen_at) AS wk FROM login_events
|
||||
UNION
|
||||
SELECT user_id, date_trunc('week', acted_at) FROM summary_actions
|
||||
)
|
||||
SELECT user_id, COUNT(DISTINCT wk) AS active_weeks
|
||||
FROM weeks GROUP BY user_id
|
||||
ORDER BY active_weeks DESC;
|
||||
```
|
||||
Gate passes when any user_id (maintainer or friend) reaches `active_weeks >= 2` within the window.
|
||||
|
||||
## Honesty caveats to carry (from VISION/ADR-016)
|
||||
- **"Unprompted" is not measurable here.** login_events records *that* a user returned, not *why*. A
|
||||
nudged return looks identical to an organic one. This build does not close that gap and must not
|
||||
claim to — the VISION measurement note stands: count returns, read a nudged return as weaker signal.
|
||||
(If prompt-tracking is ever wanted, that's a separate decision, not this build.)
|
||||
- **Data accrues from deploy onward.** The gate window's read-data starts when this ships — so ship
|
||||
soon (maintainer's call) rather than batching with the infra tooling session.
|
||||
- **`date_trunc('week')` is ISO/timezone-sensitive** and noisy at low volume (N=3). Two visits days
|
||||
apart can fall in the same or different weeks. Acceptable, but don't over-read a single-week-margin
|
||||
pass/fail.
|
||||
|
||||
## Out of scope
|
||||
Page/event analytics; prompt-vs-organic tracking; dashboards beyond the one gate query; anything
|
||||
touching the engine or sinks (this is web/store only — ADR-003 holds).
|
||||
|
||||
## Tests
|
||||
- Migration up/down; RLS on `login_events` (extend the two-user isolation test to cover it).
|
||||
- Throttle: N requests same day → 1 row; next day → 2nd row.
|
||||
- `DeleteUser` removes the user's `login_events` and leaves others' intact (extend the delete test).
|
||||
- The gate query returns correct distinct-week counts across a seeded reads+acts fixture.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -10,6 +10,7 @@ require (
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// Package dex creates Dex local-password accounts by writing
|
||||
// passwords.dex.coreos.com custom resources directly against the in-cluster
|
||||
// Kubernetes API. This is the write side of the invite flow: a recipient sets a
|
||||
// password on /invite/{token}, Tapir bcrypt-hashes it and POSTs a Password CR into
|
||||
// the auth namespace, and Dex (configured with kubernetes storage) then serves
|
||||
// local-password login for that email.
|
||||
//
|
||||
// Why the raw API and not kubectl/client-go: the deployed pod already carries a
|
||||
// service-account token and the cluster CA at the well-known mount paths, so a
|
||||
// single net/http POST needs no extra dependency and no shelling out. Standalone /
|
||||
// dev has no such mount — NewPasswordClient returns ErrNotInCluster and the web
|
||||
// handler degrades gracefully (account creation only works in the deployed env).
|
||||
package dex
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sentinel errors let the web handler turn API outcomes into clear user messages.
|
||||
var (
|
||||
// ErrNotInCluster means the service-account token mount is absent, so there is
|
||||
// no in-cluster API to talk to (local dev / tests). Construction-time only.
|
||||
ErrNotInCluster = errors.New("dex: not running in-cluster (no service-account token)")
|
||||
// ErrPasswordExists maps the API's 409 Conflict — a Password CR for this email
|
||||
// already exists. The handler treats it as a benign "log in instead".
|
||||
ErrPasswordExists = errors.New("dex: password already exists")
|
||||
// ErrForbidden maps 401/403 — the tapir ServiceAccount lacks create/get on
|
||||
// passwords.dex.coreos.com in the auth namespace (RBAC not applied).
|
||||
ErrForbidden = errors.New("dex: forbidden — missing RBAC for passwords.dex.coreos.com")
|
||||
)
|
||||
|
||||
// Well-known in-cluster service-account mount paths (projected by kubelet).
|
||||
const (
|
||||
saTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // path, not a secret
|
||||
saCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||
// apiServer is the in-cluster API endpoint; its TLS is validated against the
|
||||
// mounted cluster CA.
|
||||
apiServer = "https://kubernetes.default.svc"
|
||||
// passwordsPath is the Dex Password collection in the auth namespace.
|
||||
passwordsPath = "/apis/dex.coreos.com/v1/namespaces/auth/passwords"
|
||||
)
|
||||
|
||||
// PasswordClient writes Dex Password CRs against the in-cluster API. Construct it
|
||||
// with NewPasswordClient; the zero value is not usable.
|
||||
type PasswordClient struct {
|
||||
server string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewPasswordClient reads the service-account token and cluster CA from the
|
||||
// well-known mount paths and returns a client that authenticates as the pod's
|
||||
// ServiceAccount. It returns ErrNotInCluster when the token mount is absent (dev /
|
||||
// tests / standalone), so callers can detect "no Dex available" and degrade.
|
||||
func NewPasswordClient() (*PasswordClient, error) {
|
||||
token, err := os.ReadFile(saTokenPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotInCluster
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dex: read service-account token: %w", err)
|
||||
}
|
||||
|
||||
caPEM, err := os.ReadFile(saCAPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dex: read cluster CA: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return nil, errors.New("dex: cluster CA is not valid PEM")
|
||||
}
|
||||
|
||||
hc := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
|
||||
},
|
||||
}
|
||||
return newClient(apiServer, strings.TrimSpace(string(token)), hc), nil
|
||||
}
|
||||
|
||||
// newClient is the injectable constructor shared by NewPasswordClient and tests
|
||||
// (which point server at an httptest.Server and pass its TLS client).
|
||||
func newClient(server, token string, hc *http.Client) *PasswordClient {
|
||||
return &PasswordClient{server: server, token: token, http: hc}
|
||||
}
|
||||
|
||||
// password is the wire form of a Dex Password CR. NOTE: Dex's kubernetes storage
|
||||
// types the hash as []byte, which Kubernetes JSON-marshals as base64. So the
|
||||
// `hash` field must carry the base64 encoding of the bcrypt string, NOT the raw
|
||||
// bcrypt string — store the raw string and Dex's base64-decode on login yields
|
||||
// garbage and every login fails. CreatePassword does that encoding.
|
||||
type password struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Email string `json:"email"`
|
||||
Hash string `json:"hash"`
|
||||
Username string `json:"username"`
|
||||
UserID string `json:"userID"`
|
||||
}
|
||||
|
||||
// CreatePassword creates a Dex local-password account for email with the given
|
||||
// bcrypt hash and Dex user id. The CR name is derived from the email so it is a
|
||||
// valid, stable, idempotent Kubernetes object name. Returns ErrPasswordExists on
|
||||
// 409 (the account already exists) and ErrForbidden on 401/403 (RBAC missing).
|
||||
func (c *PasswordClient) CreatePassword(ctx context.Context, email, bcryptHash, userID string) error {
|
||||
body, err := json.Marshal(password{
|
||||
APIVersion: "dex.coreos.com/v1",
|
||||
Kind: "Password",
|
||||
Metadata: map[string]string{"name": passwordName(email), "namespace": "auth"},
|
||||
Email: email,
|
||||
// base64 of the bcrypt string — see the password type's NOTE.
|
||||
Hash: base64.StdEncoding.EncodeToString([]byte(bcryptHash)),
|
||||
Username: email,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dex: marshal password: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.server+passwordsPath, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("dex: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dex: create password: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated, http.StatusOK:
|
||||
return nil
|
||||
case http.StatusConflict:
|
||||
return ErrPasswordExists
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return ErrForbidden
|
||||
default:
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("dex: create password: unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
}
|
||||
|
||||
// invalidNameChars matches anything not allowed in an RFC-1123 subdomain segment
|
||||
// after the explicit @/. substitutions, so any stray character becomes '-'.
|
||||
var invalidNameChars = regexp.MustCompile(`[^a-z0-9-]`)
|
||||
|
||||
// passwordName maps an email to a valid, deterministic Kubernetes object name:
|
||||
// lowercase, '@' -> '-at-', '.' -> '-dot-', any remaining invalid char -> '-',
|
||||
// with leading/trailing '-' trimmed. Deterministic so a re-invite targets the
|
||||
// same CR (and so Dex's 409 is meaningful).
|
||||
func passwordName(email string) string {
|
||||
n := strings.ToLower(strings.TrimSpace(email))
|
||||
n = strings.ReplaceAll(n, "@", "-at-")
|
||||
n = strings.ReplaceAll(n, ".", "-dot-")
|
||||
n = invalidNameChars.ReplaceAllString(n, "-")
|
||||
n = strings.Trim(n, "-")
|
||||
if n == "" {
|
||||
n = "user"
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package dex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTestClient points a PasswordClient at an httptest server, using that
|
||||
// server's TLS client so the in-cluster TLS path is exercised without a real CA.
|
||||
func newTestClient(srv *httptest.Server) *PasswordClient {
|
||||
return newClient(srv.URL, "test-token", srv.Client())
|
||||
}
|
||||
|
||||
func TestCreatePasswordSuccess(t *testing.T) {
|
||||
var gotAuth, gotPath, gotMethod string
|
||||
var gotBody password
|
||||
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth, gotPath, gotMethod = r.Header.Get("Authorization"), r.URL.Path, r.Method
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &gotBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"kind":"Password"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := newTestClient(srv).CreatePassword(context.Background(),
|
||||
"New.User@Example.com", "$2a$12$abcdefghijklmnopqrstuv", "user-uuid-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, http.MethodPost, gotMethod)
|
||||
require.Equal(t, passwordsPath, gotPath)
|
||||
require.Equal(t, "Bearer test-token", gotAuth)
|
||||
|
||||
// Email/username carry the raw address; the CR name is sanitised + lowercased.
|
||||
require.Equal(t, "New.User@Example.com", gotBody.Email)
|
||||
require.Equal(t, "New.User@Example.com", gotBody.Username)
|
||||
require.Equal(t, "user-uuid-1", gotBody.UserID)
|
||||
require.Equal(t, "new-dot-user-at-example-dot-com", gotBody.Metadata["name"])
|
||||
require.Equal(t, "auth", gotBody.Metadata["namespace"])
|
||||
|
||||
// The hash is the BASE64 of the bcrypt string (Dex stores hash as []byte).
|
||||
decoded, err := base64.StdEncoding.DecodeString(gotBody.Hash)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "$2a$12$abcdefghijklmnopqrstuv", string(decoded))
|
||||
}
|
||||
|
||||
func TestCreatePasswordConflict(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := newTestClient(srv).CreatePassword(context.Background(), "dup@example.com", "$2a$12$x", "u")
|
||||
require.ErrorIs(t, err, ErrPasswordExists)
|
||||
}
|
||||
|
||||
func TestCreatePasswordForbidden(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := newTestClient(srv).CreatePassword(context.Background(), "x@example.com", "$2a$12$x", "u")
|
||||
require.ErrorIs(t, err, ErrForbidden)
|
||||
}
|
||||
|
||||
func TestCreatePasswordUnexpectedStatus(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("boom"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := newTestClient(srv).CreatePassword(context.Background(), "x@example.com", "$2a$12$x", "u")
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrPasswordExists)
|
||||
require.NotErrorIs(t, err, ErrForbidden)
|
||||
require.Contains(t, err.Error(), "500")
|
||||
}
|
||||
|
||||
func TestNewPasswordClientNotInCluster(t *testing.T) {
|
||||
// In the test environment the SA token mount does not exist.
|
||||
_, err := NewPasswordClient()
|
||||
require.ErrorIs(t, err, ErrNotInCluster)
|
||||
}
|
||||
|
||||
func TestPasswordName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Alice@Example.com": "alice-at-example-dot-com",
|
||||
"a.b+c@gmail.com": "a-dot-b-c-at-gmail-dot-com",
|
||||
"UPPER@DOMAIN.IO": "upper-at-domain-dot-io",
|
||||
}
|
||||
for in, want := range cases {
|
||||
require.Equal(t, want, passwordName(in), in)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Invitations are NOT routed through withUser: an invitation exists before its
|
||||
// user does, so there is no user_id to scope by and no authenticated context when
|
||||
// one is minted (host CLI) or claimed (the public /invite handler). The token is
|
||||
// the capability — single-use, time-boxed, crypto-random. The invitations table
|
||||
// is deliberately outside RLS for the same reason (see migration 009).
|
||||
|
||||
// CreateInvitation mints a single-use invite for email, valid for ttl, and
|
||||
// returns its token. The token is 32 bytes of crypto-random entropy, hex-encoded;
|
||||
// it is the only secret a recipient needs to claim the invite.
|
||||
func (s *Store) CreateInvitation(ctx context.Context, email string, ttl time.Duration) (string, error) {
|
||||
token, err := newInviteToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO invitations (email, token, expires_at)
|
||||
VALUES ($1, $2, NOW() + $3::interval)`,
|
||||
email, token, ttl.String()); err != nil {
|
||||
return "", fmt.Errorf("store: create invitation: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// PeekInvitation returns the invited email for a token that is real, unexpired,
|
||||
// and unused WITHOUT consuming it — the read the /invite form does to validate the
|
||||
// link before showing the password fields. Returns ErrNotFound when the token is
|
||||
// missing, expired, or already used. Use ClaimInvitation to consume.
|
||||
func (s *Store) PeekInvitation(ctx context.Context, token string) (string, error) {
|
||||
var email string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT email FROM invitations
|
||||
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`,
|
||||
token).Scan(&email)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store: peek invitation: %w", err)
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// ClaimInvitation atomically consumes a valid invite and returns its email. The
|
||||
// UPDATE ... WHERE used_at IS NULL AND expires_at > NOW() guarded by RETURNING
|
||||
// makes the claim a single round-trip race-free check-and-set: two concurrent
|
||||
// claims of the same token, only one updates a row, the other gets no rows and so
|
||||
// ErrNotFound. Same ErrNotFound for missing/expired/already-used tokens.
|
||||
func (s *Store) ClaimInvitation(ctx context.Context, token string) (string, error) {
|
||||
var email string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`UPDATE invitations
|
||||
SET used_at = NOW()
|
||||
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()
|
||||
RETURNING email`,
|
||||
token).Scan(&email)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store: claim invitation: %w", err)
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// newInviteToken returns 32 bytes of crypto-random entropy, hex-encoded (64
|
||||
// chars). Hex keeps the token URL-safe with no escaping in /invite/{token}.
|
||||
func newInviteToken() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("store: invite token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
)
|
||||
|
||||
// resetInvitations clears the invitations table between cases. It is not in the
|
||||
// shared resetDB TRUNCATE list (invitations is not user-owned and has no FK to
|
||||
// users), so the invite tests wipe it themselves.
|
||||
func resetInvitations(t *testing.T, p *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
_, err := p.Exec(context.Background(), `TRUNCATE invitations`)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateInvitationReturnsUsableToken(t *testing.T) {
|
||||
s, p := newStore(t), rawPool(t)
|
||||
resetInvitations(t, p)
|
||||
ctx := context.Background()
|
||||
|
||||
token, err := s.CreateInvitation(ctx, "new@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, token, 64, "32 random bytes hex-encoded")
|
||||
|
||||
// Peek does not consume: the same token previews twice.
|
||||
email, err := s.PeekInvitation(ctx, token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "new@example.com", email)
|
||||
email, err = s.PeekInvitation(ctx, token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "new@example.com", email)
|
||||
}
|
||||
|
||||
func TestCreateInvitationTokensAreUnique(t *testing.T) {
|
||||
s, p := newStore(t), rawPool(t)
|
||||
resetInvitations(t, p)
|
||||
ctx := context.Background()
|
||||
|
||||
t1, err := s.CreateInvitation(ctx, "a@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
t2, err := s.CreateInvitation(ctx, "b@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, t1, t2)
|
||||
}
|
||||
|
||||
func TestClaimInvitationHappyPath(t *testing.T) {
|
||||
s, p := newStore(t), rawPool(t)
|
||||
resetInvitations(t, p)
|
||||
ctx := context.Background()
|
||||
|
||||
token, err := s.CreateInvitation(ctx, "claim@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
email, err := s.ClaimInvitation(ctx, token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "claim@example.com", email)
|
||||
}
|
||||
|
||||
func TestClaimInvitationIsSingleUse(t *testing.T) {
|
||||
s, p := newStore(t), rawPool(t)
|
||||
resetInvitations(t, p)
|
||||
ctx := context.Background()
|
||||
|
||||
token, err := s.CreateInvitation(ctx, "once@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.ClaimInvitation(ctx, token)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second claim fails — already used.
|
||||
_, err = s.ClaimInvitation(ctx, token)
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
|
||||
// And a used token no longer previews.
|
||||
_, err = s.PeekInvitation(ctx, token)
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestClaimInvitationExpired(t *testing.T) {
|
||||
s, p := newStore(t), rawPool(t)
|
||||
resetInvitations(t, p)
|
||||
ctx := context.Background()
|
||||
|
||||
// Negative ttl => already expired.
|
||||
token, err := s.CreateInvitation(ctx, "old@example.com", -time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.PeekInvitation(ctx, token)
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
_, err = s.ClaimInvitation(ctx, token)
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestClaimInvitationNotFound(t *testing.T) {
|
||||
s, p := newStore(t), rawPool(t)
|
||||
resetInvitations(t, p)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := s.ClaimInvitation(ctx, "does-not-exist")
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
_, err = s.PeekInvitation(ctx, "does-not-exist")
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE videos DROP COLUMN IF EXISTS rate_limited_at;
|
||||
ALTER TABLE videos DROP COLUMN IF EXISTS transcript_status;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Migration 007: per-video transcript fetch status, for rate-limit backoff.
|
||||
--
|
||||
-- transcript_status records the outcome of the last transcript attempt:
|
||||
-- NULL = not yet attempted
|
||||
-- 'none' = checked, no usable transcript (permanent — SourceNone)
|
||||
-- 'fetched' = transcript resolved and summarized (summary_id not null)
|
||||
-- 'rate_limited'= the caption endpoint returned 429; retry after a backoff window
|
||||
--
|
||||
-- rate_limited_at stamps WHEN the 429 was seen, so the runner can skip re-fetching
|
||||
-- a still-throttled video until NOW() - rate_limited_at exceeds TAPIR_FETCH_BACKOFF.
|
||||
-- It is cleared (set NULL) whenever the status moves off 'rate_limited'.
|
||||
--
|
||||
-- No RLS policy changes needed: videos already has ENABLE + FORCE ROW LEVEL
|
||||
-- SECURITY (migration 003) with the videos_isolation policy. New columns inherit
|
||||
-- that protection automatically.
|
||||
ALTER TABLE videos ADD COLUMN transcript_status TEXT;
|
||||
ALTER TABLE videos ADD COLUMN rate_limited_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS invitations;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Migration 009: invitations — an email-based invite to join Tapir (Stage-1
|
||||
-- onboarding gate). Mathias mints one with `tapir invite <email>`; the recipient
|
||||
-- visits /invite/{token}, sets a password, and Tapir creates their Dex account.
|
||||
--
|
||||
-- Deliberately NOT user-owned and NOT under RLS: an invitation exists BEFORE the
|
||||
-- user does, so there is no user_id to scope by and no authenticated user context
|
||||
-- when the invite is created (host CLI) or consumed (public /invite handler, no
|
||||
-- Dex session). The token itself is the capability — a 32-byte crypto-random,
|
||||
-- single-use, time-boxed secret. Hence no `user_id` FK and no ENABLE/FORCE ROW
|
||||
-- LEVEL SECURITY here (unlike every user-owned table in migrations 003/005).
|
||||
CREATE TABLE invitations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Lookups are by token (both the claim and the form preview); the UNIQUE
|
||||
-- constraint already creates an index, this names one explicitly for clarity.
|
||||
CREATE INDEX idx_invitations_token ON invitations(token);
|
||||
@@ -49,6 +49,10 @@ type SummaryRow struct {
|
||||
// set by the web "Summarize" button and cleared by the next `tapir run`. Only
|
||||
// populated by ListVideos/GetVideoRow (summary-only reads leave it false).
|
||||
SummarizeRequested bool
|
||||
// TranscriptStatus mirrors videos.transcript_status (migration 007): "" (unset),
|
||||
// "none", "rate_limited", or "fetched". Drives the "Retrying later" list badge.
|
||||
// Only populated by ListVideos/GetVideoRow ("" on summary-only reads).
|
||||
TranscriptStatus string
|
||||
}
|
||||
|
||||
// selectSummary is the shared projection for both reads. videos is LEFT JOINed
|
||||
@@ -132,7 +136,8 @@ const selectVideo = `
|
||||
COALESCE(s.fallback_used, FALSE),
|
||||
COALESCE(s.created_at, v.seen_at),
|
||||
(s.id IS NOT NULL) AS summarized,
|
||||
v.summarize_requested
|
||||
v.summarize_requested,
|
||||
COALESCE(v.transcript_status, '')
|
||||
FROM videos v
|
||||
LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id`
|
||||
|
||||
@@ -245,6 +250,7 @@ func scanVideoRow(rows pgx.Row) (SummaryRow, error) {
|
||||
&row.CreatedAt,
|
||||
&row.Summarized,
|
||||
&row.SummarizeRequested,
|
||||
&row.TranscriptStatus,
|
||||
); err != nil {
|
||||
return SummaryRow{}, fmt.Errorf("store: scan video: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// validTranscriptStatuses bounds SetTranscriptStatus input. "" clears the status
|
||||
// (column NULL); the three named states mirror migration 007's documented values.
|
||||
var validTranscriptStatuses = map[string]bool{
|
||||
"": true,
|
||||
"none": true,
|
||||
"rate_limited": true,
|
||||
"fetched": true,
|
||||
}
|
||||
|
||||
// SetTranscriptStatus records the outcome of the last transcript attempt for a
|
||||
// video (migration 007). When status is "rate_limited" it also stamps
|
||||
// rate_limited_at = NOW() so the runner can back off; every other status clears
|
||||
// that timestamp. "" unsets the status (column NULL). An unknown status is
|
||||
// rejected. Scoped via withUser, so RLS confines the UPDATE to the caller's own
|
||||
// video; ErrNotFound when the user has no such video.
|
||||
func (s *Store) SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error {
|
||||
if !validTranscriptStatuses[status] {
|
||||
return fmt.Errorf("store: invalid transcript status %q", status)
|
||||
}
|
||||
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
ct, err := tx.Exec(ctx,
|
||||
`UPDATE videos
|
||||
SET transcript_status = NULLIF($1, ''),
|
||||
rate_limited_at = CASE WHEN $1 = 'rate_limited' THEN NOW() ELSE NULL END
|
||||
WHERE id = $2`,
|
||||
status, videoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set transcript status: %w", err)
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetTranscriptStatus returns a video's transcript_status ("" when unset/NULL).
|
||||
// Returns ErrNotFound when the user has no such video. Scoped via withUser.
|
||||
func (s *Store) GetTranscriptStatus(ctx context.Context, userID, videoID string) (string, error) {
|
||||
var status string
|
||||
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
err := tx.QueryRow(ctx,
|
||||
`SELECT COALESCE(transcript_status, '') FROM videos WHERE id = $1`, videoID).Scan(&status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", fmt.Errorf("store: get transcript status: %w", err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// RateLimitedVideoIDs returns the user's videos currently in the "rate_limited"
|
||||
// state, mapped to when the 429 was stamped (rate_limited_at). The run loop loads
|
||||
// it once per pass (mirroring SeenVideoIDs) to skip re-fetching a video still
|
||||
// inside the backoff window, saving caption requests. Scoped by user_id.
|
||||
func (s *Store) RateLimitedVideoIDs(ctx context.Context, userID string) (map[string]time.Time, error) {
|
||||
out := make(map[string]time.Time)
|
||||
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx,
|
||||
`SELECT id, rate_limited_at FROM videos
|
||||
WHERE user_id = $1 AND transcript_status = 'rate_limited' AND rate_limited_at IS NOT NULL`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: rate limited video ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
id string
|
||||
at time.Time
|
||||
)
|
||||
if err := rows.Scan(&id, &at); err != nil {
|
||||
return fmt.Errorf("store: scan rate limited id: %w", err)
|
||||
}
|
||||
out[id] = at
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("store: iterate rate limited ids: %w", err)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
)
|
||||
|
||||
func TestSetTranscriptStatus_RoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newStore(t)
|
||||
resetDB(t, rawPool(t))
|
||||
|
||||
id, err := s.UpsertVideo(ctx, ytVideo(userA, "rt12345", "round trip"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unset by default.
|
||||
got, err := s.GetTranscriptStatus(ctx, userA, id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", got)
|
||||
|
||||
for _, status := range []string{"none", "fetched", "rate_limited", ""} {
|
||||
require.NoError(t, s.SetTranscriptStatus(ctx, userA, id, status))
|
||||
got, err := s.GetTranscriptStatus(ctx, userA, id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, status, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTranscriptStatus_RejectsInvalid(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newStore(t)
|
||||
resetDB(t, rawPool(t))
|
||||
|
||||
id, err := s.UpsertVideo(ctx, ytVideo(userA, "bad12345", "bad status"))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Error(t, s.SetTranscriptStatus(ctx, userA, id, "bogus"))
|
||||
|
||||
// The rejected write left the status untouched.
|
||||
got, err := s.GetTranscriptStatus(ctx, userA, id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", got)
|
||||
}
|
||||
|
||||
func TestSetTranscriptStatus_NotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newStore(t)
|
||||
resetDB(t, rawPool(t))
|
||||
|
||||
require.ErrorIs(t, s.SetTranscriptStatus(ctx, userA, videoX, "fetched"), store.ErrNotFound)
|
||||
|
||||
_, err := s.GetTranscriptStatus(ctx, userA, videoX)
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestRateLimitedVideoIDs_StampsAndClears(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newStore(t)
|
||||
resetDB(t, rawPool(t))
|
||||
|
||||
id, err := s.UpsertVideo(ctx, ytVideo(userA, "rl12345", "rate limited"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Marking rate_limited stamps rate_limited_at, so the video appears.
|
||||
require.NoError(t, s.SetTranscriptStatus(ctx, userA, id, "rate_limited"))
|
||||
rl, err := s.RateLimitedVideoIDs(ctx, userA)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, rl, id)
|
||||
require.False(t, rl[id].IsZero(), "rate_limited_at must be stamped")
|
||||
|
||||
// Moving off rate_limited clears the timestamp, so it drops out.
|
||||
require.NoError(t, s.SetTranscriptStatus(ctx, userA, id, "fetched"))
|
||||
rl, err = s.RateLimitedVideoIDs(ctx, userA)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, rl, id)
|
||||
}
|
||||
@@ -60,6 +60,12 @@ func (a *Adapter) FetchTranscript(ctx context.Context, v domain.Video) (domain.T
|
||||
if err != nil {
|
||||
return domain.Transcript{}, fmt.Errorf("download caption track for %q: %w", v.ProviderVideoID, err)
|
||||
}
|
||||
if status == http.StatusTooManyRequests {
|
||||
// 429 means the IP is rate-limited; record for retry, not a permanent
|
||||
// absence. Degrade gracefully (no error, no text) like SourceNone, but
|
||||
// flag it distinctly so the runner backs off and retries (ADR-007/010).
|
||||
return domain.Transcript{VideoID: v.ID, UserID: v.UserID, Source: domain.SourceRateLimited}, nil
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
// Owner-only 403, region/age gate, or transient unavailability: not an error.
|
||||
return noTranscript(v), nil
|
||||
|
||||
@@ -388,6 +388,37 @@ func TestFetchTranscriptBaseURLForbiddenDegrades(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A 429 on the baseUrl fetch is the IP being rate-limited, NOT a permanent
|
||||
// absence of captions: it returns SourceRateLimited (no error, no text) so the
|
||||
// runner can record it and retry after a backoff window rather than recording a
|
||||
// false "no transcript".
|
||||
func TestFetchTranscriptRateLimitedReturnsSourceRateLimited(t *testing.T) {
|
||||
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/youtubei/v1/player":
|
||||
base := "http://" + r.Host
|
||||
_, _ = w.Write([]byte(`{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[` +
|
||||
`{"baseUrl":"` + base + `/api/timedtext?lang=en","languageCode":"en"}]}}}`))
|
||||
case "/api/timedtext":
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"})
|
||||
if err != nil {
|
||||
t.Fatalf("429 on baseUrl must degrade, not error: %v", err)
|
||||
}
|
||||
if tr.Source != domain.SourceRateLimited {
|
||||
t.Fatalf("expected SourceRateLimited on 429, got %q", tr.Source)
|
||||
}
|
||||
if tr.HasText() {
|
||||
t.Error("expected HasText() false for SourceRateLimited")
|
||||
}
|
||||
if tr.Content != "" {
|
||||
t.Errorf("expected empty content on 429, got %q", tr.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty baseUrl on the selected track degrades to SourceNone, never an error.
|
||||
func TestFetchTranscriptEmptyBaseURLDegrades(t *testing.T) {
|
||||
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -59,9 +59,20 @@ type Config struct {
|
||||
// PollInterval, when > 0, makes `run` loop on that cadence; 0 means run once.
|
||||
PollInterval time.Duration
|
||||
|
||||
// FetchBackoff is how long the run loop waits before re-fetching a transcript
|
||||
// that previously returned HTTP 429 (rate_limited). Inside the window the video
|
||||
// is skipped without hitting the caption endpoint, saving requests; after it
|
||||
// expires the video is retried. Zero means "always retry" (no backoff).
|
||||
FetchBackoff time.Duration
|
||||
|
||||
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
|
||||
HTTPAddr string
|
||||
|
||||
// PublicURL is the externally-reachable base URL of the deployed service,
|
||||
// e.g. "https://tapir.d-ma.be". Used to build absolute links handed to humans
|
||||
// (the `tapir invite` URL). No trailing slash is assumed — callers trim it.
|
||||
PublicURL string
|
||||
|
||||
// Dex OIDC (web login, ADR-011/012). When OIDCIssuer is empty, `serve` falls
|
||||
// back to the allow-all StubAuth (local dev). When set, serve uses Dex: any
|
||||
// Dex-authenticated subject may sign in, then registers a tapir user (ADR-012).
|
||||
@@ -85,6 +96,8 @@ const (
|
||||
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
|
||||
defaultOAuthRedirectAddr = "localhost:8080"
|
||||
defaultHTTPAddr = ":8080"
|
||||
defaultFetchBackoff = time.Hour
|
||||
defaultPublicURL = "https://tapir.d-ma.be"
|
||||
)
|
||||
|
||||
// Load reads the environment into a Config, applying defaults. It does not
|
||||
@@ -105,6 +118,7 @@ func Load() (Config, error) {
|
||||
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
|
||||
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
|
||||
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
|
||||
PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL),
|
||||
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
|
||||
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
|
||||
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
|
||||
@@ -124,6 +138,12 @@ func Load() (Config, error) {
|
||||
}
|
||||
c.PollInterval = interval
|
||||
|
||||
backoff, err := durationOr("TAPIR_FETCH_BACKOFF", defaultFetchBackoff)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c.FetchBackoff = backoff
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ func TestLoad_AppliesDefaults(t *testing.T) {
|
||||
if c.PollInterval != 0 {
|
||||
t.Errorf("PollInterval = %v, want 0 (run once)", c.PollInterval)
|
||||
}
|
||||
if c.FetchBackoff != defaultFetchBackoff {
|
||||
t.Errorf("FetchBackoff = %v, want default %v", c.FetchBackoff, defaultFetchBackoff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_ParsesValues(t *testing.T) {
|
||||
@@ -56,6 +59,7 @@ func TestLoad_ParsesValues(t *testing.T) {
|
||||
"TAPIR_SUMMARIZER_TIMEOUT": "90s",
|
||||
"TAPIR_DB_DSN": "postgres://x",
|
||||
"TAPIR_POLL_INTERVAL": "10m",
|
||||
"TAPIR_FETCH_BACKOFF": "30m",
|
||||
})
|
||||
|
||||
c, err := Load()
|
||||
@@ -77,6 +81,9 @@ func TestLoad_ParsesValues(t *testing.T) {
|
||||
if c.PollInterval != 10*time.Minute {
|
||||
t.Errorf("PollInterval = %v, want 10m", c.PollInterval)
|
||||
}
|
||||
if c.FetchBackoff != 30*time.Minute {
|
||||
t.Errorf("FetchBackoff = %v, want 30m", c.FetchBackoff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_RejectsBadDuration(t *testing.T) {
|
||||
|
||||
@@ -18,6 +18,12 @@ type TranscriptSource string
|
||||
const (
|
||||
SourceCaptions TranscriptSource = "captions"
|
||||
SourceNone TranscriptSource = "none"
|
||||
// SourceRateLimited records that the caption endpoint returned HTTP 429.
|
||||
// Unlike SourceNone (a permanent absence), this is a transient "retry later":
|
||||
// the IP is rate-limited, not the video caption-less. It carries no text
|
||||
// (HasText is false), so the engine degrades the same as SourceNone, but the
|
||||
// runner persists it distinctly to retry after a backoff window.
|
||||
SourceRateLimited TranscriptSource = "rate_limited"
|
||||
)
|
||||
|
||||
// User is the Tapir-side profile. At Stage 0 there is exactly one.
|
||||
|
||||
@@ -32,6 +32,12 @@ type VideoStore interface {
|
||||
GetAutoSummarize(ctx context.Context, userID string) (bool, error)
|
||||
RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error)
|
||||
ClearSummarizeRequested(ctx context.Context, userID, videoID string) error
|
||||
// RateLimitedVideoIDs maps the user's still-throttled videos to when they were
|
||||
// rate-limited, so the loop can back off without re-hitting the caption endpoint.
|
||||
RateLimitedVideoIDs(ctx context.Context, userID string) (map[string]time.Time, error)
|
||||
// SetTranscriptStatus records the outcome of a transcript attempt: "none",
|
||||
// "rate_limited" (stamps the backoff clock), or "fetched".
|
||||
SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error
|
||||
}
|
||||
|
||||
// Processor runs the core use case for a single video. *usecase.Engine
|
||||
@@ -48,14 +54,35 @@ type Runner struct {
|
||||
engine Processor
|
||||
userID string
|
||||
log *slog.Logger
|
||||
backoff time.Duration // rate-limit retry window; 0 = always retry
|
||||
now func() time.Time // injectable clock (tests); defaults to time.Now
|
||||
}
|
||||
|
||||
// Option configures a Runner at construction. Variadic so existing call sites
|
||||
// stay valid as new knobs (backoff, clock) are added.
|
||||
type Option func(*Runner)
|
||||
|
||||
// WithBackoff sets the rate-limit retry window. A video that returned HTTP 429 is
|
||||
// skipped (no caption fetch) until this much time has passed; 0 = always retry.
|
||||
func WithBackoff(d time.Duration) Option { return func(r *Runner) { r.backoff = d } }
|
||||
|
||||
// WithClock overrides the clock used for backoff comparisons. Tests inject a
|
||||
// fixed time; production leaves the time.Now default.
|
||||
func WithClock(now func() time.Time) Option { return func(r *Runner) { r.now = now } }
|
||||
|
||||
// New builds a Runner. A nil logger falls back to slog.Default.
|
||||
func New(src ports.VideoSource, store VideoStore, engine Processor, userID string, log *slog.Logger) *Runner {
|
||||
func New(src ports.VideoSource, store VideoStore, engine Processor, userID string, log *slog.Logger, opts ...Option) *Runner {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Runner{src: src, store: store, engine: engine, userID: userID, log: log}
|
||||
r := &Runner{src: src, store: store, engine: engine, userID: userID, log: log, now: time.Now}
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
if r.now == nil {
|
||||
r.now = time.Now
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Stats summarizes one RunOnce pass.
|
||||
@@ -65,6 +92,7 @@ type Stats struct {
|
||||
SkippedSeen int
|
||||
SkippedNoText int
|
||||
SkippedManual int // discovered but not queued, in manual mode
|
||||
SkippedRateLimited int // 429'd previously and still inside the backoff window
|
||||
Errors int
|
||||
}
|
||||
|
||||
@@ -104,6 +132,18 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rate-limit backoff: videos that 429'd on a prior pass, mapped to when. Inside
|
||||
// the backoff window they are skipped before any caption fetch, so a throttled
|
||||
// IP is not hammered. Loaded once per pass (like seen/requested). Disabled when
|
||||
// backoff <= 0 ("always retry").
|
||||
var rateLimited map[string]time.Time
|
||||
if r.backoff > 0 {
|
||||
rateLimited, err = r.store.RateLimitedVideoIDs(ctx, r.userID)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("runner: load rate-limited videos: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
subs, err := r.src.ListSubscriptions(ctx, r.userID)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("runner: list subscriptions: %w", err)
|
||||
@@ -142,6 +182,15 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Still inside the rate-limit backoff window: skip without fetching, so
|
||||
// we don't re-hit a caption endpoint that just 429'd us. After the window
|
||||
// expires the video falls through and is retried normally.
|
||||
if at, ok := rateLimited[id]; ok && r.now().Sub(at) < r.backoff {
|
||||
stats.SkippedRateLimited++
|
||||
r.log.Info("skipped video (rate-limited, backing off)", "video", v.ProviderVideoID, "title", v.Title)
|
||||
continue
|
||||
}
|
||||
|
||||
if fetchDelay > 0 {
|
||||
time.Sleep(fetchDelay)
|
||||
}
|
||||
@@ -152,11 +201,28 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case res.Skipped && res.TranscriptSource == string(domain.SourceRateLimited):
|
||||
// Fresh 429 this pass: persist rate_limited (stamps the backoff clock)
|
||||
// so the next pass skips it until the window expires.
|
||||
stats.SkippedRateLimited++
|
||||
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "rate_limited"); err != nil {
|
||||
errs = append(errs, fmt.Errorf("set rate_limited status %q: %w", v.ProviderVideoID, err))
|
||||
stats.Errors++
|
||||
}
|
||||
r.log.Info("skipped video (rate-limited)", "video", v.ProviderVideoID, "title", v.Title)
|
||||
case res.Skipped:
|
||||
stats.SkippedNoText++
|
||||
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "none"); err != nil {
|
||||
errs = append(errs, fmt.Errorf("set none status %q: %w", v.ProviderVideoID, err))
|
||||
stats.Errors++
|
||||
}
|
||||
r.log.Info("skipped video (no transcript)", "video", v.ProviderVideoID, "title", v.Title)
|
||||
case res.Summary != nil:
|
||||
stats.Summarized++
|
||||
if err := r.store.SetTranscriptStatus(ctx, r.userID, id, "fetched"); err != nil {
|
||||
errs = append(errs, fmt.Errorf("set fetched status %q: %w", v.ProviderVideoID, err))
|
||||
stats.Errors++
|
||||
}
|
||||
// In manual mode the video was processed because it was queued;
|
||||
// clear the flag so it is not re-summarized and the UI drops the
|
||||
// "Queued" chip. (Auto mode never sets the flag.)
|
||||
@@ -184,7 +250,8 @@ func (r *Runner) Loop(ctx context.Context, interval time.Duration) error {
|
||||
r.log.Info("run pass complete",
|
||||
"candidates", stats.Candidates, "summarized", stats.Summarized,
|
||||
"skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
|
||||
"skipped_manual", stats.SkippedManual, "errors", stats.Errors)
|
||||
"skipped_manual", stats.SkippedManual, "skipped_rate_limited", stats.SkippedRateLimited,
|
||||
"errors", stats.Errors)
|
||||
if err != nil {
|
||||
r.log.Warn("run pass had errors", "err", err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -48,6 +49,8 @@ type fakeStore struct {
|
||||
auto bool
|
||||
requested map[string]bool
|
||||
cleared []string
|
||||
rateLimited map[string]time.Time // id -> when 429'd (seeds the backoff window)
|
||||
statuses map[string]string // id -> last SetTranscriptStatus value
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) {
|
||||
@@ -80,6 +83,22 @@ func (f *fakeStore) ClearSummarizeRequested(_ context.Context, _, videoID string
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RateLimitedVideoIDs(_ context.Context, _ string) (map[string]time.Time, error) {
|
||||
cp := make(map[string]time.Time, len(f.rateLimited))
|
||||
for k, v := range f.rateLimited {
|
||||
cp[k] = v
|
||||
}
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetTranscriptStatus(_ context.Context, _, videoID, status string) error {
|
||||
if f.statuses == nil {
|
||||
f.statuses = map[string]string{}
|
||||
}
|
||||
f.statuses[videoID] = status
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeSummarizer struct{}
|
||||
|
||||
func (fakeSummarizer) Summarize(_ context.Context, v domain.Video, _ domain.Transcript) (domain.Summary, error) {
|
||||
@@ -207,6 +226,62 @@ func TestRunOnce_ManualMode_ProcessesRequested(t *testing.T) {
|
||||
require.Equal(t, []string{"id-v1"}, st.cleared, "the queue flag is cleared after summarizing")
|
||||
}
|
||||
|
||||
// noFetchSource fails the test if a transcript fetch happens — used to prove the
|
||||
// runner skips a rate-limited video before touching the caption endpoint.
|
||||
type noFetchSource struct{ *fakeSource }
|
||||
|
||||
func (noFetchSource) FetchTranscript(context.Context, domain.Video) (domain.Transcript, error) {
|
||||
panic("FetchTranscript must not be called for a rate-limited video within the backoff window")
|
||||
}
|
||||
|
||||
func TestRunOnce_SkipsRateLimitedWithinBackoff(t *testing.T) {
|
||||
base := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
|
||||
src := &fakeSource{
|
||||
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
||||
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
|
||||
}
|
||||
// v1 was rate-limited 5m ago; backoff is 1h, so it is still inside the window.
|
||||
st := &fakeStore{
|
||||
seen: map[string]bool{},
|
||||
auto: true,
|
||||
rateLimited: map[string]time.Time{"id-v1": base.Add(-5 * time.Minute)},
|
||||
}
|
||||
eng := usecase.NewEngine(noFetchSource{src}, fakeSummarizer{}, &recordingSink{})
|
||||
r := runner.New(noFetchSource{src}, st, eng, testUser, quietLogger(),
|
||||
runner.WithBackoff(time.Hour), runner.WithClock(func() time.Time { return base }))
|
||||
|
||||
stats, err := r.RunOnce(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, stats.SkippedRateLimited, "still throttled -> skipped")
|
||||
require.Equal(t, 0, stats.Summarized)
|
||||
require.Empty(t, st.statuses, "no status write: the engine was never invoked")
|
||||
}
|
||||
|
||||
func TestRunOnce_RetriesRateLimitedAfterBackoff(t *testing.T) {
|
||||
base := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
|
||||
src := &fakeSource{
|
||||
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
||||
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
|
||||
}
|
||||
// v1 was rate-limited 2h ago; backoff is 1h, so the window has expired.
|
||||
st := &fakeStore{
|
||||
seen: map[string]bool{},
|
||||
auto: true,
|
||||
rateLimited: map[string]time.Time{"id-v1": base.Add(-2 * time.Hour)},
|
||||
}
|
||||
sink := &recordingSink{}
|
||||
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
||||
r := runner.New(src, st, eng, testUser, quietLogger(),
|
||||
runner.WithBackoff(time.Hour), runner.WithClock(func() time.Time { return base }))
|
||||
|
||||
stats, err := r.RunOnce(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, stats.SkippedRateLimited, "window expired -> not skipped")
|
||||
require.Equal(t, 1, stats.Summarized, "the video is retried and summarized")
|
||||
require.Len(t, sink.delivered, 1)
|
||||
require.Equal(t, "fetched", st.statuses["id-v1"], "status advances to fetched on success")
|
||||
}
|
||||
|
||||
func TestRunOnce_UpsertsEveryCandidate(t *testing.T) {
|
||||
src := &fakeSource{
|
||||
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
||||
|
||||
@@ -45,6 +45,11 @@ type ProcessResult struct {
|
||||
Video domain.Video
|
||||
Skipped bool
|
||||
Reason string // set when Skipped (e.g. "no transcript")
|
||||
// TranscriptSource is how the transcript resolved (or that there was none):
|
||||
// the domain.TranscriptSource value as a string. The runner reads it to tell a
|
||||
// permanent absence (SourceNone) from a transient 429 (SourceRateLimited) and
|
||||
// persist the right transcript_status. Empty when a fetch error short-circuits.
|
||||
TranscriptSource string
|
||||
Summary *domain.Summary // nil when Skipped
|
||||
}
|
||||
|
||||
@@ -59,7 +64,9 @@ func (e *Engine) ProcessNewVideo(ctx context.Context, v domain.Video) (ProcessRe
|
||||
if !t.HasText() {
|
||||
// No usable transcript: record the skip, produce no summary, deliver nothing
|
||||
// (captions-first, ADR-007; the watcher uses this to avoid reprocessing).
|
||||
return ProcessResult{Video: v, Skipped: true, Reason: "no transcript"}, nil
|
||||
// Surface the source so the runner separates SourceNone (permanent) from
|
||||
// SourceRateLimited (retry after a backoff window).
|
||||
return ProcessResult{Video: v, Skipped: true, Reason: "no transcript", TranscriptSource: string(t.Source)}, nil
|
||||
}
|
||||
|
||||
sum, err := e.AI.Summarize(ctx, v, t)
|
||||
@@ -76,7 +83,7 @@ func (e *Engine) ProcessNewVideo(ctx context.Context, v domain.Video) (ProcessRe
|
||||
}
|
||||
}
|
||||
|
||||
return ProcessResult{Video: v, Summary: &sum}, errors.Join(errs...)
|
||||
return ProcessResult{Video: v, Summary: &sum, TranscriptSource: string(t.Source)}, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// ProcessNewVideos walks a user's subscriptions and processes each newly seen
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package web
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Test-only handles to the unexported invite handlers so the external web_test
|
||||
// package can mount them on an httptest mux (and get PathValue routing) without
|
||||
// standing up the full Router + auth stack. export_test.go compiles only under
|
||||
// `go test`, so these never widen the package's real API.
|
||||
func (a *App) HandleInviteFormForTest(w http.ResponseWriter, r *http.Request) {
|
||||
a.handleInviteForm(w, r)
|
||||
}
|
||||
func (a *App) HandleInviteSubmitForTest(w http.ResponseWriter, r *http.Request) {
|
||||
a.handleInviteSubmit(w, r)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
flashDisconnected = "disconnected"
|
||||
flashDeleted = "deleted"
|
||||
flashRegistered = "registered"
|
||||
flashAccountCreated = "account_created"
|
||||
)
|
||||
|
||||
// flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to
|
||||
|
||||
@@ -68,6 +68,14 @@ type App struct {
|
||||
// Processing tracks in-flight immediate summarizations so the status endpoint
|
||||
// shows the animation until the summary lands. The zero value is ready to use.
|
||||
Processing ProcessingSet
|
||||
// Invitations validates and consumes email-invite tokens for the public
|
||||
// /invite/{token} flow. Nil = the invite routes report "invalid" (the flow is
|
||||
// effectively off). *store.Store satisfies it.
|
||||
Invitations InvitationStore
|
||||
// Dex creates the Dex local-password account when an invite is claimed. Nil =
|
||||
// not in-cluster (dev): the submit handler degrades to a clear "deployed-only"
|
||||
// message instead of creating an account. *dex.PasswordClient satisfies it.
|
||||
Dex DexPasswordCreator
|
||||
}
|
||||
|
||||
func (a *App) logger() *slog.Logger {
|
||||
@@ -87,6 +95,11 @@ func (a *App) Router() http.Handler {
|
||||
root.Handle("GET /static/", staticHandler())
|
||||
root.Handle("/auth/", a.Auth.Routes())
|
||||
|
||||
// Email invitation claim (public — the visitor has no Dex session yet, so this
|
||||
// sits OUTSIDE Auth.Middleware). The token in the path is the capability.
|
||||
root.HandleFunc("GET /invite/{token}", a.handleInviteForm)
|
||||
root.HandleFunc("POST /invite/{token}", a.handleInviteSubmit)
|
||||
|
||||
app := http.NewServeMux()
|
||||
app.HandleFunc("GET /{$}", a.handleList)
|
||||
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
||||
@@ -155,11 +168,24 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rows = f.apply(rows)
|
||||
|
||||
if isHTMX(r) {
|
||||
a.render(w, r, summaryList(rows))
|
||||
// hasConnected drives the empty state: a fresh account with a connection but
|
||||
// no `tapir run` yet has zero rows, and we want it to read "connected, run
|
||||
// tapir" rather than "nothing here". Only needed when the list is empty.
|
||||
hasConnected := false
|
||||
if len(rows) == 0 {
|
||||
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
a.serverError(w, r, "connections for user", err)
|
||||
return
|
||||
}
|
||||
a.render(w, r, ListPage(rows, f, takeFlash(w, r)))
|
||||
hasConnected = len(conns) > 0
|
||||
}
|
||||
|
||||
if isHTMX(r) {
|
||||
a.render(w, r, summaryList(rows, hasConnected))
|
||||
return
|
||||
}
|
||||
a.render(w, r, ListPage(rows, f, takeFlash(w, r), hasConnected))
|
||||
}
|
||||
|
||||
// handleDetail renders one summary in full (highlights, takeaways, action group).
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/dex"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
)
|
||||
|
||||
// InvitationStore is the narrow store surface the public invite flow needs:
|
||||
// PeekInvitation validates a token without consuming it (the GET form preview);
|
||||
// ClaimInvitation consumes it atomically (the POST). *store.Store satisfies it.
|
||||
// Deliberately separate from Store (the user-scoped surface) — invites run with no
|
||||
// authenticated user (the user does not exist yet).
|
||||
type InvitationStore interface {
|
||||
PeekInvitation(ctx context.Context, token string) (email string, err error)
|
||||
ClaimInvitation(ctx context.Context, token string) (email string, err error)
|
||||
}
|
||||
|
||||
// DexPasswordCreator creates a Dex local-password account from a bcrypt hash.
|
||||
// *dex.PasswordClient satisfies it; tests substitute a fake. A nil App.Dex means
|
||||
// the process is not in-cluster (dev) and account creation is unavailable.
|
||||
type DexPasswordCreator interface {
|
||||
CreatePassword(ctx context.Context, email, bcryptHash, userID string) error
|
||||
}
|
||||
|
||||
// bcryptCost is the work factor for hashing invite passwords. 12 is a sensible
|
||||
// 2020s default — noticeably slow to brute-force, fast enough for a single login.
|
||||
const bcryptCost = 12
|
||||
|
||||
// minPasswordLen is the floor for an invite password. Length beats composition
|
||||
// rules; 8 is the practical minimum we accept.
|
||||
const minPasswordLen = 8
|
||||
|
||||
// handleInviteForm renders the set-password form for a valid invite token, or a
|
||||
// clear "expired / already used" page otherwise. It only previews the token
|
||||
// (PeekInvitation) — the token is consumed on submit, not on view, so a refresh
|
||||
// or a link-preview fetch never burns the invite.
|
||||
func (a *App) handleInviteForm(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
if a.Invitations == nil {
|
||||
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
|
||||
return
|
||||
}
|
||||
email, err := a.Invitations.PeekInvitation(r.Context(), token)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.serverError(w, r, "peek invitation", err)
|
||||
return
|
||||
}
|
||||
a.render(w, r, InvitePage(email, token, ""))
|
||||
}
|
||||
|
||||
// handleInviteSubmit validates the chosen password, consumes the invite, and
|
||||
// creates the Dex local-password account. Order matters (see inline): password is
|
||||
// validated first (no token burned on a typo), then the invite is claimed exactly
|
||||
// once, then the Dex account is created. On success the visitor is sent to the Dex
|
||||
// login to sign in with the email + new password.
|
||||
func (a *App) handleInviteSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
if a.Invitations == nil {
|
||||
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
password := r.FormValue("password")
|
||||
confirm := r.FormValue("password_confirm")
|
||||
|
||||
// 1. Validate before consuming the token, so a mismatch/typo is retryable.
|
||||
if len(password) < minPasswordLen {
|
||||
a.reshowInvite(w, r, token, "Password must be at least 8 characters.")
|
||||
return
|
||||
}
|
||||
if password != confirm {
|
||||
a.reshowInvite(w, r, token, "Passwords do not match.")
|
||||
return
|
||||
}
|
||||
|
||||
// Off-cluster (dev): we cannot create a Dex account. Degrade clearly WITHOUT
|
||||
// consuming the invite, so it still works once deployed.
|
||||
if a.Dex == nil {
|
||||
a.render(w, r, InviteNoticePage("Account creation only works in the deployed environment.", false))
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Consume the invite exactly once. If the token vanished between GET and
|
||||
// POST (expired, replay, concurrent claim) this is where it surfaces.
|
||||
email, err := a.Invitations.ClaimInvitation(r.Context(), token)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.serverError(w, r, "claim invitation", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Hash the password (cost 12). The Dex client base64-encodes it for the CR.
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
a.serverError(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Create the Dex local-password account.
|
||||
userID, err := newID()
|
||||
if err != nil {
|
||||
a.serverError(w, r, "new user id", err)
|
||||
return
|
||||
}
|
||||
switch err := a.Dex.CreatePassword(r.Context(), email, string(hash), userID); {
|
||||
case err == nil:
|
||||
// 5. Off to the Dex login — a flash surfaces on the first page after login.
|
||||
setFlash(w, flashAccountCreated)
|
||||
http.Redirect(w, r, loginPath, http.StatusSeeOther)
|
||||
case errors.Is(err, dex.ErrPasswordExists):
|
||||
a.render(w, r, InviteNoticePage("An account with this email already exists. Try logging in.", true))
|
||||
case errors.Is(err, dex.ErrForbidden):
|
||||
a.render(w, r, InviteNoticePage("Unable to create your Dex account — please contact the administrator.", false))
|
||||
default:
|
||||
a.serverError(w, r, "create dex password", err)
|
||||
}
|
||||
}
|
||||
|
||||
// reshowInvite re-renders the password form with a validation message, re-fetching
|
||||
// the email from the (still-unconsumed) token. A token that became invalid in the
|
||||
// meantime falls back to the expired/used page.
|
||||
func (a *App) reshowInvite(w http.ResponseWriter, r *http.Request, token, errMsg string) {
|
||||
email, err := a.Invitations.PeekInvitation(r.Context(), token)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
a.renderStatus(w, r, http.StatusOK, InviteInvalidPage())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.serverError(w, r, "peek invitation", err)
|
||||
return
|
||||
}
|
||||
a.renderStatus(w, r, http.StatusBadRequest, InvitePage(email, token, errMsg))
|
||||
}
|
||||
|
||||
// newID returns a fresh random RFC-4122 v4 UUID for the Dex userID field
|
||||
// (crypto/rand, no new dependency). Kept local rather than coupling web to the
|
||||
// store package's unexported generator.
|
||||
func newID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("web: new id: %w", err)
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/dex"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||
)
|
||||
|
||||
// fakeDex captures the CreatePassword call and returns a canned error.
|
||||
type fakeDex struct {
|
||||
called bool
|
||||
email, hash, userID string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDex) CreatePassword(_ context.Context, email, hash, userID string) error {
|
||||
f.called = true
|
||||
f.email, f.hash, f.userID = email, hash, userID
|
||||
return f.err
|
||||
}
|
||||
|
||||
func resetInvites(t *testing.T, p *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
_, err := p.Exec(context.Background(), `TRUNCATE invitations`)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// inviteMux mounts only the two public invite routes against app, so PathValue
|
||||
// ("token") is populated exactly as in production without the full Router/auth.
|
||||
func inviteMux(app *web.App) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /invite/{token}", app.HandleInviteFormForTest)
|
||||
mux.HandleFunc("POST /invite/{token}", app.HandleInviteSubmitForTest)
|
||||
return mux
|
||||
}
|
||||
|
||||
func newInvite(t *testing.T, st *store.Store, email string, ttl time.Duration) string {
|
||||
t.Helper()
|
||||
token, err := st.CreateInvitation(context.Background(), email, ttl)
|
||||
require.NoError(t, err)
|
||||
return token
|
||||
}
|
||||
|
||||
func TestInviteFormValidToken(t *testing.T) {
|
||||
st, p := newStore(t), rawPool(t)
|
||||
resetInvites(t, p)
|
||||
token := newInvite(t, st, "invitee@example.com", time.Hour)
|
||||
|
||||
app := &web.App{Invitations: st, Dex: &fakeDex{}}
|
||||
rr := httptest.NewRecorder()
|
||||
inviteMux(app).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/invite/"+token, nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
body := rr.Body.String()
|
||||
require.Contains(t, body, "invitee@example.com")
|
||||
require.Contains(t, body, "Create my account")
|
||||
}
|
||||
|
||||
func TestInviteFormInvalidToken(t *testing.T) {
|
||||
st, p := newStore(t), rawPool(t)
|
||||
resetInvites(t, p)
|
||||
|
||||
app := &web.App{Invitations: st, Dex: &fakeDex{}}
|
||||
rr := httptest.NewRecorder()
|
||||
inviteMux(app).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/invite/nope", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), "no longer valid")
|
||||
}
|
||||
|
||||
func postInvite(app *web.App, token string, form url.Values) *httptest.ResponseRecorder {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/invite/"+token, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
inviteMux(app).ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestInviteSubmitPasswordMismatch(t *testing.T) {
|
||||
st, p := newStore(t), rawPool(t)
|
||||
resetInvites(t, p)
|
||||
token := newInvite(t, st, "a@example.com", time.Hour)
|
||||
fd := &fakeDex{}
|
||||
app := &web.App{Invitations: st, Dex: fd}
|
||||
|
||||
rr := postInvite(app, token, url.Values{"password": {"longenough1"}, "password_confirm": {"different1"}})
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), "do not match")
|
||||
require.False(t, fd.called)
|
||||
// Token not consumed — still claimable.
|
||||
_, err := st.PeekInvitation(context.Background(), token)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestInviteSubmitShortPassword(t *testing.T) {
|
||||
st, p := newStore(t), rawPool(t)
|
||||
resetInvites(t, p)
|
||||
token := newInvite(t, st, "a@example.com", time.Hour)
|
||||
fd := &fakeDex{}
|
||||
app := &web.App{Invitations: st, Dex: fd}
|
||||
|
||||
rr := postInvite(app, token, url.Values{"password": {"short"}, "password_confirm": {"short"}})
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), "at least 8")
|
||||
require.False(t, fd.called)
|
||||
}
|
||||
|
||||
func TestInviteSubmitValidCreatesAccount(t *testing.T) {
|
||||
st, p := newStore(t), rawPool(t)
|
||||
resetInvites(t, p)
|
||||
token := newInvite(t, st, "new@example.com", time.Hour)
|
||||
fd := &fakeDex{}
|
||||
app := &web.App{Invitations: st, Dex: fd}
|
||||
|
||||
rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}})
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rr.Code)
|
||||
require.Equal(t, "/auth/login", rr.Header().Get("Location"))
|
||||
|
||||
require.True(t, fd.called)
|
||||
require.Equal(t, "new@example.com", fd.email)
|
||||
require.NotEmpty(t, fd.userID)
|
||||
// The handler hands Dex a real bcrypt hash of the chosen password.
|
||||
require.NoError(t, bcrypt.CompareHashAndPassword([]byte(fd.hash), []byte("correcthorse")))
|
||||
|
||||
// Flash queued for the post-login page.
|
||||
require.Contains(t, rr.Header().Get("Set-Cookie"), "tapir_flash=account_created")
|
||||
|
||||
// Token consumed — a second claim fails.
|
||||
_, err := st.ClaimInvitation(context.Background(), token)
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestInviteSubmitDevModeNoDex(t *testing.T) {
|
||||
st, p := newStore(t), rawPool(t)
|
||||
resetInvites(t, p)
|
||||
token := newInvite(t, st, "dev@example.com", time.Hour)
|
||||
app := &web.App{Invitations: st, Dex: nil} // not in-cluster
|
||||
|
||||
rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}})
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), "deployed environment")
|
||||
// Token preserved so it still works once deployed.
|
||||
_, err := st.PeekInvitation(context.Background(), token)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestInviteSubmitPasswordExists(t *testing.T) {
|
||||
st, p := newStore(t), rawPool(t)
|
||||
resetInvites(t, p)
|
||||
token := newInvite(t, st, "dup@example.com", time.Hour)
|
||||
app := &web.App{Invitations: st, Dex: &fakeDex{err: dex.ErrPasswordExists}}
|
||||
|
||||
rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}})
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), "already exists")
|
||||
}
|
||||
|
||||
func TestInviteSubmitForbidden(t *testing.T) {
|
||||
st, p := newStore(t), rawPool(t)
|
||||
resetInvites(t, p)
|
||||
token := newInvite(t, st, "x@example.com", time.Hour)
|
||||
app := &web.App{Invitations: st, Dex: &fakeDex{err: dex.ErrForbidden}}
|
||||
|
||||
rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}})
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), "administrator")
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
)
|
||||
|
||||
func renderVideoCard(t *testing.T, r store.SummaryRow) string {
|
||||
t.Helper()
|
||||
var sb strings.Builder
|
||||
if err := VideoCard(r).Render(context.Background(), &sb); err != nil {
|
||||
t.Fatalf("render VideoCard: %v", err)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// A rate-limited, unsummarized video shows the passive "Retrying later" badge and
|
||||
// hides the Summarize button — the user can't fix it, retry is automatic.
|
||||
func TestVideoCard_RateLimitedShowsRetryingBadge(t *testing.T) {
|
||||
html := renderVideoCard(t, store.SummaryRow{
|
||||
VideoID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
Title: "Throttled Video",
|
||||
Summarized: false,
|
||||
TranscriptStatus: "rate_limited",
|
||||
})
|
||||
|
||||
if !strings.Contains(html, "Retrying later") {
|
||||
t.Errorf("expected a 'Retrying later' badge, got:\n%s", html)
|
||||
}
|
||||
if !strings.Contains(html, "chip-retry") {
|
||||
t.Errorf("expected the passive chip-retry styling, got:\n%s", html)
|
||||
}
|
||||
if strings.Contains(html, ">Summarize<") {
|
||||
t.Errorf("the Summarize button must be hidden for a rate-limited video, got:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary unsummarized video still offers the Summarize button.
|
||||
func TestVideoCard_UnsummarizedShowsSummarize(t *testing.T) {
|
||||
html := renderVideoCard(t, store.SummaryRow{
|
||||
VideoID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
Title: "Fresh Video",
|
||||
Summarized: false,
|
||||
})
|
||||
|
||||
if !strings.Contains(html, ">Summarize<") {
|
||||
t.Errorf("expected a Summarize button, got:\n%s", html)
|
||||
}
|
||||
if strings.Contains(html, "Retrying later") {
|
||||
t.Errorf("no retry badge for a non-rate-limited video, got:\n%s", html)
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,11 @@ func statusURL(videoID string) templ.SafeURL {
|
||||
return templ.SafeURL("/v/" + videoID + "/status")
|
||||
}
|
||||
|
||||
// inviteURL builds the claim path (POST) for an invite token.
|
||||
func inviteURL(token string) templ.SafeURL {
|
||||
return templ.SafeURL("/invite/" + token)
|
||||
}
|
||||
|
||||
// Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
|
||||
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
|
||||
// so the inline span colours and the CSS track/fill share one source of truth.
|
||||
@@ -338,6 +343,7 @@ var flashMessages = map[string]flashView{
|
||||
flashDisconnected: {"success", "Account disconnected."},
|
||||
flashDeleted: {"success", "Your account and all its data were deleted."},
|
||||
flashRegistered: {"success", "Welcome to Tapir — your account is ready."},
|
||||
flashAccountCreated: {"success", "Account created — log in with your email and password."},
|
||||
}
|
||||
|
||||
func flashFor(code string) (flashView, bool) {
|
||||
@@ -479,6 +485,10 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
||||
.filters input { font: inherit; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); min-width: 9rem; }
|
||||
.filters input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
|
||||
.btn { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid var(--accent); border-radius: var(--radius); background: var(--accent); color: var(--accent-fg); cursor: pointer; }
|
||||
/* anchors styled as buttons: the generic a{} / a:visited{} colour rules outrank
|
||||
.btn on <a>, painting the label accent-on-accent (invisible). Restore the
|
||||
button foreground for anchor buttons, visited included. */
|
||||
a.btn, a.btn:visited { color: var(--accent-fg); }
|
||||
.btn:hover { filter: brightness(1.05); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
|
||||
@@ -490,6 +500,9 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
||||
.card-preview { color: var(--muted); font-size: .9rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 1; line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card-foot { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; margin-top: var(--s1); }
|
||||
.chip { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: .72rem; font-weight: 600; }
|
||||
/* passive "retrying later" chip: dim/grey (CharmDim), not the accent — it is a
|
||||
status, not an action the user can take. */
|
||||
.chip-retry { background: rgba(108, 108, 108, .16); color: #6c6c6c; }
|
||||
.card-state { color: var(--muted); font-size: .8rem; }
|
||||
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
|
||||
|
||||
@@ -531,6 +544,11 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
||||
.empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); }
|
||||
.empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); }
|
||||
.empty code { background: var(--accent-weak); color: var(--accent); padding: .1rem .35rem; border-radius: .3rem; }
|
||||
.empty p { margin: var(--s3) 0 0; }
|
||||
/* connected-but-empty: a distinct accent callout, not a muted blank state, so a
|
||||
fresh account knows the next step is to run tapir, not "something is broken". */
|
||||
.empty-connected { border-style: solid; border-color: var(--accent); background: var(--accent-weak); color: var(--fg); }
|
||||
.empty-connected strong { color: var(--accent); }
|
||||
|
||||
/* flash / notification banner */
|
||||
.flash { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); }
|
||||
|
||||
@@ -22,7 +22,7 @@ templ Layout(title string) {
|
||||
<body>
|
||||
<header>
|
||||
<a href="/" class="brand">Tapir</a>
|
||||
<nav class="nav"><a href="/account">Account</a></nav>
|
||||
<nav class="nav"><a href="/account">Account</a><a href="/auth/logout">Log out</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
{ children... }
|
||||
@@ -59,7 +59,7 @@ templ WelcomePage(user User, loggedIn bool) {
|
||||
<div class="welcome-cta">
|
||||
<a class="btn btn-lg" href="/auth/login">Get Started</a>
|
||||
</div>
|
||||
<p class="welcome-sub">Already have an account? You'll go straight through.</p>
|
||||
<p class="welcome-sub">Access is by invitation. If you have an invite link, it will set up your account automatically. Returning users with credentials can log in above.</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -79,12 +79,12 @@ templ flashBanner(code string) {
|
||||
// #summary-list region; a non-HTMX request renders the whole page. flash carries
|
||||
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival
|
||||
// after a POST→redirect.
|
||||
templ ListPage(rows []store.SummaryRow, f Filter, flash string) {
|
||||
templ ListPage(rows []store.SummaryRow, f Filter, flash string, hasConnected bool) {
|
||||
@Layout("Tapir — Summaries") {
|
||||
@flashBanner(flash)
|
||||
@filterForm(f)
|
||||
<div id="summary-list">
|
||||
@summaryList(rows)
|
||||
@summaryList(rows, hasConnected)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -110,12 +110,20 @@ templ filterForm(f Filter) {
|
||||
// summaryList is the swappable list fragment: one card per video (summarized or
|
||||
// not). Cards reflow to a single column on mobile; an empty list shows a friendly
|
||||
// first-run state instead of a blank table.
|
||||
templ summaryList(rows []store.SummaryRow) {
|
||||
templ summaryList(rows []store.SummaryRow, hasConnected bool) {
|
||||
if len(rows) == 0 {
|
||||
if hasConnected {
|
||||
<div class="empty empty-connected">
|
||||
<strong>Your YouTube account is connected!</strong>
|
||||
<span>Run <code>tapir run</code> to discover your subscriptions. Videos will appear here once discovered. In manual mode, each new video gets a Summarize button.</span>
|
||||
</div>
|
||||
} else {
|
||||
<div class="empty">
|
||||
<strong>No videos yet</strong>
|
||||
<span>Videos appear here as your subscriptions are processed — run <code>tapir run</code> to fetch them. In manual mode, use the Summarize button to queue one.</span>
|
||||
<span>Connect your YouTube account to get started.</span>
|
||||
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
<ul class="cards">
|
||||
for _, r := range rows {
|
||||
@@ -156,6 +164,8 @@ templ VideoCard(r store.SummaryRow) {
|
||||
if len(r.Actions) > 0 {
|
||||
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
|
||||
}
|
||||
} else if r.TranscriptStatus == "rate_limited" {
|
||||
<span class="chip chip-retry" title="Caption fetch was rate-limited; tapir will retry automatically.">⏳ Retrying later</span>
|
||||
} else if r.SummarizeRequested {
|
||||
<span class="chip">Queued</span>
|
||||
<span class="card-state muted">waiting for the next run</span>
|
||||
@@ -297,6 +307,68 @@ templ RegisterPage(email, errMsg string) {
|
||||
}
|
||||
}
|
||||
|
||||
// InvitePage is the public set-password form an invited user reaches via their
|
||||
// emailed /invite/{token} link. The email is shown read-only (it is fixed by the
|
||||
// invite, not chosen here); the visitor sets a password to create their account.
|
||||
// errMsg, when set, reports a validation problem on the prior submit. No auth
|
||||
// chrome (header nav) is appropriate — the visitor has no session yet — but the
|
||||
// shared Layout keeps the look consistent.
|
||||
templ InvitePage(email, token, errMsg string) {
|
||||
@Layout("Tapir — Set your password") {
|
||||
<article class="register">
|
||||
<h1>Set up your Tapir account</h1>
|
||||
<p class="meta">Invitation for { email }.</p>
|
||||
<p>Choose a password to finish creating your account. You'll then log in with this email and password.</p>
|
||||
if errMsg != "" {
|
||||
<p class="error" role="alert">{ errMsg }</p>
|
||||
}
|
||||
<form method="post" action={ inviteURL(token) } class="register-form">
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" value={ email } readonly/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" minlength="8" required autofocus autocomplete="new-password"/>
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<input type="password" name="password_confirm" minlength="8" required autocomplete="new-password"/>
|
||||
</label>
|
||||
<button type="submit" class="btn">Create my account</button>
|
||||
</form>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// InviteInvalidPage is shown when an invite token is missing, expired, or already
|
||||
// used — a dead-end with no form, so a stale or replayed link reads clearly.
|
||||
templ InviteInvalidPage() {
|
||||
@Layout("Tapir — Invitation") {
|
||||
<article class="register">
|
||||
<h1>This invite link is no longer valid</h1>
|
||||
<p>This invitation has expired or has already been used. Ask for a fresh invite link, or log in if you already have an account.</p>
|
||||
<p><a class="btn" href="/auth/login">Log in</a></p>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// InviteNoticePage is a terminal message after a submit that neither succeeded nor
|
||||
// is a retryable validation error (account already exists, RBAC missing, or the
|
||||
// dev "deployed-only" degrade). showLogin adds a log-in CTA where that is the
|
||||
// natural next step.
|
||||
templ InviteNoticePage(message string, showLogin bool) {
|
||||
@Layout("Tapir — Invitation") {
|
||||
<article class="register">
|
||||
<h1>Invitation</h1>
|
||||
<p>{ message }</p>
|
||||
if showLogin {
|
||||
<p><a class="btn" href="/auth/login">Log in</a></p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// AccountPage is the account-management view: the registered display name and
|
||||
// signed-in email, the user's connected video accounts (each with a Disconnect
|
||||
// control), a Connect-YouTube link when none is connected, and the delete-account
|
||||
|
||||
+467
-217
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user