From f9367c2ae57f6be3da530b98ce2ade72ac0c8d35 Mon Sep 17 00:00:00 2001 From: Keysat Date: Mon, 15 Jun 2026 20:10:45 -0500 Subject: [PATCH] Handoff: record operator-absorbed relay convention; condense Current state --- AGENTS.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e69f5cb..ab46572 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ vendor/keysat-licensing-client/ local-link Keysat SDK - **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`. +- **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. - **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. @@ -129,17 +130,15 @@ unsure whether a change is contract-affecting, assume it is and check. **Live on the operator's StartOS box** — app **0.2.158** + relay **0.2.126**. Tests: `cd server && npm test` → **142 pass**. -**Done & live:** self-serve Pro/Max purchase (Bitcoin inline-Lightning + Zaprite card, prepaid, relay owns tier/expiry), core-decoupling, per-tenant subscriptions, and expiry-reminder emails (`POST /api/admin/reminders/run {test_email}`). Plans in `docs/*-plan.md`. +**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}`), and **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`. Plans in `docs/*-plan.md`. -**Shipped this session (committed + pushed + verified live):** **0.2.156** — iOS sign-in "network error" flake; both sign-in paths now retry 3× with growing backoff (`91af0b7`). **0.2.157** — mobile/UX cluster: YT-minimize black-frame, podcast-audio + scroll loss on background re-render, redundant loading box, and a best-effort iOS scroll tweak (`693bb98`). Mechanics now captured as conventions above. **0.2.158** — opt-in Daily Digest, all 5 phases (`b4fa5d7`); installed + verified on the box (`start-cli package list` → `0.2.158:0`). - -**Daily Digest — LIVE on the box at 0.2.158, pending on-box smoke test** (`docs/daily-digest-plan.md`). Opt-in (off by default) once-a-day email of a user's last ~24h of library recaps, each a 1–2 paragraph overview synthesized from the recap's stored topic summaries; clones the `subscription-reminders.js` scan pattern. **All 5 phases built:** schema (`users.digest_enabled`/`last_digest_at`/`digest_unsub_token` + `migrateUserDigestPrefs`); `GET`/`POST /api/account/digest` (opt-in stamps the watermark to now) + settings-modal toggle; `server/daily-digest.js` (synthesis via `/relay/analyze`, **operator-absorbed** via operator install identity, `digestOverview` cache; `selectDigestEpisodes` watermark/cap/overflow; `runDigestScan` acts at `SEND_HOUR=8`, `MIN_RESEND_MS=20h`, advances watermark only on send, never throws; `startDigestScheduler`; public `GET /api/digest/unsubscribe?token=`); `renderDigestEmail`; `listScopeSessions`; `POST /api/admin/digest/run {test_email}`. Wired in `index.js` (multi) + `tenant-auth.js` public path. **19 digest tests, full suite 142 pass.** Verified on a real multi-mode boot (migrations apply incl. existing-DB upgrade, scheduler starts, unsubscribe 400/404/200 flips the flag end-to-end). **Q4** — `/relay/analyze` fits as-is, no relay change. **Q1** — operator-absorbed, zero operator action. **Only the relay-synthesis + SMTP path is still unverified (can't run off-box) → on-box smoke test pending** (see Pending operator actions). (Aside: relay `AGENTS.md:78` mis-describes `/relay/analyze` as `{transcript}→topic sections JSON` — stale; flagged for `../recap-relay` in the inbox.) +**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. **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 new 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. +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. **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).