# Configuration

> Typed, schema-validated environment variables with a structural public/secret boundary. Declare once in app.config.ts; read public vars in the browser, secrets only on the server — and let the build fail loudly if you ever cross the line.



---

<!-- source: en/configuration/environment.md -->
## Environment variables

_Typed, schema-validated environment variables with a structural public/secret boundary. Declare once in app.config.ts; read public vars in the browser, secrets only on the server — and let the build fail loudly if you ever cross the line._

`@voltro/env` replaces scattered `process.env.X!` reads with a single typed
declaration in `app.config.ts`. You get three things `process.env` can't give
you:

1. **Boot-time validation** — every variable is validated once at startup; a
   missing or malformed value aborts the boot with *all* problems listed at
   once, not a confusing crash deep in a request.
2. **Typed accessors** — the coerced, narrowed type (`number`, `boolean`, a
   literal union), not `string | undefined`.
3. **A structural public/secret boundary** — a `secret` variable can never
   reach the browser. It's enforced by the compiler and the bundler, not by
   discipline: a secret read in browser code is a **compile error**.

## Declare

```ts
// apps/api/app.config.ts — on an API, `public` = NON-SENSITIVE server config
// (there is no browser bundle), so these stay UNPREFIXED. `secret` = server-only.
import { defineEnv, envVar } from '@voltro/env'

export const env = defineEnv({
  LOG_LEVEL:   envVar.enum(['debug', 'info', 'warn'], { access: 'public', default: 'info' }),
  MAX_BATCH:   envVar.number({ access: 'public', default: '50' }),
  STRIPE_KEY:  envVar.string({ access: 'secret' }),                 // server-only
  WEBHOOK_URL: envVar.url({ access: 'secret', optional: true }),
})

export default { type: 'api' as const, name: 'myApi', store: 'postgres' as const, env }
```

```ts
// apps/web/app.config.ts — on a WEB app, `public` vars ARE browser-bundled, so
// they MUST be named `VOLTRO_PUBLIC_*` (the framework's default public prefix).
export const env = defineEnv({
  VOLTRO_PUBLIC_SENTRY_DSN: envVar.string({ access: 'public', description: 'Browser Sentry DSN' }),
  VOLTRO_PUBLIC_APP_NAME:   envVar.string({ access: 'public', default: 'Voltro' }),
})
```

## `access` is mandatory

Every field declares `access: 'public' | 'secret'`. There is **no default** —
choosing it is a security decision (the same reason `cache.scope` is required).
`public` means different things by app type:

- **`public`** — non-sensitive. On an **api** it is server-side config (there is
  no browser bundle), so the var stays unprefixed. On a **web** app it is
  **baked into the browser bundle** at build time and read via `publicEnv` —
  and there it MUST be named `VOLTRO_PUBLIC_*` (see below).
- **`secret`** — must never reach the browser. Read only on the server via
  `serverEnv` / `getSecret`. Never bundled, never logged, never reported by
  value over the inspect surface. Resolved through the configured
  [`secrets:`](/docs/configuration/secrets) backend (env / Vault / Doppler /
  http), so a remote secret works without extra wiring.

## The `VOLTRO_PUBLIC_` prefix invariant (web)

A browser-exposed public var (web app `access: 'public'`) MUST be named
`VOLTRO_PUBLIC_*` — the framework's default public prefix. A `secret` var may
**never** carry that prefix. The boot gate **rejects** a mismatch, so the
variable name and its `access` tag can't silently disagree, and anyone reading
`.env` can tell at a glance what ships to the browser. Override or opt out with
`defineEnv(map, { publicPrefix: 'MYAPP_PUBLIC_' | false })`.

Vite's own `VITE_`→`import.meta.env` channel is **disabled** by the framework
(its `envPrefix` is pinned to a non-matching sentinel in every web Vite
config), so public config reaches the browser ONLY through `@voltro/env`
(`VOLTRO_PUBLIC_*`), never a raw `.env` var. (`import.meta.env.DEV`, a
build-time constant, still works.)

