# Security

> How Voltro handles security — reporting a vulnerability, the supply-chain gates (dependency audit, inbound-license allowlist, SBOM), supported versions, and why self-hosting keeps your data yours.



---

<!-- source: en/security/overview.md -->
## Overview

_How Voltro handles security — reporting a vulnerability, the supply-chain gates (dependency audit, inbound-license allowlist, SBOM), supported versions, and why self-hosting keeps your data yours._

Security is a first-class concern, not an afterthought. This page is the public summary of how we handle it; the machine-readable policy also ships as a `SECURITY.md` inside every `@voltro/*` package.

## Reporting a vulnerability

Please report suspected vulnerabilities **privately** — never in public issues or channels.

- **Email [support@voltro.dev](mailto:support@voltro.dev)** with the subject `SECURITY`.
- Include the affected package(s) and version(s), the impact, and a minimal reproduction if you have one.

You'll get an **acknowledgement within 3 business days**, an assessment once we've reproduced the issue, and a **coordinated disclosure** timeline agreed with you — with credit in the release notes if you'd like it. We don't run a paid bug-bounty program yet, but we genuinely value responsible disclosure.

## Procedures are default-DENY

Every wire-exposed procedure must declare **who may call it**. A `*.query.ts` /
`*.mutation.ts` / `*.action.ts` / `*.stream.ts` that declares neither `guards:`
nor `openAccess:` is refused at boot — by `voltro dev`, by `voltro serve`, and by
`voltro doctor` as a preflight.

Before this, `guards:` defaulted to "allowed": a discovered procedure with no
guard was callable by any authenticated session. Nothing in the code said so,
and the only structural check was a report a human had to run.

```ts
guards: [{ scope: 'invoices:read' }]          // the caller must hold a scope
openAccess: 'public pricing, no caller data'  // anyone may call it — and why
internal: true                                // not on the wire at all
```

`openAccess` takes a reason rather than a boolean on purpose: without an explicit
way to declare an endpoint open, the only way to satisfy the gate is to invent a
guard, and the guard people invent is one every caller already holds. That reads
as protection and enforces nothing.

The whole app can opt out with one field — `security: { defaultDeny: false }` in
`app.config.ts`. There is no environment variable for it: the only direction
anyone reaches for is off, and an env var is how that becomes permanent in one CI
job with no diff to review. Full detail — including how plugin routes are
covered per-request in the dispatch spine rather than by the boot gate — in
[Authorization](/docs/authentication/authorization).

### A decode failure on a guarded procedure is NOT a guard verdict

The payload is decoded before the handler runs, so a guard on a procedure with a
malformed payload never gets the chance to refuse. Calling one with an incomplete
payload therefore answers with a decode error, not a `ScopeError` — and reading
that as "the guard is not applied" is the wrong conclusion in the dangerous
direction.

That conclusion is easy to reach while auditing a guard, and flipped
back the moment they sent a complete payload. So a guarded procedure says it in
the failure:

```
workAreas.create input (guarded — the guard did NOT run: the payload failed to
decode first, so this says nothing about access)
└─ ["type"] └─ is missing
```

**Measure a guard with a VALID payload.** The ordering itself is not fixable at
that seam — the request never reaches the handler or the auth middleware, so
"evaluate guards first" means replacing the protocol layer rather than annotating
a schema. An `openAccess:` declaration is not an enforced guard and gets no such
sentence.

## Outbound HTTP is SSRF-guarded by default

The `HttpClient` your handlers `yield*` refuses internal targets:

- loopback, RFC-1918, CGNAT and link-local addresses — including the
  `169.254.169.254` cloud-metadata endpoint
- the hostnames `localhost`, `*.internal`, `*.local`
- any non-`http(s)` scheme

**Every redirect hop is revalidated, not just the URL you passed.** A public URL
that `302`s to the metadata endpoint is the actual attack; checking only the
initial target catches none of it.

This matters because a caller-supplied URL is ordinary product surface — a
scraper, a webhook-registration form, an importer, a "test this connection"
button. Those reach `HttpClient` with whatever the user typed.

### Handling a refusal

A blocked request fails on the **error channel**, as an `HttpClientError.RequestError` whose `cause` is the `SsrfBlockedError`. So the handler that supplied the URL can decide what it means:

```ts
yield* enrich(userSuppliedUrl).pipe(
  Effect.catchTag('RequestError', (e) =>
    e.cause instanceof SsrfBlockedError
      ? Effect.succeed(none)          // an optional enrichment: skip it
      : Effect.fail(e)),              // a real transport failure: keep failing
)
```

