Files
recap/AGENTS.md
T
Keysat d0e98424c1 Fix five P0/P1 security & correctness findings from the full-eval
- Arbitrary file write (P0): validate import keys in /api/library/import via
  a now-exported safeFilename(); a ../../ key is skipped, not written out of
  the scope dir.
- SSRF (P0): guard downloadPodcastAudio — reject non-HTTP(S) schemes, block
  IP-literal and DNS-resolved private/link-local/loopback/reserved/multicast
  and embedded-IPv4 IPv6 targets (closes DNS rebinding), cap + resolve redirects.
- ESM require (P1): top-level import of randomBytes in license-purchase.js
  (the inner require threw on the anon purchase-settle path).
- Concurrency lock (P1): skip the process-global free-tier slot in multi-mode
  so it no longer serializes every cloud tenant onto one job.
- X-Forwarded-For bypass (P1): set Express trust proxy from
  RECAP_TRUSTED_PROXY_HOPS (default 1); getClientIp now reads req.ip instead
  of a client-spoofable XFF entry.

Tests added for safeFilename, the SSRF guard, and getClientIp (119 pass).
Registry blockers deferred (ROADMAP); leaked-key history purge queued.
2026-06-15 13:36:40 -05:00

18 KiB

AGENTS.md — Recaps

YouTube + podcast summarizer + library, served as a single-page app from a Node.js backend. Ships as a StartOS .s9pk (single-mode self-host) and as the public recaps.cc cloud (multi-mode tenants).

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.

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.
  • Frontend: One file, public/index.html, with vanilla JS embedded (no framework, no bundler). Render is a render-string-into-innerHTML loop driven by a module-scoped state object.
  • DB: SQLite via better-sqlite3. Multi-mode only; single-mode keeps everything on the filesystem.
  • Packaging: @start9labs/start-sdk under startos/ — version graph at startos/versions/index.ts.
  • Deps of note: @anthropic-ai/sdk, @google/genai, openai, nodemailer, express, @keysat/licensing-client (vendored at vendor/keysat-licensing-client).

Commands

Run from repo root unless noted.

Action Command
Dev server (single-mode default) cd server && npm run dev
Prod server cd server && npm start
Run all tests cd server && npm test
Run one test file cd server && node --test --test-reporter=spec test/<file>.test.js
Run one test by name cd server && node --test --test-reporter=spec --test-name-pattern='<substring>' test/<file>.test.js
Build .s9pk (x86) make x86
Bump version (interactive) make bump
Install to local StartOS make install (see Always/Never — bump first; the binary is start-cli under the hood)
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).

Directory layout

server/
  index.js                 main HTTP + SSE entry; mounts every route
  providers/               relay.js, gemini.js, openai.js, anthropic.js, ollama.js,
                           openai-compatible.js, whisper.js — each implements the
                           provider interface in providers/index.js
  anon-trial.js            multi-mode trial-cookie minting + IP cap
  tenant-credits.js        multi-mode signed-in-tenant credit pool
  history.js               per-scope library save/load + REST handlers
  config.js                StartOS config snapshot + server-side API-key resolver
  db.js                    SQLite schema apply + getDb() handle (multi-mode only)
  billing-routes.js        multi-mode self-serve purchase: /api/billing/{plans,buy,status};
                           Bitcoin (BTCPay inline Lightning) + card (Zaprite) rails
  subscription-reminders.js  daily expiry-reminder scan → sendMail (multi-mode)
  smtp.js                  StartOS System-SMTP transport (magic links + reminders)
  test/                    node --test files
public/
  index.html               the whole single-page app, ~10k lines vanilla JS
  auth.html                standalone magic-link landing page (multi-mode)
startos/
  manifest/                StartOS package manifest
  versions/<vN>.ts         one file per shipped version + index.ts version graph
  actions/                 operator-facing StartOS Actions
docs/                      design notes; treat as in-progress, not authoritative
bin/bump-version.sh        used by `make bump` and `make deploy`
vendor/keysat-licensing-client/   local-link Keysat SDK