## Field builders

| Builder | Value type | Coercion |
|---|---|---|
| `envVar.string(opts)` | `string` | — |
| `envVar.number(opts)` | `number` | `"5"` → `5` |
| `envVar.port(opts)` | `number` | `"5"` → `5`, range `1..65535` |
| `envVar.boolean(opts)` | `boolean` | `true/1/yes/on` ↔ `false/0/no/off/""` |
| `envVar.url(opts)` | `string` | validated with `URL` |
| `envVar.enum([…] as const, opts)` | literal union | must be one of the values |
| `envVar.secret(opts)` | `string` | server-only, required, length floor |

### `envVar.secret` — for values that must not be guessable

```ts
env: defineEnv({
  // Ours to invent → `voltro dev` mints one per project.
  VOLTRO_SESSION_SECRET: envVar.secret({ generate: 'base64url' }),
  // Someone else's to issue → must be fetched, never invented.
  STRIPE_SECRET_KEY:     envVar.secret({ minLength: 20 }),
})
```

It differs from `envVar.string({ access: 'secret' })` in three ways, each
closing a specific failure:

- **`access` is forced to `'secret'`** — a value with a length floor is never
  something you meant to bundle into a browser.
- **There is no `default`.** A default secret is not a secret: every
  deployment that forgot to set the variable would share it.
- **A length floor** (default 32). Presence alone does not catch the real
  failure mode — a variable that is *set* but reads `change-me` signs forgeable
  cookies while looking completely healthy.

**`generate` opts a variable into per-project minting.** On first `voltro dev`,
any declared-but-unset mintable secret is written to a gitignored `.env.local`
and the boot continues. That is why no Voltro template ships a secret value: a
placeholder in a template is a signing key published to everyone who downloads
it, and it passes every check you could write.

Leave `generate` off for anything a third party issues. A WorkOS API key is
just as secret and just as required, but inventing one produces a value that
merely *looks* right and authenticates nobody — better that the boot gate fails
and a human fetches the real one.

Minting is **development only**. `voltro serve`, `build` and `start` never mint:
in production a missing secret is a boot failure, which is the whole point.
Generate deployment values with `voltro secret generate <purpose>`.

Every builder takes `{ access, optional?, default?, description?, example? }`.
Use `default` for a fallback (the value type stays present, `A`); use
`optional: true` when the variable may legitimately be absent (the value type
becomes `A | undefined`). Use one or the other, not both.

## Read values — two entry points

The boundary is enforced by the **import graph**, not by convention:

```ts
// SERVER — a mutation / action / query / loader / *.startup.tsx:
import { serverEnv, getSecret } from '@voltro/env/server'
const title = serverEnv.APP_TITLE          // typed via generated augmentation
const key   = getSecret('STRIPE_KEY')      // intent-signalling sugar for a secret

// BROWSER — a page / component / *.query.ts descriptor:
import { publicEnv } from '@voltro/env/public'
<Sentry dsn={publicEnv.VOLTRO_PUBLIC_SENTRY_DSN} />   // typed; publicEnv.STRIPE_KEY ⇒ compile error
```

`@voltro/env/server` imports `@voltro/runtime`, so importing it from a
browser-reachable module breaks the build loudly — the same boundary guard the
descriptor/executor split relies on. `@voltro/env/public` has zero server
dependencies and its type only ever names public keys. A secret in the browser
is therefore *unrepresentable*, not merely discouraged: the `PublicEnv`
interface has **no index signature**, and the codegen augments it with the
public keys only — so reading a `secret` or an undeclared key through
`publicEnv` is a **compile error**.

## When are values available?

Everything is validated and resolved **once at boot**, then read from a frozen
snapshot. So:

- Read env inside a **handler / loader / startup hook** — never at module
  top-level (that runs before the boot gate and throws a clear error).
- A remote-backend secret rotation takes effect on the **next restart**. For a
  rare live re-read, use `resolveSecretLive(key)` from `@voltro/env/server`.

## Web apps are public-only

On a `type: 'web'` app, `defineEnv` declares the **public** variables baked
into the browser bundle, read with `publicEnv`. A `secret` declared on a web app
is accepted but only reachable from server-side render code, and the boot
**warns** — declare secrets on the **api** instead.

## Tooling

- `voltro env` (or `voltro env check`) — print the manifest (app + plugin +
  framework variables, each with `isSet`); exits non-zero if a required
  variable is unset (a CI gate).
- `voltro env sync` — (re)generate `.env.example` from the manifest. Secrets are
  left blank; public vars carry their example/default.
- `voltro env types` — (re)generate `env.generated.d.ts` (the `ServerEnv` /
  `PublicEnv` type augmentation) from `app.config.ts`'s `defineEnv`, WITHOUT a
  full `voltro dev` boot. Run it in CI before `tsc` so a standalone typecheck
  resolves `serverEnv.X` / `publicEnv.X` — whose types come from that generated
  file, which otherwise only a dev boot writes. (`getSecret('X')` takes a string
  and is codegen-independent, so it typechecks without this step.)
- `voltro env turbo` — list the framework variables for `turbo.json`
  `globalPassThroughEnv`.
- `GET /_voltro/inspect/env` — a value-free manifest (secrets report `isSet`
  only, never a value). Drives the dashboard **Env** panel.

## Plugins declare their env

A plugin that reads environment variables declares them via `declaredEnv` on its
`VoltroPlugin` (metadata only — the plugin still reads its own values). This
makes a plugin's env needs visible in the manifest, `.env.example`, and the
dashboard. The first-party plugins already declare theirs, so the manifest is
complete out of the box.

## Anti-pattern

Don't reach for `process.env.X` in app code — it skips validation, the
public/secret boundary, and the manifest. Declare the variable in `defineEnv`
and read it via `serverEnv` / `publicEnv`. Framework internals (`DB_*`,
`CACHE_*`, `VOLTRO_*`) are catalogued by the framework itself; you don't
redeclare them.



---

<!-- source: en/configuration/secrets.md -->
## Secrets

_The pluggable Secrets-Resolver — resolve API keys / signing keys / encryption keys from env (default), an HTTP vault, or a custom backend, set once in app.config._

