Skip to content

feat: first-class secrets — a forwardable slot with SecretBox (TML-3018)#70

Open
wmadden-electric wants to merge 18 commits into
mainfrom
claude/jovial-yalow-a63910
Open

feat: first-class secrets — a forwardable slot with SecretBox (TML-3018)#70
wmadden-electric wants to merge 18 commits into
mainfrom
claude/jovial-yalow-a63910

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

First-class secrets — a forwardable slot

A secret is its own slot kind — not a config param, not a dependency. A service/module declares a nameless need; the application root binds it to a platform env-var name and forwards it down the module boundary; it reads back as a redacting SecretBox. The framework only ever handles the name — the value never touches the deploy machine, deploy state, the generated stack, or a log.

// A reusable module declares a need — it never learns the platform var name:
const auth = compute({ name: 'auth', secrets: { signingKey: secret() }, build:  });
const { signingKey } = auth.secrets();     // SecretBox<string>
signingKey.expose();                        // the sole reader; everything else redacts

// The root owns the binding and forwards it in:
module('app', ({ provision }) => {
  provision(auth, { secrets: { signingKey: envSecret('AUTH_SIGNING_KEY') } });
});

Design of record: ADR-0029 (+ config-params.md § Secrets).

How a name (never a value) crosses the system

  1. Declare + forward. secret() is the need; envSecret('NAME') is the source the root binds; a module forwards its need through ctx.secrets, on the same rail Load already validates for inputs (ADR-0016). Load records each resolved binding { serviceAddress, slot, name } on the graph.
  2. Pointer serialization. The deploy target writes a pointer row COMPOSE_<addr>_<slot> = NAME — the platform name, never a value. Every generated key carries a reserved COMPOSE_ prefix so it can't collide with a user-provisioned variable.
  3. Boot double-lookup. The service reads the pointer row for the name, then process.env[NAME], and wraps it in a SecretBox — surfaced through a secrets() accessor beside load()/config() (ADR-0021).
  4. Deploy preflight. Before provisioning, verify every bound name exists on the platform for the stage's class/branch. A name absent from the platform but present in the deploy shell is provisioned with a direct write-only POST (never an Alchemy resource, so no value reaches deploy state) — single-step CI. A name absent from both fails the deploy, naming exactly what's missing.

