Skip to content

Secrets

context.secrets is a tiny, runtime-agnostic accessor for configuration secrets and bindings. Core never reads process.env directly, so the same context.secrets.require("STRIPE_KEY") call resolves the same way everywhere: a VPS (Node/Bun process.env), Cloudflare Workers (the per-request env binding), or Deno. The environment difference lives in the adapter, not your procedures.

It reads secrets Outer doesn't already own. It is not a store: Outer never writes, encrypts, rotates, or persists secrets. .auth()'s own secret is still passed explicitly.

The accessor

type OuterSecrets<T = Record<string, string | undefined>> = {
  get<K extends keyof T & string>(key: K): T[K];
  require<K extends keyof T & string>(key: K): NonNullable<T[K]>;
  readonly all: T;
};

require throws Missing required secret "X". when a value is unset or empty — the deterministic replacement for process.env.X!, which silently yields undefined at runtime on platforms without a process global rather than failing loudly.

Reads are synchronous by design: secrets come from an already-materialized source, never a fetch. For an async backend (Vault, AWS Secrets Manager), resolve its values at startup and pass the resulting object to fromRecord.

Pass secrets on the constructor

Pick the adapter that matches how your runtime hands you the environment, then read it in any handler via context.secrets:

import { Outer, fromEnv } from "@outerjs/server";
import { pglite } from "@outerjs/server/pglite";

const outer = new Outer({ db: pglite(), secrets: fromEnv() })
  .schema(v1_0)
  .auth({ secret: process.env.AUTH_SECRET! })
  .procedure("billing.charge", (base) =>
    base.handler(async ({ context }) => {
      const key = context.secrets.require("STRIPE_KEY"); // throws early if unset
      // …
    }),
  )
  .build();

Adapters

Four adapters ship with the package.

AdapterReads fromTypes
fromSchema(schema, env)Any value, validated against a Zod / Standard SchemaInferred from the schema (defaults, transforms)
fromEnv()process.env (Node / Bun)string | undefined
fromRecord(record)A plain object — the Workers env binding, Deno.env.toObject(), …Inferred from the record
memorySecrets(values?)A fixed mapInferred — for tests

The rule of thumb: typed platform env → fromRecord, untyped process.envfromSchema.

fromSchema — validate untyped env

Node's process.env is string | undefined with no types, so validating it up front earns its keep. fromSchema accepts any Standard Schema (Zod, Valibot, ArkType) and returns a fully-typed accessor — the integrated replacement for a standalone schema.parse(env). Defaults and transforms flow through to get, require, and all; invalid input throws an error listing every failing path.

import { Outer, fromSchema } from "@outerjs/server";
import { z } from "zod";

const secrets = fromSchema(
  z.object({
    PORT: z.coerce.number().default(3000),
    AUTH_SECRET: z.string().default("dev-only-secret"),
    // comma-separated string → string[]
    CORS_ORIGINS: z
      .string()
      .default("https://hub.outer.now")
      .transform((s) =>
        s
          .split(",")
          .map((o) => o.trim())
          .filter(Boolean),
      ),
    ADMIN_EMAIL: z.email().optional(),
  }),
  process.env,
);

secrets.all.CORS_ORIGINS; // string[] — validated once, typed everywhere
secrets.get("PORT"); // number

new Outer({ db: pglite(), cors: { origins: secrets.get("CORS_ORIGINS") }, secrets })
  .schema(v1_0)
  .auth({ secret: secrets.get("AUTH_SECRET") })
  .build();

A schema whose validation is asynchronous (async refinements) throws — resolve those at startup and pass the resolved object to fromRecord.

fromRecord — a typed env, straight through

On Cloudflare Workers the Env interface generated by wrangler types already types the bindings and secrets, so there's nothing to re-declare. fromRecord takes the env directly and infers OuterSecrets<Env>:

import { Outer, fromRecord } from "@outerjs/server";

export class OuterDO extends DurableObject<Env> {
  readonly outer;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    const secrets = fromRecord(env);

    this.outer = new Outer({
      db: { dialect: new DurableObjectSqliteDialect(ctx.storage.sql as any), kind: "sqlite" },
      storage: fromR2(secrets.get("OUTER_FILES")), // typed as R2Bucket
      secrets,
    })
      .schema(v1_0)
      .auth({ secret: secrets.get("AUTH_SECRET") ?? "dev-only-secret" })
      .build();
  }
}

fromRecord does no validation or transforms — apply defaults (?? "…") and any string splitting at the use site. Values not present in the generated Env (config supplied via .dev.vars) can be added with a small type hint: fromRecord(env as Env & { BASE_URL?: string }).

Typing note

context.secrets is typed to the default string-bag shape (get returns string | undefined), not your schema's inferred types. For the precise per-key types, read the accessor you constructed — in a single-file server or Worker it's in scope on the procedure closures. If you define procedures in separate modules and want the narrow types there, annotate the read: (context.secrets as OuterSecrets<Env>).require("OUTER_FILES").

Next steps

  • Deployment — set the env per host, and match adapters to runtimes
  • Auth — where the secret your app validates first gets used