Two things worth knowing if you are upgrading from a version before this was a failure:

- **It used to be a defect** (`Effect.die`), so code that handled it did so with `Effect.catchAllDefect`. That arm no longer fires. If you also have an `Effect.mapError` above it, the rejection now reaches `mapError` FIRST — and a delivery that classified an SSRF block as *terminal* in the defect arm will be reclassified as whatever `mapError` returns. A deployment hit exactly this: a permanent condition became retryable and burned the full backoff budget re-attempting a request that can never succeed. Nothing in the type system can see that, because the error channel already carried `HttpClientError`.
- **`description` names the policy** (`SSRF policy: blocked private/reserved address …`), which is what you want in a log line; the `cause` is what you want in a branch.

### Allowing a target on purpose

```ts
// app.config.ts
export default defineApiConfig({
  http: { allowHosts: ['*.svc.cluster.local', 'billing.internal'] },
})
```

An entry may be an exact host, a `*.suffix` wildcard (which does **not** match the
apex — one that did would silently widen your exception), or `host:port` when only
one port should be reachable.

There is no boolean off-switch, deliberately: *"we call one internal service"* and
*"we do not check URLs"* are different postures, and a boolean cannot tell them
apart six months later.

### In tests, allow the stub — don't mock the guard

Use the same `allowHosts` to reach a local stub server (`['127.0.0.1:8787']`).
That keeps the test on the real guarded code path with a narrow exception. Mocking
the guard away instead means the production path is never exercised — which is
exactly how this framework's own webhook delivery once ended up behind a
`NODE_ENV` check.

### What this does NOT cover

DNS is not resolved. A public hostname that *resolves* to a private address (DNS
rebinding) still passes. That vector needs network-layer egress control; it is
stated here rather than silently implied.

## Where the transport settings live

Everything in the next three sections — the origin check, which proxies may
forward a client IP, and the response headers — is one `security:` block in
`app.config.ts`, read identically by `voltro dev` and `voltro serve`:

```ts
// app.config.ts
export default {
  security: {
    // Cross-site protection for every state-changing request. Default 'same-origin'.
    originGuard: 'same-origin',
    // Extra origins to accept — a split web/api deployment.
    allowedOrigins: ['https://app.example.com'],
    // Whose x-forwarded-for to believe. Empty (the default) ignores the header.
    trustedProxies: ['private'],
    // Security response headers. On by default; 'strict' applies the api policy to HTML too.
    headers: { mode: 'default' },
  },
}
```

Every field has a safe default, so an app that writes none of this is already
protected. Each also has an environment override, listed with its section below
— use those to correct a deployment you cannot rebuild, and the config file for
everything else. A security decision that exists only as an exported variable on
one machine is invisible to code review.

## Cross-site requests are refused

**Every request that can change state is checked** — anything but `GET`, `HEAD`
and `OPTIONS` — plus **every WebSocket upgrade**, which is a GET: the rpc
socket's `/ws` AND every raw gateway path a `*.ws.ts` file mounts. That is deliberately a
rule about the METHOD rather than a list of paths, because a mutation reaches
your api by more than one road: `POST /rpc`, every REST route projected from a
`publicApi:` mutation, everything in `apiConfig.restRoutes`, and
`POST /v1/api-keys`. They all resolve the caller through the same auth chain,
so they are all reachable **with the session cookie automatically attached**.

A browser request is accepted when its `Origin` matches the `Host` it was
addressed to, or is in your allowlist. Anything else gets `403 origin not
allowed` — including `Origin: null`, which is what a sandboxed iframe sends, and
a request with no `Origin` whose `Sec-Fetch-Site` says `cross-site`.

Before this, framework-wide CSRF protection rested entirely on the
`SameSite=Lax` cookie default — one caller-overridable attribute that still
permits top-level-navigation POST, and that does nothing at all for a
bearer/JWT flow.

Reads are not checked: a `GET` cannot be a cross-site write, and checking it
would break every link into your api.

### Raw WebSocket gateways are guarded before the upgrade