Conventions

  • Plain language over jargon, especially for git / packaging / dev-tooling steps.
  • Don't be sycophantic. Push back when something doesn't add up.
  • Honest reports. A failing test/build is a failure, even if pre-existing or unrelated. Don't fold it into a "success" summary.
  • Diff size matches change scope. Small reviewable diffs, not sweeping rewrites.
  • Comments explain WHY, not what. No narrating self-evident code. No referencing tasks/PRs/callers in source — that rots.
  • Match the file's own style over any default of your own. The frontend's vanilla-JS shape is intentional; don't reach for a framework.
  • Write the test alongside the change when the area already has tests (server/test/*.test.js). The repo uses the built-in node --test.
  • Plans persist in docs/ when scoped + named (e.g., docs/per-tenant-subscriptions-plan.md); ephemeral planning lives in conversation/tasks, not Markdown files.

Conventions for this codebase specifically

  • Relay is the modern default provider. The legacy "must have a Gemini API key configured" gate is dead — server-side callers should pick relay when configured, fall back to gemini only if a local key exists, otherwise surface a clear user-facing error. The frontend stores the choice in localStorage; the server can't read it.
  • 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.
  • 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.
  • 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.

Client-side contract with the relay

The full client-side relay contract — env vars, the /relay/* endpoint list, X-Recap-* header directions, and the file map — lives in docs/guides/relay-client.md. Read it before editing server/providers/relay.js, relay-capabilities.js, relay-default.js, billing-routes.js, credits-purchase.js, subscription-reminders.js, or the relay env-var resolution in config.js. Canonical endpoint shapes are in ../recap-relay/AGENTS.md.

Cross-repo changes (sibling: ../recap-relay)

This repo and the relay (../recap-relay) share a live client/server contract — the /relay/* endpoints, the X-Recap-* headers, request/response shapes, and tier/credit semantics. Before finishing any change that touches that boundary, check whether ../recap-relay needs a matching change. If you add/rename/remove a relay call, alter a payload shape or header, or shift tier/credit/billing behavior, update the relay side too — and reflect it in BOTH repos' AGENTS.md (the contract docs) and ROADMAP.md (if it's staged work). Purely local changes (UI, library handling, packaging) don't need this. When unsure whether a change is contract-affecting, assume it is and check.

Always

  • Bump the version before make install. StartOS dedupes sideloads by version string — installing the same version twice silently no-ops. Use make bump or edit startos/versions/index.ts + add a vN.ts file. Applies to EVERY iteration, even a one-line edit.
  • Add new version files to BOTH the import block AND the other: list in startos/versions/index.ts, and update current: to the new version constant.
  • Ask before make deploy / make redeploy. These push to the Start9 community registry — public-facing, attribution-tracked, irreversible from your laptop. make install is the safe iteration loop.
  • Verify mDNS resolution before blaming it when make install fails. Substitute the operator's actual StartOS hostname (the host: field in ~/.startos/config.yaml) and run curl -sk "https://${STARTOS_HOST}/rpc/v1" -X POST -d '{}' -H 'Content-Type: application/json' — if that reaches the box but start-cli doesn't on the same target, it's almost certainly macOS Local Network privacy blocking the third-party start-cli binary (Apple's curl/ping are exempt, so the box looks reachable). Tell: node -e TCP-connect to <box-ip>:443 also gives EHOSTUNREACH while curl gets 200. Fix: System Settings → Privacy & Security → Local Network → enable the Claude app (restart Claude Code if it doesn't take). This often flips off after a Claude Code update. Details: memory feedback_macos_local_network_install.
  • Reference env-var names, never values. Secrets live in .env / .deploy.env (both gitignored). Examples for new vars belong in .deploy.env.example.

Never

  • No "Co-Authored-By" trailers on commits, no mention of "Claude" in source files, comments, or commit messages. Commits are authored by the user.
  • Never claim make install succeeded without verifying it. Confirm the make exit code is 0 AND the new version actually shows on the box (start-cli package list) — not just that the command ran (a tail/pipe can mask a non-zero exit). Installs DO work from this agent's shell now; the old "start-cli is blocked by the sandbox" framing was a misdiagnosis — it was macOS Local Network privacy, which is fixable (see the Always "verify mDNS" rule).
  • Never make deploy to the registry without explicit per-action approval, even if a prior session ran one.
  • Never edit startos/versions/<v>.ts for a version that's already been built and is being tested. Add a new version file instead — operators may already have the prior .s9pk cached.
  • Don't add the relay's internal-meetings feature here. That lives in the sibling ../recap-relay repo. This repo is the client/library; the relay does diarization + clustering + meeting analysis.
  • Don't push to GitHub by default. The configured remote is self-hosted Gitea unless the user says otherwise.
  • Don't pull cookies.txt into commits — it's an operational yt-dlp artifact, not source.
  • Never modify ~/.startos/config.yaml without authorization (contains host credentials).

Adjacent repo

  • ../recap-relay — the operator-side credit-metered service this client talks to. Owns Gemini/Parakeet/Sortformer routing, diarization, internal-meeting analysis, and the operator dashboard. See ../recap-relay/AGENTS.md for its endpoint shapes, build/deploy rules, and roadmap. Reference it but do not change it from inside this repo.

Current state

Live on the operator's StartOS box (app 0.2.155 + relay 0.2.124, installed 2026-06-09):

  • 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.

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.

Evaluation work queue (P0/P1) — from the 2026-06-14 full-eval (EVALUATION.md)

Five of seven items FIXED + tested (2026-06-15, reviewer-checked); the leaked-key purge awaits operator confirmation and the registry blockers are deferred.

  • [P0] FIXED — arbitrary file write in POST /api/library/import. safeFilename() is now exported from history.js and validates each import key (server/library.js); a ../../ key is skipped, not written outside the scope dir. Tests in test/history.test.js. (Adjacent P2 still open: array-form import + PUT /api/history/move write unvalidated IDs into _meta.json — no file-path escape, and read-time safeFilename guards the load. See Known debt.)
  • [P0] FIXED — SSRF in podcast download. downloadPodcastAudio (server/audio.js) rejects non-HTTP(S) schemes and blocks IP-literal AND DNS-resolved private/link-local/loopback/reserved/multicast/translation-prefix targets (closing the DNS-rebinding window), caps + resolves redirects. Tests in test/audio.test.js. (Response size/time cap still deferred — ROADMAP P3.)
  • [P0] Leaked Gemini key in git history (git show d5046a0:.env). Operator to rotate in Google AI Studio (recommended, not strictly required since the repo was never shared); git-history purge via git filter-repo --path .env --invert-paths + force-push to Gitea is queued and runs on the operator's go-ahead.
  • [P1] FIXED — ESM require("crypto"). Replaced with a top-level import { randomBytes } in server/license-purchase.js.
  • [P1] FIXED — global currentFreeJob lock serialized the whole cloud. Now skipped in multi-mode (server/index.js: const isFree = req.recapMode !== "multi" && isFreeUser()); per-tenant credit metering is the control there.
  • [P1] FIXED — X-Forwarded-For trial-cap bypass. app.set("trust proxy", …) from RECAP_TRUSTED_PROXY_HOPS (default 1) + getClientIp now returns req.ip (server/index.js, server/anon-trial.js). Tests in test/anon-trial.test.js. Watch: if the cloud ever gains a CDN/LB hop, bump RECAP_TRUSTED_PROXY_HOPS or the bypass reopens.
  • (StartOS registry-submission blockers — deferred by decision 2026-06-15; moved to ROADMAP.md. They never affected make install.)

Known debt (P2) — track; not release-blocking for self-host

  • Operator-internal strings leak to cloud users at the SSE error boundary (Parakeet/Gemma/CUDA/LAN IPs) — no scrub exists, violating the scrub contract above. server/index.js:3432,3003,4246 + providers/relay.js:135-144. (Sharp edge: index.js:3419 detects these strings, then forwards them anyway.)
  • Credit over-spend TOCTOU on licensed installs — N parallel requests pass the total>0 check before any blind debitOne lands. Make check+debit atomic (reserve up front, refund on failure). index.js:2497-2550 vs :3158,:4197.
  • Multi-mode tenant can spend the operator's server Gemini key via transcriptionProvider:"gemini" + empty key (bypasses relay metering) — providers/index.js:104-114. Refuse the operator-key fallback for non-admin tenants.
  • GET /api/history parses every full session file (transcript+summary, MB each) just to list ~8 metadata fields — cache them into _meta.json on save. history.js:418-437.
  • Dependency CVEs — nodemailer 6.10.1 (high; low practical reach here), ws/qs/express/protobufjs (moderate). npm audit fix (nodemailer is a major bump).
  • No tests on the riskiest files (/api/process gating, relay.js, tenant-auth.js, billing) — every code P0/P1 above lives here, and no agent could run the real summarize→save→debit path end-to-end (no key/credits). Add an integration test as the regression net before/with the fixes.
  • Smaller hardening: unsanitized IDs persisted to _meta.json (array-form import + history/move); PUT /api/history/meta accepts arbitrary JSON shapes with no schema; index.js is 4351 lines mixing routing/pipeline/yt-dlp/SSE.
  • Doc drift (high-value): AGENTS.md credit-gate order omits the "paid cloud user" bypass state (:77 vs index.js:2464-2472); operator-facing startos-registry/.../INSTRUCTIONS.md + assets/ABOUT.md are stale Gemini-first (relay is the default provider). Lower-severity doc nits are deferred in ROADMAP.md.

Known issues / open decisions (details + next actions in ROADMAP.md):

  • Zaprite recurring isn't built — Zaprite's API only does one-time orders. Card = prepaid until that's confirmed with Zaprite.
  • "Take Recaps home" is likely broken for relay-tier users (no keysat_license after decoupling).
  • Cloud still has a free signed-in tier (the simplification plan's "cloud paid-only" is unbuilt).
  • No CI lint / type-check (unchanged).