Secrets
Typed vault.secret contracts, boot-time resolution, Redacted reads through fx.vault.get, and per-tenant paths.
Secret contracts (vault.secret) declare the credentials your backend requires — Stripe keys,
webhook HMACs, SMTP URLs. Values resolve at boot; Flows read them as Redacted through
fx.vault.get.
For developers who must never leak credentials into logs or HTTP bodies — declare the name, resolve it, reveal only at the provider edge.
The one rule
Touch secrets only through fx.vault.get(contract). That records the secret effect and returns
Redacted — .reveal() belongs at the SDK / HMAC boundary, not in return values.
Smallest Example
Declare the contract
import { vault } from "okengine";
import { z } from "zod";
export const stripeKey = vault.secret("STRIPE_KEY", {
description: "Stripe secret API key",
schema: z.string().startsWith("sk_"),
rotate: "90d",
dev: "sk_test_local",
});Import this module before oke() so auto-registry can adopt the contract (or pass it in
oke({ secrets: […] })).
Read inside do
import { on, flow, http } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";
import { stripeKey } from "@/core/vault";
export const charge = on(
http
.post({
in: z.object({ amount: z.number().int().positive() }),
})
.gate(member),
flow({
do: async ({ amount }, fx) => {
const key = await fx.vault.get(stripeKey);
const stripe = new Stripe(key.reveal());
const intent = await stripe.paymentIntents.create({ amount, currency: "usd" });
return { id: intent.id };
},
}),
);Confirm the effect
Manifest / Console list secrets: ["STRIPE_KEY"] on payments.charge. Logs that stringify
key show [redacted].
Progressive Patterns
From a bare name to schema, cadence, tenancy, and sensitivity overrides:
Name only — description helps boot gaps and Console:
export const webhookSecret = vault.secret("APP_WEBHOOK_SECRET", {
description: "HMAC secret for outbound webhooks",
});Options Reference
Third argument shape is VaultSecretOptions on vault.secret(name, options):
| Option | Type | Default | Meaning |
|---|---|---|---|
description | string | — | Boot-gap and Console label |
rotate | string | omit ≈ "never" | Cadence hint ("90d") for Console posture — not automatic rotation |
schema | Schema | — | Declared validator for docs / tooling |
dev | string | — | Dev-only fallback (vault.fromDocker(role) allowed) |
sensitive | boolean | true | Fingerprinted; Console never shows cleartext |
perTenant | boolean | true when tenancy on | Storage path {tenantId}/{name}; skipped in boot-gap scan when true |
Empty name throws TypeError: vault.secret: name is required.
Reading secrets
Redacted until you reveal
fx.vault.get(secret) → Redactedgetlogjsonrevealhold Redactedawait fx.vault.get(stripeKey)fx.log.info(key)·JSON.stringify({ key })·
shows
[redacted]fx.log, String(), and JSON.stringify never see the value — nested Redacted included.
reveal at boundarykey.reveal()new Stripe(key.reveal())·fx.log after reveal·
shows
sk_test_…One explicit call at the Stripe (or SMTP, or SDK) edge — the credential crosses only there.
| Call | Returns | Notes |
|---|---|---|
await fx.vault.get(handle) | Redacted<string> | Preferred — capability from the handle |
await fx.vault.get("NAME") | Redacted<string> | Same when the name is declared |
key.reveal() | string | One explicit cleartext escape |
key.map(fn) | Redacted<U> | Transform without exposing to callers |
String(key) / toJSON | "[redacted]" | Safe for accidental serialization |
const key = await fx.vault.get(stripeKey);
fx.log.info(`using ${key}`); // message contains [redacted]
// JSON.stringify({ key }) → { "key": "[redacted]" }
const client = new Stripe(key.reveal());Secret access is never journaled — durable replay re-reads the live value so a rotated credential is not resurrected from the journal.
Effects & Manifest
Calling fx.vault.get records effects.secrets with the contract name. Declare the same list
explicitly when you want Manifest truth without inference:
flow("payments.charge", {
effects: { secrets: ["STRIPE_KEY"] },
do: async (_, fx) => {
await fx.vault.get(stripeKey);
},
});Capability enforcement: reading a name not allowed by the Flow’s effects fails the secret
capability check.
Boot gaps
Missing non-tenant contracts fail boot with every hole listed once:
vault boot failed — 2 missing secret(s):
- STRIPE_KEY: Stripe secret API key
- APP_WEBHOOK_SECRET: HMAC secret for outbound webhooksFill gaps with:
| Layer | How |
|---|---|
| Driver | oke vault set NAME, managed provider write, memory seed |
| process.env | Export NAME=… in the host / CI |
.env.local | Local override file (gitignored) |
dev: | Contract fallback — only when dev fallbacks are allowed |
On a TTY, oke dev prompts for each gap before the app starts and writes values
into .env.local (same store as oke vault set). Non-interactive runs still
fail with VaultBootError listing every hole.
Troubleshooting
No resolution layer supplied a value. On a TTY, oke dev prompts into .env.local; otherwise use
oke vault set / env / managed, or a dev: fallback. Per-tenant contracts are skipped at boot —
seed the tenant path.
Boot succeeded but the name is absent from the merged bag (or you never declared it). Import the
declaring module; confirm auto-registry or oke({ secrets: [handle] }).
Tenancy is on, the contract is per-tenant, and fx.tenant.id is null. Resolve a tenant on the
request, or set perTenant: false for a platform-wide secret.
The Flow’s effects.secrets (inferred or declared) must include the name you get. Touch the
handle inside do, or list the name explicitly.
Vault scrubs fx.log and known substrings. Direct console.log(key.reveal()) or foreign sinks
are outside the redactor — keep cleartext off those paths.
Learn more
- Vault overview — resolution chain and drivers
- Config — cleartext contracts vs secrets
- Key Rotation — version and master-key rotate
- Tenancy —
fx.tenant.idfor per-tenant paths - fx — full
fx.vaulttable