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.
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.mdexists, 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 runsv25.6.1; container runtime is whatever theDockerfilepins — check before assuming. - Frontend: One file,
public/index.html, with vanilla JS embedded (no framework, no bundler). Render is a render-string-into-innerHTMLloop driven by a module-scopedstateobject. - DB: SQLite via
better-sqlite3. Multi-mode only; single-mode keeps everything on the filesystem. - Packaging:
@start9labs/start-sdkunderstartos/— version graph atstartos/versions/index.ts. - Deps of note:
@anthropic-ai/sdk,@google/genai,openai,nodemailer,express,@keysat/licensing-client(vendored atvendor/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.
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-innode --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
relaywhen configured, fall back togeminionly 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,
*.localURLs come from the sibling relay and must not reach cloud users. - Multi-mode credit gates fire BEFORE the pipeline. See
/api/processfor 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 isusers.tier— cached from the relay, keyed by the Recaps user-id — NOT a per-user Keysat license. Don't gate cloud paid features bykeysat_license; the license only matters for self-hosted "take it home" portability. Cloud requests carryX-Recap-User-Id+ the operator key; server-to-server tier reads/writes go throughproviders/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_periodon/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. Usemake bumpor editstartos/versions/index.ts+ add avN.tsfile. Applies to EVERY iteration, even a one-line edit. - Add new version files to BOTH the import block AND the
other:list instartos/versions/index.ts, and updatecurrent: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 installis the safe iteration loop. - Verify mDNS resolution before blaming it when
make installfails. Substitute the operator's actual StartOS hostname (thehost:field in~/.startos/config.yaml) and runcurl -sk "https://${STARTOS_HOST}/rpc/v1" -X POST -d '{}' -H 'Content-Type: application/json'— if that reaches the box butstart-clidoesn't on the same target, it's almost certainly macOS Local Network privacy blocking the third-partystart-clibinary (Apple'scurl/pingare exempt, so the box looks reachable). Tell:node -eTCP-connect to<box-ip>:443also givesEHOSTUNREACHwhilecurlgets 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: memoryfeedback_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 installsucceeded without verifying it. Confirm themakeexit code is 0 AND the new version actually shows on the box (start-cli package list) — not just that the command ran (atail/pipe can mask a non-zero exit). Installs DO work from this agent's shell now; the old "start-cliis 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 deployto the registry without explicit per-action approval, even if a prior session ran one. - Never edit
startos/versions/<v>.tsfor a version that's already been built and is being tested. Add a new version file instead — operators may already have the prior.s9pkcached. - Don't add the relay's
internal-meetingsfeature here. That lives in the sibling../recap-relayrepo. 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.txtinto commits — it's an operational yt-dlp artifact, not source. - Never modify
~/.startos/config.yamlwithout 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.mdfor 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 atextendUserTier. Expiry-reminder emails (7d / 1d / lapsed) ride the existing System SMTP; operator test trigger:POST /api/admin/reminders/runwith{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):
- First on-device Bitcoin purchase — sign in as a Core tenant → Upgrade → Pay with Bitcoin → pay the inline invoice → badge flips.
- Enable cards — run the relay's "Set Zaprite Connection" action (API key) + register the Zaprite webhook at
https://<relay-host>/relay/zaprite/webhook. - Eyeball a reminder email via the test trigger above.
Evaluation work queue (P0/P1) — from the 2026-06-14 full-eval (EVALUATION.md)
Fix before exposing the cloud to untrusted users. The P0s are reproduced/verified, not theoretical.
- [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-139uses the key as a filename withoutsafeFilename(). Fix: exportsafeFilename()fromhistory.js(it's currently module-private — see:656) and validate the key here plus in the array-import andPUT /api/history/movepaths. - [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 fromindex.js:3455) does an unguardedhttp.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 toorigin/master. Rotate the key in Google AI Studio now; then purge from history (BFG/filter-repo) and force-push. - [P1] ESM
require("crypto")throwsReferenceErroron the anon license-purchase settle path —server/license-purchase.js:423(called from:353-354). ImportrandomBytes/randomUUIDat the top of the file. - [P1] Global
currentFreeJoblock serializes the entire multi-tenant cloud.isFreeUser()returns true for every tenant in multi-mode, so a 2nd concurrent/api/processfrom any user gets409.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(notrust 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/upstreamRepopoint tohttps://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 affectmake 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:3419detects these strings, then forwards them anyway.) - Credit over-spend TOCTOU on licensed installs — N parallel requests pass the
total>0check before any blinddebitOnelands. Make check+debit atomic (reserve up front, refund on failure).index.js:2497-2550vs: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/historyparses every full session file (transcript+summary, MB each) just to list ~8 metadata fields — cache them into_meta.jsonon 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/processgating,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/metaaccepts arbitrary JSON shapes with no schema;index.jsis 4351 lines mixing routing/pipeline/yt-dlp/SSE. - Doc drift (high-value): AGENTS.md credit-gate order omits the "paid cloud user" bypass state (
:77vsindex.js:2464-2472); operator-facingstartos-registry/.../INSTRUCTIONS.md+assets/ABOUT.mdare stale Gemini-first (relay is the default provider). Lower-severity doc nits are deferred inROADMAP.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_licenseafter decoupling). - Cloud still has a free signed-in tier (the simplification plan's "cloud paid-only" is unbuilt).
- No CI lint / type-check (unchanged).