A [`defineWebSocket` gateway](/docs/data/subscriptions#raw-websocket-gateways--definewebsocket)
mounts its own upgrade path beside the rpc socket, and the listener treats it
exactly like the rpc upgrade:

- **Origin-checked BEFORE the upgrade.** An upgrade is a GET, so the
  state-changing rule above never covers it — every gateway path therefore
  joins the upgrade origin guard's set, and a cross-origin upgrade is `403`.
  That closes cross-site WebSocket hijacking for every gateway, not only the
  framework's own socket.
- **Authenticated BEFORE the upgrade.** `auth` is required with no default:
  `'subject'` resolves the caller through the SAME auth chain as rpc/SSR and
  answers `401` while the request is still plain http — no socket ever exists
  for an unauthenticated caller — and the connection is bound to the
  credential's expiry (application close code `4001`, so a foreign client can
  re-auth and reconnect). `'public'` is a decision somebody wrote down, not a
  default anyone fell into.

### The web listener's `/form/*` endpoint is checked the same way

`<AutoForm>`'s no-JavaScript fallback POSTs to `/form/<mutationTag>` on the
**web** listener — mounted on both boot paths, `voltro dev` and `voltro start`.
That is a state-changing surface too, and it is classified by the same
`classifyRequestOrigin` as the api rpc listener: a cross-origin form POST gets
the same `403`.

Behind the check, the endpoint forwards the request server-side to the api as
`POST /rpc` — auth middleware, guards and rpc interceptors run identically to
the normal rpc path, with the session resolved from the cookie exactly as an
SSR render resolves it.

One boundary, stated explicitly: `@voltro/plugin-ratelimit`'s **pre-auth IP
shield** runs only on the api listener and never sees `/form/*`. The
ratelimit **rpc interceptor** does see every write, because the endpoint
reaches the api as `POST /rpc` like any other mutation.

### A split web/api deployment must declare its origins

If your web app is served from a different origin than your api, say so — or
every mutation, every REST write and every socket from that page will 403:

```ts
// app.config.ts
export default {
  security: {
    allowedOrigins: ['https://app.example.com', 'https://admin.example.com'],
  },
}
```

Same-origin apps (the default layout, and anything behind one ingress) need no
change. `security: { originGuard: 'off' }` disables the check; a misspelled
value falls back to **enforcing**, never to off.

Environment overrides, for a deployment you cannot rebuild:
`VOLTRO_ALLOWED_ORIGINS` (comma-separated) and `VOLTRO_ORIGIN_GUARD=off`. If you
embed the runtime yourself, the same values are
`RpcServerOptions.security.originGuard` / `.allowedOrigins`.

### Server-to-server callers are exempt, and that is deliberate

A request carrying neither `Origin` nor `Sec-Fetch-Site` did not come from a
browsing context, so it is allowed. That covers:

- **SSR loaders** — `voltro dev` / `voltro start` render pages server-side and
  their loaders call the api from node, which attaches no origin header. Without
  this exemption, server-side first paint would break on every page with a
  loader.
- mobile SDKs, `curl`, and any other service calling your api.

A CSRF attack needs the victim's ambient credentials, and only a browser attaches
those — a browser cannot be made to omit `Origin` on a cross-origin POST or a
WebSocket handshake. An attacker's own server can POST without one, but it has no
session to ride: that is simply an unauthenticated request, and auth and guards
still apply to it.

Origins are compared by **authority** (host + port), not scheme. Behind a
TLS-terminating ingress your app sees plain http while the browser reports
`https://…`, and there is no unforgeable way to learn the external scheme.

### Local development is not a special case you have to configure

When **both** the origin and the `Host` are loopback (`localhost`, `127.0.0.1`,
`*.localhost`), the request is accepted. That is the `voltro dev` layout: the web
dev server proxies the api with `changeOrigin: true`, so the api sees
`Host: localhost:4000` while the browser correctly reports
`Origin: http://localhost:5190`. Strictly compared those differ, and every dev
session would lose its websocket.

Both sides must be loopback. A production api on `api.example.com` still refuses
`Origin: http://localhost:5190` — a browser cannot be made to claim a loopback
origin it is not on.

**Testing a dev server from a phone on your wifi is the one case that needs a
line.** The page is then `http://192.168.1.5:5190`, which is not loopback, so the
socket is refused and the api logs `refused cross-site request` naming the
origin. Add it while you test:

```ts
// app.config.ts
export default {
  security: {
    allowedOrigins: ['http://192.168.1.5:5190'],
  },
}
```

The carve-out is deliberately not widened to "both sides are private
addresses": on a self-hosted internal deployment that would accept any other
host on the same network, which is a different claim than "the attacker already
runs code on this machine".

### Routes that a third party legitimately POSTs to

