Compare commits
17 Commits
d0e98424c1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f2188fa797 | |||
| 64f3e6628e | |||
| 82e544af47 | |||
| d3ab281baa | |||
| 211287aed5 | |||
| 1741fb11a5 | |||
| c9ad731860 | |||
| 621af7ca14 | |||
| f38ecc6c86 | |||
| cb961cd2d9 | |||
| f9367c2ae5 | |||
| be9692daa7 | |||
| b4fa5d7be8 | |||
| 962423ca10 | |||
| 693bb981ff | |||
| 91af0b711e | |||
| aca2ba9e2e |
@@ -19,6 +19,9 @@ image.tar
|
|||||||
|
|
||||||
# Runtime / user data — must never be committed
|
# Runtime / user data — must never be committed
|
||||||
history/
|
history/
|
||||||
|
# Per-install identity (UUID) the server mints on first boot. On the box it
|
||||||
|
# lives at /data/install-id; in local dev the server writes it to the repo root.
|
||||||
|
/install-id
|
||||||
cookies.txt
|
cookies.txt
|
||||||
*.txt.bak
|
*.txt.bak
|
||||||
youtube-summarizer-library-export*.json
|
youtube-summarizer-library-export*.json
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ YouTube + podcast summarizer + library, served as a single-page app from a Node.
|
|||||||
> **Inbox check:** At session start, if `~/Projects/standards/INBOX.md` exists, scan it for
|
> **Inbox check:** At session start, if `~/Projects/standards/INBOX.md` exists, scan it for
|
||||||
> items tagged `(recap)` and surface them before proposing next steps; triage with `/triage`.
|
> items tagged `(recap)` and surface them before proposing next steps; triage with `/triage`.
|
||||||
|
|
||||||
|
> **Design:** before building or changing any user-facing UI, read `design/DESIGN.md` and
|
||||||
|
> `design/tokens.tokens.json` and conform to them. Accent is indigo `#818cf8`; purple
|
||||||
|
> `#a855f7` is premium-only. The same tokens govern three surfaces that must stay in sync:
|
||||||
|
> the main `public/index.html` stylesheet, its `SHARE_PAGE_CSS` share-export string, and
|
||||||
|
> `public/auth.html`.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Server**: Node.js (`type: module`, ES modules). The dev box currently runs `v25.6.1`; container runtime is whatever the `Dockerfile` pins — check before assuming.
|
- **Server**: Node.js (`type: module`, ES modules). The dev box currently runs `v25.6.1`; container runtime is whatever the `Dockerfile` pins — check before assuming.
|
||||||
@@ -79,10 +85,16 @@ vendor/keysat-licensing-client/ local-link Keysat SDK
|
|||||||
- **Sanitize operator-internal language at error boundaries.** Strings like "Spark Control", "parakeet", "vLLM", LAN IPs, `*.local` URLs come from the sibling relay and must not reach cloud users.
|
- **Sanitize operator-internal language at error boundaries.** Strings like "Spark Control", "parakeet", "vLLM", LAN IPs, `*.local` URLs come from the sibling relay and must not reach cloud users.
|
||||||
- **Multi-mode credit gates fire BEFORE the pipeline.** See `/api/process` for the order — admin → license → free tenant → trial → anonymous-mint. Don't reorder without reading the comment block.
|
- **Multi-mode credit gates fire BEFORE the pipeline.** See `/api/process` for the order — admin → license → free tenant → trial → anonymous-mint. Don't reorder without reading the comment block.
|
||||||
- **Trial IP cap is per-IP for IPv4, per-/64 prefix for IPv6.** Dual-stack home networks would otherwise bypass it via privacy-extension address rotation.
|
- **Trial IP cap is per-IP for IPv4, per-/64 prefix for IPv6.** Dual-stack home networks would otherwise bypass it via privacy-extension address rotation.
|
||||||
- **`safeFilename()` already exists** for any user-content → on-disk path. Use it; don't roll your own.
|
- **Client IP comes from `req.ip`, never a raw `X-Forwarded-For` entry.** Express `trust proxy` is set in `index.js` from `RECAP_TRUSTED_PROXY_HOPS` (default 1); `getClientIp` (`anon-trial.js`) returns `req.ip`. Trusting raw `XFF[0]` let clients spoof the trial-cap IP — don't reintroduce it.
|
||||||
|
- **`safeFilename()` is exported from `history.js`** — import and use it for any user-content → on-disk path; don't roll your own. It validates against `/^[A-Za-z0-9_-]+$/` and throws on traversal/separators (the library-import file-write hole was a missing call).
|
||||||
- **The relay owns cloud Pro/Max tier + expiry** (core-decoupling; `docs/core-decoupling-plan.md`). In multi-mode, paid status is `users.tier` — cached from the relay, keyed by the Recaps user-id — NOT a per-user Keysat license. Don't gate cloud paid features by `keysat_license`; the license only matters for self-hosted "take it home" portability. Cloud requests carry `X-Recap-User-Id` + the operator key; server-to-server tier reads/writes go through `providers/relay.js`.
|
- **The relay owns cloud Pro/Max tier + expiry** (core-decoupling; `docs/core-decoupling-plan.md`). In multi-mode, paid status is `users.tier` — cached from the relay, keyed by the Recaps user-id — NOT a per-user Keysat license. Don't gate cloud paid features by `keysat_license`; the license only matters for self-hosted "take it home" portability. Cloud requests carry `X-Recap-User-Id` + the operator key; server-to-server tier reads/writes go through `providers/relay.js`.
|
||||||
|
- **Server-side background relay calls that the operator should eat go through `resolveProviderOpts("relay", { req: null })`** → the operator install identity, the *same* relay credit pool free signed-in users' summaries already draw from (`providers/index.js` `pickRelayIdentity`). That's the "operator-absorbed" lane — no comped system user-id, no operator action. To bill a specific user instead, pass their cloud identity (`{cloud:true, userId, operatorKey}`). The Daily Digest synthesis (`daily-digest.js`) is the reference use.
|
||||||
- **Self-serve purchase has two rails, both prepaid (no auto-renew yet).** Bitcoin = a BTCPay invoice rendered as an INLINE Lightning QR on-screen — the relay returns the BOLT11 server-to-server, so the buyer never loads BTCPay (replicate the buy-credits inline flow; do NOT redirect to a hosted checkout). Card = a Zaprite one-time hosted order (Zaprite's API has no recurring — see ROADMAP). Both settle webhooks land on the relay (it owns subscription expiry); the frontend just polls `/api/billing/status`. Expiry reminders go out via the existing System-SMTP transport (`smtp.js`): the relay enumerates who's expiring (`GET /relay/expiring-subscriptions`), Recaps maps user-id → email and sends.
|
- **Self-serve purchase has two rails, both prepaid (no auto-renew yet).** Bitcoin = a BTCPay invoice rendered as an INLINE Lightning QR on-screen — the relay returns the BOLT11 server-to-server, so the buyer never loads BTCPay (replicate the buy-credits inline flow; do NOT redirect to a hosted checkout). Card = a Zaprite one-time hosted order (Zaprite's API has no recurring — see ROADMAP). Both settle webhooks land on the relay (it owns subscription expiry); the frontend just polls `/api/billing/status`. Expiry reminders go out via the existing System-SMTP transport (`smtp.js`): the relay enumerates who's expiring (`GET /relay/expiring-subscriptions`), Recaps maps user-id → email and sends.
|
||||||
- **Tier credit allotments are operator-config-driven, never hardcoded.** The cards' "N relay credits each period" comes from the relay's tier-quota config (`credits_per_period` on `/relay/tier-plans`); `null` → "Unlimited". Don't bake a number or "Unlimited" into the UI.
|
- **Tier credit allotments are operator-config-driven, never hardcoded.** The cards' "N relay credits each period" comes from the relay's tier-quota config (`credits_per_period` on `/relay/tier-plans`); `null` → "Unlimited". Don't bake a number or "Unlimited" into the UI.
|
||||||
|
- **`recaps.cc` IS the operator's StartOS box**, served via Start9 Pages + StartTunnel. So `make install` (after a version bump) updates the public cloud site automatically — there is no separate cloud deploy step. A frontend-only change reaches recaps.cc as soon as the box serves the new `public/` files.
|
||||||
|
- **`render()` rebuilds the whole view via `innerHTML` — preserve live media + scroll across it.** It re-attaches the live podcast `<audio>` node (`replaceWith`, src-matched) and restores `.chunks-scroll` scrollTop, so a background re-render (e.g. the ~60s relay-credit poll) doesn't stop playback or bounce the reader to the top. YouTube minimize toggles the `.results-left.minimized` CSS class **in place** — never `render()`, because creating the YT iframe inside a `display:none` container wedges the IFrame API (black frame, needs reload); `ensureYtMounted()` + a `!state.videoMinimized` guard on `needsMount` keep the player from ever being built hidden, and `initPodcastPlayer()` is idempotent (`dataset.inited`). Don't reintroduce a full `render()` on minimize or drop these preservation steps.
|
||||||
|
- **URL parsing is duplicated server + client — edit both in lockstep.** `extractVideoId` (and the sibling classifiers `isChannelUrl` / `isPodcastUrl`) live in BOTH `server/util.js` and `public/index.html` with the same logic; the server copy is the one that actually rejects a submit (`server/index.js` → "Invalid YouTube URL"). When you teach one a new URL form, teach the other or the frontend preview and server will disagree. `extractVideoId` accepts `/watch?v=`, `youtu.be/`, `/embed/`, `/v/`, `/live/`, `/shorts/`, and a bare 11-char id; trailing `?si=…` tracking params are fine (the regex stops after the id). Tests: `server/test/util.test.js`.
|
||||||
|
- **Colors flow from CSS custom properties — edit the token, not the value.** `public/index.html`'s `<style>` opens with a `:root` token block (the single source of truth, mirroring `design/tokens.tokens.json`); the stylesheet **and the inline `style=` attributes** (var-ified in Phase 2) reference `var(--accent)` / `var(--surface)` / etc. Change a brand color there, once. **Two surfaces use `:root`+`var()` and must stay in sync:** the main stylesheet and `public/auth.html` (a standalone doc with its own `:root` copy). **The `SHARE_PAGE_*` share export is the exception** — a standalone document with **no `:root`**, so it's **pure literal hex**; a token change must be hand-mirrored into its literal values, and you must NOT introduce `var()` there (it won't resolve). **Left literal on purpose even in the main doc (don't blind-sweep):** `#fff`, hexes with no token, hex in JS *logic* (quoted ternary branches, `const c=…`), SVG `fill`/`stroke`, and `<meta theme-color>`. Phase 2 var-ified everything else by scoping to **CSS-value position** (hex preceded by `:`/space/`,`, never a quote) — reuse that rule for any future sweep.
|
||||||
|
|
||||||
### Client-side contract with the relay
|
### Client-side contract with the relay
|
||||||
|
|
||||||
@@ -124,42 +136,19 @@ unsure whether a change is contract-affecting, assume it is and check.
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
**Live on the operator's StartOS box** (app **0.2.155** + relay **0.2.124**, installed 2026-06-09):
|
**Live on the operator's StartOS box** — app **0.2.161** + relay **0.2.126**. Tests: `cd server && npm test` → **144 pass**.
|
||||||
|
|
||||||
- **Self-serve purchase COMPLETE — all 5 phases** (`docs/self-serve-purchase-plan.md`). Signed-in cloud users buy Pro/Max themselves: "Pay with Bitcoin" renders an inline Lightning QR on-screen (no redirect); "Pay by card" mints a Zaprite one-time order (the card link shows only when the operator has configured Zaprite). Prepaid 30-day periods; the relay owns tier + expiry; both settle webhooks land at `extendUserTier`. Expiry-reminder emails (7d / 1d / lapsed) ride the existing System SMTP; operator test trigger: `POST /api/admin/reminders/run` with `{test_email}`. Tier cards show the real per-period credit allotment from the relay quota config (this box: Max = 120, Pro = 50).
|
**Done & live:** self-serve Pro/Max purchase (Bitcoin inline-Lightning + Zaprite card, prepaid, relay owns tier/expiry), core-decoupling, per-tenant subscriptions, expiry-reminder emails (`POST /api/admin/reminders/run {test_email}`), **opt-in Daily Digest** (0.2.158, `b4fa5d7`): off-by-default daily email of a user's last ~24h of library recaps, each synthesized via `/relay/analyze` (operator-absorbed); `daily-digest.js` scan at `SEND_HOUR=8`, per-user watermark dedup, public tokenized unsubscribe, admin trigger `POST /api/admin/digest/run`; **YouTube `/live/` + `/shorts/` URL support** (0.2.159, `cb961cd`): `extractVideoId` now accepts those forms (was rejecting them as "Invalid YouTube URL"); and **self-contained shareable HTML export** (0.2.160, `621af7c`; first installed in 0.2.161): the Export menu offers a standalone `.html` with the embedded video + expandable timestamped summaries baked in, no account needed (native share sheet on mobile, download on desktop). Plans in `docs/*-plan.md`.
|
||||||
- **Core-decoupling live** (relay owns cloud tier; `docs/core-decoupling-plan.md`) and **per-tenant subscriptions live** (`docs/per-tenant-subscriptions-plan.md`).
|
|
||||||
- The Bitcoin pill matches the standard purple; the relay-side **internal-meeting re-polish fix** (re-attributes topic summaries to the operator's corrected speaker names) shipped this session.
|
|
||||||
|
|
||||||
**Pending real-world tests (operator):**
|
**Design system — DONE & live (0.2.161).** The `design/` contract + both conformance-cleanup phases are installed: Phase 1 (canonical `:root` token block; stylesheet + `auth.html` on `var()`; drift fixed) and Phase 2 (var-ified the inline `style=` hexes — 346 + 7 `#475569` — and snapped off-scale fonts/radii to the scale). Verified: 144 tests, both pages serve 200, every `var()` resolves, no off-scale residue. The var-ify scoping rule + the `SHARE_PAGE_*` literal-hex exception now live in **Conventions**; only the Style-Dictionary `palette.css` stretch goal remains (`ROADMAP.md`).
|
||||||
1. First on-device Bitcoin purchase — sign in as a Core tenant → Upgrade → Pay with Bitcoin → pay the inline invoice → badge flips.
|
|
||||||
2. Enable cards — run the relay's "Set Zaprite Connection" action (API key) + register the Zaprite webhook at `https://<relay-host>/relay/zaprite/webhook`.
|
|
||||||
3. Eyeball a reminder email via the test trigger above.
|
|
||||||
|
|
||||||
### Evaluation work queue (P0/P1) — from the 2026-06-14 full-eval (`EVALUATION.md`)
|
**Only loose end:** the Daily Digest's relay-synthesis + SMTP path can't be exercised off-box, so it's installed but **not yet smoke-tested** — that's operator action #5 below. Everything else (schema/upgrade, scheduler boot, unsubscribe flow) is verified.
|
||||||
|
|
||||||
Five of seven items FIXED + tested (2026-06-15, reviewer-checked); the leaked-key purge awaits operator confirmation and the registry blockers are deferred.
|
**Pending operator actions:**
|
||||||
|
1. **Verify the mobile can't-scroll-to-top fix on the iPad** — UNVERIFIED in 0.2.157 (iOS-layout-specific, not reproducible off-device); send a screen recording if it persists. Inbox item kept open + annotated.
|
||||||
|
2. (optional) Rotate the still-live Gemini key in AI Studio, then `rm /Users/macpro/Projects/recap-keyleak-purge-backup.bundle`.
|
||||||
|
3. Real-world cloud tests: first Bitcoin purchase; enable Zaprite cards (relay "Set Zaprite Connection" + webhook); eyeball a reminder email.
|
||||||
|
4. If recaps.cc ever gains a CDN/LB hop, set `RECAP_TRUSTED_PROXY_HOPS` or the trial-cap bypass reopens.
|
||||||
|
5. **Smoke-test the Daily Digest (0.2.158):** (a) `POST /api/admin/digest/run {test_email}` to eyeball the sample render; (b) toggle it on in Settings, add a recap, then `POST /api/admin/digest/run` (no body) to force a real scan — confirms relay synthesis + SMTP send + the unsubscribe link end-to-end. Needs System-SMTP configured.
|
||||||
|
|
||||||
- **[P0] ✅ FIXED — arbitrary file write in `POST /api/library/import`.** `safeFilename()` is now exported from `history.js` and validates each import key (`server/library.js`); a `../../` key is skipped, not written outside the scope dir. Tests in `test/history.test.js`. *(Adjacent P2 still open: array-form import + `PUT /api/history/move` write unvalidated IDs into `_meta.json` — no file-path escape, and read-time `safeFilename` guards the load. See Known debt.)*
|
**Backlog** in `ROADMAP.md`: eval **P2** known-debt (SSE error-string scrub, credit-debit TOCTOU, multi-tenant gemini-key bypass, `GET /api/history` perf, dependency CVEs, integration tests, doc drift) + **P3** cleanup, and standing decisions (Zaprite recurring, "take Recaps home" broken for relay-tier users, cloud paid-only, no CI lint/type-check).
|
||||||
- **[P0] ✅ FIXED — SSRF in podcast download.** `downloadPodcastAudio` (`server/audio.js`) rejects non-HTTP(S) schemes and blocks IP-literal AND DNS-resolved private/link-local/loopback/reserved/multicast/translation-prefix targets (closing the DNS-rebinding window), caps + resolves redirects. Tests in `test/audio.test.js`. *(Response size/time cap still deferred — ROADMAP P3.)*
|
|
||||||
- **[P0] ⏳ Leaked Gemini key in git history** (`git show d5046a0:.env`). Operator to rotate in Google AI Studio (recommended, not strictly required since the repo was never shared); git-history purge via `git filter-repo --path .env --invert-paths` + force-push to Gitea is queued and runs on the operator's go-ahead.
|
|
||||||
- **[P1] ✅ FIXED — ESM `require("crypto")`.** Replaced with a top-level `import { randomBytes }` in `server/license-purchase.js`.
|
|
||||||
- **[P1] ✅ FIXED — global `currentFreeJob` lock serialized the whole cloud.** Now skipped in multi-mode (`server/index.js`: `const isFree = req.recapMode !== "multi" && isFreeUser()`); per-tenant credit metering is the control there.
|
|
||||||
- **[P1] ✅ FIXED — `X-Forwarded-For` trial-cap bypass.** `app.set("trust proxy", …)` from `RECAP_TRUSTED_PROXY_HOPS` (default `1`) + `getClientIp` now returns `req.ip` (`server/index.js`, `server/anon-trial.js`). Tests in `test/anon-trial.test.js`. **Watch:** if the cloud ever gains a CDN/LB hop, bump `RECAP_TRUSTED_PROXY_HOPS` or the bypass reopens.
|
|
||||||
- *(StartOS registry-submission blockers — deferred by decision 2026-06-15; moved to `ROADMAP.md`. They never affected `make install`.)*
|
|
||||||
|
|
||||||
### Known debt (P2) — track; not release-blocking for self-host
|
|
||||||
|
|
||||||
- **Operator-internal strings leak to cloud users at the SSE error boundary** (Parakeet/Gemma/CUDA/LAN IPs) — no scrub exists, violating the scrub contract above. `server/index.js:3432,3003,4246` + `providers/relay.js:135-144`. (Sharp edge: `index.js:3419` *detects* these strings, then forwards them anyway.)
|
|
||||||
- **Credit over-spend TOCTOU on licensed installs** — N parallel requests pass the `total>0` check before any blind `debitOne` lands. Make check+debit atomic (reserve up front, refund on failure). `index.js:2497-2550` vs `:3158,:4197`.
|
|
||||||
- **Multi-mode tenant can spend the operator's server Gemini key** via `transcriptionProvider:"gemini"` + empty key (bypasses relay metering) — `providers/index.js:104-114`. Refuse the operator-key fallback for non-admin tenants.
|
|
||||||
- **`GET /api/history` parses every full session file** (transcript+summary, MB each) just to list ~8 metadata fields — cache them into `_meta.json` on save. `history.js:418-437`.
|
|
||||||
- **Dependency CVEs** — nodemailer 6.10.1 (high; low practical reach here), ws/qs/express/protobufjs (moderate). `npm audit fix` (nodemailer is a major bump).
|
|
||||||
- **No tests on the riskiest files** (`/api/process` gating, `relay.js`, `tenant-auth.js`, billing) — every code P0/P1 above lives here, and no agent could run the real summarize→save→debit path end-to-end (no key/credits). Add an integration test as the regression net before/with the fixes.
|
|
||||||
- **Smaller hardening:** unsanitized IDs persisted to `_meta.json` (array-form import + `history/move`); `PUT /api/history/meta` accepts arbitrary JSON shapes with no schema; `index.js` is 4351 lines mixing routing/pipeline/yt-dlp/SSE.
|
|
||||||
- **Doc drift (high-value):** AGENTS.md credit-gate order omits the "paid cloud user" bypass state (`:77` vs `index.js:2464-2472`); operator-facing `startos-registry/.../INSTRUCTIONS.md` + `assets/ABOUT.md` are stale Gemini-first (relay is the default provider). Lower-severity doc nits are deferred in `ROADMAP.md`.
|
|
||||||
|
|
||||||
**Known issues / open decisions** (details + next actions in `ROADMAP.md`):
|
|
||||||
- **Zaprite recurring isn't built** — Zaprite's API only does one-time orders. Card = prepaid until that's confirmed with Zaprite.
|
|
||||||
- **"Take Recaps home" is likely broken** for relay-tier users (no `keysat_license` after decoupling).
|
|
||||||
- **Cloud still has a free signed-in tier** (the simplification plan's "cloud paid-only" is unbuilt).
|
|
||||||
- No CI lint / type-check (unchanged).
|
|
||||||
|
|||||||
+54
@@ -11,6 +11,60 @@ Longer-term backlog for Recaps. Near-term in-flight work and known issues live i
|
|||||||
- **Zaprite recurring card billing (BLOCKED on Zaprite).** Grant wants card payments to DEFAULT to recurring (buyer can opt out at checkout). Zaprite's public API (`api.zaprite.com/openapi.json`) only creates one-time `/v1/orders` — recurring is a hosted/dashboard feature with no per-buyer metadata, no renewal webhook, and no billing-portal URL via API. The shipped card rail is one-time prepaid. UNBLOCK by confirming with Zaprite support whether the account can: (a) attach a per-buyer reference/metadata to a recurring checkout (so a payment maps to a Recaps user), (b) fire a webhook on each renewal charge (so we extend the tier each period), (c) expose a customer/billing-portal URL (for the chosen "link to Zaprite portal" cancel path). Decisions already made: no reminder emails for auto-renewing cards; a failed charge = lapse at period end (the relay's expiry-enforcement already does this — a missed renewal just doesn't extend `expires_at`).
|
- **Zaprite recurring card billing (BLOCKED on Zaprite).** Grant wants card payments to DEFAULT to recurring (buyer can opt out at checkout). Zaprite's public API (`api.zaprite.com/openapi.json`) only creates one-time `/v1/orders` — recurring is a hosted/dashboard feature with no per-buyer metadata, no renewal webhook, and no billing-portal URL via API. The shipped card rail is one-time prepaid. UNBLOCK by confirming with Zaprite support whether the account can: (a) attach a per-buyer reference/metadata to a recurring checkout (so a payment maps to a Recaps user), (b) fire a webhook on each renewal charge (so we extend the tier each period), (c) expose a customer/billing-portal URL (for the chosen "link to Zaprite portal" cancel path). Decisions already made: no reminder emails for auto-renewing cards; a failed charge = lapse at period end (the relay's expiry-enforcement already does this — a missed renewal just doesn't extend `expires_at`).
|
||||||
- **Close the architecture-simplification gaps** (`docs/architecture-simplification-plan.md`). After core-decoupling + self-serve, these steps remain OPEN: **(8) "Take Recaps home"** — mint a fresh Keysat token on demand at click time; likely BROKEN today because relay-tier cloud users have no `keysat_license` for `/api/account/license-key` to return. **(10) cloud paid-only** — the free signed-in tier + signup-grant credits are still live; the plan wanted cloud to be paid-only with self-hosted as the free path (product call — confirm intent before building). **(5, partial) anon signup→Pro** still routes through `/api/license/purchase` + `pending_signups` (Keysat license) instead of the relay tier like the signed-in flow does. **(6, partial) tokenized renew** — the reminder email's renew link is `?renew=1` (requires sign-in); the plan wanted a one-time-token `/renew?token=…` for friction-free renewal. NOTE: the doc's Zaprite-*recurring* / cancel-button / Recaps-DB-owns-expiry parts were intentionally SUPERSEDED by the prepaid + relay-owns-tier model — don't build those.
|
- **Close the architecture-simplification gaps** (`docs/architecture-simplification-plan.md`). After core-decoupling + self-serve, these steps remain OPEN: **(8) "Take Recaps home"** — mint a fresh Keysat token on demand at click time; likely BROKEN today because relay-tier cloud users have no `keysat_license` for `/api/account/license-key` to return. **(10) cloud paid-only** — the free signed-in tier + signup-grant credits are still live; the plan wanted cloud to be paid-only with self-hosted as the free path (product call — confirm intent before building). **(5, partial) anon signup→Pro** still routes through `/api/license/purchase` + `pending_signups` (Keysat license) instead of the relay tier like the signed-in flow does. **(6, partial) tokenized renew** — the reminder email's renew link is `?renew=1` (requires sign-in); the plan wanted a one-time-token `/renew?token=…` for friction-free renewal. NOTE: the doc's Zaprite-*recurring* / cancel-button / Recaps-DB-owns-expiry parts were intentionally SUPERSEDED by the prepaid + relay-owns-tier model — don't build those.
|
||||||
- **Decide the Max tier-quota default.** The relay code default is `max.monthly: null` (unlimited) → cards render "Unlimited" on a fresh install. The operator set `max.monthly: 120` on their box via the Adjust-Tier-Quotas action (so cards show 120 there). Decide whether a metered number (e.g. 120) should be the shipped default in `recap-relay/server/config.js` — note it also enforces the ceiling, not just the card label.
|
- **Decide the Max tier-quota default.** The relay code default is `max.monthly: null` (unlimited) → cards render "Unlimited" on a fresh install. The operator set `max.monthly: 120` on their box via the Adjust-Tier-Quotas action (so cards show 120 there). Decide whether a metered number (e.g. 120) should be the shipped default in `recap-relay/server/config.js` — note it also enforces the ceiling, not just the card label.
|
||||||
|
- **Add Gemini 3.5 to model selection.** First have a research agent confirm which stable Gemini model versions are actually available and the correct model id/name before wiring anything. The model list is duplicated server + client (provider config under `server/providers/` + the model picker in `public/index.html`) — add the option in lockstep, like the URL-parser convention. Coordinate with the matching relay-side capture (the relay routes Gemini, so its model list must agree). — captured 2026-06-16
|
||||||
|
|
||||||
|
## Design-contract conformance cleanup (from the 2026-06-16 `/design` extract)
|
||||||
|
|
||||||
|
The `design/` contract (`design/DESIGN.md` + `design/tokens.tokens.json`) was extracted
|
||||||
|
from the as-built UI and reconciled with Grant on 2026-06-16. The code was structurally
|
||||||
|
aligned but a set of legacy values had survived as off-contract drift.
|
||||||
|
|
||||||
|
**Phase 1 — DONE 2026-06-16 (not yet deployed to the box).** Introduced a canonical `:root`
|
||||||
|
token block (the single source of truth, mirroring `tokens.tokens.json`) at the top of the
|
||||||
|
`public/index.html` `<style>` block and migrated the whole stylesheet to `var(--token)`;
|
||||||
|
`public/auth.html` got its own subset `:root` and was migrated too. Fixed **all** color +
|
||||||
|
weight drift across every surface (stylesheet, ~447 inline styles, JS handlers, the
|
||||||
|
`SHARE_PAGE_CSS` export): legacy indigos `#6366f1`/`#4f46e5`/`#4338ca` + `rgba(99,102,241,…)`
|
||||||
|
→ `#818cf8`/`#a5b4fc`/`rgba(129,140,248,…)`; blue `#3b82f6` interactive buttons (incl. the
|
||||||
|
whole auth screen) → indigo; legacy darks `#0a0e17`/`#0b1120`/`#020617`/`#121828`/`#1f2942`
|
||||||
|
→ the ladder; `#f5f9ff`→`#f1f5f9`; `#312e81`→`#1e293b`; weights `650→600`, `680→700`.
|
||||||
|
Verified: 144 tests pass, both pages serve 200, all 426+27 `var()` references resolve, no
|
||||||
|
undefined vars. (`SHARE_PAGE_CSS` and `auth.html` are standalone documents that each carry
|
||||||
|
their own copy — kept in sync; the meta `theme-color` stays a literal `#0a0e1a`.)
|
||||||
|
|
||||||
|
**Phase 2 — DONE 2026-06-17 (shipped in app 0.2.161).**
|
||||||
|
- **Var-ified the long-tail inline `style=` attributes** — 346 inline-style hexes (+7
|
||||||
|
`#475569`, mapped by property to `--text-faint`/`--border-strong`) → `var(--token)`. Scoped
|
||||||
|
to CSS-value position (hex preceded by `:`/space/`,`, never a quote), which cleanly dodged
|
||||||
|
the non-`var()` spots: the `<meta theme-color>`, SVG `fill`/`stroke` attrs, and hex held in
|
||||||
|
JS *logic* (quoted ternary branches like `${cond ? "#1e293b" : ...}`, `const colour = …`).
|
||||||
|
Left as literals on purpose: `#fff` (its uses split between on-accent button text and
|
||||||
|
functional white — `--on-accent` doesn't cleanly cover both, zero visual gain), no-token
|
||||||
|
hexes (`#e0e7ff`/`#c7d2fe`/`#a78bfa`/`#04210f`/etc.), and the entire `SHARE_PAGE_*` export
|
||||||
|
region (a standalone doc with no `:root` — `var()` wouldn't resolve there).
|
||||||
|
- **Snapped off-scale font sizes** (21 occ): `9/10.5→10`, `11.5/12.5→12`, `15→16`, `24→22`.
|
||||||
|
Left `40px`/`56px` display glyphs (success numeral, buy spinner) — off-scale by design.
|
||||||
|
- **Snapped off-scale radii** (18 occ): `3→4`, `5→6`, `7→6`, `11→12`; `9→10` for the two 18px
|
||||||
|
capsules (`.menu-badge`, `.rc-spk` — radius clamps at 9 on an 18px box, so on-scale and
|
||||||
|
visually identical) and `9→8` for `.icon-btn`/`.buy-select-btn`/`.buy-discount-input`. Left
|
||||||
|
the `1px` hamburger-bar radius. Verified: 144 tests pass, both pages serve 200, every
|
||||||
|
introduced `var()` resolves against `:root`, no off-scale residue.
|
||||||
|
- **(stretch, NOT done) Generate `design/brand/palette.css` from the tokens** (Style
|
||||||
|
Dictionary) and `@import`/inline it, so the `:root` block isn't hand-maintained in three
|
||||||
|
places. Still open.
|
||||||
|
|
||||||
|
## Known debt (P2, from the 2026-06-14 full-eval — `EVALUATION.md`)
|
||||||
|
|
||||||
|
Real but not release-blocking for self-host. The P0/P1 findings from the same eval were fixed 2026-06-15 (see git log + `EVALUATION.md`).
|
||||||
|
|
||||||
|
- **Operator-internal strings leak to cloud users at the SSE error boundary** (Parakeet/Gemma/CUDA/LAN IPs) — no scrub exists, violating the scrub convention in `AGENTS.md`. `server/index.js:3432,3003,4246` + `providers/relay.js:135-144`. (Sharp edge: `index.js:3419` *detects* these strings, then forwards them anyway.)
|
||||||
|
- **Credit over-spend TOCTOU on licensed installs** — N parallel requests pass the `total>0` check before any blind `debitOne` lands. Make check+debit atomic (reserve up front, refund on failure). `index.js:2497-2550` vs `:3158,:4197`.
|
||||||
|
- **Multi-mode tenant can spend the operator's server Gemini key** via `transcriptionProvider:"gemini"` + empty key (bypasses relay metering) — `providers/index.js:104-114`. Refuse the operator-key fallback for non-admin tenants.
|
||||||
|
- **`GET /api/history` parses every full session file** (transcript+summary, MB each) just to list ~8 metadata fields — cache them into `_meta.json` on save. `history.js:418-437`.
|
||||||
|
- **Dependency CVEs** — nodemailer 6.10.1 (high; low practical reach here), ws/qs/express/protobufjs (moderate). `npm audit fix` (nodemailer is a major bump).
|
||||||
|
- **No tests on the riskiest files** (`/api/process` gating, `relay.js`, `tenant-auth.js`, billing) — the real summarize→save→debit path can't run end-to-end without a key/credits. Add an integration test as the regression net.
|
||||||
|
- **Smaller hardening:** unsanitized IDs persisted to `_meta.json` (array-form library import + `PUT /api/history/move`) — no file-path escape (read-time `safeFilename` guards the load), but sanitize at write too; `PUT /api/history/meta` accepts arbitrary JSON shapes with no schema; `index.js` is 4351 lines mixing routing/pipeline/yt-dlp/SSE.
|
||||||
|
- **Doc drift (high-value):** AGENTS.md credit-gate order omits the "paid cloud user" bypass state (`:77` vs `index.js:2464-2472`); operator-facing `startos-registry/.../INSTRUCTIONS.md` + `assets/ABOUT.md` are stale Gemini-first (relay is the default provider).
|
||||||
|
|
||||||
## Deferred hardening & cleanup (P3, from the 2026-06-14 full-eval — `EVALUATION.md`)
|
## Deferred hardening & cleanup (P3, from the 2026-06-14 full-eval — `EVALUATION.md`)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Recap — Design brief
|
||||||
|
|
||||||
|
The durable brand contract for Recap's user-facing UI. Read this and
|
||||||
|
`design/tokens.tokens.json` before building or changing any UI. This was
|
||||||
|
**extracted** from the as-built `recaps.cc` interface (no prior guidelines), then
|
||||||
|
**reconciled** with the owner on 2026-06-16 — so where the live code still
|
||||||
|
disagrees with a value here, *this file is the intent* and the code is the cleanup
|
||||||
|
backlog (see `ROADMAP.md`). Provenance: there was no design tool or export; the
|
||||||
|
source is the shipped `public/index.html`, `public/auth.html`, and the app icon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visual theme
|
||||||
|
|
||||||
|
A **dense, dark, information-first developer-tool aesthetic.** Restraint over
|
||||||
|
decoration: small type, tight radii, thin hairline borders, flat panels on a
|
||||||
|
near-black navy field, with the indigo accent used sparingly to mark what's
|
||||||
|
interactive or active. It should read like a fast, technical instrument — closer
|
||||||
|
to a code editor or an ops dashboard than a consumer media app. Calm, quiet, and
|
||||||
|
legible at high density; never glossy, never playful.
|
||||||
|
|
||||||
|
The brand mark (app icon) is a **play-triangle filled with a blue→purple gradient
|
||||||
|
over four light "transcript" lines**, on the dark navy field — the product in one
|
||||||
|
glyph: *press play on a video, get the text back.* That blue→purple gradient is
|
||||||
|
the origin of the whole accent story (§2).
|
||||||
|
|
||||||
|
Voice: precise, plain-spoken, unhyped. The UI explains tersely and trusts the
|
||||||
|
user. What it is **not**: not gradient-heavy, not glassmorphic beyond a light
|
||||||
|
overlay blur, not rounded-and-bubbly, not light-mode, not framework-flashy.
|
||||||
|
|
||||||
|
## 2. Color palette
|
||||||
|
|
||||||
|
Built almost entirely on the **Tailwind Slate + Indigo** ramps over a custom
|
||||||
|
near-black navy base. Canonical roles below; full values in
|
||||||
|
`tokens.tokens.json`.
|
||||||
|
|
||||||
|
**Surface ladder (warm/cool split — the agreed system).**
|
||||||
|
| Role | Value | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| Base | `#0a0e1a` | page, body, sticky top bars, full-height side panels (history sidebar, log drawer). Also the PWA `theme_color`. |
|
||||||
|
| Card | `#111827` (warm gray-900) | raised cards, modals, popovers, toasts, icon-buttons, pipeline steps, skeletons. |
|
||||||
|
| Inset | `#0f172a` (cool slate-900) | recessed fields & list rows: settings/key inputs, queue items, subscription items, model buttons, expanded-chunk bg. |
|
||||||
|
| Raised field | `#1e293b` | the **primary URL input only** — deliberately lighter than the inset so the hero field pops. |
|
||||||
|
|
||||||
|
> Legacy darks **`#0a0e17`, `#0b1120`, `#020617`** are near-duplicates that
|
||||||
|
> leaked in; fold them into the nearest rung (`#0a0e17`/`#0b1120` → Base,
|
||||||
|
> `#020617` → Inset). Don't add new background darks.
|
||||||
|
|
||||||
|
**Accent — indigo (THE single interactive accent).**
|
||||||
|
- `#818cf8` (indigo-400) — **the accent.** Fills (submit button, active tab/icon,
|
||||||
|
processing badge) and marks (links, focus ring, active state, timestamps,
|
||||||
|
expanded-chunk arrow).
|
||||||
|
- `#a5b4fc` (indigo-300) — accent hover (lighter), active-line emphasis.
|
||||||
|
- Indigo tints `rgba(129,140,248, .06–.20)` — hover washes, active backgrounds,
|
||||||
|
the `0 0 0 3px …/.15` focus ring, the `0 4px 24px …/.3` submit glow.
|
||||||
|
- **Demoted:** `#6366f1` (indigo-500) and its hovers `#4f46e5`/`#4338ca` are
|
||||||
|
legacy dups — migrate them to `#818cf8`; do not introduce new ones.
|
||||||
|
|
||||||
|
**Premium — purple (RESERVED for paid/upgrade only).**
|
||||||
|
- `#a855f7` (purple-500) — upgrade button, highlighted tier, buy badge, primary
|
||||||
|
buy CTA. `#c084fc` hover; `#9333ea` deep.
|
||||||
|
- `#c4b5fd`/`#d8b4fe` (purple-300/200) — tier-badge & Pro text.
|
||||||
|
- Purple appears **nowhere** in non-premium UI.
|
||||||
|
|
||||||
|
**Text ramp (slate).**
|
||||||
|
`#e2e8f0` primary · `#f1f5f9` strong/headings (canonical near-white — fold the
|
||||||
|
stray `#f5f9ff` into this) · `#cbd5e1` running body copy · `#94a3b8` muted/
|
||||||
|
secondary · `#64748b` labels & placeholders · `#475569` faint meta/timestamps ·
|
||||||
|
`#334155` dimmest (doubles as the hover-border). `#fff` only on filled
|
||||||
|
accent/premium buttons.
|
||||||
|
|
||||||
|
**Borders.** `#1e293b` (slate-800) default hairline → `#334155` (slate-700) on
|
||||||
|
hover/active → `#475569` (slate-600) strongest hover. Borders, not shadows, are
|
||||||
|
the primary way surfaces are separated.
|
||||||
|
|
||||||
|
**Semantic status (consistent, intentional — keep).**
|
||||||
|
- Success/green: `#22c55e` base · `#4ade80` text · `#86efac` soft · `#16a34a` deep.
|
||||||
|
- Error/red: `#ef4444` base · `#f87171` text · `#fca5a5` soft · `#dc2626` deep.
|
||||||
|
- Warning/amber: `#fbbf24` base/dot · `#fcd34d` · `#fde68a` · `#f59e0b` (update btn).
|
||||||
|
- Info/blue: `#3b82f6` base · `#60a5fa` · `#93c5fd`. **Blue is status/info only**
|
||||||
|
(and the legacy auth-screen accent, which should migrate to indigo) — never a
|
||||||
|
primary interactive color in-app.
|
||||||
|
|
||||||
|
**Speaker chips (8-hue categorical set — intentional, keep as-is).** A→H:
|
||||||
|
red/blue/green/amber/purple/sky/pink/slate, each as the *tinted triplet*
|
||||||
|
(background α≈.18, light-shade text, border α≈.35).
|
||||||
|
|
||||||
|
## 3. Typography
|
||||||
|
|
||||||
|
- **Sans (everything):** `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
sans-serif`. System stack — **no web fonts.**
|
||||||
|
- **Mono (timestamps, license keys, URLs, code):** `ui-monospace, "SF Mono",
|
||||||
|
Menlo, Consolas, monospace`. One canonical stack — snap the near-variants to it.
|
||||||
|
- **Scale (px):** `10 · 11 · 12 · 13 · 14 · 16 · 18 · 20 · 22 · 28`.
|
||||||
|
- 10–11 — micro labels, badges, meta, section labels, pills, hints.
|
||||||
|
- 12 — secondary UI default (buttons, labels, stats, list titles).
|
||||||
|
- 13 — body default (descriptions, list items, transcript-adjacent).
|
||||||
|
- 14 — emphasis / larger body, drawer & settings headers.
|
||||||
|
- 16 — input text, modal `h2`, section heads.
|
||||||
|
- 18 — sub-headers, logo wordmark, large numerals.
|
||||||
|
- 20–22 — headings, modal/activation titles, tier names.
|
||||||
|
- 28 — display (price). Snap strays `15→16`, `24→22`, fractionals
|
||||||
|
`12.5/11.5→12`, `10.5/9→10`.
|
||||||
|
- **Weights:** 400 normal · 500 medium · **600 semibold (the UI default)** · 700
|
||||||
|
bold (headings, badges) · 800 extrabold (tier badge, big numerals). Snap
|
||||||
|
`650→600`, `680→700`.
|
||||||
|
- **Line-height:** 1.5 body · 1.55 denser reading blocks · 1.3–1.4 titles · 1.25
|
||||||
|
large titles · 1 single-line badges.
|
||||||
|
- **Letter-spacing:** 0 default. Uppercase micro-labels track `0.05em`; badges
|
||||||
|
`0.04em`; tier/section labels `0.06–0.08em`; one display title tightens
|
||||||
|
`-0.01em`.
|
||||||
|
|
||||||
|
## 4. Component styling
|
||||||
|
|
||||||
|
- **Buttons.** *Primary:* filled `#818cf8`, white text, radius 8–10px, weight
|
||||||
|
600, hover lifts `translateY(-1px)` + indigo glow; disabled → `#1e293b` bg /
|
||||||
|
`#475569` text. *Secondary:* `#1e293b` fill, `#334155` border, `#94a3b8`→
|
||||||
|
`#cbd5e1` text. *Icon/ghost:* `#111827` (or transparent), `#1e293b` border,
|
||||||
|
muted glyph; hover lightens bg + border; `.active` → accent fill. *Premium:*
|
||||||
|
filled `#a855f7`.
|
||||||
|
- **Inputs.** Inset `#0f172a` (or `#1e293b` for the hero URL input), `#334155`/
|
||||||
|
`#1e293b` border; focus → accent border + `0 0 0 3px rgba(129,140,248,.15)`
|
||||||
|
ring. Mono font for keys/codes; placeholders `#64748b`/`#475569`.
|
||||||
|
- **Cards.** `#111827` bg, `#1e293b` border, radius 10–14px; border lightens on
|
||||||
|
hover; expanded/active → accent border + faint `0 2px 16px …/.06` accent glow.
|
||||||
|
- **Pills / badges / chips — the "tinted triplet".** Background at α≈.1,
|
||||||
|
text in the light shade, border at α≈.2–.45 of the same hue. Status pills,
|
||||||
|
tier badges, `queue-from` tags, clip badges, and speaker chips all follow this
|
||||||
|
one rule.
|
||||||
|
- **Modals.** `#111827` (settings) / `#0f172a` (buy) bg, radius 16px, sticky
|
||||||
|
header with `1px #1e293b` bottom border, `slideUp` entrance, scrim
|
||||||
|
`rgba(0,0,0,.6)` + `backdrop-filter: blur(4px)`.
|
||||||
|
- **Toasts.** Top-right stack, `#1e293b` bg, `#334155` border, slide-in from
|
||||||
|
right, auto-fade.
|
||||||
|
- **Spinner.** 3px ring, `#1e293b` track, `#818cf8` top, `spin 0.8s linear`.
|
||||||
|
- **Pipeline / tracker.** Stepped pills: idle neutral, `.active` → accent
|
||||||
|
tint+border, `.done` → green tint+border.
|
||||||
|
|
||||||
|
## 5. Layout
|
||||||
|
|
||||||
|
- **Single fluid column.** `.container` `max-width:100%`, padding `36px 24px`
|
||||||
|
(landing) tightening to `16px 24px` (results) and down on mobile.
|
||||||
|
- **Results = split screen.** `.results-left` 58% (video/player, sticky top) +
|
||||||
|
`.results-right` (scrolling chunk list), 16px gap; stacks vertically <900px.
|
||||||
|
- **Persistent left history sidebar**, 320px, *pushes* content on desktop /
|
||||||
|
overlays on mobile. **Right log drawer**, 440px, slides in.
|
||||||
|
- **Dense vertical rhythm.** Card margin 14px, chunk margin 6px, gaps 6–16px.
|
||||||
|
- **Spacing steps (de-facto):** `4 · 6 · 8 · 10 · 12 · 14 · 16 · 20 · 24` for
|
||||||
|
controls/gaps, `28 · 32 · 36` for section padding. Not a strict 4/8 grid —
|
||||||
|
dense and organic by design; prefer these steps over new in-between values.
|
||||||
|
|
||||||
|
## 6. Depth / elevation
|
||||||
|
|
||||||
|
**Flat by default — separation comes from 1px borders, not shadows.** Shadows are
|
||||||
|
reserved for things that genuinely float:
|
||||||
|
- Overlays/drawers/modals: `0 8px 24px …/.3` (toast) · `0 8px 32px …/.4` (video,
|
||||||
|
side drawers `±8px 0 32px`) · `0 12px 32px …/.5` (menu) · `0 20px 60px …/.5`
|
||||||
|
and `0 24px 64px …/.5–.6` (panel, settings/buy modal). All `rgba(0,0,0,α)`.
|
||||||
|
- **Accent glows** signal primary/active: submit `0 4px 24px rgba(129,140,248,.3)`;
|
||||||
|
focus ring `0 0 0 3px rgba(129,140,248,.15)`; expanded chunk `0 2px 16px …/.06`;
|
||||||
|
premium tier `0 12px 40px rgba(168,85,247,.25)`.
|
||||||
|
- Overlay scrims use `rgba(0,0,0,.4–.65)` + `backdrop-filter: blur(4–6px)`.
|
||||||
|
- Drag/drop affordance is an accent line: `box-shadow: 0 ±2px 0 0 #818cf8`.
|
||||||
|
|
||||||
|
## 7. Do's and don'ts
|
||||||
|
|
||||||
|
**Do**
|
||||||
|
- Use `#818cf8` as the *single* interactive accent; reserve purple `#a855f7`
|
||||||
|
strictly for premium/upgrade.
|
||||||
|
- Separate surfaces with 1px `#1e293b` borders that lighten to `#334155` on hover;
|
||||||
|
keep shadows for true overlays only.
|
||||||
|
- Build every status/category chip as the tinted triplet (bg α.1 / light text /
|
||||||
|
border α.2).
|
||||||
|
- Keep type dense — 12–13px body, 10–11px meta — with 600 as the default weight.
|
||||||
|
- Follow the surface ladder: base `#0a0e1a` → card `#111827` → inset `#0f172a`.
|
||||||
|
- Use the system font stack; mono only for timestamps / keys / URLs.
|
||||||
|
|
||||||
|
**Don't**
|
||||||
|
- Don't introduce new `#6366f1`/`#4f46e5` indigos — they migrate to `#818cf8`.
|
||||||
|
- Don't put blue `#3b82f6` on primary interactive elements; blue is info/status
|
||||||
|
(and legacy auth) only — auth should move to indigo.
|
||||||
|
- Don't add new background darks (`#0a0e17`/`#0b1120`/`#020617` are legacy dups).
|
||||||
|
- Don't use purple for non-premium UI, or the accent indigo for premium.
|
||||||
|
- Don't add fractional font sizes (12.5/11.5/10.5) or off-scale weights (650/680).
|
||||||
|
- Don't reach for a framework, web font, or heavy drop shadows — vanilla JS,
|
||||||
|
system fonts, flat-with-borders is the intentional language.
|
||||||
|
|
||||||
|
## 8. Responsive behavior
|
||||||
|
|
||||||
|
- **Breakpoints:** `900px` (primary: split→stack, sidebar push→overlay), `880px`
|
||||||
|
(tablet: top breadcrumb swaps to a hoisted mobile copy), `600px` (phone:
|
||||||
|
icon-only submit, hamburger menu, desktop toolbar/pills hidden), `640px` (share
|
||||||
|
export). Plus `≤900px landscape` → fullscreen video.
|
||||||
|
- **Mobile rules:** form inputs forced to `16px` (iOS auto-zoom guard); touch
|
||||||
|
targets 44–48px; sticky top bar; full-width drawers/panels; hover-gated
|
||||||
|
controls pinned visible (touch has no hover). Use `100dvh` for full-height.
|
||||||
|
|
||||||
|
## 9. Agent prompt guide
|
||||||
|
|
||||||
|
When building or changing UI in this repo:
|
||||||
|
- Read `design/tokens.tokens.json` and pull values from it — don't eyeball new hex.
|
||||||
|
- Match the **dense dark vanilla-JS** style. The frontend is one file,
|
||||||
|
`public/index.html`, with two `<style>` blocks + ~447 inline `style=` attrs and
|
||||||
|
a render-into-`innerHTML` loop. No framework, no bundler, no web fonts — keep it
|
||||||
|
that way.
|
||||||
|
- Accent is `#818cf8`; purple is premium-only; follow the surface ladder; build
|
||||||
|
chips as the tinted triplet; system font for text, mono for timestamps/keys.
|
||||||
|
- **Three surfaces stay in sync:** the main app stylesheet, the `SHARE_PAGE_CSS`
|
||||||
|
string (the self-contained share export), and `public/auth.html`. A token change
|
||||||
|
must be reflected in all three. Auth currently uses the legacy blue accent —
|
||||||
|
new auth work should adopt indigo.
|
||||||
|
- Sanitize operator-internal strings at error boundaries (per `AGENTS.md`) — they
|
||||||
|
must never reach cloud users, design surfaces included.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Brand assets
|
||||||
|
|
||||||
|
- **`icon.png`** — the app icon (1024×1024). A rounded-square (squircle) on the
|
||||||
|
dark navy field, with a **play-triangle filled by a blue→purple gradient** over
|
||||||
|
four light "transcript" lines. Used as the PWA icon (`manifest.json` →
|
||||||
|
`/assets/icon.png`, also `assets/icon.png` / `icon.png` at repo root) and the
|
||||||
|
auth-screen logo. The gradient's blue end and purple end are the source of the
|
||||||
|
app's blue (info) and purple (premium) accents; the indigo `#818cf8` interactive
|
||||||
|
accent sits between them.
|
||||||
|
|
||||||
|
**Fonts:** none shipped — Recap uses the system font stack (`-apple-system, …`)
|
||||||
|
and a system monospace stack. No web-font files to vendor here.
|
||||||
|
|
||||||
|
**Palette:** the machine-readable source of truth is `../tokens.tokens.json`. No
|
||||||
|
generated `palette.css` yet — styles live inline in `public/index.html`;
|
||||||
|
consolidating them behind CSS custom properties is tracked as cleanup in the
|
||||||
|
repo `ROADMAP.md`.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
# Inspiration / provenance
|
||||||
|
|
||||||
|
This is an **extract** run (Case B): Recap had no prior design guidelines and no
|
||||||
|
design tool was used. There are no external reference images — the *de-facto* look
|
||||||
|
**is** the source, harvested directly from the shipped code on 2026-06-16:
|
||||||
|
|
||||||
|
- `public/index.html` — the whole app + landing (two `<style>` blocks + ~447
|
||||||
|
inline `style=` attributes).
|
||||||
|
- `public/index.html` → `SHARE_PAGE_CSS` — the self-contained share-export styles.
|
||||||
|
- `public/auth.html` — the magic-link auth screen.
|
||||||
|
- `public/manifest.json` — PWA name/colors/icons (`theme_color` = `#0a0e1a`).
|
||||||
|
- `design/brand/icon.png` — the app icon (the only first-class brand asset). Its
|
||||||
|
blue→purple gradient play-triangle is the origin of the accent story.
|
||||||
|
|
||||||
|
The conflict reconciliation (which dark, which indigo, the type scale, the
|
||||||
|
accent-hue strategy) was decided with the owner and is recorded in `../DESIGN.md`.
|
||||||
|
If real reference images are gathered later, drop them here with a one-line note
|
||||||
|
on what's liked about each.
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://tr.designtokens.org/format/",
|
||||||
|
"$description": "Recap design tokens (W3C DTCG). Extracted from the as-built recaps.cc UI and reconciled 2026-06-16. Canonical roles only; see design/DESIGN.md for usage. Composite shadows and tint scales are documented strings, not strict DTCG primitives.",
|
||||||
|
|
||||||
|
"color": {
|
||||||
|
"$type": "color",
|
||||||
|
|
||||||
|
"surface": {
|
||||||
|
"$description": "Warm/cool elevation ladder over the navy base. Legacy darks #0a0e17/#0b1120 fold into base; #020617 folds into inset.",
|
||||||
|
"base": { "$value": "#0a0e1a", "$description": "Page, body, sticky bars, full-height side panels. Also PWA theme_color/background_color." },
|
||||||
|
"card": { "$value": "#111827", "$description": "Warm gray-900. Raised cards, modals, popovers, toasts, icon-buttons, steps, skeletons." },
|
||||||
|
"inset": { "$value": "#0f172a", "$description": "Cool slate-900. Recessed fields & list rows: inputs, queue/sub items, model buttons, expanded chunk." },
|
||||||
|
"raised-field": { "$value": "#1e293b", "$description": "Primary URL input only — lighter than inset so the hero field pops." }
|
||||||
|
},
|
||||||
|
|
||||||
|
"border": {
|
||||||
|
"default": { "$value": "#1e293b", "$description": "Slate-800. Default hairline — primary surface separator." },
|
||||||
|
"hover": { "$value": "#334155", "$description": "Slate-700. Border on hover/active." },
|
||||||
|
"strong": { "$value": "#475569", "$description": "Slate-600. Strongest hover border." }
|
||||||
|
},
|
||||||
|
|
||||||
|
"accent": {
|
||||||
|
"$description": "Indigo — THE single interactive accent.",
|
||||||
|
"default": { "$value": "#818cf8", "$description": "Indigo-400. Submit/active fills, links, focus ring, timestamps, active marks." },
|
||||||
|
"hover": { "$value": "#a5b4fc", "$description": "Indigo-300. Accent hover (lighter), active-line emphasis." },
|
||||||
|
"legacy-500": { "$value": "#6366f1", "$description": "DEMOTED indigo-500 dup — migrate to accent.default. Do not introduce new." },
|
||||||
|
"legacy-600": { "$value": "#4f46e5", "$description": "DEMOTED legacy hover for #6366f1 — migrate away." }
|
||||||
|
},
|
||||||
|
|
||||||
|
"premium": {
|
||||||
|
"$description": "Purple — RESERVED for paid/upgrade UI only. Never in non-premium surfaces.",
|
||||||
|
"default": { "$value": "#a855f7", "$description": "Purple-500. Upgrade button, highlighted tier, buy badge, primary buy CTA." },
|
||||||
|
"hover": { "$value": "#c084fc", "$description": "Purple-400." },
|
||||||
|
"deep": { "$value": "#9333ea", "$description": "Purple-600. Pro-CTA hover." },
|
||||||
|
"text": { "$value": "#c4b5fd", "$description": "Purple-300. Tier-badge & Pro text." },
|
||||||
|
"text-soft": { "$value": "#d8b4fe", "$description": "Purple-200." }
|
||||||
|
},
|
||||||
|
|
||||||
|
"text": {
|
||||||
|
"primary": { "$value": "#e2e8f0", "$description": "Slate-200. Primary body/UI text." },
|
||||||
|
"strong": { "$value": "#f1f5f9", "$description": "Slate-100. Headings/emphasis near-white. Canonical — fold stray #f5f9ff into this." },
|
||||||
|
"body": { "$value": "#cbd5e1", "$description": "Slate-300. Running body copy (transcript, descriptions, bullets)." },
|
||||||
|
"muted": { "$value": "#94a3b8", "$description": "Slate-400. Secondary/muted." },
|
||||||
|
"label": { "$value": "#64748b", "$description": "Slate-500. Labels & placeholders." },
|
||||||
|
"faint": { "$value": "#475569", "$description": "Slate-600. Faint meta, timestamps." },
|
||||||
|
"dim": { "$value": "#334155", "$description": "Slate-700. Dimmest text (doubles as border.hover)." },
|
||||||
|
"on-accent": { "$value": "#ffffff", "$description": "White — only on filled accent/premium buttons." }
|
||||||
|
},
|
||||||
|
|
||||||
|
"status": {
|
||||||
|
"$description": "Semantic status ramps. Use the tinted-triplet pattern for chips (see tint.*).",
|
||||||
|
"success": { "$value": "#22c55e" },
|
||||||
|
"success-text": { "$value": "#4ade80" },
|
||||||
|
"success-soft": { "$value": "#86efac" },
|
||||||
|
"success-deep": { "$value": "#16a34a" },
|
||||||
|
"error": { "$value": "#ef4444" },
|
||||||
|
"error-text": { "$value": "#f87171" },
|
||||||
|
"error-soft": { "$value": "#fca5a5" },
|
||||||
|
"error-deep": { "$value": "#dc2626" },
|
||||||
|
"warning": { "$value": "#fbbf24" },
|
||||||
|
"warning-soft": { "$value": "#fcd34d" },
|
||||||
|
"warning-faint": { "$value": "#fde68a" },
|
||||||
|
"warning-deep": { "$value": "#f59e0b" },
|
||||||
|
"info": { "$value": "#3b82f6", "$description": "Blue-500. Info/status only (+ legacy auth accent). Not a primary interactive color in-app." },
|
||||||
|
"info-mid": { "$value": "#60a5fa" },
|
||||||
|
"info-soft": { "$value": "#93c5fd" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"speaker": {
|
||||||
|
"$description": "8-hue categorical set for diarized speaker chips A–H. Rendered as the tinted triplet (bg α≈.18 / this text / border α≈.35).",
|
||||||
|
"a": { "$value": "#fca5a5", "$description": "red — base rgb(239,68,68)" },
|
||||||
|
"b": { "$value": "#93c5fd", "$description": "blue — base rgb(59,130,246)" },
|
||||||
|
"c": { "$value": "#86efac", "$description": "green — base rgb(34,197,94)" },
|
||||||
|
"d": { "$value": "#fcd34d", "$description": "amber — base rgb(245,158,11)" },
|
||||||
|
"e": { "$value": "#d8b4fe", "$description": "purple — base rgb(168,85,247)" },
|
||||||
|
"f": { "$value": "#7dd3fc", "$description": "sky — base rgb(14,165,233)" },
|
||||||
|
"g": { "$value": "#f9a8d4", "$description": "pink — base rgb(236,72,153)" },
|
||||||
|
"h": { "$value": "#cbd5e1", "$description": "slate — base rgb(100,116,139)" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"tint": {
|
||||||
|
"$type": "color",
|
||||||
|
"$description": "Recurring translucent washes. Stored as documented rgba strings — they are tints of the named hues, not standalone primitives.",
|
||||||
|
"accent-wash": { "$value": "rgba(129,140,248,0.06)", "$description": "Hover/active wash of accent.default. Family: .06 .08 .10 .15 .20." },
|
||||||
|
"accent-active": { "$value": "rgba(129,140,248,0.08)", "$description": "Active background of accent.default." },
|
||||||
|
"accent-ring": { "$value": "rgba(129,140,248,0.15)", "$description": "Focus-ring color → box-shadow 0 0 0 3px." },
|
||||||
|
"chip-bg": { "$value": "rgba(34,197,94,0.10)", "$description": "Representative tinted-triplet background (α≈.10) — swap the hue per status/category." },
|
||||||
|
"chip-border": { "$value": "rgba(34,197,94,0.20)", "$description": "Representative tinted-triplet border (α≈.20)." },
|
||||||
|
"scrim": { "$value": "rgba(0,0,0,0.6)", "$description": "Overlay scrim behind modals; family .4 .5 .6 .65." }
|
||||||
|
},
|
||||||
|
|
||||||
|
"font": {
|
||||||
|
"family": {
|
||||||
|
"$type": "fontFamily",
|
||||||
|
"sans": { "$value": ["-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", "sans-serif"], "$description": "System stack — no web fonts." },
|
||||||
|
"mono": { "$value": ["ui-monospace", "SF Mono", "Menlo", "Consolas", "monospace"], "$description": "Timestamps, license keys, URLs, code. Canonical — snap near-variants to this." }
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"$description": "Normalized scale (px). Snap strays 15→16, 24→22, 12.5/11.5→12, 10.5/9→10.",
|
||||||
|
"10": { "$value": "10px", "$description": "micro labels, badges, meta, section labels" },
|
||||||
|
"11": { "$value": "11px", "$description": "small labels, pills, hints, queue meta" },
|
||||||
|
"12": { "$value": "12px", "$description": "secondary UI default — buttons, labels, stats" },
|
||||||
|
"13": { "$value": "13px", "$description": "body default — descriptions, list items" },
|
||||||
|
"14": { "$value": "14px", "$description": "emphasis / larger body, drawer & settings headers" },
|
||||||
|
"16": { "$value": "16px", "$description": "input text, modal h2, section heads" },
|
||||||
|
"18": { "$value": "18px", "$description": "sub-headers, logo wordmark, large numerals" },
|
||||||
|
"20": { "$value": "20px", "$description": "headings" },
|
||||||
|
"22": { "$value": "22px", "$description": "large headings, modal/activation titles, tier names" },
|
||||||
|
"28": { "$value": "28px", "$description": "display — price" }
|
||||||
|
},
|
||||||
|
"weight": {
|
||||||
|
"$type": "fontWeight",
|
||||||
|
"$description": "Snap 650→600, 680→700.",
|
||||||
|
"normal": { "$value": 400 },
|
||||||
|
"medium": { "$value": 500 },
|
||||||
|
"semibold": { "$value": 600, "$description": "The UI default weight." },
|
||||||
|
"bold": { "$value": 700, "$description": "Headings, badges." },
|
||||||
|
"extrabold": { "$value": 800, "$description": "Tier badge, big numerals." }
|
||||||
|
},
|
||||||
|
"lineHeight": {
|
||||||
|
"$type": "number",
|
||||||
|
"body": { "$value": 1.5 },
|
||||||
|
"reading": { "$value": 1.55, "$description": "Denser reading blocks." },
|
||||||
|
"title": { "$value": 1.3 },
|
||||||
|
"title-large": { "$value": 1.25 },
|
||||||
|
"single": { "$value": 1, "$description": "Single-line badges/controls." }
|
||||||
|
},
|
||||||
|
"letterSpacing": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"label": { "$value": "0.05em", "$description": "Uppercase micro-labels." },
|
||||||
|
"badge": { "$value": "0.04em" },
|
||||||
|
"tracked": { "$value": "0.06em", "$description": "Tier/section labels (up to 0.08em)." },
|
||||||
|
"tight": { "$value": "-0.01em", "$description": "Large display titles." }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"radius": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"$description": "Snap strays 3→4, 5→6, 7→6|8, 9→8|10, 11→10|12.",
|
||||||
|
"4": { "$value": "4px", "$description": "tiny chips, badges, drop-zones, skeleton lines" },
|
||||||
|
"6": { "$value": "6px", "$description": "default small controls — buttons, chips, menu items" },
|
||||||
|
"8": { "$value": "8px", "$description": "default medium — inputs, buttons, pills" },
|
||||||
|
"10": { "$value": "10px", "$description": "larger cards/chunks, status bars" },
|
||||||
|
"12": { "$value": "12px", "$description": "modals, popovers, menus" },
|
||||||
|
"14": { "$value": "14px", "$description": "big cards, video embed, activation card" },
|
||||||
|
"16": { "$value": "16px", "$description": "settings / buy modal" },
|
||||||
|
"18": { "$value": "18px", "$description": "listen panel" },
|
||||||
|
"pill": { "$value": "999px", "$description": "fully-rounded badges" },
|
||||||
|
"circle": { "$value": "50%", "$description": "avatars, dots, play controls, spinner" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"space": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"$description": "De-facto dense scale (px). Not a strict 4/8 grid; prefer these steps over new in-between values.",
|
||||||
|
"4": { "$value": "4px" },
|
||||||
|
"6": { "$value": "6px" },
|
||||||
|
"8": { "$value": "8px" },
|
||||||
|
"10": { "$value": "10px" },
|
||||||
|
"12": { "$value": "12px" },
|
||||||
|
"14": { "$value": "14px", "$description": "card margin" },
|
||||||
|
"16": { "$value": "16px", "$description": "split-screen gap" },
|
||||||
|
"20": { "$value": "20px" },
|
||||||
|
"24": { "$value": "24px", "$description": "container horizontal padding" },
|
||||||
|
"28": { "$value": "28px", "$description": "section padding" },
|
||||||
|
"32": { "$value": "32px" },
|
||||||
|
"36": { "$value": "36px", "$description": "landing container vertical padding" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"shadow": {
|
||||||
|
"$type": "shadow",
|
||||||
|
"$description": "Flat-by-default — shadows only for floating surfaces. Composite/glow values kept as documented strings.",
|
||||||
|
"toast": { "$value": "0 8px 24px rgba(0,0,0,0.3)" },
|
||||||
|
"drawer": { "$value": "0 8px 32px rgba(0,0,0,0.4)", "$description": "Video embed; side drawers use ±8px 0 32px." },
|
||||||
|
"menu": { "$value": "0 12px 32px rgba(0,0,0,0.5)" },
|
||||||
|
"panel": { "$value": "0 20px 60px rgba(0,0,0,0.5)", "$description": "Listen panel, activation card." },
|
||||||
|
"modal": { "$value": "0 24px 64px rgba(0,0,0,0.5)", "$description": "Settings/buy modal (up to ...0.6)." },
|
||||||
|
"glow-accent": { "$value": "0 4px 24px rgba(129,140,248,0.3)", "$description": "Submit button — signals primary." },
|
||||||
|
"glow-accent-faint": { "$value": "0 2px 16px rgba(129,140,248,0.06)", "$description": "Expanded chunk." },
|
||||||
|
"ring-accent": { "$value": "0 0 0 3px rgba(129,140,248,0.15)", "$description": "Input focus ring." },
|
||||||
|
"glow-premium": { "$value": "0 12px 40px rgba(168,85,247,0.25)", "$description": "Highlighted premium tier." }
|
||||||
|
},
|
||||||
|
|
||||||
|
"motion": {
|
||||||
|
"$description": "Transitions are quick and functional. Durations as documented strings.",
|
||||||
|
"duration": {
|
||||||
|
"fast": { "$value": "0.15s", "$description": "Default control transition (transition: all 0.15s)." },
|
||||||
|
"base": { "$value": "0.2s", "$description": "Cards, layout shifts." },
|
||||||
|
"slow": { "$value": "0.4s", "$description": "Chunk-body max-height expand." }
|
||||||
|
},
|
||||||
|
"overlay-blur": { "$value": "blur(4px)", "$description": "Modal/overlay backdrop-filter; 4–6px range." }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Daily Digest — plan
|
||||||
|
|
||||||
|
Status: **proposed** (awaiting go-ahead). Captures the design agreed with Grant on
|
||||||
|
2026-06-15. Build only after sign-off.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
An **opt-in** (off by default) daily "wake-up" email to recaps.cc users: the recaps
|
||||||
|
added to their library in the last ~24 hours, each shown as a **synthesized 1–2
|
||||||
|
paragraph overview** generated from that recap's existing per-topic summaries. Turns
|
||||||
|
passive subscriptions into a daily touchpoint without making the user open the app.
|
||||||
|
|
||||||
|
## Decisions (locked 2026-06-15)
|
||||||
|
|
||||||
|
- **Content** — "overnight recaps": library additions since the user's last digest.
|
||||||
|
- **Audience / opt-in** — multi-mode (recaps.cc) first; **off by default**; per-user toggle.
|
||||||
|
- **Per-episode depth** — a 1–2 paragraph overview *synthesized from the stored topic
|
||||||
|
summaries* (`chunks`). NOT raw full text (too long, Gmail clips >~102 KB), NOT a
|
||||||
|
one-sentence blurb (too thin). This is Grant's call and it's what bounds email size.
|
||||||
|
- **Volume** — per-episode size is bounded by the 2-paragraph synthesis. Still cap at
|
||||||
|
~10 episodes per email with an "and N more in your library →" overflow link for
|
||||||
|
extreme days.
|
||||||
|
- **Cadence** — once per user per ~24h at a fixed server-time hour (default 08:00).
|
||||||
|
Timezone-aware send is a v2. **Skip the email entirely when nothing is new.**
|
||||||
|
- **Dedup** — a per-user `last_digest_at` watermark; each digest covers recaps created
|
||||||
|
since that instant, so nothing repeats and nothing is missed.
|
||||||
|
|
||||||
|
## Data (grounded in code)
|
||||||
|
|
||||||
|
- Saved recap record (`server/history.js` `saveToHistory`): `id`, `title`, `type`,
|
||||||
|
`url`, `createdAt` (ISO), `topicCount`, `chunks` (topics, each with bullet
|
||||||
|
summaries), `entries` (transcript), `speakers`/`speakerNames`. **No top-level
|
||||||
|
summary is stored** → the 1–2 paragraph overview must be synthesized.
|
||||||
|
- Multi-mode users live in the `users` table (`id`, `email`, …); a user's library
|
||||||
|
scope is their user id.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Mirror `server/subscription-reminders.js` (the proven daily-scan-plus-email pattern:
|
||||||
|
self-gating, deduped, never throws).
|
||||||
|
|
||||||
|
- **`server/daily-digest.js`** (new)
|
||||||
|
- `runDigestScan({ force })`: gate on `isSmtpReady()` + public URL set. For each
|
||||||
|
opted-in user, list sessions with `createdAt > last_digest_at`; if none, skip. For
|
||||||
|
each new recap, get-or-generate its overview (see below), render the email,
|
||||||
|
`sendMail`, then advance the watermark. Returns a `{sent, skipped}` summary; never
|
||||||
|
throws.
|
||||||
|
- `startDigestScheduler()`: boot delay + interval, fires near the target hour.
|
||||||
|
Idempotent; safe to start unconditionally in multi mode.
|
||||||
|
- **Synthesis** — `synthesizeEpisodeOverview(record)`: send the recap's topic titles +
|
||||||
|
bullet summaries to the relay LLM with a "write a 1–2 paragraph overview" prompt.
|
||||||
|
**Cache** the result back onto the session JSON (e.g. `digestOverview`) so it's
|
||||||
|
generated once and could later power an in-app episode overview. **Sanitize
|
||||||
|
operator-internal strings at this boundary** (Parakeet/CUDA/LAN IPs etc. must not
|
||||||
|
reach cloud users — existing repo convention).
|
||||||
|
- **Email** — `renderDigestEmail({ brandName, episodes, manageUrl, unsubscribeUrl })`
|
||||||
|
in `server/email-template.js`, matching the existing reminder/magic-link templates.
|
||||||
|
- **Opt-in storage** — migration in `server/db.js`: add `users.digest_enabled`
|
||||||
|
(default 0) and `users.last_digest_at` (ms, nullable). Toggle endpoint in
|
||||||
|
`server/account-routes.js` (requires session). Settings-modal toggle in
|
||||||
|
`public/index.html`.
|
||||||
|
- **Unsubscribe** — a one-click tokenized GET link in every email that flips
|
||||||
|
`digest_enabled = 0` without requiring login (signed token), plus the in-app toggle.
|
||||||
|
Consent + deliverability hygiene on the young recaps.cc domain.
|
||||||
|
- **Operator test trigger** — `POST /api/admin/digest/run { test_email }`, mirroring
|
||||||
|
the reminders test hook, so it can be smoke-tested without waiting a day.
|
||||||
|
|
||||||
|
## Cost / credits
|
||||||
|
|
||||||
|
The synthesis is one small relay LLM call per new recap per opted-in user, run once and
|
||||||
|
cached. Bounded by (opted-in users × new recaps/day). **Recommend operator-absorbed**
|
||||||
|
(it's a retention feature, input is already-short topic summaries) rather than drawing
|
||||||
|
the user's credits. Confirm.
|
||||||
|
|
||||||
|
## Open questions (defaults chosen; confirm or adjust)
|
||||||
|
|
||||||
|
1. **Synthesis cost owner** — ~~operator-absorbed (default) vs user credits?~~
|
||||||
|
**RESOLVED 2026-06-15: operator-absorbed, zero operator action.** The synthesis
|
||||||
|
provider is built with `resolveProviderOpts("relay", { req: null })` → the operator's
|
||||||
|
install identity, the *same* relay credit pool free signed-in users' summaries already
|
||||||
|
draw from (`providers/index.js` `pickRelayIdentity`). No comped system user-id needed.
|
||||||
|
Flipping to user-billing later = pass the recipient's cloud identity at the marked line
|
||||||
|
in `daily-digest.js` `buildSynthesisProvider()`.
|
||||||
|
2. **Send hour** — 08:00 server time (default)?
|
||||||
|
3. **Single-mode operator digest** — defer to a follow-on (default: multi-mode only v1)?
|
||||||
|
4. **Relay contract** — ~~does an existing relay endpoint (`/relay/analyze`) fit~~
|
||||||
|
**RESOLVED 2026-06-15: `/relay/analyze` fits as-is, no new relay capability.** The
|
||||||
|
route (`recap-relay/server/routes/analyze.js`) takes a free-form `{ prompt: string }`
|
||||||
|
and returns `{ result: { text } }`; the client already wraps it as
|
||||||
|
`relay.js` `analyzeText({ prompt }) → result.text`. "Topic sections JSON" is only what
|
||||||
|
today's `chunked-analyze.js` caller asks for in *its* prompt — the endpoint is generic.
|
||||||
|
Synthesis = build a "summarize these summaries into 1–2 paragraphs" prompt, read
|
||||||
|
`result.text`. **No cross-repo change.** (Aside: relay `AGENTS.md:78` still describes
|
||||||
|
this endpoint as `{ transcript, … } → topic sections JSON` — stale; flag for that repo.)
|
||||||
|
Billing: each standalone analyze charges 1 credit on the call's credit key unless it
|
||||||
|
shares an `X-Recap-Job-Id` — that's the Q1 (cost-owner) mechanism, decided at phase 2.
|
||||||
|
|
||||||
|
## Build phases
|
||||||
|
|
||||||
|
1. **BUILT 2026-06-15.** Schema + opt-in toggle. `db.js`: `users.digest_enabled`
|
||||||
|
(default 0) + `users.last_digest_at` (ms, nullable) via SCHEMA_SQL +
|
||||||
|
`migrateUserDigestPrefs`. `account-routes.js`: `GET`/`POST /api/account/digest`
|
||||||
|
(enabling stamps `last_digest_at = now` so the first send isn't a backlog dump).
|
||||||
|
`public/index.html`: settings-modal toggle (`renderDigestBlock` + `loadMyDigest` /
|
||||||
|
`setDigestEnabled`, optimistic with revert).
|
||||||
|
2. **BUILT 2026-06-15.** Synthesis + cache → `server/daily-digest.js`:
|
||||||
|
`buildOverviewPrompt` (pure), `scrubOperatorStrings` (conservative backstop — infra
|
||||||
|
proper nouns + LAN/private hosts; dropped CUDA to avoid mangling legit tech content),
|
||||||
|
`synthesizeEpisodeOverview` (relay `analyzeText`, operator-absorbed identity, stable
|
||||||
|
per-episode jobId), `getOrCreateEpisodeOverview` (`digestOverview` cache + best-effort
|
||||||
|
`patchSession` write-back). NOT wired into a scheduler yet — dormant until phase 3.
|
||||||
|
Tests: `test/daily-digest.test.js` (12, pass). Note: chunks carry a `summary` text per
|
||||||
|
topic (not bullets — the Data section's "bullet summaries" wording was loose).
|
||||||
|
3. **BUILT 2026-06-15.** Email + scan + scheduler + dedup + overflow cap.
|
||||||
|
`email-template.js` `renderDigestEmail` (minimal inline style, per-episode title→source
|
||||||
|
link + overview, overflow line, one-click unsubscribe). `daily-digest.js`:
|
||||||
|
`selectDigestEpisodes` (pure: watermark filter + cap + overflow), `runDigestScan`
|
||||||
|
(hourly tick, acts at `SEND_HOUR=8`; per-user `MIN_RESEND_MS=20h` + watermark dedup;
|
||||||
|
skips empty; advances watermark only on successful send; never throws),
|
||||||
|
`startDigestScheduler`, `setupDigestRoutes` (public `GET /api/digest/unsubscribe?token=`).
|
||||||
|
`history.js` `listScopeSessions`. `db.js` adds `users.digest_unsub_token` (minted lazily
|
||||||
|
on first send). Wired in `index.js` (multi-mode) + `tenant-auth.js` public path.
|
||||||
|
4. **BUILT 2026-06-15.** `POST /api/admin/digest/run` — `{test_email}` sends a sample
|
||||||
|
render; bare body forces a real scan now (bypasses the hour gate, not the resend gate).
|
||||||
|
Mirrors `/api/admin/reminders/run`.
|
||||||
|
5. **DONE.** `test/daily-digest.test.js` — 19 tests (prompt, scrub, synth/cache,
|
||||||
|
`selectDigestEpisodes` watermark/cap/overflow/empty, `scopeForUser`, email render).
|
||||||
|
Full suite **138 pass**. Verified on a real multi-mode boot: migrations apply, scheduler
|
||||||
|
starts, and the unsubscribe route (400/404/200 + flips `digest_enabled`) works end-to-end.
|
||||||
|
|
||||||
|
## Status: feature-complete, awaiting on-box smoke test
|
||||||
|
|
||||||
|
Built end-to-end but **not yet installed** (no version bump). The relay synthesis call and
|
||||||
|
SMTP send can only be exercised on the operator's box. Operator smoke test:
|
||||||
|
`POST /api/admin/digest/run {test_email}` to eyeball the render; then opt in, add a recap,
|
||||||
|
and force a scan (or wait for 08:00) to see a real synthesized digest.
|
||||||
|
|
||||||
|
**Fresh-eyes review applied (2026-06-15).** Three correctness fixes after a reviewer pass:
|
||||||
|
(1) the watermark now advances to the newest *sent* recap but never past a failed/deferred
|
||||||
|
one (`nextDigestWatermark`) — the old `now` stamp silently dropped both synthesis-failures
|
||||||
|
and over-cap overflow recaps forever; (2) `force` no longer bypasses the in-progress lock,
|
||||||
|
so an operator force-run during the scheduled tick can't double-send; (3) `idx_users_unsub_token`
|
||||||
|
is created in the migration, not `SCHEMA_SQL` (the latter runs before the column exists on
|
||||||
|
upgraded DBs → would crash boot). Existing-DB upgrade verified on a realistic pre-digest
|
||||||
|
schema. Also added an index on the unauthenticated token lookup + a null-scope guard.
|
||||||
+68
-41
@@ -30,12 +30,31 @@
|
|||||||
|
|
||||||
<link rel="icon" type="image/png" href="/assets/icon.png">
|
<link rel="icon" type="image/png" href="/assets/icon.png">
|
||||||
<style>
|
<style>
|
||||||
|
/* Design tokens (subset used by this page) — mirror of design/tokens.tokens.json.
|
||||||
|
This is a standalone document, so it carries its own copy; keep it in sync with
|
||||||
|
the canonical :root in index.html. Accent is indigo, not the old blue. */
|
||||||
|
:root {
|
||||||
|
--bg: #0a0e1a;
|
||||||
|
--surface: #111827;
|
||||||
|
--border: #1e293b;
|
||||||
|
--accent: #818cf8;
|
||||||
|
--accent-hover: #a5b4fc;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--text-strong: #f1f5f9;
|
||||||
|
--text-body: #cbd5e1;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--text-label: #64748b;
|
||||||
|
--text-faint: #475569;
|
||||||
|
--error-soft: #fca5a5;
|
||||||
|
--success-text: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
background: #0a0e1a;
|
background: var(--bg);
|
||||||
color: #e2e8f0;
|
color: var(--text);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -44,8 +63,8 @@
|
|||||||
.card {
|
.card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
background: #121828;
|
background: var(--surface);
|
||||||
border: 1px solid #1f2942;
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 32px 28px;
|
padding: 32px 28px;
|
||||||
}
|
}
|
||||||
@@ -56,65 +75,65 @@
|
|||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
.logo img { width: 32px; height: 32px; border-radius: 6px; }
|
.logo img { width: 32px; height: 32px; border-radius: 6px; }
|
||||||
.logo span { font-size: 18px; font-weight: 600; color: #f5f9ff; }
|
.logo span { font-size: 18px; font-weight: 600; color: var(--text-strong); }
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f5f9ff;
|
color: var(--text-strong);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
p.lede {
|
p.lede {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: #94a3b8;
|
color: var(--text-muted);
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #cbd5e1;
|
color: var(--text-body);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
input[type=email],
|
input[type=email],
|
||||||
input[type=password] {
|
input[type=password] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #0a0e1a;
|
background: var(--bg);
|
||||||
border: 1px solid #1f2942;
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
color: #f5f9ff;
|
color: var(--text-strong);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s ease;
|
transition: border-color 0.15s ease;
|
||||||
/* Browsers auto-fill password fields with their own bright
|
/* Browsers auto-fill password fields with their own bright
|
||||||
background; -webkit-text-fill-color + a long inset shadow
|
background; -webkit-text-fill-color + a long inset shadow
|
||||||
override that so the field stays on-brand. */
|
override that so the field stays on-brand. */
|
||||||
-webkit-text-fill-color: #f5f9ff;
|
-webkit-text-fill-color: var(--text-strong);
|
||||||
-webkit-box-shadow: 0 0 0 1000px #0a0e1a inset;
|
-webkit-box-shadow: 0 0 0 1000px var(--bg) inset;
|
||||||
caret-color: #f5f9ff;
|
caret-color: var(--text-strong);
|
||||||
}
|
}
|
||||||
input[type=email]:focus,
|
input[type=email]:focus,
|
||||||
input[type=password]:focus { border-color: #3b82f6; }
|
input[type=password]:focus { border-color: var(--accent); }
|
||||||
input[type=email]::placeholder,
|
input[type=email]::placeholder,
|
||||||
input[type=password]::placeholder { color: #475569; }
|
input[type=password]::placeholder { color: var(--text-faint); }
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
background: #3b82f6;
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
button:hover:not(:disabled) { background: #2563eb; }
|
button:hover:not(:disabled) { background: var(--accent-hover); }
|
||||||
button:disabled { background: #1e3a8a; cursor: not-allowed; opacity: 0.6; }
|
button:disabled { background: var(--border); cursor: not-allowed; opacity: 0.6; }
|
||||||
.feedback {
|
.feedback {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
@@ -124,26 +143,26 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.feedback.success {
|
.feedback.success {
|
||||||
background: rgba(16, 185, 129, 0.1);
|
background: rgba(34, 197, 94, 0.1);
|
||||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
color: #6ee7b7;
|
color: var(--success-text);
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.feedback.error {
|
.feedback.error {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
color: #fca5a5;
|
color: var(--error-soft);
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #64748b;
|
color: var(--text-label);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.footer a { color: #94a3b8; text-decoration: none; }
|
.footer a { color: var(--text-muted); text-decoration: none; }
|
||||||
.footer a:hover { color: #cbd5e1; }
|
.footer a:hover { color: var(--text-body); }
|
||||||
/* Password group hidden by default — most users want the magic
|
/* Password group hidden by default — most users want the magic
|
||||||
link and the optional-password field cluttered the form. The
|
link and the optional-password field cluttered the form. The
|
||||||
"Use password instead" link below the submit button reveals
|
"Use password instead" link below the submit button reveals
|
||||||
@@ -153,14 +172,14 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #64748b;
|
color: var(--text-label);
|
||||||
}
|
}
|
||||||
.toggle-pwd-row a {
|
.toggle-pwd-row a {
|
||||||
color: #94a3b8;
|
color: var(--text-muted);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.toggle-pwd-row a:hover { color: #cbd5e1; }
|
.toggle-pwd-row a:hover { color: var(--text-body); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -335,22 +354,30 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Silent retry on the first fetch — iOS Safari sometimes
|
// Silent retry on transport failures — iOS Safari can dispatch a
|
||||||
// aborts the very first request from a cold tab with a generic
|
// POST onto a pooled keep-alive socket the server (or a proxy in
|
||||||
// "Load failed" TypeError. A single ~500ms retry hides the
|
// front of it) has already closed. Unlike a GET, a non-idempotent
|
||||||
// flake; server-side errors (4xx/5xx) are not retried because
|
// POST isn't transparently re-sent on a fresh connection; it
|
||||||
// they're deliberate responses, not transport issues.
|
// surfaces a "Load failed" TypeError instead. A single quick retry
|
||||||
|
// tends to reuse the same dead socket and fail again (the reported
|
||||||
|
// "first tap errors, second works"), so retry a few times with
|
||||||
|
// growing backoff to outlast Safari evicting the socket. Server
|
||||||
|
// errors (4xx/5xx) are returned as-is and never retried — they're
|
||||||
|
// deliberate responses, not transport flakes.
|
||||||
const reqInit = {
|
const reqInit = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
};
|
};
|
||||||
async function postWithRetry() {
|
async function postWithRetry() {
|
||||||
try {
|
const backoffsMs = [400, 1200];
|
||||||
return await fetch('/auth/request-link', reqInit);
|
for (let attempt = 0; ; attempt++) {
|
||||||
} catch (e) {
|
try {
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
return await fetch('/auth/request-link', reqInit);
|
||||||
return await fetch('/auth/request-link', reqInit);
|
} catch (e) {
|
||||||
|
if (attempt >= backoffsMs.length) throw e;
|
||||||
|
await new Promise((r) => setTimeout(r, backoffsMs[attempt]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
+1016
-652
File diff suppressed because it is too large
Load Diff
@@ -279,4 +279,60 @@ export function setupAccountRoutes(app) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Daily Digest opt-in ────────────────────────────────────────────
|
||||||
|
// Opt-in (off by default) daily email of the last ~24h of library
|
||||||
|
// recaps. The relay-owned subscription tier is unrelated — any
|
||||||
|
// signed-in user may toggle this. GET reads current state; POST
|
||||||
|
// {enabled:bool} flips it. Enabling stamps last_digest_at to "now"
|
||||||
|
// so the first digest covers only recaps added AFTER opt-in, never
|
||||||
|
// the user's whole backlog (the scan picks createdAt > watermark).
|
||||||
|
app.get("/api/account/digest", requireUser, (req, res) => {
|
||||||
|
if (!req.user || !req.user.id) {
|
||||||
|
return res.status(401).json({ error: "auth_required" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare("SELECT digest_enabled, last_digest_at FROM users WHERE id = ?")
|
||||||
|
.get(req.user.id);
|
||||||
|
res.json({
|
||||||
|
enabled: !!row?.digest_enabled,
|
||||||
|
last_digest_at: row?.last_digest_at ?? null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[account] digest read failed:", err);
|
||||||
|
res.status(500).json({ error: "internal_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/account/digest", requireUser, (req, res) => {
|
||||||
|
if (!req.user || !req.user.id) {
|
||||||
|
return res.status(401).json({ error: "auth_required" });
|
||||||
|
}
|
||||||
|
const enabled = req.body?.enabled;
|
||||||
|
if (typeof enabled !== "boolean") {
|
||||||
|
return res.status(400).json({ error: "enabled_must_be_boolean" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (enabled) {
|
||||||
|
// Start the watermark at opt-in so the first send isn't a backlog dump.
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
"UPDATE users SET digest_enabled = 1, last_digest_at = ? WHERE id = ?",
|
||||||
|
)
|
||||||
|
.run(Date.now(), req.user.id);
|
||||||
|
} else {
|
||||||
|
getDb()
|
||||||
|
.prepare("UPDATE users SET digest_enabled = 0 WHERE id = ?")
|
||||||
|
.run(req.user.id);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[account] digest ${enabled ? "enabled" : "disabled"} for user ${req.user.id}`,
|
||||||
|
);
|
||||||
|
res.json({ ok: true, enabled });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[account] digest toggle failed:", err);
|
||||||
|
res.status(500).json({ error: "internal_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,70 @@ export function setupAdminRoutes(app) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Daily-digest test trigger. With {test_email}, sends a sample digest
|
||||||
|
// to that address so the operator can eyeball the rendering without
|
||||||
|
// opted-in users or waiting for the send hour. Without it, forces a
|
||||||
|
// real scan now (bypassing the 08:00 gate, NOT the per-user resend gate).
|
||||||
|
app.post("/api/admin/digest/run", requireOperator, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const testEmail =
|
||||||
|
typeof req.body?.test_email === "string" ? req.body.test_email.trim() : "";
|
||||||
|
if (testEmail) {
|
||||||
|
const { isSmtpReady, sendMail } = await import("./smtp.js");
|
||||||
|
if (!isSmtpReady()) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: "smtp_not_ready",
|
||||||
|
message: "Configure StartOS System SMTP first.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { renderDigestEmail } = await import("./email-template.js");
|
||||||
|
const { getConfigSnapshot } = await import("./config.js");
|
||||||
|
const snap = await getConfigSnapshot();
|
||||||
|
const publicUrl = (snap.recap_public_url || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
const msg = renderDigestEmail({
|
||||||
|
brandName: "Recaps",
|
||||||
|
episodes: [
|
||||||
|
{
|
||||||
|
title: "Sample podcast episode",
|
||||||
|
type: "podcast",
|
||||||
|
url: "https://example.com/episode",
|
||||||
|
overview:
|
||||||
|
"This is a sample overview paragraph so you can see how a digest entry renders. The real thing is synthesized from each recap's stored topic summaries.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Sample YouTube video",
|
||||||
|
type: "youtube",
|
||||||
|
url: "https://youtube.com/watch?v=example",
|
||||||
|
overview:
|
||||||
|
"A second sample entry, showing how multiple recaps stack in one email.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
overflowCount: 0,
|
||||||
|
manageUrl: `${publicUrl}/`,
|
||||||
|
unsubscribeUrl: `${publicUrl}/api/digest/unsubscribe?token=sample`,
|
||||||
|
});
|
||||||
|
await sendMail({
|
||||||
|
to: testEmail,
|
||||||
|
subject: msg.subject,
|
||||||
|
text: msg.text,
|
||||||
|
html: msg.html,
|
||||||
|
});
|
||||||
|
return res.json({ ok: true, test_email_sent_to: testEmail });
|
||||||
|
}
|
||||||
|
const { runDigestScan } = await import("./daily-digest.js");
|
||||||
|
const result = await runDigestScan({ force: true });
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin] digest run failed:", err?.message || err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: "digest_run_failed",
|
||||||
|
message: err?.message || String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── List all tenants ───────────────────────────────────────────────────
|
// ── List all tenants ───────────────────────────────────────────────────
|
||||||
app.get("/api/admin/tenants", requireOperator, (req, res) => {
|
app.get("/api/admin/tenants", requireOperator, (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
// Daily Digest — per-episode overview synthesis (multi-mode / cloud).
|
||||||
|
//
|
||||||
|
// Phase 2 of the Daily Digest feature: turn a saved recap's stored topic
|
||||||
|
// summaries into a 1–2 paragraph overview via the relay LLM, then cache
|
||||||
|
// the result back onto the session JSON (`digestOverview`) so it's
|
||||||
|
// generated at most once per episode. The daily scan + email (phase 3)
|
||||||
|
// will call getOrCreateEpisodeOverview(); no scheduler lives here yet.
|
||||||
|
//
|
||||||
|
// Cost ownership (Q1 = operator-absorbed): the synthesis call uses the
|
||||||
|
// OPERATOR's relay identity — the same credit pool that free signed-in
|
||||||
|
// users' summaries already draw from (resolveProviderOpts with req=null
|
||||||
|
// → operator install identity). A retention email shouldn't silently
|
||||||
|
// drain the recipient's quota for recaps they already made. To bill the
|
||||||
|
// recipient instead, build the provider with their cloud identity at the
|
||||||
|
// one marked line below.
|
||||||
|
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { getProvider, resolveProviderOpts } from "./providers/index.js";
|
||||||
|
import { patchSession, loadSession, listScopeSessions } from "./history.js";
|
||||||
|
import { getDb } from "./db.js";
|
||||||
|
import { sendMail, isSmtpReady } from "./smtp.js";
|
||||||
|
import { renderDigestEmail } from "./email-template.js";
|
||||||
|
import { getConfigSnapshot } from "./config.js";
|
||||||
|
|
||||||
|
// Operator-internal vocabulary the sibling relay could surface in model
|
||||||
|
// output (backend / hardware names, LAN hosts). Scrubbed before any
|
||||||
|
// digest text reaches a cloud user — the same error-boundary rule the
|
||||||
|
// rest of the app follows. This is a backstop: the synthesis input is
|
||||||
|
// the recap's own (already user-facing) topic summaries, so a leak here
|
||||||
|
// is unlikely. Kept conservative to avoid mangling legitimate prose —
|
||||||
|
// only unambiguous infra tokens and private/LAN hosts, never common
|
||||||
|
// words or public data.
|
||||||
|
const OPERATOR_TERMS = [
|
||||||
|
/\bspark[\s-]?control\b/gi,
|
||||||
|
/\bparakeet\b/gi,
|
||||||
|
/\bsortformer\b/gi,
|
||||||
|
/\btitanet\b/gi,
|
||||||
|
/\bvllm\b/gi,
|
||||||
|
];
|
||||||
|
const LAN_HOST_RE = /\bhttps?:\/\/[^\s)]*\.local\b[^\s)]*/gi;
|
||||||
|
const PRIVATE_IP_RE =
|
||||||
|
/\b(?:10|127)\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|\b192\.168\.\d{1,3}\.\d{1,3}\b|\b172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b/g;
|
||||||
|
|
||||||
|
export function scrubOperatorStrings(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
let out = String(text);
|
||||||
|
for (const re of OPERATOR_TERMS) out = out.replace(re, "");
|
||||||
|
out = out.replace(LAN_HOST_RE, "");
|
||||||
|
out = out.replace(PRIVATE_IP_RE, "");
|
||||||
|
// Tidy whitespace / orphaned punctuation the removals may have left.
|
||||||
|
return out
|
||||||
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
|
.replace(/\s+([.,;:])/g, "$1")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the LLM prompt from a saved recap record. Pure — exported for
|
||||||
|
// testing. Uses each topic's title + summary (the chunk shape is
|
||||||
|
// { title, summary, … }); a topic with no summary still contributes its
|
||||||
|
// title so the overview knows it was covered.
|
||||||
|
export function buildOverviewPrompt(record) {
|
||||||
|
const title = (record?.title || "Untitled").trim();
|
||||||
|
const type =
|
||||||
|
record?.type === "podcast"
|
||||||
|
? "podcast episode"
|
||||||
|
: record?.type === "youtube"
|
||||||
|
? "video"
|
||||||
|
: "recording";
|
||||||
|
const topics = Array.isArray(record?.chunks) ? record.chunks : [];
|
||||||
|
const topicBlock = topics
|
||||||
|
.map((c, i) => {
|
||||||
|
const t = (c?.title || `Topic ${i + 1}`).trim();
|
||||||
|
const s = (c?.summary || "").trim();
|
||||||
|
return s ? `- ${t}: ${s}` : `- ${t}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
return [
|
||||||
|
`Below are the per-topic summaries of a ${type} titled "${title}".`,
|
||||||
|
"",
|
||||||
|
"Write a tight 1–2 paragraph overview (about 100–150 words) that captures " +
|
||||||
|
"the main throughline and the few most important takeaways, as if briefing " +
|
||||||
|
"a busy reader who hasn't seen it. Do not invent anything beyond the " +
|
||||||
|
"summaries below, use no headings or bullet points, and write in plain prose.",
|
||||||
|
"",
|
||||||
|
"Topic summaries:",
|
||||||
|
topicBlock,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operator-identity relay provider for synthesis (operator-absorbed).
|
||||||
|
// Throws if the relay isn't configured (no install id / base URL) — the
|
||||||
|
// caller treats that as "skip this episode", not a fatal error.
|
||||||
|
function buildSynthesisProvider() {
|
||||||
|
// req=null → operator install identity. Swap in a per-recipient cloud
|
||||||
|
// identity here to bill the user instead of the operator.
|
||||||
|
const opts = resolveProviderOpts("relay", { req: null });
|
||||||
|
return getProvider("relay", opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthesize (no cache) — call the relay, scrub, return the overview
|
||||||
|
// text. Throws on no-topics or an empty model result. `provider` is
|
||||||
|
// injectable for testing; defaults to the operator-identity relay.
|
||||||
|
export async function synthesizeEpisodeOverview(record, { provider } = {}) {
|
||||||
|
const topics = Array.isArray(record?.chunks) ? record.chunks : [];
|
||||||
|
if (topics.length === 0) {
|
||||||
|
throw new Error("no topic summaries to synthesize");
|
||||||
|
}
|
||||||
|
const p = provider || buildSynthesisProvider();
|
||||||
|
const prompt = buildOverviewPrompt(record);
|
||||||
|
const result = await p.analyzeText({
|
||||||
|
prompt,
|
||||||
|
retries: 1,
|
||||||
|
// Stable per-episode billing key: a retry within the relay's job
|
||||||
|
// window reuses the same credit rather than charging twice.
|
||||||
|
jobId: record?.id ? `digest-${record.id}` : undefined,
|
||||||
|
});
|
||||||
|
const text = scrubOperatorStrings(result?.text || "");
|
||||||
|
if (!text) throw new Error("empty synthesis result");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get-or-generate the cached overview. Returns { overview, cached }. On a
|
||||||
|
// cache miss it synthesizes and (unless save:false) writes the result
|
||||||
|
// back onto the session JSON so the next caller is a cache hit. The
|
||||||
|
// write-back is best-effort — a failed patch just means we re-synthesize
|
||||||
|
// next time, never a user-visible error.
|
||||||
|
export async function getOrCreateEpisodeOverview({
|
||||||
|
scope,
|
||||||
|
id,
|
||||||
|
record,
|
||||||
|
provider,
|
||||||
|
save = true,
|
||||||
|
}) {
|
||||||
|
const cached = (record?.digestOverview || "").trim();
|
||||||
|
if (cached) return { overview: cached, cached: true };
|
||||||
|
const overview = await synthesizeEpisodeOverview(record, { provider });
|
||||||
|
if (save && scope && id) {
|
||||||
|
try {
|
||||||
|
await patchSession(scope, id, { digestOverview: overview });
|
||||||
|
} catch {
|
||||||
|
// best-effort cache; ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { overview, cached: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Daily scan + scheduler (mirrors subscription-reminders.js) ──────────
|
||||||
|
|
||||||
|
const SEND_HOUR = 8; // 08:00 server-local — when the daily scan acts
|
||||||
|
const SCAN_INTERVAL_MS = 60 * 60 * 1000; // tick hourly; act only at SEND_HOUR
|
||||||
|
const BOOT_DELAY_MS = 2 * 60 * 1000;
|
||||||
|
// A user gets at most one digest per ~day even if the loop ticks more
|
||||||
|
// than once inside the send hour or they add content right after a send.
|
||||||
|
const MIN_RESEND_MS = 20 * 60 * 60 * 1000;
|
||||||
|
const MAX_EPISODES = 10; // cap per email; the rest become an overflow count
|
||||||
|
|
||||||
|
let scanning = false;
|
||||||
|
let scheduled = false;
|
||||||
|
|
||||||
|
// Which library scope a user's recaps live under. Mirrors
|
||||||
|
// history.js scopeForRequest: the multi-mode admin keeps the "owner"
|
||||||
|
// scope; everyone else is scoped by their user id. Pure — exported for
|
||||||
|
// testing.
|
||||||
|
export function scopeForUser(user) {
|
||||||
|
return user?.is_admin ? "owner" : user?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the recaps created after the watermark, oldest first, capped.
|
||||||
|
// Pure — exported for testing. Returns { episodes, overflow, total }.
|
||||||
|
export function selectDigestEpisodes(sessions, watermarkMs, cap = MAX_EPISODES) {
|
||||||
|
const since = typeof watermarkMs === "number" ? watermarkMs : 0;
|
||||||
|
const fresh = (sessions || [])
|
||||||
|
.filter((s) => {
|
||||||
|
const t = new Date(s?.createdAt).getTime();
|
||||||
|
return Number.isFinite(t) && t > since;
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
||||||
|
return {
|
||||||
|
episodes: fresh.slice(0, cap),
|
||||||
|
overflow: Math.max(0, fresh.length - cap),
|
||||||
|
total: fresh.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskEmail(email) {
|
||||||
|
return String(email).replace(/^(.).*(@.*)$/, "$1***$2");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint (and persist) a user's unsubscribe token if they don't have one
|
||||||
|
// yet. Returns the token. Stable per user — re-enabling reuses it.
|
||||||
|
function ensureUnsubToken(db, user) {
|
||||||
|
if (user.digest_unsub_token) return user.digest_unsub_token;
|
||||||
|
const token = randomBytes(32).toString("base64url");
|
||||||
|
db.prepare("UPDATE users SET digest_unsub_token = ? WHERE id = ?").run(
|
||||||
|
token,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build one user's digest: synthesize an overview per selected episode
|
||||||
|
// (operator-absorbed, cached). Returns { built, failed } where built are
|
||||||
|
// the episode payloads ready for the template (each carrying its source
|
||||||
|
// createdAt) and failed is the createdAt list of episodes that errored —
|
||||||
|
// the caller uses both to set a watermark that never skips a failure.
|
||||||
|
async function buildUserEpisodes(scope, selected) {
|
||||||
|
const built = [];
|
||||||
|
const failed = [];
|
||||||
|
for (const ep of selected) {
|
||||||
|
try {
|
||||||
|
const record = await loadSession(scope, ep.id);
|
||||||
|
if (!record) {
|
||||||
|
failed.push(ep.createdAt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { overview } = await getOrCreateEpisodeOverview({
|
||||||
|
scope,
|
||||||
|
id: ep.id,
|
||||||
|
record,
|
||||||
|
});
|
||||||
|
built.push({
|
||||||
|
title: ep.title,
|
||||||
|
type: ep.type,
|
||||||
|
url: ep.url,
|
||||||
|
overview,
|
||||||
|
createdAt: ep.createdAt,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
failed.push(ep.createdAt);
|
||||||
|
console.warn(
|
||||||
|
`[digest] synthesis failed for ${scope}/${ep.id}: ${err?.message || err}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { built, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The watermark to stamp after a send. Advances to the newest
|
||||||
|
// successfully-sent recap, but never past the oldest one that FAILED (so
|
||||||
|
// the next scan retries gaps) and never past un-synthesized overflow
|
||||||
|
// recaps (their createdAt is newer than any sent one, so they're picked
|
||||||
|
// up next scan too). Pure — exported for testing. Returns null when
|
||||||
|
// nothing was sent (caller should not advance). createdAt inputs are ISO
|
||||||
|
// strings; output is ms epoch.
|
||||||
|
export function nextDigestWatermark(sentCreatedAts, failedCreatedAts) {
|
||||||
|
const toMs = (x) => new Date(x).getTime();
|
||||||
|
const sent = (sentCreatedAts || []).map(toMs).filter(Number.isFinite);
|
||||||
|
if (sent.length === 0) return null;
|
||||||
|
const failed = (failedCreatedAts || []).map(toMs).filter(Number.isFinite);
|
||||||
|
const newestSent = Math.max(...sent);
|
||||||
|
const oldestFailed = failed.length ? Math.min(...failed) : Infinity;
|
||||||
|
return Math.min(newestSent, oldestFailed - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One scan pass. Self-gating, deduped, NEVER throws — returns a small
|
||||||
|
// summary so the scheduler stays alive. `force` bypasses the send-hour
|
||||||
|
// gate (used by the operator test trigger), not the per-user resend gate.
|
||||||
|
export async function runDigestScan({ force = false } = {}) {
|
||||||
|
// `force` bypasses the send-hour gate (operator test trigger), NOT the
|
||||||
|
// in-progress lock — a forced run alongside the scheduled tick would
|
||||||
|
// otherwise double-send to every opted-in user.
|
||||||
|
if (scanning) return { skipped: "already_running" };
|
||||||
|
scanning = true;
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && new Date(now).getHours() !== SEND_HOUR) {
|
||||||
|
return { skipped: "off_hour" };
|
||||||
|
}
|
||||||
|
if (!isSmtpReady()) return { skipped: "smtp_not_ready" };
|
||||||
|
const snap = await getConfigSnapshot();
|
||||||
|
const publicUrl = (snap.recap_public_url || "").trim().replace(/\/$/, "");
|
||||||
|
if (!publicUrl) return { skipped: "public_url_not_set" };
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const users = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, email, is_admin, last_digest_at, digest_unsub_token FROM users WHERE digest_enabled = 1",
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
const email = (user.email || "").trim();
|
||||||
|
if (!email) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Defensive: a row with no watermark (set via SQL, not the opt-in
|
||||||
|
// endpoint) would dump the whole backlog — start the clock now
|
||||||
|
// and pick up new recaps next scan instead.
|
||||||
|
if (typeof user.last_digest_at !== "number") {
|
||||||
|
db.prepare("UPDATE users SET last_digest_at = ? WHERE id = ?").run(
|
||||||
|
now,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (now - user.last_digest_at < MIN_RESEND_MS) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const scope = scopeForUser(user);
|
||||||
|
if (!scope) {
|
||||||
|
// No usable id (shouldn't happen for a real row) — skip rather
|
||||||
|
// than read an "undefined" scope dir.
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sessions = await listScopeSessions(scope);
|
||||||
|
const { episodes: selected, overflow } = selectDigestEpisodes(
|
||||||
|
sessions,
|
||||||
|
user.last_digest_at,
|
||||||
|
MAX_EPISODES,
|
||||||
|
);
|
||||||
|
if (selected.length === 0) {
|
||||||
|
skipped++; // nothing new — skip the email, leave the watermark
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { built, failed } = await buildUserEpisodes(scope, selected);
|
||||||
|
if (built.length === 0) {
|
||||||
|
// Synthesis failed for all of them — don't advance the
|
||||||
|
// watermark, so the next scan retries the same recaps.
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const token = ensureUnsubToken(db, user);
|
||||||
|
const message = renderDigestEmail({
|
||||||
|
brandName: "Recaps",
|
||||||
|
episodes: built,
|
||||||
|
overflowCount: overflow,
|
||||||
|
manageUrl: `${publicUrl}/`,
|
||||||
|
unsubscribeUrl: `${publicUrl}/api/digest/unsubscribe?token=${encodeURIComponent(token)}`,
|
||||||
|
});
|
||||||
|
await sendMail({
|
||||||
|
to: email,
|
||||||
|
subject: message.subject,
|
||||||
|
text: message.text,
|
||||||
|
html: message.html,
|
||||||
|
});
|
||||||
|
// Advance the watermark only after a successful send — to the
|
||||||
|
// newest sent recap, but never past a failed or deferred one, so
|
||||||
|
// the next scan retries gaps instead of skipping them.
|
||||||
|
const watermark = nextDigestWatermark(
|
||||||
|
built.map((e) => e.createdAt),
|
||||||
|
failed,
|
||||||
|
);
|
||||||
|
db.prepare("UPDATE users SET last_digest_at = ? WHERE id = ?").run(
|
||||||
|
watermark ?? now,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
sent++;
|
||||||
|
console.log(
|
||||||
|
`[digest] sent to ${maskEmail(email)} (${episodes.length} recap${episodes.length === 1 ? "" : "s"}${overflow ? `, +${overflow} more` : ""})`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[digest] user ${user.id} failed: ${err?.message || err}`,
|
||||||
|
);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sent) {
|
||||||
|
console.log(`[digest] scan complete: ${sent} sent, ${skipped} skipped`);
|
||||||
|
}
|
||||||
|
return { sent, skipped };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[digest] scan error: ${err?.message || err}`);
|
||||||
|
return { skipped: "error", error: err?.message || String(err) };
|
||||||
|
} finally {
|
||||||
|
scanning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the hourly scan loop. Idempotent; self-gates inside the scan, so
|
||||||
|
// it's safe to call whenever multi mode boots.
|
||||||
|
export function startDigestScheduler() {
|
||||||
|
if (scheduled) return;
|
||||||
|
scheduled = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
runDigestScan().catch(() => {});
|
||||||
|
}, BOOT_DELAY_MS);
|
||||||
|
setInterval(() => {
|
||||||
|
runDigestScan().catch(() => {});
|
||||||
|
}, SCAN_INTERVAL_MS);
|
||||||
|
console.log("[digest] daily-digest scheduler started");
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-click unsubscribe — a public GET (no session) keyed by the per-user
|
||||||
|
// token in the email. Mounted in index.js (multi mode) and whitelisted in
|
||||||
|
// tenant-auth's public paths. Flips digest_enabled off; the in-app toggle
|
||||||
|
// can turn it back on.
|
||||||
|
export function setupDigestRoutes(app) {
|
||||||
|
app.get("/api/digest/unsubscribe", (req, res) => {
|
||||||
|
const token = String(req.query?.token || "").trim();
|
||||||
|
const page = (msg) =>
|
||||||
|
`<!doctype html><html><body style="margin:0;padding:48px 16px;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;text-align:center;color:#333;"><div style="max-width:420px;margin:0 auto;background:#fff;border-radius:8px;padding:32px;font-size:15px;line-height:1.6;">${msg}</div></body></html>`;
|
||||||
|
if (!token) {
|
||||||
|
return res.status(400).send(page("Invalid unsubscribe link."));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = getDb()
|
||||||
|
.prepare(
|
||||||
|
"UPDATE users SET digest_enabled = 0 WHERE digest_unsub_token = ?",
|
||||||
|
)
|
||||||
|
.run(token);
|
||||||
|
if (r.changes === 0) {
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.send(page("This unsubscribe link is no longer valid."));
|
||||||
|
}
|
||||||
|
return res.send(
|
||||||
|
page(
|
||||||
|
"You've been unsubscribed from the daily digest. You can turn it back on anytime in Settings.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[digest] unsubscribe failed:", err);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.send(page("Something went wrong. Please try again later."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+45
-1
@@ -41,10 +41,24 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
-- NOT used for auth decisions — just data for the operator to grep
|
-- NOT used for auth decisions — just data for the operator to grep
|
||||||
-- when an abuse pattern shows up in the admin dashboard.
|
-- when an abuse pattern shows up in the admin dashboard.
|
||||||
signup_ip TEXT,
|
signup_ip TEXT,
|
||||||
signup_user_agent TEXT
|
signup_user_agent TEXT,
|
||||||
|
-- Daily Digest (opt-in, multi-mode): a daily email of the user's last
|
||||||
|
-- ~24h of library recaps. Off by default. last_digest_at is the
|
||||||
|
-- ms-epoch watermark of the last send; the scan covers recaps created
|
||||||
|
-- AFTER it (dedup), and opt-in stamps it to "now" so the first digest
|
||||||
|
-- doesn't dump the whole backlog. NULL = never sent.
|
||||||
|
-- digest_unsub_token is a per-user random string for the one-click
|
||||||
|
-- unsubscribe link in each digest email (no login needed); minted
|
||||||
|
-- lazily on first send.
|
||||||
|
digest_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_digest_at INTEGER,
|
||||||
|
digest_unsub_token TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_signup_ip ON users(signup_ip);
|
CREATE INDEX IF NOT EXISTS idx_users_signup_ip ON users(signup_ip);
|
||||||
|
-- NB: idx_users_unsub_token is created in migrateUserDigestPrefs, not here
|
||||||
|
-- — SCHEMA_SQL runs before the column migration on existing DBs, so an
|
||||||
|
-- index over digest_unsub_token here would fail with "no such column".
|
||||||
|
|
||||||
-- ── sessions ───────────────────────────────────────────────────────────
|
-- ── sessions ───────────────────────────────────────────────────────────
|
||||||
-- Server-side session store so we can revoke individual sessions from
|
-- Server-side session store so we can revoke individual sessions from
|
||||||
@@ -293,6 +307,7 @@ export async function initDb({ dataDir }) {
|
|||||||
migrateTenantCreditsSchema(db);
|
migrateTenantCreditsSchema(db);
|
||||||
migrateMagicLinkTokensTrialCookie(db);
|
migrateMagicLinkTokensTrialCookie(db);
|
||||||
migrateUsersTier(db);
|
migrateUsersTier(db);
|
||||||
|
migrateUserDigestPrefs(db);
|
||||||
|
|
||||||
dbInstance = db;
|
dbInstance = db;
|
||||||
console.log(`[db] opened ${dbPath} (multi-tenant store)`);
|
console.log(`[db] opened ${dbPath} (multi-tenant store)`);
|
||||||
@@ -314,6 +329,35 @@ function migrateUsersTier(db) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Daily Digest — add the opt-in columns to existing DBs (fresh installs get
|
||||||
|
// them from SCHEMA_SQL). Idempotent: ALTERs only the columns still missing.
|
||||||
|
function migrateUserDigestPrefs(db) {
|
||||||
|
let cols;
|
||||||
|
try {
|
||||||
|
cols = db.prepare("PRAGMA table_info(users)").all();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!cols.some((c) => c.name === "digest_enabled")) {
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN digest_enabled INTEGER NOT NULL DEFAULT 0");
|
||||||
|
console.log("[db] added users.digest_enabled column (daily-digest)");
|
||||||
|
}
|
||||||
|
if (!cols.some((c) => c.name === "last_digest_at")) {
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN last_digest_at INTEGER");
|
||||||
|
console.log("[db] added users.last_digest_at column (daily-digest)");
|
||||||
|
}
|
||||||
|
if (!cols.some((c) => c.name === "digest_unsub_token")) {
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN digest_unsub_token TEXT");
|
||||||
|
console.log("[db] added users.digest_unsub_token column (daily-digest)");
|
||||||
|
}
|
||||||
|
// Created here (not in SCHEMA_SQL) so it runs AFTER the column exists on
|
||||||
|
// both fresh and migrated DBs. Idempotent. Keeps the public unsubscribe
|
||||||
|
// token lookup off a full-table scan.
|
||||||
|
db.exec(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_users_unsub_token ON users(digest_unsub_token)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// v0.2.92 — split the single tenant_credits.balance into two buckets
|
// v0.2.92 — split the single tenant_credits.balance into two buckets
|
||||||
// (purchased + replenish) so we can refill the latter periodically
|
// (purchased + replenish) so we can refill the latter periodically
|
||||||
// without wiping the former.
|
// without wiping the former.
|
||||||
|
|||||||
@@ -167,6 +167,110 @@ export function renderSubscriptionReminderEmail({
|
|||||||
return { subject, text, html };
|
return { subject, text, html };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderDigestEmail({ brandName, episodes, overflowCount, manageUrl,
|
||||||
|
// unsubscribeUrl }) → { subject, text, html }
|
||||||
|
// episodes: [{ title, type, url, overview }] — already capped + synthesized
|
||||||
|
// by the scan. overflowCount: how many more are in the library beyond the
|
||||||
|
// shown set (0 = none). Same minimal, spam-filter-friendly style as the
|
||||||
|
// other emails: no images, inline CSS, one CTA. The unsubscribe link is a
|
||||||
|
// one-click GET (no login) — required for deliverability + consent.
|
||||||
|
export function renderDigestEmail({
|
||||||
|
brandName = "Recaps",
|
||||||
|
episodes = [],
|
||||||
|
overflowCount = 0,
|
||||||
|
manageUrl,
|
||||||
|
unsubscribeUrl,
|
||||||
|
}) {
|
||||||
|
const n = episodes.length;
|
||||||
|
const subject =
|
||||||
|
n === 1
|
||||||
|
? `Your ${brandName} digest: 1 new recap`
|
||||||
|
: `Your ${brandName} digest: ${n} new recaps`;
|
||||||
|
|
||||||
|
const typeLabel = (t) =>
|
||||||
|
t === "podcast" ? "Podcast" : t === "youtube" ? "Video" : "Recording";
|
||||||
|
|
||||||
|
const epText = episodes
|
||||||
|
.map((ep) =>
|
||||||
|
[
|
||||||
|
`${ep.title || "Untitled"} (${typeLabel(ep.type)})`,
|
||||||
|
ep.overview || "",
|
||||||
|
ep.url || "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
`Here's what you added to ${brandName} in the last day:`,
|
||||||
|
"",
|
||||||
|
epText,
|
||||||
|
"",
|
||||||
|
overflowCount > 0
|
||||||
|
? `…and ${overflowCount} more in your library: ${manageUrl}`
|
||||||
|
: `Open your library: ${manageUrl}`,
|
||||||
|
"",
|
||||||
|
`You're receiving this because you turned on the daily digest. Unsubscribe: ${unsubscribeUrl}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const episodeBlocks = episodes
|
||||||
|
.map((ep) => {
|
||||||
|
const title = escapeHtml(ep.title || "Untitled");
|
||||||
|
const titleHtml = ep.url
|
||||||
|
? `<a href="${escapeAttr(ep.url)}" style="color:#111;text-decoration:none;">${title}</a>`
|
||||||
|
: title;
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom:20px;border-bottom:1px solid #eee;">
|
||||||
|
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:#999;padding-bottom:4px;">${escapeHtml(typeLabel(ep.type))}</div>
|
||||||
|
<div style="font-size:16px;font-weight:600;color:#111;padding-bottom:8px;line-height:1.35;">${titleHtml}</div>
|
||||||
|
<div style="font-size:14px;line-height:1.55;color:#444;">${escapeHtml(ep.overview || "")}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td style="height:20px;"></td></tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const overflowHtml =
|
||||||
|
overflowCount > 0
|
||||||
|
? `<tr><td style="font-size:13px;color:#888;padding-bottom:16px;">…and ${overflowCount} more in your library.</td></tr>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#fafafa;padding:32px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="520" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;padding:32px;max-width:90%;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:18px;font-weight:600;color:#111;padding-bottom:20px;">
|
||||||
|
Your ${escapeHtml(brandName)} digest
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
${episodeBlocks}
|
||||||
|
${overflowHtml}
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:8px 0 24px;">
|
||||||
|
<a href="${escapeAttr(manageUrl)}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:6px;">Open your library</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:12px;line-height:1.5;color:#888;border-top:1px solid #eee;padding-top:16px;">
|
||||||
|
You're receiving this because you turned on the daily digest. <a href="${escapeAttr(unsubscribeUrl)}" style="color:#888;">Unsubscribe</a> anytime, or manage it in Settings.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
return { subject, text, html };
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
|
|||||||
@@ -220,6 +220,43 @@ export async function loadSession(scope, id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List a scope's saved sessions as lightweight metadata (no entries /
|
||||||
|
// chunks), oldest first. The daily-digest scan uses this to pick recaps
|
||||||
|
// created after a watermark before loading each full record for
|
||||||
|
// synthesis. Returns [] when the scope has no library yet (or the id is
|
||||||
|
// malformed — safeComponent throws inside scopeDir, caught here).
|
||||||
|
export async function listScopeSessions(scope) {
|
||||||
|
let dir;
|
||||||
|
try {
|
||||||
|
dir = scopeDir(scope);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
let files = [];
|
||||||
|
try {
|
||||||
|
files = await fs.readdir(dir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const file of files.filter(
|
||||||
|
(f) => f.endsWith(".json") && !f.startsWith("_") && !ROOT_SIDECARS.has(f),
|
||||||
|
)) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await fs.readFile(path.join(dir, file), "utf-8"));
|
||||||
|
out.push({
|
||||||
|
id: data.id,
|
||||||
|
title: data.title,
|
||||||
|
type: data.type || "youtube",
|
||||||
|
url: data.url,
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
out.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Shallow-merge `patch` into a session record on disk (e.g. to stamp
|
// Shallow-merge `patch` into a session record on disk (e.g. to stamp
|
||||||
// `summaryAudio` availability). No-op-safe: returns null if the record
|
// `summaryAudio` availability). No-op-safe: returns null if the record
|
||||||
// is missing rather than throwing.
|
// is missing rather than throwing.
|
||||||
|
|||||||
@@ -264,6 +264,15 @@ if (RECAP_MODE === "multi") {
|
|||||||
// public URL + relay being configured, so it's a safe no-op until then.
|
// public URL + relay being configured, so it's a safe no-op until then.
|
||||||
const { startReminderScheduler } = await import("./subscription-reminders.js");
|
const { startReminderScheduler } = await import("./subscription-reminders.js");
|
||||||
startReminderScheduler();
|
startReminderScheduler();
|
||||||
|
// Daily Digest: opt-in (off by default) once-a-day email of a user's
|
||||||
|
// last ~24h of library recaps. Same self-gating shape as reminders —
|
||||||
|
// no-op until SMTP + public URL are set. The one-click unsubscribe GET
|
||||||
|
// is public (whitelisted in tenant-auth) since the email has no session.
|
||||||
|
const { startDigestScheduler, setupDigestRoutes } = await import(
|
||||||
|
"./daily-digest.js"
|
||||||
|
);
|
||||||
|
setupDigestRoutes(app);
|
||||||
|
startDigestScheduler();
|
||||||
|
|
||||||
// /api/account/whoami — frontend hits this on every page load to
|
// /api/account/whoami — frontend hits this on every page load to
|
||||||
// determine which UI state to render:
|
// determine which UI state to render:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const PUBLIC_PATH_PREFIXES = [
|
|||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/auth/", // future client-facing auth shims (CSRF token issue, etc.)
|
"/api/auth/", // future client-facing auth shims (CSRF token issue, etc.)
|
||||||
"/api/btcpay/webhook", // BTCPay needs to reach this without a session
|
"/api/btcpay/webhook", // BTCPay needs to reach this without a session
|
||||||
|
"/api/digest/unsubscribe", // one-click unsubscribe from a digest email (no session)
|
||||||
"/api/network-mode", // returns lan-vs-local; safe to expose
|
"/api/network-mode", // returns lan-vs-local; safe to expose
|
||||||
"/api/relay/status", // public relay capabilities — pre-trial visibility
|
"/api/relay/status", // public relay capabilities — pre-trial visibility
|
||||||
"/api/account/whoami", // returns state — anonymous visitors must call this
|
"/api/account/whoami", // returns state — anonymous visitors must call this
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// Pure / injectable-logic tests for Daily Digest episode synthesis. The
|
||||||
|
// relay round-trip and FS cache write-back aren't exercised here (a fake
|
||||||
|
// provider stands in, save:false skips disk); this nails prompt shaping,
|
||||||
|
// the operator-string scrub backstop, and the get-or-generate cache gate.
|
||||||
|
|
||||||
|
import { test, describe } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildOverviewPrompt,
|
||||||
|
scrubOperatorStrings,
|
||||||
|
synthesizeEpisodeOverview,
|
||||||
|
getOrCreateEpisodeOverview,
|
||||||
|
selectDigestEpisodes,
|
||||||
|
scopeForUser,
|
||||||
|
nextDigestWatermark,
|
||||||
|
} from "../daily-digest.js";
|
||||||
|
import { renderDigestEmail } from "../email-template.js";
|
||||||
|
|
||||||
|
const record = (over = {}) => ({
|
||||||
|
id: "1700000000000-abc",
|
||||||
|
title: "How Markets Work",
|
||||||
|
type: "podcast",
|
||||||
|
chunks: [
|
||||||
|
{ title: "Supply & demand", summary: "Prices clear where the two curves meet." },
|
||||||
|
{ title: "Information", summary: "Asymmetry distorts outcomes." },
|
||||||
|
],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A provider stub recording calls, returning a fixed analyze result.
|
||||||
|
const fakeProvider = (text, sink = {}) => ({
|
||||||
|
async analyzeText(args) {
|
||||||
|
sink.calls = (sink.calls || 0) + 1;
|
||||||
|
sink.lastArgs = args;
|
||||||
|
return { text };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildOverviewPrompt", () => {
|
||||||
|
test("includes title, type, and each topic's title + summary", () => {
|
||||||
|
const p = buildOverviewPrompt(record());
|
||||||
|
assert.match(p, /"How Markets Work"/);
|
||||||
|
assert.match(p, /podcast episode/);
|
||||||
|
assert.match(p, /- Supply & demand: Prices clear/);
|
||||||
|
assert.match(p, /- Information: Asymmetry distorts/);
|
||||||
|
assert.match(p, /1–2 paragraph/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a topic with no summary still contributes its title", () => {
|
||||||
|
const p = buildOverviewPrompt(
|
||||||
|
record({ chunks: [{ title: "Loose ends" }] }),
|
||||||
|
);
|
||||||
|
assert.match(p, /- Loose ends/);
|
||||||
|
assert.doesNotMatch(p, /Loose ends:/); // no trailing colon when summary absent
|
||||||
|
});
|
||||||
|
|
||||||
|
test("null-safe: missing fields fall back", () => {
|
||||||
|
const p = buildOverviewPrompt({});
|
||||||
|
assert.match(p, /"Untitled"/);
|
||||||
|
assert.match(p, /recording/); // unknown type
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scrubOperatorStrings", () => {
|
||||||
|
test("removes operator/infra tokens", () => {
|
||||||
|
const out = scrubOperatorStrings(
|
||||||
|
"Routed via Spark Control and the vLLM box on Parakeet.",
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(out, /spark control/i);
|
||||||
|
assert.doesNotMatch(out, /vllm/i);
|
||||||
|
assert.doesNotMatch(out, /parakeet/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("strips LAN hosts and private IPs, keeps public/content data", () => {
|
||||||
|
assert.doesNotMatch(
|
||||||
|
scrubOperatorStrings("see http://immense-voyage.local/admin"),
|
||||||
|
/\.local/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(scrubOperatorStrings("host 192.168.1.42 here"), /192\.168/);
|
||||||
|
// A public dotted quad in content is the user's data, not a leak.
|
||||||
|
assert.match(scrubOperatorStrings("DNS is 8.8.8.8"), /8\.8\.8\.8/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves ordinary prose intact", () => {
|
||||||
|
const clean = "The episode covers supply, demand, and information costs.";
|
||||||
|
assert.equal(scrubOperatorStrings(clean), clean);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("null-safe", () => {
|
||||||
|
assert.equal(scrubOperatorStrings(null), "");
|
||||||
|
assert.equal(scrubOperatorStrings(""), "");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("synthesizeEpisodeOverview", () => {
|
||||||
|
test("scrubs the model result and passes a stable per-episode jobId", async () => {
|
||||||
|
const sink = {};
|
||||||
|
const out = await synthesizeEpisodeOverview(record(), {
|
||||||
|
provider: fakeProvider("A clear overview from Spark Control.", sink),
|
||||||
|
});
|
||||||
|
assert.doesNotMatch(out, /spark control/i);
|
||||||
|
assert.equal(sink.calls, 1);
|
||||||
|
assert.equal(sink.lastArgs.jobId, "digest-1700000000000-abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when there are no topics", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => synthesizeEpisodeOverview(record({ chunks: [] }), { provider: fakeProvider("x") }),
|
||||||
|
/no topic summaries/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when the model returns nothing usable", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => synthesizeEpisodeOverview(record(), { provider: fakeProvider(" ") }),
|
||||||
|
/empty synthesis result/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOrCreateEpisodeOverview", () => {
|
||||||
|
test("cache hit returns stored overview without calling the provider", async () => {
|
||||||
|
const sink = {};
|
||||||
|
const res = await getOrCreateEpisodeOverview({
|
||||||
|
record: record({ digestOverview: "Already done." }),
|
||||||
|
provider: fakeProvider("fresh", sink),
|
||||||
|
save: false,
|
||||||
|
});
|
||||||
|
assert.equal(res.cached, true);
|
||||||
|
assert.equal(res.overview, "Already done.");
|
||||||
|
assert.equal(sink.calls || 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cache miss synthesizes (save:false skips disk)", async () => {
|
||||||
|
const sink = {};
|
||||||
|
const res = await getOrCreateEpisodeOverview({
|
||||||
|
record: record(),
|
||||||
|
provider: fakeProvider("A fresh overview.", sink),
|
||||||
|
save: false,
|
||||||
|
});
|
||||||
|
assert.equal(res.cached, false);
|
||||||
|
assert.equal(res.overview, "A fresh overview.");
|
||||||
|
assert.equal(sink.calls, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectDigestEpisodes", () => {
|
||||||
|
const at = (iso, id) => ({ id, title: id, type: "youtube", url: "", createdAt: iso });
|
||||||
|
const sessions = [
|
||||||
|
at("2026-06-14T00:00:00.000Z", "old"),
|
||||||
|
at("2026-06-15T09:00:00.000Z", "a"),
|
||||||
|
at("2026-06-15T10:00:00.000Z", "b"),
|
||||||
|
];
|
||||||
|
const watermark = new Date("2026-06-15T08:00:00.000Z").getTime();
|
||||||
|
|
||||||
|
test("keeps only recaps created after the watermark, oldest first", () => {
|
||||||
|
const { episodes, total, overflow } = selectDigestEpisodes(sessions, watermark);
|
||||||
|
assert.deepEqual(episodes.map((e) => e.id), ["a", "b"]);
|
||||||
|
assert.equal(total, 2);
|
||||||
|
assert.equal(overflow, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("caps the list and reports the overflow", () => {
|
||||||
|
const many = Array.from({ length: 13 }, (_, i) =>
|
||||||
|
at(`2026-06-15T1${i % 10}:00:00.000Z`, `e${i}`),
|
||||||
|
);
|
||||||
|
const { episodes, overflow, total } = selectDigestEpisodes(many, 0, 10);
|
||||||
|
assert.equal(episodes.length, 10);
|
||||||
|
assert.equal(overflow, 3);
|
||||||
|
assert.equal(total, 13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty / malformed-date inputs", () => {
|
||||||
|
assert.deepEqual(selectDigestEpisodes([], watermark).episodes, []);
|
||||||
|
assert.deepEqual(selectDigestEpisodes(null, watermark).episodes, []);
|
||||||
|
assert.deepEqual(
|
||||||
|
selectDigestEpisodes([at("not-a-date", "x")], 0).episodes,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("null watermark treats everything as fresh", () => {
|
||||||
|
assert.equal(selectDigestEpisodes(sessions, null).total, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nextDigestWatermark", () => {
|
||||||
|
const t = (iso) => new Date(iso).getTime();
|
||||||
|
const e1 = "2026-06-15T09:00:00.000Z";
|
||||||
|
const e2 = "2026-06-15T10:00:00.000Z";
|
||||||
|
const e3 = "2026-06-15T11:00:00.000Z";
|
||||||
|
|
||||||
|
test("all sent → newest sent createdAt", () => {
|
||||||
|
assert.equal(nextDigestWatermark([e1, e2, e3], []), t(e3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never advances past the oldest failure (so it's retried)", () => {
|
||||||
|
// sent e1 & e3, e2 failed → watermark just before e2, NOT now/e3.
|
||||||
|
assert.equal(nextDigestWatermark([e1, e3], [e2]), t(e2) - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failures newer than everything sent don't pull the watermark back", () => {
|
||||||
|
assert.equal(nextDigestWatermark([e1, e2], [e3]), t(e2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing sent → null (caller must not advance)", () => {
|
||||||
|
assert.equal(nextDigestWatermark([], [e1]), null);
|
||||||
|
assert.equal(nextDigestWatermark(null, null), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scopeForUser", () => {
|
||||||
|
test("admin keeps the owner scope; everyone else is their id", () => {
|
||||||
|
assert.equal(scopeForUser({ id: "u1", is_admin: 1 }), "owner");
|
||||||
|
assert.equal(scopeForUser({ id: "u1", is_admin: 0 }), "u1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderDigestEmail", () => {
|
||||||
|
const episodes = [
|
||||||
|
{ title: "First", type: "podcast", url: "https://x/1", overview: "Ov one." },
|
||||||
|
{ title: "Second", type: "youtube", url: "", overview: "Ov two." },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("subject reflects count; body carries overviews + unsubscribe link", () => {
|
||||||
|
const m = renderDigestEmail({
|
||||||
|
episodes,
|
||||||
|
overflowCount: 3,
|
||||||
|
manageUrl: "https://recaps.cc/",
|
||||||
|
unsubscribeUrl: "https://recaps.cc/api/digest/unsubscribe?token=tok",
|
||||||
|
});
|
||||||
|
assert.match(m.subject, /2 new recaps/);
|
||||||
|
assert.match(m.html, /Ov one\./);
|
||||||
|
assert.match(m.html, /unsubscribe\?token=tok/);
|
||||||
|
assert.match(m.html, /3 more/);
|
||||||
|
assert.match(m.text, /Unsubscribe: https:\/\/recaps\.cc/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("singular subject for one recap", () => {
|
||||||
|
const m = renderDigestEmail({
|
||||||
|
episodes: [episodes[0]],
|
||||||
|
manageUrl: "https://recaps.cc/",
|
||||||
|
unsubscribeUrl: "https://recaps.cc/u",
|
||||||
|
});
|
||||||
|
assert.match(m.subject, /1 new recap\b/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,17 @@ describe("extractVideoId", () => {
|
|||||||
assert.equal(extractVideoId("https://www.youtube.com/embed/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
assert.equal(extractVideoId("https://www.youtube.com/embed/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("extracts from /live/ URL (with ?si tracking param)", () => {
|
||||||
|
assert.equal(
|
||||||
|
extractVideoId("https://www.youtube.com/live/QEq1Fa-Br0U?si=CqlsUBpyTs_ksqi3"),
|
||||||
|
"QEq1Fa-Br0U"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts from /shorts/ URL", () => {
|
||||||
|
assert.equal(extractVideoId("https://www.youtube.com/shorts/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
||||||
|
});
|
||||||
|
|
||||||
test("accepts a bare 11-character id", () => {
|
test("accepts a bare 11-character id", () => {
|
||||||
assert.equal(extractVideoId("dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
assert.equal(extractVideoId("dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-3
@@ -14,12 +14,12 @@ export function sendEvent(res, event, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── YouTube video-id extraction ─────────────────────────────────────────────
|
// ── YouTube video-id extraction ─────────────────────────────────────────────
|
||||||
// Accepts watch URLs, youtu.be, /embed/, /v/, or a bare 11-char id.
|
// Accepts watch URLs, youtu.be, /embed/, /v/, /live/, /shorts/, or a bare
|
||||||
// Returns null when no id can be extracted.
|
// 11-char id. Returns null when no id can be extracted.
|
||||||
export function extractVideoId(url) {
|
export function extractVideoId(url) {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/live\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
||||||
/^([a-zA-Z0-9_-]{11})$/,
|
/^([a-zA-Z0-9_-]{11})$/,
|
||||||
];
|
];
|
||||||
for (const p of patterns) {
|
for (const p of patterns) {
|
||||||
|
|||||||
@@ -174,8 +174,14 @@ import { v_0_2_152 } from './v0.2.152'
|
|||||||
import { v_0_2_153 } from './v0.2.153'
|
import { v_0_2_153 } from './v0.2.153'
|
||||||
import { v_0_2_154 } from './v0.2.154'
|
import { v_0_2_154 } from './v0.2.154'
|
||||||
import { v_0_2_155 } from './v0.2.155'
|
import { v_0_2_155 } from './v0.2.155'
|
||||||
|
import { v_0_2_156 } from './v0.2.156'
|
||||||
|
import { v_0_2_157 } from './v0.2.157'
|
||||||
|
import { v_0_2_158 } from './v0.2.158'
|
||||||
|
import { v_0_2_159 } from './v0.2.159'
|
||||||
|
import { v_0_2_160 } from './v0.2.160'
|
||||||
|
import { v_0_2_161 } from './v0.2.161'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_2_155,
|
current: v_0_2_161,
|
||||||
other: [v_0_2_154, v_0_2_153, v_0_2_152, v_0_2_151, v_0_2_150, v_0_2_149, v_0_2_148, v_0_2_147, v_0_2_146, v_0_2_145, v_0_2_144, v_0_2_143, v_0_2_142, v_0_2_141, v_0_2_140, v_0_2_139, v_0_2_138, v_0_2_137, v_0_2_136, v_0_2_135, v_0_2_134, v_0_2_133, v_0_2_132, v_0_2_131, v_0_2_130, v_0_2_129, v_0_2_128, v_0_2_127, v_0_2_126, v_0_2_125, v_0_2_124, v_0_2_123, v_0_2_122, v_0_2_121, v_0_2_120, v_0_2_119, v_0_2_118, v_0_2_117, v_0_2_116, v_0_2_115, v_0_2_114, v_0_2_113, v_0_2_112, v_0_2_111, v_0_2_110, v_0_2_109, v_0_2_108, v_0_2_107, v_0_2_106, v_0_2_105, v_0_2_104, v_0_2_103, v_0_2_102, v_0_2_101, v_0_2_100, v_0_2_99, v_0_2_98, v_0_2_97, v_0_2_96, v_0_2_95, v_0_2_94, v_0_2_93, v_0_2_92, v_0_2_91, v_0_2_90, v_0_2_89, v_0_2_88, v_0_2_87, v_0_2_86, v_0_2_85, v_0_2_84, v_0_2_83, v_0_2_82, v_0_2_81, v_0_2_80, v_0_2_79, v_0_2_78, v_0_2_77, v_0_2_76, v_0_2_75, v_0_2_74, v_0_2_73, v_0_2_72, v_0_2_71, v_0_2_70, v_0_2_69, v_0_2_68, v_0_2_67, v_0_2_66, v_0_2_65, v_0_2_64, v_0_2_63, v_0_2_62, v_0_2_61, v_0_2_60, v_0_2_59, v_0_2_58, v_0_2_57, v_0_2_56, v_0_2_55, v_0_2_54, v_0_2_53, v_0_2_52, v_0_2_51, v_0_2_50, v_0_2_49, v_0_2_48, v_0_2_47, v_0_2_46, v_0_2_45, v_0_2_44, v_0_2_43, v_0_2_42, v_0_2_41, v_0_2_40, v_0_2_39, v_0_2_38, v_0_2_37, v_0_2_36, v_0_2_35, v_0_2_34, v_0_2_33, v_0_2_32, v_0_2_31, v_0_2_30, v_0_2_29, v_0_2_28, v_0_2_27, v_0_2_26, v_0_2_25, v_0_2_24, v_0_2_23, v_0_2_22, v_0_2_21, v_0_2_20, v_0_2_19, v_0_2_18, v_0_2_17, v_0_2_16, v_0_2_15, v_0_2_14, v_0_2_13, v_0_2_12, v_0_2_11, v_0_2_10, v_0_2_9, v_0_2_8, v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_18, v_0_1_17, v_0_1_16, v_0_1_15, v_0_1_14, v_0_1_13, v_0_1_12, v_0_1_11, v_0_1_10, v_0_1_9, v_0_1_8, v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0],
|
other: [v_0_2_160, v_0_2_159, v_0_2_158, v_0_2_157, v_0_2_156, v_0_2_155, v_0_2_154, v_0_2_153, v_0_2_152, v_0_2_151, v_0_2_150, v_0_2_149, v_0_2_148, v_0_2_147, v_0_2_146, v_0_2_145, v_0_2_144, v_0_2_143, v_0_2_142, v_0_2_141, v_0_2_140, v_0_2_139, v_0_2_138, v_0_2_137, v_0_2_136, v_0_2_135, v_0_2_134, v_0_2_133, v_0_2_132, v_0_2_131, v_0_2_130, v_0_2_129, v_0_2_128, v_0_2_127, v_0_2_126, v_0_2_125, v_0_2_124, v_0_2_123, v_0_2_122, v_0_2_121, v_0_2_120, v_0_2_119, v_0_2_118, v_0_2_117, v_0_2_116, v_0_2_115, v_0_2_114, v_0_2_113, v_0_2_112, v_0_2_111, v_0_2_110, v_0_2_109, v_0_2_108, v_0_2_107, v_0_2_106, v_0_2_105, v_0_2_104, v_0_2_103, v_0_2_102, v_0_2_101, v_0_2_100, v_0_2_99, v_0_2_98, v_0_2_97, v_0_2_96, v_0_2_95, v_0_2_94, v_0_2_93, v_0_2_92, v_0_2_91, v_0_2_90, v_0_2_89, v_0_2_88, v_0_2_87, v_0_2_86, v_0_2_85, v_0_2_84, v_0_2_83, v_0_2_82, v_0_2_81, v_0_2_80, v_0_2_79, v_0_2_78, v_0_2_77, v_0_2_76, v_0_2_75, v_0_2_74, v_0_2_73, v_0_2_72, v_0_2_71, v_0_2_70, v_0_2_69, v_0_2_68, v_0_2_67, v_0_2_66, v_0_2_65, v_0_2_64, v_0_2_63, v_0_2_62, v_0_2_61, v_0_2_60, v_0_2_59, v_0_2_58, v_0_2_57, v_0_2_56, v_0_2_55, v_0_2_54, v_0_2_53, v_0_2_52, v_0_2_51, v_0_2_50, v_0_2_49, v_0_2_48, v_0_2_47, v_0_2_46, v_0_2_45, v_0_2_44, v_0_2_43, v_0_2_42, v_0_2_41, v_0_2_40, v_0_2_39, v_0_2_38, v_0_2_37, v_0_2_36, v_0_2_35, v_0_2_34, v_0_2_33, v_0_2_32, v_0_2_31, v_0_2_30, v_0_2_29, v_0_2_28, v_0_2_27, v_0_2_26, v_0_2_25, v_0_2_24, v_0_2_23, v_0_2_22, v_0_2_21, v_0_2_20, v_0_2_19, v_0_2_18, v_0_2_17, v_0_2_16, v_0_2_15, v_0_2_14, v_0_2_13, v_0_2_12, v_0_2_11, v_0_2_10, v_0_2_9, v_0_2_8, v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_18, v_0_1_17, v_0_1_16, v_0_1_15, v_0_1_14, v_0_1_13, v_0_1_12, v_0_1_11, v_0_1_10, v_0_1_9, v_0_1_8, v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_156 = VersionInfo.of({
|
||||||
|
version: '0.2.156:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: 'Sign-in is more reliable on iPad/iPhone: the "Send sign-in link" button now retries a few times with growing backoff when Safari dispatches the request onto a stale connection, so it no longer shows a spurious "network error" on the first tap.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_157 = VersionInfo.of({
|
||||||
|
version: '0.2.157:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: 'Mobile/UX fixes: minimizing the video player no longer shows a black frame on expand (the iframe stays mounted instead of being rebuilt); background processing no longer interrupts podcast audio playback or jumps the transcript back to the top while a job runs; removed the redundant centered "Processing…" box (the staged progress tracker already covers it); transcript scrolling tuned for iOS.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_158 = VersionInfo.of({
|
||||||
|
version: '0.2.158:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'New: opt-in Daily Digest — a once-a-day email summarizing the recaps you added to your library in the last 24 hours, each as a short synthesized overview. Off by default; turn it on in Settings, and every email has a one-click unsubscribe.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_159 = VersionInfo.of({
|
||||||
|
version: '0.2.159:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Fix: YouTube "live" and "shorts" links (youtube.com/live/… and youtube.com/shorts/…) are now accepted — previously they were rejected as "Invalid YouTube URL".',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_160 = VersionInfo.of({
|
||||||
|
version: '0.2.160:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'New: Share page (HTML) export for YouTube recaps. The Export menu now offers a self-contained .html file with the embedded video and expandable timestamped summaries baked in — send it to anyone and they can open it with no account. On mobile it opens the native share sheet; on desktop it downloads.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_161 = VersionInfo.of({
|
||||||
|
version: '0.2.161:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: 'New: shareable HTML export for YouTube recaps — a self-contained .html file with the embedded video and expandable timestamped summaries baked in, openable by anyone with no account (native share sheet on mobile, download on desktop). Plus a design-system pass: colors, type sizes, and corner radii now follow one consistent set of tokens across the app.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user