Secrets (API keys, signing keys, the field-encryption key) resolve through a **pluggable backend** installed once at boot. The default reads `process.env`; swap it for an HTTP vault or a custom resolver without touching the code that consumes secrets. Plugins (e.g. `@voltro/plugin-governance`'s field encryption) read keys through this resolver, so the same wiring serves every consumer.

## Configure in `app.config.ts`

```ts
export default {
  type: 'api' as const,
  name: 'api',
  // 'env' (default) — read process.env. Omit `secrets` entirely for this.
  secrets: 'env',
  // OR an HTTP vault: GET <url>/<key> (or a whole-map fetch), bearer-authed.
  // secrets: { backend: 'http', url: process.env.VAULT_URL!, token: process.env.VAULT_TOKEN, ttlMs: 60_000 },
  // OR a custom backend (any { get(key) => Promise<string | undefined> }).
  // secrets: customBackend,
}
```

The same chain serves `voltro dev` and `voltro serve` — they can't drift. With no `secrets` field, the env backend is used.

## Reading a secret

In framework code (and plugins) resolve a key through the runtime resolver — never hard-read `process.env` for something a vault might own:

```ts
import { resolveSecret, resolveSecretSync } from '@voltro/runtime'

const key = await resolveSecret('STRIPE_SECRET_KEY')   // backend → env fallback
const sync = resolveSecretSync('SESSION_SECRET')        // env-only fast path (sync)
```

`resolveSecret` consults the active backend first, then falls back to `process.env`. `resolveSecretSync` is the synchronous env-only path for hot code that can't await.

## Backends

| `secrets` | Resolution |
|---|---|
| `'env'` (default) | `process.env[key]` |
| `{ backend: 'http', url, token?, ttlMs? }` | `GET <url>/<key>` (bearer `token`); `ttlMs` caches results (`cachedBackend`). Falls back to env on miss. |
| a `SecretsBackend` value | your own `{ get(key) => Promise<string \| undefined> }` — wrap a cloud secrets manager, KMS, etc. |

`httpSecretsBackend` supports both a per-key `GET` and a whole-map prefetch; `cachedBackend(backend, ttlMs)` wraps any backend with a TTL cache.

## Field encryption

The field-encryption key (for `.encrypted()` columns — see [plugin-governance](/docs/plugins/governance)) resolves through this same backend. `governancePlugin({ fieldEncryption: true })` reads the secret `VOLTRO_FIELD_ENCRYPTION_KEY` (override with `fieldEncryption: { secretKey }`); point `secrets` at your vault and the key never touches an env file.

## Live rotation — swap a secret without a restart

The boot env gate resolves every secret once, at start-up. Rotating a leaked key normally means a redeploy. `@voltro/env/server` lets a running process cut over to a re-resolved value **and keep accepting the old one for a grace window** — so requests signed with the previous key still verify while callers catch up.

```ts
import { rotateSecretLive, getSecretWithOverlap } from '@voltro/env/server'

// Re-resolve WEBHOOK_SIGNING_SECRET through the active backend and cut over,
// holding the OLD value valid for a 5-minute overlap (the default).
await rotateSecretLive('WEBHOOK_SIGNING_SECRET', { graceMs: 5 * 60_000 })

// A verifier accepts BOTH during the overlap — try current first, fall back:
const { current, previous } = getSecretWithOverlap('WEBHOOK_SIGNING_SECRET')
```

`getSecretWithOverlap(key)` returns `{ current, previous }` — the same current/previous pattern session verification uses for `VOLTRO_SESSION_SECRET` + `_PREVIOUS`. `previous` is present only while a rotation's grace window is open, then `undefined` (revoked lazily, on read — no timer). For a value you already have in hand (e.g. fetched from your own KMS), the lower-level `refreshEnvValue` installs it directly:

```ts
import { refreshEnvValue } from '@voltro/env'

refreshEnvValue('WEBHOOK_SIGNING_SECRET', nextValue, {
  previous: oldValue,      // held valid for the grace window
  graceMs:  5 * 60_000,
})
```

`refreshEnvValue` throws if called **before** the boot env gate ran — a live rotation is a post-boot operation that replaces a value the gate already resolved, not a way to set one that was missing.

### What live rotation actually reaches — the honest bound

This updates what code that reads a secret **per use** sees: outbound API keys resolved on each call, webhook-signing verification, `.encrypted()` field encryption. It does **not** reconnect a live resource built once, at boot, from the old credential — a database connection pool created with the previous password keeps that connection. Rotating a DB password stays a reconnect concern (drain + rebuild the pool, or redeploy); rotating a signing or outbound key is what this is for.

## Testing

`setSecretsBackend(backend)` installs a backend for a test; `resetSecretsBackend()` restores the env default. `resolveSecretsBackend(config)` is the pure resolver the boot path uses to turn the `secrets` config value into a backend.



---

<!-- source: en/configuration/api-keys.md -->
## API keys

_First-class API keys — built into the framework. Bearer-token auth for headless callers + admin-gated issue/list/revoke management, with hash-only storage._

API keys are a **first-class framework feature**, not a plugin — the `_voltro_api_keys` table is framework-internal, and a single `apiKeys: true` in `app.config.ts` turns on both verification and management. Only the SHA-256 hash of a key is ever stored; the raw token is shown once at issue time and is unrecoverable thereafter.

## Enable

```ts
// app.config.ts
export default {
  type: 'api' as const, name: 'api',
  apiKeys: true,                       // or { prefix?: 'myapp_', managementPath?: '/v1/api-keys' }
}
```

This does two things:

1. **Verification.** `Authorization: Bearer <prefix>…` on any request resolves to an `apiKey` Subject carrying the key's `tenantId` + `scopes` — exactly like a session, so handlers, `requireScope`, and tenant scoping all just work. The strategy runs in the same auth chain as `voltro dev` and `voltro serve`.
2. **Management.** Admin-gated REST routes mount under `managementPath` (default `/v1/api-keys`):

| Method + path | Action |
|---|---|
| `POST /v1/api-keys/issue` | mint a key — returns the raw `token` **once** |
| `GET /v1/api-keys` | list the tenant's keys (no secrets) |
| `POST /v1/api-keys/revoke` | revoke a key by id |

All three require `admin:full` (`ADMIN_SCOPE`), so only an admin Subject can manage keys.

```bash
# An admin issues a key for a CI pipeline:
curl -XPOST https://api.example.com/v1/api-keys/issue \
  -H 'Authorization: Bearer <admin-session>' \
  -d '{"name":"CI deploy","scopes":["deploy:write"],"expiresInDays":90}'
#   → { "id": "apikey_…", "token": "voltro_…", "keyPrefix": "voltro_ab12" }   ← copy the token now

# The CI pipeline then authenticates with it:
curl https://api.example.com/... -H 'Authorization: Bearer voltro_…'
```

## In-handler service

Build your own management UI on the same `ApiKeyService` (issue / verify / rotate / revoke / list):

```ts
import { makeApiKeyService, dataStoreApiKeyStore } from '@voltro/runtime'

const svc = makeApiKeyService(dataStoreApiKeyStore(ctx.store))
const issued = await svc.issue({ tenantId, name: 'mobile app', scopes: ['read'] })
// show issued.token ONCE; later: svc.rotate(id), svc.revoke(id), svc.list(tenantId)
```

## The second ownership axis — `metadata`

`tenantId` and `onBehalfOf` are the two relationships the framework models. If
your keys also belong to something else — a team, a project, an environment —
and that binding is what authorizes them, store it in `metadata`:

```ts
const key = await keys.issue({
  tenantId: ctx.request.subject.tenantId,
  name: 'CI deploy',
  createdBy: ctx.request.subject.id,   // who minted it
  onBehalfOf: null,                    // an ORG key: acts as no person
  metadata: { teamId: 'team_7' },      // your axis
})
```

It comes straight back on resolve, so a guard needs no second query:

```ts
const resolved = await keys.verify(token)
resolved?.metadata   // { teamId: 'team_7' }
```

It survives `rotate` — a rotated key is the same credential with a new secret,
so dropping it would silently de-authorize every rotated key. And it reaches the
Subject as `metadata`, alongside the framework's own claims.

**It is app data, never identity.** The strategy merges your bag UNDER its own
claims: `provider`, and the acting `userId`, are written afterwards from
`onBehalfOf` and always win — including when the answer is "none". A bag that
could set `userId` would let whoever minted a key choose who the request is.

Before this slot existed, an app with a team axis could authenticate through the
built-in strategy and still not authorize, so `apiKeys: true` was unusable for
it. The alternatives people reached for were a second table joined on every auth
check, or `team:<id>` smuggled into `scopes` — where `hasScope` then sees a scope
that is not a scope.

## Two strategies, one prefix

If your app already runs its own key strategy on a prefix and you then enable
`apiKeys: true`, both claim the same shape. The chain is first-match-wins, so the
first one decides the Subject — and if they resolve to different authority,
*which strategy answered* decides whether authorization works.

`voltro dev` / `voltro serve` warn at boot when this happens. Give them distinct
prefixes (`apiKeys: { prefix: 'vk_' }`) or drop one.

## Security model

- **Hash-only storage.** A DB dump never exposes a usable key — only `sha256(token)`. Lose a token → rotate it (`rotate` revokes the old + issues a fresh one with the same scopes).
- **Scopes** gate what a key can do (`requireScope(ctx.subject, 'deploy:write')`); **expiry** (`expiresInDays`) and **revoke** stop it. Verification checks not-revoked + not-expired and stamps `lastUsedAt`.
- Keys are tenant-scoped — a key carries its `tenantId` into every call.