A few surfaces are outside the check, because their caller cannot be a browser
riding your user's cookie:

- **The inspect surface** (`/_voltro/inspect/*`) — token-gated, and meant to be
  read cross-origin by the local and cloud dashboards.
- **Incoming webhooks** (`*.webhook.tsx`) — authenticated by the sender's
  signature, which the framework refuses to boot without.
- **Plugin routes that declare it.** First-party examples:
  `@voltro/plugin-sso-saml` (`/saml` — the IdP makes the browser form-POST a
  signed assertion, which IS a cross-site POST), `@voltro/plugin-storage`'s
  upload routes (a signed upload ticket, plus their own CORS allowlist),
  `@voltro/plugin-billing`'s webhook and `@voltro/plugin-scim` (bearer-only).

If you write a plugin HTTP route in that category, declare it:

```ts
import type { PluginHttpRoute } from '@voltro/protocol'

const route: PluginHttpRoute = {
  method: 'POST',
  path: '/partner/callback',
  // The signature on the body is the authority — not the session cookie.
  originGuard: 'exempt',
  handle: async (req) => {
    if (!verifySignature(req)) return { status: 401 }
    return { status: 204 }
  },
}
```

Apply exactly one test before you write that line: *if an attacker's page makes
a browser send this request with your user's cookies attached, does anything
happen?* If the answer is "no — it still needs a signature, a bearer token or a
signed ticket the attacker does not have", the route is exempt. Otherwise it is
not. The exemption covers the route's whole path prefix, not the sub-paths its
handler branches on.

## Shared (isr) renders never see credentials

An `isr` page's HTML is cached and served to every visitor inside its
revalidate window — a shared artefact. The framework therefore strips
credential material before the render runs: the cookie jar (except
`voltro:locale`), `authorization`, and every `x-voltro-*` header never reach an
isr page's loaders, `ctx.query`, or `useServerRequest()`. A subject-reading
loader gets the anonymous answer instead of caching one user's data for
everyone, and the strip applies identically under `voltro dev` and
`voltro start`. Details: [Render modes → isr renders are
anonymous](/docs/routing/render-modes).

## The client IP comes from the socket, not from a header

`x-forwarded-for` is a request header, so **any client can write it**. Voltro
ignores it unless you declare which proxies are allowed to forward — the address
used for rate limiting, geo-blocking and audit records is
`socket.remoteAddress`, the one value nobody upstream of the kernel can forge.

If you run behind a load balancer, ingress or CDN **and** rate-limit or geo-block
per IP, declare it:

```ts
// app.config.ts
export default {
  security: {
    trustedProxies: ['private'],        // RFC1918 + CGNAT + link-local + unique-local
    // ['loopback']                     — local / docker-compose
    // ['10.0.0.0/8', 'fc00::/7']       — explicit CIDRs
    // ['2']                            — trust two hops
    // ['*']                            — any peer; only when your ingress
    //                                    OVERWRITES rather than appends
  },
}
```

The environment override is `VOLTRO_TRUSTED_PROXIES`, comma-separated
(`VOLTRO_TRUSTED_PROXIES=10.0.0.0/8,fc00::/7`).

With a list configured, the peer that opened the connection must itself be
trusted, and the chain is walked right-to-left past every declared proxy — so a
client that prepends a fake hop cannot push the resolved address further left.
The same setting decides whether `x-forwarded-proto` is believed, which is what
gates HSTS below.

Without it, a per-IP limiter still works; it just counts every request against
your proxy's address instead of the caller's. Embedders pass the same list as
`RpcServerOptions.security.trustedProxies`.

### The same address reaches your plugin routes

A plugin HTTP route receives it as `req.remoteAddr` — already resolved through
the policy above. **Use that, never `req.headers['x-forwarded-for']`.** The
built-in auth routes do: what `@voltro/plugin-auth` and
`@voltro/plugin-auth-social` write into `sessions.ipAddress` is the resolved
address, so the column a breach investigation reads cannot be chosen by the
caller.

```ts
import type { PluginHttpRoute } from '@voltro/protocol'

const route: PluginHttpRoute = {
  method: 'POST',
  path: '/partner/callback',
  handle: async (req) => {
    // Resolved once per request; `undefined` only when there is no socket
    // address (a unix socket, or a hand-built request in a test).
    await audit(req.remoteAddr ?? null)
    return { status: 204 }
  },
}
```

## Security headers ship by default

Every response from the api listener carries:

