feat: first-class secrets — a forwardable slot with SecretBox (TML-3018)#70
Open
wmadden-electric wants to merge 18 commits into
Open
feat: first-class secrets — a forwardable slot with SecretBox (TML-3018)#70wmadden-electric wants to merge 18 commits into
wmadden-electric wants to merge 18 commits into
Conversation
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>
commit: |
wmadden
requested changes
Jul 13, 2026
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 |
Collaborator
There was a problem hiding this comment.
OK first, you can't put transient project IDs in ADRs. Second: build the damn secret forwarding.
Collaborator
There was a problem hiding this comment.
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.) |
Collaborator
There was a problem hiding this comment.
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.', |
| const lines = missing.map( | ||
| (m) => ` - ${m.external} (required by service "${m.serviceAddress}")`, | ||
| ); | ||
| return new Error( |
Collaborator
There was a problem hiding this comment.
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
reviewed
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 { |
Collaborator
There was a problem hiding this comment.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Design of record: ADR-0029 (+ config-params.md § Secrets).
How a name (never a value) crosses the system
secret()is the need;envSecret('NAME')is the source the root binds; a module forwards its need throughctx.secrets, on the same rail Load already validates for inputs (ADR-0016). Load records each resolved binding{ serviceAddress, slot, name }on the graph.COMPOSE_<addr>_<slot> = NAME— the platform name, never a value. Every generated key carries a reservedCOMPOSE_prefix so it can't collide with a user-provisioned variable.process.env[NAME], and wraps it in aSecretBox— surfaced through asecrets()accessor besideload()/config()(ADR-0021).SecretBox(copied into@internal/foundation, matching the platform's ownsecrecytype) redacts ontoString/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
@prisma/compose)secret()need;secretsoncompute()/module();ctx.secretsforwarding + Load validation;secrets()accessor; opaqueSecretSource+secretSource()SPI (core never reads a source's payload);provisionManifestintrospection; param↔secret name-collision guardSecretBox@prisma/compose-prisma-cloud)envSecret('NAME')— the target's source constructor (the env-var name +COMPOSE_/DATABASE_URLrules live here, not core); pointer rows + boot double-lookup keyed on the secret slot; preflight (per-stage verify + shell fill-missing); restrictedEnvironmentVariableadoptionexamples/storefront-auth— the auth module declares a need, the root binds (envSecret) + forwards it; non-leaking boolean round-trip proof + a committedLoadtest pinning the forwardingLayering (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 shipsenvSecret('NAME')(env vars); another target would shipvaultSecret({ 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:castsdelta 0. The live proof runs in CI'se2e-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 rendersSecret /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 (f3391d7…da64506), then to the properly-layered opaque-source split (507ace6, movingenvSecretto 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'sport, which would have left a Next.js service on a non-default port misbinding (Next bindsprocess.env.PORT, which nothing set post-rename; dormant at the default 3000). Fixed here:run()exposes the resolved service port asprocess.env.PORTbefore boot — the universal port convention, so Next binds the port Compute routes to. Harmless to services that readconfig().portexplicitly (the Bun auth service ignoresPORT).Closes TML-3018.
🤖 Generated with Claude Code