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.