```
content-security-policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
x-frame-options: DENY
referrer-policy: no-referrer
x-content-type-options: nosniff
strict-transport-security: max-age=15552000; includeSubDomains   (https only)
```

`default-src 'none'` is safe here because this listener serves the **api** —
rpc JSON, the socket upgrade, inspect routes, webhooks, plugin routes — not your
web app's HTML, which is a different listener. A `text/html` response from a
plugin route gets a relaxed policy instead
(`frame-ancestors 'none'; base-uri 'none'; object-src 'none'`), so a docs page
still renders while clickjacking and base-tag injection stay closed.

HSTS is sent **only over https** and deliberately never carries `preload`:
preload is effectively irreversible for a domain, so it has to be your decision.
`Cross-Origin-Opener-Policy` and `Cross-Origin-Resource-Policy` are not defaulted
either — guessing them breaks a legitimate cross-origin dashboard — but you can
add them.

A route that sets a header itself always wins; the framework only fills gaps.

The whole policy is one field, including an `extra` bag for headers the
framework does not default:

```ts
// app.config.ts
export default {
  security: {
    headers: {
      mode: 'default',                              // 'off' | 'default' | 'strict'
      hsts: 'max-age=31536000; includeSubDomains',  // or false to drop it
      extra: { 'permissions-policy': 'geolocation=()' },
    },
  },
}
```

`mode: 'strict'` applies the api policy to HTML too. That is the documented
trade: it blanks `@voltro/plugin-openapi`'s `/docs` page, which loads its viewer
from a CDN. Choose it when this listener serves no HTML you own.

Environment overrides: `VOLTRO_SECURITY_HEADERS` (`off|default|strict`),
`VOLTRO_CSP`, `VOLTRO_CSP_HTML`, `VOLTRO_HSTS` — each accepting `off` to drop
just that one. Embedders pass the same object as
`RpcServerOptions.security.headers`.

### A nonce-based CSP for the web app's HTML

