Compare commits

..

50 Commits

Author SHA1 Message Date
Keysat 3bf9d37d39 Triage P3 eval findings into deferred backlog
Defer the low-severity full-eval findings (request caps, invoice-ID claim,
root container, rate-limit buckets, repo hygiene, StartOS polish, bulk doc
reconciliation) for later batching. P0/P1 and P2 live in AGENTS.md.
2026-06-15 12:30:58 -05:00
Keysat e1e2087989 Add inbox-check line; align .gitignore with canonical .claude policy
Cross-repo git-hygiene audit remediation: surface ~/Projects/standards/INBOX.md items at session start, and switch .gitignore to the deny-by-default .claude/* block (shared wiring allow-listed) plus the canonical secrets/env lines — per standards/portability.md.
2026-06-14 12:17:16 -05:00
Keysat c4ce29c647 Add full-evaluation report
Independent six-lens evaluation (evaluator, security-auditor, exerciser,
doc-auditor, start9-spec-checker). Surfaces three P0s on the cloud surface
and a registry-submission block; full priority queue in the file.
2026-06-14 09:39:46 -05:00
Keysat 3dc9727d40 Retrofit: fix stale command docs, extract relay-client guide
- Replace the Commands-table Lint/Type-check TODOs with the real, verified
  commands: `npm run check` (tsc --noEmit over startos/) and `npm run prettier`.
  There is no ESLint/linter; server/ JS is untooled.
- Move the client-side relay contract (env vars, /relay/* endpoints, X-Recap-*
  headers, file map) out of AGENTS.md into docs/guides/relay-client.md with
  paths: frontmatter, lazy-loaded via a .claude/rules symlink; AGENTS.md keeps
  a one-line pointer.
- Un-ignore .claude/rules/ so the guide auto-attaches in any clone, while
  .claude/ local state (worktrees, plans) stays ignored.
2026-06-13 14:58:05 -05:00
Keysat b2beb2c2cb Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
2026-06-13 14:25:05 -05:00
Keysat eb57b81b29 Add cross-repo change-impact convention 2026-06-13 12:18:40 -05:00
Keysat cbeaa9547f Complete client-side relay contract in AGENTS.md
Add the /relay/* endpoints the app actually calls that were omitted (capabilities, policy, tts, jobs/:id, credits/*); fix the Files attribution (add relay-capabilities.js + credits-purchase.js; the /relay/policy proxy lives in index.js only).
2026-06-13 12:02:36 -05:00
Keysat 6097d96c82 Tidy relay-internal detail; add client-side relay contract
- Add Client-side contract with the relay sub-section: env vars
  (RECAP_RELAY_BASE_URL, RECAP_RELAY_OPERATOR_KEY ↔ relay_cloud_operator_key),
  auth direction the client SENDS, the 12 /relay/* endpoints the consumer
  actually calls (verified against providers/relay.js + billing-routes.js +
  subscription-reminders.js).
- Drop two relay-internal references now canonical in ../recap-relay/AGENTS.md:
  the extendUserTier function name and the Adjacent-repo bullet's
  "Private; ships via make install only" sentence.
2026-06-13 11:13:21 -05:00
Keysat cfb7fd105a Add agent docs (AGENTS.md, ROADMAP.md, CLAUDE.md symlink) 2026-06-13 10:38:51 -05:00
Keysat b12a4e9477 Pluggable AI providers, relay credit system, picker UX overhaul
Captures roughly forty version bumps (v0.2.6 → v0.2.47) of work that
accumulated without commits.

- Pluggable provider system under server/providers/: gemini, anthropic,
  openai, openai-compatible, ollama, whisper-compatible, relay. Mix and
  match transcription + analysis per request via the picker UI.
- Relay backend integration. Hardcoded relay URL in server/relay-default.js
  (operator-controlled at build time, not user-configurable). New
  /api/relay/{status,policy} endpoints proxy to the relay; balance pings
  populate a cached credit display.
- Per-install identity in server/install-id.js for relay credit accounting.
  Sent to the relay as X-Recap-Install-Id; persists across upgrades, lost
  on a full uninstall + reinstall. Not surfaced in the UI.
- Admin login gate (server/admin-auth.js + setAdminPassword action). Scrypt
  password hash + HMAC-signed session cookie.
- Entitlement scheme rename: pro / max (each paired with subscriptions and
  relay_pro / relay_max), replacing the misleading "core" entitlement
  that conflicted with the user-facing "Core" tier name.
- Activation screen: dynamic credit count pulled from /api/relay/policy,
  "Skip — use free mode" button, accurate paid-feature list.
- Top toolbar: inline credit-balance pill (or "BYO configured" fallback),
  Upgrade + "I have a key" buttons.
- Picker UI: per-provider sections with Save/Test/Delete buttons, sections
  collapsible by chevron, default-collapsed unless currently selected,
  "Use comped credits (reset to relay)" link when the user has strayed,
  green hint under inputs whose values are server-configured.
- Activity log: chevron-collapsible groups per video, refresh-survival via
  localStorage + a 500-entry server-side buffer, explicit Clear button.
- YouTube captions fast-path with user toggle (skips audio download + AI
  transcription when captions are available — uncheck for speaker labels).
- Cancel button: AbortController plumbed through every provider SDK call;
  retryAPI short-circuits on AbortError; cancellation events surface in
  the activity log instead of silent retries.
- Long-video analysis: auto-coalesce transcript entries before building the
  analysis prompt so local-model context windows (32k-ish) don't overflow.
  Original entries preserved for transcript display via an index map; the
  analyzer sees a coarser view but click-to-seek timestamps stay precise.
- StartOS action grouping (Setup / AI Providers) so the actions list is
  navigable.
- Manifest description rewritten to reflect multi-provider support and
  free-tier relay credits.
- Smaller fixes: summarize-button enablement no longer requires a Gemini
  key when other providers are configured; analysis fallback chain handles
  context-length and 503 capacity errors; single-segment expansion for
  providers that don't return per-segment timestamps (Parakeet et al.);
  many other UX polish items.
2026-05-11 23:46:20 -05:00
Keysat 9d05775cc3 Repoint FileBrowser upload from /websites/packages to /websites/keysat-registry
Aligns the upload path with the keysat.xyz registry-domain rebrand —
the directory name now matches the registry it serves.
2026-05-09 20:03:19 -05:00
Keysat f65cf61110 Repoint deploy script defaults from satsflows.com to keysat.xyz
Both REGISTRY_URL (registry index re-index endpoint) and
REGISTRY_PUBLIC_URL (the URL embedded in the registry entry pointing
at the .s9pk download) now default to keysat.xyz. The hosts route to
the same data via StarTunnel; this just stops embedding the legacy
satsflows.com hostname in the registered package metadata.
2026-05-09 19:52:18 -05:00
Keysat c3e9c75cb4 Bump version to 0.2.5
Tighter license-poll cadence + opportunistic online refresh.
2026-05-09 19:36:47 -05:00
Keysat 6a7bf9f43b Tighten license-poll cadence; add opportunistic online refresh
Three changes that together make license state changes feel
near-instant in the UI without burning real I/O / network budget:

  1. File-poll interval: 30s → 5s
     Action-set keys (via "Set Recap License") get picked up almost
     immediately. Cost: a single stat per file every 5s, negligible.

  2. Online validation interval: 6h → 30min
     A license revoked on Keysat now flips to invalid within 30 min
     worst-case, instead of sitting unnoticed for hours. Bounded
     latency makes revocation usable in production.

  3. Opportunistic online refresh on /api/license-status
     If the cached LIC was last validated more than 10 min ago, fire
     validateOnline() in the background (non-blocking) when the web
     UI hits the status endpoint. Since the UI hits status on every
     page load, revocations get caught the next time anyone opens
     the app — usually well under the scheduled 30 min tick.

Three new env vars for tuning:
  RECAP_LICENSE_FILE_POLL_MS         (default 5000)
  RECAP_VALIDATE_INTERVAL_MS         (default 1800000)
  RECAP_OPPORTUNISTIC_REFRESH_MS     (default 600000)
2026-05-09 19:36:46 -05:00
Keysat ed7a58c94e Strip prepare/build scripts from vendored keysat-client package.json
When npm install runs in server/ and processes the file: dep at
vendor/keysat-licensing-client/, it fires the vendor's 'prepare'
script even with --ignore-scripts (npm bug for file/git deps).
The prepare script calls tsup which isn't installed in the build
container, so the build fails with 'sh: 1: tsup: not found'.

The vendored copy ships its prebuilt dist/ in the repo, so we don't
need tsup. Drop the scripts entirely.
2026-05-09 11:59:55 -05:00
Keysat c15d88a385 Bump version to 0.2.4
Critical fix for v0.2.3 startup crash (missing modules in Docker
image). Also refreshes vendored Keysat client to v0.2.0 ahead of
in-app buy flow.
2026-05-09 11:57:58 -05:00
Keysat 7492b29cea Fix Dockerfile to copy all server/*.js modules; refresh vendor to v0.2.0
The runtime crash on v0.2.3:

  Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/server/util.js'
  imported from /app/server/index.js

happened because the Dockerfile's stage-2 COPY only listed server/
index.js + server/license.js explicitly. When I started extracting
modules in v0.2.3 (util.js, gemini-helpers.js, audio.js, ytdlp.js,
cookies.js, config.js, license-middleware.js, history.js, library.js)
I forgot to update the COPY list, so those files were never copied
into the runner image. Local 'node' tests passed because the modules
exist on disk; the .s9pk container had only the two original files
and crashed on first import.

Fix:

  COPY server/*.js ./server/

Glob picks up all top-level .js files automatically, including any
future extractions, while still skipping server/test/ and server/
node_modules/. This is the simplest forward-compatible form.

Bonus: refresh the vendored @keysat/licensing-client from 0.1.0 to
0.2.0. The new SDK adds:

  • policySlug field on StartPurchaseOptions (so we can drive Core/
    Pro tier selection programmatically from our backend)
  • client.listPublicPolicies(productSlug) for fetching the tier
    cards' data without auth

Both are prerequisites for the in-app buy flow planned in
~/.claude/plans/in-app-buy-flow.md. The vendor's own node_modules
(@noble/ed25519, @noble/hashes) is gitignored as before — Docker
builds re-install via `npm install --omit=dev --ignore-scripts` in
the vendor dir during stage 1.

Also includes the license-middleware update from earlier in the day:
a 30s license-file poll so a key set via the "Set Recap License"
StartOS action is picked up within seconds (instead of waiting for
the 6h scheduled validateOnline tick).
2026-05-09 11:57:41 -05:00
Keysat 67aa9c2d49 Module split: library export/import → server/library.js
• setupLibraryRoutes(app) — registers GET /api/library/export and
                              POST /api/library/import

The library module reads through history.js helpers (getHistoryDir,
loadMeta, saveMeta) and reads/writes subscriptions.json directly.
Subscriptions integration is via raw fs because (a) the library merge
logic is library-specific (skip-if-already-exists semantics), and (b)
the subscriptions module hasn't been extracted yet — the only thing
the import path needs is to merge dedupe-by-URL into the file.

server/index.js: 2079 → 1971 lines.

Smoke tested: server boots; /api/license-status, /api/health respond;
/api/library/export still returns 402 license_required for unlicensed
(unchanged Pro-gate behavior). 69 unit tests still pass.
2026-05-09 10:39:09 -05:00
Keysat e2f4b7cf1b Add unit tests for util / gemini-helpers / license / history modules
69 tests across 16 suites, ~120 ms total. Uses node:test (built into
Node 20+) — no new dependency, no Docker rebuild churn. Run with:

  cd server && npm test

Coverage:
  • util.js               extractVideoId, formatTime,
                          parseTimestampedTranscript, safeText,
                          retryGemini (incl. 503 retry, network-error
                          retry, non-retryable passthrough), sendEvent
  • gemini-helpers.js     PRICING table integrity, calcCost (model-
                          specific rates, default fallback, missing
                          fields, sub-cent ¢ formatting, totalTokens
                          precedence), buildAnalysisPrompt
  • license.js            checkLicense (no key, malformed, fallback to
                          startos-config.json, license.txt priority),
                          activate (bad-format throw, file write),
                          deactivate (file removal, idempotent),
                          publicView (no raw key leak, sorted
                          entitlements, ISO dates), has()
  • history.js            initHistory + getHistoryDir, saveToHistory
                          (id shape, defaults, podcast guid encoding),
                          loadMeta + saveMeta round-trip, corrupt-file
                          tolerance

Tests that need module-private file paths (license, history) use a
mkdtemp'd tmp dir as DATA_DIR + dynamic import() so each suite starts
clean. No test mocks the filesystem — they read/write real files
inside the tmp dir, matching production behavior.

Deliberately not yet covered (need an Express app harness or external
binaries): license-middleware (gate behavior), config (live-reload
poll), audio (ffmpeg/ffprobe), ytdlp (yt-dlp + git), cookies (state
mutation routes), the /api/process pipeline. Worth a follow-up after
the current refactor settles.
2026-05-09 10:36:12 -05:00
Keysat 8cc020519c Bump version to 0.2.3
New 'Set Recap License' StartOS action; split server/index.js into
8 focused modules (util, gemini-helpers, audio, ytdlp, cookies,
config, license-middleware, history); 'Recap license' copy fix.
2026-05-09 07:06:36 -05:00
Keysat 1f47fef107 Add 'Set Recap License' StartOS action + s/Keysat license/Recap license/
Two related changes:

1. New StartOS action: 'Set Recap License'

   Symmetric with the existing 'Set Gemini API Key' action — paste a
   LIC1-... key into the StartOS Actions menu and it gets persisted.
   Added because some users prefer the StartOS form for credentials
   over the in-app activation modal.

   Implementation:
     • startos/file-models/config.json.ts: schema gains recap_license_key
     • startos/actions/setLicense.ts: input form (masked, regex-checks
       for the LIC1- prefix), persists via configFile.merge()
     • startos/actions/index.ts: registers the new action
     • server/license.js: readLicenseString() falls back to
       startos-config.json after the legacy license.txt path. Resolution
       order: env → license.txt → startos-config.json
     • server/license-middleware.js: faster license-file poll (30 s,
       env-overridable RECAP_LICENSE_FILE_POLL_MS) re-runs checkLicense
       so action-set keys take effect within seconds, not the 6 h online
       cycle. If the new key parses as 'licensed', kicks an immediate
       online check to confirm.

2. Copy fix: 'Keysat license' → 'Recap license' in user-facing text

   Keysat is the licensing system underneath, but customers buy a
   'Recap license'. Updated:
     • Activation screen subtitle (public/index.html)
     • 402 message in the activation gate (server/license-middleware.js)

   Internal references (PRODUCT_SLUG, KEYSAT_BASE_URL, the issuer.pub
   filename, the 'Issuer: licensing.keysat.xyz' display in the
   activation card) stay as Keysat — those are accurate.

Smoke tested locally: starting the server with no license, then
writing a fake LIC1-... key into startos-config.json, the
license-file poll picks it up within ~2 s and transitions state from
'unlicensed' to 'invalid' (since the fake key fails Ed25519
verification, as expected). With a real key, the same path would land
in 'licensed'.
2026-05-09 07:06:21 -05:00
Keysat 4b1ffcf3ac Module split: history storage + meta + 9 routes → server/history.js
• initHistory({ dataDir })          — boot setup; mkdir + path init
  • saveToHistory(...)                — write one summary file
  • loadMeta() / saveMeta(meta)       — _meta.json folder structure
  • setupHistoryRoutes(app, deps)     — registers GET /api/history,
                                        GET/PUT/DELETE /api/history/:id,
                                        PUT /api/history/:id/title,
                                        PUT /api/history/meta,
                                        POST/PUT/DELETE /api/history/folders[/:id],
                                        PUT /api/history/folders/:id/collapsed,
                                        PUT /api/history/move
  • getHistoryDir()                   — exposes the directory for code
                                        that hasn't been extracted yet
                                        (subscriptions / library / process
                                        pipeline)

The DELETE route needs to add the deleted videoId to the skip list so
subscriptions don't re-queue it. That's a cross-module concern, so
setupHistoryRoutes takes addToSkipList as a dependency. For now it's
late-bound to the still-local function in index.js (lambda captures
the scope, not the value); when subscriptions are extracted next, the
import flips cleanly.

server/index.js: 2300 → 2079 lines.

Smoke tested: /api/license-status, /api/health, and /api/history (402
license-gated for unlicensed) all respond as expected.
2026-05-08 17:09:10 -05:00
Keysat 596995c13e Module split: license gate + Pro gates + license routes → server/license-middleware.js
• LIC                              — exported live binding (ESM)
  • setupLicenseMiddleware(app)      — registers activation gate + Pro
                                       feature gates (must run before any
                                       /api/* route)
  • setupLicenseRoutes(app)          — /api/license-status, /api/license/
                                       activate, /api/license/deactivate
  • startLicenseRefresh()            — startup + 6h periodic online check
  • refreshLicenseOnline(reason)     — ad-hoc refresh (e.g., during activate)
  • isFreeUser()                     — 'no license || no core entitlement'
  • tryAcquireFreeSlot() / releaseFreeSlot() — the free-tier concurrency
                                                 lock previously open-coded
                                                 in /api/process

Local 'const isFreeUser = ...' in /api/process renamed to 'isFree' to
avoid shadowing the imported helper. Open-coded freeJobInFlight reads/
writes replaced with the slot helpers.

server/index.js: 2461 → 2300 lines.

Smoke tested: server boots; /api/license-status, /api/health, /api/
process (rejects with 400 'No API key' as expected for unlicensed +
no key) all behave as before.
2026-05-08 17:05:35 -05:00
Keysat e0736245ec Module split: extract API key + live reload to server/config.js
• initConfig({ dataDir })           — boot-time setup; mkdir's configDir,
                                        reads initial value, kicks off the
                                        3 s poll loop
  • serverApiKey                      — exported as a 'let' binding (ESM
                                        live binding) so importers see the
                                        current value
  • resolveApiKey(clientKey)          — picks per-request key (BYO vs
                                        server)
  • getEnvPath()                      — exposes /data/.env path so the
                                        cookies module can read its own
                                        legacy YT_COOKIES_FROM setting

Bug fix uncovered during this extraction: /api/health was directly
referencing ytCookiesFileExists / ytCookiesFilePath (module-scoped
vars I'd already moved to cookies.js). The route silently 500'd on the
first request after the cookies extraction. Now uses ytCookieMethod()
and getCookieFilePath() instead.

server/index.js: 2510 → 2461 lines.

Smoke tested: server boots; /api/health responds; updating
/data/config/startos-config.json flips hasServerKey from false → true
within 3 s ([config] server API key loaded log line confirms).
2026-05-08 17:01:45 -05:00
Keysat c26d44a783 Module split: extract cookies state + helpers + routes to server/cookies.js
• initCookies({ dataDir, envPath })  — boot-time setup; reads .env's
                                         YT_COOKIES_FROM and probes for
                                         cookies.txt
  • ytCookieArgs() / ytExtraArgs()     — yt-dlp arg builders
  • ytCookieMethod()                   — human-readable active method
  • setupCookieRoutes(app)             — registers the four /api/cookies/*
                                         routes (upload / delete / test /
                                         status)

Module owns its private state (browser-name, file-exists, file-path).
Upload and delete routes flip the file-exists flag inside the same
module, so subsequent yt-dlp calls reflect the change immediately
without callers re-reading.

server/index.js: 2614 → 2510 lines.

Smoke tested: server boots; /api/license-status and /api/health
respond. /api/cookies/status returns the existing license-gated 402
for unlicensed callers (unchanged behavior).
2026-05-08 16:57:03 -05:00
Keysat 8aa005ed01 Module split: extract yt-dlp lifecycle helpers to server/ytdlp.js
• checkYtdlp()                  — version + GitHub-latest check (24h-cached)
  • autoUpdateYtdlp(dataDir)      — multi-strategy update (-U, pip, brew, binary)

Module-private memoization (ytdlpVersion, ytdlpLastCheck) now stays
inside ytdlp.js where it belongs. autoUpdateYtdlp gained an explicit
dataDir parameter — strategy 4 (StartOS binary download to /data/bin/)
needs it; passing it in keeps the module pure of caller-side state.

server/index.js: 2694 → 2614 lines.

Smoke tested: server boots; /api/license-status, /api/health respond.
No behavior change.
2026-05-08 16:54:51 -05:00
Keysat f84ee71efe Module split: extract audio I/O helpers to server/audio.js
• getAudioDuration(path)         — ffprobe wrapper, returns seconds | null
  • splitAudioFile(in, dir, secs)  — ffmpeg -acodec copy chunking
  • downloadPodcastAudio(url, dst) — streams HTTP audio to disk

Also moved fetchUrl into util.js (alongside the other stateless
helpers) — it's a generic HTTP-GET-with-redirects used by RSS parsing
and channel discovery, not strictly audio.

server/index.js: 2758 → 2694 lines.

Smoke tested: server boots; /api/license-status, /api/health, /
respond. No behavior change.
2026-05-08 16:53:06 -05:00
Keysat f5ab82ba8e Module split: extract Gemini-specific helpers to server/gemini-helpers.js
• PRICING table             — per-1M-token rates by model
  • calcCost(model, usage)    — Gemini usage object → cost record
  • buildAnalysisPrompt(...)  — JSON-output topic-analysis prompt

These all share the Gemini contract — pricing schema, usage shape, and
prompt format. When we add other providers, each gets its own
provider-specific helpers file; this becomes the basis of the Gemini
provider implementation.

server/index.js: 2828 → 2758 lines.

Smoke tested: server boots; /api/license-status, /api/health, and /
(frontend) all respond. No behavior change.
2026-05-08 16:50:34 -05:00
Keysat 0e512623ba Module split: extract pure helpers to server/util.js
First step of breaking up the 2914-line server/index.js. Pulled out the
zero-state, no-side-effects helpers:

  • sendEvent(res, event, data)         — writes one SSE frame
  • extractVideoId(url)                 — YouTube URL → 11-char id
  • formatTime(seconds)                 — seconds → 'M:SS' or 'H:MM:SS'
  • parseTimestampedTranscript(text)    — Gemini transcript text → entries[]
  • safeText(result)                    — robust .text getter for Gemini responses
  • retryGemini(fn, opts)               — 503/429/network retry with linear backoff

server/index.js: 2914 → 2828 lines.
server/util.js : new, 113 lines.

Smoke tested: server boots, /api/license-status responds. No behavior
change.
2026-05-08 16:48:40 -05:00
Keysat 01a3beea73 Bump version to 0.2.2
Live-reload of the Gemini API key, and fix a startup-crash bug in
0.2.0 / 0.2.1 where the vendored keysat client could not resolve its
@noble/* deps inside the running container.
2026-05-08 16:38:44 -05:00
Keysat 65ae853bc4 Live-reload Gemini API key config + fix vendor module resolution
Two related changes that ship together because the second was uncovered
while testing the first.

1. Live config reload (the ostensible feature):

   The "Set Gemini API Key" StartOS action writes to /data/config/
   startos-config.json. The server used to read that file once at
   startup (and via a separate Python read in docker_entrypoint.sh
   before that), which meant a key change required a service restart
   to take effect. Now the server polls the file every 3 s
   (RECAP_CONFIG_POLL_MS, env-overridable) and updates serverApiKey
   in place. fs.watch was tried first and dropped — it's flaky on
   macOS (FSEvents single-file quirks) and behaves inconsistently with
   atomic-rename writes the SDK file model uses. Polling is dead
   simple and a stat call every 3 s is free.

   Also dropped the Python config read from docker_entrypoint.sh; the
   server now handles it natively. Entrypoint still loads /data/.env
   for arbitrary env vars (RECAP_*, etc.).

2. Vendor module resolution (the silently-broken thing):

   The earlier vendor change (move @keysat/licensing-client from a
   git+https dep to a file: dep at vendor/) created a symlink in
   server/node_modules. That symlink to the vendor dir was getting
   resolved by Node, so the keysat client tried to import @noble/
   ed25519 from /app/vendor/keysat-licensing-client/dist/, walked up
   to /app/vendor/, then /app/, neither of which had node_modules.

   Result: v0.2.0 and v0.2.1 would crash at startup with
   ERR_MODULE_NOT_FOUND on @noble/ed25519. The Docker BUILD succeeded
   because npm install with file: deps doesn't pull transitive deps
   into the parent node_modules — but the runtime would have failed
   the moment server/license.js ran.

   Fix:
     • Dockerfile builder now `npm install`s inside vendor/keysat-
       licensing-client/ so @noble/* lands in its own node_modules,
       where Node's resolver finds it.
     • Dockerfile runner now COPYs vendor/ to the runner image
       (previously not copied — the symlink in server/node_modules
       would have pointed at nothing).
     • vendor/keysat-licensing-client/package-lock.json is committed
       so the in-Docker install is reproducible.
2026-05-08 16:38:33 -05:00
Keysat 8a2d914965 Bump version to 0.2.1
Reword short description on the registry card.
2026-05-08 14:13:44 -05:00
Keysat a2f9520db8 Reword short description to lead with the output, not the tech
Old: Download, transcribe, and summarize YouTube videos and podcasts
     with AI.
New: Turn videos and podcasts into structured topic summaries with
     clickable timestamps.

Updated in both the StartOS manifest (startos/manifest/i18n.ts) and
the registry card (startos-registry/packages/recap/package.json).
2026-05-08 14:13:44 -05:00
Keysat 0131a345d2 Vendor @keysat/licensing-client to avoid private-repo auth in Docker build
The keysat-client-ts repo is private. Previous builds were succeeding
purely because Docker layer caching reused a node_modules from when
the repo had been accessible — once anything invalidated the
server/package.json or server/package-lock.json hash (the rename did),
npm in a fresh container hit github with no credentials and 404'd.

Fix: copy the built dist/ from server/node_modules/@keysat/licensing-
client/ into vendor/keysat-licensing-client/, strip the prepare/build
scripts (we already have the compiled output), and switch the server
package.json dep to a file: path:

  "@keysat/licensing-client": "file:../vendor/keysat-licensing-client"

Dockerfile now COPY's vendor/ before npm ci. No git, no SSH, no
credentials needed in the build container — and the npm step is
pure-local so it's deterministic.

Side cleanup: dropped the apt-install-git + url.insteadOf gymnastics
that existed solely to work around the now-removed git+https resolution.
The image is slightly smaller (no git in the builder stage). Switched
the npm flag to the modern --omit=dev (the legacy --production printed
a warning).

If keysat-client-ts updates, regenerate vendor/ by:

  cp -r server/node_modules/@keysat/licensing-client/{dist,package.json,LICENSE,README.md} \
        vendor/keysat-licensing-client/
  # then strip prepare/build scripts and devDeps from the copied package.json
  # (or just hand-edit if the upstream package.json hasn't changed)
2026-05-08 13:45:12 -05:00
Keysat a1e9db92f1 Bump version to 0.2.0
Renamed to Recap. New StartOS package id, new Keysat product slug,
new display name everywhere user-visible. StartOS will treat this as
a fresh install.
2026-05-08 13:35:47 -05:00
Keysat 1995cc0ea0 Rename project: youtube-summarizer → recap
The product was always more than YouTube — it handles podcast feeds
too, and the upcoming multi-provider work makes it less Gemini-
specific. New name: Recap.

This is a coordinated identity change across:

  • StartOS package id: youtube-summarizer → recap
    (manifest.id; the .s9pk filename, Docker image namespace, and
    install path under StartOS all derive from this automatically)
  • Display name: "YouTube Summarizer" → "Recap"
    (manifest title, activation screen heading, page <title>, console
    log on boot, i18n strings, ABOUT.md, Dockerfile header,
    docker_entrypoint banner)
  • Keysat product slug: youtube-summarizer → recap
    (server/license.js PRODUCT_SLUG; frontend fallback strings)
  • Daemon subscription id: youtube-summarizer-sub → recap-sub
  • Env var prefix: YT_SUMMARIZER_* → RECAP_*
    (LICENSE_KEY, LICENSE_KEY_PATH, MAX_OFFLINE_DAYS,
    VALIDATE_INTERVAL_MS)
  • localStorage keys: yt-summarizer-* → recap-*
    (gemini-key, activation-skipped, clips)
  • Library export filename: youtube-summarizer-library.json →
    recap-library.json
  • npm package names: youtube-summarizer-{startos,server} → recap-*
  • Deploy paths: youtube-summarizer_x86_64.s9pk → recap_x86_64.s9pk
    (default values in bin/deploy.sh; .deploy.env on dev machine
    needs the same update before next push)
  • Self-hosted registry directory: startos-registry/packages/
    youtube-summarizer → .../recap (with package.json + INSTRUCTIONS
    rewritten)

What does NOT change:
  • Filesystem repo path (still /Users/.../youtube-summarizer/)
  • Git history / commit messages
  • Existing version files in startos/versions/ (kept as-is — the
    version chain belongs to the package's own history regardless of
    its display name)

User-side follow-ups required:
  1. Create "recap" product in Keysat admin, set up Core/Pro tier
     policies (same entitlements as before), mint a fresh test
     license. Old "youtube-summarizer" licenses won't activate
     against the new slug.
  2. Update .deploy.env (gitignored) so FILEBROWSER_PATH and
     REGISTRY_PUBLIC_URL point at recap_x86_64.s9pk.

StartOS will treat this as a brand-new app on install — existing
youtube-summarizer installs will not auto-migrate (acknowledged
intentional given no real users yet).
2026-05-08 13:35:27 -05:00
Keysat faea58124e Bump version to 0.1.18
Fix Settings modal failing to open for licensed users.
2026-05-08 13:00:29 -05:00
Keysat dc66902348 Fix Settings modal crash: send licenseId as string, not Uint8Array
The Keysat client's payload exposes both:
  • licenseId    — raw 16-byte UUID as Uint8Array
  • licenseUuid  — same value, canonical string form

server/license.js was sending licenseId (the Uint8Array). After JSON
serialization that turned into an object like {"0":1,"1":2,...} on
the wire. The frontend's renderLicenseBlock() calls
`lic.licenseId.slice(0, 8)` to abbreviate the ID for display — .slice
doesn't exist on that object, so the template threw, the
app.innerHTML assignment silently aborted, and clicking the gear
looked like a no-op.

The render error guard added in 0.1.17 caught it on first repro:
  Render error: lic.licenseId.slice is not a function

Fix: switch to payload.licenseUuid (the string form).
2026-05-08 13:00:29 -05:00
Keysat b8f77bd9fb Drop transcription/analysis cost lines from logs
The cost breakdowns (token counts + dollar figures) are tied to the
hardcoded Gemini PRICING table, which won't make sense once we add
OpenAI/Claude/local providers — different APIs report tokens
differently, some are free, and the pricing table can't keep up. Drop
the cost and token-count lines from the activity log so the per-step
timing is the durable signal we keep.

Removed:
  • "Total cost: <in> in / <out> out — cost: $X" (chunked transcription)
  • "Transcription tokens: <in>/<out> — cost: $X" (single-shot)
  • "Analysis tokens: <in>/<out>/<thinking> — cost: $X"
  • "Pipeline finished in Ns — total cost: $X (Y tokens)" → now just
    "Pipeline finished in Ns"

Cost calculation helpers (calcCost, PRICING) stay in the codebase for
now — they may come back via a per-provider plugin layer later. They
just no longer write to the activity-log stream.
2026-05-08 12:50:10 -05:00
Keysat f7ea5c5bc6 Bump version to 0.1.17
See pending notes
2026-05-08 12:26:53 -05:00
Keysat 004b39b574 UI: persistent upgrade banner; surface render errors
Two bugs reported in 0.1.16 testing:

1. Library button shows a toast "Library is a paid feature ... Tap
   upgrade to unlock" but no Upgrade button is visible anywhere on the
   page. The free-tier banner only rendered when isLicensed() was false,
   so a licensed-but-missing-entitlements user got the toast directing
   them at an invisible button.

2. Clicking the gear (Settings) icon does nothing — the modal never
   appears. The Activity Log button works on the same page.

Fix 1: replace the unlicensed-only renderFreeBanner with renderUpgrade
       Banner driven by shouldShowUpgradeBanner() = !isProTier(). Copy
       adapts by tier:
         • Free: "Free mode · one video at a time · no library, no subs"
         • Limited license (missing core entitlements): "Limited license
           — your license is missing some Core features"
         • Core tier: "Core tier — upgrade to Pro for subs, auto-queue,
           clips"
       Pro tier shows nothing.

Fix 1b: rewordtoasts on Library / Subscribe clicks to drop the
        "tap Upgrade" instruction — the persistent banner is the action
        path now, no need to direct users at a maybe-not-there button.

Fix 2 (diagnostic): wrap the main app.innerHTML build in try/catch.
       A thrown exception in any of the ${...} template substitutions
       silently aborts the innerHTML assignment, leaving the previous
       DOM in place — which looks exactly like a button doing nothing
       when it actually fired and called render(). Now an exception
       lands in a visible error-box with the message + console trace,
       so the next reproduction tells us what's actually throwing.
2026-05-08 12:26:29 -05:00
Keysat 6796c57542 Bump version to 0.1.16
Release notes:
  Add free mode for unlicensed users (one video at a time, no library).
  Online license revocation check via Keysat /v1/validate.
  Fix blank-screen bug after processing long videos.
  Ship deploy-script polish (no double-bump prompt).
2026-05-08 11:34:28 -05:00
Keysat 18912a2693 Free tier: drop spurious BYO key gate; clarify bundled vs BYO
The previous free-tier commit (c0975fe) blocked USE_SERVER_KEY for
unlicensed users on the theory that this protected a "bundled key."
That conflated two different things:

  • USE_SERVER_KEY = the user's OWN Gemini key, just stored server-side
    via the StartOS configuration action (vs. browser localStorage).
    Both paths are BYO — the user pays Google directly either way.

  • Bundled key = a future relay where paid users' /api/process requests
    are proxied through the operator's service and the operator absorbs
    the API cost. Sketched in UPGRADE-DESIGN.md (deleted, in git history)
    but NOT YET BUILT.

Blocking USE_SERVER_KEY broke a legitimate flow: a free user installs
the app on their own StartOS, sets their Gemini key via the config
action, then summarizes from the web UI without re-entering it.

This commit:
  • Drops the BYO/USE_SERVER_KEY rejection in /api/process. Free users
    can use a key from either path; the existing `if (!apiKey)` check
    still catches the no-key-anywhere case with a helpful message.
  • Reverts the frontend submit-button and handleSubmit checks to the
    same key requirement for both tiers (state.apiKey OR state.hasServerKey).
  • Drops "bundled API key" from the activation-screen subtitle and
    "bring your own Gemini key" from the free-mode banner. Until the
    relay is built, paid users still BYO too — promising otherwise in
    upgrade copy is misleading.
  • Keeps the parts that ARE legitimate free-vs-paid differentiators:
    the one-at-a-time concurrency lock and skipping saveToHistory.

Also fixes the `make deploy` redundancy:
  • bin/bump-version.sh accepts --from-deploy. When set, if there is no
    .release-notes-pending.txt (consumed by a prior bump or never
    written), exit 0 without prompting — the current version is already
    fresh.
  • Makefile passes --from-deploy from the deploy target. Standalone
    `make bump` is unchanged (always prompts).

Result: `make bump` then `make deploy` no longer double-prompts. And
calling `make deploy` twice in a row (no new work) is idempotent on
the bump step.
2026-05-08 11:32:30 -05:00
Keysat 8cf2393db0 Add free tier (unlicensed users get one-at-a-time summarization)
Unlicensed users can now summarize a single video at a time using their
own Gemini API key. The result renders in the UI exactly like a paid
summary, but is not persisted — there's no library entry, no history,
and a second submission while one is in flight is rejected.

Server (server/index.js):
  • /api/process is now in LICENSE_OPEN_PATHS. The route handler
    distinguishes free users (state !== "licensed" || no "core") and:
      - rejects USE_SERVER_KEY / empty key with 402 byo_key_required
        (so the bundled Gemini key stays paid-only)
      - rejects a second concurrent job with 409 processing_in_progress
        via a module-level freeJobInFlight flag, released in finally
      - skips saveToHistory so the host's library stays clean
  • Pro feature gates (history/library/subscriptions) unchanged — still
    return 402 feature_not_in_tier for unlicensed callers.

Frontend (public/index.html):
  • New state.activationSkipped flag (persisted to localStorage). The
    activation screen still appears on first launch, but now offers a
    "Skip — use free mode" button alongside Activate / Buy a key.
    Once skipped, the main app renders normally.
  • Free-mode upgrade banner under the top bar with Upgrade and "I have
    a key" buttons (the latter routes back to the activation screen).
  • handleLibraryClick / handleSubscribeClick wrappers — for unlicensed
    users, the library (clock) icon and the channel-URL Subscribe
    submission show a toast explaining the upgrade rather than opening
    an empty sidebar / hitting a 402.
  • Submit button enforces BYO key for unlicensed users (the bundled
    state.hasServerKey doesn't enable submit). handleSubmit shows a
    toast when an unlicensed user tries to queue a second video.
2026-05-08 11:16:02 -05:00
Keysat 22bf5eaf78 Fix SSE event type lost across reader chunks (blank screen post-process)
Symptom: after a long video finishes processing, the screen goes blank
even though the video and chunks save correctly to history (visible
after a refresh + library click).

Cause: the manual SSE parser in processUrl declared `let eventType = ""`
inside the `while (await reader.read())` loop, so the variable reset on
every chunk. The server emits each event as a single write of
`event: X\ndata: Y\n\n`, but for a long video the result payload (entries
+ chunks + logs) is tens of KB and gets split across reader chunks by
the browser. When the split landed between the `event: result\n` line
and the `data: ...` line, the event type was lost and `handleSSE("",
data)` matched no branch — the result event was silently dropped, and
state.chunks stayed at the empty array set during processUrl reset.

Fix: hoist eventType outside the loop so it persists across chunks, and
reset it after each dispatch (per the SSE spec, event type returns to
default after an event is fired).

Short videos with small result payloads fit in a single chunk and so
were unaffected — which is why the bug looked intermittent.
2026-05-08 11:04:50 -05:00
Keysat 3390748e47 Add online license revocation check (Keysat /v1/validate)
Without this, a license revoked in the Keysat admin UI keeps unlocking
the app on the customer's machine — Ed25519 signatures are perpetually
valid, so the offline-only check never sees the revocation.

What this adds:

  • license.js: validateOnline() calls licensing.keysat.xyz/v1/validate
    via @keysat/licensing-client's Client. Hard rejections (revoked,
    suspended, expired, not_found, product_mismatch, fingerprint_mismatch,
    too_many_machines, invalid_state) immediately flip state to "invalid"
    and persist the verdict to <license>.state.json so it survives
    restarts. rate_limited and unknown reasons are treated as transient.

  • Network errors keep the prior state for up to MAX_OFFLINE_DAYS
    (default 7, env-overridable) since the last successful validate.
    Past the ceiling, lock out with reason=validation_overdue. This
    avoids breaking customers when Keysat is briefly down while still
    catching revocations on machines that go offline forever.

  • license.js: deactivate() helper that removes both license.txt and
    its sidecar state file (idempotent). publicView() now exposes
    lastValidatedAt, serverStatus, graceUntil for the UI.

  • index.js: refreshLicenseOnline() runs on startup (async, non-
    blocking), every 6h thereafter (env-overridable), and at activation
    time with an 8s timeout cap so a slow Keysat doesn't hang the
    activation UI. State changes are logged.

  • index.js: /api/license/activate now awaits an online confirmation
    after the offline signature check passes. A revoked key pasted into
    the activation modal fails fast instead of working until the next
    poll.
2026-05-08 10:39:11 -05:00
Keysat 525c4fdd8a Clean up legacy 0.3.5 scaffolding and standalone-mode artifacts
- Removes start9/0.4/ — the StartOS 0.3.5-style YAML manifest folder,
  superseded by the TypeScript-based startos/ package.
- Removes pre-StartOS standalone-mode artifacts: setup.sh, create-app.sh,
  Start Summarizer.command, build-guide-pdf.py, GET-STARTED.md/.pdf.
- Removes conversion-era design docs (START9_PACKAGING_GAMEPLAN.md,
  UPGRADE-DESIGN.md, KEYSAT_INTEGRATION.md). Recoverable from history if
  needed.
- Tightens .gitignore: untracks node_modules, javascript/ build output,
  *.s9pk, history/ user data, cookies.txt, library export, .env, .DS_Store,
  and .claude/ worktree state. Files remain on disk; just removed from
  the index.
2026-05-08 09:41:42 -05:00
Keysat 41e4a2b3be Save in-progress keysat integration and StartOS 0.4 work
Snapshot of the working tree before cleanup. Captures:
- Keysat licensing: server/license.js, /api/license/* endpoints in
  server/index.js, activation modal in public/index.html, embedded
  Ed25519 issuer key (assets/issuer.pub).
- StartOS 0.4 expansion: setApiKey action, version files v0.1.1
  through v0.1.15, file-models/config.json.ts, manifest updates.
- Self-hosted registry server (startos-registry/).
- Build/deploy scripts (bin/bump-version.sh, bin/deploy.sh, vendored
  yt-dlp binary), .gitignore, .deploy.env.example.
- Recent design docs (KEYSAT_INTEGRATION.md, UPGRADE-DESIGN.md) —
  retained here so they remain recoverable when removed in the
  follow-up cleanup commit.
2026-05-08 09:39:17 -05:00
MacPro 0b70cbb2bf Fix StartOS 0.4 TypeScript packaging to match SDK API 2026-04-09 15:10:44 -05:00
MacPro d5046a0daf Add StartOS 0.4.0 packaging 2026-04-09 15:03:31 -05:00
36 changed files with 771 additions and 3144 deletions
-3
View File
@@ -19,9 +19,6 @@ image.tar
# Runtime / user data — must never be committed
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
*.txt.bak
youtube-summarizer-library-export*.json
+36 -25
View File
@@ -5,12 +5,6 @@ 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
> 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
- **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.
@@ -36,7 +30,7 @@ Run from repo root unless noted.
| Type-check (StartOS TS) | `npm run check` *(repo root; runs `tsc --noEmit` over `startos/**/*.ts`. The `server/` is plain JS and is not type-checked.)* |
| Format (StartOS TS) | `npm run prettier` *(repo root; `prettier --write startos`. There is **no** ESLint/linter — `server/` JS is untooled. Many `startos/versions/*.ts` are currently unformatted.)* |
Mode is selected at boot via the `RECAP_MODE` env var: `single` (default) or `multi`. Other runtime env var of note: `RECAP_TRUSTED_PROXY_HOPS` (default `1`) — how many trusted reverse proxies sit in front of the app, so the anonymous-trial per-IP cap reads the real client IP from `X-Forwarded-For` (set `0` if the app is directly internet-facing, `2`+ behind a CDN/LB; setting it too high re-opens the trial-cap bypass).
Mode is selected at boot via the `RECAP_MODE` env var: `single` (default) or `multi`.
## Directory layout
@@ -85,16 +79,10 @@ 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.
- **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.
- **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).
- **`safeFilename()` already exists** for any user-content → on-disk path. Use it; don't roll your own.
- **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.
- **`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
@@ -136,19 +124,42 @@ unsure whether a change is contract-affecting, assume it is and check.
## Current state
**Live on the operator's StartOS box** app **0.2.161** + relay **0.2.126**. Tests: `cd server && npm test` → **144 pass**.
**Live on the operator's StartOS box** (app **0.2.155** + relay **0.2.124**, installed 2026-06-09):
**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`.
- **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).
- **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.
**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`).
**Pending real-world tests (operator):**
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.
**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.
### Evaluation work queue (P0/P1) — from the 2026-06-14 full-eval (`EVALUATION.md`)
**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.
Fix before exposing the cloud to untrusted users. The P0s are reproduced/verified, not theoretical.
**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] Arbitrary file write — `POST /api/library/import`.** A `../../` session key escapes the scope dir (reproduced writing `/tmp/rce_test.json`). `server/library.js:131-139` uses the key as a filename without `safeFilename()`. Fix: export `safeFilename()` from `history.js` (it's currently module-private — see `:656`) and validate the key here plus in the array-import and `PUT /api/history/move` paths.
- **[P0] SSRF with read-back — podcast download.** An anonymous trial can POST `{type:"podcast", url:"http://169.254.169.254/..."}`; `downloadPodcastAudio` (`server/audio.js:78-97`, reached from `index.js:3455`) does an unguarded `http.get`, follows redirects to any host, and the body is transcribed back to the attacker. Fix: reject private/link-local/loopback/reserved IPs, https-only, re-validate on each redirect, add a size/time cap.
- **[P0] Live Gemini key in git history, still the active key** — `git show d5046a0:.env`, pushed to `origin/master`. Rotate the key in Google AI Studio now; then purge from history (BFG/filter-repo) and force-push.
- **[P1] ESM `require("crypto")` throws** `ReferenceError` on the anon license-purchase settle path — `server/license-purchase.js:423` (called from `:353-354`). Import `randomBytes`/`randomUUID` at the top of the file.
- **[P1] Global `currentFreeJob` lock serializes the entire multi-tenant cloud.** `isFreeUser()` returns true for every tenant in multi-mode, so a 2nd concurrent `/api/process` from any user gets `409`. `server/index.js:2621`, `license-middleware.js:143`. Scope the slot per-identity, or skip it in multi-mode.
- **[P1] Trial IP-cap + magic-link rate-limit bypass via spoofed `X-Forwarded-For`** (no `trust proxy` / XFF normalization) — `server/anon-trial.js:50-57`, `auth-routes.js:209`. Deployment-dependent: confirm the recaps.cc edge proxy *overwrites* XFF and trust only the last hop. Self-hosted StartOS is unaffected.
- **[P1] StartOS registry submission BLOCKED** (3 blockers) — missing root `instructions.md`; `packageRepo`/`upstreamRepo` point to `https://ten31.xyz` (a homepage, not a source repo); `license: 'Proprietary'` fails the "source available" gate (`startos/manifest/index.ts`). **Only blocks a community-registry submission — does NOT affect `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).
+1 -55
View File
@@ -11,60 +11,6 @@ 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`).
- **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.
- **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`)
@@ -75,7 +21,7 @@ Low-severity; batch when convenient. None block release. (P0/P1 work queue and P
- **Container runs as root** (no `USER` in `Dockerfile`) — acceptable under StartOS isolation; add a non-root user for the cloud image.
- **In-memory auth rate-limit buckets reset on restart** (`server/auth-routes.js:106`) — fine for self-host single-operator; note for cloud HA.
- **Repo hygiene.** Delete the stale `youtube-summarizer_x86_64.s9pk` (~223MB, old package ID) and rename the root `package.json` (still `youtube-summarizer-startos`). `cookies.txt` is sensitive plaintext in the repo root and is expiring (`/api/health` already reports `fileExpiring:true`) — gitignored, but rotate/move it.
- **StartOS community-registry submission — deferred (decision 2026-06-15: self-host + cloud only for now).** Hard blockers if/when we submit: add a root `instructions.md`; point `packageRepo`/`upstreamRepo` at a public source repo (currently `https://ten31.xyz`, a homepage); choose a source-available license for the wrapper (currently `Proprietary`). Softer polish: empty `manifest.docsUrls`; verify the multi-tenant cloud actions (`enableMultiTenantMode` et al., `startos/actions/index.ts`) run cleanly — not stack-trace — in single mode; 172 empty version-file migration stubs are a growing maintenance surface. None of this affects `make install`.
- **StartOS packaging polish** (only if submitting to the registry): empty `manifest.docsUrls`; verify the multi-tenant cloud actions (`enableMultiTenantMode` et al., `startos/actions/index.ts`) run cleanly — not stack-trace — in single mode; 172 empty version-file migration stubs are a growing maintenance surface.
- **Doc reconciliation (bulk).** AGENTS.md directory layout omits ~25 server modules; `docs/guides/relay-client.md:17` Authorization header is missing the `Bearer` scheme; `index.html` is stated as `~10k` lines but is 12.5k (`AGENTS.md:51`); the `safeFilename()` convention (`AGENTS.md:79`) becomes accurate once the function is exported (the P0 fix).
## Larger plans (already drafted in `docs/`)
-213
View File
@@ -1,213 +0,0 @@
# 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`.
- 1011 — 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.
- 2022 — 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.31.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.060.08em`; one display title tightens
`-0.01em`.
## 4. Component styling
- **Buttons.** *Primary:* filled `#818cf8`, white text, radius 810px, 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 1014px; 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 616px.
- **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(46px)`.
- 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 — 1213px body, 1011px 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 4448px; 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.
-17
View File
@@ -1,17 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 11 KiB

-18
View File
@@ -1,18 +0,0 @@
# 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.
-194
View File
@@ -1,194 +0,0 @@
{
"$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 AH. 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; 46px range." }
}
}
-145
View File
@@ -1,145 +0,0 @@
# 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 12
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 12 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 12 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 12 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 12 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.
+41 -68
View File
@@ -30,31 +30,12 @@
<link rel="icon" type="image/png" href="/assets/icon.png">
<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; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
background: #0a0e1a;
color: #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
@@ -63,8 +44,8 @@
.card {
width: 100%;
max-width: 420px;
background: var(--surface);
border: 1px solid var(--border);
background: #121828;
border: 1px solid #1f2942;
border-radius: 12px;
padding: 32px 28px;
}
@@ -75,65 +56,65 @@
margin-bottom: 28px;
}
.logo img { width: 32px; height: 32px; border-radius: 6px; }
.logo span { font-size: 18px; font-weight: 600; color: var(--text-strong); }
.logo span { font-size: 18px; font-weight: 600; color: #f5f9ff; }
h1 {
font-size: 22px;
font-weight: 600;
color: var(--text-strong);
color: #f5f9ff;
margin-bottom: 8px;
}
p.lede {
font-size: 14px;
line-height: 1.55;
color: var(--text-muted);
color: #94a3b8;
margin-bottom: 24px;
}
label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-body);
color: #cbd5e1;
margin-bottom: 8px;
}
input[type=email],
input[type=password] {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
background: #0a0e1a;
border: 1px solid #1f2942;
border-radius: 8px;
padding: 12px 14px;
font-size: 16px;
color: var(--text-strong);
font-size: 15px;
color: #f5f9ff;
font-family: inherit;
outline: none;
transition: border-color 0.15s ease;
/* Browsers auto-fill password fields with their own bright
background; -webkit-text-fill-color + a long inset shadow
override that so the field stays on-brand. */
-webkit-text-fill-color: var(--text-strong);
-webkit-box-shadow: 0 0 0 1000px var(--bg) inset;
caret-color: var(--text-strong);
-webkit-text-fill-color: #f5f9ff;
-webkit-box-shadow: 0 0 0 1000px #0a0e1a inset;
caret-color: #f5f9ff;
}
input[type=email]:focus,
input[type=password]:focus { border-color: var(--accent); }
input[type=password]:focus { border-color: #3b82f6; }
input[type=email]::placeholder,
input[type=password]::placeholder { color: var(--text-faint); }
input[type=password]::placeholder { color: #475569; }
button {
width: 100%;
margin-top: 16px;
background: var(--accent);
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
padding: 12px 14px;
font-size: 16px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
font-family: inherit;
}
button:hover:not(:disabled) { background: var(--accent-hover); }
button:disabled { background: var(--border); cursor: not-allowed; opacity: 0.6; }
button:hover:not(:disabled) { background: #2563eb; }
button:disabled { background: #1e3a8a; cursor: not-allowed; opacity: 0.6; }
.feedback {
margin-top: 20px;
padding: 14px 16px;
@@ -143,26 +124,26 @@
display: none;
}
.feedback.success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: var(--success-text);
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
color: #6ee7b7;
display: block;
}
.feedback.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: var(--error-soft);
color: #fca5a5;
display: block;
}
.footer {
margin-top: 28px;
font-size: 12px;
color: var(--text-label);
color: #64748b;
text-align: center;
line-height: 1.5;
}
.footer a { color: var(--text-muted); text-decoration: none; }
.footer a:hover { color: var(--text-body); }
.footer a { color: #94a3b8; text-decoration: none; }
.footer a:hover { color: #cbd5e1; }
/* Password group hidden by default — most users want the magic
link and the optional-password field cluttered the form. The
"Use password instead" link below the submit button reveals
@@ -172,14 +153,14 @@
text-align: center;
margin-top: 14px;
font-size: 12px;
color: var(--text-label);
color: #64748b;
}
.toggle-pwd-row a {
color: var(--text-muted);
color: #94a3b8;
text-decoration: underline;
cursor: pointer;
}
.toggle-pwd-row a:hover { color: var(--text-body); }
.toggle-pwd-row a:hover { color: #cbd5e1; }
</style>
</head>
<body>
@@ -354,30 +335,22 @@
return;
}
// Silent retry on transport failures — iOS Safari can dispatch a
// POST onto a pooled keep-alive socket the server (or a proxy in
// front of it) has already closed. Unlike a GET, a non-idempotent
// POST isn't transparently re-sent on a fresh connection; it
// 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.
// Silent retry on the first fetch — iOS Safari sometimes
// aborts the very first request from a cold tab with a generic
// "Load failed" TypeError. A single ~500ms retry hides the
// flake; server-side errors (4xx/5xx) are not retried because
// they're deliberate responses, not transport issues.
const reqInit = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
};
async function postWithRetry() {
const backoffsMs = [400, 1200];
for (let attempt = 0; ; attempt++) {
try {
return await fetch('/auth/request-link', reqInit);
} catch (e) {
if (attempt >= backoffsMs.length) throw e;
await new Promise((r) => setTimeout(r, backoffsMs[attempt]));
}
try {
return await fetch('/auth/request-link', reqInit);
} catch (e) {
await new Promise((r) => setTimeout(r, 500));
return await fetch('/auth/request-link', reqInit);
}
}
try {
+652 -1016
View File
File diff suppressed because it is too large Load Diff
-56
View File
@@ -279,60 +279,4 @@ 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" });
}
});
}
-64
View File
@@ -84,70 +84,6 @@ 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 ───────────────────────────────────────────────────
app.get("/api/admin/tenants", requireOperator, (req, res) => {
try {
+11 -8
View File
@@ -42,15 +42,18 @@ async function getTrialConfig() {
};
}
// Resolve the real client IP. We rely on Express's `trust proxy` setting
// (configured in index.js to the number of trusted proxies in front of the
// app) so req.ip is the address the trusted proxy observed — NOT a value the
// client can spoof by sending their own X-Forwarded-For. This previously took
// the first XFF entry verbatim, which a client could forge to mint unlimited
// trials. Falls back to the raw socket address if req.ip isn't populated.
// Crude IPv4-or-IPv6 string extraction. Trusts the X-Forwarded-For
// header's first hop because Recap sits behind StartOS's tunnel — the
// header is set by the operator's infrastructure, not by clients
// directly. If you ever expose the server without a trusted proxy,
// revisit this.
export function getClientIp(req) {
const ip = req.ip || req.socket?.remoteAddress || "";
return ip.replace(/^::ffff:/, "");
const xff = req.headers?.["x-forwarded-for"];
if (xff) {
const first = String(xff).split(",")[0].trim();
if (first) return first;
}
return (req.socket?.remoteAddress || "").replace(/^::ffff:/, "");
}
// Expand an IPv6 string to its full 8-group :-separated form with
+18 -118
View File
@@ -7,8 +7,6 @@ import { promisify } from "util";
import path from "path";
import http from "http";
import https from "https";
import dns from "dns";
import net from "net";
import { createWriteStream } from "fs";
const execFileAsync = promisify(execFile);
@@ -73,125 +71,27 @@ export async function splitAudioFile(inputPath, outputDir, chunkSeconds = 2700)
return chunks;
}
// ── SSRF guard for outbound podcast fetches ─────────────────────────────────
// downloadPodcastAudio fetches a fully user-controlled URL, so without a
// guard a caller could point it at internal services (cloud metadata at
// 169.254.169.254, LAN hosts, localhost) and read the response back through
// the transcript. isBlockedAddress rejects loopback / private / link-local /
// reserved / multicast targets for IPv4, IPv6, and IPv4-mapped IPv6.
export function isBlockedAddress(ip) {
if (!ip || typeof ip !== "string") return true;
// IPv4-mapped IPv6 in dotted form (::ffff:1.2.3.4) — judge by the embedded
// IPv4. (The hex-encoded forms are caught in the IPv6 branch below.)
const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
const addr = mapped ? mapped[1] : ip;
if (net.isIPv4(addr)) {
const [a, b] = addr.split(".").map(Number);
if (a === 0) return true; // 0.0.0.0/8 "this network"
if (a === 10) return true; // private
if (a === 127) return true; // loopback
if (a === 169 && b === 254) return true; // link-local (cloud metadata)
if (a === 172 && b >= 16 && b <= 31) return true; // private
if (a === 192 && b === 168) return true; // private
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT (100.64.0.0/10)
if (a >= 224) return true; // multicast + reserved (224.0.0.0+)
return false;
}
if (net.isIPv6(addr)) {
const a = addr.toLowerCase();
if (a === "::1" || a === "::") return true; // loopback / unspecified
if (a.startsWith("fc") || a.startsWith("fd")) return true; // fc00::/7 ULA
if (/^fe[89ab]/.test(a)) return true; // fe80::/10 link-local
if (a.startsWith("ff")) return true; // ff00::/8 multicast
// Translation / embedded-IPv4 prefixes can smuggle a private IPv4 past the
// rules above (the dotted ::ffff:1.2.3.4 form is normalized to IPv4 at the
// top; these catch the hex-encoded forms: IPv4-mapped/-compatible, SIIT,
// NAT64, 6to4). None is ever a real podcast host, so block the whole
// prefix rather than decode the embedded address.
if (/^::[0-9a-f]/.test(a)) return true; // ::/96 mapped / compat / SIIT (hex)
if (a.startsWith("64:ff9b:")) return true; // NAT64 well-known (RFC 6052)
if (a.startsWith("2002:")) return true; // 6to4
return false;
}
return true; // unrecognized → block
}
// dns.lookup wrapper that fails the connection if the host resolves to a
// blocked address. Passed as the `lookup` option to http(s).get, so the
// check runs at connect time on every attempt — including each redirect
// hop — which also closes the DNS-rebinding window (the address we validate
// is the address the socket connects to).
function guardedLookup(hostname, options, callback) {
if (typeof options === "function") {
callback = options;
options = {};
}
dns.lookup(hostname, options, (err, address, family) => {
if (err) return callback(err);
const addrs = Array.isArray(address) ? address : [{ address, family }];
for (const a of addrs) {
if (isBlockedAddress(a.address)) {
return callback(
new Error(`refusing to fetch podcast audio from disallowed address ${a.address}`),
);
}
}
callback(null, address, family);
});
}
// ── Download a podcast episode by URL ───────────────────────────────────────
// Streams the HTTP response straight to disk. Follows up to MAX_PODCAST_REDIRECTS
// redirects (resolving relative Location headers), rejects on any non-200 final
// status, and refuses non-HTTP(S) schemes and internal addresses (see the SSRF
// guard above). Used by /api/process when the input is a podcast episode.
const MAX_PODCAST_REDIRECTS = 5;
// Streams the HTTP response straight to disk. Follows redirects. Rejects
// on any non-200 final status. Used by /api/process when the input URL is
// a podcast episode rather than a YouTube video.
export function downloadPodcastAudio(audioUrl, destPath) {
return new Promise((resolve, reject) => {
const doFetch = (rawUrl, redirectsLeft) => {
let url;
try {
url = new URL(rawUrl);
} catch {
return reject(new Error("invalid podcast audio URL"));
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
return reject(new Error(`refusing non-HTTP podcast URL (${url.protocol})`));
}
// IP-literal hosts (e.g. http://169.254.169.254) never hit the DNS
// `lookup` hook — the socket connects to the literal directly — so they
// must be checked here. guardedLookup below covers hostnames that
// *resolve* to a blocked address (and the DNS-rebinding case).
const host = url.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
if (net.isIP(host) && isBlockedAddress(host)) {
return reject(
new Error(`refusing to fetch podcast audio from disallowed address ${host}`),
);
}
const getter = url.protocol === "https:" ? https : http;
getter
.get(url, { lookup: guardedLookup }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume(); // drain so the socket is freed
if (redirectsLeft <= 0) {
return reject(new Error("too many redirects downloading podcast audio"));
}
const next = new URL(res.headers.location, url).toString();
return doFetch(next, redirectsLeft - 1);
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode} downloading podcast audio`));
}
const fileStream = createWriteStream(destPath);
res.pipe(fileStream);
fileStream.on("finish", () => fileStream.close(resolve));
fileStream.on("error", reject);
})
.on("error", reject);
const doFetch = (url) => {
const getter = url.startsWith("https") ? https : http;
getter.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return doFetch(res.headers.location);
}
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode} downloading podcast audio`));
}
const fileStream = createWriteStream(destPath);
res.pipe(fileStream);
fileStream.on("finish", () => fileStream.close(resolve));
fileStream.on("error", reject);
}).on("error", reject);
};
doFetch(audioUrl, MAX_PODCAST_REDIRECTS);
doFetch(audioUrl);
});
}
-426
View File
@@ -1,426 +0,0 @@
// 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 12 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 12 paragraph overview (about 100150 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."));
}
});
}
+1 -45
View File
@@ -41,24 +41,10 @@ CREATE TABLE IF NOT EXISTS users (
-- NOT used for auth decisions — just data for the operator to grep
-- when an abuse pattern shows up in the admin dashboard.
signup_ip 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
signup_user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
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 ───────────────────────────────────────────────────────────
-- Server-side session store so we can revoke individual sessions from
@@ -307,7 +293,6 @@ export async function initDb({ dataDir }) {
migrateTenantCreditsSchema(db);
migrateMagicLinkTokensTrialCookie(db);
migrateUsersTier(db);
migrateUserDigestPrefs(db);
dbInstance = db;
console.log(`[db] opened ${dbPath} (multi-tenant store)`);
@@ -329,35 +314,6 @@ 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
// (purchased + replenish) so we can refill the latter periodically
// without wiping the former.
-104
View File
@@ -167,110 +167,6 @@ export function renderSubscriptionReminderEmail({
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) {
return String(s)
.replace(/&/g, "&amp;")
+1 -38
View File
@@ -220,43 +220,6 @@ 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
// `summaryAudio` availability). No-op-safe: returns null if the record
// is missing rather than throwing.
@@ -690,7 +653,7 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
// Allow the same character set as scope components for session ids.
// Belt-and-suspenders against ../../ in :id; ids generated by
// saveToHistory always match.
export function safeFilename(s) {
function safeFilename(s) {
if (typeof s !== "string" || !/^[A-Za-z0-9_-]+$/.test(s)) {
throw new Error("invalid_session_id");
}
+1 -26
View File
@@ -116,18 +116,6 @@ import { buildTenantAuthMiddleware } from "./tenant-auth.js";
const execFileAsync = promisify(execFile);
const app = express();
// Trust the operator's reverse proxy (StartOS / StartTunnel, or a cloud proxy)
// so req.ip is the real client address rather than a client-spoofable
// X-Forwarded-For entry. The value is how many trusted proxies sit in front of
// this process — default 1 (the StartOS/StartTunnel hop). Erring low is safe
// (it can only over-count clients onto one IP, hitting the trial cap sooner);
// erring high would re-open the trial-cap bypass. Override via
// RECAP_TRUSTED_PROXY_HOPS (0 = no proxy in front; use the socket address only).
const hopsParsed = parseInt(process.env.RECAP_TRUSTED_PROXY_HOPS, 10);
const trustedProxyHops =
Number.isInteger(hopsParsed) && hopsParsed >= 0 ? hopsParsed : 1;
app.set("trust proxy", trustedProxyHops);
const PORT = process.env.PORT || 3001;
// ── Multi-tenant mode toggle ────────────────────────────────────────────
@@ -264,15 +252,6 @@ if (RECAP_MODE === "multi") {
// public URL + relay being configured, so it's a safe no-op until then.
const { startReminderScheduler } = await import("./subscription-reminders.js");
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
// determine which UI state to render:
@@ -2639,11 +2618,7 @@ app.post("/api/process", async (req, res) => {
// through the relay. Non-relay providers ignore this opt.
const jobId = randomUUID();
// The free-tier single-flight lock is a single-mode concept (one operator,
// BYO key, one job at a time). In multi mode, per-tenant credit metering is
// the resource control, so a process-global lock would wrongly serialize
// every tenant onto one job at a time — never apply it there.
const isFree = req.recapMode !== "multi" && isFreeUser();
const isFree = isFreeUser();
if (isFree) {
if (!tryAcquireFreeSlot({ url, title: itemTitle, abortController })) {
const current = getCurrentFreeJob();
+1 -12
View File
@@ -25,7 +25,6 @@ import {
loadMeta,
saveMeta,
scopeForRequest,
safeFilename,
ROOT_SIDECARS,
} from "./history.js";
@@ -130,17 +129,7 @@ export function setupLibraryRoutes(app) {
// Sessions — skip if already present.
for (const [id, session] of Object.entries(data.sessions)) {
// The import file is fully attacker-controlled; validate the key
// before using it as a filename. A "../../" id would otherwise
// escape the scope dir and write anywhere the process can reach.
let safeId;
try {
safeId = safeFilename(id);
} catch {
skipped++;
continue;
}
const filePath = path.join(scopeDir, `${safeId}.json`);
const filePath = path.join(scopeDir, `${id}.json`);
try {
await fs.access(filePath);
skipped++;
+3 -1
View File
@@ -18,7 +18,6 @@
import { Client } from "@keysat/licensing-client";
import * as license from "./license.js";
import { randomBytes } from "crypto";
const KEYSAT_BASE_URL = license.KEYSAT_BASE_URL;
const PRODUCT_SLUG = license.PRODUCT_SLUG;
@@ -417,7 +416,10 @@ async function maybeApplyPendingSignup(invoiceId, licenseKey, req) {
}
// Local UUID helper — same shape we use in auth-routes for new users.
// Avoids a hard import dep just for this one call.
function randomUuid() {
// Same crypto.randomBytes(16).toString("hex") pattern used elsewhere.
// eslint-disable-next-line global-require
const { randomBytes } = require("crypto");
return randomBytes(16).toString("hex");
}
-1
View File
@@ -35,7 +35,6 @@ const PUBLIC_PATH_PREFIXES = [
"/api/health",
"/api/auth/", // future client-facing auth shims (CSRF token issue, etc.)
"/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/relay/status", // public relay capabilities — pre-trial visibility
"/api/account/whoami", // returns state — anonymous visitors must call this
-37
View File
@@ -1,37 +0,0 @@
// Tests for server/anon-trial.js — focused on getClientIp, which underpins
// the per-IP trial cap. The DB-backed minting paths need a multi-mode SQLite
// handle and are exercised by integration tests; here we lock down that the
// client IP is taken from Express's trust-proxy-resolved req.ip, never from a
// raw client-supplied X-Forwarded-For header.
import { test, describe } from "node:test";
import { strict as assert } from "node:assert";
import { getClientIp } from "../anon-trial.js";
describe("getClientIp", () => {
test("uses req.ip (Express's trust-proxy-resolved client address)", () => {
assert.equal(getClientIp({ ip: "203.0.113.7" }), "203.0.113.7");
});
test("strips the IPv4-mapped IPv6 prefix", () => {
assert.equal(getClientIp({ ip: "::ffff:203.0.113.7" }), "203.0.113.7");
});
test("falls back to the socket address when req.ip is absent", () => {
assert.equal(
getClientIp({ socket: { remoteAddress: "::ffff:198.51.100.9" } }),
"198.51.100.9",
);
});
test("does NOT trust a raw client-supplied X-Forwarded-For header", () => {
// Express, not getClientIp, decides the client IP from trust proxy. A
// header Express hasn't blessed must be ignored — so with no req.ip we
// fall through to the socket address, never the spoofed header value.
const spoofed = {
headers: { "x-forwarded-for": "1.2.3.4" },
socket: { remoteAddress: "203.0.113.7" },
};
assert.equal(getClientIp(spoofed), "203.0.113.7");
});
});
-88
View File
@@ -1,88 +0,0 @@
// Tests for server/audio.js — focused on the SSRF guard around
// downloadPodcastAudio (a fully user-controlled outbound fetch). The
// ffprobe/ffmpeg helpers need real media + binaries and aren't covered here.
import { test, describe } from "node:test";
import { strict as assert } from "node:assert";
import { isBlockedAddress, downloadPodcastAudio } from "../audio.js";
const SINK = "/tmp/recap-audio-test-should-not-be-written";
describe("isBlockedAddress", () => {
test("blocks IPv4 loopback / private / link-local / reserved", () => {
for (const ip of [
"127.0.0.1", "127.1.2.3",
"10.0.0.1", "172.16.0.1", "172.31.255.255", "192.168.1.1",
"169.254.169.254", // cloud metadata
"100.64.0.1", // CGNAT
"0.0.0.0",
"224.0.0.1", "255.255.255.255",
]) {
assert.equal(isBlockedAddress(ip), true, `${ip} should be blocked`);
}
});
test("allows ordinary public IPv4 (incl. 172.x boundaries)", () => {
for (const ip of ["8.8.8.8", "1.1.1.1", "93.184.216.34", "172.15.0.1", "172.32.0.1"]) {
assert.equal(isBlockedAddress(ip), false, `${ip} should be allowed`);
}
});
test("blocks IPv6 loopback / ULA / link-local / multicast + IPv4-mapped privates", () => {
for (const ip of [
"::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "ff02::1",
"::ffff:127.0.0.1", "::ffff:169.254.169.254",
]) {
assert.equal(isBlockedAddress(ip), true, `${ip} should be blocked`);
}
});
test("blocks hex-encoded embedded-IPv4 IPv6 forms (mapped/SIIT/NAT64/6to4)", () => {
for (const ip of [
"::ffff:7f00:1", // IPv4-mapped 127.0.0.1, hex form
"::ffff:0:7f00:1", // SIIT 127.0.0.1
"64:ff9b::7f00:1", // NAT64 well-known prefix of 127.0.0.1
"2002:7f00:1::", // 6to4 of 127.0.0.1
]) {
assert.equal(isBlockedAddress(ip), true, `${ip} should be blocked`);
}
});
test("allows ordinary public IPv6", () => {
assert.equal(isBlockedAddress("2606:4700:4700::1111"), false);
assert.equal(isBlockedAddress("::ffff:8.8.8.8"), false);
});
test("blocks junk / empty / non-strings", () => {
assert.equal(isBlockedAddress(""), true);
assert.equal(isBlockedAddress(null), true);
assert.equal(isBlockedAddress("not-an-ip"), true);
});
});
describe("downloadPodcastAudio SSRF guard", () => {
test("rejects non-HTTP(S) schemes", async () => {
await assert.rejects(downloadPodcastAudio("file:///etc/passwd", SINK), /non-HTTP/);
await assert.rejects(downloadPodcastAudio("ftp://example.com/x", SINK), /non-HTTP/);
});
test("rejects internal / private destinations before connecting", async () => {
// 127.0.0.1:1 would refuse instantly if we connected; we must reject at
// the DNS-guard step instead (proving the guard fires before connect).
await assert.rejects(
downloadPodcastAudio("http://127.0.0.1:1/x", SINK),
/disallowed address/,
);
await assert.rejects(
downloadPodcastAudio("http://169.254.169.254/latest/meta-data/", SINK),
/disallowed address/,
);
});
test("rejects a malformed URL", async () => {
await assert.rejects(
downloadPodcastAudio("not a url at all", SINK),
/invalid podcast audio URL/,
);
});
});
-248
View File
@@ -1,248 +0,0 @@
// 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, /12 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/);
});
});
-21
View File
@@ -138,24 +138,3 @@ describe("loadMeta + saveMeta", () => {
assert.deepEqual(loaded.uncategorized, []);
});
});
describe("safeFilename", () => {
// Exported so callers writing user content to disk (e.g. library import)
// can share the one guard instead of rolling their own.
test("returns valid ids unchanged", () => {
for (const id of ["abc123", "a_b-c", "VIDEOid123", "0"]) {
assert.equal(history.safeFilename(id), id);
}
});
test("rejects traversal, separators, and other unsafe chars", () => {
for (const bad of ["../../evil", "..", "a/b", "a\\b", "a.json", "foo bar", "", "a:b"]) {
assert.throws(() => history.safeFilename(bad), /invalid_session_id/);
}
});
test("rejects non-strings", () => {
assert.throws(() => history.safeFilename(123), /invalid_session_id/);
assert.throws(() => history.safeFilename(null), /invalid_session_id/);
});
});
-11
View File
@@ -22,17 +22,6 @@ describe("extractVideoId", () => {
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", () => {
assert.equal(extractVideoId("dQw4w9WgXcQ"), "dQw4w9WgXcQ");
});
+3 -3
View File
@@ -14,12 +14,12 @@ export function sendEvent(res, event, data) {
}
// ── YouTube video-id extraction ─────────────────────────────────────────────
// Accepts watch URLs, youtu.be, /embed/, /v/, /live/, /shorts/, or a bare
// 11-char id. Returns null when no id can be extracted.
// Accepts watch URLs, youtu.be, /embed/, /v/, or a bare 11-char id.
// Returns null when no id can be extracted.
export function extractVideoId(url) {
if (!url) return null;
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/live\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
/^([a-zA-Z0-9_-]{11})$/,
];
for (const p of patterns) {
+2 -8
View File
@@ -174,14 +174,8 @@ import { v_0_2_152 } from './v0.2.152'
import { v_0_2_153 } from './v0.2.153'
import { v_0_2_154 } from './v0.2.154'
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({
current: v_0_2_161,
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],
current: v_0_2_155,
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],
})
-12
View File
@@ -1,12 +0,0 @@
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 }) => {},
},
})
-12
View File
@@ -1,12 +0,0 @@
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 }) => {},
},
})
-13
View File
@@ -1,13 +0,0 @@
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 }) => {},
},
})
-13
View File
@@ -1,13 +0,0 @@
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 }) => {},
},
})
-13
View File
@@ -1,13 +0,0 @@
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 }) => {},
},
})
-12
View File
@@ -1,12 +0,0 @@
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 }) => {},
},
})