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

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:

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

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:

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.

VOLTRO_OG_SECRET — conditional, multi-replica only

The signed on-demand OG-image route (ssr pages exporting ogImage) signs its URLs with VOLTRO_OG_SECRET. The requirement is CONDITIONAL: a single process mints a per-boot secret and is self-consistent — only a multi-replica deploy needs the env var (the signing replica and the fetching replica differ), and a deploy boot with ssr ogImage pages and no secret refuses loudly rather than serving images that 403 on every other replica. Any long random value; same on every replica; never a default.