The headers above are the **api** listener's. For the web app's rendered HTML,
`middleware.ts` can mint a per-request `cspNonce`: the framework stamps
`nonce="…"` onto every script tag of that render — the state script, the
deferred registry, the shell's bundle tags, and React's own bootstrap/settle
scripts — while the policy header itself stays the middleware's to set via
`responseHeaders`, carrying the same nonce. An `isr` page combined with
`cspNonce` refuses the render: a cached nonce is a lie the browser enforces.
Mechanics + examples: [Middleware → cspNonce](/docs/routing/middleware#a-per-request-csp-nonce--cspnonce).

## Incoming webhooks must verify their caller

An incoming webhook is a **public POST that runs your application code**. A
`defineIncomingWebhook({...})` that declares no signature scheme now refuses to
boot, naming the endpoint.

```ts
export default defineIncomingWebhook({
  id: 'orders.paid',
  provider: stripeWebhookProvider(),   // HMAC scheme + replay window + idempotency
  payload: OrderPaid,
  handler: async (ctx) => { /* the caller is already verified here */ },
})
```

Four ways to satisfy it:

| Declaration | Means |
|---|---|
| `provider: stripeWebhookProvider()` | a preset brings the scheme, replay window and idempotency key |
| `signature: { _tag: 'hmac', … }` | a hand-declared scheme for a sender with its own convention |
| `verification: 'provider'` | your handler verifies with the provider's own SDK |
| `verification: 'none'` | deliberately public — a gateway or IP allow-list owns the boundary; logged as a warning on every boot |

A signature-verified webhook reads its shared secret from an env var named after
its id:

```bash
VOLTRO_WEBHOOK_SECRET_ORDERS_PAID=<the value the sender holds>
```

**A missing secret is now a 503, not a skipped check.** It used to run the
handler unverified, so forgetting one variable silently converted a verified
webhook into an open one — in production, with nothing in the code to review.
The framework never invents this value: the sender holds the other half, so a
generated secret would authenticate nobody.

### `voltro doctor` tells you before a deploy does

The preflight reports the same verdict the boot reaches:

```
incoming webhook verification
  ✓ signature / provider verified            2
  ! deliberately public (none)               1
      internal.sync — `verification: 'none'`; a gateway / IP allow-list owns this URL
  ✗ nothing verifies the caller              0
```

An endpoint in the last row exits non-zero — that app does not start. The middle
row never fails; it is there so a deliberately public URL is visible in a review
instead of only in a boot log. Both rows are in `voltro doctor --json` under
`webhookVerification`.

## The body cap applies EVERYWHERE — and answers 413 either way

The 8-MiB body cap covers every surface that reads a request body: `POST /rpc`
(as it always did, `VOLTRO_MAX_RPC_BODY_BYTES`), plugin HTTP routes, REST
routes and incoming webhooks — whose read is also **binary-clean** now (it used
to round-trip through UTF-8, corrupting binary payloads). The non-rpc cap is
`http.maxBodyBytes` in `app.config.ts` (env `VOLTRO_MAX_BODY_BYTES`), with
per-route overrides on `defineRestRoute({ maxBodyBytes })` and on a webhook
handler's `maxBodyBytes` — fat provider payloads are the normal case there.
Routes **sharing a path share one body read**, so the widest override in the
group applies to the group.

The limit is enforced **while the body streams**, so a
`Transfer-Encoding: chunked` request with no `Content-Length` is cut off at the
cap rather than buffered without bound. A declared oversize length is still
rejected up front, so an honest client gets its `413` without uploading
anything.

**Both shapes end in the same response:**

| Request | Response |
| --- | --- |
| `Content-Length` over the cap | `413 Payload Too Large`, before the upload |
| `Transfer-Encoding: chunked` over the cap | `413 Payload Too Large`, once the counter crosses the cap |
| Either, under the cap | handled normally |

The refusal is logged server-side under the `voltro:security` scope, with the cap
and the number of bytes read before the server stopped (never the body's real
size — it is not read).

Uploads ride separate plugin routes with their own `limits.maxBytes`, and are
unaffected.

## The global HTTP interceptor is fail-closed

The pre-auth plugin hook (`onHttpRequest`) wraps every request, which makes it
a place people put security gates — rate limits, geo-blocks, bot detection. A
gate that crashes must not become an open door, so **a throwing interceptor is
a `500` plus a log line; the request does not continue**. It used to: the
failure was swallowed and the request flowed on, silently, which is the worst
possible failure mode for exactly the code this hook attracts.

An interceptor that wants to tolerate its OWN outage — protection with a
dependency, like a rate-limit counter in Redis — catches internally and
degrades **loudly**. `@voltro/plugin-ratelimit`'s `httpShield` does exactly
that: a Redis outage means unlimited-with-a-warning, never a self-inflicted API
outage. The full authoring guidance is in the
[plugin contract](/docs/plugins/contract#onhttprequest-httprequestinterceptor--pre-auth-http-pipeline-hook).

## Response compression — and where BREACH sits

Buffered responses are compression-negotiated on both boot paths: brotli
preferred, gzip as the fallback, a client's `q=0` respected. Only an allowlist
of compressible types is touched (`text/*`, `application/json`,
`application/javascript`, `application/xml`, `image/svg+xml`), nothing under
1 KiB is compressed, and `Vary: Accept-Encoding` is set on every compressible
type — including responses that go out uncompressed, so caches never mix
encodings. It covers the api's plugin/REST/webhook responses AND the HTML
`voltro start` serves. Streams and SSE are never compressed. Config:

```ts
// app.config.ts
export default {
  http: { compression: { enabled: true, minBytes: 1024 } },
}
```

**The BREACH position is structural, not a tuning knob:** `POST /rpc`
responses are NEVER compressed — that is where session-authenticated reads
flow, the exact combination (secret + attacker-reflected input + compression
+ observable length) the attack needs. The residual risk is an
**authenticated REST route** that carries a secret next to reflected caller
input in one response; an app with such a route sets
`http.compression.enabled: false`.

One cache note: an `isr` page's cache holds ONE uncompressed entry and
compresses per hit (brotli q4, single-digit milliseconds) — caching a variant
per encoding would have multiplied the memory for no measurable win.

## Logs are redacted by default

Every logger surface — `createLogger`, `makeLogger`, `LoggerLayer` — installs a redactor with no configuration, and it runs **before** formatting and **before** the sink fan-out, so a masked value reaches neither stdout nor any downstream sink (the CLI buffer, logship, Datadog).

```ts
import { createLogger } from '@voltro/logger'

const log = createLogger({ scope: 'orders' })
log.info('request', { headers: req.headers, body: input })
// → headers.authorization = [redacted]
//   headers.cookie        = [redacted]
//   body.newPassword      = [redacted]
//   headers['user-agent'] = curl/8      ← untouched
```

Three rules, all on:

- **Key names** (`DEFAULT_REDACT_KEYS`) — matched exactly on a normalised key, so `apiKey`, `api_key`, `API-KEY` and `Api Key` are one key. Credentials, bearer/API tokens, `authorization` / `cookie` / `set-cookie` / `x-api-key`, session and second-factor values, and the payment / government identifiers (`creditCard`, `cvv`, `iban`, `ssn`, …) that must never reach a log index.
- **Key fragments** (`DEFAULT_REDACT_KEY_PARTS`) — substring matches for the compound names real code writes: `newPassword`, `oldPassword`, `stripeApiKey`, `userAccessToken`.
- **Value shapes** — a credential is masked regardless of its key: `Bearer …`, `Basic …`, a JWT, `sk_` / `pk_` / `whsec_`-prefixed keys, GitHub / Slack / AWS key ids, a PEM private key.

There is deliberately **no entropy heuristic**. A trace id, a content hash, a git sha and a base64 thumbnail are all long and high-entropy — a redactor that eats the fields an incident is read through gets switched off wholesale, which costs more than the gap it closed.

### Adding to the list, and why nothing subtracts

`redactKeys` **adds**; it does not replace:

```ts
createLogger({ redactKeys: ['patientId', 'policyNumber'] })
```

There is no way to remove a built-in key. The only reason to un-mask `password` is to read it while debugging, and the answer to that is to log a non-secret projection — not to publish the value to stdout and to whatever log shipper the deployment happens to have. A subtraction knob would also apply to every **dependency** logging under that key, including plugins you never read, which is a blast radius an app cannot assess.

The deliberate escape hatch is `redact`, a transform you write and own — and it runs *after* the built-in redactor, so it can mask more and structurally cannot unmask:

```ts
createLogger({ redact: (record) => ({ ...record, fields: { ...record.fields, region: 'eu' } }) })
```

> **If a log line you relied on went quiet**, rename the field rather than reaching for an exemption. `traceId`, `requestId` and `cookieName` are all untouched — the framework's own session diagnostic was renamed from `cookie` to `cookieName` for exactly this reason.

## Auditing what your log tables actually hold

```bash
voltro db scan-credentials
voltro db scan-credentials --table my_events:actor
```

Counts rows in which a credential-shaped **name** appears anywhere in the stored value — `token`, `secret`, `password`, `apikey`, `credential`, `privatekey` — plus any table you name with `--table <name>[:<column>]`. Exit code `1` on an unexplained hit, so CI can gate on it.

By default it scans every column that can physically HOLD one:

| Table | Columns |
|---|---|
| `_voltro_audit_log` | `subject`, `actor`, `scope`, `metadata`, `input`, `outcome`, `subjectId` |
| `_voltro_row_history` | `data`, `actor`, `scope`, `subjectId` |

`input` and `outcome` are a procedure's arguments and result — the likeliest place of all for a token to arrive by accident, since nobody puts a credential on a subject on purpose and plenty of code passes one as an argument. `_voltro_row_history.data` is a full-row snapshot, so a credential column on *any* of your tables is in there, and it outlives deleting the source row.

Every hit line names the needles that matched, with the number of rows each appears in:

```
✗  _voltro_audit_log.outcome — 69 of 149 row(s) contain a credential-shaped NAME (anywhere in the value)
       matched (rows per needle, may overlap): token (61), secret (12)
       examined 69 of 69 matched row(s): 61 as a JSON KEY, 8 only inside a redaction marker
```

**Those counts overlap and do not sum to the hit count** — a row holding both a token and a secret is counted by both needles. Without the breakdown the advice underneath (*purge them AND rotate the credentials*) is neither executable — purge what, rotate which — nor refutable: a column mentioning the word `token` in prose reads identically to one holding a live one.

### Hits are explained, not just counted

The predicate is a substring match over the whole serialized column, so a match is a match on a *name*, wherever it sits. The third line above is a bounded second pass that reads the matched rows back and says what actually matched — a JSON **key**, or only a **redaction marker**. Values are never printed and never logged.

A redaction marker is the framework's own record that a credential was deliberately *not* stored: `_omitted` (`@voltro/plugin-row-history`, the column names left out of a row snapshot) and `__redacted` (`@voltro/plugin-audit`). A target whose every matched row is one of those is reported as explained, and does **not** fail CI:

```
~  _voltro_row_history.data — 69 of 149 row(s) contain a credential-shaped NAME (anywhere in the value)
       matched (rows per needle, may overlap): token (69)
       examined 69 of 69 matched row(s): 0 as a JSON KEY, 69 only inside a redaction marker
       EXPLAINED — every match is `_omitted` / `__redacted`, the framework's own
       record that a `.sensitive()` column was left OUT. Nothing to purge, nothing to rotate.
```

The bar for that is deliberately high: **every** matched row examined, **every** one of them a marker and nothing else. A target where the read-back was capped (at 500 rows), or where one row is a real key, or where one row is not parseable as JSON, stays a finding and still exits `1`. A partially-explained target is still a target.

> **Why this exists.** A team with `.serverOnly().sensitive('secret')` columns and correctly-redacting plugins got 69 rows flagged, with *purge them AND rotate the credentials* underneath, against the framework's own proof that nothing was stored. The shape mattered more than the one key name: the more columns an app classifies correctly, the more markers it writes, and the redder the scan turns. A false-positive rate that rises with the care you take gets the tool muted — and a muted scan is worth less than none, because its silence still reads as evidence.

> **If you ran this on 0.30.0, 0.30.1 or 0.30.2, run it again.** Those releases scanned `subjectId` on both tables — a flat opaque id that cannot hold a credential — and none of the blob columns above. The command ran cleanly, printed a scanned count beside a hit count, and exited `0` having never looked where credentials are. It came from fixing a crash: the default column had been the literal `subject` for every table, `_voltro_row_history` has no such column, and the fix replaced the name on *both* tables instead of the one that was wrong. A clean answer from a scan that looked in the wrong place is worse than the crash it replaced. `voltro update` prints this as a manual step on the way to 0.31.0.

**It is a command and not a documented query on purpose.** The same check once shipped as SQL you were asked to run yourself, in its postgres spelling (`subject::text ILIKE '%token%'`). On MySQL/MariaDB the natural translation is a bare `LIKE` — and against the `utf8mb4_bin` collation the migrator emits for a `json()` column, `LIKE` is case-**sensitive**. So `'%token%'` does not match `jiraToken`, and almost every JSON key that carries a credential is camelCase. A team ran the translated query over 141 rows, got `0`, and nearly reported themselves clean; 117 of those rows held a working credential. Every dialect now casts to its own text type before lowering, in code you do not have to translate.

**A `0` here never means two things.** Each line prints the number of rows *scanned* beside the number of hits. An empty table says so in words rather than reading as clean, a missing table reports as missing rather than as zero, and a run that examined nothing exits `2`.

Run it on every environment. A development database is not a sample of production.

If it finds something: purge the rows **and** rotate the credentials — assume anything written to a log table has been read — then move the credential off the Subject entirely with `connectionCredentials(...)`, which keeps it in the framework vault.

If the finding is a credential column of your own sitting in plaintext, `.encrypted()` only protects rows written *after* you add it — [`voltro db encrypt-column`](/docs/database/sensitivity) converts the ones already there.

## Supply-chain assurance

Every release passes automated supply-chain gates in CI before a single package is published:

- **Dependency audit.** The production dependency graph is audited on every build (`pnpm audit --prod`); a HIGH or CRITICAL advisory **fails the release**. Known-but-unfixed-upstream transitives are pinned deliberately, not waved through.
- **Inbound-license allowlist.** Every third-party runtime dependency's license is checked against an allowlist — a disallowed license **fails the release**. The aggregated attributions ship as `THIRD-PARTY-NOTICES.md` in each package.
- **SBOM.** A CycloneDX Software Bill of Materials of the third-party runtime graph is generated for, and attached to, each release. If your procurement or security team needs it, request it at [support@voltro.dev](mailto:support@voltro.dev).
- **Release integrity.** Publishing is gated behind organization 2FA and a narrowly-scoped, short-lived automation token, and is all-or-nothing (two-phase) so consumers never see a half-published release.

## Supported versions

Voltro is pre-1.0 and ships in lockstep — all `@voltro/*` packages share one version. **Only the latest published release receives security fixes.** Pin exact versions in production and upgrade promptly.

## Your data stays yours

Voltro is **self-hosted**: it runs next to *your* database, with *your* connection string, *your* backups, and *your* access controls. **When you self-host**, the framework never proxies your data through Voltro infrastructure — there is no phone-home and no hosted control plane in the request path; the runtime is a library that lives in your own deployment.

Managed **Voltro Cloud** is coming as a separate, opt-in deploy target. Choosing it means running your workload on Voltro-operated infrastructure under its own data-processing terms — a deliberate choice, not the default. Self-hosting remains the default and keeps the guarantees above.
