Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb24a24b9c | ||
|
|
21e6ddd61e | ||
|
|
152aab7a4a | ||
|
|
1018dc0df9 | ||
|
|
74f4fd7f2a | ||
|
|
0cc441d6ce | ||
|
|
8415a97d15 | ||
|
|
fb425cbf9a | ||
|
|
e77edf58ef | ||
|
|
f15f57f9ed | ||
|
|
d208110002 | ||
|
|
3a27bf1126 | ||
|
|
8ca374e657 | ||
|
|
0fdf2f7218 | ||
|
|
d83943c86a | ||
|
|
6b817f11b9 | ||
|
|
672a0c8580 | ||
|
|
a4aeb5efcd | ||
|
|
8c6c7ca947 | ||
|
|
25215cbcbd | ||
|
|
404f74c55c |
+1
-21
@@ -90,24 +90,4 @@ jobs:
|
|||||||
&& echo "Smoke test passed" \
|
&& echo "Smoke test passed" \
|
||||||
|| echo "Smoke test inconclusive: $OUTPUT"
|
|| echo "Smoke test inconclusive: $OUTPUT"
|
||||||
|
|
||||||
# ── 3. Mirror to GitHub (deploy intentionally omitted until manifests exist) ─
|
# ── 3. Mirror to GitHub — skipped for now (SSH key rotation pending) ─
|
||||||
mirror:
|
|
||||||
name: Mirror to GitHub
|
|
||||||
needs: build
|
|
||||||
runs-on: self-hosted
|
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Push to GitHub
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo '${{ secrets.GH_DEPLOY_KEY }}' > ~/.ssh/id_rsa_gh_mirror
|
|
||||||
chmod 600 ~/.ssh/id_rsa_gh_mirror
|
|
||||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa_gh_mirror -o IdentitiesOnly=yes" \
|
|
||||||
git push git@github.com:mathiasb/tapir.git HEAD:main
|
|
||||||
rm ~/.ssh/id_rsa_gh_mirror
|
|
||||||
echo "Mirrored to GitHub"
|
|
||||||
|
|||||||
@@ -79,23 +79,33 @@ Skills live in the canonical library `mathias/skills` and are wired into this re
|
|||||||
|
|
||||||
## Current build state (start here for the first task)
|
## Current build state (start here for the first task)
|
||||||
|
|
||||||
The repo is **scaffolded and intentionally RED**:
|
The repo is **green and shipping** — last tag `v0.4.0`. `task check` passes (fmt, vet, lint,
|
||||||
|
`go test -p 1 ./...`). Go is `1.26.1` (see `go.mod`).
|
||||||
|
|
||||||
- Clean Architecture skeleton exists: `internal/domain` (entities), `internal/ports`
|
- Clean Architecture core is implemented: `internal/domain` (entities), `internal/ports`
|
||||||
(interfaces), `internal/usecase` (engine), `cmd/tapir` (entrypoint stub),
|
(interfaces), `internal/usecase.Engine.ProcessNewVideo` (resolve transcript → summarize →
|
||||||
`internal/adapters` (empty — concrete adapters go here).
|
deliver to sinks | skip on no-transcript). The acceptance tests in `test/acceptance/` are
|
||||||
- `usecase.Engine.ProcessNewVideo` returns `ErrNotImplemented`.
|
green against it.
|
||||||
- `test/acceptance/summarize_new_video_test.go` translates the first two Gherkin scenarios and
|
- Adapters present under `internal/adapters/`: `youtube` (captions-first `VideoSource`,
|
||||||
**fails** against the stub. `task check` is therefore red on `test`.
|
timedtext/InnerTube acquisition per ADR-010), `summarizer` + `llm` (the copied AI router,
|
||||||
- **First build task:** implement `ProcessNewVideo` (resolve transcript -> summarize -> deliver to
|
Primary→Fallback per ADR-004), `store` (Postgres, golang-migrate migrations 001–006),
|
||||||
sinks | skip on no-transcript) to make the acceptance tests green, following the `.feature`
|
`secrets` (file-backed `SecretStore`). The brain HTTP sink (ADR-005) is the remaining
|
||||||
files. Then add the AI-router `Summarizer` (copy `llm` per ADR-004), the YouTube `VideoSource`
|
optional sink.
|
||||||
adapter (captions-first), and the store + brain sinks.
|
- Stage 1 is open (ADR-012): multi-user with **DB-enforced** isolation — Postgres RLS `FORCE`d
|
||||||
|
on all user-owned tables (migration 003), two-user isolation test in
|
||||||
|
`internal/adapters/store/rls_test.go`. Registration gate, per-user YouTube web connect, and
|
||||||
|
account management (disconnect / delete, ADR-013) all shipped.
|
||||||
|
- `cmd/tapir` subcommands: `list`, `show`, `auth` (interactive host-side OAuth), `run` (batch
|
||||||
|
watch→summarize), `serve` (the HTMX+Templ web reader/writer under `internal/web`, a new
|
||||||
|
transport over the unchanged engine/ports — ADR-003). `tapir env` prints config.
|
||||||
|
- **Build/run:** `task check` is the gate; `task build` produces the binary. Local dev uses
|
||||||
|
`StubAuth` (allow-all) and a `TAPIR_DB_DSN` Postgres; the deployed service uses Dex OIDC.
|
||||||
|
|
||||||
**Unverified setup items** (see `docs/homelab-integration.md`, marked `confirm`): the Go version
|
**Setup facts** (resolved — see `docs/homelab-integration.md` for the live values): LiteLLM is
|
||||||
in `go.mod` (1.23 — match the koala runner; estate elsewhere uses 1.26.1), the brain-mcp URL, the
|
off-cluster at `koala:30401/v1/` with `LITELLM_MASTER_KEY` from 1Password; the summarization
|
||||||
exact ESO secret-ref naming, and the summarization model alias. Resolve against the live cluster
|
model is config (`TAPIR_SUMMARIZER_MODEL`, default `koala/phi4-mini`), never hardcoded. The
|
||||||
before depending on them, and pin answers back into `docs/homelab-integration.md`.
|
brain-mcp base URL and ESO ref scheme are pinned in that doc; check it before wiring rather than
|
||||||
|
re-deriving.
|
||||||
|
|
||||||
## Provenance (where this design came from)
|
## Provenance (where this design came from)
|
||||||
|
|
||||||
|
|||||||
+114
-16
@@ -154,6 +154,22 @@ governs advancement. Reversible: if demand appears, a new ADR opens the Future C
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-009 — Trunk-Based Development
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-06-02)
|
||||||
|
|
||||||
|
**Context.** Platform-wide convention (homelab architecture review invariant; gitea-mcp #27):
|
||||||
|
commit directly to `main`, one logical change per commit, every commit deployable.
|
||||||
|
|
||||||
|
**Decision.** Tapir follows TBD. Commit directly to `main`. No feature branches or PRs for
|
||||||
|
solo/agent work; short-lived `agent/<desc>` branches only when parallel agents are active on
|
||||||
|
the repo simultaneously. CI is the quality gate, not branch protection.
|
||||||
|
|
||||||
|
**Consequences.** Consistent with the rest of the estate. Depends on the direct-to-main write
|
||||||
|
path tracked in gitea-mcp #35 (item #1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ADR-010 — Third-party caption acquisition via the timedtext/player baseUrl
|
## ADR-010 — Third-party caption acquisition via the timedtext/player baseUrl
|
||||||
|
|
||||||
**Status:** Accepted (2026-06-02)
|
**Status:** Accepted (2026-06-02)
|
||||||
@@ -203,22 +219,6 @@ player/timedtext baseUrl) only. ADR-007's captions-first stance and the STT defe
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADR-009 — Trunk-Based Development
|
|
||||||
|
|
||||||
**Status:** Accepted (2026-06-02)
|
|
||||||
|
|
||||||
**Context.** Platform-wide convention (homelab architecture review invariant; gitea-mcp #27):
|
|
||||||
commit directly to `main`, one logical change per commit, every commit deployable.
|
|
||||||
|
|
||||||
**Decision.** Tapir follows TBD. Commit directly to `main`. No feature branches or PRs for
|
|
||||||
solo/agent work; short-lived `agent/<desc>` branches only when parallel agents are active on
|
|
||||||
the repo simultaneously. CI is the quality gate, not branch protection.
|
|
||||||
|
|
||||||
**Consequences.** Consistent with the rest of the estate. Depends on the direct-to-main write
|
|
||||||
path tracked in gitea-mcp #35 (item #1).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-011 — Web read-surface at Stage 0: Dex authn (single-user authz), action signal, public ingress + GitOps
|
## ADR-011 — Web read-surface at Stage 0: Dex authn (single-user authz), action signal, public ingress + GitOps
|
||||||
|
|
||||||
**Status:** Accepted (2026-06-02)
|
**Status:** Accepted (2026-06-02)
|
||||||
@@ -290,6 +290,104 @@ explicit call, with isolation as the guardrail that keeps it safe.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-013 — Account deletion is Tapir-side only; the Dex identity is left intact
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-06-03)
|
||||||
|
|
||||||
|
**Context.** Stage 1 (ADR-012) added account deletion. A registered user is two things: a
|
||||||
|
`users` row (plus all their data, cascade-linked) in Tapir's Postgres, and a subject identity
|
||||||
|
in **Dex** (the homelab OIDC provider, shared across the estate — Tapir does not own it).
|
||||||
|
"Delete my account" could mean (a) erase all Tapir-side data and secrets, or (b) that plus
|
||||||
|
deprovision the Dex identity. The maintainer chose (a).
|
||||||
|
|
||||||
|
**Decision.** Deleting a Tapir account removes **only Tapir-side state**:
|
||||||
|
- The `users` row, cascading to all user-owned tables (`videos`, `transcripts`, `summaries`,
|
||||||
|
`sink_deliveries`, `video_connections`, and — via an **explicit delete**, because it has no
|
||||||
|
FK — `summary_actions`). The delete test asserts the cascade reaches every table and leaves
|
||||||
|
other users' rows untouched.
|
||||||
|
- All of that user's secrets in the SecretStore (the per-user YouTube refresh-token refs).
|
||||||
|
|
||||||
|
The **Dex identity is deliberately left intact.** Tapir does not deprovision, disable, or
|
||||||
|
modify the shared Dex directory.
|
||||||
|
|
||||||
|
**Consequences.**
|
||||||
|
- **Clean re-registration:** a deleted user who logs in again arrives as a Dex-authenticated
|
||||||
|
subject with no `users` row, so they hit the registration gate as a "new" user — no special
|
||||||
|
resurrection path needed. This is a feature of the choice, not an accident.
|
||||||
|
- **Right-to-erasure is partial.** The user's *identity* still exists in Dex after deletion.
|
||||||
|
For Future B (trusted friends) this is acceptable: Dex is the maintainer's own directory and
|
||||||
|
the identity carries no Tapir content. **But if Tapir ever moves toward Future C (real
|
||||||
|
external/public users), this is a GDPR-shaped gap** — a true "delete my account" there must
|
||||||
|
also deprovision or anonymise the Dex identity, which is a new ADR and likely a Dex-admin
|
||||||
|
integration Tapir does not currently have.
|
||||||
|
- **Blast radius stays small:** Tapir never holds write access to the shared identity provider,
|
||||||
|
consistent with the estate's blast-radius-minimisation posture (ADR-002, architecture review).
|
||||||
|
|
||||||
|
**Reversibility.** Adding Dex deprovisioning later is a superseding ADR; nothing about the
|
||||||
|
current choice blocks it. Recorded now because "deletion is partial by design" is a deliberate
|
||||||
|
semantic that future-Tapir (and any compliance review) must know was chosen, not overlooked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-014 — Timedtext 429 handling: per-host backoff + honest in-flight UX, before any Whisper reconsideration
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-06-03)
|
||||||
|
|
||||||
|
**Context.** ADR-010 acquires captions from the unauthenticated `timedtext` baseUrl. Live runs
|
||||||
|
show that endpoint **rate-limits per source IP (HTTP 429) under volume** — many videos fetched
|
||||||
|
in one pass from one egress IP. Stage 1 (ADR-012) made this sharper in two ways: multiple users
|
||||||
|
now drive fetches from the *same cluster egress IP*, and the v0.4.0 "Summarize" button fires an
|
||||||
|
**immediate, synchronous-feeling** fetch on click (HTMX polls `/v/{videoId}/status`), so a 429
|
||||||
|
now surfaces as a *user-facing stall* rather than a background batch hiccup. A throttle
|
||||||
|
(`TAPIR_FETCH_DELAY`) exists but is a fixed inter-fetch delay, not 429-aware, and does not
|
||||||
|
coordinate across the concurrent click-path and the `tapir run` batch path.
|
||||||
|
|
||||||
|
This ADR is **not** a decision to build Whisper. ADR-007/010 keep STT deferred *pending
|
||||||
|
measurement of the sustainable caption rate* — and that rate cannot be measured while the
|
||||||
|
client reacts badly to the 429s it already provokes. Fix the backoff and the UX first; the
|
||||||
|
clean data then tells you whether Whisper is warranted.
|
||||||
|
|
||||||
|
**Decision.**
|
||||||
|
|
||||||
|
1. **429-aware backoff at the fetch layer.** On a 429 from the timedtext/InnerTube fetch,
|
||||||
|
respect `Retry-After` when present; otherwise exponential backoff with jitter. This replaces
|
||||||
|
reliance on a fixed `TAPIR_FETCH_DELAY` alone (which stays as a floor/politeness delay).
|
||||||
|
2. **A single per-egress-IP rate gate** shared by *both* the `tapir run` batch path and the
|
||||||
|
web click path, so they cannot collectively exceed the sustainable rate. Concurrency into
|
||||||
|
the timedtext endpoint is serialised/limited at this gate regardless of how many users or
|
||||||
|
goroutines are upstream. (The 429 is per *IP*, not per user — so the gate is process-/
|
||||||
|
cluster-egress-wide, not per-`withUser`.)
|
||||||
|
3. **Honest in-flight UX (the product-shaping part).** The status poll distinguishes states
|
||||||
|
the user can understand instead of a spinner that silently stalls:
|
||||||
|
- *summarizing* — actively processing (the existing tapir spinner).
|
||||||
|
- *queued / waiting for rate limit* — fetch deferred behind the rate gate; show a calm
|
||||||
|
"queued, this can take a few minutes when busy" state, not a stuck spinner.
|
||||||
|
- *no transcript* — terminal, per ADR-010's degrade-never-error (a 429 that exhausts retries
|
||||||
|
resolves to `SourceNone`, same as any unavailable caption — it must not present as a hard
|
||||||
|
error to the user).
|
||||||
|
The spinner promising imminence is the wrong signal under rate-limiting; the UX must be able
|
||||||
|
to say "waiting" truthfully.
|
||||||
|
4. **Measurement before Whisper.** Only once (1)-(3) are in and a real sustainable
|
||||||
|
per-IP rate is observed do we revisit whether caption coverage is good enough or whether the
|
||||||
|
deferred Whisper fallback (ADR-007) is finally warranted. That reconsideration is a future
|
||||||
|
ADR, gated on this data.
|
||||||
|
|
||||||
|
**Consequences.**
|
||||||
|
- Caption fetching becomes well-behaved under multi-user load instead of self-inflicting 429s;
|
||||||
|
the endpoint is treated as the shared, rate-limited resource it is.
|
||||||
|
- The click-path UX stays honest: "waiting" reads as waiting, failure degrades to "no
|
||||||
|
transcript", never a stuck spinner or error spew.
|
||||||
|
- A future per-IP cooldown / second egress IP / proxy becomes an option the rate gate can sit
|
||||||
|
in front of without UX changes.
|
||||||
|
- **Still no Whisper** — and now there's a clean path to the *data* that decides whether it's
|
||||||
|
ever needed (`docs/homelab-integration.md` and a future ADR own that measurement).
|
||||||
|
|
||||||
|
**Open (tracked, not in this ADR's scope):** the actual sustainable rate number; whether a
|
||||||
|
dedicated egress IP / outbound proxy is worth it; CronJob-driven `tapir run` interaction with
|
||||||
|
the rate gate (the batch path moves into k3s per the deferred CronJob item).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Rejected alternatives
|
## Rejected alternatives
|
||||||
|
|
||||||
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
||||||
|
|||||||
+24
-21
@@ -22,15 +22,11 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
"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/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/summarizer"
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
|
"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
|
||||||
)
|
)
|
||||||
@@ -120,24 +116,16 @@ func cmdRun(ctx context.Context, log *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
// Same wiring the web serve path uses (buildProcessor). ValidateForRun above
|
||||||
src := youtube.New(youtube.Config{
|
// already required the engine's inputs, so a nil here is a genuine config gap.
|
||||||
ClientID: cfg.YTClientID,
|
engine, err := buildProcessor(cfg, st)
|
||||||
ClientSecret: cfg.YTClientSecret,
|
if err != nil {
|
||||||
TokenSecretRef: cfg.YTTokenRef,
|
return err
|
||||||
PreferredLanguages: []string{"en"},
|
|
||||||
}, secretStore)
|
|
||||||
|
|
||||||
// Local Primary only; no BYO fallback for the demo (fallback nil).
|
|
||||||
primary := summarizer.Endpoint{
|
|
||||||
Client: llm.New(cfg.GatewayURL, cfg.GatewayKey, cfg.SummarizerModel, cfg.SummarizerTimeout),
|
|
||||||
Provider: "local",
|
|
||||||
Model: cfg.SummarizerModel,
|
|
||||||
}
|
}
|
||||||
sum := summarizer.New(primary, nil)
|
if engine == nil {
|
||||||
|
return fmt.Errorf("run: incomplete summarization config (gateway, youtube credentials, secrets file)")
|
||||||
engine := usecase.NewEngine(src, sum, st)
|
}
|
||||||
r := runner.New(src, st, engine, cfg.UserID, log)
|
r := runner.New(engine.Source, st, engine, cfg.UserID, log)
|
||||||
|
|
||||||
log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
|
log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
|
||||||
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval)
|
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval)
|
||||||
@@ -204,6 +192,21 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
|||||||
log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL)
|
log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Immediate summarization for the web "Summarize" button. When the engine can
|
||||||
|
// be built (gateway + YouTube credentials + secrets present), a click runs the
|
||||||
|
// summary now in the background; otherwise the button stays queue-only and the
|
||||||
|
// next `tapir run` does the work (buildProcessor returns nil — never an error).
|
||||||
|
engine, err := buildProcessor(cfg, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if engine != nil {
|
||||||
|
app.Processor = &engineProcessor{engine: engine, store: st}
|
||||||
|
log.Info("web immediate summarization enabled", "model", cfg.SummarizerModel)
|
||||||
|
} else {
|
||||||
|
log.Info("web summarization is queue-only (incomplete engine config)")
|
||||||
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: app.Router(),
|
Handler: app.Router(),
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
|
||||||
|
"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/adapters/summarizer"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildProcessor wires the summarization engine — YouTube source (captions-first),
|
||||||
|
// AI-router summarizer, store sink — shared by `tapir run` and the web
|
||||||
|
// "Summarize now" path so the wiring lives in one place. It returns (nil, nil) —
|
||||||
|
// not an error — when the config cannot support live summarization (no gateway
|
||||||
|
// URL, no YouTube client credentials, or no secrets file). That nil is the
|
||||||
|
// queue-only fallback: the web UI keeps working (the button just queues) and
|
||||||
|
// `tapir run` reports the gap via its own ValidateForRun. Missing engine config
|
||||||
|
// is never an error here.
|
||||||
|
func buildProcessor(cfg config.Config, st *store.Store) (*usecase.Engine, error) {
|
||||||
|
if cfg.GatewayURL == "" || cfg.YTClientID == "" || cfg.YTClientSecret == "" || cfg.SecretsFile == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
||||||
|
src := youtube.New(youtube.Config{
|
||||||
|
ClientID: cfg.YTClientID,
|
||||||
|
ClientSecret: cfg.YTClientSecret,
|
||||||
|
TokenSecretRef: cfg.YTTokenRef,
|
||||||
|
PreferredLanguages: []string{"en"},
|
||||||
|
}, secretStore)
|
||||||
|
|
||||||
|
// Local Primary only; no BYO fallback for the demo (fallback nil).
|
||||||
|
primary := summarizer.Endpoint{
|
||||||
|
Client: llm.New(cfg.GatewayURL, cfg.GatewayKey, cfg.SummarizerModel, cfg.SummarizerTimeout),
|
||||||
|
Provider: "local",
|
||||||
|
Model: cfg.SummarizerModel,
|
||||||
|
}
|
||||||
|
sum := summarizer.New(primary, nil)
|
||||||
|
|
||||||
|
return usecase.NewEngine(src, sum, st), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// engineProcessor adapts the engine (which works in terms of a domain.Video) to
|
||||||
|
// the web.Processor port (which works in terms of a stored video id): it loads the
|
||||||
|
// video row, runs the engine, and — on a produced summary — clears the manual
|
||||||
|
// queue flag, mirroring the runner so the video is not re-summarized on the next
|
||||||
|
// `tapir run` and the UI drops the "Queued" chip. A skip (no transcript) leaves
|
||||||
|
// the flag set so a later run can retry.
|
||||||
|
type engineProcessor struct {
|
||||||
|
engine *usecase.Engine
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *engineProcessor) ProcessVideo(ctx context.Context, userID, videoID string) error {
|
||||||
|
row, err := p.store.GetVideoRow(ctx, userID, videoID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load video %q: %w", videoID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := domain.Video{
|
||||||
|
ID: row.VideoID,
|
||||||
|
UserID: userID,
|
||||||
|
Provider: domain.Provider(row.Channel),
|
||||||
|
ProviderVideoID: row.ProviderVideoID,
|
||||||
|
Title: row.Title,
|
||||||
|
URL: row.URL,
|
||||||
|
PublishedAt: row.PublishedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := p.engine.ProcessNewVideo(ctx, v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("process video %q: %w", videoID, err)
|
||||||
|
}
|
||||||
|
if res.Summary != nil {
|
||||||
|
if err := p.store.ClearSummarizeRequested(ctx, userID, videoID); err != nil {
|
||||||
|
return fmt.Errorf("clear summarize flag %q: %w", videoID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBuildProcessorNilOnIncompleteConfig asserts the queue-only fallback: when a
|
||||||
|
// required input is missing, buildProcessor returns (nil, nil) — never an error —
|
||||||
|
// so the web UI degrades to queue-only instead of failing to start.
|
||||||
|
func TestBuildProcessorNilOnIncompleteConfig(t *testing.T) {
|
||||||
|
// A complete config (the fields buildProcessor gates on). The store is nil:
|
||||||
|
// buildProcessor must not touch it on the incomplete paths, and the complete
|
||||||
|
// path only stores the pointer (no connection), so nil is fine for this test.
|
||||||
|
complete := config.Config{
|
||||||
|
GatewayURL: "http://gw/v1",
|
||||||
|
YTClientID: "id",
|
||||||
|
YTClientSecret: "secret",
|
||||||
|
SecretsFile: "/tmp/secrets.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(config.Config) config.Config
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{"complete", func(c config.Config) config.Config { return c }, false},
|
||||||
|
{"no gateway url", func(c config.Config) config.Config { c.GatewayURL = ""; return c }, true},
|
||||||
|
{"no yt client id", func(c config.Config) config.Config { c.YTClientID = ""; return c }, true},
|
||||||
|
{"no yt client secret", func(c config.Config) config.Config { c.YTClientSecret = ""; return c }, true},
|
||||||
|
{"no secrets file", func(c config.Config) config.Config { c.SecretsFile = ""; return c }, true},
|
||||||
|
{"empty config", func(config.Config) config.Config { return config.Config{} }, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
engine, err := buildProcessor(tt.mutate(complete), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildProcessor returned an error, want nil: %v", err)
|
||||||
|
}
|
||||||
|
if (engine == nil) != tt.wantNil {
|
||||||
|
t.Fatalf("engine == nil is %v, want %v", engine == nil, tt.wantNil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ adapter behind an interface (Clean Architecture ports & adapters).
|
|||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
subgraph tapir["Tapir (Go)"]
|
subgraph tapir["Tapir (Go)"]
|
||||||
http["HTTP server<br/>OAuth callbacks +<br/>user-facing API"]
|
http["tapir serve<br/>(HTMX+Templ web surface:<br/>read summaries, connect,<br/>account, summarize)"]
|
||||||
watcher["Watcher<br/>detects new videos<br/>(WebSub + poll)"]
|
watcher["Watcher<br/>detects new videos<br/>(WebSub + poll)"]
|
||||||
engine["Summarization engine<br/>(use-case core)"]
|
engine["Summarization engine<br/>(use-case core)"]
|
||||||
resolver["Transcript resolver<br/>(captions-first)"]
|
resolver["Transcript resolver<br/>(captions-first)"]
|
||||||
@@ -95,6 +95,66 @@ two codebases (ADR-003).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Web surface — `tapir serve` (Stage 1, ADR-011 → ADR-012)
|
||||||
|
|
||||||
|
A later transport added over the **unchanged** engine/ports/sinks core (ADR-003): `tapir serve`
|
||||||
|
is an HTMX+Templ reader/writer (`internal/web`) over the existing `store`. It added no business
|
||||||
|
logic to the engine — it reads the store and, for one action, kicks the existing engine. ADR-011
|
||||||
|
shipped it single-user; ADR-012 opened multi-user with DB-enforced (RLS) isolation.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
browser["Browser<br/>(Dex-authenticated user)"]
|
||||||
|
subgraph web["internal/web (tapir serve)"]
|
||||||
|
oidc["oidc<br/>Dex OIDC session<br/>(authenticate-only)"]
|
||||||
|
gate["registration gate<br/>new subject -> /register"]
|
||||||
|
pages["summary list + detail<br/>(read) + actions"]
|
||||||
|
connect["/oauth/youtube/callback<br/>per-user token connect"]
|
||||||
|
account["account<br/>(disconnect, delete)"]
|
||||||
|
summarize["Summarize button<br/>-> background goroutine"]
|
||||||
|
end
|
||||||
|
store[("store<br/>(Postgres, RLS per user)")]
|
||||||
|
engine["Summarization engine<br/>(unchanged core)"]
|
||||||
|
secrets["SecretStore<br/>(per-user token refs)"]
|
||||||
|
|
||||||
|
browser --> oidc
|
||||||
|
oidc --> gate
|
||||||
|
gate --> pages
|
||||||
|
pages --> store
|
||||||
|
connect --> secrets
|
||||||
|
connect --> store
|
||||||
|
account --> store
|
||||||
|
account --> secrets
|
||||||
|
summarize -->|background| engine
|
||||||
|
summarize -->|HTMX status poll| store
|
||||||
|
engine --> store
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Dex OIDC session layer** (`internal/web/oidc`) — **authenticate-only** (ADR-012). It proves
|
||||||
|
*who*; authorization/isolation is the DB's job (RLS), not the session's.
|
||||||
|
- **Registration gate** — a Dex subject with no `users` row is routed to `/register`, which
|
||||||
|
creates the `users` row + the `user_identities` mapping (migration 004). Returning subjects
|
||||||
|
pass straight through.
|
||||||
|
- **Web-initiated YouTube connect** — `/oauth/youtube/connect` → `/oauth/youtube/callback`
|
||||||
|
persists a **per-user** refresh-token ref (`youtube/<userID>/refresh_token`) via `SecretStore`
|
||||||
|
and a `video_connections` row (ADR-006, migration 005). Distinct from the CLI `tapir auth`.
|
||||||
|
- **Account management** — `/account` offers disconnect and **delete account**. Delete removes
|
||||||
|
only Tapir-side state (cascade across the user's tables + secret refs); the shared Dex identity
|
||||||
|
is left intact (ADR-013).
|
||||||
|
- **Immediate summarization** — the web "Summarize" button (`POST /v/{id}/summarize`) fires the
|
||||||
|
engine in a **background goroutine** inside `serve`; the page HTMX-polls `/v/{id}/status`,
|
||||||
|
showing a Charmbracelet spinner while in-flight (and an honest "queued/waiting" state under
|
||||||
|
rate-limiting — ADR-014).
|
||||||
|
- **Summarization mode** — `users.auto_summarize` (migration 006). Auto: every new video is
|
||||||
|
summarized. Manual (default): new videos appear unsummarized; the button sets
|
||||||
|
`videos.summarize_requested`, which the next `tapir run` processes and clears. Both the click
|
||||||
|
path and the batch `tapir run` drive the same unchanged engine.
|
||||||
|
|
||||||
|
The engine, ports, and sink adapters are **untouched** by all of the above — the web surface only
|
||||||
|
reads the store and triggers the existing engine. Adding it changed wiring, not the core (ADR-003).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Sequence — core use case: new video summarized
|
## Sequence — core use case: new video summarized
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -191,6 +251,8 @@ Gherkin features in `docs/use-cases/`).
|
|||||||
|
|
||||||
- Audio-download + speech-to-text resolver (ADR-007) — would be an additional `VideoSource`
|
- Audio-download + speech-to-text resolver (ADR-007) — would be an additional `VideoSource`
|
||||||
fallback path, drawn when built.
|
fallback path, drawn when built.
|
||||||
- Multi-tenant isolation primitives (per-tenant Postgres role, NetworkPolicy, tenant label)
|
- Per-user isolation is **live, not deferred**: Postgres RLS `FORCE`d on every user-owned table
|
||||||
— activate at Stage 1 (ADR-002); single-user Stage 0 doesn't exercise them.
|
(ADR-012, migration 003), realising ADR-002's per-tenant intent at the DB layer. The coarser
|
||||||
|
multi-tenant primitives (per-namespace NetworkPolicy, Kyverno, tenant label) remain a
|
||||||
|
Stage-2 hardening item, not exercised yet.
|
||||||
- Public SaaS surface (sign-up, billing) — Future C, not built (ADR-008).
|
- Public SaaS surface (sign-up, billing) — Future C, not built (ADR-008).
|
||||||
|
|||||||
+87
-23
@@ -23,11 +23,16 @@ only opaque references to them; the secret material lives in ESO/1Password (ADR-
|
|||||||
|
|
||||||
## Entities
|
## Entities
|
||||||
|
|
||||||
|
Solid entities below are **persisted today** (migrations 001–006). `AI_CREDENTIAL` and
|
||||||
|
`SUBSCRIPTION` are **planned, not yet a table** — kept in the model for intent; see the notes.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
erDiagram
|
erDiagram
|
||||||
|
USER ||--|| USER_IDENTITY : "logs in via (Dex subject)"
|
||||||
USER ||--o{ VIDEO_CONNECTION : has
|
USER ||--o{ VIDEO_CONNECTION : has
|
||||||
USER ||--o{ AI_CREDENTIAL : has
|
USER ||--o{ SUMMARY_ACTION : records
|
||||||
VIDEO_CONNECTION ||--o{ SUBSCRIPTION : exposes
|
USER ||--o{ AI_CREDENTIAL : "has (planned)"
|
||||||
|
VIDEO_CONNECTION ||--o{ SUBSCRIPTION : "exposes (planned)"
|
||||||
SUBSCRIPTION ||--o{ VIDEO : "produces (per user)"
|
SUBSCRIPTION ||--o{ VIDEO : "produces (per user)"
|
||||||
VIDEO ||--o| TRANSCRIPT : "has at most one"
|
VIDEO ||--o| TRANSCRIPT : "has at most one"
|
||||||
VIDEO ||--o| SUMMARY : "has at most one"
|
VIDEO ||--o| SUMMARY : "has at most one"
|
||||||
@@ -36,14 +41,20 @@ erDiagram
|
|||||||
USER {
|
USER {
|
||||||
uuid id PK
|
uuid id PK
|
||||||
text display_name
|
text display_name
|
||||||
|
bool auto_summarize "default false -> manual mode out of the box (migration 006)"
|
||||||
|
timestamptz created_at
|
||||||
|
}
|
||||||
|
USER_IDENTITY {
|
||||||
|
text dex_subject PK
|
||||||
|
uuid user_id FK "UNIQUE -> USER, ON DELETE CASCADE; NOT RLS-enabled"
|
||||||
timestamptz created_at
|
timestamptz created_at
|
||||||
}
|
}
|
||||||
VIDEO_CONNECTION {
|
VIDEO_CONNECTION {
|
||||||
uuid id PK
|
uuid id PK
|
||||||
uuid user_id FK
|
uuid user_id FK "-> USER, ON DELETE CASCADE"
|
||||||
text provider "youtube | vimeo"
|
text provider "youtube | vimeo"
|
||||||
text provider_account
|
text provider_account "nullable"
|
||||||
text token_secret_ref "-> SecretStore, never the token"
|
text token_ref "-> SecretStore, never the token"
|
||||||
text status "active | revoked | error"
|
text status "active | revoked | error"
|
||||||
timestamptz connected_at
|
timestamptz connected_at
|
||||||
}
|
}
|
||||||
@@ -66,14 +77,15 @@ erDiagram
|
|||||||
}
|
}
|
||||||
VIDEO {
|
VIDEO {
|
||||||
uuid id PK
|
uuid id PK
|
||||||
uuid user_id FK
|
uuid user_id FK "-> USER, ON DELETE CASCADE"
|
||||||
uuid subscription_id FK
|
uuid subscription_id "nullable; no FK at Stage 0"
|
||||||
text provider
|
text provider
|
||||||
text provider_video_id
|
text provider_video_id
|
||||||
text title
|
text title
|
||||||
int duration_s
|
int duration_s
|
||||||
timestamptz published_at
|
timestamptz published_at
|
||||||
text url
|
text url
|
||||||
|
bool summarize_requested "default false -> manual-mode queue flag (migration 006)"
|
||||||
timestamptz seen_at
|
timestamptz seen_at
|
||||||
}
|
}
|
||||||
TRANSCRIPT {
|
TRANSCRIPT {
|
||||||
@@ -87,7 +99,7 @@ erDiagram
|
|||||||
SUMMARY {
|
SUMMARY {
|
||||||
uuid id PK
|
uuid id PK
|
||||||
uuid user_id FK
|
uuid user_id FK
|
||||||
uuid video_id FK
|
uuid video_id "no FK to videos; (user_id, video_id) UNIQUE is the dedup key"
|
||||||
text summary
|
text summary
|
||||||
jsonb highlights
|
jsonb highlights
|
||||||
jsonb takeaways
|
jsonb takeaways
|
||||||
@@ -98,26 +110,53 @@ erDiagram
|
|||||||
}
|
}
|
||||||
SINK_DELIVERY {
|
SINK_DELIVERY {
|
||||||
uuid id PK
|
uuid id PK
|
||||||
uuid summary_id FK
|
uuid summary_id FK "-> SUMMARY, ON DELETE CASCADE; ownership derived via this FK"
|
||||||
text sink "store | brain"
|
text sink "store | brain"
|
||||||
text status "pending | delivered | error"
|
text status "pending | delivered | error"
|
||||||
text detail "nullable; error message etc"
|
text detail "nullable; error message etc"
|
||||||
timestamptz updated_at
|
timestamptz updated_at
|
||||||
}
|
}
|
||||||
|
SUMMARY_ACTION {
|
||||||
|
uuid id PK
|
||||||
|
uuid user_id FK "-> USER"
|
||||||
|
text video_id "TEXT, not FK (mirrors summaries' standalone key)"
|
||||||
|
text action "watched | skipped | saved"
|
||||||
|
timestamptz acted_at
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`SUMMARY_ACTION` has `UNIQUE (user_id, video_id, action)`; `VIDEO_CONNECTION` has
|
||||||
|
`UNIQUE (user_id, provider)` (one connection per provider — reconnect upserts in place).
|
||||||
|
RLS (`ENABLE` + `FORCE`) is on **every solid user-owned table above** — `users`, `videos`,
|
||||||
|
`transcripts`, `summaries`, `summary_actions`, `video_connections`. `sink_deliveries` is
|
||||||
|
RLS'd via an `EXISTS` on its parent summary; `user_identities` is intentionally **not** RLS'd
|
||||||
|
(auth plumbing). See the *Isolation invariant* section for the mechanism.
|
||||||
|
|
||||||
## Notes per entity
|
## Notes per entity
|
||||||
|
|
||||||
- **USER** — at Stage 0 there is exactly one row. At Stage 1, identity comes via Dex; this
|
- **USER** — one row per registered user (Stage 1, ADR-012; no longer single-row). The Tapir-side
|
||||||
table holds the Tapir-side profile keyed to the Dex subject.
|
profile; the Dex identity is held separately in `USER_IDENTITY`, not on this row. `auto_summarize`
|
||||||
- **VIDEO_CONNECTION** — a connected YouTube/Vimeo account. `token_secret_ref` resolves to
|
(migration 006) is the per-user mode flag: `FALSE` (default) = manual, `TRUE` = auto-summarize
|
||||||
the OAuth refresh token via `SecretStore`. Revocation flips `status`, doesn't delete history.
|
every new video.
|
||||||
- **AI_CREDENTIAL** — optional, per provider, per user (ADR-004's Fallback). Absent for users
|
- **USER_IDENTITY** (migration 004) — the `dex_subject → user_id` map. `dex_subject` is the PK,
|
||||||
who only use the local stack. One row per provider max.
|
`user_id` a `UNIQUE` FK to `users` with `ON DELETE CASCADE`. This is the bridge resolved at login
|
||||||
- **SUBSCRIPTION** — a watched channel. `websub_expires` tracks the YouTube push lease so the
|
*before* a `user_id` is known, so it is **deliberately not RLS-enabled** (it holds no user data;
|
||||||
watcher knows when to re-subscribe; null for poll-based (Vimeo).
|
RLS here would deadlock the lookup that yields the id used for scoping). Account deletion cascades
|
||||||
|
the mapping away (ADR-013).
|
||||||
|
- **VIDEO_CONNECTION** (migration 005) — a connected YouTube/Vimeo account. `token_ref` resolves to
|
||||||
|
the OAuth refresh token via `SecretStore` (per-user scheme `youtube/<userID>/refresh_token`).
|
||||||
|
`UNIQUE (user_id, provider)`: one connection per provider, reconnect upserts. Revocation/disconnect
|
||||||
|
flips `status`, doesn't delete history. FORCE RLS'd.
|
||||||
|
- **AI_CREDENTIAL** — *planned, no table yet.* Optional, per provider, per user (ADR-004's Fallback).
|
||||||
|
BYO keys are currently resolved via `SecretStore` refs without a dedicated table; this entity is
|
||||||
|
modelled for when per-credential metadata is needed.
|
||||||
|
- **SUBSCRIPTION** — *planned, no table yet.* A watched channel; `websub_expires` would track the
|
||||||
|
YouTube push lease. At Stage 0/1 `videos.subscription_id` is a nullable column with **no FK** (the
|
||||||
|
subscriptions table is not part of the shipped store-sink slice — migration 001).
|
||||||
- **VIDEO** — one row per (user, video) — note `user_id`, reflecting the per-user-isolation
|
- **VIDEO** — one row per (user, video) — note `user_id`, reflecting the per-user-isolation
|
||||||
decision. The same video seen by two users is two rows. `seen_at` is when Tapir detected it.
|
decision. The same video seen by two users is two rows. `seen_at` is when Tapir detected it.
|
||||||
|
`summarize_requested` (migration 006) is the manual-mode queue flag: the web "Summarize" button
|
||||||
|
sets it `TRUE`; the next `tapir run` picks it up, summarizes, and clears it back to `FALSE`.
|
||||||
- **TRANSCRIPT** — at most one per video. `source = none` records "checked, no usable
|
- **TRANSCRIPT** — at most one per video. `source = none` records "checked, no usable
|
||||||
transcript" so the watcher doesn't reprocess (ADR-007). `content` null in that case.
|
transcript" so the watcher doesn't reprocess (ADR-007). `content` null in that case.
|
||||||
- **SUMMARY** — at most one per video. `fallback_used` + `ai_provider`/`ai_model` make the
|
- **SUMMARY** — at most one per video. `fallback_used` + `ai_provider`/`ai_model` make the
|
||||||
@@ -125,14 +164,39 @@ erDiagram
|
|||||||
`takeaways` as jsonb to stay schema-flexible while the output format settles.
|
`takeaways` as jsonb to stay schema-flexible while the output format settles.
|
||||||
- **SINK_DELIVERY** — one row per (summary, sink) attempt. This is where "also sent to brain"
|
- **SINK_DELIVERY** — one row per (summary, sink) attempt. This is where "also sent to brain"
|
||||||
lives — no brain tables, just a delivery row with `sink = brain`. Sinks fail independently;
|
lives — no brain tables, just a delivery row with `sink = brain`. Sinks fail independently;
|
||||||
a failed brain delivery doesn't fail the store delivery.
|
a failed brain delivery doesn't fail the store delivery. No own `user_id`; RLS ownership is
|
||||||
|
derived from the parent summary via `EXISTS` (migration 003).
|
||||||
|
- **SUMMARY_ACTION** (migration 002) — records the maintainer's act on a summary (watch / skip /
|
||||||
|
save) — the column that makes the Stage-0 headline metric ("acts on ≥1 summary") queryable
|
||||||
|
(ui-spec.md §5, ADR-011). `video_id` is `TEXT` and **not** FK-constrained, mirroring summaries'
|
||||||
|
standalone `(user_id, video_id)` key. `UNIQUE (user_id, video_id, action)`. FORCE RLS'd.
|
||||||
|
|
||||||
## Isolation invariant (Stage 1+)
|
## Isolation invariant (Stage 1+) — LIVE
|
||||||
|
|
||||||
Every user-owned table carries `user_id`. At Stage 1, this is enforced at the DB layer via a
|
Every user-owned table carries `user_id`, and isolation is **enforced at the DB layer**, not
|
||||||
per-tenant Postgres role + row grants (architecture review SC7), not only in application code.
|
only in application code. ADR-011 shipped this surface single-user (one allowlisted subject,
|
||||||
At Stage 0 (single user) the column exists but the enforcement is dormant. The isolation test
|
enforcement dormant); **ADR-012 opened Stage 1 and turned enforcement on in the same slice.**
|
||||||
in VISION Stage 2 asserts user A cannot read user B's rows.
|
|
||||||
|
Enforcement is **Postgres Row-Level Security** (migration `003_rls.up.sql`):
|
||||||
|
|
||||||
|
- RLS is `ENABLE`d **and** `FORCE`d on every user-owned table — `users`, `videos`,
|
||||||
|
`transcripts`, `summaries`, `summary_actions`, `video_connections`. `FORCE` is load-bearing:
|
||||||
|
the app connects as the table **owner** (`tapir` role), and owners bypass RLS unless forced.
|
||||||
|
- Each policy keys off the per-request GUC `tapir.current_user_id`, set transaction-locally by
|
||||||
|
the store's `withUser` helper via `set_config('tapir.current_user_id', $1, true)` — it
|
||||||
|
auto-resets on commit/rollback, so it never leaks across a pooled connection.
|
||||||
|
- `current_setting('tapir.current_user_id', true)` uses `missing_ok = true`: an **unset** GUC
|
||||||
|
yields `NULL`, the predicate matches no rows, and access **denies by default**.
|
||||||
|
- `sink_deliveries` has no `user_id`; its policy derives ownership from the parent summary via
|
||||||
|
`EXISTS (SELECT 1 FROM summaries …)`.
|
||||||
|
- `user_identities` (the Dex-subject → user_id map) is **deliberately not RLS-enabled** — it is
|
||||||
|
auth plumbing read *before* a user_id is known; putting RLS there would deadlock. It holds no
|
||||||
|
user data.
|
||||||
|
|
||||||
|
The Stage-2 isolation bar is **pulled forward, not deferred**: `internal/adapters/store/rls_test.go`
|
||||||
|
runs two users against a non-superuser, non-`BYPASSRLS` role and asserts user A reads/writes zero
|
||||||
|
of user B's rows across every table. It ships green with the multi-user features (ADR-012); no
|
||||||
|
multi-user feature merges ahead of it passing.
|
||||||
|
|
||||||
## Job / processing state
|
## Job / processing state
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Spec — Landing page + documentation reconciliation
|
||||||
|
|
||||||
|
**Date:** 2026-06-03
|
||||||
|
**Status:** Ready to build
|
||||||
|
**Scope:** Two parallel workstreams — (A) a public landing page; (B) reconciling the
|
||||||
|
requirements / use-case / architecture / data-model docs against the deployed reality
|
||||||
|
(v0.4.0). These are separate concerns; do not let one worker do both, or the audit gets
|
||||||
|
done cursorily.
|
||||||
|
|
||||||
|
All work: read `CLAUDE.md` + `DECISIONS.md` first. TBD — commit directly to `main`, one
|
||||||
|
logical change per commit, conventional commits, `task check` green before every commit.
|
||||||
|
After editing any `.templ`, run `templ generate` (the repo commits both `views.templ` and the
|
||||||
|
generated `views_templ.go`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream A — Public landing page
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
A public landing page at `/welcome`, in the established bubbletea aesthetic, that lets a
|
||||||
|
visitor sign in (one Dex flow) and, if already logged in, jump to their Tapir page or log out.
|
||||||
|
New public transport surface only — no engine/core change (ADR-003).
|
||||||
|
|
||||||
|
### Verified facts (read from `internal/web/oidc/oidc.go` @ main — do not re-guess)
|
||||||
|
- Auth endpoints are exactly `/auth/login`, `/auth/callback`, `/auth/logout`.
|
||||||
|
- `isPublicPath(p)` = `p == "/healthz" || strings.HasPrefix(p, "/auth/")` — the single
|
||||||
|
public-route chokepoint inside `DexAuth.Middleware`.
|
||||||
|
- `DexAuth.CurrentUser(r) (web.User, bool)` reads the session cookie and does NOT redirect —
|
||||||
|
this is the "peek" the landing page uses to branch logged-in vs logged-out.
|
||||||
|
- `handleCallback` redirects to `/` on success (correct — leave as-is).
|
||||||
|
- `handleLogout` currently redirects to `loginPath` (`/auth/login`) — this is wrong for this
|
||||||
|
feature (see A3).
|
||||||
|
- There is NO separate "sign up" against Dex/OIDC: one authorization flow. Registration is
|
||||||
|
Tapir's own `/register` step (ADR-012), reached after first login for an unknown subject.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
**A1 — make `/welcome` public.** In `oidc.go`, extend `isPublicPath`:
|
||||||
|
```go
|
||||||
|
func isPublicPath(p string) bool {
|
||||||
|
return p == "/healthz" || p == "/welcome" || strings.HasPrefix(p, "/auth/")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**A2 — unauthenticated bare-`/` → `/welcome`; deep links unchanged.** In `DexAuth.Middleware`,
|
||||||
|
the unauthenticated branch currently always calls `redirectToLogin`. Change it so that when
|
||||||
|
`r.URL.Path == "/"` an unauthenticated visitor is redirected to `/welcome`; for any other
|
||||||
|
guarded path keep `redirectToLogin` (so a shared `/v/{id}` deep link still bounces through Dex
|
||||||
|
and returns to the destination). Keep the `isPublicPath` check first (redirect-loop guard).
|
||||||
|
|
||||||
|
**A3 — logout lands on `/welcome`, not login.** In `handleLogout`, change the final redirect
|
||||||
|
from `loginPath` to `/welcome`. As written it sends the user to `/auth/login`, which
|
||||||
|
immediately starts a fresh Dex login — visibly failing to log out. This intentionally breaks
|
||||||
|
the existing logout test (oidc_test.go) which asserts redirect to `/auth/login`; update that
|
||||||
|
test to expect `/welcome`. That break is expected, not a regression.
|
||||||
|
|
||||||
|
**A4 — mount the landing handler** in `internal/web/handlers.go` `Router()`, on `root`,
|
||||||
|
OUTSIDE `Auth.Middleware`, alongside `/healthz`:
|
||||||
|
```go
|
||||||
|
root.HandleFunc("GET /welcome", a.handleWelcome)
|
||||||
|
```
|
||||||
|
`handleWelcome` peeks `a.Auth.CurrentUser(r)` and renders `WelcomePage(user, ok)`. Not behind
|
||||||
|
`Auth.Middleware` or `registrationGate`.
|
||||||
|
|
||||||
|
**A5 — `WelcomePage` templ component** in `views.templ`. Reuse the existing shared
|
||||||
|
layout/header partial and the established aesthetic (#7653FC purple rounded ╭─╮╰─╯ box, pink
|
||||||
|
tapir mascot, #0EF9B6 mint accents) — match the existing pages, do not reinvent styling.
|
||||||
|
- Logged out (`ok == false`): tapir mascot + tagline; one primary CTA **"Get Started"** →
|
||||||
|
`/auth/login`; honest sub-text: "New here? You'll set up your account right after signing in
|
||||||
|
— returning users go straight through." One button only (see verified facts: no separate
|
||||||
|
Dex sign-up; two buttons to the same URL would mislead).
|
||||||
|
- Logged in (`ok == true`): "Go to my Tapir" → `/`; "Log Out" → `/auth/logout`. May greet via
|
||||||
|
`user.Email`.
|
||||||
|
|
||||||
|
**A6 — tests** (extend `handlers_test.go` patterns). Note `StubAuth.CurrentUser` always returns
|
||||||
|
true; for the logged-out case use a fake Auth returning `(web.User{}, false)`.
|
||||||
|
- `GET /welcome`, no session → "Get Started" → `/auth/login`.
|
||||||
|
- `GET /welcome`, with session → "Go to my Tapir" + "Log Out".
|
||||||
|
- Unauthenticated `GET /` → 302 `/welcome`.
|
||||||
|
- Unauthenticated `GET /v/{id}` → still 302 `/auth/login` (deep link preserved).
|
||||||
|
- Authenticated `GET /` → still serves the list, unchanged.
|
||||||
|
- oidc: `handleLogout` → 302 `/welcome` (update the existing test).
|
||||||
|
|
||||||
|
**A out of scope:** no Dex config change, no new auth/session logic, no sign-up backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream B — Documentation reconciliation
|
||||||
|
|
||||||
|
### Why
|
||||||
|
The guardrail docs were written before Stage 1 and the web surface. Several now describe the
|
||||||
|
opposite of the deployed reality (v0.4.0). Stale guardrail docs are worse than none — a future
|
||||||
|
cold session (human or agent) trusts them. This workstream brings requirements, use cases,
|
||||||
|
architecture, and data-model back in sync with `main`. Each fix is one commit; cite the ADR or
|
||||||
|
migration that is the source of truth.
|
||||||
|
|
||||||
|
### Known drift to fix (verified this session — not exhaustive; the worker confirms against code)
|
||||||
|
**B1 — `internal/web/auth.go` comments.** The `User.Subject` doc and package doc still say
|
||||||
|
"single-user allowlist (ADR-011)" / "Stage-0". Code is multi-user (ADR-012). Update the
|
||||||
|
comments to describe the current multi-user reality; reference ADR-012.
|
||||||
|
|
||||||
|
**B2 — `docs/data-model.md` isolation status.** It says isolation enforcement is "dormant at
|
||||||
|
Stage 0". It is now LIVE: Postgres RLS, `FORCE`d on all user-owned tables, with a passing
|
||||||
|
two-user isolation test (ADR-012, migration 003). Rewrite that section to describe enforced
|
||||||
|
RLS as the current state; keep the history honest (was dormant at Stage 0, enforced from
|
||||||
|
Stage 1).
|
||||||
|
|
||||||
|
**B3 — `docs/data-model.md` schema completeness.** The doc predates migrations 002–006. Add
|
||||||
|
the entities/columns that now exist: `summary_actions` (002), RLS (003), `user_identities`
|
||||||
|
(004, dex_subject→user_id), `video_connections` (005), `users.auto_summarize` +
|
||||||
|
`videos.summarize_requested` (006). The ER section should match the live schema. Cross-check
|
||||||
|
against `internal/adapters/store/migrations/*.up.sql` — those are ground truth.
|
||||||
|
|
||||||
|
**B4 — `docs/architecture/architecture.md`.** Predates the entire web surface. Update the C4
|
||||||
|
container diagram and text to include: `tapir serve` (HTMX+Templ web reader/writer), the Dex
|
||||||
|
OIDC session layer (`internal/web/oidc`), registration gate, web-initiated YouTube connect,
|
||||||
|
account management, and the immediate-processing path (web "Summarize" button → background
|
||||||
|
goroutine → status poll). The engine/ports/sinks core is unchanged (ADR-003) — show the web
|
||||||
|
surface as a new transport over the same core, not a core change.
|
||||||
|
|
||||||
|
**B5 — `docs/use-cases/*.feature`.** Add scenarios for the behaviours now live and unspecced:
|
||||||
|
register (new subject → registration → user row; returning user straight through), connect
|
||||||
|
YouTube (web OAuth), disconnect, delete-account (cascade + secret purge, Dex untouched —
|
||||||
|
ADR-013), manual-vs-auto summarize mode + the Summarize button, and the landing page
|
||||||
|
(logged-out CTA; logged-in shortcuts). Keep them as executable-style Gherkin consistent with
|
||||||
|
the existing files.
|
||||||
|
|
||||||
|
**B6 — `DECISIONS.md` ADR ordering (cosmetic).** ADR-010 sits before ADR-009/011 (append
|
||||||
|
order). Reorder to numeric while you're in the file. Pure tidy, no content change.
|
||||||
|
|
||||||
|
**B7 — requirements check.** If a requirements doc exists (e.g. `docs/ui-spec.md`, referenced
|
||||||
|
by ADR-011), reconcile it with what shipped: note where the build deviated (e.g. the spinner /
|
||||||
|
immediate processing / summarize mode were beyond the original spec) so the spec reflects
|
||||||
|
reality or explicitly records the deviation. Do not silently rewrite history — record
|
||||||
|
deviations as deviations.
|
||||||
|
|
||||||
|
### B working method
|
||||||
|
- Source of truth order: migrations + code > ADRs > prose docs. When a prose doc disagrees
|
||||||
|
with code, the code wins and the doc is corrected (unless the code is the bug — then flag it,
|
||||||
|
don't quietly doc around it).
|
||||||
|
- One logical doc per commit. Cite the ADR/migration that justifies each change in the commit
|
||||||
|
body.
|
||||||
|
- This is an audit, not a rewrite: preserve the docs' structure and the "rejected alternatives
|
||||||
|
/ history" honesty. The goal is *current and trustworthy*, not *pretty*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coordination
|
||||||
|
A and B touch mostly different files (A: oidc.go, handlers.go, views.templ, tests; B: docs/* +
|
||||||
|
auth.go comments). The one overlap is `auth.go` (B1 edits comments) vs A (reads it) — no
|
||||||
|
conflict. Run A and B in parallel; commit independently to `main`.
|
||||||
|
|
||||||
|
If anything in B reveals that code, not docs, is wrong (e.g. an isolation gap, a migration that
|
||||||
|
doesn't match the data-model intent), STOP and surface it — that's a finding, not a doc edit.
|
||||||
@@ -147,3 +147,28 @@ Gate (lane A) commits first; B/C/D follow.
|
|||||||
|
|
||||||
`task check` green per lane; B/C/D rebase on A. Deploy (D) lands last, after the binary serves
|
`task check` green per lane; B/C/D rebase on A. Deploy (D) lands last, after the binary serves
|
||||||
locally.
|
locally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deviations and additions (as-built)
|
||||||
|
|
||||||
|
This spec describes the **Stage-0 single-user reader** (ADR-011). What actually shipped through
|
||||||
|
v0.4.0 went further — Stage 1 (ADR-012) opened multi-user, and several UX features were added on
|
||||||
|
top. Recorded here (append-only; the spec above is left intact) so intent and reality stay
|
||||||
|
distinguishable.
|
||||||
|
|
||||||
|
| As-built feature | What it is | Why | Covered by |
|
||||||
|
|------------------|-----------|-----|------------|
|
||||||
|
| **Multi-user + RLS isolation** | Several Dex users per deployment; isolation enforced by Postgres RLS, not the single-subject allowlist of §6. | Maintainer opened Stage 1 ahead of the formal Stage-0 gate, with DB-enforced isolation as the guardrail that keeps it safe. | ADR-012; migration 003 (`6775e5f`, `f28fdc0`, `2ae66da`) |
|
||||||
|
| **Registration gate** | A Dex subject with no `users` row is routed to `/register`, which creates the `users` row + a `user_identities` mapping. (§2 listed "sign-up / user CRUD" as a non-goal.) | Explicit registration is how a multi-user surface stays honest — no just-in-time row creation. | ADR-012; `f396e01` |
|
||||||
|
| **Per-user YouTube web connect** | `/oauth/youtube/connect` → `/oauth/youtube/callback` stores a per-user refresh-token ref + a `video_connections` row. (The spec assumed a host-side `tapir auth` only.) | Multi-user means each user connects their own account from the browser. | ADR-006, ADR-012; migration 005 (`0c9531a`, `2aad79b`) |
|
||||||
|
| **Account management** | `/account` page with **disconnect** and **delete account**; delete removes only Tapir-side state and leaves the Dex identity intact. (§2 listed isolation/CRUD as non-goals.) | A real account needs a way out; deletion semantics are deliberately Tapir-side only. | ADR-013; `22eafcf`, `c7624d9`, `17d5e8c` |
|
||||||
|
| **Immediate web summarization** | A "Summarize" button (`POST /v/{id}/summarize`) runs the engine in a background goroutine inside `serve`; the page HTMX-polls `GET /v/{id}/status`. (§2 said "triggering runs from the browser … do NOT build".) | Reading a list you can't act on is half a product; on-demand summarize closes the loop without waiting for a batch `tapir run`. | ADR-012, ADR-014; `25215cb`, `8c6c7ca` |
|
||||||
|
| **Charmbracelet tapir spinner** | An animated in-flight indicator (charm palette) shown while a summarize is processing; an honest "queued/waiting" state under rate-limiting rather than a stuck spinner. | The spinner must tell the truth when the timedtext endpoint rate-limits (429), not imply imminence. | ADR-014; `25215cb`, `a4aeb5e` |
|
||||||
|
| **Auto/manual summarization mode** | Per-user `auto_summarize`; manual (default) lists new videos unsummarized and queues via `summarize_requested`; a mode toggle at `/account/summarize-mode`. | Control over compute/noise — only summarize what the user cares about. | migration 006 (`748d5eb`, `bdbdce7`, `3014ee0`, `a269d4a`) |
|
||||||
|
| **Public landing page** | `/welcome` mounted **outside** the auth guard; unauthenticated `/` redirects there; logout returns there (not `/auth/login`). (The spec guarded everything except `/healthz` and `/auth/*`.) | A first-time visitor needs a public "what is this / get started" page before the login wall. | `d83943c`, `0fdf2f7`, `3a27bf1`, `d208110`, `8ca374e`, `f15f57f` |
|
||||||
|
|
||||||
|
The original Stage-0 goals (read summaries, record watch/skip/save actions, Dex login, GitOps
|
||||||
|
deploy) still hold — these are additions over that base, not replacements. The architecture
|
||||||
|
stance is unchanged: every item above is web-surface or store work; the engine/ports/sinks core
|
||||||
|
was not modified (ADR-003).
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
Feature: Public landing page
|
||||||
|
As a first-time visitor
|
||||||
|
I want a public welcome page before I log in
|
||||||
|
So that I understand what Tapir is and how to get started without hitting a login wall
|
||||||
|
|
||||||
|
Scenario: An unauthenticated visit to the root is sent to the welcome page
|
||||||
|
Given I am not logged in
|
||||||
|
When I open the root path "/"
|
||||||
|
Then I am redirected to "/welcome"
|
||||||
|
|
||||||
|
Scenario: The welcome page invites an unauthenticated visitor to start
|
||||||
|
Given I am not logged in
|
||||||
|
When I open "/welcome"
|
||||||
|
Then I see a "Get Started" call to action
|
||||||
|
|
||||||
|
Scenario: An authenticated user on the welcome page sees their way in and out
|
||||||
|
Given I am logged in
|
||||||
|
When I open "/welcome"
|
||||||
|
Then I see a link to my summaries
|
||||||
|
And I see a way to log out
|
||||||
|
|
||||||
|
Scenario: Logging out returns to the welcome page
|
||||||
|
Given I am logged in
|
||||||
|
When I log out
|
||||||
|
Then I am returned to "/welcome"
|
||||||
|
|
||||||
|
# /welcome is mounted outside the auth guard so it is reachable without a session;
|
||||||
|
# the root and all data routes stay behind it (commits around the WelcomePage work).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
Feature: Register and manage a multi-user account
|
||||||
|
As one of a handful of trusted users
|
||||||
|
I want my own account, isolated from everyone else's
|
||||||
|
So that Tapir can serve several people from one deployment without leaking data
|
||||||
|
|
||||||
|
# Stage 1 (ADR-012): Dex authenticates, Tapir authorizes per user. A Dex subject
|
||||||
|
# with no users row is a new user and must register before reaching any data.
|
||||||
|
|
||||||
|
Scenario: A new Dex subject is routed to registration
|
||||||
|
Given I am authenticated by Dex with a subject that has no Tapir account
|
||||||
|
When I open any page that requires an account
|
||||||
|
Then I am routed to the registration page
|
||||||
|
And no summaries are shown until I register
|
||||||
|
|
||||||
|
Scenario: Registering creates the account and its identity mapping
|
||||||
|
Given I am authenticated by Dex with a subject that has no Tapir account
|
||||||
|
When I complete registration
|
||||||
|
Then a user row is created for me
|
||||||
|
And a user_identities row maps my Dex subject to that user
|
||||||
|
And I am taken into the app as a registered user
|
||||||
|
|
||||||
|
Scenario: A returning subject passes straight through
|
||||||
|
Given I am authenticated by Dex with a subject that already has a Tapir account
|
||||||
|
When I open the app
|
||||||
|
Then I am not asked to register again
|
||||||
|
And I see my own summaries
|
||||||
|
|
||||||
|
Scenario: Deleting an account removes only my data and leaves other users untouched
|
||||||
|
Given I am a registered user with summaries, a connected account, and recorded actions
|
||||||
|
And another user exists with their own summaries
|
||||||
|
When I delete my account
|
||||||
|
Then all of my rows are removed across every user-owned table
|
||||||
|
And my stored secret references are removed
|
||||||
|
And the other user's data remains intact
|
||||||
|
And my Dex identity is left intact
|
||||||
|
|
||||||
|
Scenario: A deleted user can register again as a fresh account
|
||||||
|
Given I deleted my Tapir account but my Dex identity still exists
|
||||||
|
When I sign in again
|
||||||
|
Then I am routed to the registration page as a new user
|
||||||
|
And registering creates a fresh user row with none of my old data
|
||||||
|
|
||||||
|
# Isolation is DB-enforced (Postgres RLS, ADR-012, migration 003): a user can never
|
||||||
|
# read or write another user's rows even if an application WHERE clause is wrong.
|
||||||
|
# Deletion is Tapir-side only — the shared Dex directory is never modified (ADR-013).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
Feature: Choose how new videos get summarized
|
||||||
|
As a user who wants control over compute and noise
|
||||||
|
I want to pick whether new videos are summarized automatically or on demand
|
||||||
|
So that I only spend summarization on the videos I actually care about
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am a registered user with a connected video account
|
||||||
|
|
||||||
|
Scenario: Auto mode summarizes every new video
|
||||||
|
Given my summarization mode is "auto"
|
||||||
|
When a subscribed channel posts a new video with captions
|
||||||
|
Then Tapir summarizes it without my asking
|
||||||
|
And the summary appears in my list
|
||||||
|
|
||||||
|
Scenario: Manual mode is the default and leaves new videos unsummarized
|
||||||
|
Given I have not changed my summarization mode
|
||||||
|
Then my mode is "manual"
|
||||||
|
When a subscribed channel posts a new video with captions
|
||||||
|
Then the video appears in my list with no summary
|
||||||
|
And nothing is summarized until I request it
|
||||||
|
|
||||||
|
Scenario: Requesting a summary in manual mode queues it for the next run
|
||||||
|
Given my summarization mode is "manual"
|
||||||
|
And a new video is in my list with no summary
|
||||||
|
When I click "Summarize" on that video
|
||||||
|
Then the video is marked as requested
|
||||||
|
And the next run summarizes it
|
||||||
|
And the request flag is cleared after it is processed
|
||||||
|
|
||||||
|
# auto_summarize is a per-user setting and summarize_requested is a per-video queue
|
||||||
|
# flag (migration 006). The web button sets the flag; `tapir run` processes both the
|
||||||
|
# auto videos and the manually queued ones, then clears the flag.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Package web is the Stage-0 HTTP read/write surface (ADR-011, docs/ui-spec.md).
|
// Package web is the multi-user HTTP read/write surface (ADR-012, docs/ui-spec.md).
|
||||||
// It serves the summary reader over the existing store; the engine and ports are
|
// It serves the summary reader over the existing store; the engine and ports are
|
||||||
// untouched (ADR-003).
|
// untouched (ADR-003). ADR-011 shipped this as a single-user Stage-0 reader; ADR-012
|
||||||
|
// opened Stage 1 — multiple Dex-authenticated users with DB-enforced (RLS) isolation.
|
||||||
//
|
//
|
||||||
// This file defines the auth SEAM so the Dex session layer (internal/web/oidc)
|
// This file defines the auth SEAM so the Dex session layer (internal/web/oidc)
|
||||||
// and the page/handler layer can be built independently: handlers depend only on
|
// and the page/handler layer can be built independently: handlers depend only on
|
||||||
@@ -10,9 +11,10 @@ package web
|
|||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
// User is the authenticated principal. Subject is the Dex subject used for the
|
// User is the authenticated principal. Subject is the Dex subject — the key for the
|
||||||
// single-user allowlist (ADR-011); store operations key off the configured
|
// user_identities lookup (ADR-012) that resolves to a tapir user_id (UUID); store
|
||||||
// tapir user_id (UUID), not this subject.
|
// operations scope every row by that id, not by this subject. A subject with no
|
||||||
|
// users row is routed through the registration gate (see registration.go).
|
||||||
type User struct {
|
type User struct {
|
||||||
Subject string
|
Subject string
|
||||||
Email string
|
Email string
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ type App struct {
|
|||||||
// Secrets removes a user's OAuth tokens on disconnect / delete-account. The
|
// Secrets removes a user's OAuth tokens on disconnect / delete-account. The
|
||||||
// account routes require it; cmd/tapir wires the file-backed store.
|
// account routes require it; cmd/tapir wires the file-backed store.
|
||||||
Secrets SecretRemover
|
Secrets SecretRemover
|
||||||
|
// Processor, when non-nil, summarizes a queued video immediately in a
|
||||||
|
// background goroutine (the "Summarize" button kicks it off). Nil = queue-only:
|
||||||
|
// the button flips the DB flag and the next `tapir run` does the work.
|
||||||
|
Processor Processor
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) logger() *slog.Logger {
|
func (a *App) logger() *slog.Logger {
|
||||||
@@ -76,6 +83,7 @@ func (a *App) logger() *slog.Logger {
|
|||||||
func (a *App) Router() http.Handler {
|
func (a *App) Router() http.Handler {
|
||||||
root := http.NewServeMux()
|
root := http.NewServeMux()
|
||||||
root.HandleFunc("GET /healthz", a.handleHealthz)
|
root.HandleFunc("GET /healthz", a.handleHealthz)
|
||||||
|
root.HandleFunc("GET /welcome", a.handleWelcome)
|
||||||
root.Handle("GET /static/", staticHandler())
|
root.Handle("GET /static/", staticHandler())
|
||||||
root.Handle("/auth/", a.Auth.Routes())
|
root.Handle("/auth/", a.Auth.Routes())
|
||||||
|
|
||||||
@@ -84,6 +92,7 @@ func (a *App) Router() http.Handler {
|
|||||||
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
||||||
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
||||||
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
||||||
|
app.HandleFunc("GET /v/{videoId}/status", a.handleStatus)
|
||||||
app.HandleFunc("GET /register", a.handleRegisterForm)
|
app.HandleFunc("GET /register", a.handleRegisterForm)
|
||||||
app.HandleFunc("POST /register", a.handleRegister)
|
app.HandleFunc("POST /register", a.handleRegister)
|
||||||
|
|
||||||
@@ -109,6 +118,15 @@ func (a *App) Router() http.Handler {
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleWelcome renders the public landing page (/welcome). It is mounted outside
|
||||||
|
// Auth.Middleware, so it must not assume a session: CurrentUser peeks the cookie
|
||||||
|
// without redirecting and the page renders the logged-out or logged-in variant
|
||||||
|
// accordingly.
|
||||||
|
func (a *App) handleWelcome(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.Auth.CurrentUser(r)
|
||||||
|
a.render(w, r, WelcomePage(user, ok))
|
||||||
|
}
|
||||||
|
|
||||||
// handleHealthz is the unauthenticated liveness/readiness probe.
|
// handleHealthz is the unauthenticated liveness/readiness probe.
|
||||||
func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
@@ -208,10 +226,12 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
|
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRequestSummarize queues a video for manual summarization. It does NOT run
|
// handleRequestSummarize handles the "Summarize" button. It always flips the DB
|
||||||
// the engine inline — it only flips summarize_requested; the next `tapir run`
|
// flag (summarize_requested) so the work is durable. With a Processor wired it
|
||||||
// picks it up (the single summarization driver). For HTMX it returns the refreshed
|
// then summarizes immediately in the background and answers with the animated
|
||||||
// card (now showing "Queued"); without JS it redirects back to the list.
|
// processing card that polls /status until done; without one (queue-only) it
|
||||||
|
// answers with the "Queued" card — the next `tapir run` does the work. Without
|
||||||
|
// JS it redirects back to the list (POST→redirect→GET).
|
||||||
func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := a.currentUserID(w, r)
|
userID, ok := a.currentUserID(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -238,9 +258,60 @@ func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.serverError(w, r, "get video", err)
|
a.serverError(w, r, "get video", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.Processor != nil {
|
||||||
|
a.startProcessing(userID, videoID)
|
||||||
|
a.render(w, r, processingCard(*row))
|
||||||
|
return
|
||||||
|
}
|
||||||
a.render(w, r, VideoCard(*row))
|
a.render(w, r, VideoCard(*row))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startProcessing marks a video in-flight and summarizes it in the background.
|
||||||
|
// The goroutine uses a detached context — not the request's, which is cancelled
|
||||||
|
// when the handler returns — and clears the in-flight mark on completion. On
|
||||||
|
// error the DB flag stays set, so the video remains queued for the next
|
||||||
|
// `tapir run`; a successful Processor.ProcessVideo clears it itself.
|
||||||
|
func (a *App) startProcessing(userID, videoID string) {
|
||||||
|
key := processingKey(userID, videoID)
|
||||||
|
a.Processing.Add(key)
|
||||||
|
go func() {
|
||||||
|
defer a.Processing.Remove(key)
|
||||||
|
if err := a.Processor.ProcessVideo(context.Background(), userID, videoID); err != nil {
|
||||||
|
a.logger().Error("background summarize", "user", userID, "video", videoID, "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatus is the HTMX poll target for an in-flight summarization. It returns
|
||||||
|
// the card in its current state: the full summary card once the summary exists,
|
||||||
|
// otherwise the animated processing card while still in-flight (which keeps
|
||||||
|
// polling), or the queued/button card when neither holds. VideoCard carries no
|
||||||
|
// polling attributes, so HTMX stops polling once it swaps in.
|
||||||
|
func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoID := r.PathValue("videoId")
|
||||||
|
|
||||||
|
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "get video", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Summarized || !a.Processing.Has(processingKey(userID, videoID)) {
|
||||||
|
a.render(w, r, VideoCard(*row))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, processingCard(*row))
|
||||||
|
}
|
||||||
|
|
||||||
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
|
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
|
||||||
// submits the desired new value (enabled=true|false). For HTMX it returns the
|
// submits the desired new value (enabled=true|false). For HTMX it returns the
|
||||||
// refreshed mode control; without JS it redirects back to the account page.
|
// refreshed mode control; without JS it redirects back to the account page.
|
||||||
|
|||||||
@@ -161,11 +161,11 @@ func (d *DexAuth) Middleware(h http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
sid, ok := d.sessionID(r)
|
sid, ok := d.sessionID(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
d.redirectToLogin(w, r)
|
d.redirectUnauthenticated(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := d.sessions.get(sid, d.now()); !ok {
|
if _, ok := d.sessions.get(sid, d.now()); !ok {
|
||||||
d.redirectToLogin(w, r)
|
d.redirectUnauthenticated(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh
|
d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh
|
||||||
@@ -266,7 +266,21 @@ func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
d.sessions.delete(sid)
|
d.sessions.delete(sid)
|
||||||
}
|
}
|
||||||
d.clearSessionCookie(w)
|
d.clearSessionCookie(w)
|
||||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
// Land on the public landing page, not the login endpoint: a just-logged-out
|
||||||
|
// visitor should see /welcome, not be bounced straight back into a Dex login.
|
||||||
|
http.Redirect(w, r, "/welcome", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectUnauthenticated sends an unauthenticated visitor somewhere useful: the
|
||||||
|
// bare root goes to the public landing page (/welcome), any deeper guarded path
|
||||||
|
// goes to login so the post-login round-trip can return them to it. isPublicPath
|
||||||
|
// has already let /welcome and /auth/* through, so this never loops.
|
||||||
|
func (d *DexAuth) redirectUnauthenticated(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
http.Redirect(w, r, "/welcome", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.redirectToLogin(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -305,5 +319,5 @@ func (d *DexAuth) clearSessionCookie(w http.ResponseWriter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isPublicPath(p string) bool {
|
func isPublicPath(p string) bool {
|
||||||
return p == "/healthz" || strings.HasPrefix(p, "/auth/")
|
return p == "/healthz" || p == "/welcome" || strings.HasPrefix(p, "/auth/")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,9 +248,15 @@ func TestMiddlewareRedirectsUnauthenticated(t *testing.T) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// The bare root sends an unauthenticated visitor to the public landing page.
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
require.Equal(t, "/welcome", rec.Header().Get("Location"))
|
||||||
|
|
||||||
|
// A deeper guarded path goes to login so the post-login round-trip returns there.
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v/some-id", nil))
|
||||||
require.Equal(t, http.StatusFound, rec.Code)
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
require.Equal(t, "/auth/login", rec.Header().Get("Location"))
|
require.Equal(t, "/auth/login", rec.Header().Get("Location"))
|
||||||
}
|
}
|
||||||
@@ -280,7 +286,7 @@ func TestMiddlewarePublicPathsBypassAuth(t *testing.T) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
for _, path := range []string{"/healthz", "/auth/login"} {
|
for _, path := range []string{"/healthz", "/welcome", "/auth/login"} {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
require.Equal(t, http.StatusOK, rec.Code, "expected %s to bypass auth", path)
|
require.Equal(t, http.StatusOK, rec.Code, "expected %s to bypass auth", path)
|
||||||
@@ -298,6 +304,7 @@ func TestLogoutClearsSession(t *testing.T) {
|
|||||||
auth.Routes().ServeHTTP(rec, req)
|
auth.Routes().ServeHTTP(rec, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusFound, rec.Code)
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page")
|
||||||
cleared := sessionCookie(t, rec.Result())
|
cleared := sessionCookie(t, rec.Result())
|
||||||
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie")
|
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Processor runs the core summarization use case for a single already-discovered
|
||||||
|
// video — resolve its transcript, summarize, deliver to the store. *usecase.Engine
|
||||||
|
// wrapped with the store satisfies it (wired in cmd/tapir). Optional on App: a nil
|
||||||
|
// Processor means queue-only — the "Summarize" button only flips the DB flag and
|
||||||
|
// the next `tapir run` does the work.
|
||||||
|
type Processor interface {
|
||||||
|
ProcessVideo(ctx context.Context, userID, videoID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessingSet tracks the (user, video) ids currently being summarized in-process
|
||||||
|
// so the status endpoint can show the animation until the summary lands. It is
|
||||||
|
// ephemeral (single-instance Stage-1): a restart drops it, and the DB holds the
|
||||||
|
// durable state — the summary is present, or summarize_requested is still set so
|
||||||
|
// `tapir run` retries. The zero value is ready to use; methods are concurrency-safe.
|
||||||
|
type ProcessingSet struct {
|
||||||
|
m sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add marks a key in-flight.
|
||||||
|
func (p *ProcessingSet) Add(key string) { p.m.Store(key, struct{}{}) }
|
||||||
|
|
||||||
|
// Remove clears a key once its summarization finishes (success or failure).
|
||||||
|
func (p *ProcessingSet) Remove(key string) { p.m.Delete(key) }
|
||||||
|
|
||||||
|
// Has reports whether a key is currently in-flight.
|
||||||
|
func (p *ProcessingSet) Has(key string) bool {
|
||||||
|
_, ok := p.m.Load(key)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// processingKey scopes the in-flight key by user so one user's summarization is
|
||||||
|
// never confused with another's for the same video id.
|
||||||
|
func processingKey(userID, videoID string) string {
|
||||||
|
return userID + "|" + videoID
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeProcessor records ProcessVideo calls. With block set it parks until the
|
||||||
|
// channel is closed, so a test can observe the handler return before the
|
||||||
|
// background work finishes (proving it ran in a goroutine).
|
||||||
|
type fakeProcessor struct {
|
||||||
|
block chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeProcessor) ProcessVideo(_ context.Context, _, videoID string) error {
|
||||||
|
if f.block != nil {
|
||||||
|
<-f.block
|
||||||
|
}
|
||||||
|
f.calls = append(f.calls, videoID)
|
||||||
|
if f.done != nil {
|
||||||
|
close(f.done)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeImmediateProcessing(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
fp := &fakeProcessor{block: make(chan struct{}), done: make(chan struct{})}
|
||||||
|
app.Processor = fp
|
||||||
|
|
||||||
|
rec := postSummarize(t, app, videoX, true)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
|
||||||
|
// The processing card came back while ProcessVideo is still parked on block:
|
||||||
|
// the work runs in a goroutine, the handler did not wait for it.
|
||||||
|
require.Contains(t, html, "Summarizing", "processing card returned")
|
||||||
|
require.Contains(t, html, "╭", "charm box rendered")
|
||||||
|
require.Contains(t, html, "▓", "tapir body block chars rendered")
|
||||||
|
require.Contains(t, html, "∩", "wiggling snout frame rendered")
|
||||||
|
require.Contains(t, html, web.CharmPurple, "charm palette applied to the border")
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"/status", "card polls the status endpoint")
|
||||||
|
require.Contains(t, html, `hx-trigger="every 2s"`, "card auto-polls every 2s")
|
||||||
|
require.NotContains(t, html, "Queued", "not the queue-only card")
|
||||||
|
|
||||||
|
close(fp.block)
|
||||||
|
select {
|
||||||
|
case <-fp.done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("ProcessVideo was not called in the background")
|
||||||
|
}
|
||||||
|
require.Equal(t, []string{videoX}, fp.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusProcessingThenDone(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
// Park ProcessVideo so the video stays in-flight while we poll status.
|
||||||
|
fp := &fakeProcessor{block: make(chan struct{})}
|
||||||
|
app.Processor = fp
|
||||||
|
require.Equal(t, http.StatusOK, postSummarize(t, app, videoX, true).Code)
|
||||||
|
|
||||||
|
// Processing: status returns the animation card, still polling.
|
||||||
|
rec := getStatus(t, app, videoX)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Summarizing", "in-flight → animation card")
|
||||||
|
require.Contains(t, html, `hx-trigger="every 2s"`, "still polling")
|
||||||
|
|
||||||
|
close(fp.block)
|
||||||
|
|
||||||
|
// Done: once a summary exists, status returns the summary card with no poll.
|
||||||
|
require.NoError(t, deliver(ctx, app, videoX, "the summary body"))
|
||||||
|
rec = getStatus(t, app, videoX)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html = body(t, rec)
|
||||||
|
require.NotContains(t, html, "Summarizing", "done → no animation")
|
||||||
|
require.NotContains(t, html, "every 2s", "done card does not poll (polling stops)")
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"\"", "links to the detail page")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusQueuedWhenNotInFlight(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
// Flag set but nothing in-flight (e.g. queue-only, or after a restart).
|
||||||
|
require.NoError(t, app.Store.RequestSummarize(ctx, userID, videoX))
|
||||||
|
|
||||||
|
rec := getStatus(t, app, videoX)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Queued", "queued chip card")
|
||||||
|
require.NotContains(t, html, "Summarizing", "not processing")
|
||||||
|
require.NotContains(t, html, "every 2s", "queued card does not poll")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusNotFound(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
rec := getStatus(t, app, videoX)
|
||||||
|
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStatus(t *testing.T, app *web.App, videoID string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v/"+videoID+"/status", nil)
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.Router().ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestProcessingSetAddHasRemove(t *testing.T) {
|
||||||
|
var s ProcessingSet // zero value is usable
|
||||||
|
|
||||||
|
key := processingKey("user-1", "video-1")
|
||||||
|
if s.Has(key) {
|
||||||
|
t.Fatal("fresh set must not report a key as in-flight")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Add(key)
|
||||||
|
if !s.Has(key) {
|
||||||
|
t.Fatal("Add must mark the key in-flight")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different user with the same video id is a distinct key.
|
||||||
|
if s.Has(processingKey("user-2", "video-1")) {
|
||||||
|
t.Fatal("keys must be scoped by user")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Remove(key)
|
||||||
|
if s.Has(key) {
|
||||||
|
t.Fatal("Remove must clear the key")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
|
|
||||||
@@ -176,6 +177,121 @@ func summarizeURL(videoID string) templ.SafeURL {
|
|||||||
return templ.SafeURL("/v/" + videoID + "/summarize")
|
return templ.SafeURL("/v/" + videoID + "/summarize")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statusURL builds the processing-status poll path (GET) for a video id — the
|
||||||
|
// HTMX poll target while an immediate summarization is in flight.
|
||||||
|
func statusURL(videoID string) templ.SafeURL {
|
||||||
|
return templ.SafeURL("/v/" + videoID + "/status")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const (
|
||||||
|
CharmPurple = "#7653FC" // box border
|
||||||
|
CharmPink = "#FF6E9C" // tapir body
|
||||||
|
CharmMint = "#0EF9B6" // snout, eyes, progress fill
|
||||||
|
CharmCream = "#FFFDF5" // bright text
|
||||||
|
CharmDim = "#6C6C6C" // dim text
|
||||||
|
charmTrack = "#2D2D2D" // empty progress track (internal: dark char colour)
|
||||||
|
)
|
||||||
|
|
||||||
|
// tapirInteriorW is the fixed inner width of the Charm box, in monospace cells.
|
||||||
|
const tapirInteriorW = 34
|
||||||
|
|
||||||
|
// tapirBarFill is the mint progress fill (27 cells), revealed left→right by the
|
||||||
|
// CSS width/clip animation over the dim track drawn in each frame.
|
||||||
|
const tapirBarFill = "███████████████████████████"
|
||||||
|
|
||||||
|
// tapirRun is one coloured (or uncoloured) text segment of a box row.
|
||||||
|
type tapirRun struct {
|
||||||
|
s string
|
||||||
|
color string // "" = no span (plain text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tapirSpan(color, s string) string {
|
||||||
|
if color == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return `<span style="color:` + color + `">` + s + `</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tapirLine renders one interior box row: concatenate the coloured runs, pad with
|
||||||
|
// spaces to the fixed interior width, then flank with the purple side borders.
|
||||||
|
// Padding is computed from the runs' rune counts, so every row's right border
|
||||||
|
// lines up no matter how many runs it has (assuming 1-cell monospace glyphs).
|
||||||
|
func tapirLine(runs ...tapirRun) string {
|
||||||
|
var b strings.Builder
|
||||||
|
width := 0
|
||||||
|
for _, r := range runs {
|
||||||
|
b.WriteString(tapirSpan(r.color, r.s))
|
||||||
|
width += utf8.RuneCountInString(r.s)
|
||||||
|
}
|
||||||
|
if width < tapirInteriorW {
|
||||||
|
b.WriteString(strings.Repeat(" ", tapirInteriorW-width))
|
||||||
|
}
|
||||||
|
bar := tapirSpan(CharmPurple, "│")
|
||||||
|
return bar + b.String() + bar
|
||||||
|
}
|
||||||
|
|
||||||
|
// tapirFrameHTML builds one animation frame: a rounded Charm box around a colored
|
||||||
|
// ASCII tapir, a dim progress track, and labels. snout is the wiggling nose glyph
|
||||||
|
// that differs between the three frames. Returned as raw HTML (coloured spans),
|
||||||
|
// emitted verbatim by the template via templ.Raw.
|
||||||
|
func tapirFrameHTML(snout string) string {
|
||||||
|
top := tapirSpan(CharmPurple, "╭"+strings.Repeat("─", tapirInteriorW)+"╮")
|
||||||
|
bottom := tapirSpan(CharmPurple, "╰"+strings.Repeat("─", tapirInteriorW)+"╯")
|
||||||
|
lines := []string{
|
||||||
|
top,
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "◆", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "tapir", color: CharmCream}),
|
||||||
|
tapirLine(),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄▄▄▄▄", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄█▓▓▓▓█▄", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: snout, color: CharmMint}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "█▓(", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "◕ ◕", color: CharmMint}, tapirRun{s: ")▓█", color: CharmPink}, tapirRun{s: "──┘", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "< thinking...", color: CharmDim}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀█▓▓▓▓█▀", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "██▄▄██", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}),
|
||||||
|
tapirLine(),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "[", color: CharmDim}, tapirRun{s: strings.Repeat("░", 27), color: charmTrack}, tapirRun{s: "]", color: CharmDim}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "summarizing", color: CharmDim}),
|
||||||
|
bottom,
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three frames differ only in the snout glyph (∩ → ∪ → ~), cross-faded by CSS
|
||||||
|
// to read as a tapir wiggling its nose while it thinks.
|
||||||
|
var (
|
||||||
|
tapirFrameHTML1 = tapirFrameHTML("∩")
|
||||||
|
tapirFrameHTML2 = tapirFrameHTML("∪")
|
||||||
|
tapirFrameHTML3 = tapirFrameHTML("~")
|
||||||
|
)
|
||||||
|
|
||||||
|
// welcomeHeroHTML is the static Charm-box tapir mascot on the public landing
|
||||||
|
// page — the same rounded purple box / pink tapir / mint accents as the spinner,
|
||||||
|
// but a single still frame with a friendly tagline instead of the animation.
|
||||||
|
// Built from the shared tapirLine helpers so the aesthetic stays in one place.
|
||||||
|
func welcomeHeroHTML() string {
|
||||||
|
top := tapirSpan(CharmPurple, "╭"+strings.Repeat("─", tapirInteriorW)+"╮")
|
||||||
|
bottom := tapirSpan(CharmPurple, "╰"+strings.Repeat("─", tapirInteriorW)+"╯")
|
||||||
|
lines := []string{
|
||||||
|
top,
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "◆", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "tapir", color: CharmCream}),
|
||||||
|
tapirLine(),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄▄▄▄▄", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄█▓▓▓▓█▄", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "∩", color: CharmMint}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "█▓(", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "◕ ◕", color: CharmMint}, tapirRun{s: ")▓█", color: CharmPink}, tapirRun{s: "──┘", color: CharmMint}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀█▓▓▓▓█▀", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "██▄▄██", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}),
|
||||||
|
tapirLine(),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "watch less, know more", color: CharmMint}),
|
||||||
|
bottom,
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
var welcomeHero = welcomeHeroHTML()
|
||||||
|
|
||||||
// summarizeModeLabel names the current mode for display.
|
// summarizeModeLabel names the current mode for display.
|
||||||
func summarizeModeLabel(auto bool) string {
|
func summarizeModeLabel(auto bool) string {
|
||||||
if auto {
|
if auto {
|
||||||
@@ -381,6 +497,31 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
|||||||
.card-pending { border-style: dashed; }
|
.card-pending { border-style: dashed; }
|
||||||
.card-pending .card-title { color: var(--muted); font-weight: 600; }
|
.card-pending .card-title { color: var(--muted); font-weight: 600; }
|
||||||
|
|
||||||
|
/* summarizing animation — a Charmbracelet-style TUI panel rendered in the
|
||||||
|
browser: a dark terminal card, a rounded purple box around a pink ASCII tapir,
|
||||||
|
and a lipgloss-style progress bar. Three frames are stacked and cross-faded by
|
||||||
|
a stepped keyframe (staggered delays) so the snout appears to wiggle; the
|
||||||
|
progress fill grows independently via a clip animation over the dim track. */
|
||||||
|
.card-processing { border-style: dashed; }
|
||||||
|
.tapir-charm { position: relative; display: inline-block; background: #0d0d12; border-radius: 10px; padding: .8em 1em; margin: var(--s2) 0; font: .82rem/1.15 ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace; box-shadow: 0 2px 14px rgba(118, 83, 252, .25); }
|
||||||
|
.tapir-charm pre { margin: 0; white-space: pre; opacity: 0; animation: tapir-cycle 1.2s steps(1, end) infinite; }
|
||||||
|
.tapir-charm .tapir-f1 { position: relative; animation-delay: 0s; }
|
||||||
|
.tapir-charm .tapir-f2 { position: absolute; top: .8em; left: 1em; animation-delay: .4s; }
|
||||||
|
.tapir-charm .tapir-f3 { position: absolute; top: .8em; left: 1em; animation-delay: .8s; }
|
||||||
|
@keyframes tapir-cycle { 0%, 33.32% { opacity: 1; } 33.33%, 100% { opacity: 0; } }
|
||||||
|
/* progress fill: 27 mint cells overlaying the dim track at box row 10, col 3,
|
||||||
|
revealed left→right over 8s, looping. */
|
||||||
|
.tapir-bar { position: absolute; top: calc(.8em + 11.5em); left: calc(1em + 3ch); height: 1.15em; line-height: 1.15; overflow: hidden; }
|
||||||
|
.tapir-bar-fill { animation: tapir-fill 8s linear infinite; text-shadow: 0 0 6px rgba(14, 249, 182, .7); }
|
||||||
|
@keyframes tapir-fill { 0% { clip-path: inset(0 100% 0 0); } 100% { clip-path: inset(0 0 0 0); } }
|
||||||
|
.tapir-label { color: var(--muted); font-size: .9rem; margin: 0; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tapir-charm pre { animation: none; }
|
||||||
|
.tapir-charm .tapir-f2, .tapir-charm .tapir-f3 { display: none; }
|
||||||
|
.tapir-charm .tapir-f1 { opacity: 1; }
|
||||||
|
.tapir-bar-fill { animation: none; clip-path: inset(0 35% 0 0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* summarization mode toggle on the account page */
|
/* summarization mode toggle on the account page */
|
||||||
.summarize-mode { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; }
|
.summarize-mode { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; }
|
||||||
.summarize-mode p { margin: 0; }
|
.summarize-mode p { margin: 0; }
|
||||||
@@ -458,6 +599,16 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
|||||||
.confirm-delete > summary:hover { background: #3a1714; }
|
.confirm-delete > summary:hover { background: #3a1714; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* public landing page (/welcome) — the Charm-box mascot hero plus the sign-in CTA */
|
||||||
|
.welcome { text-align: center; padding: var(--s5) var(--s3); display: flex; flex-direction: column; align-items: center; gap: var(--s4); }
|
||||||
|
.welcome-hero { background: #0d0d12; border-radius: 10px; padding: .9em 1.1em; display: inline-block; box-shadow: 0 2px 14px rgba(118, 83, 252, .25); }
|
||||||
|
.welcome-hero pre { margin: 0; white-space: pre; font: .82rem/1.15 ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace; }
|
||||||
|
.welcome-title { font-size: 1.9rem; line-height: 1.2; margin: 0; }
|
||||||
|
.welcome-tagline { color: var(--muted); font-size: 1.05rem; line-height: 1.5; margin: 0; max-width: 32rem; }
|
||||||
|
.welcome-cta { display: flex; gap: var(--s3); flex-wrap: wrap; justify-content: center; align-items: center; }
|
||||||
|
.welcome-sub { color: var(--muted); font-size: .9rem; margin: 0; }
|
||||||
|
.btn-lg { padding: .6rem 1.6rem; font-size: 1.05rem; }
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
main { padding: var(--s3) var(--s2); }
|
main { padding: var(--s3) var(--s2); }
|
||||||
.filters { gap: var(--s2); }
|
.filters { gap: var(--s2); }
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
func TestEmbedURL(t *testing.T) {
|
func TestEmbedURL(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -27,3 +32,19 @@ func TestEmbedURL(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTapirFrameRowsAligned asserts every box row has the same cell width once
|
||||||
|
// the inline-colour spans are stripped, so the rounded border lines up on every
|
||||||
|
// line (the panel only looks right if the right │ is flush across all rows).
|
||||||
|
func TestTapirFrameRowsAligned(t *testing.T) {
|
||||||
|
stripSpan := regexp.MustCompile(`</?span[^>]*>`)
|
||||||
|
for name, frame := range map[string]string{"f1": tapirFrameHTML1, "f2": tapirFrameHTML2, "f3": tapirFrameHTML3} {
|
||||||
|
plain := stripSpan.ReplaceAllString(frame, "")
|
||||||
|
want := tapirInteriorW + 2 // both purple side borders
|
||||||
|
for i, line := range strings.Split(plain, "\n") {
|
||||||
|
if got := utf8.RuneCountInString(line); got != want {
|
||||||
|
t.Errorf("%s line %d width = %d, want %d: %q", name, i, got, want, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,40 @@ templ Layout(title string) {
|
|||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WelcomePage is the public landing page (served at /welcome, outside the auth
|
||||||
|
// guard — ADR-012). Logged out: the tapir mascot, a one-line tagline, and a
|
||||||
|
// single "Get Started" CTA into the shared Dex flow (sign-in and sign-up are the
|
||||||
|
// same URL). Logged in: a greeting plus links back into the app and to log out.
|
||||||
|
templ WelcomePage(user User, loggedIn bool) {
|
||||||
|
@Layout("Tapir — Watch less, know more") {
|
||||||
|
<section class="welcome">
|
||||||
|
<div class="welcome-hero">
|
||||||
|
<pre aria-hidden="true">@templ.Raw(welcomeHero)</pre>
|
||||||
|
</div>
|
||||||
|
if loggedIn {
|
||||||
|
<h1 class="welcome-title">Welcome back</h1>
|
||||||
|
if user.Email != "" {
|
||||||
|
<p class="welcome-tagline">Signed in as { user.Email }.</p>
|
||||||
|
}
|
||||||
|
<div class="welcome-cta">
|
||||||
|
<a class="btn btn-lg" href="/">Go to my Tapir</a>
|
||||||
|
<a class="btn-secondary" href="/auth/logout">Log Out</a>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<h1 class="welcome-title">Watch less, know more</h1>
|
||||||
|
<p class="welcome-tagline">
|
||||||
|
Tapir summarizes the videos your subscriptions publish, so you can
|
||||||
|
skim the gist and decide what is worth your time.
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// flashBanner renders a one-shot notification for a flash code (connect success/
|
// flashBanner renders a one-shot notification for a flash code (connect success/
|
||||||
// failure, disconnect, delete, registration). An empty or unknown code renders
|
// failure, disconnect, delete, registration). An empty or unknown code renders
|
||||||
// nothing, so it is safe to drop into any page unconditionally. Reused across the
|
// nothing, so it is safe to drop into any page unconditionally. Reused across the
|
||||||
@@ -140,6 +174,42 @@ templ VideoCard(r store.SummaryRow) {
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TapirSpinner is the summarizing animation: a Charmbracelet-style TUI panel —
|
||||||
|
// three richly coloured ASCII tapir frames (inline span colours, snout wiggling
|
||||||
|
// ∩→∪→~) cross-faded by CSS, plus a lipgloss-style progress bar whose mint fill
|
||||||
|
// grows over the dim track. The panel is aria-hidden (decorative); the
|
||||||
|
// "Summarizing…" label below carries the meaning for assistive tech.
|
||||||
|
templ TapirSpinner() {
|
||||||
|
<div class="tapir-charm" aria-hidden="true">
|
||||||
|
<pre class="tapir-f1">@templ.Raw(tapirFrameHTML1)</pre>
|
||||||
|
<pre class="tapir-f2">@templ.Raw(tapirFrameHTML2)</pre>
|
||||||
|
<pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre>
|
||||||
|
<div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div>
|
||||||
|
</div>
|
||||||
|
<p class="tapir-label" role="status" aria-live="polite"><em>Summarizing…</em></p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// processingCard is the in-flight summarization card. It replaces the Summarize
|
||||||
|
// button card and polls /v/{id}/status every 2s, swapping itself (outerHTML, same
|
||||||
|
// id as VideoCard) for whatever state comes back: it keeps polling while still
|
||||||
|
// processing, and the summary/queued card it is eventually replaced by carries no
|
||||||
|
// poll, so polling stops on its own when the fragment changes.
|
||||||
|
templ processingCard(r store.SummaryRow) {
|
||||||
|
<li
|
||||||
|
class="card card-processing"
|
||||||
|
id={ "video-" + r.VideoID }
|
||||||
|
hx-get={ string(statusURL(r.VideoID)) }
|
||||||
|
hx-trigger="every 2s"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<div class="card-title">{ displayTitle(r) }</div>
|
||||||
|
if cardMeta(r) != "" {
|
||||||
|
<div class="card-meta">{ cardMeta(r) }</div>
|
||||||
|
}
|
||||||
|
@TapirSpinner()
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
||||||
// and the action button group.
|
// and the action button group.
|
||||||
templ DetailPage(r store.SummaryRow) {
|
templ DetailPage(r store.SummaryRow) {
|
||||||
|
|||||||
+688
-407
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeAuth is a configurable web.Auth for the landing-page tests: it reports a
|
||||||
|
// fixed (user, ok) from CurrentUser and, when logged out, replicates DexAuth's
|
||||||
|
// redirect split in Middleware — bare root → /welcome, deeper paths → login.
|
||||||
|
// StubAuth can't express the logged-out case (it allows everything), so the
|
||||||
|
// welcome routing needs this.
|
||||||
|
type fakeAuth struct {
|
||||||
|
user web.User
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeAuth) CurrentUser(*http.Request) (web.User, bool) { return f.user, f.ok }
|
||||||
|
func (f fakeAuth) Routes() http.Handler { return http.NewServeMux() }
|
||||||
|
|
||||||
|
func (f fakeAuth) Middleware(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if f.ok {
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
http.Redirect(w, r, "/welcome", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// appWithAuth builds an App with a given Auth but no store wiring — enough for
|
||||||
|
// the /welcome page (which never touches the store) and the unauthenticated
|
||||||
|
// redirect paths (which never reach a handler).
|
||||||
|
func appWithAuth(auth web.Auth) *web.App {
|
||||||
|
return &web.App{Auth: auth}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWelcomeLoggedOut(t *testing.T) {
|
||||||
|
app := appWithAuth(fakeAuth{ok: false})
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/welcome", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Get Started", "logged-out CTA present")
|
||||||
|
require.Contains(t, html, `href="/auth/login"`, "CTA links into the Dex flow")
|
||||||
|
require.NotContains(t, html, "Go to my Tapir", "no logged-in controls")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWelcomeLoggedIn(t *testing.T) {
|
||||||
|
app := appWithAuth(fakeAuth{user: web.User{Subject: "s", Email: "me@d-ma.be"}, ok: true})
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/welcome", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Go to my Tapir", "logged-in CTA present")
|
||||||
|
require.Contains(t, html, `href="/"`, "links back into the app")
|
||||||
|
require.Contains(t, html, "me@d-ma.be", "greets by email")
|
||||||
|
require.NotContains(t, html, "Get Started", "no logged-out CTA")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnauthenticatedRootRedirectsToWelcome(t *testing.T) {
|
||||||
|
app := appWithAuth(fakeAuth{ok: false})
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
require.Equal(t, "/welcome", rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnauthenticatedDeepLinkRedirectsToLogin(t *testing.T) {
|
||||||
|
app := appWithAuth(fakeAuth{ok: false})
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/v/some-id", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
|
require.Equal(t, "/auth/login", rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticatedRootRendersList(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.Contains(t, body(t, rec), "<html", "authenticated root still renders the list page")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user