From 18e13da237364831caeed292874a41370d012394 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 10:09:45 +0200 Subject: [PATCH 01/19] =?UTF-8?q?docs:=20ADR-0029=20=E2=80=94=20secrets=20?= =?UTF-8?q?are=20env-named=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/config-params.md | 64 ++++- .../ADR-0029-secrets-are-env-named-params.md | 254 ++++++++++++++++++ docs/design/90-decisions/README.md | 1 + 3 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 docs/design/90-decisions/ADR-0029-secrets-are-env-named-params.md diff --git a/docs/design/10-domains/config-params.md b/docs/design/10-domains/config-params.md index a8f865a4..cde03a1f 100644 --- a/docs/design/10-domains/config-params.md +++ b/docs/design/10-domains/config-params.md @@ -5,9 +5,11 @@ read back at boot. Rests on [ADR-0018](../90-decisions/ADR-0018-config-params-carry-a-caller-owned-schema.md) (a param's type is a caller-owned schema), [ADR-0019](../90-decisions/ADR-0019-the-target-owns-config-serialization.md) (the -deploy target owns serialization), and +deploy target owns serialization), [ADR-0021](../90-decisions/ADR-0021-params-are-read-through-config-not-load.md) -(params are read through `config()`). +(params are read through `config()`), and +[ADR-0029](../90-decisions/ADR-0029-secrets-are-env-named-params.md) (a secret +param is bound to an explicit platform env-var name). ## The problem in one example @@ -135,6 +137,62 @@ Job[] → Config (structured) → target serialize (its medium) → stored confi → target deserialize → schema-validated Job[] → config() ``` +## Secrets + +A secret is a param whose value the user provisions on the platform, never the +framework. `envSecret` declares one, binding it to the platform env-var name +that holds the value: + +```ts +compute({ + name: 'ingest', + params: { stripeKey: envSecret('STRIPE_SECRET_KEY') }, + // ... +}); +``` + +`envSecret(name)` is `{ schema: , secret: true, external: name }`. +`secret` forbids `default` — a fallback value would let a missing secret pass +silently and would leak into introspection, which is exactly what `secret` +exists to prevent. `optional` is still allowed. + +The round trip carries only the name, never the value: + +1. **Declare.** A leaf binds its own secret param with `envSecret`. A param + that is `secret: true` with no `external` name has no value source and + fails at deploy build — either bind it directly or wire it from a + producer's own bound param, the same as any other dependency value. +2. **Manifest.** `configOf` reports every param's `external` name; the + graph's aggregate of secret declarations is the app's provision manifest + — everything that must exist on the platform before deploy. +3. **Preflight.** Before Alchemy runs, the deploy pipeline checks that every + manifest name exists on the platform for the target stage's class/branch, + filling a name from the deploy shell's environment when the platform + doesn't have it yet (a direct API call, never an Alchemy resource, never + overwriting an existing platform value). A name missing from both fails + the deploy, listing exactly what's absent. +4. **Pointer row.** The pack writes a secret param's row as a pointer — the + generated key (`COMPOSE_`-prefixed, like every generated key) maps to the + `external` name, not a value: + ``` + COMPOSE_INGEST_STRIPEKEY = "STRIPE_SECRET_KEY" + ``` +5. **Boot double-lookup.** Deserialize reads the pointer, then reads + `process.env` for the name it names, validating through the param's + schema. + +The `COMPOSE_` prefix reserves the framework's generated keys into their own +namespace, so a generated key never collides with — and silently overwrites — +a user's own secret name. + +**Rotation is PATCH + redeploy.** A compute version snapshots its whole env +map at creation and never re-resolves it, so changing a secret's value is the +platform's existing semantics: `PATCH` the value on the platform, then create +a new version. Nothing about that is specific to secrets. + +See [ADR-0029](../90-decisions/ADR-0029-secrets-are-env-named-params.md) for +the full design and its alternatives. + ## Introspection A service's config surface is enumerable from the graph alone, nothing booted: @@ -162,6 +220,8 @@ Three lines the model holds everywhere: [ADR-0019](../90-decisions/ADR-0019-the-target-owns-config-serialization.md), [ADR-0021](../90-decisions/ADR-0021-params-are-read-through-config-not-load.md) — the decisions this documents. +- [ADR-0029](../90-decisions/ADR-0029-secrets-are-env-named-params.md) — the + secrets model this document's § Secrets summarizes. - [`core-model.md`](core-model.md) — where params sit in the node → graph → Config model. - [`ADR-0020`](../90-decisions/ADR-0020-scheduled-work-is-a-driver-not-a-resource.md) diff --git a/docs/design/90-decisions/ADR-0029-secrets-are-env-named-params.md b/docs/design/90-decisions/ADR-0029-secrets-are-env-named-params.md new file mode 100644 index 00000000..c00f2f6c --- /dev/null +++ b/docs/design/90-decisions/ADR-0029-secrets-are-env-named-params.md @@ -0,0 +1,254 @@ +# ADR-0029: Secrets are env-named params + +## Status + +Proposed + +## Decision + +A secret is an ordinary config param carrying the `secret` facet, bound to an +explicit platform environment-variable name. The framework only ever handles +that name — never the value: + +```ts +compute({ + name: 'ingest', + params: { stripeKey: envSecret('STRIPE_SECRET_KEY') }, + // ... +}); +``` + +`envSecret(name)` is a core param constructor +([config.ts](../../../packages/0-framework/1-core/core/src/config.ts)): + +```ts +export interface ConfigParam { + readonly schema: S; + readonly secret?: boolean; + readonly external?: string; // platform env-var name; provisioned out-of-band + readonly optional?: boolean; + readonly default?: StandardSchemaV1.InferOutput; +} + +export function envSecret( + name: string, + opts?: { readonly optional?: boolean }, +): ConfigParam>; +``` + +`envSecret(name)` returns `{ schema: , secret: true, external: +name }`. `external` is a new facet on `ConfigParam`/`ConfigDeclaration` +carrying the platform name a secret is bound to — the user provisions the +matching value on the platform; the framework never does. + +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 +`secret: true` but carries no `external` name fails loudly at deploy build: +"bind it with `envSecret` or wire it" — "wire it" meaning: make the value +arrive as an ordinary dependency input from a producer node whose own param +does carry the binding, the same way any other connection value already +flows. + +`secret: true` **forbids `default`**, enforced at the type level (a param +options type that splits secret from non-secret) and at runtime +(`withFacets`/`freezeParams`). `optional` is still allowed on a secret param. + +**Reserved names.** `external` must not start with the framework's own +`COMPOSE_` prefix (below) and must not be `DATABASE_URL` / +`DATABASE_URL_POOLED`, which Prisma Compose poisons at project provision. +Both are validated when the param is constructed. + +The framework carries the name across three moves: + +1. **Manifest introspection.** `configOf` reports each param's `external` + name; the graph's aggregate of every secret declaration with an `external` + name is the app's *provision manifest* — everything that must exist on the + platform before deploy. +2. **Pointer serialization.** The pack + ([serializer.ts](../../../packages/1-prisma-cloud/1-extensions/target/src/serializer.ts)) + writes a secret param's row as a pointer — the generated key maps to the + `external` name, not a value: + ``` + COMPOSE_INGEST_STRIPEKEY = "STRIPE_SECRET_KEY" + ``` + Boot deserializes by reading the generated key to get the pointer, then + reading `process.env[pointer]`. A secret's value never passes through the + serializer's own encoding, is never written as a `ConfigVariable` row's + value by the framework, and never enters Alchemy's deploy state. +3. **Deploy preflight.** Before Alchemy runs, the pipeline verifies every + manifest name exists on the platform for the target stage's resolved + class/branch. A name missing from the platform but present in the deploy + shell's own environment is filled in directly; anything still missing + fails the deploy, listing exactly what's absent. + +## Reasoning + +### The platform already treats env-var values as write-only + +Per [pdp-data-model.md](../05-prisma-cloud/pdp-data-model.md), +`ConfigVariable` values are encrypted under the project's key and are never +returned by a read — the Management API only ever accepts a value +(`POST`/`PATCH`); it never returns one. Every compute version snapshots the +**entire branch env map** at version-create time +(`materializeBranchEnvVars`), so a variable a user provisions directly on the +platform is already visible to every service attached to that branch — no +framework machinery is needed to get a value from "provisioned" to "running +instance." Rotation is just the platform's existing semantics: `PATCH` the +value, then create a new version, since a version's env is frozen at creation +and there is no live re-resolution. This design adds no new security +primitive — it routes *names* so a booting service knows which platform +variable holds its value. + +### Why a name, not a value, crosses into the framework + +If the framework carried a secret's value at all — through `buildConfig`, +into a generated stack file, into Alchemy's deploy state — it would sit +somewhere designed to be inspected, diffed, or read back +(a stack file is generated to be inspectable by design, per +[ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md)). +None of that machinery was built to hold a secret safely, and adding that +would mean every future exporter or lowering pass has to remember not to leak +the field. Keeping the framework's job to *names* sidesteps the problem +instead of defending against it: a name is exactly as safe to write, log, and +diff as any other config value, because a name is not the secret. + +This is also the constraint a future secrets-manager integration needs +already satisfied: the endgame is the platform pulling a value from a +customer's secrets manager at version materialization, with the value never +touching the deploy machine. A framework that has only ever carried names has +nothing to change when that lands. + +### Pointer rows and the `COMPOSE_` prefix + +A secret param's stored row is a pointer: the generated key (e.g. +`INGEST_STRIPEKEY`) maps to the platform name (`STRIPE_SECRET_KEY`), not a +value. Boot double-looks-up: read the generated key to get the pointer, then +read `process.env[pointer]`. + +Every generated key now carries a `COMPOSE_` prefix +(`COMPOSE_INGEST_STRIPEKEY`), fixing a real latent bug: the +`EnvironmentVariable` reconciler adopts and `PATCH`es whatever row already +exists at the same `(project, class, key)` +([EnvironmentVariable.ts](../../../packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts)), +so a generated key that happened to collide with a user's own secret name +would silently overwrite it. The prefix reserves a namespace for everything +the framework writes, leaving the user's namespace (`STRIPE_SECRET_KEY`, +`SENDGRID_API_KEY`, …) untouched. Adoption is further restricted: the +reconciler may only adopt a match when its own prior state row exists or the +key is a poison key (`DATABASE_URL`/`DATABASE_URL_POOLED`); adopting a +`COMPOSE_`-prefixed row it did not itself create fails loudly instead of +silently taking it over. + +### Preflight: verify before Alchemy runs, fill from the shell when possible + +Every compute version snapshots the branch's env map at creation, so a +secret's platform variable has to exist *before* that service's version is +created — the same timing constraint every `ConfigVariable` is already under +(pdp-data-model.md). Preflight makes that constraint explicit instead of +surfacing as a runtime failure inside an already-deployed instance: before +Alchemy runs, the deploy pipeline aggregates the manifest (every secret +declaration with an `external` name), resolves the check scope from the +target stage +([ADR-0024](ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md)) — +the default stage checks production-class project templates; a named stage +checks preview-class templates merged with that branch's overrides, the +platform's own materialization order — and calls `GET +/v1/environment-variables` (metadata only) to verify each name exists. + +A name absent from the platform but present in the deploy shell's own +environment (`process.env[externalName]`) is filled in during preflight: a +**direct management-API `POST`, never an Alchemy resource** — resource props +persist in hosted deploy state, and a secret's value must not. The value +transits CLI process memory exactly once and is never logged. Fill-missing +never overwrites an existing platform value, so a rotation made on the +platform always wins over a stale shell value. This is what makes a first +deploy single-step in CI: a CI secret lands in the runner's environment, and +the CLI provisions it onto the platform on the way to deploying. + +A name absent from both the platform and the shell fails the deploy, listing +every missing name with its class/branch scope and directing the user to set +it in the shell or on the platform. + +### `secret` forbids `default` + +A `default` on a secret param would let preflight pass while the real secret +is still missing — a service would boot on the fallback value instead of +failing — and it would put that fallback value into the graph's introspection +output, exactly what `secret` exists to keep out. `optional` carries no such +risk: an optional secret's absence is a legitimate outcome, validated the same +way any other optional param's absence is. + +## Consequences + +- **The framework never handles a secret's value** — not in `buildConfig`, + not in the generated stack file, not in Alchemy deploy state, not in any + log. Everything the framework touches is a name. +- **A first deploy in CI is single-step**: shell env → fill-missing → deploy, + with no separate provisioning command. +- **Rotation has no framework-specific procedure.** `PATCH` the platform + value and redeploy — the platform's existing version-snapshot semantics, + not something this design adds. +- **The same key works across stages without renaming.** Class/branch + materialization already gives `STRIPE_SECRET_KEY` a different value in + preview than in production; the pointer row is stage-neutral. +- **The `COMPOSE_` prefix renames every existing generated key.** An + existing app's first redeploy under this design rewrites every config row + and creates a new version for every service — a one-time churn the + no-op-redeploy E2E check needs to re-baseline against. +- **Leaf→root secret forwarding across nested modules is not built.** A + service-own secret param with no `external` name fails at deploy build + instead of silently having no value; extending forwarding to nested modules + is deferred to whatever work builds hex-composition's boundary ports. +- **A future secrets-manager integration slots in without a framework + change**: the platform pulling values from a customer's secrets manager at + version materialization needs exactly the "framework carries only names" + constraint this design already establishes. + +## Alternatives considered + +- **Derive the platform name from the param key.** Rejected: a naive + uppercase of `stripeKey` gives `STRIPEKEY`, which never matches a real + platform variable like `STRIPE_SECRET_KEY`; a camelCase→SCREAMING_SNAKE + transform is magic, and ambiguous the moment a key has digits or an + acronym in it. An explicit name removes the guessing. +- **Value-as-default sourced from the deployer's environment** — the + `fromEnv()` stopgap on branch `claude/datahub-port`. That branch bridged + secret values as an app-side convention: a value read from the deployer's + own environment became the param's `default`, and the serializer wrote that + default into an `EnvironmentVariable`'s *value* — landing a secret's actual + value in Alchemy deploy state, with no deploy-time check that the value was + even present. That branch's own plan flagged this as needing "a + first-class values mechanism … the durable fix"; this ADR is that + mechanism. Rejected as the durable design because it persists a secret's + value where deploy state is inspected, and because `secret: true` + forbidding `default` makes the pattern unexpressible going forward. +- **Bake wiring into the bundle to avoid a pointer row** — resolve a secret's + platform name at build time and inline it into the compiled bundle instead + of writing it as a config row. Rejected: it breaks the invariant that a + bundle carries only the node's own declaration + ([ADR-0008](ADR-0008-wrapper-inlines-everything-except-runtime-builtins.md)), + and it makes the bundle stage-specific — the same artifact could no longer + deploy unchanged to preview and production, which the platform's own + class/branch materialization is designed to let it do. +- **Unprefixed generated keys.** Rejected: the `EnvironmentVariable` + reconciler adopts and `PATCH`es any pre-existing row at the same + `(project, class, key)` + ([EnvironmentVariable.ts](../../../packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts)), + so an unprefixed generated key colliding with a user's own secret name + would be silently overwritten by the framework's own provisioning. + +## Related + +- [ADR-0018](ADR-0018-config-params-carry-a-caller-owned-schema.md) — a param + carries a caller-owned schema plus facets; `secret` and `external` are two + more facets on that same plain object. +- [ADR-0019](ADR-0019-the-target-owns-config-serialization.md) — the target + owns serialization; the pointer row and double-lookup are + `@prisma/compose-prisma-cloud`'s serialization choice, not core's. +- [ADR-0024](ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md) — + preflight's class/branch scope resolution rides the same stage → Project/Branch + resolution this ADR builds on. +- [`config-params.md`](../10-domains/config-params.md) — the params model end + to end; § Secrets is this ADR's summary in that document's voice. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index a4f1b278..5bae2eac 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -44,3 +44,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0026](ADR-0026-name-the-framework-prisma-compose.md) — The framework is **Prisma Compose**; "Prisma App" names only the artifact you build. Supersedes ADR-0014's framework, package, and CLI names: `@prisma/compose*` (incl. `compose-alchemy`), `prisma-compose`, `prisma-compose.config.ts`. - [ADR-0027](ADR-0027-two-packages-compose-and-compose-prisma-cloud.md) — Ship two **public** packages: `@prisma/compose` (core + CLI + agnostic subpaths) and `@prisma/compose-prisma-cloud` (target + first-party modules as entrypoints, cron first). Boundary = the user's one choice: where does it run. - [ADR-0028](ADR-0028-numbered-domains-and-layers-enforced-by-dependency-cruiser.md) — `packages/` organizes into numbered domains (0-framework, 1-prisma-cloud, 9-public) and layers; planes as config-mapped entrypoints; internals are `@internal/*`; only 9-public publishes; dependency-cruiser enforces. +- [ADR-0029](ADR-0029-secrets-are-env-named-params.md) — A secret is an ordinary config param with the `secret` facet, bound to an explicit platform env-var name via `envSecret`; the framework carries only the name — pointer rows, preflight, and rotation follow from that. *(Proposed)* From a494d40aa7fff0eb95ff2c5301d16b6f32706a29 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 10:18:33 +0200 Subject: [PATCH 02/19] docs: fix config-params prefix consistency + sharpen ADR-0029 bundle-alt citation Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/config-params.md | 14 ++++++++------ .../ADR-0029-secrets-are-env-named-params.md | 13 +++++++------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/design/10-domains/config-params.md b/docs/design/10-domains/config-params.md index cde03a1f..9ef86170 100644 --- a/docs/design/10-domains/config-params.md +++ b/docs/design/10-domains/config-params.md @@ -97,14 +97,16 @@ Config = { service: { jobs: [ {jobId:'tick',…}, {jobId:'mrr',…} ], port: 300 ``` **Deploy — serialize (the target).** `@prisma/compose-prisma-cloud` encodes each value into -its medium — key/value strings, keyed `ADDRESS_OWNER_NAME` to stay unique in the -shared project — validating structured values and passing dependency-input values -(provisioning refs) through untouched: +its medium — key/value strings, keyed `COMPOSE_ADDRESS_OWNER_NAME` to stay unique in +the shared project — validating structured values and passing dependency-input values +(provisioning refs) through untouched. Every generated key carries the `COMPOSE_` +prefix — the framework's reserved namespace, kept clear of the user-provisioned +variables secrets point at (§ Secrets): ``` -SCHEDULER_JOBS = '[{"jobId":"tick","every":"60s"},…]' -SCHEDULER_PORT = '3000' -SCHEDULER_TRIGGER_URL = 'https://…runner…' +COMPOSE_SCHEDULER_JOBS = '[{"jobId":"tick","every":"60s"},…]' +COMPOSE_SCHEDULER_PORT = '3000' +COMPOSE_SCHEDULER_TRIGGER_URL = 'https://…runner…' ``` The encoding (JSON here) is app-cloud's own; a different target would store the diff --git a/docs/design/90-decisions/ADR-0029-secrets-are-env-named-params.md b/docs/design/90-decisions/ADR-0029-secrets-are-env-named-params.md index c00f2f6c..ef048215 100644 --- a/docs/design/90-decisions/ADR-0029-secrets-are-env-named-params.md +++ b/docs/design/90-decisions/ADR-0029-secrets-are-env-named-params.md @@ -226,12 +226,13 @@ way any other optional param's absence is. forbidding `default` makes the pattern unexpressible going forward. - **Bake wiring into the bundle to avoid a pointer row** — resolve a secret's platform name at build time and inline it into the compiled bundle instead - of writing it as a config row. Rejected: it breaks the invariant that a - bundle carries only the node's own declaration - ([ADR-0008](ADR-0008-wrapper-inlines-everything-except-runtime-builtins.md)), - and it makes the bundle stage-specific — the same artifact could no longer - deploy unchanged to preview and production, which the platform's own - class/branch materialization is designed to let it do. + of writing it as a config row. Rejected: it makes the bundle stage-specific. + The same artifact could no longer deploy unchanged to preview and + production — which the platform's own class/branch materialization exists to + let it do, and which target-owned serialization keeps stage-neutral + ([ADR-0019](ADR-0019-the-target-owns-config-serialization.md)). A pointer + row, resolved by the target at deploy, keeps the built artifact identical + across stages. - **Unprefixed generated keys.** Rejected: the `EnvironmentVariable` reconciler adopts and `PATCH`es any pre-existing row at the same `(project, class, key)` From b8805781f95080b80d9edbded9ee9a05694fa3d3 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 10:33:47 +0200 Subject: [PATCH 03/19] feat(core): envSecret + external facet; secret forbids default Signed-off-by: willbot Signed-off-by: Will Madden --- .../core/src/__tests__/config.test-d.ts | 36 +++++++ .../1-core/core/src/__tests__/config.test.ts | 98 ++++++++++++++++++- .../1-core/core/src/__tests__/helpers.ts | 3 +- .../core/src/__tests__/lowering.test.ts | 51 +++++++++- .../0-framework/1-core/core/src/config.ts | 78 ++++++++++++++- .../0-framework/1-core/core/src/deploy.ts | 24 +++++ packages/0-framework/1-core/core/src/index.ts | 2 +- 7 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 packages/0-framework/1-core/core/src/__tests__/config.test-d.ts diff --git a/packages/0-framework/1-core/core/src/__tests__/config.test-d.ts b/packages/0-framework/1-core/core/src/__tests__/config.test-d.ts new file mode 100644 index 00000000..2d09c1b8 --- /dev/null +++ b/packages/0-framework/1-core/core/src/__tests__/config.test-d.ts @@ -0,0 +1,36 @@ +/** + * Type-level facet rules for config params (ADR-0029): `secret` and `default` + * are mutually exclusive, and `envSecret` infers a string ConfigParam. + * Type-only (vitest `--typecheck`, never executed). + */ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { expectTypeOf, test } from 'vitest'; +import type { ConfigParam } from '../config.ts'; +import { envSecret, param, string } from '../config.ts'; + +test('envSecret infers ConfigParam>', () => { + expectTypeOf(envSecret('STRIPE_KEY')).toEqualTypeOf< + ConfigParam> + >(); + expectTypeOf(envSecret('STRIPE_KEY', { optional: true })).toEqualTypeOf< + ConfigParam> + >(); +}); + +test('a non-secret param may carry a default; a secret one may still be optional', () => { + string({ default: 'x' }); + string({ optional: true, default: 'x' }); + string({ secret: true }); + string({ secret: true, optional: true }); +}); + +test('a secret param may not carry a default', () => { + // @ts-expect-error secret forbids default + string({ secret: true, default: 'x' }); +}); + +test('a secret param over an arbitrary schema may not carry a default either', () => { + const schema = {} as StandardSchemaV1; + // @ts-expect-error secret forbids default + param(schema, { secret: true, default: 'x' }); +}); diff --git a/packages/0-framework/1-core/core/src/__tests__/config.test.ts b/packages/0-framework/1-core/core/src/__tests__/config.test.ts index 67985521..ac8280a2 100644 --- a/packages/0-framework/1-core/core/src/__tests__/config.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { configOf, number, string } from '../config.ts'; +import { configOf, envSecret, number, param, string } from '../config.ts'; import { dependency, service } from '../node.ts'; import { conn, scalarDeclaration } from './helpers.ts'; @@ -127,3 +127,99 @@ describe('configOf over dependency inputs', () => { ]); }); }); + +describe('envSecret', () => { + test('binds a secret string param to a platform env-var name — no default', () => { + const p = envSecret('STRIPE_SECRET_KEY'); + expect(p.secret).toBe(true); + expect(p.external).toBe('STRIPE_SECRET_KEY'); + expect(p.optional).toBeUndefined(); + expect(p.default).toBeUndefined(); + // Reuses the shared string schema — same singleton string() carries. + expect(p.schema).toBe(string().schema); + }); + + test('carries optional when asked, still no default', () => { + const p = envSecret('SENDGRID_API_KEY', { optional: true }); + expect(p.secret).toBe(true); + expect(p.optional).toBe(true); + expect(p.external).toBe('SENDGRID_API_KEY'); + expect(p.default).toBeUndefined(); + }); + + test('rejects an empty name at construction', () => { + expect(() => envSecret('')).toThrow(/non-empty platform env-var name/); + }); + + test('rejects the reserved COMPOSE_ prefix', () => { + expect(() => envSecret('COMPOSE_STRIPE')).toThrow(/COMPOSE_/); + }); + + test('rejects the poisoned DATABASE_URL keys', () => { + expect(() => envSecret('DATABASE_URL')).toThrow(/reserved/); + expect(() => envSecret('DATABASE_URL_POOLED')).toThrow(/reserved/); + }); +}); + +describe('secret forbids default (runtime guard)', () => { + test('string({ secret: true, default }) throws', () => { + expect(() => string({ secret: true, default: 'x' } as never)).toThrow( + /secret config param cannot declare a `default`/, + ); + }); + + test('param(schema, { secret: true, default }) throws', () => { + expect(() => param(string().schema, { secret: true, default: 'x' } as never)).toThrow( + /secret config param cannot declare a `default`/, + ); + }); + + test('a non-secret param keeps its default', () => { + expect(string({ default: 'x' }).default).toBe('x'); + }); +}); + +describe('configOf reports external', () => { + test('a service-own secret param reports its platform name; a non-secret one reports undefined', () => { + const root = service({ + name: 'ingest', + extension: 'test/pack', + type: 'fake/app', + inputs: {}, + params: { stripeKey: envSecret('STRIPE_SECRET_KEY'), port: number({ default: 3000 }) }, + build, + }); + + expect(configOf(root)).toEqual([ + scalarDeclaration('service', 'stripeKey', { + secret: true, + external: 'STRIPE_SECRET_KEY', + }), + scalarDeclaration('service', 'port', { default: 3000 }), + ]); + }); + + test('a dependency-input secret connection param reports external when bound', () => { + const root = service({ + name: 'ingest', + extension: 'test/pack', + type: 'fake/app', + inputs: { + billing: dependency({ + name: 'billing', + type: 'fake/rpc', + connection: conn({ key: envSecret('BILLING_KEY') }, () => ({})), + }), + }, + params: {}, + build, + }); + + expect(configOf(root)).toEqual([ + scalarDeclaration({ input: 'billing' }, 'key', { + secret: true, + external: 'BILLING_KEY', + }), + ]); + }); +}); diff --git a/packages/0-framework/1-core/core/src/__tests__/helpers.ts b/packages/0-framework/1-core/core/src/__tests__/helpers.ts index a4ca4b28..18cd77f3 100644 --- a/packages/0-framework/1-core/core/src/__tests__/helpers.ts +++ b/packages/0-framework/1-core/core/src/__tests__/helpers.ts @@ -23,13 +23,14 @@ export const providerContract = (kind: K, cmp: Cmp): Cont export function scalarDeclaration( owner: ConfigDeclaration['owner'], name: string, - opts: { secret?: boolean; optional?: boolean; default?: unknown } = {}, + opts: { secret?: boolean; optional?: boolean; default?: unknown; external?: string } = {}, ): ConfigDeclaration { return { owner, name, schema: { vendor: '@prisma/compose' }, secret: opts.secret ?? false, + ...(opts.external !== undefined ? { external: opts.external } : {}), optional: opts.optional ?? false, default: opts.default, }; diff --git a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts index f521445d..c4398782 100644 --- a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts @@ -3,7 +3,7 @@ import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import type { ExtensionDescriptor, PrismaAppConfig } from '../app-config.ts'; import type { Config, Params } from '../config.ts'; -import { number, string } from '../config.ts'; +import { envSecret, number, string } from '../config.ts'; import { type AlchemyStateLayer, type Artifact, @@ -209,6 +209,55 @@ describe('buildConfig', () => { }); }); +describe('a service-own secret with no platform binding fails the deploy build', () => { + test('an unbound secret param is a LowerError naming the param, service, and the fix', () => { + const { config, calls } = fakeExtension(); + const root = module('hello', {}, (h) => { + h.provision(app('fake/compute', {}, { stripeKey: string({ secret: true }) }), { id: 'svc' }); + return {}; + }); + + const error = runError(lowering(root, config, opts(svcBundles))); + + expect(error).toBeInstanceOf(LowerError); + expect(error.message).toContain('secret param "stripeKey"'); + expect(error.message).toContain('service "svc"'); + expect(error.message).toContain('envSecret'); + // Fails before the service is provisioned — no side effects. + expect(calls.map((c) => c.phase)).not.toContain('provision'); + }); + + test('a bound secret (envSecret) lowers cleanly', () => { + const { config } = fakeExtension(); + const root = module('hello', {}, (h) => { + h.provision(app('fake/compute', {}, { stripeKey: envSecret('STRIPE_SECRET_KEY') }), { + id: 'svc', + }); + return {}; + }); + + expect(run(lowering(root, config, opts(svcBundles)))).toEqual({ outputs: {} }); + }); + + test('a dependency-input secret param without external lowers fine — its value comes from the producer', () => { + const secretDbEnd = () => + dependency({ + name: 'db', + type: 'fake/db', + connection: conn({ url: string({ secret: true }) }, () => ({})), + required: providerContract('fake/db', { url: '' }), + }); + const { config } = fakeExtension(); + const root = module('hello', {}, (h) => { + const db = h.provision(dbResource(), { id: 'db' }); + h.provision(app('fake/compute', { db: secretDbEnd() }), { id: 'svc', deps: { db } }); + return {}; + }); + + expect(run(lowering(root, config, opts(svcBundles)))).toEqual({ outputs: {} }); + }); +}); + const singleServiceModule = ( type: string, params: Params = {}, diff --git a/packages/0-framework/1-core/core/src/config.ts b/packages/0-framework/1-core/core/src/config.ts index 15a064b9..c7ec0f3b 100644 --- a/packages/0-framework/1-core/core/src/config.ts +++ b/packages/0-framework/1-core/core/src/config.ts @@ -22,6 +22,11 @@ export interface ConfigParam { readonly schema: S; /** Redacted in any introspection output. */ readonly secret?: boolean; + /** + * The platform env-var name a secret param is bound to (set by `envSecret`, + * ADR-0029). The framework carries only this name — never the value. + */ + readonly external?: string; readonly optional?: boolean; readonly default?: StandardSchemaV1.InferOutput; } @@ -61,6 +66,8 @@ export interface ConfigDeclaration { readonly name: string; readonly schema: Readonly>; readonly secret: boolean; + /** The platform env-var name a secret param is bound to; omitted for a non-secret param (ADR-0029). */ + readonly external?: string; readonly optional: boolean; readonly default: unknown; } @@ -112,6 +119,7 @@ export function configOf(root: ServiceNode): readonly ConfigDeclaration[] { name, schema: projectSchema(param.schema), secret: param.secret === true, + ...(param.external !== undefined ? { external: param.external } : {}), optional: param.optional === true, default: param.default, }); @@ -124,6 +132,7 @@ export function configOf(root: ServiceNode): readonly ConfigDeclaration[] { name, schema: projectSchema(param.schema), secret: param.secret === true, + ...(param.external !== undefined ? { external: param.external } : {}), optional: param.optional === true, default: param.default, }); @@ -162,21 +171,63 @@ const numberSchema = scalarSchema( (v): v is number => typeof v === 'number' && Number.isFinite(v), ); -export interface ParamOptions { +/** + * Param facets. `secret` and `default` are mutually exclusive: a secret's value + * lives on the platform (ADR-0029), so a default would both defeat the + * deploy-time presence check and put a value into introspection output. The + * union makes `{ secret: true, default }` a type error; `withFacets` re-checks + * at runtime for callers that dodge the types. + */ +export type ParamOptions = + | { readonly secret?: false; readonly optional?: boolean; readonly default?: T } + | { readonly secret: true; readonly optional?: boolean }; + +/** `withFacets`' internal option shape — adds `external`, which only `envSecret` sets. */ +interface FacetOptions { readonly secret?: boolean; readonly optional?: boolean; readonly default?: T; + readonly external?: string; } +const COMPOSE_PREFIX = 'COMPOSE_'; +const POISONED_NAMES = new Set(['DATABASE_URL', 'DATABASE_URL_POOLED']); + +/** + * The single construction chokepoint: assembles a ConfigParam and enforces the + * facet invariants the types express (ADR-0029), so a value that bypasses the + * types still fails loudly. + */ function withFacets( schema: S, - opts: ParamOptions>, + opts: FacetOptions>, ): ConfigParam { + if (opts.secret === true && opts.default !== undefined) { + throw new Error( + 'a secret config param cannot declare a `default` — its value is provisioned on the platform ' + + '(ADR-0029); a default would defeat deploy preflight and leak a value into introspection.', + ); + } + if (opts.external !== undefined) { + if (opts.external.startsWith(COMPOSE_PREFIX)) { + throw new Error( + `envSecret name "${opts.external}" may not start with "${COMPOSE_PREFIX}" — that prefix is ` + + "reserved for the framework's own generated config keys.", + ); + } + if (POISONED_NAMES.has(opts.external)) { + throw new Error( + `envSecret name "${opts.external}" is reserved — ${[...POISONED_NAMES].join(' and ')} are ` + + 'poisoned at project provision and cannot back a secret param.', + ); + } + } return { schema, ...(opts.secret !== undefined ? { secret: opts.secret } : {}), ...(opts.optional !== undefined ? { optional: opts.optional } : {}), ...(opts.default !== undefined ? { default: opts.default } : {}), + ...(opts.external !== undefined ? { external: opts.external } : {}), }; } @@ -201,3 +252,26 @@ export function param( ): ConfigParam { return withFacets(schema, opts); } + +/** + * A secret string param bound to an explicit platform env-var `name` (ADR-0029). + * The framework carries only the name; the value is provisioned out-of-band on + * the platform. `secret` forbids `default`; `optional` is allowed. `name` may + * not use the reserved `COMPOSE_` prefix or the poisoned `DATABASE_URL(_POOLED)` + * keys. + */ +export function envSecret( + name: string, + opts: { readonly optional?: boolean } = {}, +): ConfigParam> { + if (typeof name !== 'string' || name.length === 0) { + throw new Error( + "envSecret() requires a non-empty platform env-var name, e.g. envSecret('STRIPE_SECRET_KEY').", + ); + } + return withFacets(stringSchema, { + secret: true, + external: name, + ...(opts.optional !== undefined ? { optional: opts.optional } : {}), + }); +} diff --git a/packages/0-framework/1-core/core/src/deploy.ts b/packages/0-framework/1-core/core/src/deploy.ts index de969d93..05f7120e 100644 --- a/packages/0-framework/1-core/core/src/deploy.ts +++ b/packages/0-framework/1-core/core/src/deploy.ts @@ -159,6 +159,26 @@ function missingBundleError(id: NodeId): LowerError { ); } +function unboundSecretError(id: NodeId, param: string): LowerError { + return new LowerError( + `secret param "${param}" on service "${id}" has no platform binding — declare it with ` + + "envSecret('NAME'), or wire it from a producer.", + ); +} + +/** + * The name of the first service-OWN param that is `secret` with no platform + * binding (`external`), or `undefined`. A dependency input's secret connection + * params are exempt — their value comes from the producer, not a platform name — + * so only `node.params` is inspected (S1: no leaf→root forwarding, ADR-0029). + */ +function firstUnboundServiceSecret(node: ServiceNode): string | undefined { + for (const [name, param] of Object.entries(node.params)) { + if (param.secret === true && param.external === undefined) return name; + } + return undefined; +} + function duplicateExtensionError(id: string): LowerError { return new LowerError( `Extension "${id}" is listed more than once in \`extensions\` — each extension id must be unique.`, @@ -307,6 +327,10 @@ export function lowering( ); } + const unbound = firstUnboundServiceSecret(node as ServiceNode); + if (unbound !== undefined) { + return yield* Effect.fail(unboundSecretError(id, unbound)); + } const provisioned = yield* descriptor.provision(ctx); const typedConfig = buildConfig(node as ServiceNode, id, graph, lowered); const serialized = yield* descriptor.serialize(ctx, provisioned, typedConfig); diff --git a/packages/0-framework/1-core/core/src/index.ts b/packages/0-framework/1-core/core/src/index.ts index f42eeb92..a7088df7 100644 --- a/packages/0-framework/1-core/core/src/index.ts +++ b/packages/0-framework/1-core/core/src/index.ts @@ -13,7 +13,7 @@ export type { Params, Values, } from './config.ts'; -export { configOf, number, param, string } from './config.ts'; +export { configOf, envSecret, number, param, string } from './config.ts'; export type { Contract } from './contract.ts'; export type { Edge, Graph, GraphNode, NodeId } from './graph.ts'; export { Load, LoadError } from './graph.ts'; From 633156ee17ae7e1ab1157804364aa3dbc5990d54 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 10:44:35 +0200 Subject: [PATCH 04/19] fix(core): fold secret cast to satisfy ratchet; exhaustive external guard Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-core/core/src/__tests__/config.test.ts | 12 +++++++++++- packages/0-framework/1-core/core/src/config.ts | 10 +++++----- packages/0-framework/1-core/core/src/deploy.ts | 6 ++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/0-framework/1-core/core/src/__tests__/config.test.ts b/packages/0-framework/1-core/core/src/__tests__/config.test.ts index ac8280a2..966c4d86 100644 --- a/packages/0-framework/1-core/core/src/__tests__/config.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/config.test.ts @@ -148,7 +148,7 @@ describe('envSecret', () => { }); test('rejects an empty name at construction', () => { - expect(() => envSecret('')).toThrow(/non-empty platform env-var name/); + expect(() => envSecret('')).toThrow(/must be a non-empty string/); }); test('rejects the reserved COMPOSE_ prefix', () => { @@ -159,6 +159,10 @@ describe('envSecret', () => { expect(() => envSecret('DATABASE_URL')).toThrow(/reserved/); expect(() => envSecret('DATABASE_URL_POOLED')).toThrow(/reserved/); }); + + test('withFacets is the chokepoint — an empty external dodged in via string() is still caught', () => { + expect(() => string({ external: '' } as never)).toThrow(/must be a non-empty string/); + }); }); describe('secret forbids default (runtime guard)', () => { @@ -168,6 +172,12 @@ describe('secret forbids default (runtime guard)', () => { ); }); + test('number({ secret: true, default }) throws', () => { + expect(() => number({ secret: true, default: 1 } as never)).toThrow( + /secret config param cannot declare a `default`/, + ); + }); + test('param(schema, { secret: true, default }) throws', () => { expect(() => param(string().schema, { secret: true, default: 'x' } as never)).toThrow( /secret config param cannot declare a `default`/, diff --git a/packages/0-framework/1-core/core/src/config.ts b/packages/0-framework/1-core/core/src/config.ts index c7ec0f3b..83c09a5c 100644 --- a/packages/0-framework/1-core/core/src/config.ts +++ b/packages/0-framework/1-core/core/src/config.ts @@ -209,6 +209,11 @@ function withFacets( ); } if (opts.external !== undefined) { + if (opts.external.length === 0) { + throw new Error( + "envSecret name must be a non-empty string, e.g. envSecret('STRIPE_SECRET_KEY').", + ); + } if (opts.external.startsWith(COMPOSE_PREFIX)) { throw new Error( `envSecret name "${opts.external}" may not start with "${COMPOSE_PREFIX}" — that prefix is ` + @@ -264,11 +269,6 @@ export function envSecret( name: string, opts: { readonly optional?: boolean } = {}, ): ConfigParam> { - if (typeof name !== 'string' || name.length === 0) { - throw new Error( - "envSecret() requires a non-empty platform env-var name, e.g. envSecret('STRIPE_SECRET_KEY').", - ); - } return withFacets(stringSchema, { secret: true, external: name, diff --git a/packages/0-framework/1-core/core/src/deploy.ts b/packages/0-framework/1-core/core/src/deploy.ts index 05f7120e..c8f613c4 100644 --- a/packages/0-framework/1-core/core/src/deploy.ts +++ b/packages/0-framework/1-core/core/src/deploy.ts @@ -327,12 +327,14 @@ export function lowering( ); } - const unbound = firstUnboundServiceSecret(node as ServiceNode); + // One cast for both uses below — folded so the ratchet sees no new site. + const service = node as ServiceNode; + const unbound = firstUnboundServiceSecret(service); if (unbound !== undefined) { return yield* Effect.fail(unboundSecretError(id, unbound)); } const provisioned = yield* descriptor.provision(ctx); - const typedConfig = buildConfig(node as ServiceNode, id, graph, lowered); + const typedConfig = buildConfig(service, id, graph, lowered); const serialized = yield* descriptor.serialize(ctx, provisioned, typedConfig); const bundle = opts.bundles[id]; if (bundle === undefined) { From 596d695098d0829117ca4732a01baf7b012a03d4 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 11:07:09 +0200 Subject: [PATCH 05/19] feat(prisma-cloud): COMPOSE_ prefix + secret pointer rows + restricted env-var adoption Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/__tests__/EnvironmentVariable.test.ts | 168 ++++++++++++++++++ .../src/compute/EnvironmentVariable.ts | 32 +++- .../src/__tests__/control-lowering.test.ts | 66 +++++-- .../target/src/__tests__/extension.test.ts | 148 ++++++++++++--- .../target/src/__tests__/invariants.test.ts | 4 +- .../target/src/descriptors/compute.ts | 13 +- .../1-extensions/target/src/serializer.ts | 60 ++++++- 7 files changed, 442 insertions(+), 49 deletions(-) create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts new file mode 100644 index 00000000..3d920f5a --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import * as Cause from 'effect/Cause'; +import * as Effect from 'effect/Effect'; +import { type ManagementApiClient, ManagementClient } from '../client.ts'; +import { + EnvironmentVariable, + EnvironmentVariableProvider, +} from '../compute/EnvironmentVariable.ts'; + +interface RecordedCall { + method: 'GET' | 'POST' | 'PATCH'; + path: string; + body?: unknown; +} + +interface FakeState { + calls: RecordedCall[]; + /** Rows the own-row GET /{envVarId} resolves (keyed by id); absent → 404. */ + byId: Record; + /** What the list GET (project, class, key) returns as its `data` array. */ + listMatch: { id: string }[]; +} + +const okResponse = (data: T, status = 200) => ({ + data, + error: undefined, + response: new Response(null, { status }), +}); + +const notFoundResponse = () => ({ + data: undefined, + error: undefined, + response: new Response(null, { status: 404 }), +}); + +/** + * A stubbed `ManagementApiClient` covering the EnvironmentVariable provider's + * endpoints, recording every call — the ComputeService.test.ts idiom. `as + * unknown as ManagementApiClient` is acceptable here (test file — exempt from + * the no-bare-cast rule). + */ +const fakeClient = (state: FakeState): ManagementApiClient => { + const GET = (path: string, init: { params?: { path?: { envVarId?: string } } } = {}) => { + state.calls.push({ method: 'GET', path }); + if (path === '/v1/environment-variables/{envVarId}') { + const id = init.params?.path?.envVarId ?? ''; + const row = state.byId[id]; + return Promise.resolve(row ? okResponse(row) : notFoundResponse()); + } + if (path === '/v1/environment-variables') { + return Promise.resolve(okResponse({ data: state.listMatch })); + } + throw new Error(`fakeClient: unexpected GET ${path}`); + }; + + const POST = (path: string, init: { body?: Record } = {}) => { + state.calls.push({ method: 'POST', path, body: init.body }); + return Promise.resolve( + okResponse({ data: { id: 'ev-created', key: String(init.body?.['key']) } }, 201), + ); + }; + + const PATCH = (path: string, init: { body?: Record } = {}) => { + state.calls.push({ method: 'PATCH', path, body: init.body }); + return Promise.resolve(okResponse({ ok: true })); + }; + + return { GET, POST, PATCH } as unknown as ManagementApiClient; +}; + +const getService = (state: FakeState) => + Effect.runPromise( + EnvironmentVariable.Provider.pipe( + Effect.provide(EnvironmentVariableProvider()), + Effect.provideService(ManagementClient, fakeClient(state)), + ), + ); + +const reconcile = async ( + state: FakeState, + input: { + news: Record; + output?: { id: string; key: string } | undefined; + }, +) => { + const svc = await getService(state); + return Effect.runPromise(svc.reconcile(input as unknown as Parameters[0])); +}; + +const reconcileExit = async ( + state: FakeState, + input: { news: Record; output?: { id: string; key: string } | undefined }, +) => { + const svc = await getService(state); + return Effect.runPromiseExit( + svc.reconcile(input as unknown as Parameters[0]), + ); +}; + +describe('EnvironmentVariable reconcile — restricted adoption (ADR-0029)', () => { + let state: FakeState; + + beforeEach(() => { + state = { calls: [], byId: {}, listMatch: [] }; + }); + + test('own prior row (output.id still exists): PATCHes it, no adoption GET-list', async () => { + state.byId['ev-mine'] = { id: 'ev-mine', key: 'COMPOSE_INGEST_STRIPEKEY' }; + + const result = await reconcile(state, { + news: { projectId: 'proj-1', key: 'COMPOSE_INGEST_STRIPEKEY', value: 'STRIPE_SECRET_KEY' }, + output: { id: 'ev-mine', key: 'COMPOSE_INGEST_STRIPEKEY' }, + }); + + expect(result).toEqual({ id: 'ev-mine', key: 'COMPOSE_INGEST_STRIPEKEY' }); + // GET the own row, then PATCH it — never the (project,class,key) adoption list. + expect(state.calls.map((c) => c.method)).toEqual(['GET', 'PATCH']); + expect(state.calls.filter((c) => c.path === '/v1/environment-variables')).toHaveLength(0); + }); + + test('a poison key with a pre-existing platform row is adopted and PATCHed', async () => { + state.listMatch = [{ id: 'ev-poison' }]; + + const result = await reconcile(state, { + news: { projectId: 'proj-1', key: 'DATABASE_URL', value: '-' }, + output: undefined, + }); + + expect(result).toEqual({ id: 'ev-poison', key: 'DATABASE_URL' }); + expect(state.calls.map((c) => c.method)).toEqual(['GET', 'PATCH']); + expect(state.calls.filter((c) => c.method === 'POST')).toHaveLength(0); + }); + + test('a COMPOSE_ key with a pre-existing row it has no state for fails loudly, never overwrites', async () => { + state.listMatch = [{ id: 'ev-foreign' }]; + + const exit = await reconcileExit(state, { + news: { projectId: 'proj-1', key: 'COMPOSE_INGEST_STRIPEKEY', value: 'STRIPE_SECRET_KEY' }, + output: undefined, + }); + + expect(exit._tag).toBe('Failure'); + if (exit._tag === 'Failure') { + expect(Cause.pretty(exit.cause)).toContain('reserved COMPOSE_ namespace'); + } + // It observed the collision, then refused — no PATCH, no POST. + expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); + expect(state.calls.filter((c) => c.method === 'POST')).toHaveLength(0); + }); + + test('a COMPOSE_ key with no pre-existing row creates it', async () => { + state.listMatch = []; + + const result = await reconcile(state, { + news: { projectId: 'proj-1', key: 'COMPOSE_INGEST_STRIPEKEY', value: 'STRIPE_SECRET_KEY' }, + output: undefined, + }); + + expect(result).toEqual({ id: 'ev-created', key: 'COMPOSE_INGEST_STRIPEKEY' }); + const post = state.calls.find((c) => c.method === 'POST'); + expect(post?.body).toMatchObject({ + projectId: 'proj-1', + key: 'COMPOSE_INGEST_STRIPEKEY', + value: 'STRIPE_SECRET_KEY', + }); + expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts index 990a47a4..cd384ed8 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts @@ -50,10 +50,19 @@ export const EnvironmentVariableProvider = () => const cls = news.class ?? 'production'; // The value is write-only (encrypted), so we never diff it — we PATCH. // Find the row to write, adopting in order: our own prior row - // (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.) let id = output?.id; if (id !== undefined) { const priorId = id; @@ -72,7 +81,20 @@ export const EnvironmentVariableProvider = () => }, }), ); - id = (match as { data?: Array<{ id: string }> }).data?.[0]?.id; + const matchId = (match as { data?: Array<{ id: string }> }).data?.[0]?.id; + if (matchId !== undefined) { + const isPoison = news.key === 'DATABASE_URL' || news.key === 'DATABASE_URL_POOLED'; + if (!isPoison) { + throw new Error( + `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; ' + + 'a pre-existing one it did not create signals a collision. Remove the variable ' + + 'on the platform, or rebind the conflicting secret to a non-reserved name.', + ); + } + id = matchId; + } } if (id !== undefined) { const targetId = id; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index 9556950c..e32d68b2 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -88,7 +88,7 @@ mock.module('../pg-warm-resource.ts', () => ({ const { prismaCloud } = await import('../control.ts'); const { compute, postgres } = await import('../index.ts'); -const { module } = await import('@internal/core'); +const { module, envSecret } = await import('@internal/core'); const { lowering } = await import('@internal/core/deploy'); const run = (eff: Effect.Effect): A => @@ -339,10 +339,10 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { expect(recorded.envVar.slice(-2)).toEqual([ [ - 'AUTH_DB_URL-var', + 'COMPOSE_AUTH_DB_URL-var', { projectId: 'shop-project#cloud-id', - key: 'AUTH_DB_URL', + key: 'COMPOSE_AUTH_DB_URL', value: 'postgres://real-db', class: 'production', }, @@ -351,18 +351,18 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { // the ConfigVariable value field is string-typed, and deserialize reads // it back to a number (round-tripped in pack.test.ts). [ - 'AUTH_PORT-var', + 'COMPOSE_AUTH_PORT-var', { projectId: 'shop-project#cloud-id', - key: 'AUTH_PORT', + key: 'COMPOSE_AUTH_PORT', value: '3000', class: 'production', }, ], ]); expect(result.outputs['environment']).toEqual([ - { id: 'AUTH_DB_URL-var#cloud-id', key: 'AUTH_DB_URL' }, - { id: 'AUTH_PORT-var#cloud-id', key: 'AUTH_PORT' }, + { id: 'COMPOSE_AUTH_DB_URL-var#cloud-id', key: 'COMPOSE_AUTH_DB_URL' }, + { id: 'COMPOSE_AUTH_PORT-var#cloud-id', key: 'COMPOSE_AUTH_PORT' }, ]); // serialize also surfaces the resolved listen port for deploy() — the // Deployment must route to whatever the app binds, not a constant. @@ -370,6 +370,48 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { }); }); + test('a secret param serializes to a POINTER row — value is the external NAME, never the value (ADR-0029)', async () => { + await withEnv( + // The real secret value is present in the deploy shell, proving it still + // cannot reach the serialized row. + { PRISMA_BRANCH_ID: undefined, STRIPE_SECRET_KEY: 'sk_live_should_not_leak' }, + () => { + const target = prismaCloud({ workspaceId: 'ws_1' }); + const node = compute({ + name: 'ingest', + deps: {}, + params: { stripeKey: envSecret('STRIPE_SECRET_KEY') }, + build: { + extension: '@prisma/compose/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', + }, + }); + const ctx = { address: 'ingest', node } as unknown as LowerContext; + const provisioned: LoweredNode = { outputs: { projectId: 'shop-project#cloud-id' } }; + // buildConfig omits the defaultless secret; serialize still writes its pointer row. + const config = { service: { port: 3000 }, inputs: {} }; + const before = recorded.envVar.length; + + run( + serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config), + ); + + const writes = recorded.envVar.slice(before).map(([, props]) => props); + // The secret's row holds the external NAME (a pointer), never a value. + expect(writes).toContainEqual({ + projectId: 'shop-project#cloud-id', + key: 'COMPOSE_INGEST_STRIPEKEY', + value: 'STRIPE_SECRET_KEY', + class: 'production', + }); + // No serialized EnvironmentVariable output carries the secret's value. + expect(JSON.stringify(writes)).not.toContain('sk_live'); + }, + ); + }); + test('named stage (PRISMA_BRANCH_ID set): serialize writes env vars with class "preview" and branchId', async () => { await withEnv({ PRISMA_BRANCH_ID: 'branch_1' }, () => { const target = prismaCloud({ workspaceId: 'ws_1' }); @@ -392,10 +434,10 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { expect(recorded.envVar.slice(before)).toEqual([ [ - 'AUTH3_PORT-var', + 'COMPOSE_AUTH3_PORT-var', { projectId: 'shop-project#cloud-id', - key: 'AUTH3_PORT', + key: 'COMPOSE_AUTH3_PORT', value: '3000', class: 'preview', branchId: 'branch_1', @@ -465,7 +507,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { const artifact = { path: '/tmp/auth.tar.gz', sha256: 'sha-auth' }; const serialized: LoweredNode = { outputs: { - environment: [{ id: 'AUTH_DB_URL-var#cloud-id', key: 'AUTH_DB_URL' }], + environment: [{ id: 'COMPOSE_AUTH_DB_URL-var#cloud-id', key: 'COMPOSE_AUTH_DB_URL' }], // A non-default port from serialize must reach the Deployment verbatim. port: 8080, }, @@ -548,13 +590,13 @@ describe('sharing: one module-provisioned postgres, two compute consumers — th const writes = recorded.envVar.slice(before.envVar).map(([, props]) => props); expect(writes).toContainEqual({ projectId: 'shop-project#cloud-id', - key: 'AUTH_MAIN_URL', + key: 'COMPOSE_AUTH_MAIN_URL', value: 'postgres://data-conn', class: 'production', }); expect(writes).toContainEqual({ projectId: 'shop-project#cloud-id', - key: 'BILLING_STORE_URL', + key: 'COMPOSE_BILLING_STORE_URL', value: 'postgres://data-conn', class: 'production', }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts index 384b0a17..8d01426c 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; import type { ConfigDeclaration, Contract } from '@internal/core'; -import { configOf, hydrateSync, isNode, number, param, string } from '@internal/core'; +import { configOf, envSecret, hydrateSync, isNode, number, param, string } from '@internal/core'; import { type } from 'arktype'; import { compute, postgres, postgresContract } from '../index.ts'; import { configKey, deserialize, encode } from '../serializer.ts'; @@ -9,13 +9,14 @@ import { bootstrapService } from '../testing.ts'; function scalarDeclaration( owner: ConfigDeclaration['owner'], name: string, - opts: { secret?: boolean; optional?: boolean; default?: unknown } = {}, + opts: { secret?: boolean; optional?: boolean; default?: unknown; external?: string } = {}, ): ConfigDeclaration { return { owner, name, schema: { vendor: '@prisma/compose' }, secret: opts.secret ?? false, + ...(opts.external !== undefined ? { external: opts.external } : {}), optional: opts.optional ?? false, default: opts.default, }; @@ -158,7 +159,7 @@ describe('compute({ expose })', () => { }); describe("the config serializer (shared by run() and /control's serialize)", () => { - test("configKey: lone-service root (address '') is unprefixed — owner ▸ name", () => { + test("configKey: lone-service root (address '') has no address segment — COMPOSE_ ▸ owner ▸ name", () => { const app = compute({ name: 'test-service', deps: { @@ -169,8 +170,8 @@ describe("the config serializer (shared by run() and /control's serialize)", () const [dbUrl, port] = configOf(app); if (dbUrl === undefined || port === undefined) throw new Error('expected config declarations'); - expect(configKey('', dbUrl)).toBe('DB_URL'); - expect(configKey('', port)).toBe('PORT'); + expect(configKey('', dbUrl)).toBe('COMPOSE_DB_URL'); + expect(configKey('', port)).toBe('COMPOSE_PORT'); }); test('configKey: a module-addressed service prefixes with its address segment', () => { @@ -184,7 +185,7 @@ describe("the config serializer (shared by run() and /control's serialize)", () const [dbUrl] = configOf(app); if (dbUrl === undefined) throw new Error('expected a config declaration'); - expect(configKey('auth', dbUrl)).toBe('AUTH_DB_URL'); + expect(configKey('auth', dbUrl)).toBe('COMPOSE_AUTH_DB_URL'); }); test('configKey: a connection-end input keys the same way as a resource input', () => { @@ -204,7 +205,7 @@ describe("the config serializer (shared by run() and /control's serialize)", () default: undefined, }; - expect(configKey('storefront', decl)).toBe('STOREFRONT_AUTH_URL'); + expect(configKey('storefront', decl)).toBe('COMPOSE_STOREFRONT_AUTH_URL'); void app; }); @@ -217,7 +218,7 @@ describe("the config serializer (shared by run() and /control's serialize)", () build, }); - await withEnv({ DB_URL: 'postgres://x', PORT: '4001' }, () => { + await withEnv({ COMPOSE_DB_URL: 'postgres://x', COMPOSE_PORT: '4001' }, () => { const config = deserialize(app, ''); expect(config).toEqual({ service: { port: 4001 }, inputs: { db: { url: 'postgres://x' } } }); }); @@ -256,7 +257,7 @@ describe("the config serializer (shared by run() and /control's serialize)", () build, }); - await withEnv({ PORT: 'not-a-number' }, () => { + await withEnv({ COMPOSE_PORT: 'not-a-number' }, () => { expect(() => deserialize(app, '')).toThrow(/port/); }); }); @@ -300,17 +301,24 @@ describe('compute().run(address, boot) → load() — the round trip', () => { }); let loaded: unknown; - await withEnv({ AUTH_DB_URL: 'postgres://x', AUTH_PORT: '4001', DB_URL: '', PORT: '' }, () => - app.run('auth', async () => { - loaded = app.load(); - }), + await withEnv( + { + COMPOSE_AUTH_DB_URL: 'postgres://x', + COMPOSE_AUTH_PORT: '4001', + COMPOSE_DB_URL: '', + COMPOSE_PORT: '', + }, + () => + app.run('auth', async () => { + loaded = app.load(); + }), ); expect(loaded).toEqual({ db: { url: 'postgres://x' } }); expect(app.config()).toEqual({ port: 4001 }); }); - test("a lone-service deploy (address '') reads and re-stashes the same unprefixed keys", async () => { + test("a lone-service deploy (address '') reads and re-stashes the same COMPOSE_-prefixed keys", async () => { const app = compute({ name: 'test-service', deps: { @@ -320,7 +328,7 @@ describe('compute().run(address, boot) → load() — the round trip', () => { }); let loaded: unknown; - await withEnv({ DB_URL: 'postgres://y', PORT: '' }, () => + await withEnv({ COMPOSE_DB_URL: 'postgres://y', COMPOSE_PORT: '' }, () => app.run('', async () => { loaded = app.load(); }), @@ -335,7 +343,7 @@ describe('compute().run(address, boot) → load() — the round trip', () => { const app = compute({ name: 'test-service', deps: { db }, build }); let loaded: unknown; - await withEnv({ DB_URL: 'postgres://dual', PORT: '' }, () => + await withEnv({ COMPOSE_DB_URL: 'postgres://dual', COMPOSE_PORT: '' }, () => app.run('', async () => { loaded = app.load(); }), @@ -367,7 +375,7 @@ describe('bootstrapService(service, config, boot) — the in-process integration let deps: unknown; let cfg: unknown; - await withEnv({ DB_URL: '', PORT: '' }, () => + await withEnv({ COMPOSE_DB_URL: '', COMPOSE_PORT: '' }, () => bootstrapService( app, { service: { port: 4321 }, inputs: { db: { url: 'postgres://bootstrap' } } }, @@ -387,7 +395,7 @@ describe('bootstrapService(service, config, boot) — the in-process integration let deps: unknown; let cfg: unknown; - await withEnv({ DB_URL: 'stale', PORT: 'stale' }, () => + await withEnv({ COMPOSE_DB_URL: 'stale', COMPOSE_PORT: 'stale' }, () => bootstrapService( app, { service: { port: 5555 }, inputs: { db: { url: 'postgres://fresh' } } }, @@ -432,7 +440,7 @@ describe('compute().load()', () => { build, }); - await withEnv({ DB_URL: 'postgres://z', PORT: '' }, () => { + await withEnv({ COMPOSE_DB_URL: 'postgres://z', COMPOSE_PORT: '' }, () => { const first = app.load(); const second = app.load(); @@ -480,7 +488,7 @@ describe('importing a service module', () => { // top-level does nothing but construct nodes (pure). expect(fixture.imported).toBe(true); - const loaded = await withEnv({ DB_URL: 'postgres://fixture', PORT: '' }, () => + const loaded = await withEnv({ COMPOSE_DB_URL: 'postgres://fixture', COMPOSE_PORT: '' }, () => fixture.default.load(), ); @@ -528,3 +536,103 @@ describe('structured params + target-owned serialization (ADR-0018/0019)', () => expect(encode('service', [{ jobId: 'tick' }])).toBe('[{"jobId":"tick"}]'); }); }); + +describe('secret pointer rows + double-lookup (ADR-0029)', () => { + const secretApp = (opts?: { optional?: boolean }) => + compute({ + name: 'ingest', + deps: {}, + params: { + stripeKey: + opts?.optional === true + ? envSecret('STRIPE_SECRET_KEY', { optional: true }) + : envSecret('STRIPE_SECRET_KEY'), + }, + build, + }); + + const stripeKeyDecl = () => { + const decl = configOf(secretApp()).find((d) => d.name === 'stripeKey'); + if (decl === undefined) throw new Error('expected a stripeKey declaration'); + return decl; + }; + + test('configOf reports the external platform name; the generated key is COMPOSE_-prefixed', () => { + expect(configOf(secretApp())).toContainEqual( + scalarDeclaration('service', 'stripeKey', { secret: true, external: 'STRIPE_SECRET_KEY' }), + ); + expect(configKey('', stripeKeyDecl())).toBe('COMPOSE_STRIPEKEY'); + }); + + test('the COMPOSE_ key holds the external NAME; the value lives only in that platform var', async () => { + const app = secretApp(); + await withEnv( + { + [configKey('', stripeKeyDecl())]: 'STRIPE_SECRET_KEY', + STRIPE_SECRET_KEY: 'sk_live_abc', + COMPOSE_PORT: '', + }, + () => { + expect(deserialize(app, '').service['stripeKey']).toBe('sk_live_abc'); + }, + ); + }); + + test('a required secret whose platform var is unset fails loudly, naming both keys', async () => { + const app = secretApp(); + await withEnv( + { [configKey('', stripeKeyDecl())]: 'STRIPE_SECRET_KEY', COMPOSE_PORT: '' }, + () => { + expect(() => deserialize(app, '')).toThrow(/COMPOSE_STRIPEKEY.*STRIPE_SECRET_KEY/); + }, + ); + }); + + test('a required secret whose platform var is empty ("") fails loudly — empty is unresolved', async () => { + const app = secretApp(); + await withEnv( + { + [configKey('', stripeKeyDecl())]: 'STRIPE_SECRET_KEY', + STRIPE_SECRET_KEY: '', + COMPOSE_PORT: '', + }, + () => { + expect(() => deserialize(app, '')).toThrow(/STRIPE_SECRET_KEY/); + }, + ); + }); + + test('an optional secret whose platform var is unset resolves to undefined', async () => { + const app = secretApp({ optional: true }); + await withEnv( + { [configKey('', stripeKeyDecl())]: 'STRIPE_SECRET_KEY', COMPOSE_PORT: '' }, + () => { + expect(deserialize(app, '').service['stripeKey']).toBeUndefined(); + }, + ); + }); + + test('a secret survives run() → stash → config(): the address-free re-read double-looks-up identically (the stash trap)', async () => { + const app = secretApp(); + let cfg: unknown; + await withEnv( + { + // address-keyed pointer row + the user-provisioned external var: + COMPOSE_INGEST_STRIPEKEY: 'STRIPE_SECRET_KEY', + STRIPE_SECRET_KEY: 'sk_live_trap', + COMPOSE_INGEST_PORT: '', + // address-free keys stash (over)writes — tracked so withEnv restores them: + COMPOSE_STRIPEKEY: '', + COMPOSE_PORT: '', + }, + () => + app.run('ingest', async () => { + cfg = app.config(); + }), + ); + // If stash re-emitted the resolved VALUE instead of the pointer, the + // address-free deserialize would read it as a pointer to a non-existent var + // and config() would have thrown — this asserts the pointer survives. + expect(cfg).toEqual({ port: 3000, stripeKey: 'sk_live_trap' }); + }); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index d4a59a6b..feb2f622 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -85,7 +85,7 @@ describe('invariant 2: authoring imports stay lean (core + pack)', () => { }); describe('invariant 4: environment touches are confined to the config serializer and the control factory', () => { - test("the process-env token appears only in serializer.ts (deserialize's one read, stash's one write) and control.ts's prismaCloud() (the extension factory's env read, ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; ADR-0019 — PRISMA_PROJECT_ID + optional PRISMA_BRANCH_ID)", () => { + test("the process-env token appears only in serializer.ts (readParam's reads incl. the secret double-lookup, stash's one write) and control.ts's prismaCloud() (the extension factory's env read, ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; ADR-0019 — PRISMA_PROJECT_ID + optional PRISMA_BRANCH_ID)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); @@ -97,7 +97,7 @@ describe('invariant 4: environment touches are confined to the config serializer expect(hits.sort((a, b) => a.file.localeCompare(b.file))).toEqual([ { file: 'control.ts', count: 4 }, - { file: 'serializer.ts', count: 2 }, + { file: 'serializer.ts', count: 4 }, ]); }); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts index af9dbf2d..7b628897 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts @@ -4,7 +4,7 @@ import type { ServiceNode } from '@internal/core'; import type { NodeDescriptor } from '@internal/core/config'; import * as Prisma from '@internal/lowering'; import * as Effect from 'effect/Effect'; -import { configKey, encode, paramEntries } from '../serializer.ts'; +import { configKey, paramEntries, storedForm } from '../serializer.ts'; import { DEFAULT_REGION, projectIdOf, type ResolvedCloudOptions, validateName } from './shared.ts'; export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor { @@ -26,9 +26,10 @@ export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor { }; }), - // A dependency-input value may be a provisioning ref, not a literal - // string — `encode` passes it through untouched so it keeps carrying the - // ordering edge; only service-own literals are actually stringified. + // storedForm keys each row: a service-own literal is JSON-stringified, a + // dependency-input provisioning ref passes through untouched (keeping its + // ordering edge), and a secret bound to a platform name writes only that + // name (a pointer), never a value (ADR-0029). serialize: ({ address, node }, provisioned, config) => Effect.gen(function* () { const records = []; @@ -40,7 +41,9 @@ export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor { yield* Prisma.EnvironmentVariable(`${key}-var`, { projectId: projectIdOf(provisioned), key, - value: encode(d.owner, value), + // A secret bound to a platform name writes only that NAME (a + // pointer), never a value (ADR-0029); storedForm holds the rule. + value: storedForm(d, value), class: o.branchId ? 'preview' : 'production', ...(o.branchId !== undefined ? { branchId: o.branchId } : {}), }), diff --git a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts index 54245a8b..f3f057af 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts @@ -69,9 +69,36 @@ export const configKey = ( ): string => { const segments = address.split('.').filter((s) => s.length > 0); const owner = d.owner === 'service' ? [] : [d.owner.input]; - return [...segments, ...owner, d.name].join('_').toUpperCase(); + // Every generated key lives in the framework's reserved COMPOSE_ namespace + // (ADR-0029), so it can never collide with — and silently overwrite — a + // user-provisioned platform var (e.g. a secret's external name). The poison + // keys DATABASE_URL(_POOLED) are written directly in control.ts, not here, so + // they stay unprefixed (they are the platform's own names). + return ['COMPOSE', ...segments, ...owner, d.name].join('_').toUpperCase(); }; +/** + * A secret bound to a platform env-var NAME (ADR-0029): its config row holds + * that name as a pointer, and the value lives ONLY in the external platform var. + * A `secret` param WITHOUT `external` — e.g. a database url valued by its + * producer — is not a pointer; it carries its value the ordinary way. + */ +function isPointerParam(param: ConfigParam): boolean { + return param.secret === true && param.external !== undefined; +} + +/** + * The string stored in a param's config row — shared by deploy `serialize` and + * boot `stash` so writer and reader cannot drift. A pointer secret stores its + * external platform-var NAME (never a value); every other param encodes its + * value. + */ +export function storedForm(d: ParamEntry, value: unknown): string { + const external = d.param.external; + if (d.param.secret === true && external !== undefined) return external; + return encode(d.owner, value); +} + /** * Typed value → its stored string. Service-own literals are JSON-encoded; a * dependency-input value is a provisioning ref at deploy (and a resolved @@ -104,13 +131,33 @@ function coerce(raw: string | undefined, d: ParamEntry, key: string): unknown { throw new Error(`missing required config param "${d.name}" (env ${key})`); } try { - return standardValidateSync(d.param.schema, decode(d.owner, raw)); + // A pointer secret's raw value is the plain platform string (already the + // value, not JSON) — validate it directly; everything else decodes by owner. + const decoded = isPointerParam(d.param) ? raw : decode(d.owner, raw); + return standardValidateSync(d.param.schema, decoded); } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); throw new Error(`invalid value for config param "${d.name}" (env ${key}): ${message}`); } } +/** + * Reads a param's stored form from the environment. A non-secret (or a secret + * with no platform binding) reads its key directly. A pointer secret + * double-looks-up (ADR-0029): the COMPOSE_ key holds the external platform-var + * NAME, and the value lives ONLY in the external platform var. The label names both + * keys so a missing required secret fails loudly and unambiguously. + */ +function readParam(d: ParamEntry, key: string): { raw: string | undefined; label: string } { + const external = d.param.external; + if (d.param.secret === true && external !== undefined) { + const stored = process.env[key]; + const name = stored !== undefined && stored !== '' ? stored : external; + return { raw: process.env[name], label: `${key} → ${name}` }; + } + return { raw: process.env[key], label: key }; +} + /** * Boot: read each declared param from env by its key, reverse the param's own * serialization (missing/invalid fails loudly), assemble the typed Config. @@ -121,8 +168,8 @@ export const deserialize = (node: ServiceNode, address: string): Config => { const inputs: Record> = {}; for (const d of paramEntries(node)) { - const key = configKey(address, d); - const value = coerce(process.env[key], d, key); + const { raw, label } = readParam(d, configKey(address, d)); + const value = coerce(raw, d, label); if (d.owner === 'service') { service[d.name] = value; } else { @@ -150,7 +197,10 @@ export const stash = (node: ServiceNode, config: Config): void => { const value = d.owner === 'service' ? config.service[d.name] : config.inputs[d.owner.input]?.[d.name]; if (value === undefined) continue; - process.env[configKey('', d)] = encode(d.owner, value); + // A pointer secret re-emits its external-name POINTER (never the resolved + // value), so the address-free deserialize double-looks-up identically — the + // value stays only in the external platform var. storedForm holds this rule. + process.env[configKey('', d)] = storedForm(d, value); } }; From 30b9362a9e5526679556da60215a1caa27234059 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 11:18:47 +0200 Subject: [PATCH 06/19] refactor(prisma-cloud): route pointer check through isPointerParam; accurate reserved-namespace remedy Signed-off-by: willbot Signed-off-by: Will Madden --- .../lowering/src/compute/EnvironmentVariable.ts | 7 ++++--- .../1-extensions/target/src/serializer.ts | 10 ++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts index cd384ed8..7a327c6b 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts @@ -88,9 +88,10 @@ export const EnvironmentVariableProvider = () => throw new Error( `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; ' + - 'a pre-existing one it did not create signals a collision. Remove the variable ' + - 'on the platform, or rebind the conflicting secret to a non-reserved name.', + '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.', ); } id = matchId; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts index f3f057af..c862104f 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts @@ -83,7 +83,7 @@ export const configKey = ( * A `secret` param WITHOUT `external` — e.g. a database url valued by its * producer — is not a pointer; it carries its value the ordinary way. */ -function isPointerParam(param: ConfigParam): boolean { +function isPointerParam(param: ConfigParam): param is ConfigParam & { external: string } { return param.secret === true && param.external !== undefined; } @@ -94,8 +94,7 @@ function isPointerParam(param: ConfigParam): boolean { * value. */ export function storedForm(d: ParamEntry, value: unknown): string { - const external = d.param.external; - if (d.param.secret === true && external !== undefined) return external; + if (isPointerParam(d.param)) return d.param.external; return encode(d.owner, value); } @@ -149,10 +148,9 @@ function coerce(raw: string | undefined, d: ParamEntry, key: string): unknown { * keys so a missing required secret fails loudly and unambiguously. */ function readParam(d: ParamEntry, key: string): { raw: string | undefined; label: string } { - const external = d.param.external; - if (d.param.secret === true && external !== undefined) { + if (isPointerParam(d.param)) { const stored = process.env[key]; - const name = stored !== undefined && stored !== '' ? stored : external; + const name = stored !== undefined && stored !== '' ? stored : d.param.external; return { raw: process.env[name], label: `${key} → ${name}` }; } return { raw: process.env[key], label: key }; From 937e35c59176017444eaa9c4d27c997ceb8d9810 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 13:17:01 +0200 Subject: [PATCH 07/19] feat(prisma-cloud): deploy preflight verifies secret manifest + shell fill-missing Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-core/core/src/__tests__/config.test.ts | 69 ++++- .../0-framework/1-core/core/src/app-config.ts | 21 ++ .../0-framework/1-core/core/src/config.ts | 32 ++ packages/0-framework/1-core/core/src/index.ts | 3 +- .../3-tooling/cli/src/__tests__/run.test.ts | 82 ++++++ .../0-framework/3-tooling/cli/src/main.ts | 17 ++ .../target/src/__tests__/invariants.test.ts | 3 +- .../target/src/__tests__/preflight.test.ts | 276 ++++++++++++++++++ .../1-extensions/target/src/control.ts | 6 + .../1-extensions/target/src/preflight.ts | 166 +++++++++++ 10 files changed, 671 insertions(+), 4 deletions(-) create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/preflight.ts diff --git a/packages/0-framework/1-core/core/src/__tests__/config.test.ts b/packages/0-framework/1-core/core/src/__tests__/config.test.ts index 966c4d86..331b900e 100644 --- a/packages/0-framework/1-core/core/src/__tests__/config.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/config.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { configOf, envSecret, number, param, string } from '../config.ts'; -import { dependency, service } from '../node.ts'; +import { configOf, envSecret, number, param, provisionManifest, string } from '../config.ts'; +import { Load } from '../graph.ts'; +import { dependency, module, service } from '../node.ts'; import { conn, scalarDeclaration } from './helpers.ts'; const build = { @@ -233,3 +234,67 @@ describe('configOf reports external', () => { ]); }); }); + +describe('provisionManifest', () => { + test('aggregates pointer secrets across services; excludes non-secret and secret-without-external', () => { + const ingest = service({ + name: 'ingest', + extension: 'test/pack', + type: 'fake/app', + inputs: {}, + params: { + stripeKey: envSecret('STRIPE_SECRET_KEY'), // pointer secret → manifest + rawSecret: string({ secret: true }), // secret, no binding → excluded + port: number({ default: 3000 }), // non-secret → excluded + }, + build, + }); + const web = service({ + name: 'web', + extension: 'test/pack', + type: 'fake/app', + inputs: {}, + params: { sendgrid: envSecret('SENDGRID_API_KEY', { optional: true }) }, + build, + }); + const graph = Load( + module('app', {}, (h) => { + h.provision(ingest, { id: 'ingest' }); + h.provision(web, { id: 'web' }); + return {}; + }), + ); + + const manifest = provisionManifest(graph); + expect(manifest).toHaveLength(2); + expect(manifest).toContainEqual({ + external: 'STRIPE_SECRET_KEY', + optional: false, + serviceAddress: 'ingest', + }); + expect(manifest).toContainEqual({ + external: 'SENDGRID_API_KEY', + optional: true, + serviceAddress: 'web', + }); + }); + + test('is empty when no service binds a pointer secret', () => { + const svc = service({ + name: 'x', + extension: 'test/pack', + type: 'fake/app', + inputs: {}, + params: { port: number({ default: 3000 }), raw: string({ secret: true }) }, + build, + }); + const graph = Load( + module('app', {}, (h) => { + h.provision(svc, { id: 'x' }); + return {}; + }), + ); + + expect(provisionManifest(graph)).toEqual([]); + }); +}); diff --git a/packages/0-framework/1-core/core/src/app-config.ts b/packages/0-framework/1-core/core/src/app-config.ts index beaa1403..bf32c1bd 100644 --- a/packages/0-framework/1-core/core/src/app-config.ts +++ b/packages/0-framework/1-core/core/src/app-config.ts @@ -8,6 +8,7 @@ import type { Lowering, ServiceLowering, } from './deploy.ts'; +import type { Graph } from './graph.ts'; /** * One extension's control-plane registry: everything the deploy pipeline may @@ -23,6 +24,26 @@ export interface ExtensionDescriptor { readonly application?: ApplicationDescriptor; /** The extension's Alchemy providers — merged across all configured extensions (config order). */ readonly providers?: () => Layer.Layer; + /** + * Deploy-time prerequisite check — the CLI runs it once, after the app's + * Project/Branch are resolved and BEFORE any stack file is written or Alchemy + * runs. A target uses it to verify platform prerequisites (e.g. that every + * secret env var in the provision manifest exists for the resolved stage) and + * throws to abort the deploy. Async: it talks to the platform (ADR-0029). + */ + readonly preflight?: (input: PreflightInput) => Promise; +} + +/** The resolved deploy context handed to an extension's `preflight` hook. */ +export interface PreflightInput { + /** The loaded application graph — the manifest of prerequisites is read from it (`provisionManifest`). */ + readonly graph: Graph; + /** The resolved Prisma Cloud Project id. */ + readonly projectId: string; + /** The resolved Branch id for a named stage; `undefined` for the default (production) stage. */ + readonly branchId: string | undefined; + /** The stage name (`--stage`), or `undefined` for the default stage — for diagnostics/scope. */ + readonly stage: string | undefined; } /** diff --git a/packages/0-framework/1-core/core/src/config.ts b/packages/0-framework/1-core/core/src/config.ts index 83c09a5c..ee3a0fa3 100644 --- a/packages/0-framework/1-core/core/src/config.ts +++ b/packages/0-framework/1-core/core/src/config.ts @@ -8,6 +8,7 @@ * never touches an environment. */ import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { Graph } from './graph-types.ts'; import type { ServiceNode } from './node.ts'; /** @@ -141,6 +142,37 @@ export function configOf(root: ServiceNode): readonly ConfigDeclaration[] { return entries; } +/** One pointer secret in the app's provision manifest — a platform env-var NAME that must exist before deploy (ADR-0029). */ +export interface ManifestEntry { + /** The platform env-var name the secret is bound to (its `external` facet). */ + readonly external: string; + /** Whether the binding is optional — an absent optional secret is not a deploy failure. */ + readonly optional: boolean; + /** The graph address of the service that declares the binding (for diagnostics). */ + readonly serviceAddress: string; +} + +/** + * The app's provision manifest: every pointer secret (a secret param bound to a + * platform env-var NAME via `envSecret`) across the graph's services. Pure graph + * introspection over `configOf`, so it is TARGET-AGNOSTIC — a deploy target's + * preflight consumes it to verify each name exists on the platform (ADR-0029). + * Non-secret params and secrets with no binding (e.g. a producer-valued database + * url) are excluded — only external-bearing secret declarations are manifest. + */ +export function provisionManifest(graph: Graph): readonly ManifestEntry[] { + const entries: ManifestEntry[] = []; + for (const { id, node } of graph.nodes) { + if (node.kind !== 'service') continue; + for (const decl of configOf(node)) { + if (decl.secret && decl.external !== undefined) { + entries.push({ external: decl.external, optional: decl.optional, serviceAddress: id }); + } + } + } + return entries; +} + // ——— Param constructors: plain data, target-agnostic (ADR-0018/0019). ——— // // A param is just a schema plus facets; serialization is the deploy target's diff --git a/packages/0-framework/1-core/core/src/index.ts b/packages/0-framework/1-core/core/src/index.ts index a7088df7..7f90fd07 100644 --- a/packages/0-framework/1-core/core/src/index.ts +++ b/packages/0-framework/1-core/core/src/index.ts @@ -10,10 +10,11 @@ export type { ConfigDeclaration, ConfigParam, Connection, + ManifestEntry, Params, Values, } from './config.ts'; -export { configOf, envSecret, number, param, string } from './config.ts'; +export { configOf, envSecret, number, param, provisionManifest, string } from './config.ts'; export type { Contract } from './contract.ts'; export type { Edge, Graph, GraphNode, NodeId } from './graph.ts'; export { Load, LoadError } from './graph.ts'; diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/run.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/run.test.ts index 54549206..fe935881 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/run.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/run.test.ts @@ -279,6 +279,88 @@ describe('run() — the full pipeline over fakes', () => { ).rejects.toThrow(/name it at authoring, or pass --name/); }); + test('invokes each extension preflight with the resolved context, before alchemy runs', async () => { + const app = makeAppDir('hello-preflight'); + process.chdir(app.dir); + const preflightCalls: Array<{ + projectId: string; + branchId: string | undefined; + stage: string | undefined; + nodeCount: number; + }> = []; + const config = fakeConfig(); + const withPreflight: PrismaAppConfig = { + ...config, + extensions: config.extensions.map((e) => + e.id === 'fixture-extension' + ? { + ...e, + preflight: async (input) => { + preflightCalls.push({ + projectId: input.projectId, + branchId: input.branchId, + stage: input.stage, + nodeCount: input.graph.nodes.length, + }); + }, + } + : e, + ), + }; + + const status = await run(['deploy', app.entryPath, '--stage', 'ci-7'], { + config: withPreflight, + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => 0, + }); + + expect(status).toBe(0); + expect(preflightCalls).toHaveLength(1); + expect(preflightCalls[0]).toMatchObject({ + projectId: 'proj-fake', + branchId: 'branch-ci-7', + stage: 'ci-7', + }); + expect(preflightCalls[0]!.nodeCount).toBeGreaterThan(0); + }); + + test('a preflight failure aborts as a CliError before any stack file is written or alchemy runs', async () => { + const app = makeAppDir('hello-preflight-fail'); + process.chdir(app.dir); + const config = fakeConfig(); + let alchemyRan = false; + const withFailingPreflight: PrismaAppConfig = { + ...config, + extensions: config.extensions.map((e) => + e.id === 'fixture-extension' + ? { + ...e, + preflight: async () => { + throw new Error('SECRET_X is not provisioned'); + }, + } + : e, + ), + }; + + const error: unknown = await run(['deploy', app.entryPath, '--stage', 'ci-7'], { + config: withFailingPreflight, + runAssembler: fakeAssembler, + ensureContainers: fakeEnsureContainers, + alchemy: () => { + alchemyRan = true; + return 0; + }, + }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).message).toContain('SECRET_X is not provisioned'); + expect(alchemyRan).toBe(false); + // Preflight (step 7.5) runs before writeStackFile (step 8) — nothing side-effected. + expect(fs.existsSync(path.join(app.dir, '.prisma-compose', 'alchemy.run.ts'))).toBe(false); + }); + test('an alchemy failure propagates the nonzero exit and prints the generated file path', async () => { const app = makeAppDir(); process.chdir(app.dir); diff --git a/packages/0-framework/3-tooling/cli/src/main.ts b/packages/0-framework/3-tooling/cli/src/main.ts index b6ffa2b9..458179f9 100644 --- a/packages/0-framework/3-tooling/cli/src/main.ts +++ b/packages/0-framework/3-tooling/cli/src/main.ts @@ -252,6 +252,23 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise< stage, }); + // 7.5 Preflight (deploy only): each extension verifies its platform + // prerequisites — e.g. that every secret env var in the provision manifest + // exists for the resolved stage (ADR-0029) — BEFORE any stack file is written + // or Alchemy runs, so a missing secret fails fast with nothing side-effected. + if (args.command === 'deploy') { + for (const extension of config.extensions) { + if (extension.preflight === undefined) continue; + try { + await extension.preflight({ graph, projectId, branchId, stage }); + } catch (error) { + throw error instanceof CliError + ? error + : new CliError(error instanceof Error ? error.message : String(error)); + } + } + } + // 8. Generate .prisma-compose/alchemy.run.ts (tool state lives where you run the tool). const stackPath = writeStackFile({ entryPath: entryModule.path, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index feb2f622..16ac8e7d 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -85,7 +85,7 @@ describe('invariant 2: authoring imports stay lean (core + pack)', () => { }); describe('invariant 4: environment touches are confined to the config serializer and the control factory', () => { - test("the process-env token appears only in serializer.ts (readParam's reads incl. the secret double-lookup, stash's one write) and control.ts's prismaCloud() (the extension factory's env read, ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; ADR-0019 — PRISMA_PROJECT_ID + optional PRISMA_BRANCH_ID)", () => { + test("the process-env token appears only in serializer.ts (readParam's reads incl. the secret double-lookup, stash's one write), control.ts's prismaCloud(), and preflight.ts (shell token + fill-missing lookup) (the extension factory's env read, ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; ADR-0019 — PRISMA_PROJECT_ID + optional PRISMA_BRANCH_ID)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); @@ -97,6 +97,7 @@ describe('invariant 4: environment touches are confined to the config serializer expect(hits.sort((a, b) => a.file.localeCompare(b.file))).toEqual([ { file: 'control.ts', count: 4 }, + { file: 'preflight.ts', count: 2 }, { file: 'serializer.ts', count: 4 }, ]); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts new file mode 100644 index 00000000..e449d0db --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts @@ -0,0 +1,276 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { envSecret, Load, module } from '@internal/core'; +import type { ManagementApiClient } from '@internal/lowering'; +import { compute } from '../index.ts'; +import { runPreflight } from '../preflight.ts'; + +const build = { + extension: '@prisma/compose/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; + +interface Row { + projectId: string; + class: 'production' | 'preview'; + key: string; + branchId: string | null; +} + +interface FakeState { + gets: Record[]; + posts: Record[]; + rows: Row[]; + postStatus: number; +} + +/** A stubbed Management API client — test file, exempt from the no-bare-cast rule. */ +const fakeClient = (state: FakeState): ManagementApiClient => + ({ + GET: async (_path: string, init: { params: { query: Record } }) => { + const q = init.params.query; + state.gets.push(q); + const rows = state.rows.filter( + (r) => r.projectId === q['projectId'] && r.class === q['class'] && r.key === q['key'], + ); + return { + data: { data: rows, pagination: { nextCursor: null, hasMore: false } }, + error: undefined, + response: new Response(null, { status: 200 }), + }; + }, + POST: async (_path: string, init: { body: Record }) => { + state.posts.push(init.body); + if (state.postStatus === 409) { + return { + data: undefined, + error: { code: 'conflict', message: 'already exists' }, + response: new Response(null, { status: 409 }), + }; + } + return { + data: { data: { id: 'ev-new', key: init.body['key'] } }, + error: undefined, + response: new Response(null, { status: 201 }), + }; + }, + }) as unknown as ManagementApiClient; + +const secretGraph = (opts?: { optional?: boolean }) => + Load( + module('app', {}, (h) => { + h.provision( + compute({ + name: 'ingest', + deps: {}, + params: { + stripeKey: + opts?.optional === true + ? envSecret('STRIPE_SECRET_KEY', { optional: true }) + : envSecret('STRIPE_SECRET_KEY'), + }, + build, + }), + { id: 'ingest' }, + ); + return {}; + }), + ); + +const noSecretGraph = () => + Load( + module('app', {}, (h) => { + h.provision(compute({ name: 'ingest', deps: {}, build }), { id: 'ingest' }); + return {}; + }), + ); + +/** Sets env vars for the duration of `fn`, restoring whatever was there before. */ +async function withEnv(values: Record, fn: () => Promise | T): Promise { + const previous = new Map(Object.keys(values).map((k) => [k, process.env[k]])); + for (const [k, v] of Object.entries(values)) process.env[k] = v; + try { + return await fn(); + } finally { + for (const [k, v] of previous) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +describe('runPreflight — secret manifest verification (ADR-0029)', () => { + let state: FakeState; + + beforeEach(() => { + state = { gets: [], posts: [], rows: [], postStatus: 201 }; + // The manifest secret must not leak from the ambient shell into "absent" tests. + delete process.env['STRIPE_SECRET_KEY']; + }); + + test('default stage: checks the production class; all-present passes with no writes', async () => { + state.rows = [ + { projectId: 'proj', class: 'production', key: 'STRIPE_SECRET_KEY', branchId: null }, + ]; + + await runPreflight( + { graph: secretGraph(), projectId: 'proj', branchId: undefined, stage: undefined }, + { client: fakeClient(state) }, + ); + + expect(state.gets).toEqual([ + { projectId: 'proj', class: 'production', key: 'STRIPE_SECRET_KEY' }, + ]); + expect(state.posts).toEqual([]); + }); + + test('named stage: a preview TEMPLATE (branchId null) counts as present', async () => { + state.rows = [ + { projectId: 'proj', class: 'preview', key: 'STRIPE_SECRET_KEY', branchId: null }, + ]; + + await runPreflight( + { graph: secretGraph(), projectId: 'proj', branchId: 'br-1', stage: 'pr-1' }, + { client: fakeClient(state) }, + ); + + expect(state.gets[0]?.['class']).toBe('preview'); + expect(state.posts).toEqual([]); + }); + + test("named stage: this branch's own OVERRIDE counts as present", async () => { + state.rows = [ + { projectId: 'proj', class: 'preview', key: 'STRIPE_SECRET_KEY', branchId: 'br-1' }, + ]; + + await runPreflight( + { graph: secretGraph(), projectId: 'proj', branchId: 'br-1', stage: 'pr-1' }, + { client: fakeClient(state) }, + ); + + expect(state.posts).toEqual([]); + }); + + test("named stage: another branch's override does NOT count — absent from both fails", async () => { + state.rows = [ + { projectId: 'proj', class: 'preview', key: 'STRIPE_SECRET_KEY', branchId: 'br-2' }, + ]; + + const error: unknown = await runPreflight( + { graph: secretGraph(), projectId: 'proj', branchId: 'br-1', stage: 'pr-1' }, + { client: fakeClient(state) }, + ).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('STRIPE_SECRET_KEY'); + expect(state.posts).toEqual([]); + }); + + test('fill-missing (named stage): absent-but-in-shell is POSTed as a preview branch override', async () => { + state.rows = []; + + await withEnv({ STRIPE_SECRET_KEY: 'sk_live_fill' }, () => + runPreflight( + { graph: secretGraph(), projectId: 'proj', branchId: 'br-1', stage: 'pr-1' }, + { client: fakeClient(state) }, + ), + ); + + expect(state.posts).toEqual([ + { + projectId: 'proj', + class: 'preview', + key: 'STRIPE_SECRET_KEY', + value: 'sk_live_fill', + branchId: 'br-1', + }, + ]); + }); + + test('fill-missing (default stage): POSTed as a production template, no branchId', async () => { + state.rows = []; + + await withEnv({ STRIPE_SECRET_KEY: 'sk_live_fill' }, () => + runPreflight( + { graph: secretGraph(), projectId: 'proj', branchId: undefined, stage: undefined }, + { client: fakeClient(state) }, + ), + ); + + expect(state.posts).toEqual([ + { projectId: 'proj', class: 'production', key: 'STRIPE_SECRET_KEY', value: 'sk_live_fill' }, + ]); + }); + + test('present on the platform is never overwritten, even when also in the shell', async () => { + state.rows = [ + { projectId: 'proj', class: 'production', key: 'STRIPE_SECRET_KEY', branchId: null }, + ]; + + await withEnv({ STRIPE_SECRET_KEY: 'sk_live_ignored' }, () => + runPreflight( + { graph: secretGraph(), projectId: 'proj', branchId: undefined, stage: undefined }, + { client: fakeClient(state) }, + ), + ); + + expect(state.posts).toEqual([]); + }); + + test('absent from both platform and shell fails, naming the missing name, service, and scope', async () => { + state.rows = []; + + const error: unknown = await runPreflight( + { graph: secretGraph(), projectId: 'proj', branchId: undefined, stage: undefined }, + { client: fakeClient(state) }, + ).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain('STRIPE_SECRET_KEY'); + expect(message).toContain('service "ingest"'); + expect(message).toContain('production'); + expect(state.posts).toEqual([]); + }); + + test('an optional secret absent from both passes (no failure, no write)', async () => { + state.rows = []; + + await runPreflight( + { + graph: secretGraph({ optional: true }), + projectId: 'proj', + branchId: undefined, + stage: undefined, + }, + { client: fakeClient(state) }, + ); + + expect(state.posts).toEqual([]); + }); + + test('a graph with no pointer secrets is a pass-through — no platform calls at all', async () => { + await runPreflight( + { graph: noSecretGraph(), projectId: 'proj', branchId: undefined, stage: undefined }, + { client: fakeClient(state) }, + ); + + expect(state.gets).toEqual([]); + expect(state.posts).toEqual([]); + }); + + test('a race 409 on fill-missing is tolerated as already-provisioned', async () => { + state.rows = []; + state.postStatus = 409; + + await withEnv({ STRIPE_SECRET_KEY: 'sk_live_race' }, () => + runPreflight( + { graph: secretGraph(), projectId: 'proj', branchId: undefined, stage: undefined }, + { client: fakeClient(state) }, + ), + ); + + expect(state.posts).toHaveLength(1); + }); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control.ts b/packages/1-prisma-cloud/1-extensions/target/src/control.ts index 7e64f8fb..c4bd1424 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control.ts @@ -15,6 +15,7 @@ import { prismaNextDescriptor } from './descriptors/prisma-next.ts'; import type { ResolvedCloudOptions } from './descriptors/shared.ts'; import { PgWarmProvider } from './pg-warm-resource.ts'; import { PnMigrationProvider } from './pn-migration-resource.ts'; +import { runPreflight } from './preflight.ts'; export { prismaState }; @@ -78,6 +79,11 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor providers: () => asProvidersLayer(Layer.mergeAll(Prisma.providers(), PgWarmProvider(), PnMigrationProvider())), + // Deploy-time prerequisite check (ADR-0029): verify every pointer secret in + // the provision manifest exists for the resolved stage, filling absent-but- + // in-shell names via a direct API POST — before any stack file or Alchemy. + preflight: (input) => runPreflight(input), + // Runs once per lowering, before any service: references the CLI-ensured // Project, with the poison DATABASE_URL variables written immediately so // nothing can ever rely on the platform default. diff --git a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts new file mode 100644 index 00000000..d29fe995 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts @@ -0,0 +1,166 @@ +/** + * Deploy preflight (ADR-0029): before Alchemy runs, verify every pointer secret + * in the app's provision manifest exists on Prisma Cloud for the target stage. + * A name absent on the platform but present in the deploy shell is provisioned + * via a direct Management API POST — NEVER an Alchemy resource, so the value + * never lands in hosted deploy state. A name absent from both fails the deploy, + * listing exactly what is missing and where to set it. + * + * Control-plane only (imported by control.ts → prisma-compose.config.ts); runs + * in the CLI parent, so it builds its own Management API client from env — the + * same credential path `ensureContainers` uses. + */ +import { provisionManifest } from '@internal/core'; +import type { PreflightInput } from '@internal/core/config'; +import { blindCast } from '@internal/foundation/casts'; +import { + fromEnv, + type ManagementApiClient, + ManagementClient, + managementClientLayer, +} from '@internal/lowering'; +import * as Effect from 'effect/Effect'; +import * as Layer from 'effect/Layer'; + +type EnvClass = 'production' | 'preview'; + +/** production for the default stage; preview for a named stage — matching how the pack writes config rows. */ +const classFor = (branchId: string | undefined): EnvClass => + branchId === undefined ? 'production' : 'preview'; + +/** + * Does `key` exist for the target stage's scope? Default stage → any + * production-class template. Named stage → a preview template (branchId null) + * OR this branch's own override — the platform's preview materialization + * (pdp-data-model.md). Metadata read only; env-var values are write-only. + */ +async function existsOnPlatform( + client: ManagementApiClient, + projectId: string, + branchId: string | undefined, + key: string, +): Promise { + const res = await client.GET('/v1/environment-variables', { + params: { + query: blindCast< + never, + 'openapi-fetch types this list query as never; the endpoint accepts projectId/class/key' + >({ projectId, class: classFor(branchId), key }), + }, + }); + if (res.error !== undefined) { + throw new Error( + `deploy preflight: Prisma Management API error listing "${key}": ${JSON.stringify(res.error)}.`, + ); + } + const rows = res.data?.data ?? []; + return branchId === undefined + ? rows.length > 0 + : rows.some((r) => r.branchId === null || r.branchId === branchId); +} + +/** + * Provision `key`=`value` directly via the Management API for the target + * stage's scope (a production template for the default stage; a preview branch + * override for a named stage — the same scope the pack writes config rows to, + * EnvironmentVariable.ts). A 409 means a concurrent deploy already provisioned + * it — tolerated. The value is never logged. + */ +async function fillMissing( + client: ManagementApiClient, + input: PreflightInput, + key: string, + value: string, +): Promise { + const res = await client.POST('/v1/environment-variables', { + body: { + projectId: input.projectId, + class: classFor(input.branchId), + key, + value, + ...(input.branchId !== undefined ? { branchId: input.branchId } : {}), + }, + }); + if (res.error !== undefined && res.response.status !== 409) { + throw new Error( + `deploy preflight: failed to provision "${key}" from the deploy shell: ${JSON.stringify(res.error)}.`, + ); + } +} + +interface MissingSecret { + readonly external: string; + readonly serviceAddress: string; +} + +function missingError(missing: readonly MissingSecret[], input: PreflightInput): Error { + const scope = + input.branchId === undefined + ? 'the production class (project-level template)' + : `the preview class of stage "${input.stage ?? input.branchId}" (branch override or template)`; + const lines = missing.map( + (m) => ` - ${m.external} (required by service "${m.serviceAddress}")`, + ); + return new Error( + `Deploy preflight failed — ${missing.length} secret env var(s) are not provisioned on Prisma ` + + `Cloud for ${scope}, and are absent from the deploy shell:\n${lines.join('\n')}\n\n` + + 'Set each in the deploy shell environment (the CLI will provision it on deploy), or create ' + + `it on the platform (Prisma Console or the Management API) in ${scope}.`, + ); +} + +async function managementClient(): Promise { + if ((process.env['PRISMA_SERVICE_TOKEN'] ?? '').length === 0) { + throw new Error('environment variable PRISMA_SERVICE_TOKEN is required for deploy preflight.'); + } + return Effect.runPromise( + Effect.gen(function* () { + return yield* ManagementClient; + }).pipe(Effect.provide(managementClientLayer().pipe(Layer.provide(fromEnv())))), + ); +} + +/** + * The Prisma Cloud extension's `preflight`. Aggregates the target-agnostic + * manifest (core's `provisionManifest`), checks each pointer secret against the + * platform, fills from the shell where possible, and fails loudly on anything + * absent from both. Accepts an injected client for tests; otherwise builds one + * from env. + */ +export async function runPreflight( + input: PreflightInput, + deps?: { readonly client?: ManagementApiClient }, +): Promise { + const manifest = provisionManifest(input.graph); + if (manifest.length === 0) return; + + // One check per external name; a name is required if ANY binding of it is required. + const names = new Map(); + for (const entry of manifest) { + const prev = names.get(entry.external); + if (prev === undefined) { + names.set(entry.external, { + external: entry.external, + serviceAddress: entry.serviceAddress, + optional: entry.optional, + }); + } else if (!entry.optional) { + names.set(entry.external, { ...prev, optional: false }); + } + } + + const client = deps?.client ?? (await managementClient()); + const missing: MissingSecret[] = []; + for (const meta of names.values()) { + if (await existsOnPlatform(client, input.projectId, input.branchId, meta.external)) continue; + const shellValue = process.env[meta.external]; + if (shellValue !== undefined && shellValue.length > 0) { + await fillMissing(client, input, meta.external, shellValue); + continue; + } + if (!meta.optional) { + missing.push({ external: meta.external, serviceAddress: meta.serviceAddress }); + } + } + if (missing.length > 0) throw missingError(missing, input); +} From e2151a9f5486fae83a07aec96d41477fd18c11b0 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 13:32:03 +0200 Subject: [PATCH 08/19] fix(prisma-cloud): paginate preflight env-var lookup; accurate missing-secret attribution Signed-off-by: willbot Signed-off-by: Will Madden --- .../target/src/__tests__/preflight.test.ts | 80 ++++++++++++++++++ .../1-extensions/target/src/preflight.ts | 83 +++++++++++++++---- 2 files changed, 147 insertions(+), 16 deletions(-) diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts index e449d0db..535b4e54 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts @@ -86,6 +86,32 @@ const noSecretGraph = () => }), ); +/** Two services binding the SAME external name — one optional (web), one required (ingest). */ +const sharedSecretGraph = () => + Load( + module('app', {}, (h) => { + h.provision( + compute({ + name: 'web', + deps: {}, + params: { key: envSecret('STRIPE_SECRET_KEY', { optional: true }) }, + build, + }), + { id: 'web' }, + ); + h.provision( + compute({ + name: 'ingest', + deps: {}, + params: { key: envSecret('STRIPE_SECRET_KEY') }, + build, + }), + { id: 'ingest' }, + ); + return {}; + }), + ); + /** Sets env vars for the duration of `fn`, restoring whatever was there before. */ async function withEnv(values: Record, fn: () => Promise | T): Promise { const previous = new Map(Object.keys(values).map((k) => [k, process.env[k]])); @@ -273,4 +299,58 @@ describe('runPreflight — secret manifest verification (ADR-0029)', () => { expect(state.posts).toHaveLength(1); }); + + test('follows pagination — a present name on a later page is not reported missing', async () => { + const pages = [ + { data: [], pagination: { nextCursor: 'c1', hasMore: true } }, + { + data: [ + { projectId: 'proj', class: 'production', key: 'STRIPE_SECRET_KEY', branchId: null }, + ], + pagination: { nextCursor: null, hasMore: false }, + }, + ]; + const queries: Record[] = []; + const posts: unknown[] = []; + let call = 0; + const client = { + GET: async (_path: string, init: { params: { query: Record } }) => { + queries.push(init.params.query); + return { + data: pages[call++], + error: undefined, + response: new Response(null, { status: 200 }), + }; + }, + POST: async (_path: string, init: { body: Record }) => { + posts.push(init.body); + return { + data: { data: { id: 'ev-new', key: init.body['key'] } }, + error: undefined, + response: new Response(null, { status: 201 }), + }; + }, + } as unknown as ManagementApiClient; + + await runPreflight( + { graph: secretGraph(), projectId: 'proj', branchId: undefined, stage: undefined }, + { client }, + ); + + expect(call).toBe(2); // followed to the second page + expect(queries[1]?.['cursor']).toBe('c1'); // carried the cursor forward + expect(posts).toEqual([]); // found present → no fill + }); + + test('a name declared optional by one service and required by another is attributed to the requiring service', async () => { + state.rows = []; + + const error: unknown = await runPreflight( + { graph: sharedSecretGraph(), projectId: 'proj', branchId: undefined, stage: undefined }, + { client: fakeClient(state) }, + ).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('service "ingest"'); + }); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts index d29fe995..c32803a3 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts @@ -34,29 +34,73 @@ const classFor = (branchId: string | undefined): EnvClass => * OR this branch's own override — the platform's preview materialization * (pdp-data-model.md). Metadata read only; env-var values are write-only. */ -async function existsOnPlatform( +/** The fields of one env-var list page that preflight consumes (metadata only; values are write-only). */ +interface EnvVarListPage { + readonly data: readonly { readonly branchId: string | null }[]; + readonly pagination: { readonly nextCursor: string | null; readonly hasMore: boolean }; +} +interface EnvVarListResult { + readonly data?: EnvVarListPage; + readonly error?: unknown; +} + +/** + * One page of the env-var list. The query is `blindCast` to `never` because + * openapi-fetch types this path's query as `never` (an SDK path/operation + * mismatch); that same workaround defeats the client's response-type inference, + * so the result is projected to the small shape we actually read. + */ +async function listEnvVars( client: ManagementApiClient, - projectId: string, - branchId: string | undefined, - key: string, -): Promise { + query: { projectId: string; class: EnvClass; key: string; cursor?: string }, +): Promise { const res = await client.GET('/v1/environment-variables', { params: { query: blindCast< never, - 'openapi-fetch types this list query as never; the endpoint accepts projectId/class/key' - >({ projectId, class: classFor(branchId), key }), + 'openapi-fetch types this list query as never; the endpoint accepts projectId/class/key/cursor' + >(query), }, }); - if (res.error !== undefined) { - throw new Error( - `deploy preflight: Prisma Management API error listing "${key}": ${JSON.stringify(res.error)}.`, + return blindCast< + EnvVarListResult, + 'project the SDK list response to the fields preflight reads; the query-never workaround defeats response inference' + >(res); +} + +async function existsOnPlatform( + client: ManagementApiClient, + projectId: string, + branchId: string | undefined, + key: string, +): Promise { + const cls = classFor(branchId); + // Default stage → any production template counts; named stage → a preview + // template (branchId null) OR this branch's own override. + const visible = (row: { branchId: string | null }): boolean => + branchId === undefined || row.branchId === null || row.branchId === branchId; + + // The list is paginated: a key with more preview rows (template + many + // per-branch overrides) than one page must be followed to the end, or a + // present name is falsely reported missing. Short-circuit as soon as a + // visible row is seen. + let cursor: string | null = null; + do { + const res = await listEnvVars( + client, + cursor === null ? { projectId, class: cls, key } : { projectId, class: cls, key, cursor }, ); - } - const rows = res.data?.data ?? []; - return branchId === undefined - ? rows.length > 0 - : rows.some((r) => r.branchId === null || r.branchId === branchId); + if (res.error !== undefined) { + throw new Error( + `deploy preflight: Prisma Management API error listing "${key}": ${JSON.stringify(res.error)}.`, + ); + } + const page = res.data; + if (page === undefined) return false; + if (page.data.some(visible)) return true; + cursor = page.pagination.hasMore ? page.pagination.nextCursor : null; + } while (cursor !== null); + return false; } /** @@ -145,7 +189,14 @@ export async function runPreflight( optional: entry.optional, }); } else if (!entry.optional) { - names.set(entry.external, { ...prev, optional: false }); + // Upgrade to required AND carry this (required) binding's serviceAddress, + // so a missing-secret error never attributes it to a service that only + // declared it optional. + names.set(entry.external, { + external: entry.external, + serviceAddress: entry.serviceAddress, + optional: false, + }); } } From 3ec8636e47fea68fc8beb68aa7696efe066b17da Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 13:47:41 +0200 Subject: [PATCH 09/19] test(e2e): storefront-auth proves an envSecret round-trips via runner-env fill-missing Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/e2e-deploy.yml | 7 +++++++ .../storefront-auth/modules/auth/src/contract.ts | 3 +++ .../storefront-auth/modules/auth/src/server.ts | 14 +++++++++++++- .../storefront-auth/modules/auth/src/service.ts | 7 +++++++ .../storefront-auth/modules/auth/testing/fake.ts | 4 ++++ .../storefront/app/page.integration.test.ts | 13 +++++++++++-- .../modules/storefront/app/page.test.tsx | 6 +++++- .../modules/storefront/app/page.tsx | 2 ++ examples/storefront-auth/scripts/e2e-verify.sh | 7 ++++--- 9 files changed, 56 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e-deploy.yml b/.github/workflows/e2e-deploy.yml index 648b7e38..081adb92 100644 --- a/.github/workflows/e2e-deploy.yml +++ b/.github/workflows/e2e-deploy.yml @@ -29,6 +29,13 @@ jobs: env: PRISMA_SERVICE_TOKEN: ${{ secrets.PRISMA_SERVICE_TOKEN }} PRISMA_WORKSPACE_ID: ${{ vars.PRISMA_WORKSPACE_ID }} + # The auth service declares envSecret('AUTH_SIGNING_SECRET') (ADR-0029). + # This non-sensitive test marker in the runner env is what deploy preflight + # fill-missing provisions onto the ephemeral per-run stack — the single-step + # CI path (runner env -> direct API POST -> platform). It must match the + # auth service's EXPECTED_SIGNING_SECRET (modules/auth/src/server.ts); the + # value never enters deploy state, only its pointer name does. + AUTH_SIGNING_SECRET: sk_test_ci_storefront_auth steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: diff --git a/examples/storefront-auth/modules/auth/src/contract.ts b/examples/storefront-auth/modules/auth/src/contract.ts index 77fefb5f..485a4bea 100644 --- a/examples/storefront-auth/modules/auth/src/contract.ts +++ b/examples/storefront-auth/modules/auth/src/contract.ts @@ -8,4 +8,7 @@ import { type } from 'arktype'; export const authContract = contract({ verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), + // Non-leaking proof that auth received its injected `AUTH_SIGNING_SECRET` + // (ADR-0029): returns ONLY a boolean, never the secret value. + secretCheck: rpc({ input: type({}), output: type({ ok: 'boolean' }) }), }); diff --git a/examples/storefront-auth/modules/auth/src/server.ts b/examples/storefront-auth/modules/auth/src/server.ts index 02c1384f..14667846 100644 --- a/examples/storefront-auth/modules/auth/src/server.ts +++ b/examples/storefront-auth/modules/auth/src/server.ts @@ -8,7 +8,16 @@ import { SQL } from 'bun'; import service from './service.ts'; const { db } = service.load(); // db: PostgresConfig — the app owns its client -const { port } = service.config(); // config params are read separately from deps (ADR-0021) +const { port, signingSecret } = service.config(); // config params are read separately from deps (ADR-0021) + +// The E2E's KNOWN test marker for AUTH_SIGNING_SECRET (matched by the value the +// deploy provisions — see .github/workflows/e2e-deploy.yml and +// scripts/e2e-verify.sh). `secretCheck` below proves the secret round-tripped +// through the platform (pointer row -> boot double-lookup -> injected value) by +// comparing it to this marker and returning ONLY a boolean — the raw secret +// value is never exposed. This constant is a non-sensitive demonstration +// marker, not a real credential. +const EXPECTED_SIGNING_SECRET = 'sk_test_ci_storefront_auth'; // The app constructs its own client from the binding (ADR-0015). Module-scoped, // so it is one pool per process. idleTimeout closes the pooled connection @@ -36,6 +45,9 @@ const handler = serve(service, { return { ok: false }; } }, + // True iff the injected secret matches the expected marker — proof it was + // provisioned and double-looked-up, without ever returning the value. + secretCheck: async () => ({ ok: signingSecret === EXPECTED_SIGNING_SECRET }), }, }); export default handler; diff --git a/examples/storefront-auth/modules/auth/src/service.ts b/examples/storefront-auth/modules/auth/src/service.ts index 786d7ff7..ad889a64 100644 --- a/examples/storefront-auth/modules/auth/src/service.ts +++ b/examples/storefront-auth/modules/auth/src/service.ts @@ -1,3 +1,4 @@ +import { envSecret } from '@prisma/compose'; import node from '@prisma/compose/node'; import { compute, postgres } from '@prisma/compose-prisma-cloud'; import { authContract } from './contract.ts'; @@ -10,6 +11,12 @@ export default compute({ deps: { db: postgres(), }, + // A secret bound to the platform env var `AUTH_SIGNING_SECRET` (ADR-0029): + // the framework carries only the NAME; the value is provisioned out-of-band + // (in CI, preflight fill-missing provisions it from the runner env). + params: { + signingSecret: envSecret('AUTH_SIGNING_SECRET'), + }, build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), expose: { rpc: authContract }, }); diff --git a/examples/storefront-auth/modules/auth/testing/fake.ts b/examples/storefront-auth/modules/auth/testing/fake.ts index 4a2a90b8..70040bbb 100644 --- a/examples/storefront-auth/modules/auth/testing/fake.ts +++ b/examples/storefront-auth/modules/auth/testing/fake.ts @@ -27,5 +27,9 @@ const fakeAuth = compute({ export default serve(fakeAuth, { rpc: { verify: async ({ token }) => ({ ok: token.length > 0 }), + // The fake stands in for the auth dependency's wiring, not its secret: it + // always proves the round trip. The real secret injection is proven by the + // live auth service in the CI E2E. + secretCheck: async () => ({ ok: true }), }, }); diff --git a/examples/storefront-auth/modules/storefront/app/page.integration.test.ts b/examples/storefront-auth/modules/storefront/app/page.integration.test.ts index 88a9637a..71bd4193 100644 --- a/examples/storefront-auth/modules/storefront/app/page.integration.test.ts +++ b/examples/storefront-auth/modules/storefront/app/page.integration.test.ts @@ -33,9 +33,17 @@ function isNextjsBuild(build: BuildAdapter): build is NextjsBuildAdapter { } /** Boots the built standalone Next entry — its own `server.js`, unmodified — via the same path `assemble()` resolves for deploy (not the same bootstrap chain: deploy goes bootstrap.js -> main.mjs -> server.js; here `bootstrapService`'s `stash` stands in for the wrapper's env write). */ -function bootStandaloneNext(build: NextjsBuildAdapter): () => Promise { +function bootStandaloneNext(build: NextjsBuildAdapter, port: number): () => Promise { const entryPath = standaloneEntryPath(build); return async () => { + // Next's standalone server binds `process.env.PORT` directly (its own + // convention) — it does NOT read the service's config(). The framework's + // address-free config keys are COMPOSE_-prefixed (ADR-0029), so `stash` no + // longer writes a bare `PORT` for Next to pick up. A real nextjs deploy uses + // the reserved default port (3000), which Next also defaults to, so the + // deployed storefront is unaffected; this test drives a non-default port to + // avoid local conflicts, so it hands PORT to the raw server explicitly. + process.env.PORT = String(port); await import(pathToFileURL(entryPath).href); }; } @@ -51,7 +59,7 @@ describe('storefront -> auth round trip, driven over real HTTP (bootstrapService const app = await bootstrapService( storefrontService, { service: { port: PORT }, inputs: { auth: { url: fake.url.href } } }, - bootStandaloneNext(storefrontService.build), + bootStandaloneNext(storefrontService.build, PORT), ); const res = await app.fetch(new Request(app.url)); @@ -60,5 +68,6 @@ describe('storefront -> auth round trip, driven over real HTTP (bootstrapService const html = await res.text(); expect(html).toContain('Auth /verify says: true'); + expect(html).toContain('Secret /check says: true'); }); }); diff --git a/examples/storefront-auth/modules/storefront/app/page.test.tsx b/examples/storefront-auth/modules/storefront/app/page.test.tsx index 5c79ac22..8b2a0363 100644 --- a/examples/storefront-auth/modules/storefront/app/page.test.tsx +++ b/examples/storefront-auth/modules/storefront/app/page.test.tsx @@ -12,7 +12,10 @@ vi.mock('../src/service.ts', async () => { const actual = await vi.importActual<{ default: typeof Service }>('../src/service.ts'); return { default: mockService(actual.default, { - auth: { verify: async ({ token }) => ({ ok: token.length > 0 }) }, + auth: { + verify: async ({ token }) => ({ ok: token.length > 0 }), + secretCheck: async () => ({ ok: true }), + }, }), }; }); @@ -24,5 +27,6 @@ describe('Home (page.tsx)', () => { const html = renderToStaticMarkup(await Home()); expect(html).toContain('Auth /verify says: true'); + expect(html).toContain('Secret /check says: true'); }); }); diff --git a/examples/storefront-auth/modules/storefront/app/page.tsx b/examples/storefront-auth/modules/storefront/app/page.tsx index 6cc5d573..4fd2631a 100644 --- a/examples/storefront-auth/modules/storefront/app/page.tsx +++ b/examples/storefront-auth/modules/storefront/app/page.tsx @@ -7,11 +7,13 @@ export const dynamic = 'force-dynamic'; export default async function Home() { const { auth } = service.load(); const { ok } = await auth.verify({ token: 'demo-token' }); + const { ok: secretOk } = await auth.secretCheck({}); return (

Storefront

Auth /verify says: {String(ok)}

+

Secret /check says: {String(secretOk)}

); } diff --git a/examples/storefront-auth/scripts/e2e-verify.sh b/examples/storefront-auth/scripts/e2e-verify.sh index 3b3a0318..1b4c6b36 100644 --- a/examples/storefront-auth/scripts/e2e-verify.sh +++ b/examples/storefront-auth/scripts/e2e-verify.sh @@ -35,12 +35,13 @@ body="" while [ "$SECONDS" -lt "$deadline" ]; do body="$(curl -sS --max-time 30 "$url" || true)" clean="$(printf '%s' "$body" | sed -e 's///g' -e 's/"/"/g')" - if printf '%s' "$clean" | grep -q 'Auth /verify says: true'; then - echo 'Round trip OK — storefront rendered auth.verify() -> { ok: true }' + if printf '%s' "$clean" | grep -q 'Auth /verify says: true' \ + && printf '%s' "$clean" | grep -q 'Secret /check says: true'; then + echo 'Round trip OK — storefront rendered auth.verify() -> { ok: true } AND the secret proof (secretCheck -> { ok: true })' exit 0 fi sleep 6 done -echo "Round trip never rendered 'Auth /verify says: true' within the deadline. Last body:" +echo "Round trip never rendered both 'Auth /verify says: true' and 'Secret /check says: true' within the deadline. Last body:" printf '%s' "$body" | head -c 3000 exit 1 From f3391d7a75fcea78df25c43ced8e9035debb8e02 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 16:57:51 +0200 Subject: [PATCH 10/19] feat(core): secrets as a distinct forwardable slot; SecretBox Signed-off-by: willbot Signed-off-by: Will Madden --- .../0-foundation/foundation/package.json | 4 +- .../foundation/src/secret.test.ts | 37 ++++ .../0-foundation/foundation/src/secret.ts | 48 +++++ .../0-foundation/foundation/tsconfig.json | 3 + .../0-foundation/foundation/tsdown.config.ts | 2 +- .../core/src/__tests__/config.test-d.ts | 64 +++--- .../1-core/core/src/__tests__/config.test.ts | 176 +++------------- .../fixtures/probe-core-authoring.ts | 2 +- .../1-core/core/src/__tests__/helpers.ts | 4 +- .../1-core/core/src/__tests__/hydrate.test.ts | 2 +- .../core/src/__tests__/lowering.test.ts | 51 +---- .../src/__tests__/module-composition.test.ts | 2 +- .../src/__tests__/module-wiring.test-d.ts | 2 +- .../1-core/core/src/__tests__/module.test.ts | 2 +- .../1-core/core/src/__tests__/node.test.ts | 4 +- .../1-core/core/src/__tests__/secrets.test.ts | 117 ++++++++++ .../core/src/__tests__/testing.test-d.ts | 3 + .../1-core/core/src/__tests__/testing.test.ts | 5 + .../0-framework/1-core/core/src/config.ts | 134 ++---------- .../0-framework/1-core/core/src/deploy.ts | 25 --- .../1-core/core/src/graph-types.ts | 18 ++ packages/0-framework/1-core/core/src/graph.ts | 2 +- .../0-framework/1-core/core/src/hydrate.ts | 34 ++- packages/0-framework/1-core/core/src/index.ts | 17 +- .../1-core/core/src/load-module.ts | 143 ++++++++++++- .../1-core/core/src/load-service.ts | 3 + packages/0-framework/1-core/core/src/node.ts | 199 ++++++++++++++---- .../0-framework/1-core/core/src/testing.ts | 8 +- 28 files changed, 673 insertions(+), 438 deletions(-) create mode 100644 packages/0-framework/0-foundation/foundation/src/secret.test.ts create mode 100644 packages/0-framework/0-foundation/foundation/src/secret.ts create mode 100644 packages/0-framework/1-core/core/src/__tests__/secrets.test.ts diff --git a/packages/0-framework/0-foundation/foundation/package.json b/packages/0-framework/0-foundation/foundation/package.json index 57fe1ab9..e7764373 100644 --- a/packages/0-framework/0-foundation/foundation/package.json +++ b/packages/0-framework/0-foundation/foundation/package.json @@ -7,12 +7,14 @@ "exports": { "./assertions": "./dist/assertions.mjs", "./casts": "./dist/casts.mjs", + "./secret": "./dist/secret.mjs", "./package.json": "./package.json" }, "scripts": { "typecheck": "tsc --noEmit", "build": "tsdown", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "test": "bun test" }, "devDependencies": { "typescript": "^6.0.3", diff --git a/packages/0-framework/0-foundation/foundation/src/secret.test.ts b/packages/0-framework/0-foundation/foundation/src/secret.test.ts new file mode 100644 index 00000000..9e558329 --- /dev/null +++ b/packages/0-framework/0-foundation/foundation/src/secret.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test'; +import { inspect } from 'node:util'; +import { SecretBox } from './secret.ts'; + +describe('SecretBox', () => { + test('expose() round-trips the wrapped value', () => { + expect(new SecretBox('sk_live_abc').expose()).toBe('sk_live_abc'); + expect(new SecretBox(42).expose()).toBe(42); + }); + + test('String() and template interpolation redact', () => { + const box = new SecretBox('sk_live_abc'); + expect(String(box)).toBe('[REDACTED]'); + expect(`${box}`).toBe('[REDACTED]'); + expect(box.toString()).toBe('[REDACTED]'); + }); + + test('valueOf redacts (arithmetic/coercion never sees the value)', () => { + const box = new SecretBox('sk_live_abc'); + expect(box.valueOf()).toBe('[REDACTED]'); + // biome-ignore lint/style/useTemplate: exercising `+` coercion (valueOf) on purpose. + expect(box + '').toBe('[REDACTED]'); + }); + + test('JSON.stringify redacts', () => { + const box = new SecretBox('sk_live_abc'); + expect(JSON.stringify(box)).toBe('"[REDACTED]"'); + expect(JSON.stringify({ key: box })).toBe('{"key":"[REDACTED]"}'); + }); + + test('console/util.inspect redacts (so an accidental log cannot leak it)', () => { + const box = new SecretBox('sk_live_abc'); + expect(inspect(box)).toBe('[REDACTED]'); + expect(inspect({ key: box })).toContain('[REDACTED]'); + expect(inspect(box)).not.toContain('sk_live'); + }); +}); diff --git a/packages/0-framework/0-foundation/foundation/src/secret.ts b/packages/0-framework/0-foundation/foundation/src/secret.ts new file mode 100644 index 00000000..952eea3c --- /dev/null +++ b/packages/0-framework/0-foundation/foundation/src/secret.ts @@ -0,0 +1,48 @@ +/** + * A value wrapper that redacts everywhere except the one explicit reader, + * `expose()`. Sensitivity is carried by the TYPE (`SecretBox`), not a flag a + * sink must remember to check: `String(box)`, template interpolation, + * `JSON.stringify`, and `console.log`/`util.inspect` all print `[REDACTED]`, so + * a secret can't leak through an accidental log or serialization. + * + * Shape matches the platform's own `secrecy` type (pdp-control-plane). The class + * is nominal enough on its own — no phantom brand. + */ + +const REDACTED = '[REDACTED]'; + +export class SecretBox { + readonly #value: T; + + constructor(value: T) { + this.#value = value; + } + + /** The sole explicit door to the wrapped value. */ + expose(): T { + return this.#value; + } + + toString(): string { + return REDACTED; + } + + toJSON(): string { + return REDACTED; + } + + valueOf(): string { + return REDACTED; + } + + [Symbol.toPrimitive](): string { + return REDACTED; + } + + [Symbol.for('nodejs.util.inspect.custom')](): string { + return REDACTED; + } +} + +/** The common case: a secret string. */ +export type SecretString = SecretBox; diff --git a/packages/0-framework/0-foundation/foundation/tsconfig.json b/packages/0-framework/0-foundation/foundation/tsconfig.json index fa0ec533..a16ed256 100644 --- a/packages/0-framework/0-foundation/foundation/tsconfig.json +++ b/packages/0-framework/0-foundation/foundation/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "types": ["bun-types"] + }, "include": ["src"] } diff --git a/packages/0-framework/0-foundation/foundation/tsdown.config.ts b/packages/0-framework/0-foundation/foundation/tsdown.config.ts index bfc7f855..a12e5a58 100644 --- a/packages/0-framework/0-foundation/foundation/tsdown.config.ts +++ b/packages/0-framework/0-foundation/foundation/tsdown.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from '@internal/tsdown-config'; export default defineConfig({ - entry: { casts: 'src/casts.ts', assertions: 'src/assertions.ts' }, + entry: { casts: 'src/casts.ts', assertions: 'src/assertions.ts', secret: 'src/secret.ts' }, }); diff --git a/packages/0-framework/1-core/core/src/__tests__/config.test-d.ts b/packages/0-framework/1-core/core/src/__tests__/config.test-d.ts index 2d09c1b8..e47bedcc 100644 --- a/packages/0-framework/1-core/core/src/__tests__/config.test-d.ts +++ b/packages/0-framework/1-core/core/src/__tests__/config.test-d.ts @@ -1,36 +1,48 @@ /** - * Type-level facet rules for config params (ADR-0029): `secret` and `default` - * are mutually exclusive, and `envSecret` infers a string ConfigParam. - * Type-only (vitest `--typecheck`, never executed). + * Type-level rules for the secret slot vocabulary (ADR-0029): `secret()` is a + * need, `envSecret()` is a source, `SecretValues` boxes each slot, and + * `provision` requires a source per declared secret slot. Type-only (vitest + * `--typecheck`, never executed). */ -import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { SecretBox } from '@internal/foundation/secret'; import { expectTypeOf, test } from 'vitest'; -import type { ConfigParam } from '../config.ts'; -import { envSecret, param, string } from '../config.ts'; +import type { BuildAdapter, SecretNeed, SecretSource, SecretValues } from '../node.ts'; +import { envSecret, module, secret, service } from '../node.ts'; -test('envSecret infers ConfigParam>', () => { - expectTypeOf(envSecret('STRIPE_KEY')).toEqualTypeOf< - ConfigParam> - >(); - expectTypeOf(envSecret('STRIPE_KEY', { optional: true })).toEqualTypeOf< - ConfigParam> - >(); -}); +const build: BuildAdapter = { + extension: '@prisma/compose/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; -test('a non-secret param may carry a default; a secret one may still be optional', () => { - string({ default: 'x' }); - string({ optional: true, default: 'x' }); - string({ secret: true }); - string({ secret: true, optional: true }); +test('secret() is a SecretNeed; envSecret() is a SecretSource', () => { + expectTypeOf(secret()).toEqualTypeOf(); + expectTypeOf(envSecret('AUTH_SIGNING_KEY')).toEqualTypeOf(); }); -test('a secret param may not carry a default', () => { - // @ts-expect-error secret forbids default - string({ secret: true, default: 'x' }); +test('SecretValues maps each declared slot to a SecretBox', () => { + type S = { signingKey: SecretNeed; apiKey: SecretNeed }; + expectTypeOf>().toEqualTypeOf<{ + readonly signingKey: SecretBox; + readonly apiKey: SecretBox; + }>(); }); -test('a secret param over an arbitrary schema may not carry a default either', () => { - const schema = {} as StandardSchemaV1; - // @ts-expect-error secret forbids default - param(schema, { secret: true, default: 'x' }); +test('provisioning a service with a secret slot requires a source per slot', () => { + const svc = service({ + name: 'auth', + extension: 'test/pack', + type: 'fake/app', + inputs: {}, + params: {}, + secrets: { signingKey: secret() }, + build, + }); + + module('root', ({ provision }) => { + // @ts-expect-error a declared secret slot must be bound + provision(svc, { id: 'auth' }); + provision(svc, { id: 'auth', secrets: { signingKey: envSecret('AUTH_SIGNING_KEY') } }); + }); }); diff --git a/packages/0-framework/1-core/core/src/__tests__/config.test.ts b/packages/0-framework/1-core/core/src/__tests__/config.test.ts index 331b900e..ab329d2e 100644 --- a/packages/0-framework/1-core/core/src/__tests__/config.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/config.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { configOf, envSecret, number, param, provisionManifest, string } from '../config.ts'; +import { configOf, number, provisionManifest, string } from '../config.ts'; import { Load } from '../graph.ts'; -import { dependency, module, service } from '../node.ts'; +import { dependency, envSecret, module, secret, service } from '../node.ts'; import { conn, scalarDeclaration } from './helpers.ts'; const build = { @@ -21,10 +21,7 @@ describe('configOf', () => { db: dependency({ name: 'db', type: 'fake/db', - connection: conn( - { url: string({ secret: true }), schema: string({ optional: true }) }, - () => ({}), - ), + connection: conn({ url: string(), schema: string({ optional: true }) }, () => ({})), }), }, params: { port: number({ default: 3000 }) }, @@ -32,7 +29,7 @@ describe('configOf', () => { }); expect(configOf(root)).toEqual([ - scalarDeclaration({ input: 'db' }, 'url', { secret: true }), + scalarDeclaration({ input: 'db' }, 'url'), scalarDeclaration({ input: 'db' }, 'schema', { optional: true }), scalarDeclaration('service', 'port', { default: 3000 }), ]); @@ -98,155 +95,31 @@ describe('configOf', () => { expect(hydrateCalls).toBe(0); }); -}); -describe('configOf over dependency inputs', () => { - test('every dependency input appears with owner { input }, whatever it will be wired to', () => { - const root = service({ - name: 'test-service', - extension: 'test/pack', - type: 'fake/app', - inputs: { - db: dependency({ - name: 'db', - type: 'fake/db', - connection: conn({ url: string({ secret: true }) }, () => ({})), - }), - auth: dependency({ - type: 'fake/http', - connection: conn({ url: string() }, () => ({})), - }), - }, - params: { port: number({ default: 3000 }) }, - build, - }); - - expect(configOf(root)).toEqual([ - scalarDeclaration({ input: 'db' }, 'url', { secret: true }), - scalarDeclaration({ input: 'auth' }, 'url'), - scalarDeclaration('service', 'port', { default: 3000 }), - ]); - }); -}); - -describe('envSecret', () => { - test('binds a secret string param to a platform env-var name — no default', () => { - const p = envSecret('STRIPE_SECRET_KEY'); - expect(p.secret).toBe(true); - expect(p.external).toBe('STRIPE_SECRET_KEY'); - expect(p.optional).toBeUndefined(); - expect(p.default).toBeUndefined(); - // Reuses the shared string schema — same singleton string() carries. - expect(p.schema).toBe(string().schema); - }); - - test('carries optional when asked, still no default', () => { - const p = envSecret('SENDGRID_API_KEY', { optional: true }); - expect(p.secret).toBe(true); - expect(p.optional).toBe(true); - expect(p.external).toBe('SENDGRID_API_KEY'); - expect(p.default).toBeUndefined(); - }); - - test('rejects an empty name at construction', () => { - expect(() => envSecret('')).toThrow(/must be a non-empty string/); - }); - - test('rejects the reserved COMPOSE_ prefix', () => { - expect(() => envSecret('COMPOSE_STRIPE')).toThrow(/COMPOSE_/); - }); - - test('rejects the poisoned DATABASE_URL keys', () => { - expect(() => envSecret('DATABASE_URL')).toThrow(/reserved/); - expect(() => envSecret('DATABASE_URL_POOLED')).toThrow(/reserved/); - }); - - test('withFacets is the chokepoint — an empty external dodged in via string() is still caught', () => { - expect(() => string({ external: '' } as never)).toThrow(/must be a non-empty string/); - }); -}); - -describe('secret forbids default (runtime guard)', () => { - test('string({ secret: true, default }) throws', () => { - expect(() => string({ secret: true, default: 'x' } as never)).toThrow( - /secret config param cannot declare a `default`/, - ); - }); - - test('number({ secret: true, default }) throws', () => { - expect(() => number({ secret: true, default: 1 } as never)).toThrow( - /secret config param cannot declare a `default`/, - ); - }); - - test('param(schema, { secret: true, default }) throws', () => { - expect(() => param(string().schema, { secret: true, default: 'x' } as never)).toThrow( - /secret config param cannot declare a `default`/, - ); - }); - - test('a non-secret param keeps its default', () => { - expect(string({ default: 'x' }).default).toBe('x'); - }); -}); - -describe('configOf reports external', () => { - test('a service-own secret param reports its platform name; a non-secret one reports undefined', () => { + test('a secret slot is NOT a config param — configOf never reports it', () => { const root = service({ name: 'ingest', extension: 'test/pack', type: 'fake/app', inputs: {}, - params: { stripeKey: envSecret('STRIPE_SECRET_KEY'), port: number({ default: 3000 }) }, - build, - }); - - expect(configOf(root)).toEqual([ - scalarDeclaration('service', 'stripeKey', { - secret: true, - external: 'STRIPE_SECRET_KEY', - }), - scalarDeclaration('service', 'port', { default: 3000 }), - ]); - }); - - test('a dependency-input secret connection param reports external when bound', () => { - const root = service({ - name: 'ingest', - extension: 'test/pack', - type: 'fake/app', - inputs: { - billing: dependency({ - name: 'billing', - type: 'fake/rpc', - connection: conn({ key: envSecret('BILLING_KEY') }, () => ({})), - }), - }, - params: {}, + params: { port: number({ default: 3000 }) }, + secrets: { stripeKey: secret() }, build, }); - expect(configOf(root)).toEqual([ - scalarDeclaration({ input: 'billing' }, 'key', { - secret: true, - external: 'BILLING_KEY', - }), - ]); + expect(configOf(root)).toEqual([scalarDeclaration('service', 'port', { default: 3000 })]); }); }); describe('provisionManifest', () => { - test('aggregates pointer secrets across services; excludes non-secret and secret-without-external', () => { + test('aggregates the root-bound secret names across the graph', () => { const ingest = service({ name: 'ingest', extension: 'test/pack', type: 'fake/app', inputs: {}, - params: { - stripeKey: envSecret('STRIPE_SECRET_KEY'), // pointer secret → manifest - rawSecret: string({ secret: true }), // secret, no binding → excluded - port: number({ default: 3000 }), // non-secret → excluded - }, + params: {}, + secrets: { stripeKey: secret() }, build, }); const web = service({ @@ -254,44 +127,43 @@ describe('provisionManifest', () => { extension: 'test/pack', type: 'fake/app', inputs: {}, - params: { sendgrid: envSecret('SENDGRID_API_KEY', { optional: true }) }, + params: {}, + secrets: { sendgrid: secret() }, build, }); const graph = Load( - module('app', {}, (h) => { - h.provision(ingest, { id: 'ingest' }); - h.provision(web, { id: 'web' }); - return {}; + module('app', ({ provision }) => { + provision(ingest, { id: 'ingest', secrets: { stripeKey: envSecret('STRIPE_SECRET_KEY') } }); + provision(web, { id: 'web', secrets: { sendgrid: envSecret('SENDGRID_API_KEY') } }); }), ); const manifest = provisionManifest(graph); expect(manifest).toHaveLength(2); expect(manifest).toContainEqual({ - external: 'STRIPE_SECRET_KEY', - optional: false, serviceAddress: 'ingest', + slot: 'stripeKey', + name: 'STRIPE_SECRET_KEY', }); expect(manifest).toContainEqual({ - external: 'SENDGRID_API_KEY', - optional: true, serviceAddress: 'web', + slot: 'sendgrid', + name: 'SENDGRID_API_KEY', }); }); - test('is empty when no service binds a pointer secret', () => { + test('is empty when no service declares a secret slot', () => { const svc = service({ name: 'x', extension: 'test/pack', type: 'fake/app', inputs: {}, - params: { port: number({ default: 3000 }), raw: string({ secret: true }) }, + params: { port: number({ default: 3000 }) }, build, }); const graph = Load( - module('app', {}, (h) => { - h.provision(svc, { id: 'x' }); - return {}; + module('app', ({ provision }) => { + provision(svc, { id: 'x' }); }), ); diff --git a/packages/0-framework/1-core/core/src/__tests__/fixtures/probe-core-authoring.ts b/packages/0-framework/1-core/core/src/__tests__/fixtures/probe-core-authoring.ts index 1fad16ec..435440fd 100644 --- a/packages/0-framework/1-core/core/src/__tests__/fixtures/probe-core-authoring.ts +++ b/packages/0-framework/1-core/core/src/__tests__/fixtures/probe-core-authoring.ts @@ -15,7 +15,7 @@ const db = dependency({ name: 'db', type: 'probe/db', connection: { - params: { url: string({ secret: true }) }, + params: { url: string() }, hydrate: (v) => ({ url: v.url }), }, required: dbContract, diff --git a/packages/0-framework/1-core/core/src/__tests__/helpers.ts b/packages/0-framework/1-core/core/src/__tests__/helpers.ts index 18cd77f3..5b3c6bd7 100644 --- a/packages/0-framework/1-core/core/src/__tests__/helpers.ts +++ b/packages/0-framework/1-core/core/src/__tests__/helpers.ts @@ -23,14 +23,12 @@ export const providerContract = (kind: K, cmp: Cmp): Cont export function scalarDeclaration( owner: ConfigDeclaration['owner'], name: string, - opts: { secret?: boolean; optional?: boolean; default?: unknown; external?: string } = {}, + opts: { optional?: boolean; default?: unknown } = {}, ): ConfigDeclaration { return { owner, name, schema: { vendor: '@prisma/compose' }, - secret: opts.secret ?? false, - ...(opts.external !== undefined ? { external: opts.external } : {}), optional: opts.optional ?? false, default: opts.default, }; diff --git a/packages/0-framework/1-core/core/src/__tests__/hydrate.test.ts b/packages/0-framework/1-core/core/src/__tests__/hydrate.test.ts index 5c1398c9..8735c693 100644 --- a/packages/0-framework/1-core/core/src/__tests__/hydrate.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/hydrate.test.ts @@ -15,7 +15,7 @@ const dbEnd = (record?: (values: { url: string }) => void) => dependency({ name: 'db', type: 'fake/db', - connection: conn({ url: string({ secret: true }) }, (v) => { + connection: conn({ url: string() }, (v) => { record?.(v); return { client: v.url }; }), diff --git a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts index c4398782..f521445d 100644 --- a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts @@ -3,7 +3,7 @@ import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import type { ExtensionDescriptor, PrismaAppConfig } from '../app-config.ts'; import type { Config, Params } from '../config.ts'; -import { envSecret, number, string } from '../config.ts'; +import { number, string } from '../config.ts'; import { type AlchemyStateLayer, type Artifact, @@ -209,55 +209,6 @@ describe('buildConfig', () => { }); }); -describe('a service-own secret with no platform binding fails the deploy build', () => { - test('an unbound secret param is a LowerError naming the param, service, and the fix', () => { - const { config, calls } = fakeExtension(); - const root = module('hello', {}, (h) => { - h.provision(app('fake/compute', {}, { stripeKey: string({ secret: true }) }), { id: 'svc' }); - return {}; - }); - - const error = runError(lowering(root, config, opts(svcBundles))); - - expect(error).toBeInstanceOf(LowerError); - expect(error.message).toContain('secret param "stripeKey"'); - expect(error.message).toContain('service "svc"'); - expect(error.message).toContain('envSecret'); - // Fails before the service is provisioned — no side effects. - expect(calls.map((c) => c.phase)).not.toContain('provision'); - }); - - test('a bound secret (envSecret) lowers cleanly', () => { - const { config } = fakeExtension(); - const root = module('hello', {}, (h) => { - h.provision(app('fake/compute', {}, { stripeKey: envSecret('STRIPE_SECRET_KEY') }), { - id: 'svc', - }); - return {}; - }); - - expect(run(lowering(root, config, opts(svcBundles)))).toEqual({ outputs: {} }); - }); - - test('a dependency-input secret param without external lowers fine — its value comes from the producer', () => { - const secretDbEnd = () => - dependency({ - name: 'db', - type: 'fake/db', - connection: conn({ url: string({ secret: true }) }, () => ({})), - required: providerContract('fake/db', { url: '' }), - }); - const { config } = fakeExtension(); - const root = module('hello', {}, (h) => { - const db = h.provision(dbResource(), { id: 'db' }); - h.provision(app('fake/compute', { db: secretDbEnd() }), { id: 'svc', deps: { db } }); - return {}; - }); - - expect(run(lowering(root, config, opts(svcBundles)))).toEqual({ outputs: {} }); - }); -}); - const singleServiceModule = ( type: string, params: Params = {}, diff --git a/packages/0-framework/1-core/core/src/__tests__/module-composition.test.ts b/packages/0-framework/1-core/core/src/__tests__/module-composition.test.ts index 6c1024e8..1bbc1c10 100644 --- a/packages/0-framework/1-core/core/src/__tests__/module-composition.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/module-composition.test.ts @@ -522,7 +522,7 @@ describe('a resource-backed input now forwards across a module boundary (unified dependency({ name: 'db', type: 'fake/db', - connection: conn({ url: string({ secret: true }) }, (v) => ({ url: v.url })), + connection: conn({ url: string() }, (v) => ({ url: v.url })), required: dbContract, }); diff --git a/packages/0-framework/1-core/core/src/__tests__/module-wiring.test-d.ts b/packages/0-framework/1-core/core/src/__tests__/module-wiring.test-d.ts index 935caa85..be865d68 100644 --- a/packages/0-framework/1-core/core/src/__tests__/module-wiring.test-d.ts +++ b/packages/0-framework/1-core/core/src/__tests__/module-wiring.test-d.ts @@ -33,7 +33,7 @@ const cacheNode = resource({ name: 'cache', extension: 'test/pack', provides: ca const pgDep = dependency({ name: 'db', type: 'fake/postgres', - connection: conn({ url: string({ secret: true }) }, (v) => ({ url: v.url })), + connection: conn({ url: string() }, (v) => ({ url: v.url })), required: pgContract, }); diff --git a/packages/0-framework/1-core/core/src/__tests__/module.test.ts b/packages/0-framework/1-core/core/src/__tests__/module.test.ts index 9d280cf5..1cd616da 100644 --- a/packages/0-framework/1-core/core/src/__tests__/module.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/module.test.ts @@ -27,7 +27,7 @@ const dbDep = () => dependency({ name: 'db', type: 'fake/db', - connection: conn({ url: string({ secret: true }) }, (v) => ({ url: v.url })), + connection: conn({ url: string() }, (v) => ({ url: v.url })), required: dbContract(), }); diff --git a/packages/0-framework/1-core/core/src/__tests__/node.test.ts b/packages/0-framework/1-core/core/src/__tests__/node.test.ts index 29930255..ad68bf2a 100644 --- a/packages/0-framework/1-core/core/src/__tests__/node.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/node.test.ts @@ -65,14 +65,14 @@ describe('dependency()', () => { const end = dependency({ name: 'db', type: 'fake/db', - connection: conn({ url: string({ secret: true }) }, (v) => ({ url: v.url })), + connection: conn({ url: string() }, (v) => ({ url: v.url })), }); expect(isNode(end)).toBe(true); expect(end.kind).toBe('dependency'); expect(end.name).toBe('db'); expect(end.type).toBe('fake/db'); - expect(end.connection.params).toEqual({ url: string({ secret: true }) }); + expect(end.connection.params).toEqual({ url: string() }); expect(Object.isFrozen(end)).toBe(true); expect(Object.isFrozen(end.connection)).toBe(true); expect(Object.isFrozen(end.connection.params)).toBe(true); diff --git a/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts b/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts new file mode 100644 index 00000000..29229140 --- /dev/null +++ b/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'bun:test'; +import { blindCast } from '@internal/foundation/casts'; +import { Load } from '../graph.ts'; +import type { Secrets } from '../node.ts'; +import { envSecret, isSecretSource, module, secret, service } from '../node.ts'; + +const build = { + extension: '@prisma/compose/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; + +// Generic so `svc('plain')` infers an EMPTY secret map (no required secrets), +// while `svc('auth', { k: secret() })` infers its declared slots. +const svc = >( + name: string, + secrets: S = blindCast({}), +) => + service({ + name, + extension: 'test/pack', + type: 'fake/app', + inputs: {}, + params: {}, + secrets, + build, + }); + +describe('secret sources', () => { + test('envSecret is a secret source; secret() and plain values are not', () => { + expect(isSecretSource(envSecret('AUTH_KEY'))).toBe(true); + expect(isSecretSource(secret())).toBe(false); + expect(isSecretSource({})).toBe(false); + expect(isSecretSource(undefined)).toBe(false); + }); + + test('envSecret rejects empty, COMPOSE_-prefixed, and poisoned names', () => { + expect(() => envSecret('')).toThrow(/non-empty/); + expect(() => envSecret('COMPOSE_X')).toThrow(/COMPOSE_/); + expect(() => envSecret('DATABASE_URL')).toThrow(/reserved/); + expect(() => envSecret('DATABASE_URL_POOLED')).toThrow(/reserved/); + }); +}); + +describe('Load records secret bindings', () => { + test('the root binds a service secret directly (the leaf case)', () => { + const auth = svc('auth', { signingKey: secret() }); + const graph = Load( + module('root', ({ provision }) => { + provision(auth, { id: 'auth', secrets: { signingKey: envSecret('AUTH_SIGNING_KEY') } }); + }), + ); + expect(graph.secrets).toEqual([ + { serviceAddress: 'auth', slot: 'signingKey', name: 'AUTH_SIGNING_KEY' }, + ]); + }); + + test('a module forwards a secret slot to an inner service (the multi-level case)', () => { + const inner = svc('inner', { key: secret() }); + const authModule = module('auth', { secrets: { key: secret() } }, ({ secrets, provision }) => { + provision(inner, { id: 'inner', secrets: { key: secrets.key } }); + }); + const graph = Load( + module('root', ({ provision }) => { + provision(authModule, { id: 'auth', secrets: { key: envSecret('AUTH_KEY') } }); + }), + ); + // The name flows root -> module.secrets.key -> inner's slot; the binding is + // recorded at the inner service's full address. + expect(graph.secrets).toEqual([ + { serviceAddress: 'auth.inner', slot: 'key', name: 'AUTH_KEY' }, + ]); + }); + + test('a service with no secret slots yields no bindings', () => { + const graph = Load( + module('root', ({ provision }) => { + provision(svc('plain'), { id: 'plain' }); + }), + ); + expect(graph.secrets).toEqual([]); + }); +}); + +describe('Load validates secret wiring', () => { + test('a module that declares a secret but never forwards it fails', () => { + const m = module('auth', { secrets: { key: secret() } }, ({ provision }) => { + provision(svc('plain'), { id: 'plain' }); // never forwards secrets.key + }); + expect(() => + Load( + module('root', ({ provision }) => { + provision(m, { id: 'auth', secrets: { key: envSecret('AUTH_KEY') } }); + }), + ), + ).toThrow(/declares secret "key" but never forwards/); + }); + + test('a root module that declares its own secret slot fails — the root binds, it does not declare', () => { + const root = module('root', { secrets: { key: secret() } }, () => {}); + expect(() => Load(root)).toThrow(/deployed as the root/); + }); + + test('a resource may not receive secrets', () => { + // A non-secret value wired into a secret slot is rejected. + const auth = svc('auth', { signingKey: secret() }); + expect(() => + Load( + module('root', ({ provision }) => { + // @ts-expect-error a secret slot must be bound with a secret source + provision(auth, { id: 'auth', secrets: { signingKey: 'not-a-secret' } }); + }), + ), + ).toThrow(/non-secret value/); + }); +}); diff --git a/packages/0-framework/1-core/core/src/__tests__/testing.test-d.ts b/packages/0-framework/1-core/core/src/__tests__/testing.test-d.ts index 3b392819..8549cfbf 100644 --- a/packages/0-framework/1-core/core/src/__tests__/testing.test-d.ts +++ b/packages/0-framework/1-core/core/src/__tests__/testing.test-d.ts @@ -56,6 +56,9 @@ const consumer = (): RunnableServiceNode => config() { throw new Error('unused — type-only file, never executed'); }, + secrets() { + throw new Error('unused — type-only file, never executed'); + }, }); test('a correctly-shaped double, with or without the optional param override, compiles', () => { diff --git a/packages/0-framework/1-core/core/src/__tests__/testing.test.ts b/packages/0-framework/1-core/core/src/__tests__/testing.test.ts index f5a06350..c7c9e77e 100644 --- a/packages/0-framework/1-core/core/src/__tests__/testing.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/testing.test.ts @@ -54,6 +54,11 @@ const consumer = (): RunnableServiceNode => 'consumer.config() should never be reached — mockService replaces it entirely.', ); }, + secrets() { + throw new Error( + 'consumer.secrets() should never be reached — mockService replaces it entirely.', + ); + }, }); describe('mockService', () => { diff --git a/packages/0-framework/1-core/core/src/config.ts b/packages/0-framework/1-core/core/src/config.ts index ee3a0fa3..b273b758 100644 --- a/packages/0-framework/1-core/core/src/config.ts +++ b/packages/0-framework/1-core/core/src/config.ts @@ -6,9 +6,12 @@ * pack owns encoding — serializing that Config to the platform environment * and reversing it (see core-model.md § Runtime). Core never stringifies and * never touches an environment. + * + * Secrets are NOT params — they are their own forwardable slot (ADR-0029, see + * `secret()`/`envSecret()`/`secrets()` in node.ts). A param is never secret. */ import type { StandardSchemaV1 } from '@standard-schema/spec'; -import type { Graph } from './graph-types.ts'; +import type { Graph, SecretBinding } from './graph-types.ts'; import type { ServiceNode } from './node.ts'; /** @@ -21,13 +24,6 @@ import type { ServiceNode } from './node.ts'; */ export interface ConfigParam { readonly schema: S; - /** Redacted in any introspection output. */ - readonly secret?: boolean; - /** - * The platform env-var name a secret param is bound to (set by `envSecret`, - * ADR-0029). The framework carries only this name — never the value. - */ - readonly external?: string; readonly optional?: boolean; readonly default?: StandardSchemaV1.InferOutput; } @@ -56,19 +52,16 @@ export interface Connection

{ /** * The enumerable config surface of a service — derivable from the graph - * alone, nothing booted, no platform keys. The introspection artifact - * (secrets marked, values absent). `schema` is a data-only projection of the - * param's Standard Schema (JSON Schema when the vendor supports the optional - * conversion, a `{ vendor }` tag otherwise) — never the param's functions. - * Physical locations are the target pack's business. + * alone, nothing booted, no platform keys. The introspection artifact (values + * absent). `schema` is a data-only projection of the param's Standard Schema + * (JSON Schema when the vendor supports the optional conversion, a `{ vendor }` + * tag otherwise) — never the param's functions. Physical locations are the + * target pack's business. Secrets are not here — they live on their own slot. */ export interface ConfigDeclaration { readonly owner: 'service' | { readonly input: string }; readonly name: string; readonly schema: Readonly>; - readonly secret: boolean; - /** The platform env-var name a secret param is bound to; omitted for a non-secret param (ADR-0029). */ - readonly external?: string; readonly optional: boolean; readonly default: unknown; } @@ -119,8 +112,6 @@ export function configOf(root: ServiceNode): readonly ConfigDeclaration[] { owner: { input }, name, schema: projectSchema(param.schema), - secret: param.secret === true, - ...(param.external !== undefined ? { external: param.external } : {}), optional: param.optional === true, default: param.default, }); @@ -132,8 +123,6 @@ export function configOf(root: ServiceNode): readonly ConfigDeclaration[] { owner: 'service', name, schema: projectSchema(param.schema), - secret: param.secret === true, - ...(param.external !== undefined ? { external: param.external } : {}), optional: param.optional === true, default: param.default, }); @@ -142,35 +131,15 @@ export function configOf(root: ServiceNode): readonly ConfigDeclaration[] { return entries; } -/** One pointer secret in the app's provision manifest — a platform env-var NAME that must exist before deploy (ADR-0029). */ -export interface ManifestEntry { - /** The platform env-var name the secret is bound to (its `external` facet). */ - readonly external: string; - /** Whether the binding is optional — an absent optional secret is not a deploy failure. */ - readonly optional: boolean; - /** The graph address of the service that declares the binding (for diagnostics). */ - readonly serviceAddress: string; -} - /** - * The app's provision manifest: every pointer secret (a secret param bound to a - * platform env-var NAME via `envSecret`) across the graph's services. Pure graph - * introspection over `configOf`, so it is TARGET-AGNOSTIC — a deploy target's - * preflight consumes it to verify each name exists on the platform (ADR-0029). - * Non-secret params and secrets with no binding (e.g. a producer-valued database - * url) are excluded — only external-bearing secret declarations are manifest. + * The app's provision manifest: every secret binding the root resolved across + * the graph (ADR-0029) — a platform env-var NAME per service secret slot. Pure + * graph introspection, TARGET-AGNOSTIC — a deploy target's preflight consumes + * it to verify each name exists on the platform before deploy. The framework + * carries only the names; the values are provisioned out-of-band. */ -export function provisionManifest(graph: Graph): readonly ManifestEntry[] { - const entries: ManifestEntry[] = []; - for (const { id, node } of graph.nodes) { - if (node.kind !== 'service') continue; - for (const decl of configOf(node)) { - if (decl.secret && decl.external !== undefined) { - entries.push({ external: decl.external, optional: decl.optional, serviceAddress: id }); - } - } - } - return entries; +export function provisionManifest(graph: Graph): readonly SecretBinding[] { + return graph.secrets; } // ——— Param constructors: plain data, target-agnostic (ADR-0018/0019). ——— @@ -203,68 +172,19 @@ const numberSchema = scalarSchema( (v): v is number => typeof v === 'number' && Number.isFinite(v), ); -/** - * Param facets. `secret` and `default` are mutually exclusive: a secret's value - * lives on the platform (ADR-0029), so a default would both defeat the - * deploy-time presence check and put a value into introspection output. The - * union makes `{ secret: true, default }` a type error; `withFacets` re-checks - * at runtime for callers that dodge the types. - */ -export type ParamOptions = - | { readonly secret?: false; readonly optional?: boolean; readonly default?: T } - | { readonly secret: true; readonly optional?: boolean }; - -/** `withFacets`' internal option shape — adds `external`, which only `envSecret` sets. */ -interface FacetOptions { - readonly secret?: boolean; +export interface ParamOptions { readonly optional?: boolean; readonly default?: T; - readonly external?: string; } -const COMPOSE_PREFIX = 'COMPOSE_'; -const POISONED_NAMES = new Set(['DATABASE_URL', 'DATABASE_URL_POOLED']); - -/** - * The single construction chokepoint: assembles a ConfigParam and enforces the - * facet invariants the types express (ADR-0029), so a value that bypasses the - * types still fails loudly. - */ function withFacets( schema: S, - opts: FacetOptions>, + opts: ParamOptions>, ): ConfigParam { - if (opts.secret === true && opts.default !== undefined) { - throw new Error( - 'a secret config param cannot declare a `default` — its value is provisioned on the platform ' + - '(ADR-0029); a default would defeat deploy preflight and leak a value into introspection.', - ); - } - if (opts.external !== undefined) { - if (opts.external.length === 0) { - throw new Error( - "envSecret name must be a non-empty string, e.g. envSecret('STRIPE_SECRET_KEY').", - ); - } - if (opts.external.startsWith(COMPOSE_PREFIX)) { - throw new Error( - `envSecret name "${opts.external}" may not start with "${COMPOSE_PREFIX}" — that prefix is ` + - "reserved for the framework's own generated config keys.", - ); - } - if (POISONED_NAMES.has(opts.external)) { - throw new Error( - `envSecret name "${opts.external}" is reserved — ${[...POISONED_NAMES].join(' and ')} are ` + - 'poisoned at project provision and cannot back a secret param.', - ); - } - } return { schema, - ...(opts.secret !== undefined ? { secret: opts.secret } : {}), ...(opts.optional !== undefined ? { optional: opts.optional } : {}), ...(opts.default !== undefined ? { default: opts.default } : {}), - ...(opts.external !== undefined ? { external: opts.external } : {}), }; } @@ -289,21 +209,3 @@ export function param( ): ConfigParam { return withFacets(schema, opts); } - -/** - * A secret string param bound to an explicit platform env-var `name` (ADR-0029). - * The framework carries only the name; the value is provisioned out-of-band on - * the platform. `secret` forbids `default`; `optional` is allowed. `name` may - * not use the reserved `COMPOSE_` prefix or the poisoned `DATABASE_URL(_POOLED)` - * keys. - */ -export function envSecret( - name: string, - opts: { readonly optional?: boolean } = {}, -): ConfigParam> { - return withFacets(stringSchema, { - secret: true, - external: name, - ...(opts.optional !== undefined ? { optional: opts.optional } : {}), - }); -} diff --git a/packages/0-framework/1-core/core/src/deploy.ts b/packages/0-framework/1-core/core/src/deploy.ts index c8f613c4..d73ed716 100644 --- a/packages/0-framework/1-core/core/src/deploy.ts +++ b/packages/0-framework/1-core/core/src/deploy.ts @@ -159,26 +159,6 @@ function missingBundleError(id: NodeId): LowerError { ); } -function unboundSecretError(id: NodeId, param: string): LowerError { - return new LowerError( - `secret param "${param}" on service "${id}" has no platform binding — declare it with ` + - "envSecret('NAME'), or wire it from a producer.", - ); -} - -/** - * The name of the first service-OWN param that is `secret` with no platform - * binding (`external`), or `undefined`. A dependency input's secret connection - * params are exempt — their value comes from the producer, not a platform name — - * so only `node.params` is inspected (S1: no leaf→root forwarding, ADR-0029). - */ -function firstUnboundServiceSecret(node: ServiceNode): string | undefined { - for (const [name, param] of Object.entries(node.params)) { - if (param.secret === true && param.external === undefined) return name; - } - return undefined; -} - function duplicateExtensionError(id: string): LowerError { return new LowerError( `Extension "${id}" is listed more than once in \`extensions\` — each extension id must be unique.`, @@ -327,12 +307,7 @@ export function lowering( ); } - // One cast for both uses below — folded so the ratchet sees no new site. const service = node as ServiceNode; - const unbound = firstUnboundServiceSecret(service); - if (unbound !== undefined) { - return yield* Effect.fail(unboundSecretError(id, unbound)); - } const provisioned = yield* descriptor.provision(ctx); const typedConfig = buildConfig(service, id, graph, lowered); const serialized = yield* descriptor.serialize(ctx, provisioned, typedConfig); diff --git a/packages/0-framework/1-core/core/src/graph-types.ts b/packages/0-framework/1-core/core/src/graph-types.ts index 2a99f3a6..737ea996 100644 --- a/packages/0-framework/1-core/core/src/graph-types.ts +++ b/packages/0-framework/1-core/core/src/graph-types.ts @@ -22,11 +22,29 @@ export interface Edge { readonly kind: 'input' | 'dependency'; } +/** + * A resolved secret binding: the root bound a service's secret slot to a + * platform env-var NAME, and the wiring forwarded it to that service's address + * (ADR-0029). The framework carries only the name — the value is provisioned + * out-of-band. A target's serializer keys the pointer row off this; the + * preflight manifest aggregates the names. + */ +export interface SecretBinding { + /** The graph address of the service that declares the secret slot. */ + readonly serviceAddress: NodeId; + /** The secret slot key on that service. */ + readonly slot: string; + /** The platform env-var name the root bound the slot to. */ + readonly name: string; +} + export interface Graph { readonly root: GraphNode; /** Root + one per input, topo-ordered (deps first). */ readonly nodes: readonly GraphNode[]; readonly edges: readonly Edge[]; + /** Every service secret slot resolved to its root-bound platform name. */ + readonly secrets: readonly SecretBinding[]; } /** Thrown by Load when the graph is malformed. */ diff --git a/packages/0-framework/1-core/core/src/graph.ts b/packages/0-framework/1-core/core/src/graph.ts index 0275b7f9..3390a8c4 100644 --- a/packages/0-framework/1-core/core/src/graph.ts +++ b/packages/0-framework/1-core/core/src/graph.ts @@ -3,7 +3,7 @@ import { loadModule } from './load-module.ts'; import { loadService } from './load-service.ts'; import { isNode, type ModuleNode, type ServiceNode } from './node.ts'; -export type { Edge, Graph, GraphNode, NodeId } from './graph-types.ts'; +export type { Edge, Graph, GraphNode, NodeId, SecretBinding } from './graph-types.ts'; export { LoadError } from './graph-types.ts'; /** diff --git a/packages/0-framework/1-core/core/src/hydrate.ts b/packages/0-framework/1-core/core/src/hydrate.ts index c77beb87..7b41e356 100644 --- a/packages/0-framework/1-core/core/src/hydrate.ts +++ b/packages/0-framework/1-core/core/src/hydrate.ts @@ -6,8 +6,10 @@ * validation, no strings — the pack's `load()` already read the process-local * stash into a typed Config before calling this. */ +import { blindCast } from '@internal/foundation/casts'; +import { SecretBox } from '@internal/foundation/secret'; import type { Config } from './config.ts'; -import type { Deps, HydratedDeps, ServiceNode } from './node.ts'; +import type { Deps, HydratedDeps, Secrets, SecretValues, ServiceNode } from './node.ts'; /** * Given a service and a concrete typed Config, hydrate every input @@ -45,3 +47,33 @@ export function hydrateSync(root: ServiceNode, config: Config): HydratedDeps; } + +/** + * Wraps each of a service's resolved secret values in a redacting `SecretBox` + * — what the node's `secrets()` accessor returns (ADR-0021, sibling to + * `load()`/`config()`). The RESOLUTION of a secret's value (the boot + * double-lookup that reads the platform var the pointer names) is the target + * pack's job; core is handed the already-resolved strings and only boxes them, + * so a secret is redacted by TYPE from here on. A declared slot missing from + * `values` is a target contract violation, named loudly. + */ +export function hydrateSecrets( + root: ServiceNode, + values: Record, +): SecretValues { + const boxed: Record> = {}; + for (const slot of Object.keys(root.secretSlots)) { + const value = values[slot]; + if (value === undefined) { + throw new Error( + `secret slot "${slot}" has no resolved value — the target must resolve every declared ` + + 'secret before hydrateSecrets().', + ); + } + boxed[slot] = new SecretBox(value); + } + return blindCast< + SecretValues, + 'boxed holds one SecretBox per declared secret slot — exactly the SecretValues shape' + >(boxed); +} diff --git a/packages/0-framework/1-core/core/src/index.ts b/packages/0-framework/1-core/core/src/index.ts index 7f90fd07..1fd3663a 100644 --- a/packages/0-framework/1-core/core/src/index.ts +++ b/packages/0-framework/1-core/core/src/index.ts @@ -5,20 +5,21 @@ * surface grows.) Pure barrel — no implementations live here. */ +export type { SecretString } from '@internal/foundation/secret'; +export { SecretBox } from '@internal/foundation/secret'; export type { Config, ConfigDeclaration, ConfigParam, Connection, - ManifestEntry, Params, Values, } from './config.ts'; -export { configOf, envSecret, number, param, provisionManifest, string } from './config.ts'; +export { configOf, number, param, provisionManifest, string } from './config.ts'; export type { Contract } from './contract.ts'; -export type { Edge, Graph, GraphNode, NodeId } from './graph.ts'; +export type { Edge, Graph, GraphNode, NodeId, SecretBinding } from './graph.ts'; export { Load, LoadError } from './graph.ts'; -export { hydrate, hydrateSync } from './hydrate.ts'; +export { hydrate, hydrateSecrets, hydrateSync } from './hydrate.ts'; export type { BuildAdapter, DependencyEnd, @@ -35,14 +36,22 @@ export type { RefPort, ResourceNode, RunnableServiceNode, + SecretBindings, + SecretNeed, + SecretSource, + Secrets, + SecretValues, ServiceNode, } from './node.ts'; export { dependency, + envSecret, freezeNode, isNode, + isSecretSource, module, ResourceNodeBase, resource, + secret, service, } from './node.ts'; diff --git a/packages/0-framework/1-core/core/src/load-module.ts b/packages/0-framework/1-core/core/src/load-module.ts index d8bdc1eb..4b77a906 100644 --- a/packages/0-framework/1-core/core/src/load-module.ts +++ b/packages/0-framework/1-core/core/src/load-module.ts @@ -1,10 +1,18 @@ import { blindCast } from '@internal/foundation/casts'; -import { type Edge, type Graph, type GraphNode, LoadError, type NodeId } from './graph-types.ts'; +import { + type Edge, + type Graph, + type GraphNode, + LoadError, + type NodeId, + type SecretBinding, +} from './graph-types.ts'; import { serviceInputs } from './load-service.ts'; import { type DependencyEnd, type Deps, isNode, + isSecretSource, type ModuleBuilder, type ModuleContext, type ModuleNode, @@ -59,6 +67,9 @@ function producerIdOf(ref: unknown): string | undefined { */ const MODULE_INPUT_KEY = Symbol('prisma:module-input-key'); +/** Same per-key identity trick as MODULE_INPUT_KEY, for the parallel `ctx.secrets` forwarding channel. */ +const MODULE_SECRET_KEY = Symbol('prisma:module-secret-key'); + /** Whether `ref` carries a callable `satisfies` that accepts `required` truthily. */ function satisfiesRequired(ref: unknown, required: unknown): boolean { return ( @@ -165,6 +176,45 @@ function wiringEdges(wiring: Record, targetId: string): Edge[] })); } +/** + * Checks the secrets wired into one provisioned child: every declared slot is + * bound to a real secret source (an `envSecret` or a forwarded ctx.secrets + * ref), and no wired key names a slot the child doesn't declare — the secret + * analog of `validateWiring`'s per-input checks, but resolved inline (a source + * carries its own name, so no whole-graph pass is needed). + */ +function validateSecretBinding( + child: ServiceNode | ModuleNode, + id: string, + secretWiring: Record, + enclosingModuleName: string, +): void { + const { kind } = child; + for (const slot of Object.keys(child.secretSlots)) { + const bound = secretWiring[slot]; + if (bound === undefined) { + throw new LoadError( + `Secret slot "${slot}" of provisioned ${kind} "${id}" is not bound (module ` + + `"${enclosingModuleName}") — bind it with envSecret('NAME') or forward ctx.secrets.`, + ); + } + if (!isSecretSource(bound)) { + throw new LoadError( + `Secret slot "${slot}" of "${id}" (module "${enclosingModuleName}") was wired with a ` + + "non-secret value — use envSecret('NAME') or a forwarded ctx.secrets ref.", + ); + } + } + for (const slot of Object.keys(secretWiring)) { + if (!Object.hasOwn(child.secretSlots, slot)) { + throw new LoadError( + `The secrets for "${id}" name "${slot}", which is not a secret slot of that ${kind} ` + + `(module "${enclosingModuleName}").`, + ); + } + } +} + /** * Recursively flattens one module's body into the shared graph state and * returns its resolved ModuleOutputs (one ref-port per expose key) for the @@ -183,13 +233,16 @@ function flatten( moduleNode: ModuleNode, address: string | undefined, wiring: Record, + secretWiring: Record, nodes: GraphNode[], edges: Edge[], pending: PendingWiring[], + secretBindings: SecretBinding[], byId: Map, ): Record { const localIds = new Set(); const used = new Set(); + const usedSecrets = new Set(); // Each ctx.inputs entry gets its OWN object identity: a shallow copy of the // wired producer ref, branded (symbol-keyed) with the input key it stands @@ -217,17 +270,35 @@ function flatten( } }; + // The secrets forwarding channel mirrors ctx.inputs: each declared secret slot + // gets its OWN branded copy of the source bound to it, so forwarding one down a + // provision counts precisely (identity), and `name` reads through unchanged. + const ctxSecrets: Record = {}; + for (const key of Object.keys(moduleNode.secretSlots)) { + const bound = secretWiring[key]; + ctxSecrets[key] = + typeof bound === 'object' && bound !== null ? { ...bound, [MODULE_SECRET_KEY]: key } : bound; + } + const markSecretsUsed = (values: Record): void => { + for (const value of Object.values(values)) { + for (const key of Object.keys(ctxSecrets)) { + if (value === ctxSecrets[key]) usedSecrets.add(key); + } + } + }; + const provision = ( child: ServiceNode | ResourceNode | ModuleNode, - opts?: { id?: string; deps?: Record }, + opts?: { id?: string; deps?: Record; secrets?: Record }, // biome-ignore lint/suspicious/noExplicitAny: ModuleBuilder's real overload set is checked at the call site; the collector implementation is untyped by design. ): any => { - // The id defaults to the node's own `name`; `opts.deps` carries the - // producers that satisfy its dependency slots. The "_"/"." and duplicate-id - // checks, brand-check and address join below are identical whether the id - // was written or inferred. + // The id defaults to the node's own `name`; `opts.deps`/`opts.secrets` carry + // the producers and secret sources that satisfy its slots. The "_"/"." and + // duplicate-id checks, brand-check and address join below are identical + // whether the id was written or inferred. const id = opts?.id ?? child.name; const provisionWiring = opts?.deps; + const provisionSecrets = opts?.secrets; if (typeof id !== 'string' || id.length === 0) { throw new LoadError(`provision() requires a non-empty id (module "${moduleNode.name}").`); } @@ -267,6 +338,11 @@ function flatten( `provision("${id}") received deps for a resource — a resource has no dependency slots to satisfy.`, ); } + if (provisionSecrets !== undefined) { + throw new LoadError( + `provision("${id}") received secrets for a resource — a resource has no secret slots to satisfy.`, + ); + } if (child.type.length === 0) { throw new LoadError(`provision("${id}") received a resource with an empty node type.`); } @@ -278,7 +354,22 @@ function flatten( const localWiring = { ...(provisionWiring ?? {}) }; markUsed(localWiring); + // Secrets: validate every declared slot is bound to a real secret source, + // reject extras, and mark forwarded refs used (identity). No `pending` + // deferral — a secret source carries its own name, so nothing needs the + // whole graph to resolve (unlike a dependency's producer). + const localSecrets = { ...(provisionSecrets ?? {}) }; + markSecretsUsed(localSecrets); + validateSecretBinding(child, id, localSecrets, moduleNode.name); + if (child.kind === 'service') { + // A service's slots resolve to names HERE — record one binding per slot. + for (const slot of Object.keys(child.secretSlots)) { + const bound = localSecrets[slot]; + if (isSecretSource(bound)) { + secretBindings.push({ serviceAddress: fullAddress, slot, name: bound.name }); + } + } const inputs = serviceInputs(child, fullAddress); nodes.push(...inputs.nodes, { id: fullAddress, node: child }); edges.push(...inputs.edges, ...wiringEdges(localWiring, fullAddress)); @@ -301,7 +392,17 @@ function flatten( targetKind: 'module', enclosingModuleName: moduleNode.name, }); - const childOutputs = flatten(child, fullAddress, localWiring, nodes, edges, pending, byId); + const childOutputs = flatten( + child, + fullAddress, + localWiring, + localSecrets, + nodes, + edges, + pending, + secretBindings, + byId, + ); nodes.push({ id: fullAddress, node: child }); byId.set(fullAddress, child); return blindCast< @@ -312,9 +413,10 @@ function flatten( const ctx = blindCast< ModuleContext, - "ctxInputs holds one resolved InputRef per moduleNode.deps key (built above from wiring, the same ref-port shape a producer's port has), and provision is exactly ModuleBuilder['provision'] — together they satisfy ModuleContext structurally for whatever D this moduleNode declares" + "ctxInputs/ctxSecrets hold one resolved ref per moduleNode.deps/secrets key (the same shapes a producer port and an envSecret source carry), and provision is exactly ModuleBuilder['provision'] — together they satisfy ModuleContext structurally for whatever D/S this moduleNode declares" >({ inputs: ctxInputs, + secrets: ctxSecrets, provision: blindCast< ModuleBuilder['provision'], 'single implementation behind the provision() overloads — returns the contract-carrying ref for a resource, a ProvisionedRef for a service, and a ProvisionedRef for a nested module, exactly what each overload pins, but an object property cannot carry an overloaded implementation signature' @@ -324,7 +426,7 @@ function flatten( const outputs = blindCast< Record, 'ModuleOutputs is a mapped type over the declared expose keys; the loop below reads it by key, which is all a Record view needs' - >(moduleNode.body(ctx)); + >(moduleNode.body(ctx) ?? {}); // Pass-through: returning an input as an expose port re-offers it to the // enclosing scope — that is using it, not ignoring it (module-composition.md @@ -340,6 +442,16 @@ function flatten( } } + // A declared secret slot must be forwarded into a provision (a secret is not + // re-exposed as a port, so there is no pass-through case for it). + for (const key of Object.keys(moduleNode.secretSlots)) { + if (!usedSecrets.has(key)) { + throw new LoadError( + `Module "${moduleNode.name}" declares secret "${key}" but never forwards it into a provision.`, + ); + } + } + for (const [key, contract] of Object.entries(moduleNode.expose)) { const port = outputs[key]; if (port === undefined) { @@ -368,13 +480,23 @@ export function loadModule(root: ModuleNode, opts?: { id?: NodeId }): Graph { `"${root.name}" from another module that provisions and wires it instead.`, ); } + const rootSecretKeys = Object.keys(root.secretSlots); + if (rootSecretKeys.length > 0) { + const names = rootSecretKeys.map((k) => `"${k}"`).join(', '); + throw new LoadError( + `Module "${root.name}" declares secret${rootSecretKeys.length > 1 ? 's' : ''} ${names} but is ` + + 'being deployed as the root — a root has no enclosing scope to bind them; the root binds ' + + `secrets with envSecret('NAME'), it does not declare secret slots of its own.`, + ); + } const nodes: GraphNode[] = []; const edges: Edge[] = []; const pending: PendingWiring[] = []; + const secretBindings: SecretBinding[] = []; const byId = new Map(); - flatten(root, undefined, {}, nodes, edges, pending, byId); + flatten(root, undefined, {}, {}, nodes, edges, pending, secretBindings, byId); for (const entry of pending) validateWiring(entry, byId); assertDependencyDag(edges); @@ -384,6 +506,7 @@ export function loadModule(root: ModuleNode, opts?: { id?: NodeId }): Graph { root: rootGraphNode, nodes: [...topoSort(nodes, edges), rootGraphNode], edges, + secrets: secretBindings, }; } diff --git a/packages/0-framework/1-core/core/src/load-service.ts b/packages/0-framework/1-core/core/src/load-service.ts index eae0cac3..441fba96 100644 --- a/packages/0-framework/1-core/core/src/load-service.ts +++ b/packages/0-framework/1-core/core/src/load-service.ts @@ -54,5 +54,8 @@ export function loadService(root: ServiceNode, rootId: NodeId): Graph { root: rootGraphNode, nodes: [...topoSort(nodes, edges), rootGraphNode], edges, + // A lone service has no enclosing scope to bind its secrets; a service that + // declares secret slots must be composed by a module that binds them. + secrets: [], }; } diff --git a/packages/0-framework/1-core/core/src/node.ts b/packages/0-framework/1-core/core/src/node.ts index 0e7bdc95..f5a201bd 100644 --- a/packages/0-framework/1-core/core/src/node.ts +++ b/packages/0-framework/1-core/core/src/node.ts @@ -3,6 +3,7 @@ * data objects. A node's `extension` + `type` form its deploy-time registry key (ADR-0017). */ import { blindCast } from '@internal/foundation/casts'; +import type { SecretString } from '@internal/foundation/secret'; import type { ConfigParam, Connection, Params, Values } from './config.ts'; import type { Contract } from './contract.ts'; @@ -10,6 +11,87 @@ import type { Contract } from './contract.ts'; // Symbol.for so the check survives duplicated module instances in a workspace. const NODE: unique symbol = Symbol.for('prisma:node') as never; +// A secret slot rides its OWN brand + field, structurally distinct from deps and +// params (ADR-0029): sensitivity is by type, not a flag. `secret()` is the NEED +// (a nameless slot); `envSecret('NAME')` is the SOURCE (the platform name the +// root binds it to) — the same need-vs-source split as rpc(contract) vs a +// producer's exposed port. +const SECRET_NEED: unique symbol = blindCast( + Symbol.for('prisma:secret-need'), +); +const SECRET_SOURCE: unique symbol = blindCast( + Symbol.for('prisma:secret-source'), +); + +/** A declared secret input slot — nameless; the root binds it and the topology forwards it in. */ +export interface SecretNeed { + readonly [SECRET_NEED]: true; + readonly kind: 'secret'; +} + +/** A service/module's secret slots: name → the need it declares. */ +export type Secrets = Record; + +/** The wiring value bound to a secret slot — a leaf source carrying the platform env-var NAME, or a forwarded ctx.secrets ref (also carrying it). */ +export interface SecretSource { + readonly [SECRET_SOURCE]: true; + /** The platform env-var name the slot is bound to. */ + readonly name: string; +} + +/** What `provision(node, { secrets })` supplies: one source per declared secret slot. */ +export type SecretBindings = { [K in keyof S]: SecretSource }; + +/** What `secrets()` returns: one redacting SecretBox per declared slot. */ +export type SecretValues = { readonly [K in keyof S]: SecretString }; + +/** Declares a secret NEED. Nameless — the platform name is bound at the root via `envSecret`. */ +export function secret(): SecretNeed { + return Object.freeze({ [SECRET_NEED]: true as const, kind: 'secret' as const }); +} + +const RESERVED_SECRET_PREFIX = 'COMPOSE_'; +const POISONED_SECRET_NAMES: ReadonlySet = new Set(['DATABASE_URL', 'DATABASE_URL_POOLED']); + +/** + * The secret SOURCE bound at the root: a leaf value naming the platform env var + * a secret slot resolves to (ADR-0029). The framework carries only the name. + * The name may not use the reserved `COMPOSE_` prefix or the poisoned + * `DATABASE_URL(_POOLED)` keys. + */ +export function envSecret(name: string): SecretSource { + if (typeof name !== 'string' || name.length === 0) { + throw new Error( + "envSecret() requires a non-empty platform env-var name, e.g. envSecret('STRIPE_SECRET_KEY').", + ); + } + if (name.startsWith(RESERVED_SECRET_PREFIX)) { + throw new Error( + `envSecret name "${name}" may not start with "${RESERVED_SECRET_PREFIX}" — that prefix is ` + + "reserved for the framework's own generated config keys.", + ); + } + if (POISONED_SECRET_NAMES.has(name)) { + throw new Error( + `envSecret name "${name}" is reserved — ${[...POISONED_SECRET_NAMES].join(' and ')} are ` + + 'poisoned at project provision and cannot back a secret.', + ); + } + return Object.freeze({ [SECRET_SOURCE]: true as const, name }); +} + +/** True if `value` is a secret source (an `envSecret` result or a forwarded ctx.secrets ref). */ +export function isSecretSource(value: unknown): value is SecretSource { + return ( + typeof value === 'object' && + value !== null && + blindCast< + Record, + 'reading the secret-source brand off an unknown object' + >(value)[SECRET_SOURCE] === true + ); +} + /** Opaque `Contract` bound shared by every node/port type that doesn't care which contract. */ // biome-ignore lint/suspicious/noExplicitAny: the one alias for this bound — see doc comment. export type AnyContract = Contract; @@ -56,6 +138,7 @@ export interface ServiceNode< D extends Deps = Deps, P extends Params = Params, E extends Expose = Expose, + S extends Secrets = Secrets, > { readonly [NODE]: true; readonly kind: 'service'; @@ -67,6 +150,8 @@ export interface ServiceNode< readonly inputs: D; /** Service-level config declarations (e.g. port). */ readonly params: P; + /** Declared secret input slots (authored as `secrets`) — bound at the root via `envSecret`, read via the `secrets()` accessor (ADR-0029). Named `secretSlots` on the node so the data field does not collide with that accessor. */ + readonly secretSlots: S; /** How the app's entry is built + assembled. */ readonly build: BuildAdapter; /** Named output ports this service exposes — the Contracts a consumer's `rpc(contract)` can require. `undefined` when the service exposes nothing. */ @@ -82,10 +167,13 @@ export interface RunnableServiceNode< D extends Deps = Deps, P extends Params = Params, E extends Expose = Expose, -> extends ServiceNode { + S extends Secrets = Secrets, +> extends ServiceNode { run(address: string, boot: () => Promise): Promise; load(): HydratedDeps; config(): Values

; + /** The service's secrets, each a redacting SecretBox — a third accessor beside load()/config() (ADR-0021). */ + secrets(): SecretValues; } /** @@ -105,23 +193,31 @@ export interface DependencyEnd { } /** A Module: the same Deps/Expose boundary a service has, around transparent wiring instead of a black-box body — its `body` runs at Load, not at authoring. */ -export interface ModuleNode { +export interface ModuleNode< + D extends Deps = Deps, + E extends Expose = Expose, + S extends Secrets = Secrets, +> { readonly [NODE]: true; readonly kind: 'module'; /** Human-readable, given at authoring — logs/diagnostics only. */ readonly name: string; readonly deps: D; + /** Declared secret input slots (authored as `secrets`) — forwarded to internals via `ctx.secrets` (ADR-0029). */ + readonly secretSlots: S; readonly expose: E; - body(ctx: ModuleContext): ModuleOutputs; + body(ctx: ModuleContext): ModuleOutputs | void; } /** * What a module's body receives: its declared inputs as forwardable wiring * values, plus `provision` to register the owned services/modules it wires them into. */ -export interface ModuleContext { +export interface ModuleContext { /** The module's declared inputs as wiring values — pass them into provision(). */ readonly inputs: { [K in keyof D]: InputRef }; + /** The module's declared secret slots as forwardable sources — pass them into a child's `secrets` (ADR-0029). */ + readonly secrets: { readonly [K in keyof S]: SecretSource }; /** Registers an owned child (service or module) under a stable id. */ readonly provision: ModuleBuilder['provision']; } @@ -176,9 +272,13 @@ type DepBindings = { * never be left unwired at compile time. The whole object is therefore * optional for a slot-less node and required for one with slots. */ -type ProvisionArgs = [keyof D] extends [never] - ? [opts?: { id?: string }] - : [opts: { id?: string; deps: DepBindings }]; +type ProvisionArgs = [keyof D] extends [never] + ? [keyof S] extends [never] + ? [opts?: { id?: string }] + : [opts: { id?: string; secrets: SecretBindings }] + : [keyof S] extends [never] + ? [opts: { id?: string; deps: DepBindings }] + : [opts: { id?: string; deps: DepBindings; secrets: SecretBindings }]; export interface ModuleBuilder { /** Provisions an owned resource; its id defaults to the node's `name`. */ @@ -186,32 +286,32 @@ export interface ModuleBuilder { resource: ResourceNode, opts?: { id?: string }, ): { readonly id: string } & RefPort; - /** Registers an owned service; its id defaults to the node's `name`, and `deps` is required iff it declares dependency slots. */ - provision( + /** Registers an owned service; its id defaults to the node's `name`; `deps`/`secrets` are required iff it declares them. */ + provision( // biome-ignore lint/suspicious/noExplicitAny: accepts any concrete service node; ServiceNode's params generic is opaque here. - service: ServiceNode, - ...args: ProvisionArgs + service: ServiceNode, + ...args: ProvisionArgs ): ProvisionedRef; /** - * The service call with `deps` spelled out. `ProvisionArgs` above cannot - * resolve while `D` is still an unbound type parameter — a generic wrapper - * like `cron()` provisioning a caller-supplied service — so that call site - * resolves to this concrete-`deps` overload instead. + * The service call with `deps`/`secrets` spelled out. `ProvisionArgs` above + * cannot resolve while `D`/`S` are still unbound type parameters — a generic + * wrapper like `cron()` provisioning a caller-supplied service — so that call + * site resolves to this concrete overload instead. */ - provision( + provision( // biome-ignore lint/suspicious/noExplicitAny: accepts any concrete service node; ServiceNode's params generic is opaque here. - service: ServiceNode, - opts: { id?: string; deps: DepBindings }, + service: ServiceNode, + opts: { id?: string; deps: DepBindings; secrets?: SecretBindings }, ): ProvisionedRef; - /** Registers an owned child module; its id defaults to the node's `name`, and `deps` is required iff it declares dependency slots. */ - provision( - child: ModuleNode, - ...args: ProvisionArgs + /** Registers an owned child module; its id defaults to the node's `name`; `deps`/`secrets` are required iff it declares them. */ + provision( + child: ModuleNode, + ...args: ProvisionArgs ): ProvisionedRef; - /** The child-module call with `deps` spelled out — the same generic-wrapper escape as the service overload above. */ - provision( - child: ModuleNode, - opts: { id?: string; deps: DepBindings }, + /** The child-module call with `deps`/`secrets` spelled out — the same generic-wrapper escape as the service overload above. */ + provision( + child: ModuleNode, + opts: { id?: string; deps: DepBindings; secrets?: SecretBindings }, ): ProvisionedRef; } @@ -252,7 +352,11 @@ function requireExtension(extension: string, factory: string): void { * underscore inside a name would collide with that separator (e.g. param * "db_url" vs input "db"'s param "url" both hitting env key "DB_URL"). */ -function requireNoUnderscoreName(name: string, kind: 'input' | 'param', factory: string): void { +function requireNoUnderscoreName( + name: string, + kind: 'input' | 'param' | 'secret', + factory: string, +): void { if (name.includes('_')) { throw new Error( `${factory}() ${kind} name "${name}" may not contain "_" — config keys join names with ` + @@ -264,7 +368,7 @@ function requireNoUnderscoreName(name: string, kind: 'input' | 'param', factory: function requireNoUnderscoreNames( names: Iterable, - kind: 'input' | 'param', + kind: 'input' | 'param' | 'secret', factory: string, ): void { for (const name of names) requireNoUnderscoreName(name, kind, factory); @@ -278,6 +382,16 @@ function freezeParams

(params: P): P { return Object.freeze(frozen) as P; } +function freezeSecrets(secrets: S): S { + const frozen: Record = {}; + for (const [name, need] of Object.entries(secrets)) { + frozen[name] = Object.freeze({ ...need }); + } + return blindCast( + Object.freeze(frozen), + ); +} + /** A frozen shallow copy that keeps the caller's declared type. */ function frozenShallowCopy(obj: T): T { return blindCast< @@ -363,20 +477,23 @@ export function service< D extends Deps, P extends Params, E extends Expose = Record, + S extends Secrets = Record, >(def: { name: string; extension: string; type: string; inputs: D; params: P; + secrets?: S; build: BuildAdapter; expose?: E; -}): ServiceNode { +}): ServiceNode { requireName(def.name, 'service'); requireExtension(def.extension, 'service'); requireType(def.type, 'service'); requireNoUnderscoreNames(Object.keys(def.inputs), 'input', 'service'); requireNoUnderscoreNames(Object.keys(def.params), 'param', 'service'); + requireNoUnderscoreNames(Object.keys(def.secrets ?? {}), 'secret', 'service'); return Object.freeze({ [NODE]: true as const, kind: 'service' as const, @@ -385,6 +502,9 @@ export function service< type: def.type, inputs: frozenShallowCopy(def.inputs), params: freezeParams(def.params), + secretSlots: freezeSecrets( + def.secrets ?? blindCast({}), + ), build: Object.freeze({ ...def.build }), expose: def.expose !== undefined ? frozenShallowCopy(def.expose) : undefined, }); @@ -425,8 +545,8 @@ export function dependency

(def: { */ export function module( name: string, - body: (ctx: ModuleContext>) => void, -): ModuleNode, Record>; + body: (ctx: ModuleContext, Record>) => void, +): ModuleNode, Record, Record>; /** * A module with a boundary: `deps` and/or `expose` declare what wires in and * out, the same way a service does. The body returns one port per `expose` key. @@ -434,26 +554,30 @@ export function module( export function module< D extends Deps = Record, E extends Expose = Record, + S extends Secrets = Record, >( name: string, - boundary: { deps?: D; expose?: E }, - body: (ctx: ModuleContext) => ModuleOutputs, -): ModuleNode; + boundary: { deps?: D; secrets?: S; expose?: E }, + body: (ctx: ModuleContext) => ModuleOutputs | void, +): ModuleNode; /** * Constructs a branded, frozen Module node. Construction is INERT — the body is * wiring, not user code, and runs only when the module is Loaded. */ export function module( name: string, - boundaryOrBody: { deps?: Deps; expose?: Expose } | ((ctx: ModuleContext) => void), - maybeBody?: (ctx: ModuleContext) => ModuleOutputs, + boundaryOrBody: + | { deps?: Deps; secrets?: Secrets; expose?: Expose } + | ((ctx: ModuleContext) => void), + maybeBody?: (ctx: ModuleContext) => ModuleOutputs | void, ): ModuleNode { requireName(name, 'module'); const closedRoot = typeof boundaryOrBody === 'function'; const boundary = closedRoot ? {} : boundaryOrBody; const deps = frozenShallowCopy(boundary.deps ?? {}); + const secretSlots = frozenShallowCopy(boundary.secrets ?? {}); const expose = frozenShallowCopy(boundary.expose ?? {}); - const body: (ctx: ModuleContext) => ModuleOutputs = closedRoot + const body: (ctx: ModuleContext) => ModuleOutputs | void = closedRoot ? (ctx) => { boundaryOrBody(ctx); return {}; @@ -465,6 +589,7 @@ export function module( kind: 'module' as const, name, deps, + secretSlots, expose, body, }); diff --git a/packages/0-framework/1-core/core/src/testing.ts b/packages/0-framework/1-core/core/src/testing.ts index 159e0071..2b943612 100644 --- a/packages/0-framework/1-core/core/src/testing.ts +++ b/packages/0-framework/1-core/core/src/testing.ts @@ -10,7 +10,7 @@ */ import { blindCast } from '@internal/foundation/casts'; import type { Params, Values } from './config.ts'; -import type { Deps, Expose, HydratedDeps, RunnableServiceNode } from './node.ts'; +import type { Deps, Expose, HydratedDeps, RunnableServiceNode, Secrets } from './node.ts'; /** * `mockService`'s override argument: every declared dependency, typed against @@ -40,10 +40,10 @@ function paramDefaults

(params: P): Partial> { * to `load()`, param keys to `config()`. `run()` is not meaningful on a mock * (there is no boot, no environment) and throws if called. */ -export function mockService( - service: RunnableServiceNode, +export function mockService( + service: RunnableServiceNode, overrides: LoadOverrides, -): RunnableServiceNode { +): RunnableServiceNode { const entries = Object.entries(overrides); const deps = blindCast< HydratedDeps, From 5d119c847d52528ad3ac6918db43ec8870cc5887 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 17:10:58 +0200 Subject: [PATCH 11/19] fix(core): reject lone-service secret slots at Load; pin used-tracking invariants Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-core/core/src/__tests__/secrets.test.ts | 89 ++++++++++++++++++- .../1-core/core/src/load-service.ts | 14 ++- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts b/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts index 29229140..dd197b97 100644 --- a/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from 'bun:test'; import { blindCast } from '@internal/foundation/casts'; import { Load } from '../graph.ts'; -import type { Secrets } from '../node.ts'; -import { envSecret, isSecretSource, module, secret, service } from '../node.ts'; +import type { SecretSource, Secrets } from '../node.ts'; +import { envSecret, isSecretSource, module, resource, secret, service } from '../node.ts'; +import { providerContract } from './helpers.ts'; const build = { extension: '@prisma/compose/node', @@ -102,8 +103,12 @@ describe('Load validates secret wiring', () => { expect(() => Load(root)).toThrow(/deployed as the root/); }); - test('a resource may not receive secrets', () => { - // A non-secret value wired into a secret slot is rejected. + test('a lone service that declares secret slots is rejected at Load', () => { + const auth = svc('auth', { signingKey: secret() }); + expect(() => Load(auth)).toThrow(/no enclosing scope to bind them/); + }); + + test('a non-secret value wired into a secret slot is rejected', () => { const auth = svc('auth', { signingKey: secret() }); expect(() => Load( @@ -114,4 +119,80 @@ describe('Load validates secret wiring', () => { ), ).toThrow(/non-secret value/); }); + + test('a resource may not receive secrets', () => { + const db = resource({ + name: 'db', + extension: 'test/pack', + provides: providerContract('fake/db', { url: '' }), + }); + expect(() => + Load( + module('root', ({ provision }) => { + // The resource provision overload takes no `secrets`; inject it to + // exercise the runtime rejection (load-module.ts). + provision( + db, + blindCast< + { id: string }, + 'a resource takes no secrets — inject one to hit the runtime check' + >({ id: 'db', secrets: { k: envSecret('K') } }), + ); + }), + ), + ).toThrow(/resource has no secret slots/); + }); + + test('a declared secret slot left unbound in a provision fails at Load', () => { + const auth = svc('auth', { signingKey: secret() }); + expect(() => + Load( + module('root', ({ provision }) => { + provision( + auth, + blindCast< + { id: string; secrets: { signingKey: SecretSource } }, + 'deliberately omit the binding to exercise the runtime not-bound check' + >({ id: 'auth', secrets: {} }), + ); + }), + ), + ).toThrow(/is not bound/); + }); + + test('an extra secrets key naming a non-slot fails at Load', () => { + const auth = svc('auth', { signingKey: secret() }); + expect(() => + Load( + module('root', ({ provision }) => { + provision( + auth, + blindCast< + { id: string; secrets: { signingKey: SecretSource } }, + 'inject an extra non-slot key to exercise the runtime extra-key check' + >({ id: 'auth', secrets: { signingKey: envSecret('K'), bogus: envSecret('B') } }), + ); + }), + ), + ).toThrow(/"bogus", which is not a secret slot/); + }); + + test('branded copies keep used-tracking per-slot — the SAME source bound to two slots, forwarding only one, flags the other unused', () => { + const inner = svc('inner', { key: secret() }); + const m = module('m', { secrets: { a: secret(), b: secret() } }, ({ secrets, provision }) => { + // Forward only `a`; `b` is never forwarded. + provision(inner, { id: 'inner', secrets: { key: secrets.a } }); + }); + // The SAME source object is bound to BOTH a and b. Without the per-slot + // branded copies, forwarding secrets.a would alias-mark b used too; with + // them, b is correctly flagged as never forwarded. + const src = envSecret('SHARED'); + expect(() => + Load( + module('root', ({ provision }) => { + provision(m, { id: 'm', secrets: { a: src, b: src } }); + }), + ), + ).toThrow(/declares secret "b" but never forwards/); + }); }); diff --git a/packages/0-framework/1-core/core/src/load-service.ts b/packages/0-framework/1-core/core/src/load-service.ts index 441fba96..f8d5ff01 100644 --- a/packages/0-framework/1-core/core/src/load-service.ts +++ b/packages/0-framework/1-core/core/src/load-service.ts @@ -48,14 +48,24 @@ export function loadService(root: ServiceNode, rootId: NodeId): Graph { ); } } + // A lone service has no enclosing scope to bind its secrets — nothing writes + // the pointer rows or runs preflight for them, so it would fail opaquely at + // boot. Reject it at Load, the same as an unwired dependency above. + const secretSlots = Object.keys(root.secretSlots); + if (secretSlots.length > 0) { + const names = secretSlots.map((k) => `"${k}"`).join(', '); + throw new LoadError( + `Service "${rootId}" declares secret slot${secretSlots.length > 1 ? 's' : ''} ${names} but is ` + + 'being loaded directly — a lone service has no enclosing scope to bind them. Compose it ' + + `inside a module that binds each with envSecret('NAME').`, + ); + } const rootGraphNode: GraphNode = { id: rootId, node: root }; const { nodes, edges } = serviceInputs(root, rootId); return { root: rootGraphNode, nodes: [...topoSort(nodes, edges), rootGraphNode], edges, - // A lone service has no enclosing scope to bind its secrets; a service that - // declares secret slots must be composed by a module that binds them. secrets: [], }; } From 9ba4f9c9e13ecf202974a0febf3f62f3e2516ea0 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 17:28:14 +0200 Subject: [PATCH 12/19] feat(prisma-cloud): re-key pointer rows + preflight on secret slots; SecretBox boot Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/__tests__/EnvironmentVariable.test.ts | 2 +- .../src/compute/EnvironmentVariable.ts | 29 +--- .../src/__tests__/control-lowering.test.ts | 21 +-- .../target/src/__tests__/extension.test.ts | 119 ++++++--------- .../target/src/__tests__/invariants.test.ts | 4 +- .../target/src/__tests__/preflight.test.ts | 84 +++-------- .../target/src/__tests__/prisma-next.test.ts | 2 +- .../1-extensions/target/src/compute.ts | 31 +++- .../target/src/descriptors/compute.ts | 43 ++++-- .../1-extensions/target/src/postgres.ts | 2 +- .../1-extensions/target/src/preflight.ts | 75 +++++----- .../1-extensions/target/src/prisma-next.ts | 2 +- .../1-extensions/target/src/serializer.ts | 138 +++++++++++------- 13 files changed, 274 insertions(+), 278 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts index 3d920f5a..d4508800 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts @@ -141,7 +141,7 @@ describe('EnvironmentVariable reconcile — restricted adoption (ADR-0029)', () expect(exit._tag).toBe('Failure'); if (exit._tag === 'Failure') { - expect(Cause.pretty(exit.cause)).toContain('reserved COMPOSE_ namespace'); + expect(Cause.pretty(exit.cause)).toContain('reserved COMPOSE_ key'); } // It observed the collision, then refused — no PATCH, no POST. expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts index 7a327c6b..e0e172c6 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts @@ -48,21 +48,10 @@ export const EnvironmentVariableProvider = () => list: () => Effect.succeed([] as EnvironmentVariableAttributes[]), reconcile: Effect.fn(function* ({ news, output }) { const cls = news.class ?? 'production'; - // The value is write-only (encrypted), so we never diff it — we PATCH. - // Find the row to write, adopting in order: our own prior row - // (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.) + // Value is write-only, so we PATCH, never diff. Adopt our own prior + // row (output.id), or a pre-existing poison-key row (DATABASE_URL(_POOLED), + // platform-seeded). Any other untracked match is a COMPOSE_ collision we + // refuse to overwrite (see the throw below). let id = output?.id; if (id !== undefined) { const priorId = id; @@ -86,12 +75,10 @@ export const EnvironmentVariableProvider = () => const isPoison = news.key === 'DATABASE_URL' || news.key === 'DATABASE_URL_POOLED'; if (!isPoison) { throw new Error( - `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.', + `EnvironmentVariable "${news.key}" (project "${news.projectId}", class "${cls}") ` + + 'exists but is untracked in this deploy state — refusing to overwrite a reserved ' + + "COMPOSE_ key. Restore this deploy's hosted state, or remove the variable to let " + + 'this deploy recreate it.', ); } id = matchId; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index e32d68b2..c5144a37 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -88,7 +88,7 @@ mock.module('../pg-warm-resource.ts', () => ({ const { prismaCloud } = await import('../control.ts'); const { compute, postgres } = await import('../index.ts'); -const { module, envSecret } = await import('@internal/core'); +const { module, secret } = await import('@internal/core'); const { lowering } = await import('@internal/core/deploy'); const run = (eff: Effect.Effect): A => @@ -327,7 +327,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { entry: 'server.js', }, }); - const ctx = { address: 'auth', node } as unknown as LowerContext; + const ctx = { address: 'auth', node, graph: { secrets: [] } } as unknown as LowerContext; const provisioned: LoweredNode = { outputs: { serviceId: 'auth-svc#cloud-id', projectId: 'shop-project#cloud-id' }, }; @@ -370,7 +370,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { }); }); - test('a secret param serializes to a POINTER row — value is the external NAME, never the value (ADR-0029)', async () => { + test('a secret slot serializes to a POINTER row — value is the bound platform NAME, never a value (ADR-0029)', async () => { await withEnv( // The real secret value is present in the deploy shell, proving it still // cannot reach the serialized row. @@ -380,7 +380,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { const node = compute({ name: 'ingest', deps: {}, - params: { stripeKey: envSecret('STRIPE_SECRET_KEY') }, + secrets: { stripeKey: secret() }, build: { extension: '@prisma/compose/node', type: 'node', @@ -388,9 +388,12 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { entry: 'server.js', }, }); - const ctx = { address: 'ingest', node } as unknown as LowerContext; + // The root bound the slot to STRIPE_SECRET_KEY — it rides on graph.secrets. + const graph = { + secrets: [{ serviceAddress: 'ingest', slot: 'stripeKey', name: 'STRIPE_SECRET_KEY' }], + }; + const ctx = { address: 'ingest', node, graph } as unknown as LowerContext; const provisioned: LoweredNode = { outputs: { projectId: 'shop-project#cloud-id' } }; - // buildConfig omits the defaultless secret; serialize still writes its pointer row. const config = { service: { port: 3000 }, inputs: {} }; const before = recorded.envVar.length; @@ -399,7 +402,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { ); const writes = recorded.envVar.slice(before).map(([, props]) => props); - // The secret's row holds the external NAME (a pointer), never a value. + // The pointer row holds the bound platform NAME, never a value. expect(writes).toContainEqual({ projectId: 'shop-project#cloud-id', key: 'COMPOSE_INGEST_STRIPEKEY', @@ -425,7 +428,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { entry: 'server.js', }, }); - const ctx = { address: 'auth3', node } as unknown as LowerContext; + const ctx = { address: 'auth3', node, graph: { secrets: [] } } as unknown as LowerContext; const provisioned: LoweredNode = { outputs: { projectId: 'shop-project#cloud-id' } }; const config = { service: { port: 3000 }, inputs: {} }; const before = recorded.envVar.length; @@ -460,7 +463,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { entry: 'server.js', }, }); - const ctx = { address: 'auth', node } as unknown as LowerContext; + const ctx = { address: 'auth', node, graph: { secrets: [] } } as unknown as LowerContext; const provisioned: LoweredNode = { outputs: { projectId: 'shop-project#cloud-id' } }; // A port other than the pack default: serialize must carry 8080 through, // not silently normalize it back to 3000. diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts index 8d01426c..d401aab1 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts @@ -1,22 +1,29 @@ import { describe, expect, test } from 'bun:test'; import type { ConfigDeclaration, Contract } from '@internal/core'; -import { configOf, envSecret, hydrateSync, isNode, number, param, string } from '@internal/core'; +import { + configOf, + hydrateSync, + isNode, + number, + param, + SecretBox, + secret, + string, +} from '@internal/core'; import { type } from 'arktype'; import { compute, postgres, postgresContract } from '../index.ts'; -import { configKey, deserialize, encode } from '../serializer.ts'; +import { configKey, deserialize, deserializeSecrets, encode, secretKey } from '../serializer.ts'; import { bootstrapService } from '../testing.ts'; function scalarDeclaration( owner: ConfigDeclaration['owner'], name: string, - opts: { secret?: boolean; optional?: boolean; default?: unknown; external?: string } = {}, + opts: { optional?: boolean; default?: unknown } = {}, ): ConfigDeclaration { return { owner, name, schema: { vendor: '@prisma/compose' }, - secret: opts.secret ?? false, - ...(opts.external !== undefined ? { external: opts.external } : {}), optional: opts.optional ?? false, default: opts.default, }; @@ -58,7 +65,7 @@ describe('postgres({ name })', () => { }); describe('postgres()', () => { - test('returns a branded dependency end requiring postgresContract, declaring { url: string, secret }', () => { + test('returns a branded dependency end requiring postgresContract, declaring { url: string }', () => { const end = postgres(); expect(isNode(end)).toBe(true); @@ -66,7 +73,7 @@ describe('postgres()', () => { expect(end.type).toBe('postgres'); expect(end.name).toBe('postgres'); expect(end.required).toBe(postgresContract); - expect(end.connection.params).toEqual({ url: string({ secret: true }) }); + expect(end.connection.params).toEqual({ url: string() }); }); test('the binding IS the typed config — hydrate is the identity on its values (ADR-0015)', () => { @@ -200,7 +207,6 @@ describe("the config serializer (shared by run() and /control's serialize)", () owner: { input: 'auth' }, name: 'url', schema: {}, - secret: false, optional: false, default: undefined, }; @@ -453,7 +459,7 @@ describe('compute().load()', () => { }); describe('the config pipeline over extension nodes', () => { - test('configOf is semantic — owner/name/type/secret, no platform keys', () => { + test('configOf is semantic — owner/name/type, no platform keys', () => { const app = compute({ name: 'test-service', deps: { @@ -463,7 +469,7 @@ describe('the config pipeline over extension nodes', () => { }); expect(configOf(app)).toEqual([ - scalarDeclaration({ input: 'db' }, 'url', { secret: true }), + scalarDeclaration({ input: 'db' }, 'url'), scalarDeclaration('service', 'port', { default: 3000 }), ]); expect(JSON.stringify(configOf(app))).not.toContain('DATABASE_URL'); @@ -537,102 +543,71 @@ describe('structured params + target-owned serialization (ADR-0018/0019)', () => }); }); -describe('secret pointer rows + double-lookup (ADR-0029)', () => { - const secretApp = (opts?: { optional?: boolean }) => - compute({ - name: 'ingest', - deps: {}, - params: { - stripeKey: - opts?.optional === true - ? envSecret('STRIPE_SECRET_KEY', { optional: true }) - : envSecret('STRIPE_SECRET_KEY'), - }, - build, - }); - - const stripeKeyDecl = () => { - const decl = configOf(secretApp()).find((d) => d.name === 'stripeKey'); - if (decl === undefined) throw new Error('expected a stripeKey declaration'); - return decl; - }; +describe('secret slots — pointer rows + boot double-lookup (ADR-0029)', () => { + const secretApp = () => + compute({ name: 'ingest', deps: {}, secrets: { stripeKey: secret() }, build }); - test('configOf reports the external platform name; the generated key is COMPOSE_-prefixed', () => { - expect(configOf(secretApp())).toContainEqual( - scalarDeclaration('service', 'stripeKey', { secret: true, external: 'STRIPE_SECRET_KEY' }), - ); - expect(configKey('', stripeKeyDecl())).toBe('COMPOSE_STRIPEKEY'); + test('secretKey derives COMPOSE__', () => { + expect(secretKey('', 'stripeKey')).toBe('COMPOSE_STRIPEKEY'); + expect(secretKey('ingest', 'stripeKey')).toBe('COMPOSE_INGEST_STRIPEKEY'); }); - test('the COMPOSE_ key holds the external NAME; the value lives only in that platform var', async () => { + test('the pointer key holds the platform NAME; deserializeSecrets double-looks-up the value', async () => { const app = secretApp(); await withEnv( - { - [configKey('', stripeKeyDecl())]: 'STRIPE_SECRET_KEY', - STRIPE_SECRET_KEY: 'sk_live_abc', - COMPOSE_PORT: '', - }, + { [secretKey('', 'stripeKey')]: 'STRIPE_SECRET_KEY', STRIPE_SECRET_KEY: 'sk_live_abc' }, () => { - expect(deserialize(app, '').service['stripeKey']).toBe('sk_live_abc'); + expect(deserializeSecrets(app, '')).toEqual({ stripeKey: 'sk_live_abc' }); }, ); }); - test('a required secret whose platform var is unset fails loudly, naming both keys', async () => { + test('a missing pointer row fails loudly', async () => { const app = secretApp(); - await withEnv( - { [configKey('', stripeKeyDecl())]: 'STRIPE_SECRET_KEY', COMPOSE_PORT: '' }, - () => { - expect(() => deserialize(app, '')).toThrow(/COMPOSE_STRIPEKEY.*STRIPE_SECRET_KEY/); - }, - ); + await withEnv({}, () => { + expect(() => deserializeSecrets(app, '')).toThrow(/missing secret pointer/); + }); }); - test('a required secret whose platform var is empty ("") fails loudly — empty is unresolved', async () => { + test('a missing platform var fails loudly, naming both keys', async () => { const app = secretApp(); - await withEnv( - { - [configKey('', stripeKeyDecl())]: 'STRIPE_SECRET_KEY', - STRIPE_SECRET_KEY: '', - COMPOSE_PORT: '', - }, - () => { - expect(() => deserialize(app, '')).toThrow(/STRIPE_SECRET_KEY/); - }, - ); + await withEnv({ [secretKey('', 'stripeKey')]: 'STRIPE_SECRET_KEY' }, () => { + expect(() => deserializeSecrets(app, '')).toThrow(/STRIPE_SECRET_KEY/); + }); }); - test('an optional secret whose platform var is unset resolves to undefined', async () => { - const app = secretApp({ optional: true }); + test('an empty platform var fails loudly — empty is unresolved', async () => { + const app = secretApp(); await withEnv( - { [configKey('', stripeKeyDecl())]: 'STRIPE_SECRET_KEY', COMPOSE_PORT: '' }, + { [secretKey('', 'stripeKey')]: 'STRIPE_SECRET_KEY', STRIPE_SECRET_KEY: '' }, () => { - expect(deserialize(app, '').service['stripeKey']).toBeUndefined(); + expect(() => deserializeSecrets(app, '')).toThrow(/unset or empty/); }, ); }); - test('a secret survives run() → stash → config(): the address-free re-read double-looks-up identically (the stash trap)', async () => { + test('secrets() returns a redacting SecretBox; it survives run() → stashSecrets → secrets() (the stash trap)', async () => { const app = secretApp(); - let cfg: unknown; + let box: SecretBox | undefined; await withEnv( { - // address-keyed pointer row + the user-provisioned external var: + // address-keyed pointer row + the user-provisioned platform var: COMPOSE_INGEST_STRIPEKEY: 'STRIPE_SECRET_KEY', STRIPE_SECRET_KEY: 'sk_live_trap', COMPOSE_INGEST_PORT: '', - // address-free keys stash (over)writes — tracked so withEnv restores them: + // address-free keys stashSecrets/stash (over)write — tracked so withEnv restores them: COMPOSE_STRIPEKEY: '', COMPOSE_PORT: '', }, () => app.run('ingest', async () => { - cfg = app.config(); + box = app.secrets().stripeKey; }), ); - // If stash re-emitted the resolved VALUE instead of the pointer, the - // address-free deserialize would read it as a pointer to a non-existent var - // and config() would have thrown — this asserts the pointer survives. - expect(cfg).toEqual({ port: 3000, stripeKey: 'sk_live_trap' }); + expect(box).toBeInstanceOf(SecretBox); + expect(box?.expose()).toBe('sk_live_trap'); + // Redacted everywhere but expose() — if stashSecrets re-emitted the VALUE + // instead of the POINTER, the address-free double-lookup would have thrown. + expect(String(box)).toBe('[REDACTED]'); }); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index 16ac8e7d..46c6945b 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -85,7 +85,7 @@ describe('invariant 2: authoring imports stay lean (core + pack)', () => { }); describe('invariant 4: environment touches are confined to the config serializer and the control factory', () => { - test("the process-env token appears only in serializer.ts (readParam's reads incl. the secret double-lookup, stash's one write), control.ts's prismaCloud(), and preflight.ts (shell token + fill-missing lookup) (the extension factory's env read, ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; ADR-0019 — PRISMA_PROJECT_ID + optional PRISMA_BRANCH_ID)", () => { + test("the process-env token appears only in serializer.ts (param read+stash, secret double-lookup+stash), control.ts's prismaCloud(), and preflight.ts (shell token + fill-missing lookup) (the extension factory's env read, ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; ADR-0019 — PRISMA_PROJECT_ID + optional PRISMA_BRANCH_ID)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); @@ -98,7 +98,7 @@ describe('invariant 4: environment touches are confined to the config serializer expect(hits.sort((a, b) => a.file.localeCompare(b.file))).toEqual([ { file: 'control.ts', count: 4 }, { file: 'preflight.ts', count: 2 }, - { file: 'serializer.ts', count: 4 }, + { file: 'serializer.ts', count: 6 }, ]); }); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts index 535b4e54..e05fbeea 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from 'bun:test'; -import { envSecret, Load, module } from '@internal/core'; +import { envSecret, Load, module, secret } from '@internal/core'; import type { ManagementApiClient } from '@internal/lowering'; import { compute } from '../index.ts'; import { runPreflight } from '../preflight.ts'; @@ -57,58 +57,35 @@ const fakeClient = (state: FakeState): ManagementApiClient => }, }) as unknown as ManagementApiClient; -const secretGraph = (opts?: { optional?: boolean }) => +const secretGraph = () => Load( - module('app', {}, (h) => { - h.provision( - compute({ - name: 'ingest', - deps: {}, - params: { - stripeKey: - opts?.optional === true - ? envSecret('STRIPE_SECRET_KEY', { optional: true }) - : envSecret('STRIPE_SECRET_KEY'), - }, - build, - }), - { id: 'ingest' }, - ); - return {}; + module('app', ({ provision }) => { + provision(compute({ name: 'ingest', deps: {}, secrets: { stripeKey: secret() }, build }), { + id: 'ingest', + secrets: { stripeKey: envSecret('STRIPE_SECRET_KEY') }, + }); }), ); const noSecretGraph = () => Load( - module('app', {}, (h) => { - h.provision(compute({ name: 'ingest', deps: {}, build }), { id: 'ingest' }); - return {}; + module('app', ({ provision }) => { + provision(compute({ name: 'ingest', deps: {}, build }), { id: 'ingest' }); }), ); -/** Two services binding the SAME external name — one optional (web), one required (ingest). */ +/** Two services binding the SAME platform name — the manifest dedups it. */ const sharedSecretGraph = () => Load( - module('app', {}, (h) => { - h.provision( - compute({ - name: 'web', - deps: {}, - params: { key: envSecret('STRIPE_SECRET_KEY', { optional: true }) }, - build, - }), - { id: 'web' }, - ); - h.provision( - compute({ - name: 'ingest', - deps: {}, - params: { key: envSecret('STRIPE_SECRET_KEY') }, - build, - }), - { id: 'ingest' }, - ); - return {}; + module('app', ({ provision }) => { + provision(compute({ name: 'web', deps: {}, secrets: { key: secret() }, build }), { + id: 'web', + secrets: { key: envSecret('STRIPE_SECRET_KEY') }, + }); + provision(compute({ name: 'ingest', deps: {}, secrets: { key: secret() }, build }), { + id: 'ingest', + secrets: { key: envSecret('STRIPE_SECRET_KEY') }, + }); }), ); @@ -260,22 +237,6 @@ describe('runPreflight — secret manifest verification (ADR-0029)', () => { expect(state.posts).toEqual([]); }); - test('an optional secret absent from both passes (no failure, no write)', async () => { - state.rows = []; - - await runPreflight( - { - graph: secretGraph({ optional: true }), - projectId: 'proj', - branchId: undefined, - stage: undefined, - }, - { client: fakeClient(state) }, - ); - - expect(state.posts).toEqual([]); - }); - test('a graph with no pointer secrets is a pass-through — no platform calls at all', async () => { await runPreflight( { graph: noSecretGraph(), projectId: 'proj', branchId: undefined, stage: undefined }, @@ -342,7 +303,7 @@ describe('runPreflight — secret manifest verification (ADR-0029)', () => { expect(posts).toEqual([]); // found present → no fill }); - test('a name declared optional by one service and required by another is attributed to the requiring service', async () => { + test('the same platform name bound by two services is checked once and named in the failure', async () => { state.rows = []; const error: unknown = await runPreflight( @@ -351,6 +312,9 @@ describe('runPreflight — secret manifest verification (ADR-0029)', () => { ).catch((e: unknown) => e); expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain('service "ingest"'); + expect((error as Error).message).toContain('STRIPE_SECRET_KEY'); + expect((error as Error).message).toMatch(/service "(web|ingest)"/); + // The shared name is deduped to a single platform existence check. + expect(state.gets).toHaveLength(1); }); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/prisma-next.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/prisma-next.test.ts index 8cc7f355..9741feef 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/prisma-next.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/prisma-next.test.ts @@ -103,7 +103,7 @@ describe('pnPostgres() factory shapes', () => { expect(dep.type).toBe('prisma-next'); expect(dep.required).toBe(widget); expect(Object.keys(dep.connection.params)).toEqual(['url']); - expect(dep.connection.params['url']).toEqual(string({ secret: true })); + expect(dep.connection.params['url']).toEqual(string()); }); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/compute.ts index 225aea49..f21523ea 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/compute.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/compute.ts @@ -6,11 +6,13 @@ import type { HydratedDeps, Params, RunnableServiceNode, + Secrets, + SecretValues, Values, } from '@internal/core'; -import { hydrateSync, number, service } from '@internal/core'; +import { hydrateSecrets, hydrateSync, number, service } from '@internal/core'; import { blindCast } from '@internal/foundation/casts'; -import { deserialize, stash } from './serializer.ts'; +import { deserialize, deserializeSecrets, stash, stashSecrets } from './serializer.ts'; const reservedParams = { port: number({ default: 3000 }) } as const; type ReservedParams = typeof reservedParams; @@ -38,13 +40,15 @@ export const compute = < D extends Deps, P extends Params = Record, E extends Expose = Record, + S extends Secrets = Record, >(def: { name: string; deps: D; params?: P; + secrets?: S; build: BuildAdapter; expose?: E; -}): RunnableServiceNode => { +}): RunnableServiceNode => { const userParams = def.params ?? blindCast({}); // load() merges deps and service params into one object; a dep or a user @@ -67,12 +71,13 @@ export const compute = < ...userParams, ...reservedParams, }); - const node = service({ + const node = service({ name: def.name, extension: '@prisma/compose-prisma-cloud', type: 'compute', inputs: def.deps, params, + ...(def.secrets !== undefined ? { secrets: def.secrets } : {}), build: def.build, ...(def.expose !== undefined ? { expose: def.expose } : {}), }); @@ -81,6 +86,7 @@ export const compute = < let resolved: Config | undefined; let loadedDeps: HydratedDeps | undefined; let loadedParams: Values

| undefined; + let loadedSecrets: SecretValues | undefined; function processConfig(): Config { if (resolved === undefined) resolved = deserialize(node, ''); return resolved; @@ -90,6 +96,9 @@ export const compute = < ...node, async run(address: string, boot: () => Promise) { stash(node, deserialize(node, address)); + // Re-emit the secret POINTERS address-free too, so secrets() double-looks-up + // the same way with no address (the value stays only in its platform var). + stashSecrets(node, address); return boot(); }, load() { @@ -110,11 +119,21 @@ export const compute = < } return loadedParams; }, + secrets() { + if (loadedSecrets === undefined) { + // Double-lookup (address-free) → resolved strings → SecretBoxes (core). + loadedSecrets = blindCast< + SecretValues, + 'hydrateSecrets boxes one string per declared slot; for this node the slots are S' + >(hydrateSecrets(node, deserializeSecrets(node, ''))); + } + return loadedSecrets; + }, }; return Object.freeze( blindCast< - RunnableServiceNode, - "the spread copies node's own enumerable data (including the Symbol.for brand) and adds run/load — exactly RunnableServiceNode's shape" + RunnableServiceNode, + "the spread copies node's own enumerable data (including the Symbol.for brand) and adds run/load/config/secrets — exactly RunnableServiceNode's shape" >(runnable), ); }; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts index 7b628897..e2489609 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts @@ -4,7 +4,7 @@ import type { ServiceNode } from '@internal/core'; import type { NodeDescriptor } from '@internal/core/config'; import * as Prisma from '@internal/lowering'; import * as Effect from 'effect/Effect'; -import { configKey, paramEntries, storedForm } from '../serializer.ts'; +import { configKey, encode, paramEntries, secretPointerRows } from '../serializer.ts'; import { DEFAULT_REGION, projectIdOf, type ResolvedCloudOptions, validateName } from './shared.ts'; export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor { @@ -26,29 +26,46 @@ export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor { }; }), - // storedForm keys each row: a service-own literal is JSON-stringified, a - // dependency-input provisioning ref passes through untouched (keeping its - // ordering edge), and a secret bound to a platform name writes only that - // name (a pointer), never a value (ADR-0029). - serialize: ({ address, node }, provisioned, config) => + // Two channels of rows: PARAMS (service-own literals JSON-encoded; dependency + // provisioning refs passed through, keeping their ordering edge) and SECRETS + // (a POINTER row per slot holding the bound platform NAME, never a value — + // ADR-0029). The class/branch scope is identical for both. + serialize: ({ address, node, graph }, provisioned, config) => Effect.gen(function* () { + const cls = o.branchId ? ('preview' as const) : ('production' as const); + const branch = o.branchId !== undefined ? { branchId: o.branchId } : {}; + const projectId = projectIdOf(provisioned); + const svc = node as ServiceNode; const records = []; - for (const d of paramEntries(node as ServiceNode)) { + + for (const d of paramEntries(svc)) { const value = d.owner === 'service' ? config.service[d.name] : config.inputs[d.owner.input]?.[d.name]; const key = configKey(address, d); records.push( yield* Prisma.EnvironmentVariable(`${key}-var`, { - projectId: projectIdOf(provisioned), + projectId, key, - // A secret bound to a platform name writes only that NAME (a - // pointer), never a value (ADR-0029); storedForm holds the rule. - value: storedForm(d, value), - class: o.branchId ? 'preview' : 'production', - ...(o.branchId !== undefined ? { branchId: o.branchId } : {}), + value: encode(d.owner, value), + class: cls, + ...branch, }), ); } + + for (const { key, name } of secretPointerRows(svc, address, graph.secrets)) { + records.push( + yield* Prisma.EnvironmentVariable(`${key}-var`, { + projectId, + key, + // The pointer: the platform env-var NAME the root bound the slot to. + value: name, + class: cls, + ...branch, + }), + ); + } + // Carries the resolved port to deploy() via serialize's outputs; falls back to 3000 if unset. const port = typeof config.service['port'] === 'number' ? config.service['port'] : 3000; return { outputs: { environment: records, port } }; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/postgres.ts b/packages/1-prisma-cloud/1-extensions/target/src/postgres.ts index 4cb9ec59..6faeb6bf 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/postgres.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/postgres.ts @@ -48,7 +48,7 @@ export function postgres(opts?: { return dependency({ type: 'postgres', connection: { - params: { url: string({ secret: true }) }, + params: { url: string() }, // The binding IS the typed config: hydrate is the identity on its values // ({ url: string } = PostgresConfig). The app constructs its own client. hydrate: (v): PostgresConfig => v, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts index c32803a3..6af28c01 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts @@ -1,6 +1,7 @@ /** - * Deploy preflight (ADR-0029): before Alchemy runs, verify every pointer secret - * in the app's provision manifest exists on Prisma Cloud for the target stage. + * Deploy preflight (ADR-0029): before Alchemy runs, verify every secret binding + * in the app's provision manifest (each a platform env-var NAME) exists on Prisma + * Cloud for the target stage. * A name absent on the platform but present in the deploy shell is provisioned * via a direct Management API POST — NEVER an Alchemy resource, so the value * never lands in hosted deploy state. A name absent from both fails the deploy, @@ -90,11 +91,7 @@ async function existsOnPlatform( client, cursor === null ? { projectId, class: cls, key } : { projectId, class: cls, key, cursor }, ); - if (res.error !== undefined) { - throw new Error( - `deploy preflight: Prisma Management API error listing "${key}": ${JSON.stringify(res.error)}.`, - ); - } + if (res.error !== undefined) throw listFailedError(key, res.error); const page = res.data; if (page === undefined) return false; if (page.data.some(visible)) return true; @@ -126,25 +123,36 @@ async function fillMissing( }, }); if (res.error !== undefined && res.response.status !== 409) { - throw new Error( - `deploy preflight: failed to provision "${key}" from the deploy shell: ${JSON.stringify(res.error)}.`, - ); + throw fillFailedError(key, res.error); } } interface MissingSecret { - readonly external: string; + readonly name: string; readonly serviceAddress: string; } +// ——— Preflight errors (centralized; ADR-0029). Values are never logged. ——— + +const tokenRequiredError = (): Error => + new Error('environment variable PRISMA_SERVICE_TOKEN is required for deploy preflight.'); + +const listFailedError = (key: string, error: unknown): Error => + new Error( + `deploy preflight: Prisma Management API error listing "${key}": ${JSON.stringify(error)}.`, + ); + +const fillFailedError = (key: string, error: unknown): Error => + new Error( + `deploy preflight: failed to provision "${key}" from the deploy shell: ${JSON.stringify(error)}.`, + ); + function missingError(missing: readonly MissingSecret[], input: PreflightInput): Error { const scope = input.branchId === undefined ? 'the production class (project-level template)' : `the preview class of stage "${input.stage ?? input.branchId}" (branch override or template)`; - const lines = missing.map( - (m) => ` - ${m.external} (required by service "${m.serviceAddress}")`, - ); + const lines = missing.map((m) => ` - ${m.name} (required by service "${m.serviceAddress}")`); return new Error( `Deploy preflight failed — ${missing.length} secret env var(s) are not provisioned on Prisma ` + `Cloud for ${scope}, and are absent from the deploy shell:\n${lines.join('\n')}\n\n` + @@ -154,9 +162,7 @@ function missingError(missing: readonly MissingSecret[], input: PreflightInput): } async function managementClient(): Promise { - if ((process.env['PRISMA_SERVICE_TOKEN'] ?? '').length === 0) { - throw new Error('environment variable PRISMA_SERVICE_TOKEN is required for deploy preflight.'); - } + if ((process.env['PRISMA_SERVICE_TOKEN'] ?? '').length === 0) throw tokenRequiredError(); return Effect.runPromise( Effect.gen(function* () { return yield* ManagementClient; @@ -178,40 +184,25 @@ export async function runPreflight( const manifest = provisionManifest(input.graph); if (manifest.length === 0) return; - // One check per external name; a name is required if ANY binding of it is required. - const names = new Map(); - for (const entry of manifest) { - const prev = names.get(entry.external); - if (prev === undefined) { - names.set(entry.external, { - external: entry.external, - serviceAddress: entry.serviceAddress, - optional: entry.optional, - }); - } else if (!entry.optional) { - // Upgrade to required AND carry this (required) binding's serviceAddress, - // so a missing-secret error never attributes it to a service that only - // declared it optional. - names.set(entry.external, { - external: entry.external, - serviceAddress: entry.serviceAddress, - optional: false, - }); + // One check per platform NAME (many slots/services may bind the same one). + // Every wired secret is required — the forwarding model has no optional slot. + const names = new Map(); + for (const binding of manifest) { + if (!names.has(binding.name)) { + names.set(binding.name, { name: binding.name, serviceAddress: binding.serviceAddress }); } } const client = deps?.client ?? (await managementClient()); const missing: MissingSecret[] = []; for (const meta of names.values()) { - if (await existsOnPlatform(client, input.projectId, input.branchId, meta.external)) continue; - const shellValue = process.env[meta.external]; + if (await existsOnPlatform(client, input.projectId, input.branchId, meta.name)) continue; + const shellValue = process.env[meta.name]; if (shellValue !== undefined && shellValue.length > 0) { - await fillMissing(client, input, meta.external, shellValue); + await fillMissing(client, input, meta.name, shellValue); continue; } - if (!meta.optional) { - missing.push({ external: meta.external, serviceAddress: meta.serviceAddress }); - } + missing.push(meta); } if (missing.length > 0) throw missingError(missing, input); } diff --git a/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts b/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts index 7cd55498..fe38b545 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts @@ -121,7 +121,7 @@ export function pnPostgres( return dependency({ type: 'prisma-next', connection: { - params: { url: string({ secret: true }) }, + params: { url: string() }, hydrate: ({ url }) => buildClient(contract, url), }, required: contract, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts index c862104f..041277ae 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts @@ -20,7 +20,7 @@ * input's params are provisioning refs at deploy (resolved strings at boot) * and pass through untouched, so a ref keeps carrying its ordering edge. */ -import type { Config, ConfigParam, Params, ServiceNode } from '@internal/core'; +import type { Config, ConfigParam, Params, SecretBinding, ServiceNode } from '@internal/core'; import { blindCast } from '@internal/foundation/casts'; import type { StandardSchemaV1 } from '@standard-schema/spec'; @@ -77,27 +77,6 @@ export const configKey = ( return ['COMPOSE', ...segments, ...owner, d.name].join('_').toUpperCase(); }; -/** - * A secret bound to a platform env-var NAME (ADR-0029): its config row holds - * that name as a pointer, and the value lives ONLY in the external platform var. - * A `secret` param WITHOUT `external` — e.g. a database url valued by its - * producer — is not a pointer; it carries its value the ordinary way. - */ -function isPointerParam(param: ConfigParam): param is ConfigParam & { external: string } { - return param.secret === true && param.external !== undefined; -} - -/** - * The string stored in a param's config row — shared by deploy `serialize` and - * boot `stash` so writer and reader cannot drift. A pointer secret stores its - * external platform-var NAME (never a value); every other param encodes its - * value. - */ -export function storedForm(d: ParamEntry, value: unknown): string { - if (isPointerParam(d.param)) return d.param.external; - return encode(d.owner, value); -} - /** * Typed value → its stored string. Service-own literals are JSON-encoded; a * dependency-input value is a provisioning ref at deploy (and a resolved @@ -130,44 +109,25 @@ function coerce(raw: string | undefined, d: ParamEntry, key: string): unknown { throw new Error(`missing required config param "${d.name}" (env ${key})`); } try { - // A pointer secret's raw value is the plain platform string (already the - // value, not JSON) — validate it directly; everything else decodes by owner. - const decoded = isPointerParam(d.param) ? raw : decode(d.owner, raw); - return standardValidateSync(d.param.schema, decoded); + return standardValidateSync(d.param.schema, decode(d.owner, raw)); } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); throw new Error(`invalid value for config param "${d.name}" (env ${key}): ${message}`); } } -/** - * Reads a param's stored form from the environment. A non-secret (or a secret - * with no platform binding) reads its key directly. A pointer secret - * double-looks-up (ADR-0029): the COMPOSE_ key holds the external platform-var - * NAME, and the value lives ONLY in the external platform var. The label names both - * keys so a missing required secret fails loudly and unambiguously. - */ -function readParam(d: ParamEntry, key: string): { raw: string | undefined; label: string } { - if (isPointerParam(d.param)) { - const stored = process.env[key]; - const name = stored !== undefined && stored !== '' ? stored : d.param.external; - return { raw: process.env[name], label: `${key} → ${name}` }; - } - return { raw: process.env[key], label: key }; -} - /** * Boot: read each declared param from env by its key, reverse the param's own * serialization (missing/invalid fails loudly), assemble the typed Config. - * The one place in the extension that reads the platform environment. + * Secrets ride a separate channel (deserializeSecrets), not this one. */ export const deserialize = (node: ServiceNode, address: string): Config => { const service: Record = {}; const inputs: Record> = {}; for (const d of paramEntries(node)) { - const { raw, label } = readParam(d, configKey(address, d)); - const value = coerce(raw, d, label); + const key = configKey(address, d); + const value = coerce(process.env[key], d, key); if (d.owner === 'service') { service[d.name] = value; } else { @@ -195,10 +155,90 @@ export const stash = (node: ServiceNode, config: Config): void => { const value = d.owner === 'service' ? config.service[d.name] : config.inputs[d.owner.input]?.[d.name]; if (value === undefined) continue; - // A pointer secret re-emits its external-name POINTER (never the resolved - // value), so the address-free deserialize double-looks-up identically — the - // value stays only in the external platform var. storedForm holds this rule. - process.env[configKey('', d)] = storedForm(d, value); + process.env[configKey('', d)] = encode(d.owner, value); + } +}; + +// ——— Secret channel (ADR-0029): a POINTER row per secret slot, boot double-lookup. +// +// A secret is NOT a param — it is its own slot on the node (`node.secretSlots`). +// Deploy writes COMPOSE__ = (the name the +// root bound it to, from `graph.secrets`); the value never enters this row or +// deploy state. Boot reads that pointer, then the platform var it names, and +// wraps the result in a SecretBox (core's `hydrateSecrets`). Writer (deploy) and +// reader (boot) share `secretKey`, so they cannot drift. + +/** The pointer-row key for a secret slot: COMPOSE__ (secrets are service-level). */ +export const secretKey = (address: string, slot: string): string => + configKey(address, { owner: 'service', name: slot }); + +/** One secret pointer row to write at deploy: the slot's key mapped to the bound platform NAME. */ +export interface SecretRow { + readonly key: string; + readonly name: string; +} + +/** + * Deploy: the pointer rows for a node's secret slots — each slot's key mapped to + * the platform NAME the root bound it to (looked up in `graph.secrets`). Never a + * value. A declared slot with no binding is a Load-invariant violation (Load + * binds every slot), surfaced loudly here rather than written as a blank row. + */ +export function secretPointerRows( + node: ServiceNode, + address: string, + bindings: readonly SecretBinding[], +): readonly SecretRow[] { + const rows: SecretRow[] = []; + for (const slot of Object.keys(node.secretSlots)) { + const binding = bindings.find((b) => b.serviceAddress === address && b.slot === slot); + if (binding === undefined) { + throw new Error( + `secret slot "${slot}" of "${address}" has no bound platform name — Load should have bound it (ADR-0029).`, + ); + } + rows.push({ key: secretKey(address, slot), name: binding.name }); + } + return rows; +} + +/** + * Boot: resolve every secret slot to its value by double-lookup — read the + * pointer key (the platform NAME), then read that platform var. A missing + * pointer or a missing/empty platform value is a loud failure naming both keys. + * Returns a plain Record for core's `hydrateSecrets` to box. + */ +export const deserializeSecrets = (node: ServiceNode, address: string): Record => { + const values: Record = {}; + for (const slot of Object.keys(node.secretSlots)) { + const key = secretKey(address, slot); + const name = process.env[key]; + if (name === undefined || name === '') { + throw new Error( + `missing secret pointer for slot "${slot}" (env ${key}) — the deploy did not write it.`, + ); + } + const value = process.env[name]; + if (value === undefined || value === '') { + throw new Error( + `secret "${slot}" is not provisioned (env ${key} → ${name}): the platform var "${name}" is unset or empty.`, + ); + } + values[slot] = value; + } + return values; +}; + +/** + * run()'s setup step for secrets: re-emit each slot's pointer NAME under its + * address-free key, so the address-free `deserializeSecrets` double-looks-up + * identically. Never the value — the value stays only in the platform var. + */ +export const stashSecrets = (node: ServiceNode, address: string): void => { + for (const slot of Object.keys(node.secretSlots)) { + const name = process.env[secretKey(address, slot)]; + if (name === undefined) continue; + process.env[secretKey('', slot)] = name; } }; From 19aa3b388c2ac8a795110cde8464d3495902a8b1 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 17:38:49 +0200 Subject: [PATCH 13/19] fix(core): reject param name colliding with a secret slot name Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-core/core/src/__tests__/node.test.ts | 36 ++++++++++++++++++- packages/0-framework/1-core/core/src/node.ts | 13 +++++++ .../target/src/__tests__/extension.test.ts | 12 +++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/0-framework/1-core/core/src/__tests__/node.test.ts b/packages/0-framework/1-core/core/src/__tests__/node.test.ts index ad68bf2a..51e432cb 100644 --- a/packages/0-framework/1-core/core/src/__tests__/node.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/node.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { number, string } from '../config.ts'; import type { Contract } from '../contract.ts'; import { Load } from '../graph.ts'; -import { dependency, isNode, module, resource, service } from '../node.ts'; +import { dependency, isNode, module, resource, secret, service } from '../node.ts'; import { conn, providerContract } from './helpers.ts'; const fakeContract = (cmp: Cmp): Contract<'rpc', Cmp> => ({ @@ -264,6 +264,40 @@ describe('service()', () => { ).toThrow(/param name "max_conns" may not contain "_"/); }); + test('rejects a secret slot name that collides with a service param name', () => { + expect(() => + service({ + name: 'hello', + extension: 'test/pack', + type: 'fake/app', + inputs: {}, + params: { token: string() }, + secrets: { token: secret() }, + build, + }), + ).toThrow(/secret slot "token" collides with a param of the same name/); + }); + + test('a secret slot name may match a dependency input name — their config keys never collide', () => { + const node = service({ + name: 'hello', + extension: 'test/pack', + type: 'fake/app', + inputs: { + token: dependency({ + name: 'token', + type: 'fake/rpc', + connection: conn({ url: string() }, () => ({})), + }), + }, + params: {}, + secrets: { token: secret() }, + build, + }); + expect(Object.keys(node.secretSlots)).toEqual(['token']); + expect(Object.keys(node.inputs)).toEqual(['token']); + }); + test('expose is absent by default', () => { const node = service({ name: 'hello', diff --git a/packages/0-framework/1-core/core/src/node.ts b/packages/0-framework/1-core/core/src/node.ts index f5a201bd..8627820a 100644 --- a/packages/0-framework/1-core/core/src/node.ts +++ b/packages/0-framework/1-core/core/src/node.ts @@ -494,6 +494,19 @@ export function service< requireNoUnderscoreNames(Object.keys(def.inputs), 'input', 'service'); requireNoUnderscoreNames(Object.keys(def.params), 'param', 'service'); requireNoUnderscoreNames(Object.keys(def.secrets ?? {}), 'secret', 'service'); + // A secret slot and a service-OWN param of the same name derive the SAME + // config key (COMPOSE__), so they'd write two rows to one key. + // A same-named dependency is fine — its keys carry the input+param segments + // (COMPOSE___), which never collide — matching how a dep + // and a param may already share a name (ADR-0021). + for (const slot of Object.keys(def.secrets ?? {})) { + if (Object.hasOwn(def.params, slot)) { + throw new Error( + `service() secret slot "${slot}" collides with a param of the same name — a secret slot and ` + + `a service param derive the same config key (COMPOSE__${slot.toUpperCase()}); rename one.`, + ); + } + } return Object.freeze({ [NODE]: true as const, kind: 'service' as const, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts index d401aab1..e364bbaa 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts @@ -130,6 +130,18 @@ describe('compute()', () => { expect(deps).toEqual({ db: { url: 'postgres://fake' } }); }); + + test('rejects a secret slot name that collides with a param name (same config key)', () => { + expect(() => + compute({ + name: 'test-service', + deps: {}, + params: { token: string() }, + secrets: { token: secret() }, + build, + }), + ).toThrow(/secret slot "token" collides with a param of the same name/); + }); }); describe('compute({ expose })', () => { From ef368f03dc0db24a3f9ed5cb42aacfef86fd6a13 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 17:44:04 +0200 Subject: [PATCH 14/19] test(e2e): storefront-auth forwards a secret from root to auth module Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/e2e-deploy.yml | 2 +- examples/storefront-auth/module.ts | 9 ++++++-- .../modules/auth/src/module.ts | 23 ++++++++++++++----- .../modules/auth/src/server.ts | 19 +++++++-------- .../modules/auth/src/service.ts | 12 +++++----- 5 files changed, 41 insertions(+), 24 deletions(-) diff --git a/.github/workflows/e2e-deploy.yml b/.github/workflows/e2e-deploy.yml index 081adb92..14247779 100644 --- a/.github/workflows/e2e-deploy.yml +++ b/.github/workflows/e2e-deploy.yml @@ -29,7 +29,7 @@ jobs: env: PRISMA_SERVICE_TOKEN: ${{ secrets.PRISMA_SERVICE_TOKEN }} PRISMA_WORKSPACE_ID: ${{ vars.PRISMA_WORKSPACE_ID }} - # The auth service declares envSecret('AUTH_SIGNING_SECRET') (ADR-0029). + # The root binds the auth module's secret need to AUTH_SIGNING_SECRET (ADR-0029). # This non-sensitive test marker in the runner env is what deploy preflight # fill-missing provisions onto the ephemeral per-run stack — the single-step # CI path (runner env -> direct API POST -> platform). It must match the diff --git a/examples/storefront-auth/module.ts b/examples/storefront-auth/module.ts index f5f81862..1d83f312 100644 --- a/examples/storefront-auth/module.ts +++ b/examples/storefront-auth/module.ts @@ -1,4 +1,4 @@ -import { module } from '@prisma/compose'; +import { envSecret, module } from '@prisma/compose'; import authModule from '@storefront-auth/auth'; import storefrontService from '@storefront-auth/storefront'; @@ -10,6 +10,11 @@ import storefrontService from '@storefront-auth/storefront'; * that contract. */ export default module('storefront-auth', ({ provision }) => { - const auth = provision(authModule); + // The ROOT binds the auth module's secret need to the platform env var + // AUTH_SIGNING_SECRET (ADR-0029); preflight fill-missing provisions it from + // the deploy shell (the CI runner env). + const auth = provision(authModule, { + secrets: { signingKey: envSecret('AUTH_SIGNING_SECRET') }, + }); provision(storefrontService, { deps: { auth: auth.rpc } }); }); diff --git a/examples/storefront-auth/modules/auth/src/module.ts b/examples/storefront-auth/modules/auth/src/module.ts index b305b9c0..e39801fb 100644 --- a/examples/storefront-auth/modules/auth/src/module.ts +++ b/examples/storefront-auth/modules/auth/src/module.ts @@ -14,13 +14,24 @@ * The auth service keeps an explicit "service" id: its own name is "auth", the * same as this enclosing module, so a defaulted id would read as "auth.auth". */ -import { module } from '@prisma/compose'; +import { module, secret } from '@prisma/compose'; import { postgres } from '@prisma/compose-prisma-cloud'; import { authContract } from './contract.ts'; import authService from './service.ts'; -export default module('auth', { expose: { rpc: authContract } }, ({ provision }) => { - const db = provision(postgres({ name: 'database' })); - const service = provision(authService, { id: 'service', deps: { db } }); - return { rpc: service.rpc }; -}); +export default module( + 'auth', + // Declare the secret NEED as a forwardable module input; the root binds it to + // a platform name. This module never learns the name (ADR-0029) — that is the + // point of the forwarding model. + { secrets: { signingKey: secret() }, expose: { rpc: authContract } }, + ({ secrets, provision }) => { + const db = provision(postgres({ name: 'database' })); + const service = provision(authService, { + id: 'service', + deps: { db }, + secrets: { signingKey: secrets.signingKey }, + }); + return { rpc: service.rpc }; + }, +); diff --git a/examples/storefront-auth/modules/auth/src/server.ts b/examples/storefront-auth/modules/auth/src/server.ts index 14667846..3358bab9 100644 --- a/examples/storefront-auth/modules/auth/src/server.ts +++ b/examples/storefront-auth/modules/auth/src/server.ts @@ -8,15 +8,16 @@ import { SQL } from 'bun'; import service from './service.ts'; const { db } = service.load(); // db: PostgresConfig — the app owns its client -const { port, signingSecret } = service.config(); // config params are read separately from deps (ADR-0021) +const { port } = service.config(); // config params are read separately from deps (ADR-0021) +const { signingKey } = service.secrets(); // signingKey: SecretBox — redacts everywhere but expose() -// The E2E's KNOWN test marker for AUTH_SIGNING_SECRET (matched by the value the -// deploy provisions — see .github/workflows/e2e-deploy.yml and -// scripts/e2e-verify.sh). `secretCheck` below proves the secret round-tripped -// through the platform (pointer row -> boot double-lookup -> injected value) by -// comparing it to this marker and returning ONLY a boolean — the raw secret -// value is never exposed. This constant is a non-sensitive demonstration -// marker, not a real credential. +// The E2E's KNOWN test marker for the secret the root binds to AUTH_SIGNING_SECRET +// (matched by the value the deploy provisions — see .github/workflows/e2e-deploy.yml +// and scripts/e2e-verify.sh). `secretCheck` below proves the secret round-tripped +// (root envSecret -> pointer row -> boot double-lookup -> SecretBox) by comparing +// `signingKey.expose()` to this marker and returning ONLY a boolean. `.expose()` is +// the sole reader; the value is never rendered or logged (SecretBox redacts). This +// constant is a non-sensitive demonstration marker, not a real credential. const EXPECTED_SIGNING_SECRET = 'sk_test_ci_storefront_auth'; // The app constructs its own client from the binding (ADR-0015). Module-scoped, @@ -47,7 +48,7 @@ const handler = serve(service, { }, // True iff the injected secret matches the expected marker — proof it was // provisioned and double-looked-up, without ever returning the value. - secretCheck: async () => ({ ok: signingSecret === EXPECTED_SIGNING_SECRET }), + secretCheck: async () => ({ ok: signingKey.expose() === EXPECTED_SIGNING_SECRET }), }, }); export default handler; diff --git a/examples/storefront-auth/modules/auth/src/service.ts b/examples/storefront-auth/modules/auth/src/service.ts index ad889a64..f5861c70 100644 --- a/examples/storefront-auth/modules/auth/src/service.ts +++ b/examples/storefront-auth/modules/auth/src/service.ts @@ -1,4 +1,4 @@ -import { envSecret } from '@prisma/compose'; +import { secret } from '@prisma/compose'; import node from '@prisma/compose/node'; import { compute, postgres } from '@prisma/compose-prisma-cloud'; import { authContract } from './contract.ts'; @@ -11,11 +11,11 @@ export default compute({ deps: { db: postgres(), }, - // A secret bound to the platform env var `AUTH_SIGNING_SECRET` (ADR-0029): - // the framework carries only the NAME; the value is provisioned out-of-band - // (in CI, preflight fill-missing provisions it from the runner env). - params: { - signingSecret: envSecret('AUTH_SIGNING_SECRET'), + // A secret NEED (ADR-0029) — nameless here. The root binds it to a platform + // env-var name via `envSecret`, and the auth module forwards it in; this + // service never knows the name. Read via `secrets().signingKey.expose()`. + secrets: { + signingKey: secret(), }, build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), expose: { rpc: authContract }, From 6b95702918ba9bc104be52a9f1381a6eab7a99d7 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 17:50:13 +0200 Subject: [PATCH 15/19] test(storefront-auth): pin secret forwarding in a Load test Signed-off-by: willbot Signed-off-by: Will Madden --- examples/storefront-auth/module.test.ts | 15 +++++++++++++++ examples/storefront-auth/package.json | 3 ++- examples/storefront-auth/tsconfig.json | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 examples/storefront-auth/module.test.ts diff --git a/examples/storefront-auth/module.test.ts b/examples/storefront-auth/module.test.ts new file mode 100644 index 00000000..576df52b --- /dev/null +++ b/examples/storefront-auth/module.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'bun:test'; +import { Load } from '@prisma/compose'; +import root from './module.ts'; + +describe('storefront-auth root graph', () => { + test('the auth secret need forwards from the root binding down to the inner service', () => { + // Loads the REAL app graph: dropping the module→service forward, renaming + // the slot, or removing the root envSecret binding all break this — none of + // which typecheck catches, and the only other graph-Loading gate is CI E2E. + const graph = Load(root); + expect(graph.secrets).toEqual([ + { serviceAddress: 'auth.service', slot: 'signingKey', name: 'AUTH_SIGNING_SECRET' }, + ]); + }); +}); diff --git a/examples/storefront-auth/package.json b/examples/storefront-auth/package.json index ca82b532..de2b0c9c 100644 --- a/examples/storefront-auth/package.json +++ b/examples/storefront-auth/package.json @@ -7,7 +7,8 @@ "build": "echo 'nothing to build for this package itself \u2014 @storefront-auth/auth and @storefront-auth/storefront build via turbo ^build, from the workspace deps below'", "typecheck": "tsc --noEmit", "deploy": "pnpm turbo run build --filter @prisma/example-storefront-auth... && ( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose deploy module.ts ${STOREFRONT_STACK_NAME:+--name \"$STOREFRONT_STACK_NAME\"} )", - "destroy": "( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose destroy module.ts --production ${STOREFRONT_STACK_NAME:+--name \"$STOREFRONT_STACK_NAME\"} )" + "destroy": "( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose destroy module.ts --production ${STOREFRONT_STACK_NAME:+--name \"$STOREFRONT_STACK_NAME\"} )", + "test": "bun test module.test.ts" }, "dependencies": { "@effect/platform-bun": "4.0.0-beta.92", diff --git a/examples/storefront-auth/tsconfig.json b/examples/storefront-auth/tsconfig.json index e09a8d21..15732ebf 100644 --- a/examples/storefront-auth/tsconfig.json +++ b/examples/storefront-auth/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["bun"] }, - "include": ["module.ts"] + "include": ["module.ts", "module.test.ts"] } From da645060360020cce0df539807fb45a7a87eca14 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 17:54:51 +0200 Subject: [PATCH 16/19] =?UTF-8?q?docs:=20rewrite=20ADR-0029=20=E2=80=94=20?= =?UTF-8?q?secrets=20are=20a=20forwardable=20slot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/config-params.md | 105 ++++---- ...ADR-0029-secrets-are-a-forwardable-slot.md | 94 +++++++ .../ADR-0029-secrets-are-env-named-params.md | 255 ------------------ docs/design/90-decisions/README.md | 2 +- 4 files changed, 150 insertions(+), 306 deletions(-) create mode 100644 docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md delete mode 100644 docs/design/90-decisions/ADR-0029-secrets-are-env-named-params.md diff --git a/docs/design/10-domains/config-params.md b/docs/design/10-domains/config-params.md index 9ef86170..a273663f 100644 --- a/docs/design/10-domains/config-params.md +++ b/docs/design/10-domains/config-params.md @@ -8,8 +8,8 @@ read back at boot. Rests on deploy target owns serialization), [ADR-0021](../90-decisions/ADR-0021-params-are-read-through-config-not-load.md) (params are read through `config()`), and -[ADR-0029](../90-decisions/ADR-0029-secrets-are-env-named-params.md) (a secret -param is bound to an explicit platform env-var name). +[ADR-0029](../90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md) (a secret +is a distinct forwardable slot, read through `secrets()`). ## The problem in one example @@ -48,9 +48,9 @@ compute({ }); ``` -The facets are `default` (used when no value is stored), `secret` (redacted in -introspection, placed securely by the target), and `optional`. They describe how -a value is *handled*, not what it *is*. `string()`/`number()` are one-word helpers +The facets are `default` (used when no value is stored) and `optional`. They +describe how a value is *handled*, not what it *is*. (A secret is not a param +facet — it is its own slot, see § Secrets.) `string()`/`number()` are one-word helpers for the common scalars; `param(schema)` wraps any other schema. There is no framework enum of permitted types. @@ -141,66 +141,70 @@ Job[] → Config (structured) → target serialize (its medium) → stored confi ## Secrets -A secret is a param whose value the user provisions on the platform, never the -framework. `envSecret` declares one, binding it to the platform env-var name -that holds the value: +A secret is **not** a param — it is its own forwardable slot (ADR-0029). The +value is provisioned out-of-band on the platform; the framework carries only the +NAME. A service declares a nameless *need* with `secret()`; the root binds it to +a platform env-var with `envSecret('NAME')` and forwards it in; it reads back +through a third accessor, `secrets()`, as a redacting `SecretBox`: ```ts -compute({ - name: 'ingest', - params: { stripeKey: envSecret('STRIPE_SECRET_KEY') }, - // ... -}); +// A service/module declares the need — no platform name here: +compute({ name: 'auth', secrets: { signingKey: secret() }, build: … }); + +// A module forwards its need down to an inner service (ctx.secrets): +module('auth', { secrets: { signingKey: secret() }, expose: … }, ({ secrets, provision }) => + provision(inner, { secrets: { signingKey: secrets.signingKey } }), +); + +// Only the ROOT names the platform variable: +provision(auth, { secrets: { signingKey: envSecret('AUTH_SIGNING_KEY') } }); + +// Read at the point of use — expose() is the sole reader: +const { signingKey } = service.secrets(); // SecretBox +signingKey.expose(); // the one door to the value ``` -`envSecret(name)` is `{ schema: , secret: true, external: name }`. -`secret` forbids `default` — a fallback value would let a missing secret pass -silently and would leak into introspection, which is exactly what `secret` -exists to prevent. `optional` is still allowed. +`secrets()` sits beside `load()`/`config()` (ADR-0021). The `SecretBox` redacts +under `toString`/`toJSON`/`valueOf`/`inspect`, so a stray log or serialization +prints `[REDACTED]`; only `expose()` returns the value. The round trip carries only the name, never the value: -1. **Declare.** A leaf binds its own secret param with `envSecret`. A param - that is `secret: true` with no `external` name has no value source and - fails at deploy build — either bind it directly or wire it from a - producer's own bound param, the same as any other dependency value. -2. **Manifest.** `configOf` reports every param's `external` name; the - graph's aggregate of secret declarations is the app's provision manifest - — everything that must exist on the platform before deploy. -3. **Preflight.** Before Alchemy runs, the deploy pipeline checks that every - manifest name exists on the platform for the target stage's class/branch, - filling a name from the deploy shell's environment when the platform - doesn't have it yet (a direct API call, never an Alchemy resource, never - overwriting an existing platform value). A name missing from both fails - the deploy, listing exactly what's absent. -4. **Pointer row.** The pack writes a secret param's row as a pointer — the - generated key (`COMPOSE_`-prefixed, like every generated key) maps to the - `external` name, not a value: +1. **Bind + forward.** The root binds each need to a platform name and wires it + down the module topology (the same rail dependency inputs use). Load records + one binding per resolved service secret slot: `{ serviceAddress, slot, name }`. +2. **Preflight.** Before Alchemy runs, the deploy verifies every bound name + exists on the platform for the target stage's class/branch, filling a name + from the deploy shell when the platform lacks it (a direct write-only POST, + never an Alchemy resource, never overwriting an existing value). A name + missing from both fails the deploy, listing exactly what's absent. +3. **Pointer row.** The target writes a pointer per slot — the generated key + (`COMPOSE_`-prefixed, like every generated key) maps to the bound name, not a + value: ``` - COMPOSE_INGEST_STRIPEKEY = "STRIPE_SECRET_KEY" + COMPOSE_AUTH_SIGNINGKEY = "AUTH_SIGNING_KEY" ``` -5. **Boot double-lookup.** Deserialize reads the pointer, then reads - `process.env` for the name it names, validating through the param's - schema. +4. **Boot double-lookup.** Boot reads the pointer for the name, then reads + `process.env[name]` for the platform value, and wraps it in a `SecretBox`. The `COMPOSE_` prefix reserves the framework's generated keys into their own -namespace, so a generated key never collides with — and silently overwrites — -a user's own secret name. +namespace, so a generated key never collides with — and silently overwrites — a +user-provisioned platform variable. -**Rotation is PATCH + redeploy.** A compute version snapshots its whole env -map at creation and never re-resolves it, so changing a secret's value is the -platform's existing semantics: `PATCH` the value on the platform, then create -a new version. Nothing about that is specific to secrets. +**Rotation is PATCH + redeploy.** A compute version snapshots its whole env map +at creation and never re-resolves it, so changing a secret's value is the +platform's own semantics: `PATCH` the value, then create a new version. -See [ADR-0029](../90-decisions/ADR-0029-secrets-are-env-named-params.md) for -the full design and its alternatives. +See [ADR-0029](../90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md) for the full design and its +alternatives. ## Introspection A service's config surface is enumerable from the graph alone, nothing booted: every param lists its owner, facets, and **schema**, so a structured param reports -its real shape rather than "a string". Secret params appear with values redacted. -This is what keeps topology tooling and agents able to answer "what is this service +its real shape rather than "a string". Secrets are not params — they are a +separate slot (§ Secrets) whose value never enters this surface at all. This is +what keeps topology tooling and agents able to answer "what is this service configured with?" truthfully. ## Boundaries @@ -213,8 +217,9 @@ Three lines the model holds everywhere: - **Params are static data.** A value that must come from another node — an address, a URL, credentials a resource mints — is a dependency input, never a field inside a param value. -- **`secret` is handling, not type.** The schema says what a value is; `secret` - says it must be redacted and placed securely. The two never mix. +- **Secrets are not params.** A secret is a distinct forwardable slot (§ Secrets, + ADR-0029), read through `secrets()` as a `SecretBox` — sensitivity is carried by + the type, never a param facet. ## Related @@ -222,7 +227,7 @@ Three lines the model holds everywhere: [ADR-0019](../90-decisions/ADR-0019-the-target-owns-config-serialization.md), [ADR-0021](../90-decisions/ADR-0021-params-are-read-through-config-not-load.md) — the decisions this documents. -- [ADR-0029](../90-decisions/ADR-0029-secrets-are-env-named-params.md) — the +- [ADR-0029](../90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md) — the secrets model this document's § Secrets summarizes. - [`core-model.md`](core-model.md) — where params sit in the node → graph → Config model. diff --git a/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md b/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md new file mode 100644 index 00000000..093df98f --- /dev/null +++ b/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md @@ -0,0 +1,94 @@ +# ADR-0029: Secrets are a forwardable slot + +## Status + +Proposed + +## Context + +A service needs a secret — a signing key, an API token — whose value the platform +holds and the deploy machine must never see. The value is provisioned out-of-band +on the platform; the framework's only job is to route the service to it at boot. +That routing has to survive composition: a reusable module declares that it needs +a secret without knowing which platform variable an application will bind it to, +and the application composing that module supplies the name. The framework already +forwards ordinary inputs down a module's boundary (ADR-0016) — secrets ride the +same rail. + +## Decision + +A secret is its own slot kind — not a config param, not a dependency. + +- **`secret()`** declares a nameless *need* on `compute()`/`module()` + (`secrets: { signingKey: secret() }`). A module forwards its need to an inner + service through `ctx.secrets`, exactly as it forwards a dependency input. +- **`envSecret('NAME')`** is the *source*: the root binds a need to a platform + env-var name — `provision(auth, { secrets: { signingKey: envSecret('AUTH_SIGNING_KEY') } })`. + Only the root names the variable; the module never does. +- **`secrets()`** reads the value at the point of use, a third accessor beside + `load()`/`config()` (ADR-0021), returning one `SecretBox` per slot. + `expose()` is the sole reader; the box prints `[REDACTED]` from `toString`, + `toJSON`, `valueOf`, and `inspect`. + +The framework carries only the **name**. Deploy writes a pointer row +`COMPOSE__ = NAME` — never a value. Boot double-looks-up: read the +pointer row for the name, then read `process.env[NAME]` (the platform injects the +user-provisioned variable into the version's env), and wrap the result in a +`SecretBox`. Load records each resolved binding on the graph, and the deploy +target keys its pointer row off that. **Deploy preflight** verifies every bound +name exists on the platform for the stage's class/branch before provisioning; 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); a name absent from both fails the deploy. + +## Rationale + +- **A distinct slot makes sensitivity structural.** Redaction is carried by the + type (`SecretBox`), not a flag every sink must remember to check — a secret + cannot be logged or serialized by accident, and `expose()` marks the one place + value access is intended. +- **Names, not values, keep secrets out of inspectable state.** The value never + enters the typed `Config`, the generated stack file, deploy state, or a log — + only a name does, which is as safe to write and diff as any other key. This is + also exactly the constraint a future platform-side secrets-manager integration + needs, with nothing to unwind. +- **Root-binds-and-forwards keeps modules reusable.** A module declares the need + and forwards it; it never hardcodes a platform variable name, so the same + module composes into any application, each binding its own name. + +## Consequences + +- Every generated key carries a reserved `COMPOSE_` prefix, so a framework key can + never collide with — and silently overwrite — a user-provisioned platform + variable. +- Every wired secret is required; an "optional secret" would be a distinct future + construct, not a facet on this one. +- Rotation is `PATCH` the value on the platform, then redeploy — the platform's + own version-snapshot semantics (an env map is frozen per compute version), not a + framework mechanism. +- A secret slot and a service-own param may not share a name (they derive the same + config key); a dependency may, since its keys carry the input and param segments. + +## Alternatives considered + +- **A secret as a param facet bound at the leaf** (the service names the platform + variable itself). Rejected: the binding cannot be forwarded through the topology, + so a reusable module would have to hardcode the platform name — the opposite of + composable. +- **A secret as an ordinary dependency.** Rejected: it would read back as a client + through `load()`, and its sensitivity would be a flag on a value rather than a + property of the type — the same leak-by-omission the distinct slot removes. +- **Value-as-default sourced from the deploy environment.** Rejected: it persists + the secret value in deploy state and offers no way to verify the value is present + before provisioning. +- **Unprefixed generated keys.** Rejected: a generated key could collide with a + user-provisioned platform variable of the same name and silently overwrite it. + +## Related + +- [ADR-0016](ADR-0016-a-module-has-the-same-boundary-as-a-service.md) — the module + input-forwarding rail secrets ride. +- [ADR-0021](ADR-0021-params-are-read-through-config-not-load.md) — + `load()`/`config()`/`secrets()` as separate read namespaces. +- [`../10-domains/config-params.md`](../10-domains/config-params.md) — the config + model, with a § Secrets summary. diff --git a/docs/design/90-decisions/ADR-0029-secrets-are-env-named-params.md b/docs/design/90-decisions/ADR-0029-secrets-are-env-named-params.md deleted file mode 100644 index ef048215..00000000 --- a/docs/design/90-decisions/ADR-0029-secrets-are-env-named-params.md +++ /dev/null @@ -1,255 +0,0 @@ -# ADR-0029: Secrets are env-named params - -## Status - -Proposed - -## Decision - -A secret is an ordinary config param carrying the `secret` facet, bound to an -explicit platform environment-variable name. The framework only ever handles -that name — never the value: - -```ts -compute({ - name: 'ingest', - params: { stripeKey: envSecret('STRIPE_SECRET_KEY') }, - // ... -}); -``` - -`envSecret(name)` is a core param constructor -([config.ts](../../../packages/0-framework/1-core/core/src/config.ts)): - -```ts -export interface ConfigParam { - readonly schema: S; - readonly secret?: boolean; - readonly external?: string; // platform env-var name; provisioned out-of-band - readonly optional?: boolean; - readonly default?: StandardSchemaV1.InferOutput; -} - -export function envSecret( - name: string, - opts?: { readonly optional?: boolean }, -): ConfigParam>; -``` - -`envSecret(name)` returns `{ schema: , secret: true, external: -name }`. `external` is a new facet on `ConfigParam`/`ConfigDeclaration` -carrying the platform name a secret is bound to — the user provisions the -matching value on the platform; the framework never does. - -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 -`secret: true` but carries no `external` name fails loudly at deploy build: -"bind it with `envSecret` or wire it" — "wire it" meaning: make the value -arrive as an ordinary dependency input from a producer node whose own param -does carry the binding, the same way any other connection value already -flows. - -`secret: true` **forbids `default`**, enforced at the type level (a param -options type that splits secret from non-secret) and at runtime -(`withFacets`/`freezeParams`). `optional` is still allowed on a secret param. - -**Reserved names.** `external` must not start with the framework's own -`COMPOSE_` prefix (below) and must not be `DATABASE_URL` / -`DATABASE_URL_POOLED`, which Prisma Compose poisons at project provision. -Both are validated when the param is constructed. - -The framework carries the name across three moves: - -1. **Manifest introspection.** `configOf` reports each param's `external` - name; the graph's aggregate of every secret declaration with an `external` - name is the app's *provision manifest* — everything that must exist on the - platform before deploy. -2. **Pointer serialization.** The pack - ([serializer.ts](../../../packages/1-prisma-cloud/1-extensions/target/src/serializer.ts)) - writes a secret param's row as a pointer — the generated key maps to the - `external` name, not a value: - ``` - COMPOSE_INGEST_STRIPEKEY = "STRIPE_SECRET_KEY" - ``` - Boot deserializes by reading the generated key to get the pointer, then - reading `process.env[pointer]`. A secret's value never passes through the - serializer's own encoding, is never written as a `ConfigVariable` row's - value by the framework, and never enters Alchemy's deploy state. -3. **Deploy preflight.** Before Alchemy runs, the pipeline verifies every - manifest name exists on the platform for the target stage's resolved - class/branch. A name missing from the platform but present in the deploy - shell's own environment is filled in directly; anything still missing - fails the deploy, listing exactly what's absent. - -## Reasoning - -### The platform already treats env-var values as write-only - -Per [pdp-data-model.md](../05-prisma-cloud/pdp-data-model.md), -`ConfigVariable` values are encrypted under the project's key and are never -returned by a read — the Management API only ever accepts a value -(`POST`/`PATCH`); it never returns one. Every compute version snapshots the -**entire branch env map** at version-create time -(`materializeBranchEnvVars`), so a variable a user provisions directly on the -platform is already visible to every service attached to that branch — no -framework machinery is needed to get a value from "provisioned" to "running -instance." Rotation is just the platform's existing semantics: `PATCH` the -value, then create a new version, since a version's env is frozen at creation -and there is no live re-resolution. This design adds no new security -primitive — it routes *names* so a booting service knows which platform -variable holds its value. - -### Why a name, not a value, crosses into the framework - -If the framework carried a secret's value at all — through `buildConfig`, -into a generated stack file, into Alchemy's deploy state — it would sit -somewhere designed to be inspected, diffed, or read back -(a stack file is generated to be inspectable by design, per -[ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md)). -None of that machinery was built to hold a secret safely, and adding that -would mean every future exporter or lowering pass has to remember not to leak -the field. Keeping the framework's job to *names* sidesteps the problem -instead of defending against it: a name is exactly as safe to write, log, and -diff as any other config value, because a name is not the secret. - -This is also the constraint a future secrets-manager integration needs -already satisfied: the endgame is the platform pulling a value from a -customer's secrets manager at version materialization, with the value never -touching the deploy machine. A framework that has only ever carried names has -nothing to change when that lands. - -### Pointer rows and the `COMPOSE_` prefix - -A secret param's stored row is a pointer: the generated key (e.g. -`INGEST_STRIPEKEY`) maps to the platform name (`STRIPE_SECRET_KEY`), not a -value. Boot double-looks-up: read the generated key to get the pointer, then -read `process.env[pointer]`. - -Every generated key now carries a `COMPOSE_` prefix -(`COMPOSE_INGEST_STRIPEKEY`), fixing a real latent bug: the -`EnvironmentVariable` reconciler adopts and `PATCH`es whatever row already -exists at the same `(project, class, key)` -([EnvironmentVariable.ts](../../../packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts)), -so a generated key that happened to collide with a user's own secret name -would silently overwrite it. The prefix reserves a namespace for everything -the framework writes, leaving the user's namespace (`STRIPE_SECRET_KEY`, -`SENDGRID_API_KEY`, …) untouched. Adoption is further restricted: the -reconciler may only adopt a match when its own prior state row exists or the -key is a poison key (`DATABASE_URL`/`DATABASE_URL_POOLED`); adopting a -`COMPOSE_`-prefixed row it did not itself create fails loudly instead of -silently taking it over. - -### Preflight: verify before Alchemy runs, fill from the shell when possible - -Every compute version snapshots the branch's env map at creation, so a -secret's platform variable has to exist *before* that service's version is -created — the same timing constraint every `ConfigVariable` is already under -(pdp-data-model.md). Preflight makes that constraint explicit instead of -surfacing as a runtime failure inside an already-deployed instance: before -Alchemy runs, the deploy pipeline aggregates the manifest (every secret -declaration with an `external` name), resolves the check scope from the -target stage -([ADR-0024](ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md)) — -the default stage checks production-class project templates; a named stage -checks preview-class templates merged with that branch's overrides, the -platform's own materialization order — and calls `GET -/v1/environment-variables` (metadata only) to verify each name exists. - -A name absent from the platform but present in the deploy shell's own -environment (`process.env[externalName]`) is filled in during preflight: a -**direct management-API `POST`, never an Alchemy resource** — resource props -persist in hosted deploy state, and a secret's value must not. The value -transits CLI process memory exactly once and is never logged. Fill-missing -never overwrites an existing platform value, so a rotation made on the -platform always wins over a stale shell value. This is what makes a first -deploy single-step in CI: a CI secret lands in the runner's environment, and -the CLI provisions it onto the platform on the way to deploying. - -A name absent from both the platform and the shell fails the deploy, listing -every missing name with its class/branch scope and directing the user to set -it in the shell or on the platform. - -### `secret` forbids `default` - -A `default` on a secret param would let preflight pass while the real secret -is still missing — a service would boot on the fallback value instead of -failing — and it would put that fallback value into the graph's introspection -output, exactly what `secret` exists to keep out. `optional` carries no such -risk: an optional secret's absence is a legitimate outcome, validated the same -way any other optional param's absence is. - -## Consequences - -- **The framework never handles a secret's value** — not in `buildConfig`, - not in the generated stack file, not in Alchemy deploy state, not in any - log. Everything the framework touches is a name. -- **A first deploy in CI is single-step**: shell env → fill-missing → deploy, - with no separate provisioning command. -- **Rotation has no framework-specific procedure.** `PATCH` the platform - value and redeploy — the platform's existing version-snapshot semantics, - not something this design adds. -- **The same key works across stages without renaming.** Class/branch - materialization already gives `STRIPE_SECRET_KEY` a different value in - preview than in production; the pointer row is stage-neutral. -- **The `COMPOSE_` prefix renames every existing generated key.** An - existing app's first redeploy under this design rewrites every config row - and creates a new version for every service — a one-time churn the - no-op-redeploy E2E check needs to re-baseline against. -- **Leaf→root secret forwarding across nested modules is not built.** A - service-own secret param with no `external` name fails at deploy build - instead of silently having no value; extending forwarding to nested modules - is deferred to whatever work builds hex-composition's boundary ports. -- **A future secrets-manager integration slots in without a framework - change**: the platform pulling values from a customer's secrets manager at - version materialization needs exactly the "framework carries only names" - constraint this design already establishes. - -## Alternatives considered - -- **Derive the platform name from the param key.** Rejected: a naive - uppercase of `stripeKey` gives `STRIPEKEY`, which never matches a real - platform variable like `STRIPE_SECRET_KEY`; a camelCase→SCREAMING_SNAKE - transform is magic, and ambiguous the moment a key has digits or an - acronym in it. An explicit name removes the guessing. -- **Value-as-default sourced from the deployer's environment** — the - `fromEnv()` stopgap on branch `claude/datahub-port`. That branch bridged - secret values as an app-side convention: a value read from the deployer's - own environment became the param's `default`, and the serializer wrote that - default into an `EnvironmentVariable`'s *value* — landing a secret's actual - value in Alchemy deploy state, with no deploy-time check that the value was - even present. That branch's own plan flagged this as needing "a - first-class values mechanism … the durable fix"; this ADR is that - mechanism. Rejected as the durable design because it persists a secret's - value where deploy state is inspected, and because `secret: true` - forbidding `default` makes the pattern unexpressible going forward. -- **Bake wiring into the bundle to avoid a pointer row** — resolve a secret's - platform name at build time and inline it into the compiled bundle instead - of writing it as a config row. Rejected: it makes the bundle stage-specific. - The same artifact could no longer deploy unchanged to preview and - production — which the platform's own class/branch materialization exists to - let it do, and which target-owned serialization keeps stage-neutral - ([ADR-0019](ADR-0019-the-target-owns-config-serialization.md)). A pointer - row, resolved by the target at deploy, keeps the built artifact identical - across stages. -- **Unprefixed generated keys.** Rejected: the `EnvironmentVariable` - reconciler adopts and `PATCH`es any pre-existing row at the same - `(project, class, key)` - ([EnvironmentVariable.ts](../../../packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts)), - so an unprefixed generated key colliding with a user's own secret name - would be silently overwritten by the framework's own provisioning. - -## Related - -- [ADR-0018](ADR-0018-config-params-carry-a-caller-owned-schema.md) — a param - carries a caller-owned schema plus facets; `secret` and `external` are two - more facets on that same plain object. -- [ADR-0019](ADR-0019-the-target-owns-config-serialization.md) — the target - owns serialization; the pointer row and double-lookup are - `@prisma/compose-prisma-cloud`'s serialization choice, not core's. -- [ADR-0024](ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md) — - preflight's class/branch scope resolution rides the same stage → Project/Branch - resolution this ADR builds on. -- [`config-params.md`](../10-domains/config-params.md) — the params model end - to end; § Secrets is this ADR's summary in that document's voice. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 5bae2eac..1cb5a2f2 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -44,4 +44,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0026](ADR-0026-name-the-framework-prisma-compose.md) — The framework is **Prisma Compose**; "Prisma App" names only the artifact you build. Supersedes ADR-0014's framework, package, and CLI names: `@prisma/compose*` (incl. `compose-alchemy`), `prisma-compose`, `prisma-compose.config.ts`. - [ADR-0027](ADR-0027-two-packages-compose-and-compose-prisma-cloud.md) — Ship two **public** packages: `@prisma/compose` (core + CLI + agnostic subpaths) and `@prisma/compose-prisma-cloud` (target + first-party modules as entrypoints, cron first). Boundary = the user's one choice: where does it run. - [ADR-0028](ADR-0028-numbered-domains-and-layers-enforced-by-dependency-cruiser.md) — `packages/` organizes into numbered domains (0-framework, 1-prisma-cloud, 9-public) and layers; planes as config-mapped entrypoints; internals are `@internal/*`; only 9-public publishes; dependency-cruiser enforces. -- [ADR-0029](ADR-0029-secrets-are-env-named-params.md) — A secret is an ordinary config param with the `secret` facet, bound to an explicit platform env-var name via `envSecret`; the framework carries only the name — pointer rows, preflight, and rotation follow from that. *(Proposed)* +- [ADR-0029](ADR-0029-secrets-are-a-forwardable-slot.md) — A secret is a distinct forwardable slot (not a param): a service declares a nameless `secret()` need, the root binds it to a platform env-var via `envSecret`, and it reads back as a redacting `SecretBox`; the framework carries only the name (pointer rows + boot double-lookup + preflight). *(Proposed)* From 507ace60c51a1b0ccbbc94f3d3572905296d7ab8 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 18:48:30 +0200 Subject: [PATCH 17/19] refactor: move envSecret to the target; core's SecretSource is opaque Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/config-params.md | 8 +++- ...ADR-0029-secrets-are-a-forwardable-slot.md | 17 +++++-- examples/storefront-auth/module.test.ts | 9 ++-- examples/storefront-auth/module.ts | 3 +- .../core/src/__tests__/config.test-d.ts | 10 ++--- .../1-core/core/src/__tests__/config.test.ts | 27 +++++------ .../1-core/core/src/__tests__/secrets.test.ts | 45 +++++++++---------- .../0-framework/1-core/core/src/config.ts | 8 ++-- .../1-core/core/src/graph-types.ts | 18 ++++---- packages/0-framework/1-core/core/src/index.ts | 2 +- .../1-core/core/src/load-module.ts | 2 +- packages/0-framework/1-core/core/src/node.ts | 39 +++------------- .../src/__tests__/control-lowering.test.ts | 6 ++- .../target/src/__tests__/preflight.test.ts | 3 +- .../target/src/__tests__/secret.test.ts | 20 +++++++++ .../1-extensions/target/src/index.ts | 1 + .../1-extensions/target/src/preflight.ts | 8 +++- .../1-extensions/target/src/secret.ts | 45 +++++++++++++++++++ .../1-extensions/target/src/serializer.ts | 3 +- 19 files changed, 169 insertions(+), 105 deletions(-) create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/__tests__/secret.test.ts create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/secret.ts diff --git a/docs/design/10-domains/config-params.md b/docs/design/10-domains/config-params.md index a273663f..f6bbd16b 100644 --- a/docs/design/10-domains/config-params.md +++ b/docs/design/10-domains/config-params.md @@ -156,7 +156,8 @@ module('auth', { secrets: { signingKey: secret() }, expose: … }, ({ secrets, p provision(inner, { secrets: { signingKey: secrets.signingKey } }), ); -// Only the ROOT names the platform variable: +// Only the ROOT names the platform variable (envSecret is the TARGET's, from +// @prisma/compose-prisma-cloud — secret() is core's, from @prisma/compose): provision(auth, { secrets: { signingKey: envSecret('AUTH_SIGNING_KEY') } }); // Read at the point of use — expose() is the sole reader: @@ -166,7 +167,10 @@ signingKey.expose(); // the one door to the value `secrets()` sits beside `load()`/`config()` (ADR-0021). The `SecretBox` redacts under `toString`/`toJSON`/`valueOf`/`inspect`, so a stray log or serialization -prints `[REDACTED]`; only `expose()` returns the value. +prints `[REDACTED]`; only `expose()` returns the value. `secret()` and the opaque +`SecretSource` are core (`@prisma/compose`); `envSecret('NAME')` — the source +constructor that names and validates a platform variable — is the target's, from +`@prisma/compose-prisma-cloud` (ADR-0018/0019 applied to secrets). The round trip carries only the name, never the value: diff --git a/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md b/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md index 093df98f..5219f049 100644 --- a/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md +++ b/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md @@ -19,11 +19,11 @@ same rail. A secret is its own slot kind — not a config param, not a dependency. -- **`secret()`** declares a nameless *need* on `compute()`/`module()` - (`secrets: { signingKey: secret() }`). A module forwards its need to an inner +- **`secret()`** (from `@prisma/compose`) declares a nameless *need* on + `compute()`/`module()` (`secrets: { signingKey: secret() }`). A module forwards its need to an inner service through `ctx.secrets`, exactly as it forwards a dependency input. -- **`envSecret('NAME')`** is the *source*: the root binds a need to a platform - env-var name — `provision(auth, { secrets: { signingKey: envSecret('AUTH_SIGNING_KEY') } })`. +- **`envSecret('NAME')`** (from `@prisma/compose-prisma-cloud`) is the *source*: + the root binds a need to a platform env-var name — `provision(auth, { secrets: { signingKey: envSecret('AUTH_SIGNING_KEY') } })`. Only the root names the variable; the module never does. - **`secrets()`** reads the value at the point of use, a third accessor beside `load()`/`config()` (ADR-0021), returning one `SecretBox` per slot. @@ -41,6 +41,12 @@ name absent from the platform but present in the deploy shell is provisioned wit a direct write-only POST (never an Alchemy resource, so no value reaches deploy state); a name absent from both fails the deploy. +**The need is core; the source is the target's.** `secret()` and the opaque +`SecretSource` are `@prisma/compose`; core forwards a source and never reads its +payload. The source constructor belongs to the target — Prisma Cloud ships +`envSecret('NAME')` from `@prisma/compose-prisma-cloud`, which validates the name +and wraps it via core's `secretSource()`. The ADR-0018/0019 split, for secrets. + ## Rationale - **A distinct slot makes sensitivity structural.** Redaction is carried by the @@ -88,6 +94,9 @@ state); a name absent from both fails the deploy. - [ADR-0016](ADR-0016-a-module-has-the-same-boundary-as-a-service.md) — the module input-forwarding rail secrets ride. +- [ADR-0019](ADR-0019-the-target-owns-config-serialization.md) — the declaration/ + encoding split this applies to secrets: core carries the need, the target owns + the source and its serialization. - [ADR-0021](ADR-0021-params-are-read-through-config-not-load.md) — `load()`/`config()`/`secrets()` as separate read namespaces. - [`../10-domains/config-params.md`](../10-domains/config-params.md) — the config diff --git a/examples/storefront-auth/module.test.ts b/examples/storefront-auth/module.test.ts index 576df52b..ae2fcbbe 100644 --- a/examples/storefront-auth/module.test.ts +++ b/examples/storefront-auth/module.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { Load } from '@prisma/compose'; +import { secretName } from '@prisma/compose-prisma-cloud'; import root from './module.ts'; describe('storefront-auth root graph', () => { @@ -8,8 +9,10 @@ describe('storefront-auth root graph', () => { // the slot, or removing the root envSecret binding all break this — none of // which typecheck catches, and the only other graph-Loading gate is CI E2E. const graph = Load(root); - expect(graph.secrets).toEqual([ - { serviceAddress: 'auth.service', slot: 'signingKey', name: 'AUTH_SIGNING_SECRET' }, - ]); + expect(graph.secrets.length).toBe(1); + expect(graph.secrets[0]?.serviceAddress).toBe('auth.service'); + expect(graph.secrets[0]?.slot).toBe('signingKey'); + // The env-var name lives in the target's opaque payload, read via secretName. + expect(secretName(graph.secrets[0]!)).toBe('AUTH_SIGNING_SECRET'); }); }); diff --git a/examples/storefront-auth/module.ts b/examples/storefront-auth/module.ts index 1d83f312..8b25c606 100644 --- a/examples/storefront-auth/module.ts +++ b/examples/storefront-auth/module.ts @@ -1,4 +1,5 @@ -import { envSecret, module } from '@prisma/compose'; +import { module } from '@prisma/compose'; +import { envSecret } from '@prisma/compose-prisma-cloud'; import authModule from '@storefront-auth/auth'; import storefrontService from '@storefront-auth/storefront'; diff --git a/packages/0-framework/1-core/core/src/__tests__/config.test-d.ts b/packages/0-framework/1-core/core/src/__tests__/config.test-d.ts index e47bedcc..108c13e8 100644 --- a/packages/0-framework/1-core/core/src/__tests__/config.test-d.ts +++ b/packages/0-framework/1-core/core/src/__tests__/config.test-d.ts @@ -1,13 +1,13 @@ /** * Type-level rules for the secret slot vocabulary (ADR-0029): `secret()` is a - * need, `envSecret()` is a source, `SecretValues` boxes each slot, and + * need, `secretSource()` is a source, `SecretValues` boxes each slot, and * `provision` requires a source per declared secret slot. Type-only (vitest * `--typecheck`, never executed). */ import type { SecretBox } from '@internal/foundation/secret'; import { expectTypeOf, test } from 'vitest'; import type { BuildAdapter, SecretNeed, SecretSource, SecretValues } from '../node.ts'; -import { envSecret, module, secret, service } from '../node.ts'; +import { module, secret, secretSource, service } from '../node.ts'; const build: BuildAdapter = { extension: '@prisma/compose/node', @@ -16,9 +16,9 @@ const build: BuildAdapter = { entry: 'server.js', }; -test('secret() is a SecretNeed; envSecret() is a SecretSource', () => { +test('secret() is a SecretNeed; secretSource() is a SecretSource', () => { expectTypeOf(secret()).toEqualTypeOf(); - expectTypeOf(envSecret('AUTH_SIGNING_KEY')).toEqualTypeOf(); + expectTypeOf(secretSource('AUTH_SIGNING_KEY')).toEqualTypeOf>(); }); test('SecretValues maps each declared slot to a SecretBox', () => { @@ -43,6 +43,6 @@ test('provisioning a service with a secret slot requires a source per slot', () module('root', ({ provision }) => { // @ts-expect-error a declared secret slot must be bound provision(svc, { id: 'auth' }); - provision(svc, { id: 'auth', secrets: { signingKey: envSecret('AUTH_SIGNING_KEY') } }); + provision(svc, { id: 'auth', secrets: { signingKey: secretSource('AUTH_SIGNING_KEY') } }); }); }); diff --git a/packages/0-framework/1-core/core/src/__tests__/config.test.ts b/packages/0-framework/1-core/core/src/__tests__/config.test.ts index ab329d2e..7edfc06c 100644 --- a/packages/0-framework/1-core/core/src/__tests__/config.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/config.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { configOf, number, provisionManifest, string } from '../config.ts'; import { Load } from '../graph.ts'; -import { dependency, envSecret, module, secret, service } from '../node.ts'; +import { dependency, module, secret, secretSource, service } from '../node.ts'; import { conn, scalarDeclaration } from './helpers.ts'; const build = { @@ -133,23 +133,24 @@ describe('provisionManifest', () => { }); const graph = Load( module('app', ({ provision }) => { - provision(ingest, { id: 'ingest', secrets: { stripeKey: envSecret('STRIPE_SECRET_KEY') } }); - provision(web, { id: 'web', secrets: { sendgrid: envSecret('SENDGRID_API_KEY') } }); + provision(ingest, { + id: 'ingest', + secrets: { stripeKey: secretSource('STRIPE_SECRET_KEY') }, + }); + provision(web, { id: 'web', secrets: { sendgrid: secretSource('SENDGRID_API_KEY') } }); }), ); const manifest = provisionManifest(graph); + // Core records the binding per (service, slot) with an opaque source; the + // env-var name lives in the target's payload, which core never reads. expect(manifest).toHaveLength(2); - expect(manifest).toContainEqual({ - serviceAddress: 'ingest', - slot: 'stripeKey', - name: 'STRIPE_SECRET_KEY', - }); - expect(manifest).toContainEqual({ - serviceAddress: 'web', - slot: 'sendgrid', - name: 'SENDGRID_API_KEY', - }); + expect(manifest).toContainEqual( + expect.objectContaining({ serviceAddress: 'ingest', slot: 'stripeKey' }), + ); + expect(manifest).toContainEqual( + expect.objectContaining({ serviceAddress: 'web', slot: 'sendgrid' }), + ); }); test('is empty when no service declares a secret slot', () => { diff --git a/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts b/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts index dd197b97..99b972f8 100644 --- a/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { blindCast } from '@internal/foundation/casts'; import { Load } from '../graph.ts'; import type { SecretSource, Secrets } from '../node.ts'; -import { envSecret, isSecretSource, module, resource, secret, service } from '../node.ts'; +import { isSecretSource, module, resource, secret, secretSource, service } from '../node.ts'; import { providerContract } from './helpers.ts'; const build = { @@ -29,19 +29,12 @@ const svc = >( }); describe('secret sources', () => { - test('envSecret is a secret source; secret() and plain values are not', () => { - expect(isSecretSource(envSecret('AUTH_KEY'))).toBe(true); + test('secretSource builds a secret source; secret() and plain values are not', () => { + expect(isSecretSource(secretSource('AUTH_KEY'))).toBe(true); expect(isSecretSource(secret())).toBe(false); expect(isSecretSource({})).toBe(false); expect(isSecretSource(undefined)).toBe(false); }); - - test('envSecret rejects empty, COMPOSE_-prefixed, and poisoned names', () => { - expect(() => envSecret('')).toThrow(/non-empty/); - expect(() => envSecret('COMPOSE_X')).toThrow(/COMPOSE_/); - expect(() => envSecret('DATABASE_URL')).toThrow(/reserved/); - expect(() => envSecret('DATABASE_URL_POOLED')).toThrow(/reserved/); - }); }); describe('Load records secret bindings', () => { @@ -49,12 +42,15 @@ describe('Load records secret bindings', () => { const auth = svc('auth', { signingKey: secret() }); const graph = Load( module('root', ({ provision }) => { - provision(auth, { id: 'auth', secrets: { signingKey: envSecret('AUTH_SIGNING_KEY') } }); + provision(auth, { id: 'auth', secrets: { signingKey: secretSource('AUTH_SIGNING_KEY') } }); }), ); - expect(graph.secrets).toEqual([ - { serviceAddress: 'auth', slot: 'signingKey', name: 'AUTH_SIGNING_KEY' }, - ]); + // Core records the binding at the right address/slot with an opaque source; + // it never reads the payload (the target's concern). + expect(graph.secrets.length).toBe(1); + expect(graph.secrets[0]?.serviceAddress).toBe('auth'); + expect(graph.secrets[0]?.slot).toBe('signingKey'); + expect(isSecretSource(graph.secrets[0]?.source)).toBe(true); }); test('a module forwards a secret slot to an inner service (the multi-level case)', () => { @@ -64,14 +60,15 @@ describe('Load records secret bindings', () => { }); const graph = Load( module('root', ({ provision }) => { - provision(authModule, { id: 'auth', secrets: { key: envSecret('AUTH_KEY') } }); + provision(authModule, { id: 'auth', secrets: { key: secretSource('AUTH_KEY') } }); }), ); - // The name flows root -> module.secrets.key -> inner's slot; the binding is - // recorded at the inner service's full address. - expect(graph.secrets).toEqual([ - { serviceAddress: 'auth.inner', slot: 'key', name: 'AUTH_KEY' }, - ]); + // The source flows root -> module.secrets.key -> inner's slot; the binding is + // recorded at the inner service's full address (payload untouched by core). + expect(graph.secrets.length).toBe(1); + expect(graph.secrets[0]?.serviceAddress).toBe('auth.inner'); + expect(graph.secrets[0]?.slot).toBe('key'); + expect(isSecretSource(graph.secrets[0]?.source)).toBe(true); }); test('a service with no secret slots yields no bindings', () => { @@ -92,7 +89,7 @@ describe('Load validates secret wiring', () => { expect(() => Load( module('root', ({ provision }) => { - provision(m, { id: 'auth', secrets: { key: envSecret('AUTH_KEY') } }); + provision(m, { id: 'auth', secrets: { key: secretSource('AUTH_KEY') } }); }), ), ).toThrow(/declares secret "key" but never forwards/); @@ -136,7 +133,7 @@ describe('Load validates secret wiring', () => { blindCast< { id: string }, 'a resource takes no secrets — inject one to hit the runtime check' - >({ id: 'db', secrets: { k: envSecret('K') } }), + >({ id: 'db', secrets: { k: secretSource('K') } }), ); }), ), @@ -170,7 +167,7 @@ describe('Load validates secret wiring', () => { blindCast< { id: string; secrets: { signingKey: SecretSource } }, 'inject an extra non-slot key to exercise the runtime extra-key check' - >({ id: 'auth', secrets: { signingKey: envSecret('K'), bogus: envSecret('B') } }), + >({ id: 'auth', secrets: { signingKey: secretSource('K'), bogus: secretSource('B') } }), ); }), ), @@ -186,7 +183,7 @@ describe('Load validates secret wiring', () => { // The SAME source object is bound to BOTH a and b. Without the per-slot // branded copies, forwarding secrets.a would alias-mark b used too; with // them, b is correctly flagged as never forwarded. - const src = envSecret('SHARED'); + const src = secretSource('SHARED'); expect(() => Load( module('root', ({ provision }) => { diff --git a/packages/0-framework/1-core/core/src/config.ts b/packages/0-framework/1-core/core/src/config.ts index b273b758..d7f5cb9c 100644 --- a/packages/0-framework/1-core/core/src/config.ts +++ b/packages/0-framework/1-core/core/src/config.ts @@ -133,10 +133,10 @@ export function configOf(root: ServiceNode): readonly ConfigDeclaration[] { /** * The app's provision manifest: every secret binding the root resolved across - * the graph (ADR-0029) — a platform env-var NAME per service secret slot. Pure - * graph introspection, TARGET-AGNOSTIC — a deploy target's preflight consumes - * it to verify each name exists on the platform before deploy. The framework - * carries only the names; the values are provisioned out-of-band. + * the graph (ADR-0029) — an opaque, target-defined source per service secret + * slot; a deploy target's preflight reads its own payload. Pure graph + * introspection, TARGET-AGNOSTIC — the target consumes it to verify each secret + * exists on the platform before deploy. The values are provisioned out-of-band. */ export function provisionManifest(graph: Graph): readonly SecretBinding[] { return graph.secrets; diff --git a/packages/0-framework/1-core/core/src/graph-types.ts b/packages/0-framework/1-core/core/src/graph-types.ts index 737ea996..a52c3943 100644 --- a/packages/0-framework/1-core/core/src/graph-types.ts +++ b/packages/0-framework/1-core/core/src/graph-types.ts @@ -1,4 +1,4 @@ -import type { DependencyEnd, ModuleNode, ResourceNode, ServiceNode } from './node.ts'; +import type { DependencyEnd, ModuleNode, ResourceNode, SecretSource, ServiceNode } from './node.ts'; /** Path-derived: root-scope children are bare ids ("auth", "db"); a nested module's own children dot-join under its address ("auth.db"). */ export type NodeId = string; @@ -23,19 +23,19 @@ export interface Edge { } /** - * A resolved secret binding: the root bound a service's secret slot to a - * platform env-var NAME, and the wiring forwarded it to that service's address - * (ADR-0029). The framework carries only the name — the value is provisioned - * out-of-band. A target's serializer keys the pointer row off this; the - * preflight manifest aggregates the names. + * A resolved secret binding: the root bound a service's secret slot to an + * opaque, target-defined source, and the wiring forwarded it to that service's + * address (ADR-0029). Core never inspects the source; the deploy target reads + * its own payload. A target's serializer keys the pointer row off this; the + * preflight manifest aggregates the sources. */ export interface SecretBinding { /** The graph address of the service that declares the secret slot. */ readonly serviceAddress: NodeId; /** The secret slot key on that service. */ readonly slot: string; - /** The platform env-var name the root bound the slot to. */ - readonly name: string; + /** The opaque source the root bound the slot to. Core never inspects it; the deploy target reads back its own payload. */ + readonly source: SecretSource; } export interface Graph { @@ -43,7 +43,7 @@ export interface Graph { /** Root + one per input, topo-ordered (deps first). */ readonly nodes: readonly GraphNode[]; readonly edges: readonly Edge[]; - /** Every service secret slot resolved to its root-bound platform name. */ + /** Every service secret slot resolved to its root-bound opaque source. */ readonly secrets: readonly SecretBinding[]; } diff --git a/packages/0-framework/1-core/core/src/index.ts b/packages/0-framework/1-core/core/src/index.ts index 1fd3663a..e984c1c7 100644 --- a/packages/0-framework/1-core/core/src/index.ts +++ b/packages/0-framework/1-core/core/src/index.ts @@ -45,7 +45,6 @@ export type { } from './node.ts'; export { dependency, - envSecret, freezeNode, isNode, isSecretSource, @@ -53,5 +52,6 @@ export { ResourceNodeBase, resource, secret, + secretSource, service, } from './node.ts'; diff --git a/packages/0-framework/1-core/core/src/load-module.ts b/packages/0-framework/1-core/core/src/load-module.ts index 4b77a906..ed66df5e 100644 --- a/packages/0-framework/1-core/core/src/load-module.ts +++ b/packages/0-framework/1-core/core/src/load-module.ts @@ -367,7 +367,7 @@ function flatten( for (const slot of Object.keys(child.secretSlots)) { const bound = localSecrets[slot]; if (isSecretSource(bound)) { - secretBindings.push({ serviceAddress: fullAddress, slot, name: bound.name }); + secretBindings.push({ serviceAddress: fullAddress, slot, source: bound }); } } const inputs = serviceInputs(child, fullAddress); diff --git a/packages/0-framework/1-core/core/src/node.ts b/packages/0-framework/1-core/core/src/node.ts index 8627820a..cd3dd558 100644 --- a/packages/0-framework/1-core/core/src/node.ts +++ b/packages/0-framework/1-core/core/src/node.ts @@ -32,11 +32,11 @@ export interface SecretNeed { /** A service/module's secret slots: name → the need it declares. */ export type Secrets = Record; -/** The wiring value bound to a secret slot — a leaf source carrying the platform env-var NAME, or a forwarded ctx.secrets ref (also carrying it). */ -export interface SecretSource { +/** The wiring value bound to a secret slot: a target-defined payload core forwards but never inspects. A target (e.g. @prisma/compose-prisma-cloud's `envSecret`) builds one via `secretSource()`. */ +export interface SecretSource { readonly [SECRET_SOURCE]: true; - /** The platform env-var name the slot is bound to. */ - readonly name: string; + /** Target-defined. Core never reads this; the target that authored the source reads it back. */ + readonly payload: T; } /** What `provision(node, { secrets })` supplies: one source per declared secret slot. */ @@ -50,34 +50,9 @@ export function secret(): SecretNeed { return Object.freeze({ [SECRET_NEED]: true as const, kind: 'secret' as const }); } -const RESERVED_SECRET_PREFIX = 'COMPOSE_'; -const POISONED_SECRET_NAMES: ReadonlySet = new Set(['DATABASE_URL', 'DATABASE_URL_POOLED']); - -/** - * The secret SOURCE bound at the root: a leaf value naming the platform env var - * a secret slot resolves to (ADR-0029). The framework carries only the name. - * The name may not use the reserved `COMPOSE_` prefix or the poisoned - * `DATABASE_URL(_POOLED)` keys. - */ -export function envSecret(name: string): SecretSource { - if (typeof name !== 'string' || name.length === 0) { - throw new Error( - "envSecret() requires a non-empty platform env-var name, e.g. envSecret('STRIPE_SECRET_KEY').", - ); - } - if (name.startsWith(RESERVED_SECRET_PREFIX)) { - throw new Error( - `envSecret name "${name}" may not start with "${RESERVED_SECRET_PREFIX}" — that prefix is ` + - "reserved for the framework's own generated config keys.", - ); - } - if (POISONED_SECRET_NAMES.has(name)) { - throw new Error( - `envSecret name "${name}" is reserved — ${[...POISONED_SECRET_NAMES].join(' and ')} are ` + - 'poisoned at project provision and cannot back a secret.', - ); - } - return Object.freeze({ [SECRET_SOURCE]: true as const, name }); +/** Builds an opaque secret source from a target-defined payload — the SPI a deploy target's own source constructor (e.g. `envSecret`) calls. Core forwards the source and never inspects the payload. */ +export function secretSource(payload: T): SecretSource { + return Object.freeze({ [SECRET_SOURCE]: true as const, payload }); } /** True if `value` is a secret source (an `envSecret` result or a forwarded ctx.secrets ref). */ diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index c5144a37..1db6d97c 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -87,7 +87,7 @@ mock.module('../pg-warm-resource.ts', () => ({ })); const { prismaCloud } = await import('../control.ts'); -const { compute, postgres } = await import('../index.ts'); +const { compute, envSecret, postgres } = await import('../index.ts'); const { module, secret } = await import('@internal/core'); const { lowering } = await import('@internal/core/deploy'); @@ -390,7 +390,9 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { }); // The root bound the slot to STRIPE_SECRET_KEY — it rides on graph.secrets. const graph = { - secrets: [{ serviceAddress: 'ingest', slot: 'stripeKey', name: 'STRIPE_SECRET_KEY' }], + secrets: [ + { serviceAddress: 'ingest', slot: 'stripeKey', source: envSecret('STRIPE_SECRET_KEY') }, + ], }; const ctx = { address: 'ingest', node, graph } as unknown as LowerContext; const provisioned: LoweredNode = { outputs: { projectId: 'shop-project#cloud-id' } }; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts index e05fbeea..757d798f 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, test } from 'bun:test'; -import { envSecret, Load, module, secret } from '@internal/core'; +import { Load, module, secret } from '@internal/core'; import type { ManagementApiClient } from '@internal/lowering'; import { compute } from '../index.ts'; import { runPreflight } from '../preflight.ts'; +import { envSecret } from '../secret.ts'; const build = { extension: '@prisma/compose/node', diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/secret.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/secret.test.ts new file mode 100644 index 00000000..18e935a8 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/secret.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test'; +import { isSecretSource } from '@internal/core'; +import { envSecret, secretName } from '../secret.ts'; + +describe('envSecret (Prisma Cloud secret source)', () => { + test('builds an opaque secret source; secretName reads its env-var name back', () => { + const source = envSecret('STRIPE_SECRET_KEY'); + expect(isSecretSource(source)).toBe(true); + expect(secretName({ serviceAddress: 'ingest', slot: 'stripeKey', source })).toBe( + 'STRIPE_SECRET_KEY', + ); + }); + + test('rejects empty, COMPOSE_-prefixed, and poisoned names', () => { + expect(() => envSecret('')).toThrow(/non-empty/); + expect(() => envSecret('COMPOSE_X')).toThrow(/COMPOSE_/); + expect(() => envSecret('DATABASE_URL')).toThrow(/reserved/); + expect(() => envSecret('DATABASE_URL_POOLED')).toThrow(/reserved/); + }); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/index.ts b/packages/1-prisma-cloud/1-extensions/target/src/index.ts index 8d998e74..364a810d 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/index.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/index.ts @@ -10,4 +10,5 @@ export type { HttpClient } from './http.ts'; export { http } from './http.ts'; export type { PostgresConfig } from './postgres.ts'; export { postgres, postgresContract } from './postgres.ts'; +export { envSecret, secretName } from './secret.ts'; export { configKey } from './serializer.ts'; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts index 6af28c01..cc2d1fb4 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts @@ -22,6 +22,7 @@ import { } from '@internal/lowering'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; +import { secretName } from './secret.ts'; type EnvClass = 'production' | 'preview'; @@ -188,8 +189,11 @@ export async function runPreflight( // Every wired secret is required — the forwarding model has no optional slot. const names = new Map(); for (const binding of manifest) { - if (!names.has(binding.name)) { - names.set(binding.name, { name: binding.name, serviceAddress: binding.serviceAddress }); + if (!names.has(secretName(binding))) { + names.set(secretName(binding), { + name: secretName(binding), + serviceAddress: binding.serviceAddress, + }); } } diff --git a/packages/1-prisma-cloud/1-extensions/target/src/secret.ts b/packages/1-prisma-cloud/1-extensions/target/src/secret.ts new file mode 100644 index 00000000..85d85751 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/secret.ts @@ -0,0 +1,45 @@ +import { type SecretBinding, type SecretSource, secretSource } from '@internal/core'; +import { blindCast } from '@internal/foundation/casts'; + +/** The Prisma Cloud secret source payload: the platform env-var name the slot resolves to. */ +export interface EnvSecretPayload { + readonly name: string; +} + +const RESERVED_SECRET_PREFIX = 'COMPOSE_'; +const POISONED_SECRET_NAMES: ReadonlySet = new Set(['DATABASE_URL', 'DATABASE_URL_POOLED']); + +/** + * Binds a secret slot to a named Prisma Cloud platform env var (ADR-0029). The + * value is provisioned out-of-band; only the name is carried. The name may not + * use the framework's reserved `COMPOSE_` prefix or the poisoned + * `DATABASE_URL(_POOLED)` keys. + */ +export function envSecret(name: string): SecretSource { + if (typeof name !== 'string' || name.length === 0) { + throw new Error( + "envSecret() requires a non-empty platform env-var name, e.g. envSecret('STRIPE_SECRET_KEY').", + ); + } + if (name.startsWith(RESERVED_SECRET_PREFIX)) { + throw new Error( + `envSecret name "${name}" may not start with "${RESERVED_SECRET_PREFIX}" — that prefix is ` + + "reserved for the framework's own generated config keys.", + ); + } + if (POISONED_SECRET_NAMES.has(name)) { + throw new Error( + `envSecret name "${name}" is reserved — ${[...POISONED_SECRET_NAMES].join(' and ')} are ` + + 'poisoned at project provision and cannot back a secret.', + ); + } + return secretSource({ name }); +} + +/** Reads the Prisma Cloud env-var name back out of a secret binding's opaque source — the target reading the payload its own `envSecret` authored. */ +export function secretName(binding: SecretBinding): string { + return blindCast< + EnvSecretPayload, + 'the prisma-cloud target reads back the {name} payload its own envSecret authored' + >(binding.source.payload).name; +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts index 041277ae..d7947694 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts @@ -23,6 +23,7 @@ import type { Config, ConfigParam, Params, SecretBinding, ServiceNode } from '@internal/core'; import { blindCast } from '@internal/foundation/casts'; import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { secretName } from './secret.ts'; // The ambient environment of whatever runtime hosts the bundle. Declared // structurally so this entry imports no runtime's types. Writable: stash() @@ -197,7 +198,7 @@ export function secretPointerRows( `secret slot "${slot}" of "${address}" has no bound platform name — Load should have bound it (ADR-0029).`, ); } - rows.push({ key: secretKey(address, slot), name: binding.name }); + rows.push({ key: secretKey(address, slot), name: secretName(binding) }); } return rows; } From 3a1a58f346ceda845056435ffa8d4d4ec60f664c Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 14 Jul 2026 02:03:55 +0200 Subject: [PATCH 18/19] fix(prisma-cloud): expose the resolved service port as process.env.PORT at boot Signed-off-by: willbot Signed-off-by: Will Madden --- .../target/src/__tests__/extension.test.ts | 24 +++++++++++++++++++ .../target/src/__tests__/invariants.test.ts | 3 ++- .../1-extensions/target/src/compute.ts | 11 ++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts index e364bbaa..e67c71fa 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts @@ -385,6 +385,30 @@ describe('compute().run(address, boot) → load() — the round trip', () => { expect(bootCalls).toBe(1); }); + + test('run() exposes the resolved service port as process.env.PORT before boot (non-default)', async () => { + const app = compute({ name: 'ingest', deps: {}, build }); + let portAtBoot: string | undefined; + await withEnv({ COMPOSE_INGEST_PORT: '8080', COMPOSE_PORT: '', PORT: '' }, () => + app.run('ingest', async () => { + // Set before boot() runs — a framework-unaware server (Next standalone) + // binds process.env.PORT, so it must see the port Compute routes to. + portAtBoot = process.env['PORT']; + }), + ); + expect(portAtBoot).toBe('8080'); + }); + + test('run() exposes the default port (3000) when none is configured', async () => { + const app = compute({ name: 'ingest', deps: {}, build }); + let portAtBoot: string | undefined; + await withEnv({ COMPOSE_INGEST_PORT: '', COMPOSE_PORT: '', PORT: '' }, () => + app.run('ingest', async () => { + portAtBoot = process.env['PORT']; + }), + ); + expect(portAtBoot).toBe('3000'); + }); }); describe('bootstrapService(service, config, boot) — the in-process integration seam', () => { diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index 46c6945b..c7f73d19 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -85,7 +85,7 @@ describe('invariant 2: authoring imports stay lean (core + pack)', () => { }); describe('invariant 4: environment touches are confined to the config serializer and the control factory', () => { - test("the process-env token appears only in serializer.ts (param read+stash, secret double-lookup+stash), control.ts's prismaCloud(), and preflight.ts (shell token + fill-missing lookup) (the extension factory's env read, ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; ADR-0019 — PRISMA_PROJECT_ID + optional PRISMA_BRANCH_ID)", () => { + test("the process-env token appears only in serializer.ts (param read+stash, secret double-lookup+stash), control.ts's prismaCloud(), preflight.ts (shell token + fill-missing lookup), and compute.ts (boot exposes the resolved port as PORT) (the extension factory's env read, ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; ADR-0019 — PRISMA_PROJECT_ID + optional PRISMA_BRANCH_ID)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); @@ -96,6 +96,7 @@ describe('invariant 4: environment touches are confined to the config serializer }); expect(hits.sort((a, b) => a.file.localeCompare(b.file))).toEqual([ + { file: 'compute.ts', count: 1 }, { file: 'control.ts', count: 4 }, { file: 'preflight.ts', count: 2 }, { file: 'serializer.ts', count: 6 }, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/compute.ts index f21523ea..b14e74e4 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/compute.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/compute.ts @@ -95,10 +95,19 @@ export const compute = < const runnable = { ...node, async run(address: string, boot: () => Promise) { - stash(node, deserialize(node, address)); + const config = deserialize(node, address); + stash(node, config); // Re-emit the secret POINTERS address-free too, so secrets() double-looks-up // the same way with no address (the value stays only in its platform var). stashSecrets(node, address); + // Expose the resolved service port under the near-universal PORT convention, + // so a framework-unaware server (Next.js's standalone server.js binds the + // PORT env var) listens on the port Compute routes to — not its own default. + // A server that reads config().port explicitly (e.g. a Bun HTTP listener) + // simply ignores it. Read the reserved `port` param the same way serialize + // does (descriptors/compute.ts). + const port = config.service['port']; + if (typeof port === 'number') process.env['PORT'] = String(port); return boot(); }, load() { From d4661f663f036a07afcc5571d9dbb67c49e443c0 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 15 Jul 2026 10:44:48 +0200 Subject: [PATCH 19/19] docs(adr): drop ADR-0029 Status section (follows the no-Status convention) Signed-off-by: willbot Signed-off-by: Will Madden --- .../90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md b/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md index 5219f049..07f8e8d2 100644 --- a/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md +++ b/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md @@ -1,9 +1,5 @@ # ADR-0029: Secrets are a forwardable slot -## Status - -Proposed - ## Context A service needs a secret — a signing key, an API token — whose value the platform