# Security Model

CoreReflex is a multi-tenant, white-label SaaS that mints billed AI calls, holds
biometric consent data (voice clones, persona likenesses), stores third-party
credentials, and runs on a dependency-free Node HTTP server. This doc is the
single map of the security model: what protects each surface, where the code
lives, and which env vars tune it. It reflects the 2026-06-23 hardening pass,
the nonce-CSP tightening, and the shipped 2026-07 surfaces (API keys, social
credential encryption, proofing, chat safety, content credentials, agent
guardrails).

Everything is scoped to `user.workspace.id`. The standing rule: **fail closed,
never fake it** — an unconfigured engine returns an honest `503`, never a silent
stub, and an unauthorized read returns nothing.

## On this page

- [Defense map at a glance](#defense-map-at-a-glance)
- [HTTP security headers & CSP](#http-security-headers--csp)
- [Editor (nginx) headers](#editor-nginx-headers)
- [Authentication & sessions](#authentication--sessions)
- [API keys (programmatic auth)](#api-keys-programmatic-auth)
- [Rate limiting](#rate-limiting)
- [SSRF guard (net-guard.js)](#ssrf-guard-net-guardjs)
- [Tenant isolation (IDOR fixes)](#tenant-isolation-idor-fixes)
- [Secrets at rest (crypto-box.js)](#secrets-at-rest-crypto-boxjs)
- [Chat safety: redaction + grounding gate](#chat-safety-redaction--grounding-gate)
- [Agent guardrails](#agent-guardrails)
- [Proof review tokens](#proof-review-tokens)
- [Content credentials (Ed25519 provenance)](#content-credentials-ed25519-provenance)
- [Booking transaction](#booking-transaction-no-double-booking)
- [Billing idempotency](#billing-idempotency-no-double-credit)
- [God Mode admin escalation guard](#god-mode-admin-escalation-guard)
- [Voice-bridge abuse caps](#voice-bridge-abuse-caps)
- [Consent gates (biometrics)](#consent-gates-biometrics)
- [DoS / abuse hardening](#dos--abuse-hardening)
- [Error masking](#error-masking)
- [CORS](#cors)
- [The cost-free pre-push gate](#the-cost-free-pre-push-gate-githookspre-push)
- [Operator checklist](#operator-checklist)

---

## Defense map at a glance

| Surface | Threat | Control | Code |
|---------|--------|---------|------|
| Every HTTP response | XSS, clickjacking, MIME sniffing | Nonce-based CSP + security headers + HSTS + Permissions-Policy | [`src/server.js`](../../src/server.js) |
| Public/auth/spend endpoints | Brute force, scraping, cost abuse | Per-IP / per-user sliding-window rate limits | [`src/rate-limit.js`](../../src/rate-limit.js), [`src/api.js`](../../src/api.js) |
| Programmatic API (SDK/MCP/CI) | Credential theft from the DB | `cr_live_` bearer keys stored as sha256 hashes only | [`src/api-keys.js`](../../src/api-keys.js), [`migrations/018`](../../migrations/018_api_keys.sql) |
| Stored third-party credentials | DB dump exposes GoHighLevel keys | AES-256-GCM at rest (scrypt-derived key) | [`src/crypto-box.js`](../../src/crypto-box.js), [`src/social.js`](../../src/social.js) |
| Public chat widget | Secret leakage, ungrounded product claims, prompt injection | Secret redaction both directions + grounding gate + advisory moderation | [`src/chat-safety.js`](../../src/chat-safety.js), [`src/chat.js`](../../src/chat.js) |
| Suite-wide AI agent | Runaway spend, destructive plans, privilege escalation via forged plans | Policy tiers + step-class allowlists + hard credit/step caps + approval & confirmation floors + read-step field whitelist | [`src/agent.js`](../../src/agent.js) |
| Client proofing links | Guessable share URLs | 192-bit random `pr_` tokens; token IS the grant; workspace-bound deliverables | [`src/proofing.js`](../../src/proofing.js), [`migrations/022`](../../migrations/022_proofing.sql) |
| Generated-asset provenance | Tampered/unattributed AI output | Ed25519-signed content credentials + public verify/pubkey endpoints | [`src/content-credentials.js`](../../src/content-credentials.js) |
| Voice WS / Twilio bridge | Free/anon billed audio, origin hijack | Origin allowlist, Twilio signature check, per-IP + concurrency + lifetime/idle caps | [`src/voice-agent/`](../../src/voice-agent), [`src/voice-agent/telephony.js`](../../src/voice-agent/telephony.js) |
| Outbound media fetch | SSRF to metadata / internal hosts | `safePublicUrl()` guard | [`src/net-guard.js`](../../src/net-guard.js) |
| Asset proxy | Cross-tenant file access (IDOR) | Key-namespacing + capability HMAC | [`src/storage.js`](../../src/storage.js), [`src/api.js`](../../src/api.js) |
| Personas | Cross-tenant reference asset (IDOR) | Workspace-join on resolve | [`src/personas.js`](../../src/personas.js) |
| Director KB grounding | Cross-tenant knowledge-base disclosure | Session-derived `workspaceId` overrides any client value | [`src/api.js`](../../src/api.js) (`/api/director/plan`, `/api/director/produce`) |
| Public booking | Double-booking, body bombs | Advisory-lock transaction | [`src/booking.js`](../../src/booking.js) |
| Billing webhook | Retry double-credit | Partial-unique-index idempotency | [`src/billing.js`](../../src/billing.js), [`migrations/011`](../../migrations/011_billing_credit_idempotency.sql) |
| God Mode admin | Self-escalation | No-self-edit + privilege-subset filter | [`src/admin-api.js`](../../src/admin-api.js) |
| Voice/persona cloning | Unconsented biometrics | Hard consent gate (`consent_status = 'granted'`) | [`src/personas.js`](../../src/personas.js), [`src/voice.js`](../../src/voice.js) |
| Auth | Credential theft, token forgery | scrypt + HS256 JWT + HttpOnly cookies | [`src/auth.js`](../../src/auth.js), [`src/session.js`](../../src/session.js) |
| Editor static host | Clickjacking, MIME sniffing | nginx baseline header set (no CSP — intentional, see below) | [`editor/nginx.conf`](../../editor/nginx.conf) |
| Whole process | DoS via slow/large requests | Header/request timeouts + 1 MiB body cap | [`src/server.js`](../../src/server.js), [`src/api.js`](../../src/api.js) |
| Pre-push | Untested code reaching prod | Cost-free local git hook | [`.githooks/pre-push`](../../.githooks/pre-push) |

---

## HTTP security headers & CSP

[`src/server.js`](../../src/server.js) sets baseline headers on **every** response
(API and static) with `response.setHeader`, which persists through the downstream
`writeHead` calls in the API and static handlers:

| Header | Value | Why |
|--------|-------|-----|
| `content-security-policy` | see below | blocks injected scripts, object embeds, `<base>` hijack, cross-origin framing |
| `x-content-type-options` | `nosniff` | no MIME sniffing |
| `x-frame-options` | `SAMEORIGIN` | clickjacking |
| `referrer-policy` | `strict-origin-when-cross-origin` | leak-free referrers |
| `strict-transport-security` | `max-age=31536000; includeSubDomains` | forces HTTPS across `*.corereflex.com` (honored at the HTTPS edge; no `preload` — that is effectively irreversible for every subdomain) |
| `permissions-policy` | `camera=(self), microphone=(self), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=(), interest-cohort=()` | the editor records via getUserMedia; everything else denied |
| `alt-svc` | `clear` | forces clients to forget cached HTTP/3 (QUIC stalls this VPS) |

The CSP is:

```
default-src 'self';
script-src 'self' 'nonce-<per-request>';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' https: data: blob:;
media-src 'self' https: blob:;
connect-src 'self' https: wss:;
font-src 'self' https: data:;
frame-ancestors 'self'; object-src 'none'; base-uri 'self'
```

### Nonce-based `script-src` (no more `'unsafe-inline'` for scripts)

Every request mints a fresh 16-byte random nonce (`randomBytes(16).toString('base64')`).
The static HTML handler — and the server-rendered blog and case-study pages —
stamp **every** inline `<script>` tag with that nonce on the way out
(`html.replace(/<script(?=[\s>])/g, '<script nonce="…"')`), so `script-src` can
drop `'unsafe-inline'` entirely: an injected inline `<script>` without the
unguessable, per-request nonce is refused by the browser. None of the pages use
inline `on*=` handlers (which a nonce cannot cover), so this is a clean
tightening.

Two deliberate consequences:

- **`style-src` keeps `'unsafe-inline'`** — the pages rely on inline `style=""`
  attributes, which nonces cannot authorize. The theme early-paint injection
  also writes a `<style>` block per response.
- **HTML is `cache-control: no-store`** — the HTML body carries a per-request
  nonce, so a shared proxy must never cache it (a stale body's nonce would not
  match the fresh CSP header). Fingerprint-safe static assets
  (`.css .js .png .jpg .svg .webp .ico .woff2`) are still cached aggressively
  (`public, max-age=31536000, immutable`).

When `GA_MEASUREMENT_ID` is set (Google Analytics opt-in), `script-src` is
widened with `https://www.googletagmanager.com https://www.google-analytics.com`
— it stays nonce-only otherwise. First-party analytics
(`POST /api/analytics/event`) needs no CSP change.

The theme cookie (`cr-theme`) that server-stamps `data-theme` onto `<html>` is
sanitized against a strict allowlist in `theme-settings.js` before it touches
the markup, so a poisoned cookie cannot inject attributes or CSS.

Stored user/AI content rendered into server-side HTML (journal posts, case
studies) is escaped at the boundary — `escapeHtml()` in
[`src/blog.js`](../../src/blog.js), used by `blog-render.js` / `blog-markdown.js`
/ `case-study-render.js` — so content can't break out into markup.

---

## Editor (nginx) headers

The editor studio (`editor.corereflex.com`) is a static SPA served by nginx
([`editor/nginx.conf`](../../editor/nginx.conf)) with its own baseline set:

| Header | Value |
|--------|-------|
| `X-Frame-Options` | `SAMEORIGIN` |
| `X-Content-Type-Options` | `nosniff` (re-stated inside the `/assets/` location — nginx does not inherit server-level `add_header` into a location that defines its own) |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `Permissions-Policy` | `camera=(self), microphone=(self), geolocation=(), payment=(), usb=()` |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |

A CSP is **intentionally not set** on the editor host: the Remotion
player/renderer relies on `blob:` workers, `data:`/`blob:` media, and (for some
codecs) wasm eval — a too-strict CSP breaks video playback. `X-Frame-Options`
already blocks the clickjacking vector; a tested editor CSP is a future
hardening step. The apex (`corereflex.com`) keeps the full CSP above.

---

## Authentication & sessions

[`src/auth.js`](../../src/auth.js) and [`src/session.js`](../../src/session.js):

- **Password hashing** — `scrypt` (`N=16384, r=8, p=1`, 16-byte random salt,
  64-byte key), verified with `timingSafeEqual`. No external crypto deps.
- **Tokens** — HS256 JWTs signed with `JWT_SECRET`. The verifier rejects any
  `alg`/`typ` other than `HS256`/`JWT` (blocks `alg:none` and confusion attacks)
  and compares the signature with `timingSafeEqual`. **`JWT_SECRET` must be ≥ 32
  characters** — the app refuses to sign otherwise.
- **Cookies** — `HttpOnly; SameSite=Lax; Path=/`, plus `Secure` in production
  (`NODE_ENV=production`). TTL `AUTH_TOKEN_TTL_SECONDS` (default 12h). The editor
  studio at `editor.corereflex.com` stays same-origin via a cookie-domain rewrite,
  so SameSite=Lax holds.
- **Password reset** ([`src/password-reset.js`](../../src/password-reset.js)) — the
  reset token is a short-lived (30 min) signed JWT bound to a **fingerprint of the
  user's current password hash**, which makes it **single-use** (using it changes
  the hash, invalidating the fingerprint) and self-expiring. The token is delivered
  **out-of-band via the email seam** ([`src/email.js`](../../src/email.js)), never
  in an HTTP response. The request endpoint is **anti-enumeration**: it always
  returns `{ ok: true }` whether or not the email exists. On reset, **all of the
  user's sessions are revoked** (`auth_sessions … set revoked_at`).
- **Account settings** ([`src/account.js`](../../src/account.js)) — `change-password`
  verifies the current password and is rate-limited; `PATCH /api/auth/me` updates
  the profile name after session auth and input validation.

---

## API keys (programmatic auth)

[`src/api-keys.js`](../../src/api-keys.js) +
[`migrations/018_api_keys.sql`](../../migrations/018_api_keys.sql) add a second
principal type for server-to-server use (SDK / MCP / CI): a workspace-scoped
bearer key.

- **Format** — `cr_live_` + 48 hex chars (24 random bytes of entropy). The prefix
  is how `isApiKey()` distinguishes a key from a JWT on the `Authorization: Bearer`
  header.
- **Hash-only storage** — the DB stores **only** `sha256(full key)` (`key_hash`,
  unique) plus a short displayable `key_prefix` (`cr_live_` + first 6 chars).
  The full secret is returned **exactly once** at creation and is never
  retrievable again — a DB dump yields no usable credentials.
- **Resolution** — `resolveApiKeyUser()` looks up the presented key by its hash
  (partial index `api_keys_hash_active_idx … where revoked_at is null`), joins
  through the **creating user's membership**, and returns a full `publicUser`
  context tagged `{ apiKey: { id } }`. A key acts with its creator's role, so all
  existing workspace scoping and credit metering apply unchanged. Revoked keys,
  expired keys (`expires_at`), inactive users, and orphaned keys (creator removed)
  all resolve to nothing.
- **The guard** — `requireApiUser(request)` is the choke point on every
  programmatic route (`/api/json`, `/api/catalog`, the composition engine,
  upscale/image ops, auto-clips, director produce, …): if a `cr_live_` bearer is
  presented it MUST resolve (`401 Invalid or expired API key` otherwise — a bad
  key never silently falls through to the session path); with no key, the normal
  session/JWT path runs so browser callers behave exactly as before.
- **Management is session-only** — `POST/GET /api/keys` and
  `DELETE /api/keys/:id` require a logged-in session (`requireUser`), never a
  key: a leaked key cannot mint or revoke keys.
- **Deploy-gate tolerance** — pre-migration (`42P01` undefined table), key auth
  degrades to null, list returns `{ keys: [] }`, and create/revoke return a clear
  `503` instead of a raw 500.

See [Platform API](platform-api.md) for the full programmatic surface.

---

## Rate limiting

[`src/rate-limit.js`](../../src/rate-limit.js) is a shared, in-memory **sliding-window**
limiter: `rateLimited(key, max, windowMs)` returns `true` when `key` has exceeded
`max` hits in the trailing window. The bucket map self-caps at 5,000 keys (clears
on overflow) to bound memory. It is **per-process** — fine for the current single /
low-replica deploy; swap the bucket store for Redis if the app ever scales to many
replicas.

### Client-IP extraction (`clientIp`)

The rate-limit key is the client IP, resolved in priority order:

1. **`cf-connecting-ip`** — Cloudflare fronts the whole fleet and sets this to the
   true client IP. We prefer it **first** because a client-supplied
   `X-Forwarded-For` can be spoofed to rotate buckets and defeat the limiter.
2. `x-forwarded-for` (first hop) — fallback for non-CF paths.
3. `socket.remoteAddress` — last resort.

`clientIp` is used **only** as a rate-limit key, never for authorization.

### Where limits apply

All windows are per minute. `<ip>`-keyed limits guard **unauthenticated** paths;
`<userId>`-keyed limits guard **billed or DB-heavy** authenticated paths.

**Auth & public pages** ([`src/api.js`](../../src/api.js)):

| Endpoint | Key | Limit |
|----------|-----|-------|
| `GET /api/calendars/:slug/slots` | `slots:<ip>` | 60 |
| `POST /api/calendars/:slug/book` | `book:<ip>` | 10 |
| `POST /api/auth/register` | `register:<ip>` | 5 |
| `POST /api/auth/login` | `login:<ip>` | 10 |
| `POST /api/auth/change-password` | `pwchange:<ip>` | 10 |
| `POST /api/auth/forgot` | `forgot:<ip>` | 5 |
| `POST /api/auth/reset` | `reset:<ip>` | 10 |
| `POST /api/auth/verify` | `verify:<ip>` | 20 |
| `POST /api/auth/resend-verification` | `resendverify:<userId>` | 3 |
| `POST /api/analytics/event` | `analytics:<ip>` | 240 |
| `POST /api/leads` | `leads:<ip>` | 10 |

**Chat widget** ([`src/chat-routes.js`](../../src/chat-routes.js),
[`src/chat-handoff-routes.js`](../../src/chat-handoff-routes.js)) — all public,
so IP-keyed:

| Endpoint | Key | Limit |
|----------|-----|-------|
| `POST /api/chat` (SSE answer stream) | `chat:msg:<ip>` | 12 |
| `POST /api/chat/feedback` | `chat:fb:<ip>` | 30 |
| `POST /api/chat/handoff` | `chat:handoff:<ip>` | 6 |

**Generation, Director & Auto-Clips**:

| Endpoint | Key | Limit |
|----------|-----|-------|
| `POST /api/generate` | `generate:<ip>` | 20 |
| `POST /api/director/plan` | `plan:<ip>` | 20 |
| `POST /api/director/produce` | `produce:<ip>` | 6 |
| `POST /api/auto-clips` | `auto-clips:<userId>` | 8 |
| `POST /api/auto-clips/story` | `story:<userId>` | 12 |
| Auto-clips publish | `autoclips-publish:<userId>` | 6 |
| Auto-clips voiceover | `autoclips-vo:<userId>` | 30 |
| `POST /api/captions/generate` | `captions-generate:<userId>` | 12 |
| `POST /api/highlights/detect` | `highlights-detect:<userId>` | 12 |
| `POST /api/highlights/proxy` | `clip-proxy:<userId>` | 6 |

**JSON engine, agent & collaboration**:

| Endpoint | Key | Limit |
|----------|-----|-------|
| `POST /api/json` (+ composition engine POST) | `json:<userId>` | 120 |
| `POST /api/agent/plan` | `agent-plan:<userId>` | 30 |
| `POST /api/agent/execute` | `agent-exec:<userId>` | 30 |
| `POST /api/agent/ask` | `agent-ask:<userId>` | 60 |
| `POST /api/agent/plan-to-recipe` | `agent-recipe:<userId>` | 20 |
| `GET /api/agent/compositions/:id/events` (SSE) | `agent-stream:<userId>` | 20 |
| Comments SSE stream | `comments-stream:<userId>` | 20 |
| Comment writes | `comments:<userId>` | 60 |
| Recipe run | `recipe-run:<userId>` | 12 |
| Recipe bulk run | `recipe-bulk:<userId>` | 3 |

**Image ops, upscale, audio & media search**:

| Endpoint | Key | Limit |
|----------|-----|-------|
| `POST /api/upscale` | `upscale:<userId>` | 20 |
| `POST /api/image/remove-bg` | `removebg:<userId>` | 20 |
| `POST /api/image/vectorize` | `vectorize:<userId>` | 20 |
| `POST /api/image/edit` | `image-edit:<userId>` | 10 |
| `POST /api/image/subject-box` | `subject-box:<userId>` | 30 |
| `POST /api/audio/fx` | `audiofx:<userId>` | 30 |
| `GET /api/media/search` | `media-search:<userId>` | 30 |

**Content / Write & social publishing**:

| Endpoint | Key | Limit |
|----------|-----|-------|
| `POST /api/content/write` + `/api/content/rewrite` | `content:<userId>` | 30 |
| `POST /api/social/connection` | `social-connect:<userId>` | 10 |
| `GET /api/social/accounts` | `social-accounts:<userId>` | 30 |
| `POST /api/social/posts` | `social-publish:<userId>` | 20 |

**Voice bridge**:

| Endpoint | Key | Limit |
|----------|-----|-------|
| `GET/POST /api/voice/twilio` | `voice:twilio:webhook:<ip>` | 60 |
| `WS /api/voice/live` | `voice:<ip>` | `VOICE_MAX_NEW_PER_IP` (default 5) |
| `WS /api/voice/twilio/media` | `voice:twilio:<ip>` | `VOICE_MAX_NEW_PER_IP` (default 5) |

Over-limit requests get a `429 { error: 'Too many requests' }` (or a
route-specific message). The public booking/auth/chat limits exist because those
endpoints are **unauthenticated**; the generation/agent/image limits exist
because each call costs real Vertex or worker money.

---

## SSRF guard (`net-guard.js`)

Several flows hand a **user-supplied URL** to the server (or the render worker)
to fetch: voice-clone reference audio (forwarded to `VOICE_ENGINE_URL`), persona
reference images (forwarded to `PERSONA_LIPSYNC_URL`), and every worker-fetched
media source — upscale, background removal, vectorize, audio FX, highlight
detection (`https` sources are fetched + inlined by our server; `gs://` URIs are
read Vertex-internally), the clip proxy, and auto-clips sources. Without a
guard, an attacker could point those at `http://169.254.169.254/...` (cloud
metadata) or an internal service.

[`src/net-guard.js`](../../src/net-guard.js) exports `safePublicUrl(raw)`, which
returns a normalized URL string only if it is a **safe public https URL**, else
`null`. It enforces:

- **https only** — `http:`, `file:`, `gopher:` etc. all rejected.
- **No loopback / private / link-local / metadata hosts** — `127.0.0.0/8`,
  `10.0.0.0/8`, `192.168.0.0/16`, `172.16.0.0/12`, `169.254.0.0/16` (which covers
  the `169.254.169.254` cloud-metadata address), `0.0.0.0/8`.
- **No local names** — `localhost`, `*.local`, `*.internal`, `*.localhost`.
- **IPv6** — rejects `::1`, `::`, `::0`, and unique-local / link-local (`fc`,
  `fd`, `fe80`) prefixes.
- **IPv4-mapped / NAT64 IPv6** — the subtle one: `embeddedV4()` extracts an IPv4
  address hidden inside an `::ffff:` or `64:ff9b::` IPv6 literal in **either**
  dotted (`::ffff:169.254.169.254`) **or** hex (`::ffff:a9fe:a9fe`, the form Node
  normalizes to) notation, then applies the IPv4 private-range rules. This closes
  the bypass where `::ffff:a9fe:a9fe` reaches metadata while looking like a benign
  IPv6 address.

**Known limitation (documented in the module):** this validates literal-IP hosts
and obvious local names; it does **not** resolve DNS (the app is dependency-free),
so it is a strong-but-not-complete guard against **DNS rebinding** (a hostname that
resolves to a private IP). Pair it with **network egress rules on the fleet** for
full coverage. Callers reject a non-public URL with a `400`.

---

## Tenant isolation (IDOR fixes)

Every persistable object carries a `workspace_id`, and authenticated queries always
filter by `user.workspace.id`. The cross-tenant gaps that have been closed
explicitly:

### Asset presign + proxy (`storage.js` + `api.js`)

- **Presign is namespaced.** `presignArgs()` in [`src/api.js`](../../src/api.js)
  forces every client upload under `uploads/<workspaceId>/…`, stripping any
  caller-supplied `uploads/<ws>/` prefix and rejecting `..`. A tenant **cannot**
  presign a PUT over another tenant's object key.
- **Reads are key-bound or session-scoped.** The signed asset proxy
  (`GET /api/assets/*`) authorizes a request two ways:
  1. a **capability token** `?t=` — an HMAC of the object key
     (`assetToken()` / `verifyAssetToken()` in [`src/storage.js`](../../src/storage.js),
     compared with `timingSafeEqual`), used for stable URLs in saved compositions
     and system reads; or
  2. a **session** whose `workspaceId` appears as a path segment of the key
     (`keyInWorkspace()` in `api.js`) — every legitimate key is namespaced
     `<ns>/<workspaceId>/…` (`uploads`, `generated`, `renders`, `voice`, …), so a
     logged-in caller can only stream keys inside their own workspace.

### Persona reference media (`personas.js`)

`primaryReferenceUrl()` resolves a persona's reference image to a signed URL only
when the asset lives in the **same workspace** as the reference row
(`where r.id = $1 and a.workspace_id = r.workspace_id`). This is **defense in
depth**: even if a poisoned cross-tenant `asset_id` got into `reference_media`, it
can never resolve to a signed URL. `addReference()` independently checks the asset
is in the caller's workspace before linking.

### Director KB grounding (cross-tenant disclosure fix)

The Director's research-grounded planning (COPILOT-001) retrieves passages from a
workspace's knowledge base and feeds them into the film-planning prompt. The
grounding read is keyed by `workspaceId` — and an adversarial review found the
route originally accepted it **from the client body**, letting a caller point
grounding at **another tenant's knowledge base** and read its content back out
of the generated plan. The fix (in both `/api/director/plan` and
`/api/director/produce` in [`src/api.js`](../../src/api.js)): the
**session-derived** `user.workspace.id` is spread **after** the client body, so
it always overrides any client-supplied value. The agent's KB grounding
([`src/agent-context.js`](../../src/agent-context.js)) was built this way from
the start — `searchKb(user.workspace.id, …)` only.

Note the deliberate exception: the public chat bot's corpus
([`src/chat-kb.js`](../../src/chat-kb.js), table `chat_kb`) is **global and
un-gated by design** — it holds only published marketing/docs/blog content and
answers anonymous visitors. Workspace knowledge (`knowledge_docs`) is a separate,
per-workspace, Pro-gated table.

### Collections

`addToCollection()` in [`src/collections.js`](../../src/collections.js) only
links an asset when **both** the collection and the asset belong to the caller's
workspace — no cross-tenant attach.

---

## Secrets at rest (`crypto-box.js`)

Third-party credentials that must be **recoverable** (a one-way hash won't do —
we make outbound calls with them) are encrypted at rest by
[`src/crypto-box.js`](../../src/crypto-box.js):

- **Cipher** — AES-256-GCM (authenticated encryption: tamper = decrypt failure).
- **Key derivation** — `scryptSync(secret, salt, 32)` where `secret` is
  `SECRET_ENCRYPTION_KEY`, falling back to the `JWT_SECRET` the app already
  requires (≥ 32 chars enforced) — no new secret to provision by default.
- **Format** — self-describing `v1$salt$iv$tag$ct` (all base64url). The 16-byte
  salt is **per-record**, so two equal plaintexts produce different ciphertexts.
- **Degradation** — `tryDecryptSecret()` returns `null` instead of throwing
  (e.g. after a key rotation), so listing routes degrade with a clear
  "reconnect the account" error rather than 500-ing.

**Current consumer:** the social publishing relay
([`src/social.js`](../../src/social.js)). A workspace's GoHighLevel API key is
live-validated **before** storage (a bad key fails fast), then stored as
`api_key_enc = encryptSecret(key)` in `social_connections`. The decrypted key is
resolved **internally only** (`resolveConnection`) for egress calls; status and
connection listings never include the secret or its ciphertext.

If you rotate `JWT_SECRET`, set `SECRET_ENCRYPTION_KEY` to the **old** value
first (or accept that stored connections must be re-connected) — the derivation
root changing makes existing ciphertexts unreadable.

---

## Chat safety: redaction + grounding gate

The public chat widget ([Chat System](chat-system.md)) answers anonymous
visitors with a billed model, so it has its own safety layer in
[`src/chat-safety.js`](../../src/chat-safety.js) +
[`src/chat.js`](../../src/chat.js):

- **Secret redaction, both directions.** `redactSecrets()` strips secret-shaped
  substrings — bearer tokens, JWTs, provider API keys/PATs (OpenAI/Stripe-style
  `sk|pk|rk`, GitHub `ghp_…`, Slack `xox…`, Google `AIza…`, AWS `AKIA…`), and
  `password:`/`key=` assignments — from the **inbound** user text before it
  reaches the model or is stored, and from the **outbound** answer before it is
  streamed/stored. A pasted credential never round-trips through the LLM or
  lands in `chat_messages`. Deliberately **not** redacted: emails and phone
  numbers, which visitors volunteer for lead capture.
- **The grounding gate (honesty gate).** `answer()` retrieves from the global
  `chat_kb` corpus and only generates when `hits.length >= min_sources` **and**
  the top score clears `min_source_score` (settings in
  [`src/chat-config.js`](../../src/chat-config.js)). Otherwise the bot returns
  the configured `no_answer_message` and offers a human — it never invents
  product claims. Every reply records `grounded: true|false` plus source chips.
- **Advisory moderation.** `moderateInput()` flags prompt-injection
  (`ignore previous instructions…`) and credential-probe patterns. v1 is
  `log_only`: flags land in message metadata for God Console review; they do not
  block.
- **System-prompt hardening.** The prompt pins the retrieved context as the
  only source of truth and instructs the model never to reveal keys, passwords,
  tokens, or credentials.
- **Body cap** — the chat routes read at most 64 KiB of JSON, and messages are
  clamped to 4,000 chars.
- **Transcript access control** — `GET /api/chat/conversations/:id/messages`
  requires the owning user, an admin with `support_debug`, or the conversation's
  own `sessionId`; anything else is a `403`.

The same `redactSecrets`/`moderateInput` primitives are reused by the agent (below).

---

## Agent guardrails

The suite-wide agent ([`src/agent.js`](../../src/agent.js), routes in
[`src/api.js`](../../src/api.js)) turns natural language into executable plans —
which makes the **plan itself untrusted input** (LLM- or client-supplied). The
guardrails are deterministic and layered; every one runs at **execute** time too,
not just at plan time, because `/api/agent/execute` accepts a client-echoed plan.

### Policy tiers (per-tier tool allowlists, AGENT-084)

```js
AGENT_POLICIES = {
  full:     ['read', 'mutate', 'generate'],  // default — a normal session
  editor:   ['read', 'mutate'],              // deterministic edits, no credit spend
  readonly: ['read'],                        // shared/proof/embedded surfaces
}
```

`validateAgentPlan()` classifies every step (`classifyStep`) and rejects any step
whose class the tier forbids **before anything else**. Two escalation paths are
closed explicitly:

- **A caller can only downgrade itself.** `resolvePolicy()` resolves unknown or
  absent tiers to `full` — but the tier only ever *removes* classes, and
  `Object.hasOwn` guards the lookup so a prototype-key probe
  (`policy: "__proto__"`/`"constructor"`) can't resolve to an inherited non-Set
  and crash or bypass validation.
- **The execute-path escalation fix.** Read steps (`query`/`describe`) pass
  policy validation freely — and originally their params were spread flat into
  the engine request, so a forged read step carrying `params.kind: 'command'`
  could override the request framing and smuggle a **mutating** command past
  `classifyStep`/policy/approval. `compileReadRequest()` now copies **only** a
  whitelist of query fields (`target, where, select, orderBy, limit, offset,
  aggregate`); `kind` is fixed by the tool name and can never come from params.

### Hard deterministic caps (AGENT-082)

| Cap | Default | Env |
|-----|---------|-----|
| Max steps per plan | 200 | `AGENT_MAX_STEPS` |
| Max worst-case plan cost | 5,000 credits | `AGENT_MAX_PLAN_CREDITS` |
| Bulk delete needing explicit confirm | > 25 items | `AGENT_MAX_BULK_DELETE` |
| Unconfirmed spend threshold | 100 credits | `AGENT_CONFIRM_CREDITS` |
| Unconfirmed generation count | 8 | `AGENT_MAX_UNCONFIRMED_GENERATIONS` |

A plan whose worst-case cost blows the cap is **invalid up front**, regardless of
workspace balance. Bulk-delete counting is **cumulative across the plan** with
**distinct ids** (splitting one big delete into sub-threshold steps can't slip
the gate; padding an id list can't inflate it), keyed on the whole destructive
verb set (`deleteItems`, `cutItems`, and `deleteTrack {deleteItems:true}` — which
always confirms, since it wipes an unbounded implicit set).

### Approval + confirmation floors

- **Two-phase by design**: `/api/agent/plan` has zero side effects;
  `/api/agent/execute` re-normalizes the untrusted client plan through the same
  choke point (`normalizeAgentPlan`), re-binds references against the **server's**
  stored manifest (never `body.state` — otherwise `@last`/`@selected` could
  resolve to a client-forged item and be persisted), re-validates, and enforces
  approval: a plan that mutates or spends without `approved: true` gets a `412`.
- **Approval policies (AGENT-028)**: `auto` / `guided` (default) / `manual`.
  Reads are always auto; **generate + publish/external steps are always gated**,
  every policy; destructive engine verbs stay human-gated **even under `auto`**
  (an adversarial probe deleted a 12-item composition unattended before this
  floor). Unknown policy input resolves to `guided` — never to `auto`.
- **Hard confirm floor (AGENT-029/082)**: `publish` (leaves the platform,
  can't be un-sent), spend above `AGENT_CONFIRM_CREDITS`, batches of more than
  `AGENT_MAX_UNCONFIRMED_GENERATIONS` irreversible generations, and bulk deletes
  all require an explicit `confirm: true` at execute (`assertConfirmed`), on top
  of approval.
- **Budget floor (AGENT-037/038)**: `assertAffordable` refuses a plan the
  workspace can't afford **before** any spend, and a run-scoped ledger hard-stops
  per step during execution.

### Input redaction + moderation

`normalizeAgentPlan()` redacts secret-shaped substrings from every **free-text**
field — the user message (2,000-char clamp), plan summary, and step rationales
(a forged `/execute` plan could carry a secret in metadata) — and runs
`moderateInput()` on the message. The result rides as `plan.safety` on the
approval card and into the `agent_runs` audit ledger. Step **params** are
deliberately *not* blanket-redacted: the model only ever sees the
already-redacted message, and redacting params would corrupt legitimate text
(AGENT-083 review).

KB passages entering the planner prompt are treated as **untrusted data**:
injection-flagged docs are **excluded** from the context bundle
([`src/agent-context.js`](../../src/agent-context.js)), and the exclusion is
surfaced to the human as `kb:*` safety flags on the approval card (AGENT-055) —
merged into `plan.safety` so the signal survives the echo to `/execute` and the
audit row.

### Audit trail

Every execution opens an `agent_runs` row **before** running (migration 032),
records per-step results + committed credits, and closes with a verdict — a
mid-run failure still ledgers the steps that already spent.
`POST /api/agent/ask` is read-only **by construction** (it only reaches engine
`evaluate()` and the runs readers — no mutation path exists).

---

## Proof review tokens

Client proofing ([`src/proofing.js`](../../src/proofing.js),
[`migrations/022_proofing.sql`](../../migrations/022_proofing.sql)) shares a
deliverable with an external reviewer over a tokenized link — no login.

- **The token is the grant.** `pr_` + 48 hex chars (24 random bytes — 192 bits
  of entropy, unguessable), stored `unique` in `proof_reviews`. The public
  routes (`GET /api/proofs/review/:token`, `POST …/comments`, `POST …/decision`,
  `GET …/asset/:versionId`) take **no session**; possession of the token
  authorizes exactly one proof's review surface, nothing else.
- **Workspace-bound deliverables.** Creating a version checks the referenced
  asset/document belongs to the proof's workspace (no cross-tenant references),
  and a DB `CHECK` enforces exactly one deliverable kind per version.
  `getReviewAssetUrl` only signs an asset URL for a version that belongs to the
  token's own proof.
- **Untrusted region input.** Pin-comment regions (`{x,y,w,h}`) coming from the
  public token surface are sanitized/clamped before storage.
- **Owner routes** (`/api/proofs`, versions, share, comment-resolve) require a
  session and are Pro-gated inside `proofing.js`. The `/proof/:token` page is
  `Disallow`ed in robots.txt so review links stay out of search indexes.

---

## Content credentials (Ed25519 provenance)

CoreReflex signs a **detached provenance record** for generated/rendered assets
([`src/content-credentials.js`](../../src/content-credentials.js), signing
primitives in [`src/crypto-box.js`](../../src/crypto-box.js)) — including an
explicit `aiGenerated` flag, model/provider, and a sha256 of the asset bytes.

**Honest-messaging contract (load-bearing):** this is **NOT C2PA**. No JUMBF box
is embedded in the file, no X.509 chain, and it will not verify in Adobe Content
Authenticity tools. The public responses say so (`notC2pa: true` + a plain
disclosure string). Never render the C2PA logo or claim compliance.

- **Keys** — an Ed25519 keypair derived **deterministically** from
  `CREDENTIAL_SIGNING_KEY` (falling back to `SECRET_ENCRYPTION_KEY`, then
  `JWT_SECRET`): the 32-byte seed is `sha256("content-credential:" + secret)`
  wrapped in a fixed PKCS8 prefix. Set a dedicated, **stable**
  `CREDENTIAL_SIGNING_KEY` in prod so rotating the auth secret can't invalidate
  every signature.
- **Signing** — claims are canonically serialized (sorted keys) so verification
  hashes byte-identical input; the signed payload carries a short `pubkeyId`
  (sha256 of the SPKI, 12 hex chars) for key matching.
- **Verification** — `verifyAgainstBytes()` requires the signature to be valid
  **and** the stored `assetHash` to match a fresh hash of the live bytes
  (tamper = `hashMatches: false`). `verifyCredential` never throws.
- **Public endpoints** — `GET /api/assets/<key>/credentials` verifies an asset's
  credential for **anyone** (no ownership gate) but returns `publicView()`,
  which **redacts the prompt** (it rides inside the signed payload for
  integrity, but a public endpoint must not leak client prompt text).
  `GET /api/credentials/pubkey` publishes the signing public key (PEM + id) so
  anyone can verify a credential offline.
- **Fail-open minting** — `tryMintCredential()` returns null on any failure so a
  missing signing key can never fail a generation or render.

---

## Booking transaction (no double-booking)

`book()` in [`src/booking.js`](../../src/booking.js) runs inside a real
transaction (`withTransaction`). The race it closes: two concurrent public bookers
grabbing the same slot.

1. `pg_advisory_xact_lock(hashtext(assigneeId))` serializes all in-flight bookings
   for one assignee. This **must** run in a transaction — under a plain
   auto-committing connection the advisory lock would release immediately and guard
   nothing.
2. With the lock held, it re-checks for an overlapping `calendar_events` row
   (`starts_at < $end and ends_at > $start`, excluding `cancelled`) and throws
   `409` if the slot was just taken.
3. The contact upsert, calendar event, booking row, and CRM activity all commit
   atomically — a mid-way failure rolls back instead of orphaning a
   `calendar_events` row.

A `min_notice_min` check rejects bookings too close to now. The `booking_created`
automation fires **after** commit (best-effort) so a webhook failure can't fail the
booking. Inputs (`notes`, `phone`) are length-clamped.

---

## Billing idempotency (no double-credit)

A retried Stripe webhook (Stripe retries on any non-2xx, and on timeouts) could
double-credit an account. `grantCredits(accountId, amount, reason, { dedupe })` in
[`src/billing.js`](../../src/billing.js) makes a grant idempotent on a **stable
reason** — `grant:checkout:<sessionId>` on activation,
`grant:invoice:<invoiceId>` on monthly renewal.

The guarantee is enforced at the **database level** by
[`migrations/011_billing_credit_idempotency.sql`](../../migrations/011_billing_credit_idempotency.sql):

```sql
create unique index if not exists credit_ledger_grant_idem_idx
  on credit_ledger (account_id, reason) where delta > 0;
```

A **partial** unique index — only on positive deltas (grants/purchases). Usage
debits (`delta < 0`) are deliberately excluded, so the same `usage:*` reason can be
metered many times. `grantCredits` claims the slot with
`insert … on conflict (account_id, reason) where delta > 0 do nothing returning id`:
if the row already existed, nothing was inserted and the balance is left untouched.
This wins even under a true concurrent-retry race, where the prior SELECT-then-INSERT
dedupe could lose. There's a graceful fallback to the SELECT dedupe if migration
011 isn't applied yet (Postgres error `42P10`), so an enabled-billing webhook never
500s. Billing is inert until `BILLING_ENABLED=true`.

---

## God Mode admin escalation guard

`upsertAdmin()` in [`src/admin-api.js`](../../src/admin-api.js) is the privilege-
delegation surface. Two rules block escalation:

1. **No self-edit.** You cannot edit your own admin row
   (`email === actor.profile.email` → `403`). This stops an admin from minting
   `god_mode` / `manage_admins` for themselves.
2. **Privilege subset only.** The granted privileges are filtered to those the
   actor **already holds**: `normalizePrivileges(input.privileges).filter(p => actorPrivs.includes(p))`.
   A lesser admin (if ever given `manage_admins`) cannot grant a privilege above
   their own level. Owner holds every privilege, so normal delegation is
   unaffected.

Every admin route also requires a non-impersonating session
(`requireAdmin` throws if `user.impersonation` is set — *"Return to your admin
session before using God Mode controls"*) and the specific privilege via
`canAdmin()`. Impersonation runs in a transaction, records an
`impersonation_sessions` row, and writes an `admin_audit_events` entry; **every**
admin action (`admin.granted`, `impersonation.started/ended`, `admin.overview.viewed`)
is audit-logged.

---

## Voice-bridge abuse caps

`/api/voice/live` (the browser Gemini Live WebSocket bridge) and
`/api/voice/twilio/media` (Twilio Media Streams) are **unauthenticated and billed**
— every accepted session mints a Vertex token and opens a metered audio stream.
They are reached via HTTP `Upgrade`, which is **not subject to CORS**, so a normal
CORS policy protects nothing here. The bridge ([`src/voice-agent/index.js`](../../src/voice-agent/index.js)
and [`bridge.js`](../../src/voice-agent/bridge.js)) layers caps, all rejecting
**before** any handshake / token mint / upstream connect:

| Guard | Default | Env |
|-------|---------|-----|
| **Origin allowlist** | browser `/api/voice/live`: same-origin always allowed; missing Origin (non-browser) allowed but rate-limited | `VOICE_ALLOWED_ORIGINS` (CSV) |
| **Twilio webhook signature** | `GET/POST /api/voice/twilio` verifies `X-Twilio-Signature` when configured before returning TwiML | `TWILIO_AUTH_TOKEN` |
| **Per-IP new sessions** | 5 / min for both browser WS and Twilio media WS | `VOICE_MAX_NEW_PER_IP` |
| **Global concurrent sessions** | 50 | `VOICE_MAX_CONCURRENT` |
| **Hard session lifetime** | 10 min | `VOICE_MAX_SESSION_MS` |
| **Idle timeout** (no client audio/text) | 90 s | `VOICE_IDLE_MS` |

The Origin allowlist is the browser gate for a CORS-exempt upgrade: a third-party
page can't drive our billed bridge. Twilio calls start at `/api/voice/twilio`,
which returns signed/verified TwiML that connects to `/api/voice/twilio/media`;
the media socket then reuses the same per-IP and global caps as the browser
bridge. The lifetime + idle timers (in `bridge.js`) stop a stuck or abusive
socket bleeding billed Live audio forever. The session counter is decremented
exactly once on close or error, and an `error` listener is always attached so a
socket error can't crash the process. The **service-account token never leaves
the server** — browsers and Twilio only ever talk to us; we hold the Vertex Live
connection.

---

## Consent gates (biometrics)

Voice clones and persona likenesses are biometric data, so they sit behind a
**hard consent gate** that fails closed.

- **Persona render** — `renderPersonaClip()` in [`src/personas.js`](../../src/personas.js)
  throws `403` unless `persona.consent_status === 'granted'`. Consent is recorded by
  `grantConsent()` (who attested, when, the attestation scope, `is_self`); revoking
  flips the persona to `disabled`. A persona can **never** be rendered without
  granted consent.
- **Voice clone** — `cloneVoice()` is equally gated (`403` until consent granted).
  The Chirp 3 Instant Custom Voice path mints a cloning key only from a reference +
  a **spoken consent recording** matching Google's exact consent script
  (`consentScriptFor()` in [`src/voice.js`](../../src/voice.js)), and is itself
  allow-list gated behind `VOICE_CLONE_ENABLED` (Google approval). The self-hosted
  open-engine path is zero-shot (the 6–10s reference rides with each synthesis; no
  key, no training) — see [Own The Stack](own-the-stack.md).
- Reference / consent URLs handed to an engine are **SSRF-guarded** (above).
- Voice/persona features are also **plan-gated** (pro+ entitlement) before any of
  this runs.

---

## DoS / abuse hardening

- **Body cap** — JSON/text bodies are capped at **1 MiB** (`MAX_BODY_BYTES`) in
  `readBodyBuffer()` ([`src/api.js`](../../src/api.js)); over-limit returns `413`
  without destroying the socket. One deliberate exception:
  `/api/image/edit` and `/api/image/subject-box` carry inline base64 frames (no
  storage fetch → no SSRF surface) and read up to 12 MiB
  (`IMAGE_EDIT_MAX_BODY_BYTES`). Chat routes cap at 64 KiB. Asset PUTs stream
  straight to storage and are capped separately in `uploadAsset`.
- **Slowloris / slow-body** — `server.headersTimeout` (default 60s,
  `HEADERS_TIMEOUT_MS`) and `server.requestTimeout` (default 300s,
  `REQUEST_TIMEOUT_MS`) in [`src/server.js`](../../src/server.js) bound how long a
  client may take to send headers / a whole request. Generous so large asset
  uploads still complete on slow links.
- **Per-request Vertex timeouts + backoff retry** on every upstream AI call, so a
  hung provider can't tie up a connection indefinitely (see
  [`src/vertex.js`](../../src/vertex.js)).
- **Plan-size + spend caps on the agent** — see [Agent guardrails](#agent-guardrails):
  a client-supplied plan can't compile an unbounded synchronous batch or a
  runaway spend. Similarly, `/api/director/produce` clamps a hand-crafted
  `body.plan` to `MAX_SHOTS` and meters **once per shot** (an unclamped plan was
  an unbounded vendor-cost vector even with billing off).
- **Process backstop** — `uncaughtException` / `unhandledRejection` are logged and
  swallowed in `server.js`; a stray socket error must not take the single-process
  server down. Per-connection handlers do the real cleanup.

---

## Error masking

The top-level `catch` in [`src/api.js`](../../src/api.js) masks internal
failures: any `5xx` returns `{ error: 'Server error' }` and logs the real
message server-side. Sub-5xx errors, and errors explicitly flagged
`error.expose = true` (e.g. the `503` "captions/voice not configured" states the
editor surfaces), return their real message. **New code that throws a user-facing
4xx/503 should set `expose: true`** via the module's `httpError()` helper; raw `5xx`
detail is never leaked to the client. The agent execute route mirrors the same
contract for its structured failure reports — a masked 5xx doesn't leak detail
through `failure.message` either.

---

## CORS

`corsHeaders()` ([`src/api.js`](../../src/api.js)) reflects the request `Origin`
only when it is in the `CORS_ALLOWED_ORIGINS` allowlist (default
`https://editor.corereflex.com`), and sets `Access-Control-Allow-Credentials: true`
with `Vary: Origin`. An un-allowlisted origin gets **no** CORS headers (the browser
blocks the cross-origin read). Note CORS does **not** protect the WS bridge — that's
the Origin allowlist's job (above).

---

## The cost-free pre-push gate (`.githooks/pre-push`)

The fleet's standing cost policy is **no cloud CI** (no GitHub Actions billing)
unless the owner asks for it. Instead, [`.githooks/pre-push`](../../.githooks/pre-push)
runs `npm run lint` + `npm test` locally before every push, so nothing reaches a
Coolify deploy without them passing — at zero cost.

Enable once per clone:

```bash
git config core.hooksPath .githooks
```

If lint or tests fail, the push is blocked (`set -e`). Keep the suite green. See
[Contributing](contributing.md) and [Deployment](deployment.md) for the rest of the
release flow.

---

## Operator checklist

Before exposing the app:

- [ ] `JWT_SECRET` set, **≥ 32 chars**, unique per environment.
- [ ] `ASSET_TOKEN_SECRET` set (else asset capability tokens fall back to `JWT_SECRET`).
- [ ] `SECRET_ENCRYPTION_KEY` set if you ever plan to rotate `JWT_SECRET` (it is the
      at-rest encryption root for stored third-party credentials).
- [ ] `CREDENTIAL_SIGNING_KEY` set to a dedicated, **stable** value (content-credential
      signatures survive auth-secret rotation).
- [ ] `NODE_ENV=production` (turns on the `Secure` cookie flag).
- [ ] `CORS_ALLOWED_ORIGINS` and `VOICE_ALLOWED_ORIGINS` scoped to your real domains.
- [ ] Fleet **egress firewall** in place (the SSRF guard does not resolve DNS — pair it).
- [ ] `git config core.hooksPath .githooks` on every dev clone.
- [ ] `VOICE_CLONE_ENABLED` left **unset** until your Google project is allow-listed.
- [ ] All migrations applied via `npm run db:migrate` — currently **001–033**
      (billing idempotency 011, API keys 018, proofing 022, chat/self-heal 027,
      agent runs 032, workspace theme 033). Note the tree carries **two files
      numbered 014** (`014_automation_schedule.sql` and `014_email_verification.sql`)
      — both must be applied; verify `schema_migrations` lists both.

---

Related: [Architecture](architecture.md) · [Configuration](configuration.md) ·
[Platform API](platform-api.md) · [Chat System](chat-system.md) ·
[Own The Stack](own-the-stack.md) · [Billing](billing.md) ·
[Deployment](deployment.md) · [Contributing](contributing.md).
