diff --git a/.github/workflows/e2e-deploy.yml b/.github/workflows/e2e-deploy.yml index 648b7e38..14247779 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 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 + # 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/docs/design/10-domains/config-params.md b/docs/design/10-domains/config-params.md index a8f865a4..f6bbd16b 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-a-forwardable-slot.md) (a secret +is a distinct forwardable slot, read through `secrets()`). ## The problem in one example @@ -46,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. @@ -95,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 @@ -135,12 +139,76 @@ Job[] → Config (structured) → target serialize (its medium) → stored confi → target deserialize → schema-validated Job[] → config() ``` +## Secrets + +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 +// 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 (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: +const { signingKey } = service.secrets(); // SecretBox +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. `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: + +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_AUTH_SIGNINGKEY = "AUTH_SIGNING_KEY" + ``` +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-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 own semantics: `PATCH` the value, then create a new version. + +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 @@ -153,8 +221,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 @@ -162,6 +231,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-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. - [`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-a-forwardable-slot.md b/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md new file mode 100644 index 00000000..5219f049 --- /dev/null +++ b/docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md @@ -0,0 +1,103 @@ +# 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()`** (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')`** (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. + `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. + +**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 + 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-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 + model, with a § Secrets summary. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index a4f1b278..1cb5a2f2 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-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)* diff --git a/examples/storefront-auth/module.test.ts b/examples/storefront-auth/module.test.ts new file mode 100644 index 00000000..ae2fcbbe --- /dev/null +++ b/examples/storefront-auth/module.test.ts @@ -0,0 +1,18 @@ +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', () => { + 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.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 f5f81862..8b25c606 100644 --- a/examples/storefront-auth/module.ts +++ b/examples/storefront-auth/module.ts @@ -1,4 +1,5 @@ import { module } from '@prisma/compose'; +import { envSecret } from '@prisma/compose-prisma-cloud'; import authModule from '@storefront-auth/auth'; import storefrontService from '@storefront-auth/storefront'; @@ -10,6 +11,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/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/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 02c1384f..3358bab9 100644 --- a/examples/storefront-auth/modules/auth/src/server.ts +++ b/examples/storefront-auth/modules/auth/src/server.ts @@ -9,6 +9,16 @@ 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 { signingKey } = service.secrets(); // signingKey: SecretBox — redacts everywhere but expose() + +// 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, // so it is one pool per process. idleTimeout closes the pooled connection @@ -36,6 +46,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: 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 786d7ff7..f5861c70 100644 --- a/examples/storefront-auth/modules/auth/src/service.ts +++ b/examples/storefront-auth/modules/auth/src/service.ts @@ -1,3 +1,4 @@ +import { secret } 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 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 }, }); 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/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/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 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"] } 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 new file mode 100644 index 00000000..108c13e8 --- /dev/null +++ b/packages/0-framework/1-core/core/src/__tests__/config.test-d.ts @@ -0,0 +1,48 @@ +/** + * Type-level rules for the secret slot vocabulary (ADR-0029): `secret()` is a + * 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 { module, secret, secretSource, service } from '../node.ts'; + +const build: BuildAdapter = { + extension: '@prisma/compose/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; + +test('secret() is a SecretNeed; secretSource() is a SecretSource', () => { + expectTypeOf(secret()).toEqualTypeOf(); + expectTypeOf(secretSource('AUTH_SIGNING_KEY')).toEqualTypeOf>(); +}); + +test('SecretValues maps each declared slot to a SecretBox', () => { + type S = { signingKey: SecretNeed; apiKey: SecretNeed }; + expectTypeOf>().toEqualTypeOf<{ + readonly signingKey: SecretBox; + readonly apiKey: SecretBox; + }>(); +}); + +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: 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 67985521..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,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { configOf, number, string } from '../config.ts'; -import { dependency, service } from '../node.ts'; +import { configOf, number, provisionManifest, string } from '../config.ts'; +import { Load } from '../graph.ts'; +import { dependency, module, secret, secretSource, service } from '../node.ts'; import { conn, scalarDeclaration } from './helpers.ts'; const build = { @@ -20,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 }) }, @@ -31,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 }), ]); @@ -97,33 +95,79 @@ 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', () => { + test('a secret slot is NOT a config param — configOf never reports it', () => { const root = service({ - name: 'test-service', + name: 'ingest', 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() }, () => ({})), - }), - }, + inputs: {}, params: { port: number({ default: 3000 }) }, + secrets: { stripeKey: secret() }, build, }); - expect(configOf(root)).toEqual([ - scalarDeclaration({ input: 'db' }, 'url', { secret: true }), - scalarDeclaration({ input: 'auth' }, 'url'), - scalarDeclaration('service', 'port', { default: 3000 }), - ]); + expect(configOf(root)).toEqual([scalarDeclaration('service', 'port', { default: 3000 })]); + }); +}); + +describe('provisionManifest', () => { + test('aggregates the root-bound secret names across the graph', () => { + const ingest = service({ + name: 'ingest', + extension: 'test/pack', + type: 'fake/app', + inputs: {}, + params: {}, + secrets: { stripeKey: secret() }, + build, + }); + const web = service({ + name: 'web', + extension: 'test/pack', + type: 'fake/app', + inputs: {}, + params: {}, + secrets: { sendgrid: secret() }, + build, + }); + const graph = Load( + module('app', ({ provision }) => { + 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( + 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', () => { + const svc = service({ + name: 'x', + extension: 'test/pack', + type: 'fake/app', + inputs: {}, + params: { port: number({ default: 3000 }) }, + build, + }); + const graph = Load( + module('app', ({ provision }) => { + provision(svc, { id: 'x' }); + }), + ); + + expect(provisionManifest(graph)).toEqual([]); }); }); 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 a4ca4b28..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,13 +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 } = {}, + opts: { optional?: boolean; default?: unknown } = {}, ): ConfigDeclaration { return { owner, name, schema: { vendor: '@prisma/compose' }, - secret: opts.secret ?? false, 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__/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..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> => ({ @@ -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); @@ -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/__tests__/secrets.test.ts b/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts new file mode 100644 index 00000000..99b972f8 --- /dev/null +++ b/packages/0-framework/1-core/core/src/__tests__/secrets.test.ts @@ -0,0 +1,195 @@ +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 { isSecretSource, module, resource, secret, secretSource, service } from '../node.ts'; +import { providerContract } from './helpers.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('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); + }); +}); + +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: secretSource('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)', () => { + 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: secretSource('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', () => { + 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: secretSource('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 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( + 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/); + }); + + 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: secretSource('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: secretSource('K'), bogus: secretSource('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 = secretSource('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/__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/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 15a064b9..d7f5cb9c 100644 --- a/packages/0-framework/1-core/core/src/config.ts +++ b/packages/0-framework/1-core/core/src/config.ts @@ -6,8 +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, SecretBinding } from './graph-types.ts'; import type { ServiceNode } from './node.ts'; /** @@ -20,8 +24,6 @@ import type { ServiceNode } from './node.ts'; */ export interface ConfigParam { readonly schema: S; - /** Redacted in any introspection output. */ - readonly secret?: boolean; readonly optional?: boolean; readonly default?: StandardSchemaV1.InferOutput; } @@ -50,17 +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; readonly optional: boolean; readonly default: unknown; } @@ -111,7 +112,6 @@ export function configOf(root: ServiceNode): readonly ConfigDeclaration[] { owner: { input }, name, schema: projectSchema(param.schema), - secret: param.secret === true, optional: param.optional === true, default: param.default, }); @@ -123,7 +123,6 @@ export function configOf(root: ServiceNode): readonly ConfigDeclaration[] { owner: 'service', name, schema: projectSchema(param.schema), - secret: param.secret === true, optional: param.optional === true, default: param.default, }); @@ -132,6 +131,17 @@ export function configOf(root: ServiceNode): readonly ConfigDeclaration[] { return entries; } +/** + * The app's provision manifest: every secret binding the root resolved across + * 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; +} + // ——— Param constructors: plain data, target-agnostic (ADR-0018/0019). ——— // // A param is just a schema plus facets; serialization is the deploy target's @@ -163,7 +173,6 @@ const numberSchema = scalarSchema( ); export interface ParamOptions { - readonly secret?: boolean; readonly optional?: boolean; readonly default?: T; } @@ -174,7 +183,6 @@ function withFacets( ): ConfigParam { return { schema, - ...(opts.secret !== undefined ? { secret: opts.secret } : {}), ...(opts.optional !== undefined ? { optional: opts.optional } : {}), ...(opts.default !== undefined ? { default: opts.default } : {}), }; diff --git a/packages/0-framework/1-core/core/src/deploy.ts b/packages/0-framework/1-core/core/src/deploy.ts index de969d93..d73ed716 100644 --- a/packages/0-framework/1-core/core/src/deploy.ts +++ b/packages/0-framework/1-core/core/src/deploy.ts @@ -307,8 +307,9 @@ export function lowering( ); } + const service = node as ServiceNode; 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) { 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..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; @@ -22,11 +22,29 @@ export interface Edge { readonly kind: 'input' | 'dependency'; } +/** + * 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 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 { 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 opaque source. */ + 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 f42eeb92..e984c1c7 100644 --- a/packages/0-framework/1-core/core/src/index.ts +++ b/packages/0-framework/1-core/core/src/index.ts @@ -5,6 +5,8 @@ * 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, @@ -13,11 +15,11 @@ export type { Params, Values, } from './config.ts'; -export { configOf, number, param, 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, @@ -34,14 +36,22 @@ export type { RefPort, ResourceNode, RunnableServiceNode, + SecretBindings, + SecretNeed, + SecretSource, + Secrets, + SecretValues, ServiceNode, } from './node.ts'; export { dependency, freezeNode, isNode, + isSecretSource, module, 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 d8bdc1eb..ed66df5e 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, source: bound }); + } + } 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..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,11 +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, + 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..cd3dd558 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,62 @@ 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 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; + /** 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. */ +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 }); +} + +/** 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). */ +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 +113,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 +125,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 +142,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 +168,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 +247,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 +261,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 +327,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 +343,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 +357,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 +452,36 @@ 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'); + // 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, @@ -385,6 +490,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 +533,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 +542,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 +577,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, 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/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..d4508800 --- /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_ key'); + } + // 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..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,12 +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 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. + // 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; @@ -72,7 +70,19 @@ 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}" (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; + } } 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..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,8 +87,8 @@ 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 { compute, envSecret, postgres } = await import('../index.ts'); +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' }, }; @@ -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,53 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { }); }); + 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. + { PRISMA_BRANCH_ID: undefined, STRIPE_SECRET_KEY: 'sk_live_should_not_leak' }, + () => { + const target = prismaCloud({ workspaceId: 'ws_1' }); + const node = compute({ + name: 'ingest', + deps: {}, + secrets: { stripeKey: secret() }, + build: { + extension: '@prisma/compose/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', + }, + }); + // The root bound the slot to STRIPE_SECRET_KEY — it rides on graph.secrets. + const graph = { + 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' } }; + 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 pointer row holds the bound platform NAME, 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' }); @@ -383,7 +430,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; @@ -392,10 +439,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', @@ -418,7 +465,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. @@ -465,7 +512,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 +595,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..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 @@ -1,21 +1,29 @@ 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, + 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 } = {}, + opts: { optional?: boolean; default?: unknown } = {}, ): ConfigDeclaration { return { owner, name, schema: { vendor: '@prisma/compose' }, - secret: opts.secret ?? false, optional: opts.optional ?? false, default: opts.default, }; @@ -57,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); @@ -65,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)', () => { @@ -122,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 })', () => { @@ -158,7 +178,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 +189,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 +204,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', () => { @@ -199,12 +219,11 @@ describe("the config serializer (shared by run() and /control's serialize)", () owner: { input: 'auth' }, name: 'url', schema: {}, - secret: false, optional: false, default: undefined, }; - expect(configKey('storefront', decl)).toBe('STOREFRONT_AUTH_URL'); + expect(configKey('storefront', decl)).toBe('COMPOSE_STOREFRONT_AUTH_URL'); void app; }); @@ -217,7 +236,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 +275,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 +319,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 +346,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 +361,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(); }), @@ -359,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', () => { @@ -367,7 +417,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 +437,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 +482,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(); @@ -445,7 +495,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: { @@ -455,7 +505,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'); @@ -480,7 +530,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 +578,72 @@ describe('structured params + target-owned serialization (ADR-0018/0019)', () => expect(encode('service', [{ jobId: 'tick' }])).toBe('[{"jobId":"tick"}]'); }); }); + +describe('secret slots — pointer rows + boot double-lookup (ADR-0029)', () => { + const secretApp = () => + compute({ name: 'ingest', deps: {}, secrets: { stripeKey: secret() }, build }); + + test('secretKey derives COMPOSE__', () => { + expect(secretKey('', 'stripeKey')).toBe('COMPOSE_STRIPEKEY'); + expect(secretKey('ingest', 'stripeKey')).toBe('COMPOSE_INGEST_STRIPEKEY'); + }); + + test('the pointer key holds the platform NAME; deserializeSecrets double-looks-up the value', async () => { + const app = secretApp(); + await withEnv( + { [secretKey('', 'stripeKey')]: 'STRIPE_SECRET_KEY', STRIPE_SECRET_KEY: 'sk_live_abc' }, + () => { + expect(deserializeSecrets(app, '')).toEqual({ stripeKey: 'sk_live_abc' }); + }, + ); + }); + + test('a missing pointer row fails loudly', async () => { + const app = secretApp(); + await withEnv({}, () => { + expect(() => deserializeSecrets(app, '')).toThrow(/missing secret pointer/); + }); + }); + + test('a missing platform var fails loudly, naming both keys', async () => { + const app = secretApp(); + await withEnv({ [secretKey('', 'stripeKey')]: 'STRIPE_SECRET_KEY' }, () => { + expect(() => deserializeSecrets(app, '')).toThrow(/STRIPE_SECRET_KEY/); + }); + }); + + test('an empty platform var fails loudly — empty is unresolved', async () => { + const app = secretApp(); + await withEnv( + { [secretKey('', 'stripeKey')]: 'STRIPE_SECRET_KEY', STRIPE_SECRET_KEY: '' }, + () => { + expect(() => deserializeSecrets(app, '')).toThrow(/unset or empty/); + }, + ); + }); + + test('secrets() returns a redacting SecretBox; it survives run() → stashSecrets → secrets() (the stash trap)', async () => { + const app = secretApp(); + let box: SecretBox | undefined; + await withEnv( + { + // 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 stashSecrets/stash (over)write — tracked so withEnv restores them: + COMPOSE_STRIPEKEY: '', + COMPOSE_PORT: '', + }, + () => + app.run('ingest', async () => { + box = app.secrets().stripeKey; + }), + ); + 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 d4a59a6b..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 (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 (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,8 +96,10 @@ 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: 'serializer.ts', count: 2 }, + { file: 'preflight.ts', count: 2 }, + { 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 new file mode 100644 index 00000000..757d798f --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts @@ -0,0 +1,321 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +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', + 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 = () => + Load( + 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', ({ provision }) => { + provision(compute({ name: 'ingest', deps: {}, build }), { id: 'ingest' }); + }), + ); + +/** Two services binding the SAME platform name — the manifest dedups it. */ +const sharedSecretGraph = () => + Load( + 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') }, + }); + }), + ); + +/** 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('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); + }); + + 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('the same platform name bound by two services is checked once and named in the failure', 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('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/__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/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/compute.ts index 225aea49..b14e74e4 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; @@ -89,7 +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() { @@ -110,11 +128,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/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/descriptors/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts index af9dbf2d..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, encode, paramEntries } 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,26 +26,46 @@ 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. - 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, value: encode(d.owner, value), - class: o.branchId ? 'preview' : 'production', - ...(o.branchId !== undefined ? { branchId: o.branchId } : {}), + 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/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/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 new file mode 100644 index 00000000..cc2d1fb4 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts @@ -0,0 +1,212 @@ +/** + * 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, + * 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'; +import { secretName } from './secret.ts'; + +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. + */ +/** 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, + 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/cursor' + >(query), + }, + }); + 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 }, + ); + 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; + cursor = page.pagination.hasMore ? page.pagination.nextCursor : null; + } while (cursor !== null); + return false; +} + +/** + * 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 fillFailedError(key, res.error); + } +} + +interface MissingSecret { + 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.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` + + '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 tokenRequiredError(); + 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 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(secretName(binding))) { + names.set(secretName(binding), { + name: secretName(binding), + 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.name)) continue; + const shellValue = process.env[meta.name]; + if (shellValue !== undefined && shellValue.length > 0) { + await fillMissing(client, input, meta.name, shellValue); + continue; + } + 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/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 54245a8b..d7947694 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts @@ -20,9 +20,10 @@ * 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'; +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() @@ -69,7 +70,12 @@ 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(); }; /** @@ -114,7 +120,7 @@ function coerce(raw: string | undefined, d: ParamEntry, key: string): unknown { /** * 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 = {}; @@ -154,6 +160,89 @@ export const stash = (node: ServiceNode, config: Config): void => { } }; +// ——— 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: secretName(binding) }); + } + 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; + } +}; + /** Synchronous Standard Schema validation — see the matching note in core's `config.ts`. */ function standardValidateSync( schema: S,