SecretBox (copied into @internal/foundation, matching the platform's own secrecy type) redacts on toString/toJSON/valueOf/Symbol.toPrimitive/util.inspect, so an accidental log or serialize prints [REDACTED]; expose() is the one explicit door. Sensitivity is structural — carried by the type, not a flag every sink must remember.

What's here

Area Change
Core (@prisma/compose) secret() need; secrets on compute()/module(); ctx.secrets forwarding + Load validation; secrets() accessor; opaque SecretSource + secretSource() SPI (core never reads a source's payload); provisionManifest introspection; param↔secret name-collision guard
Foundation SecretBox
Pack (@prisma/compose-prisma-cloud) envSecret('NAME') — the target's source constructor (the env-var name + COMPOSE_/DATABASE_URL rules live here, not core); pointer rows + boot double-lookup keyed on the secret slot; preflight (per-stage verify + shell fill-missing); restricted EnvironmentVariable adoption
Example / E2E examples/storefront-auth — the auth module declares a need, the root binds (envSecret) + forwards it; non-leaking boolean round-trip proof + a committed Load test pinning the forwarding

Layering (ADR-0018/0019, applied to secrets): core owns the need (secret()) and forwards an opaque source it never inspects; the target owns the source constructor and provisioning. Prisma Cloud ships envSecret('NAME') (env vars); another target would ship vaultSecret({ path, key, version }) — no core change. import { secret } from '@prisma/compose' + import { envSecret } from '@prisma/compose-prisma-cloud'.

Evidence

Workspace green: core / pack / lowering / cron / storefront-auth typecheck + tests, lint:casts delta 0. The live proof runs in CI's e2e-deploy.yml: a real ephemeral Prisma Cloud deploy where the secret value exists only in the runner env, fill-missing provisions it, and the deployed service renders Secret /check says: true, then destroys everything.

History

The first pass (commits through 3ec8636) shipped a leaf-bound param-facet design; operator review corrected it to the forwardable-slot model (f3391d7da64506), then to the properly-layered opaque-source split (507ace6, moving envSecret to the target). Both earlier shapes are on the branch; the ADR and code reflect only the final design.

Next.js port fix (included)

The COMPOSE_ prefix renamed every generated env key, including a service's port, which would have left a Next.js service on a non-default port misbinding (Next binds process.env.PORT, which nothing set post-rename; dormant at the default 3000). Fixed here: run() exposes the resolved service port as process.env.PORT before boot — the universal port convention, so Next binds the port Compute routes to. Harmless to services that read config().port explicitly (the Bun auth service ignores PORT).

Closes TML-3018.

🤖 Generated with Claude Code

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…alt citation

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…uard

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…d env-var adoption

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ccurate reserved-namespace remedy

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… fill-missing

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…g-secret attribution

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…-env fill-missing

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@pkg-pr-new

pkg-pr-new Bot commented Jul 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/compose@70
npm i https://pkg.pr.new/@prisma/compose-prisma-cloud@70

commit: 3a1a58f

Comment on lines +44 to +47
A leaf module binds its own secret param directly, as above. Forwarding an
*unbound* secret param up through an enclosing module for something else to
bind later is out of scope for this slice — it depends on hex-composition's
boundary-port seam, which S1 doesn't build. So a service-own param that is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK first, you can't put transient project IDs in ADRs. Second: build the damn secret forwarding.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is way too long and carries inappropriate material for an ADR

Comment on lines +53 to +65
// (output.id), then a pre-existing row at the same (project, class,
// key). The platform seeds DATABASE_URL/_POOLED at project creation,
// which Prisma App poisons — a duplicate POST 409s and the API directs
// callers to PATCH. Adopting also makes create idempotent.
// (output.id), then — ONLY for a platform-seeded poison key — a
// pre-existing row at the same (project, class, key). The platform
// seeds DATABASE_URL/_POOLED at project creation, which Prisma App
// poisons; adopting that row makes the poison write idempotent.
//
// Every other key the framework writes is COMPOSE_-prefixed (ADR-0029,
// a reserved namespace users cannot bind into). So a pre-existing
// non-poison match we hold no state for is NOT ours to overwrite —
// something is squatting the reserved namespace, and we fail loudly
// rather than clobber it. (Trade-off: a redeploy after hosted deploy
// state is lost will also hit this on its own COMPOSE_ rows and must
// be resolved by hand — the spec chooses fail-loud over silent
// overwrite.)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut this down to 2-3 lines. NO MORE

Comment on lines +89 to +94
`EnvironmentVariable "${news.key}" already exists on project "${news.projectId}" ` +
`(class "${cls}") but is not tracked in this deploy's state — refusing to ` +
'overwrite. Keys the framework writes live in the reserved COMPOSE_ namespace ' +
"users cannot bind into, so this row is almost certainly this app's own prior " +
"deploy: restore this deploy's hosted state if it was lost, or remove the " +
'variable on the platform to let this deploy recreate it.',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WHY IS THIS SO LONG

const lines = missing.map(
(m) => ` - ${m.external} (required by service "${m.serviceAddress}")`,
);
return new Error(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centrtalize with other errors

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…g invariants

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…SecretBox boot

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric wmadden-electric changed the title feat: first-class secrets — envSecret, pointer rows, deploy preflight (TML-3018) feat: first-class secrets — a forwardable slot with SecretBox (TML-3018) Jul 13, 2026
* The name may not use the reserved `COMPOSE_` prefix or the poisoned
* `DATABASE_URL(_POOLED)` keys.
*/
export function envSecret(name: string): SecretSource {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notice this in framework. Secrets are passed as env vars in Prisma Cloud - not necessarily other implementations. Are we coupling to deployment target implementation details?

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…RT at boot

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants