diff --git a/docs/guide/auth.md b/docs/guide/auth.md index bc2595f8d..c08903bde 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -249,6 +249,14 @@ Void manages Better Auth migrations as part of the normal Void migration flow: - deploy runs auth migrations together with app migrations - users do not run a separate Better Auth CLI path +This applies to `void deploy` (the managed platform), which creates the Better Auth +tables at runtime after dispatch. Deploying to your own Cloudflare account with +[`--backend cloudflare`](/integrations/cloudflare) does **not** run that step: there, +your checked-in `db/migrations/*.sql` must already produce the Better Auth schema, and +deploy fails closed if they do not. Define the tables in `db/schema.ts` and run +`void db generate` — Drizzle's `.unique()` and `.references()` are opt-in, and the +constraints are verified too. See [#274](https://github.com/voidzero-dev/void/issues/274). + ## Unsupported Modes Void-managed Better Auth is supported only for Cloudflare Void apps in v1. diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 638a16c63..fd26f2b26 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -106,6 +106,6 @@ Connect the repository to your project once with `void github connect ## Other Targets -If you prefer to deploy directly to your own Cloudflare account instead of using Void's managed platform, see the [Cloudflare integration guide](../integrations/cloudflare.md). +If you prefer to deploy directly to your own Cloudflare account instead of using Void's managed platform, run `void deploy --backend cloudflare` (add `--provision` on the first deploy to create the D1/KV/R2/Queues/Hyperdrive resources your app needs). This uses your local wrangler auth and root `wrangler.jsonc` -- pin an account (`account_id` or `CLOUDFLARE_ACCOUNT_ID`) and keep real secrets in `wrangler secret put` rather than `.env`. `wrangler login` is enough to authenticate, with one exception: provisioning a **Hyperdrive** config for the first time needs `CLOUDFLARE_API_TOKEN`, because wrangler exposes no machine-readable Hyperdrive list for Void to check against (an already-provisioned Hyperdrive app deploys fine under `wrangler login`). In v1 it deploys **full Void apps on the Cloudflare Workers target** (D1/KV/R2/Queues/Hyperdrive; auth and ISR on D1/SQLite); framework SSR (TanStack Start, React Router, vinext, SvelteKit, Nuxt, Analog, Astro), static/SPA/SSG apps (including `output: "static"`), WebSocket/Durable Object apps (`*.ws.ts`), a custom `migrations_pattern`, `--skip-build`, `node`/`bun`/`deno` targets, and PostgreSQL apps with auth all fail closed with guidance. Auth apps must ship checked-in migrations that produce the Better Auth schema. Provision is a single-operator, dev-machine step and is disabled in CI. See the [Cloudflare integration guide](../integrations/cloudflare.md#deploy-to-your-own-cloudflare-account) for the full walk-through and scope. To deploy to Node.js, Bun, or Deno instead of Cloudflare, set [`target`](../reference/config.md) in `void.json`. This builds a standalone server you can run anywhere, including Docker, Railway, and Fly.io. These targets do not have access to Void platform features such as D1, KV, R2, built-in auth, Workers AI, or cron scheduling. See the [Node.js, Bun, and Deno guide](../integrations/nodejs-bun-deno.md). diff --git a/docs/guide/env-vars.md b/docs/guide/env-vars.md index ee04a0060..8df3417a9 100644 --- a/docs/guide/env-vars.md +++ b/docs/guide/env-vars.md @@ -109,15 +109,23 @@ In practice `env.ts` should only import schema helpers from `void/env` and — a Void uses Vite's standard `.env` convention to populate the schema: -| File | Loaded in dev | Shipped on deploy | -| ----------------------- | ------------- | ------------------ | -| `.env` | yes | yes (`plain_text`) | -| `.env.local` | yes | no | -| `.env.production` | yes | yes (`plain_text`) | -| `.env.production.local` | yes | no | +Whether a file's values ship depends on which deploy path you use: + +| File | Loaded in dev | Shipped by `void deploy` | Shipped by `--backend cloudflare` | +| ----------------------- | ------------- | ------------------------ | --------------------------------- | +| `.env` | yes | yes (`plain_text`) | yes (worker `vars`) | +| `.env.local` | yes | no | **yes** (worker `vars`) | +| `.env.production` | yes | yes (`plain_text`) | yes (worker `vars`) | +| `.env.production.local` | yes | no | **yes** (worker `vars`) | `.local` files are gitignored by convention — use them for secrets you don't want in source control. +::: warning `.local` files are not deploy-excluded on `--backend cloudflare` +Managed `void deploy` reads only `.env` and `.env.production`, so a `.local` file keeps secrets off the platform. The self-host `void deploy --backend cloudflare` path instead runs Vite's production env loading, which reads all four files and bakes them into the worker's `vars` as plaintext. A value that is also `export`ed in the shell with the same value is stripped back out, so this bites hardest in CI, where the variable usually only exists in the file. + +On that path, keep real secrets out of every `.env*` file and use `wrangler secret put ` instead. See [Cloudflare deploy](/integrations/cloudflare). +::: + ### Dotenv variable expansion Values can reference other keys defined in the same (or earlier-precedence) `.env` file using `${VAR}` or `$VAR`: diff --git a/docs/guide/queues.md b/docs/guide/queues.md index 6223a96fd..a61295a97 100644 --- a/docs/guide/queues.md +++ b/docs/guide/queues.md @@ -10,6 +10,10 @@ Void supports Cloudflare Queues for asynchronous message processing from a top-l Create files in `queues/**/*.ts`; `.mts`, `.js`, and `.mjs` also work. The queue name is inferred from the filename. For example, `queues/emails.ts` creates a queue named `"emails"`, and `queues/order/notifications.ts` creates `"order/notifications"`. +::: warning Nested files produce a name that cannot be deployed +Cloudflare queue names allow only letters, digits and `-`, so a name containing `/` is rejected when the queue is provisioned — on both the managed platform and a self-hosted `--backend cloudflare` deploy. Nested files work in local development, but keep queue files flat (`queues/order-notifications.ts` → `"order-notifications"`) for any app you intend to deploy. +::: + Each queue file should export a default handler wrapped with [`defineQueue`](../reference/api.md#definequeuet-handler). The generic `` parameter defines the message body type. That is the type of each `msg.body` in the batch, and it is also used by the typed `queues` proxy for `send()` calls. ```ts diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index a0ff59272..a78a3d018 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -214,9 +214,68 @@ When deploying via `void deploy` (to the Void platform), the `wrangler.json` in ## Deploy to your own Cloudflare account -Void's default deployment path is `void deploy`, which uploads to the Void platform. But the generated worker is a standard Cloudflare Worker -- you can deploy it directly to your own account with `wrangler deploy`. +Void's default deployment path is `void deploy`, which uploads to the Void platform. But the generated worker is a standard Cloudflare Worker -- you can deploy it directly to your own account. Two paths get you there: -### 1. Create your resources +- **[`void deploy --backend cloudflare`](#one-command-void-deploy-backend-cloudflare)** -- a first-class flow that provisions bindings, applies remote migrations, checks secrets, builds, and runs `wrangler deploy` for you. +- **[Manual `vite build && wrangler deploy`](#manual-build-and-deploy-with-wrangler)** -- create resources and edit `wrangler.jsonc` yourself, then build and deploy with wrangler directly. + +Either way, the [Local development](#local-development), [AI](#ai-self-host), and [ISR](#isr-self-host) notes below apply. + +### One command: `void deploy --backend cloudflare` + +`void deploy --backend cloudflare` deploys the built worker to **your own** Cloudflare account. It uses your local wrangler auth and your root `wrangler.jsonc` -- there is no Void login or linked project. + +```bash +# Deploy using the resources already declared in wrangler.jsonc +void deploy --backend cloudflare + +# Create any missing resources first, then deploy +void deploy --backend cloudflare --provision +``` + +**Prerequisites** + +- **Pin an account.** Set `account_id` in your root `wrangler.jsonc`, or export `CLOUDFLARE_ACCOUNT_ID`. A multi-account token otherwise makes wrangler prompt (or error in CI), which Void cannot intercept -- so the account must be pinned first. +- **Authenticate wrangler.** Run `wrangler login`, or set `CLOUDFLARE_API_TOKEN`. Deploy needs a token with `Workers Scripts:Edit` plus read on the resources you bind. `--provision` additionally needs per-product `*:Edit` (D1, KV, R2, Queues, Hyperdrive). +- **`CLOUDFLARE_API_TOKEN` is required to provision a Hyperdrive config for the first time.** `wrangler login` covers every other resource, but wrangler exposes no machine-readable Hyperdrive list, so Void checks whether the config already exists over the Cloudflare REST API -- which an OAuth session cannot authenticate. Without a token `--provision` stops **before** touching your account rather than risk minting a duplicate config. Either export `CLOUDFLARE_API_TOKEN` (with Hyperdrive edit permission), or create the Hyperdrive config yourself and add its id to the `hyperdrive` binding in `wrangler.jsonc` -- deploying an **already-provisioned** Hyperdrive app needs no token and works under `wrangler login` alone. +- **Docker**, if your app uses the sandbox -- the build needs it locally. + +**What it does** (in order): settles the app class (a full Void app on the Cloudflare Workers target -- see [scope](#scope-and-limitations)) before any account operation; pins the account and confirms wrangler auth; provisions or drift-checks resources; **builds**; then, against the artifact the build actually emitted, warns on plaintext vars, gates on missing required secrets, checks the auth schema, and validates migrations; then applies remote D1 migrations for SQLite apps (and verifies none remain pending); then runs `wrangler deploy` on exactly the artifact it verified. + +Note that **the build runs before the secret, auth-schema, and migration gates** -- those gates read the real emitted worker, so there has to be one to read. A missing production secret or a bad migration is therefore reported _after_ the build has run, which is worth knowing if your build hooks are slow or have side effects. In exchange, nothing remote is touched until every ground-truth gate has passed: remote D1 migrations are applied only afterwards, so a failed gate leaves your account, your database, and your live worker exactly as they were. + +**Trust boundary: the build runs your project's code.** Exactly like the manual `vite build && wrangler deploy` below, this backend runs your project's build -- your config, every Vite plugin, and every build dependency -- with filesystem access before the credentialed deploy. Deploy narrows what that build can quietly change: it runs the build with your Cloudflare credentials scrubbed, verifies the emitted `wrangler.json` against a pre-build snapshot of your account, name, and binding identities, and checks that the wrangler CLI it is about to run with your token was not modified during the build. Those checks catch a build that tampers with the deploy target or the uploader. They do not sandbox the build itself, so a fully-compromised build dependency remains a trust boundary -- the same one you accept running `vite build` by hand. Vet your dependencies as you would for any deploy; stronger build isolation is future work. + +Without `--provision`, deploy is a **drift check**: if a binding your source needs is not yet in `wrangler.jsonc` with a real id, deploy stops and names each missing resource, telling you to run `--provision` once. Queues carry no id in the config, so deploy instead checks each one against your account (`wrangler queues info`) at the same point -- a queue that does not exist stops the deploy before anything is built or migrated. + +**`--provision`** creates any D1 database, KV namespace, R2 bucket, Queues, Hyperdrive config, and the ISR cache namespace your source needs, then lets wrangler write the real ids into your root `wrangler.jsonc`. It is **idempotent** -- it reads existing ids first, so re-running creates nothing that already exists. + +#### Scope and limitations + +- **Supported apps (v1): full Void apps on the Cloudflare Workers target only** -- worker-bearing apps that run Void's routing (`routes/` and/or `pages/`), with D1 (SQLite) and/or KV, R2, Queues, and Hyperdrive, plus auth on D1/SQLite and ISR. Everything else fails closed **before** any account operation, with guidance: + - **Framework SSR is not supported here (yet).** The whole framework path -- TanStack Start, React Router, vinext (and SvelteKit, Nuxt, Analog, Astro) -- is deferred in v1. Deploy them with `void deploy` (the managed platform) or the framework's own Cloudflare adapter. Framework SSR support for this backend is a follow-up. + - **Static-only / SPA / SSG apps are not supported here** -- including unconfigured ones, `output: "static"` in `void.json`, and `--dir` / `--spa` deploys. Deploy those with `void deploy` (the managed platform) or host the built assets on Cloudflare Pages / any static host. + - **WebSocket / Durable Object routes (`*.ws.ts`) are not supported here (yet).** A WebSocket route makes the build emit a key-value-backed Durable Object namespace (a `new_classes` migration), which a fresh Cloudflare account -- and every Workers Free account -- refuses to create. Deploy those with `void deploy`. SQLite-backed WebSocket support for this backend is a follow-up. + - **Custom `migrations_pattern` is not supported here.** v1 deploys only Void's default migration convention -- `db/migrations/*.sql` with wrangler's default pattern (leave `migrations_pattern` unset). Remove a custom `migrations_pattern` from the D1 binding, or deploy with `void deploy`. Supporting custom patterns is a follow-up. + - **Non-Cloudflare targets (`node` / `bun` / `deno`) are not supported here** -- this backend deploys Cloudflare Workers only. + - **`--skip-build` is not supported here.** Every check on this path validates the artifact the build emits -- the worker `vars` in `dist/ssr/wrangler.json` and the generated auth schema -- so skipping the build would leave them reading a stale or missing artifact instead of the worker being uploaded. Drop `--skip-build`, or use `void deploy` (the managed platform), which supports it. + - **PostgreSQL apps with auth enabled are not supported here.** This backend verifies + applies auth tables through remote D1 (SQLite) migrations only, so it cannot provision or verify the Better Auth schema on Postgres/Hyperdrive. Deploy with `void deploy`, or use D1 for the auth app. + - **PostgreSQL apps with checked-in migrations are not supported here.** This backend applies only remote D1 (SQLite) migrations. A PostgreSQL app's `db/migrations/*.sql` are applied over Hyperdrive by the managed platform (`void deploy`) at deploy time; this self-host backend never runs them, so it would deploy against an unmigrated database. Deploy with `void deploy`, or apply the migrations yourself against your Postgres and remove them from `db/migrations/`. (A pg app _without_ auth **and** with an externally-managed schema — no checked-in migrations — still deploys.) +- **Auth requires checked-in migrations that produce the Better Auth schema.** The managed platform creates the Better Auth tables at runtime after deploy; this self-host backend never runs that step. Deploy reads the required Better Auth schema from the one the build itself generated (`.void/better-auth-schema.ts`, so configured renames and plugin tables are already reflected), then verifies the checked-in `db/migrations/*.sql` produce it by applying them to an in-memory SQLite database. Names alone are not enough: it also checks the constraints that carry correctness -- every auth table's `id` must be uniquely constrained (a `PRIMARY KEY` or a `UNIQUE` constraint/index; SQLite refuses a foreign key whose parent key is not unique, failing at runtime with `foreign key mismatch`), every field the schema marks `unique` must be covered by a unique constraint (a column-level `UNIQUE` or a single-column unique index both count, under any index name), and every field with a `references` target must have a matching foreign key. Column types, non-unique indexes and the exact `ON DELETE` action are deliberately not asserted. If a check fails, deploy fails closed naming the offending `table.column` -- fix the schema in `db/schema.ts` (Drizzle's `.primaryKey()`, `.unique()` and `.references()` are opt-in), run `void db generate`, commit the migration, or deploy with `void deploy`. +- **Migrations must apply in the same order Void validated.** Deploy checks that the files wrangler will apply to remote D1 (and the order it applies them, by numeric prefix) exactly match Void's `db/migrations/*.sql` set -- no stray files (e.g. `seed.sql`), same content, same order. Zero-pad your migration prefixes (`0001_`, `0002_`, ...) so numeric and lexicographic order agree; deploy fails closed on a mismatch. +- **Provision is a single-operator, dev-machine action.** The lock guarding it is per local config path only; it does not coordinate across machines or CI hosts. Two people provisioning the same account at once could create duplicate resources. +- **Provision fails closed in CI.** In non-interactive shells, `--provision` is disabled unless your committed `wrangler.jsonc` already covers every resource (a provable no-op). The workflow is: provision **locally**, commit the updated `wrangler.jsonc`, then let CI run `void deploy --backend cloudflare` (deploy itself is CI-safe). +- **Your `wrangler.jsonc` is rewritten on provision.** Wrangler preserves your comments but normalizes the whole file's indentation when it writes the new ids -- expect that in the diff. +- **Your `.env*` values ship as plaintext.** The build bakes them into the worker's `vars`. This backend runs Vite's production env loading, so **all four** of `.env`, `.env.local`, `.env.production` and `.env.production.local` are loaded and shipped -- including the `.local` files, which are gitignored but **not** deploy-excluded here (unlike managed `void deploy`, which reads only `.env` and `.env.production`). The one exception: a value that is also present in the shell environment with the same value is stripped back out, so a var you `export` in CI does not get baked in. Move real secrets to `wrangler secret put ` so they are never written into `wrangler.json`. Deploy warns on likely-plaintext secrets -- by key name, and by value shape for credential-bearing connection URLs such as `DATABASE_URL` -- and hard-blocks on missing required secrets. +- **First deploy of a new worker.** A worker that has never been deployed has no remote secrets to list yet, so the secret gate prints the still-unset required key names and the `wrangler secret put ` commands that bootstrap them on the draft worker (or add a value to `.env` / `.env.production` and rerun). + +The manual steps below are the by-hand equivalent -- reach for them when you want to manage resources and `wrangler.jsonc` yourself. + +### Manual: build and deploy with wrangler + +Prefer to manage everything by hand? Create the resources, write `wrangler.jsonc`, and run `wrangler deploy` yourself. + +#### 1. Create your resources Create whatever bindings your app uses: @@ -231,7 +290,7 @@ wrangler kv namespace create KV wrangler r2 bucket create my-app-storage ``` -### 2. Add a `wrangler.jsonc` +#### 2. Add a `wrangler.jsonc` Create `wrangler.jsonc` in your project root with the resource IDs from step 1: @@ -264,7 +323,7 @@ Create `wrangler.jsonc` in your project root with the resource IDs from step 1: Only include the bindings your app actually uses. You can also add service bindings, custom routes, environment overrides, and any other standard wrangler fields -- they all flow through to the build output. You don't need `main` or `assets` -- those are set by the plugin. -### 3. Run migrations +#### 3. Run migrations If your app uses D1, apply migrations before deploying: @@ -274,7 +333,7 @@ wrangler d1 migrations apply my-app-db --remote This uses the same `db/migrations/` directory that Void uses locally. `my-app-db` is the database name from your `wrangler.jsonc`; using the database name avoids accidentally applying migrations to the wrong binding. -### 4. Build and deploy +#### 4. Build and deploy ```bash vite build && wrangler deploy diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 868fccf31..f56b14fd5 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -226,19 +226,22 @@ If `--project` is provided, purges that project's cache instead of the linked pr ``` void deploy [--project ] [--dir ] [--spa] [--skip-build] [--debug] +void deploy --backend cloudflare [--provision] ``` Auto-detects your project type and chooses the right pipeline. See [Supported App Types](../guide/app-types.md) and [Deployment](../guide/deployment.md) for details. For Drizzle projects, deploy performs a read-only schema drift check. If a new migration would be generated, deploy stops and tells you to run `void db generate`, review the migration, commit it yourself, and rerun `void deploy`. -| Flag | Purpose | -| ------------------ | ---------------------------------------------------------------------------- | -| `--project ` | Target a specific project by slug | -| `--dir ` | Deploy a pre-built static directory (skips build) | -| `--spa` | Use SPA mode instead of SSG for static deploys | -| `--skip-build` | Skip the build step (use existing build output) | -| `--debug` | Mirror the structured deploy log to stderr (also written to `~/.void/logs/`) | +| Flag | Purpose | +| ---------------------- | -------------------------------------------------------------------------------------------- | +| `--project ` | Target a specific project by slug; not supported with `--backend cloudflare` | +| `--dir ` | Deploy a pre-built static directory (skips build); not supported with `--backend cloudflare` | +| `--spa` | Use SPA mode instead of SSG for static deploys; not supported with `--backend cloudflare` | +| `--skip-build` | Skip the build step (use existing build output); not supported with `--backend cloudflare` | +| `--backend cloudflare` | Deploy to your own Cloudflare account instead of the Void platform | +| `--provision` | Create missing bindings (D1/KV/R2/Queues/Hyperdrive); requires `--backend cloudflare` | +| `--debug` | Mirror the structured deploy log to stderr (also written to `~/.void/logs/`) | Every deploy writes a structured JSONL trace to `~/.void/logs/deploy-.jsonl` regardless of `--debug`. On failure the path is printed at the end of the error message so you can attach it when reporting platform issues. `VOID_DEPLOY_DEBUG=1` is accepted as an alternate trigger for stderr mirroring. @@ -252,6 +255,37 @@ If no project is linked and no override is provided, CLI prompts to link or crea That fallback is mainly for projects that skipped Void project setup during `void init`. +### `void deploy --backend cloudflare` + +Deploy the built worker straight to **your own** Cloudflare account instead of the Void platform. This path uses your local `wrangler` auth and your root `wrangler.jsonc` — no Void login or linked project is involved. + +``` +void deploy --backend cloudflare # deploy using resources already in wrangler.jsonc +void deploy --backend cloudflare --provision # create any missing resources first, then deploy +``` + +Prerequisites: + +- A Cloudflare account must be **pinned**: set `account_id` in your root `wrangler.jsonc`, or export `CLOUDFLARE_ACCOUNT_ID`. A multi-account token otherwise makes wrangler prompt (or error in CI), which Void cannot intercept. +- Authenticate wrangler (`wrangler login`, or set `CLOUDFLARE_API_TOKEN`). Deploy needs a token with `Workers Scripts:Edit` plus read on the resources you bind; `--provision` additionally needs per-product `*:Edit` (D1, KV, R2, Queues, Hyperdrive). +- `CLOUDFLARE_API_TOKEN` is **required** to provision a Hyperdrive config for the first time — `wrangler login` covers every other resource, but wrangler exposes no machine-readable Hyperdrive list, so Void checks for an existing config over the Cloudflare REST API, which OAuth cannot authenticate. Without a token, `--provision` stops before touching your account. Alternatively create the Hyperdrive config yourself and put its id in `wrangler.jsonc` — deploying an already-provisioned Hyperdrive app needs no token. +- `--skip-build` is **not supported** with `--backend cloudflare`: this backend validates the artifact the build emits (worker `vars` in `dist/ssr/wrangler.json`, the generated auth schema), so there is nothing to check without a fresh build. +- `--project`, `--dir` and `--spa` are **not supported** with `--backend cloudflare` either, and are rejected rather than ignored: no Void project is resolved on this path, and it uploads the worker your build emits rather than a static directory. +- Local Docker is required to build apps that use the sandbox. + +What it does, in order: settles the app class before any account op (v1 supports **full Void apps on the Cloudflare Workers target** -- worker-bearing apps running Void's routing, with D1/KV/R2/Queues/Hyperdrive and, on D1/SQLite, auth + ISR; framework SSR of every kind, static/SPA/SSG apps, node/bun/deno targets, and PostgreSQL apps with auth or with checked-in migrations all fail closed with guidance), pins the account and checks auth, provisions or drift-checks resources, **builds**, then gates on the artifact the build emitted — production secrets checked against the build's effective mode/envDir, the auth schema, and migration validation — then applies remote D1 migrations for SQLite apps (verifying the applied set equals the validated set and none remain pending), and finally runs `wrangler deploy` on exactly the verified artifact. Auth apps must ship checked-in migrations that produce the Better Auth schema — the managed platform's runtime auth-migration step does not run on this backend. + +The build deliberately comes **before** the secret, auth-schema, and migration gates, because those gates inspect the real emitted worker rather than a prediction of it. A missing secret or a bad migration surfaces after the build has run — relevant when a build is expensive or has side effects. Remote D1 migrations run only once every gate has passed, so a failed gate mutates nothing remote. + +`--provision` creates any D1 database, KV namespace, R2 bucket, Queues, Hyperdrive config, and the ISR cache namespace your source needs, then lets wrangler write the real ids into your root `wrangler.jsonc`. It is **idempotent** — re-running creates nothing that already exists (it reads existing ids first). Notes: + +- **Provision is a single-operator, dev-machine action.** The lock that guards it is per local config path only; it does not coordinate across machines. Two people provisioning the same account at once could create duplicate resources. `--provision` also **fails closed in CI / non-interactive shells** unless your committed `wrangler.jsonc` already covers every resource (a provable no-op). Provision locally, commit the updated `wrangler.jsonc`, then let CI run `void deploy --backend cloudflare`. +- **Your `wrangler.jsonc` is rewritten.** When wrangler writes the new ids, it preserves your comments but normalizes the whole file's indentation — expect that in the diff. +- **Your `.env*` values ship as plaintext.** All four of `.env`, `.env.local`, `.env.production` and `.env.production.local` are loaded by this backend and baked into the worker's `vars` — the `.local` files included, unlike managed `void deploy`. A value also present in the shell environment is stripped back out. Move real secrets to `wrangler secret put ` so they are not committed into `wrangler.json`. Deploy warns on likely-plaintext secrets and hard-blocks on missing required secrets. +- **First deploy of a not-yet-deployed worker:** its remote secrets can't be listed yet, so the secret gate prints the required key names and the `wrangler secret put ` commands to bootstrap them on the draft worker before deploying (or add a value to `.env` / `.env.production` and rerun). + +See the [Cloudflare integration guide](../integrations/cloudflare.md#deploy-to-your-own-cloudflare-account) for the full walk-through. + ## Database ### `void db push` diff --git a/packages/void/AGENT_PROMPT.md b/packages/void/AGENT_PROMPT.md index a69239e7a..d35b7953f 100644 --- a/packages/void/AGENT_PROMPT.md +++ b/packages/void/AGENT_PROMPT.md @@ -12,4 +12,6 @@ Rewrites and redirects: declare static rules in `void.json` under `routing.redir Logs: surface app-level errors that should show up under `void project logs --level error` via `import { logger } from "void/log"` and `logger.error(msg, fields?)` (also `.warn` / `.info`). Anything caught and only persisted to your own DB is invisible to Cloudflare Tail; route it through `logger.*` or `console.*` so the platform can see it. +Self-host deploy: `void deploy` targets the Void platform; to deploy to the user's OWN Cloudflare account instead, use `void deploy --backend cloudflare` (add `--provision` on the first deploy to create the D1/KV/R2/Queues/Hyperdrive resources the source needs). It uses local wrangler auth, so pin an account (`account_id` in `wrangler.jsonc` or `CLOUDFLARE_ACCOUNT_ID`) and keep real secrets in `wrangler secret put` — every `.env*` file this backend loads (`.env`, `.env.local`, `.env.production`, `.env.production.local`) ships as plaintext worker vars, `.local` included. Provisioning a Hyperdrive config reads `DATABASE_URL` from the shell, not from `.env*`. v1 supports full Void apps on the Cloudflare Workers target only (D1/KV/R2/Queues/Hyperdrive; auth and ISR on D1/SQLite); framework SSR of every kind (TanStack Start, React Router, vinext, SvelteKit, Nuxt, Analog, Astro), static/SPA/SSG apps (including `output: "static"`), WebSocket/Durable Object apps (`*.ws.ts`), a custom `migrations_pattern`, node/bun/deno targets, and PostgreSQL apps with auth all fail closed with guidance. Auth apps must ship checked-in migrations that produce the Better Auth schema. `--provision` is a single-operator, dev-machine step and is disabled in CI. + Full docs are in `node_modules/void/docs/`. If you have the `void` skill available, use it for a complete API reference covering project structure, routing, pages mode, database, auth, typed fetch, KV, storage, queues, cron jobs, CLI, configuration, and deployment. diff --git a/packages/void/getting-started-prompt.txt b/packages/void/getting-started-prompt.txt index c777c51ed..977d97fdb 100644 --- a/packages/void/getting-started-prompt.txt +++ b/packages/void/getting-started-prompt.txt @@ -15,6 +15,8 @@ At the end of the interactive flow, `void init` can also log you in and link or void deploy +To deploy to your OWN Cloudflare account instead of the Void platform, use `void deploy --backend cloudflare` (add `--provision` on the first deploy to create the D1/KV/R2/Queues/Hyperdrive resources your app needs). Pin an account first (`account_id` in `wrangler.jsonc` or `CLOUDFLARE_ACCOUNT_ID`) and keep real secrets in `wrangler secret put` — `.env` values ship as plaintext worker vars. v1 deploys full Void apps on the Cloudflare Workers target only (D1/KV/R2/Queues/Hyperdrive; auth and ISR on D1/SQLite); framework SSR of every kind (TanStack Start, React Router, vinext, SvelteKit, Nuxt, Analog, Astro), static/SPA/SSG apps (including output: "static"), WebSocket/Durable Object apps (*.ws.ts), a custom migrations_pattern, node/bun/deno targets, and PostgreSQL apps with auth all fail closed with guidance, and auth apps need checked-in migrations that produce the Better Auth schema. `--provision` is a single-operator, dev-machine step and is disabled in CI. + Database workflow: define Drizzle tables in `db/schema.ts`, import `db` from `void/db` and tables from `@schema`. Use `void db push` to prototype (applies schema directly, no migration files). Use `void db generate` when ready to create production migration files in `db/migrations/`. `drizzle-orm` and `drizzle-kit` ship with void — no extra install needed. Scaffold new tables with `void gen model [columns...]`. Routing extras: declare static redirects/rewrites/fallbacks in `void.json` under `routing.*`, or in a `public/_redirects` file. For dynamic rewrites, call `c.rewrite(path)` in a `defineMiddleware`. diff --git a/packages/void/package.json b/packages/void/package.json index d00e86611..d11994dc0 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -332,6 +332,7 @@ "ignore": "^7.0.5", "jsonc-parser": "3.3.1", "pg": "8.22.0", + "proper-lockfile": "^4.1.2", "wrangler": "catalog:" }, "devDependencies": { @@ -340,6 +341,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/node": "catalog:", "@types/pg": "^8.20.0", + "@types/proper-lockfile": "^4.1.4", "@typescript/native-preview": "catalog:", "arktype": "^2.2.2", "estree-walker": "^3.0.3", diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index 392b9357c..0de3be5ee 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -32,39 +32,40 @@ Then ask what to do next. ## Task Routing -| User intent | Docs file(s) | -| ----------------------------------------- | ----------------------------------------------------------------------------------------- | -| CLI command syntax, flags, env vars | `docs/reference/cli.md` | -| Initial setup, onboarding, first app | `docs/guide/quickstart.md`, `docs/reference/cli.md` | -| App type detection and mode behavior | `docs/guide/app-types.md`, `docs/reference/config.md` | -| Server/API routing and middleware | `docs/guide/server-routing.md`, `docs/integrations/hono.md` | -| Pages mode, loader/action, forms, layouts | `docs/guide/pages-routing/*.md`, `docs/guide/type-safety.md` | -| Database and migrations | `docs/guide/database.md`, `docs/guide/type-safety.md` | -| Typed fetch and end-to-end typing | `docs/guide/typed-fetch.md`, `docs/guide/type-safety.md` | -| Authentication | `docs/guide/auth.md`, `docs/guide/env-vars.md` | -| Cloudflare runtime bindings and config | `docs/integrations/cloudflare.md`, `docs/reference/config.md`, `docs/guide/env-vars.md` | -| AI inference (Workers AI, providers) | `docs/guide/ai.md` | -| KV / storage / queues / cron jobs | `docs/guide/kv.md`, `docs/guide/storage.md`, `docs/guide/queues.md`, `docs/guide/jobs.md` | -| SSR and caching | `docs/guide/ssr.md`, `docs/guide/edge/*.md` | -| Rewrites, redirects, fallbacks | `docs/guide/edge/rewrites.md`, `docs/guide/edge/redirects.md`, `docs/reference/config.md` | -| Static site generation | `docs/guide/ssg.md` | -| Deployment and CI | `docs/guide/deployment.md`, `docs/reference/cli.md` | -| Project status, deployment history | `docs/reference/cli.md` | -| Cache purging | `docs/reference/cli.md` | -| Project logs, runtime errors | `docs/reference/cli.md` | -| Secrets management (put/sync/delete) | `docs/reference/cli.md`, `docs/guide/env-vars.md` | -| Typed env vars (`defineEnv`, `env.ts`) | `docs/guide/env-vars.md` | -| Custom domain setup | `docs/reference/cli.md` | -| Database status, reset, seed, export | `docs/reference/cli.md`, `docs/guide/database.md` | -| Auth login/logout/whoami | `docs/reference/cli.md` | -| Overview / introduction | `docs/guide/index.md` | -| API surface details | `docs/reference/api.md` | -| Meta framework integration | `docs/integrations/frameworks/*.md` | -| Coding agent setup | `docs/integrations/agents.md` | -| Node.js / Bun / Deno targets | `docs/integrations/nodejs-bun-deno.md` | -| ORMs and external databases | `docs/integrations/orms-and-external-dbs.md` | -| Project structure and conventions | `docs/reference/structure.md` | -| Resource/binding inference | `docs/reference/resource-inference.md` | +| User intent | Docs file(s) | +| ------------------------------------------ | ----------------------------------------------------------------------------------------- | +| CLI command syntax, flags, env vars | `docs/reference/cli.md` | +| Initial setup, onboarding, first app | `docs/guide/quickstart.md`, `docs/reference/cli.md` | +| App type detection and mode behavior | `docs/guide/app-types.md`, `docs/reference/config.md` | +| Server/API routing and middleware | `docs/guide/server-routing.md`, `docs/integrations/hono.md` | +| Pages mode, loader/action, forms, layouts | `docs/guide/pages-routing/*.md`, `docs/guide/type-safety.md` | +| Database and migrations | `docs/guide/database.md`, `docs/guide/type-safety.md` | +| Typed fetch and end-to-end typing | `docs/guide/typed-fetch.md`, `docs/guide/type-safety.md` | +| Authentication | `docs/guide/auth.md`, `docs/guide/env-vars.md` | +| Cloudflare runtime bindings and config | `docs/integrations/cloudflare.md`, `docs/reference/config.md`, `docs/guide/env-vars.md` | +| AI inference (Workers AI, providers) | `docs/guide/ai.md` | +| KV / storage / queues / cron jobs | `docs/guide/kv.md`, `docs/guide/storage.md`, `docs/guide/queues.md`, `docs/guide/jobs.md` | +| SSR and caching | `docs/guide/ssr.md`, `docs/guide/edge/*.md` | +| Rewrites, redirects, fallbacks | `docs/guide/edge/rewrites.md`, `docs/guide/edge/redirects.md`, `docs/reference/config.md` | +| Static site generation | `docs/guide/ssg.md` | +| Deployment and CI | `docs/guide/deployment.md`, `docs/reference/cli.md` | +| Self-host deploy to own Cloudflare account | `docs/integrations/cloudflare.md`, `docs/reference/cli.md` | +| Project status, deployment history | `docs/reference/cli.md` | +| Cache purging | `docs/reference/cli.md` | +| Project logs, runtime errors | `docs/reference/cli.md` | +| Secrets management (put/sync/delete) | `docs/reference/cli.md`, `docs/guide/env-vars.md` | +| Typed env vars (`defineEnv`, `env.ts`) | `docs/guide/env-vars.md` | +| Custom domain setup | `docs/reference/cli.md` | +| Database status, reset, seed, export | `docs/reference/cli.md`, `docs/guide/database.md` | +| Auth login/logout/whoami | `docs/reference/cli.md` | +| Overview / introduction | `docs/guide/index.md` | +| API surface details | `docs/reference/api.md` | +| Meta framework integration | `docs/integrations/frameworks/*.md` | +| Coding agent setup | `docs/integrations/agents.md` | +| Node.js / Bun / Deno targets | `docs/integrations/nodejs-bun-deno.md` | +| ORMs and external databases | `docs/integrations/orms-and-external-dbs.md` | +| Project structure and conventions | `docs/reference/structure.md` | +| Resource/binding inference | `docs/reference/resource-inference.md` | ## Working Rules diff --git a/packages/void/src/cli/args.ts b/packages/void/src/cli/args.ts index 3508892e9..ea73be04a 100644 --- a/packages/void/src/cli/args.ts +++ b/packages/void/src/cli/args.ts @@ -6,6 +6,8 @@ export type DeployArgs = { spa?: boolean; skipBuild?: boolean; debug?: boolean; + backend?: 'cloudflare'; + provision?: boolean; }; export function parseDeployArgs(args: Array): DeployArgs { @@ -19,6 +21,8 @@ export function parseDeployArgs(args: Array): DeployArgs { spa: { type: 'boolean' }, 'skip-build': { type: 'boolean' }, debug: { type: 'boolean' }, + backend: { type: 'string' }, + provision: { type: 'boolean' }, }, allowPositionals: true, strict: true, @@ -28,6 +32,10 @@ export function parseDeployArgs(args: Array): DeployArgs { const message = error instanceof Error ? error.message : String(error); if (message.includes('argument missing') || message.includes('argument is ambiguous')) { + const optionMatch = message.match(/Option '(--[a-z-]+)/); + if (optionMatch?.[1] === '--backend') { + throw new Error("deploy: --backend requires a value ('cloudflare')."); + } throw new Error('deploy: --project requires a slug.'); } @@ -47,6 +55,13 @@ export function parseDeployArgs(args: Array): DeployArgs { throw new Error('deploy: --project can only be specified once.'); } + const backendTokens = tokens.filter( + (token) => token.kind === 'option' && token.name === 'backend', + ); + if (backendTokens.length > 1) { + throw new Error('deploy: --backend can only be specified once.'); + } + if (parsed.positionals.length > 0) { throw new Error(`deploy: Unexpected argument '${parsed.positionals[0]}'.`); } @@ -62,5 +77,44 @@ export function parseDeployArgs(args: Array): DeployArgs { const skipBuild = parsed.values['skip-build'] === true ? true : undefined; const debug = parsed.values.debug === true ? true : undefined; - return { projectSlug, dir, spa, skipBuild, debug }; + const backendValue = + typeof parsed.values.backend === 'string' ? parsed.values.backend : undefined; + if (backendValue !== undefined && backendValue !== 'cloudflare') { + throw new Error("deploy: --backend must be 'cloudflare'."); + } + const backend = backendValue; + + const provision = parsed.values.provision === true ? true : undefined; + if (provision && backend !== 'cloudflare') { + throw new Error('deploy: --provision requires --backend cloudflare.'); + } + + // The Cloudflare backend deploys the built worker to the user's OWN Cloudflare account + // via their local wrangler auth — it never resolves a Void project, and it never takes + // the managed static-site path. `--project`, `--dir` and `--spa` all read as instructions + // that path cannot carry out, and it used to accept and then ignore them: `--project foo` + // in particular looked like it selected a deploy destination while the worker went to the + // local wrangler account instead. Reject them here rather than deploy somewhere else. + if (backend === 'cloudflare') { + if (projectSlug !== undefined) { + throw new Error( + 'deploy: --project is not supported with --backend cloudflare — this backend deploys ' + + 'to your own Cloudflare account and no Void project is involved.', + ); + } + if (dir !== undefined) { + throw new Error( + 'deploy: --dir is not supported with --backend cloudflare — this backend uploads the ' + + 'worker your build emits, not a static directory.', + ); + } + if (spa) { + throw new Error( + 'deploy: --spa is not supported with --backend cloudflare — this backend uploads the ' + + 'worker your build emits, not a static SPA.', + ); + } + } + + return { projectSlug, dir, spa, skipBuild, debug, backend, provision }; } diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 5583b43a3..519bad838 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -101,6 +101,8 @@ if (helpPath) { spa: deployArgs.spa, skipBuild: deployArgs.skipBuild, debug: deployArgs.debug, + backend: deployArgs.backend, + provision: deployArgs.provision, }).catch((err) => { log.error(err.message ?? err); process.exit(1); diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 390853345..16bcdadda 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -1,4 +1,5 @@ import { execSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { cpSync, existsSync, @@ -9,7 +10,8 @@ import { rmSync, } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { join, relative, resolve } from 'pathe'; +import { parse as parseJsoncConfig, type ParseError } from 'jsonc-parser'; +import { basename, dirname, join, relative, resolve } from 'pathe'; const drizzleKitBin = join(fileURLToPath(import.meta.resolve('drizzle-kit')), '..', 'bin.cjs'); const deployDriftCheckDir = join('.void', 'deploy-drift-check'); @@ -20,6 +22,7 @@ import { resolveAssetConfig, } from '../asset-routing.ts'; import { findVoidAuthConfig } from '../auth/config.ts'; +import type { VoidAuthMigrationSchema } from '../auth/drizzle.ts'; import { findEnvFile } from '../codegen/env-types.ts'; import { readConfig, @@ -33,7 +36,7 @@ import { import { scanJobsSync } from '../crons/scan.ts'; import { writeDrizzleConfig } from '../drizzle/config.ts'; import { discoverSchema } from '../drizzle/discover.ts'; -import { collectRevalidate, collectPages } from '../isr-config.ts'; +import { collectRevalidate, collectPages, hasRevalidate } from '../isr-config.ts'; import { voidWarn } from '../log.ts'; import { collectMigrations } from '../migrations/collect.ts'; import { assertJournalCoherence, validateMigrations } from '../migrations/validate.ts'; @@ -46,7 +49,11 @@ import { type DetectedFramework, } from '../plugin-inference.ts'; import { collectPrerenderPaths, collectPrerenderPathsNode } from '../prerender.ts'; -import { readProjectPaths, resolveProjectPaths } from '../project-paths.ts'; +import { + assertNoRootSourceConventionConflicts, + readProjectPaths, + resolveProjectPaths, +} from '../project-paths.ts'; import { scanQueuesSync } from '../queues/scan.ts'; import { scanRoutes } from '../router/scan.ts'; import { scanWebSocketRoutesSync } from '../router/websocket.ts'; @@ -55,7 +62,7 @@ import { requiresSandboxPlatformImage, resolveSandboxConfig, } from '../sandbox/config.ts'; -import { mergeAndExpand, parseDotenvFile } from '../shared/dotenv.ts'; +import { mergeAndExpand, parseDotenvFile, scanWranglerDotenvKeys } from '../shared/dotenv.ts'; import { resolveRemoteMode, stripInternalEnvKeys } from '../shared/env.ts'; import { validateSsrEntry } from '../ssr/entry.ts'; import { getApiUrl, getToken, getTokenSource } from './auth.ts'; @@ -72,11 +79,28 @@ import { formatStreamDeployError, } from './deploy-errors.ts'; import { uploadAssetsToR2, type DirectR2PutItem } from './direct-r2-upload.ts'; +import type { + EnvSchemaProbeInput, + EnvSchemaProbeResult, + EnvSchemaValidateResult, +} from './env-schema-probe.ts'; +import type { PlaintextVarWarning } from './env-validation.ts'; import { + collectPlaintextVarWarningsFromVars, + deriveSelfHostEnvContextFromVars, + dischargeSelfHostEnvContext, fetchRemoteSecretNames, formatEnvReport, getDeployEnvDefaults, + readEmittedWorkerVars, + selfHostSchemaFailureMessage, + SelfHostEnvValidationError, + SelfHostFirstDeployError, + SelfHostSchemaError, + SelfHostSecretListError, validateProdEnv, + type EmittedWorkerVarsResult, + type SelfHostSchemaFacts, } from './env-validation.ts'; import { collectGitDeployMetadata, type GitDeployMetadata } from './git-metadata.ts'; import { @@ -100,14 +124,56 @@ import { } from './package.ts'; import { detectPreset, + hasBackendFiles, FRAMEWORK_PRESETS, formatProjectCommand, resolveProjectCommand, + resolveWranglerArgv, type StaticPreset, type FrameworkPreset, } from './preset.ts'; import { readProjectConfig, writeProjectConfig, type ProjectConfig } from './project.ts'; import { promptProjectSelection, promptProjectSetupAction } from './prompt.ts'; +// The provision module pulls in the whole-provision cross-process lock +// (`proper-lockfile` → `signal-exit`), which patches `process.emit` / +// `process.reallyExit` and installs exit handlers the moment it is imported. An +// ordinary MANAGED `void deploy` never enters the Cloudflare-backend fork, so it +// must not pay that side effect. Keep ONLY erasable `import type` at module scope +// and load the runtime values with a DYNAMIC `import()` inside the fork +// (`loadProvisionModule`), so importing this file (which `cli.ts` does for every +// deploy) never evaluates the provision lifecycle. +import type { + DesiredResourceSet, + ProvisionDriftEntry, + provisionCloudflareResources, +} from './provision.ts'; + +/** The provision module, loaded lazily inside the Cloudflare fork only. */ +type ProvisionModule = typeof import('./provision.ts'); +let loadedProvisionModule: ProvisionModule | undefined; + +/** + * Dynamically load the provision module (memoized). Called only from the async + * Cloudflare fork (`runCloudflareDeploy` / `computeDesiredCloudflareResources`) + * so a managed deploy never imports it — and thus never loads `proper-lockfile`. + */ +async function loadProvisionModule(): Promise { + loadedProvisionModule ??= await import('./provision.ts'); + return loadedProvisionModule; +} + +/** + * The provision module after it has been loaded by the fork, for the SYNC helpers + * (`resolveDeployTransport` / `deployChildEnv` / `createDeployWranglerRunner`) + * that cannot await. They only ever run after {@link loadProvisionModule} has + * resolved inside {@link runCloudflareDeploy}, so this never throws in practice. + */ +function requireProvisionModule(): ProvisionModule { + if (!loadedProvisionModule) { + throw new Error('internal: provision module accessed before it was loaded'); + } + return loadedProvisionModule; +} import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; import type { AssetConfig, @@ -122,6 +188,9 @@ import { ensureWranglerCompat, resolveLiveBindingConfig, syncWranglerBindings, + writeProvisionedWranglerConfig, + bindingEntriesForField, + FRIENDLY_BINDING_NAMES, type LiveBindingConfig, type WranglerCompat, } from './wrangler.ts'; @@ -136,6 +205,8 @@ export type RunDeployOptions = { spa?: boolean; skipBuild?: boolean; debug?: boolean; + backend?: 'cloudflare'; + provision?: boolean; }; type DeployDoneEvent = Extract; @@ -382,11 +453,4113 @@ export function buildEnv(extra?: Record): NodeJS.ProcessEnv { return env; } -async function runDeployInner( +/** + * Deploy-time env selectors that pin the WRONG worker/config out from under the + * top-level config this flow provisions + preflights. Scrubbed from every child env + * and fenced in the project dotenv (see below), on top of the provisioner's transport + * scrub/fence which owns the endpoint-redirect + `CLOUDFLARE_ENV` vars: + * - `WRANGLER_CI_OVERRIDE_NAME` — wrangler 4.107 applies it AFTER config load + * (`mergeSharedConfigArgs` → `getCIOverrideName()`), REPLACING the upload name, so a + * bare `wrangler deploy` uploads a worker whose name the generated-config check never + * validated. + * - `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH` — the CF Vite plugin points the build at an + * ALTERNATE wrangler config, flattening a different binding set into + * `dist/ssr/wrangler.json`. + */ +const DEPLOY_CONFIG_OVERRIDE_ENV_VARS = [ + 'WRANGLER_CI_OVERRIDE_NAME', + 'CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH', +] as const; + +/** + * The dotenv files wrangler 4.107 reloads into `process.env` (`override: true`) for + * the orchestrator's `--env=` calls (`getDefaultEnvFiles("")`), so a repo dotenv here + * would resurrect `WRANGLER_CI_OVERRIDE_NAME` at the DEPLOY step after the child-env + * scrub removed it. + */ +const DEPLOY_WRANGLER_DOTENV_FILES = ['.env', '.env.local', '.env.', '.env..local']; + +/** + * The dotenv files Vite's `loadEnv(mode, …)` reads for a production `vite build` + * (`.env`, `.env.local`, `.env.production`, `.env.production.local`), where the CF Vite + * plugin picks up `CLOUDFLARE_ENV` / `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH` — a set + * DISJOINT from wrangler's `--env=` files, so it is fenced separately for the build. + */ +const DEPLOY_VITE_DOTENV_FILES = ['.env', '.env.local', '.env.production', '.env.production.local']; + +/** + * FAIL CLOSED when the AMBIENT env selects a non-top-level worker/config for the + * Cloudflare backend, which deploys the top-level environment of the canonical root + * config ONLY. `CLOUDFLARE_ENV` picks a named env block (different account/name/bindings); + * `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH` points the build at a different config. We refuse + * rather than silently deploy a target the drift-check / provision never reasoned about. + */ +function assertNoAmbientConfigOverride(): void { + const cloudflareEnv = process.env.CLOUDFLARE_ENV; + if (typeof cloudflareEnv === 'string' && cloudflareEnv.trim().length > 0) { + throw new Error( + 'CLOUDFLARE_ENV is set. --backend cloudflare deploys the TOP-LEVEL environment of ' + + 'your root wrangler config only (a named environment can carry a different account, ' + + 'worker name, or binding set). Unset CLOUDFLARE_ENV and retry.', + ); + } + const configPathOverride = process.env.CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH; + if (typeof configPathOverride === 'string' && configPathOverride.trim().length > 0) { + throw new Error( + 'CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH is set. --backend cloudflare builds from the ' + + 'canonical root wrangler config only. Unset it and retry.', + ); + } +} + +/** + * FAIL CLOSED when the project's dotenv files declare a deploy-time config-override + * selector. wrangler / Vite reload dotenv on every command, so a repo dotenv would + * reintroduce a scrubbed var AFTER the child-env scrub — reselecting the upload worker + * name (wrangler `--env=` files) or the build's wrangler config (Vite production files). + * The provisioner's transport fence ({@link assertNoTransportRedirectInProjectDotenv}) + * owns `CLOUDFLARE_ENV` + the endpoint-redirect vars; this closes the deploy-specific + * name/config-path selectors it does not cover, across BOTH consumers' file sets. + * Exported for direct unit testing of the file-set + win32 case rule. + */ +export function assertNoConfigOverrideInProjectDotenv( root: string, - options: RunDeployOptions | undefined, - cliLog: CliLogger, -): Promise { + platform: NodeJS.Platform = process.platform, +): void { + const caseInsensitive = platform === 'win32'; + const declaresAny = (file: string, names: ReadonlyArray): string | undefined => { + const rawKeys = scanWranglerDotenvKeys(join(root, file)); + const keys = caseInsensitive ? new Set([...rawKeys].map((key) => key.toLowerCase())) : rawKeys; + return names.find((name) => keys.has(caseInsensitive ? name.toLowerCase() : name)); + }; + // WRANGLER_CI_OVERRIDE_NAME reaches the DEPLOY step via wrangler's `--env=` dotenv reload. + for (const file of DEPLOY_WRANGLER_DOTENV_FILES) { + if (declaresAny(file, ['WRANGLER_CI_OVERRIDE_NAME'])) { + throw new Error( + `the project file "${file}" sets WRANGLER_CI_OVERRIDE_NAME, which wrangler reloads on ` + + `every command and applies AFTER config load to REPLACE the deployed worker name — so ` + + `\`wrangler deploy\` would upload a worker whose name was never validated. Remove it from ` + + `"${file}" (set it in your shell/CI only if you truly need a CI name override).`, + ); + } + } + // CLOUDFLARE_ENV / CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH reach the BUILD via Vite's + // production `loadEnv`, whose file set is disjoint from wrangler's `--env=` files. + for (const file of DEPLOY_VITE_DOTENV_FILES) { + const offender = declaresAny(file, ['CLOUDFLARE_ENV', 'CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH']); + if (offender) { + throw new Error( + `the project file "${file}" sets ${offender}, which the Cloudflare Vite plugin reads at ` + + `build time to select a named environment or an ALTERNATE wrangler config — flattening a ` + + `different binding set into dist/ssr/wrangler.json than this deploy provisioned. Remove ` + + `${offender} from "${file}".`, + ); + } + } +} + +/** + * Resolve + validate the ONE pinned transport for the whole deploy, and FAIL CLOSED + * on any ambient/dotenv redirect that would move the target out from under the pinned + * top-level config. Reuses the provisioner's resolver + dotenv fence so provision AND + * the deploy tail (build / whoami / migrate / deploy) share one source of truth. + * Returns the single resolved API base URL to pin into every child env. + */ +function resolveDeployTransport(root: string, rootConfig: Record): string { + const provision = requireProvisionModule(); + assertNoAmbientConfigOverride(); + provision.assertNoTransportRedirectInProjectDotenv(root); + assertNoConfigOverrideInProjectDotenv(root); + return provision.resolvePinnedApiBaseUrl( + process.env, + readWranglerTopLevelString(rootConfig, 'compliance_region'), + ); +} + +/** + * Build a child env for an orchestrator-owned wrangler / build invocation. Reuses the + * provisioner's {@link provisionEnv} (scrubs the CF Access pair + EVERY transport + * selector incl. `CLOUDFLARE_ENV` + win32 case variants, pins ONE account + API base), + * then scrubs the deploy-specific config-override selectors on top. + * + * This is the env {@link createDeployWranglerRunner} spawns `whoami` / `d1 migrations apply` + * / `deploy` with, so it deliberately KEEPS the wrangler-auth credentials — only the + * untrusted-code children layer {@link scrubWranglerAuthCredentials} on top. Exported so a + * test can assert that directly. + */ +export function deployChildEnv(accountId: string, apiBaseUrl: string): NodeJS.ProcessEnv { + const provision = requireProvisionModule(); + const env = provision.provisionEnv({ accountId, apiBaseUrl }); + for (const name of DEPLOY_CONFIG_OVERRIDE_ENV_VARS) { + provision.deleteEnvVarCaseAware(env, name); + } + // Pin WRANGLER_LOG=log for the whole deploy so wrangler's `d1 migrations list` sentinels + // ('No migrations to apply!' / 'Migrations to be applied:'), emitted at logger level + // `log`, are NEVER suppressed by an ambient WRANGLER_LOG=none|error|warn|info — otherwise + // a declined/partial apply prints no marker and the pending-check would pass open. The + // canonical spelling is scrubbed case-aware FIRST so no mixed-case ambient value shadows + // the pin on win32; `log` is wrangler's own default level, so normal output is unchanged. + provision.deleteEnvVarCaseAware(env, 'WRANGLER_LOG'); + env.WRANGLER_LOG = 'log'; + return env; +} + +/** The id-bearing binding fields whose identity must survive the build unchanged. */ +const DEPLOY_BINDING_IDENTITY_FIELDS: Record< + ProvisionDriftEntry['kind'], + { field: string; idField: string } +> = { + d1: { field: 'd1_databases', idField: 'database_id' }, + kv: { field: 'kv_namespaces', idField: 'id' }, + r2: { field: 'r2_buckets', idField: 'bucket_name' }, + hyperdrive: { field: 'hyperdrive', idField: 'id' }, +}; + +/** Every id-bearing binding in the desired set, as `{ kind, binding }` pairs. */ +function desiredIdentityBindings( + desired: DesiredResourceSet, +): Array<{ kind: ProvisionDriftEntry['kind']; binding: string }> { + const out: Array<{ kind: ProvisionDriftEntry['kind']; binding: string }> = []; + if (desired.d1) { + out.push({ kind: 'd1', binding: desired.d1.binding }); + } + for (const kv of desired.kvNamespaces) { + out.push({ kind: 'kv', binding: kv.binding }); + } + if (desired.r2) { + out.push({ kind: 'r2', binding: desired.r2.binding }); + } + if (desired.hyperdrive) { + out.push({ kind: 'hyperdrive', binding: desired.hyperdrive.binding }); + } + return out; +} + +/** + * A pin of every desired id-bearing binding's IDENTITY, keyed `:` (the config + * section joined to the binding — both fixed/identifier-shaped, so the pair never collides), + * captured from the root config AFTER provision/drift resolves every real id and BEFORE the + * untrusted `vite build` runs. The remote-migrate assert and the emitted-config check both + * compare against THIS map, so the id defense is symmetric with the pinned `account_id` — see + * {@link snapshotBindingIdentities}. + */ +export type BindingIdentitySnapshot = Map; + +/** + * The snapshot/lookup key for one binding identity. `field` is one of a fixed set of wrangler + * config sections (`d1_databases` / `kv_namespaces` / `r2_buckets` / `hyperdrive`) and `binding` + * is a wrangler binding name (an identifier — no `:`), so the joined key is collision-free. + */ +function bindingIdentityKey(field: string, binding: string): string { + return `${field}:${binding}`; +} + +/** + * Enumerate EVERY id-bearing binding DECLARED in `config` across the four id-bearing sections + * (`d1_databases` / `kv_namespaces` / `r2_buckets` / `hyperdrive`), as `{ field, binding, id }`. + * `id` is `undefined` when the entry omits it or leaves it empty (never pinned, never asserted — + * the same "absent or empty → undefined" collapse as {@link readStoredResourceName}). Reads each + * entry on its OWN terms (never a first-match re-scan) so a duplicated / appended entry is seen as + * itself. Section + id-field names come from {@link DEPLOY_BINDING_IDENTITY_FIELDS} — no per-kind + * branching. Unlike {@link desiredIdentityBindings} this is NOT restricted to the desired set: it + * captures a user's hand-declared extra binding too, which is exactly what lets the completeness + * check tell a pre-existing binding apart from one the build injected. + */ +function collectConfigBindingIdentities( + config: Record, +): Array<{ field: string; binding: string; id: string | undefined }> { + const out: Array<{ field: string; binding: string; id: string | undefined }> = []; + for (const { field, idField } of Object.values(DEPLOY_BINDING_IDENTITY_FIELDS)) { + const list = config[field]; + if (!Array.isArray(list)) { + continue; + } + for (const entry of list) { + const binding = readD1EntryString(entry, 'binding'); + if (binding === undefined) { + continue; + } + out.push({ field, binding, id: readD1EntryString(entry, idField) }); + } + } + return out; +} + +/** + * Snapshot the real, id-bearing identity of EVERY binding declared in the root `config` into an + * in-memory map — the id defense's ground truth, mirroring the pinned in-memory `accountId`. + * + * Taken AFTER provision (which writes each real id into the root config via + * `wrangler create --update-config`) and the `name`/`account_id` reconcile, and BEFORE + * the build. `vite build` runs untrusted project code with its Cloudflare credentials scrubbed, + * so a compromised build dependency can only edit FILES — it could rewrite + * `wrangler.jsonc`'s `d1_databases[].database_id` (keeping the binding + database_name) + * to redirect the privileged remote D1 migrate or the deploy to a DIFFERENT same-account + * resource, OR ADD a wholly new id-bearing binding (`EXTRA_DB`/`EXTRA_KV`/…) that the CF plugin + * would then merge verbatim into `dist/ssr/wrangler.json`. Snapshotting EVERY declared id here — + * not only the desired ones — and asserting the post-build config + emitted artifact carry + * exactly this set closes both gaps for D1/KV/R2/Hyperdrive alike. A binding whose id is absent + * post-drift (which the drift check already rejects for desired bindings) is simply not pinned — + * never asserted. + */ +export function snapshotBindingIdentities( + config: Record, +): BindingIdentitySnapshot { + const snapshot: BindingIdentitySnapshot = new Map(); + for (const { field, binding, id } of collectConfigBindingIdentities(config)) { + if (id !== undefined) { + snapshot.set(bindingIdentityKey(field, binding), id); + } + } + return snapshot; +} + +/** + * Resource fields Void's OWN build legitimately emits into `dist/ssr/wrangler.json` — either + * merged by `mergeBindings` (index.ts) or default-filled by the wrangler normalizer — so an entry + * the artifact declares in one of THESE is NOT an injection and must not be asserted against the + * pre-build root. Audited against `mergeBindings` (index.ts) + 13 real playground + * `dist/ssr/wrangler.json` artifacts: the only {@link FRIENDLY_BINDING_NAMES} fields ever emitted + * non-empty are these. The four id-bearing fields are excluded here because the id-pin + * ({@link DEPLOY_BINDING_IDENTITY_FIELDS}) already pins them precisely. + */ +export const DEPLOY_PASSTHROUGH_EXCLUDE_FIELDS: ReadonlySet = new Set([ + // id-pinned elsewhere (DEPLOY_BINDING_IDENTITY_FIELDS) + 'd1_databases', + 'kv_namespaces', + 'r2_buckets', + 'hyperdrive', + // Void-emitted / merged by mergeBindings + 'ai', + 'durable_objects', + 'assets', + 'vars', + // bundled content / worker self-metadata — no external-resource grant, and worker code is + // already fully controlled by a malicious build, so pinning these adds nothing + 'data_blobs', + 'text_blobs', + 'wasm_modules', + 'version_metadata', +]); + +/** + * The pass-through assert-set: every binding field wrangler knows about + * ({@link FRIENDLY_BINDING_NAMES}, Void's authoritative transcription of wrangler's binding + * registry) that Void does NOT itself emit. An entry the EMITTED artifact declares in one of + * these is either a verbatim pass-through of the user's OWN root config (captured in the pre-build + * snapshot) or a build-time INJECTION — the latter would grant the deployed worker a same-account + * capability the source never asked for (a `services` binding to another worker, a + * `secrets_store_secrets` handle, a `dispatch_namespaces` entry, an `unsafe` binding of an + * arbitrary type, …), which the CF plugin passes through into the emitted config and the + * credentialed `wrangler deploy` then binds. + * + * Complete-by-construction against the known registry: it auto-covers every capability field + * (browser/images/stream/media/websearch/unsafe included) and any FUTURE field added to the + * registry fails CLOSED — a new registry field is asserted until explicitly excluded here. Fields + * wrangler does not yet know about are absent from the registry and still fail OPEN — the same + * residual the id-pin has, so a future resource field can never false-positive a legitimate build. + */ +export const DEPLOY_PASSTHROUGH_ASSERT_FIELDS: ReadonlyArray = Object.keys( + FRIENDLY_BINDING_NAMES, +).filter((field) => !DEPLOY_PASSTHROUGH_EXCLUDE_FIELDS.has(field)); + +/** + * Fields the CF plugin passes through VERBATIM that the per-binding ENTRY pin cannot cover, so + * each is compared WHOLE against the raw pre-build root value instead: + * - `unsafe` — is in {@link DEPLOY_PASSTHROUGH_ASSERT_FIELDS} (so it stays in the drift + * invariant) but its `.metadata`/`.capnp` are never entry-iterated, and wrangler applies + * `unsafe.metadata` LAST at upload, OVERWRITING the whole uploaded bindings array — so an + * entry-level pin is blind to it. Routed OUT of the entry loop into the whole-object pin. + * - `tail_consumers` / `streaming_tail_consumers` — are NOT "bindings" (absent from + * {@link FRIENDLY_BINDING_NAMES}), so the assert-set structurally can't see them, yet wrangler + * uploads them and they forward every log/request to the named worker (exfil). + * - `routes` / `route` / `workers_dev` / `preview_urls` — the worker's TRAFFIC attachment. A + * build that injects a `routes`/`route` entry attaches the deployed worker to a production + * hostname/path the account controls (traffic interception), and flipping `workers_dev` / + * `preview_urls` changes its public exposure. None is a "binding" either, so the assert-set + * can't see them, yet wrangler uploads them. + * Void's own build emits NONE of them: a clean `vite build` (root declaring none) leaves every one + * ABSENT from the emitted artifact (verified against a real build), so an emptiness check + * ({@link isEmptyPassthroughValue}) keeps the normalizer's default-fill from false-positiving. + */ +export const DEPLOY_WHOLE_OBJECT_PINNED_FIELDS: ReadonlyArray = [ + 'unsafe', + 'tail_consumers', + 'streaming_tail_consumers', + 'routes', + 'route', + 'workers_dev', + 'preview_urls', +]; + +/** + * Fields whose RAW pre-build root value {@link snapshotPassthroughRawFields} captures for + * {@link verifyGeneratedDeployConfig}'s whole-object + targeted checks: the + * {@link DEPLOY_WHOLE_OBJECT_PINNED_FIELDS} plus `durable_objects` (external-DO check), `ai` + * (binding pin), and `migrations` (destructive-DO-migration check). The last three are Void-emitted + * (excluded from the assert-set) yet still need a pin because a build can add an EXTERNAL DO handle + * (`script_name`), a rewritten/removed `ai` binding, or a DESTRUCTIVE Durable Object migration. + */ +const DEPLOY_RAW_SNAPSHOT_FIELDS: ReadonlyArray = [ + ...DEPLOY_WHOLE_OBJECT_PINNED_FIELDS, + 'durable_objects', + 'ai', + 'migrations', +]; + +/** Void's own injected Workers AI binding shape (`mergeBindings` in index.ts). */ +const VOID_EMITTED_AI_BINDING = { binding: 'AI' }; + +/** + * Sub-fields on a `durable_objects` binding that make it target ANOTHER worker's Durable Objects. + * `script_name` is the one wrangler binds a cross-script DO through; `environment` narrows a + * cross-script binding to a named environment — either marks a binding Void's own build never + * emits (its DOs are always LOCAL `{name, class_name}`). + */ +const DURABLE_OBJECT_EXTERNAL_FIELDS = ['script_name', 'environment']; + +/** + * Keys on a wrangler `migrations` entry that DELETE or MOVE existing Durable Object classes (and + * their stored data): `deleted_classes` drops them, `renamed_classes` renames them, and the two + * `transferred_classes*` keys move them from another script. Void's own emitted migrations only + * ever carry the CREATION keys (`new_classes` / `new_sqlite_classes`), never one of these — so an + * emitted entry carrying one is a build injection that `wrangler deploy` would run against + * production Durable Object data. + */ +const DESTRUCTIVE_MIGRATION_KEYS = [ + 'deleted_classes', + 'renamed_classes', + 'transferred_classes', + 'transferred_classes_from_script', +]; + +/** + * A pin of every pass-through binding's ENTRY per {@link DEPLOY_PASSTHROUGH_ASSERT_FIELDS} field — + * field → (bindingName → the root entry object) — captured from the root config at the SAME + * pre-build point as {@link snapshotBindingIdentities} (after provision/drift resolves ids, before + * the untrusted build). Lets a user's LEGITIMATELY hand-declared pass-through binding (e.g. a + * `services` entry present before the build) survive, while an entry the build ADDED — absent from + * this pin — OR one whose TARGET the build rewrote (same name, different service/namespace/type) + * fails closed at {@link verifyGeneratedDeployConfig}. Entries come from each field's wrangler + * shape via {@link bindingEntriesForField} (the SAME shapes the build's uniqueness check uses), so + * `send_email` / `ratelimits` key on `.name` and `logfwdr` on its nested `.bindings[].name`. A + * present field whose shape the strict reader cannot parse is recorded as an EMPTY inner map, so + * any emitted entry in it fails closed on the symmetric read. + */ +export function snapshotPassthroughIdentities( + config: Record, +): Map>> { + const snapshot = new Map>>(); + for (const field of DEPLOY_PASSTHROUGH_ASSERT_FIELDS) { + if (config[field] === undefined) { + continue; + } + const entries = new Map>(); + try { + for (const { name, entry } of bindingEntriesForField(field, config[field])) { + entries.set(name, entry); + } + } catch { + // A malformed field records an EMPTY pin, so any emitted entry in it fails closed below. + entries.clear(); + } + snapshot.set(field, entries); + } + return snapshot; +} + +/** + * Companion of {@link snapshotPassthroughIdentities} for the fields the per-binding ENTRY pin + * cannot cover ({@link DEPLOY_RAW_SNAPSHOT_FIELDS}): capture the RAW pre-build root value for each + * of `unsafe` / `tail_consumers` / `streaming_tail_consumers` (whole-object-pinned), + * `durable_objects` (external-DO check), and `ai` (binding pin). Taken at the SAME pre-build point + * as the other snapshots, so a build that INJECTS or REWRITES one of these into the emitted + * artifact is caught at {@link verifyGeneratedDeployConfig}, while a user's own hand-declared value + * (present here, unchanged) passes. + */ +export function snapshotPassthroughRawFields( + config: Record, +): Map { + const snapshot = new Map(); + for (const field of DEPLOY_RAW_SNAPSHOT_FIELDS) { + snapshot.set(field, config[field]); + } + return snapshot; +} + +/** + * Reconstruct the exact `containers` set Void's build emits into `dist/ssr/wrangler.json`, computed + * PRE-BUILD from the SAME inputs `mergeBindings` (index.ts) uses so a legitimate build's emitted + * entry deepEquals one reconstructed here: + * 1. every root-declared container passes through VERBATIM (mergeBindings spreads + * `resolved.containers` unchanged), and + * 2. PLUS — only when the source needs the sandbox (`needsSandbox`, the SAME + * `isSandboxEnabled(config) || inferProjectBindings(...).needsSandbox` signal index.ts gates + * emission on) AND no root-declared container already declares the sandbox class — the single + * Void sandbox container, built field-for-field from {@link resolveSandboxConfig} (the SAME + * pure function the build calls, so the reconstructed entry is byte-identical to the emitted + * one, absent optionals omitted). + * A bare `wrangler deploy` PROVISIONS a container application from this array (billable, and it runs + * the image's code), so pinning it lets {@link verifyGeneratedDeployConfig} reject a container the + * build injected, dropped, or retargeted. + */ +export function reconstructExpectedContainers( + config: VoidConfig, + root: string, + rootConfig: Record, + needsSandbox: boolean, +): Array> { + const rootContainers: Array> = Array.isArray(rootConfig.containers) + ? (rootConfig.containers as Array>) + : []; + const expected: Array> = [...rootContainers]; + if (needsSandbox) { + const sandbox = resolveSandboxConfig(config, root); + const alreadyDeclared = rootContainers.some( + (container) => readD1EntryString(container, 'class_name') === sandbox.className, + ); + if (!alreadyDeclared) { + expected.push({ + name: sandbox.containerName, + class_name: sandbox.className, + image: sandbox.image, + ...(sandbox.imageBuildContext && { image_build_context: sandbox.imageBuildContext }), + ...(sandbox.instanceType && { instance_type: sandbox.instanceType }), + ...(sandbox.maxInstances != null && { max_instances: sandbox.maxInstances }), + }); + } + } + return expected; +} + +/** + * Deterministic sha256 over the wrangler uploader BUNDLE the credentialed deploy executes — the + * resolved `wrangler-dist/cli.js` PLUS every sibling file in its directory. `cli.js` loads sibling + * chunks from its own `__dirname` (the proxy workers, the experimental-config module), so the + * whole directory is the trust surface, not just the entry file. Content hash of the bytes, NOT + * mtime. Resolves the SAME entry {@link createDeployWranglerRunner} spawns (via + * {@link resolveWranglerArgv}, whose launcher's LAST argv element is the JS entry). Returns + * `undefined` when the entry cannot be resolved or a file cannot be read, so the caller FAILS + * CLOSED — it never deploys a binary it could not verify. The resolver is injectable for tests. + */ +export function hashWranglerUploader( + root: string, + resolveArgv: (root: string) => Array = resolveWranglerArgv, +): string | undefined { + let entry: string | undefined; + try { + const argv = resolveArgv(root); + entry = argv[argv.length - 1]; + } catch { + return undefined; + } + if (typeof entry !== 'string' || entry.length === 0 || !existsSync(entry)) { + return undefined; + } + return hashDirectoryContents(dirname(entry)); +} + +/** + * sha256 over every file under `dir`, walked in a STABLE (name-sorted, depth-first) order with + * each file's relative path folded into the digest, so the hash depends only on the directory's + * contents — never on filesystem enumeration order. Returns `undefined` on any read error. + */ +function hashDirectoryContents(dir: string): string | undefined { + try { + const hash = createHash('sha256'); + const walk = (rel: string): void => { + const entries = readdirSync(join(dir, rel), { withFileTypes: true }).sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ); + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(childRel); + } else { + hash.update(childRel); + hash.update('\0'); + hash.update(readFileSync(join(dir, childRel))); + hash.update('\0'); + } + } + }; + walk(''); + return hash.digest('hex'); + } catch { + return undefined; + } +} + +/** + * Assert the wrangler uploader bundle was NOT modified between the pre-build snapshot and now. + * A legitimate `vite build` never touches `node_modules/wrangler`, so the hash is stable across + * the build. A build that overwrote the uploader — to run attacker code WITH the Cloudflare token + * at the post-build remote migrate / deploy — changes it, and an unresolvable/unreadable binary + * (either snapshot `undefined`) is a binary that cannot be verified: both FAIL CLOSED. + */ +export function assertWranglerUploaderUnchanged( + before: string | undefined, + after: string | undefined, +): { ok: boolean; reason?: string } { + if (before === undefined || after === undefined) { + return { + ok: false, + reason: `could not resolve or hash the wrangler uploader to verify it across the build — refusing to run a deploy binary that cannot be verified.`, + }; + } + if (before !== after) { + return { + ok: false, + reason: `the build modified the wrangler uploader (its bundle hash changed across the build) — refusing to run it with your Cloudflare credentials.`, + }; + } + return { ok: true }; +} + +/** + * True when a resource field's emitted value carries NO capability: `undefined`/`null`, an empty + * array, an empty object, or a `{ bindings: [] }`. A clean `vite build` (root declaring none of + * the whole-object-pinned fields) emits `unsafe` / `tail_consumers` / `streaming_tail_consumers` + * ABSENT (verified against a real build); this also absorbs any normalizer default-fill + * (`[]` / `{}` / `{ bindings: [] }`) so the whole-object pin never false-positives on an empty + * shape while still catching a value that actually grants a capability. + */ +function isEmptyPassthroughValue(value: unknown): boolean { + if (value === undefined || value === null) { + return true; + } + if (Array.isArray(value)) { + return value.length === 0; + } + if (typeof value === 'object') { + const keys = Object.keys(value as Record); + if (keys.length === 0) { + return true; + } + if (keys.length === 1 && keys[0] === 'bindings') { + const bindings = (value as Record).bindings; + return Array.isArray(bindings) && bindings.length === 0; + } + } + return false; +} + +/** + * Reject an emitted `durable_objects` binding that targets ANOTHER worker (carrying a + * {@link DURABLE_OBJECT_EXTERNAL_FIELDS} sub-field) unless the pre-build root declared the SAME + * binding byte-for-byte. Void's own build only ever emits LOCAL DO bindings (`{name, class_name}`; + * index.ts), so an external DO in the artifact is a build injection — a handle to a same-account + * worker's Durable Objects the source never asked for. Emitted LOCAL DOs (no cross-script field) + * always pass. A malformed emitted `durable_objects` fails closed (Void never emits one). + */ +function verifyNoInjectedExternalDurableObject( + emitted: unknown, + root: unknown, +): { ok: boolean; reason?: string } { + let emittedEntries: Array<{ name: string; entry: Record }>; + try { + emittedEntries = bindingEntriesForField('durable_objects', emitted); + } catch { + return { + ok: false, + reason: `generated config declares a malformed durable_objects binding — refusing to deploy a binding set that cannot be verified.`, + }; + } + const rootByName = new Map>(); + try { + for (const { name, entry } of bindingEntriesForField('durable_objects', root)) { + rootByName.set(name, entry); + } + } catch { + // A malformed root records no pins, so any emitted EXTERNAL DO fails closed below. + rootByName.clear(); + } + for (const { name, entry } of emittedEntries) { + if (!DURABLE_OBJECT_EXTERNAL_FIELDS.some((key) => entry[key] !== undefined)) { + continue; + } + const rootEntry = rootByName.get(name); + if (rootEntry === undefined || !deepEqual(entry, rootEntry)) { + return { + ok: false, + reason: `generated config declares a durable_objects binding ${JSON.stringify(name)} bound to another worker's script that was not present before the build — refusing to deploy an external Durable Object the build injected.`, + }; + } + } + return { ok: true }; +} + +/** + * Reject an emitted `migrations` entry that carries a {@link DESTRUCTIVE_MIGRATION_KEYS} key unless + * the pre-build root declared the SAME entry byte-for-byte. Void's build only ever emits migrations + * with CREATION keys (its own DOs), so a destructive entry in the artifact is a build injection — + * `wrangler deploy` runs the emitted migrations and would DELETE or MOVE production Durable Object + * data. Emitted entries with only creation keys are NEVER inspected (Void's own migrations pass + * untouched), and a user's own root-declared destructive migration (e.g. a legit rename) is pinned + * and passes. This is the destructive-injection guard only — deriving Void's complete desired + * migration set (full array pinning) is out of scope. + */ +function verifyNoInjectedDestructiveMigration( + emitted: unknown, + root: unknown, +): { ok: boolean; reason?: string } { + if (!Array.isArray(emitted)) { + // Void emits `migrations` as an array; a non-array (or absent) value carries no destructive + // entry to check here, and full migration-array pinning is out of scope. + return { ok: true }; + } + const rootEntries = Array.isArray(root) ? root : []; + for (const entry of emitted) { + if (entry === null || typeof entry !== 'object') { + continue; + } + const record = entry as Record; + if (!DESTRUCTIVE_MIGRATION_KEYS.some((key) => record[key] !== undefined)) { + continue; + } + if (!rootEntries.some((rootEntry) => deepEqual(record, rootEntry))) { + const tag = typeof record.tag === 'string' ? ` ${JSON.stringify(record.tag)}` : ''; + return { + ok: false, + reason: `generated config declares a destructive Durable Object migration${tag} (deleting, renaming, or transferring classes) that was not present before the build — refusing to deploy a migration the build added that would delete or move Durable Object data.`, + }; + } + } + return { ok: true }; +} + +/** + * Minimal JSON-safe structural equality: primitives by `===`, arrays element-wise, plain objects + * key-for-key (order-insensitive). Used to compare a pinned pre-build pass-through binding entry's + * values against the emitted artifact's, so a build that RETARGETS a binding (rewrites a + * service/namespace/type the user wrote in root) is caught while a normalizer-added default + * sub-field is ignored. No cyclic/`undefined`/class support needed — wrangler config is JSON. + */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true; + } + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false; + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + return a.every((item, i) => deepEqual(item, b[i])); + } + const aObj = a as Record; + const bObj = b as Record; + const aKeys = Object.keys(aObj); + const bKeys = Object.keys(bObj); + if (aKeys.length !== bKeys.length) { + return false; + } + return aKeys.every( + (key) => Object.prototype.hasOwnProperty.call(bObj, key) && deepEqual(aObj[key], bObj[key]), + ); +} + +/** + * Recursively normalize every `Map` (at any depth) to a plain object keyed by its entries, so a + * Map-shaped value can be compared with {@link deepEqual} — which is JSON-shaped and does NOT + * handle `Map`. Handles the nested `Map>` {@link snapshotPassthroughIdentities} + * returns, as well as the flat `Map` / `Map` the other snapshots + * return. Arrays and plain objects pass through with their elements normalized. + */ +function normalizeMapsForCompare(value: unknown): unknown { + if (value instanceof Map) { + const obj: Record = {}; + for (const [key, val] of value) { + obj[String(key)] = normalizeMapsForCompare(val); + } + return obj; + } + if (Array.isArray(value)) { + return value.map(normalizeMapsForCompare); + } + if (value !== null && typeof value === 'object') { + const out: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + out[key] = normalizeMapsForCompare(val); + } + return out; + } + return value; +} + +/** + * Structural equality for the possibly-`Map`-shaped pre-build snapshots — normalizes every Map to + * a plain object first (see {@link normalizeMapsForCompare}), then compares with the key-order- + * independent {@link deepEqual}. Used to assert the step-6 binding/pass-through snapshots match the + * pre-probe ones captured before any project code ran. + */ +function snapshotsDeepEqual(a: unknown, b: unknown): boolean { + return deepEqual(normalizeMapsForCompare(a), normalizeMapsForCompare(b)); +} + +/** + * The reject shown when a PostgreSQL app ships checked-in `db/migrations/*.sql` through the + * self-host Cloudflare backend. This backend applies ONLY remote D1 (SQLite) migrations; a pg + * app desires Hyperdrive (never `desired.d1`), so its migrations would never run here and the + * worker would hit an unmigrated database. Shared by the pre-build fail-fast guard and the + * post-build re-check (a build plugin can write these files during `vite build`). + */ +const PG_CHECKED_IN_MIGRATIONS_REJECT_MESSAGE = [ + 'deploy: --backend cloudflare will not run a PostgreSQL app’s checked-in migrations.', + 'This backend applies only remote D1 (SQLite) migrations. A PostgreSQL app’s', + 'db/migrations/*.sql are applied over Hyperdrive by the managed platform (`void deploy`);', + 'this self-host backend does not run them, so your database would be left unmigrated.', + 'Deploy this app with `void deploy`, or apply the migrations yourself against your', + 'Postgres and remove them from db/migrations/, then rerun.', +].join('\n'); + +/** + * Every queue NAME the emitted artifact binds — `queues.producers[].queue` + + * `queues.consumers[].queue`. Non-throwing: a wrong-shaped `queues` field or an entry with no + * readable `queue` string contributes no name (an unnamed queue binds nothing), so only real, + * NAMED queue bindings are asserted against the desired set. + */ +function collectEmittedQueueNames(value: unknown): Array { + const names: Array = []; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return names; + } + for (const group of ['producers', 'consumers'] as const) { + const list = (value as Record)[group]; + if (!Array.isArray(list)) { + continue; + } + for (const entry of list) { + const name = readD1EntryString(entry, 'queue'); + if (name !== undefined) { + names.push(name); + } + } + } + return names; +} + +/** + * The queue NAMES the emitted artifact binds in ONE group (`producers` or `consumers`), as a Set. + * Read defensively — a wrong-shaped `queues` field, a missing group, or an entry with no readable + * `queue` string contributes no name — so a missing/empty group yields an EMPTY set. Used to assert + * every DESIRED queue survives in BOTH groups: `mergeBindings` (index.ts) emits each desired queue as + * both a producer AND a consumer, so per-group presence is required — {@link collectEmittedQueueNames} + * merges the two groups and cannot tell that a queue lost one of its two sides. + */ +function collectEmittedQueueGroupNames( + value: unknown, + group: 'producers' | 'consumers', +): Set { + const names = new Set(); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return names; + } + const list = (value as Record)[group]; + if (!Array.isArray(list)) { + return names; + } + for (const entry of list) { + const name = readD1EntryString(entry, 'queue'); + if (name !== undefined) { + names.add(name); + } + } + return names; +} + +/** The CF Vite plugin's deploy-config redirect that a bare `wrangler deploy` follows. */ +function deployPointerPath(root: string): string { + return join(root, '.wrangler', 'deploy', 'config.json'); +} + +/** + * Wrangler 4.107's FULL credential env-var set, verified against + * `wrangler-dist/cli.js`. It covers every env var wrangler reads to authenticate — + * the API-client auth inputs (`apiToken`/`apiKey`/`apiEmail`/`userServiceKey`) plus + * the `getEnvironmentVariableFactory` auth tokens — each canonical name AND its + * deprecated `CF_*` alias: + * - API token: `CLOUDFLARE_API_TOKEN` / `CF_API_TOKEN` + * - global auth key: `CLOUDFLARE_API_KEY` / `CF_API_KEY` + * - global auth email: `CLOUDFLARE_EMAIL` / `CF_EMAIL` + * - user service key: `CLOUDFLARE_API_USER_SERVICE_KEY` + * - CF authorization tok: `WRANGLER_CF_AUTHORIZATION_TOKEN` + * - R2 SQL auth token: `WRANGLER_R2_SQL_AUTH_TOKEN` (a Bearer credential wrangler + * reads via `getEnvironmentVariableFactory`, falling back to `CLOUDFLARE_API_TOKEN`) + * - Access service token: `CLOUDFLARE_ACCESS_CLIENT_ID` / `CLOUDFLARE_ACCESS_CLIENT_SECRET` + * Scrubbed from EVERY child that runs untrusted project code — the env-schema probes, the + * `vite build`, and `drizzle-kit generate` (which loads the user's schema). None of them + * authenticates against Cloudflare, so a leaking `env.ts` / build plugin / schema module can + * never read a real credential the parent holds. NOT scrubbed from the wrangler runner's env + * (`createDeployWranglerRunner`), where `whoami` / `d1 migrations apply` / `deploy` need them. + * (`provisionEnv` already scrubs Void's own `CF_ACCESS_CLIENT_ID/SECRET` pair + the + * account-id selector; this adds the wrangler-account credentials it does not.) + */ +const WRANGLER_AUTH_CREDENTIAL_ENV_VARS = [ + 'CLOUDFLARE_API_TOKEN', + 'CF_API_TOKEN', + 'CLOUDFLARE_API_KEY', + 'CF_API_KEY', + 'CLOUDFLARE_EMAIL', + 'CF_EMAIL', + 'CLOUDFLARE_API_USER_SERVICE_KEY', + 'WRANGLER_CF_AUTHORIZATION_TOKEN', + 'WRANGLER_R2_SQL_AUTH_TOKEN', + 'CLOUDFLARE_ACCESS_CLIENT_ID', + 'CLOUDFLARE_ACCESS_CLIENT_SECRET', +]; + +/** + * Delete every {@link WRANGLER_AUTH_CREDENTIAL_ENV_VARS} entry from `env` IN PLACE and + * return it, for a child that executes UNTRUSTED project code and needs no Cloudflare + * credential of its own. + * + * The case-aware delete lives in the provision module, which ONLY the Cloudflare fork + * loads (a managed deploy must not pay `proper-lockfile`'s import-time side effects). One + * caller — `runDrizzleKitGenerate`, via the shared schema-drift check — also runs on the + * MANAGED path, where that module is legitimately absent, so read the memoized handle + * directly instead of {@link requireProvisionModule} (which throws when the fork never ran) + * and fall back to the exact-case delete. That fallback IS the whole rule off win32 (POSIX + * env keys are case-sensitive); the case-insensitive win32 sweep only matters for the + * Cloudflare fork, and by the time either call site runs there the module is always loaded. + */ +function scrubWranglerAuthCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const deleteVar = + loadedProvisionModule?.deleteEnvVarCaseAware ?? + ((target: NodeJS.ProcessEnv, name: string): void => { + delete target[name]; + }); + for (const name of WRANGLER_AUTH_CREDENTIAL_ENV_VARS) { + deleteVar(env, name); + } + return env; +} + +/** + * Build the child env for the isolated env-schema probe. Starts from + * {@link deployChildEnv} (CF Access + every transport selector scrubbed, one account + + * API base pinned), then (1) scrubs the FULL wrangler-auth credential set case-aware — + * the probe needs no credentials — and (2) pins `NODE_ENV=production` so the child + * evaluates env.ts under the SAME environment the build uses (`vite build` sets + * `NODE_ENV=production`), keeping the required-key set the probe derives identical to + * the built worker's. Used by BOTH the pre-build facts probe and the post-build + * `validate-emitted` probe. + */ +function buildSchemaProbeEnv(accountId: string, apiBaseUrl: string): NodeJS.ProcessEnv { + const env = scrubWranglerAuthCredentials(deployChildEnv(accountId, apiBaseUrl)); + // Scrub any ambient spelling first (case-aware on win32) so it cannot shadow the pin. + requireProvisionModule().deleteEnvVarCaseAware(env, 'NODE_ENV'); + env.NODE_ENV = 'production'; + return env; +} + +/** + * Default {@link CloudflareDeployDeps.resolveSchemaFacts} seam: derive the + * env-INSENSITIVE schema facts (required-secret NAMES + `.secret()` flags) — and the + * v1 envDir/mode narrowing — in an ISOLATED SUBPROCESS ({@link + * resolveEnvSchemaFactsViaSubprocess}) under the scrubbed `env`. The parent runs NO + * project code: importing the user's env.ts (and resolving the Vite config) happens in + * the child, so a deferred handler can never read the parent's credentials. Loaded + * lazily so a managed deploy never pulls the probe in. + */ +async function resolveEnvSchemaFactsDefault( + input: EnvSchemaProbeInput, + env: NodeJS.ProcessEnv, +): Promise { + const { resolveEnvSchemaFactsViaSubprocess } = await import('./env-schema-probe.ts'); + return resolveEnvSchemaFactsViaSubprocess(input, env); +} + +/** + * Default {@link CloudflareDeployDeps.validateEmittedVars} seam: validate the REAL + * emitted worker `vars` (post-build ground truth) against the schema validators in the + * SAME isolated subprocess ({@link validateEmittedVarsViaSubprocess}) under the scrubbed + * `env`, returning the FAILING key NAMES only. Runs the user's validators (project + * code) off the credentialed parent; loaded lazily so a managed deploy never pulls it in. + */ +async function validateEmittedVarsDefault( + input: EnvSchemaProbeInput, + env: NodeJS.ProcessEnv, +): Promise { + const { validateEmittedVarsViaSubprocess } = await import('./env-schema-probe.ts'); + return validateEmittedVarsViaSubprocess(input, env); +} + +/** + * Clear, values-free guidance for the v1 envDir/mode rejection the facts probe raised + * (a custom Vite `envDir` or a non-`production` build mode). Rendered from the fixed + * reason CODE alone — the raw resolved envDir/mode strings never crossed the probe + * boundary, so nothing here can echo a Vite-config-derived value. + */ +function cloudflareEnvDirModeRejectMessage( + reject: Extract, +): string { + if (reject.reason === 'custom-envdir') { + return [ + 'deploy: --backend cloudflare does not support a custom Vite `envDir` (including `envDir: false`).', + 'v1 loads env from the project root only. The build reads `.env*` from your envDir but', + 'generates the auth schema from the project root, so a custom envDir could validate a', + 'different table set than the worker actually ships. Remove `envDir` from your Vite config', + '(or point it back at the project root), then rerun — or deploy this app with `void deploy`', + '(the managed platform).', + ].join('\n'); + } + return [ + 'deploy: --backend cloudflare builds in production mode only (your build resolves to a non-production mode).', + 'A non-production build mode selects a different `.env.` set than this backend validates.', + 'Remove the custom `mode` from your Vite config / build command so the build runs in production,', + 'then rerun — or deploy this app with `void deploy` (the managed platform).', + ].join('\n'); +} + +/** Re-export so existing importers (and tests) keep reading the type from deploy.ts. */ +export type { EmittedWorkerVarsResult }; + +/** + * Default {@link CloudflareDeployDeps.readEmittedWorkerVars} seam: read the REAL + * `vars` map the build baked into `dist/ssr/wrangler.json` (ground truth — the exact + * plaintext the worker ships). Delegates to the shared {@link readEmittedWorkerVars} + * reader (also used by the isolated `validate-emitted` probe child), so the parent + * and the child resolve the SAME artifact. + */ +function readEmittedWorkerVarsDefault(root: string): EmittedWorkerVarsResult { + return readEmittedWorkerVars(root); +} + +/** + * Remove the CF plugin's deploy pointer before building so the post-build + * verification can only pass on a pointer THIS build re-created. The CF plugin + * overwrites (never deletes) it, and only when the CF Vite plugin runs — a build + * that emits no worker artifact would otherwise leave a stale pointer from a prior + * run, which a bare `wrangler deploy` would follow to old code. + */ +function removeDeployPointer(root: string): void { + rmSync(deployPointerPath(root), { force: true }); +} + +/** + * Confirm the deploy config the CF Vite plugin emitted targets the pinned account, the + * expected worker name, AND the exact resources this deploy provisioned — at the + * TOP-LEVEL environment, BEFORE a bare `wrangler deploy` uploads anything. + * `wrangler deploy` (no `-c`) follows `.wrangler/deploy/config.json` → the generated + * `dist/.../wrangler.json`. A `CLOUDFLARE_ENV` / `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH` + * in the ambient env or a production `.env` can make the build FLATTEN a named env or an + * alternate config into that artifact — keeping the same `account_id`/`name` while + * swapping the binding set, so `wrangler deploy --env=` ships it as top-level with no + * mismatch. When a `bindingIdentitySnapshot` is given we therefore assert, in TWO directions, + * that the generated artifact's id-bearing bindings (D1 database_id / KV id / R2 bucket_name / + * Hyperdrive id) equal the identities PINNED before the build (the in-memory snapshot the + * drift-check / provision resolved) — NOT a post-build re-read of the root config, so a build + * that consistently rewrote BOTH the root config and this artifact is still caught: + * (a) with `desired`, every DESIRED binding must still be present with its pinned id — catches a + * binding the build DROPPED or whose id it rewrote; and + * (b) COMPLETENESS — every id-bearing binding the artifact declares must be one the snapshot + * pinned, with the pinned id — catches a binding the build ADDED (absent from the snapshot) + * or a pre-existing NON-desired binding whose id it rewrote, either of which the CF plugin + * would otherwise merge verbatim into the artifact and `wrangler deploy` would bind. + * Only these four id-bearing kinds are inspected; every other binding the build legitimately + * emits (ai / queues / durable_objects / vars / …) passes through untouched. This makes the id + * defense symmetric with the `account_id` check above, which already compares against the + * in-memory pinned account. Returns `{ ok:false, reason }` on any mismatch — or when the config + * is missing/unreadable — so the caller fails closed. + */ +export function verifyGeneratedDeployConfig( + root: string, + expected: { + accountId: string; + workerName: string; + desired?: DesiredResourceSet; + bindingIdentitySnapshot?: BindingIdentitySnapshot; + /** + * Per-assert-field binding ENTRY pin taken pre-build ({@link snapshotPassthroughIdentities}) — + * field → (bindingName → root entry). Drives the pass-through assertion: an emitted entry in a + * field Void never emits whose binding was not pinned pre-build is a build-injected binding, + * and one whose pinned target keys were rewritten is a build-retargeted binding. + */ + passthroughIdentitySnapshot?: Map>>; + /** + * Raw pre-build root values ({@link snapshotPassthroughRawFields}) for the fields the ENTRY + * pin cannot cover: `unsafe` / `tail_consumers` / `streaming_tail_consumers` + * (whole-object-pinned), `durable_objects` (external-DO check), and `ai` (binding pin). An + * emitted value that diverges from its root pin — a whole-object field added/removed/rewritten, + * an external DO absent from root, or an `ai` binding that is neither the root's nor Void's own + * (and only when the source uses AI) — fails closed. + */ + passthroughRawFields?: Map; + /** + * Whether the pre-build inference found Workers AI use in the source (the SAME `needsAI` + * signal `shouldBindWorkersAi` gates on in index.ts). Authorizes an emitted `{binding:'AI'}` + * ai binding when the root config declares no ai binding: only an AI-using app legitimately + * receives Void's injected Workers AI binding, so a build that injects one into a non-AI app + * (billable inference the source never asked for) fails closed. + */ + needsAI?: boolean; + /** + * The exact `containers` set Void's build emits, reconstructed PRE-BUILD + * ({@link reconstructExpectedContainers}) from the root-declared containers plus — when the + * source needs the sandbox — Void's own sandbox container. A bare `wrangler deploy` PROVISIONS a + * container application from the emitted array (billable, runs the image), so an emitted + * container absent from this set is one the build INJECTED, an expected container missing from + * the emitted array is one it DROPPED, and one present but not byte-identical is one it + * RETARGETED (e.g. a rewritten `image` pointing at an attacker's). + */ + expectedContainers?: Array>; + /** + * The exact cron trigger schedules Void's build emits into `triggers.crons`, reconstructed + * PRE-BUILD from the SAME `scanJobsSync` scan index.ts derives them from + * ({@link computeDesiredCloudflareResources}). A bare `wrangler deploy` reading + * `dist/ssr/wrangler.json` INSTALLS the worker's Cron Triggers from `triggers.crons`, so an + * emitted schedule absent from this multiset is one the build INJECTED (attacker code runs on a + * billable schedule) and an expected schedule missing from the emitted array is one it DROPPED + * (or, paired with an injected replacement, RETARGETED). + */ + expectedCrons?: Array; + }, +): { ok: boolean; reason?: string } { + const deployConfigPath = deployPointerPath(root); + if (!existsSync(deployConfigPath)) { + return { + ok: false, + reason: `expected the CF plugin's deploy config at ${deployConfigPath}, but it was not written by the build (a stale pointer is removed before building, so only a fresh one from this build is trusted).`, + }; + } + let redirectedPath: string; + try { + const parsed = JSON.parse(readFileSync(deployConfigPath, 'utf-8')) as { configPath?: unknown }; + if (typeof parsed.configPath !== 'string' || parsed.configPath.length === 0) { + return { ok: false, reason: `${deployConfigPath} has no "configPath" pointer.` }; + } + redirectedPath = resolve(dirname(deployConfigPath), parsed.configPath); + } catch { + return { ok: false, reason: `${deployConfigPath} is not valid JSON.` }; + } + if (!existsSync(redirectedPath)) { + return { ok: false, reason: `generated wrangler config ${redirectedPath} does not exist.` }; + } + let generated: Record; + try { + generated = JSON.parse(readFileSync(redirectedPath, 'utf-8')) as Record; + } catch { + return { ok: false, reason: `generated wrangler config ${redirectedPath} is not valid JSON.` }; + } + if (generated.account_id !== expected.accountId) { + return { + ok: false, + reason: `generated config account_id ${JSON.stringify(generated.account_id)} does not match the pinned account ${JSON.stringify(expected.accountId)}.`, + }; + } + if (generated.name !== expected.workerName) { + return { + ok: false, + reason: `generated config worker name ${JSON.stringify(generated.name)} does not match the expected worker ${JSON.stringify(expected.workerName)}.`, + }; + } + // Binding-identity check: the generated artifact's id-bearing bindings must be the SAME ones + // (by id) as were PINNED before the build (the in-memory snapshot). A flattened named-env / + // alternate config — or a build that rewrote/added an id in BOTH the root config and this + // artifact — keeps account+name but swaps the binding set, caught here against the snapshot. + if (expected.bindingIdentitySnapshot) { + const snapshot = expected.bindingIdentitySnapshot; + // (a) Every DESIRED binding must still be present with its pinned id — catches a binding the + // build DROPPED (the completeness loop below, which only walks what the artifact DECLARES, + // cannot see an omission) or whose id it rewrote. + if (expected.desired) { + for (const { kind, binding } of desiredIdentityBindings(expected.desired)) { + const { field, idField } = DEPLOY_BINDING_IDENTITY_FIELDS[kind]; + const pinnedId = snapshot.get(bindingIdentityKey(field, binding)); + // A binding with no pinned id was never provisioned with a real one (the drift check's + // job); here we only assert the generated artifact did not DIVERGE from the pre-build pin. + if (pinnedId === undefined) { + continue; + } + const generatedId = readStoredResourceName(generated, field, binding, idField); + if (generatedId === undefined) { + return { + ok: false, + reason: `generated config is missing the ${field} binding ${JSON.stringify(binding)} (pinned identity ${JSON.stringify(pinnedId)}) — a named environment or alternate config flattened a different binding set.`, + }; + } + if (generatedId !== pinnedId) { + return { + ok: false, + reason: `generated config ${field} binding ${JSON.stringify(binding)} has identity ${JSON.stringify(generatedId)} but the pinned pre-build identity is ${JSON.stringify(pinnedId)} — refusing to deploy a flattened/alternate or tampered binding set.`, + }; + } + } + } + // (b) COMPLETENESS — every id-bearing binding the artifact DECLARES must be one the snapshot + // pinned (by binding name) AND carry the pinned id. The desired loop above only inspects the + // desired set, so a binding the build ADDED to the root config (absent from the snapshot) or a + // pre-existing NON-desired binding whose id it rewrote would otherwise flow into the artifact + // and get bound by `wrangler deploy`. Scoped to the four id-bearing kinds only — every other + // binding the build emits (ai / queues / durable_objects / vars / …) is neither inspected nor + // rejected. The pin is the pre-build snapshot of the SAME root config the CF plugin merges, so + // a legitimately emitted binding's id equals what was captured; only an added / rewritten one + // diverges. + for (const { field, binding, id } of collectConfigBindingIdentities(generated)) { + const pinnedId = snapshot.get(bindingIdentityKey(field, binding)); + if (pinnedId === undefined) { + return { + ok: false, + reason: `generated config declares a ${field} binding ${JSON.stringify(binding)} that was not present before the build — refusing to deploy an id-bearing binding the build injected.`, + }; + } + if (id !== pinnedId) { + return { + ok: false, + reason: `generated config ${field} binding ${JSON.stringify(binding)} has identity ${JSON.stringify(id)} but the pinned pre-build identity is ${JSON.stringify(pinnedId)} — refusing to deploy a rewritten binding set.`, + }; + } + } + // (c) Reverse-presence — the desired loop (a) inspects only the DESIRED bindings and the + // completeness loop (b) walks only what the artifact DECLARES, so neither sees a NON-desired, + // manually-declared root id-bearing binding (e.g. a hand-added `EXTRA_DB`) the build DROPPED: it + // is absent from `desired`, and (b) cannot iterate an omission. Walk the PINNED snapshot instead — + // every pinned `:` must still appear among the emitted id-bearing bindings (read + // the SAME way (b) reads them). A field the build removed entirely leaves every pinned binding in + // it missing. This is the exact mirror of the pass-through reverse-presence loop (A1) below and is + // FP-safe on the same reasoning: a legitimate CF-plugin build spreads the root config, so no + // pinned id-binding is ever dropped; `bindingIdentitySnapshot` holds only bindings that had a real + // id pre-build, so a placeholder/absent id is never asserted here. + const emittedIdentityKeys = new Set(); + for (const { field, binding } of collectConfigBindingIdentities(generated)) { + emittedIdentityKeys.add(bindingIdentityKey(field, binding)); + } + for (const key of snapshot.keys()) { + if (!emittedIdentityKeys.has(key)) { + const separator = key.indexOf(':'); + const field = key.slice(0, separator); + const binding = key.slice(separator + 1); + return { + ok: false, + reason: `generated config no longer declares the ${field} binding ${JSON.stringify(binding)} that was present before the build — refusing to deploy a build that dropped an id-bearing binding the root config declared.`, + }; + } + } + } + // (A) Pass-through assert-set — every entry the artifact declares in a resource field Void's own + // build never emits ({@link DEPLOY_PASSTHROUGH_ASSERT_FIELDS}, derived from the wrangler binding + // registry) must have been present in the pre-build root snapshot, matched by its wrangler-shape + // identity key, AND be byte-identical to the pinned root entry. Two failures fail closed: + // • the binding name was NOT pinned pre-build → a binding the build INJECTED; and + // • the emitted entry differs from the pinned root entry → a binding the build REWROTE + // (kept the name but changed the service/namespace/type it points at, or added a sub-field). + // Compare the WHOLE entry (exact equality): a real `vite build` adds NOTHING to a PRESENT + // pass-through entry — services/vectorize/workflows/mtls_certificates/send_email/ratelimits/ + // dispatch_namespaces all emit byte-identical to root — so any added/removed/rewritten sub-field + // is the build tampering with the binding's target. Fields NOT in the assert-set are deliberately + // NOT asserted here (fail OPEN): a resource field wrangler does not yet know about must never + // false-positive a legitimate build. `unsafe` stays in the assert-set (for the drift invariant) + // but is routed to the whole-object pin below — its `.metadata`/`.capnp` are never entry-iterated. + if (expected.passthroughIdentitySnapshot) { + const passthrough = expected.passthroughIdentitySnapshot; + for (const field of DEPLOY_PASSTHROUGH_ASSERT_FIELDS) { + if (DEPLOY_WHOLE_OBJECT_PINNED_FIELDS.includes(field) || generated[field] === undefined) { + continue; + } + let emittedEntries: Array<{ name: string; entry: Record }>; + try { + emittedEntries = bindingEntriesForField(field, generated[field]); + } catch { + return { + ok: false, + reason: `generated config declares a malformed ${field} binding — refusing to deploy a binding set that cannot be verified.`, + }; + } + const pinned = passthrough.get(field) ?? new Map>(); + for (const { name, entry } of emittedEntries) { + const pinnedEntry = pinned.get(name); + if (pinnedEntry === undefined) { + return { + ok: false, + reason: `generated config declares a ${field} binding ${JSON.stringify(name)} that was not present before the build — refusing to deploy a ${field} binding the build injected.`, + }; + } + if (!deepEqual(entry, pinnedEntry)) { + return { + ok: false, + reason: `generated config ${field} binding ${JSON.stringify(name)} differs from the pre-build config — refusing to deploy a ${field} binding the build rewrote.`, + }; + } + } + } + // (A1) Reverse-presence — the forward loop above only walks EMITTED entries, so it cannot see a + // root-declared pass-through binding the build DROPPED (removing a `services` / `mtls_certificates` + // / … binding the source declared silently strips a capability). Walk the PINNED entries instead: + // every pinned binding NAME must still appear in the emitted entries for its field. A field + // ABSENT from the emitted config means every pinned name in it is missing. Whole-object-pinned + // fields are skipped — their SYMMETRIC raw pin already rejects a removed value. FP-safe: a + // legitimate CF-plugin build spreads the root config, so no pinned pass-through binding is ever + // dropped; only a build that omits one to strip what the source declared is caught here. + for (const [field, pinnedEntries] of passthrough) { + if (DEPLOY_WHOLE_OBJECT_PINNED_FIELDS.includes(field) || pinnedEntries.size === 0) { + continue; + } + const emittedNames = new Set(); + if (generated[field] !== undefined) { + try { + for (const { name } of bindingEntriesForField(field, generated[field])) { + emittedNames.add(name); + } + } catch { + // A malformed emitted field is already rejected by the forward loop; treat it as carrying + // no names so any pinned name is reported missing here too (fail closed). + } + } + for (const name of pinnedEntries.keys()) { + if (!emittedNames.has(name)) { + return { + ok: false, + reason: `generated config no longer declares the ${field} binding ${JSON.stringify(name)} that was present before the build — refusing to deploy a build that dropped a ${field} binding the source declared.`, + }; + } + } + } + } + // (A2) Whole-object + targeted pins for the fields the per-binding ENTRY pin cannot cover, each + // compared against the raw pre-build root value ({@link snapshotPassthroughRawFields}): + // • `unsafe` / `tail_consumers` / `streaming_tail_consumers` — the emitted value must be + // byte-identical to the root's. The empty check is SYMMETRIC: an emitted empty value passes + // ONLY when the root value is ALSO empty (the clean-build shape / a normalizer default-fill). + // A root that DECLARED a non-empty value emitted back EMPTY is a REMOVAL — clearing + // `tail_consumers` silently disables the source's audit/log forwarding — so it is rejected + // like any other rewrite. `unsafe.metadata` overwrites the WHOLE uploaded bindings array at + // upload, and `tail_consumers`/`streaming_tail_consumers` forward every log/request — so a + // build injecting, rewriting, OR removing one changes exfil/audit the source declared. + // • `routes` / `route` / `workers_dev` / `preview_urls` — the worker's traffic attachment. + // Void emits none, so undeclared → absent → both-empty → pass; a build-injected `routes` + // entry (attaching the worker to a hostname the account controls) or a flipped + // `workers_dev`/`preview_urls` fails the deepEqual against the absent/declared root value. + // • `durable_objects` — an emitted binding carrying a cross-script field targets ANOTHER + // worker's DOs; Void's own DOs are always LOCAL, so it must match a root binding. + // • `ai` — when the root declared its own ai binding it must SURVIVE byte-identical (a build + // that REMOVED or rewrote it is rejected); otherwise the emitted binding must be ABSENT or — + // ONLY when the source's pre-build inference says the app uses Workers AI — Void's own + // `{binding:'AI'}`. An injected `{binding:'AI'}` on an app that does not use AI would incur + // account-billed inference from build-injected worker code. + // • `migrations` — an emitted Durable Object migration carrying a DESTRUCTIVE key (deleting, + // renaming, or transferring classes) must match a root-declared migration byte-for-byte; + // Void emits only creation-key migrations, so a destructive one is a build injection that + // `wrangler deploy` would run against production Durable Object data. + if (expected.passthroughRawFields) { + const raw = expected.passthroughRawFields; + for (const field of DEPLOY_WHOLE_OBJECT_PINNED_FIELDS) { + const emitted = generated[field]; + const rootValue = raw.get(field); + // Both-empty is the only case an empty emitted value passes: a root that declared a + // non-empty value emitted back empty REMOVES a control the source configured. + if (isEmptyPassthroughValue(emitted) && isEmptyPassthroughValue(rootValue)) { + continue; + } + if (!deepEqual(emitted, rootValue)) { + return { + ok: false, + reason: `generated config ${field} value does not match the pre-build config — refusing to deploy a ${field} value the build added, removed, or rewrote.`, + }; + } + } + const externalDo = verifyNoInjectedExternalDurableObject( + generated.durable_objects, + raw.get('durable_objects'), + ); + if (!externalDo.ok) { + return externalDo; + } + // Read `rootAi` FIRST (not gated on `emittedAi !== undefined`) so a root ai binding the build + // REMOVED — emitted ai absent — is rejected too, not only a rewrite. + const emittedAi = generated.ai; + const rootAi = raw.get('ai'); + if (rootAi !== undefined) { + // The root declared its own `ai` binding — the emitted one must equal it EXACTLY, so a build + // can neither rewrite a custom root ai binding down to the default `{binding:'AI'}` nor drop + // it (`deepEqual(undefined, rootAi)` is false → a removed root ai fails closed here). + if (!deepEqual(emittedAi, rootAi)) { + return { + ok: false, + reason: `generated config ai binding does not match the pre-build root ai binding — refusing to deploy an ai binding the build removed or rewrote.`, + }; + } + } else if (emittedAi !== undefined) { + // No root ai binding: the only ai binding Void's own build emits is `{binding:'AI'}`, and + // ONLY when the pre-build inference says the source uses Workers AI (the SAME signal + // `shouldBindWorkersAi` gates on). Anything else here is a build-injected billable binding. + if (!(expected.needsAI === true && deepEqual(emittedAi, VOID_EMITTED_AI_BINDING))) { + return { + ok: false, + reason: `generated config declares an ai binding the build injected — the pre-build inference found no Workers AI use in the source and the root config declares no ai binding.`, + }; + } + } + // Reject a build-injected DESTRUCTIVE Durable Object migration. Only emitted migration entries + // carrying a destructive key are checked, and each must match a root-declared migration + // byte-for-byte, so Void's creation-only migrations and a user's own root-declared destructive + // migration both pass — only an injected one absent from root is rejected. + const destructiveMigration = verifyNoInjectedDestructiveMigration( + generated.migrations, + raw.get('migrations'), + ); + if (!destructiveMigration.ok) { + return destructiveMigration; + } + } + // (C) Containers — pin the emitted `containers` array against the set Void's build emits, + // reconstructed PRE-BUILD ({@link reconstructExpectedContainers}): root-declared containers pass + // through verbatim, plus Void's own sandbox container when the source needs it. A bare `wrangler + // deploy` PROVISIONS a container application from this array — billable, and it runs the image's + // code — so a build that INJECTS a container (an attacker image on an app that declared none), + // DROPS the sandbox container, or RETARGETS one (rewrites its `image`/`instance_type`/… to an + // attacker's) must fail closed. Compared as a set keyed by `class_name` — the identity + // `mergeBindings` itself dedups on: every emitted container's class must be expected AND + // byte-identical, and every expected class must still be emitted. `generated.containers` is read + // defensively (absent/non-array → none), and absent-vs-`[]` is treated as both-empty so a clean + // no-sandbox build (no root containers, Void adds none, emitted none) passes. Containers with no + // `class_name` are not a shape Void or a buildable root config produce; they are compared as a + // deepEqual multiset so a verbatim root passthrough still passes while an injected/dropped one + // fails closed. + if (expected.expectedContainers) { + const emittedContainers: Array = Array.isArray(generated.containers) + ? generated.containers + : []; + const expectedContainers = expected.expectedContainers; + if (emittedContainers.length !== 0 || expectedContainers.length !== 0) { + const expectedByClass = new Map>(); + const expectedNoClass: Array = []; + for (const container of expectedContainers) { + const className = readD1EntryString(container, 'class_name'); + if (className === undefined) { + expectedNoClass.push(container); + } else { + expectedByClass.set(className, container); + } + } + const emittedByClass = new Map(); + const emittedNoClass: Array = []; + for (const container of emittedContainers) { + const className = readD1EntryString(container, 'class_name'); + if (className === undefined) { + emittedNoClass.push(container); + } else { + emittedByClass.set(className, container); + } + } + // Injected / retargeted — every emitted container must be expected AND byte-identical. + for (const [className, container] of emittedByClass) { + const expectedContainer = expectedByClass.get(className); + if (expectedContainer === undefined) { + return { + ok: false, + reason: `generated config declares a container application ${JSON.stringify(className)} that Void did not derive from source — refusing to deploy a container application the build injected.`, + }; + } + if (!deepEqual(container, expectedContainer)) { + return { + ok: false, + reason: `generated config container application ${JSON.stringify(className)} does not match the pre-build config — refusing to deploy a container application the build retargeted.`, + }; + } + } + // Dropped — every expected container must still be emitted. + for (const className of expectedByClass.keys()) { + if (!emittedByClass.has(className)) { + return { + ok: false, + reason: `generated config no longer declares the container application ${JSON.stringify(className)} that Void derived from source — refusing to deploy a build that dropped a container application.`, + }; + } + } + // Class-less containers — deepEqual multiset (never a Void-emitted shape). + for (const emitted of emittedNoClass) { + const matchIndex = expectedNoClass.findIndex((candidate) => deepEqual(emitted, candidate)); + if (matchIndex === -1) { + return { + ok: false, + reason: `generated config declares a container application that Void did not derive from source — refusing to deploy a container application the build injected.`, + }; + } + expectedNoClass.splice(matchIndex, 1); + } + if (expectedNoClass.length > 0) { + return { + ok: false, + reason: `generated config no longer declares a container application that was present before the build — refusing to deploy a build that dropped a container application.`, + }; + } + } + } + // (B) Queues ⊆ desired — every queue the artifact binds (producers/consumers) must be one Void + // desired from source (`scanQueuesSync`, the SAME scan the build binds from), so a legitimate + // build always satisfies emitted ⊆ desired. A queue NAME the artifact binds that is not in the + // desired set is a queue binding the build injected — fail closed. + if (expected.desired) { + const desiredQueues = new Set(expected.desired.queues); + for (const name of collectEmittedQueueNames(generated.queues)) { + if (!desiredQueues.has(name)) { + return { + ok: false, + reason: `generated config declares a queue binding ${JSON.stringify(name)} that Void did not derive from source — refusing to deploy a queue binding the build injected.`, + }; + } + } + // (B1) desired ⊆ emitted, PER GROUP — the emitted ⊆ desired check above rejects an INJECTED + // queue but waves through a DROPPED one: an absent/empty `queues` field, or one retaining only + // the producer OR only the consumer, would deploy a worker that loses message processing or + // leaves a `QUEUE_*` binding undefined. `mergeBindings` (index.ts) emits EVERY desired queue as + // BOTH a producer and a consumer (both groups mapped from the SAME scanned array), so requiring + // each desired queue name in both emitted groups is FP-safe. Read each group defensively (a + // missing/empty/wrong-shaped `queues` field yields empty sets → any desired queue is reported + // missing → reject); per-group presence is why this cannot reuse the merged reader above. + const emittedProducers = collectEmittedQueueGroupNames(generated.queues, 'producers'); + const emittedConsumers = collectEmittedQueueGroupNames(generated.queues, 'consumers'); + for (const name of expected.desired.queues) { + if (!emittedProducers.has(name) || !emittedConsumers.has(name)) { + return { + ok: false, + reason: `generated config no longer binds the queue ${JSON.stringify(name)} as both a producer and a consumer that Void derived from source — refusing to deploy a build that dropped a queue binding the source declared.`, + }; + } + } + } + // (D) Cron Triggers — pin the emitted `triggers.crons` schedules against the set Void's build + // emits, reconstructed PRE-BUILD from the SAME `scanJobsSync` scan index.ts derives them from + // (`scanJobsSync(root, paths.cronsDir).flatMap((job) => job.crons)`). A bare `wrangler deploy` + // reading `dist/ssr/wrangler.json` INSTALLS the worker's Cron Triggers from this array — a + // schedule the build INJECTED runs attacker code on a billable schedule, and one it DROPPED (or + // RETARGETED, caught as a drop of the old string plus an injection of the new one) silently + // changes the worker's scheduled behavior. Compared as an order-independent, count-aware multiset + // of schedule strings. `generated.triggers` is read DEFENSIVELY: only an object with an array + // `crons` yields emitted schedules — anything else (absent, a bare `{}` the CF plugin + // materializes for a no-cron app, a non-array `crons`) yields none, so a no-cron app (expected + // empty, emitted none) passes symmetric-empty. A non-string emitted element is never a shape Void + // derives, so it fails the expected lookup and is rejected as injected. + if (expected.expectedCrons) { + const triggers = generated.triggers; + const emittedCrons: Array = + typeof triggers === 'object' && + triggers !== null && + Array.isArray((triggers as Record).crons) + ? ((triggers as Record).crons as Array) + : []; + const expectedCronCounts = new Map(); + for (const schedule of expected.expectedCrons) { + expectedCronCounts.set(schedule, (expectedCronCounts.get(schedule) ?? 0) + 1); + } + // Injected / retargeted — every emitted schedule must be one the source derived, with a + // remaining count; anything else is a schedule the build injected. + for (const schedule of emittedCrons) { + const remaining = typeof schedule === 'string' ? (expectedCronCounts.get(schedule) ?? 0) : 0; + if (typeof schedule !== 'string' || remaining <= 0) { + return { + ok: false, + reason: `generated config declares a cron trigger schedule ${JSON.stringify(schedule)} that Void did not derive from source — refusing to deploy a schedule the build injected.`, + }; + } + expectedCronCounts.set(schedule, remaining - 1); + } + // Dropped — every expected schedule must still be emitted; any leftover count is a schedule the + // build dropped (e.g. the old side of a retarget). + for (const [schedule, remaining] of expectedCronCounts) { + if (remaining > 0) { + return { + ok: false, + reason: `generated config no longer installs the cron trigger schedule ${JSON.stringify(schedule)} that Void derived from source — refusing to deploy a build that dropped a cron trigger.`, + }; + } + } + } + return { ok: true }; +} + +/** Result of one orchestrator-owned wrangler invocation (whoami/migrate/deploy). */ +export type DeployWranglerRunResult = { + status: number; + stdout: string; + stderr: string; + /** Non-null ONLY when the child was killed by a signal — a HARD failure. */ + signal?: NodeJS.Signals | null; + /** Spawn-error message when the child could not start — a HARD failure. */ + error?: string | null; +}; + +/** + * Injectable seam over the orchestrator's OWN wrangler calls (`whoami`, + * `d1 migrations apply`, `deploy`). `capture:true` pipes stdout/stderr (so + * `whoami --json` can be classified); `capture:false` inherits the terminal + * (migrate/deploy stream their own progress + URL). Unit tests inject a stub so + * no real wrangler runs. Mirrors the provisioner's `RunWrangler` structured + * provenance so a signal-killed / unspawnable child is never read as success. + */ +export type DeployWranglerRun = ( + args: Array, + opts: { capture: boolean }, +) => DeployWranglerRunResult | Promise; + +/** + * Injectable seams for {@link runCloudflareDeploy}, so the whole orchestration + * is unit-testable with NO real wrangler / network / Cloudflare account — the + * same pattern the provisioner (`cli/provision.ts`) and the self-host secret + * units (`cli/env-validation.ts`) use. + */ +export interface CloudflareDeployDeps { + /** wrangler runner for the orchestrator's own whoami/migrate/deploy calls. */ + runWrangler?: DeployWranglerRun; + /** Run the project's `vite build` (inherited stdio). Throws on a nonzero build. */ + runBuild?: (env: NodeJS.ProcessEnv) => void; + /** + * Wrangler-uploader content-hash seam (default: {@link hashWranglerUploader}). Snapshotted + * BEFORE the build and re-checked AFTER, so a build that overwrote the wrangler bundle the + * credentialed remote migrate / deploy execute fails closed before either runs. + */ + hashWranglerUploader?: (root: string) => string | undefined; + /** Provisioner seam (default: `provisionCloudflareResources`, loaded dynamically). */ + provision?: typeof provisionCloudflareResources; + /** Parent-side secret-gate discharge seam (default: {@link dischargeSelfHostEnvContext}). */ + dischargeSelfHostEnv?: typeof dischargeSelfHostEnvContext; + /** Generated-deploy-config verification seam (default: {@link verifyGeneratedDeployConfig}). */ + verifyDeployConfig?: typeof verifyGeneratedDeployConfig; + /** Pre-remote-migrate gate seam (default: {@link assertRemoteMigrationsDeployable}). */ + assertMigrationsDeployable?: typeof assertRemoteMigrationsDeployable; + /** Migration apply-set equality gate seam (default: {@link checkWranglerMigrationApplySet}). */ + checkMigrationApplySet?: typeof checkWranglerMigrationApplySet; + /** + * Auth-schema validation seam (default: {@link validateAuthMigrationSchema}). + * Runs POST-build: reads the generated auth schema the build emitted + * (`.void/better-auth-schema.ts`) and asserts the checked-in migrations produce it. + */ + validateAuthMigrations?: typeof validateAuthMigrationSchema; + /** + * Isolated env-schema probe seam (default: the {@link EnvSchemaProbeInput} + * subprocess). Receives a scrubbed env and returns the env-INSENSITIVE schema facts + * (required-secret NAMES + `.secret()` flags), derived by importing env.ts in the + * child, so the parent runs no project code. A `schema-error` (env.ts failed to + * evaluate, or a validator threw) fails the deploy closed. + */ + resolveSchemaFacts?: ( + input: EnvSchemaProbeInput, + env: NodeJS.ProcessEnv, + ) => EnvSchemaProbeResult | Promise; + /** + * Isolated emitted-var validation seam (default: the {@link EnvSchemaProbeInput} + * subprocess in `validate-emitted` mode). Runs POST-build: validates the REAL emitted + * worker `vars` against the schema's validators in the child and returns the FAILING + * key NAMES only, which fold into the secret-gate context so a malformed required + * value fails the deploy closed before any remote mutation. + */ + validateEmittedVars?: ( + input: EnvSchemaProbeInput, + env: NodeJS.ProcessEnv, + ) => EnvSchemaValidateResult | Promise; + /** + * Emitted-worker-vars read seam (default: {@link readEmittedWorkerVarsDefault}). + * Runs POST-build: reads the REAL `vars` map the build baked into + * `dist/ssr/wrangler.json` — the ground truth the plaintext warnings + secret gate + * validate. + */ + readEmittedWorkerVars?: (root: string) => EmittedWorkerVarsResult; + /** Override CI / non-interactive detection (default: `!stdin.isTTY || getTokenSource()==='ci'`). */ + isCI?: boolean; + /** Override `process.exit` (tests assert the exit code without halting). */ + exit?: (code: number) => never; +} + +/** Human labels for a drifted resource kind, used in guidance messages. */ +const CLOUDFLARE_RESOURCE_LABELS: Record = { + d1: 'D1 database', + kv: 'KV namespace', + r2: 'R2 bucket', + hyperdrive: 'Hyperdrive config', +}; + +/** + * Why a plaintext worker var was flagged, rendered next to its key name. These + * are VALUES-FREE by construction — the classifier inspects a value to pick the + * reason, but only ever returns the key, so nothing here can echo a secret. + */ +const PLAINTEXT_WARNING_REASONS: Record = { + 'schema-secret': ' (env.ts .secret())', + heuristic: '', + 'value-shape': ' (value looks like a connection URL with embedded credentials)', +}; + +/** True only for a wrangler child that ran to completion with a clean exit 0. */ +function isCleanWranglerExit(result: DeployWranglerRunResult): boolean { + return ( + result.status === 0 && result.signal == null && (result.error == null || result.error === '') + ); +} + +/** + * Parse the root wrangler config (BOM-aware, trailing commas allowed), matching + * the provisioner's `readRootConfig`. Returns `{}` when the file is absent or + * unreadable — callers then treat every field as unset (safe: a genuinely broken + * config fails later in wrangler with a clear error). + */ +function parseRootWranglerConfig(configPath: string): Record { + if (!existsSync(configPath)) { + return {}; + } + let raw: string; + try { + raw = readFileSync(configPath, 'utf-8'); + } catch { + return {}; + } + const content = raw.startsWith('') ? raw.slice(1) : raw; + const errors: Array = []; + const parsed = parseJsoncConfig(content, errors, { allowTrailingComma: true }); + if (errors.length > 0 || typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return {}; + } + return parsed as Record; +} + +/** Read a non-empty top-level string field (`account_id` / `name`) from the config. */ +function readWranglerTopLevelString( + config: Record, + field: string, +): string | undefined { + const value = config[field]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** Read the stored resource name (`database_name` / `bucket_name`) for a binding. */ +function readStoredResourceName( + config: Record, + field: string, + binding: string, + nameField: string, +): string | undefined { + const list = config[field]; + if (!Array.isArray(list)) { + return undefined; + } + for (const entry of list) { + if ( + entry && + typeof entry === 'object' && + (entry as { binding?: unknown }).binding === binding + ) { + const name = (entry as Record)[nameField]; + return typeof name === 'string' && name.length > 0 ? name : undefined; + } + } + return undefined; +} + +/** + * Whether a resource binding DECLARES a field at all — present with ANY defined + * value, INCLUDING an empty string. Distinct from {@link readStoredResourceName} + * (which collapses both "absent" and "empty string" to `undefined`): a + * present-but-empty `migrations_pattern: ""` is still a CUSTOM pattern — wrangler + * 4.107 takes its non-default branch for any value that is not `undefined` + * (`rawPattern !== void 0`), so an empty pattern diverges from the default flat + * `*.sql` enumeration this backend validates against. v1 therefore rejects a + * custom pattern on PRESENCE, not on a non-empty length. Returns the raw value so + * the caller can name it (never a secret — a wrangler config field). + */ +function readBindingFieldPresence( + config: Record, + field: string, + binding: string, + key: string, +): { present: boolean; value: unknown } { + const list = config[field]; + if (!Array.isArray(list)) { + return { present: false, value: undefined }; + } + for (const entry of list) { + if ( + entry && + typeof entry === 'object' && + (entry as { binding?: unknown }).binding === binding + ) { + return Object.hasOwn(entry as object, key) + ? { present: true, value: (entry as Record)[key] } + : { present: false, value: undefined }; + } + } + return { present: false, value: undefined }; +} + +/** Read a `d1_databases` entry's string field. */ +function readD1EntryString(entry: unknown, field: string): string | undefined { + if (!entry || typeof entry !== 'object') { + return undefined; + } + const value = (entry as Record)[field]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * Resolve the token to pass to `wrangler d1 migrations apply` so wrangler's OWN + * resolution (`getDatabaseInfoFromConfig`, wrangler 4.107 src/d1/utils.ts) selects + * EXACTLY the `d1_databases` entry the gate validated (matched by `binding`). wrangler + * iterates the array and returns the FIRST entry where + * `token === database_name || token === binding`, then uses that entry's `migrations_dir`. + * We prefer the validated entry's own `database_name` as the token (falling back to the + * binding), and FAIL CLOSED when an EARLIER entry also matches it — because wrangler would + * then select the earlier entry (a different DB / an unrelated migrations folder) though + * the gate validated ours. + */ +function resolveD1MigrateTarget( + config: Record, + binding: string, +): { ok: true; token: string } | { ok: false; reason: string } { + const list = config.d1_databases; + if (!Array.isArray(list)) { + return { ok: false, reason: `the root config has no "d1_databases" array` }; + } + const validatedIndex = list.findIndex((entry) => readD1EntryString(entry, 'binding') === binding); + if (validatedIndex === -1) { + return { ok: false, reason: `no d1_databases entry has binding ${JSON.stringify(binding)}` }; + } + const validated = list[validatedIndex]; + // wrangler matches by database_name OR binding; the validated entry always matches its + // own binding, so the binding is a safe fallback token when it has no database_name. + const token = readD1EntryString(validated, 'database_name') ?? binding; + for (let i = 0; i < validatedIndex; i++) { + const earlier = list[i]; + if ( + readD1EntryString(earlier, 'database_name') === token || + readD1EntryString(earlier, 'binding') === token + ) { + return { + ok: false, + reason: + `an earlier d1_databases entry (binding ${JSON.stringify(readD1EntryString(earlier, 'binding'))}, ` + + `database_name ${JSON.stringify(readD1EntryString(earlier, 'database_name'))}) also matches the ` + + `token ${JSON.stringify(token)}, so wrangler would migrate it instead`, + }; + } + } + return { ok: true, token }; +} + +/** The length cap {@link toValidWorkerName} applies (the CF plugin's own rule). */ +const MAX_WORKER_NAME_LENGTH = 63; + +/** + * Sanitize a raw name into a valid Cloudflare Worker name — the SAME rule + * `@cloudflare/vite-plugin`'s `toValidWorkerName` applies (lowercase, `_`→`-`, + * any non `[a-z0-9-]`→`-`, strip leading/trailing dashes, ≤63 chars, empty → + * `my-worker`). Deriving it identically means the name we pin in the config and + * the name the build emits into `dist/ssr/wrangler.json` agree, so + * `wrangler secret list -c ` and `wrangler deploy` target one worker. + */ +function toValidWorkerName(raw: string): string { + const name = raw + .toLowerCase() + .replace(/_/g, '-') + .replace(/[^a-z0-9-]/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, MAX_WORKER_NAME_LENGTH) + .replace(/-+$/g, ''); + return name.length > 0 ? name : 'my-worker'; +} + +/** + * Derive a `-` resource name, RESERVING the suffix's budget before + * the length cap applies. + * + * `toValidWorkerName` truncates whatever string it is handed, so + * `toValidWorkerName(`${workerName}-kv`)` cuts from the RIGHT — the end that carries + * the suffix. A worker name at or above the cap therefore loses the suffix entirely + * and every derivation collapses to the bare (truncated) worker name, so `KV` and + * `ISR_CACHE` would ask for ONE namespace title. Since KV titles are not + * account-unique and the provisioner matches remote namespaces BY TITLE (re-listing + * per binding), the second binding would adopt the namespace the first just created: + * two bindings sharing one namespace and one quota slot. + * + * The suffix is kept intact by trimming the BASE to `63 - suffix.length - 1` first. + * When the suffix survives the plain derivation (every worker name short enough to + * leave room — the normal case), the result is byte-identical to what it has always + * been, so no existing app's resource name churns. + */ +function suffixedResourceName(workerName: string, suffix: string): string { + const full = toValidWorkerName(`${workerName}-${suffix}`); + if (full.endsWith(`-${suffix}`)) { + return full; + } + const base = toValidWorkerName(workerName) + .slice(0, MAX_WORKER_NAME_LENGTH - suffix.length - 1) + .replace(/-+$/g, ''); + return toValidWorkerName(`${base}-${suffix}`); +} + +/** + * Derive the worker name the way the CF plugin's `getWorkerNameFromProject` does + * MINUS the `WRANGLER_CI_OVERRIDE_NAME` precedence: package.json `name` → directory + * basename → `my-worker`. The override is deliberately NOT consulted — this backend + * scrubs it from every child env and fences it in the project dotenv (see + * {@link deployChildEnv} / {@link assertNoConfigOverrideInProjectDotenv}), so the + * build emits (and the deploy uploads) the non-override name; deriving from the + * override here would pin a name the scrubbed build never produces. + */ +function deriveWorkerName(root: string): string { + let pkgName: string | undefined; + try { + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')) as { name?: unknown }; + if (typeof pkg.name === 'string' && pkg.name.length > 0) { + pkgName = pkg.name; + } + } catch { + // no package.json / unreadable — fall through to the directory basename. + } + return toValidWorkerName(pkgName ?? basename(root) ?? ''); +} + +/** + * Fail closed when two KV bindings of this deploy would end up on ONE namespace. + * + * KV is the only resource kind where a name collision silently WORKS: titles are not + * account-unique, the provisioner matches remote namespaces BY TITLE, and it re-lists + * per binding — so a second binding desiring an already-created title adopts that + * namespace and reports success. Both checks below are cheap and purely local: + * + * 1. DESIRED titles must be distinct — catches any future suffix collision at the + * source, before a single namespace is created. + * 2. Already-RECORDED ids must be distinct — an app provisioned before the suffix + * budget was reserved can carry two bindings pointing at the same namespace id. + * Read-first is keyed by binding and both entries hold a real id, so the + * provisioner would skip both forever and never surface it. + * + * Scoped to KV (and to the bindings THIS deploy manages): D1/R2/Hyperdrive names live + * in separate Cloudflare namespaces and are identity-checked against their stored + * values elsewhere. + */ +function assertDistinctKvNamespaces( + kvNamespaces: Array<{ binding: string; title: string }>, + config: Record, + configPath: string, + isRealKvId: (value: unknown) => value is string, +): void { + const bindingByTitle = new Map(); + for (const { binding, title } of kvNamespaces) { + const owner = bindingByTitle.get(title); + if (owner !== undefined) { + throw new Error( + `deploy: KV bindings ${JSON.stringify(owner)} and ${JSON.stringify(binding)} both resolve ` + + `to the namespace title ${JSON.stringify(title)}. KV titles are not account-unique, so ` + + `provisioning would put both bindings on ONE namespace. Give the worker a shorter ` + + `"name" in "${configPath}" so each binding derives its own title, then retry.`, + ); + } + bindingByTitle.set(title, binding); + } + + const managed = new Set(kvNamespaces.map((kv) => kv.binding)); + const entries = config.kv_namespaces; + if (!Array.isArray(entries)) { + return; + } + const bindingById = new Map(); + for (const entry of entries) { + if (!entry || typeof entry !== 'object') { + continue; + } + const { binding, id } = entry as { binding?: unknown; id?: unknown }; + if (typeof binding !== 'string' || !managed.has(binding) || !isRealKvId(id)) { + continue; + } + const owner = bindingById.get(id); + if (owner !== undefined && owner !== binding) { + throw new Error( + `deploy: KV bindings ${JSON.stringify(owner)} and ${JSON.stringify(binding)} in ` + + `"${configPath}" both point at KV namespace id ${JSON.stringify(id)}. They resolved to a ` + + `single namespace when they were provisioned, so the two bindings share one namespace ` + + `and one quota slot. Give one of them a fresh namespace (create it, or drop its entry ` + + `from "${configPath}" and re-run with --provision), then retry.`, + ); + } + bindingById.set(id, binding); + } +} + +/** + * Compute the DESIRED id/name-bearing Cloudflare resources from SOURCE + * (pre-build, fail-fast) — the single set shared by the provision and drift + * paths. Reuses the deploy-time inference primitives directly + * (`inferProjectBindings` + the scanners + the pg-dialect / `hasRevalidate` + * signals) rather than `assembleVoidManifest`, which runs only inside the + * managed `runFullDeploy` branches that need a `PlatformClient`. + * + * Resource NAMES prefer the value already stored in the root wrangler config + * (so a re-provision reuses the same database/bucket and the provisioner's + * stored-name identity check passes); otherwise they default off the worker + * name. Returns a per-binding REASON map used to explain drift. + */ +export async function computeDesiredCloudflareResources( + root: string, + voidConfig: VoidConfig, + configPath: string, +): Promise<{ + desired: DesiredResourceSet; + reasons: Map; + /** Whether Void-managed auth is enabled (drives the auth-migration gate). */ + authEnabled: boolean; + /** Resolved database dialect (sqlite → D1, postgresql → Hyperdrive). */ + dialect: DatabaseDialect; + /** Absolute migrations dir on disk (sourceDir-aware) — used by the migrate gates. */ + migrationsDir: string; + /** Checked-in migrations (`db/migrations/*.sql`) collected pre-build. */ + migrations: Array<{ name: string; sql: string }>; + /** + * Whether the pre-build inference found Workers AI use in the source — the SAME `needsAI` + * signal `shouldBindWorkersAi` (index.ts) gates the emitted `{binding:'AI'}` on. Threaded + * into {@link verifyGeneratedDeployConfig} to authorize that emitted ai binding. + */ + needsAI: boolean; + /** + * Whether Void's build emits the sandbox container application — the SAME signal `mergeBindings` + * (index.ts) gates emission on: `isSandboxEnabled(config)` (a `sandbox` field in `void.json`) OR + * the import walk detecting a `void/sandbox` import (`inferProjectBindings(...).needsSandbox`). + * Threaded into {@link verifyGeneratedDeployConfig} (via {@link reconstructExpectedContainers}) + * to pin the emitted `containers` array. + */ + needsSandbox: boolean; + /** + * The exact cron trigger schedules Void's build emits into `dist/ssr/wrangler.json` + * `triggers.crons`, reconstructed PRE-BUILD from the SAME `scanJobsSync(root, paths.cronsDir)` + * scan index.ts derives them from (`scanJobsSync(...).flatMap((job) => job.crons)`). Threaded + * into {@link verifyGeneratedDeployConfig} to pin the emitted schedules against a build that + * injected, dropped, or retargeted a Cron Trigger. + */ + expectedCrons: Array; +}> { + // The id predicates are resolved via the memoized dynamic import so this exported + // helper works when called directly in unit tests (outside the fork) without a + // static provision edge that would eagerly load the provision lifecycle. + const { isRealD1Id, isRealKvId } = await loadProvisionModule(); + const paths = resolveProjectPaths(root, voidConfig); + const config = parseRootWranglerConfig(configPath); + const workerName = readWranglerTopLevelString(config, 'name') ?? deriveWorkerName(root); + + // Match runFullDeploy's inference: framework (Class-A) apps expand the scan + // dirs so bindings used in framework-specific dirs are still discovered. + const isFrameworkMode = detectFramework(root) != null; + const scanDirs = + voidConfig.inference?.scanDirs ?? (isFrameworkMode ? FRAMEWORK_SCAN_DIRS : undefined); + // Mirror voidPlugin's inference options EXACTLY (index.ts): when `void.json` + // declares `inference.bindings`, those explicit overrides (forced `true`, a + // custom binding name, or disabled `false`) drive the build — so the desired set + // must consume the same overrides, or a `false` override would still provision a + // binding the build never emits (and a forced/custom one would be missed). + const inferenceOptions = voidConfig.inference?.bindings + ? { bindings: voidConfig.inference.bindings } + : undefined; + const bindings = inferProjectBindings(root, { ...inferenceOptions, paths }, scanDirs); + const authEnabled = + bindings.needsAuth || findVoidAuthConfig(root, paths) !== null || voidConfig.auth !== undefined; + const dialect = getDatabaseDialect(voidConfig); + const bindingNames = resolveBindingNames(voidConfig.inference?.bindings); + // Checked-in migrations count toward the database gate, matching the managed + // manifest (`buildVoidManifest`: `needsD1 || needsAuth || hasMigrations`). Without + // this a migration-only SQLite app would provision no D1 and silently skip remote + // migrations. Collected ONCE pre-build from `db/migrations/` (sourceDir-aware) and + // carried through so the migrate step can skip when there are none and run the + // deploy gates on the exact same files. + const migrations = collectMigrations(paths.migrationsDir); + const hasMigrations = migrations.length > 0; + // Config-relative migrations dir wrangler will resolve from the config-file dir + // (the root). sourceDir moves the files to `/db/migrations`, so the + // written `migrations_dir` must include it — a hardcoded `./db/migrations` would + // point wrangler at the wrong (or missing) folder for a sourceDir project. + const migrationsDirRelative = paths.sourceDir + ? `./${paths.sourceDir}/db/migrations` + : './db/migrations'; + + const desired: DesiredResourceSet = { kvNamespaces: [], queues: [] }; + const reasons = new Map(); + + // Database — desired on source-use OR auth OR checked-in migrations (the SAME + // predicate the managed manifest applies), with the dialect selecting D1 (sqlite) + // vs Hyperdrive (postgresql). This keeps provision/drift equivalent to the build: + // a migration-only SQLite app still provisions D1, and an unused pg config does + // not needlessly require Hyperdrive. + const needsDatabase = bindings.needsD1 || authEnabled || hasMigrations; + const databaseReason = bindings.needsD1 + ? 'app uses env.DB (D1)' + : authEnabled + ? 'auth requires a D1 database' + : 'checked-in migrations need a D1 database'; + if (needsDatabase && dialect !== 'postgresql') { + const binding = bindingNames?.d1 ?? 'DB'; + // Trust a stored `database_name` ONLY when the paired `database_id` is a REAL + // (non-placeholder) id. A synced-but-unprovisioned entry carries the local + // placeholder id `"local"` (often alongside `database_name: "default"`); trusting + // that name would provision/adopt an account-wide DB literally named `default`. + const storedName = readStoredResourceName(config, 'd1_databases', binding, 'database_name'); + const storedId = readStoredResourceName(config, 'd1_databases', binding, 'database_id'); + const name = + storedName != null && isRealD1Id(storedId) + ? storedName + : suffixedResourceName(workerName, 'db'); + // Carry the sourceDir-aware config-relative migrations dir so the provisioner + // writes the correct `migrations_dir` (it otherwise hardcodes `./db/migrations`). + desired.d1 = { binding, name, migrationsDir: migrationsDirRelative }; + reasons.set(binding, databaseReason); + } + + // KV + if (bindings.needsKV) { + const binding = bindingNames?.kv ?? 'KV'; + desired.kvNamespaces.push({ binding, title: suffixedResourceName(workerName, 'kv') }); + reasons.set(binding, 'app uses env.KV'); + } + + // R2 + if (bindings.needsR2) { + const binding = bindingNames?.r2 ?? 'STORAGE'; + const storedName = readStoredResourceName(config, 'r2_buckets', binding, 'bucket_name'); + const bucketName = + storedName && storedName !== 'default' + ? storedName + : suffixedResourceName(workerName, 'storage'); + desired.r2 = { binding, bucketName }; + reasons.set(binding, 'app uses env.STORAGE (R2)'); + } + + // Queues (resource names — account-unique; the build binds them for native apps). + desired.queues = scanQueuesSync(root, paths.queuesDir).map((q) => q.name); + + // Hyperdrive (PostgreSQL apps) — same database gate as D1, dialect-selected. The + // connection string is only consumed on a fresh create; a re-provision reads-first + // from the config id and never needs it. + if (needsDatabase && dialect === 'postgresql') { + const binding = 'HYPERDRIVE'; + desired.hyperdrive = { + binding, + name: suffixedResourceName(workerName, 'hyperdrive'), + connectionString: process.env.DATABASE_URL ?? '', + }; + reasons.set(binding, 'app uses PostgreSQL (needs a Hyperdrive config)'); + } + + // ISR_CACHE (a KV namespace) — self-host-only, not in the import walk. Desired + // when the merged revalidate config (void.json `routing.revalidate` + per-page + // `export const revalidate`) turns ISR on — the SAME gate the build's `emitIsr` + // uses (`plugin-routing.ts`). + const pageScan = existsSync(paths.pagesDir) + ? await scanPages(root, { output: voidConfig.output, paths }) + : null; + if (hasRevalidate(collectRevalidate(voidConfig, pageScan))) { + const binding = 'ISR_CACHE'; + desired.kvNamespaces.push({ binding, title: suffixedResourceName(workerName, 'isr-cache') }); + reasons.set(binding, 'routes use `revalidate` (ISR) — needs an ISR_CACHE KV namespace'); + } + + // Every KV binding must own its namespace — both the titles this deploy would + // provision and the ids an earlier provision already recorded. + assertDistinctKvNamespaces(desired.kvNamespaces, config, configPath, isRealKvId); + + // Cron trigger schedules — the EXACT expression index.ts emits into `triggers.crons` + // (`scanJobsSync(root, paths.cronsDir).flatMap((job) => job.crons)`), from the SAME scan and the + // SAME `paths` the build's emission sees, so a legitimate build's emitted schedules multiset-equal + // this reconstruction. + const expectedCrons = scanJobsSync(root, paths.cronsDir).flatMap((job) => job.crons); + + return { + desired, + reasons, + authEnabled, + dialect, + migrationsDir: paths.migrationsDir, + migrations, + needsAI: bindings.needsAI, + expectedCrons, + // Mirror index.ts's emission signal EXACTLY (line 658): the sandbox container is emitted when + // `void.json` enables it OR the source imports `void/sandbox` (the import-walk signal). Using + // `isSandboxEnabled` alone would miss an import-only sandbox app (e.g. kitchen-sink) and the + // container pin would then FALSE-POSITIVE a legitimate build. + needsSandbox: isSandboxEnabled(voidConfig) || bindings.needsSandbox, + }; +} + +/** A column the emitted auth schema requires, with the constraints that carry correctness. */ +export type RequiredAuthColumn = { + column: string; + /** The emitted field is `unique: true` — a duplicate row here is a duplicate auth identity. */ + unique: boolean; + /** + * The emitted field is `required: true` — the column must be NOT NULL. `PRAGMA + * table_info.notnull` answers this directly, with one exception: the `INTEGER PRIMARY KEY` + * rowid alias reports `notnull = 0` yet SQLite assigns a rowid on a NULL insert, so it can + * never hold NULL and must not be rejected. A true alias is identified by the ABSENCE of an + * `origin = 'pk'` row in `PRAGMA index_list`, not by its declared type — `INTEGER PRIMARY + * KEY DESC` declares the same type but is no alias and really does store NULL. + */ + required?: boolean; + /** + * This is the table's implicit `id` — the row identity every auth foreign key points at. + * SQLite requires a PARENT KEY to be uniquely constrained (a PRIMARY KEY or a UNIQUE + * constraint/index), so an `id` that is an ordinary column cannot carry the auth schema's + * foreign keys. `PRAGMA foreign_key_check` is the authority on whether an EXISTING foreign + * key's parent key is usable, but it only validates foreign keys that were actually + * declared — a child table that declares none at all comes back clean. This flag is + * therefore asserted on its own, so a non-unique `id` is caught even before any child + * references it. + */ + primaryKey?: boolean; + /** The emitted field carries `references` — the parent table + column it must point at. */ + references?: { table: string; column: string }; +}; + +/** + * One row of `PRAGMA table_info`. `type` is the type the migration DECLARED, echoed back — one + * of the conditions for recognizing the `INTEGER PRIMARY KEY` rowid alias, which no column of + * this PRAGMA distinguishes from an ordinary nullable primary key on its own (`PRAGMA + * index_list` supplies the discriminator). + */ +type TableInfoRow = { name: string; type: string | null; notnull: number; pk: number }; + +/** One table of the emitted auth schema, reduced to what the migrations must reproduce. */ +export type RequiredAuthTable = { + table: string; + columns: Array; +}; + +/** + * Apply the checked-in migrations to an in-memory SQLite database (`node:sqlite`, a repo + * dependency — the same driver `auth/node.ts` uses) and assert the effective schema contains + * every REQUIRED auth table, its columns, AND the constraints that carry correctness. This + * replaces a raw `CREATE TABLE` token search (defeated by comments/strings/renames/plugin + * tables) with the real POST-migration schema — the identical thing wrangler will apply to + * remote D1 (SQLite). + * + * Names alone are not enough: Void ships no auth migration scaffolder, so users hand-write + * `db/schema.ts` and run `void db generate`, where Drizzle's `.unique()` and `.references()` + * are both opt-in. A migration set with every right NAME but no unique index on `user.email` + * and no `session.userId` foreign key is a materially weaker schema than the one the managed + * path builds (`applySqliteAuthMigrations`), and it used to sail through this gate. + * + * Asserted: column presence, the implicit `id`'s uniqueness (`primaryKey`), `required` + * (NOT NULL), `unique: true`, and `references` (parent table + column — including that an + * implicit `REFERENCES "parent"` resolves to the parent's actual PRIMARY KEY). Those + * reference checks answer only WHICH column a foreign key names; whether SQLite will ACCEPT + * that parent key is settled by `PRAGMA foreign_key_check` on the migrated (still empty) + * database, which applies SQLite's own rule rather than a local re-derivation of it. + * Deliberately NOT asserted — these would reject legitimate schemas: + * - column TYPE — SQLite is dynamically typed, and Drizzle's output varies by mode. + * - non-unique `index: true` — a performance property, not a correctness one. + * - the exact `onDelete` action — over-tight; the reference itself is the invariant. + * + * Throws naming the exact offending `table.column` and what is missing (identifiers only, + * never values), or when a migration cannot even be applied. Exported for unit testing (the + * `required` set is passed in, so this needs no Better Auth resolution). + */ +export async function assertMigrationsProduceAuthSchema( + migrations: Array<{ name: string; sql: string }>, + required: Array, +): Promise { + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(':memory:'); + const quote = (identifier: string): string => `"${identifier.replace(/"/g, '""')}"`; + // SQLite resolves identifiers case-insensitively, but `sqlite_master.name` and every PRAGMA + // echo back the casing the migration DECLARED. So `CREATE TABLE "User" ("Email" TEXT UNIQUE)` + // is the same schema as the emitted `user.email` — lowercase SELECTs resolve against it and + // the UNIQUE constraint enforces — and must not read as missing here. Every identifier this + // function compares (table, column, unique index member, FK parent) is folded through this + // one notion of equality; only the PRAGMA arguments keep the caller's original spelling, + // since SQLite resolves those case-insensitively anyway. + const foldIdentifier = (identifier: string): string => identifier.toLowerCase(); + const sameIdentifier = (a: string, b: string): boolean => foldIdentifier(a) === foldIdentifier(b); + // `PRAGMA table_info`, memoized. A foreign key is validated against the PARENT table's info, + // and the parent is not necessarily a table this loop has reached (or will ever reach), so + // this must be able to answer for an arbitrary table at any point. SQLite resolves the PRAGMA + // argument case-insensitively, so the cache is keyed on the FOLDED name while the query keeps + // the caller's spelling. A table no migration created yields `[]`, not an error. + const tableInfoCache = new Map>(); + const tableInfoFor = (table: string): Array => { + const key = foldIdentifier(table); + const cached = tableInfoCache.get(key); + if (cached !== undefined) { + return cached; + } + const rows = db.prepare(`PRAGMA table_info(${quote(table)})`).all() as Array; + tableInfoCache.set(key, rows); + return rows; + }; + // The table's single-column PRIMARY KEY, or null when it has none or a COMPOSITE one. This is + // the ONLY thing SQLite resolves an omitted foreign-key target column to. + const singleColumnPrimaryKey = (table: string): string | null => { + const primaryKey = tableInfoFor(table).filter((row) => Number(row.pk) > 0); + return primaryKey.length === 1 && primaryKey[0] ? primaryKey[0].name : null; + }; + try { + for (const migration of migrations) { + try { + db.exec(migration.sql); + } catch (err) { + throw new Error( + `checked-in migration ${migration.name} could not be applied to a fresh SQLite database, ` + + `so the auth schema cannot be verified: ${getErrorMessage(err)}`, + ); + } + } + const tables = new Set( + ( + db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ + name: string; + }> + ).map((row) => foldIdentifier(row.name)), + ); + const problems: Array = []; + for (const req of required) { + if (!tables.has(foldIdentifier(req.table))) { + problems.push(`table "${req.table}" is not created by any checked-in migration`); + continue; + } + const tableInfo = tableInfoFor(req.table); + const columns = new Set(tableInfo.map((row) => foldIdentifier(row.name))); + const missing = req.columns + .filter((required) => !columns.has(foldIdentifier(required.column))) + .map((required) => required.column); + if (missing.length > 0) { + problems.push(`table "${req.table}" is missing column(s): ${missing.join(', ')}`); + } + + // Uniqueness — collect every column SQLite actually enforces as unique, so BOTH + // spellings a real project produces are honored: a column-level `email TEXT UNIQUE` + // (SQLite materializes an implicit `sqlite_autoindex_*`) and a separate + // `CREATE UNIQUE INDEX ... ON user (email)` (what `applySqliteAuthMigrations` emits). + // Any single-column, non-partial unique index over the column counts — the index NAME + // is never required. A single-column PRIMARY KEY counts too, including the + // `INTEGER PRIMARY KEY` rowid alias, which materializes no `origin = 'pk'` index row. + const uniqueColumns = new Set(); + const primaryKey = tableInfo.filter((row) => Number(row.pk) > 0); + if (primaryKey.length === 1 && primaryKey[0]) { + uniqueColumns.add(foldIdentifier(primaryKey[0].name)); + } + const indexes = db.prepare(`PRAGMA index_list(${quote(req.table)})`).all() as Array<{ + name: string; + unique: number; + origin?: string | null; + partial?: number; + }>; + for (const index of indexes) { + // A PARTIAL unique index only constrains the rows matching its WHERE clause, so it + // does not make the column globally unique. + if (Number(index.unique) !== 1 || Number(index.partial ?? 0) !== 0) { + continue; + } + const indexColumns = db.prepare(`PRAGMA index_info(${quote(index.name)})`).all() as Array<{ + name: string | null; + }>; + // A composite unique index constrains the TUPLE, not this column on its own. + if (indexColumns.length !== 1) { + continue; + } + const only = indexColumns[0]?.name; + if (typeof only === 'string') { + uniqueColumns.add(foldIdentifier(only)); + } + } + // The implicit `id` — the parent key every auth foreign key resolves against. SQLite + // only accepts a parent key that is uniquely constrained, so a migration that creates + // `id` as an ordinary column produces a schema whose FKs are DEAD: `CREATE TABLE` + // succeeds, `PRAGMA foreign_key_list` still reports the constraint, and then every + // statement touching the child table fails at prepare time with `foreign key mismatch` + // under `foreign_keys=ON` (which D1 always enforces) — i.e. auth breaks in production + // AFTER a green deploy. PRIMARY KEY and UNIQUE are both valid parent keys, so reuse + // `uniqueColumns` (it already accepts the single-column PK, the `INTEGER PRIMARY KEY` + // rowid alias, and any single-column non-partial unique index) rather than demanding + // PRIMARY KEY specifically. + const notIdentity = req.columns + .filter( + (required) => + required.primaryKey === true && + columns.has(foldIdentifier(required.column)) && + !uniqueColumns.has(foldIdentifier(required.column)), + ) + .map((required) => required.column); + for (const column of notIdentity) { + problems.push( + `table "${req.table}" column "${column}" must be the PRIMARY KEY (or otherwise ` + + `uniquely constrained) — it is the row identity the auth foreign keys point at, and ` + + `SQLite rejects a foreign key whose parent key is not unique at runtime with ` + + `"foreign key mismatch"`, + ); + } + + // NOT NULL — `PRAGMA table_info.notnull` answers it directly, with ONE exception. The + // `INTEGER PRIMARY KEY` rowid alias reports `notnull = 0`, but SQLite assigns a rowid on + // a NULL insert, so the column can never actually hold NULL; rejecting it would false- + // reject a common, legitimate schema. + // + // The declared type CANNOT decide this on its own. `INTEGER PRIMARY KEY DESC` is not a + // rowid alias — a historical SQLite quirk — yet `PRAGMA table_info` reports it + // identically to the real alias (`type = INTEGER`, `pk = 1`, `notnull = 0`) while a NULL + // insert genuinely stores NULL. What separates them is that a true alias IS the rowid, + // so SQLite materializes no primary-key index for it: `PRAGMA index_list` carries an + // `origin = 'pk'` row for `DESC`, for `TEXT PRIMARY KEY`, for `INT`/`BIGINT PRIMARY KEY` + // and for a composite PK, but none for `INTEGER PRIMARY KEY` / `INTEGER PRIMARY KEY ASC`. + // That is the discriminator used here — the absence of the `origin = 'pk'` row, read off + // the `index_list` the uniqueness check above already fetched, with no SQL text parsing. + // Note the test is the ORIGIN, not the row count: a rowid-alias table may still carry + // unrelated `origin = 'u'` / `origin = 'c'` index rows from a column-level UNIQUE or a + // `CREATE INDEX`. The single-column-PK and exact-`INTEGER` conditions are kept alongside + // it, so nothing widens beyond the one shape SQLite actually aliases to the rowid. + const hasPrimaryKeyIndex = indexes.some( + (index) => String(index.origin ?? '').toLowerCase() === 'pk', + ); + const isRowidAlias = (row: TableInfoRow): boolean => + primaryKey.length === 1 && + Number(row.pk) > 0 && + !hasPrimaryKeyIndex && + String(row.type ?? '') + .trim() + .toUpperCase() === 'INTEGER'; + const notNullColumns = new Set( + tableInfo + .filter((row) => Number(row.notnull) === 1 || isRowidAlias(row)) + .map((row) => foldIdentifier(row.name)), + ); + + // The identity's own NOT NULL. SQLite permits NULL in any PRIMARY KEY column that is not + // the rowid alias — a longstanding file-format compatibility quirk — so `id text primary + // key` accepts rows carrying NO identity, and more than one of them, because NULLs never + // collide under a unique constraint. Reported apart from the ordinary required columns + // below: the constraint reads as already satisfied in the migration, so the message has + // to name the quirk to be actionable. + const nullableIdentity = req.columns + .filter( + (required) => + required.primaryKey === true && + required.required === true && + columns.has(foldIdentifier(required.column)) && + !notNullColumns.has(foldIdentifier(required.column)), + ) + .map((required) => required.column); + for (const column of nullableIdentity) { + problems.push( + `table "${req.table}" column "${column}" must be declared NOT NULL — SQLite permits ` + + `NULL in a PRIMARY KEY column unless it is the INTEGER rowid alias, so rows could ` + + `be inserted with no identity at all, and more than one of them (NULLs do not ` + + `collide under a unique constraint)`, + ); + } + + // Every other column the emitted schema marks `required`. + const nullableRequired = req.columns + .filter( + (required) => + required.required === true && + required.primaryKey !== true && + columns.has(foldIdentifier(required.column)) && + !notNullColumns.has(foldIdentifier(required.column)), + ) + .map((required) => required.column); + if (nullableRequired.length > 0) { + problems.push( + `table "${req.table}" is missing a NOT NULL constraint on column(s): ` + + `${nullableRequired.join(', ')} (the emitted auth schema marks them required)`, + ); + } + + const notUnique = req.columns + .filter( + (required) => + required.unique && + columns.has(foldIdentifier(required.column)) && + !uniqueColumns.has(foldIdentifier(required.column)), + ) + .map((required) => required.column); + if (notUnique.length > 0) { + problems.push( + `table "${req.table}" is missing a UNIQUE constraint on column(s): ${notUnique.join(', ')} ` + + `(no column-level UNIQUE and no single-column unique index covers them)`, + ); + } + + // References — the emitted schema points these columns at a parent row, and without the + // foreign key the migrations permit orphaned auth rows (e.g. a session with no user). + // `PRAGMA foreign_key_list` returns ONE ROW PER COLUMN, not one per constraint: the rows + // of a single constraint share an `id` and are ordered by `seq`. Grouping by `id` is what + // makes the width of each constraint visible, and the width is load-bearing — see below. + const foreignKeys = db + .prepare(`PRAGMA foreign_key_list(${quote(req.table)})`) + .all() as Array<{ + id: number; + table: string; + from: string; + to: string | null; + }>; + const foreignKeyConstraints = new Map>(); + for (const fk of foreignKeys) { + const group = foreignKeyConstraints.get(Number(fk.id)); + if (group) { + group.push(fk); + } else { + foreignKeyConstraints.set(Number(fk.id), [fk]); + } + } + const constraints = Array.from(foreignKeyConstraints.values()); + for (const required of req.columns) { + const reference = required.references; + if (!reference || !columns.has(foldIdentifier(required.column))) { + continue; + } + const namesReference = (fk: (typeof foreignKeys)[number]): boolean => + typeof fk.from === 'string' && + sameIdentifier(fk.from, required.column) && + typeof fk.table === 'string' && + sameIdentifier(fk.table, reference.table); + // ONLY a SINGLE-COLUMN foreign key satisfies an auth reference. SQLite applies + // MATCH SIMPLE, under which a COMPOSITE foreign key is skipped ENTIRELY whenever ANY + // of its child columns is NULL — so `FOREIGN KEY (userId, tenant) REFERENCES + // user(id, tenant)` with a nullable `tenant` lets a session naming a nonexistent user + // be inserted, exactly the orphan the `references` requirement exists to prevent. + // Matching per ROW would accept that constraint through its `userId` member alone. + // `PRAGMA foreign_key_check` cannot cover for this either: it reports orphaned DATA, + // and the freshly-migrated database it runs against holds no rows. + const matching = constraints.flatMap((group) => + group.length === 1 && group[0] && namesReference(group[0]) ? [group[0]] : [], + ); + const composite = constraints.some( + (group) => group.length > 1 && group.some(namesReference), + ); + // WHICH COLUMN the foreign key points at — a question of NAMES, and the only + // question this block answers. Whether SQLite will ACCEPT that parent key at all is + // orthogonal and is settled below by `PRAGMA foreign_key_check`, so nothing here + // needs to model SQLite's parent-key rules. + // + // `to` is NULL when the migration omitted the target column (`REFERENCES "user"`). + // SQLite resolves an omitted target ONLY to the parent's single-column PRIMARY KEY, + // so the shorthand names the required column only when that column IS the parent's + // PRIMARY KEY. The answer lives only in the PARENT's own `table_info`, which is why + // this reaches outside the table being validated. This check is load-bearing beyond + // the pragma: a foreign key aimed at a perfectly valid parent key that is the WRONG + // column (`REFERENCES "user"` where the parent's PRIMARY KEY is `email`, not `id`) + // is structurally sound, so `foreign_key_check` passes it — only this comparison + // catches it. An EXPLICIT `to` matches by name directly. + const satisfied = matching.some((fk) => { + if (fk.to != null) { + return sameIdentifier(String(fk.to), reference.column); + } + const parentPrimaryKey = singleColumnPrimaryKey(fk.table); + return parentPrimaryKey !== null && sameIdentifier(parentPrimaryKey, reference.column); + }); + if (!satisfied) { + problems.push( + matching.length === 0 && composite + ? `table "${req.table}" column "${required.column}" references ` + + `"${reference.table}"."${reference.column}" only as part of a MULTI-COLUMN ` + + `foreign key — SQLite skips a composite foreign key entirely when any of its ` + + `child columns is NULL, so a row naming a "${reference.table}" that does not ` + + `exist is still accepted. This reference needs its own single-column foreign ` + + `key on "${required.column}"` + : matching.some((fk) => fk.to == null) + ? `table "${req.table}" column "${required.column}" references "${reference.table}" ` + + `without naming a target column, but "${reference.table}"."${reference.column}" ` + + `is not that table's single-column PRIMARY KEY — SQLite resolves an omitted ` + + `target only to the parent's PRIMARY KEY (a UNIQUE constraint does not qualify, ` + + `nor does a composite PRIMARY KEY) and rejects this at runtime with ` + + `"foreign key mismatch"` + : `table "${req.table}" column "${required.column}" is missing a foreign key ` + + `referencing "${reference.table}"."${reference.column}"`, + ); + } + } + + // AUTHORITATIVE structural gate for this table's foreign keys — ask SQLite instead of + // re-deriving its parent-key rules here. Those rules are subtler than they look: a + // parent key must be a PRIMARY KEY, or a UNIQUE index whose columns AND COLLATING + // SEQUENCES match the reference, and an omitted target column resolves only to the + // parent's PRIMARY KEY. Hand-modelling them has produced a green deploy on a schema + // SQLite rejects more than once (an ordinary parent column; a UNIQUE-only parent under + // the implicit shorthand; a unique index declared `COLLATE NOCASE` over a plainly + // declared column). `PRAGMA foreign_key_check` applies SQLite's own rule, so a case + // this file has not anticipated can no longer pass. + // + // It reports a STRUCTURAL mismatch by THROWING (`foreign key mismatch - "child" + // referencing "parent"`), not by returning rows; returned rows mean orphaned DATA, + // which a freshly-migrated database holding no rows cannot have, but are surfaced too + // so neither outcome is silently dropped. Scoped to this table (the CHILD side) so an + // unrelated application table with its own broken foreign key is not this gate's + // business. Any other error is a real fault and propagates untouched. + // + // Why this matters: `CREATE TABLE` accepts a dead foreign key, and `PRAGMA + // foreign_key_list` echoes it back as if valid. Only when a statement touches the + // child does SQLite fail at PREPARE time with `foreign key mismatch` — under + // `foreign_keys=ON`, which D1 always enforces. Without this gate that surfaces as auth + // breaking in production AFTER a green deploy. + try { + const violations = db + .prepare(`PRAGMA foreign_key_check(${quote(req.table)})`) + .all() as Array<{ parent?: string | null }>; + for (const violation of violations) { + const parent = typeof violation.parent === 'string' ? violation.parent : 'its parent'; + problems.push( + `table "${req.table}" has rows violating its foreign key to "${parent}" ` + + `(PRAGMA foreign_key_check)`, + ); + } + } catch (err) { + const message = getErrorMessage(err); + const mismatch = /foreign key mismatch\s*-\s*"(.+?)"\s+referencing\s+"(.+?)"/i.exec( + message, + ); + if (!mismatch) { + throw err; + } + problems.push( + `table "${mismatch[1]}" has a foreign key referencing "${mismatch[2]}" whose parent ` + + `key is not a valid foreign-key target — SQLite requires the referenced column(s) ` + + `to be a PRIMARY KEY, or a UNIQUE index that MATCHES the reference (an index ` + + `declared with a different COLLATE does not match), and an omitted target column ` + + `resolves only to the parent's PRIMARY KEY. The table is created, but SQLite ` + + `rejects every statement touching it at runtime with "foreign key mismatch"`, + ); + } + } + if (problems.length > 0) { + throw new Error(problems.join('; ')); + } + } finally { + db.close(); + } +} + +/** + * Read the REQUIRED Better Auth schema (tables + columns) from the schema the BUILD + * actually generated — ground truth, not a facsimile. `writeAuthDatabaseFiles` + * (plugin-routing.ts) emits `/.void/better-auth-schema.ts` during the build, + * containing an `export const migrationSchema = { ... } as const;` literal — + * `JSON.stringify(resolveVoidAuthMigrationSchema(...), null, 2)` derived under the + * build's real mode, so it already reflects configured table/column RENAMES + PLUGIN + * tables. The literal's top-level object closes with `}` at column 0, so the + * `\n} as const;` terminator uniquely bounds the JSON (inner braces are indented). + * The `id` column is the implicit PRIMARY KEY, so it is prepended to each table's + * columns — matching `resolveVoidAuthMigrationSchema`'s field set exactly. Each field + * also carries its `required` (NOT NULL) and `unique` flags and its `references` target, + * so the check below can assert the CONSTRAINTS the schema requires and not merely the + * names. Throws a fail-closed error when the artifact is missing/malformed. + */ +function readEmittedAuthMigrationSchema(root: string): Array { + const schemaPath = join(root, '.void', 'better-auth-schema.ts'); + if (!existsSync(schemaPath)) { + throw new Error( + `the build did not emit ${schemaPath}, so the generated auth schema could not be read from the build output.`, + ); + } + const content = readFileSync(schemaPath, 'utf-8'); + const marker = 'export const migrationSchema = '; + const start = content.indexOf(marker); + if (start === -1) { + throw new Error( + `${schemaPath} has no migrationSchema literal — cannot verify the auth schema.`, + ); + } + const jsonStart = start + marker.length; + const end = content.indexOf('\n} as const;', jsonStart); + if (end === -1) { + throw new Error( + `${schemaPath} migrationSchema literal is malformed — cannot verify the auth schema.`, + ); + } + let migrationSchema: VoidAuthMigrationSchema; + try { + migrationSchema = JSON.parse(content.slice(jsonStart, end + 2)) as VoidAuthMigrationSchema; + } catch { + throw new Error( + `${schemaPath} migrationSchema is not valid JSON — cannot verify the auth schema.`, + ); + } + return migrationSchema.tables.map((table) => ({ + table: table.name, + columns: [ + // `id` is the implicit PRIMARY KEY — never carries `unique`/`references` of its own, + // and is absent from `fields`, so this synthesized descriptor is the ONLY thing that + // represents it. `primaryKey` makes the check assert it is a usable FK parent key + // (see {@link RequiredAuthColumn.primaryKey}); `required` makes it assert the column + // cannot be NULL, which PRIMARY KEY alone does not guarantee in SQLite. + { column: 'id', unique: false, required: true, primaryKey: true }, + ...Object.entries(table.fields).map(([column, field]) => ({ + column, + unique: field.unique === true, + required: field.required === true, + references: field.references + ? { table: field.references.table, column: field.references.field } + : undefined, + })), + ], + })); +} + +/** + * Default {@link CloudflareDeployDeps.validateAuthMigrations} seam: read the auth + * schema the BUILD generated ({@link readEmittedAuthMigrationSchema}) and assert the + * checked-in migrations produce it (applied to in-memory SQLite). Runs POST-build, + * against ground truth — no auth-config subprocess, no mode/envDir divergence. + * Throws on any missing table/column (or an unapplyable migration); the caller + * renders `.message` + fails closed. + */ +export async function validateAuthMigrationSchema( + root: string, + migrations: Array<{ name: string; sql: string }>, +): Promise { + const required = readEmittedAuthMigrationSchema(root); + await assertMigrationsProduceAuthSchema(migrations, required); +} + +/** Format the drift / CI-gate guidance naming each un-provisioned resource. */ +function formatCloudflareDriftMessage( + missing: Array, + reasons: Map, + header: string, + footer: string, +): string { + const lines = missing.map((entry) => { + const reason = reasons.get(entry.binding); + const label = CLOUDFLARE_RESOURCE_LABELS[entry.kind]; + return ` • ${entry.binding} (${label})${reason ? ` — ${reason}` : ''}`; + }); + return [header, ...lines, '', footer].join('\n'); +} + +/** + * Run the SAME migration validations the MANAGED deploy runs before applying + * migrations to remote D1 — schema drift (drizzle-kit), journal coherence, and + * destructive-op-without-pragma — reusing the managed building blocks + * ({@link hasDeploySchemaDrift} / {@link getDeploySchemaDriftMessage} / + * `assertJournalCoherence` / `validateMigrations`). Unlike the managed + * `assertDeploySchemaDriftFree` (which calls `process.exit` itself), this THROWS on + * any failure so the Cloudflare orchestrator routes the exit through its injectable + * seam. This is the default of the {@link CloudflareDeployDeps.assertMigrationsDeployable} + * seam; tests inject a stub to avoid spawning drizzle-kit. + */ +export function assertRemoteMigrationsDeployable( + root: string, + dialect: DatabaseDialect, + migrationsDir: string, + migrations: Array<{ name: string; sql: string }>, +): void { + if (hasDeploySchemaDrift(root, dialect)) { + throw new Error(getDeploySchemaDriftMessage()); + } + assertJournalCoherence(migrationsDir, migrations); + if (migrations.length > 0) { + validateMigrations(migrations); + } +} + +/** + * wrangler 4.107's own migration-file order comparator, reproduced EXACTLY + * (`compareMigrationPaths` / `compareSegments` / `leadingMigrationNumber`, + * `wrangler-dist/cli.js` `src/d1/migrations/helpers.ts`): segment-by-segment, comparing each + * segment's LEADING NUMERIC PREFIX (`parseInt(segment.split('_')[0], 10)`) numerically first, + * so `2_x.sql` sorts BEFORE `10_x.sql` (lexicographic would invert them). A finite number + * sorts before a non-numeric segment; equal/both-NaN falls back to lexicographic. This is the + * exact order `d1 migrations apply` walks (`listFilesRelative(...).sort(compareMigrationPaths)` + * → `getMigrationNames`), so ordering the gated set the same way tells us what remote D1 will + * receive, in what order. + */ +function leadingWranglerMigrationNumber(relativePath: string): number { + const firstSegment = relativePath.split('/')[0] ?? ''; + return parseInt(firstSegment.split('_')[0] ?? '', 10); +} + +function compareWranglerMigrationSegments(a: string, b: string): number { + const aNum = leadingWranglerMigrationNumber(a); + const bNum = leadingWranglerMigrationNumber(b); + if (aNum !== bNum) { + if (Number.isFinite(aNum) && Number.isFinite(bNum)) { + return aNum - bNum; + } + if (Number.isFinite(aNum)) { + return -1; + } + if (Number.isFinite(bNum)) { + return 1; + } + } + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; +} + +export function compareWranglerMigrationPaths(a: string, b: string): number { + const aSegments = a.split('/'); + const bSegments = b.split('/'); + const shared = Math.min(aSegments.length, bSegments.length); + for (let i = 0; i < shared; i++) { + const cmp = compareWranglerMigrationSegments(aSegments[i] as string, bSegments[i] as string); + if (cmp !== 0) { + return cmp; + } + } + return aSegments.length - bSegments.length; +} + +/** + * Assert the migration files wrangler 4.107 will APPLY — in the ORDER it applies them — equal + * the gated source set. wrangler resolves its OWN files from the D1 entry's `migrations_dir` + * (relative to the config-file dir) with the DEFAULT pattern `/*.sql` (a custom + * `migrations_pattern` is rejected pre-account, so only the default flat `*.sql` enumeration — + * top-level, `{ dot:false }` — reaches here), tracks each by its `.sql` name, and applies them + * sorted by its numeric-prefix comparator ({@link compareWranglerMigrationPaths}) — the exact + * sequence `d1 migrations apply` runs against REMOTE D1. This backend VALIDATES the set + * `collectMigrations` reads (flat dir, `seed.sql` excluded, sorted lexicographically). Two + * things must hold: (1) SET equality by name AND content — a stray/extra file (e.g. `seed.sql`, + * which `collectMigrations` skips but wrangler's `*.sql` matches), a differing dir, or a + * divergent content would otherwise be applied UNVALIDATED; and (2) ORDER parity — the numeric + * order wrangler applies must equal Void's gated order position-for-position, so the local + * runner + deploy gates validate the exact order remote D1 receives (an unpadded prefix like + * `2_` vs `10_` sorts differently lexicographically-vs-numerically and diverges — reject). A + * defensive custom-pattern check fails closed if one slips past the pre-account guard. Exported + * for unit testing. + */ +export async function checkWranglerMigrationApplySet( + configPath: string, + config: Record, + binding: string, + migrationsDirRaw: string, + gated: Array<{ name: string; sql: string }>, +): Promise<{ ok: true } | { ok: false; reason: string }> { + const absDir = resolve(dirname(configPath), migrationsDirRaw); + if (!existsSync(absDir)) { + return { + ok: false, + reason: + `deploy: configured migrations_dir ${JSON.stringify(migrationsDirRaw)} resolves to ` + + `${absDir}, which does not exist — wrangler would have nothing to apply (or error). ` + + `Align "migrations_dir" with your db/migrations, then rerun.`, + }; + } + // Defense in depth: a custom migrations_pattern is rejected pre-account (v1 = default + // convention only). If one reaches here, fail closed rather than assume the flat `*.sql`. + // Reject on PRESENCE (any defined value, incl. `""`): a present-but-empty pattern is + // still non-default to wrangler, and it would otherwise be indistinguishable from absent. + const migrationsPattern = readBindingFieldPresence( + config, + 'd1_databases', + binding, + 'migrations_pattern', + ); + if (migrationsPattern.present) { + return { + ok: false, + reason: + `deploy: a custom migrations_pattern (${JSON.stringify(migrationsPattern.value)}) is not ` + + `supported — v1 deploys only the default db/migrations/*.sql convention. Remove it, then rerun.`, + }; + } + // wrangler's DEFAULT enumeration: top-level `.sql` files, dotfiles excluded (Minimatch + // `*.sql` with `{ dot:false }` never crosses `/`, so `meta/` is not walked), APPLIED in + // wrangler's numeric-prefix order. + const wranglerNames = readdirSync(absDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.sql') && !entry.name.startsWith('.')) + .map((entry) => entry.name.replace(/\\/g, '/')) + .sort(compareWranglerMigrationPaths); + const wranglerContent = new Map( + wranglerNames.map((name) => [name, readFileSync(join(absDir, name), 'utf-8')]), + ); + const gatedNames = gated.map((m) => m.name.replace(/\\/g, '/')); + const gatedContent = new Map(gatedNames.map((name, i) => [name, gated[i]!.sql])); + + // 1) SET equality by name + content. + const wranglerSet = new Set(wranglerNames); + const extra = wranglerNames.filter((k) => !gatedContent.has(k)); + const missing = gatedNames.filter((k) => !wranglerSet.has(k)).sort(); + const changed = wranglerNames.filter( + (k) => gatedContent.has(k) && gatedContent.get(k) !== wranglerContent.get(k), + ); + if (extra.length > 0 || missing.length > 0 || changed.length > 0) { + const lines = [ + `deploy: the migrations wrangler would apply to remote D1 do not match Void's validated set (${absDir}).`, + ]; + if (extra.length > 0) { + lines.push(` • wrangler would ALSO apply (not validated by Void): ${extra.join(', ')}`); + } + if (missing.length > 0) { + lines.push(` • validated by Void but NOT in wrangler's set: ${missing.join(', ')}`); + } + if (changed.length > 0) { + lines.push(` • content differs between Void's copy and wrangler's: ${changed.join(', ')}`); + } + lines.push( + 'Only Void-managed db/migrations/*.sql may be applied. Remove stray .sql (e.g. seed.sql) ' + + 'from migrations_dir, then rerun.', + ); + return { ok: false, reason: lines.join('\n') }; + } + + // 2) ORDER parity — wrangler's numeric apply order must equal Void's gated (collectMigrations) + // order position-for-position, so the order remote D1 receives is the order Void validated. + const orderDiffers = + gatedNames.length !== wranglerNames.length || + gatedNames.some((name, i) => name !== wranglerNames[i]); + if (orderDiffers) { + return { + ok: false, + reason: [ + `deploy: wrangler would apply your migrations in a DIFFERENT order than Void validated (${absDir}).`, + ` • wrangler (numeric-prefix) order: ${wranglerNames.join(', ')}`, + ` • Void's validated order: ${gatedNames.join(', ')}`, + "This happens when a migration's numeric prefix is not zero-padded (e.g. `2_` vs `10_`),", + 'so it sorts differently for wrangler (numeric) than for Void (lexicographic) — remote D1', + 'would receive them out of the validated order. Zero-pad the prefixes (0001_, 0002_, …),', + 'e.g. with `void db rename-migrations`, then rerun.', + ].join('\n'), + }; + } + + return { ok: true }; +} + +/** + * Default {@link DeployWranglerRun}: spawns the project-local wrangler REAL CLI + * (via {@link resolveWranglerArgv}, shell-free) with ONE pinned transport in the + * child env (via {@link deployChildEnv} — reused provisioner scrub/pin: CF Access + + * every transport selector incl. `CLOUDFLARE_ENV` + the deploy config-override vars + * scrubbed, one account + API base pinned), so `whoami`/`migrate`/`deploy` can never + * consult a different ambient account or endpoint. `capture` pipes or inherits stdio. + */ +function createDeployWranglerRunner( + root: string, + accountId: string, + apiBaseUrl: string, +): DeployWranglerRun { + const launcher = resolveWranglerArgv(root); + const [command, ...prefix] = launcher; + return (args, opts) => { + const childEnv = deployChildEnv(accountId, apiBaseUrl); + const result = spawnSync(command as string, [...prefix, ...args], { + cwd: root, + stdio: opts.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + encoding: 'utf-8', + env: childEnv, + shell: false, + windowsHide: true, + }); + return { + status: typeof result.status === 'number' ? result.status : 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + signal: result.signal ?? null, + error: result.error ? result.error.message : null, + }; + }; +} + +/** + * Deploy to the user's OWN Cloudflare account (no Void platform). + * + * Orchestrates the existing units — it does NOT re-implement provisioning + * (`cli/provision.ts`), the config writer (`cli/wrangler.ts`), or the self-host + * secret preflight (`cli/env-validation.ts`). Flow: + * + * app-class guard → desired set (from source, pre-build) → preflight (auth + + * account pin + CI gate) → provision OR drift-check → ensure worker name → + * remote D1 migrate → secrets (warn + gate) → clean-env build → wrangler deploy + * + * ORDERING DECISION (documented): the secrets GATE runs AFTER provision + remote + * migrations resolve but BEFORE build + deploy — the brief's recommended default. + * Provision and D1 migrations are idempotent (read-first / tracked in wrangler's + * `d1_migrations` table), so re-running them after a gate failure is safe, while + * build + `wrangler deploy` (the non-idempotent worker upload) is what the gate + * protects. The warning is emitted first (non-fatal); a first-deploy secret-list + * failure (worker not yet created) renders actionable `wrangler secret put` + * bootstrap guidance rather than dead-ending. + * + * Every wrangler / provisioner / secret-unit call is an injected seam + * ({@link CloudflareDeployDeps}) so the whole orchestration is unit-testable with + * no real wrangler, network, or Cloudflare account. + */ +export async function runCloudflareDeploy( + root: string, + options: RunDeployOptions, + cliLog: CliLogger, + deps: CloudflareDeployDeps = {}, +): Promise { + const exit = deps.exit ?? ((code: number): never => process.exit(code)); + const voidConfig = readConfig(root); + cliLog.info('cloudflare_deploy_start', { + provision: options.provision === true ? true : undefined, + }); + + // 0. Flag guard — `--skip-build` cannot be honored by this backend, so REJECT the + // combination rather than silently ignore it. Every gate on this path reads the artifact + // `vite build` emits as ground truth: the plaintext-var warnings, the required-secret gate + // and the invalid-value gate all read the REAL `dist/ssr/wrangler.json`, and the auth check + // reads the generated `.void/better-auth-schema.ts`. Skipping the build would leave them + // validating a stale or absent artifact — i.e. reporting a worker that is not the one being + // uploaded. Rejected here, before any account op, so the flag can never cost a build hook. + if (options.skipBuild) { + log.error( + [ + 'deploy: --skip-build is not supported with --backend cloudflare.', + 'This backend validates the artifact the build emits — the worker vars in', + 'dist/ssr/wrangler.json and the generated auth schema — so a skipped build would', + 'leave every check reading a stale or missing artifact instead of the worker you', + 'are about to upload. Drop --skip-build, or deploy with `void deploy` (the managed', + 'platform), which supports it.', + ].join('\n'), + ); + return exit(1); + } + + // Load the provision module ONCE, dynamically, so the sync deploy helpers can + // reach its transport/drift/scrub primitives without a static import that would + // make every managed `void deploy` eagerly load the provision lifecycle. Awaited + // here BEFORE any sync helper that calls `requireProvisionModule()`. + const provisionModule = await loadProvisionModule(); + + // 1. App-class narrowing — v1 of `--backend cloudflare` supports ONE well-understood + // shape: a FULL VOID APP (worker-bearing, running `routingPlugin`) on the Cloudflare + // Workers target, with D1/KV/R2/Queues/Hyperdrive and — on D1/SQLite — auth + ISR. + // Everything else FAILS CLOSED here, BEFORE any account / preflight / provision / build + // op, reusing the SAME classifiers the managed deploy path uses (`detectPreset` + + // `detectFramework`) so this fork can never disagree with the build about what the app is. + + // 1a. Target guard — this backend deploys Cloudflare Workers ONLY. A node/bun/deno target + // builds a non-CF worker; reject before the desired set or any account operation. + if (isNodeTarget(voidConfig.target)) { + log.error( + [ + `deploy: --backend cloudflare is not supported for the "${voidConfig.target}" target.`, + 'This backend deploys Cloudflare Workers only. Deploy a node/bun/deno build with its', + 'own host/runtime, or use the default Cloudflare target for --backend cloudflare.', + ].join('\n'), + ); + return exit(1); + } + + // 1b. Framework guard — v1 defers the ENTIRE framework SSR path (every class). Class A + // (tanstack-start / react-router / vinext) composes the Void CF stack but the framework + // owns its own entry + routing; Class B/C (sveltekit / nuxt / analog / astro) build + // through their own adapter with NO CF Vite plugin, so `vite build` emits no worker + // artifact here. Rejecting all of them leaves exactly one supported class — the full Void + // app that runs `routingPlugin` and emits ISR coherently — so this fork never has to + // reason about framework wiring. Checked before the static guard so a framework app with + // no Void backend dirs gets the framework message, not a misleading static one. + const framework = detectFramework(root); + if (framework) { + log.error( + [ + `deploy: --backend cloudflare does not yet support framework SSR (${framework.name}).`, + 'v1 deploys full Void apps only. Deploy this framework with `void deploy` (the managed', + 'platform) or the framework’s own Cloudflare adapter. Framework SSR support for this', + 'backend is a follow-up.', + ].join('\n'), + ); + return exit(1); + } + + // 1c. Static / SPA / SSG guard — reuse the MANAGED path's own classifier `detectPreset` + // (NOT `resolveStaticDeployPreset`, whose fallback fires only for an explicit `--dir` or a + // configured `inference.appType`, so an UNCONFIGURED static/SPA/SSG app slips past it). + // `detectPreset` structurally detects an SSG dep, a Vite-only SPA, and prebuilt html; the + // explicit `--dir` / `--spa` intents and a configured static appType are checked alongside + // it (a configured-but-not-yet-built static app has no structural marker for `detectPreset`). + // `voidConfig.output === 'static'` is the SSG signal the BUILD itself branches on + // (index.ts `void:env` skips worker minify + prerenders the SSR bundle for it, deploy.ts + // `deployConfig.output === 'static'` takes the static-asset path). A pages/ project with + // `output: "static"` satisfies `hasBackendFiles` and yields a `null` `detectPreset`, so + // without this signal it would slip past the guard and reach account/build as if it were a + // full worker app — include it in the reject. + const staticPreset = detectPreset(root, { dir: options.dir, spa: options.spa }); + const configuredStatic = + voidConfig.inference?.appType === 'static' || + voidConfig.inference?.appType === 'spa' || + voidConfig.output === 'static'; + if (staticPreset || configuredStatic || options.dir || options.spa) { + const appType = staticPreset?.appType ?? (options.spa ? 'spa' : 'static'); + log.error( + [ + `deploy: --backend cloudflare does not yet support ${appType} (static-asset) apps.`, + 'The Cloudflare backend v1 deploys full Void apps (worker-bearing) on the Cloudflare', + 'Workers target. Deploy this app with `void deploy` (the managed platform), or host', + 'the built assets on Cloudflare Pages / any static host.', + ].join('\n'), + ); + return exit(1); + } + + // 1d. Positive full-Void assertion — the app must actually be worker-bearing. Mirrors the + // managed dispatch's `runFullDeploy` branch (a `null` preset with no framework) and rejects + // a bare project with no backend files (no routes/pages/middleware/crons/queues/SSR entry) + // that `detectPreset` also returns `null` for. + if (!hasBackendFiles(root)) { + log.error( + [ + 'deploy: --backend cloudflare found no Void backend to deploy.', + 'A full Void app has at least one of: routes/, pages/, middleware, crons/, queues/,', + 'or an SSR entry. Add your server code, or use `void deploy` for a static site.', + ].join('\n'), + ); + return exit(1); + } + + // 1e. voidPlugin structural rejects — mirror the deterministic throws voidPlugin itself + // hits during `vite build`, reusing voidPlugin's OWN validators (no re-derivation), so an + // app that cannot build never reaches account mutation first: a sourceDir convention + // duplicated at the root (`assertNoRootSourceConventionConflicts`), a broken SSR entry + // (`validateSsrEntry`), and a malformed cron file (`scanJobsSync`). (sandbox / void/live / + // AI / remote / websocket are wired fine by the full-Void self-host build — no guard.) + const paths = resolveProjectPaths(root, voidConfig); + try { + assertNoRootSourceConventionConflicts(paths); + validateSsrEntry(root, paths); + scanJobsSync(root, paths.cronsDir); + } catch (err) { + log.error(`deploy: ${getErrorMessage(err)}`); + return exit(1); + } + + // 1f. WebSocket / Durable Object guard — FAIL CLOSED before any account op. For a `.ws.ts` + // route, the self-host `vite build` emits a `durable_objects` binding plus a DO migration + // block keyed `new_classes` (index.ts `mergeBindings`, via the SAME `scanWebSocketRoutesSync` + // scan; also cli/wrangler.ts for --provision) — i.e. a KEY-VALUE-backed Durable Object + // namespace. As of Cloudflare's 2026-07-09 change, a fresh account (every Workers Free + // account, and any account with no pre-existing KV-backed namespace) REJECTS a `new_classes` + // migration at `wrangler deploy` ("create a namespace using a new_sqlite_classes migration + // instead"). So a websocket app deployed to a fresh own-account hard-fails after the build. + // Emitting `new_sqlite_classes` is a build-side follow-up; until then reject those apps. + // (sandbox / void/live DOs already emit `new_sqlite_classes` — they stay supported.) + if (scanWebSocketRoutesSync(root, paths).length > 0) { + log.error( + [ + 'deploy: --backend cloudflare does not yet support WebSocket / Durable Object routes.', + 'A `.ws.ts` route makes the build emit a key-value-backed Durable Object namespace', + '(a `new_classes` migration), which a fresh Cloudflare account (and every Workers Free', + 'account) refuses to create. Deploy this app with `void deploy` (the managed platform).', + 'SQLite-backed WebSocket support for this backend is a follow-up.', + ].join('\n'), + ); + return exit(1); + } + + // 2. Desired resource set — computed ONCE from SOURCE, pre-build (fail-fast), + // shared by the provision and drift paths. `authEnabled` / `migrationsDir` / + // `dialect` drive the auth + migration gates below. The checked-in migrations are + // deliberately NOT read here: the auth + migration gates re-collect them AFTER the + // build (see step 8), because `vite build` runs arbitrary project plugins that could + // add/rewrite migration SQL, and the remote apply reads the live post-build files. + const configPath = discoverRootWranglerConfig(root); + const { + desired, + reasons, + authEnabled, + dialect, + migrationsDir, + migrations: preBuildMigrations, + needsAI, + needsSandbox, + expectedCrons, + } = await computeDesiredCloudflareResources(root, voidConfig, configPath); + + // 2a. Postgres + auth guard — reject before any account op. This backend applies only + // checked-in `db/migrations/*.sql` to REMOTE D1 (SQLite); a Postgres/Hyperdrive app runs + // no D1 migrate step here, so it can neither create NOR verify the Better Auth tables. v1 + // supports Void-managed auth on D1/SQLite only. (A pg app WITHOUT auth is still supported.) + if (authEnabled && dialect === 'postgresql') { + log.error( + [ + 'deploy: --backend cloudflare does not yet support Void-managed auth on a PostgreSQL app.', + 'This backend verifies + applies auth tables through remote D1 (SQLite) migrations', + 'only; it cannot provision or verify the Better Auth schema on Postgres/Hyperdrive.', + 'Deploy this app with `void deploy` (the managed platform), or switch the app to D1.', + ].join('\n'), + ); + return exit(1); + } + + // 2b. Postgres + checked-in migrations guard — reject before any account op. This backend + // applies ONLY remote D1 (SQLite) migrations; the remote-migrate step (8d/9) is gated on + // `desired.d1`, which a PostgreSQL app never has (it desires `hyperdrive` instead). The managed + // platform DOES apply a pg app's `db/migrations/*.sql` over Hyperdrive at deploy (its runtime + // migration handler), so shipping them through this self-host backend would silently deploy + // against an UNMIGRATED database → a 500 on the first query. Keyed on migration PRESENCE, not + // dialect alone: a pg app with an externally-managed schema and ZERO checked-in migrations is + // legitimately deployable and MUST still pass. `preBuildMigrations` is the SAME pre-build + // `collectMigrations` set the desired-database gate counts, so this guard and `desired.hyperdrive` + // agree. (A pg app WITH auth is already rejected at 2a.) + if (dialect === 'postgresql' && preBuildMigrations.length > 0) { + log.error(PG_CHECKED_IN_MIGRATIONS_REJECT_MESSAGE); + return exit(1); + } + + // 2c. Custom migrations_pattern guard — FAIL CLOSED before any account op. v1 supports ONLY + // Void's default migration convention: `db/migrations/*.sql` with wrangler's default + // `migrations_pattern` (unset). An explicit `migrations_pattern` makes wrangler enumerate its + // remote-apply set through Minimatch over a custom glob, which can diverge from Void's + // `collectMigrations` (flat dir, `seed.sql` excluded) — so it would apply an unverifiable set + // to remote D1. Rejecting a custom pattern removes that divergence entirely; the default + // convention still gets ORDER-parity checked at 6b. (Only the DEPLOYED D1 entry matters.) + if (desired.d1) { + // Reject on PRESENCE (any defined value, incl. `""`) — a present-but-empty pattern is + // still non-default to wrangler, and readStoredResourceName can't tell absent from empty. + const migrationsPattern = readBindingFieldPresence( + parseRootWranglerConfig(configPath), + 'd1_databases', + desired.d1.binding, + 'migrations_pattern', + ); + if (migrationsPattern.present) { + log.error( + [ + `deploy: --backend cloudflare does not support a custom migrations_pattern (${JSON.stringify(migrationsPattern.value)}).`, + 'v1 deploys only the default Void migration convention: db/migrations/*.sql with', + "wrangler's default pattern (leave `migrations_pattern` unset). A custom glob would", + 'apply a set to remote D1 that Void cannot verify against its own db/migrations.', + `Remove "migrations_pattern" from the ${desired.d1.binding} D1 binding, then rerun.`, + ].join('\n'), + ); + return exit(1); + } + } + + // (The auth-migration guard runs at 3d below, AFTER the transport + account pin, so it can + // derive the Better Auth schema in an isolated subprocess under the build's EFFECTIVE mode + + // the SAME scrubbed env the build uses — still BEFORE whoami / provision / build / deploy.) + + // 3. Preflight — pin ONE transport, pin the account, confirm wrangler auth. + const rootConfig = parseRootWranglerConfig(configPath); + + // 3a. Resolve + pin the ONE transport for the WHOLE deploy (build + whoami + migrate + + // deploy), reusing the provisioner's resolver + dotenv fence. Fail closed on any + // ambient/dotenv redirect (endpoint, named env, worker-name override, alternate + // build config) BEFORE any wrangler call — so every child shares one account + API base. + let apiBaseUrl: string; + try { + apiBaseUrl = resolveDeployTransport(root, rootConfig); + } catch (err) { + log.error(`deploy: ${getErrorMessage(err)}`); + return exit(1); + } + + // 3b. Pin the account (static check — no network) BEFORE spawning wrangler. + const accountId = + readWranglerTopLevelString(rootConfig, 'account_id') ?? + (process.env.CLOUDFLARE_ACCOUNT_ID?.trim() || undefined); + if (!accountId) { + log.error( + [ + 'deploy: no Cloudflare account is pinned.', + 'Set `account_id` in your root wrangler.jsonc, or export CLOUDFLARE_ACCOUNT_ID.', + 'A multi-account token makes wrangler prompt (or error in CI) on its own, which', + 'void cannot intercept — so the account must be pinned before deploying.', + ].join('\n'), + ); + return exit(1); + } + + // 3b-i. Pin the wrangler UPLOADER bundle NOW — at the START of the credentialed sequence, BEFORE + // any project code runs and BEFORE any credentialed wrangler call. The env-schema probe (3c), the + // `whoami` auth check (3e), and — under `--provision` — the provisioner all run before the build, + // and the probe executes ARBITRARY project code (the user's Vite config + env.ts). That code runs + // with the parent's Cloudflare creds scrubbed so it can only edit FILES — but that INCLUDES the + // `wrangler-dist` bundle every credentialed wrangler run executes (whoami, the provisioner's own + // `wrangler create`, and the post-build secret list / remote D1 migrate / deploy), so a + // probe/build that overwrites it would run attacker code WITH the CF token. Snapshot the bundle's + // content hash HERE; this pre-probe snapshot is the SINGLE baseline every later check compares + // against — it is never re-baselined (that would bless a mutation). Everything above only + // AST-parses source (`computeDesiredCloudflareResources`) or reads config files — no project code + // runs — so this is genuinely captured before the FIRST project-code execution. A legitimate + // probe/build never touches `node_modules/wrangler`, so the hash is stable from here through every + // credentialed run. (This closes the uploader-swap-before-a-credentialed-run vector only; a + // fully-compromised build dependency remains a trust boundary.) + const hashUploader = deps.hashWranglerUploader ?? hashWranglerUploader; + const wranglerUploaderHashBefore = hashUploader(root); + // Re-hash the wrangler uploader and assert it still matches the one baseline above. Invoked + // IMMEDIATELY BEFORE EACH credentialed run — the pre-build `whoami` and provision, and the + // post-build secret list, D1 migrate/list, and deploy — always against this same pre-probe + // baseline. Fails closed on any change or an unresolvable/unreadable binary (either hash + // `undefined`). + const assertUploaderStillPinned = (): boolean => { + const check = assertWranglerUploaderUnchanged(wranglerUploaderHashBefore, hashUploader(root)); + if (check.ok) { + return true; + } + log.error( + [ + 'deploy: refusing to run the wrangler uploader with your Cloudflare credentials.', + check.reason, + ] + .filter(Boolean) + .join('\n'), + ); + return false; + }; + + // 3c. Derive the env-INSENSITIVE schema facts — the required-secret NAMES + the keys + // flagged `.secret()` — AND enforce the v1 envDir/mode narrowing, in an ISOLATED + // SUBPROCESS under a scrubbed env. Importing the user's env.ts + resolving the Vite + // config is arbitrary project code, so it must not run in this credential-bearing + // parent; the child does it safely (it `process.exit`s). The child resolves the config + // purely to read the effective `{ envDir, mode }` and REJECTS a custom envDir / + // non-production mode: the build generates the auth artifact under the project ROOT + // while the worker `vars` come from the build's envDir, so a custom envDir could + // validate a different table set than the worker ships. The REAL worker vars the gate + // validates come from the emitted `dist/ssr/wrangler.json` AFTER the build (ground + // truth), not from here. The child env scrubs the FULL wrangler-auth credential set and + // pins `NODE_ENV=production` (see {@link buildSchemaProbeEnv}). Run pre-build so a custom + // envDir / non-production mode / broken env.ts fails fast, before any account op + // (whoami / provision / build). + const schemaProbeEnv = buildSchemaProbeEnv(accountId, apiBaseUrl); + let schemaResult: EnvSchemaProbeResult; + try { + schemaResult = await (deps.resolveSchemaFacts ?? resolveEnvSchemaFactsDefault)( + { root }, + schemaProbeEnv, + ); + } catch (err) { + log.error( + `deploy: could not derive the env schema (required secrets / .secret() flags) — ${getErrorMessage(err)}`, + ); + return exit(1); + } + if (schemaResult.kind === 'reject') { + log.error(cloudflareEnvDirModeRejectMessage(schemaResult)); + return exit(1); + } + if (schemaResult.kind === 'schema-error') { + log.error(`deploy: ${selfHostSchemaFailureMessage(schemaResult.reason)}`); + return exit(1); + } + const schemaFacts: SelfHostSchemaFacts = schemaResult.facts; + + // 3e. Confirm wrangler auth. `whoami` gets the SAME `-c`/`--env=`/pinned-transport + // context as migrate/deploy, so it authenticates against the exact endpoint they use. + const runWrangler = deps.runWrangler ?? createDeployWranglerRunner(root, accountId, apiBaseUrl); + // Re-verify the uploader immediately before this FIRST credentialed run — the env-schema probe + // above ran project code (env.ts) that could have overwritten the hashed bundle `whoami` executes. + if (!assertUploaderStillPinned()) { + return exit(1); + } + const whoami = await Promise.resolve( + runWrangler(['whoami', '--json', '-c', configPath, '--env='], { capture: true }), + ); + if (!isCleanWranglerExit(whoami)) { + log.error( + [ + 'deploy: not authenticated with Cloudflare.', + 'Run `wrangler login`, or set CLOUDFLARE_API_TOKEN (a token with', + 'Workers Scripts:Edit + read on your bound resources; --provision also needs', + 'per-product *:Edit for D1/KV/R2/Queues/Hyperdrive). Then rerun.', + ].join('\n'), + ); + return exit(1); + } + + // CI gate for --provision: fail closed in non-interactive shells unless the + // committed config already covers every desired resource (a provable no-op) — + // CI cannot commit back the ids a create would mint, so a re-run would dup. + const isCI = deps.isCI ?? (!process.stdin.isTTY || getTokenSource() === 'ci'); + if (options.provision && isCI) { + const { missing } = provisionModule.checkCloudflareProvisionDrift(configPath, desired); + // Queues are name-bearing (no id in the committed config), so the id-drift check + // CANNOT prove a desired queue is already created — and `provisionCloudflareResources` + // would run `wrangler queues create` for it, mutating the account. That breaks the + // "CI --provision is a provable no-op" contract, so fail closed on ANY desired queue. + if (missing.length > 0 || desired.queues.length > 0) { + const lines = ['deploy: --provision is disabled in CI / non-interactive shells.']; + if (missing.length > 0) { + lines.push('', 'These id-bearing resources are not yet in your committed wrangler.jsonc:'); + for (const entry of missing) { + const reason = reasons.get(entry.binding); + lines.push( + ` • ${entry.binding} (${CLOUDFLARE_RESOURCE_LABELS[entry.kind]})${reason ? ` — ${reason}` : ''}`, + ); + } + } + if (desired.queues.length > 0) { + lines.push( + '', + 'These queues cannot be proven already created (queues carry no id in the config):', + ); + for (const name of desired.queues) { + lines.push(` • ${name} (queue)`); + } + } + lines.push( + '', + 'Provision them locally (`void deploy --backend cloudflare --provision`), commit the', + 'updated wrangler.jsonc, then let CI run `void deploy --backend cloudflare`.', + ); + log.error(lines.join('\n')); + return exit(1); + } + } + + // 4. Provision (--provision) OR drift-check (no --provision). + if (options.provision) { + // Re-verify the uploader immediately before the credentialed provisioner, which runs + // `wrangler create` with the CF token. Nothing between `whoami` and here runs project + // code, so this is defense-in-depth against the same probe-swap vector — against the one + // pre-probe baseline, never re-baselined. + if (!assertUploaderStillPinned()) { + return exit(1); + } + const provision = deps.provision ?? provisionModule.provisionCloudflareResources; + let result; + try { + result = await provision(root, configPath, desired, { + accountId, + log: { info: (msg) => log.info(msg), warn: (msg) => log.warn(msg) }, + }); + cliLog.info('cloudflare_provisioned', { + resources: result.resources.map((r) => `${r.kind}:${r.action}`), + }); + } catch (err) { + log.error(`deploy: provisioning failed — ${getErrorMessage(err)}`); + return exit(1); + } + // A DESIRED resource the provisioner could not satisfy is FATAL, not success. + // Hyperdrive returns action "skipped" when there is no connection source and + // nothing adoptable — deploying past that would ship a worker with no HYPERDRIVE + // binding that fails on its first DB request. Fail closed with guidance. + const skipped = result.resources.filter((r) => r.action === 'skipped'); + if (skipped.length > 0) { + log.error( + [ + 'deploy: these required resources were skipped during provisioning and are NOT available:', + ...skipped.map((r) => { + const label = + CLOUDFLARE_RESOURCE_LABELS[r.kind as ProvisionDriftEntry['kind']] ?? r.kind; + return ` • ${r.binding ?? r.name} (${label})${r.reason ? ` — ${r.reason}` : ''}`; + }), + 'Resolve the connection source (e.g. set DATABASE_URL for Hyperdrive, or adopt an', + 'existing config), then rerun. Nothing was built or deployed — but provisioning runs', + 'before this check, so any resource listed as created above already exists in your', + 'account (rerunning adopts it rather than duplicating it).', + ].join('\n'), + ); + return exit(1); + } + // Re-run the id-drift predicate AFTER provisioning: any desired id-bearing + // resource the provisioner did not write a real id for still drifts and must + // block the build/deploy (the desired set is the single source of truth). + const { missing } = provisionModule.checkCloudflareProvisionDrift(configPath, desired); + if (missing.length > 0) { + log.error( + formatCloudflareDriftMessage( + missing, + reasons, + 'deploy: provisioning did not satisfy every required resource:', + 'Rerun `void deploy --backend cloudflare --provision`, or provision the listed resources manually.', + ), + ); + return exit(1); + } + } else { + const { missing } = provisionModule.checkCloudflareProvisionDrift(configPath, desired); + if (missing.length > 0) { + log.error( + formatCloudflareDriftMessage( + missing, + reasons, + 'Deploy blocked — resources not yet provisioned in your Cloudflare account:', + 'Run: void deploy --backend cloudflare --provision', + ), + ); + return exit(1); + } + // Queue names are taken VERBATIM from filenames (`queues/.ts` → ``, see + // `queues/scan.ts`) with no sanitization, and the probe below passes each one to wrangler + // as an ARGV positional. Gate them on the SAME `assertValidResourceName('queue', …)` the + // `--provision` path already applies, BEFORE the probe runs: a name beginning with `-` is + // parsed by wrangler as a FLAG (`queues info --help` exits 0 printing usage), which the + // probe would classify as inconclusive and wave through — silently forfeiting the + // early-ordering guarantee this whole step exists to provide. Collect EVERY offending name + // (as the drift/absent-queue errors do) so one rename pass clears them all. + const invalidQueues = desired.queues.filter((name) => { + try { + provisionModule.assertValidResourceName('queue', name); + return false; + } catch { + return true; + } + }); + if (invalidQueues.length > 0) { + log.error( + [ + 'Deploy blocked — these queue names are not valid Cloudflare queue names:', + ...invalidQueues.map((name) => ` • ${name} (queue)`), + '', + 'A queue takes its name from its filename in queues/ (queues/.ts → "").', + 'A queue name must be 1–63 characters, contain only letters, digits and "-", and', + 'begin and end with a letter or digit.', + '', + 'Rename the offending file(s) in queues/ and retry.', + ].join('\n'), + ); + return exit(1); + } + // The drift check above is config-only, and queues carry NO id in the config — so a + // desired queue that was never created is invisible to it. `wrangler deploy` (step 11) + // does reject a missing queue before uploading, but only AFTER the build (step 7) and + // the remote D1 apply (step 9) have run. Probe each desired queue by name HERE so that + // failure lands before either. `--provision` needs no probe: it creates the queues. + // + // `queues list` is unusable (a human table with no `--json`, whose single un-looped + // page-1 request silently truncates on large accounts), so this is N `queues info` + // calls — one clean binary probe per desired queue. Only the provisioner's exact, + // name-aware not-found signal counts as absence: an inconclusive probe (auth or + // network error, signal-killed child, unexpected output) must NOT block, since + // `wrangler deploy`'s own pre-upload check still backstops it. + const absentQueues: Array = []; + for (const name of desired.queues) { + const info = await Promise.resolve( + runWrangler(['queues', 'info', name, '-c', configPath, '--env='], { capture: true }), + ); + if (provisionModule.isQueueConfirmedAbsent(info, name)) { + absentQueues.push(name); + } + } + if (absentQueues.length > 0) { + log.error( + [ + 'Deploy blocked — these queues do not exist in your Cloudflare account:', + ...absentQueues.map((name) => ` • ${name} (queue)`), + '', + 'Create them by rerunning with --provision:', + ' void deploy --backend cloudflare --provision', + 'Or create each one yourself:', + ...absentQueues.map((name) => ` wrangler queues create ${name}`), + ].join('\n'), + ); + return exit(1); + } + } + + // 5. Reconcile the worker `name` AND the pinned `account_id` into the root config + // BEFORE the build. `name` is only-when-absent (never clobber the user's); the build + // defaults a name into dist/ssr/wrangler.json, but `wrangler secret list -c ` + // (the secrets gate) resolves the worker from the ROOT config's `name`, so pinning it + // makes the gate and deploy agree. `account_id` is always overwritten with the + // reconciled account: the CF Vite plugin copies `account_id` ONLY from the root config + // file (never from CLOUDFLARE_ACCOUNT_ID) into dist/ssr/wrangler.json, so an env-only + // pin would otherwise emit an artifact with NO `account_id` and the identity check + // below could never confirm the pin. Persisting it makes the documented env-only pin + // deploy the verified account. + const workerName = readWranglerTopLevelString(rootConfig, 'name') ?? deriveWorkerName(root); + writeProvisionedWranglerConfig(root, { name: workerName, accountId }, { configPath }); + + // 6. Pin the id-bearing binding IDENTITIES across the build, mirroring the account_id pin. + // Provision (step 4) wrote each real id into the root config via `wrangler create + // --update-config`, and the reconcile above only touches `name`/`account_id` — so RE-READING + // the config here captures exactly what every legitimate PRE-BUILD write persisted. The build + // (step 7) runs untrusted project code with its Cloudflare credentials scrubbed, so a + // compromised build dependency can only edit FILES: it could rewrite `wrangler.jsonc`'s + // `d1_databases[].database_id` (keeping the binding + database_name) to redirect the + // privileged remote D1 migrate (step 9) or the deploy (step 11) to a DIFFERENT same-account + // resource — OR ADD a brand-new id-bearing binding (`EXTRA_DB`/`EXTRA_KV`/…) the CF plugin then + // merges verbatim into the emitted artifact. So the snapshot captures EVERY id-bearing binding + // declared here, not only the desired ones: the emitted-config check (step 10) rejects any + // id-bearing binding in `dist/ssr/wrangler.json` absent from this pin, so a binding the build + // added is caught while a user's own extra binding (present here) passes. This snapshot is the + // ground truth the migrate assert (step 9) and the emitted-config check (step 10) compare + // against. No legitimate path rewrites a binding id in the ROOT config after this point — the + // CF plugin's only post-build id writes go to the emitted dist/ssr/wrangler.json, never back to + // the root — so equality here is safe to assert. + const preBuildRootConfig = parseRootWranglerConfig(configPath); + const bindingIdentitySnapshot = snapshotBindingIdentities(preBuildRootConfig); + // Companion pin taken from the SAME pre-build root config: the per-binding ENTRY per resource + // field Void's own build never emits (`DEPLOY_PASSTHROUGH_ASSERT_FIELDS`). The emitted-config + // check (step 10) rejects any such binding in `dist/ssr/wrangler.json` absent from this pin — or + // one whose target it rewrote — so a build that INJECTS or RETARGETS a `services` / + // `secrets_store_secrets` / `dispatch_namespaces` / `unsafe` / … binding is caught, while a + // user's own hand-declared pass-through binding (present here, unchanged) passes. Void never + // writes these fields, so no legitimate build adds one that was not already in the root. + const passthroughIdentitySnapshot = snapshotPassthroughIdentities(preBuildRootConfig); + // Companion RAW-value pin from the SAME pre-build root config for the fields the ENTRY pin cannot + // cover: `unsafe` / `tail_consumers` / `streaming_tail_consumers` (whole-object-pinned — wrangler + // applies `unsafe.metadata` LAST at upload and the tail consumers forward logs/requests; the pin + // is symmetric, so removing a root-declared value is caught too), plus `durable_objects` (reject a + // build-injected EXTERNAL DO `script_name`) and `ai` (pin the binding to the root's, or to Void's + // own `{binding:'AI'}` only when `needsAI`). Void's build emits none of the first three. + const passthroughRawFields = snapshotPassthroughRawFields(preBuildRootConfig); + // Companion pin: the exact `containers` set Void's build emits — the root-declared containers + // (spread verbatim) plus Void's own sandbox container when the source needs it (`needsSandbox`, + // the SAME `isSandboxEnabled(config) || import-walk` signal index.ts gates emission on). A bare + // `wrangler deploy` provisions a container application from this array, so the emitted-config + // check (step 10) rejects a container the build injected, dropped, or retargeted. + // `resolveSandboxConfig` is the SAME pure function the build uses, so the reconstructed sandbox + // container is byte-identical to the emitted one. + const expectedContainers = reconstructExpectedContainers( + voidConfig, + root, + preBuildRootConfig, + needsSandbox, + ); + + // 6a. Reject a PRE-BUILD PROBE that laundered the root bindings (NO-PROVISION path only). The + // step-3c env-schema probe runs project code BEFORE the step-6 snapshots above, so a probe that + // rewrote wrangler.jsonc — swapping a binding id, ADDING an id-bearing / pass-through binding, or + // changing unsafe / tail_consumers / routes / containers — would be captured HERE as the trusted + // baseline, emitted by the build, and waved through the step-10 verifier that pins against it. + // `rootConfig` was parsed at step 3, BEFORE any project code ran, so it is the pre-probe ground + // truth. Rebuild the same four snapshots from it and assert they deep-equal the step-6 ones. + // + // Only in the no-provision path: the ONLY config write between the pre-probe parse and step 6 is + // step 5's reconcile, which touches only top-level `name`/`account_id` — none of which any of + // these snapshots read (they cover d1/kv/r2/hyperdrive ids, pass-through binding entries, + // unsafe/tail/routes raw fields, and containers) — so absent laundering the two are identical and + // a clean deploy always passes. Under `--provision` the provisioner legitimately writes real ids + // into the config after the probe, so pre-probe ≠ step-6 by design; closing that path needs + // delta-authorization against a live-provision model and is a known residual tracked in #283. + if (!options.provision) { + const preProbeBindingIdentities = snapshotBindingIdentities(rootConfig); + const preProbePassthroughIdentities = snapshotPassthroughIdentities(rootConfig); + const preProbePassthroughRawFields = snapshotPassthroughRawFields(rootConfig); + const preProbeExpectedContainers = reconstructExpectedContainers( + voidConfig, + root, + rootConfig, + needsSandbox, + ); + // `snapshotBindingIdentities` / `…PassthroughIdentities` / `…PassthroughRawFields` return Maps, + // which the JSON-shaped `deepEqual` cannot compare — use `snapshotsDeepEqual` (Map-aware). + // `expectedContainers` is a plain array, so `deepEqual` handles it directly. + const rootBindingsLaundered = + !snapshotsDeepEqual(preProbeBindingIdentities, bindingIdentitySnapshot) || + !snapshotsDeepEqual(preProbePassthroughIdentities, passthroughIdentitySnapshot) || + !snapshotsDeepEqual(preProbePassthroughRawFields, passthroughRawFields) || + !deepEqual(preProbeExpectedContainers, expectedContainers); + if (rootBindingsLaundered) { + log.error( + [ + 'deploy: the root wrangler config’s bindings, pass-through entries, or containers changed', + 'during the pre-build env-schema probe (the config differs from its state before any', + 'project code ran). Refusing to deploy a config a pre-build step rewrote — the binding set', + 'the deploy would ship no longer matches the one present before the probe. Rebuild from a', + 'clean checkout, then rerun.', + ].join('\n'), + ); + return exit(1); + } + } + + // (The LOCAL migration gates run POST-build at step 8d, immediately before the remote + // apply — not here — so they validate the exact files on disk AFTER the build. See step 8d.) + + // 7. Build with the pinned deploy transport — WITHOUT VOID_DEPLOY_PROJECT_ID — so + // self-host ISR emits and the CF plugin merges the wrangler.jsonc real ids into + // dist/ssr/wrangler.json. `deployChildEnv` scrubs CLOUDFLARE_ENV + + // CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH (and pins one account + API base), so the CF + // plugin generates the pinned TOP-LEVEL config from the CANONICAL root wrangler config, + // not a named-env / alternate-config one whose account/name/bindings could differ. + // + // The build runs BEFORE the secret / auth gates: the redesign validates the REAL artifact + // the build emits (`dist/ssr/wrangler.json` `vars` + the generated auth schema) rather than + // predicting it, so a missing secret / bad auth schema is caught AFTER the build — the + // accepted trade-off for reading ground truth. No REMOTE mutation has happened yet (provision + // ran earlier, but the remote D1 apply + deploy are still ahead), so a failed gate below + // never leaves a half-migrated / half-deployed account. + // + // DELETE the CF plugin's deploy pointer (`.wrangler/deploy/config.json`) FIRST. The + // plugin OVERWRITES it (never deletes) on each build, but ONLY when the CF Vite plugin + // participates — a build that emits no worker artifact leaves a STALE pointer from a + // previous run in place, and a bare `wrangler deploy` would follow it to an old + // `dist/ssr/wrangler.json`. Removing it now means the reads below (which require the + // pointer to exist) can only pass on a pointer THIS build re-created. + removeDeployPointer(root); + // `vite build` runs arbitrary project code — the user's config, every plugin, and env.ts + // — and needs NO Cloudflare credential of its own (the CF plugin makes no authenticated + // call from a build hook; Void's `remote: true` is a serve-only proxy to Void's own + // platform, and build-time prerender/ISR runs on local Miniflare). So the build child gets + // the same credential scrub as the probes, on the same rationale. + // + // Not a leak this can close: the CF plugin's `config` hook `loadEnv`s the project's own + // `CLOUDFLARE_*` dotenv keys back into the child's `process.env`. Those values are already + // on disk and readable by the build code either way — the scrub is about the credentials + // the PARENT holds, which the project never had. + const buildChildEnv = scrubWranglerAuthCredentials(deployChildEnv(accountId, apiBaseUrl)); + delete buildChildEnv.VOID_DEPLOY_PROJECT_ID; + // Pin NODE_ENV=production for the build too, so the build and the env-schema probes evaluate + // env.ts under the SAME environment. `vite build` only DEFAULTS NODE_ENV to production when it + // is unset (`if (!isNodeEnvSet) process.env.NODE_ENV = defaultNodeEnv`), so an ambient + // NODE_ENV=development would otherwise build a development schema while the probes (pinned + // production) derive a production one — the exact divergence the probe pin exists to close. + // Scrub any ambient spelling first (case-aware on win32) so it cannot shadow the pin. + requireProvisionModule().deleteEnvVarCaseAware(buildChildEnv, 'NODE_ENV'); + buildChildEnv.NODE_ENV = 'production'; + const runBuild = + deps.runBuild ?? + ((env: NodeJS.ProcessEnv) => { + execSync(resolveProjectCommand(root, 'vite build'), { cwd: root, stdio: 'inherit', env }); + }); + // The wrangler UPLOADER was pinned into `wranglerUploaderHashBefore` at the START of the deploy + // (before the env-schema probe and the pre-build credentialed runs), and `assertUploaderStillPinned` + // re-checks it against that ONE baseline before every credentialed spawn — never re-baselined. The + // build below runs untrusted project code with the parent's Cloudflare creds scrubbed, so it can + // only edit FILES — but that INCLUDES the `wrangler-dist` bundle every post-build credentialed + // wrangler run (the secret list, the remote D1 migrate, and the deploy) executes — so re-verify + // against the same baseline right after it, and again immediately before each spawn. + try { + runBuild(buildChildEnv); + } catch (err) { + log.error(`deploy: build failed — ${getErrorMessage(err)}`); + return exit(getExitCode(err)); + } + // Early fail — re-check right after the build, before the ground-truth gates do any work. No + // remote mutation has happened since the baseline was pinned, so a changed/unverifiable uploader + // here leaves nothing half-done. Each credentialed spawn below re-checks again immediately. + if (!assertUploaderStillPinned()) { + return exit(1); + } + + // Re-collect the checked-in migrations from disk AFTER the build. `vite build` runs + // arbitrary project plugins that could add or rewrite `db/migrations` SQL (or the + // `migrations_dir` presence), and step 9's `wrangler d1 migrations apply` reads the + // LIVE post-build files — so every migration that can reach remote D1 must be validated + // against exactly what is on disk now, not a pre-build snapshot. This one set feeds both + // the auth-schema check (8c) and the local migration gates (8d). + const migrations = collectMigrations(migrationsDir); + + // Re-run the PostgreSQL checked-in-migrations reject on the POST-build set. The pre-build guard + // (step 2b) saw an empty set, but a build plugin can WRITE `db/migrations/*.sql` during + // `vite build`; the local migration apply/validate (step 8d) is gated on `desired.d1`, which a + // pg app never has, so those files would ship UNRUN and the worker would hit an unmigrated + // database. Re-checking here — before the remote migrate (step 9) and the deploy (step 11), the + // first remote mutations — fails closed without leaving a half-deployed account. A pg app with + // ZERO checked-in migrations still passes (an externally-managed schema is legitimately + // deployable — never reject on dialect alone). + if (dialect === 'postgresql' && migrations.length > 0) { + log.error(PG_CHECKED_IN_MIGRATIONS_REJECT_MESSAGE); + return exit(1); + } + + // 8. Post-build GROUND-TRUTH gates — validate the REAL artifact the build emitted, not a + // facsimile. Read the actual `vars` the build baked into `dist/ssr/wrangler.json`, then + // WARN (plaintext secret-looking vars) + GATE (required secrets, incl. INVALID values) + + // auth-schema check + local migration gates, all against that ground truth. The only + // project code that runs does so in the isolated `validate-emitted` probe (8b). + const emitted = (deps.readEmittedWorkerVars ?? readEmittedWorkerVarsDefault)(root); + if (!emitted.ok) { + log.error( + [ + 'deploy: could not read the worker vars the build emitted — refusing to validate secrets.', + ` ${emitted.reason}`, + 'The build must emit dist/ssr/wrangler.json for this backend. Rerun the build, or deploy', + 'this app with `void deploy` (the managed platform).', + ].join('\n'), + ); + return exit(1); + } + const emittedVars = emitted.vars; + + // 8a. Plaintext-var warnings — classify the secret-looking keys among the REAL emitted vars + // (`.secret()`-flagged from the schema facts, OR the shared name heuristic). Ground truth — + // no `loadEnv`, no facsimile. + const warnings = collectPlaintextVarWarningsFromVars( + emittedVars, + schemaFacts.hasSchema ? schemaFacts.secretKeys : [], + ); + if (warnings.secretKeys.length > 0) { + log.warn( + [ + 'deploy: these worker vars ship as PLAINTEXT in the deployed config:', + // Key names + why they were flagged only — never a value. + ...warnings.secretKeys.map((k) => ` • ${k.key}${PLAINTEXT_WARNING_REASONS[k.reason]}`), + 'Move real secrets to `wrangler secret put ` so they are not baked into wrangler.json.', + ].join('\n'), + ); + } + + // 8b. Required-secret gate — build the values-free context from the REAL emitted vars + the + // env-insensitive required-key names (pure parent work, no project code): a required key + // shipped EMPTY (`K=""`) SHADOWS a same-named remote secret → fail closed always; a required + // key ABSENT from the emitted vars is discharged only by `wrangler secret list`. + const envContext = deriveSelfHostEnvContextFromVars(emittedVars, schemaFacts); + // Then VALUE-validate the REAL emitted vars against the schema's validators in the isolated + // `validate-emitted` probe (project code, off this credentialed parent) and fold the FAILING + // key NAMES into the context, so a required var shipped with a MALFORMED value (e.g. a + // non-number `PORT`, a bad JSON object) fails closed HERE — before any remote mutation — + // rather than only at worker boot. Values-free: only key names cross the process boundary. + if (schemaFacts.hasSchema) { + let validateResult: EnvSchemaValidateResult; + try { + validateResult = await (deps.validateEmittedVars ?? validateEmittedVarsDefault)( + { root }, + buildSchemaProbeEnv(accountId, apiBaseUrl), + ); + } catch (err) { + log.error( + `deploy: could not validate the emitted worker vars against the env schema — ${getErrorMessage(err)}`, + ); + return exit(1); + } + if (validateResult.kind === 'schema-error') { + log.error(`deploy: ${selfHostSchemaFailureMessage(validateResult.reason)}`); + return exit(1); + } + envContext.invalidKeys = validateResult.invalidKeys; + } + // DISCHARGE the context. Reuses the same `dischargeSelfHostEnvContext` the unit tests + // exercise — now fed the ground-truth vars + the real invalid-value set. It may spawn a + // credentialed `wrangler secret list` (its own runner, via `createDefaultSecretListRunner`), + // which runs the hashed uploader bundle — and the emitted-var validation above just ran project + // code (env.ts) that could have overwritten it. Re-verify the uploader against the pre-build + // baseline immediately before the spawn. + if (!assertUploaderStillPinned()) { + return exit(1); + } + try { + await (deps.dischargeSelfHostEnv ?? dischargeSelfHostEnvContext)(envContext, { + root, + configPath, + accountId, + // Pin the worker NAME too: the secret list resolves the worker from the config's + // `name`, which the emitted-var probe above (project code) could have rewritten + // since step 5 pinned it. The gate reconciles the config against this and fails + // closed on a mismatch, mirroring the account pin. + workerName, + }); + } catch (err) { + if ( + err instanceof SelfHostFirstDeployError || + err instanceof SelfHostSecretListError || + err instanceof SelfHostEnvValidationError || + err instanceof SelfHostSchemaError + ) { + log.error(err.message); + return exit(1); + } + throw err; + } + + // 8c. Auth-schema check — the managed platform creates Void-managed Better Auth tables at + // RUNTIME (POST /__void/migrate/auth) after dispatch; this self-host backend never calls it, + // it only applies checked-in `db/migrations/*.sql`. So verify the checked-in migrations + // PRODUCE the required Better Auth schema (tables + columns, incl. configured renames + plugin + // tables) by reading the schema the BUILD generated (`.void/better-auth-schema.ts` — ground + // truth) and applying the migrations to in-memory SQLite. `dialect` is guaranteed `'sqlite'` + // here (pg+auth was rejected at 2a). + if (authEnabled) { + try { + await (deps.validateAuthMigrations ?? validateAuthMigrationSchema)(root, migrations); + } catch (err) { + log.error( + [ + 'deploy: --backend cloudflare cannot verify the Void-managed Better Auth schema.', + 'The managed platform creates these tables at runtime (POST /__void/migrate/auth)', + 'after deploy; this self-host backend only applies checked-in db/migrations/*.sql, so', + 'they must already produce the Better Auth schema — tables, columns, and the primary', + 'keys, unique constraints and foreign keys it declares:', + ` ${getErrorMessage(err)}`, + 'Define the Better Auth tables in `db/schema.ts`, run `void db generate`, commit the', + 'migration, then rerun — or deploy this app with `void deploy` (the managed platform).', + "Note that Drizzle opts out of constraints by default: every auth table's `id` needs", + '`.primaryKey()`, a field the schema marks unique needs `.unique()`, and one that', + 'references another table needs `.references()`.', + ].join('\n'), + ); + return exit(1); + } + } + + // 8d. Local migration gates (D1/SQLite apps only; pg/Hyperdrive skip) — run POST-build, + // immediately before the remote apply, against the RE-COLLECTED post-build migrations. Moving + // them here (from pre-build) closes the window where a build plugin could add/rewrite migration + // SQL that step 9 would then apply to remote D1 ungated: `wrangler d1 migrations apply` reads + // the live post-build files, and these gates now validate exactly that set. Nothing between the + // build and here mutates remote state, so a failed gate never leaves a half-migrated account. + let d1MigrateToken: string | undefined; + if (desired.d1) { + // 8d-i. Run the SAME gates the managed deploy runs — schema drift, journal coherence, + // destructive-op-without-pragma — for EVERY D1 app REGARDLESS of migration count. A + // `db/schema.ts` with no generated migration is drift the old count-gated check silently + // skipped (shipping a schemaless remote DB); wrangler also auto-applies destructive SQL in CI + // (its confirmation defaults to "yes" non-interactively). `validateMigrations` self-guards on + // an empty set. + try { + (deps.assertMigrationsDeployable ?? assertRemoteMigrationsDeployable)( + root, + dialect, + migrationsDir, + migrations, + ); + } catch (err) { + log.error(getErrorMessage(err)); + return exit(1); + } + + // Resolve + validate the migrate target ONLY when there ARE checked-in migrations: a D1 + // desired purely from DB-use or auth can have no `db/migrations`, and wrangler 4.107 + // HARD-FAILS `d1 migrations apply` on a missing/empty folder (it never no-ops). + if (migrations.length > 0) { + // `migrations_dir` must be present in the config: wrangler defaults a missing value to + // `./migrations` (relative to the config) and would apply an unrelated folder. + const migrateConfig = parseRootWranglerConfig(configPath); + const d1MigrationsDir = readStoredResourceName( + migrateConfig, + 'd1_databases', + desired.d1.binding, + 'migrations_dir', + ); + if (!d1MigrationsDir) { + log.error( + [ + `deploy: D1 binding ${desired.d1.binding} has no "migrations_dir" in ${configPath}.`, + 'Refusing to run `wrangler d1 migrations apply` — wrangler would default to', + '`./migrations` (relative to the config) and apply an unrelated folder to your', + 'remote database, or error. Run `void deploy --backend cloudflare --provision`', + 'once to set `migrations_dir` (it writes `./db/migrations`), or add it yourself.', + ].join('\n'), + ); + return exit(1); + } + // wrangler 4.107 resolves the migrate target by FIRST-matching the passed token + // against `database_name` OR `binding` (getDatabaseInfoFromConfig, src/d1/utils.ts) + // and reads THAT entry's `migrations_dir`. The gate validated the entry matched by + // BINDING, so migrate by a token that resolves to exactly that entry — fail closed + // when an earlier alias entry would shadow it (else wrangler could migrate the wrong + // DB / an unrelated folder though the gate passed). + const migrateTarget = resolveD1MigrateTarget(migrateConfig, desired.d1.binding); + if (!migrateTarget.ok) { + log.error( + [ + `deploy: cannot safely run D1 migrations for binding ${desired.d1.binding} — ${migrateTarget.reason}`, + 'wrangler resolves `d1 migrations apply ` by first-matching the name against', + "each entry's database_name OR binding, so an ambiguous token could migrate a", + 'different database than the one validated. Give each D1 database a unique', + 'database_name (and binding), then rerun.', + ].join('\n'), + ); + return exit(1); + } + + // 8d-iii. Pin check — the remote apply below resolves its target from THIS post-build + // `migrateConfig` (the root config wrangler re-reads to map the token → database_id), so + // assert that database_id still equals what was snapshotted at step 6 BEFORE the build. + // A tampered build that rewrote `d1_databases[].database_id` — keeping the binding + // + database_name so the token still resolves — would otherwise redirect this privileged + // `--remote` migration to a DIFFERENT same-account database. Fail closed, mirroring the + // account_id pin. A binding never pinned (no real id pre-build, which the drift check + // already rejects) is not asserted. + const { field: d1Field, idField: d1IdField } = DEPLOY_BINDING_IDENTITY_FIELDS.d1; + const pinnedD1Id = bindingIdentitySnapshot.get( + bindingIdentityKey(d1Field, desired.d1.binding), + ); + const postBuildD1Id = readStoredResourceName( + migrateConfig, + d1Field, + desired.d1.binding, + d1IdField, + ); + if (pinnedD1Id !== undefined && postBuildD1Id !== pinnedD1Id) { + log.error( + [ + `deploy: the ${desired.d1.binding} D1 database identity changed between validation and the remote migrate.`, + `Refusing to run \`wrangler d1 migrations apply\` — the build rewrote ${d1IdField} in ${configPath}`, + `(validated ${JSON.stringify(pinnedD1Id)}, now ${JSON.stringify(postBuildD1Id)}), which would redirect this`, + 'privileged remote migration to a different database. Rebuild from a clean checkout, then rerun.', + ].join('\n'), + ); + return exit(1); + } + + // 8d-ii. The EXACT files wrangler will apply (its own `migrations_dir` + `migrations_pattern` + // resolution) must EQUAL the gated source set by normalized name AND content. Otherwise + // a stray/extra `.sql` (e.g. `seed.sql`, which the gate's `collectMigrations` skips but + // wrangler's `*.sql` glob matches), a differing dir, or a divergent file would be applied + // to REMOTE D1 unvalidated. The gate above only checked `migrations`; this makes the + // applied set == the validated set. + const applyCheck = await (deps.checkMigrationApplySet ?? checkWranglerMigrationApplySet)( + configPath, + migrateConfig, + desired.d1.binding, + d1MigrationsDir, + migrations, + ); + if (!applyCheck.ok) { + log.error(applyCheck.reason); + return exit(1); + } + d1MigrateToken = migrateTarget.token; + } + } + + // 9. Apply pending migrations to remote D1 — AFTER all local + ground-truth gates pass, so a + // failed gate never mutates remote D1. Uses the token resolved + validated at step 8d, and the + // pinned root config (`-c`), which targets the account already verified by provision/drift. + if (d1MigrateToken) { + // The local migration gates above ran project code (drizzle-kit loaded db/schema.ts), which + // could have overwritten the hashed uploader bundle after the last check. Re-verify against the + // pre-build baseline immediately before this credentialed, remote-mutating apply. + if (!assertUploaderStillPinned()) { + return exit(1); + } + // 9a. Apply. + const migrate = await Promise.resolve( + runWrangler( + ['d1', 'migrations', 'apply', d1MigrateToken, '--remote', '-c', configPath, '--env='], + { capture: false }, + ), + ); + if (!isCleanWranglerExit(migrate)) { + log.error( + `deploy: remote D1 migrations failed for "${d1MigrateToken}" (exit ${String(migrate.status)}). ` + + 'Fix the migration and rerun. Note: D1 always enforces foreign_keys=ON — a Drizzle ' + + 'table-rebuild using PRAGMA foreign_keys=OFF must use PRAGMA defer_foreign_keys instead.', + ); + return exit(1); + } + + // 9b. Verify NO migrations remain pending on remote. `d1 migrations apply` EXITS 0 when + // its confirmation is DECLINED (interactive) — a no-op that reads as success — and prints + // no machine-readable applied set. `d1 migrations list --remote` queries the remote + // `d1_migrations` table and prints "Migrations to be applied:" when any remain, else the + // affirmative "No migrations to apply!". Both emit at wrangler's `log` level, which the + // child env PINS (`WRANGLER_LOG=log`), so an ambient reduced level can never suppress + // them. Reject on the pending marker, REQUIRE the affirmative sentinel, and reject any + // empty/unrecognized output rather than trust an absent marker. + // Re-verify the uploader before this distinct credentialed run too (defense in depth — the + // pre-build baseline is the constant reference for every spawn). + if (!assertUploaderStillPinned()) { + return exit(1); + } + const migrateList = await Promise.resolve( + runWrangler( + ['d1', 'migrations', 'list', d1MigrateToken, '--remote', '-c', configPath, '--env='], + { capture: true }, + ), + ); + if (!isCleanWranglerExit(migrateList)) { + log.error( + `deploy: could not verify the remote migration state for "${d1MigrateToken}" ` + + `(\`d1 migrations list\` exit ${String(migrateList.status)}). Refusing to deploy against ` + + 'an unverified schema — rerun once wrangler can reach your account.', + ); + return exit(1); + } + if (migrateList.stdout.includes('Migrations to be applied:')) { + log.error( + [ + `deploy: remote D1 still has UNAPPLIED migrations for "${d1MigrateToken}" after apply.`, + '`wrangler d1 migrations apply` exits 0 even when its confirmation is declined, so', + 'the schema may be un-migrated while the code that needs it is about to deploy.', + 'Re-run and confirm the migration (or apply it non-interactively), then rerun.', + ].join('\n'), + ); + return exit(1); + } + if (!migrateList.stdout.includes('No migrations to apply!')) { + log.error( + [ + `deploy: could not confirm the remote migration state for "${d1MigrateToken}".`, + '`wrangler d1 migrations list` printed neither the "no migrations to apply" nor the', + '"migrations to be applied" marker (WRANGLER_LOG is pinned so they are never', + 'suppressed) — refusing to deploy against an unverified schema. Rerun once wrangler', + 'can reach your account.', + ].join('\n'), + ); + return exit(1); + } + } + + // 10. Verify the generated deploy config targets the pinned account + expected worker + // AND the exact provisioned binding IDENTITIES BEFORE uploading. A `CLOUDFLARE_ENV` / + // `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH` the CF plugin loaded could have baked a + // different account/name — or the SAME account/name with a flattened, DIFFERENT binding + // set — into dist/ssr/wrangler.json; passing `desired` + the step-6 snapshots lets the check + // reject a divergent binding set, not only an account/name mismatch. The comparison is against + // the in-memory `bindingIdentitySnapshot` (NOT a post-build root re-read), so a build that + // consistently rewrote both the root config and this artifact is still caught. The + // `passthroughIdentitySnapshot` extends the same defense to the resource fields Void never emits + // (`services` / `secrets_store_secrets` / …): an entry the build injected into one of them — or a + // queue not in `desired.queues` — fails closed here too, before the credentialed upload binds it. + const verification = (deps.verifyDeployConfig ?? verifyGeneratedDeployConfig)(root, { + accountId, + workerName, + desired, + bindingIdentitySnapshot, + passthroughIdentitySnapshot, + passthroughRawFields, + needsAI, + expectedContainers, + expectedCrons, + }); + if (!verification.ok) { + log.error( + [ + 'deploy: the generated deploy config does not match the pinned target — refusing to deploy.', + verification.reason, + 'This typically means CLOUDFLARE_ENV / CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH (in the', + 'environment or a production `.env`) selected a different named environment or config', + 'at build time. This backend always deploys the canonical top-level config — remove', + 'them and rebuild.', + ] + .filter(Boolean) + .join('\n'), + ); + return exit(1); + } + + // 11. Deploy — bare `wrangler deploy` follows .wrangler/deploy/config.json → + // dist/ssr/wrangler.json (which carries the real ids + pinned account). `--env=` pins + // the top-level env so an ambient CLOUDFLARE_ENV cannot reselect a named one. + // `--experimental-provision=false` / `--experimental-auto-create=false` disable + // wrangler 4.107's OWN resource provisioning (both default true, deployCommand + + // globalFlags, wrangler-dist/cli.js), so ONLY `provisionCloudflareResources` may create + // resources — a bare deploy can never auto-create a missing binding (e.g. an R2 bucket + // absent remotely but passing the local drift predicate), even in CI. + // `--autoconfig=false` disables wrangler 4.107's framework detection + auto-config + // (`autoconfig` defaults true, deployCommand cli.js:292315). Without it, and because we + // pass no `-c`, a detected framework marker (e.g. OpenNext) makes wrangler DELEGATE the + // deploy to another tool (`maybeDelegateToOpenNextDeployCommand`, cli.js:292343) — which + // would upload something OTHER than the artifact Void just built and verified. Forcing it + // off guarantees wrangler uploads exactly the verified `dist/ssr/wrangler.json`. + // + // Final uploader re-verification immediately before the deploy — the last and most privileged + // credentialed run of the hashed bundle. Against the same pre-build baseline; fail closed. + if (!assertUploaderStillPinned()) { + return exit(1); + } + const deployResult = await Promise.resolve( + runWrangler( + [ + 'deploy', + '--env=', + '--experimental-provision=false', + '--experimental-auto-create=false', + '--autoconfig=false', + ], + { capture: false }, + ), + ); + if (!isCleanWranglerExit(deployResult)) { + log.error(`deploy: \`wrangler deploy\` failed (exit ${String(deployResult.status)}).`); + return exit(1); + } + cliLog.info('cloudflare_deploy_end', { ok: true, worker: workerName }); + log.success(`Deployed "${workerName}" to your Cloudflare account.`); +} + +/** + * Discover the root wrangler config path the same way the build / bindings + * writer do (wrangler.jsonc first, then wrangler.json). Falls back to the + * default wrangler.jsonc path when none exists yet — the provisioner creates it. + */ +function discoverRootWranglerConfig(root: string): string { + for (const name of ['wrangler.jsonc', 'wrangler.json']) { + const candidate = join(root, name); + if (existsSync(candidate)) { + return candidate; + } + } + return join(root, 'wrangler.jsonc'); +} + +async function runDeployInner( + root: string, + options: RunDeployOptions | undefined, + cliLog: CliLogger, +): Promise { + // Fork to the self-hosted Cloudflare backend BEFORE any managed-platform work + // (auth token, PlatformClient, project resolution, config read, branch + // selection). Everything below stays byte-for-byte the managed `void` path. + if (options?.backend === 'cloudflare') { + return runCloudflareDeploy(root, options, cliLog); + } + let reauthenticated = false; while (true) { try { @@ -2107,8 +6280,9 @@ function runDrizzleKitGenerate(root: string, configPath: string): string { cwd: root, encoding: 'utf-8', // drizzle-kit loads the user's drizzle config + schema (user code); keep the - // CF Access service token out of it, matching the build spawns. - env: buildEnv(), + // CF Access service token AND every wrangler-auth credential out of it, matching the + // build spawns. Generating migrations needs no Cloudflare credential. + env: scrubWranglerAuthCredentials(buildEnv()), }); const stderr = bufferToString(result.stderr); const stdout = bufferToString(result.stdout); diff --git a/packages/void/src/cli/env-schema-probe.ts b/packages/void/src/cli/env-schema-probe.ts new file mode 100644 index 000000000..19f1352e6 --- /dev/null +++ b/packages/void/src/cli/env-schema-probe.ts @@ -0,0 +1,419 @@ +import { spawnSync } from 'node:child_process'; +import { writeSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'pathe'; +import type { SelfHostSchemaFacts, SelfHostSchemaFailureReason } from './env-validation.ts'; + +/** + * The self-host `void deploy --backend cloudflare` orchestrator validates the REAL + * build artifact (`dist/ssr/wrangler.json` `vars`) instead of predicting it. Two + * pieces still need project code, and BOTH run here — in an ISOLATED SUBPROCESS — + * because the orchestrator runs in a credential-bearing PARENT (CF Access service + * token / `CLOUDFLARE_API_TOKEN` in `process.env`) and must NOT evaluate arbitrary + * project code in-process: + * + * 1. `facts` — the env-INSENSITIVE schema facts (required-secret NAMES + the + * `.secret()` flags), fixed by env.ts's structure. The child ALSO resolves the + * Vite config here purely to read the final `{ envDir, mode }` and reject a + * custom `envDir` or a non-`production` build mode (v1 supports neither): the + * build generates the auth artifact under the project root while the worker + * `vars` come from the build's `envDir`, so a custom `envDir` could validate a + * different table set than the worker ships. Resolving the config runs the full + * plugin pipeline (arbitrary code, timers/`beforeExit` risk) — safe HERE in the + * child (which `process.exit`s), never in the credentialed parent. + * 2. `validate-emitted` — validate the REAL emitted worker `vars` (post-build + * ground truth) against the schema's Standard-Schema validators and return the + * FAILING key NAMES only. A required var shipped with an INVALID value (fails + * the validator) must fail the deploy closed before any remote mutation, not + * only at worker boot. + * + * Three hardening rules apply to the child: + * - the framed result is written to a DEDICATED file descriptor (fd 3), NOT + * stdout, so anything the user's code prints while evaluating can never + * truncate or interleave with the result; + * - the caller scrubs the FULL wrangler-auth credential set from the child env + * (neither derivation needs credentials); + * - the parent NEVER captures the child's stdout/stderr — they are IGNORED + * (fds 1 + 2), never buffered — so a leaking env.ts / validator cannot print a + * raw secret to the terminal or CI log, and every failure surfaces as a stable + * classification only. + */ + +/** argv[2] sentinel that switches this module from library import to child-process entry. */ +const PROBE_MARKER = '__void-deploy-env-schema-probe__'; + +/** The dedicated fd the child writes its framed result to (see stdio config below). */ +const RESULT_FD = 3; + +/** The two operations the child can perform, selected by the parent. */ +type EnvSchemaProbeOp = 'facts' | 'validate-emitted'; + +/** Input handed to the child (JSON in argv[3]). Path only — no env values cross here. */ +export type EnvSchemaProbeInput = { root: string }; + +/** Full child input (adds the operation discriminant to {@link EnvSchemaProbeInput}). */ +type ChildInput = { op: EnvSchemaProbeOp; root: string }; + +/** The reason the `facts` probe rejected the app as unsupported (values-free). */ +export type EnvSchemaProbeRejectReason = 'custom-envdir' | 'non-production-mode'; + +/** + * The structured result of the `facts` operation. `ok` carries the env-insensitive + * schema facts; `reject` fails the deploy closed pre-account with clear v1-scope + * guidance; `schema-error` surfaces a values-free env.ts/validator failure. + * + * `reject` crosses the process boundary as a FIXED reason CODE only — never the raw + * resolved `mode` / `envDir` strings. Those are arbitrary Vite-config-derived values + * (a config could set `envDir`/`mode` from any inherited secret), so echoing them + * over fd 3 or into a parent log would break the values-free boundary; the parent + * renders fixed guidance from the code alone. + */ +export type EnvSchemaProbeResult = + | { kind: 'ok'; facts: SelfHostSchemaFacts } + | { kind: 'reject'; reason: EnvSchemaProbeRejectReason } + | { kind: 'schema-error'; reason: SelfHostSchemaFailureReason }; + +/** + * The structured result of the `validate-emitted` operation. `invalid-keys` carries + * the FAILING key NAMES only (never a value); `schema-error` surfaces a values-free + * env.ts/validator failure. + */ +export type EnvSchemaValidateResult = + | { kind: 'invalid-keys'; invalidKeys: Array } + | { kind: 'schema-error'; reason: SelfHostSchemaFailureReason }; + +/** The full union any child operation can emit — narrowed by the public wrappers. */ +type EnvSchemaProbeRawResult = EnvSchemaProbeResult | EnvSchemaValidateResult; + +/** Minimal structural shape of a `spawnSync`-style result (injectable in tests). */ +export type EnvSchemaProbeSpawnResult = { + status: number | null; + /** The fd-3 buffer (the framed result) — dedicated so stdout noise can't truncate it. */ + fd3: string | null; + error?: Error | null; + signal?: NodeJS.Signals | null; +}; + +export type EnvSchemaProbeSpawn = ( + command: string, + args: Array, + options: { cwd: string; env: NodeJS.ProcessEnv }, +) => EnvSchemaProbeSpawnResult; + +/** + * Default spawner: run THIS module as a child and read ONLY the dedicated fd-3 + * result. stdin is ignored, stdout/stderr are IGNORED (fds 1 + 2) so a broken / + * leaking env.ts can never print a raw secret to the terminal or a captured CI log, + * and fd 3 is a pipe carrying the framed JSON result. `spawnSync`'s `output` array + * is indexed by fd, so `output[3]` is the result channel — isolated from stdout + * entirely (and unaffected by ignoring fds 1 + 2). + */ +const defaultSpawn: EnvSchemaProbeSpawn = (command, args, options) => { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'ignore', 'ignore', 'pipe'], + encoding: 'utf-8', + }); + const fd3 = result.output?.[RESULT_FD]; + return { + status: result.status, + fd3: typeof fd3 === 'string' ? fd3 : fd3 == null ? null : String(fd3), + error: result.error ?? null, + signal: result.signal ?? null, + }; +}; + +/** + * Spawn THIS module as a child (`node `) under the scrubbed + * `env` for one operation, and return its framed fd-3 result. The child inherits + * ONLY `env` (the caller scrubs the full wrangler-auth credential set), so the + * arbitrary project code never sees the parent's credentials. `import.meta.url` is + * this module's own path — a named build entry (`dist/cli/env-schema-probe.mjs`), + * stable in source and dist — so the child re-enters here and hits the {@link + * PROBE_MARKER} branch. Throws (caller fails closed) on a spawn error, a signal-kill, + * a nonzero exit, or a missing/unparseable result — exactly the cases where the + * project code itself could not be evaluated. NEVER echoes the child's + * stdout/stderr (never captured), so no env value the child may have printed can + * reach the thrown error. + */ +async function spawnEnvSchemaProbe( + op: EnvSchemaProbeOp, + input: EnvSchemaProbeInput, + env: NodeJS.ProcessEnv, + spawn: EnvSchemaProbeSpawn, +): Promise { + const childInput: ChildInput = { op, root: input.root }; + const self = fileURLToPath(import.meta.url); + const result = spawn(process.execPath, [self, PROBE_MARKER, JSON.stringify(childInput)], { + cwd: input.root, + env, + }); + if (result.error) { + throw new Error(`could not spawn the env-schema probe: ${result.error.message}`); + } + if (result.signal) { + throw new Error(`the env-schema probe was killed by signal ${result.signal}`); + } + if (result.status !== 0) { + // The child's stdout/stderr is never captured, so there is nothing to echo — + // only the stable exit code (no env value can ride along). + throw new Error(`the env-schema probe exited with code ${String(result.status)}.`); + } + const parsed = parseProbeResult(result.fd3 ?? ''); + if (!parsed) { + throw new Error('the env-schema probe produced no parseable result.'); + } + return parsed; +} + +/** + * PARENT side (`facts`): return the env-insensitive schema facts + the v1 envDir/mode + * narrowing. Runs pre-build so a custom envDir / non-production mode / broken env.ts + * fails fast, before any account op. + */ +export async function resolveEnvSchemaFactsViaSubprocess( + input: EnvSchemaProbeInput, + env: NodeJS.ProcessEnv, + spawn: EnvSchemaProbeSpawn = defaultSpawn, +): Promise { + const result = await spawnEnvSchemaProbe('facts', input, env, spawn); + if (result.kind === 'invalid-keys') { + // The `facts` operation never emits invalid-keys; treat it as an infra fault. + throw new Error('the env-schema probe returned an unexpected result for the facts operation.'); + } + return result; +} + +/** + * PARENT side (`validate-emitted`): validate the REAL emitted worker `vars` against + * the schema validators and return the FAILING key NAMES only. Runs post-build so + * the values validated are exactly what the worker ships. + */ +export async function validateEmittedVarsViaSubprocess( + input: EnvSchemaProbeInput, + env: NodeJS.ProcessEnv, + spawn: EnvSchemaProbeSpawn = defaultSpawn, +): Promise { + const result = await spawnEnvSchemaProbe('validate-emitted', input, env, spawn); + if (result.kind === 'ok' || result.kind === 'reject') { + // The `validate-emitted` operation never emits ok/reject; treat it as infra. + throw new Error( + 'the env-schema probe returned an unexpected result for the validate-emitted operation.', + ); + } + return result; +} + +/** + * Parse the child's framed result from the DEDICATED fd-3 channel. The whole buffer + * is the JSON result (nothing else writes to fd 3), so no sentinel scan is needed — + * that is the truncation fix: config/env.ts stdout noise lives on a different fd and + * can never reach here. Returns undefined on a missing/unparseable/ill-shaped result + * so the caller fails closed. + */ +function parseProbeResult(fd3: string): EnvSchemaProbeRawResult | undefined { + const trimmed = fd3.trim(); + if (!trimmed) { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return undefined; + } + if (!parsed || typeof parsed !== 'object') { + return undefined; + } + const record = parsed as { + kind?: unknown; + facts?: unknown; + reason?: unknown; + invalidKeys?: unknown; + }; + if (record.kind === 'ok' && isSchemaFacts(record.facts)) { + return { kind: 'ok', facts: record.facts }; + } + if ( + record.kind === 'reject' && + (record.reason === 'custom-envdir' || record.reason === 'non-production-mode') + ) { + return { kind: 'reject', reason: record.reason }; + } + if ( + record.kind === 'invalid-keys' && + Array.isArray(record.invalidKeys) && + record.invalidKeys.every((k) => typeof k === 'string') + ) { + return { kind: 'invalid-keys', invalidKeys: record.invalidKeys }; + } + if ( + record.kind === 'schema-error' && + (record.reason === 'schema-load-failed' || record.reason === 'schema-validation-error') + ) { + return { kind: 'schema-error', reason: record.reason }; + } + return undefined; +} + +/** Structural guard for the values-free facts the child returns. */ +function isSchemaFacts(value: unknown): value is SelfHostSchemaFacts { + if (!value || typeof value !== 'object') { + return false; + } + const f = value as { hasSchema?: unknown; requiredKeys?: unknown; secretKeys?: unknown }; + return ( + typeof f.hasSchema === 'boolean' && + Array.isArray(f.requiredKeys) && + f.requiredKeys.every((k) => typeof k === 'string') && + Array.isArray(f.secretKeys) && + f.secretKeys.every((k) => typeof k === 'string') + ); +} + +/** Write the framed result to the dedicated fd 3 (synchronous → flushed before exit). */ +function emitResult(result: EnvSchemaProbeRawResult): void { + writeSync(RESULT_FD, JSON.stringify(result)); +} + +/** + * Read the RAW (root-relative, unnormalized) envDir the build's worker-vars loader + * actually uses, straight from the `void:env` config hook's captured value (exposed on + * that plugin's `api.getVoidDeployBuildConfig`). This is the only faithful input to the + * build's OWN `join(root, rawEnvDir)` computation: Vite's `resolved.envDir` is already + * normalized (`path.resolve`), which loses the distinction an absolute-root envDir needs. + * Returns `undefined` when the hook is absent (a non-void config), which the caller + * treats as "no custom raw envDir" and falls back to Vite's normalized view. + */ +function readBuildRawEnvDir( + plugins: ReadonlyArray<{ name: string; api?: unknown }>, +): string | undefined { + const plugin = plugins.find((p) => p.name === 'void:env'); + const api = plugin?.api as + | { getVoidDeployBuildConfig?: () => { mode: string; envDir: string | undefined } | undefined } + | undefined; + return api?.getVoidDeployBuildConfig?.()?.envDir; +} + +/** + * CHILD side (`facts`): resolve the Vite config HERE (the FULL plugin pipeline the + * real `vite build` runs, under the scrubbed env — never in the parent) purely to + * read the effective `{ envDir, mode }` and reject a custom envDir / non-production + * mode; then, when accepted, import the user's env.ts and derive the env-insensitive + * schema facts. All heavy imports are DYNAMIC so a plain library import of this + * module (by the CLI, for the parent-side helper) never pulls Vite / env-validation / + * the user's env.ts in. + */ +async function runFactsChild(root: string): Promise { + const { resolveConfig } = await import('vite'); + // The FULL config-hook pipeline (exactly what `vite build` runs) so a top-level + // `mode`/`envDir` a config file or plugin sets is observed. `resolved.envDir` is + // `false` (env loading disabled) or the absolute normalized dir; the default + // equals `resolved.root`. + const resolved = await resolveConfig({ root }, 'build', 'production', 'production'); + // Reject a custom envDir by the SAME computation the BUILD uses. Vite normalizes + // `resolved.envDir` via `path.resolve(root, envDir)` (absolute-safe), so an ABSOLUTE + // envDir equal to the project root collapses to `resolved.root` and would ACCEPT — but + // the build's worker-vars loader (`index.ts`) uses `join(root, rawEnvDir)`, and + // `join(root, )` DUPLICATES the path (`/proj/proj`), so the build loads + // `vars` from the WRONG dir while the auth artifact is generated under the root: the + // exact vars-vs-auth divergence this guard exists to reject. So derive the build's + // ACTUAL vars dir from the RAW envDir the `void:env` hook captured and reject unless it + // equals the root — unset / `'.'` → root → accept; a relative subdir → non-root → + // reject; an absolute-root → `join` duplicates → non-root → reject. Also keep rejecting + // Vite's own `envDir === false` (env loading disabled) and its normalized-non-root view + // (a robust fallback that still catches a custom subdir if the `void:env` hook is absent). + // NOTE: `index.ts` uses `join` (not Vite's `resolve`) for the raw envDir, so the build + // itself mishandles an absolute envDir; this guard fails such configs CLOSED here. + // Switching `index.ts` `join`→`resolve` is a separate build-side follow-up (not done here). + const rawEnvDir = readBuildRawEnvDir(resolved.plugins); + const buildVarsDir = rawEnvDir ? join(resolved.root, rawEnvDir) : resolved.root; + if ( + resolved.envDir === false || + resolved.envDir !== resolved.root || + buildVarsDir !== resolved.root + ) { + // v1 supports only a root-relative envDir. Only the fixed reason code crosses the + // boundary (never the raw envDir string). + emitResult({ kind: 'reject', reason: 'custom-envdir' }); + return; + } + if (resolved.mode !== 'production') { + // A non-production build mode selects a different `.env.` set than the + // deploy validates — v1 supports only the production build. Fixed reason code only. + emitResult({ kind: 'reject', reason: 'non-production-mode' }); + return; + } + + // `resolveConfig` above already ran `void:env-schema`'s config hook, which imports the + // user's `env.ts` — so deriving the facts imports it a SECOND time, through the + // cache-busting loader. That is deliberate, and reusing the registration from config + // resolution instead would be unsafe: `importUserEnv` SWALLOWS a load error (it warns + // and returns), so a throwing `env.ts` would leave nothing registered, report + // `hasSchema: false`, and the required-secret gate would then validate ZERO keys and let + // the deploy through. Re-importing surfaces that failure as `schema-error`, which the + // orchestrator treats as fatal. Re-evaluation is a supported path — `defineEnv` is + // last-write-wins and resets its caches for exactly this (HMR re-imports do the same), + // and the build itself evaluates `env.ts` several times per process. + const { deriveSelfHostSchemaFacts, SelfHostSchemaError } = await import('./env-validation.ts'); + try { + const facts = await deriveSelfHostSchemaFacts(root); + emitResult({ kind: 'ok', facts }); + } catch (err) { + if (err instanceof SelfHostSchemaError) { + emitResult({ kind: 'schema-error', reason: err.reason }); + return; + } + throw err; + } +} + +/** + * CHILD side (`validate-emitted`): read the REAL emitted worker `vars` the build + * baked into `dist/ssr/wrangler.json` (ground truth), validate them against the + * schema's validators, and emit the FAILING key NAMES only. The parent already + * confirmed the artifact exists, so a read failure here is a probe-infrastructure + * fault → fail closed as a values-free schema error. + */ +async function runValidateEmittedChild(root: string): Promise { + const { computeInvalidEmittedKeys, readEmittedWorkerVars, SelfHostSchemaError } = + await import('./env-validation.ts'); + const emitted = readEmittedWorkerVars(root); + if (!emitted.ok) { + emitResult({ kind: 'schema-error', reason: 'schema-load-failed' }); + return; + } + try { + const invalidKeys = await computeInvalidEmittedKeys(root, emitted.vars); + emitResult({ kind: 'invalid-keys', invalidKeys }); + } catch (err) { + if (err instanceof SelfHostSchemaError) { + emitResult({ kind: 'schema-error', reason: err.reason }); + return; + } + throw err; + } +} + +/** Dispatch the child to the requested operation. */ +async function runEnvSchemaProbeChild(rawInput: string): Promise { + const input = JSON.parse(rawInput) as ChildInput; + if (input.op === 'validate-emitted') { + await runValidateEmittedChild(input.root); + return; + } + await runFactsChild(input.root); +} + +// Child-process entry: only when invoked as `node `. A normal +// library import (the CLI loading the parent-side helper) has a different argv[2], so +// this never runs in the parent and never eagerly loads Vite / env-validation / env.ts. +// On ANY unexpected failure the child exits nonzero with NO raw echo — the parent +// fails closed on the exit code alone. +if (process.argv[2] === PROBE_MARKER) { + runEnvSchemaProbeChild(process.argv[3] ?? 'null').then( + () => process.exit(0), + () => process.exit(1), + ); +} diff --git a/packages/void/src/cli/env-validation.ts b/packages/void/src/cli/env-validation.ts index c3e4286bb..cc00bf97a 100644 --- a/packages/void/src/cli/env-validation.ts +++ b/packages/void/src/cli/env-validation.ts @@ -8,13 +8,20 @@ * Used by both `void deploy` (hard error on missing prod env) and * `void env check` (CI/pre-push validation). */ -import { join } from 'pathe'; +import { spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; +import { dirname, join, resolve } from 'pathe'; import { findEnvFile } from '../codegen/env-types.ts'; import { loadUserEnvFile } from '../host/load-user-env.ts'; import { voidWarn } from '../log.ts'; +import { getSecretOverride } from '../runtime/env-helpers.ts'; import type { EnvValidationReport } from '../runtime/env-public.ts'; import { mergeAndExpand, parseDotenvFile } from '../shared/dotenv.ts'; +import { isLikelySecretKey } from '../shared/env-mask.ts'; +import type { StandardSchemaV1 } from '../standard-schema.ts'; import type { PlatformClient } from './client.ts'; +import { resolveWranglerArgv } from './preset.ts'; type EnvPublicModule = { _getRegisteredSchema(): Record | null; @@ -142,3 +149,1340 @@ export async function fetchRemoteSecretNames( return []; } } + +// --------------------------------------------------------------------------- +// Self-host (`void deploy --backend cloudflare`) secrets preflight units. +// +// The managed platform path gates secrets via `assertEnvSchemaValid` (deploy.ts) +// against the platform's secret store. The self-host path bypasses the platform +// entirely, so it needs its own safety units over the REAL build artifact +// (`dist/ssr/wrangler.json` `vars`), defined further below: +// 1. `collectPlaintextVarWarningsFromVars` — WARN: the build bakes worker +// `vars` verbatim into the uploaded config (plaintext); report which of the +// emitted keys look like secrets so the caller can warn. +// 2. `deriveSelfHostEnvContextFromVars` + `dischargeSelfHostEnvContext` — GATE: +// the platform-free analogue of `assertEnvSchemaValid`, sourcing remote +// secret names from `wrangler secret list` instead of the platform API. +// Both consume ground truth (the emitted map), never a pre-build facsimile. +// --------------------------------------------------------------------------- + +/** One worker-vars key that looks like a secret, with why it was flagged. */ +export type PlaintextVarWarning = { + key: string; + /** + * `schema-secret` — the user's env.ts flags this key `.secret()` (explicit, + * always wins). `heuristic` — the key NAME matches the shared + * `isLikelySecretKey` credential heuristic (`KEY|TOKEN|SECRET|…`). + * `value-shape` — the key name looks benign but the VALUE is a + * credential-bearing connection URL ({@link isCredentialBearingUrlValue}). + * An explicit `.public()` does NOT suppress the heuristics here (it only means + * "not a schema secret"): the value still ships as PLAINTEXT in the worker + * config, so a secret-looking public key is still worth flagging. + */ + reason: 'schema-secret' | 'heuristic' | 'value-shape'; +}; + +/** + * A `scheme://user:password@host…` connection URL — the shape that carries a + * live credential in a key nothing in the NAME heuristic would catch. The + * motivating case is `DATABASE_URL`: `SECRET_KEY_RE` has no `URL`/`DATABASE` + * branch, and the shared `isLikelySecretValue` cannot help either — its + * `HIGH_ENTROPY_RE` character class excludes `@`, so a connection URL never + * matches it. Both halves of the userinfo must be non-empty, so a bare + * `postgres://host/db` or a password-less `https://user@host` is NOT flagged. + * + * Deliberately local to the DEPLOY-side warning rather than added to + * `shared/env-mask.ts`: that module is also the runtime/managed redaction + * heuristic, and widening it would change masking behaviour well outside this + * warning. + */ +const CREDENTIAL_URL_VALUE_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/; + +/** + * `true` when a var's VALUE is a connection URL carrying inline credentials. + * Inspecting the value is safe here: only the KEY name ever leaves + * {@link collectPlaintextVarWarningsFromVars}. + */ +function isCredentialBearingUrlValue(value: unknown): boolean { + return typeof value === 'string' && CREDENTIAL_URL_VALUE_RE.test(value); +} + +/** + * Result of {@link collectPlaintextVarWarningsFromVars}. Carries KEY NAMES ONLY — + * never values — so nothing here can leak a secret into logs, no matter how + * the caller formats it. + */ +export type PlaintextVarWarningsResult = { + /** Secret-looking keys the build will bake into worker `vars`, sorted by key. */ + secretKeys: Array; + /** Total number of keys the build will merge into worker `vars`. */ + totalVarKeys: number; +}; + +/** Minimal structural view of a Standard Schema validator's probe entrypoint. */ +type SchemaProbe = StandardSchemaV1['~standard']; + +/** + * The required-key set, derived independently of how any validator treats + * the empty string. A key is required iff its validator REJECTS the ABSENT + * value (probe with `undefined`) — the SAME notion of "required" the managed + * gate uses (a key lands in `validateAllEnv().missing` when its absent value + * yields issues; `_getSchemaDefaults` probes `undefined` the exact same way). + * Requiredness is NEVER inferred from whether `""` validates, so a schema that + * ACCEPTS `""` (e.g. a bare Zod `z.string()`) is still correctly required. + * + * Runs the user's validators (arbitrary code), so callers MUST invoke this + * inside {@link withSchemaBoundary}: a validator that THROWS synchronously or + * REJECTS (async) on the probe surfaces as a values-free structural error, and + * the awaited async probe can never float as an unhandled rejection carrying a + * value. A NORMAL rejection (an `{ issues }` Result, not a thrown/rejected + * error) simply marks the key required. + */ +async function requiredSchemaKeys(schema: Record): Promise> { + const required = new Set(); + for (const [key, validator] of Object.entries(schema)) { + const probe = (validator as { '~standard': SchemaProbe })['~standard']; + const resultMaybe = probe.validate(undefined); + const result = resultMaybe instanceof Promise ? await resultMaybe : resultMaybe; + if ((result as { issues?: unknown }).issues) { + required.add(key); + } + } + return required; +} + +/** + * The values-free provenance of a var's WINNING value. The self-host gate reads + * ground truth — the REAL worker `vars` the build emitted into + * `dist/ssr/wrangler.json` — so every present key's source is `emitted`. Only the + * KIND is ever surfaced (never a value), so a configured-empty error can name the + * source without echoing anything sensitive. + */ +export type VarSource = { kind: 'emitted' }; + +/** + * Result of one `wrangler secret list` invocation. Same structured-provenance + * contract as the provisioner's `RunWranglerResult` (cli/provision.ts): a + * non-null `signal` or a present `error` means the child did NOT run to + * completion, so its buffered output is unreliable and must NEVER be read as + * "no secrets exist" — only `status === 0` with clean provenance may be parsed. + */ +export type SecretListRunResult = { + stdout: string; + stderr: string; + status: number; + /** Non-null ONLY when the child was terminated by a signal — a HARD failure. */ + signal?: NodeJS.Signals | null; + /** The spawn-error message when the child could not start — a HARD failure. */ + error?: string | null; +}; + +/** + * Injectable seam over spawning `wrangler secret list` — same pattern as the + * provisioner's `RunWrangler`. The default ({@link createDefaultSecretListRunner}) + * spawns the real wrangler CLI; unit tests inject a stub so no real wrangler + * and no network runs. Sync stubs are fine — the caller `Promise.resolve`s. + */ +export type RunSecretList = ( + args: Array, +) => SecretListRunResult | Promise; + +/** + * The stable, values-free classification of a hard secret-list failure. + * The child inherits `process.env`, so a wrangler/OS/config diagnostic can + * embed an env value; the raw rejection/stderr/stdout text is therefore used + * only INTERNALLY (worker-not-found classification) and NEVER copied into a + * thrown error. Every {@link SelfHostSecretListError} instead carries one of + * these codes plus safe scalars (exit code / signal name), so neither the + * message, the attached fields, nor the JSON serialization can echo a value. + * - `spawn-error` — the child could not be launched (runner threw, or + * reported a spawn `error`). + * - `nonzero-exit` — clean nonzero exit (wrangler's own error). + * - `signal` — terminated by a signal (includes the timeout SIGKILL). + * - `malformed-output` — exit 0 but stdout was not the expected JSON array of + * `{ name }` secret objects. + * - `account-mismatch` — config-vs-requested account gate (thrown before any + * spawn; its message names the account IDs, which are + * caller/config identifiers, not wrangler output). + * - `name-mismatch` — config-vs-pinned worker-name gate (thrown before any + * spawn; its message names the worker names, which are + * caller/config identifiers, not wrangler output). + */ +export type SecretListFailureReason = + | 'spawn-error' + | 'nonzero-exit' + | 'signal' + | 'malformed-output' + | 'account-mismatch' + | 'name-mismatch'; + +/** Wrangler-run failure reasons (excludes the pre-spawn account/name gates). */ +type SecretListRunFailureReason = Exclude< + SecretListFailureReason, + 'account-mismatch' | 'name-mismatch' +>; + +/** Safe scalars attached to a {@link SelfHostSecretListError} — never wrangler output. */ +export type SecretListFailureDetail = { + /** The child's exit code, when it exited (nonzero-exit). */ + exitCode?: number; + /** The terminating signal name, when killed (signal). */ + signal?: string; +}; + +/** + * Hard failure fetching the remote secret list (spawn error, signal, nonzero + * exit, timeout, or malformed output). DISTINCT from + * {@link SelfHostEnvValidationError}: this means "we could not learn what + * secrets exist" (fail closed), never "a required secret is missing". + * + * The message and attached fields are values-free by construction: only + * the structural {@link SecretListFailureReason} code and safe scalars + * ({@link SecretListFailureDetail}) are exposed. Raw wrangler output (stderr, + * stdout, rejection/spawn-error text) is NEVER stored here, so no env value the + * child may have echoed can reach `Error.message`, the `reason`/`detail` + * fields, or their JSON serialization — even under `VOID_ENV_UNMASK`. + */ +export class SelfHostSecretListError extends Error { + override name = 'SelfHostSecretListError'; + /** Stable, values-free failure classification. */ + readonly reason: SecretListFailureReason; + /** Safe scalars (exit code / signal name) — never contain wrangler output. */ + readonly detail: SecretListFailureDetail; + // Explicit fields (not TS parameter properties) so this module type-strips under + // plain Node — the build-context probe imports it in a subprocess. + constructor( + message: string, + reason: SecretListFailureReason, + detail: SecretListFailureDetail = {}, + ) { + super(message); + this.reason = reason; + this.detail = detail; + } +} + +/** + * FIRST-DEPLOY specialization of {@link SelfHostSecretListError}: the remote + * secret list could not be fetched because the worker has NOT been deployed + * yet. wrangler 4.107's `secret list` catches the API worker-not-found error + * (codes 10007 / 10090 — `WORKER_NOT_FOUND` / `WORKER_LEGACY_ENVIRONMENT_NOT_FOUND`, + * per `isWorkerNotFoundError` in wrangler-dist/cli.js) and rethrows a `UserError` + * ("… not found. If this is a new Worker, run `wrangler deploy` first to create + * it."), exiting nonzero. Rather than surface that circular "deploy first" dead + * end, this carries the still-unsatisfied required key NAMES plus a + * worker-not-found flag so the deploy orchestrator can render actionable + * `wrangler secret put ` bootstrap guidance (wrangler can `secret put` to + * a draft worker). Still a HARD, fail-closed error — subclassing keeps existing + * `instanceof SelfHostSecretListError` fail-closed handling intact. + */ +export class SelfHostFirstDeployError extends SelfHostSecretListError { + override name = 'SelfHostFirstDeployError'; + /** Always true — distinguishes this from a generic list failure structurally. */ + readonly workerNotFound = true; + /** Required schema key NAMES still unsatisfied locally (values-free). */ + readonly missingKeys: Array; + constructor(message: string, missingKeys: Array, exitCode?: number) { + // Structurally still a nonzero-exit; `message` is the values-free + // first-deploy guidance and `missingKeys` carries the names. + super(message, 'nonzero-exit', { exitCode }); + this.missingKeys = missingKeys; + } +} + +/** + * The env schema is not satisfied for a self-host production deploy: required + * keys are missing (not in `.env`/`.env.production` and not present as remote + * worker secrets), local values are invalid, or an EMPTY local assignment would + * shadow a remote secret. The message and the attached `report` carry the + * offending key NAMES plus stable, values-free reason codes only — never raw + * input, never resolved values — so nothing on this path can echo a secret + * (see {@link selfHostEnvError}), even under `VOID_ENV_UNMASK`. + */ +export class SelfHostEnvValidationError extends Error { + override name = 'SelfHostEnvValidationError'; + readonly report: EnvValidationReport; + constructor(message: string, report: EnvValidationReport) { + super(message); + this.report = report; + } +} + +/** + * The stable, values-free classification of a self-host schema failure. + * env.ts evaluation and Standard-Schema validators run ARBITRARY user code that + * can embed an env value in a thrown/rejected error (its `message`, `stack`, or + * `cause`), and `VOID_ENV_UNMASK` disables redaction — so the original error is + * never surfaced. Every {@link SelfHostSchemaError} instead carries only one of + * these codes plus fixed text, so neither its message, its `reason` field, nor + * its JSON serialization can echo a value. + * - `schema-load-failed` — importing/evaluating the user's env.ts threw. + * - `schema-validation-error` — a registered validator threw or rejected while + * checking a production value. + */ +export type SelfHostSchemaFailureReason = 'schema-load-failed' | 'schema-validation-error'; + +/** + * A self-host schema evaluation/validation step threw. DISTINCT from + * {@link SelfHostEnvValidationError} (which means "a required value is missing + * or invalid", carrying key names): this means the schema machinery itself + * raised — env.ts failed to evaluate, or a validator threw/rejected rather than + * returning issues. Values-free by construction: the original error's + * message/stack/cause are DROPPED at the boundary and only the structural + * {@link SelfHostSchemaFailureReason} code + fixed guidance survive, so no env + * value the user's code may have embedded can reach the thrown error, even + * under `VOID_ENV_UNMASK`. + */ +export class SelfHostSchemaError extends Error { + override name = 'SelfHostSchemaError'; + /** Stable, values-free failure classification. */ + readonly reason: SelfHostSchemaFailureReason; + constructor(message: string, reason: SelfHostSchemaFailureReason) { + super(message); + this.reason = reason; + } +} + +/** The fixed, values-free message for each schema-failure reason. */ +export function selfHostSchemaFailureMessage(reason: SelfHostSchemaFailureReason): string { + switch (reason) { + case 'schema-load-failed': + return ( + 'env: the project env schema could not be loaded (reason: schema-load-failed). ' + + 'Check that env.ts evaluates without error, then run the deploy again.' + ); + case 'schema-validation-error': + return ( + 'env: an env schema validator raised an error while checking production values ' + + '(reason: schema-validation-error). Re-run the validator locally to see its own diagnostics.' + ); + } +} + +/** + * Values-free boundary around EVERY self-host schema evaluation + + * validation step. `loadSchema` evaluates the user's env.ts and each + * `validateProdEnv` / `validateAllEnv` pass runs the registered validators — + * all arbitrary code that can throw/reject an error EMBEDDING an env value in + * its message, stack, or cause (and `VOID_ENV_UNMASK` disables redaction). Any + * throw/reject from `fn` is caught, its original message/stack/cause DROPPED, + * and a fixed structural {@link SelfHostSchemaError} rethrown in its place. The + * managed `validateProdEnv` itself is NEVER wrapped — only the self-host CALL + * SITES pass through here, so the managed path is byte-identical. + */ +async function withSchemaBoundary( + reason: SelfHostSchemaFailureReason, + fn: () => Promise, +): Promise { + try { + return await fn(); + } catch { + // Drop the caught error entirely (message/stack/cause) — it may carry a + // value — and rethrow only the structural, values-free classification. + throw new SelfHostSchemaError(selfHostSchemaFailureMessage(reason), reason); + } +} + +/** + * The structured, VALUES-FREE outcome of the self-host env DERIVATION — everything + * decidable from the effective build var map + schema WITHOUT a network round-trip, + * so the derivation can run in the isolated build-context subprocess (under the + * scrubbed build env, where the user's env.ts + `loadEnv` never see the parent's + * credentials) and the parent discharges it with only pure work + one `wrangler + * secret list`. Carries KEY NAMES + source kinds/filenames + the fixed invalid + * reason ONLY — never a value — so it crosses the process boundary as safe JSON. + */ +export type SelfHostEnvContext = { + /** Whether the project registered an env schema at all (no schema → nothing to gate). */ + hasSchema: boolean; + /** + * Required keys the effective build map assigns an EMPTY string (`K=""`). The build + * bakes these as plaintext `vars` that win over a same-named remote secret, so they + * FAIL CLOSED unconditionally — the parent throws before any wrangler spawn. + */ + presentEmptyRequired: Array; + /** Per-empty-key WINNING source (kind + filename) so the parent names it — never a value. */ + presentEmptySources: Record; + /** Required keys the effective build map does NOT set — the only state a remote secret discharges. */ + absentRequired: Array; + /** Present-non-empty keys the schema REJECTED — malformed, never dischargeable by a secret. */ + invalidKeys: Array; +}; + +export type SelfHostProdEnvResult = { + hasSchema: boolean; + /** + * Remote secret names consulted via `wrangler secret list`, or `null` when + * wrangler was never invoked (no schema, or the effective build map already + * satisfied every required key present-and-non-empty). + */ + remoteSecretNames: Array | null; +}; + +/** A no-schema {@link SelfHostEnvContext} — nothing to gate. */ +function emptyEnvContext(): SelfHostEnvContext { + return { + hasSchema: false, + presentEmptyRequired: [], + presentEmptySources: {}, + absentRequired: [], + invalidKeys: [], + }; +} + +// --------------------------------------------------------------------------- +// Post-build ground-truth self-host units. +// +// The redesigned `void deploy --backend cloudflare` gate validates the REAL +// build artifact the build emits — `dist/ssr/wrangler.json` `vars` (the plaintext +// worker vars) — instead of a pre-build `loadEnv` facsimile. It needs only ONE +// piece of project-derived data: the env-INSENSITIVE schema facts (required key +// names + `.secret()` flags), which do NOT depend on which `.env` file the build +// reads. Those are derived once in an isolated subprocess; everything else is pure +// parent work over the emitted map. +// --------------------------------------------------------------------------- + +/** + * The env-INSENSITIVE structural facts the self-host secret gate needs from the + * project's env schema: whether a schema is registered, the REQUIRED key names (a + * validator that rejects the ABSENT value — {@link requiredSchemaKeys}), and the + * keys the schema flags `.secret()` ({@link getSecretOverride} === 'secret'). + * NONE of these depend on which `.env` file the build reads — they are fixed by + * env.ts's structure — so this needs no `loadEnv` and no build env, and the result + * is the SAME regardless of the build's mode/envDir. That is precisely why the old + * probe-vs-build divergence class is gone: nothing derived here can diverge from + * the build over which `.env*` set was loaded. + */ +export type SelfHostSchemaFacts = { + /** Whether the project registered an env schema at all (no schema → nothing to gate). */ + hasSchema: boolean; + /** Required key NAMES — validator rejects `undefined`. Values-free. */ + requiredKeys: Array; + /** Keys the schema flags `.secret()`. Values-free. */ + secretKeys: Array; +}; + +/** + * Derive the {@link SelfHostSchemaFacts} by importing the user's env.ts. Runs + * ARBITRARY project code (env.ts evaluation) + the registered validators' probes, + * so the caller MUST run this in an ISOLATED SUBPROCESS ({@link + * ../cli/env-schema-probe.ts}) under a scrubbed env. Throws only a values-free + * {@link SelfHostSchemaError} (via {@link withSchemaBoundary}) when the schema + * machinery itself fails; the derived names/flags are returned as values-free data. + */ +export async function deriveSelfHostSchemaFacts(root: string): Promise { + const envFile = findEnvFile(root); + if (envFile == null) { + return { hasSchema: false, requiredKeys: [], secretKeys: [] }; + } + const mod = await withSchemaBoundary('schema-load-failed', () => loadSchema(envFile)); + if (!mod) { + return { hasSchema: false, requiredKeys: [], secretKeys: [] }; + } + const schema = mod._getRegisteredSchema() ?? {}; + const requiredKeys = await withSchemaBoundary('schema-validation-error', () => + requiredSchemaKeys(schema), + ); + // `.secret()` is a STATIC flag on the validator (getSecretOverride) — no value + // read, no env dependency. + const secretKeys = Object.keys(schema).filter( + (key) => getSecretOverride(schema[key]) === 'secret', + ); + return { hasSchema: true, requiredKeys: [...requiredKeys], secretKeys }; +} + +/** + * Build the values-free {@link SelfHostEnvContext} from the REAL worker `vars` the + * build emitted into `dist/ssr/wrangler.json` (ground truth — never a facsimile) + * plus the env-insensitive {@link SelfHostSchemaFacts}. PURE (no project code, no + * I/O): the parent runs this AFTER the build with the real emitted map, so the + * gate reads exactly what the worker ships. + * - a required key present with an EMPTY value (`K=""` or `K=null`) → SHADOWS a + * same-named remote secret with an unusable value → fail closed ALWAYS + * (`presentEmptyRequired`). Both spellings are treated the same because the + * schema validators classify `null` and `""` as "missing" (not "invalid"), so + * they would otherwise slip past both the invalid-value probe and the absent + * fence while still shadowing the remote secret. + * - a required key ABSENT from the emitted vars → the only state a remote secret + * can discharge (`absentRequired`). + * `invalidKeys` is populated separately from the value-validation probe (the parent + * folds it in); a PRESENT, NON-EMPTY value that fails the schema validator lands there. + */ +export function deriveSelfHostEnvContextFromVars( + emittedVars: Record, + facts: SelfHostSchemaFacts, +): SelfHostEnvContext { + if (!facts.hasSchema) { + return emptyEnvContext(); + } + // `null` and `""` are the two "present but unusable" shapes a parsed wrangler.json var can + // take that the schema validators bucket as "missing" — treat both as an empty shadow. + const isEmptyShadow = (value: unknown): boolean => value === '' || value === null; + const presentEmptyRequired = facts.requiredKeys.filter( + (key) => Object.hasOwn(emittedVars, key) && isEmptyShadow(emittedVars[key]), + ); + const absentRequired = facts.requiredKeys.filter((key) => !Object.hasOwn(emittedVars, key)); + const presentEmptySources: Record = {}; + for (const key of presentEmptyRequired) { + presentEmptySources[key] = { kind: 'emitted' }; + } + return { + hasSchema: true, + presentEmptyRequired, + presentEmptySources, + absentRequired, + invalidKeys: [], + }; +} + +/** The REAL worker `vars` the build baked into `dist/ssr/wrangler.json`, or a fail-closed reason. */ +export type EmittedWorkerVarsResult = + | { ok: true; vars: Record } + | { ok: false; reason: string }; + +/** + * Follow the CF plugin's deploy pointer (`.wrangler/deploy/config.json` → + * `configPath`) to the generated wrangler config the build emitted, and return it + * parsed. Shared by the parent's ground-truth reads and the isolated + * `validate-emitted` probe child, so both resolve the SAME artifact. Returns a + * fail-closed reason (never throws) when the pointer/config is missing or malformed. + */ +function readGeneratedWranglerConfig( + root: string, +): { ok: true; config: Record } | { ok: false; reason: string } { + const deployConfigPath = join(root, '.wrangler', 'deploy', 'config.json'); + if (!existsSync(deployConfigPath)) { + return { + ok: false, + reason: `the CF plugin's deploy pointer at ${deployConfigPath} was not written by the build.`, + }; + } + let redirectedPath: string; + try { + const parsed = JSON.parse(readFileSync(deployConfigPath, 'utf-8')) as { configPath?: unknown }; + if (typeof parsed.configPath !== 'string' || parsed.configPath.length === 0) { + return { ok: false, reason: `${deployConfigPath} has no "configPath" pointer.` }; + } + redirectedPath = resolve(dirname(deployConfigPath), parsed.configPath); + } catch { + return { ok: false, reason: `${deployConfigPath} is not valid JSON.` }; + } + if (!existsSync(redirectedPath)) { + return { ok: false, reason: `generated wrangler config ${redirectedPath} does not exist.` }; + } + try { + const config = JSON.parse(readFileSync(redirectedPath, 'utf-8')) as Record; + return { ok: true, config }; + } catch { + return { ok: false, reason: `generated wrangler config ${redirectedPath} is not valid JSON.` }; + } +} + +/** + * Read the REAL `vars` map the build baked into `dist/ssr/wrangler.json` (ground + * truth — the exact plaintext the worker ships). A missing `vars` field is an empty + * map (a worker with no vars), not a failure; a missing/malformed artifact fails + * closed. The self-host orchestrator reads this in the parent; the `validate-emitted` + * probe reads it in the child to run the schema validators against those exact + * values in isolation. + */ +export function readEmittedWorkerVars(root: string): EmittedWorkerVarsResult { + const resolved = readGeneratedWranglerConfig(root); + if (!resolved.ok) { + return resolved; + } + const vars = resolved.config.vars; + // An ABSENT `vars` field is a worker with no vars → empty map (not a failure). But a + // PRESENT-but-malformed `vars` (a scalar, null, or array) must FAIL CLOSED rather than be + // silently normalized to `{}`: normalizing would make every required key read as "absent" + // and let a remote secret discharge it, hiding a genuinely broken build artifact. + if (vars === undefined) { + return { ok: true, vars: {} }; + } + if (vars === null || typeof vars !== 'object' || Array.isArray(vars)) { + return { + ok: false, + reason: 'the generated wrangler config has a malformed "vars" field (expected an object).', + }; + } + return { ok: true, vars: vars as Record }; +} + +/** + * Validate the REAL emitted worker `vars` against the registered schema's + * validators and return the FAILING key NAMES only (values-free). A key FAILS iff + * the validator REJECTS the value it was SHIPPED WITH — the UNION of: + * - `report.invalid` (a PRESENT, NON-EMPTY value the validator rejected), plus + * - the `report.missing` keys that are STILL PRESENT in `emittedVars` (the key + * exists in the emitted map with value `null`/`""`). `validateAllEnv` buckets a + * REJECTED `undefined`/`null`/`""` as "missing", not "invalid" (see + * `runtime/env-public.ts` — `isMissing = raw === undefined || raw === null || raw + * === ''`), so an OPTIONAL-with-validator key shipped as `""`/`null` (e.g. + * `z.url().optional()`, which rejects those but the required-empty fence in + * `deriveSelfHostEnvContextFromVars` — `requiredKeys` only — never covers) would + * otherwise slip EVERY gate and boot-reject the deployed worker. + * A `report.missing` key ABSENT from `emittedVars` is NEVER counted: it is a + * genuinely-unset REQUIRED secret that a same-named remote `wrangler secret list` + * secret can still discharge, so it stays with the context's absent fence rather than + * becoming a hard invalid. Runs the user's validators (arbitrary code), so the caller + * MUST run this in the isolated {@link ../cli/env-schema-probe.ts} subprocess; a + * validator that THROWS/REJECTS surfaces as a values-free {@link SelfHostSchemaError} + * via {@link withSchemaBoundary}. NEVER reads `report.invalid[].messages` (which can + * embed the offending value) — only the key names leave here. + */ +export async function computeInvalidEmittedKeys( + root: string, + emittedVars: Record, +): Promise> { + const envFile = findEnvFile(root); + if (envFile == null) { + return []; + } + const mod = await withSchemaBoundary('schema-load-failed', () => loadSchema(envFile)); + if (!mod) { + return []; + } + const report = await withSchemaBoundary('schema-validation-error', () => + mod.validateAllEnv(emittedVars), + ); + const invalid = new Set(report.invalid.map((entry) => entry.key)); + // A rejected value the runtime bucketed as "missing" still fails closed WHEN THE KEY + // WAS SHIPPED (present in the emitted map) — otherwise it is a genuinely-absent + // required secret a remote secret can still discharge, so leave it to the fences. + for (const key of report.missing) { + if (Object.hasOwn(emittedVars, key)) { + invalid.add(key); + } + } + return Array.from(invalid).sort((a, b) => a.localeCompare(b)); +} + +/** + * Classify the secret-looking keys among the REAL worker `vars` the build baked + * into `dist/ssr/wrangler.json` (ground truth). PURE: for each emitted var key, a + * `.secret()`-flagged key ({@link SelfHostSchemaFacts.secretKeys}) is + * `schema-secret`; otherwise the shared {@link isLikelySecretKey} name heuristic + * applies (so a secret-looking key ships plaintext regardless of any schema, and + * heuristic classification still works with no env.ts at all); failing both, a + * VALUE shaped like a credential-bearing connection URL + * ({@link isCredentialBearingUrlValue}) flags the key as `value-shape`, catching + * the `DATABASE_URL`-class leak no name heuristic sees. + * + * KEYS ONLY leave here — a value may be INSPECTED to classify it, but is never + * copied into the result, so no caller can format a value into a log. + */ +export function collectPlaintextVarWarningsFromVars( + emittedVars: Record, + secretFlaggedKeys: Array, +): PlaintextVarWarningsResult { + const flagged = new Set(secretFlaggedKeys); + const keys = Object.keys(emittedVars); + const secretKeys: Array = []; + for (const key of keys) { + if (flagged.has(key)) { + secretKeys.push({ key, reason: 'schema-secret' }); + continue; + } + if (isLikelySecretKey(key)) { + secretKeys.push({ key, reason: 'heuristic' }); + continue; + } + if (isCredentialBearingUrlValue(emittedVars[key])) { + secretKeys.push({ key, reason: 'value-shape' }); + } + } + secretKeys.sort((a, b) => a.key.localeCompare(b.key)); + return { secretKeys, totalVarKeys: keys.length }; +} + +/** Build a values-free invalid {@link EnvValidationReport} from key names only. */ +function invalidEnvReport(missing: Array, invalidKeys: Array): EnvValidationReport { + return { + valid: false, + missing, + invalid: invalidKeys.map((key) => ({ key, messages: [SELF_HOST_INVALID_REASON] })), + values: new Map(), + }; +} + +/** + * DISCHARGE half of the self-host gate — PURE parent work over the values-free + * {@link SelfHostEnvContext} the subprocess derived, plus at most one `wrangler + * secret list`. Runs NO project code, NO `loadEnv`, and imports NO env.ts, so the + * parent never evaluates arbitrary code with live credentials in scope. Applies + * the three fences unchanged: + * - a PRESENT-EMPTY required key fails closed ALWAYS, before any wrangler spawn; + * - the pinned worker `name` is reconciled against the config's `name`, and the + * target account against the config's `account_id`, BOTH BEFORE `secret list` + * is spawned; + * - an ABSENT required key is discharged only by a same-named remote secret, + * else fails closed ({@link SelfHostFirstDeployError} on a not-yet-deployed + * worker, carrying the missing names + `wrangler secret put` guidance). + */ +export async function dischargeSelfHostEnvContext( + context: SelfHostEnvContext, + options: { + root: string; + configPath: string; + accountId?: string; + /** + * The worker name the deploy pinned (deploy.ts step 5). `wrangler secret list` + * resolves the worker from the config's top-level `name`, which a post-build + * probe can rewrite — so the config's `name` is reconciled against this before + * any spawn, mirroring the `account_id` reconcile. + */ + workerName?: string; + runWrangler?: RunSecretList; + timeoutMs?: number; + }, +): Promise { + if (!context.hasSchema) { + return { hasSchema: false, remoteSecretNames: null }; + } + + // PRESENT-and-EMPTY required keys fail closed ALWAYS — remote membership is + // irrelevant. Names the key + its actual source, never a value. + if (context.presentEmptyRequired.length > 0) { + throw emptyAssignmentError(context.presentEmptyRequired, context.presentEmptySources); + } + + const invalidKeys = context.invalidKeys; + if (context.absentRequired.length === 0) { + // Every required key is present-and-non-empty; a remote secret could not + // change the outcome — no wrangler call. Invalid local values still fail closed. + if (invalidKeys.length > 0) { + throw selfHostEnvError(invalidEnvReport([], invalidKeys)); + } + return { hasSchema: true, remoteSecretNames: null }; + } + + // Reconcile the pinned worker NAME against the config BEFORE any wrangler spawn: + // `wrangler secret list` resolves the worker from the config's `name`, which a + // post-build probe can rewrite, so a config `name` that no longer matches the + // pinned one fails closed here (mirrors the account reconcile below). + reconcileSelfHostWorkerName(options.configPath, options.workerName); + + // Reconcile the effective account BEFORE any wrangler spawn (config `account_id` + // wins over CLOUDFLARE_ACCOUNT_ID, so a mismatch could list a different account). + const effectiveAccountId = reconcileSelfHostAccount(options.configPath, options.accountId); + + const run = + options.runWrangler ?? + createDefaultSecretListRunner(options.root, { + accountId: effectiveAccountId, + timeoutMs: options.timeoutMs, + }); + const remoteSecretNames = await fetchSelfHostSecretNames( + run, + options.configPath, + context.absentRequired, + ); + + const remoteSet = new Set(remoteSecretNames); + const stillMissing = context.absentRequired.filter((key) => !remoteSet.has(key)); + if (stillMissing.length > 0 || invalidKeys.length > 0) { + throw selfHostEnvError(invalidEnvReport(stillMissing, invalidKeys)); + } + return { hasSchema: true, remoteSecretNames }; +} + +/** + * Read the pinned config's top-level `account_id` and reconcile it with + * the caller's requested account, mirroring the provisioner's account gate + * (provision.ts `provisionCloudflareResourcesLocked`). wrangler 4.107 gives the + * config's `account_id` PRECEDENCE over `CLOUDFLARE_ACCOUNT_ID`, so a real + * config-vs-requested mismatch is a fail-closed error (thrown BEFORE any spawn). + * Returns the ONE effective account to pin (config wins, else requested), or + * undefined when neither is set (wrangler then resolves it as it normally would). + */ +function reconcileSelfHostAccount( + configPath: string, + requestedAccountId: string | undefined, +): string | undefined { + const configAccountId = readConfigAccountId(configPath); + if (configAccountId && requestedAccountId && configAccountId !== requestedAccountId) { + throw new SelfHostSecretListError( + `env: Cloudflare account mismatch — "${configPath}" pins account_id ` + + `${JSON.stringify(configAccountId)} but the deploy targets ` + + `${JSON.stringify(requestedAccountId)}. wrangler resolves the config's account_id before ` + + `CLOUDFLARE_ACCOUNT_ID, so \`wrangler secret list\` would consult ` + + `${JSON.stringify(configAccountId)} while the deploy expects ` + + `${JSON.stringify(requestedAccountId)} — a same-named secret there could falsely pass this ` + + `preflight. Reconcile them (correct or remove the config's account_id, or deploy to the ` + + `pinned account) and retry.`, + 'account-mismatch', + ); + } + return configAccountId ?? requestedAccountId; +} + +/** + * Reconcile the config's top-level worker `name` against the name the deploy PINNED + * (deploy.ts step 5 writes `name: workerName` into the root config before the build). + * `wrangler secret list` resolves the worker from the config's `name` + * (`getLegacyScriptName`: `args.name ?? config.name` — we pass no `--name`), so a + * config whose `name` was CHANGED after it was pinned would make the gate list secrets + * for a DIFFERENT worker than the deploy ships — and a same-named secret there could + * falsely pass this preflight while the pinned worker deploys WITHOUT it. + * + * Fires ONLY when the config actually declares a `name` that DIFFERS from a passed + * pinned name (the same "both set and differ" guard as {@link reconcileSelfHostAccount}) + * — post-pin the config's `name` IS `workerName`, so a later divergence is exactly the + * laundering signal, while an undefined pinned name or an undeclared config `name` never + * trips a legitimate deploy. Thrown BEFORE any spawn (its message names worker names, + * which are caller/config identifiers, never wrangler output). + */ +function reconcileSelfHostWorkerName( + configPath: string, + pinnedWorkerName: string | undefined, +): void { + const configName = readConfigName(configPath); + if (configName && pinnedWorkerName && configName !== pinnedWorkerName) { + throw new SelfHostSecretListError( + `env: Cloudflare worker name mismatch — "${configPath}" declares name ` + + `${JSON.stringify(configName)} but the deploy pinned ${JSON.stringify(pinnedWorkerName)}. ` + + `wrangler resolves the worker name from the config's \`name\`, so \`wrangler secret list\` ` + + `would consult ${JSON.stringify(configName)} while the deploy ships ` + + `${JSON.stringify(pinnedWorkerName)} — a same-named secret there could falsely pass this ` + + `preflight. The config's name was changed after it was pinned; rebuild from a clean ` + + `checkout, then rerun.`, + 'name-mismatch', + ); + } +} + +/** UTF-8 BOM, stripped before JSONC parsing (matches provision.ts readRootConfig). */ +const UTF8_BOM = ''; + +/** True when `value` is a non-array object (JSONC-parsed config record). */ +function isConfigRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Parse the resolved root wrangler config (`-c` target) into a top-level record, + * mirroring the provisioner's `readRootConfig` (jsonc-parser, BOM-stripped, + * trailing commas allowed). We read the SAME file the deploy pins via `-c` and + * force top-level via `--env=`, so its top-level fields are exactly what + * wrangler's `secret list` and the build both resolve. An unreadable/malformed + * config yields undefined — callers then fall back safely (no account gate / no + * root-wrangler var layer), and wrangler itself later fails on a truly broken + * config with a distinct fail-closed {@link SelfHostSecretListError}. + */ +function parseRootConfig(configPath: string): Record | undefined { + if (!existsSync(configPath)) { + return undefined; + } + let raw: string; + try { + raw = readFileSync(configPath, 'utf-8'); + } catch { + return undefined; + } + const content = raw.startsWith(UTF8_BOM) ? raw.slice(UTF8_BOM.length) : raw; + const errors: Array = []; + const parsed = parseJsonc(content, errors, { allowTrailingComma: true }); + if (errors.length > 0 || !isConfigRecord(parsed)) { + return undefined; + } + return parsed; +} + +/** + * Read the top-level `account_id` string from the resolved wrangler config + * (`-c` target). Same file/parse as {@link parseRootConfig}; this is exactly the + * account wrangler's `secret list` will resolve. An unreadable/malformed config + * yields undefined (no reconciliation gate). + */ +function readConfigAccountId(configPath: string): string | undefined { + const parsed = parseRootConfig(configPath); + if (!parsed) { + return undefined; + } + const accountId = parsed.account_id; + return typeof accountId === 'string' && accountId.length > 0 ? accountId : undefined; +} + +/** + * Read the top-level `name` string from the resolved wrangler config (`-c` target). + * Same file/parse as {@link parseRootConfig}; this is exactly the worker name + * wrangler's `secret list` will resolve. An unreadable/malformed config or an + * absent/empty `name` yields undefined (no reconciliation gate). + */ +function readConfigName(configPath: string): string | undefined { + const parsed = parseRootConfig(configPath); + if (!parsed) { + return undefined; + } + const name = parsed.name; + return typeof name === 'string' && name.length > 0 ? name : undefined; +} + +/** + * Stable, values-free reason code stamped on every invalid key on the + * self-host path. The raw validator message (e.g. `Expected a number, got + * "not-a-number"`) can EMBED the offending input, and redaction preserves + * benign values / can be disabled via `VOID_ENV_UNMASK`, so the message text is + * never surfaced here — only the key name and this fixed code. + */ +const SELF_HOST_INVALID_REASON = 'failed schema validation'; + +/** + * Build the hard error for an unsatisfied schema from KEY NAMES + the + * stable {@link SELF_HOST_INVALID_REASON} code ONLY. It never reads + * `report.invalid[].messages` (which can embed raw input) and never resolved + * `report.values`, and it attaches a freshly-sanitized report of the same + * shape — so neither the thrown message, the attached report, nor its JSON + * serialization can echo a value, even with `VOID_ENV_UNMASK` set. + */ +function selfHostEnvError(report: EnvValidationReport): SelfHostEnvValidationError { + const sortNames = (names: Array): Array => + [...names].sort((a, b) => a.localeCompare(b)); + const missing = sortNames(report.missing); + const invalidKeys = sortNames(report.invalid.map((i) => i.key)); + + const lines: Array = []; + if (missing.length > 0) { + lines.push('Missing required env vars:'); + for (const key of missing) { + lines.push(` • ${key}`); + } + } + if (invalidKeys.length > 0) { + if (lines.length > 0) { + lines.push(''); + } + lines.push('Invalid env vars:'); + for (const key of invalidKeys) { + lines.push(` • ${key} (${SELF_HOST_INVALID_REASON})`); + } + } + if (missing.length > 0) { + lines.push( + '', + 'Set each missing production secret with `wrangler secret put `, ' + + 'or add a value to .env / .env.production.', + ); + } + // Sanitized report: key names + the fixed reason code, no messages, no values. + const safeReport: EnvValidationReport = { + valid: report.valid, + missing: report.missing, + invalid: report.invalid.map((i) => ({ key: i.key, messages: [SELF_HOST_INVALID_REASON] })), + values: new Map(), + }; + return new SelfHostEnvValidationError(lines.join('\n'), safeReport); +} + +/** + * The values-free description of a var's WINNING source, used to name the + * ACTUAL origin of a configured-empty key in the remediation. Only the source + * KIND is ever surfaced — never a value. The self-host gate reads ground truth + * (`dist/ssr/wrangler.json` `vars`), so every present key's source is `emitted`. + */ +function describeVarSource(source: VarSource | undefined): string { + switch (source?.kind) { + case 'emitted': + return 'the built worker config (`dist/ssr/wrangler.json` `vars`)'; + default: + // Unreachable — a present key always has a recorded source. Fall back to a + // generic, values-free phrasing rather than naming a wrong source. + return 'your build env configuration'; + } +} + +/** + * The hard error for a REQUIRED key set to an EMPTY value (`FOO=""`) in + * the effective build map. Values-free (key names + source KIND/filename only), + * thrown ALWAYS — independent of whether the key exists as a remote secret. The + * build bakes the empty string into plaintext `vars`, and wrangler 4.107 gives a + * config var precedence over a same-named remote secret (`checkRemoteSecretsOverride` + * in wrangler-dist/cli.js warns "… will replace these remote secrets with the + * configuration values"), so shipping this would ship an empty value for a + * required secret (overwriting a remote one if it exists). The fix is to REMOVE + * the empty assignment, not `secret put`. + * + * The empty value can come from ANY of the four build layers, so the + * remediation names each key's ACTUAL source (its {@link VarSource} provenance) + * — a dotenv file / `void.json` `worker.vars` / root `wrangler.jsonc` `vars` — + * instead of misdirecting the user to `.env` files. + */ +function emptyAssignmentError( + keys: Array, + sources: Record, +): SelfHostEnvValidationError { + const sorted = [...keys].sort((a, b) => a.localeCompare(b)); + const lines = [ + 'Empty value for a required env var:', + ...sorted.map((key) => ` • ${key} (from ${describeVarSource(sources[key])})`), + '', + 'These keys are REQUIRED by your env schema but assigned an EMPTY value in the source noted ' + + 'above. The build bakes the empty value into the worker config as a plaintext var, and ' + + 'Cloudflare gives a config var precedence over any same-named remote secret — so the deploy ' + + 'would ship an empty value for a required secret (overwriting a remote secret if one exists).', + '', + 'Remove the empty assignment (so a remote secret can take effect), or set a real value:', + ...sorted.map((key) => ` • ${key} — from ${describeVarSource(sources[key])}`), + ]; + const safeReport: EnvValidationReport = { + valid: false, + missing: sorted, + invalid: [], + values: new Map(), + }; + return new SelfHostEnvValidationError(lines.join('\n'), safeReport); +} + +/** + * Actionable, values-free bootstrap guidance for the FIRST-DEPLOY case: + * the worker does not exist yet, so its remote secrets can't be listed. Lists + * the still-unsatisfied required key NAMES and the `wrangler secret put ` + * command that bootstraps each on the draft worker (wrangler can `secret put` + * before a worker's first deploy). Key names only — no values. + */ +function firstDeployMessage(missingKeys: Array): string { + const sorted = [...missingKeys].sort((a, b) => a.localeCompare(b)); + const names = sorted.length > 0 ? sorted : ['']; + return [ + 'This Worker has not been deployed yet, so its remote secrets could not be listed.', + '', + 'Required production secrets still unset locally:', + ...sorted.map((key) => ` • ${key}`), + '', + 'Bootstrap them on the draft Worker before deploying:', + ...names.map((key) => ` wrangler secret put ${key}`), + '', + 'or add a value to .env / .env.production, then run the deploy again.', + ].join('\n'); +} + +/** + * The exact `wrangler secret list` argv. Verified against the wrangler 4.107.0 + * bundle (`wrangler-dist/cli.js`, `secretListCommand`): + * - `secret list` accepts `--format` with choices `json`/`pretty`; `json` is + * the default AND suppresses the banner (`printBanner` is format==='pretty'), + * printing `JSON.stringify(secrets, null, ' ')` — an array of secret + * objects (each with a string `name`, per `fetchSecrets` → + * `GET /accounts/{id}/workers/scripts/{name}/secrets`). Pinned explicitly + * so a future default change can't silently switch us to `pretty`. + * - the worker name resolves from the config's `name` (`getLegacyScriptName`: + * `args.name ?? config.name` — we pass no `--name`, the pinned config is + * the single source of truth). + * - `-c ` (global `--config` alias) pins the ONE resolved config; + * `--env=` (explicit EMPTY environment) forces the top-level config env so + * a project dotenv exporting `CLOUDFLARE_ENV` can never reselect a named + * `env.*` block — same rationale as the provisioner's wranglerConfigArgs. + */ +function secretListArgs(configPath: string): Array { + return ['secret', 'list', '--format=json', '-c', configPath, '--env=']; +} + +/** + * wrangler 4.107's `secret list` worker-not-found signal. When the worker has + * never been deployed, `fetchSecrets` throws the API worker-not-found error and + * `secretListCommand.handler` — ONLY when `isWorkerNotFoundError(e)` is true + * (`e.code === 10007` WORKER_NOT_FOUND or `10090` + * WORKER_LEGACY_ENVIRONMENT_NOT_FOUND, per wrangler-dist/cli.js) — rethrows a + * `UserError` with this exact fixed diagnostic block: + * + * Worker ""[ (env: )] not found. + * + * If this is a new Worker, run `wrangler deploy` first to create it. + * Otherwise, check that the Worker name is correct and you're logged into the right account. + * + * The API code itself is caught and never printed, so the child's stderr + * carries this MESSAGE, not `10007`/`10090`. Matching only the bare lead-in + * `If this is a new Worker` misclassifies any unrelated status-1 + * diagnostic that merely contains that fragment. So we require the COMPLETE + * structured block — the `… not found.` lead AND both fixed sentences of the + * guidance — after normalizing whitespace (wrangler may wrap/indent the + * multi-line UserError when it prints). The block carries no secret content, so + * it survives `WRANGLER_LOG_SANITIZE=true`; the combined output is inspected + * INTERNALLY only and never copied into any thrown error. + */ +function isWorkerNotFoundOutput(output: string): boolean { + // Collapse whitespace runs so wrapped/indented multi-line printing still + // matches the fixed sentences below. + const normalized = output.replace(/\s+/g, ' '); + return ( + /Worker ".*?"(?: \(env: [^)]*\))? not found\./.test(normalized) && + normalized.includes('If this is a new Worker, run `wrangler deploy` first to create it.') && + normalized.includes( + "Otherwise, check that the Worker name is correct and you're logged into the right account.", + ) + ); +} + +/** + * Build the THROWN message for a wrangler-run failure from the structural + * reason code + safe scalars ONLY. It NEVER interpolates `result.stderr`, + * `result.stdout`, a spawn-error string, or a rejection message — those are + * unconstrained (the child inherits `process.env`) and could embed an env + * value. Where wrangler's own diagnostics would help, the message tells the + * user to re-run wrangler themselves rather than echoing its output here. + */ +function secretListFailureMessage( + reason: SecretListRunFailureReason, + detail: SecretListFailureDetail, +): string { + const base = 'env: `wrangler secret list` could not confirm the remote secrets'; + switch (reason) { + case 'spawn-error': + return ( + `${base} (reason: spawn-error) — the wrangler process could not be started. ` + + 'Check that wrangler is installed and the pinned config path is valid.' + ); + case 'signal': + return ( + `${base} (reason: signal) — the process was terminated by ` + + `${detail.signal ?? 'a signal'} before completing.` + ); + case 'nonzero-exit': + return ( + `${base} (reason: nonzero-exit, status ${detail.exitCode ?? 'unknown'}). ` + + 'Re-run `wrangler secret list -c ` to see wrangler’s own diagnostics.' + ); + case 'malformed-output': + return ( + `${base} (reason: malformed-output) — \`wrangler secret list --format=json\` ` + + 'did not return the expected JSON array of secret objects.' + ); + } +} + +/** Construct a values-free {@link SelfHostSecretListError} for a run failure. */ +function secretListError( + reason: SecretListRunFailureReason, + detail: SecretListFailureDetail = {}, +): SelfHostSecretListError { + return new SelfHostSecretListError(secretListFailureMessage(reason, detail), reason, detail); +} + +/** + * Run `wrangler secret list` through the seam and parse STRICTLY, failing + * CLOSED: any signal, spawn error, nonzero exit, unparseable stdout, or + * malformed list member throws {@link SelfHostSecretListError}. A failure here + * is never interpreted as "no secrets exist" — that would let a deploy proceed + * with required secrets actually missing on the worker. + * + * A nonzero exit whose output carries wrangler's worker-not-found signal is a + * DISTINCT, structured {@link SelfHostFirstDeployError} carrying the still + * unsatisfied required `missingKeys` + `wrangler secret put` bootstrap guidance, + * instead of the generic "list exited nonzero" dead end. + * + * Raw child output (stderr/stdout, spawn-error text, rejection messages) + * is used ONLY internally: for worker-not-found classification the combined + * output is matched to a boolean that never leaves this function. Every THROWN + * error carries only a structural {@link SecretListFailureReason} code and safe + * scalars (exit code / signal name), so no env value the child may have echoed + * can reach the error's message, fields, or JSON — even under `VOID_ENV_UNMASK`. + */ +async function fetchSelfHostSecretNames( + run: RunSecretList, + configPath: string, + missingKeys: Array, +): Promise> { + let result: SecretListRunResult; + try { + result = await Promise.resolve(run(secretListArgs(configPath))); + } catch { + // The runner threw — the child produced no usable result. The rejection + // message is unconstrained (could embed a value), so it is DROPPED; only + // the structural code survives. + throw secretListError('spawn-error'); + } + if (result.error != null && result.error !== '') { + // Spawn-error string is unconstrained wrangler/OS text — dropped. + throw secretListError('spawn-error'); + } + if (result.signal != null) { + // Signal name is a safe scalar; stderr is NOT surfaced. + throw secretListError('signal', { signal: String(result.signal) }); + } + if (typeof result.status !== 'number' || result.status !== 0) { + const exitCode = typeof result.status === 'number' ? result.status : undefined; + // A not-yet-deployed worker: classify from the child's combined output + // INTERNALLY (the match result is a boolean that never leaves), then surface + // the missing key names + bootstrap guidance rather than the circular + // "deploy first" list failure. Only a clean nonzero exit reaches here + // (signal/spawn-error handled above), exactly how wrangler's worker-not-found + // UserError surfaces. The output itself is NOT copied into the thrown error. + if (isWorkerNotFoundOutput(`${result.stdout}\n${result.stderr}`)) { + throw new SelfHostFirstDeployError(firstDeployMessage(missingKeys), missingKeys, exitCode); + } + throw secretListError('nonzero-exit', { exitCode }); + } + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch { + // The unparseable stdout is NOT echoed (it could embed a value). + throw secretListError('malformed-output'); + } + if (!Array.isArray(parsed)) { + throw secretListError('malformed-output'); + } + const names: Array = []; + for (const member of parsed) { + // Whole-list validation: ANY malformed member fails the entire call. A + // partially-parsed list could vouch for secrets we never actually saw. + const name = + member !== null && typeof member === 'object' && !Array.isArray(member) + ? (member as { name?: unknown }).name + : undefined; + if (typeof name !== 'string' || name.length === 0) { + throw secretListError('malformed-output'); + } + names.push(name); + } + return names; +} + +/** + * Hard timeout for one `wrangler secret list` invocation — same bound as the + * provisioner's per-command timeout. A hung wrangler must never hang the + * whole deploy preflight. + */ +const SECRET_LIST_TIMEOUT_MS = 120_000; + +/** + * Default {@link RunSecretList}: resolves wrangler's REAL CLI entry + * (`wrangler-dist/cli.js`, not the signal-swallowing `bin` wrapper) via + * {@link resolveWranglerArgv} and spawns it ASYNC with `shell: false`, so every + * argv element is passed literally (no shell → no metacharacter injection) and + * the event loop stays free. A hard timeout SIGKILLs a hung child; the result + * carries `{status, signal, error}` provenance so the caller fails closed on + * anything but a clean exit 0. + * + * Deliberately simpler than the provisioner's runner: this is a ONE-SHOT + * READ-ONLY query outside the provision lock — no lock heartbeat to protect, + * nothing a lingering child could mutate — so the group-kill/quarantine + * machinery is unnecessary. If the SIGKILL cannot be confirmed (close never + * fires), a bounded fallback settles the call as a HARD failure anyway. + */ +export function createDefaultSecretListRunner( + root: string, + options?: { + /** Pinned into the child's `CLOUDFLARE_ACCOUNT_ID` when provided. */ + accountId?: string; + /** Test seam — the argv launcher. Defaults to the real wrangler CLI entry. */ + launcher?: Array; + /** Test seam — the hard timeout. Defaults to {@link SECRET_LIST_TIMEOUT_MS}. */ + timeoutMs?: number; + }, +): RunSecretList { + const launcher = options?.launcher ?? resolveWranglerArgv(root); + const timeoutMs = options?.timeoutMs ?? SECRET_LIST_TIMEOUT_MS; + const [command, ...prefix] = launcher; + return (args) => + new Promise((resolvePromise) => { + const child = spawn(command as string, [...prefix, ...args], { + cwd: root, + shell: false, + env: secretListEnv(options?.accountId), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + let settled = false; + let timedOut = false; + let killFallbackTimer: ReturnType | undefined; + child.stdout?.setEncoding('utf-8'); + child.stderr?.setEncoding('utf-8'); + child.stdout?.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr?.on('data', (chunk: string) => { + stderr += chunk; + }); + const settle = (result: SecretListRunResult): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(commandTimer); + clearTimeout(killFallbackTimer); + resolvePromise(result); + }; + const timeoutNote = `env: wrangler secret list exceeded the ${timeoutMs}ms timeout`; + // REFERENCED timers (no unref): the outcome must keep the event loop + // alive until it is known; both are cleared in settle(). + const commandTimer = setTimeout(() => { + timedOut = true; + try { + child.kill('SIGKILL'); + } catch { + // fall through to the fallback timer — settle as a hard failure below + } + // Fail-safe: if `close` never fires (kill failed / unkillable child), + // settle as a HARD failure — never leave the preflight hanging, and + // never read the partial output as a result. + killFallbackTimer = setTimeout(() => { + settle({ + stdout, + stderr: [stderr, timeoutNote].filter(Boolean).join('\n'), + status: -1, + signal: 'SIGKILL', + error: 'timed out and termination could not be confirmed', + }); + }, 5_000); + }, timeoutMs); + child.on('error', (error: Error) => { + settle({ stdout, stderr, status: -1, signal: null, error: error.message }); + }); + child.on('close', (code, signal) => { + settle({ + stdout, + stderr: timedOut ? [stderr, timeoutNote].filter(Boolean).join('\n') : stderr, + // A signal-killed child reports `code: null` — normalize to a + // nonzero status AND carry the signal, so no path reads it as clean. + status: typeof code === 'number' ? code : -1, + signal: signal ?? (timedOut ? 'SIGKILL' : null), + }); + }); + }); +} + +/** + * Child env for the spawned `wrangler secret list`. Mirrors deploy.ts's + * `buildEnv` (not imported — deploy.ts imports this module): the CF Access + * service-token pair is for the Void platform API client only and must never + * leak into a spawned wrangler. Pins `WRANGLER_LOG_SANITIZE=true` (same as the + * provisioner) and the caller's account id when provided. Unlike the + * provisioner's `provisionEnv`, ambient transport env (`CLOUDFLARE_API_BASE_URL` + * etc.) is left untouched: this preflight has no second transport to keep in + * lockstep, and scrubbing would silently redirect a staging/FedRAMP user's + * `secret list` at the production endpoint. + * + * Windows environment keys are CASE-INSENSITIVE (same hazard `provisionEnv` + * guards): an ambient mixed-case spelling (`Cloudflare_Account_Id`) could + * shadow an exact-case pin, so on win32 every case-variant of a scrubbed or + * pinned key is removed first. Exported (with a platform seam) for direct + * unit testing of that case rule; production callers pass no platform. + */ +export function secretListEnv(accountId?: string, platform?: NodeJS.Platform): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + const caseInsensitive = (platform ?? process.platform) === 'win32'; + const scrub = (name: string): void => { + if (!caseInsensitive) { + delete env[name]; + return; + } + const lower = name.toLowerCase(); + for (const key of Object.keys(env)) { + if (key.toLowerCase() === lower) { + delete env[key]; + } + } + }; + scrub('CF_ACCESS_CLIENT_ID'); + scrub('CF_ACCESS_CLIENT_SECRET'); + scrub('WRANGLER_LOG_SANITIZE'); + env.WRANGLER_LOG_SANITIZE = 'true'; + if (accountId) { + scrub('CLOUDFLARE_ACCOUNT_ID'); + env.CLOUDFLARE_ACCOUNT_ID = accountId; + } + return env; +} diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 233806a72..76592e920 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -46,7 +46,19 @@ const HELP_TREE: HelpTree = { { command: '--project ', description: 'Deploy target project name' }, { command: '--dir ', description: 'Deploy a static directory and skip build' }, { command: '--spa', description: 'Use SPA mode for static deploys' }, - { command: '--skip-build', description: 'Skip the build step and use existing output' }, + { + command: '--skip-build', + description: 'Skip the build step and use existing output (not with --backend cloudflare)', + }, + { + command: '--backend cloudflare', + description: 'Deploy to your own Cloudflare account instead of the Void platform', + }, + { + command: '--provision', + description: + 'Create missing D1/KV/R2/Queue/Hyperdrive resources (with --backend cloudflare)', + }, { command: '--debug', description: 'Mirror the deploy log to stderr (always written to ~/.void/logs)', @@ -56,6 +68,14 @@ const HELP_TREE: HelpTree = { { command: 'void deploy', description: 'Deploy the linked project' }, { command: 'void deploy --project my-app', description: 'Deploy to a specific project' }, { command: 'void deploy --dir dist --spa', description: 'Deploy a static SPA directory' }, + { + command: 'void deploy --backend cloudflare', + description: 'Deploy to your own Cloudflare account', + }, + { + command: 'void deploy --backend cloudflare --provision', + description: 'Provision missing resources, then deploy to Cloudflare', + }, ], }, auth: { diff --git a/packages/void/src/cli/preset.ts b/packages/void/src/cli/preset.ts index fec40fb6c..48ca410a3 100644 --- a/packages/void/src/cli/preset.ts +++ b/packages/void/src/cli/preset.ts @@ -1,5 +1,7 @@ -import { existsSync } from 'node:fs'; -import { join, resolve } from 'pathe'; +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'pathe'; import { readProjectPaths } from '../project-paths.ts'; import { getPackageDependencies, @@ -146,10 +148,207 @@ export type StaticPreset = { */ export function resolveProjectCommand(root: string, command: string): string { const [bin, ...args] = command.trim().split(/\s+/); - const resolvedBin = localBin(root, resolveCommandBin(root, bin)); + const resolvedBin = shellQuoteExecutable(localBin(root, resolveCommandBin(root, bin))); return args.length > 0 ? `${resolvedBin} ${args.join(' ')}` : resolvedBin; } +/** + * Shell-quote a resolved executable path so it survives being concatenated into + * a command string. The deploy path runs these via `execSync`/`spawnSync`, + * which invoke a shell, so a bare absolute path containing a space (e.g. a + * project root like `/tmp/my app`) would be word-split by the shell and try to + * exec the wrong binary. Paths with no shell-significant characters are returned + * unchanged, so ordinary paths stay byte-identical and copy-pasteable; only the + * executable segment is quoted (the trailing subcommand args are known-safe + * tokens). Platform-aware: cmd.exe (Windows) uses double quotes, POSIX shells + * use single quotes. + */ +function shellQuoteExecutable(value: string): string { + if (process.platform === 'win32') { + // cmd.exe: wrap in double quotes when the path contains a space or a shell + // metacharacter. Windows filenames cannot contain `"`, so there is no + // embedded quote to escape. + return /[\s&|<>^()"]/.test(value) ? `"${value}"` : value; + } + // POSIX sh: characters in the safe set need no quoting; otherwise single-quote + // the path and escape any embedded single quote as '\''. + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) { + return value; + } + return `'${value.replace(/'/g, "'\\''")}'`; +} + +/** + * Resolve a command that runs the project's local `wrangler` binary with the + * given subcommand, preferring `node_modules/.bin/wrangler` when available. + * + * Thin wrapper over {@link resolveProjectCommand}: `wrangler` is not a + * special-cased bin (unlike `vite` → `vp`), so delegating reuses the exact + * local-bin resolution without duplicating it. Returns a shell command string + * suitable for `execSync`/`spawnSync` in the deploy path. + */ +export function resolveWranglerCommand(root: string, subcommand: string): string { + return resolveProjectCommand(root, `wrangler ${subcommand}`); +} + +/** + * Resolve the project-local `wrangler` as an ARGV LAUNCHER — an array of tokens + * `[executable, ...prefixArgs]` meant to be spawned with `shell: false` and the + * real wrangler subcommand appended as further DISCRETE argv elements. + * + * Unlike {@link resolveWranglerCommand} (which returns a shell-quoted STRING for + * `shell: true` callers), this never builds a shell command line, so untrusted + * values — resource names, a Postgres connection string riddled with `&`/`$`/`;`, + * a config path containing spaces — can be passed as their own argv elements and + * are IMMUNE to shell metacharacter injection/splitting. + * + * Resolution order — ALWAYS launches the resolved JS entry as + * `[process.execPath, ]`, which is shell-free AND + * cross-platform (it sidesteps the Windows `.cmd`/`.ps1` shim that Node cannot + * spawn with `shell: false`): + * 1. MODULE RESOLUTION of `wrangler/package.json` relative to THIS module (the + * `void` package). The provisioner targets wrangler 4.107's EXACT flags, + * `--json` shapes, and error strings, so void's own pinned wrangler must be + * PREFERRED — an app that hoists an older, incompatible wrangler at its root + * must not shadow it. Under strict pnpm (void + consumers use pnpm) void's + * wrangler is not at the app root anyway; module resolution finds it wherever + * pnpm placed it. + * 2. FALLBACK: the app-root `node_modules/wrangler` probe, used only when void's + * own wrangler cannot be resolved (e.g. a bundled/vendored layout). + * + * The resolved entry is wrangler's REAL CLI (`wrangler-dist/cli.js`, its + * package `main`/`exports["."]`), NOT the `bin.wrangler` WRAPPER. The wrapper + * (`bin/wrangler.js`) merely does a Node-version guard and then RE-SPAWNS + * `wrangler-dist/cli.js`, forwarding only SIGINT/SIGTERM to the grandchild (via a + * default `.kill()` = SIGTERM) and mapping a signal-killed grandchild (`code === null`) + * to `process.exit(0)`. Launching through it defeats the provisioner's hard command + * timeout: a `SIGKILL` sent to the wrapper cannot be relayed to the inner CLI, which + * survives holding the inherited stdio pipes so the parent's `close` never fires; and a + * killed inner CLI is masked as a clean exit 0. Launching `wrangler-dist/cli.js` + * DIRECTLY removes that layer entirely — the SIGKILL now hits the real process, and no + * wrapper can swallow the signal or rewrite the exit code. Bypassing the wrapper is + * safe: its only behaviors are the version guard (we spawn with the SAME `process.execPath` + * that already passed it) and an IPC-forwarding channel that is inert here (the runner + * spawns with `stdio: ['ignore','pipe','pipe']`, so the wrapper's `process.send` would be + * undefined and forward nothing). + * + * If wrangler cannot be resolved by EITHER avenue, throws a clear error — it never + * degrades to a bare `wrangler` (ENOENT in clean CI) or a `.cmd` shim. + */ +export function resolveWranglerArgv( + root: string, + resolveWranglerPackageJson: () => string | undefined = defaultResolveWranglerPackageJson, +): Array { + const jsBin = resolveLocalWranglerJsBin(root, resolveWranglerPackageJson); + if (jsBin) { + return [process.execPath, jsBin]; + } + throw new Error( + `provision: could not resolve the "wrangler" package. wrangler ships as a dependency of "void"; ` + + `reinstall dependencies (e.g. \`pnpm install\`) so it is present, then retry.`, + ); +} + +/** + * Resolve `wrangler/package.json` via Node module resolution, relative to THIS + * module (the `void` package), so it is found wherever pnpm placed void's own + * wrangler dependency. wrangler explicitly exports `./package.json`, so both + * `import.meta.resolve` and `createRequire().resolve()` succeed. Returns the + * absolute manifest path, or `undefined` when unresolvable. + */ +function defaultResolveWranglerPackageJson(): string | undefined { + if (typeof import.meta.resolve === 'function') { + try { + return fileURLToPath(import.meta.resolve('wrangler/package.json')); + } catch { + // fall through to createRequire + } + } + try { + return createRequire(import.meta.url).resolve('wrangler/package.json'); + } catch { + return undefined; + } +} + +/** + * Absolute path to wrangler's REAL CLI JS entry (its `package.json` `main` / + * `exports["."]` → `wrangler-dist/cli.js`, NOT the `bin.wrangler` wrapper — see + * {@link resolveWranglerArgv} for why we bypass the wrapper), trying the injected + * module resolver (void's OWN wrangler) FIRST, then falling back to the app-root + * probe. Returns `undefined` when wrangler cannot be located or its manifest is + * unreadable. + */ +function resolveLocalWranglerJsBin( + root: string, + resolveWranglerPackageJson: () => string | undefined, +): string | undefined { + // Prefer void's own wrangler (module resolution relative to preset.ts): the + // provisioner targets its exact 4.107 flags/JSON/errors, so an older app-root + // wrangler must not win over it. + const fromVoid = wranglerCliEntryFromPackageJson(resolveWranglerPackageJson()); + if (fromVoid) { + return fromVoid; + } + // Fallback: a consumer-hoisted wrangler at the project root, only when void's + // own resolution failed. + return wranglerCliEntryFromPackageJson(join(root, 'node_modules', 'wrangler', 'package.json')); +} + +/** + * Resolve wrangler's REAL CLI entry from its `package.json` at `pkgPath`. Reads the + * `main` field (`wrangler-dist/cli.js`), falling back to `exports["."]` (its `default` + * condition, or a bare string), and resolves it to an absolute JS entry relative to the + * manifest's own directory (so it works for BOTH the app-root probe and module + * resolution). Deliberately does NOT read `bin.wrangler`: that is the signal-swallowing + * wrapper the provisioner must bypass. Returns `undefined` on any missing/unreadable/ + * absent-entry case. + */ +function wranglerCliEntryFromPackageJson(pkgPath: string | undefined): string | undefined { + if (!pkgPath || !existsSync(pkgPath)) { + return undefined; + } + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { + main?: string; + exports?: unknown; + }; + const rel = wranglerCliRelativeEntry(pkg); + if (!rel) { + return undefined; + } + const jsEntry = join(dirname(pkgPath), rel); + return existsSync(jsEntry) ? jsEntry : undefined; + } catch { + return undefined; + } +} + +/** + * The relative CLI entry from a wrangler manifest: `main` first (wrangler 4.107 sets it + * to `wrangler-dist/cli.js`, which equals `exports["."].default`), then the `.` export + * (`{ default: string }` or a bare string). Returns `undefined` when neither is a string. + */ +function wranglerCliRelativeEntry(pkg: { main?: string; exports?: unknown }): string | undefined { + if (typeof pkg.main === 'string' && pkg.main.length > 0) { + return pkg.main; + } + const exports = pkg.exports; + if (exports && typeof exports === 'object' && !Array.isArray(exports)) { + const dot = (exports as Record)['.']; + if (typeof dot === 'string' && dot.length > 0) { + return dot; + } + if (dot && typeof dot === 'object' && !Array.isArray(dot)) { + const dflt = (dot as Record).default; + if (typeof dflt === 'string' && dflt.length > 0) { + return dflt; + } + } + } + return undefined; +} + /** * Format a user-facing command string, rewriting `vite` to `vp` when the * project depends on Vite+ without expanding to an absolute local bin path. diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts new file mode 100644 index 000000000..23d02518b --- /dev/null +++ b/packages/void/src/cli/provision.ts @@ -0,0 +1,4840 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { + closeSync, + existsSync, + fsyncSync, + lstatSync, + openSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + unlinkSync, + writeFileSync, + writeSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { types as utilTypes } from 'node:util'; +import { parse, type ParseError } from 'jsonc-parser'; +import { basename, dirname, join, resolve } from 'pathe'; +import lockfile from 'proper-lockfile'; +import { voidLog, voidWarn } from '../log.ts'; +import { scanWranglerDotenvKeys } from '../shared/dotenv.ts'; +import { resolveWranglerArgv } from './preset.ts'; +import { + assertNoDuplicateMembers, + readProvisionedBindingEntry, + writeProvisionedWranglerConfig, + type WriteProvisionedWranglerResult, +} from './wrangler.ts'; + +/** + * The set of Cloudflare resources an app needs, computed by the deploy + * orchestrator (Task 6) from inferred bindings + config. The provisioner + * consumes this as-is; it never computes the set itself. + * + * `binding` values are the pre-validated uppercase names the app already uses + * (`DB`, `KV`, `ISR_CACHE`, `STORAGE`, `HYPERDRIVE`) and are passed to wrangler + * verbatim. + */ +export interface DesiredResourceSet { + /** + * D1 database — `{ binding: 'DB', name: 'myapp-db' }`. `migrationsDir` is the + * config-relative `migrations_dir` to write on the D1 entry (sourceDir-aware); + * when omitted the provisioner falls back to the default `./db/migrations`. + */ + d1?: { binding: string; name: string; migrationsDir?: string }; + /** KV namespaces (app KV + `ISR_CACHE`). Titles are NOT account-unique. */ + kvNamespaces: Array<{ binding: string; title: string }>; + /** R2 bucket — bucket names ARE account-unique. */ + r2?: { binding: string; bucketName: string }; + /** Queue resource NAMES to ensure exist (account-unique). */ + queues: Array; + /** Hyperdrive config — a connection string is required only to CREATE one. */ + hyperdrive?: { binding: string; name: string; connectionString: string }; +} + +/** One DESIRED id-bearing resource absent (or placeholder-only) in the config. */ +export interface ProvisionDriftEntry { + /** Binding name (`DB`, `KV`, `ISR_CACHE`, `STORAGE`, `HYPERDRIVE`). */ + binding: string; + kind: 'd1' | 'kv' | 'r2' | 'hyperdrive'; + /** Resource name / KV title / bucket name the deploy wants. */ + name: string; +} + +export interface ProvisionDriftResult { + /** Desired id-bearing resources not yet provisioned (real-id) in the config. */ + missing: Array; +} + +/** + * Diff the DESIRED id-bearing resources against the ACTUAL bindings in the root + * wrangler config, WITHOUT any remote/wrangler call. A resource counts as + * provisioned ONLY when its binding entry exists with a REAL (non-placeholder) + * identifier — the SAME predicate the provisioner's read-first uses: + * - D1 `database_id` is a real UUID ({@link isRealD1Id}, not `'local'`); + * - KV `id` is a real 32-hex ({@link isRealKvId}, not `'local'`); + * - R2 `bucket_name` names a real bucket ({@link isRealStoredR2BucketName}, not `'default'`); + * - Hyperdrive `id` is a real 32-hex ({@link isRealHyperdriveId}). + * + * Queues are NAME-bearing (no id in the config), so they are NOT part of this + * id-drift check. A missing config file yields every desired resource as missing. + * + * Used by the deploy orchestrator (Task 6) for BOTH the no-`--provision` DRIFT + * CHECK (any `missing` → guidance error) and the CI `--provision` no-op gate + * (`missing.length === 0` ⇒ a provable no-op the committed config already covers). + * A malformed/duplicate-binding config makes {@link readProvisionedBindingEntry} + * throw — fail closed, exactly as the provisioner would. + */ +export function checkCloudflareProvisionDrift( + configPath: string, + desired: DesiredResourceSet, +): ProvisionDriftResult { + const config = readRootConfig(configPath); + const missing: Array = []; + const isProvisioned = ( + field: string, + binding: string, + hasRealId: (entry: Record) => boolean, + ): boolean => { + const entry = readProvisionedBindingEntry(config, field, binding, configPath); + return entry !== undefined && hasRealId(entry); + }; + if (desired.d1) { + if (!isProvisioned('d1_databases', desired.d1.binding, (e) => isRealD1Id(e.database_id))) { + missing.push({ binding: desired.d1.binding, kind: 'd1', name: desired.d1.name }); + } + } + for (const kv of desired.kvNamespaces) { + if (!isProvisioned('kv_namespaces', kv.binding, (e) => isRealKvId(e.id))) { + missing.push({ binding: kv.binding, kind: 'kv', name: kv.title }); + } + } + if (desired.r2) { + if ( + !isProvisioned('r2_buckets', desired.r2.binding, (e) => + isRealStoredR2BucketName(e.bucket_name), + ) + ) { + missing.push({ binding: desired.r2.binding, kind: 'r2', name: desired.r2.bucketName }); + } + } + if (desired.hyperdrive) { + if (!isProvisioned('hyperdrive', desired.hyperdrive.binding, (e) => isRealHyperdriveId(e.id))) { + missing.push({ + binding: desired.hyperdrive.binding, + kind: 'hyperdrive', + name: desired.hyperdrive.name, + }); + } + } + return { missing }; +} + +/** + * Result of one `runWrangler` invocation, carrying STRUCTURED PROVENANCE so a + * signal-killed or unspawnable child is never mistaken for a clean exit. The + * single rule ({@link isHardFailure}) every classifier defers to: a result with a + * non-null `signal` or a `error` did NOT run to completion, so its buffered + * status/stdout/stderr is unreliable and must NEVER be read as "absent" or + * "success". + */ +export type RunWranglerResult = { + stdout: string; + stderr: string; + status: number; + /** + * Non-null ONLY when the child was terminated by a signal — a HARD failure. + * (Optional so injected test stubs may omit it, meaning "clean".) + */ + signal?: NodeJS.Signals | null; + /** The spawn-error message when the child could not be started — a HARD failure. */ + error?: string | null; +}; + +/** + * Seam over shelling out to the project's `wrangler`. The default implementation + * ({@link createDefaultRunWrangler}) wraps `spawnSync` with `shell: false`; unit + * tests inject a stub so no real wrangler runs. + * + * `args` is the wrangler ARGV after the launcher — one array element per token, + * e.g. `['d1', 'list', '--json']` or + * `['hyperdrive', 'create', name, '--connection-string=' + conn, ...]`. Every + * untrusted value (resource name, connection string, config path) is its OWN + * array element and is passed literally to the process (no shell), so shell + * metacharacters (`&`, `$`, `;`, spaces) can never split, inject, or drop args. + */ +/** + * The transport context threaded into EVERY wrangler invocation (default AND + * injected). Its `accountId` is the ONE reconciled effective account this whole + * provision is pinned to: the default runner uses it to pin the spawned wrangler's + * env (`CLOUDFLARE_ACCOUNT_ID` + the top-level `--env=` on every argv), and an + * injected seam MUST bind its own transport to the SAME account (so it can never + * query account B while the config pins A and write a same-name B result into A). + */ +export interface WranglerCallContext { + /** The reconciled effective Cloudflare account (never empty for a real call). */ + accountId: string; + /** + * The ONE pinned Cloudflare API base URL this provision resolved (mirroring + * wrangler's own `getCloudflareApiBaseUrl`), threaded so the default runner can pin the + * spawned wrangler's `CLOUDFLARE_API_BASE_URL` to the SAME endpoint the Hyperdrive REST + * lister uses. An injected seam may ignore it (it owns its own transport). + */ + apiBaseUrl?: string; + cwd?: string; + captureJson?: boolean; +} + +/** + * The seam returns a Promise (or a plain value, which the choke point coerces + * with `Promise.resolve`). The default runner is ASYNC (`spawn`, awaited) so a slow + * wrangler call NEVER blocks the event loop, which keeps `proper-lockfile`'s heartbeat + * timer firing (a synchronous `spawnSync` would freeze the loop, so the heartbeat could + * not refresh the mutex mtime and another process could steal a lock we still hold). + * Injected unit-test stubs may stay synchronous (returning a plain `RunWranglerResult`) + * — `Promise.resolve` at the choke point wraps them transparently. + */ +export type RunWrangler = ( + args: Array, + opts: WranglerCallContext, +) => RunWranglerResult | Promise; + +/** A Cloudflare Hyperdrive config as returned by the account-list existence check. */ +export interface HyperdriveConfig { + id: string; + name: string; +} + +/** + * Seam over listing the account's Hyperdrive configs (wrangler 4.107 has NO + * machine-readable `hyperdrive list` and no get-by-name, so existence is checked + * via the Cloudflare REST API). The default implementation + * ({@link createDefaultListHyperdriveConfigs}) calls + * `GET /accounts/{id}/hyperdrive/configs`; unit tests inject a stub so no real + * network call runs. + * + * The lister receives the reconciled `accountId` AND the pinned `apiBaseUrl` as an + * explicit transport context (never a closure-only binding), so an injected lister can + * never query a DIFFERENT account/endpoint than the one wrangler + the config are pinned + * to. The default lister builds its REST URL from `apiBaseUrl` instead of hardcoding prod. + */ +export type ListHyperdriveConfigs = (ctx: { + accountId: string; + apiBaseUrl: string; + /** + * The abort signal enforcing the caller's OVERALL lister deadline. The default lister + * threads it into EVERY paginated `fetch`; an injected seam SHOULD honor it too. The caller + * ALSO races the whole call against the same deadline, so even a seam that ignores the signal + * cannot hold the whole-provision lock past the deadline. + */ + signal: AbortSignal; +}) => Promise>; + +/** Per-resource outcome, so the orchestrator can log what happened. */ +export interface ProvisionOutcome { + kind: 'd1' | 'kv' | 'r2' | 'hyperdrive' | 'queue'; + /** Binding name (absent for queue resources, which are not bindings here). */ + binding?: string; + /** Resource name / KV title / bucket name / queue name. */ + name: string; + action: 'created' | 'reused' | 'adopted' | 'skipped'; + /** Real resource id captured on adopt (and reuse, when known). */ + id?: string; + /** Reason, set only when `action === 'skipped'`. */ + reason?: string; +} + +export interface ProvisionResult { + resources: Array; + /** The root wrangler config the writer/wrangler operated on. */ + configPath: string; + /** True when top-level `account_id` was written from `opts.accountId`. */ + accountIdWritten: boolean; +} + +export interface ProvisionLogger { + info: (msg: string) => void; + warn: (msg: string) => void; +} + +/** Where Void keeps D1 migrations — written as `migrations_dir` on the D1 entry. */ +const D1_MIGRATIONS_DIR = './db/migrations'; + +/** UTF-8 byte-order mark. Node's utf-8 read preserves a leading one. */ +const UTF8_BOM = ''; + +// --- Per-resource local-dev placeholder metadata ----------------- +// Exactly what `syncWranglerBindings` (wrangler.ts) writes for a not-yet-provisioned +// binding — a D1 entry gets `database_id: 'local'`, a KV entry `id: 'local'`, an R2 +// entry `bucket_name: 'default'`. Each placeholder belongs to ITS OWN field only: the +// shared "reject both `local` and `default` everywhere" check this replaces falsely +// treated a stored R2 bucket_name of `local` — a grammar-valid, legitimately usable +// bucket name that is NOT Void's R2 placeholder — as unprovisioned, bypassing the +// stored-name contradiction gate (silent rewrite) and failing the create postcondition +// for a bucket genuinely named `default`. Placeholder rules are judged ONLY where +// read-first identity is resolved (and the R2 create postcondition's probe fallback); +// Hyperdrive has no placeholder (its 32-hex format alone excludes these words). +const D1_ID_PLACEHOLDER = 'local'; +const KV_ID_PLACEHOLDER = 'local'; +const R2_BUCKET_NAME_PLACEHOLDER = 'default'; + +// --- Resource-SPECIFIC real-id format validators -------------------------- +// A merely non-empty string — `" "`, `"xyz"` — must never satisfy read-first reuse or +// the create postcondition, else a resource could be reported provisioned with no valid +// identifier and NO discovery. Each id class has a FIXED Cloudflare format (verified +// 2026-07 against wrangler 4.107 + live account resources): +// - D1 `database_id`: a hyphenated UUID (e.g. `52b3bbc7-c473-4b26-9486-1345f5de61c4`). +// - KV namespace `id`: 32 lowercase-hex chars, no hyphens (e.g. `7c07b54c…153aa11a`). +// - Hyperdrive `id`: 32 lowercase-hex chars, no hyphens (e.g. `a8097c6a…34ad195c`) — +// NOT a hyphenated UUID (confirmed from a live config). +// - R2 identity IS the `bucket_name` (see {@link R2_BUCKET_NAME_RE}). +// These gate read-first reuse, discovery/adoption results, adoption writes, AND create +// postconditions — so a placeholder OR a malformed id fails closed (→ discovery, or a +// hard error) instead of being recorded as a real resource. +const D1_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const HEX32_ID_RE = /^[0-9a-f]{32}$/i; +/** R2 bucket names ARE the identity: wrangler 4.107's `isValidR2BucketName`. */ +const R2_BUCKET_NAME_RE = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/; + +/** + * A D1 `database_id` that is a real (non-placeholder) hyphenated UUID. Exported so + * the deploy orchestrator applies the SAME placeholder rule when deciding whether a + * stored `database_name` names a real DB (a synced `database_id: "local"` must not + * let its paired name become the production database identity). + */ +export function isRealD1Id(value: unknown): value is string { + return typeof value === 'string' && value !== D1_ID_PLACEHOLDER && D1_ID_RE.test(value); +} +/** + * A KV namespace `id` that is a real (non-placeholder) 32-hex string. Exported so the + * deploy orchestrator applies the SAME placeholder rule when checking whether two KV + * bindings already share one provisioned namespace. + */ +export function isRealKvId(value: unknown): value is string { + return typeof value === 'string' && value !== KV_ID_PLACEHOLDER && HEX32_ID_RE.test(value); +} +/** A Hyperdrive config `id` that is a real 32-hex string (no placeholder exists). */ +function isRealHyperdriveId(value: unknown): value is string { + return typeof value === 'string' && HEX32_ID_RE.test(value); +} +/** An R2 `bucket_name` satisfying wrangler's bucket-name GRAMMAR (format only). */ +function isValidR2BucketNameFormat(value: unknown): value is string { + return typeof value === 'string' && R2_BUCKET_NAME_RE.test(value); +} +/** + * A STORED R2 `bucket_name` that names a REAL bucket, i.e. any non-empty string + * that is not R2's own placeholder (`default`). `local` IS a real name here. Grammar is + * deliberately not required: a grammar-invalid stored name that contradicts the desired + * bucket must still fail closed as a contradiction, never be silently rewritten. + */ +function isRealStoredR2BucketName(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value !== R2_BUCKET_NAME_PLACEHOLDER; +} + +const defaultLogger: ProvisionLogger = { + info: (msg) => voidLog(msg), + warn: (msg) => voidWarn(msg), +}; + +/** + * The transport-redirecting env vars wrangler 4.107 honors. Each can point + * wrangler's authenticated API transport at a DIFFERENT endpoint (or select a + * different env-block/account) than the ONE this provision pinned: + * - `CLOUDFLARE_API_BASE_URL` / `CF_API_BASE_URL` — override the API base URL directly + * (`getCloudflareApiBaseUrlFromEnv`). + * - `WRANGLER_API_ENVIRONMENT=staging` — routes to `api.staging.cloudflare.com`. + * - `CLOUDFLARE_COMPLIANCE_REGION=fedramp_high` — routes to `api.fed.cloudflare.com`. + * - `CLOUDFLARE_ENV` — selects a named `env.*` config block (different account/bindings). + * We pin ONE resolved base explicitly (see {@link provisionEnv}) and fail closed when a + * project dotenv would reselect one (see {@link assertNoTransportRedirectInProjectDotenv}). + */ +const TRANSPORT_REDIRECT_ENV_VARS = [ + 'CLOUDFLARE_API_BASE_URL', + 'CF_API_BASE_URL', + 'WRANGLER_API_ENVIRONMENT', + 'CLOUDFLARE_COMPLIANCE_REGION', + 'CLOUDFLARE_ENV', +] as const; + +/** + * Wrangler 4.107's diagnostic-output sinks that would PERSIST the raw provisioning + * argv (including a Hyperdrive connection string, password and all) to disk: + * - `WRANGLER_OUTPUT_FILE_PATH` / `WRANGLER_OUTPUT_FILE_DIRECTORY` — when either is set + * (`getOutputFilePathFromEnv`/`getOutputFileDirectoryFromEnv`), wrangler's startup + * middleware appends a `wrangler-session` entry with the VERBATIM + * `command_line_args: argv` to that file (`writeOutput` in wrangler-dist/cli.js) — so + * `hyperdrive create --connection-string=postgres://user:PASSWORD@…` lands on disk. + * They are scrubbed from every provisioner child env ({@link provisionEnv}) and fenced in + * the project dotenv ({@link assertNoTransportRedirectInProjectDotenv}) — wrangler reloads + * dotenv into `process.env` BEFORE writing the session entry, so a dotenv-declared sink + * would resurrect the leak. `WRANGLER_LOG_SANITIZE` (`getSanitizeLogs`, default true) is + * PINNED to `true` instead: a truthy ambient value survives wrangler's dotenv reload + * (dotenv-expand keeps an already-set truthy `process.env` value), so the pin cannot be + * overridden into unsanitized logging of the secret-bearing API request. + */ +const OUTPUT_FILE_ENV_VARS = [ + 'WRANGLER_OUTPUT_FILE_PATH', + 'WRANGLER_OUTPUT_FILE_DIRECTORY', +] as const; + +/** + * Resolve the ONE Cloudflare API base URL this provision is pinned to, mirroring + * wrangler 4.107's `getCloudflareApiBaseUrl` EXACTLY (wrangler-dist/cli.js) so the + * Hyperdrive REST lister and the spawned wrangler agree on the endpoint: + * + * getCloudflareApiBaseUrl = (complianceConfig) => + * getCloudflareApiBaseUrlFromEnv() ?? + * `https://api${getComplianceRegionSubdomain(complianceConfig)}${getStagingSubdomain()}.cloudflare.com/client/v4` + * + * Order + semantics mirrored exactly: + * 1. The DIRECT base override is evaluated FIRST, by PRESENCE (wrangler's env factory + * checks `variableName in process.env`), with `CLOUDFLARE_API_BASE_URL` taking + * precedence over the deprecated `CF_API_BASE_URL`. A present-but-EMPTY override is + * returned AS-IS: `"" ?? derived` keeps the empty string (NULLISH, not falsy, + * coalescing), so wrangler pins the empty base (its API calls then fail on the + * unparsable URL) — we mirror that instead of silently substituting an endpoint. + * 2. Only on the DERIVED branch are the staging/compliance selectors read — wrangler's + * `??` short-circuits past `getComplianceRegionSubdomain`/`getStagingSubdomain` when a + * direct base is present, so a direct base + conflicting compliance settings resolves + * to the direct base in wrangler (NO conflict error) and must not be rejected here. + * 3. On the derived branch both env selectors are choice-VALIDATED (wrangler's + * `getProcessEnv` → `assertOneOf` throws on any present value outside the choices, + * e.g. `WRANGLER_API_ENVIRONMENT=stagng` or an empty string), so an invalid selector + * FAILS CLOSED instead of silently pinning public production. + * 4. The config's own `compliance_region` VALUE is validated UNCONDITIONALLY — wrangler's + * config validation (`isOneOf("public", "fedramp_high")` on the `compliance_region` + * field) rejects an invalid config value for every command regardless of which branch + * resolves the base. + * Read ONCE from the REAL ambient `process.env` (which does NOT include the project `.env` + * — Node never auto-loads it), so an ambient/CI pin is honored while a project dotenv + * redirect is rejected up front by {@link assertNoTransportRedirectInProjectDotenv}. + */ +export function resolvePinnedApiBaseUrl( + env: NodeJS.ProcessEnv = process.env, + configComplianceRegion?: string, +): string { + // (4) — validate the config's compliance_region value unconditionally, like wrangler's + // config validation (a PRESENT empty string is invalid there too). + const configRegion = + configComplianceRegion !== undefined + ? assertComplianceChoice(configComplianceRegion, 'the wrangler config "compliance_region"') + : undefined; + // (1) — presence (not truthiness) of the direct override wins, CLOUDFLARE_* first. + const direct = + env.CLOUDFLARE_API_BASE_URL !== undefined ? env.CLOUDFLARE_API_BASE_URL : env.CF_API_BASE_URL; + if (direct !== undefined) { + return direct; + } + // (2)+(3) — derived branch only: validate the selectors, reconcile the region. + const region = resolveComplianceRegion(env, configRegion); + const fed = region === 'fedramp_high' ? '.fed' : ''; + const staging = resolveApiEnvironment(env) === 'staging' ? '.staging' : ''; + return `https://api${fed}${staging}.cloudflare.com/client/v4`; +} + +/** + * Mirror wrangler 4.107's `getCloudflareApiEnvironmentFromEnv` (env factory with + * `choices: ["production", "staging"]`, default `"production"`): an ABSENT variable + * defaults to production; a PRESENT value outside the choices — `"stagng"`, an empty + * string — makes wrangler's `assertOneOf` throw, so we fail closed identically instead of + * silently pinning public production where wrangler stops. + */ +function resolveApiEnvironment(env: NodeJS.ProcessEnv): 'production' | 'staging' { + const raw = env.WRANGLER_API_ENVIRONMENT; + if (raw === undefined) { + return 'production'; + } + if (raw !== 'production' && raw !== 'staging') { + throw new Error( + `provision: the WRANGLER_API_ENVIRONMENT environment variable must be one of ` + + `"production" or "staging", got ${JSON.stringify(raw)}. wrangler rejects this value, so ` + + `provisioning refuses to guess an endpoint; fix or unset it and retry.`, + ); + } + return raw; +} + +/** The compliance-region choices wrangler 4.107 accepts (`getCloudflareComplianceRegionFromEnv`). */ +const COMPLIANCE_REGIONS = ['public', 'fedramp_high'] as const; +type ComplianceRegion = (typeof COMPLIANCE_REGIONS)[number]; + +function assertComplianceChoice(value: string, source: string): ComplianceRegion { + if (value !== 'public' && value !== 'fedramp_high') { + throw new Error( + `provision: ${source} must be one of ${COMPLIANCE_REGIONS.map((c) => JSON.stringify(c)).join( + ' or ', + )}, got ${JSON.stringify(value)}.`, + ); + } + return value; +} + +/** + * Reconcile the effective Cloudflare compliance region, mirroring wrangler 4.107's + * `getCloudflareComplianceRegion(complianceConfig)` EXACTLY (evaluated only on the + * DERIVED-base branch — with a direct base override wrangler never calls it): + * - reads `CLOUDFLARE_COMPLIANCE_REGION` by PRESENCE and validates it against the + * `public`/`fedramp_high` choices (wrangler's `getProcessEnv` → `assertOneOf`, which + * throws on ANY present out-of-choices value — an empty string included); + * - when BOTH the env var AND the (already-validated) config region are set to + * DIFFERENT values, THROWS (fail closed) — wrangler raises a `UserError` for exactly + * this conflict, so we must not paper over it by pinning one endpoint while the config + * declares the other region; + * - otherwise returns `env || config || "public"` (both values are validated choices by + * now, so wrangler's falsy-coalescing is equivalent to nullish here). + */ +function resolveComplianceRegion( + env: NodeJS.ProcessEnv, + configRegion: ComplianceRegion | undefined, +): ComplianceRegion { + const rawEnv = env.CLOUDFLARE_COMPLIANCE_REGION; + const envRegion = + rawEnv !== undefined + ? assertComplianceChoice(rawEnv, 'the CLOUDFLARE_COMPLIANCE_REGION environment variable') + : undefined; + if (envRegion !== undefined && configRegion !== undefined && envRegion !== configRegion) { + throw new Error( + `provision: the Cloudflare compliance region is set to conflicting values — the ` + + `CLOUDFLARE_COMPLIANCE_REGION environment variable is ${JSON.stringify(envRegion)} but the ` + + `wrangler config "compliance_region" is ${JSON.stringify(configRegion)}. wrangler refuses to ` + + `run with a mismatched compliance region; reconcile them (remove one, or set them equal) and ` + + `retry.`, + ); + } + return envRegion ?? configRegion ?? 'public'; +} + +// `scanWranglerDotenvKeys` lives in the leaf `../shared/dotenv.ts` (no lifecycle +// side effects) so the deploy orchestrator can read wrangler dotenv keys without +// importing this provision module. Re-exported here to keep the historical +// `provision.ts` import surface stable for existing callers. +export { scanWranglerDotenvKeys }; + +/** + * Mirror wrangler 4.107's `getDefaultEnvFiles(env)`: base `.env`/`.env.local`, then — + * when the environment argument is DEFINED (we always pass `--env=`, so wrangler sees + * `args.env === ""`, which is `!== undefined`) — the per-env pair `.env.${env}`/`.env.${env}.local`. + * With the empty environment those are the empty-env filenames `.env.` and `.env..local`. + */ +function defaultWranglerEnvFiles(env: string): Array { + return ['.env', '.env.local', `.env.${env}`, `.env.${env}.local`]; +} + +/** + * FAIL CLOSED when the project's dotenv files declare a transport-redirecting + * variable. wrangler 4.107 reloads `.env`/`.env.local` (and the dev-vars files) from the + * project root into `process.env` on EVERY command with `override: true`, so a repo `.env` + * setting `CLOUDFLARE_API_BASE_URL` / `WRANGLER_API_ENVIRONMENT` / + * `CLOUDFLARE_COMPLIANCE_REGION` / `CLOUDFLARE_ENV` would redirect wrangler's authenticated + * transport (and credentials) to a staging / FedRAMP / arbitrary endpoint AFTER we resolved + * the pinned base — while the Hyperdrive existence check runs against the pinned base. + * Single-account/single-endpoint provisioning cannot reason about a transport redirected + * out from under it, so we refuse. The fix is to export the variable in the shell/CI + * environment (which we DO honor, pinning both wrangler and the lister to it) instead of + * the project dotenv. + * + * Exported for direct unit testing of the file-set and the Windows case rule; production + * always calls it with the real `process.platform`. + */ +export function assertNoTransportRedirectInProjectDotenv( + root: string, + platform: NodeJS.Platform = process.platform, +): void { + // Scan the EXACT files wrangler 4.107 loads for the + // provisioner's `--env=` (EMPTY environment string), with wrangler's own per-family + // SELECTION and CASE semantics, using a KEY scanner that mirrors wrangler's dotenv + // parser (accepts the optional `export ` prefix + quoting): + // - DOTENV family — the per-command `process.env` reload (the startup middleware's + // `check`) loads ALL of `getDefaultEnvFiles("")` = [".env", ".env.local", ".env.", + // ".env..local"] via `loadDotEnv`, which parses into `caseInsensitiveEnv()` when + // `process.platform === "win32"` (a Proxy canonicalizing property names via + // `toLowerCase`) — so dotenv KEYS fold case ONLY on win32, exactly elsewhere. + // - DEV-VARS family — `loadDotDevDotVars(envPath, env)` is (wrangler-dist/cli.js): + // env !== undefined && tryLoadDotDevDotVars(`${envPath}.${env}`) + // || tryLoadDotDevDotVars(envPath) + // For the empty env it probes `.dev.vars.` FIRST and reads `.dev.vars` ONLY when + // `.dev.vars.` is ABSENT — exactly ONE file is ever parsed, so the fence inspects + // only the SELECTED one (fencing both falsely rejected a benign `.dev.vars.` + // paired with an unused redirect var in `.dev.vars` that wrangler never reads). + // `tryLoadDotDevDotVars` parses via `dotenv.parse` into a PLAIN object — + // case-SENSITIVE on EVERY platform (`caseInsensitiveEnv` is used only by + // `loadDotEnv`), so no case folding applies to dev-vars keys anywhere. It NEVER + // loads `.dev.vars.local`, so that file stays deliberately unfenced. + // We do NOT expand — presence of the KEY is the signal. + const dotenvCaseInsensitive = platform === 'win32'; + const devVarsFile = existsSync(join(root, '.dev.vars.')) ? '.dev.vars.' : '.dev.vars'; + const files: Array<{ file: string; caseInsensitive: boolean }> = [ + ...defaultWranglerEnvFiles('').map((file) => ({ + file, + caseInsensitive: dotenvCaseInsensitive, + })), + { file: devVarsFile, caseInsensitive: false }, + ]; + for (const { file, caseInsensitive } of files) { + const keys = scanWranglerDotenvKeys(join(root, file)); + const lowerKeys = caseInsensitive + ? new Set([...keys].map((key) => key.toLowerCase())) + : undefined; + const declares = (name: string): boolean => + lowerKeys !== undefined ? lowerKeys.has(name.toLowerCase()) : keys.has(name); + const offenders = TRANSPORT_REDIRECT_ENV_VARS.filter(declares); + if (offenders.length > 0) { + throw new Error( + `provision: refusing to provision — the project file "${file}" sets ` + + `${offenders.join(', ')}, which wrangler reloads on every command and would redirect its ` + + `authenticated Cloudflare API transport (and credentials) to a different endpoint/account ` + + `than this provision is pinned to (the Hyperdrive existence check would then run against a ` + + `DIFFERENT endpoint, risking cross-environment writes or duplicate resources). Remove ` + + `${offenders.join(', ')} from "${file}" and, if you truly need a non-default endpoint, ` + + `export it in your shell/CI environment instead — that pin IS honored for both wrangler and ` + + `the existence check.`, + ); + } + // Wrangler's session-output sinks are fenced too: wrangler reloads this dotenv + // into `process.env` BEFORE writing its `wrangler-session` output entry, whose + // `command_line_args` is the VERBATIM argv — including a Hyperdrive connection + // string's password — so a dotenv-declared sink would resurrect the leak the child-env + // scrub (see provisionEnv) just closed. + const outputOffenders = OUTPUT_FILE_ENV_VARS.filter(declares); + if (outputOffenders.length > 0) { + throw new Error( + `provision: refusing to provision — the project file "${file}" sets ` + + `${outputOffenders.join(', ')}, which wrangler reloads on every command and which makes ` + + `wrangler persist its session diagnostics — including the verbatim command line, where a ` + + `Hyperdrive connection string's password would appear — to that output file. Remove ` + + `${outputOffenders.join(', ')} from "${file}" (set it in your shell only for ` + + `non-provisioning wrangler commands if you need session output).`, + ); + } + } +} + +/** + * Child-process env with the CF Access service-token PAIR scrubbed. Mirrors + * `buildEnv` in `deploy.ts`: a shared, long-lived Access token is needed only by + * this CLI's own API client and must never leak into the spawned wrangler + * process (which may in turn run user hooks). The cloudflared `CF_ACCESS_TOKEN` + * JWT (dev path) is left untouched. + * + * Pins ONE transport context: scrubs every {@link TRANSPORT_REDIRECT_ENV_VARS} + * from the child env, then re-sets `CLOUDFLARE_API_BASE_URL` to the SINGLE resolved + * `apiBaseUrl` so the spawned wrangler and the Hyperdrive REST lister hit the exact same + * endpoint. Scrubbing the compliance/staging/env selectors makes the explicit base + * authoritative (they can no longer re-derive a different base). Combined with + * {@link assertNoTransportRedirectInProjectDotenv} (which blocks a project dotenv from + * repopulating any of them via wrangler's per-command reload), the pin cannot be moved. + */ +/** + * Delete `name` from `env` IN PLACE, honoring the platform's env-key case rule. + * Windows environment keys are CASE-INSENSITIVE: a spawned child's `process.env` + * resolves `WRANGLER_OUTPUT_FILE_PATH` from an ambient `Wrangler_Output_File_Path` + * too, so an exact-case `delete` leaves a mixed-case spelling live. On win32 every + * case-insensitive spelling is removed; elsewhere POSIX env vars are case-sensitive, + * so only the exact key is a match. Exported so the deploy orchestrator scrubs its + * OWN extra deploy-time keys (worker-name override, build-config-path) through the + * SAME rule the provisioner uses, instead of re-deriving it. + */ +export function deleteEnvVarCaseAware( + env: NodeJS.ProcessEnv, + name: string, + platform: NodeJS.Platform = process.platform, +): void { + if (platform !== 'win32') { + delete env[name]; + return; + } + const lower = name.toLowerCase(); + for (const key of Object.keys(env)) { + if (key.toLowerCase() === lower) { + delete env[key]; + } + } +} + +export function provisionEnv(ctx?: { + accountId?: string; + apiBaseUrl?: string; + /** Test seam for the win32 case rule; production always uses the real platform. */ + platform?: NodeJS.Platform; +}): NodeJS.ProcessEnv { + const env = { ...process.env }; + // Windows environment keys are CASE-INSENSITIVE: the spawned child's + // `process.env` resolves `WRANGLER_OUTPUT_FILE_PATH` from an ambient + // `Wrangler_Output_File_Path` too, so an exact-case `delete` left every scrub below + // bypassable by a mixed-case spelling (wrangler would then see the sink var and + // persist the raw argv — Hyperdrive password included — to disk). Scrub EVERY + // case-insensitive spelling of EVERY scrubbed/pinned key on win32; exact-case + // elsewhere (POSIX env vars are case-sensitive, so other spellings are genuinely + // different variables). + const platform = ctx?.platform ?? process.platform; + const scrub = (name: string): void => deleteEnvVarCaseAware(env, name, platform); + scrub('CF_ACCESS_CLIENT_ID'); + scrub('CF_ACCESS_CLIENT_SECRET'); + // Scrub EVERY transport-redirecting selector (CLOUDFLARE_ENV, the API-base overrides, + // WRANGLER_API_ENVIRONMENT, CLOUDFLARE_COMPLIANCE_REGION). CLOUDFLARE_ENV would select a + // named env-block whose account_id/bindings differ; the others would re-derive a + // different API base. We fold their effect into the ONE explicit base below. + for (const name of TRANSPORT_REDIRECT_ENV_VARS) { + scrub(name); + } + // Scrub wrangler's session-output sinks (they would persist the raw argv — + // a Hyperdrive connection string's password included — to disk) and PIN sanitized + // logging: never pass through an ambient `WRANGLER_LOG_SANITIZE=false` for a + // secret-bearing invocation. A truthy pinned value also survives wrangler's + // per-command dotenv reload (dotenv-expand keeps a set truthy process.env value). + // The pinned keys' case-variants are scrubbed FIRST so the canonical spelling + // below is the ONLY one the child sees — a mixed-case ambient value must never + // shadow a pin on win32. + for (const name of OUTPUT_FILE_ENV_VARS) { + scrub(name); + } + scrub('WRANGLER_LOG_SANITIZE'); + env.WRANGLER_LOG_SANITIZE = 'true'; + // Pin the ONE resolved API base so wrangler's `getCloudflareApiBaseUrlFromEnv()` returns + // exactly the endpoint the Hyperdrive REST lister uses (no staging/FedRAMP/arbitrary + // split-brain). The explicit `--env=` on every argv still forces the top-level config + // env even if a stray CLOUDFLARE_ENV survived elsewhere. Pinned by PRESENCE: a + // resolved EMPTY base (a present-but-empty ambient override, which wrangler honors via + // its `in process.env` + nullish coalescing) is pinned as empty too, so the child + // wrangler fails on it exactly as the ambient environment would — never silently + // re-derived into a different endpoint than the lister uses. + if (ctx?.apiBaseUrl !== undefined) { + env.CLOUDFLARE_API_BASE_URL = ctx.apiBaseUrl; + } + // Pin the target account non-interactively (CI-safe) when known, so + // `wrangler list/create` never prompts for disambiguation AND never falls + // back to a DIFFERENT ambient account than the one we resolved + persisted. + // Case-variants scrubbed first so the pin is unambiguous on win32. + if (ctx?.accountId) { + scrub('CLOUDFLARE_ACCOUNT_ID'); + env.CLOUDFLARE_ACCOUNT_ID = ctx.accountId; + } + return env; +} + +/** + * Hard per-command timeout for a single wrangler invocation. A hung wrangler must + * NEVER hang the whole provision, so the default runner SIGKILLs a child that runs + * past this and reports a HARD failure (a non-null signal — see + * {@link normalizeRunWranglerResult}). With the ASYNC runner this bound is + * INDEPENDENT of {@link LOCK_STALE_MS}: awaiting the child keeps the event loop free, + * so `proper-lockfile`'s heartbeat refreshes the mutex throughout — the timeout only + * bounds a hang, it is not load-bearing for lock safety. + */ +const COMMAND_TIMEOUT_MS = 120_000; + +/** + * After a timeout SIGKILL, how long to keep confirming the child is ACTUALLY gone — + * BOTH its `close` event AND whole-group disappearance (`kill(-pid, 0)` → ESRCH) — before + * giving up. SIGKILL is uncatchable, so a healthy kernel reaps the group within + * milliseconds; this generous bound only guards a pathological uninterruptible-sleep child. + * The runner NEVER settles a timeout as "done" until both facts are confirmed; an expiry + * (or a failed kill) REJECTS with a {@link WranglerQuarantineError} instead, so the + * whole-provision lock — released only after a CONFIRMED settle — is intentionally + * retained while the real wrangler CLI may still be creating resources. + */ +const CONFIRM_KILL_GRACE_MS = 5_000; +/** The pid-poll interval while confirming post-SIGKILL termination. */ +const CONFIRM_KILL_POLL_MS = 20; +/** + * The margin the coordinator-WIDE teardown deadline adds on top of a + * runner's own confirmation grace window: a signal teardown is bounded at + * `confirmGraceMs + this` even if the runner's bounded confirmation never calls back. + */ +const SIGNAL_TEARDOWN_DEADLINE_MARGIN_MS = 5_000; + +/** + * Classify a process-group kill error. `ESRCH` means the group ALREADY exited (there was + * nothing left to kill) → success. ANY other code (`EPERM`, …) is a REAL kill failure: the + * child may still be running, so it must be surfaced, never swallowed. Exported for direct + * unit testing of the classification the timeout/cancel paths rely on. + */ +export function isAlreadyGoneKillError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ESRCH'; +} + +/** + * A QUARANTINE failure: the runner could NOT confirm that a spawned + * wrangler child (and its whole process group) is dead — the group kill failed for a + * non-ESRCH reason (EPERM, …), or the post-SIGKILL confirmation window expired with the + * group still (possibly) alive. The child may STILL be creating resources / writing + * config, so the whole-provision mutex must be INTENTIONALLY RETAINED — never released — + * and this error surfaces with manual-recovery guidance naming the surviving pid. + * {@link provisionCloudflareResources} detects it via {@link isQuarantineError} and calls + * `lock.quarantine()` instead of `lock.release()`. + */ +export class WranglerQuarantineError extends Error { + readonly quarantine = true as const; + readonly pid: number | undefined; + constructor(pid: number | undefined, detail: string) { + const pidLabel = pid !== undefined ? ` (pid ${pid})` : ''; + super( + `provision: the spawned wrangler could not be confirmed terminated${pidLabel} — ${detail}. ` + + `It may still be running and creating resources, so the cross-process provision lock is ` + + `intentionally RETAINED (not released) and its owner is recorded in a quarantine sidecar ` + + `(".void-provision.lock.quarantine.json") that blocks concurrent ` + + `provisions. Recovery: verify the process${pidLabel} is dead (e.g. \`kill -0 \`; ` + + `terminate it if not) — the NEXT provision then confirms the recorded group is gone and ` + + `clears the quarantine automatically. To clear it manually instead, remove the sidecar ` + + `and the ".void-provision.lock.lock" directory, then re-run.`, + ); + this.name = 'WranglerQuarantineError'; + this.pid = pid; + } +} + +/** + * The owner metadata a quarantine records: the unconfirmed wrangler + * child's pid (on POSIX the child is spawned `detached`, so its pid IS its process-GROUP + * id) and a human-readable reason. Persisted in the quarantine sidecar next to the lock + * (see {@link acquireProvisionLock}) so EVERY later acquisition can probe the recorded + * group before the mutex could otherwise be stolen by the mtime-stale path. + */ +export interface QuarantineInfo { + pid?: number; + reason: string; +} + +// --- AT MOST ONE active provision per process -------------------------- +// THE structural invariant the signal coordinator (below) relies on: a single CLI +// process runs AT MOST ONE provision at a time — the deploy orchestrator (Task 6) +// calls `provisionCloudflareResources` exactly once per process, and CROSS-process +// concurrency is what the on-disk whole-provision lock serializes. Because every +// wrangler call inside a provision is awaited sequentially, this guard means the +// signal coordinator manages AT MOST ONE {wrangler child group, provision lock} pair +// at any moment — which is what makes its dispatch barrier, its 1:1 quarantine +// binding, and its bounded teardown structurally simple instead of racy. +// A second in-process provision fails fast and loudly here rather than eroding those +// invariants. The slot is taken SYNCHRONOUSLY at provision entry (before the first +// await), so two same-tick calls cannot both pass. +let activeProvisionSlot: symbol | undefined; + +function acquireProcessProvisionSlot(): () => void { + if (activeProvisionSlot !== undefined) { + throw new Error( + 'provision: a provision is already active in this process — run provisions ' + + 'sequentially. provisionCloudflareResources supports at most one concurrent ' + + 'provision per process; concurrent provisions across PROCESSES are serialized ' + + 'by the on-disk provision lock instead.', + ); + } + // While a previous provision's retention watchdog is still nursing an + // undurably-quarantined lock (unconfirmed child, no durable sidecar yet), a new + // provision would hand the coordinator a second pair; refuse until it resolves. + if (retentionWatchdog !== undefined) { + throw new Error( + 'provision: a previous provision in this process is still retaining its lock — an ' + + `unconfirmed wrangler child` + + `${retentionWatchdog.info.pid !== undefined ? ` (pid ${retentionWatchdog.info.pid})` : ''} ` + + 'could not be quarantined durably yet. No new provision may start until that ' + + 'retention resolves (the child is confirmed dead, or the quarantine metadata ' + + 'persists durably).', + ); + } + const token = Symbol('void-provision-slot'); + activeProvisionSlot = token; + return () => { + if (activeProvisionSlot === token) { + activeProvisionSlot = undefined; + } + }; +} + +/** + * The ONE active provision's lock-quarantine hook, 1:1 with the + * provision slot above. When the parent process is about to DIE re-raising a forwarded + * SIGINT/SIGTERM while a spawned wrangler child's teardown could NOT be confirmed, + * proper-lockfile's signal-exit sweep would remove the still-TRACKED provision mutex — + * releasing the lock under a possibly-live child. {@link provisionCloudflareResources} + * sets this for the duration of the provision; a registering wrangler child CAPTURES it + * at registration (see {@link registerRunner}) so an unresolved teardown quarantines + * exactly ITS OWNING provision lock with ITS OWN pid metadata — never a broadcast + * through every lock (the previous Set this replaces stamped the FIRST unresolved + * registrant's pid into every lock's sidecar, so a foreign group's death could clear a + * quarantine whose real child was alive). + */ +let activeProvisionQuarantineHook: ((info: QuarantineInfo) => void) | undefined; + +/** True when `error` is a quarantine failure whose provision lock must stay retained. */ +export function isQuarantineError(error: unknown): error is WranglerQuarantineError { + return ( + error instanceof WranglerQuarantineError || + (error as { quarantine?: boolean } | null)?.quarantine === true + ); +} + +// --- THE process-wide signal coordinator ---------------------- +// ONE coordinator owns forward → confirm → quarantine → re-raise for the (at most one +// — see the provision slot above) active {wrangler child group, provision lock} pair: +// the runner registers its pair for the child's lifetime; listeners are installed only +// while a registration / teardown / retention watchdog is active; on the FIRST +// SIGINT/SIGTERM the coordinator forwards the signal to the registered group, awaits +// its bounded both-facts confirmation, quarantines it if unresolved (the OWNING lock's +// hook, bound at registration, with THIS pair's own pid metadata), and then re-raises +// EXACTLY ONCE. Repeated signals during the teardown are coalesced by the +// still-installed handler (no second re-raise, no default-action bypass). +// The barrier is PRE-ACCOUNTED from a snapshot taken BEFORE dispatch +// (a synchronously-failing registrant can never finish the teardown mid-dispatch), new +// wrangler spawns are REJECTED outright once a teardown began (fail fast, no child +// spawned → nothing to confirm — never a late join with a fresh deadline), and one +// coordinator-WIDE deadline backstops the whole teardown (an unconfirmable pair is +// quarantined at the bound, so `pending` can never wedge the re-raise forever). + +/** What a runner registers with the coordinator for its child's lifetime. */ +interface TerminationRegistrant { + /** The child's pid (its process-GROUP id on POSIX), for quarantine metadata. */ + pid: () => number | undefined; + /** Forward `signal` to the child's whole process group (see the runner's signalGroup). */ + signalGroup: (signal: NodeJS.Signals) => 'signaled' | 'gone' | { error: Error }; + /** Record that the group is already confirmed gone (an ESRCH outcome of the forward). */ + markGroupGone: () => void; + /** Start the runner's bounded both-facts confirmation; exactly one callback fires. */ + awaitConfirmedDead: ( + onDone: (outcome: { confirmed: true } | { confirmed: false; detail: string }) => void, + ) => void; + /** Reject the runner promise with a quarantine error (idempotent once settled). */ + quarantine: (detail: string) => void; + /** + * The coordinator-WIDE teardown deadline for this pair: if the bounded + * both-facts confirmation has not called back by then, the coordinator quarantines the + * pair itself, so a fatal signal's teardown is ALWAYS globally bounded. + */ + teardownDeadlineMs: number; +} + +/** + * A registration binds the registrant to ITS OWNING provision lock's + * quarantine hook (captured from the single active provision at registration time), so + * an unresolved teardown quarantines exactly that lock with exactly this pair's pid. + */ +interface ActiveRegistration { + registrant: TerminationRegistrant; + lockQuarantine: ((info: QuarantineInfo) => void) | undefined; +} + +interface CoordinatorTeardown { + signal: NodeJS.Signals; + /** Pre-accounted from the registration snapshot BEFORE dispatch. */ + pending: number; + /** True while the initial dispatch is still running (finish latch). */ + dispatching: boolean; + persistFailed: boolean; + finished: boolean; + /** The coordinator-wide deadline backstop (referenced; cleared at finish). */ + deadlineTimer: ReturnType | undefined; + /** Expire the still-pending pair at the deadline (idempotent via its done flag). */ + expirePendingPair: ((detail: string) => void) | undefined; +} + +/** The (at most one) currently-registered {runner, owning-lock} pair. */ +let activeRegistration: ActiveRegistration | undefined; +let coordinatorListenersInstalled = false; +let activeTeardown: CoordinatorTeardown | undefined; +let reRaisedOnce = false; + +const onCoordinatorSigint = (): void => coordinatorHandleSignal('SIGINT'); +const onCoordinatorSigterm = (): void => coordinatorHandleSignal('SIGTERM'); + +function installCoordinatorListeners(): void { + if (coordinatorListenersInstalled) { + return; + } + coordinatorListenersInstalled = true; + process.on('SIGINT', onCoordinatorSigint); + process.on('SIGTERM', onCoordinatorSigterm); +} + +function removeCoordinatorListeners(): void { + if (!coordinatorListenersInstalled) { + return; + } + coordinatorListenersInstalled = false; + process.removeListener('SIGINT', onCoordinatorSigint); + process.removeListener('SIGTERM', onCoordinatorSigterm); +} + +function registerRunner(registrant: TerminationRegistrant): void { + // Unreachable backstop: the runner REJECTS pre-spawn while another child is active or + // a teardown is in progress (same synchronous block as the spawn, so no signal can + // interleave), which is what makes this invariant structural rather than checked-late. + if (activeRegistration !== undefined) { + throw new Error( + 'provision: internal invariant violated — a second wrangler child tried to register ' + + 'while one is already active in this process', + ); + } + activeRegistration = { + registrant, + // Bind the OWNING provision lock at registration (1:1). Undefined + // for a bare runner used outside a provision (unit tests): there is no lock to + // retain, so an unresolved pair only rejects its own promise. + lockQuarantine: activeProvisionQuarantineHook, + }; + installCoordinatorListeners(); +} + +function deregisterRunner(registrant: TerminationRegistrant): void { + if (activeRegistration?.registrant === registrant) { + activeRegistration = undefined; + } + // Keep the listeners through an active teardown (they COALESCE repeated signals) and + // while a retention watchdog is unresolved (a default-action death would sweep + // the still-tracked mutex under the possibly-live child). + if ( + activeRegistration === undefined && + activeTeardown === undefined && + retentionWatchdog === undefined + ) { + removeCoordinatorListeners(); + } +} + +function coordinatorHandleSignal(signal: NodeJS.Signals): void { + // Coalesce: repeated signals during an in-flight teardown are swallowed by this + // still-installed handler — no second re-raise, no bypass of the confirmation + // barrier. + if (activeTeardown !== undefined) { + return; + } + // While the retention watchdog holds an undurably-quarantined lock, + // the process must NOT die of a re-raise: the exit sweep would release the tracked + // mutex under the possibly-live child. Swallow the signal with a warning; the + // watchdog resolves by confirming the child dead or persisting the sidecar. + if (retentionWatchdog !== undefined && activeRegistration === undefined) { + process.stderr.write( + `provision: ignoring ${signal} — a provision lock is retained while an unconfirmed ` + + `wrangler child may live and its quarantine metadata is not yet durable. The ` + + `process exits once the child is confirmed dead or the metadata persists.\n`, + ); + return; + } + const teardown: CoordinatorTeardown = { + signal, + pending: 0, + dispatching: true, + persistFailed: false, + finished: false, + deadlineTimer: undefined, + expirePendingPair: undefined, + }; + activeTeardown = teardown; + // Snapshot + PRE-ACCOUNT the barrier BEFORE dispatch: `pending` is + // initialized from the snapshot size, so a registrant that fails/settles + // SYNCHRONOUSLY during dispatch can never drop it to 0 and finish the teardown while + // the dispatch loop is still outstanding (the at-most-one guard makes the snapshot + // ≤ 1; the barrier stays structurally sound regardless), and the `dispatching` latch + // defers the finish until the loop below has fully completed. + const snapshot = activeRegistration !== undefined ? [activeRegistration] : []; + teardown.pending = snapshot.length; + if (snapshot.length > 0) { + // The coordinator-WIDE deadline backstop: even if a pair's bounded + // confirmation never calls back, the teardown quarantines the pair and completes at + // this bound. REFERENCED — the teardown must hold the loop open until resolved. + const deadlineMs = Math.max(...snapshot.map((r) => r.registrant.teardownDeadlineMs)); + teardown.deadlineTimer = setTimeout(() => { + teardown.expirePendingPair?.( + `the coordinator-wide ${deadlineMs}ms teardown deadline expired before the process ` + + `group's termination was confirmed`, + ); + }, deadlineMs); + } + for (const registration of snapshot) { + coordinatorTearDownRegistrant(teardown, registration); + } + teardown.dispatching = false; + maybeFinishCoordinatorTeardown(teardown); +} + +/** Finish only after the initial dispatch fully completed AND every + * snapshot registrant resolved (confirmed or quarantined). */ +function maybeFinishCoordinatorTeardown(teardown: CoordinatorTeardown): void { + if (!teardown.dispatching && teardown.pending === 0) { + finishCoordinatorTeardown(teardown); + } +} + +function coordinatorTearDownRegistrant( + teardown: CoordinatorTeardown, + registration: ActiveRegistration, +): void { + const { registrant, lockQuarantine } = registration; + let done = false; + const complete = (): void => { + if (done) { + return; + } + done = true; + teardown.pending -= 1; + maybeFinishCoordinatorTeardown(teardown); + }; + const quarantinePair = (detail: string): void => { + if (done) { + return; + } + // Before-death retention of ONLY this pair's OWNING provision lock, + // with THIS registrant's own pid metadata (bound 1:1 at registration — never a + // broadcast through every lock hook, which stamped a foreign pair's pid into + // unrelated sidecars). Synchronous: the sidecar must be persisted and the lock + // detached from the exit sweep before the process could die of the re-raise. + if (lockQuarantine !== undefined) { + try { + lockQuarantine({ + pid: registrant.pid(), + reason: + `the spawned wrangler could not be confirmed terminated after a forwarded ` + + `${teardown.signal} — ${detail}`, + }); + } catch (failure) { + // The lock could NOT be quarantined durably; it + // stays TRACKED and the retention watchdog (armed by the hook wrapper) now owns + // the unresolved state. The finish below must not re-raise. + teardown.persistFailed = true; + process.stderr.write(`provision: ${(failure as Error).message}\n`); + } + } + const pid = registrant.pid(); + process.stderr.write( + `provision: warning — the wrangler child process group` + + `${typeof pid === 'number' ? ` (pid ${pid})` : ''} could not be confirmed terminated ` + + `after ${teardown.signal} (${detail}). It may still be running; the provision lock is ` + + `retained (quarantined) — verify the process is dead before re-running provision.\n`, + ); + registrant.quarantine(detail); + complete(); + }; + // The coordinator-wide deadline expires the pair through the same idempotent path. + teardown.expirePendingPair = quarantinePair; + const outcome = registrant.signalGroup(teardown.signal); + if (typeof outcome === 'object') { + quarantinePair(`the process group could not be signalled: ${outcome.error.message}`); + return; + } + if (outcome === 'gone') { + registrant.markGroupGone(); + } + registrant.awaitConfirmedDead((result) => { + if (result.confirmed) { + complete(); + } else { + quarantinePair(result.detail); + } + }); +} + +function finishCoordinatorTeardown(teardown: CoordinatorTeardown): void { + if (teardown.finished) { + return; + } + teardown.finished = true; + if (teardown.deadlineTimer !== undefined) { + clearTimeout(teardown.deadlineTimer); + } + // Remove the listeners so the re-raise below takes Node's DEFAULT exit-on-signal + // action instead of being swallowed by this very handler. + removeCoordinatorListeners(); + if (teardown.persistFailed || retentionWatchdog !== undefined) { + // A provision lock could NOT be quarantined durably; + // it stays TRACKED (heartbeat running — see {@link ProvisionLock.quarantine}) under + // the retention watchdog. Re-raising would kill this process, whose exit sweep + // releases the tracked mutex under the possibly-live child — so do NOT re-raise. + // The watchdog keeps signal handling installed, retries durable persistence, and + // probes the recorded group for death. + activeTeardown = undefined; + installCoordinatorListeners(); + process.stderr.write( + `provision: NOT re-raising ${teardown.signal} — a provision lock's quarantine ` + + `metadata could not be persisted, so the lock is intentionally still held by this ` + + `process to keep it from being stolen while the unconfirmed wrangler child may ` + + `live.\n`, + ); + return; + } + // Exactly-once re-raise (process-wide): the registered group is now confirmed dead + // or durably quarantined, so the parent may take the signal's default action. + if (reRaisedOnce) { + return; + } + reRaisedOnce = true; + process.kill(process.pid, teardown.signal); + // Signals are delivered asynchronously: if the event loop has drained by now, the + // process would exit NORMALLY (status 0) before the pending signal lands. A short + // REFERENCED no-op keeps the loop alive so the re-raised signal's default action + // terminates the process (it fires within milliseconds; the timer is only a bounded + // fallback if something else swallowed the signal). + setTimeout(() => {}, 2_000); +} + +// --- The retention WATCHDOG for an undurable quarantine ----------- +// When quarantine persistence FAILS, the lock stays TRACKED (heartbeat running, not +// stealable) — but a tracked mutex is exactly what proper-lockfile's exit sweep removes +// when the process drains and exits naturally, leaving the possibly-live wrangler child +// with NO mutex and NO sidecar. While that state is unresolved the process must not +// drain unprotected: a REFERENCED watchdog timer holds the loop open and, each tick, +// (a) probes the recorded group for death (ESRCH → clear state, release the mutex +// normally, resolve), then +// (b) retries the DURABLE quarantine persistence with backoff (success → sidecar +// durable + lock detached from the sweep → safe to resume normal exit semantics). +// Signal handling stays installed throughout (the coordinator swallows further +// SIGINT/SIGTERM with a warning — see {@link coordinatorHandleSignal}) and no new +// provision may start in this process until the watchdog resolves (see the slot guard). + +const RETENTION_RETRY_MIN_MS = 250; +const RETENTION_RETRY_MAX_MS = 5_000; + +interface RetentionWatchdogState { + lock: ProvisionLock; + info: QuarantineInfo; + timer: ReturnType; +} + +let retentionWatchdog: RetentionWatchdogState | undefined; + +/** + * Quarantine `lock`, and on a PERSISTENCE failure arm the retention + * watchdog BEFORE rethrowing. THE single quarantine entry point for provision-level + * code (the body-error path and the signal coordinator's hook both route through it), + * so no persistence failure can leave the process free to drain unprotected. + */ +function retainQuarantinedLock(lock: ProvisionLock, info: QuarantineInfo): void { + try { + lock.quarantine(info); + } catch (error) { + armRetentionWatchdog(lock, info); + throw error; + } +} + +function armRetentionWatchdog(lock: ProvisionLock, info: QuarantineInfo): void { + // At most one provision per process → at most one retention; re-arming is a no-op. + if (retentionWatchdog !== undefined) { + return; + } + // Surface the retained state LOUDLY, once, naming the pid and the lock path. + process.stderr.write( + `provision: warning — quarantine metadata for the unconfirmed wrangler child` + + `${info.pid !== undefined ? ` (pid ${info.pid})` : ''} could not be persisted, so the ` + + `provision lock "${lock.lockTargetPath}.lock" is RETAINED by this process, which stays ` + + `alive until the child is confirmed dead or the metadata persists durably.\n`, + ); + installCoordinatorListeners(); + let attempt = 0; + const resolveWatchdog = (): void => { + if (retentionWatchdog !== undefined) { + clearTimeout(retentionWatchdog.timer); + } + retentionWatchdog = undefined; + if (activeRegistration === undefined && activeTeardown === undefined) { + removeCoordinatorListeners(); + } + }; + const tick = (): void => { + // (a) probe the recorded group for death FIRST: a dead group needs no quarantine — + // release the mutex normally and resume normal exit semantics. + if (info.pid !== undefined) { + let dead = false; + try { + process.kill(process.platform === 'win32' ? info.pid : -info.pid, 0); + } catch (error) { + dead = (error as NodeJS.ErrnoException).code === 'ESRCH'; + } + if (dead) { + lock.release().then( + () => resolveWatchdog(), + (error: unknown) => { + process.stderr.write( + `provision: the retained provision lock could not be released after the ` + + `child's death was confirmed (${(error as Error).message}); the mutex ` + + `self-heals once stale.\n`, + ); + resolveWatchdog(); + }, + ); + return; + } + } + // (b) retry the DURABLE persistence: success detaches the lock behind a durable + // sidecar, so the process may exit while the retained mutex keeps blocking. + try { + lock.quarantine(info); + resolveWatchdog(); + return; + } catch { + // Still failing — keep holding the process open and retry with backoff. + } + attempt += 1; + const delay = Math.min(RETENTION_RETRY_MIN_MS * 2 ** attempt, RETENTION_RETRY_MAX_MS); + if (retentionWatchdog !== undefined) { + retentionWatchdog.timer = setTimeout(tick, delay); // REFERENCED — holds the process + } + }; + retentionWatchdog = { lock, info, timer: setTimeout(tick, RETENTION_RETRY_MIN_MS) }; +} + +/** + * Default `runWrangler`: resolves the project-local wrangler as an ARGV launcher + * via Task 2's {@link resolveWranglerArgv} and runs it with `spawn` and + * `shell: false`, so every argument is passed literally (no shell parsing → no + * injection). Captures stdout (for `--json` parsing) and stderr (for + * error/collision detection). Never called in unit tests — those inject a stub. + * + * ASYNC (returns a Promise). `spawn` + `await` (vs. the old blocking + * `spawnSync`) keeps the Node event loop free while wrangler runs, so + * `proper-lockfile`'s update timer keeps refreshing the mutex mtime for the WHOLE + * provision — no wrangler call, however slow, can freeze the loop long enough for + * another process to see the lock stale and steal it while we still hold it. A + * {@link COMMAND_TIMEOUT_MS} SIGKILL guards against a hung child. + */ +export function createDefaultRunWrangler( + root: string, + options?: { + /** Test seam — the argv launcher. Defaults to the real wrangler CLI entry. */ + launcher?: Array; + /** Test seam — the per-command timeout. Defaults to {@link COMMAND_TIMEOUT_MS}. */ + timeoutMs?: number; + /** + * Test seam — the process-GROUP signaller (`target` is the value passed to + * `process.kill`: a NEGATIVE pid for the group). Defaults to `process.kill`. Tests inject + * it to exercise the ESRCH-already-gone and real-kill-failure branches. + */ + killProcessGroup?: (target: number, signal: NodeJS.Signals) => void; + /** + * Test seam — the post-kill termination-confirmation window. Defaults to + * {@link CONFIRM_KILL_GRACE_MS}. Tests shrink it to exercise the quarantine expiry. + */ + confirmKillGraceMs?: number; + /** + * Test seam — the coordinator-WIDE signal-teardown deadline for this + * runner's pair. Defaults to `confirmKillGraceMs + `{@link SIGNAL_TEARDOWN_DEADLINE_MARGIN_MS}. + * Tests shrink it to exercise the backstop quarantine. + */ + signalTeardownDeadlineMs?: number; + /** + * Test seam — observe the spawned ChildProcess (unit tests use it to inject a + * post-spawn `error` event). Never set in production. + */ + onSpawnedChild?: (child: ChildProcess) => void; + }, +): RunWrangler { + // `resolveWranglerArgv` now yields wrangler's REAL CLI entry + // (`wrangler-dist/cli.js`), NOT the `bin.wrangler` wrapper, so a SIGKILL from the + // timeout below hits the real process (the wrapper could neither relay the kill nor + // be trusted to report the exit code — see `resolveWranglerArgv`). + const launcher = options?.launcher ?? resolveWranglerArgv(root); + const timeoutMs = options?.timeoutMs ?? COMMAND_TIMEOUT_MS; + const confirmGraceMs = options?.confirmKillGraceMs ?? CONFIRM_KILL_GRACE_MS; + const killProcessGroup = + options?.killProcessGroup ?? + ((target: number, signal: NodeJS.Signals) => process.kill(target, signal)); + const [command, ...prefix] = launcher; + // POSIX: spawn the child as its OWN process-group leader (`detached`) so the timeout + // can SIGKILL the WHOLE group (`process.kill(-pid, …)`) — reaping any grandchildren + // (e.g. an esbuild service) that would otherwise linger holding the inherited pipes. + // Windows has no process groups here; the direct (wrapper-free) CLI has no grandchild + // to strand, so a plain `child.kill` suffices. + const isWindows = process.platform === 'win32'; + const teardownDeadlineMs = + options?.signalTeardownDeadlineMs ?? confirmGraceMs + SIGNAL_TEARDOWN_DEADLINE_MARGIN_MS; + return (args, opts) => + new Promise((resolvePromise, rejectPromise) => { + // Once a fatal-signal teardown has begun, this process is + // terminating: REJECT new wrangler work outright BEFORE spawning (fail fast — no + // child spawned means nothing to confirm), never join the teardown late with a + // fresh deadline. + if (activeTeardown !== undefined) { + rejectPromise( + new Error( + `provision: refusing to spawn wrangler — a fatal ${activeTeardown.signal} is being ` + + `handled and this process is terminating; no new wrangler work may start.`, + ), + ); + return; + } + // The at-most-one invariant, enforced BEFORE the spawn: within one + // provision every wrangler call is awaited sequentially (and at most one + // provision runs per process), so a second concurrently-active child is always a + // caller bug. This check and the spawn+registration below run in ONE synchronous + // block, so no signal dispatch can interleave between them. + if (activeRegistration !== undefined) { + rejectPromise( + new Error( + 'provision: refusing to spawn wrangler — another wrangler child is already active ' + + 'in this process (wrangler calls must run sequentially; provisions run at most ' + + 'one per process).', + ), + ); + return; + } + const child = spawn(command, [...prefix, ...args], { + cwd: opts.cwd ?? root, + shell: false, + // Pin the account + API base PER CALL from the transport context, so the + // spawned wrangler targets the SAME reconciled account + endpoint the config + + // Hyperdrive REST path use (no split-brain between the config pin and the + // ambient/dotenv env). + env: provisionEnv({ accountId: opts.accountId, apiBaseUrl: opts.apiBaseUrl }), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + detached: !isWindows, + }); + let stdout = ''; + let stderr = ''; + let timedOut = false; + let settled = false; + // Whether the spawn SUCCEEDED. The direct-settle carve-out in the + // `error` handler is reserved for a GENUINE pre-spawn failure (no pid, `spawn` + // never fired — the child never ran, so there is nothing to confirm dead). + let spawned = false; + // A post-spawn `error` event routes through the termination + // machine; this makes the `close` handler defer to that path (like `timedOut`). + let postSpawnError: Error | undefined; + // ONE termination state machine for EVERY spawned-child outcome + // (normal close, timeout, parent signal, post-spawn error, kill failure). The TWO + // facts every path must confirm before the promise may settle — and before a + // forwarded parent signal may be re-raised: + // `closed` — the child's `close` event fired (process exited + stdio drained); + // `groupGone` — the whole process GROUP is confirmed gone (`kill(-pid, 0)` → ESRCH; + // trivially true where no group exists: Windows, or no pid). + // NO path settles-and-releases (or re-raises) without BOTH; an unconfirmable + // teardown instead QUARANTINES — rejects with a {@link WranglerQuarantineError} + // (lock intentionally retained; on the signal path the before-death hooks retain + // it synchronously first). + let closed = false; + let groupGone = false; + child.once('spawn', () => { + spawned = true; + }); + child.stdout?.setEncoding('utf-8'); + child.stderr?.setEncoding('utf-8'); + child.stdout?.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr?.on('data', (chunk: string) => { + stderr += chunk; + }); + + // Signal the child's WHOLE process group (it is `detached`, so its own group leader). + // Returns a STRUCTURED outcome so the caller distinguishes ESRCH ("already gone" → + // success) from a REAL kill failure (EPERM/… → the child may still be running → surface). + const signalGroup = (signal: NodeJS.Signals): 'signaled' | 'gone' | { error: Error } => { + try { + if (!isWindows && typeof child.pid === 'number') { + killProcessGroup(-child.pid, signal); // negative pid → the whole group + return 'signaled'; + } + if (child.kill(signal)) { + return 'signaled'; + } + // `child.kill()` returning FALSE is Node's non-throwing way of + // reporting a failed signal delivery. An already-exited child has nothing left + // to signal ("gone"); otherwise it is a FAILED signal attempt that must be + // surfaced (same path as a thrown non-ESRCH kill error), never read as sent. + if (child.exitCode !== null || child.signalCode !== null) { + return 'gone'; + } + return { + error: new Error(`child.kill(${signal}) returned false (signal not delivered)`), + }; + } catch (error) { + return isAlreadyGoneKillError(error) ? 'gone' : { error: error as Error }; + } + }; + + /** True when the whole process group no longer exists (`kill(-pid, 0)` → ESRCH). */ + const probeGroupGone = (): boolean => { + if (groupGone) { + return true; + } + if (isWindows || typeof child.pid !== 'number') { + // No process-group concept — `close` is the only (and sufficient) confirmation. + groupGone = true; + return true; + } + try { + process.kill(-child.pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + groupGone = true; + } + // EPERM → some group member exists but we cannot signal it — treat as ALIVE + // (fail safe: never confirm on a probe we could not perform). + } + return groupGone; + }; + + const timeoutStderr = (extra?: string): string => + [stderr, `provision: wrangler exceeded the ${timeoutMs}ms command timeout`, extra] + .filter(Boolean) + .join('\n'); + + // This runner's pair is registered with the ONE process-wide + // signal coordinator (below) for the child's lifetime; settling deregisters it. + const deregisterSelf = (): void => deregisterRunner(registrant); + + const finish = (result: RunWranglerResult): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(commandTimer); + deregisterSelf(); + resolvePromise(result); + }; + + // Settle by REJECTING with a quarantine error: the child could not be confirmed + // dead, so the caller must retain the whole-provision lock (never release it under a + // possibly-live child) and surface manual-recovery guidance. + const quarantine = (detail: string): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(commandTimer); + deregisterSelf(); + rejectPromise(new WranglerQuarantineError(child.pid, detail)); + }; + + // The ONE bounded both-facts confirmation loop, shared by the + // settle side (normal close + timeout) and the parent-signal side. Polls until + // BOTH facts hold, then `onConfirmed`; at `confirmGraceMs` unconfirmed, + // `onExpiry`. `close` nudges every active poll for promptness. Poll timers are + // REFERENCED (no unref): confirmation must keep the event loop alive until the + // outcome is known — an unref'd timer would let the process exit mid-confirm + // (status 0, exit hooks sweeping the mutex). A settle-side poll self-terminates + // once the promise settled (≤ one 20ms tick later); the signal-side poll must + // keep going regardless (it still has a fatal signal to re-raise). + const confirmPollNudges: Array<() => void> = []; + const nudgeConfirmPolls = (): void => { + // A poll never registers new waiters while running, so direct iteration is safe. + for (const poll of confirmPollNudges) { + poll(); + } + }; + const awaitConfirmedDead = (handlers: { + stopWhenSettled: boolean; + onConfirmed: () => void; + onExpiry: () => void; + }): void => { + const deadline = Date.now() + confirmGraceMs; + let pollTimer: ReturnType | undefined; + const poll = (): void => { + clearTimeout(pollTimer); // idempotent under an external nudge + if (handlers.stopWhenSettled && settled) { + return; + } + if (closed && probeGroupGone()) { + handlers.onConfirmed(); + return; + } + if (Date.now() >= deadline) { + handlers.onExpiry(); + return; + } + pollTimer = setTimeout(poll, CONFIRM_KILL_POLL_MS); + }; + confirmPollNudges.push(poll); + poll(); + }; + + // Settle side of the machine: confirm BOTH facts, then resolve with `makeResult`; + // an unconfirmed window QUARANTINES (rejects; lock retained) — never a silent + // settle-and-release while the real CLI (or a group member) may still be running. + const confirmThenSettle = (makeResult: () => RunWranglerResult, what: string): void => { + awaitConfirmedDead({ + stopWhenSettled: true, + onConfirmed: () => finish(makeResult()), + onExpiry: () => + quarantine( + `the process group could not be confirmed terminated within ${confirmGraceMs}ms ` + + `of ${what}`, + ), + }); + }; + + // Forward a parent SIGINT/SIGTERM to the child's process GROUP so a Ctrl-C / + // termination tears the DETACHED child (+ grandchildren) down instead of orphaning it. + // Signal handling lives in the ONE process-wide coordinator (see + // {@link registerRunner}); this runner only registers its pair (at most one exists + // — the pre-spawn checks above enforce it). The coordinator forwards the first + // SIGINT/SIGTERM to the registered group, awaits its bounded both-facts + // confirmation (via this registrant's `awaitConfirmedDead`), quarantines it if + // unresolved, coalesces repeated signals during the teardown, and re-raises + // EXACTLY ONCE — never over a pending child. + const registrant: TerminationRegistrant = { + pid: () => child.pid, + signalGroup, + teardownDeadlineMs, + markGroupGone: () => { + // ESRCH ⇒ the whole group already exited — only `close` remains to be seen. + groupGone = true; + }, + awaitConfirmedDead: (onDone) => { + awaitConfirmedDead({ + stopWhenSettled: false, + onConfirmed: () => onDone({ confirmed: true }), + onExpiry: () => + onDone({ + confirmed: false, + detail: + `the process group did not terminate within ${confirmGraceMs}ms of the ` + + `forwarded signal`, + }), + }); + }, + quarantine, + }; + + // Hard timeout: SIGKILL the whole group, then AWAIT CONFIRMED termination before + // settling — so the whole-provision lock (released only after this promise settles) + // can NEVER be released while the real wrangler CLI is still alive. A kill FAILURE is + // a QUARANTINE (rejects; lock retained), never a settle-and-release. + const commandTimer = setTimeout(() => { + timedOut = true; + const outcome = signalGroup('SIGKILL'); + if (typeof outcome === 'object') { + // A REAL kill failure (not ESRCH). The child MAY still be running — QUARANTINE. + quarantine(`the process group could not be killed: ${outcome.error.message}`); + return; + } + if (outcome === 'gone') { + groupGone = true; + } + confirmThenSettle( + () => + normalizeRunWranglerResult({ + stdout, + stderr: timeoutStderr(), + status: null, + signal: 'SIGKILL', + }), + `the timeout SIGKILL (${timeoutStderr()})`, + ); + }, timeoutMs); + // REFERENCED (no unref): if every other handle unrefs (pipes closed by a child that + // never exits), an unref'd deadline would let the event loop drain and the process + // exit 0 mid-command. It is cleared in finish/quarantine, so a completed call never + // holds the loop open. + + // Node emits `error` on a ChildProcess AFTER a successful spawn + // too (e.g. a failed in-flight signal delivery; the Windows timeout/signal paths + // go through `child.kill()`), and the old handler ALWAYS settled directly — an + // ordinary hard failure that RELEASED the whole-provision lock while the child + // could still be alive. The direct-settle carve-out is reserved for a GENUINE + // pre-spawn failure (ENOENT/EACCES: no pid, `spawn` never fired — the child never + // ran, so there is nothing to confirm dead, and `close` may never follow). Every + // POST-spawn `error` routes through the ONE bounded termination-confirmation → + // quarantine machine like any other outcome. + child.on('error', (error: Error) => { + if (!spawned && typeof child.pid !== 'number') { + finish(normalizeRunWranglerResult({ stdout, stderr, status: null, signal: null, error })); + return; + } + if (settled || timedOut || postSpawnError !== undefined) { + // Already settling through another arm of the machine (its confirm poll + // still owns the outcome) — never double-drive it. + return; + } + postSpawnError = error; + // The command is over as far as the caller is concerned — the machine's own + // grace window bounds the rest. + clearTimeout(commandTimer); + const outcome = signalGroup('SIGKILL'); + if (typeof outcome === 'object') { + quarantine( + `a post-spawn child error (${error.message}) was followed by a failed ` + + `process-group kill: ${outcome.error.message}`, + ); + return; + } + if (outcome === 'gone') { + groupGone = true; + } + confirmThenSettle( + () => + normalizeRunWranglerResult({ + stdout, + stderr: [ + stderr, + `provision: the spawned wrangler child emitted an error after spawn: ` + + `${error.message}`, + ] + .filter(Boolean) + .join('\n'), + status: null, + signal: 'SIGKILL', + }), + `a post-spawn child error (${error.message}; the group was SIGKILLed)`, + ); + }); + child.on('close', (code, signal) => { + closed = true; + if (timedOut || postSpawnError !== undefined) { + // The timeout / post-spawn-error path's confirm poll settles once the group + // is ALSO confirmed gone (both facts). Nudge every active poll now for + // promptness. + nudgeConfirmPolls(); + return; + } + // The command itself is DONE — the hard timeout no longer applies; only group + // confirmation remains (bounded by its own grace window below). + clearTimeout(commandTimer); + // `close` alone is NOT sufficient to settle: a same-group + // grandchild with its OWN stdio survives the leader (so `close` fires with the + // group still alive) and could keep creating resources after the provision lock + // is released. Resolve ONLY once the whole group is confirmed gone; if members + // linger, SIGKILL the group (exactly like the timeout path) and confirm within + // the bounded window — an unconfirmable group QUARANTINES. + const makeResult = (): RunWranglerResult => + normalizeRunWranglerResult({ stdout, stderr, status: code, signal }); + if (probeGroupGone()) { + finish(makeResult()); + return; + } + const outcome = signalGroup('SIGKILL'); + if (typeof outcome === 'object') { + quarantine( + `the child exited (status ${String(code)}) but its process group is still alive ` + + `and could not be killed: ${outcome.error.message}`, + ); + return; + } + if (outcome === 'gone') { + groupGone = true; + } + confirmThenSettle( + makeResult, + `the child's exit (status ${String(code)}; surviving process-group members were ` + + `SIGKILLed)`, + ); + // Let the signal-side poll (if any) observe `closed` promptly too. + nudgeConfirmPolls(); + }); + + // Register with the coordinator LAST (after commandTimer + + // every handler exists). The pre-spawn checks at the top of this executor ran in + // the SAME synchronous block, so no teardown can have begun and no other + // registration can exist by the time this runs. + registerRunner(registrant); + options?.onSpawnedChild?.(child); + }); +} + +/** + * Normalize a `spawnSync` result into a {@link RunWranglerResult}, treating a + * signal-killed child or a spawn error as a FAILURE — never success. + * + * The naive `status ?? (error ? 1 : 0)` collapses a SIGTERM'd child (which + * returns `status: null`, a non-null `signal`, and NO `error`) to `0`, so a + * killed `create` would be recorded as `created` though nothing happened. So we + * fail on ANY non-numeric `status`, a non-null `signal`, or a present `error`, + * and surface the signal/spawn-error text in `stderr` for diagnostics. + */ +export function normalizeRunWranglerResult(result: { + stdout?: string | null; + stderr?: string | null; + status?: number | null; + signal?: NodeJS.Signals | null; + error?: Error; +}): RunWranglerResult { + const stdout = result.stdout ?? ''; + const notes: Array = []; + if (result.signal != null) { + notes.push(`provision: wrangler was terminated by signal ${result.signal}`); + } + if (result.error != null) { + notes.push(`provision: wrangler could not be spawned: ${result.error.message}`); + } + const failed = typeof result.status !== 'number' || result.signal != null || result.error != null; + // A failure must be non-zero. Keep a genuine non-zero exit code when present; + // otherwise synthesize `1` (a null/absent status under a signal/spawn error). + const status = failed + ? typeof result.status === 'number' && result.status !== 0 + ? result.status + : 1 + : (result.status as number); + const baseStderr = result.stderr ?? ''; + const stderr = notes.length > 0 ? [baseStderr, ...notes].filter(Boolean).join('\n') : baseStderr; + // Preserve the provenance STRUCTURALLY (not just folded into stderr text) so + // downstream classifiers can distinguish a clean non-zero exit (which MAY carry + // a not-found signal) from a kill/spawn-error (which never may). + return { + stdout, + stderr, + status, + signal: result.signal ?? null, + error: result.error != null ? result.error.message : null, + }; +} + +/** + * THE single provenance rule. A result is a HARD failure when the child was + * signal-killed or could not be spawned: it did not run to completion, so its + * buffered status/stdout/stderr is unreliable and must NEVER be read as "absent" + * or "success". Every success/absence/adopt classifier below defers to this. + */ +function isHardFailure(result: RunWranglerResult): boolean { + return result.signal != null || result.error != null; +} + +/** The child ran to completion and reported SUCCESS (a clean numeric exit 0). */ +function isCleanSuccess(result: RunWranglerResult): boolean { + return !isHardFailure(result) && result.status === 0; +} + +/** + * The child ran to completion and reported a FAILURE status (a numeric non-zero + * exit, no signal, no spawn error) — the ONLY case whose stdout/stderr may be + * classified for a resource-specific not-found signal. + */ +function isCleanFailure(result: RunWranglerResult): boolean { + return !isHardFailure(result) && typeof result.status === 'number' && result.status !== 0; +} + +/** + * The OVERALL deadline for the Hyperdrive existence lister (across ALL its paginated + * requests). A stalled body or a never-settling injected seam must never hold the + * whole-provision lock (whose heartbeat keeps refreshing while the loop is free) forever, so + * the caller fails closed at this bound. + */ +const HYPERDRIVE_LIST_TIMEOUT_MS = 60_000; + +/** + * Run a lister call under an OVERALL deadline. Builds one `AbortController`, hands its + * signal to `start` (threaded into every paginated `fetch`), and races the returned promise + * against a timer that ABORTS the signal AND rejects — so even a seam that ignores the signal + * (never settling) can NOT hold the caller (and thus the provision lock) past `ms`. + */ +function withListerDeadline(start: (signal: AbortSignal) => Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + reject(new Error(`the existence check exceeded the ${ms}ms deadline`)); + }, ms); + // The deadline timer stays REFERENCED until the lister settles (both + // settle arms below clear it). An unref'd timer let a real CLI process EXIT 0 before + // the deadline fired: with a never-settling lister nothing else holds the loop — + // proper-lockfile's heartbeat is unref'd too — so the loop drained and the process + // exited mid-provision reporting success. Vitest's own handles masked this in-worker; + // the child-process regression test spawns a real node process to prove it. + // Defer `start` into the promise chain so a synchronous throw is captured as a rejection. + Promise.resolve() + .then(() => start(controller.signal)) + .then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +/** + * The Cloudflare API token the default Hyperdrive existence check authenticates with: + * the caller-supplied one, else the ambient `CLOUDFLARE_API_TOKEN`. An empty value is + * treated as absent. This is the ONE resolver — the up-front Hyperdrive fence and the + * default lister below both use it, so the fence can never promise a check the lister + * then declines to make. + */ +function resolveHyperdriveApiToken(apiToken?: string): string | undefined { + const token = apiToken ?? process.env.CLOUDFLARE_API_TOKEN; + return typeof token === 'string' && token.length > 0 ? token : undefined; +} + +/** + * Default Hyperdrive existence check: `GET /accounts/{id}/hyperdrive/configs` + * with the CF API token, matched by name in the caller. Returns `undefined` when + * it cannot be built (no account id / no token) — the caller then fails closed + * before any blind create. Never called in unit tests — those inject a stub. + */ +function createDefaultListHyperdriveConfigs(apiToken?: string): ListHyperdriveConfigs | undefined { + const token = resolveHyperdriveApiToken(apiToken); + if (!token) { + return undefined; + } + // The account AND the pinned API base come from the per-call transport context + // (not a closure), so this default REST lister always targets the reconciled effective + // account at the SAME endpoint the spawned wrangler uses (never a hardcoded prod base + // while wrangler was redirected to staging/FedRAMP/an arbitrary endpoint). + return async ({ accountId, apiBaseUrl, signal }) => { + // Build every request URL with wrangler 4.107's EXACT concatenation + // semantics. wrangler's `performApiFetchBase` (wrangler-dist/cli.js) fetches + // `${getCloudflareApiBaseUrl(complianceConfig)}${resource}${queryString}` + // where the pinned base is used VERBATIM (a direct CLOUDFLARE_API_BASE_URL override + // is returned as-is by `getCloudflareApiBaseUrlFromEnv() ?? …` — never trimmed or + // normalized) and its hyperdrive `listConfigs` resource interpolates the account id + // RAW: `/accounts/${accountId}/hyperdrive/configs`. The old one-sided + // trailing-slash strip made this lister and the spawned wrangler hit DIFFERENT + // endpoints on a custom base ending in `/` (`…/api/accounts/…` vs + // `…/api//accounts/…`), so the existence check could disagree with the create. + // Mirror the concatenation byte-for-byte instead — no trimming, no encoding. + const perPage = 100; + const listUrl = (page: number): string => + `${apiBaseUrl}/accounts/${accountId}/hyperdrive/configs?page=${page}&per_page=${perPage}`; + // Fetch ALL pages (Hyperdrive names are NOT unique — paid accounts allow + // ~25 configs, paginated) and FAIL CLOSED on any non-2xx, `success !== true`, + // or a malformed entry. Never coerce a malformed envelope/entry to "absence": + // that would mint a duplicate config on a re-run. + // + // Completeness is PROVEN, not assumed: we require consistent pagination + // metadata (`result_info.total_count`) and keep going until the DISTINCT + // config count equals it. Any of — missing metadata, an empty page before all + // configs are seen, a repeated/duplicate id, an inconsistent total, or an + // overshoot — throws (fail closed) rather than silently declaring the list + // complete (which could miss a same-named config on an unread page and mint a + // duplicate). + const byId = new Map(); + let expectedTotal: number | undefined; + for (let page = 1; ; page++) { + const res = await fetch( + listUrl(page), + // Apply the caller's deadline signal to EVERY paginated request, so a stalled + // body / never-settling page is aborted rather than holding the provision lock forever. + { headers: { Authorization: `Bearer ${token}` }, signal }, + ); + if (!res.ok) { + throw new Error(`Cloudflare API returned HTTP ${res.status} listing Hyperdrive configs`); + } + const body = (await res.json()) as Record; + if (!isRecord(body) || body.success !== true || !Array.isArray(body.result)) { + throw new Error( + 'Cloudflare API returned an unsuccessful or malformed Hyperdrive configs payload', + ); + } + // Pagination metadata is REQUIRED to prove completeness. Its absence means + // we cannot know whether more pages exist → fail closed. + const info = body.result_info; + const totalCount = + isRecord(info) && typeof info.total_count === 'number' && Number.isInteger(info.total_count) + ? info.total_count + : undefined; + if (totalCount === undefined || totalCount < 0) { + throw new Error( + 'Cloudflare API returned Hyperdrive configs without valid pagination metadata ' + + '(result_info.total_count); cannot prove the list is complete', + ); + } + if (expectedTotal === undefined) { + expectedTotal = totalCount; + } else if (totalCount !== expectedTotal) { + throw new Error( + 'Cloudflare API returned inconsistent Hyperdrive pagination totals across pages; ' + + 'cannot prove the list is complete', + ); + } + // An empty page before we have gathered every config is premature truncation. + if (body.result.length === 0 && byId.size < expectedTotal) { + throw new Error( + 'Cloudflare API returned an empty Hyperdrive page before all configs were listed; ' + + 'cannot prove the list is complete', + ); + } + for (const entry of body.result) { + if ( + !isRecord(entry) || + typeof entry.id !== 'string' || + entry.id.length === 0 || + typeof entry.name !== 'string' || + entry.name.length === 0 + ) { + throw new Error( + 'Cloudflare API returned a malformed Hyperdrive config entry (missing id/name)', + ); + } + if (byId.has(entry.id)) { + throw new Error( + 'Cloudflare API returned a duplicate Hyperdrive config id across pages; ' + + 'cannot prove the list is complete', + ); + } + byId.set(entry.id, { id: entry.id, name: entry.name }); + } + if (byId.size > expectedTotal) { + throw new Error( + 'Cloudflare API returned more Hyperdrive configs than result_info.total_count; ' + + 'cannot prove the list is complete', + ); + } + // Done ONLY when the distinct count matches the proven total. Each remaining + // iteration adds ≥1 distinct id (a duplicate or empty page threw above), so + // this loop is bounded by `expectedTotal`. + if (byId.size === expectedTotal) { + break; + } + } + // NOTE: the per-entry id/name + duplicate-id checks above are needed for this REST + // path's pagination-completeness PROOF (the `byId` map). They are NOT the adoption + // gate: `selectHyperdriveMatch` at the CALLER is the single authoritative validator + // that covers BOTH this default path AND any injected lister. + return [...byId.values()]; + }; +} + +/** + * The ONE strict Hyperdrive-list validator, applied at the CALLER after EITHER + * lister returns (the default fetch OR an injected seam), so validation can never live + * only inside the default fetch. Validates EVERY member up front — BEFORE any name + * filtering: rejects any non-object member, empty/non-string `name`, and duplicate `id`, + * AND requires every member's `id` to be a REAL 32-hex Hyperdrive id (not merely + * non-empty). A malformed id on ANY member means the list is untrustworthy, so we fail + * closed rather than skip that member and mistake the target for absent. Then requires + * EXACTLY 0 or 1 exact-name match (>1 is ambiguous — Hyperdrive names are NOT + * account-unique — and fails closed) BEFORE any match/write. Returns the single + * exact-name match, or `undefined` when none matches. + */ +function selectHyperdriveMatch( + configs: Array, + name: string, + configPath: string, +): HyperdriveConfig | undefined { + const seenIds = new Set(); + const validated: Array = []; + for (const config of configs) { + if ( + !isRecord(config) || + typeof config.id !== 'string' || + config.id.length === 0 || + typeof config.name !== 'string' || + config.name.length === 0 + ) { + throw new Error( + `provision: the Hyperdrive existence check returned a malformed config entry (each must be ` + + `an object with a non-empty string id and name). Cannot verify whether ` + + `${JSON.stringify(name)} already exists; refusing to create a possible duplicate.`, + ); + } + // EVERY member's id must be a real 32-hex Hyperdrive id, checked BEFORE name + // filtering. A non-empty-but-malformed id (`" "`, `"xyz"`) on any member means the + // list is untrustworthy → fail closed (never skip it and mistake the target for + // absent, which would mint a duplicate config). + if (!isRealHyperdriveId(config.id)) { + throw new Error( + `provision: the Hyperdrive existence check returned a config ` + + `${JSON.stringify(config.name)} with a malformed id ${JSON.stringify(config.id)} ` + + `(expected a 32-character hex Hyperdrive id). Cannot verify whether ` + + `${JSON.stringify(name)} already exists; refusing to create a possible duplicate.`, + ); + } + if (seenIds.has(config.id)) { + throw new Error( + `provision: the Hyperdrive existence check returned a duplicate config id ` + + `${JSON.stringify(config.id)}. Cannot verify existing configs; refusing to create a ` + + `possible duplicate.`, + ); + } + seenIds.add(config.id); + validated.push({ id: config.id, name: config.name }); + } + const named = validated.filter((c) => c.name === name); + if (named.length > 1) { + throw new Error( + `provision: cannot adopt Hyperdrive config ${JSON.stringify(name)} — ${named.length} configs ` + + `in this account share that name (ids: ${named.map((c) => c.id).join(', ')}). Hyperdrive ` + + `names are not account-unique; remove the duplicates or pin the id in "${configPath}", then ` + + `retry.`, + ); + } + return named[0]; +} + +// --- Whole-provision cross-process lock (proper-lockfile) ---------------- +// TWO independent races must be serialized across processes, so ONE lock is held for +// the WHOLE provision (acquired before the first config read, released in `finally`): +// 1. Account reconciliation. A first-run provision READS → RECONCILES → PERSISTS +// one effective `account_id`. Two first-run processes for accounts A and B could +// otherwise both see no `account_id`, overwrite each other, and then write an +// A-side result into a now-B-pinned config. Under the lock the second process +// re-reads the persisted account and FAILS CLOSED on mismatch (or reuses). +// 2. Hyperdrive list→create. Hyperdrive is unguarded and its names are NOT +// account-unique, so two concurrent provisions could both see "no match" and both +// create a permanent orphaned duplicate. Under the lock the second run's +// read-first sees the id the first wrote and SKIPS. +// The hand-rolled O_EXCL lock this replaces had classic protocol bugs (a freshly +// created file is visible before its metadata is written; age-only expiry ignoring a +// live owner; a check-then-unlink stale-takeover race; an ownerless release that could +// remove a successor's lock). `proper-lockfile` does atomic `mkdir`-based acquisition, +// mtime-refresh so a live owner never expires by age, stale-compromise detection, and +// ownership-scoped release. + +interface ProvisionLock { + /** The locked marker path (`.void-provision.lock`), for diagnostics. */ + readonly lockTargetPath: string; + /** + * Drop the mutex. REJECTS on a genuine (non-`onCompromised`) unlock failure so a + * LEAKED lock is surfaced, never hidden behind a "successful" provision. Idempotent: + * repeated calls return the SAME settled promise. + */ + release: () => Promise; + /** + * RETAIN the mutex intentionally (quarantine): a spawned wrangler child + * could not be confirmed dead, so the on-disk mutex directory must OUTLIVE this process + * to keep blocking concurrent provisions. `proper-lockfile` registers a signal-exit + * sweep that `rmdirSync`s every still-TRACKED lock on process exit, so simply "not + * releasing" is not enough — this detaches the lock from the library's internal + * tracking (stopping the mtime heartbeat too), leaving the mutex directory on disk. + * + * It ALSO persists the quarantine OWNER METADATA (`info`: the + * unconfirmed child's pid/pgid + reason + timestamp) in an atomically-written sidecar + * next to the lock (`.quarantine.json`). The retained mutex is NO LONGER + * released by mere age: every later acquisition gates on the sidecar first (see + * {@link acquireProvisionLock}) — it proceeds only after probing the recorded group + * dead (ESRCH), clearing the sidecar + stale mutex itself; while the group may live + * (or the probe fails) it FAILS CLOSED. Idempotent; a later `release()` becomes a + * no-op (`ERELEASED` → success) and never removes the retained directory. + * + * DURABLE sidecar persistence is a PRECONDITION for the detach: the + * detached mutex's heartbeat stops, so without a durable sidecar there is nothing for + * the acquisition gate to gate on and the mutex would go mtime-stale and be STOLEN + * while the child may live (the previous code warned and detached anyway — fail open). + * If the sidecar cannot be written+verified this THROWS instead, leaving the lock + * fully TRACKED (heartbeat running, `release()` still functional, NOT stealable): the + * error states the lock is intentionally still held and how to recover manually. A + * later `quarantine()` call retries the persistence. + */ + quarantine: (info: QuarantineInfo) => void; +} + +/** The quarantine sidecar path for a lock target. */ +function quarantineSidecarPath(lockTarget: string): string { + return `${lockTarget}.quarantine.json`; +} + +/** + * Atomically persist the quarantine owner metadata: write a temp file, + * then `rename` over the final path, so a concurrent acquisition gate can never read + * partial JSON. On POSIX the spawned wrangler child is `detached` (its own process-group + * leader), so the recorded `pgid` IS its pid; Windows has no process groups (the gate + * probes the pid directly there). + * + * DURABLE: the temp file is fsync'd before the rename, the directory is + * fsync'd after it (see the classification below), and the final sidecar is READ + * BACK and verified. Any failure THROWS — callers treat persistence as a PRECONDITION + * for detaching the lock from proper-lockfile's tracking (see + * {@link ProvisionLock.quarantine}); a sidecar that merely "probably" landed is not a + * durable marker the acquisition gate can rely on. + */ +function writeQuarantineSidecar( + sidecarPath: string, + info: QuarantineInfo, + dirDurability?: ProvisionLockOptions['dirDurability'], +): void { + const payload = JSON.stringify( + { + pid: info.pid ?? null, + pgid: process.platform === 'win32' ? null : (info.pid ?? null), + reason: info.reason, + quarantinedAt: new Date().toISOString(), + quarantinedBy: process.pid, + // The sidecar GENERATION: a recovery decision may only remove the + // exact generation it just inspected under the recovery mutex (see + // {@link assertQuarantineClearedOrClear}), so a decision made against a dead + // generation can never delete a NEWER quarantine persisted meanwhile. Random UUID + // + a monotonic timestamp so two generations can never collide. + generation: `${randomUUID()}.${process.hrtime.bigint().toString()}`, + }, + null, + 2, + ); + const tmpPath = `${sidecarPath}.tmp-${process.pid}`; + try { + const fd = openSync(tmpPath, 'w'); + try { + writeSync(fd, payload); + fsyncSync(fd); + } finally { + closeSync(fd); + } + renameSync(tmpPath, sidecarPath); + } catch (error) { + try { + unlinkSync(tmpPath); + } catch { + // Best-effort temp cleanup — the write failure below is the real signal. + } + throw error; + } + // The DIRECTORY entry (the rename above) must be durable too: without + // a directory fsync a crash can lose the just-renamed sidecar, leaving the retained + // heartbeat-less mutex stale-reclaimable under a live child. Only codes meaning "this + // platform/filesystem genuinely cannot fsync a directory" are ignored — enumerated: + // - EISDIR / EPERM — Windows cannot open a directory with O_RDONLY at all (libuv + // surfaces one of these depending on the Windows version/path kind); + // - EINVAL — fsync(2) on a directory fd is rejected by some filesystems + // (certain FUSE/network mounts) and platforms; + // - ENOTSUP/ENOSYS — the operation is not supported/implemented at all. + // EVERYTHING else (EIO, ENOSPC, EBADF, …) is a REAL durability failure: the sidecar + // is NOT a durable marker, so the just-renamed (non-durable) file is best-effort + // rolled back and the error PROPAGATES — the caller keeps the lock fully tracked and + // the retention watchdog retries the persistence. + const dirOpen = dirDurability?.openSync ?? openSync; + const dirFsync = dirDurability?.fsyncSync ?? fsyncSync; + const dirClose = dirDurability?.closeSync ?? closeSync; + try { + const dirFd = dirOpen(dirname(sidecarPath), 'r'); + try { + dirFsync(dirFd); + } finally { + dirClose(dirFd); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? ''; + if (!['EISDIR', 'EPERM', 'EINVAL', 'ENOTSUP', 'ENOSYS'].includes(code)) { + try { + unlinkSync(sidecarPath); + } catch { + // Best-effort rollback of the non-durable file — the propagated error below is + // the real signal either way (a leftover sidecar is conservative: it makes the + // gate fail closed until its recorded pid is probed). + } + throw error; + } + // Platform-unsupported directory fsync — the read-back below is still required. + } + // VERIFY: the durable marker must be present and carry the recorded owner. + const readBack: unknown = JSON.parse(readFileSync(sidecarPath, 'utf-8')); + if ( + !isRecord(readBack) || + readBack.pid !== (info.pid ?? null) || + readBack.reason !== info.reason + ) { + throw new Error(`the quarantine sidecar "${sidecarPath}" did not verify after writing`); + } +} + +function readPositiveInt(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; +} + +/** + * The quarantine acquisition GATE. Every provision acquires its mutex + * through {@link acquireProvisionLock} (the single choke point — grep-proven: no other + * code calls `lockfile.lock`), and this gate runs BEFORE proper-lockfile is even + * consulted, so a QUARANTINED mutex can never be stolen by the mtime-stale path while + * the recorded wrangler process group may still be alive: + * - no sidecar → nothing quarantined → proceed to normal acquisition; + * - sidecar present → probe the recorded group (`kill(-pgid, 0)`; the plain pid where + * no group exists): ESRCH (confirmed dead) → clear the sidecar and proceed; still + * alive OR the probe fails (EPERM, no recorded pid, unreadable sidecar) → FAIL + * CLOSED with the recorded metadata + manual-recovery guidance. PID reuse can make + * a dead group probe "alive" — that keeps failing closed (the safe direction; the + * guidance covers manual clearance). + * Clearing touches ONLY the sidecar (unlink, ENOENT-tolerant — concurrent clearers are + * fine). The retained mutex DIRECTORY is deliberately NOT removed by hand: its + * heartbeat stopped at quarantine, so proper-lockfile's own stale lifecycle reclaims it + * during the acquisition below — a manual rmdir here could race a concurrent acquirer's + * fresh mutex (remove-then-acquire is not atomic across processes) and let two + * provisions hold the lock at once. + * + * This gate now ALWAYS runs under the RECOVERY mutex (see + * {@link acquireProvisionLock}), so its read IS the re-read-under-the-mutex every + * recoverer must base its decision on, and a clear may only remove the exact sidecar + * GENERATION it just inspected: the sidecar is re-read immediately before the unlink + * and the decision fails closed if the generation changed (a newer quarantine was + * persisted by the current main-mutex holder — whose quarantine writes are the one + * interleaving the recovery mutex cannot serialize — while this decision was pending). + * `onBeforeClear` is a test seam injecting exactly that interleaving deterministically. + */ +function assertQuarantineClearedOrClear(lockTarget: string, onBeforeClear?: () => void): void { + const sidecarPath = quarantineSidecarPath(lockTarget); + if (!existsSync(sidecarPath)) { + return; + } + let recorded: Record | undefined; + try { + const parsed: unknown = JSON.parse(readFileSync(sidecarPath, 'utf-8')); + recorded = isRecord(parsed) ? parsed : undefined; + } catch { + recorded = undefined; + } + const pid = readPositiveInt(recorded?.pid); + const pgid = readPositiveInt(recorded?.pgid); + const reason = typeof recorded?.reason === 'string' ? recorded.reason : undefined; + const quarantinedAt = + typeof recorded?.quarantinedAt === 'string' ? recorded.quarantinedAt : undefined; + // Prefer the recorded GROUP (negative-pid probe); fall back to the plain pid where no + // group was recorded (Windows). No probe target at all → cannot verify → fail closed. + const probeTarget = pgid !== undefined ? -pgid : pid; + let confirmedDead = false; + if (probeTarget !== undefined) { + try { + process.kill(probeTarget, 0); + // Signal 0 succeeded — some member of the recorded group still exists → alive. + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + confirmedDead = true; + } + // EPERM/other — the probe could not be performed → treat as possibly alive. + } + } + if (!confirmedDead) { + const who = + pid !== undefined || pgid !== undefined + ? `process group ${String(pgid ?? pid)}` + : 'an unrecorded process'; + throw new Error( + `provision: refusing to provision — a previous provision QUARANTINED this config's lock ` + + `because a spawned wrangler child could not be confirmed terminated (${who}` + + `${quarantinedAt ? `, quarantined at ${quarantinedAt}` : ''}` + + `${reason ? `; reason: ${reason}` : ''}). It may still be creating resources. Verify the ` + + `process is dead (e.g. \`kill -0 ${String(pid ?? '')}\`; terminate it if not) and ` + + `re-run — the quarantine then clears automatically. If you are CERTAIN the recorded ` + + `process is unrelated (pid reuse) or gone, remove "${sidecarPath}" and the ` + + `"${lockTarget}.lock" directory manually, then retry.`, + ); + } + // Confirmed dead → clear the sidecar ONLY (see the doc comment: the retained mutex + // directory is reclaimed by proper-lockfile's stale lifecycle, never by hand). + // + // Generation-verified: RE-READ the sidecar and remove it ONLY if it + // still carries the exact generation this decision inspected. A holder quarantining + // (the one write the recovery mutex cannot serialize) between the read above and this + // unlink would otherwise have its NEWER sidecar deleted by this now-stale decision. + const inspectedGeneration = + typeof recorded?.generation === 'string' ? recorded.generation : undefined; + onBeforeClear?.(); + let current: Record | undefined; + try { + const reread: unknown = JSON.parse(readFileSync(sidecarPath, 'utf-8')); + current = isRecord(reread) ? reread : undefined; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // Concurrently cleared — nothing left to remove; proceed to normal acquisition. + return; + } + // Unreadable at the re-read → the generation cannot be verified → fail closed below. + current = undefined; + } + const currentGeneration = + typeof current?.generation === 'string' ? current.generation : undefined; + if (current === undefined || currentGeneration !== inspectedGeneration) { + throw new Error( + `provision: refusing to clear the quarantine sidecar "${sidecarPath}" — it changed while ` + + `this recovery decision was being made (a newer quarantine generation was persisted). ` + + `Re-run to re-evaluate the current quarantine.`, + ); + } + try { + unlinkSync(sidecarPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + // Another acquirer cleared it concurrently — proceed to normal acquisition. + } +} + +/** + * `proper-lockfile`'s INTERNAL lock registry, used ONLY by {@link ProvisionLock.quarantine} + * to detach a retained lock from the library's exit sweep (`onExit` → `rmdirSync` of every + * tracked mutex) and stop its mtime-refresh heartbeat. `getLocks` is exported by + * `proper-lockfile/lib/lockfile.js` (not re-exported from the package root). Loaded lazily + * and defensively: if a future proper-lockfile reshapes its internals, quarantine degrades + * to "retained until process exit" instead of crashing. + */ +type ProperLockfileInternals = { + getLocks?: () => Record< + string, + { updateTimeout?: ReturnType; released?: boolean } + >; +}; + +function loadProperLockfileInternals(): ProperLockfileInternals | undefined { + try { + return createRequire(import.meta.url)( + 'proper-lockfile/lib/lockfile.js', + ) as ProperLockfileInternals; + } catch { + return undefined; + } +} + +/** + * Canonicalize an already-absolute config path via `realpath` so the lock identity + * and every read/write/`-c` key on the REAL file, not a lexical symlink alias. When the + * config file EXISTS, its own realpath is returned (a symlink resolves to its target). + * When it does NOT yet exist, `realpath` would throw (ENOENT), so we canonicalize the + * PARENT directory (which normally exists — the project root) and rejoin the basename, so + * two aliases to the same not-yet-created config still resolve to one identity. If neither + * the file nor its parent can be canonicalized, the lexical path is returned unchanged (no + * worse than before, and the common case — an existing project dir — is always canonical). + */ +function canonicalizeConfigPath(absoluteConfigPath: string, depth = 0): string { + try { + return realpathSync(absoluteConfigPath); + } catch (error) { + // ONLY ENOENT (a path component does not exist yet) is the benign "not created yet" case + // we handle below. Any OTHER realpath error (EACCES, ELOOP for a real symlink cycle, + // ENOTDIR) means the path is genuinely unresolvable — RETHROW so it is never swallowed + // into a bogus lexical fallback that both aliases and the target could disagree on. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + // ENOENT: the FINAL path component may itself be a DANGLING SYMLINK — it exists as a link, + // but its target does not exist YET (a not-yet-created config). If so, resolve the link to + // its intended target and canonicalize THAT, so an alias and its target share ONE lock + // identity (both writes hit the same real file). `lstatSync` does NOT follow the link, so it + // reports the link itself. + let linkStat: ReturnType | undefined; + try { + linkStat = lstatSync(absoluteConfigPath); + } catch { + // The final component is genuinely absent (not even a link) — fall through to the + // parent-dir fallback below. + linkStat = undefined; + } + if (linkStat?.isSymbolicLink()) { + // Bound the walk so a pathological chain of dangling links (that `realpathSync` did not + // already reject as ELOOP) cannot recurse forever — surface it as an ELOOP instead. + if (depth >= 40) { + throw Object.assign( + new Error(`ELOOP: too many symbolic links while resolving "${absoluteConfigPath}"`), + { code: 'ELOOP' }, + ); + } + const target = readlinkSync(absoluteConfigPath); + return canonicalizeConfigPath(resolve(dirname(absoluteConfigPath), target), depth + 1); + } + // Genuinely absent, NON-symlink file — canonicalize the PARENT dir (normally the project + // root, which exists) + rejoin the real basename, so two lexical aliases to the same + // not-yet-created config still resolve to one identity. + const dir = dirname(absoluteConfigPath); + try { + return join(realpathSync(dir), basename(absoluteConfigPath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + return absoluteConfigPath; + } +} + +/** + * Overridable `proper-lockfile` knobs — production uses the module defaults; unit tests + * pass a SHORT `stale`/`update` (to exercise heartbeat freshness under a slow call) or a + * failing `fs` (to exercise a leaked-mutex unlock failure). `fs` is `proper-lockfile`'s + * callback-style filesystem seam (mkdir/stat/utimes/rmdir/realpath). + */ +export interface ProvisionLockOptions { + stale?: number; + update?: number; + retries?: number | { retries: number; factor?: number; minTimeout?: number; maxTimeout?: number }; + fs?: unknown; + /** + * Test seam — runs between a confirmed-dead recovery decision and its + * generation-verified sidecar clear, so a test can deterministically inject the ONE + * interleaving the recovery mutex cannot serialize (the current main-mutex holder + * persisting a NEWER quarantine generation). Never set in production. + */ + onBeforeQuarantineClear?: () => void; + /** + * Test seam — overrides the fs functions used for the quarantine + * sidecar's DIRECTORY fsync, so a test can inject ENOSPC/EINVAL and exercise the + * durability classification. Never set in production. + */ + dirDurability?: { + openSync?: typeof openSync; + fsyncSync?: typeof fsyncSync; + closeSync?: typeof closeSync; + }; +} + +/** + * A lock whose owner has not refreshed it within this window is treated as abandoned + * (crashed) and may be compromised/stolen, so a crashed provision cannot wedge every + * future one. A LIVE owner refreshes the mutex mtime every {@link LOCK_UPDATE_MS} + * (strictly less than this), so it never expires by age. Generous vs. a real + * provision's duration. + * + * CORRECTNESS — this heartbeat is a `setTimeout` on the Node event loop, so it only + * fires while the loop is FREE. The default runner is ASYNC (`spawn`, awaited), so no + * wrangler call blocks the loop; between/`during` every `await` the pending update timer + * runs and refreshes the mtime. Fast synchronous bookkeeping (JSON parse, small config + * reads) is « `LOCK_UPDATE_MS`, so the mtime is continuously refreshed and NO other + * process ever sees this lock stale → it can never be stolen while we hold it. (The old + * blocking `spawnSync` could freeze the loop past `LOCK_STALE_MS`, starving the + * heartbeat AND `onCompromised`, which is exactly the race this closes.) + */ +const LOCK_STALE_MS = 120_000; +/** Refresh interval keeping a live owner's lock fresh (must be < {@link LOCK_STALE_MS}). */ +const LOCK_UPDATE_MS = 20_000; +/** + * Backoff budget while waiting for a held (non-stale) lock. Polls with jittered backoff + * for roughly a minute before giving up, so a concurrent provision blocks (not spins) + * until the holder releases, then proceeds. + */ +const LOCK_RETRIES = { retries: 100, factor: 1.5, minTimeout: 200, maxTimeout: 2_000 } as const; + +/** + * The stale window of the RECOVERY mutex (the small proper-lockfile + * lock serializing every gate→probe→clear→acquire→revalidate decision — see + * {@link acquireProvisionLock}). Deliberately SHORT: its critical section is a few + * synchronous fs reads plus one mkdir, so a crashed recoverer frees it within seconds + * (proper-lockfile's own heartbeat keeps a live recoverer's window fresh). + */ +const RECOVERY_LOCK_STALE_MS = 6_000; + +/** + * Normalize the `retries` knob into the ONE backoff schedule OUR + * acquisition loop runs (proper-lockfile's internal retrying is disabled with + * `retries: 0` so the quarantine gate runs on EVERY attempt — see + * {@link acquireProvisionLock}). A bare number keeps {@link LOCK_RETRIES}' backoff shape. + */ +function normalizeLockRetries(retries: ProvisionLockOptions['retries']): { + retries: number; + factor: number; + minTimeout: number; + maxTimeout: number; +} { + if (typeof retries === 'number') { + return { ...LOCK_RETRIES, retries }; + } + return { + retries: retries?.retries ?? LOCK_RETRIES.retries, + factor: retries?.factor ?? LOCK_RETRIES.factor, + minTimeout: retries?.minTimeout ?? LOCK_RETRIES.minTimeout, + maxTimeout: retries?.maxTimeout ?? LOCK_RETRIES.maxTimeout, + }; +} + +/** + * Acquire the cross-process provision lock over the resolved config path via + * `proper-lockfile` (an atomic `mkdir`-based mutex at `.void-provision.lock.lock`). + * `proper-lockfile` requires the locked pathname to EXIST, so we lock a DEDICATED + * adjacent marker file (`.void-provision.lock`), creating it if absent — + * never the real config, so the writer's missing-vs-empty config semantics stay + * untouched. Returns a handle whose `release()` drops the mutex; ALWAYS call it in a + * `finally` so an interruption never leaks the lock. + * + * `onCompromised` FAILS CLOSED (re-throws): if the lock is ever detected stolen (its + * mtime changed, or the mutex vanished under a live owner), provisioning must NEVER + * continue — a compromised lock means another process may be racing the same config. + * + * Acquisition is ONE atomic recovery protocol, applied on EVERY + * attempt: quarantine GATE → single non-retrying proper-lockfile acquisition → + * REVALIDATE the gate under ownership → clear-or-abort. Retrying is owned by THIS + * function (proper-lockfile runs with `retries: 0`), so a waiter that entered while the + * holder was healthy re-runs the gate on every backoff attempt and can never stale-take + * a mutex whose holder QUARANTINED mid-wait; the post-acquire revalidation catches a + * sidecar that appeared between the last gate and the acquisition itself (the fresh + * lock is released and the acquisition fails closed). + * + * The ENTIRE per-attempt protocol runs under a second small + * proper-lockfile RECOVERY mutex (`.recovery`, `retries: 0`, short stale): + * without it, two recoverers could interleave ACROSS processes — B reads a dead sidecar + * S0, gets descheduled; A clears S0, acquires the main mutex, quarantines again writing + * a LIVE sidecar S1; B resumes and unlinks the PATHNAME (now S1) on its stale decision, + * then stale-reclaims A's retained mutex (the revalidation sees no sidecar). Under the + * recovery mutex every decision re-reads the sidecar after acquiring it, and the clear + * is additionally GENERATION-verified (see {@link assertQuarantineClearedOrClear}) for + * the one interleaving the recovery mutex cannot serialize (the current MAIN-mutex + * holder quarantining mid-decision). The recovery mutex deliberately does NOT take the + * caller's `fs` seam — that seam simulates main-mutex failures. + */ +export async function acquireProvisionLock( + configPath: string, + lockOptions?: ProvisionLockOptions, +): Promise { + const lockTarget = `${configPath}.void-provision.lock`; + const recoveryTarget = `${lockTarget}.recovery`; + if (!existsSync(lockTarget)) { + writeFileSync(lockTarget, ''); + } + if (!existsSync(recoveryTarget)) { + writeFileSync(recoveryTarget, ''); + } + const staleMs = lockOptions?.stale ?? LOCK_STALE_MS; + const retryCfg = normalizeLockRetries(lockOptions?.retries); + const isElocked = (error: unknown): boolean => + (error as { code?: string } | null)?.code === 'ELOCKED'; + const backoffDelay = (attempt: number): Promise => + new Promise((resolveSleep) => + setTimeout( + resolveSleep, + Math.min(retryCfg.minTimeout * retryCfg.factor ** attempt, retryCfg.maxTimeout), + ), + ); + // One NON-retrying proper-lockfile acquisition (it still performs the library's own + // internal stale-reclaim of an abandoned mutex within the single attempt). + const acquireOnce = (): ReturnType => + lockfile.lock(lockTarget, { + stale: staleMs, + update: lockOptions?.update ?? LOCK_UPDATE_MS, + retries: 0, + // `proper-lockfile`'s callback-style fs seam — overridable so a test can force an + // unlock failure. Production uses the library default (graceful-fs). + ...(lockOptions?.fs !== undefined ? { fs: lockOptions.fs as never } : {}), + onCompromised: (error) => { + throw error; + }, + }); + // One non-retrying RECOVERY-mutex acquisition (retrying is owned by + // the loop below, so contention on a recovery decision backs off like a held main + // mutex). Always the default fs — never the caller's failure-injection seam. + const acquireRecoveryOnce = (): ReturnType => + lockfile.lock(recoveryTarget, { + stale: RECOVERY_LOCK_STALE_MS, + retries: 0, + onCompromised: (error) => { + throw error; + }, + }); + // ONE atomic recovery protocol: gate → acquire → revalidate → + // clear-or-abort, on EVERY attempt — and every attempt's WHOLE protocol + // runs under the recovery mutex. This function is the single acquisition choke point + // for every provision (grep-proven: no other code calls `lockfile.lock`), and + // proper-lockfile's INTERNAL retrying is disabled (`retries: 0` above) in favor of + // OUR loop, because the previous code gated on the quarantine sidecar ONCE before the + // library's retry loop: a contender could pass that gate while the holder was still + // healthy, keep retrying while the holder QUARANTINED (sidecar written, heartbeat + // detached), then stale-take the retained mutex without ever seeing the sidecar — + // acquiring the lock while the recorded wrangler group was alive. + let release: Awaited> | undefined; + for (let attempt = 0; ; attempt++) { + // (0) the RECOVERY mutex, serializing this whole attempt's decision across + // processes. Held by another recoverer → back off and re-run the WHOLE protocol. + let releaseRecovery: Awaited>; + try { + releaseRecovery = await acquireRecoveryOnce(); + } catch (error) { + if (isElocked(error) && attempt < retryCfg.retries) { + await backoffDelay(attempt); + continue; + } + throw error; + } + let mainHeldByOther = false; + try { + // (1) the GATE, on every attempt UNDER the recovery mutex (this read is the + // re-read every recoverer's decision must be based on): fail closed while a + // recorded group may live; generation-verified clear once it is confirmed dead. + assertQuarantineClearedOrClear(lockTarget, lockOptions?.onBeforeQuarantineClear); + // (2) one acquisition attempt; a held (non-stale) mutex backs off and loops. + try { + release = await acquireOnce(); + } catch (error) { + if (isElocked(error) && attempt < retryCfg.retries) { + mainHeldByOther = true; // back off AFTER releasing the recovery mutex + } else { + throw error; + } + } + if (!mainHeldByOther) { + // (3) REVALIDATE under ownership, before the provision body may run: the one + // writer the recovery mutex cannot serialize — the PREVIOUS main-mutex holder + // quarantining — can have persisted a sidecar between this attempt's gate and + // the acquisition (its retained mutex going mtime-stale and being reclaimed by + // the very `acquireOnce` above). A recorded group that is alive/unprobeable → + // release the fresh mutex + fail closed; a dead group → the gate cleared the + // sidecar → ownership is valid. + try { + assertQuarantineClearedOrClear(lockTarget, lockOptions?.onBeforeQuarantineClear); + } catch (gateError) { + let revalidateReleaseError: unknown; + try { + await release!(); + } catch (error) { + revalidateReleaseError = error; + } + release = undefined; + if (revalidateReleaseError !== undefined) { + throw new AggregateError( + [gateError, revalidateReleaseError], + `provision: a quarantine was found right after acquiring the provision lock AND the ` + + `fresh lock could not be released afterward. Quarantine: ` + + `${(gateError as Error).message}; release error: ` + + `${(revalidateReleaseError as Error).message}`, + { cause: gateError }, + ); + } + throw gateError; + } + } + } finally { + try { + await releaseRecovery(); + } catch { + // The recovery mutex has a SHORT stale window (seconds), so a failed rmdir + // self-heals promptly and must never fail an otherwise-valid acquisition (or + // mask the real error propagating out of the protocol above). + } + } + if (!mainHeldByOther) { + break; + } + await backoffDelay(attempt); + } + // Cache ONE shared release promise so a double-release is idempotent (the SAME + // settled promise is returned, never a second `proper-lockfile` unlock), and PROPAGATE + // a genuine unlock failure instead of swallowing it. `proper-lockfile` drops its own + // ownership state (`released = true`, `delete locks[file]`) BEFORE it rmdir's the mutex + // dir, so an EACCES/EBUSY/I/O rmdir failure leaves the `.lock` directory on disk — a + // LEAKED cross-process mutex that blocks future provisions until it goes stale — while + // the old code resolved "released" and reported the provision a success. We now surface + // it: the caller fails the provision. (Because that same drop-before-rmdir ordering + // makes `proper-lockfile`'s own release non-retryable — a second call returns + // `ERELEASED` without re-attempting the rmdir — we do NOT retry via a manual dir sweep, + // which could race a successor; the leaked mutex self-heals for future runs after the + // stale window.) An `ERELEASED` error means the lock was ALREADY dropped (e.g. by an + // `onCompromised` sweep, whose re-throw is the authority) — not a leak we caused — so + // it is treated as success. + // The loop above only breaks after a successful acquisition + revalidation. + const acquiredRelease = release!; + let releasePromise: Promise | undefined; + const doRelease = async (): Promise => { + try { + await acquiredRelease(); + } catch (error) { + if ((error as { code?: string } | null)?.code === 'ERELEASED') { + return; + } + throw new Error( + `provision: failed to release the cross-process provision lock "${lockTarget}.lock" ` + + `(${(error as Error).message}). The mutex directory may still exist and will block ` + + `future provisions until it goes stale (~${Math.round(staleMs / 1000)}s). Remove it ` + + `manually if it persists, then retry.`, + ); + } + }; + return { + lockTargetPath: lockTarget, + release: () => (releasePromise ??= doRelease()), + // Quarantine: detach this lock from proper-lockfile's internal + // tracking WITHOUT removing the on-disk mutex directory, so: + // 1. the library's signal-exit sweep (which rmdirSync's every tracked lock on + // process exit) no longer removes it — the retained mutex OUTLIVES this process + // and keeps blocking concurrent provisions while the unconfirmed child may live; + // 2. the mtime heartbeat stops; the sidecar below then guards the + // retained mutex — the acquisition gate probes the recorded group and fails + // closed while it may live, and clears the quarantine once it is confirmed + // dead (so a permanent wedge is impossible, but mere age no longer suffices); + // 3. any late release() gets ERELEASED (untracked) → treated as success, never an + // rmdir of the retained directory. + // Prevent a compromise-callback race too: mark it released before detaching. + quarantine: (info: QuarantineInfo) => { + // A no-op after a successful release (nothing to retain). + if (releasePromise !== undefined) { + return; + } + // DURABLE sidecar persistence is a PRECONDITION for everything + // below: the detach stops the mtime heartbeat, so a mutex retained WITHOUT a + // durable sidecar has nothing for the acquisition gate to gate on — it goes + // mtime-stale and is stolen while the unconfirmed child may live (the previous + // code warned and detached anyway — fail open). On persistence failure the lock + // stays fully TRACKED (heartbeat running, release() functional, NOT stealable) + // and this throws the hard error below; a later quarantine() call retries. + try { + writeQuarantineSidecar(quarantineSidecarPath(lockTarget), info, lockOptions?.dirDurability); + } catch (error) { + throw new Error( + `provision: could not persist quarantine metadata to ` + + `"${quarantineSidecarPath(lockTarget)}" (${(error as Error).message}). The ` + + `cross-process provision lock "${lockTarget}.lock" is intentionally STILL HELD by ` + + `this process (tracked, heartbeat running) so it cannot go stale and be stolen ` + + `while the unconfirmed wrangler child` + + `${info.pid !== undefined ? ` (pid ${info.pid})` : ''} may still be running. ` + + `Manual recovery: verify the process is dead` + + `${info.pid !== undefined ? ` (e.g. \`kill -0 ${info.pid}\`)` : ''} and terminate ` + + `it if not; the lock is freed when this process exits — remove the ` + + `"${lockTarget}.lock" directory if it persists, then re-run.`, + ); + } + releasePromise = Promise.resolve(); // any later release() is a settled no-op + const internals = loadProperLockfileInternals(); + const locks = internals?.getLocks?.(); + if (!locks) { + return; // degraded: retained only until process exit (internals unavailable) + } + // proper-lockfile keys its registry on the REALPATH of the locked marker file. + let key: string; + try { + key = realpathSync(lockTarget); + } catch { + key = resolve(lockTarget); + } + const tracked = locks[key]; + if (tracked) { + tracked.released = true; + if (tracked.updateTimeout !== undefined) { + clearTimeout(tracked.updateTimeout); + } + delete locks[key]; + } + }, + }; +} + +/** + * Parse a wrangler `--json` stdout into an array, distinguishing a VALID JSON + * array (returns it, possibly empty) from unparseable/non-array output (returns + * `undefined`). Members are returned UNFILTERED — a malformed member (e.g. a + * `null`) is preserved, NOT silently dropped, so {@link validateResourceList} + * can fail closed on it (a dropped malformed member could BE the target and look + * like "absent", minting a duplicate). Used everywhere an empty list must mean + * "authoritatively absent" and garbage must NOT be coerced to "absent". + */ +function parseJsonArrayStrict(stdout: string): Array | undefined { + const value = parseJsonLoose(stdout); + return Array.isArray(value) ? value : undefined; +} + +/** Parse a wrangler `--json` stdout into an object, tolerating noise/failure. */ +function parseJsonObject(stdout: string): Record | undefined { + const value = parseJsonLoose(stdout); + return isRecord(value) ? value : undefined; +} + +/** + * wrangler may print human banners before the JSON payload; slice from the + * first `{`/`[` so a leading banner does not break `JSON.parse`. + */ +function parseJsonLoose(stdout: string): unknown { + const trimmed = stdout.trim(); + const start = trimmed.search(/[[{]/); + if (start === -1) { + return undefined; + } + try { + return JSON.parse(trimmed.slice(start)); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Read the single provisioned binding entry for `field`/`binding` from the resolved + * config, delegating to the STRICT canonical reader in `wrangler.ts` + * ({@link readProvisionedBindingEntry}). Unlike the old loose first-match `.find()`, this + * validates the target field's outer shape, fails closed on a DUPLICATE binding, and runs + * the worker-wide CROSS-TYPE collision check (reusing the Task-3 collector) — so a + * duplicate/collision config can never be reported `reused` or pass a create postcondition. + */ +function readBindingEntry( + configPath: string, + field: string, + binding: string, +): Record | undefined { + return readProvisionedBindingEntry(readRootConfig(configPath), field, binding, configPath); +} + +/** + * Read + parse the root wrangler config (fresh each call — wrangler mutates it). + * BOM-aware (wrangler + the Task 3 writer strip a leading BOM before parsing, so + * we must too, else a BOM-prefixed config would look unparseable). FAILS CLOSED + * on a genuine parse error or a non-object root: never silently treat a real + * config as `{}`, which would miss existing ids (re-provision/orphan) and mask a + * corrupt file. A missing file is fine (nothing to read-first → `{}`). + */ +function readRootConfig(configPath: string): Record { + if (!existsSync(configPath)) { + return {}; + } + const raw = readFileSync(configPath, 'utf-8'); + const content = raw.startsWith(UTF8_BOM) ? raw.slice(UTF8_BOM.length) : raw; + const errors: Array = []; + const parsed = parse(content, errors, { allowTrailingComma: true }); + if (errors.length > 0) { + throw new Error( + `provision: cannot read "${configPath}" — it is not valid JSON/JSONC. Fix the file and retry.`, + ); + } + if (!isRecord(parsed)) { + throw new Error(`provision: "${configPath}" must be a JSON object.`); + } + return parsed; +} + +// --- Shared fail-closed discovery gates (one place per class of check) -------- +// The three helpers below are the SINGLE decision points every resource path +// routes through, so no sibling path can drift into a per-path bug again: +// 1. assertDiscoverySucceeded — a discovery/list/info result is only trusted +// when it exited cleanly (numeric status 0). Called BEFORE any stdout parse. +// 2. validateResourceList — a `--json` list is rejected WHOLE if ANY member +// is malformed (never silently filtered — a malformed member could BE the +// target and look "absent", minting a duplicate). +// 3. isConfirmedAbsent — a NON-zero ` info`-by-name probe means +// "absent" (→ create) ONLY on wrangler's EXACT, name-aware not-found signal; +// a signal/spawn failure or any other nonzero is NEVER absence. + +/** Escape a string for literal embedding in a `RegExp`. */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * FAIL-CLOSED gate over a discovery/list/info result. Throws (no create) unless + * the child exited cleanly — a NUMERIC `status === 0`. Because + * {@link normalizeRunWranglerResult} already folds a signal-killed child or a + * spawn error into a non-zero status, a single numeric check here rejects ALL of + * nonzero-exit / SIGTERM / spawn-error / non-numeric status. MUST be called + * BEFORE parsing stdout, so a failed result can never be parsed as "absent". + */ +function assertDiscoverySucceeded(result: RunWranglerResult, label: string): void { + if (!isCleanSuccess(result)) { + throw new Error( + `provision: "${label}" failed (exit ${String(result.status)}): ` + + `${result.stderr || result.stdout || 'no output'}. Cannot verify existing resources; ` + + `refusing to create a possible duplicate.`, + ); + } +} + +/** Field spec describing a wrangler `--json` list member's required identity. */ +interface ResourceListSpec { + /** Human resource name for messages, e.g. `KV namespace` / `D1 database`. */ + resource: string; + /** Command name for messages, e.g. `wrangler kv namespace list`. */ + command: string; + /** + * The resource-SPECIFIC member-identity validator, run over EVERY member + * inside the whole-list gate BEFORE any name filtering: KV requires a real 32-hex + * `id`; D1 requires its TWO id aliases to RECONCILE ({@link reconcileD1IdAliases} — + * every present alias a well-formed UUID, all agreeing on one id). A member whose + * identity is merely non-empty but MALFORMED (`" "`, `"bogus"`, a `local` + * placeholder) — or whose secondary alias conflicts — no longer passes: the list is + * untrustworthy, so we reject it WHOLE and fail closed rather than skip that member + * and mistake the target for absent (which would mint a duplicate). + */ + hasValidId: (member: Record) => boolean; + /** Human description of the id requirement for the whole-list rejection message. */ + idRule: string; + /** The member field carrying the account-matched name/title. */ + nameField: string; + /** Grammatical label for the name field in messages: `titled` / `named`. */ + nameLabel: string; +} + +/** + * STRICT list validator shared by KV + D1. Rejects the ENTIRE list (throws) if ANY + * member is not a well-formed object carrying a non-empty name AND a + * FORMAT-VALID id (per {@link ResourceListSpec.idValidator}). A member is NEVER + * silently filtered, and a member with a malformed id is NEVER accepted, because + * either could BE the requested target (or signal a corrupt list) and treating it as + * "absent" would mint a duplicate. Validates EVERY member up front — BEFORE any + * name-filtering by the caller — so a nonmatching member with a bogus id can no longer + * pass the gate as authoritative absence. Returns the validated members (every one + * guaranteed to have a real id + name) on success. + */ +function validateResourceList( + list: Array, + spec: ResourceListSpec, +): Array> { + const validated: Array> = []; + for (const member of list) { + if (!isRecord(member)) { + throw new Error( + `provision: "${spec.command}" returned a malformed ${spec.resource} entry (not an object). ` + + `Cannot verify existing resources; refusing to create a possible duplicate.`, + ); + } + const rawName = member[spec.nameField]; + const nameValid = typeof rawName === 'string' && rawName.length > 0; + // A REAL, RECONCILED identity — not merely one non-empty alias. + const idValid = spec.hasValidId(member); + if (nameValid && !idValid) { + throw new Error( + `provision: "${spec.command}" returned a ${spec.resource} ${spec.nameLabel} ` + + `${JSON.stringify(rawName)} with no valid id (${spec.idRule}). Cannot verify existing ` + + `resources; refusing to create a possible duplicate.`, + ); + } + if (!nameValid) { + throw new Error( + `provision: "${spec.command}" returned a malformed ${spec.resource} entry with no valid ` + + `${spec.nameField}. Cannot verify existing resources; refusing to create a possible duplicate.`, + ); + } + validated.push(member); + } + return validated; +} + +/** + * The one D1 identity outcome, so a caller can distinguish "not the requested + * database" (skip → maybe create) from "IS the requested database but ambiguous" + * (fail closed → never adopt, never mistake for absent). + */ +type D1Match = { kind: 'match'; id: string } | { kind: 'claimed-but-invalid' } | { kind: 'other' }; + +/** + * THE one D1 id-alias reconciler, shared by the WHOLE-LIST gate (every + * `d1 list` member, BEFORE any name filtering) and {@link normalizeD1} (the requested + * database's identity). D1 carries TWO id aliases (`database_id`, `uuid`); `undefined` + * or an EMPTY STRING means "not provided" (wrangler omits/blanks the secondary alias). + * The reconciled id exists ONLY when at least one alias is present, EVERY present alias + * is a well-formed real D1 UUID, and ALL present aliases agree on one id. Anything + * else — a non-string alias, a malformed value (`"bogus"`), or two disagreeing UUIDs — + * is `invalid`: the entry's identity cannot be trusted. The old gate accepted a member + * when ANY ONE alias validated, so `{ name: "other-db", uuid: , + * database_id: "bogus" }` passed the whole-list gate and authorized authoritative + * absence for the target — a corrupt list minting a duplicate. + */ +type D1ReconciledId = { kind: 'id'; id: string } | { kind: 'absent' } | { kind: 'invalid' }; + +function reconcileD1IdAliases(entry: Record): D1ReconciledId { + const present = [entry.database_id, entry.uuid].filter( + (value) => value !== undefined && value !== '', + ); + if (present.length === 0) { + return { kind: 'absent' }; + } + const ids = new Set(); + for (const value of present) { + if (!isRealD1Id(value)) { + return { kind: 'invalid' }; + } + ids.add(value); + } + if (ids.size !== 1) { + return { kind: 'invalid' }; + } + return { kind: 'id', id: [...ids][0] }; +} + +/** + * THE single strict D1 identity normalizer, shared by `d1 list --json` and + * `d1 info --json`. It reconciles the TWO name aliases (`name`, + * `database_name`) and TWO id aliases (`database_id`, `uuid`) — an EMPTY STRING is + * treated as ABSENT for each — and returns a usable id ONLY when the entry + * unambiguously IS the requested database: + * - `{ kind: 'other' }` — no non-empty name alias equals `requestedName`, so this + * entry is a different database (the caller skips it, never mistakes it for the + * target, and never adopts it under a normalization of a DIFFERENT name); + * - `{ kind: 'claimed-but-invalid' }` — a name alias DOES equal `requestedName` + * but the entry is ambiguous: CONFLICTING non-empty name aliases (e.g. + * `name:"wanted"` + `database_name:"other"`), OR not EXACTLY ONE distinct + * non-empty id (0 → no id; >1 → conflicting/blank uuid vs database_id). The + * caller fails closed (never adopt, never create a duplicate); + * - `{ kind: 'match', id }` — exactly one requested-name alias and exactly one + * unambiguous non-empty id. + * The old two-helper split (`normalizeD1Identity` preferring `database_name`, + * `d1EntryName` preferring `name`) let a `{name,database_name}` mismatch be + * SELECTED under one alias yet KEYED under the other. This collapses both into one + * exact-match rule so no sibling gap remains. + */ +function normalizeD1(entry: Record | undefined, requestedName: string): D1Match { + if (!isRecord(entry)) { + return { kind: 'other' }; + } + const names = [entry.name, entry.database_name].filter( + (value): value is string => typeof value === 'string' && value.length > 0, + ); + // Not the requested database unless SOME non-empty alias equals it exactly. + if (!names.includes(requestedName)) { + return { kind: 'other' }; + } + // It claims the requested name — now it must be UNAMBIGUOUS to adopt. + // Conflicting non-empty name aliases → ambiguous identity. + if (new Set(names).size > 1) { + return { kind: 'claimed-but-invalid' }; + } + // The ONE alias reconciler: no id, ANY malformed/non-string present alias, or + // disagreeing aliases → ambiguous, never adoptable — fail closed rather than adopt + // or (worse) mistake it for absent and mint a duplicate. + const reconciled = reconcileD1IdAliases(entry); + if (reconciled.kind !== 'id') { + return { kind: 'claimed-but-invalid' }; + } + return { kind: 'match', id: reconciled.id }; +} + +/** + * `wrangler kv namespace list` → the `{ id }` of every namespace whose title + * matches. The list has ALREADY been through {@link validateResourceList} (every + * member has a non-empty string `id` + `title`), so this only filters + collects. + * + * A title-matching namespace whose `id` is NOT a real KV id (32-hex) is ambiguous: + * we can neither adopt it nor treat it as absent (which would mint a duplicate title), so + * we FAIL CLOSED. Real wrangler always returns 32-hex ids; this guards a malformed seam. + */ +function extractKvMatches( + list: Array>, + title: string, +): Array<{ id: string }> { + const matches: Array<{ id: string }> = []; + for (const ns of list) { + if (ns.title !== title) { + continue; + } + if (!isRealKvId(ns.id)) { + throw new Error( + `provision: "wrangler kv namespace list" returned a namespace titled ${JSON.stringify(title)} ` + + `with a malformed id ${JSON.stringify(ns.id)} (expected a 32-character hex namespace id). ` + + `Cannot verify whether it already exists; refusing to create a possible duplicate.`, + ); + } + matches.push({ id: ns.id }); + } + return matches; +} + +/** True when a `d1 create` failure means the name is already taken (code 7502). */ +function isAlreadyExistsError(result: RunWranglerResult): boolean { + const text = `${result.stdout}\n${result.stderr}`; + return text.includes('7502') || /already exists/i.test(text); +} + +/** + * EXACT, name-aware not-found classifier shared by R2 + queues. Given a + * already-NON-zero ` info`-by-name probe, returns `true` (confirmed absent → + * safe to create) ONLY on wrangler's exact, resource-specific not-found signal + * for THIS name (regex-escaped). Everything else returns `false` so the caller + * fails closed — a signal/spawn failure, an auth/network/metrics error, or a + * GENERIC "not found" (e.g. `Account not found`) is NEVER treated as absence, + * which would mint a duplicate resource. + */ +function isConfirmedAbsent(result: RunWranglerResult, kind: 'r2' | 'queue', name: string): boolean { + // Provenance rule: only a CLEAN non-zero exit may be classified. A signal-killed + // or unspawnable probe is a HARD failure — its buffered text (which may contain a + // not-found line printed just before the kill) is NOT proof of absence, so we + // refuse to authorize a create on it. + if (!isCleanFailure(result)) { + return false; + } + const text = `${result.stdout}\n${result.stderr}`; + switch (kind) { + case 'r2': + // CF API error code 10006 is the exact code wrangler treats as "bucket absent" + // (its `getR2Bucket`/`isConnectedToExistingResource` branch on `err.code === 10006`). + // wrangler renders an `APIError` as `${message} [code: ${err.code}]` (its `renderError` + // in wrangler 4.107), so a genuine 10006 ALWAYS surfaces as the STRUCTURED token + // `[code: 10006]` in the error output. We require that bracketed token — NOT a bare + // `10006` anywhere in output: a bucket NAME that merely contains those digits (e.g. + // `archive-10006-prod`) inside an unrelated auth/service/network failure would falsely + // read as absence and authorize a duplicating create. Only the structured code counts. + return /\[code:\s*10006\]/.test(text); + case 'queue': + // wrangler's `getQueue` throws exactly `Queue "" does not exist`. + // Match ONLY that message for THIS queue name — a generic `not found` / + // `no such` / `Account not found` must NOT be read as queue absence. + return new RegExp(`Queue\\s+"${escapeRegExp(name)}"\\s+does not exist`, 'i').test(text); + } +} + +/** + * A `wrangler queues info ` result POSITIVELY confirms THIS queue does NOT + * exist in the account. Narrow, queue-only view of {@link isConfirmedAbsent}, shared + * with the deploy orchestrator's pre-build queue probe (`cli/deploy.ts`) so both call + * sites classify the SAME wrangler output the SAME way. Only the exact, name-aware + * `Queue "" does not exist` on a clean non-zero exit returns `true`; every other + * outcome (success, signal/spawn failure, auth or network error, generic "not found") + * returns `false` and is left for the caller to treat as inconclusive. + */ +export function isQueueConfirmedAbsent(result: RunWranglerResult, name: string): boolean { + return isConfirmedAbsent(result, 'queue', name); +} + +/** + * A `wrangler queues info ` result POSITIVELY confirms THIS queue + * exists. Queues write NO config, so a create cannot be verified by re-reading the + * config (the D1/KV/R2/Hyperdrive backstop). And wrangler's `bin.wrangler` wrapper + * maps a signal-killed inner CLI to `process.exit(0)` (`bin/wrangler.js`: + * `code === null → exit(0)`), so a KILLED probe/create can surface a clean exit 0 + * with empty/partial output. A bare clean 0 is therefore NOT proof the queue exists. + * We require BOTH a clean success (numeric exit 0, no signal/spawn error — the + * provenance rule) AND the output to name THIS exact queue: wrangler 4.107's + * `queues info` prints `Queue Name: ` for the queue it fetched by name + * (account-unique), so an exact `Queue Name: ` line is the positive signal. + * Used for BOTH the reuse probe and the post-create verify probe. + */ +function queueInfoConfirmsExists(result: RunWranglerResult, name: string): boolean { + if (!isCleanSuccess(result)) { + return false; + } + const text = `${result.stdout}\n${result.stderr}`; + return new RegExp(`^Queue Name:\\s+${escapeRegExp(name)}\\s*$`, 'm').test(text); +} + +/** + * Throw when the writer could not attach `migrations_dir` because no matching + * `d1_databases[]` entry exists. The provisioner only writes `migrations_dir` + * AFTER establishing the D1 entry (create/adopt/reuse), so a skip here means the + * D1 resource is unexpectedly absent from the config — never silently succeed + * with the gap unfilled. + */ +function assertD1MigrationsDirApplied( + result: WriteProvisionedWranglerResult, + binding: string, + configPath: string, +): void { + if (result.d1MigrationsDirSkipped) { + throw new Error( + `provision: could not set migrations_dir for D1 binding ${JSON.stringify(binding)} — no ` + + `matching d1_databases entry exists in "${configPath}" after provisioning it. The D1 ` + + `resource was not written to the config as expected; inspect the file and retry.`, + ); + } +} + +/** Which config field + id key a CONFIG-WRITING create must have populated. */ +interface CreatePostcondition { + /** Config array field the create's `--update-config` upserts into. */ + field: string; + /** The member key that must hold a REAL (non-placeholder) id afterward. */ + idField: string; + /** + * Resource-SPECIFIC id-format validator (D1 UUID / KV 32-hex / R2 bucket-name / + * Hyperdrive 32-hex). Replaces the loose `isRealValue`, so a create that reported + * success but wrote a placeholder/garbage id (e.g. a signal-killed child masked as + * exit 0) fails closed instead of being recorded as provisioned. + */ + idValidator: (value: unknown) => value is string; + /** + * When set, the member key that must EXACTLY equal the requested name (the + * resource's IDENTITY). R2's `bucket_name` and D1's `database_name` ARE the + * identity, so a create that wrote a DIFFERENT resource's name (a race, a stale + * `--update-config`, a wrong-account write) must fail closed rather than binding the + * app to the wrong resource. KV/Hyperdrive config entries carry no name to + * cross-check, so they verify only a real id. + */ + nameField?: string; + /** Human resource name for the message. */ + resource: string; + /** Command name for the message. */ + command: string; +} + +/** + * Backstop — verify a create's POSTCONDITION, RESOURCE-SPECIFICALLY + identity + * checked. wrangler 4.107's `bin.wrangler` is a WRAPPER that spawns + * `wrangler-dist/cli.js` and maps a signal-killed child (`code === null`) to + * `process.exit(0)`, so `spawnSync` can see a `create` report "success" though the + * real CLI was killed and `--update-config` wrote NO id. After every config-writing + * create (d1/kv/r2/hyperdrive) reports success we RE-READ the resolved config and + * CONFIRM the target binding now holds a REAL id AND — where the resource's name is + * its identity (R2 `bucket_name`, D1 `database_name`) — that the recorded name EXACTLY + * matches the requested one; otherwise we FAIL CLOSED. A bare "some non-placeholder + * value" check is NOT enough: for R2, `bucket_name` IS the identity, so + * `{ bucket_name: 'other' }` would otherwise pass and bind the WRONG bucket. This also + * catches any partial/silent create failure, not only kills. (Queue creates write NO + * config, so they cannot be verified this way — a killed queue-create self-heals on + * the next read-first/probe run, which re-detects absence and re-creates.) + */ +function assertCreateWroteRealId( + configPath: string, + spec: CreatePostcondition, + binding: string, + name: string, +): void { + const entry = readBindingEntry(configPath, spec.field, binding); + const idOk = !!entry && spec.idValidator(entry[spec.idField]); + const nameOk = spec.nameField === undefined || (!!entry && entry[spec.nameField] === name); + if (!idOk || !nameOk) { + throw new Error( + `provision: "${spec.command} ${name}" reported success but "${configPath}" does not hold the ` + + `expected ${spec.resource} for binding ${JSON.stringify(binding)} afterward (need a real ` + + `${spec.idField}${spec.nameField ? ` and ${spec.nameField} === ${JSON.stringify(name)}` : ''}). ` + + `The create may have been interrupted or written a different resource — wrangler's launcher ` + + `can mask a signal-killed child as exit 0. Refusing to record a wrong/unprovisioned ` + + `resource; inspect the account/config and retry.`, + ); + } +} + +// --- Option-injection guard SEPARATE from per-resource service grammar ----- +// The old single regex `^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$` was applied to EVERY +// resource, but wrangler 4.107 forwards the KV namespace positional VERBATIM as the KV +// TITLE, and Cloudflare permits titles like "My Own Namespace" (spaces) — so valid KV +// namespaces were REJECTED before discovery. It was also wrong for R2 (whose real +// grammar is lowercase DNS-like). We therefore split the two concerns: +// 1. OPTION-INJECTION guard — a SECURITY invariant applied to EVERY positional, +// identical across resources: shell-free spawning stops shell injection but NOT CLI +// OPTION injection, so a positional starting with `-` (`--help`, `-c`, `--config`) +// would be parsed by wrangler as a FLAG (a queue name is derived from a filename → +// attacker-influenced). We reject empty, a leading `-`, and any control char / +// newline (which could smuggle a token or corrupt argv). +// 2. SERVICE grammar — each resource's ACTUAL Cloudflare grammar (verified +// 2026-07 against wrangler 4.107 + live resources), so a valid name is never +// rejected AND obvious garbage still fails before a remote call. + +/** True when `value` contains an ASCII control character (0x00–0x1F or 0x7F). */ +function hasControlChar(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 0x20 || code === 0x7f) { + return true; + } + } + return false; +} + +/** The universal option-injection guard for ANY wrangler ARGV positional. */ +function assertNoOptionInjection(kind: string, name: string): void { + if ( + typeof name !== 'string' || + name.length === 0 || + name.startsWith('-') || + hasControlChar(name) + ) { + throw new Error( + `provision: refusing to pass an unsafe ${kind} name ${JSON.stringify(name)} to wrangler. A ` + + `name may not be empty, begin with "-" (it would be parsed as a CLI flag), or contain ` + + `control characters. Rename the resource and retry.`, + ); + } +} + +type ResourceNameKind = 'd1' | 'kvTitle' | 'r2' | 'hyperdrive' | 'queue'; + +/** + * Per-resource Cloudflare naming GRAMMAR, verified 2026-07 against wrangler 4.107 + * and live account resources: + * - `r2` — wrangler's own `isValidR2BucketName`: 3–63 chars, lowercase letters / + * digits / `-`, begin+end alphanumeric (DNS-like). This IS the identity. + * - `kvTitle` — the KV namespace positional is forwarded verbatim as the account TITLE; + * Cloudflare allows a broad charset INCLUDING SPACES (≤512 chars). We only + * forbid control chars (already covered by the injection guard) — spaces + * and mixed case are legal, so "My Own Namespace" is accepted. + * - `d1` / `hyperdrive` / `queue` — 1–63 chars, begin with a letter/digit, then + * letters / digits / `-` / `_` (matches every live D1 `name`, Hyperdrive + * config `name`, and Void's `QUEUE_`-derived queue names). + */ +const RESOURCE_NAME_GRAMMARS: Record< + ResourceNameKind, + { re: RegExp; label: string; rule: string } +> = { + r2: { + re: R2_BUCKET_NAME_RE, + label: 'R2 bucket', + rule: 'be 3–63 characters, contain only lowercase letters, digits and "-", and begin and end with a letter or digit', + }, + kvTitle: { + // 1–512 chars, any non-control character (spaces allowed). The leading-"-" / control + // guard is enforced separately by assertNoOptionInjection. + re: /^[\s\S]{1,512}$/, + label: 'KV namespace', + rule: 'be 1–512 characters and contain no control characters (spaces are allowed)', + }, + d1: { + re: /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/, + label: 'D1 database', + rule: 'be 1–63 characters, start with a letter or digit, and contain only letters, digits, "-" or "_"', + }, + hyperdrive: { + re: /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/, + label: 'Hyperdrive config', + rule: 'be 1–63 characters, start with a letter or digit, and contain only letters, digits, "-" or "_"', + }, + queue: { + // Cloudflare Queues: 1–63 chars, letters/digits/dashes only (NO underscores), + // and the FIRST and LAST char must be alphanumeric (no leading/trailing dash). The + // leading-`-` case is also covered by the option-injection guard, kept as belt+braces. + re: /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/, + label: 'queue', + rule: 'be 1–63 characters, contain only letters, digits and "-", and begin and end with a letter or digit', + }, +}; + +/** + * Validate a name destined for a wrangler ARGV positional: FIRST the universal + * option-injection guard, THEN the resource's own Cloudflare grammar. Fail closed on any + * violation, before the account gate or any wrangler/REST call. + * + * Exported — like {@link isQueueConfirmedAbsent} — so the deploy orchestrator's + * no-`--provision` queue probe (`cli/deploy.ts`) gates names on the SAME rules this + * provisioner enforces, instead of only the `--provision` path being protected. Queue names + * are taken verbatim from filenames with no sanitization, so without this gate a name + * beginning with `-` reaches wrangler as a FLAG rather than a positional. + */ +export function assertValidResourceName(kind: ResourceNameKind, name: string): void { + assertNoOptionInjection(RESOURCE_NAME_GRAMMARS[kind].label, name); + const grammar = RESOURCE_NAME_GRAMMARS[kind]; + if (!grammar.re.test(name)) { + throw new Error( + `provision: refusing to pass an invalid ${grammar.label} name ${JSON.stringify(name)} to ` + + `wrangler. A ${grammar.label} name must ${grammar.rule}. Rename the resource and retry.`, + ); + } +} + +/** + * The trailing flags EVERY provisioner wrangler invocation MUST carry, built + * ONCE so no call site can forget them: + * - `-c ` pins the ONE resolved config (no split-brain reads/writes); + * - `--env=` (an explicit EMPTY environment) FORCES wrangler onto the top-level + * config environment. wrangler 4.107 selects its config env as + * `args.env ?? getCloudflareEnv()`; passing an explicit empty `args.env` + * short-circuits the `??` so a project `.env`/`.env.local` that sets + * `CLOUDFLARE_ENV` (reloaded into `process.env` on every wrangler command) + * can NEVER reselect a named `env.*` block — whose `account_id`/bindings could + * differ from the single account this provision resolved, patched, and pinned. + * `--env=` is wrangler's own documented sentinel for "use the top-level + * environment" (it prints exactly this guidance when multiple env blocks exist). + * Discovery/create AND `--update-config` therefore all operate on the SAME + * top-level config/account. + */ +function wranglerConfigArgs(configPath: string): Array { + return ['-c', configPath, '--env=']; +} + +/** + * Build a redactor that strips connection-string secrets from every string the + * provisioner PROPAGATES (buffered wrangler stdout/stderr, error strings, logged + * warnings). Two layers: + * 1. the EXACT desired connection string (when this provision carries one) is replaced + * whole, so no derived message can echo it verbatim; + * 2. any URL userinfo password (`scheme://user:secret@…`) is masked generically, so a + * wrangler error that re-renders a connection string in ANY spelling still never + * leaks the password (the scheme + user survive for diagnostics). + * Applied at the single runWrangler choke point (every call site reads pre-redacted + * results) and to the Hyperdrive lister's propagated failure message. + */ +function createSecretRedactor(connectionString?: string): (text: string) => string { + return (text) => { + let out = text; + if (typeof connectionString === 'string' && connectionString.length > 0) { + out = out.split(connectionString).join('[REDACTED]'); + } + out = out.replace(/(\w[\w+.-]*:\/\/[^\s/@:]+:)[^\s@/]+(@)/g, '$1[REDACTED]$2'); + return out; + }; +} + +// --- THE one no-throw redaction boundary -------------------------- +// ALL classification and inspection of a seam rejection happens INSIDE the boundary +// ({@link redactRejection}): a hostile Proxy's `instanceof`/property traps can throw an +// error of the attacker's choosing (potentially secret-bearing), so no read of the +// original value — not even `isQuarantineError` — may run outside it. If inspection is +// unsafe anywhere, the boundary FAILS CLOSED AS QUARANTINE: a fresh INERT carrier +// marked `{ quarantine: true }` propagates, so the whole-provision mutex is RETAINED +// (conservative — the hostile value could have been hiding a real quarantine) and no +// trap error ever replaces the rejection. Values bearing user accessors or Proxies are +// NEVER returned as the original — a stateful/hostile getter can present clean text +// during sanitization and mint the secret on a later read — they are converted to an +// inert plain carrier (data properties only, prototype restored only for KNOWN error +// classes). No logging path may coerce an unknown value (`String()`/template invokes an +// arbitrary `toString`): {@link safeRedactedErrorMessage} extracts only safely-read +// fields inside the boundary. + +/** + * The engine's own lazy `Error#stack` accessor pair, captured ONCE at + * module init. V8 defines `stack` as an own get/set accessor on every Error instance, + * sharing ONE getter/setter function across all of them — so FUNCTION IDENTITY proves + * "this is the engine's deterministic lazy-stack slot" (user code cannot forge these + * function identities). It is the ONLY accessor the in-place sanitizer may handle; + * every other accessor forces the inert fallback carrier. + */ +const engineStackDescriptor = Object.getOwnPropertyDescriptor(new Error(), 'stack'); +const ENGINE_STACK_GET = engineStackDescriptor?.get; +const ENGINE_STACK_SET = engineStackDescriptor?.set; + +/** + * The ONLY prototypes a rebuilt carrier may have restored. Restoring an + * ARBITRARY prototype would reintroduce hostile behavior on the otherwise-inert carrier + * (a prototype getter or `toString` the carrier's readers would still hit), so an + * unknown prototype degrades to the base Error/Object/Array prototype the fresh carrier + * was built with. `WranglerQuarantineError` is included so a rebuilt quarantine carrier + * keeps its `instanceof` classification. + */ +const KNOWN_SAFE_PROTOTYPES = new Set([ + Error.prototype, + TypeError.prototype, + RangeError.prototype, + ReferenceError.prototype, + SyntaxError.prototype, + EvalError.prototype, + URIError.prototype, + AggregateError.prototype, + WranglerQuarantineError.prototype, + Object.prototype, + Array.prototype, +]); + +/** + * Deep-redact an UNKNOWN rejection value through the ONE text + * redactor, inside one no-throw attempt. A seam may reject with ANYTHING — an Error, a + * plain STRING, an object, an array, a FROZEN value, a graph carrying secrets in + * non-enumerable slots (`Error.cause`, `AggregateError.errors`, `message`/`stack`), + * Maps/Sets, cycles, Proxies, accessor-bearing objects. The contract: + * 1. NEVER throw — any internal throw (hostile trap/getter anywhere) returns + * `{ ok: false }` so the caller fails closed; + * 2. NEVER propagate an unsanitized OR non-inert value: a graph of plain data + * properties (plus the engine's own lazy `stack` slot) that can be fully redacted + * IN PLACE returns the ORIGINAL (identity preserved — quarantine detection and + * metadata fields survive untouched); anything else — a Proxy anywhere, ANY user + * accessor, a frozen/non-writable secret-bearing slot — returns a fully sanitized + * INERT deep copy (data properties only, every string redacted, only KNOWN + * prototypes restored); + * 3. traverse own property DESCRIPTORS (string + symbol keys, non-enumerable + * included — covering `message`, `stack`, `cause`, `errors`), plus Map keys/values + * and Set members (whose internal slots `Object.freeze` never covers), cycle-safely. + */ +type RedactAttempt = { ok: true; value: unknown } | { ok: false }; + +function tryRedactValue(value: unknown, redactText: (text: string) => string): RedactAttempt { + try { + if (typeof value === 'string') { + return { ok: true, value: redactText(value) }; + } + if (typeof value !== 'object' || value === null) { + return { ok: true, value }; + } + if (!utilTypes.isProxy(value) && sanitizeInPlace(value, redactText)) { + return { ok: true, value }; + } + return { ok: true, value: deepSanitizedCopy(value, redactText) }; + } catch { + // Rule 1: never throw out of the attempt — a pathological value (throwing traps or + // getters anywhere) makes the caller fail closed instead. + return { ok: false }; + } +} + +/** + * Attempt to redact `root` fully IN PLACE. Returns `true` only when EVERY string + * that needed redaction was verifiably rewritten (assignment read back); any immutable + * or unverifiable slot holding a secret returns `false` so the caller switches to the + * fallback-carrier copy. Partial rewrites applied before a `false` are harmless — they + * only ever REMOVE secret text, and the caller then discards the original anyway. + */ +function sanitizeInPlace(root: object, redactText: (text: string) => string): boolean { + const seen = new WeakSet(); + let ok = true; + const visitChild = (child: unknown): void => { + if (typeof child === 'object' && child !== null) { + visit(child); + } + }; + const visit = (v: object): void => { + if (!ok || seen.has(v)) { + return; + } + seen.add(v); + // A Proxy ANYWHERE in the graph forces the inert fallback carrier: + // its traps run arbitrary code on every later read, so a proxied original can mint + // fresh secrets (or throw) after "passing" this walk. Detection is native + // (`util.types.isProxy`), not trap-observable. + if (utilTypes.isProxy(v)) { + ok = false; + return; + } + // Map/Set data lives in internal slots `Object.freeze` does NOT cover, so their + // members are always rewritable in place (delete + re-add / set). + if (v instanceof Map) { + // Snapshot first — the loop mutates the Map while iterating. + for (const [key, val] of Array.from(v.entries())) { + const newKey = typeof key === 'string' ? redactText(key) : key; + const newVal = typeof val === 'string' ? redactText(val) : val; + visitChild(key); + visitChild(val); + if (newKey !== key) { + v.delete(key); + } + if (newKey !== key || newVal !== val) { + v.set(newKey, newVal); + } + if (!ok) { + return; + } + } + return; + } + if (v instanceof Set) { + // Snapshot first — the loop mutates the Set while iterating. + for (const member of Array.from(v.values())) { + if (typeof member === 'string') { + const red = redactText(member); + if (red !== member) { + v.delete(member); + v.add(red); + } + } else { + visitChild(member); + } + if (!ok) { + return; + } + } + return; + } + // EVERY own property — string AND symbol keys, non-enumerable included (an Error's + // `message`/`stack`/`cause` and an AggregateError's `errors` are non-enumerable own + // data properties the old `Object.keys` walk missed entirely). + for (const key of Reflect.ownKeys(v)) { + const desc = Object.getOwnPropertyDescriptor(v, key); + if (!desc) { + continue; + } + if (desc.get !== undefined || desc.set !== undefined) { + // ONLY the engine's own lazy `Error#stack` pair may be handled + // in place: its getter is deterministic (formats the captured frames + the + // current message) and rewriting through its setter morphs the slot into a + // verified data property. EVERY other accessor forces the fallback carrier — a + // stateful/hostile getter can return clean text during THIS walk and mint the + // secret on a later read, so an accessor-bearing original must never be + // returned as "sanitized" (rule 2). + const isEngineStackSlot = + key === 'stack' && + ENGINE_STACK_GET !== undefined && + desc.get === ENGINE_STACK_GET && + desc.set === ENGINE_STACK_SET; + if (!isEngineStackSlot) { + ok = false; + return; + } + // The engine stack slot passes when its CURRENT text is verifiably clean, or + // when a secret-bearing string can be rewritten THROUGH its setter with the + // result read back (the stack text embeds the message, so it routinely carries + // the secret). An unverifiable write still forces the fallback carrier. + let current: unknown; + try { + current = desc.get !== undefined ? desc.get.call(v) : undefined; + } catch { + ok = false; + return; + } + if (typeof current === 'string') { + const red = redactText(current); + if (red !== current) { + if (desc.set === undefined) { + ok = false; + return; + } + try { + desc.set.call(v, red); + } catch { + ok = false; + return; + } + // The setter may have morphed the slot into a data property (V8's stack + // setter does) — verify via a FRESH descriptor read. + let after: unknown; + try { + const afterDesc = Object.getOwnPropertyDescriptor(v, key); + after = afterDesc?.get !== undefined ? afterDesc.get.call(v) : afterDesc?.value; + } catch { + ok = false; + return; + } + if (after !== red) { + ok = false; + return; + } + } + } else { + visitChild(current); + } + if (!ok) { + return; + } + continue; + } + const val = desc.value; + if (typeof val === 'string') { + const red = redactText(val); + if (red === val) { + continue; + } + if (desc.writable !== true) { + ok = false; // frozen / non-writable data slot holding a secret + return; + } + try { + (v as Record)[key] = red; + } catch { + ok = false; + return; + } + // VERIFY the write took (a sloppy-mode swallow / hostile Proxy must not pass). + if (Object.getOwnPropertyDescriptor(v, key)?.value !== red) { + ok = false; + return; + } + } else { + visitChild(val); + if (!ok) { + return; + } + } + } + }; + visit(root); + return ok; +} + +/** + * The fallback CARRIER: a fully sanitized deep copy of `root`, built when in-place + * redaction is impossible. Fresh objects/arrays/Errors/Maps/Sets (all mutable), every + * string redacted, every own property — enumerable or not, string or symbol key — + * carried over as a plain data property, accessors snapshotted (an unreadable getter + * becomes `'[unredactable]'`), the original PROTOTYPE restored so `instanceof` + * classification (e.g. {@link WranglerQuarantineError}) survives the rebuild. Cycle-safe + * (a cycle in the original maps to the same cycle among the copies). + */ +function deepSanitizedCopy(root: unknown, redactText: (text: string) => string): unknown { + const copies = new WeakMap(); + const copy = (v: unknown): unknown => { + if (typeof v === 'string') { + return redactText(v); + } + if (typeof v !== 'object' || v === null) { + return v; + } + if (copies.has(v)) { + return copies.get(v); + } + if (v instanceof Map) { + const out = new Map(); + copies.set(v, out); + for (const [key, val] of v.entries()) { + out.set(copy(key), copy(val)); + } + return out; + } + if (v instanceof Set) { + const out = new Set(); + copies.set(v, out); + for (const member of v.values()) { + out.add(copy(member)); + } + return out; + } + const out: object = Array.isArray(v) ? [] : v instanceof Error ? new Error() : {}; + // Restore the prototype ONLY for KNOWN error/base classes. The + // carrier must be INERT: an arbitrary prototype could carry hostile getters or a + // hostile `toString` that later reads/coercions of the carrier would invoke, so an + // unknown prototype degrades to the base one `out` was built with (the structural + // fields below still carry the metadata; `redactRejection` restores the quarantine + // classification structurally when it was lost this way). + const proto: unknown = Object.getPrototypeOf(v); + if (typeof proto === 'object' && proto !== null && KNOWN_SAFE_PROTOTYPES.has(proto)) { + try { + Object.setPrototypeOf(out, proto); + } catch { + // Keep the base prototype. + } + } + copies.set(v, out); + for (const key of Reflect.ownKeys(v)) { + const desc = Object.getOwnPropertyDescriptor(v, key); + if (!desc) { + continue; + } + let val: unknown; + if (desc.get !== undefined || desc.set !== undefined) { + try { + val = desc.get !== undefined ? desc.get.call(v) : undefined; + } catch { + val = '[unredactable]'; + } + } else { + val = desc.value; + } + try { + Object.defineProperty(out, key, { + value: copy(val), + writable: true, + enumerable: desc.enumerable === true, + configurable: true, + }); + } catch { + // e.g. an array's non-configurable `length` — the index copies already set it. + } + } + return out; + }; + return copy(root); +} + +/** + * Extract a SAFELY-READ, redacted one-line message from an unknown + * value, for logging. Never coerces an object (`String()`/template literals invoke an + * arbitrary — possibly hostile — `toString`): only primitives are stringified, and for + * objects only a plain string `message` field is read (inside the no-throw boundary; a + * throwing trap/getter degrades to the generic placeholder). THE one helper every + * logging path uses for unknown rejection values (grep-proven: the Hyperdrive lister + * warning and the rebuilt-carrier message below). + */ +function safeRedactedErrorMessage(value: unknown, redactText: (text: string) => string): string { + try { + if (typeof value === 'string') { + return redactText(value); + } + if ( + value === null || + value === undefined || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { + return String(value); + } + const message = (value as { message?: unknown }).message; + if (typeof message === 'string') { + return redactText(message); + } + } catch { + // A throwing trap/getter — fall through to the withheld placeholder. + } + return 'the rejection could not be rendered safely; its content was withheld'; +} + +/** The conservative carrier for a rejection that resists inspection. */ +function unsafeInspectionQuarantineCarrier(): Error { + const carrier = new Error( + 'provision: a propagated rejection could not be safely inspected (a property or ' + + 'prototype trap threw during redaction); its content was withheld. Treating it ' + + 'conservatively as an unconfirmed-wrangler-child (quarantine) failure: the ' + + 'whole-provision lock is retained, not released.', + ); + Object.assign(carrier, { quarantine: true as const, pid: undefined }); + return carrier; +} + +/** + * Redact a seam REJECTION with EVERYTHING — the quarantine + * classification included — inside the ONE no-throw boundary, GUARANTEEING: + * 1. this function NEVER throws (pre-fix, `isQuarantineError`'s `instanceof` ran on + * the ORIGINAL value before any boundary — a Proxy with throwing + * `getPrototypeOf`/property traps made the redactor itself throw a + * secret-bearing, classification-free replacement error, and the outer path then + * RELEASED the mutex under a possibly-live child); + * 2. if inspection is unsafe ANYWHERE (classification, redaction, message reads), it + * FAILS CLOSED AS QUARANTINE: a fresh INERT sanitized carrier marked + * `{ quarantine: true }` propagates, so the mutex is retained (conservative — the + * hostile value could have been hiding a real quarantine) and no trap error or + * secret ever escapes; + * 3. a quarantined rejection can never degrade into an ordinary failure: the + * classification + pid are captured inside the boundary and restored structurally + * on the carrier when a rebuild lost them. + */ +function redactRejection(error: unknown, redactText: (text: string) => string): unknown { + try { + const wasQuarantine = isQuarantineError(error); + const quarantinePid = + wasQuarantine && typeof (error as WranglerQuarantineError).pid === 'number' + ? (error as WranglerQuarantineError).pid + : undefined; + const attempt = tryRedactValue(error, redactText); + if (!attempt.ok) { + return unsafeInspectionQuarantineCarrier(); + } + const redacted = attempt.value; + if (!wasQuarantine || isQuarantineError(redacted)) { + return redacted; + } + // The redactor had to rebuild and the carrier lost the classification (e.g. the + // original relied on a prototype outside the known-safe set) — restore it + // structurally on the (always freshly-built, inert) carrier. + const carrier = + redacted instanceof Error + ? redacted + : new Error(safeRedactedErrorMessage(redacted, redactText)); + Object.assign(carrier, { quarantine: true as const, pid: quarantinePid }); + return carrier; + } catch { + // Rule 2 — anything that slipped past the inner boundaries still fails closed as + // an inert quarantine-conservative carrier, never a throw out of the redactor. + return unsafeInspectionQuarantineCarrier(); + } +} + +/** + * Ensure every resource in `desired` exists in the user's own Cloudflare account, + * creating the missing ones by shelling out to `wrangler` and letting + * `wrangler create --update-config` write real ids into the root config. + * For resources that already exist (adopt path) — where `create` cannot run — + * the real id is captured from `wrangler list/info` (or, for Hyperdrive, + * the account-list existence seam) and written through the Task 3 writer's adopt + * fields. Pure provisioning: NO build, deploy, or drift-check. Idempotent + + * read-first: a binding already pointing at a real id is left untouched. + */ +export async function provisionCloudflareResources( + root: string, + rootWranglerPath: string, + desired: DesiredResourceSet, + opts?: ProvisionOptions, +): Promise { + // The at-most-one-provision-per-process guard, taken SYNCHRONOUSLY at + // entry (before the first await) so two same-tick calls cannot both pass. See the + // invariant documented at {@link acquireProcessProvisionSlot}: the signal coordinator + // manages at most one {wrangler child group, provision lock} pair because of it. + const releaseProvisionSlot = acquireProcessProvisionSlot(); + try { + return await provisionWithProcessSlot(root, rootWranglerPath, desired, opts); + } finally { + releaseProvisionSlot(); + } +} + +/** The provision body, run while holding this process's single provision slot. */ +async function provisionWithProcessSlot( + root: string, + rootWranglerPath: string, + desired: DesiredResourceSet, + opts?: ProvisionOptions, +): Promise { + await Promise.resolve(); + // ONE ABSOLUTE config path threaded through read-first, wrangler `-c`, AND the + // writer, so reads and writes can never hit different files (no split-brain). + // Resolve it against `root` ONCE, up front: Node reads/writes here resolve a + // RELATIVE path against `process.cwd()`, but the default wrangler runs with + // `cwd: root` and resolves `-c` there — so with `root !== process.cwd()` a bare + // relative `rootWranglerPath` would let Node inspect one file while wrangler + // creates against another (orphan). An already-absolute path is returned as-is. + // + // Then CANONICALIZE it with `realpath` so the lock identity (and every read/ + // write/`-c`) is keyed on the REAL file, not a lexical alias. Without this, a real + // config and a symlink alias to it resolve to DIFFERENT lexical paths → DIFFERENT lock + // marker dirs (`.void-provision.lock`), so both processes acquire "the" lock at + // once and race the same underlying file. Canonicalizing collapses all aliases to one + // identity; a missing config canonicalizes its parent dir so the identity is still + // alias-stable before the file is created. + const configPath = canonicalizeConfigPath(resolve(root, rootWranglerPath)); + + // Hold ONE cross-process lock for the WHOLE provision: acquire it BEFORE + // the first config read (so account reconciliation + persistence + all discovery, + // remote work, and config writes happen against the state the lock protects), and + // ALWAYS release it (both the success and error paths below call `lock.release()`, so + // an interruption never leaks the lock; a genuine unlock failure fails the provision). + // The reconciliation + duplicate-member guard + resource work all run UNDER the lock in + // `provisionCloudflareResourcesLocked`, so a racing first-run serializes: the second + // process re-reads the persisted account/ids and fails closed (or reuses). + // + // This mutex is keyed on the local CONFIG PATH, so it only + // serializes provisions of the same config on ONE machine; two different HOSTS + // provisioning the same project can still race and create duplicates. Accepted by + // design: provision is a single-operator dev-machine action, so no remote coordination + // is built. (Documented in the Task 7 docs.) + const lock = await acquireProvisionLock(configPath, opts?.lockOptions); + // While this provision runs, a fatal-signal death under an + // UNCONFIRMED wrangler child must RETAIN the mutex: publish the lock's quarantine as + // THE single active hook (1:1 with this process's one provision slot). A registering + // wrangler child captures it at registration, and the coordinator's signal path + // invokes it SYNCHRONOUSLY — sidecar written, lock detached from the exit sweep — + // before re-raising the signal. Routed through {@link retainQuarantinedLock} so a + // persistence failure arms the retention watchdog. + const quarantineHook = (info: QuarantineInfo): void => retainQuarantinedLock(lock, info); + activeProvisionQuarantineHook = quarantineHook; + // ALWAYS release, but do NOT swallow a release failure. On body SUCCESS a failed + // unlock means the mutex leaked, so the provision must FAIL (surfaced, not hidden). On + // body FAILURE the body error is primary, but a release failure means the mutex LEAKED, + // so it must NOT be silently discarded (a leaked mutex blocks future retries). We + // preserve BOTH via an AggregateError. (`release()` is idempotent — one shared promise — + // so these paths never double-unlock.) + let result: ProvisionResult | undefined; + let threw = false; + let bodyError: unknown; + try { + result = await provisionCloudflareResourcesLocked(root, configPath, desired, opts); + } catch (error) { + threw = true; + bodyError = error; + } + // Every wrangler call has settled by here (each is awaited), so the before-death hook + // is no longer needed on any terminal path below. + if (activeProvisionQuarantineHook === quarantineHook) { + activeProvisionQuarantineHook = undefined; + } + if (threw) { + // QUARANTINE: the failure means a spawned wrangler child could not be + // confirmed dead — it may STILL be creating resources — so the mutex is INTENTIONALLY + // RETAINED (never released here; `quarantine()` also detaches it from proper-lockfile's + // exit sweep so the on-disk directory outlives this process). Releasing it would let a + // concurrent provision race the surviving child on the same config/account. + if (isQuarantineError(bodyError)) { + try { + retainQuarantinedLock(lock, { + // Defensive reads: `isQuarantineError` also accepts a structural match (a + // `{ quarantine: true }` carrier that is not the class instance). + pid: typeof bodyError.pid === 'number' ? bodyError.pid : undefined, + reason: typeof bodyError.message === 'string' ? bodyError.message : String(bodyError), + }); + } catch (quarantineError) { + // The quarantine sidecar could not be persisted, + // so the lock is intentionally STILL HELD (tracked, heartbeat running) under the + // retention watchdog {@link retainQuarantinedLock} just armed: the process will + // not drain unprotected while the unconfirmed wrangler child may still be + // creating resources. NEVER release the lock here. Surface BOTH errors. + throw new AggregateError( + [bodyError, quarantineError], + `provision failed with an unconfirmed wrangler child AND its quarantine metadata ` + + `could not be persisted — the cross-process provision lock is intentionally still ` + + `held by this process (not released, not stealable) while the child may live. ` + + `Primary error: ` + + `${bodyError instanceof Error ? bodyError.message : String(bodyError)}; quarantine ` + + `error: ` + + `${quarantineError instanceof Error ? quarantineError.message : String(quarantineError)}.`, + { cause: bodyError }, + ); + } + throw bodyError; + } + // Attempt release; if IT also fails, surface BOTH (the body error is primary but + // the leaked-mutex error must never be swallowed). An AggregateError carries both in + // `.errors`, with `.cause` set to the primary body error so a `cause`-walking logger + // still sees it. + let releaseError: unknown; + try { + await lock.release(); + } catch (error) { + releaseError = error; + } + if (releaseError !== undefined) { + throw new AggregateError( + [bodyError, releaseError], + `provision failed AND the cross-process provision lock could not be released afterward — a ` + + `leaked mutex may block future provisions. Primary error: ` + + `${bodyError instanceof Error ? bodyError.message : String(bodyError)}; release error: ` + + `${releaseError instanceof Error ? releaseError.message : String(releaseError)}.`, + { cause: bodyError }, + ); + } + throw bodyError; + } + // Body succeeded: a genuine unlock failure (leaked mutex) rejects the provision. + await lock.release(); + return result as ProvisionResult; +} + +/** Injectable seams + settings for {@link provisionCloudflareResources}. */ +export interface ProvisionOptions { + runWrangler?: RunWrangler; + accountId?: string; + apiToken?: string; + listHyperdriveConfigs?: ListHyperdriveConfigs; + log?: ProvisionLogger; + /** Test-only override of the `proper-lockfile` stale/update/retries/fs knobs. */ + lockOptions?: ProvisionLockOptions; + /** Override the OVERALL Hyperdrive-lister deadline (ms). Defaults to {@link HYPERDRIVE_LIST_TIMEOUT_MS}. */ + listTimeoutMs?: number; +} + +/** + * The provision body, run entirely UNDER the whole-provision lock (see + * {@link provisionCloudflareResources}). `configPath` is the ONE resolved absolute + * config path the lock is keyed on — threaded through read-first, wrangler `-c`, and + * the writer so reads/writes can never hit different files. + */ +async function provisionCloudflareResourcesLocked( + root: string, + configPath: string, + desired: DesiredResourceSet, + opts?: ProvisionOptions, +): Promise { + // --- Strict duplicate-member guard at the PROVISIONER BOUNDARY -------- + // `jsonc-parser.parse()` (used by `readRootConfig`) does NOT flag duplicate object + // members, and the Task 3 writer's duplicate-member guard only runs when the writer + // is invoked — which is SKIPPED when `account_id` is already correct. So without + // this, a config with duplicated members (e.g. two `kv_namespaces` arrays) could + // reach `kv namespace create` (orphan) or be falsely "reused". Run the SAME strict + // detection the writer uses (reused, not re-implemented) at the boundary — before + // account persistence, discovery, or ANY remote side effect — and fail closed. + if (existsSync(configPath)) { + const raw = readFileSync(configPath, 'utf-8'); + const content = raw.startsWith(UTF8_BOM) ? raw.slice(UTF8_BOM.length) : raw; + assertNoDuplicateMembers(content, configPath); + } + + // --- Resource-name validation (BEFORE any argv / remote work) --------- + // Every name that becomes a wrangler ARGV POSITIONAL runs through the universal + // option-injection guard (so an option-like name `--help`/`-h`/`--config` can never + // reach wrangler as a flag) AND its own per-resource Cloudflare grammar (so a valid KV + // title with spaces is accepted, while an invalid R2 bucket name is rejected). Fail + // closed here, before the account gate or any wrangler/REST call. + if (desired.d1) { + assertValidResourceName('d1', desired.d1.name); + } + for (const { title } of desired.kvNamespaces) { + assertValidResourceName('kvTitle', title); + } + if (desired.r2) { + assertValidResourceName('r2', desired.r2.bucketName); + } + if (desired.hyperdrive) { + assertValidResourceName('hyperdrive', desired.hyperdrive.name); + } + for (const queueName of desired.queues) { + assertValidResourceName('queue', queueName); + } + + // --- Resolve + pin the ONE effective Cloudflare account --------------- + // Precedence: the resolved config's `account_id` > `opts.accountId` > the ambient + // `CLOUDFLARE_ACCOUNT_ID`. wrangler 4.107 gives the resolved config's account_id + // PRECEDENCE over the env var, so if the config pins X while `opts.accountId` + // requests Y, wrangler create/list would target X while the Hyperdrive REST check + // targets Y — a cross-account create/orphan. Fail closed on a real mismatch; + // otherwise pin ONE value EVERYWHERE (the wrangler env, which scrubs CLOUDFLARE_ENV + // and overrides any different ambient account, AND the Hyperdrive REST call). + const rootConfig = readRootConfig(configPath); + const rawConfigAccountId = rootConfig.account_id; + const configAccountId = + typeof rawConfigAccountId === 'string' && rawConfigAccountId.length > 0 + ? rawConfigAccountId + : undefined; + // The config's top-level `compliance_region` co-determines the ONE pinned API base + // (FedRAMP vs public). Read + type-check it here so it can be reconciled with the ambient + // `CLOUDFLARE_COMPLIANCE_REGION` (fail closed on conflict) when the base is resolved below. + const rawComplianceRegion = rootConfig.compliance_region; + if (rawComplianceRegion !== undefined && typeof rawComplianceRegion !== 'string') { + throw new Error( + `provision: "${configPath}" has a non-string "compliance_region"; it must be "public" or ` + + `"fedramp_high". Fix the config and retry.`, + ); + } + // A PRESENT string is passed through VERBATIM (an empty string included): wrangler's + // config validation (`isOneOf("public", "fedramp_high")`) rejects any present + // out-of-choices value, so the resolver must see it to fail closed identically. + const configComplianceRegion = + typeof rawComplianceRegion === 'string' ? rawComplianceRegion : undefined; + if (configAccountId && opts?.accountId && configAccountId !== opts.accountId) { + throw new Error( + `provision: Cloudflare account mismatch — "${configPath}" pins account_id ` + + `${JSON.stringify(configAccountId)} but deploy requested ${JSON.stringify(opts.accountId)}. ` + + `Reconcile them (correct or remove the config's account_id, or deploy to the pinned account) ` + + `and retry.`, + ); + } + const ambientAccountId = + typeof process.env.CLOUDFLARE_ACCOUNT_ID === 'string' && + process.env.CLOUDFLARE_ACCOUNT_ID.length > 0 + ? process.env.CLOUDFLARE_ACCOUNT_ID + : undefined; + const effectiveAccountId = configAccountId ?? opts?.accountId ?? ambientAccountId; + + const log = opts?.log ?? defaultLogger; + const resources: Array = []; + const write = ( + patch: Parameters[1], + ): WriteProvisionedWranglerResult => writeProvisionedWranglerConfig(root, patch, { configPath }); + + // Any resource to ensure implies remote work (list/info/create/REST). + const hasRemoteWork = + !!desired.d1 || + desired.kvNamespaces.length > 0 || + !!desired.r2 || + !!desired.hyperdrive || + desired.queues.length > 0; + + // The MANDATORY account fence, at the PROVISIONER BOUNDARY (not inside the + // default runWrangler). Whenever `desired` implies remote work, an effective + // account MUST be resolvable — UNCONDITIONALLY, whether the wrangler runner and + // Hyperdrive lister are the default seams or injected. Otherwise a seam could do + // remote work (list/info/create/REST) under an ambient/interactive account we + // never persisted, so a partial run under account A followed by a retry under B + // would rebind A's resources with no reconciliation (the pin needs a + // persisted `account_id` to fail closed on the retry). Fail closed BEFORE + // selecting or calling ANY seam. + if (hasRemoteWork && !effectiveAccountId) { + throw new Error( + `provision: no Cloudflare account is resolvable — set "account_id" in your root wrangler ` + + `config or export CLOUDFLARE_ACCOUNT_ID before deploying. Refusing to provision resources ` + + `without a pinned account (wrangler would otherwise fall back to an ambient/interactive ` + + `account that is never persisted, so a later retry could rebind them elsewhere).`, + ); + } + + // Before ANY remote work, fail closed if the project dotenv would redirect + // wrangler's authenticated transport out from under the pinned base (wrangler reloads + // `.env`/`.env.local`/`.dev.vars` on every command). A single-endpoint provisioner + // cannot reason about a redirected transport vs. the pinned Hyperdrive REST base. + if (hasRemoteWork) { + assertNoTransportRedirectInProjectDotenv(root); + } + + // --- Hyperdrive blockers that are knowable BEFORE any account mutation --- + // Unlike D1/KV/R2/queues — each of which has a machine-readable wrangler probe that + // works under ANY wrangler credential, including a `wrangler login` OAuth session — + // Hyperdrive has none (wrangler 4.107 prints a human table and offers no get-by-name; + // see the Hyperdrive block below). Its existence check is therefore a direct Cloudflare + // REST call, which needs an API token of its own. Without one there is no way to tell + // "config absent" from "cannot look", and `hyperdrive create` silently mints a DUPLICATE + // on a name collision — so the run has to stop. + // + // It stops HERE, before the account pin is written and before the first create, rather + // than at the Hyperdrive block far below: by that point D1/KV/R2 have already been + // created, so the user pays for a half-provisioned account to learn about a missing + // token. The message names both ways out, because `wrangler login` is the documented + // setup and this is the one resource it does not cover. + // + // Two DIFFERENT blockers land here, and both are decided WITHOUT a network call — the + // gate is only ever "could this run reach a real Hyperdrive id by ANY route?": + // - a connection string but no token → could create, cannot check first → stop; + // - no connection string AND no token/lister → can neither create NOR adopt, so the + // only reachable outcome is the fatal `skipped` below → stop. + // A stringless run that CAN look (token or injected lister) is deliberately let through: + // adoption needs no connection string, so it may still legitimately succeed. + if (desired.hyperdrive && !opts?.listHyperdriveConfigs) { + const { binding, name, connectionString } = desired.hyperdrive; + const entry = readBindingEntry(configPath, 'hyperdrive', binding); + const alreadyProvisioned = entry !== undefined && isRealHyperdriveId(entry.id); + // The read-first path is pure config I/O and needs NO token: an already-provisioned + // Postgres app deploys fine under `wrangler login` alone. + if (!alreadyProvisioned && !resolveHyperdriveApiToken(opts?.apiToken)) { + if (connectionString) { + throw new Error( + `provision: Hyperdrive config ${JSON.stringify(name)} is not in "${configPath}" yet, and ` + + `creating it needs a Cloudflare API token. wrangler exposes no machine-readable ` + + `Hyperdrive list, so Void checks the account over the Cloudflare REST API, which a ` + + `"wrangler login" (OAuth) session cannot authenticate. Without that check a create ` + + `would silently duplicate an existing same-named config, so nothing was provisioned. ` + + `Either (a) export CLOUDFLARE_API_TOKEN with Hyperdrive edit permission and rerun, or ` + + `(b) create the Hyperdrive config yourself (dashboard / "wrangler hyperdrive create") ` + + `and add its id to the ${JSON.stringify(binding)} hyperdrive binding in ` + + `"${configPath}" — deploying an already-provisioned Hyperdrive needs no token.`, + ); + } + // NO connection string AND no way to look the account up (no token, no injected + // lister) — so this run can neither CREATE nor ADOPT. Its only possible outcome is + // the structured `skipped` far below, which the deploy orchestrator treats as FATAL. + // Reaching that point would first create D1/KV/R2 and pin `account_id`, billing the + // user for a half-provisioned account to learn about an unset variable. Stop HERE, + // before the pin and before the first create, for the SAME reason as the token fence. + // + // Deliberately NOT loaded from the project's dotenv files: project-derived env is + // read only inside the credential-scrubbed build subprocess, so making `.env.local` a + // deploy input here would re-introduce exactly the plaintext leak deploy warns about. + // The variable has to come from the shell. + throw new Error( + `provision: Hyperdrive config ${JSON.stringify(name)} is not in "${configPath}" yet, and ` + + `there is no DATABASE_URL to create it from. Nothing was provisioned. ` + + `Either (a) export DATABASE_URL with your PostgreSQL connection string and rerun ` + + `(Void reads it from the shell — a value in .env/.env.local is NOT used here, so it ` + + `never ships as a plaintext worker var), or (b) create the Hyperdrive config yourself ` + + `(dashboard / "wrangler hyperdrive create") and add its id to the ` + + `${JSON.stringify(binding)} hyperdrive binding in "${configPath}" — deploying an ` + + `already-provisioned Hyperdrive needs neither DATABASE_URL nor an API token.`, + ); + } + } + + // --- account_id (PERSIST FIRST, before selecting/calling any seam) -------- + // Pin the effective account_id NOW — before the runWrangler/lister seams are even + // selected, and before any create writes a real id — so a partial run can never + // leave freshly-minted ids in a config with NO account pin. Without this, a later + // retry under a DIFFERENT account would see the real id (read-first hit → skips + // discovery) and finally stamp its own `account_id`, binding the first account's + // resources under the second. With the pin written up front, a different-account + // retry instead hits the reconciliation above (config account ≠ requested → fail + // closed) before touching wrangler/REST. Skip the write when the config ALREADY + // pins that exact account (keeps byte-identical configs untouched). + let accountIdWritten = false; + if (effectiveAccountId && configAccountId !== effectiveAccountId) { + write({ accountId: effectiveAccountId }); + accountIdWritten = true; + } + + // The reconciled effective account, threaded as the transport context into + // EVERY wrangler + Hyperdrive invocation below (default AND injected). The boundary + // fence above guarantees a resolved account for ALL remote work, so this non-null + // assertion is sound wherever a seam is actually called (an empty `desired` reaches + // no seam and never reads it). + const transportAccountId = effectiveAccountId!; + // The ONE pinned Cloudflare API base URL (mirroring wrangler's own derivation), + // resolved from the REAL ambient env AND the config's `compliance_region` (so a FedRAMP + // config pins the `.fed` endpoint even with no ambient override, and a config-vs-ambient + // region conflict fails closed like wrangler). Threaded into BOTH the wrangler runner (via + // the spawned env's `CLOUDFLARE_API_BASE_URL`) and the Hyperdrive REST lister, so both hit + // the same endpoint. The dotenv fence above guarantees no project `.env` can move it. + const transportApiBaseUrl = resolvePinnedApiBaseUrl(process.env, configComplianceRegion); + + // Both seams are pinned to the SAME effective account + API base: the default wrangler + // runner via its env (`CLOUDFLARE_ACCOUNT_ID` + `CLOUDFLARE_API_BASE_URL` + the top-level + // `--env=` on every argv), and the default Hyperdrive lister via + // `createDefaultListHyperdriveConfigs` (below). An injected seam owns its own transport + // but is ALWAYS invoked WITH the reconciled account + base (via the single choke point + // below / the lister's `{ accountId, apiBaseUrl }` context), so it can never target a + // DIFFERENT account/endpoint than the one the config is pinned to. + const selectedRunWrangler = opts?.runWrangler ?? createDefaultRunWrangler(root); + // Every wrangler result (and any rejection message) is redacted at THIS single + // choke point, so no call site can propagate a Hyperdrive connection string (or any URL + // userinfo password a wrangler error echoes) into thrown errors / logs / diagnostics. + const redactSecrets = createSecretRedactor(desired.hyperdrive?.connectionString); + const redactResult = (result: RunWranglerResult): RunWranglerResult => ({ + ...result, + stdout: redactSecrets(result.stdout), + stderr: redactSecrets(result.stderr), + error: result.error != null ? redactSecrets(result.error) : result.error, + }); + // Single choke point: inject the reconciled account + API base into EVERY call so no + // call site can forget them (grep-proven — every `runWrangler(...)` below routes through + // here). Async: `Promise.resolve` coerces a sync-stub result OR the default runner's + // Promise, and every call site `await`s it. Awaiting yields to the event loop, so a + // real (slow) wrangler call never blocks the lock heartbeat. + const runWrangler = ( + args: Array, + callOpts?: { cwd?: string; captureJson?: boolean }, + ): Promise => + Promise.resolve( + selectedRunWrangler(args, { + ...callOpts, + accountId: transportAccountId, + apiBaseUrl: transportApiBaseUrl, + }), + ).then(redactResult, (error: unknown) => { + // A REJECTING runner (a quarantine, an injected seam rejecting a plain + // string/object/array/frozen value) must not smuggle the secret either: EVERY + // rejection value is normalized through the ONE redaction carrier before + // propagation. In-place where possible (identity/metadata survive), a sanitized + // fallback carrier otherwise — with the quarantine classification captured BEFORE + // redaction so it can never be lost to a rebuild. + throw redactRejection(error, redactSecrets); + }); + + // --- D1 ------------------------------------------------------------------ + if (desired.d1) { + const { binding, name } = desired.d1; + // The config-relative `migrations_dir` to stamp on the D1 entry. The orchestrator + // passes the sourceDir-aware value; fall back to the default when unset so a direct + // provisioner call (no orchestrator) keeps the historical `./db/migrations`. + const migrationsDir = desired.d1.migrationsDir ?? D1_MIGRATIONS_DIR; + const entry = readBindingEntry(configPath, 'd1_databases', binding); + if (entry && isRealD1Id(entry.database_id)) { + // Stored-name identity: when the entry + // STORES a `database_name`, it must EQUAL the desired name before the id is + // reused. A real id under a CONTRADICTING stored name means the binding points at + // a DIFFERENT database than this deploy wants — reusing it would silently run the + // app (and its migrations) against the wrong database. An entry storing NO name + // keeps ID-only reuse (the id alone is the identity then). + const storedName = entry.database_name; + if (storedName !== undefined && storedName !== name) { + throw new Error( + `provision: D1 binding ${JSON.stringify(binding)} in "${configPath}" stores ` + + `database_name ${JSON.stringify(storedName)} (with a real database_id), but this ` + + `deploy wants database ${JSON.stringify(name)}. Refusing to reuse a database that ` + + `contradicts the desired name. Fix "${configPath}" — correct the entry's ` + + `database_name/database_id (or remove the entry to re-provision) — or rename the ` + + `desired database, then retry.`, + ); + } + // Read-first hit. A prior run may have created the D1 (id written) then + // crashed BEFORE migrations_dir was set, so ensure + verify it here too — + // idempotent when already present — so the retry converges rather than + // leaving the gap unfilled forever. + const res = write({ d1MigrationsDir: { binding, dir: migrationsDir } }); + assertD1MigrationsDirApplied(res, binding, configPath); + resources.push({ kind: 'd1', binding, name, action: 'reused', id: entry.database_id }); + } else { + // Discovery must SUCCEED (gate) before its stdout is parsed, then the whole + // list is strictly validated — a nonzero/SIGTERM/spawn-error or a malformed + // member never parses as "absent" and mints a duplicate D1. + const listResult = await runWrangler( + ['d1', 'list', '--json', ...wranglerConfigArgs(configPath)], + { + captureJson: true, + }, + ); + assertDiscoverySucceeded(listResult, 'wrangler d1 list'); + const rawList = parseJsonArrayStrict(listResult.stdout); + if (!rawList) { + throw new Error( + `provision: "wrangler d1 list --json" did not return a JSON array; cannot verify whether ` + + `D1 database ${JSON.stringify(name)} exists. Refusing to create a possible duplicate.`, + ); + } + const databases = validateResourceList(rawList, { + resource: 'D1 database', + command: 'wrangler d1 list', + // The ONE alias reconciler gates EVERY member (never "any one alias + // valid"): a conflicting or malformed secondary alias rejects the whole list. + hasValidId: (member) => reconcileD1IdAliases(member).kind === 'id', + idRule: + 'its uuid/database_id aliases must be present, well-formed D1 UUIDs agreeing on one id', + nameField: 'name', + nameLabel: 'named', + }); + // Run the ONE strict normalizer over EVERY member against the requested + // name. Collect the members that CLAIM the requested name (match or ambiguous). + // - >1 claimant → ambiguous which to adopt → fail closed; + // - 1 ambiguous claimant → fail closed (never adopt an ambiguous id, never + // mistake it for absent and mint a duplicate); + // - 1 valid claimant → adopt; 0 → create. + const claimants = databases + .map((db) => normalizeD1(db, name)) + .filter((m): m is Exclude => m.kind !== 'other'); + if (claimants.length > 1) { + throw new Error( + `provision: "wrangler d1 list" returned ${claimants.length} D1 databases named ` + + `${JSON.stringify(name)}. D1 database names are account-unique, so this is ambiguous; ` + + `cannot pick one to adopt and refusing to create a possible duplicate. Inspect the ` + + `account and retry.`, + ); + } + if (claimants.length === 1 && claimants[0].kind === 'claimed-but-invalid') { + throw new Error( + `provision: "wrangler d1 list" returned a D1 database named ${JSON.stringify(name)} with ` + + `an ambiguous or empty id (conflicting/blank uuid/database_id, or conflicting name ` + + `aliases). Cannot adopt it, and refusing to create a possible duplicate; inspect the ` + + `account and retry.`, + ); + } + // After the guards above, a lone claimant is necessarily a valid `match`. + const existing = + claimants.length === 1 && claimants[0].kind === 'match' ? claimants[0] : undefined; + if (existing) { + log.info(`adopted existing D1 database ${name} (${existing.id})`); + const res = write({ + d1: { binding, databaseName: name, databaseId: existing.id }, + d1MigrationsDir: { binding, dir: migrationsDir }, + }); + assertD1MigrationsDirApplied(res, binding, configPath); + resources.push({ kind: 'd1', binding, name, action: 'adopted', id: existing.id }); + } else { + const created = await runWrangler([ + 'd1', + 'create', + name, + '--binding', + binding, + '--update-config', + ...wranglerConfigArgs(configPath), + ]); + if (isCleanSuccess(created)) { + // wrangler wrote database_id/database_name via --update-config; VERIFY the + // real id landed (postcondition), then fill the migrations_dir it does + // not write. + assertCreateWroteRealId( + configPath, + { + field: 'd1_databases', + idField: 'database_id', + idValidator: isRealD1Id, + nameField: 'database_name', + resource: 'D1 database', + command: 'wrangler d1 create', + }, + binding, + name, + ); + const res = write({ d1MigrationsDir: { binding, dir: migrationsDir } }); + assertD1MigrationsDirApplied(res, binding, configPath); + resources.push({ kind: 'd1', binding, name, action: 'created' }); + } else if (isCleanFailure(created) && isAlreadyExistsError(created)) { + // TOCTOU: created between our list and create (clean non-zero, code 7502). + // Capture the id via `d1 info` — which must SUCCEED (gate) and POSITIVELY + // identify THIS database (EXACT name match) before we adopt its id. + const infoResult = await runWrangler( + ['d1', 'info', name, '--json', ...wranglerConfigArgs(configPath)], + { captureJson: true }, + ); + assertDiscoverySucceeded(infoResult, `wrangler d1 info ${name}`); + // SAME strict normalizer as the list path — must POSITIVELY identify THIS + // database by an exact name match with an unambiguous id. + const info = normalizeD1(parseJsonObject(infoResult.stdout), name); + if (info.kind !== 'match') { + throw new Error( + `provision: "wrangler d1 create ${name}" reported the database already exists, ` + + `but "wrangler d1 info ${name} --json" did not return a database with a matching ` + + `name and an unambiguous id. Refusing to adopt an unverified database.`, + ); + } + log.info(`adopted existing D1 database ${name} (${info.id})`); + const res = write({ + d1: { binding, databaseName: name, databaseId: info.id }, + d1MigrationsDir: { binding, dir: migrationsDir }, + }); + assertD1MigrationsDirApplied(res, binding, configPath); + resources.push({ kind: 'd1', binding, name, action: 'adopted', id: info.id }); + } else { + // Everything else — a hard failure (signal/spawn error) or a clean + // non-7502 error — fails closed. + throw new Error( + `provision: "wrangler d1 create ${name}" failed: ${created.stderr || created.stdout}`, + ); + } + } + } + } + + // --- KV ------------------------------------------------------------------ + for (const { binding, title } of desired.kvNamespaces) { + const entry = readBindingEntry(configPath, 'kv_namespaces', binding); + if (entry && isRealKvId(entry.id)) { + resources.push({ kind: 'kv', binding, name: title, action: 'reused', id: entry.id }); + continue; + } + // KV titles are NOT account-unique and `kv namespace create` never dedupes, + // so this list is the ONLY orphan guard. Route it through the SHARED gate + + // strict validator: the discovery must exit 0 (nonzero/SIGTERM/spawn-error → + // throw), return a real JSON array, and have EVERY member well-formed (a + // malformed member — which could BE the target — fails closed rather than being + // filtered to a false "absent" that mints a duplicate namespace). + const listResult = await runWrangler( + ['kv', 'namespace', 'list', ...wranglerConfigArgs(configPath)], + { + captureJson: true, + }, + ); + assertDiscoverySucceeded(listResult, 'wrangler kv namespace list'); + const rawList = parseJsonArrayStrict(listResult.stdout); + if (!rawList) { + throw new Error( + `provision: "wrangler kv namespace list" did not return a JSON array; cannot verify whether ` + + `KV namespace ${JSON.stringify(title)} exists. Refusing to create a possible duplicate.`, + ); + } + const namespaces = validateResourceList(rawList, { + resource: 'KV namespace', + command: 'wrangler kv namespace list', + hasValidId: (member) => isRealKvId(member.id), + idRule: 'its id is missing or not a well-formed 32-hex namespace id', + nameField: 'title', + nameLabel: 'titled', + }); + const matches = extractKvMatches(namespaces, title); + if (matches.length > 0) { + // KV titles are NOT account-unique. Tie-break deterministically on the + // lexicographically-smallest id so re-runs adopt the same namespace. + const chosen = matches.reduce((min, ns) => (ns.id < min.id ? ns : min)); + if (matches.length > 1) { + log.warn( + `multiple KV namespaces titled "${title}" (${matches + .map((m) => m.id) + .join(', ')}); adopting the lexicographically-smallest id ${chosen.id}`, + ); + } + log.info(`adopted existing KV namespace ${title} (${chosen.id})`); + write({ kv: { binding, id: chosen.id } }); + resources.push({ kind: 'kv', binding, name: title, action: 'adopted', id: chosen.id }); + } else { + const created = await runWrangler([ + 'kv', + 'namespace', + 'create', + title, + '--binding', + binding, + '--update-config', + ...wranglerConfigArgs(configPath), + ]); + if (!isCleanSuccess(created)) { + throw new Error( + `provision: "wrangler kv namespace create ${title}" failed: ${created.stderr || created.stdout}`, + ); + } + // Postcondition: confirm --update-config wrote a REAL id (a killed create + // masked as exit 0 by wrangler's launcher would leave none). + assertCreateWroteRealId( + configPath, + { + field: 'kv_namespaces', + idField: 'id', + idValidator: isRealKvId, + resource: 'KV namespace', + command: 'wrangler kv namespace create', + }, + binding, + title, + ); + resources.push({ kind: 'kv', binding, name: title, action: 'created' }); + } + } + + // --- R2 ------------------------------------------------------------------ + if (desired.r2) { + const { binding, bucketName } = desired.r2; + const entry = readBindingEntry(configPath, 'r2_buckets', binding); + // `bucket_name` IS the R2 identity: a stored + // REAL (non-placeholder) bucket_name that differs from the desired name is a + // CONTRADICTION — reusing it would keep the app bound to a different bucket while + // reporting the desired one provisioned. Equal → reuse as before (the desired name + // already passed the R2 grammar gate, so an equal stored name is format-valid too). + // ONLY R2's own placeholder (`default`) or an absent/non-string name + // keeps the discovery path: a stored `local` is a grammar-valid REAL bucket name + // (the shared check that also rejected `local` let it bypass this contradiction + // gate and be silently rewritten). + const storedBucketName = entry?.bucket_name; + if (entry && isRealStoredR2BucketName(storedBucketName)) { + if (storedBucketName !== bucketName) { + throw new Error( + `provision: R2 binding ${JSON.stringify(binding)} in "${configPath}" stores ` + + `bucket_name ${JSON.stringify(storedBucketName)}, but this deploy wants bucket ` + + `${JSON.stringify(bucketName)}. bucket_name IS the R2 identity, so this entry ` + + `contradicts the desired bucket. Fix "${configPath}" — correct the entry's ` + + `bucket_name (or remove the entry to re-provision) — or rename the desired bucket, ` + + `then retry.`, + ); + } + resources.push({ kind: 'r2', binding, name: bucketName, action: 'reused' }); + } else { + // `wrangler r2 bucket list` is a human table, not JSON. Bucket names ARE + // account-unique, so probe the single bucket by name. + const info = await runWrangler( + ['r2', 'bucket', 'info', bucketName, '--json', ...wranglerConfigArgs(configPath)], + { captureJson: true }, + ); + if (isCleanSuccess(info)) { + // A clean exit 0 is not enough to adopt — the success payload must + // POSITIVELY identify the requested bucket (its `name`) before we write the + // entry, else a mismatched/garbled 0-exit payload could bind the wrong + // bucket. (A signal-killed probe is NOT a clean success — see the rule.) + const payload = parseJsonObject(info.stdout); + if (!payload || payload.name !== bucketName) { + throw new Error( + `provision: "wrangler r2 bucket info ${bucketName} --json" exited 0 but did not identify ` + + `the requested bucket (expected name ${JSON.stringify(bucketName)}). Refusing to adopt an ` + + `unverified bucket. Output: ${info.stdout || info.stderr}`, + ); + } + // Adopt it — write the `{binding,bucket_name}` entry via the writer. + log.info(`adopted existing R2 bucket ${bucketName}`); + write({ r2: { binding, bucketName } }); + resources.push({ kind: 'r2', binding, name: bucketName, action: 'adopted' }); + } else if (isConfirmedAbsent(info, 'r2', bucketName)) { + // CONFIRMED not-found (clean non-zero + exact signal) → create. + // `--update-config` upserts by binding. + const created = await runWrangler([ + 'r2', + 'bucket', + 'create', + bucketName, + '--binding', + binding, + '--update-config', + ...wranglerConfigArgs(configPath), + ]); + if (!isCleanSuccess(created)) { + throw new Error( + `provision: "wrangler r2 bucket create ${bucketName}" failed: ${created.stderr || created.stdout}`, + ); + } + // Postcondition: confirm --update-config wrote the requested bucket_name + // (grammar-valid + EXACT name equality — the name IS the R2 identity). + // No placeholder heuristic here — a desired bucket genuinely + // named `default` must not fail a create that succeeded. + assertCreateWroteRealId( + configPath, + { + field: 'r2_buckets', + idField: 'bucket_name', + idValidator: isValidR2BucketNameFormat, + nameField: 'bucket_name', + resource: 'R2 bucket', + command: 'wrangler r2 bucket create', + }, + binding, + bucketName, + ); + // When the DESIRED name IS R2's placeholder string, the config + // postcondition is vacuous: a pre-existing placeholder entry is byte-identical + // to a real create result, so a masked signal-killed create (wrangler's + // launcher can report exit 0) would pass it. Verify via the EXACT remote probe + // instead — the same positive identification the adopt path uses. + if (bucketName === R2_BUCKET_NAME_PLACEHOLDER) { + const verify = await runWrangler( + ['r2', 'bucket', 'info', bucketName, '--json', ...wranglerConfigArgs(configPath)], + { captureJson: true }, + ); + const payload = isCleanSuccess(verify) ? parseJsonObject(verify.stdout) : undefined; + if (!payload || payload.name !== bucketName) { + throw new Error( + `provision: "wrangler r2 bucket create ${bucketName}" reported success but a ` + + `follow-up "wrangler r2 bucket info ${bucketName}" did not confirm the bucket ` + + `exists. The create may have been interrupted — wrangler's launcher can mask a ` + + `signal-killed child as exit 0, and the config entry is indistinguishable from ` + + `the local-dev placeholder for this name. Refusing to record an unprovisioned ` + + `bucket; inspect the account and retry.`, + ); + } + } + resources.push({ kind: 'r2', binding, name: bucketName, action: 'created' }); + } else { + // A non-zero that is NOT the confirmed not-found signal (auth/metrics/ + // network) — the bucket may well EXIST. Surface the real error instead of + // attempting a create that would spuriously fail or risk a duplicate. + throw new Error( + `provision: "wrangler r2 bucket info ${bucketName}" failed: ${info.stderr || info.stdout}. ` + + `Cannot determine whether the bucket exists; refusing to create a possible duplicate.`, + ); + } + } + } + + // --- Hyperdrive ---------------------------------------------------------- + // wrangler 4.107 has NO machine-readable Hyperdrive list (`hyperdrive list` + // prints a cli-table3 box table; there is no `--json`, and `hyperdrive get` + // takes an id, not a name). `hyperdrive create` on a NON-UNIQUE name silently + // mints a DUPLICATE config (new UUID), so a re-run without the id in config + // would keep minting duplicates. Existence is therefore checked via an + // INJECTABLE Cloudflare REST seam (GET …/hyperdrive/configs, matched by name): + // reliable + fully testable (unit tests stub it, no real network). Adoption is + // attempted BEFORE the connection-string gate (it needs no connection string). + if (desired.hyperdrive) { + const { binding, name, connectionString } = desired.hyperdrive; + // The whole-provision lock already serializes this unguarded list→create + // across processes: the read-first check below runs UNDER that lock, so a second + // concurrent provision sees the id the first wrote and SKIPS (no duplicate). + const entry = readBindingEntry(configPath, 'hyperdrive', binding); + if (entry && isRealHyperdriveId(entry.id)) { + resources.push({ kind: 'hyperdrive', binding, name, action: 'reused', id: entry.id }); + } else { + const lister = + opts?.listHyperdriveConfigs ?? createDefaultListHyperdriveConfigs(opts?.apiToken); + // Treat the seam result as UNKNOWN until PROVEN a real array. The OUTER + // result is validated (`Array.isArray`) BEFORE existence is marked known, so a + // runtime `null`/`undefined`/non-array is NEVER coerced to "empty" (confirmed + // absence) — which would authorize a blind create. A malformed outer result and a + // failed async lister BOTH leave existence UNKNOWN → fail closed if we WOULD create. + let validatedConfigs: Array | undefined; + let existenceUnknown = true; + if (lister) { + try { + // Always invoke the lister WITH the reconciled account, so an injected + // lister can never query a DIFFERENT account than the config is pinned to. + // Bound the WHOLE call by an overall deadline threaded as an `AbortSignal`. + // `withListerDeadline` aborts the signal AND rejects at the deadline, so even a + // seam that ignores the signal (a stalled body / never-settling promise) can NEVER + // keep the heartbeat refreshing the whole-provision lock forever — it fails closed + // (→ existence unknown → a create attempt throws below → the lock is released). + const result = await withListerDeadline( + (signal) => + lister({ + accountId: transportAccountId, + apiBaseUrl: transportApiBaseUrl, + signal, + }), + opts?.listTimeoutMs ?? HYPERDRIVE_LIST_TIMEOUT_MS, + ); + if (!Array.isArray(result)) { + throw new Error( + `the existence check returned a ${result === null ? 'null' : typeof result} result ` + + `instead of an array`, + ); + } + validatedConfigs = result; + existenceUnknown = false; + } catch (error) { + // Never coerce the unknown rejection (`String()`/template + // would invoke an arbitrary — possibly hostile — `toString`, and a lister/ + // network error could echo a URL carrying userinfo credentials): log ONLY a + // safely-extracted, redacted message from inside the no-throw boundary. + log.warn( + `Hyperdrive existence check for ${name} failed: ` + + safeRedactedErrorMessage(error, redactSecrets), + ); + existenceUnknown = true; + validatedConfigs = undefined; + } + } + // The ONE strict per-entry validator runs OUTSIDE the try, on a PROVEN array + // only, so its malformed-member / duplicate-id / ambiguous-name throws stay HARD + // failures (never swallowed into "unknown"). It requires EXACTLY 0 or 1 exact-name + // match (>1 → fail closed) BEFORE matching or writing, so no injected list can be + // adopted with an empty/garbage id. + const existing = + validatedConfigs !== undefined + ? selectHyperdriveMatch(validatedConfigs, name, configPath) + : undefined; + if (existing) { + log.info(`adopted existing Hyperdrive config ${name} (${existing.id})`); + write({ hyperdrive: { binding, id: existing.id } }); + resources.push({ kind: 'hyperdrive', binding, name, action: 'adopted', id: existing.id }); + } else if (!connectionString) { + // No connection string → we can never CREATE, so there is no duplicate + // risk whether or not existence is known: skip (structured, never prompts). + const reason = 'no connection string provided'; + log.warn(`skipping Hyperdrive config ${name}: ${reason}`); + resources.push({ kind: 'hyperdrive', binding, name, action: 'skipped', reason }); + } else if (existenceUnknown) { + // A connection string is present (we WOULD create) but existence is + // unverifiable → FAIL CLOSED. Blindly creating duplicates a same-named config. + throw new Error( + `provision: cannot verify whether Hyperdrive config ${JSON.stringify(name)} already ` + + `exists (no existence check available — provide an accountId + API token, or inject a ` + + `listHyperdriveConfigs seam). "wrangler hyperdrive create" silently duplicates a ` + + `same-named config, so provisioning refuses to create it blindly.`, + ); + } else { + // Confirmed absent + connection string present → create. + const created = await runWrangler([ + 'hyperdrive', + 'create', + name, + `--connection-string=${connectionString}`, + '--binding', + binding, + '--update-config', + ...wranglerConfigArgs(configPath), + ]); + if (!isCleanSuccess(created)) { + throw new Error( + `provision: "wrangler hyperdrive create ${name}" failed: ${created.stderr || created.stdout}`, + ); + } + // Postcondition: confirm --update-config wrote a REAL Hyperdrive id. + assertCreateWroteRealId( + configPath, + { + field: 'hyperdrive', + idField: 'id', + idValidator: isRealHyperdriveId, + resource: 'Hyperdrive config', + command: 'wrangler hyperdrive create', + }, + binding, + name, + ); + resources.push({ kind: 'hyperdrive', binding, name, action: 'created' }); + } + } + } + + // --- Queues (resource only; NEVER writes queue config entries) ----------- + // `wrangler queues list` is a human table, not JSON, and `queues create` has + // no `--update-config`. So probe each queue by name: `queues info` exits + // non-zero (`Queue "x" does not exist`) when absent. Task 4 only ensures the + // queue RESOURCE exists — binding the producers/consumers into the config is + // app-class-aware and belongs to Task 6. + for (const name of desired.queues) { + const info = await runWrangler(['queues', 'info', name, ...wranglerConfigArgs(configPath)]); + // "Exists → reuse" ONLY when the probe POSITIVELY names THIS queue. A bare + // clean 0 is NOT trusted: the bin wrapper can mask a signal-killed inner CLI as + // exit 0 with empty output, which a plain `isCleanSuccess` would misread as + // reused though nothing was verified. + if (queueInfoConfirmsExists(info, name)) { + resources.push({ kind: 'queue', name, action: 'reused' }); + continue; + } + // Not confirmed-present. It is "absent" (→ create) ONLY on a CLEAN non-zero exit + // carrying the EXACT, name-aware not-found signal (`Queue "" does not + // exist`). Everything else — a signal/spawn failure, an auth error, a generic + // `Account not found`, OR a masked-kill clean-0 that did NOT positively identify + // the queue — fails closed (never a blind create, never a false reuse). + if (!isConfirmedAbsent(info, 'queue', name)) { + throw new Error( + `provision: "wrangler queues info ${name}" failed or did not confirm the queue exists: ` + + `${info.stderr || info.stdout || 'no output'}`, + ); + } + const created = await runWrangler([ + 'queues', + 'create', + name, + ...wranglerConfigArgs(configPath), + ]); + if (!isCleanSuccess(created)) { + throw new Error( + `provision: "wrangler queues create ${name}" failed: ${created.stderr || created.stdout}`, + ); + } + // Verify probe — queues write NO config, so `assertCreateWroteRealId` cannot + // cover them. A create reporting clean 0 is NOT enough (the bin wrapper masks a + // signal-killed inner CLI as exit 0, creating nothing). RE-RUN the SAME positive + // `queues info` probe and only record `created` if it now confirms THIS queue; + // otherwise fail closed (a killed create self-heals on the next run's probe). + const verify = await runWrangler(['queues', 'info', name, ...wranglerConfigArgs(configPath)]); + if (!queueInfoConfirmsExists(verify, name)) { + throw new Error( + `provision: "wrangler queues create ${name}" reported success but "wrangler queues info ` + + `${name}" did not confirm the queue exists afterward. The create may have been ` + + `interrupted — wrangler's launcher can mask a signal-killed child as exit 0. Refusing to ` + + `record an unprovisioned queue; inspect the account and retry.`, + ); + } + resources.push({ kind: 'queue', name, action: 'created' }); + } + + // account_id was persisted up front (before any remote side effect) — see the + // account_id block near the top of this function (fail-closed pin). + return { resources, configPath, accountIdWritten }; +} diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index e87f6dc94..fd05652a6 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -1,7 +1,18 @@ import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { parse, type ParseError } from 'jsonc-parser'; -import { join } from 'pathe'; +import { + applyEdits, + createScanner, + findNodeAtLocation, + modify, + parse, + parseTree, + SyntaxKind, + type FormattingOptions, + type Node, + type ParseError, +} from 'jsonc-parser'; +import { join, normalize } from 'pathe'; import type { VoidConfig } from '../config.ts'; import type { InferredBindings } from '../inference/walk.ts'; import type { WebSocketRouteDefinition } from '../router/websocket.ts'; @@ -13,6 +24,8 @@ import { } from '../sandbox/config.ts'; const COMPAT_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +/** UTF-8 byte-order mark. Node's utf-8 read preserves a leading one. */ +const UTF8_BOM = ''; const NODEJS_ALS_FLAG = 'nodejs_als'; const NODEJS_COMPAT_FLAG = 'nodejs_compat'; const VOID_CONFIG_FILE = 'void.json'; @@ -681,6 +694,685 @@ export function ensureWranglerNodejsAlsFlag(root: string): boolean { return true; } +/** A wrangler `queues.producers[]` entry (written verbatim). */ +export type WranglerQueueProducer = { + queue: string; + binding: string; +}; + +/** + * A wrangler `queues.consumers[]` entry (written verbatim, snake_case wrangler + * keys). Mirrors exactly what Void's queue scanner / `mergeBindings` emit. + */ +export type WranglerQueueConsumer = { + queue: string; + max_batch_size?: number; + max_batch_timeout?: number; + max_retries?: number; + retry_delay?: number; +}; + +/** + * Patch describing the root-config gaps that `wrangler create + * --update-config` does NOT fill. `writeProvisionedWranglerConfig` applies these + * to the project's root `wrangler.jsonc`/`wrangler.json`. + * + * Queues — per-app-class behavior (verified against `index.ts`/`deploy.ts`): + * + * - NATIVE Void mode (no meta-framework; `detectFramework` → undefined): + * `mergeBindings` (`index.ts`) emits scanner-derived `queues.producers`/ + * `consumers` into `dist/ssr/wrangler.json`, and `@cloudflare/vite-plugin` + * `defu`-merges the plugin config over the root config (arrays CONCATENATE). + * So the caller must NOT pass `queues` for a native app — it would DUPLICATE + * the scanner's entries. Leave `queues` undefined; the build binds them. + * + * - FRAMEWORK mode (Class A: `index.ts:738` forces `queues = []`; Class B/C: + * the plugin returns before `mergeBindings` runs): the build emits NO queue + * bindings, yet the queue feature IS supported in production — the deploy + * wrapper (`cli/wrapper.ts`) adds a `queue: handleQueue` consumer to the + * framework worker. On self-host there is no Void platform to bind the + * queues, and `wrangler queues create` has no `--update-config`, so the + * orchestrator (Task 6) passes the inferred producers/consumers here to + * bind them in the root config for framework self-host builds only. + */ +export type ProvisionedWranglerPatch = { + /** Cloudflare account id → top-level `account_id` (always overwritten). */ + accountId?: string; + /** Worker name → top-level `name` (only written when absent). */ + name?: string; + /** Compatibility date → top-level `compatibility_date` (only written when absent). */ + compatibilityDate?: string; + /** + * Adds `migrations_dir` to the `d1_databases[]` entry whose `binding` matches. + * The D1 entry itself is created by `wrangler d1 create --update-config`; this + * only fills its `migrations_dir`. + */ + d1MigrationsDir?: { binding: string; dir: string }; + /** + * ADOPT an already-existing resource: write its REAL id/name into the config + * entry matched by `binding`, creating the entry (and its array) if absent. + * + * These exist because `wrangler create --update-config` — which is what + * writes the id on the CREATE path — cannot run when the resource already + * exists in the account (create errors on a name collision). So on the adopt + * path the provisioner captures the id via `wrangler list/info --json` + * and routes it through here, keeping every comment-preserving jsonc write in + * this one writer. Each is an idempotent, comment-preserving property upsert + * (byte-identical on re-run) that never touches sibling fields. + */ + d1?: { binding: string; databaseName: string; databaseId: string }; + kv?: { binding: string; id: string }; + r2?: { binding: string; bucketName: string }; + hyperdrive?: { binding: string; id: string }; + /** + * Queue producer/consumer bindings, upserted into `queues.producers[]` / + * `queues.consumers[]` idempotently, keyed by each section's wrangler identity + * so re-runs never duplicate: + * + * - Producers are matched by `binding`. wrangler requires producer binding + * names to be UNIQUE across the worker (multiple producers may legally + * target one queue), so the binding — not the queue — is the identity. + * Void's binding is the deterministic `QUEUE_`. A missing binding is + * APPENDED (never rewriting a user's own same-queue producer); a binding + * already bound to a DIFFERENT queue fails closed (a binding must map to + * exactly one queue, else wrangler rejects the deploy). + * - Consumers are matched by `queue`. wrangler allows exactly one consumer + * per queue, so the queue name is the identity; matched entries are + * property-upserted (user-tuned fields preserved). + * + * FRAMEWORK self-host ONLY. See the per-app-class note on this type: a native + * Void app already emits scanner queues from the build, so passing `queues` + * for one would duplicate them via the `defu` array concat. + */ + queues?: { + producers?: Array; + consumers?: Array; + }; +}; + +export type WriteProvisionedWranglerResult = { + /** Absolute path of the config file that was operated on. */ + configPath: string; + /** True when the file content changed (or a new file was created). */ + modified: boolean; + /** + * True when `d1MigrationsDir` was requested but no `d1_databases[]` entry with + * the given binding existed. The orchestrator controls ordering — the D1 + * entry is created by `wrangler d1 create --update-config` first, so a skip + * signals the writer ran before provisioning that binding. + */ + d1MigrationsDirSkipped: boolean; +}; + +/** + * Fill the parts of the root wrangler config that `wrangler create + * --update-config` leaves blank: top-level `account_id`, `name`, + * `compatibility_date`, and `migrations_dir` on a specific D1 entry. + * + * Comment-preserving: uses `jsonc-parser` `modify()` + `applyEdits()` so + * comments and unrelated content survive (unlike `syncWranglerBindings`, which + * round-trips through `JSON.stringify` and strips comments). Every edit is an + * idempotent upsert — running twice produces byte-identical output. + */ +export function writeProvisionedWranglerConfig( + root: string, + patch: ProvisionedWranglerPatch, + options?: { configPath?: string }, +): WriteProvisionedWranglerResult { + // Resolve the root config file. When the caller pins an EXACT `configPath` + // (the provisioner does, so its read-first parse, wrangler `-c`, and this + // write all operate on the SAME file — no split-brain), honor it verbatim. + // Otherwise fall back to the same discovery idiom as the rest of this file: + // prefer wrangler.jsonc, fall back to wrangler.json, else create wrangler.jsonc. + let configPath = options?.configPath; + if (configPath === undefined) { + for (const name of ['wrangler.jsonc', 'wrangler.json']) { + const p = join(root, name); + if (existsSync(p)) { + configPath = p; + break; + } + } + configPath ??= join(root, 'wrangler.jsonc'); + } + + // Node's utf-8 read preserves a leading BOM; wrangler itself strips the BOM + // before parsing. Match that: strip it for parsing/editing, then re-add it on + // write so the file's bytes round-trip unchanged. + const rawContent = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '{}'; + const hasBom = rawContent.startsWith(UTF8_BOM); + const original = hasBom ? rawContent.slice(UTF8_BOM.length) : rawContent; + + let parsed: Record; + try { + parsed = parseJsonc(original); + } catch (error) { + // Fail closed. Never treat an unparseable config as `{}`: that would let the + // only-if-absent logic clobber an existing `name`/`compatibility_date`, miss + // real `d1_databases[]` entries, and rewrite a malformed file. Throw without + // writing so the caller surfaces the problem instead of corrupting the file. + throw new Error( + `deploy: cannot update "${configPath}" — it is not valid JSON/JSONC. ` + + `Fix the file and retry. (${(error as Error).message})`, + ); + } + + // Fail closed on duplicate member names BEFORE any edit. jsonc-parser's + // `parse()` resolves a duplicate key to the LAST member (the value wrangler + // itself reads), but `modify()` edits the FIRST. So an upsert would compare + // against the effective (last) value, patch the shadowed (first) member, and + // leave wrangler reading the stale value — while a re-run finds the effective + // value still unchanged, re-patches the already-correct first member, and + // returns `modified: false`, looking byte-idempotent even though the requested + // production setting was NEVER applied. This is config-wide (a duplicate + // `account_id`, `compatibility_date`, a queue consumer's `max_batch_size`, + // etc.). Throwing without writing surfaces the problem instead of silently + // mis-editing (matches the malformed-config guard above). We do not "target + // the last member": that would leave a malformed, still-duplicated file. + assertNoDuplicateMembers(original, configPath); + + const formattingOptions = detectFormattingOptions(original); + let text = original; + // Paths this call overwrites with a scalar. When a scalar-expected field + // (account_id, migrations_dir, a queue option) already holds a COMPOSITE + // object/array value, `jsonc-parser.modify()` replaces the whole node — + // deleting any comment nested inside it while still reporting the edit as + // applied. The final comment-preservation gate below uses these paths to name + // the offending field when it detects such a drop. + const scalarSetPaths: Array> = []; + const set = (path: Array, value: unknown): void => { + scalarSetPaths.push(path); + text = applyMemberEdit(text, path, value, { formattingOptions }); + }; + + // account_id — always overwrite so a re-resolve can correct a stale value. + if (patch.accountId !== undefined) { + set(['account_id'], patch.accountId); + } + + // name / compatibility_date — only when absent (never clobber the user's). + if (patch.name !== undefined && parsed.name === undefined) { + set(['name'], patch.name); + } + if (patch.compatibilityDate !== undefined && parsed.compatibility_date === undefined) { + set(['compatibility_date'], patch.compatibilityDate); + } + + // Lazily-built map of EVERY binding name already used anywhere in the config + // (keyed name → wrangler's friendly owner label). Used by the queue-producer + // append guard further down; built from the CURRENT `text` at first use so it + // reflects any adopt appended earlier in this call (not the stale initial + // `parsed`). The adopt guard below rebuilds its own view per adopt (it must + // include queues.producers, which this collector deliberately omits). + let workerBindingOwners: Map | undefined; + + // Adopt fields — write the REAL id/name of an already-existing resource into + // the binding's config entry (create the array + entry if absent), keyed by + // `binding`. Runs BEFORE `migrations_dir` so an adopt that CREATES a D1 entry + // is visible to the `migrations_dir` upsert below (which re-parses `text`). + // Each is an idempotent property upsert: a matched entry has only the supplied + // props set (siblings + comments preserved), a missing one is appended. + // + // The upsert upholds the SAME worker-wide binding invariants the producer path + // enforces (see `validateBindingsHaveUniqueNames` mirror below): + // - >1 entry in the target array sharing `binding` → fail closed (ambiguous: + // adoption cannot pick which to write, and wrangler rejects the duplicate). + // - The binding is already used by ANOTHER binding type — a KV bound `DB`, a + // var, a secret, a QUEUE PRODUCER, … — whether we are APPENDING a new entry + // OR UPDATING an existing matched one → fail closed (a cross-type duplicate + // binding name wrangler rejects at deploy). + const upsertAdoptedBinding = ( + field: string, + binding: string, + properties: Record, + ): void => { + // Re-parse the CURRENT text (not the initial `parsed`) so this sees any adopt + // appended earlier in THIS call — a stale map would let a multi-field patch + // adopt the same binding into two arrays without noticing the collision. + const currentConfig = parseJsonc(text); + const rawList = currentConfig[field]; + // Strictly validate the TARGET field's own shape too. The owner collector + // below EXCLUDES this field (so the matched entry does not self-collide), so + // without this a malformed member in the target array (e.g. a `null` beside a + // valid `{binding}`) would be silently skipped by the match loop and could hide a + // real duplicate binding. Reuse the ONE field-aware validator; it throws on any + // malformed member (fail closed) before we mutate the array. + bindingNamesForField(field, rawList); + const currentList = Array.isArray(rawList) ? [...rawList] : []; + const matches: Array = []; + for (let i = 0; i < currentList.length; i++) { + const entry = currentList[i]; + if (isRecord(entry) && entry.binding === binding) { + matches.push(i); + } + } + if (matches.length > 1) { + throw new Error( + `deploy: cannot update "${configPath}" — binding ${JSON.stringify(binding)} is declared ` + + `${matches.length} times in "${field}". Adoption cannot choose which entry to write the ` + + `real id into, and wrangler rejects duplicate binding names. Remove the duplicate and retry.`, + ); + } + // Worker-wide cross-type uniqueness — checked for BOTH the matched (existing + // target) and the append case: a binding shared with any OTHER binding type is + // undeployable regardless of whether we create or update this entry. Build the + // owner map from the CURRENT config, EXCLUDING the target field (its own + // matched entry must not self-collide) and INCLUDING queues.producers (the base + // collector omits producers so the producer path does not self-collide, but for + // an adopt a producer sharing the binding IS a real cross-type collision). + // Reuse the shared collector — do NOT fork it. + const owners = collectWorkerBindingNames(configWithoutField(currentConfig, field)); + addQueueProducerOwners(owners, currentConfig); + const owner = owners.get(binding); + if (owner !== undefined) { + throw new Error( + `deploy: cannot update "${configPath}" — cannot adopt ${JSON.stringify(binding)} into ` + + `"${field}" because that binding name is already used by a ${owner} binding. Wrangler ` + + `requires every binding name to be unique across the whole worker, so this would make ` + + `the config undeployable. Rename the conflicting binding and retry.`, + ); + } + if (matches.length === 1) { + const index = matches[0]; + const existing = isRecord(currentList[index]) ? currentList[index] : {}; + for (const [key, value] of Object.entries(properties)) { + if (!jsonEqual(existing[key], value)) { + set([field, index, key], value); + } + } + } else { + text = applyMemberEdit( + text, + [field, currentList.length], + { binding, ...properties }, + { + formattingOptions, + isArrayInsertion: true, + }, + ); + } + }; + if (patch.d1) { + upsertAdoptedBinding('d1_databases', patch.d1.binding, { + database_name: patch.d1.databaseName, + database_id: patch.d1.databaseId, + }); + } + if (patch.kv) { + upsertAdoptedBinding('kv_namespaces', patch.kv.binding, { id: patch.kv.id }); + } + if (patch.r2) { + upsertAdoptedBinding('r2_buckets', patch.r2.binding, { bucket_name: patch.r2.bucketName }); + } + if (patch.hyperdrive) { + upsertAdoptedBinding('hyperdrive', patch.hyperdrive.binding, { id: patch.hyperdrive.id }); + } + + // migrations_dir — upsert onto the D1 entry matched by binding. Indices are + // stable because we only set a property (never insert/reorder array entries). + // Re-parse `text` (not the initial `parsed`) so a D1 entry an adopt just + // created above is matched here too. + let d1MigrationsDirSkipped = false; + if (patch.d1MigrationsDir) { + const currentD1 = parseJsonc(text).d1_databases; + const d1Entries = Array.isArray(currentD1) + ? (currentD1 as Array<{ + binding?: unknown; + migrations_dir?: unknown; + migrations_pattern?: unknown; + }>) + : []; + // Require EXACTLY ONE D1 entry with the binding. `findIndex` (first match) + // would silently accept DUPLICATE D1 bindings — a config wrangler rejects — + // and attach migrations_dir to only the first. Fail closed on duplicates. + const matchIndices: Array = []; + d1Entries.forEach((entry, i) => { + if (entry.binding === patch.d1MigrationsDir!.binding) { + matchIndices.push(i); + } + }); + if (matchIndices.length > 1) { + throw new Error( + `deploy: cannot update "${configPath}" — binding ` + + `${JSON.stringify(patch.d1MigrationsDir.binding)} is declared ${matchIndices.length} times ` + + `in "d1_databases". A binding name must be unique across the worker (wrangler rejects the ` + + `config otherwise), so migrations_dir cannot be attached unambiguously. Remove the ` + + `duplicate D1 entry and retry.`, + ); + } + const index = matchIndices.length === 1 ? matchIndices[0] : -1; + if (index >= 0) { + // When this patch manages the D1 migrations dir, a DEFINED + // non-string `migrations_pattern` (object/array/number/null) is rejected BEFORE + // any keep-equivalent/fill branch runs. Wrangler's `resolveMigrationsConfig` + // treats the pattern as a string, so committing `migrations_dir` beside a + // malformed pattern would report provisioning success on a config that later + // `wrangler d1 migrations` commands reject. Fail closed; the file stays + // byte-identical (the write happens only after every gate passes). + const existingPattern = d1Entries[index].migrations_pattern; + if (existingPattern !== undefined && typeof existingPattern !== 'string') { + throw new Error( + `deploy: cannot update "${configPath}" — the D1 entry for binding ` + + `${JSON.stringify(patch.d1MigrationsDir.binding)} has a non-string ` + + `"migrations_pattern" value; expected a string. Wrangler rejects such a config, so ` + + `filling "migrations_dir" beside it would commit an invalid file. Fix the config ` + + `and retry.`, + ); + } + // Fill-if-absent / keep-equivalent / + // fail-closed-on-differ. `migrations_dir` is USER-OWNED once set — a reused D1 + // configured with e.g. `./legacy-migrations` must never be silently rewritten to + // Void's default, which would strand the user's migration files. + const existingDir = d1Entries[index].migrations_dir; + if (existingDir !== undefined && typeof existingDir !== 'string') { + // Malformed (object/array/number/…) — wrangler expects a string. Fail closed + // without writing (matches the writer's composite-scalar philosophy). + throw new Error( + `deploy: cannot update "${configPath}" — the D1 entry for binding ` + + `${JSON.stringify(patch.d1MigrationsDir.binding)} has a non-string ` + + `"migrations_dir" value; expected a string. Fix the config and retry.`, + ); + } + if (typeof existingDir === 'string') { + if ( + normalizeMigrationsPath(existingDir) !== + normalizeMigrationsPath(patch.d1MigrationsDir.dir) + ) { + // An explicit DIFFERENT directory — fail closed with both paths named. + throw new Error( + `deploy: cannot update "${configPath}" — the D1 entry for binding ` + + `${JSON.stringify(patch.d1MigrationsDir.binding)} already sets "migrations_dir": ` + + `${JSON.stringify(existingDir)}, but Void keeps this database's migrations in ` + + `${JSON.stringify(patch.d1MigrationsDir.dir)}. Refusing to overwrite an explicit ` + + `different migrations directory. Either remove "migrations_dir" from the entry (or ` + + `set it to ${JSON.stringify(patch.d1MigrationsDir.dir)}) to let Void manage it, or ` + + `move the migrations and keep your directory, then retry.`, + ); + } + // keep-equivalent: `db/migrations` vs `./db/migrations` vs a trailing slash all + // normalize equal (wrangler's own `normalizeRelativePath` rule) — leave the user's + // spelling untouched so the file stays byte-identical. + } else { + // Absent → fill. A companion `migrations_pattern` (if the user set one) must stay + // valid under the dir being written: wrangler requires the normalized pattern to + // start with `${migrations_dir}/` (unless the dir normalizes to "."), so filling + // the dir under an incompatible pattern would break `wrangler d1 migrations`. + // Validate before editing; fail closed and NEVER rewrite the user-owned pattern. + // (A defined pattern is narrowed to a STRING by the gate above.) + if ( + existingPattern !== undefined && + !migrationsPatternMatchesDir(existingPattern, patch.d1MigrationsDir.dir) + ) { + throw new Error( + `deploy: cannot update "${configPath}" — the D1 entry for binding ` + + `${JSON.stringify(patch.d1MigrationsDir.binding)} has "migrations_pattern": ` + + `${JSON.stringify(existingPattern)}, which is not under the requested ` + + `"migrations_dir": ${JSON.stringify(patch.d1MigrationsDir.dir)}. Wrangler requires the ` + + `pattern to start with the migrations dir, so this change would break ` + + `\`wrangler d1 migrations\`. Align "migrations_pattern" with the dir (or remove it) and retry.`, + ); + } + set(['d1_databases', index, 'migrations_dir'], patch.d1MigrationsDir.dir); + } + } else { + d1MigrationsDirSkipped = true; + } + } + + // queues — idempotent upsert into `queues.producers[]` / `queues.consumers[]`. + // Each section keys on its wrangler identity: + // - PRODUCERS key on `binding`. wrangler requires producer binding names to + // be unique across the worker (multiple producers may target one queue), + // so the binding — not the queue — is the identity. Void's binding is the + // deterministic `QUEUE_` (see `queues/scan.ts` + `runtime/queues.ts`). + // A missing binding is appended (a user's own same-queue producer with a + // different binding is left alone — Void must not hijack it); a binding + // already bound to a DIFFERENT queue fails closed. + // - CONSUMERS key on `queue`. wrangler allows exactly one consumer per queue, + // so the queue name is the identity. + // Framework self-host only (see the note on ProvisionedWranglerPatch); a native + // app would duplicate the build's scanner queues. + const upsertQueueEntries = ( + section: 'producers' | 'consumers', + entries: Array> | undefined, + ): void => { + if (!entries || entries.length === 0) { + return; + } + // Re-parse `text` (not the initial `parsed`) so earlier edits in this call + // are reflected; track appends locally to keep indices valid. + const currentQueues = assertObjectContainer(parseJsonc(text).queues, 'queues'); + // Before mutating producers, route the EXISTING `queues.producers` list + // through the SAME strict validator the adopt guard uses. Without this, a corrupt + // list (e.g. `[null]`) would be cloned, its non-record members silently skipped by + // the match loop below, and a new producer committed beside the corruption. + if (section === 'producers') { + assertValidQueueProducers(currentQueues?.producers); + } + const currentList = Array.isArray(currentQueues?.[section]) + ? [...(currentQueues[section] as Array>)] + : []; + for (const entry of entries) { + // Match on the section's identity: producers by `binding`, consumers by + // `queue`. Keying producers on the queue would rewrite the FIRST of several + // legal same-queue producers, so a `{queue,CUSTOM}` + `{queue,QUEUE_JOBS}` + // pair collapses into two identical `QUEUE_JOBS` bindings — a config + // wrangler rejects. + // + // A producer `binding` must map to exactly ONE queue, so the fail-closed + // guard must inspect EVERY producer sharing the desired binding — not just + // the first `findIndex` hit — to stay deterministic regardless of array + // order: + // - 0 matches → append the new producer (`index = -1`). + // - 1 match, same queue → no-op upsert (idempotent). + // - 1 match, different queue → fail closed (binding bound elsewhere). + // - >1 match (duplicate binding across entries) → fail closed; the + // registry is already undeployable, so never silently "converge" on it + // (an order-dependent first-match check would falsely report success). + let index: number; + if (section === 'producers') { + const matches: Array = []; + for (let i = 0; i < currentList.length; i++) { + const existing = currentList[i]; + if (isRecord(existing) && existing.binding === entry.binding) { + matches.push(i); + } + } + if (matches.length > 1) { + throw new Error( + `deploy: cannot update "${configPath}" — queue producer binding ` + + `${JSON.stringify(entry.binding)} is declared ${matches.length} times across ` + + `producers. A producer binding must map to exactly one queue, so wrangler rejects ` + + `this config. Remove the duplicate producers and retry.`, + ); + } + if (matches.length === 1) { + const existing = currentList[matches[0]]; + if (isRecord(existing) && existing.queue !== entry.queue) { + throw new Error( + `deploy: cannot update "${configPath}" — queue producer binding ` + + `${JSON.stringify(entry.binding)} is already bound to queue ` + + `${JSON.stringify(existing.queue)}, but queue ${JSON.stringify(entry.queue)} also ` + + `requests it. A producer binding must map to exactly one queue. Rename or remove ` + + `the conflicting producer and retry.`, + ); + } + } + index = matches.length === 1 ? matches[0] : -1; + // Run the canonical worker-wide binding-name collision check for EVERY + // producer upsert (matched/idempotent AND append), not only the `index === -1` + // append path. wrangler requires every binding NAME to be unique across the + // ENTIRE worker — not just within `queues.producers` — so a producer binding + // (`QUEUE_`) that ALSO names any OTHER binding (D1/KV/R2/DO/service/var/ + // secret/AI/hyperdrive/vectorize/…) is a cross-type collision wrangler rejects + // at deploy. That config is undeployable whether we APPEND the producer or leave + // a same-queue one already in place, so BOTH must fail closed: skipping the + // check on the idempotent path would silently accept an already-broken config + // with `modified:false`, and would also miss a malformed OTHER binding field the + // collector fails closed on. The collector deliberately OMITS `queues` (see + // FRIENDLY_BINDING_NAMES), so a producer never self-collides. Build from the + // CURRENT text so an adopt appended earlier in this call is seen too (not the + // stale initial `parsed`). + workerBindingOwners ??= collectWorkerBindingNames(parseJsonc(text)); + const bindingName = String(entry.binding); + const owner = workerBindingOwners.get(bindingName); + if (owner !== undefined) { + throw new Error( + `deploy: cannot update "${configPath}" — queue producer binding ` + + `${JSON.stringify(bindingName)} is already used by a ${owner} binding. Wrangler ` + + `requires every binding name to be unique across the whole worker, so this producer ` + + `would make the config undeployable. Rename the conflicting binding (or the queue) ` + + `and retry.`, + ); + } + } else { + index = currentList.findIndex( + (existing) => isRecord(existing) && existing.queue === entry.queue, + ); + } + + if (index >= 0) { + // Update ONLY the properties this patch supplies — never replace the + // whole array element. A whole-object `modify()` at the element index + // would drop inline comments AND any sibling fields the narrower + // inferred patch omits (producer `delivery_delay`; consumer + // `dead_letter_queue`, `max_concurrency`, …), silently changing + // production delivery/retry/concurrency/failure-routing. Per-property + // edits preserve unmentioned fields and comments, and stay idempotent + // (a re-run finds each value already equal and writes nothing). For a + // producer matched by binding the queue already equals (a mismatch threw + // above), so this loop is a no-op that preserves the user's entry. + const existing = isRecord(currentList[index]) ? currentList[index] : {}; + for (const [key, value] of Object.entries(entry)) { + if (value === undefined) { + continue; + } + if (!jsonEqual(existing[key], value)) { + set(['queues', section, index, key], value); + } + } + currentList[index] = { ...existing, ...entry }; + } else { + // Append at `currentList.length`; `applyMemberEdit` also creates the + // `queues` object and the section array when they don't exist yet, and + // keeps a trailing inline comment attached to the preceding entry it + // documented (see the comment-relocation logic there). + text = applyMemberEdit(text, ['queues', section, currentList.length], entry, { + formattingOptions, + isArrayInsertion: true, + }); + currentList.push(entry); + } + } + }; + if (patch.queues) { + upsertQueueEntries('producers', patch.queues.producers); + upsertQueueEntries('consumers', patch.queues.consumers); + } + + // FINAL GATE — global comment-text-preservation invariant. The writer's + // contract is that every comment's TEXT survives a write. Relocation only + // MOVES a comment (identical text, new position), so the multiset of comment + // TOKENS is preserved and this gate passes. A DROPPED comment — the + // composite-overwrite case above, or any other pathological path — makes the + // output multiset miss a token. Assert the final output is a SUPERSET of the + // original's comment tokens BEFORE writing; on any drop, throw WITHOUT writing + // so the file is never corrupted (the BOM re-add + write happen only past this + // gate). O(n): one scanner pass over each string. + const droppedComment = findDroppedComment( + extractCommentTokens(original), + extractCommentTokens(text), + ); + if (droppedComment !== undefined) { + // Prefer a specific diagnosis when the cause is a scalar-expected field that + // held a composite (object/array) value in the ORIGINAL — `modify()` would + // have replaced the whole node, dropping the nested comment. + const originalRoot = parseTree(original, undefined, { allowTrailingComma: true }); + const compositeField = originalRoot + ? scalarSetPaths.find((path) => { + const node = findNodeAtLocation(originalRoot, path); + return node?.type === 'object' || node?.type === 'array'; + }) + : undefined; + if (compositeField) { + const field = String(compositeField[compositeField.length - 1]); + throw new Error( + `deploy: cannot update "${configPath}" — "${field}" has an object/array value; ` + + `expected a string. Fix the config and retry.`, + ); + } + throw new Error( + `deploy: cannot update "${configPath}" — this edit would drop a comment ` + + `(${droppedComment}); the writer refuses to corrupt the file — simplify the ` + + `surrounding structure and retry.`, + ); + } + + const finalText = hasBom ? `${UTF8_BOM}${text}` : text; + const modified = finalText !== rawContent; + if (modified) { + writeFileSync(configPath, finalText); + } + return { configPath, modified, d1MigrationsDirSkipped }; +} + +/** + * Extract the multiset of comment TOKENS (line + block) from JSONC source using + * jsonc-parser's scanner — exact by construction, no regex. Each token is its + * verbatim source slice, so a relocated comment keeps identical text (its + * multiset entry is unchanged) while a dropped comment removes one. + */ +function extractCommentTokens(text: string): Array { + const scanner = createScanner(text, false); + const tokens: Array = []; + let kind = scanner.scan(); + while (kind !== SyntaxKind.EOF) { + if (kind === SyntaxKind.LineCommentTrivia || kind === SyntaxKind.BlockCommentTrivia) { + const offset = scanner.getTokenOffset(); + tokens.push(text.slice(offset, offset + scanner.getTokenLength())); + } + kind = scanner.scan(); + } + return tokens; +} + +/** + * Multiset-subset check: return the first comment token present in `before` but + * missing (accounting for repeats) from `after`, or `undefined` when `after` is + * a superset of `before`. The final gate uses this to prove no comment TEXT was + * dropped by the edits. + */ +function findDroppedComment(before: Array, after: Array): string | undefined { + const counts = new Map(); + for (const token of after) { + counts.set(token, (counts.get(token) ?? 0) + 1); + } + for (const token of before) { + const remaining = counts.get(token) ?? 0; + if (remaining <= 0) { + return token; + } + counts.set(token, remaining - 1); + } + return undefined; +} + +/** + * Detect indentation from an existing config so `jsonc-parser` formats inserted + * content to match. Falls back to two-space indentation (the repo default). + */ +function detectFormattingOptions(text: string): FormattingOptions { + const match = text.match(/^([ \t]+)\S/m); + if (match) { + const indent = match[1]; + if (indent.startsWith('\t')) { + return { tabSize: 1, insertSpaces: false }; + } + return { tabSize: indent.length, insertSpaces: true }; + } + return { tabSize: 2, insertSpaces: true }; +} + function parseJsonc(text: string): Record { const errors: Array = []; const result = parse(text, errors, { allowTrailingComma: true }); @@ -689,3 +1381,1012 @@ function parseJsonc(text: string): Record { } return result; } + +/** + * `modify()` + `applyEdits()`, but when the edit APPENDS a new member after a + * sibling that carries a trailing inline comment, keep that comment attached to + * the sibling it documented. + * + * jsonc-parser's formatting `modify()` reflows the whole enclosing member/entry + * region and re-emits a trailing inline comment at the END of that region — so + * inserting `migrations_dir` after `"database_id": "id" // production db` (or + * appending a queue entry after `{ … } // jobs producer`) silently reassociates + * the comment onto the NEWLY inserted member. That violates the writer's + * comment-preservation contract. Here we detect that exact case, run the + * (comment-free) formatting edit on a stripped copy, then splice the comment + * back right after the sibling's separating comma so it stays put. + * + * Every other edit (value replacement, append after a comment-free sibling, + * first insertion into a missing container) falls through to the plain path, so + * behavior is byte-identical to before outside the trailing-comment case. + */ +function applyMemberEdit( + text: string, + path: Array, + value: unknown, + options: { formattingOptions: FormattingOptions; isArrayInsertion?: boolean }, +): string { + const plain = (): string => applyEdits(text, modify(text, path, value, options)); + + const root = parseTree(text, undefined, { allowTrailingComma: true }); + if (!root) { + return plain(); + } + + // Only an APPEND can split a sibling from its trailing comment. A value + // replacement (existing object key, not an array insertion) reformats just + // that member and keeps its own comment — nothing to relocate. + if (!options.isArrayInsertion && findNodeAtLocation(root, path) !== undefined) { + return plain(); + } + + // Find the container `modify()` will append the new member to. Usually the + // leaf container (`path` minus its final key/index). But when that container + // does not exist yet, `modify()` creates the first missing segment as a NEW + // member of the deepest ANCESTOR that DOES exist — so that ancestor is the + // receiver, and its current last member is the preceding sibling whose + // trailing comment must stay put (e.g. adding `queues.producers` next to a + // `queues.consumers` that carries a `// …` trailing comment). Anchoring the + // relocation on the real receiving container keeps the comment on the sibling + // in both cases. + let receivingPath = path.slice(0, -1); + let container = findNodeAtLocation(root, receivingPath); + while (!container && receivingPath.length > 0) { + receivingPath = receivingPath.slice(0, -1); + container = findNodeAtLocation(root, receivingPath); + } + const members = container?.children; + if (!container || !members || members.length === 0) { + return plain(); + } + + // jsonc-parser appends both new object properties and new array elements after + // the container's current last member, so that member is the preceding sibling. + const last = members[members.length - 1]; + const trailing = readTrailingComment(text, last.offset + last.length); + if (!trailing) { + return plain(); + } + + // Strip the comment (and the optional separator comma `readTrailingComment` + // skipped to reach it), run the now-comment-free formatting edit, then + // re-attach the comment after the sibling's separating comma in the rewritten + // text. + const stripped = text.slice(0, last.offset + last.length) + text.slice(trailing.end); + const rewritten = applyEdits(stripped, modify(stripped, path, value, options)); + + const newRoot = parseTree(rewritten, undefined, { allowTrailingComma: true }); + const newContainer = newRoot ? findNodeAtLocation(newRoot, receivingPath) : undefined; + const newMembers = newContainer?.children; + if (!newRoot || !newContainer || !newMembers || newMembers.length < 2) { + // We just appended, so this should not happen; fall back rather than corrupt. + return plain(); + } + // The appended member is last; its predecessor is the sibling we relocate onto. + const sibling = newMembers[newMembers.length - 2]; + const commaPos = rewritten.indexOf(',', sibling.offset + sibling.length); + if (commaPos === -1) { + return plain(); + } + const relocated = `${rewritten.slice(0, commaPos + 1)} ${trailing.text}${rewritten.slice(commaPos + 1)}`; + + // Validate-before-write backstop. Comment relocation must ONLY ever change + // comment placement — never JSONC validity or data. Re-parse the relocated + // string; if it does not parse, or its data is not deeply equal to the plain + // formatting edit's data, discard it and return the plain (always-valid) + // result. This makes relocation safe by construction: the worst case is a + // comment reassociated by jsonc-parser onto the wrong sibling, but the file is + // always valid JSONC and always carries the correct data — never corruption. + const plainResult = plain(); + let relocatedParsed: Record; + try { + relocatedParsed = parseJsonc(relocated); + } catch { + return plainResult; + } + if (!jsonEqual(relocatedParsed, parseJsonc(plainResult))) { + return plainResult; + } + return relocated; +} + +/** + * If a trailing inline comment sits on the same line right after `offset`, + * return its text and end offset. Only whitespace and an OPTIONAL single + * separator comma may sit between the sibling's value and the comment — so both + * `"id": "x" // c` and `"id": "x", // c` (a JSONC trailing comma, or the + * separator once a sibling follows) are recognized. A `//` line comment or a + * single-line `/* *\/` block comment counts; a newline or closing bracket first + * means there is no trailing comment (returns undefined). `offset` points just + * past the sibling's value, and the sibling is always its container's LAST + * member, so any comma here is a trailing/separator comma — never a boundary to + * an existing following member. + */ +function readTrailingComment( + text: string, + offset: number, +): { text: string; end: number } | undefined { + let i = offset; + while (i < text.length && (text[i] === ' ' || text[i] === '\t')) { + i++; + } + // Tolerate one optional separator/trailing comma between the value and its + // inline comment. `stripped` (at the call site) removes everything from + // `offset` through the comment's end, so the skipped comma is discarded and + // the re-attach step re-inserts the comment after the sibling's fresh comma. + if (text[i] === ',') { + i++; + while (i < text.length && (text[i] === ' ' || text[i] === '\t')) { + i++; + } + } + if (text[i] === '/' && text[i + 1] === '/') { + // A line comment ends at the first line terminator — CR or LF. Stopping at + // BOTH means a classic-Mac CR-only file terminates the comment here instead + // of swallowing the rest of the line (including the closing brace, which + // would relocate the new member outside the root object and corrupt the + // file); and a CRLF file's trailing `\r` is left out of the comment text and + // preserved on write. + let end = i; + while (end < text.length && text[end] !== '\n' && text[end] !== '\r') { + end++; + } + return { text: text.slice(i, end), end }; + } + if (text[i] === '/' && text[i + 1] === '*') { + const close = text.indexOf('*/', i + 2); + if (close === -1) { + return undefined; + } + const end = close + 2; + // Only a single-line block comment is safe to relocate onto one line. A CR + // or LF inside makes it multi-line → decline (accepted best-effort path). + const slice = text.slice(i, end); + if (slice.includes('\n') || slice.includes('\r')) { + return undefined; + } + return { text: slice, end }; + } + return undefined; +} + +/** + * Throw (without writing) if any object in the config declares the same member + * name twice. See the call site for why a duplicate makes property-level upserts + * silently ineffective. Walks the jsonc AST so nested objects (queue consumers, + * D1 entries, …) are covered, not just the top level. + */ +export function assertNoDuplicateMembers(text: string, configPath: string): void { + const root = parseTree(text, undefined, { allowTrailingComma: true }); + if (!root) { + return; + } + const walk = (node: Node): void => { + if (node.type === 'object' && node.children) { + const seen = new Set(); + for (const property of node.children) { + const keyNode = property.children?.[0]; + const key = keyNode?.value; + if (typeof key !== 'string') { + continue; + } + if (seen.has(key)) { + const { line, column } = offsetToLineColumn(text, keyNode!.offset); + throw new Error( + `deploy: cannot update "${configPath}" — duplicate key ${JSON.stringify(key)} ` + + `at line ${line}, column ${column}. Wrangler reads the last of two duplicate ` + + `members, but this writer edits the first, so the update would silently not ` + + `apply. Remove the duplicate and retry.`, + ); + } + seen.add(key); + } + } + if (node.children) { + for (const child of node.children) { + walk(child); + } + } + }; + walk(root); +} + +/** 1-based line/column for a byte offset — used in duplicate-key error messages. */ +function offsetToLineColumn(text: string, offset: number): { line: number; column: number } { + let line = 1; + let column = 1; + const limit = Math.min(offset, text.length); + for (let i = 0; i < limit; i++) { + if (text[i] === '\n') { + line += 1; + column = 1; + } else { + column += 1; + } + } + return { line, column }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * wrangler's friendly label for each binding namespace. Mirrors + * `friendlyBindingNames` in `wrangler-dist/cli.js` — the map + * `validateBindingsHaveUniqueNames` iterates to gather every worker-wide binding + * name. Drives the owner name in the worker-wide producer-collision error. + * + * This is a 1:1 copy of wrangler's `friendlyBindingNames` EXCEPT `queues`, which + * is intentionally ABSENT: wrangler folds in `queues.producers`, but here producer + * identity is handled by the upsert's own binding-match logic (and consumers carry + * no binding), so including it would flag the very producer we are about to append + * as a self-collision. Every OTHER field wrangler enumerates is present (including + * `unsafe_hello_world`). `secrets.required` — which wrangler folds in separately + * under the label "Secret", not via this map — is handled in + * `collectWorkerBindingNames`. + */ +export const FRIENDLY_BINDING_NAMES: Record = { + data_blobs: 'Data Blob', + durable_objects: 'Durable Object', + kv_namespaces: 'KV Namespace', + send_email: 'Send Email', + d1_databases: 'D1 Database', + vectorize: 'Vectorize Index', + ai_search_namespaces: 'AI Search Namespace', + ai_search: 'AI Search Instance', + websearch: 'Web Search', + agent_memory: 'Agent Memory', + hyperdrive: 'Hyperdrive Config', + r2_buckets: 'R2 Bucket', + logfwdr: 'logfwdr', + services: 'Worker', + analytics_engine_datasets: 'Analytics Engine Dataset', + text_blobs: 'Text Blob', + browser: 'Browser Run', + ai: 'AI', + images: 'Images', + stream: 'Stream', + media: 'Media', + version_metadata: 'Worker Version Metadata', + unsafe: 'Unsafe Metadata', + vars: 'Environment Variable', + wasm_modules: 'Wasm Module', + dispatch_namespaces: 'Dispatch Namespace', + mtls_certificates: 'mTLS Certificate', + workflows: 'Workflow', + pipelines: 'Pipeline', + secrets_store_secrets: 'Secrets Store Secret', + artifacts: 'Artifacts', + ratelimits: 'Rate Limit', + assets: 'Assets', + unsafe_hello_world: 'Hello World', + flagship: 'Flagship', + worker_loaders: 'Worker Loader', + vpc_services: 'VPC Service', + vpc_networks: 'VPC Network', +}; + +/** + * Collect every binding NAME already used across the worker config, mapped to + * wrangler's friendly owner label (first writer wins on ties). Mirrors the exact + * name set `validateBindingsHaveUniqueNames` builds in `wrangler-dist/cli.js`: + * `getBindingNames(config[field])` for every `friendlyBindingNames` field, PLUS + * `config.secrets.required` under the label "Secret" — minus `queues` (see + * FRIENDLY_BINDING_NAMES for why). Used to fail closed before appending a queue + * producer whose binding collides with any other worker-wide binding. + */ +function collectWorkerBindingNames(config: Record): Map { + const owners = new Map(); + const claim = (name: string, friendly: string): void => { + if (!owners.has(name)) { + owners.set(name, friendly); + } + }; + for (const [field, friendly] of Object.entries(FRIENDLY_BINDING_NAMES)) { + for (const name of bindingNamesForField(field, config[field])) { + claim(name, friendly); + } + } + // wrangler folds `secrets.required` into the uniqueness set OUTSIDE the + // `friendlyBindingNames` loop, under the friendly label "Secret" (see + // `validateBindingsHaveUniqueNames`: + // `bindingsGroupedByType["Secret"] = config2.secrets?.required ?? []`). These + // ARE worker binding names at deploy, so a queue producer must never silently + // collide with one. + const secrets = assertObjectContainer(config.secrets, 'secrets'); + if (secrets && secrets.required !== undefined) { + // `secrets.required` is a string[] folded into wrangler's uniqueness set + // under "Secret". Validate it STRICTLY: a non-array, or any non-empty-string + // member, throws (fail closed) rather than being silently skipped, which would + // let a queue producer collide with a real secret binding name unnoticed. + if (!Array.isArray(secrets.required)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "secrets.required" must be an array of ` + + `secret names. Fix the wrangler config and retry.`, + ); + } + secrets.required.forEach((name, index) => { + if (typeof name !== 'string' || name.length === 0) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "secrets.required" contains a malformed ` + + `entry at index ${index} (expected a non-empty string). Refusing to proceed on a ` + + `partially-readable binding set (a dropped entry could hide a real collision). Fix the ` + + `wrangler config and retry.`, + ); + } + claim(name, 'Secret'); + }); + } + return owners; +} + +/** A shallow copy of `config` with one field omitted (never mutates the input). */ +function configWithoutField( + config: Record, + field: string, +): Record { + const { [field]: _omit, ...rest } = config; + return rest; +} + +/** + * Add every `queues.producers[].binding` to an existing worker-binding owner map, + * under the friendly label "Queue" (first-writer-wins). The base + * `collectWorkerBindingNames` deliberately OMITS queues so the producer-append + * path does not flag the producer it is adding as a self-collision — but for the + * ADOPT path a producer binding sharing the adopted binding IS a real cross-type + * collision wrangler rejects at deploy, so the adopt guard layers producers on + * top of the reused collector. + */ +function addQueueProducerOwners( + owners: Map, + config: Record, +): void { + const queues = assertObjectContainer(config.queues, 'queues'); + if (!queues || queues.producers === undefined) { + return; + } + // Validate `queues.producers` STRICTLY (the ONE shared validator, also used + // by the producer-upsert path) so a non-array or a malformed member fails closed + // rather than being silently skipped, which would let the adopt guard miss a real + // producer-binding collision. + for (const producer of assertValidQueueProducers(queues.producers)) { + const binding = producer.binding as string; + if (!owners.has(binding)) { + owners.set(binding, 'Queue'); + } + } +} + +/** + * The ONE strict `queues.producers` validator, shared by BOTH the adopt-guard's + * owner collection ({@link addQueueProducerOwners}) and the producer-upsert path (which + * otherwise clones the list and silently skips non-record members, committing a new + * producer beside a corrupt list). A non-array, or any member that is not a record with + * a non-empty string `binding`, THROWS (fail closed) rather than being dropped — a + * dropped/hidden producer could mask a real binding collision. Returns the validated + * producer records (each guaranteed to carry a non-empty string `binding`). + */ +function assertValidQueueProducers(producers: unknown): Array> { + if (producers === undefined) { + return []; + } + if (!Array.isArray(producers)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "queues.producers" must be an array. Fix ` + + `the wrangler config and retry.`, + ); + } + return producers.map((producer, index) => { + if ( + !isRecord(producer) || + typeof producer.binding !== 'string' || + producer.binding.length === 0 + ) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "queues.producers" contains a malformed ` + + `entry at index ${index} (missing a non-empty string "binding"). Refusing to proceed on a ` + + `partially-readable binding set (a dropped producer could hide a real collision). Fix the ` + + `wrangler config and retry.`, + ); + } + return producer; + }); +} + +/** + * Validate the OUTER shape of a wrangler container field that wrangler 4.107 + * requires to be an OBJECT (`queues`, `secrets`). A PRESENT-but-non-object container + * (array/string/number/null) is malformed for wrangler AND would bypass the binding + * collectors, HIDING a real cross-type binding collision — so it THROWS (fail closed). + * An ABSENT container (undefined) returns undefined (a no-op, NOT an error). + * Returns the container as a record when present-and-object. + * + * NOTE: `null` is `typeof 'object'` but `isRecord(null)` is false, so `secrets: null` / + * `queues: null` (present but null) THROWS — correct (wrangler requires an object). + * Absent (`undefined`) is the only no-op. + */ +function assertObjectContainer(value: unknown, field: string): Record | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an object. Refusing to ` + + `proceed on a partially-readable binding set (a wrong-shaped container could hide a real ` + + `collision). Fix the wrangler config and retry.`, + ); + } + return value; +} + +/** + * The STRICT canonical reader for a single provisioned binding entry in a wrangler + * config field (`d1_databases` / `kv_namespaces` / `r2_buckets` / `hyperdrive`). Replaces + * the provisioner's loose first-match `.find()` — which validated no outer shape, + * silently picked among duplicates, and bypassed the worker-wide collision check, so a + * duplicate/cross-type-collision config could be reported `reused` or pass a create + * postcondition though it is undeployable. Fails closed on: + * 1. a malformed TARGET field (reuses {@link bindingNamesForField}: a non-array outer + * shape, or any member without a non-empty string `binding`, throws); + * 2. MORE THAN ONE entry in the target field sharing `binding` (ambiguous identity — + * wrangler also rejects duplicate binding names); + * 3. a worker-wide CROSS-TYPE collision — `binding` is ALSO owned by any OTHER binding + * type (reuses the {@link collectWorkerBindingNames} Task-3 collector, EXCLUDING the + * target field so the entry never self-collides, and INCLUDING `queues.producers`). + * Returns the single matching entry, or `undefined` when the binding is absent. + */ +export function readProvisionedBindingEntry( + config: Record, + field: string, + binding: string, + configPath: string, +): Record | undefined { + // 1. Outer-shape validation of the TARGET field (throws on a non-array / malformed + // member — a member without a valid `binding` could hide a duplicate/collision). + bindingNamesForField(field, config[field]); + // 2. EXACTLY zero-or-one entry in the target field carries this binding. + const rawList = config[field]; + const list = Array.isArray(rawList) ? rawList : []; + const matches = list.filter( + (entry): entry is Record => isRecord(entry) && entry.binding === binding, + ); + if (matches.length > 1) { + throw new Error( + `provision: cannot read "${configPath}" — binding ${JSON.stringify(binding)} is declared ` + + `${matches.length} times in "${field}". Cannot pick one to reuse/verify, and wrangler rejects ` + + `duplicate binding names. Remove the duplicate and retry.`, + ); + } + // 3. Worker-wide CROSS-TYPE uniqueness — the binding must not be owned by any OTHER + // field. Build the owner map from the config EXCLUDING the target field (so the + // entry does not self-collide) and INCLUDING queues.producers. Reuse the ONE + // collector — never fork it. + const owners = collectWorkerBindingNames(configWithoutField(config, field)); + addQueueProducerOwners(owners, config); + const owner = owners.get(binding); + if (owner !== undefined) { + throw new Error( + `provision: cannot read "${configPath}" — binding ${JSON.stringify(binding)} in "${field}" is ` + + `also used by a ${owner} binding. Wrangler requires every binding name to be unique across ` + + `the whole worker, so this config is undeployable. Rename the conflicting binding and retry.`, + ); + } + return matches[0]; +} + +/** + * The OUTER binding-name SHAPE of a wrangler config field, so the collector reads + * binding NAMES by KNOWN structure instead of GUESSING from a value's content (which + * misreads a `vars` value that happens to look like a binding list/object, and lets a + * required-but-missing binding pass). Each variant + its required-ness is derived + * DIRECTLY from wrangler 4.107's config-schema.json (`RawConfig.properties`): + * + * | shape | schema example | binding name is | required | + * | ---------------- | -------------------------------------- | --------------- | --------------- | + * | key-map | vars/wasm_modules/text_blobs/data_blobs| the KEYS | — | + * | name-keyed-array | send_email/ratelimits `[{ name }]` | member `.name` | `.name` req | + * | namespace-array | kv_namespaces/d1_databases/… `[{ binding }]` | member `.binding` | `.binding` req | + * | singleton | ai/browser/…/version_metadata `{ binding }` | `.binding` | req (assets: optional) | + * | binding-list | durable_objects/logfwdr/unsafe `{ bindings: [{ name }] }` | member `.name` | `bindings` req (unsafe: optional) | + * + * `namespace-array` is the DEFAULT for every `FRIENDLY_BINDING_NAMES` field not listed + * in {@link BINDING_FIELD_SHAPES}. `secrets.required` (a `string[]`) is folded in + * separately by {@link collectWorkerBindingNames}, and `queues.producers` by + * {@link addQueueProducerOwners} / the producer-upsert path — neither uses this table. + */ +type BindingFieldShape = + | { kind: 'key-map' } + | { kind: 'name-keyed-array' } + | { kind: 'namespace-array' } + | { kind: 'singleton'; bindingRequired: boolean } + | { kind: 'binding-list'; bindingsRequired: boolean }; + +/** + * Explicit per-field shape metadata (see {@link BindingFieldShape}), transcribed + * from wrangler 4.107's config schema. Any `FRIENDLY_BINDING_NAMES` field absent here is + * a `namespace-array` (`[{ binding }]`) — the shape wrangler's `getBindingNames` reads + * via its `isNamespaceList` branch. + */ +const BINDING_FIELD_SHAPES: Record = { + // Key-maps — the KEYS are the binding names (env var / module / blob names). Never + // read the VALUES, so a value that looks like a binding list/object is not misread. + vars: { kind: 'key-map' }, + wasm_modules: { kind: 'key-map' }, + text_blobs: { kind: 'key-map' }, + data_blobs: { kind: 'key-map' }, + // Name-keyed arrays — identity on `.name` (wrangler's `getBindingNames` overlooks + // these; `validateSendEmailBinding`/`validateRateLimitBinding` require `.name`). + send_email: { kind: 'name-keyed-array' }, + ratelimits: { kind: 'name-keyed-array' }, + // Singletons `{ binding }` — `binding` REQUIRED per schema for all except `assets` + // (`validateAssetsConfig`, where a binding-less assets-only-directory is legal). + ai: { kind: 'singleton', bindingRequired: true }, + browser: { kind: 'singleton', bindingRequired: true }, + images: { kind: 'singleton', bindingRequired: true }, + stream: { kind: 'singleton', bindingRequired: true }, + media: { kind: 'singleton', bindingRequired: true }, + websearch: { kind: 'singleton', bindingRequired: true }, + version_metadata: { kind: 'singleton', bindingRequired: true }, + assets: { kind: 'singleton', bindingRequired: false }, + // Binding lists `{ bindings: [{ name }] }` — `bindings` REQUIRED for durable_objects + // and logfwdr; OPTIONAL for `unsafe` (which may carry only `metadata`). + durable_objects: { kind: 'binding-list', bindingsRequired: true }, + logfwdr: { kind: 'binding-list', bindingsRequired: true }, + unsafe: { kind: 'binding-list', bindingsRequired: false }, +}; + +/** + * The ONE field-aware strict binding-name reader for a single config field. Routes + * each field to its KNOWN shape (see {@link BINDING_FIELD_SHAPES}) and FAILS CLOSED on + * any malformed/missing REQUIRED identity or wrong outer shape — never content-guessing. + * wrangler is lenient and would silently drop such a member, letting the worker-wide + * uniqueness guard miss (or invent) a collision. + * + * Exported so the deploy orchestrator's pass-through assert-set check reads a resource field's + * per-entry identity keys through the SAME wrangler-schema shapes (handling `send_email` / + * `ratelimits` name-keying and `logfwdr`'s nested `.bindings`, among others). + */ +export function bindingNamesForField(field: string, value: unknown): Array { + const shape: BindingFieldShape = BINDING_FIELD_SHAPES[field] ?? { kind: 'namespace-array' }; + switch (shape.kind) { + case 'key-map': + return keyMapBindingNames(field, value); + case 'name-keyed-array': + return nameKeyedBindingNames(field, value); + case 'namespace-array': + return namespaceArrayBindingNames(field, value); + case 'singleton': + return singletonBindingName(field, value, shape.bindingRequired); + case 'binding-list': + return bindingListNames(field, value, shape.bindingsRequired); + } +} + +/** + * The entry-shaped sibling of {@link bindingNamesForField}: routes each field to its KNOWN + * shape and returns every binding's NAME **paired with its full config ENTRY object**, so a + * caller can compare not just WHICH bindings a field declares but the TARGET each one points + * at (a `services` binding's `service`, an `unsafe` binding's `type`, …). Same fail-closed + * philosophy as {@link bindingNamesForField} — a malformed/missing REQUIRED identity or wrong + * outer shape THROWS. By construction `bindingEntriesForField(f, v).map((e) => e.name)` equals + * `bindingNamesForField(f, v)` for every field (both read identity through the same shapes). + * + * Exported so the deploy orchestrator's pass-through assert-set check can pin a legitimately + * hand-declared binding's ENTRY pre-build and reject a build that KEEPS the name but REWRITES + * the target. + */ +export function bindingEntriesForField( + field: string, + value: unknown, +): Array<{ name: string; entry: Record }> { + const shape: BindingFieldShape = BINDING_FIELD_SHAPES[field] ?? { kind: 'namespace-array' }; + switch (shape.kind) { + case 'key-map': + return keyMapBindingEntries(field); + case 'name-keyed-array': + return nameKeyedBindingEntries(field, value); + case 'namespace-array': + return namespaceArrayBindingEntries(field, value); + case 'singleton': + return singletonBindingEntry(field, value, shape.bindingRequired); + case 'binding-list': + return bindingListEntries(field, value, shape.bindingsRequired); + } +} + +/** + * Entry-shaped reader for a KEY-MAP field. Key-map fields (vars/wasm_modules/text_blobs/ + * data_blobs) carry no per-binding config ENTRY object — the binding name is the KEY and the + * VALUE is arbitrary content — and all four are excluded from the pass-through assert-set, so + * this branch is never reached in the normal flow. It THROWS the same wrong-shape message the + * name reader raises, so a FUTURE misclassification that routed a key-map field here fails + * CLOSED rather than silently reading no entries. + */ +function keyMapBindingEntries(field: string): never { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an object mapping names ` + + `to values. Refusing to proceed on a partially-readable binding set (a wrong-shaped field ` + + `could hide a real collision). Fix the wrangler config and retry.`, + ); +} + +/** + * Entry-shaped reader for a NAMESPACE-array field (mirror of {@link namespaceArrayBindingNames}): + * a PRESENT non-array THROWS; every member is strictly validated for a non-empty string + * `.binding` and returned with its full entry object. + */ +function namespaceArrayBindingEntries( + field: string, + value: unknown, +): Array<{ name: string; entry: Record }> { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an array of binding ` + + `entries. Refusing to proceed on a partially-readable binding set (a wrong-shaped field ` + + `could hide a real collision). Fix the wrangler config and retry.`, + ); + } + return strictBindingEntries(value, 'binding', field); +} + +/** + * Entry-shaped reader for a NAME-keyed array field (mirror of {@link nameKeyedBindingNames}): + * identity is `.name` only; a PRESENT non-array THROWS; every member is strictly validated. + */ +function nameKeyedBindingEntries( + field: string, + value: unknown, +): Array<{ name: string; entry: Record }> { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an array of binding ` + + `entries. Refusing to proceed on a partially-readable binding set (a wrong-shaped field ` + + `could hide a real collision). Fix the wrangler config and retry.`, + ); + } + return strictBindingEntries(value, 'name', field); +} + +/** + * Entry-shaped reader for a binding-LIST field (mirror of {@link bindingListNames}): the entry + * of each member is the member itself (`{ name, ... }`), keyed on `.name`. Same throw semantics + * as the name reader — a wrong outer shape, or a required-but-missing `bindings`, THROWS; a + * `bindings`-less `unsafe` (optional) contributes nothing. + */ +function bindingListEntries( + field: string, + value: unknown, + bindingsRequired: boolean, +): Array<{ name: string; entry: Record }> { + if (value === undefined) { + return []; + } + if (!isRecord(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an object with a ` + + `"bindings" array. Refusing to proceed on a partially-readable binding set (a wrong-shaped ` + + `field could hide a real collision). Fix the wrangler config and retry.`, + ); + } + if (value.bindings === undefined) { + if (bindingsRequired) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" requires a "bindings" array. ` + + `Refusing to proceed on a partially-readable binding set (a wrong-shaped field could hide ` + + `a real collision). Fix the wrangler config and retry.`, + ); + } + return []; + } + if (!Array.isArray(value.bindings)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}.bindings" must be an array. ` + + `Refusing to proceed on a partially-readable binding set (a wrong-shaped field could hide ` + + `a real collision). Fix the wrangler config and retry.`, + ); + } + return strictBindingEntries(value.bindings, 'name', `${field}.bindings`); +} + +/** + * Entry-shaped reader for a SINGLETON field (mirror of {@link singletonBindingName}): the entry + * is the singleton value itself. A PRESENT non-object THROWS; a present-but-malformed `binding` + * THROWS; a required-but-absent `binding` THROWS; a binding-optional `assets` with no binding + * contributes nothing. + */ +function singletonBindingEntry( + field: string, + value: unknown, + bindingRequired: boolean, +): Array<{ name: string; entry: Record }> { + if (value === undefined) { + return []; + } + if (!isRecord(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an object with a ` + + `"binding". Refusing to proceed on a partially-readable binding set (a wrong-shaped field ` + + `could hide a real collision). Fix the wrangler config and retry.`, + ); + } + if (typeof value.binding === 'string' && value.binding.length > 0) { + return [{ name: value.binding, entry: value }]; + } + if (value.binding !== undefined) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" has a malformed "binding" ` + + `(expected a non-empty string). Refusing to proceed on a partially-readable binding set (a ` + + `dropped binding could hide a real collision). Fix the wrangler config and retry.`, + ); + } + if (bindingRequired) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" requires a "binding" (a ` + + `non-empty string). Refusing to proceed on a partially-readable binding set (a dropped ` + + `binding could hide a real collision). Fix the wrangler config and retry.`, + ); + } + return []; +} + +/** + * Read binding names from a KEY-MAP field (`vars`/`wasm_modules`/`text_blobs`/ + * `data_blobs`): the object's KEYS are the binding names. Reads ONLY the keys — never + * the values — so a value that happens to look like a binding list (`{bindings:[…]}`) + * or a singleton (`{binding:'x'}`) is NOT misread (the old generic path returned the + * VALUE, or a nested `name`, instead of the real KEY). A PRESENT value that is not a + * plain object is the wrong outer shape and THROWS (fail closed). An explicitly + * `undefined`-valued key is not a real binding and is skipped. + */ +function keyMapBindingNames(field: string, value: unknown): Array { + if (value === undefined) { + return []; + } + if (!isRecord(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an object mapping names ` + + `to values. Refusing to proceed on a partially-readable binding set (a wrong-shaped field ` + + `could hide a real collision). Fix the wrangler config and retry.`, + ); + } + return Object.keys(value).filter((key) => value[key] !== undefined); +} + +/** + * Read binding names from a NAMESPACE-array field (`[{ binding }]`: kv_namespaces, + * d1_databases, r2_buckets, hyperdrive, services, vectorize, …). A PRESENT value that + * is not an array is the wrong outer shape and THROWS rather than being re-read as a + * key-map (which could under-collect and hide a real collision); every member is + * strictly validated for a non-empty string `.binding`. + */ +function namespaceArrayBindingNames(field: string, value: unknown): Array { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an array of binding ` + + `entries. Refusing to proceed on a partially-readable binding set (a wrong-shaped field ` + + `could hide a real collision). Fix the wrangler config and retry.`, + ); + } + return strictBindingArray(value, 'binding', field); +} + +/** + * Read binding names from a NAME-keyed array field (`send_email` / `ratelimits`: + * an array of `{ name }`). Encodes the field's REQUIRED outer shape: a PRESENT value that + * is not an array THROWS (fail closed) rather than falling back to generic key collection — + * wrangler validates these via `validateBindingArray`, so a non-array is malformed and + * could hide a real collision if silently re-read as a key-map. An ABSENT value contributes + * nothing. Every array member is strictly validated (a malformed one rejects the whole + * config). + * + * Identity is the `.name` ONLY. Verified against wrangler 4.107's bundle + + * `config-schema.json`: NEITHER `send_email` NOR `ratelimits` has a `.binding` property in + * the schema, and on UPLOAD wrangler keys them by `.name` (send_email: + * `output[name] = { type: "send_email", ...}`; ratelimits push `{ name: binding.name, … }`). + * An optional `.binding` that happens to sit on such an entry is NOT a binding identity + * wrangler recognizes, so unioning it invents a PHANTOM collision — e.g. a + * `send_email:[{name:"EMAIL_A",binding:"QUEUE_JOBS"}]` would falsely block a legitimate + * `QUEUE_JOBS` queue producer / adopt / read-first. We therefore collect ONLY the `.name` + * of every member (a genuine same-`name` collision is still caught). + */ +function nameKeyedBindingNames(field: string, value: unknown): Array { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an array of binding ` + + `entries. Refusing to proceed on a partially-readable binding set (a wrong-shaped field ` + + `could hide a real collision). Fix the wrangler config and retry.`, + ); + } + return strictBindingArray(value, 'name', field); +} + +/** + * Read the binding names from a binding-LIST field (`{ bindings: [{ name }] }`: + * durable_objects / logfwdr / unsafe). A PRESENT value that is not an object is the + * wrong outer shape and THROWS. When `bindingsRequired` (durable_objects / logfwdr per + * schema), a MISSING `bindings` array THROWS (never a silent empty read); `unsafe` is + * optional, so a `bindings`-less `{ metadata }` contributes nothing. A PRESENT + * `bindings` array has EVERY member strictly validated for a non-empty string `name`. + */ +function bindingListNames(field: string, value: unknown, bindingsRequired: boolean): Array { + if (value === undefined) { + return []; + } + // Encode the REQUIRED outer shape: a binding-list field must be an object; a + // wrong-shaped value (array, string, …) THROWS rather than under-collecting (which + // could hide a real binding collision). + if (!isRecord(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an object with a ` + + `"bindings" array. Refusing to proceed on a partially-readable binding set (a wrong-shaped ` + + `field could hide a real collision). Fix the wrangler config and retry.`, + ); + } + if (value.bindings === undefined) { + if (bindingsRequired) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" requires a "bindings" array. ` + + `Refusing to proceed on a partially-readable binding set (a wrong-shaped field could hide ` + + `a real collision). Fix the wrangler config and retry.`, + ); + } + return []; + } + if (!Array.isArray(value.bindings)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}.bindings" must be an array. ` + + `Refusing to proceed on a partially-readable binding set (a wrong-shaped field could hide ` + + `a real collision). Fix the wrangler config and retry.`, + ); + } + return strictBindingArray(value.bindings, 'name', `${field}.bindings`); +} + +/** + * Read the binding name from a SINGLETON field (`{ binding }`: ai / browser / + * images / stream / media / websearch / version_metadata / assets). A PRESENT value + * that is not an object is the wrong outer shape and THROWS. A PRESENT-but-malformed + * `binding` (non-string / empty) THROWS. When `bindingRequired` (every singleton except + * `assets`, per schema), an ABSENT `binding` also THROWS — never a silent empty read, + * and never a fall-through that would mis-collect the object's OTHER keys (e.g. + * `{ staging: true }`) as bogus binding names. `assets` is binding-optional, so an + * absent binding (e.g. `{ directory }`) contributes nothing. + */ +function singletonBindingName( + field: string, + value: unknown, + bindingRequired: boolean, +): Array { + if (value === undefined) { + return []; + } + if (!isRecord(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an object with a ` + + `"binding". Refusing to proceed on a partially-readable binding set (a wrong-shaped field ` + + `could hide a real collision). Fix the wrangler config and retry.`, + ); + } + if (typeof value.binding === 'string' && value.binding.length > 0) { + return [value.binding]; + } + if (value.binding !== undefined) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" has a malformed "binding" ` + + `(expected a non-empty string). Refusing to proceed on a partially-readable binding set (a ` + + `dropped binding could hide a real collision). Fix the wrangler config and retry.`, + ); + } + if (bindingRequired) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" requires a "binding" (a ` + + `non-empty string). Refusing to proceed on a partially-readable binding set (a dropped ` + + `binding could hide a real collision). Fix the wrangler config and retry.`, + ); + } + return []; +} + +/** + * Strictly read `keyField` (a non-empty string) from EVERY member of a + * binding-bearing array. THROWS (fail closed) on ANY malformed member — never + * silently drops one. Dropping a malformed member could convert a mixed-validity + * array (e.g. `[null, { binding: 'DB', id: '…' }]`) into an empty/partial binding + * set, so the worker-wide uniqueness guard would MISS the existing `DB` and commit + * a conflicting binding — a fail-open in a documented invariant. Mirrors the + * writer's existing fail-closed-on-malformed-config philosophy. + */ +function strictBindingArray( + members: Array, + keyField: 'binding' | 'name', + field: string, +): Array { + return strictBindingEntries(members, keyField, field).map((e) => e.name); +} + +/** + * The entry-shaped sibling of {@link strictBindingArray}: reads `keyField` (a non-empty string) + * from EVERY member and returns it PAIRED with the member's full entry object. Same fail-closed + * validation — a non-record member, or one missing a non-empty string `keyField`, THROWS (never + * silently dropped). {@link strictBindingArray} is defined in terms of this, so the two agree on + * names and error messages for every field. + */ +function strictBindingEntries( + members: Array, + keyField: 'binding' | 'name', + field: string, +): Array<{ name: string; entry: Record }> { + return members.map((member, index) => { + if ( + !isRecord(member) || + typeof member[keyField] !== 'string' || + (member[keyField] as string).length === 0 + ) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" contains a malformed entry at ` + + `index ${index} (missing a non-empty string "${keyField}"). Refusing to proceed on a ` + + `partially-readable binding set (a dropped entry could hide a real collision). Fix the ` + + `wrangler config and retry.`, + ); + } + return { name: member[keyField] as string, entry: member }; + }); +} + +/** + * True when a D1 `migrations_pattern` is valid under `dir` per wrangler's rule: + * the normalized pattern must start with `${normalized dir}/` (or the dir + * normalizes to "."). Mirrors wrangler's `resolveMigrationsConfig` / + * `stripDirPrefix`. + */ +function migrationsPatternMatchesDir(pattern: string, dir: string): boolean { + const normalizedDir = normalizeMigrationsPath(dir); + if (normalizedDir === '.') { + return true; + } + return normalizeMigrationsPath(pattern).startsWith(`${normalizedDir}/`); +} + +/** + * Mirror wrangler's `normalizeRelativePath`: forward-slash, posix-normalize, then + * drop a trailing slash. Keeps the migrations validity check byte-identical with + * wrangler's own. + */ +function normalizeMigrationsPath(p: string): string { + const normalized = normalize(p.replace(/\\/g, '/')); + return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized; +} + +/** Order-insensitive structural equality — avoids rewriting an entry that only + * differs in key order (preserves the file's bytes/comments on that entry). */ +function jsonEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(canonicalize(a)) === JSON.stringify(canonicalize(b)); +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (isRecord(value)) { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonicalize(value[key])]), + ); + } + return value; +} diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 44949440f..5bb5eedcf 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -1261,8 +1261,21 @@ export function voidPlugin(options?: { // Plugin that loads .env files and merges them into workerConfig.vars. // Runs before the Cloudflare plugin so vars are available as worker bindings. + // + // The `{ mode, envDir }` this hook OBSERVES (raw, root-relative) is exposed via `api` so the + // self-host `void deploy --backend cloudflare` secret preflight can validate the SAME `.env*` + // set the build ships. It must be captured HERE, at this hook's position, not read off the + // final `resolved.envDir`: config hooks run in sequence, and a LATER plugin's `config` hook + // that returns a different `envDir` would change `resolved.envDir` while this hook already + // loaded env from the value it saw — the preflight reading the final value would then diverge + // from what the build baked. + let voidDeployBuildConfig: { mode: string; envDir: string | undefined } | undefined; const envPlugin: Plugin = { name: 'void:env', + api: { + getVoidDeployBuildConfig: (): { mode: string; envDir: string | undefined } | undefined => + voidDeployBuildConfig, + }, config(userConfig, { mode, command }) { viteCommand = command; // Exclude the .void cache directory from the file watcher so that @@ -1271,10 +1284,12 @@ export function voidPlugin(options?: { // Honour the user's `envDir` config — projects that keep .env files // in `config/` or similar would otherwise be silently ignored. Falls // back to the project root, matching Vite's own default. - const envDir = + const rawEnvDir = typeof userConfig.envDir === 'string' && userConfig.envDir.length > 0 - ? join(root, userConfig.envDir) - : root; + ? userConfig.envDir + : undefined; + voidDeployBuildConfig = { mode, envDir: rawEnvDir }; + const envDir = rawEnvDir ? join(root, rawEnvDir) : root; // loadEnv with prefix "" returns .env file vars AND all process.env vars. // filterLoadedEnv strips shell pollution (PATH, HOME, etc.) so only // .env-file vars reach worker bindings. stripInternalEnvKeys then diff --git a/packages/void/src/shared/dotenv.ts b/packages/void/src/shared/dotenv.ts index 7bf100b40..bada835a2 100644 --- a/packages/void/src/shared/dotenv.ts +++ b/packages/void/src/shared/dotenv.ts @@ -239,3 +239,39 @@ export function mergeAndExpand( } return expandDotenv(merged, options); } + +/** + * Wrangler 4.107's bundled `dotenv` LINE regex (verbatim), used ONLY to detect the + * declared KEYS wrangler would load — including the OPTIONAL `export ` prefix, the `KEY: val` + * form, and single/double/backtick quoting — so a transport-redirect / config-override fence + * sees exactly what wrangler's per-command dotenv reload would. We read keys, never values. + */ +const WRANGLER_DOTENV_LINE = + /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm; + +/** + * The set of declared dotenv KEYS in `filePath`, parsed the way wrangler does. + * Lives in this leaf module (no `proper-lockfile` / lifecycle side effects) so both + * the provisioner's transport fence and the deploy orchestrator's config-override + * fence can read keys exactly as wrangler's per-command reload would, without either + * caller pulling in the provision lifecycle just to parse dotenv keys. + */ +export function scanWranglerDotenvKeys(filePath: string): Set { + const keys = new Set(); + if (!existsSync(filePath)) { + return keys; + } + const content = readFileSync(filePath, 'utf-8'); + WRANGLER_DOTENV_LINE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = WRANGLER_DOTENV_LINE.exec(content)) !== null) { + if (match[1]) { + keys.add(match[1]); + } + // Guard against a zero-width match wedging the loop. + if (match.index === WRANGLER_DOTENV_LINE.lastIndex) { + WRANGLER_DOTENV_LINE.lastIndex++; + } + } + return keys; +} diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts new file mode 100644 index 000000000..b3dd56f5b --- /dev/null +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -0,0 +1,6990 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { log } from '@clack/prompts'; +import { dirname, join } from 'pathe'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { + assertMigrationsProduceAuthSchema, + assertNoConfigOverrideInProjectDotenv, + assertWranglerUploaderUnchanged, + checkWranglerMigrationApplySet, + compareWranglerMigrationPaths, + computeDesiredCloudflareResources, + deployChildEnv, + DEPLOY_PASSTHROUGH_ASSERT_FIELDS, + DEPLOY_PASSTHROUGH_EXCLUDE_FIELDS, + DEPLOY_WHOLE_OBJECT_PINNED_FIELDS, + hashWranglerUploader, + runCloudflareDeploy, + snapshotBindingIdentities, + snapshotPassthroughIdentities, + snapshotPassthroughRawFields, + validateAuthMigrationSchema, + verifyGeneratedDeployConfig, + type CloudflareDeployDeps, + type DeployWranglerRun, + type DeployWranglerRunResult, + type EmittedWorkerVarsResult, +} from '../../src/cli/deploy.ts'; +import { + resolveEnvSchemaFactsViaSubprocess, + validateEmittedVarsViaSubprocess, + type EnvSchemaProbeResult, + type EnvSchemaProbeSpawnResult, +} from '../../src/cli/env-schema-probe.ts'; +import { + dischargeSelfHostEnvContext, + type SelfHostEnvContext, + type SelfHostSchemaFacts, +} from '../../src/cli/env-validation.ts'; +import type { CliLogger } from '../../src/cli/log-file.ts'; +import { provisionCloudflareResources, type ProvisionResult } from '../../src/cli/provision.ts'; +import { FRIENDLY_BINDING_NAMES } from '../../src/cli/wrangler.ts'; +import { readConfig } from '../../src/config.ts'; + +const tmpDir = join(import.meta.dirname, '.tmp-cf-deploy-test'); + +/** Write a project fixture: a map of relative path → file contents. */ +function makeProject(files: Record): string { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + for (const [rel, content] of Object.entries(files)) { + const abs = join(tmpDir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + return tmpDir; +} + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +const silentCliLog: CliLogger = { + info() {}, + warn() {}, + error() {}, + debug() {}, + path: null, + close: async () => {}, +}; + +/** Synthetic real ids (shape-valid) for drift/provision fixtures. */ +const REAL_D1_ID = '52b3bbc7-c473-4b26-9486-1345f5de61c4'; +/** A second shape-valid D1 id, so a tampered config can point at a DIFFERENT database. */ +const OTHER_D1_ID = '11111111-2222-3333-4444-555555555555'; +const REAL_KV_ID = '7c07b54c9f2a4d1e8b3f6a0c153aa11a'; +/** A second shape-valid KV id, so a two-namespace config can be distinct. */ +const OTHER_KV_ID = '3f9a1c2d4e5b6a7c8d9e0f1a2b3c4d5e'; + +class ExitError extends Error { + constructor(public code: number) { + super(`exit(${code})`); + } +} + +const cleanExit: DeployWranglerRunResult = { + status: 0, + stdout: '', + stderr: '', + signal: null, + error: null, +}; + +/** No-schema (nothing-to-gate) env-insensitive facts. */ +const NO_SCHEMA_FACTS: SelfHostSchemaFacts = { hasSchema: false, requiredKeys: [], secretKeys: [] }; + +/** A default-passing env-schema probe result (no schema → nothing to gate). */ +function okSchemaFacts(overrides: Partial = {}): EnvSchemaProbeResult { + return { kind: 'ok', facts: { ...NO_SCHEMA_FACTS, ...overrides } }; +} + +/** A default emitted-vars read (empty vars — a worker that ships no plaintext vars). */ +function emittedVars(vars: Record = {}): EmittedWorkerVarsResult { + return { ok: true, vars }; +} + +/** Absolute src paths for runtime imports inside spawned-child env.ts fixtures. */ +const srcEnvPublic = join(import.meta.dirname, '..', '..', 'src', 'runtime', 'env-public.ts'); +const srcEnvHelpers = join(import.meta.dirname, '..', '..', 'src', 'runtime', 'env-helpers.ts'); + +/** + * Wrangler's FULL credential env-var set, canonical names + deprecated `CF_*` aliases. + * Mirrors `WRANGLER_AUTH_CREDENTIAL_ENV_VARS` in deploy.ts. Every child that runs untrusted + * project code (env-schema probes, `vite build`, `drizzle-kit generate`) must see NONE of + * these; the wrangler runner must still see ALL of them. + */ +const WRANGLER_AUTH_VARS = [ + 'CLOUDFLARE_API_TOKEN', + 'CF_API_TOKEN', + 'CLOUDFLARE_API_KEY', + 'CF_API_KEY', + 'CLOUDFLARE_EMAIL', + 'CF_EMAIL', + 'CLOUDFLARE_API_USER_SERVICE_KEY', + 'WRANGLER_CF_AUTHORIZATION_TOKEN', + 'WRANGLER_R2_SQL_AUTH_TOKEN', + 'CLOUDFLARE_ACCESS_CLIENT_ID', + 'CLOUDFLARE_ACCESS_CLIENT_SECRET', +]; + +/** Set every wrangler credential on the parent for the duration of `run`, then restore. */ +async function withWranglerCredentials(run: () => Promise): Promise { + const saved: Record = {}; + for (const name of WRANGLER_AUTH_VARS) { + saved[name] = process.env[name]; + process.env[name] = `secret-${name}`; + } + try { + await run(); + } finally { + for (const name of WRANGLER_AUTH_VARS) { + if (saved[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = saved[name]; + } + } + } +} + +/** + * Write a `.void/better-auth-schema.ts` fixture whose `migrationSchema` literal matches the + * shape the build emits (`JSON.stringify(resolveVoidAuthMigrationSchema(...), null, 2)`), so + * the post-build auth check reads ground truth exactly as it would after a real build. + */ +/** + * A field of the emitted artifact. A bare string is the plain case; the object form carries the + * `required` / `unique` / `references` metadata the real build emits (see + * `playground/kitchen-sink/.void/better-auth-schema.ts`, e.g. `user.email` is + * `{ required: true, unique: true }` and `session.userId` references `user.id`). + * + * `required` defaults to true because that is what the real build emits for all but a handful + * of optional fields (`user.image`, `session.ipAddress`, …). + */ +type AuthSchemaField = + | string + | { + name: string; + required?: boolean; + unique?: boolean; + references?: { table: string; field: string; onDelete?: string }; + }; + +function writeAuthSchemaArtifact( + root: string, + tables: Array<{ name: string; fields: Array }>, +): void { + const migrationSchema = { + dialect: 'sqlite', + idStrategy: 'text', + tables: tables.map((t, i) => ({ + name: t.name, + order: i + 1, + fields: Object.fromEntries( + t.fields.map((f) => { + const field = typeof f === 'string' ? { name: f } : f; + return [ + field.name, + { + type: 'string', + required: field.required !== false, + unique: field.unique === true, + index: false, + bigint: false, + ...(field.references ? { references: field.references } : {}), + }, + ]; + }), + ), + })), + }; + const dir = join(root, '.void'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'better-auth-schema.ts'), + `// Auto-generated fixture\nexport const migrationSchema = ${JSON.stringify(migrationSchema, null, 2)} as const;\n`, + ); +} + +/** + * Build a seam bag whose default seams all PASS, recording a call trace so tests + * assert ordering + that later steps are skipped on an exit. Individual seams are + * overridable per test. + */ +function harness(overrides: Partial = {}): { + deps: CloudflareDeployDeps; + trace: Array; + wranglerArgs: Array>; + exitCalls: Array; +} { + const trace: Array = []; + const wranglerArgs: Array> = []; + const exitCalls: Array = []; + const runWrangler: DeployWranglerRun = (args) => { + wranglerArgs.push(args); + trace.push(`wrangler:${args[0]}`); + if (args[0] === 'whoami') { + return { ...cleanExit, stdout: '{"loggedIn":true}' }; + } + // `d1 migrations list` (the post-apply pending verify) must print wrangler's affirmative + // "no pending" sentinel under the pinned WRANGLER_LOG=log, else the B3 check fails closed. + if (args[0] === 'd1' && args[1] === 'migrations' && args[2] === 'list') { + return { ...cleanExit, stdout: '✅ No migrations to apply!\n' }; + } + return cleanExit; + }; + const deps: CloudflareDeployDeps = { + isCI: false, + exit: (code: number) => { + exitCalls.push(code); + throw new ExitError(code); + }, + runWrangler, + runBuild: () => { + trace.push('build'); + }, + // Stub the wrangler-uploader hash so orchestration tests never hash the real ~18MB bundle + // twice; a constant means the pre-build and post-build snapshots match (an unchanged uploader). + // Dedicated tests inject a stateful stub that changes across the build to drive the fail path. + hashWranglerUploader: () => 'uploader-hash-unchanged', + provision: (async (_root, configPath): Promise => { + trace.push('provision'); + return { resources: [], configPath, accountIdWritten: false }; + }) as CloudflareDeployDeps['provision'], + // Stub the isolated env-schema probe so orchestration tests never spawn a child; the + // env-insensitive facts (required names + `.secret()` flags) are returned inline. Silent + // (no trace) — it runs pre-build and is not a step these tests order around. + resolveSchemaFacts: (() => okSchemaFacts()) as CloudflareDeployDeps['resolveSchemaFacts'], + // Stub the isolated emitted-var validation probe (F3) so orchestration tests never spawn a + // child; by default nothing is invalid. Only invoked when the schema facts declare a schema. + // Dedicated tests inject a failing set to exercise the invalid-value fail-closed path. + validateEmittedVars: (async () => ({ + kind: 'invalid-keys', + invalidKeys: [], + })) as CloudflareDeployDeps['validateEmittedVars'], + // Stub the emitted-vars read so orchestration tests need no real dist/ssr/wrangler.json; + // dedicated tests inject the REAL vars a build would emit. Silent. + readEmittedWorkerVars: (() => emittedVars()) as CloudflareDeployDeps['readEmittedWorkerVars'], + // Parent-side gate discharge (the wrangler `secret list` comparison). Dedicated tests + // inject a throwing stub or exercise the real derive+discharge units directly. + dischargeSelfHostEnv: (async () => { + trace.push('gate'); + return { hasSchema: false, remoteSecretNames: null }; + }) as CloudflareDeployDeps['dischargeSelfHostEnv'], + // The stubbed build never writes dist/ssr/wrangler.json, so the real config + // verification would have nothing to read — pass it by default; dedicated tests + // exercise the real verifier and the fail-closed branch. + verifyDeployConfig: () => ({ ok: true }), + // Stub the migration gate so orchestration tests never spawn drizzle-kit; dedicated + // tests inject a throwing stub (drift) or exercise the real pure gates (journal / + // destructive) directly. + assertMigrationsDeployable: () => { + trace.push('migrate-gate'); + }, + // Stub the migration apply-set equality gate so orchestration tests need no on-disk + // migration dir; a dedicated suite exercises the real `checkWranglerMigrationApplySet`. + checkMigrationApplySet: async () => ({ ok: true }), + // Stub auth-schema validation so orchestration tests never load Better Auth; dedicated + // tests exercise the real `validateAuthMigrationSchema` + `assertMigrationsProduceAuthSchema`. + validateAuthMigrations: async () => {}, + ...overrides, + }; + return { deps, trace, wranglerArgs, exitCalls }; +} + +/** A worker-bearing D1 app fixture with a fully-provisioned wrangler.jsonc. */ +function d1Project( + wrangler: Record, + extraFiles: Record = {}, +): string { + return makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + 'wrangler.jsonc': JSON.stringify(wrangler, null, 2), + ...extraFiles, + }); +} + +/** + * A minimal `vite.config.ts` carrying a `void:env` plugin STUB that mirrors how the real + * plugin (index.ts) captures the RAW (unnormalized) envDir and exposes it via + * `api.getVoidDeployBuildConfig`. `envDirExpr` is spliced verbatim as the config's `envDir` + * (e.g. `"'.'"`, `'import.meta.dirname'`), so the real probe exercises the build's own + * `join(root, rawEnvDir)` guard without pulling the full void pipeline into the child. + */ +function viteConfigWithVoidEnvStub(envDirExpr: string): string { + return [ + `let captured;`, + `export default {`, + ` envDir: ${envDirExpr},`, + ` plugins: [`, + ` {`, + ` name: 'void:env',`, + ` api: { getVoidDeployBuildConfig: () => captured },`, + ` config(userConfig, { mode }) {`, + ` const raw =`, + ` typeof userConfig.envDir === 'string' && userConfig.envDir.length > 0`, + ` ? userConfig.envDir`, + ` : undefined;`, + ` captured = { mode, envDir: raw };`, + ` },`, + ` },`, + ` ],`, + `};`, + ].join('\n'); +} + +/** One non-destructive checked-in migration so the remote migrate path runs. */ +const INIT_MIGRATION = { + 'db/migrations/0000_init.sql': `CREATE TABLE notes (id integer primary key);`, +}; + +const PROVISIONED_D1 = { + account_id: 'acc123', + name: 'myapp', + d1_databases: [ + { + binding: 'DB', + database_name: 'myapp-db', + database_id: REAL_D1_ID, + migrations_dir: './db/migrations', + }, + ], +}; + +/** + * The non-id bindings a real Class-A build emits into `dist/ssr/wrangler.json` (see + * `playground/kitchen-sink/dist/ssr/wrangler.json`): `ai`, `queues`, `durable_objects`, + * `migrations`, `new_sqlite_classes`, `containers`, `vars`, `assets`, `triggers`, `rules`, + * `observability`, `main`, `compatibility_date/flags`, plus the ~20 empty arrays wrangler + * always materializes. The binding-identity check MUST ignore every one of these and inspect + * ONLY the four id-bearing arrays (`d1_databases`/`kv_namespaces`/`r2_buckets`/`hyperdrive`). + */ +const KITCHEN_SINK_NON_ID_BINDINGS = { + ai: { binding: 'AI' }, + queues: { + producers: [{ binding: 'JOBS', queue: 'jobs' }], + consumers: [{ queue: 'jobs', max_batch_size: 10 }], + }, + durable_objects: { + bindings: [ + { name: 'SANDBOX', class_name: 'Sandbox' }, + { name: 'WS', class_name: 'ChatRoom' }, + ], + }, + migrations: [{ tag: 'v1', new_sqlite_classes: ['Sandbox'] }], + containers: [{ class_name: 'Sandbox', image: './Dockerfile' }], + vars: { __VOID_AI_GATEWAY: 'my-gateway', PUBLIC_FLAG: 'on' }, + assets: { directory: './dist/client', binding: 'ASSETS' }, + triggers: { crons: ['0 0 * * *'] }, + rules: [{ type: 'ESModule', globs: ['**/*.js'] }], + observability: { enabled: true }, + main: './dist/ssr/index.js', + compatibility_date: '2025-01-01', + compatibility_flags: ['nodejs_compat'], + services: [], + vectorize: [], + workflows: [], + dispatch_namespaces: [], + analytics_engine_datasets: [], + hyperdrive: [], +}; + +// --------------------------------------------------------------------------- +// Desired set (from source, pre-build) +// --------------------------------------------------------------------------- + +describe('computeDesiredCloudflareResources — desired set from source', () => { + it('maps D1/KV/R2 from the import walk and queues from the scanner', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = async (c) => { + await c.env.KV.get('k'); + await c.env.STORAGE.put('a', 'b'); + return c.env.DB.prepare('SELECT 1').first(); + };`, + 'queues/jobs.ts': `export default {};`, + }); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.d1).toEqual({ + binding: 'DB', + name: 'myapp-db', + migrationsDir: './db/migrations', + }); + expect(desired.kvNamespaces).toEqual([{ binding: 'KV', title: 'myapp-kv' }]); + expect(desired.r2).toEqual({ binding: 'STORAGE', bucketName: 'myapp-storage' }); + expect(desired.queues).toEqual(['jobs']); + expect(desired.hyperdrive).toBeUndefined(); + }); + + it('desires Hyperdrive (not D1) for a PostgreSQL app', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'pgapp' }), + 'void.json': JSON.stringify({ database: 'pg' }), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + }); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.d1).toBeUndefined(); + expect(desired.hyperdrive?.binding).toBe('HYPERDRIVE'); + expect(desired.hyperdrive?.name).toBe('pgapp-hyperdrive'); + }); + + it('desires ISR_CACHE (KV) when a page uses `revalidate`', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'israpp' }), + 'void.json': JSON.stringify({}), + 'pages/index.tsx': `export default () => null;`, + 'pages/index.server.ts': `export const revalidate = 60;\nexport const loader = () => ({});`, + }); + const { desired, reasons } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.kvNamespaces.map((k) => k.binding)).toContain('ISR_CACHE'); + expect(reasons.get('ISR_CACHE')).toMatch(/revalidate/); + }); + + it('reuses the stored database_name from wrangler.jsonc', async () => { + const root = d1Project({ + d1_databases: [{ binding: 'DB', database_name: 'legacy-db', database_id: REAL_D1_ID }], + }); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.d1?.name).toBe('legacy-db'); + }); + + it('ignores a synced placeholder database_name paired with the local id', async () => { + // A not-yet-provisioned entry: `database_id: "local"` with a placeholder name. + // The stored name must NOT become the prod DB identity — derive the worker name. + const root = d1Project({ + d1_databases: [{ binding: 'DB', database_name: 'default', database_id: 'local' }], + }); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.d1?.name).toBe('myapp-db'); + }); + + it('honors inference.bindings overrides (disabled D1, custom KV, forced R2)', async () => { + // Route uses env.DB/KV/STORAGE, but void.json disables D1 and renames KV — the + // desired set must match the build (which consumes the same overrides), so D1 is + // absent and KV uses the custom binding name. + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({ + inference: { bindings: { db: false, kv: 'MYKV', storage: true } }, + }), + 'routes/index.ts': `export const GET = async (c) => { + await c.env.KV.get('k'); + await c.env.STORAGE.put('a', 'b'); + return c.env.DB.prepare('SELECT 1').first(); + };`, + }); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.d1).toBeUndefined(); + expect(desired.kvNamespaces).toEqual([{ binding: 'MYKV', title: 'myapp-kv' }]); + expect(desired.r2).toEqual({ binding: 'STORAGE', bucketName: 'myapp-storage' }); + }); + + it('forces D1 with a custom binding via inference.bindings', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({ inference: { bindings: { db: 'MYDB' } } }), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + }); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.d1).toEqual({ + binding: 'MYDB', + name: 'myapp-db', + migrationsDir: './db/migrations', + }); + }); + + it('desires D1 for a migration-only SQLite app (no env.DB use, no auth)', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'db/migrations/0000_init.sql': `CREATE TABLE t (id INTEGER PRIMARY KEY);`, + }); + const { desired, reasons } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.d1).toEqual({ + binding: 'DB', + name: 'myapp-db', + migrationsDir: './db/migrations', + }); + expect(reasons.get('DB')).toMatch(/migrations/); + }); + + it('does not require Hyperdrive for a pg app with no DB use, auth, or migrations', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'pgapp' }), + 'void.json': JSON.stringify({ database: 'pg' }), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + }); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.hyperdrive).toBeUndefined(); + expect(desired.d1).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// PostgreSQL checked-in migration guard: this backend applies only remote D1 +// (SQLite) migrations — the remote-migrate step is gated on `desired.d1`, which +// a pg app never has. The managed platform DOES apply a pg app's migrations over +// Hyperdrive at deploy, so a pg app WITH checked-in migrations must be rejected +// (else it would ship against an unmigrated database), while a pg app WITHOUT +// migrations (externally-managed schema) is still deployable. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — PostgreSQL checked-in migration guard', () => { + /** A worker-bearing pg app with a provisioned Hyperdrive config (drift-clean). */ + function pgProject(extraFiles: Record = {}): string { + return makeProject({ + 'package.json': JSON.stringify({ name: 'pgapp' }), + 'void.json': JSON.stringify({ database: 'pg' }), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + 'wrangler.jsonc': JSON.stringify( + { + account_id: 'acc123', + name: 'pgapp', + hyperdrive: [{ binding: 'HYPERDRIVE', id: REAL_KV_ID }], + }, + null, + 2, + ), + ...extraFiles, + }); + } + + it('rejects a pg app that has checked-in migrations BEFORE any account operation', async () => { + // The remote-migrate path is gated on `desired.d1`; a pg app desires `hyperdrive` instead, so + // its db/migrations/*.sql would never run here and the worker would hit an unmigrated database. + // (Reverting the guard lets this app build + deploy against an unmigrated DB.) + const root = pgProject({ + 'db/migrations/0000_init.sql': 'CREATE TABLE notes (id integer primary key);', + }); + const { deps, trace, wranglerArgs, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Rejected pre-account-op: no whoami/provision/build/deploy seam ran. + expect(wranglerArgs).toEqual([]); + expect(trace).toEqual([]); + }); + + it('still deploys a pg app WITHOUT checked-in migrations (external schema — no false-reject)', async () => { + const root = pgProject(); + const { deps, wranglerArgs, exitCalls } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + // Reached the upload, and no D1 migrate ran (a pg app has no desired.d1). + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); + expect(wranglerArgs.some((a) => a[0] === 'd1' && a[2] === 'apply')).toBe(false); + }); + + it('rejects a pg app whose BUILD writes checked-in migrations (post-build re-check)', async () => { + // Empty pre-build (the fail-fast guard passes), but the build plugin writes db/migrations/*.sql + // during `vite build`. The remote-migrate/apply gates are `desired.d1`-only, so without the + // post-build re-check these files would ship UNRUN and the worker would hit an unmigrated DB. + const root = pgProject(); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => { + mkdirSync(join(root, 'db', 'migrations'), { recursive: true }); + writeFileSync( + join(root, 'db', 'migrations', '0000_injected.sql'), + 'CREATE TABLE notes (id integer primary key);', + ); + }, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Rejected AFTER the build but BEFORE any remote mutation — nothing deployed, no D1 migrate. + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + expect(wranglerArgs.some((a) => a[0] === 'd1' && a[2] === 'apply')).toBe(false); + }); + + it('leaves a D1/SQLite app with checked-in migrations on the remote-D1 migrate path', async () => { + // The guard keys on the postgresql dialect, so a SQLite app with migrations is untouched. + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, wranglerArgs, exitCalls } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(wranglerArgs.some((a) => a[0] === 'd1' && a[2] === 'apply')).toBe(true); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Suffixed resource names: the `-kv` / `-isr-cache` suffix carries the whole +// distinction between the two KV namespaces, so the 63-char cap must eat the +// worker name, never the suffix. +// --------------------------------------------------------------------------- + +/** A deterministic worker name of `length` chars from the repeating [a-z0-9] alphabet. */ +function workerNameOfLength(length: number): string { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + return Array.from({ length }, (_, i) => alphabet[i % alphabet.length]).join(''); +} + +/** Cloudflare's rule for these names: lowercase [a-z0-9-], no leading/trailing dash, ≤63. */ +const CF_NAME_RULE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + +/** A KV + ISR app (both namespaces desired) whose root config pins `workerName`. */ +function kvAndIsrProject(workerName: string, wrangler: Record = {}): string { + return makeProject({ + 'package.json': JSON.stringify({ name: 'longapp' }), + 'void.json': JSON.stringify({}), + 'wrangler.jsonc': JSON.stringify( + { account_id: 'acc123', name: workerName, ...wrangler }, + null, + 2, + ), + 'routes/index.ts': `export const GET = (c) => c.env.KV.get('k');`, + 'pages/index.tsx': `export default () => null;`, + 'pages/index.server.ts': `export const revalidate = 60;\nexport const loader = () => ({});`, + }); +} + +describe('computeDesiredCloudflareResources — suffixed names survive the 63-char cap', () => { + // 61 is already over budget for `-isr-cache`; 62+ used to lose BOTH suffixes, so + // `KV` and `ISR_CACHE` asked for one title and landed on one namespace. + for (const length of [61, 62, 63, 70, 100]) { + it(`keeps KV and ISR_CACHE on distinct titles for a ${length}-char worker name`, async () => { + const workerName = workerNameOfLength(length); + const root = kvAndIsrProject(workerName); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + const titles = new Map(desired.kvNamespaces.map((kv) => [kv.binding, kv.title])); + // The base is trimmed to whatever the suffix (plus its dash) leaves of the 63. + expect(titles.get('KV')).toBe(`${workerName.slice(0, 63 - 'kv'.length - 1)}-kv`); + expect(titles.get('ISR_CACHE')).toBe( + `${workerName.slice(0, 63 - 'isr-cache'.length - 1)}-isr-cache`, + ); + expect(titles.get('KV')).not.toBe(titles.get('ISR_CACHE')); + for (const title of titles.values()) { + expect(title).toMatch(CF_NAME_RULE); + expect(title.length).toBe(63); + } + }); + } + + it('caps the D1 / R2 / Hyperdrive names too, keeping each suffix intact', async () => { + const workerName = workerNameOfLength(80); + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'longapp' }), + 'void.json': JSON.stringify({}), + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: workerName }, null, 2), + 'routes/index.ts': `export const GET = async (c) => { + await c.env.STORAGE.put('a', 'b'); + return c.env.DB.prepare('SELECT 1').first(); + };`, + }); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.d1?.name).toBe(`${workerName.slice(0, 63 - 'db'.length - 1)}-db`); + expect(desired.r2?.bucketName).toBe( + `${workerName.slice(0, 63 - 'storage'.length - 1)}-storage`, + ); + for (const name of [desired.d1?.name, desired.r2?.bucketName]) { + expect(name).toMatch(CF_NAME_RULE); + expect(name?.length).toBe(63); + } + + const pgRoot = makeProject({ + 'package.json': JSON.stringify({ name: 'longpg' }), + 'void.json': JSON.stringify({ database: 'pg' }), + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: workerName }, null, 2), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + }); + const pg = await computeDesiredCloudflareResources( + pgRoot, + readConfig(pgRoot), + join(pgRoot, 'wrangler.jsonc'), + ); + expect(pg.desired.hyperdrive?.name).toBe( + `${workerName.slice(0, 63 - 'hyperdrive'.length - 1)}-hyperdrive`, + ); + expect(pg.desired.hyperdrive?.name).toMatch(CF_NAME_RULE); + }); + + it('leaves a normal (short) worker name byte-for-byte as it was', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = async (c) => { + await c.env.KV.get('k'); + await c.env.STORAGE.put('a', 'b'); + return c.env.DB.prepare('SELECT 1').first(); + };`, + 'pages/index.tsx': `export default () => null;`, + 'pages/index.server.ts': `export const revalidate = 60;\nexport const loader = () => ({});`, + }); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.kvNamespaces).toEqual([ + { binding: 'KV', title: 'myapp-kv' }, + { binding: 'ISR_CACHE', title: 'myapp-isr-cache' }, + ]); + expect(desired.d1?.name).toBe('myapp-db'); + expect(desired.r2?.bucketName).toBe('myapp-storage'); + }); +}); + +describe('computeDesiredCloudflareResources — KV bindings may not share a namespace', () => { + it('fails closed when two KV bindings already point at one namespace id', async () => { + const root = kvAndIsrProject(workerNameOfLength(70), { + kv_namespaces: [ + { binding: 'KV', id: REAL_KV_ID }, + { binding: 'ISR_CACHE', id: REAL_KV_ID }, + ], + }); + const error = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ).then( + () => undefined, + (err: unknown) => err as Error, + ); + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('"KV"'); + expect(error?.message).toContain('"ISR_CACHE"'); + expect(error?.message).toContain(REAL_KV_ID); + expect(error?.message).toMatch(/share one namespace/); + expect(error?.message).toMatch(/fresh namespace/); + }); + + it('accepts two KV bindings on distinct namespace ids', async () => { + const root = kvAndIsrProject(workerNameOfLength(70), { + kv_namespaces: [ + { binding: 'KV', id: REAL_KV_ID }, + { binding: 'ISR_CACHE', id: OTHER_KV_ID }, + ], + }); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.kvNamespaces.map((kv) => kv.binding)).toEqual(['KV', 'ISR_CACHE']); + }); + + it('ignores unprovisioned placeholder ids (a synced local-dev config is not a collision)', async () => { + const root = kvAndIsrProject(workerNameOfLength(70), { + kv_namespaces: [ + { binding: 'KV', id: 'local' }, + { binding: 'ISR_CACHE', id: 'local' }, + ], + }); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.kvNamespaces).toHaveLength(2); + }); +}); + +describe('computeDesiredCloudflareResources — provisioned resources are never re-created', () => { + it('reuses the ids/names already in the config for a long-named app (no wrangler call)', async () => { + // The derivation changed for long worker names, so prove the renamed desired set + // still resolves to the SAME resources through the provisioner's read-first. + const workerName = workerNameOfLength(70); + const root = kvAndIsrProject(workerName, { + d1_databases: [ + { + binding: 'DB', + database_name: 'legacy-db', + database_id: REAL_D1_ID, + migrations_dir: './db/migrations', + }, + ], + kv_namespaces: [ + { binding: 'KV', id: REAL_KV_ID }, + { binding: 'ISR_CACHE', id: OTHER_KV_ID }, + ], + r2_buckets: [{ binding: 'STORAGE', bucket_name: 'legacy-bucket' }], + }); + writeFileSync( + join(root, 'routes/index.ts'), + `export const GET = async (c) => { + await c.env.KV.get('k'); + await c.env.STORAGE.put('a', 'b'); + return c.env.DB.prepare('SELECT 1').first(); + };`, + ); + const configPath = join(root, 'wrangler.jsonc'); + const { desired } = await computeDesiredCloudflareResources(root, readConfig(root), configPath); + // Stored names win over the derivation, so nothing points at a new resource. + expect(desired.d1?.name).toBe('legacy-db'); + expect(desired.r2?.bucketName).toBe('legacy-bucket'); + + const calls: Array> = []; + const result = await provisionCloudflareResources(root, configPath, desired, { + runWrangler: (args) => { + calls.push(args); + return { stdout: '', stderr: '', status: 0 }; + }, + log: { info: () => {}, warn: () => {} }, + }); + // Read-first everywhere: no list, no create — the recorded resources are kept. + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { kind: 'd1', binding: 'DB', name: 'legacy-db', action: 'reused', id: REAL_D1_ID }, + { + kind: 'kv', + binding: 'KV', + name: `${workerName.slice(0, 60)}-kv`, + action: 'reused', + id: REAL_KV_ID, + }, + { + kind: 'kv', + binding: 'ISR_CACHE', + name: `${workerName.slice(0, 53)}-isr-cache`, + action: 'reused', + id: OTHER_KV_ID, + }, + { kind: 'r2', binding: 'STORAGE', name: 'legacy-bucket', action: 'reused' }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Orchestration flow +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — app-class guard (narrowed to full Void apps)', () => { + it('rejects a configured static/SPA app with exit(1) and never runs wrangler', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'spaapp' }), + 'void.json': JSON.stringify({ inference: { appType: 'spa' } }), + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + }); + + it('rejects an UNCONFIGURED Vite SPA (no void.json appType) via the managed detectPreset classifier', async () => { + // resolveStaticDeployPreset returns null here (no --dir, no configured appType), so the + // old guard let this slip; detectPreset structurally classifies it as an SPA (vite dep, + // no backend files) and this fork now rejects it. + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'viteapp', dependencies: { vite: '6.0.0' } }), + 'void.json': JSON.stringify({}), + 'index.html': '
', + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + }); + + it('rejects a bare --spa deploy before any account op', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'app' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare', spa: true }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + }); + + it('rejects a --dir static deploy before any account op', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'app' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare', dir: 'dist' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + }); + + it('rejects a bare project with no Void backend files (positive full-Void assertion)', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'bare' }), + 'void.json': JSON.stringify({}), + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + }); + + it('rejects a PostgreSQL app with auth enabled (pg + auth) before any account op', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'pgauth' }), + 'void.json': JSON.stringify({ database: 'pg', auth: {} }), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Rejected at the pg+auth guard (after the desired set, before whoami/provision). + expect(trace).toEqual([]); + }); + + it('rejects a pages app with output:"static" (SSG) — the signal the build itself branches on', async () => { + // Real-shape: the static-pages playground config — `output: "static"` + a `pages/` dir. + // `detectPreset` returns null (no SSG dep / no root index.html) and `pages/` satisfies + // `hasBackendFiles`, so without the `output === 'static'` signal this reaches account/build. + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'ssgapp' }), + 'void.json': JSON.stringify({ + output: 'static', + worker: { compatibility_date: '2026-02-12' }, + }), + 'pages/index.vue': ``, + 'pages/index.server.ts': `export const loader = () => ({});`, + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // NO account / build seam ran. + expect(trace).toEqual([]); + }); + + it('A1: rejects a project whose D1 binding sets a custom migrations_pattern before any account op', async () => { + const root = d1Project({ + ...PROVISIONED_D1, + d1_databases: [ + { + binding: 'DB', + database_name: 'myapp-db', + database_id: REAL_D1_ID, + migrations_dir: './db/migrations', + migrations_pattern: 'db/migrations/**/*.sql', + }, + ], + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Rejected at the custom-pattern guard (after the desired set, before whoami/provision). + expect(trace).toEqual([]); + }); + + it('A1: rejects a PRESENT-but-EMPTY migrations_pattern ("") before any account op', async () => { + // `migrations_pattern: ""` is present-but-empty. readStoredResourceName collapses it to + // `undefined` (== absent), so a length-based guard would wave it through as "default" — but + // wrangler 4.107 takes its non-default branch for ANY value that is not `undefined`, so an + // empty pattern diverges from the flat `*.sql` set this backend validates. Reject on presence. + const root = d1Project({ + ...PROVISIONED_D1, + d1_databases: [ + { + binding: 'DB', + database_name: 'myapp-db', + database_id: REAL_D1_ID, + migrations_dir: './db/migrations', + migrations_pattern: '', + }, + ], + }); + const { deps, trace, wranglerArgs, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Reached NO whoami / provision / migration / build seam — rejected pre-account. + expect(trace).toEqual([]); + expect(wranglerArgs).toEqual([]); + }); + + it('rejects a WebSocket / Durable Object app (.ws.ts route) before any account op', async () => { + // A `.ws.ts` route makes the build emit a `new_classes` (KV-backed) DO migration, which a + // fresh Cloudflare account refuses to create — reject via the SAME `scanWebSocketRoutesSync` + // scan the build's DO emission uses. + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'wsapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'routes/chat/[room].ws.ts': `import { defineRoom } from 'void/ws';\nexport default defineRoom({});`, + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + }); +}); + +describe('runCloudflareDeploy — preflight', () => { + it('exits when wrangler is unauthenticated (whoami --json nonzero)', async () => { + const root = d1Project(PROVISIONED_D1); + const { deps, trace, exitCalls } = harness({ + runWrangler: (args) => { + trace.push(`wrangler:${args[0]}`); + if (args[0] === 'whoami') { + return { status: 1, stdout: '{"loggedIn":false}', stderr: '', signal: null, error: null }; + } + return cleanExit; + }, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual(['wrangler:whoami']); // stopped right after the auth probe + }); + + it('exits when no account is pinned (no account_id, no CLOUDFLARE_ACCOUNT_ID)', async () => { + const savedAccount = process.env.CLOUDFLARE_ACCOUNT_ID; + delete process.env.CLOUDFLARE_ACCOUNT_ID; + try { + const root = d1Project({ ...PROVISIONED_D1, account_id: undefined }); + const { deps, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + } finally { + if (savedAccount === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = savedAccount; + } + } + }); +}); + +describe('runCloudflareDeploy — drift check (no --provision)', () => { + it('blocks with exit(1) when a desired resource is missing, naming it; never builds/deploys', async () => { + // App also uses KV (env.KV) but wrangler.jsonc only has the D1 → KV drifts. + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.KV.get('k');`, + 'wrangler.jsonc': JSON.stringify(PROVISIONED_D1, null, 2), + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // whoami ran (preflight), but no migrate/build/deploy. + expect(trace).toEqual(['wrangler:whoami']); + }); + + it('proceeds through whoami → build → gate → migrate-gate → remote-migrate → deploy when fully provisioned', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + 'db/migrations/0000_init.sql': `CREATE TABLE notes (id integer primary key);`, + 'wrangler.jsonc': JSON.stringify(PROVISIONED_D1, null, 2), + }); + const { deps, trace, wranglerArgs } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + // The BUILD runs BEFORE the secret gate (ground-truth validation reads the real emitted + // artifact); the LOCAL migration gate runs AFTER the build (against the post-build files), + // and the REMOTE D1 apply runs after that (a failed gate never mutates D1). + expect(trace).toEqual([ + 'wrangler:whoami', + 'build', + 'gate', + 'migrate-gate', + 'wrangler:d1', // migrations apply (after the gate) + 'wrangler:d1', // migrations list (pending verify) + 'wrangler:deploy', + ]); + // migrate targets the stored db name with --remote + pinned config + const d1Calls = wranglerArgs.filter((a) => a[0] === 'd1'); + expect(d1Calls[0]).toEqual([ + 'd1', + 'migrations', + 'apply', + 'myapp-db', + '--remote', + '-c', + join(root, 'wrangler.jsonc'), + '--env=', + ]); + // the post-apply pending verification lists remote migrations for the same target + expect(d1Calls[1]).toEqual([ + 'd1', + 'migrations', + 'list', + 'myapp-db', + '--remote', + '-c', + join(root, 'wrangler.jsonc'), + '--env=', + ]); + // deploy follows the .wrangler/deploy redirect, pins the top-level env, disables + // wrangler's own implicit provisioning + auto-create + framework autoconfig. + expect(wranglerArgs.find((a) => a[0] === 'deploy')).toEqual([ + 'deploy', + '--env=', + '--experimental-provision=false', + '--experimental-auto-create=false', + '--autoconfig=false', + ]); + }); +}); + +describe('runCloudflareDeploy — provision path', () => { + it('calls the provisioner with the correctly-built desired set', async () => { + let captured: unknown; + const root = d1Project(PROVISIONED_D1); + const { deps } = harness({ + provision: (async (_root, configPath, desired): Promise => { + captured = desired; + return { resources: [], configPath, accountIdWritten: false }; + }) as CloudflareDeployDeps['provision'], + }); + await runCloudflareDeploy(root, { backend: 'cloudflare', provision: true }, silentCliLog, deps); + expect(captured).toMatchObject({ + d1: { binding: 'DB', name: 'myapp-db' }, + kvNamespaces: [], + queues: [], + }); + }); +}); + +describe('runCloudflareDeploy — CI provision gate', () => { + it('fails closed under CI when the committed config is incomplete', async () => { + // Needs KV but the committed config only has D1 → not a provable no-op. + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.KV.get('k');`, + 'wrangler.jsonc': JSON.stringify(PROVISIONED_D1, null, 2), + }); + const { deps, trace, exitCalls } = harness({ isCI: true }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare', provision: true }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual(['wrangler:whoami']); // gated before provisioning + }); + + it('allows a provable no-op under CI when the committed config already covers everything', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.KV.get('k');`, + 'wrangler.jsonc': JSON.stringify( + { ...PROVISIONED_D1, kv_namespaces: [{ binding: 'KV', id: REAL_KV_ID }] }, + null, + 2, + ), + }); + const { deps, trace } = harness({ isCI: true }); + await runCloudflareDeploy(root, { backend: 'cloudflare', provision: true }, silentCliLog, deps); + expect(trace).toContain('provision'); + expect(trace).toContain('wrangler:deploy'); + }); +}); + +describe('runCloudflareDeploy — secrets gate', () => { + it('warns about a plaintext secret-looking key in the REAL emitted vars without blocking', async () => { + const root = d1Project(PROVISIONED_D1); + // The warning classifies the REAL vars the build baked into dist/ssr/wrangler.json (ground + // truth). A secret-looking key is non-fatal — the deploy still proceeds. + const { deps, trace } = harness({ + readEmittedWorkerVars: (() => + emittedVars({ + API_TOKEN: 'sk-live-xyz', + APP_NAME: 'demo', + })) as CloudflareDeployDeps['readEmittedWorkerVars'], + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(trace).toContain('wrangler:deploy'); // warning is non-fatal + }); + + it('blocks with exit(1) when the gate discharge throws (missing required secret); never deploys or migrates remote', async () => { + const { SelfHostEnvValidationError } = await import('../../src/cli/env-validation.ts'); + const root = d1Project(PROVISIONED_D1); + const { deps, trace, wranglerArgs, exitCalls } = harness({ + dischargeSelfHostEnv: (async () => { + trace.push('gate'); + throw new SelfHostEnvValidationError('env: missing required secret STRIPE_KEY', { + valid: false, + missing: ['STRIPE_KEY'], + invalid: [], + values: new Map(), + }); + }) as CloudflareDeployDeps['dischargeSelfHostEnv'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The build ran BEFORE the gate (ground truth), but the gate failing blocks the REMOTE D1 + // apply AND the deploy — no account mutation past the build. + expect(trace).toContain('build'); + expect(trace).not.toContain('wrangler:deploy'); + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + }); + + it('renders the first-deploy structured error and exits', async () => { + const { SelfHostFirstDeployError } = await import('../../src/cli/env-validation.ts'); + const root = d1Project(PROVISIONED_D1); + const { deps, exitCalls } = harness({ + dischargeSelfHostEnv: (async () => { + throw new SelfHostFirstDeployError('worker not deployed yet', ['STRIPE_KEY'], 1); + }) as CloudflareDeployDeps['dischargeSelfHostEnv'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + }); +}); + +describe('runCloudflareDeploy — pg/Hyperdrive app skips D1 migrations', () => { + it('does not call `d1 migrations apply` for a PostgreSQL app', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'pgapp' }), + 'void.json': JSON.stringify({ database: 'pg' }), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'wrangler.jsonc': JSON.stringify( + { + account_id: 'acc123', + name: 'pgapp', + hyperdrive: [{ binding: 'HYPERDRIVE', id: REAL_KV_ID }], + }, + null, + 2, + ), + }); + const { deps, wranglerArgs, trace } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(trace).toContain('wrangler:deploy'); + }); +}); + +describe('runCloudflareDeploy — D1 migrations_dir gate', () => { + it('fails closed (no `d1 migrations apply`) when migrations_dir is absent', async () => { + // Real D1 id (drift passes) + a checked-in migration, but the entry declares no + // migrations_dir — running wrangler would default to `./migrations` and apply an + // unrelated folder. + const root = d1Project( + { + account_id: 'acc123', + name: 'myapp', + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }, + INIT_MIGRATION, + ); + const { deps, wranglerArgs, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The migrations_dir gate runs POST-build now (against the live files), so the build ran — + // but it fails closed before any `d1 migrations apply` and before the deploy. + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(trace).toContain('build'); + expect(trace).not.toContain('wrangler:deploy'); + }); +}); + +describe('runCloudflareDeploy — full Void app queues', () => { + it('rejects a framework app (with a queue) via the framework guard before any account op', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ + name: 'fwapp', + dependencies: { '@tanstack/react-start': '1.0.0' }, + }), + 'void.json': JSON.stringify({}), + 'queues/jobs.ts': `export default {};`, + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'fwapp' }, null, 2), + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare', provision: true }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Guard fires before preflight/provision — nothing ran. + expect(trace).toEqual([]); + }); + + it('lets a full Void app with a queue proceed (queues wire through the build)', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'qapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'queues/jobs.ts': `export default {};`, + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'qapp' }, null, 2), + }); + const { deps, trace } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(trace).toContain('build'); + expect(trace).toContain('wrangler:deploy'); + }); +}); + +describe('runCloudflareDeploy — pre-build queue existence probe', () => { + /** A worker-bearing app desiring exactly one queue (`jobs`), fully config-provisioned. */ + function queueProject(): string { + return makeProject({ + 'package.json': JSON.stringify({ name: 'qapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'queues/jobs.ts': `export default {};`, + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'qapp' }, null, 2), + }); + } + + /** + * The default harness plus a scripted `queues info` reply, so a test drives the + * REAL classifier with the exact output wrangler would print. Every other wrangler + * call keeps the harness behaviour (and its arg/trace recording). + */ + function queueHarness( + probe: (name: string) => DeployWranglerRunResult, + overrides: Partial = {}, + ): ReturnType { + const h = harness(overrides); + const base = h.deps.runWrangler as DeployWranglerRun; + h.deps.runWrangler = (args, opts) => { + const passthrough = base(args, opts); + return args[0] === 'queues' && args[1] === 'info' ? probe(args[2] as string) : passthrough; + }; + return h; + } + + it('probes each desired queue by name before the build', async () => { + const root = queueProject(); + const { deps, wranglerArgs, trace } = queueHarness((name) => ({ + ...cleanExit, + stdout: `Queue Name: ${name}\n`, + })); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + const probeArgs = wranglerArgs.find((a) => a[0] === 'queues'); + expect(probeArgs).toEqual([ + 'queues', + 'info', + 'jobs', + '-c', + join(root, 'wrangler.jsonc'), + '--env=', + ]); + // The probe runs at step 4 — ahead of the build and everything downstream of it. + expect(trace.indexOf('wrangler:queues')).toBeLessThan(trace.indexOf('build')); + expect(trace).toContain('wrangler:deploy'); + }); + + it('fails closed naming the queue when the probe confirms it does not exist', async () => { + const root = queueProject(); + const { deps, trace, exitCalls } = queueHarness((name) => ({ + ...cleanExit, + status: 1, + stderr: `✘ [ERROR] Queue "${name}" does not exist.\n`, + })); + const messages: Array = []; + const restore = log.error; + log.error = (msg: string) => { + messages.push(msg); + }; + try { + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + } finally { + log.error = restore; + } + expect(exitCalls).toEqual([1]); + // Nothing past step 4 ran: no build, no remote D1 apply, no upload. + expect(trace).toEqual(['wrangler:whoami', 'wrangler:queues']); + const joined = messages.join('\n'); + expect(joined).toContain('jobs'); + expect(joined).toContain('--provision'); + expect(joined).toContain('wrangler queues create jobs'); + }); + + it('proceeds when the probe fails for a reason other than the queue being absent', async () => { + const root = queueProject(); + // An auth failure is NOT absence — blocking here would be a false positive, and + // `wrangler deploy` still rejects a genuinely missing queue before uploading. + const { deps, trace } = queueHarness(() => ({ + ...cleanExit, + status: 1, + stderr: '✘ [ERROR] A request to the Cloudflare API failed [code: 10000]\n', + })); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(trace).toContain('build'); + expect(trace).toContain('wrangler:deploy'); + }); + + it('proceeds when a killed probe merely buffered a not-found line', async () => { + const root = queueProject(); + // A signal-killed child never ran to completion, so its buffered text is not proof. + const { deps, trace } = queueHarness((name) => ({ + ...cleanExit, + status: 1, + signal: 'SIGKILL', + stderr: `✘ [ERROR] Queue "${name}" does not exist.\n`, + })); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(trace).toContain('build'); + expect(trace).toContain('wrangler:deploy'); + }); + + it('never probes when the app desires no queues', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, wranglerArgs } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(wranglerArgs.some((a) => a[0] === 'queues')).toBe(false); + }); + + it('never probes under --provision (the provisioner creates the queue itself)', async () => { + const root = queueProject(); + const { deps, wranglerArgs, trace } = queueHarness(() => ({ + ...cleanExit, + status: 1, + stderr: '✘ [ERROR] Queue "jobs" does not exist.\n', + })); + await runCloudflareDeploy(root, { backend: 'cloudflare', provision: true }, silentCliLog, deps); + expect(trace).toContain('provision'); + expect(wranglerArgs.some((a) => a[0] === 'queues')).toBe(false); + expect(trace).toContain('wrangler:deploy'); + }); +}); + +describe('runCloudflareDeploy — pre-build queue name validation', () => { + /** + * A worker-bearing app whose desired queues come from the given `queues/` filenames. + * Queue names are the filenames verbatim (`queues/scan.ts` strips only the extension), + * so this fixture is how an adversarial name actually reaches the deploy. + */ + function queueNameProject(...fileNames: Array): string { + return makeProject({ + 'package.json': JSON.stringify({ name: 'qapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'qapp' }, null, 2), + ...Object.fromEntries(fileNames.map((f) => [`queues/${f}`, `export default {};`])), + }); + } + + /** Run the deploy capturing `log.error` output, restoring the real logger after. */ + async function runCapturingErrors( + root: string, + deps: CloudflareDeployDeps, + ): Promise> { + const messages: Array = []; + const restore = log.error; + log.error = (msg: string) => { + messages.push(msg); + }; + try { + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + } finally { + log.error = restore; + } + return messages; + } + + it('fails closed on a leading-dash name before the build, the D1 apply, or the probe', async () => { + // `queues/--help.ts` yields the literal name `--help`, which wrangler would parse as a + // FLAG: `queues info --help` exits 0 printing usage, which the probe reads as + // inconclusive and waves through. The name gate must stop it before that. + const root = queueNameProject('--help.ts'); + const { deps, trace, wranglerArgs, exitCalls } = harness(); + const messages = await runCapturingErrors(root, deps); + + expect(exitCalls).toEqual([1]); + const joined = messages.join('\n'); + expect(joined).toContain('--help'); + expect(joined).toContain('queues/'); + expect(joined).toContain('begin and end with a letter or digit'); + // Nothing past the gate ran: no probe, no build, no remote D1 apply, no upload. + expect(wranglerArgs.some((a) => a[0] === 'queues')).toBe(false); + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(trace).toEqual(['wrangler:whoami']); + }); + + it('rejects a shell-significant name by the grammar', async () => { + // The runner uses `spawnSync` with `shell: false`, so `$(touch marker)` was never + // executable — the point is that an invalid name can no longer reach wrangler's argv + // or the printed `wrangler queues create ` guidance at all. + const root = queueNameProject('$(touch marker).ts'); + const { deps, trace, wranglerArgs, exitCalls } = harness(); + const messages = await runCapturingErrors(root, deps); + + expect(exitCalls).toEqual([1]); + const joined = messages.join('\n'); + expect(joined).toContain('$(touch marker)'); + // The absent-queue branch's `wrangler queues create ` guidance is never reached. + expect(joined).not.toContain('wrangler queues create'); + expect(wranglerArgs.some((a) => a[0] === 'queues')).toBe(false); + expect(trace).toEqual(['wrangler:whoami']); + // Belt and braces: no subshell ever ran. + expect(existsSync(join(root, 'marker'))).toBe(false); + }); + + it('reports every invalid queue name together rather than dying on the first', async () => { + const root = queueNameProject('--help.ts', '$(touch marker).ts', 'jobs.ts'); + const { deps, wranglerArgs, exitCalls } = harness(); + const messages = await runCapturingErrors(root, deps); + + expect(exitCalls).toEqual([1]); + // ONE batched message naming BOTH offenders — and not the valid sibling. + expect(messages).toHaveLength(1); + const [message] = messages; + expect(message).toContain(' • --help (queue)'); + expect(message).toContain(' • $(touch marker) (queue)'); + expect(message).not.toContain('• jobs'); + expect(wranglerArgs.some((a) => a[0] === 'queues')).toBe(false); + }); + + it('lets a valid queue name through to the probe unchanged', async () => { + const root = queueNameProject('jobs.ts'); + const { deps, wranglerArgs, trace } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + // The gate is transparent to a legal name: the probe still runs, pre-build, for `jobs`. + expect(wranglerArgs.find((a) => a[0] === 'queues')).toEqual([ + 'queues', + 'info', + 'jobs', + '-c', + join(root, 'wrangler.jsonc'), + '--env=', + ]); + expect(trace.indexOf('wrangler:queues')).toBeLessThan(trace.indexOf('build')); + expect(trace).toContain('wrangler:deploy'); + }); +}); + +describe('runCloudflareDeploy — CI queue provision gate', () => { + it('fails closed on a queue under CI --provision (queues cannot be proven created)', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'qapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'queues/jobs.ts': `export default {};`, + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'qapp' }, null, 2), + }); + const { deps, trace, exitCalls } = harness({ isCI: true }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare', provision: true }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Gated after preflight, before provisioning — no `queues create`. + expect(trace).toEqual(['wrangler:whoami']); + expect(trace).not.toContain('provision'); + }); +}); + +describe('runCloudflareDeploy — skipped desired resource is fatal', () => { + it('fails closed when the provisioner skips a desired Hyperdrive; never builds/deploys', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'pgapp' }), + 'void.json': JSON.stringify({ database: 'pg' }), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'pgapp' }, null, 2), + }); + const { deps, trace, exitCalls } = harness({ + provision: (async (_root, configPath): Promise => { + trace.push('provision'); + return { + resources: [ + { + kind: 'hyperdrive', + binding: 'HYPERDRIVE', + name: 'pgapp-hyperdrive', + action: 'skipped', + reason: 'no connection string provided', + }, + ], + configPath, + accountIdWritten: false, + }; + }) as CloudflareDeployDeps['provision'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare', provision: true }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toContain('provision'); + expect(trace).not.toContain('build'); + expect(trace).not.toContain('wrangler:deploy'); + }); +}); + +describe('runCloudflareDeploy — generated config verification', () => { + it('fails closed after build when the generated config mismatches; never deploys', async () => { + const root = d1Project(PROVISIONED_D1); + const { deps, trace, exitCalls } = harness({ + verifyDeployConfig: () => ({ ok: false, reason: 'account mismatch' }), + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toContain('build'); // build ran + expect(trace).not.toContain('wrangler:deploy'); // deploy blocked + }); +}); + +describe('runCloudflareDeploy — non-Cloudflare target guard', () => { + for (const target of ['node', 'bun', 'deno'] as const) { + it(`rejects the "${target}" target before any account/provision op`, async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'nodeapp' }), + 'void.json': JSON.stringify({ target }), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'nodeapp' }, null, 2), + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare', provision: true }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Rejected before the desired set + any wrangler/provision call. + expect(trace).toEqual([]); + }); + } +}); + +describe('runCloudflareDeploy — worker-name override (WRANGLER_CI_OVERRIDE_NAME)', () => { + it('fails closed when a project dotenv declares WRANGLER_CI_OVERRIDE_NAME', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + 'wrangler.jsonc': JSON.stringify(PROVISIONED_D1, null, 2), + '.env': 'WRANGLER_CI_OVERRIDE_NAME=other-worker\n', + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Fenced during preflight (before whoami) — nothing ran. + expect(trace).toEqual([]); + }); + + it('ignores an ambient WRANGLER_CI_OVERRIDE_NAME — deploys the derived (non-override) name', async () => { + const saved = process.env.WRANGLER_CI_OVERRIDE_NAME; + process.env.WRANGLER_CI_OVERRIDE_NAME = 'evil-name'; + try { + // Root config has NO top-level name → the worker name is derived from package.json, + // NOT the ambient override. + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + 'wrangler.jsonc': JSON.stringify( + { + account_id: 'acc123', + d1_databases: [ + { + binding: 'DB', + database_name: 'myapp-db', + database_id: REAL_D1_ID, + migrations_dir: './db/migrations', + }, + ], + }, + null, + 2, + ), + }); + let deployedWorker: unknown; + const cliLog: CliLogger = { + ...silentCliLog, + info(event: string, fields?: Record) { + if (event === 'cloudflare_deploy_end') { + deployedWorker = fields?.worker; + } + }, + }; + const { deps, trace } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, cliLog, deps); + expect(trace).toContain('wrangler:deploy'); + expect(deployedWorker).toBe('myapp'); + // The pinned root config name is the derived name, never the override. + const written = JSON.parse(readFileSync(join(root, 'wrangler.jsonc'), 'utf-8')) as { + name?: string; + }; + expect(written.name).toBe('myapp'); + } finally { + if (saved === undefined) { + delete process.env.WRANGLER_CI_OVERRIDE_NAME; + } else { + process.env.WRANGLER_CI_OVERRIDE_NAME = saved; + } + } + }); +}); + +describe('runCloudflareDeploy — build-config override fences', () => { + it('fails closed when .env.production declares CLOUDFLARE_ENV', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + 'wrangler.jsonc': JSON.stringify(PROVISIONED_D1, null, 2), + '.env.production': 'CLOUDFLARE_ENV=staging\n', + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + }); + + it('fails closed when .env.production declares CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + 'wrangler.jsonc': JSON.stringify(PROVISIONED_D1, null, 2), + '.env.production': 'CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH=./alt.jsonc\n', + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + }); + + it('fails closed on a .env. transport redirect (reused provisioner fence)', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + 'wrangler.jsonc': JSON.stringify(PROVISIONED_D1, null, 2), + '.env.': 'CLOUDFLARE_API_BASE_URL=https://evil.example.com\n', + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + }); + + it('fails closed on an ambient CLOUDFLARE_ENV (non-top-level target)', async () => { + const saved = process.env.CLOUDFLARE_ENV; + process.env.CLOUDFLARE_ENV = 'staging'; + try { + const root = d1Project(PROVISIONED_D1); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + } finally { + if (saved === undefined) { + delete process.env.CLOUDFLARE_ENV; + } else { + process.env.CLOUDFLARE_ENV = saved; + } + } + }); +}); + +describe('assertNoConfigOverrideInProjectDotenv', () => { + it('rejects WRANGLER_CI_OVERRIDE_NAME in a wrangler --env= dotenv file', () => { + const root = makeProject({ '.env': 'WRANGLER_CI_OVERRIDE_NAME=x\n' }); + expect(() => assertNoConfigOverrideInProjectDotenv(root, 'linux')).toThrow( + /WRANGLER_CI_OVERRIDE_NAME/, + ); + }); + + it('rejects CLOUDFLARE_ENV / CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH in the vite production set', () => { + const rootA = makeProject({ '.env.production': 'CLOUDFLARE_ENV=prod\n' }); + expect(() => assertNoConfigOverrideInProjectDotenv(rootA, 'linux')).toThrow(/CLOUDFLARE_ENV/); + const rootB = makeProject({ + '.env.production.local': 'CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH=./alt.jsonc\n', + }); + expect(() => assertNoConfigOverrideInProjectDotenv(rootB, 'linux')).toThrow( + /CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH/, + ); + }); + + it('passes when no override selectors are declared', () => { + const root = makeProject({ '.env': 'FOO=bar\n', '.env.production': 'BAZ=qux\n' }); + expect(() => assertNoConfigOverrideInProjectDotenv(root, 'linux')).not.toThrow(); + }); +}); + +describe('runCloudflareDeploy — D1 migrate targets the unique validated entry', () => { + it('fails closed when an earlier alias entry shares the migrate token', async () => { + // Two D1 entries share database_name "myapp-db" with DIFFERENT migrations_dir. The + // gate validated binding DB, but wrangler would first-match the earlier OLD entry. + const root = d1Project( + { + account_id: 'acc123', + name: 'myapp', + d1_databases: [ + { + binding: 'OLD', + database_name: 'myapp-db', + database_id: REAL_D1_ID, + migrations_dir: './legacy', + }, + { + binding: 'DB', + database_name: 'myapp-db', + database_id: REAL_D1_ID, + migrations_dir: './db/migrations', + }, + ], + }, + INIT_MIGRATION, + ); + const { deps, wranglerArgs, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The migrate-target gate runs POST-build now, so the build ran — but it fails closed + // before any `d1 migrations apply` and before the deploy. + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(trace).toContain('build'); + expect(trace).not.toContain('wrangler:deploy'); + }); + + it('migrates by the unique token even when the validated entry is not first', async () => { + const root = d1Project( + { + account_id: 'acc123', + name: 'myapp', + d1_databases: [ + { + binding: 'OLD', + database_name: 'legacy-db', + database_id: REAL_D1_ID, + migrations_dir: './legacy', + }, + { + binding: 'DB', + database_name: 'myapp-db', + database_id: REAL_D1_ID, + migrations_dir: './db/migrations', + }, + ], + }, + INIT_MIGRATION, + ); + const { deps, wranglerArgs, trace } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + // Targets the validated entry's own unique database_name (resolves to DB, not OLD). + // `.find` returns the first d1 call — the apply (the list verify follows it). + expect(wranglerArgs.find((a) => a[0] === 'd1')).toEqual([ + 'd1', + 'migrations', + 'apply', + 'myapp-db', + '--remote', + '-c', + join(root, 'wrangler.jsonc'), + '--env=', + ]); + expect(trace).toContain('wrangler:deploy'); + }); +}); + +describe('runCloudflareDeploy — wrangler implicit provisioning disabled on deploy', () => { + it('passes both disable-flags for an R2 app that passes local drift (no auto-create)', async () => { + // R2 bucket_name present locally (drift passes) but may be absent remotely — deploy + // must NOT let wrangler auto-create it. + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'r2app' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.STORAGE.put('a', 'b');`, + 'wrangler.jsonc': JSON.stringify( + { + account_id: 'acc123', + name: 'r2app', + r2_buckets: [{ binding: 'STORAGE', bucket_name: 'r2app-storage' }], + }, + null, + 2, + ), + }); + const { deps, wranglerArgs } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(wranglerArgs.find((a) => a[0] === 'deploy')).toEqual([ + 'deploy', + '--env=', + '--experimental-provision=false', + '--experimental-auto-create=false', + '--autoconfig=false', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// The pass-through assert-set is DERIVED from the wrangler binding registry +// (FRIENDLY_BINDING_NAMES) minus the fields Void's own build emits — so it is +// complete-by-construction (every capability field is covered) and a new registry +// field fails CLOSED (asserted until explicitly excluded). +// --------------------------------------------------------------------------- + +describe('DEPLOY_PASSTHROUGH_ASSERT_FIELDS — registry-derived composition', () => { + it('covers the capability singletons + the `unsafe` escape hatch (previously unguarded)', () => { + for (const field of ['browser', 'images', 'stream', 'media', 'websearch', 'unsafe']) { + expect(DEPLOY_PASSTHROUGH_ASSERT_FIELDS).toContain(field); + } + }); + + it('excludes every id-pinned + Void-emitted + bundled-content field (no double-assert)', () => { + for (const field of ['d1_databases', 'kv_namespaces', 'r2_buckets', 'hyperdrive']) { + expect(DEPLOY_PASSTHROUGH_ASSERT_FIELDS).not.toContain(field); + } + for (const field of ['ai', 'durable_objects', 'assets', 'vars']) { + expect(DEPLOY_PASSTHROUGH_ASSERT_FIELDS).not.toContain(field); + } + }); + + it('every excluded field is a REAL registry key (no typos)', () => { + for (const field of DEPLOY_PASSTHROUGH_EXCLUDE_FIELDS) { + expect(FRIENDLY_BINDING_NAMES).toHaveProperty(field); + } + }); + + it('EXCLUDE ∪ ASSERT === keys(FRIENDLY_BINDING_NAMES) — a new registry field forces a choice', () => { + const union = new Set([ + ...DEPLOY_PASSTHROUGH_ASSERT_FIELDS, + ...DEPLOY_PASSTHROUGH_EXCLUDE_FIELDS, + ]); + expect([...union].sort()).toEqual(Object.keys(FRIENDLY_BINDING_NAMES).sort()); + // Partition (no field is both asserted and excluded). + for (const field of DEPLOY_PASSTHROUGH_ASSERT_FIELDS) { + expect(DEPLOY_PASSTHROUGH_EXCLUDE_FIELDS.has(field)).toBe(false); + } + }); + + it('whole-object-pins unsafe, the tail-consumer fields, and the traffic-attachment fields (the entry pin cannot see them)', () => { + expect([...DEPLOY_WHOLE_OBJECT_PINNED_FIELDS].sort()).toEqual([ + 'preview_urls', + 'route', + 'routes', + 'streaming_tail_consumers', + 'tail_consumers', + 'unsafe', + 'workers_dev', + ]); + // `unsafe` stays in the assert-set (drift invariant) but is routed to the whole-object pin. + expect(DEPLOY_PASSTHROUGH_ASSERT_FIELDS).toContain('unsafe'); + // The tail-consumer + traffic-attachment fields are NOT bindings, so the assert-set structurally + // cannot see them — only the whole-object pin covers them. + for (const field of [ + 'tail_consumers', + 'streaming_tail_consumers', + 'routes', + 'route', + 'workers_dev', + 'preview_urls', + ]) { + expect(FRIENDLY_BINDING_NAMES).not.toHaveProperty(field); + } + }); +}); + +describe('verifyGeneratedDeployConfig', () => { + function deployConfigProject(generated: Record): string { + return makeProject({ + '.wrangler/deploy/config.json': JSON.stringify({ + configPath: '../../dist/ssr/wrangler.json', + }), + 'dist/ssr/wrangler.json': JSON.stringify(generated), + }); + } + + it('passes when account_id and name match the pinned target', () => { + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + expect(verifyGeneratedDeployConfig(root, { accountId: 'acc123', workerName: 'myapp' })).toEqual( + { + ok: true, + }, + ); + }); + + it('fails when the generated account_id differs (named-env redirect)', () => { + const root = deployConfigProject({ account_id: 'other-acc', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { accountId: 'acc123', workerName: 'myapp' }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/account_id/); + }); + + it('fails when the generated worker name differs (e.g. env-suffixed name)', () => { + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp-prod' }); + const result = verifyGeneratedDeployConfig(root, { accountId: 'acc123', workerName: 'myapp' }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/worker name/); + }); + + it('fails closed when the deploy config was not emitted', () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'myapp' }) }); + const result = verifyGeneratedDeployConfig(root, { accountId: 'acc123', workerName: 'myapp' }); + expect(result.ok).toBe(false); + }); + + it('fails when the generated binding identity differs from the pinned snapshot', () => { + // Same account_id + name, but the generated D1 database_id differs from the identity + // pinned before the build — a flattened named-env / alternate-config binding set, OR a + // build that rewrote the id. The snapshot (not a root re-read) is the ground truth. + const desired = { d1: { binding: 'DB', name: 'myapp-db' }, kvNamespaces: [], queues: [] }; + const snapshot = snapshotBindingIdentities({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }); + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: OTHER_D1_ID }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired, + bindingIdentitySnapshot: snapshot, + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/identity|flattened|tampered/); + }); + + it('fails when the generated config drops a pinned binding entirely', () => { + const desired = { d1: { binding: 'DB', name: 'myapp-db' }, kvNamespaces: [], queues: [] }; + const snapshot = snapshotBindingIdentities({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }); + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp', d1_databases: [] }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired, + bindingIdentitySnapshot: snapshot, + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/missing/); + }); + + it('passes when the generated binding identities match the pinned snapshot', () => { + const desired = { + d1: { binding: 'DB', name: 'myapp-db' }, + kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }], + queues: [], + }; + const snapshot = snapshotBindingIdentities({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + kv_namespaces: [{ binding: 'KV', id: REAL_KV_ID }], + }); + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + kv_namespaces: [{ binding: 'KV', id: REAL_KV_ID }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired, + bindingIdentitySnapshot: snapshot, + }); + expect(result).toEqual({ ok: true }); + }); + + it('fails when a non-D1 binding identity (R2 / Hyperdrive) differs from the snapshot', () => { + // R2 (bucket_name) and Hyperdrive (id) are pinned the same way D1 is — the snapshot + + // verify logic is uniform across kinds, so both must be caught. + const r2Desired = { + r2: { binding: 'STORAGE', bucketName: 'myapp-storage' }, + kvNamespaces: [], + queues: [], + }; + const r2Snapshot = snapshotBindingIdentities({ + r2_buckets: [{ binding: 'STORAGE', bucket_name: 'myapp-storage' }], + }); + const r2Root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + r2_buckets: [{ binding: 'STORAGE', bucket_name: 'someone-elses-bucket' }], + }); + expect( + verifyGeneratedDeployConfig(r2Root, { + accountId: 'acc123', + workerName: 'myapp', + desired: r2Desired, + bindingIdentitySnapshot: r2Snapshot, + }).ok, + ).toBe(false); + + const hyperDesired = { + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hyperdrive', connectionString: '' }, + kvNamespaces: [], + queues: [], + }; + const hyperSnapshot = snapshotBindingIdentities({ + hyperdrive: [{ binding: 'HYPERDRIVE', id: REAL_KV_ID }], + }); + const hyperRoot = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + hyperdrive: [{ binding: 'HYPERDRIVE', id: OTHER_KV_ID }], + }); + expect( + verifyGeneratedDeployConfig(hyperRoot, { + accountId: 'acc123', + workerName: 'myapp', + desired: hyperDesired, + bindingIdentitySnapshot: hyperSnapshot, + }).ok, + ).toBe(false); + }); + + it('passes with the full non-id binding set a real build emits (allow-list is exercised)', () => { + // The critical no-regression shape: every id-bearing binding matches the snapshot, and the + // artifact ALSO carries the ai/queues/DO/vars/empty-array bindings a real Class-A build emits. + // None of those non-id bindings is in the snapshot, yet the check must not reject them. + const snapshot = snapshotBindingIdentities({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + kv_namespaces: [{ binding: 'KV', id: REAL_KV_ID }], + r2_buckets: [{ binding: 'STORAGE', bucket_name: 'myapp-storage' }], + }); + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + ...KITCHEN_SINK_NON_ID_BINDINGS, + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + kv_namespaces: [{ binding: 'KV', id: REAL_KV_ID }], + r2_buckets: [{ binding: 'STORAGE', bucket_name: 'myapp-storage' }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { + d1: { binding: 'DB', name: 'myapp-db' }, + kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }], + r2: { binding: 'STORAGE', bucketName: 'myapp-storage' }, + queues: ['jobs'], + }, + bindingIdentitySnapshot: snapshot, + }); + expect(result).toEqual({ ok: true }); + }); + + it('fails when the artifact declares an id-bearing binding absent from the snapshot (build ADD)', () => { + // Only DB was pinned pre-build; the artifact ADDS an EXTRA_KV the snapshot never saw. The + // per-desired loop only walks {DB}, so completeness is what rejects this. + const snapshot = snapshotBindingIdentities({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }); + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + kv_namespaces: [{ binding: 'EXTRA_KV', id: OTHER_KV_ID }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { d1: { binding: 'DB', name: 'myapp-db' }, kvNamespaces: [], queues: [] }, + bindingIdentitySnapshot: snapshot, + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/EXTRA_KV/); + }); + + it('fails when a NON-desired binding id in the artifact diverges from the snapshot', () => { + // A hand-declared, non-desired EXTRA_DB is pinned pre-build; the artifact rewrites its id. + // The desired loop never inspects EXTRA_DB — completeness catches the divergence. + const snapshot = snapshotBindingIdentities({ + d1_databases: [ + { binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }, + { binding: 'EXTRA_DB', database_name: 'extra', database_id: REAL_D1_ID }, + ], + }); + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + d1_databases: [ + { binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }, + { binding: 'EXTRA_DB', database_name: 'extra', database_id: OTHER_D1_ID }, + ], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { d1: { binding: 'DB', name: 'myapp-db' }, kvNamespaces: [], queues: [] }, + bindingIdentitySnapshot: snapshot, + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/EXTRA_DB/); + }); + + it('passes when a NON-desired binding present in the snapshot is unchanged in the artifact', () => { + // The legitimate-extra case: a user hand-declared EXTRA_DB, it was pinned pre-build, and the + // build left it untouched — completeness must NOT false-positive on a legitimate extra. + const snapshot = snapshotBindingIdentities({ + d1_databases: [ + { binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }, + { binding: 'EXTRA_DB', database_name: 'extra', database_id: OTHER_D1_ID }, + ], + }); + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + d1_databases: [ + { binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }, + { binding: 'EXTRA_DB', database_name: 'extra', database_id: OTHER_D1_ID }, + ], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { d1: { binding: 'DB', name: 'myapp-db' }, kvNamespaces: [], queues: [] }, + bindingIdentitySnapshot: snapshot, + }); + expect(result).toEqual({ ok: true }); + }); + + it('fails closed when the build DROPS a non-desired root id-bearing binding (EXTRA_DB)', () => { + // A hand-declared, NON-desired EXTRA_DB is pinned pre-build; the emitted artifact keeps the + // desired DB but no longer declares EXTRA_DB. The desired loop only inspects DB, and the + // completeness loop only walks what the artifact declares — neither sees the omission. The + // reverse-presence loop must reject it (a dropped id-binding silently strips a capability). + const snapshot = snapshotBindingIdentities({ + d1_databases: [ + { binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }, + { binding: 'EXTRA_DB', database_name: 'extra', database_id: OTHER_D1_ID }, + ], + }); + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { d1: { binding: 'DB', name: 'myapp-db' }, kvNamespaces: [], queues: [] }, + bindingIdentitySnapshot: snapshot, + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/EXTRA_DB/); + expect(result.reason).toMatch(/dropped/); + }); + + it('fails closed when the build drops a pinned id-bearing binding by removing the whole field', () => { + // The KV field is present pre-build (one pinned namespace) but the emitted artifact omits the + // `kv_namespaces` field entirely. A removed field means every pinned binding in it is missing — + // the reverse-presence loop rejects it while the D1 binding still passes. + const snapshot = snapshotBindingIdentities({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + kv_namespaces: [{ binding: 'EXTRA_KV', id: REAL_KV_ID }], + }); + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { d1: { binding: 'DB', name: 'myapp-db' }, kvNamespaces: [], queues: [] }, + bindingIdentitySnapshot: snapshot, + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/EXTRA_KV/); + }); + + it('passes when every pinned id-bearing binding (incl. a non-desired one) survives the build', () => { + // No-regression for the reverse loop: the desired DB and a non-desired EXTRA_DB are both pinned + // pre-build and both present unchanged in the artifact, so neither the completeness nor the + // reverse-presence loop objects. + const snapshot = snapshotBindingIdentities({ + d1_databases: [ + { binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }, + { binding: 'EXTRA_DB', database_name: 'extra', database_id: OTHER_D1_ID }, + ], + }); + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + d1_databases: [ + { binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }, + { binding: 'EXTRA_DB', database_name: 'extra', database_id: OTHER_D1_ID }, + ], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { d1: { binding: 'DB', name: 'myapp-db' }, kvNamespaces: [], queues: [] }, + bindingIdentitySnapshot: snapshot, + }); + expect(result).toEqual({ ok: true }); + }); + + // --- Pass-through assert-set completeness (fields Void never emits) + queue ⊆ desired --- + + it('passes the full kitchen-sink shape (empty resource arrays, DO/containers, desired queues)', () => { + // The critical no-regression: a real Class-A artifact carries empty assert-set arrays + // (services/vectorize/…), durable_objects/containers/migrations, and a queues block whose + // names Void derived from source. NONE may be rejected by the new assertions. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + ...KITCHEN_SINK_NON_ID_BINDINGS, + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { d1: { binding: 'DB', name: 'myapp-db' }, kvNamespaces: [], queues: ['jobs'] }, + bindingIdentitySnapshot: snapshotBindingIdentities({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }), + passthroughIdentitySnapshot: snapshotPassthroughIdentities(KITCHEN_SINK_NON_ID_BINDINGS), + }); + expect(result).toEqual({ ok: true }); + }); + + it('fails closed when the build injects a services binding absent from the root snapshot', () => { + // Root had no `services` pre-build; the artifact adds one binding to another same-account + // worker — a capability the source never asked for. (Reverting assertion A lets it through.) + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + services: [{ binding: 'EVIL', service: 'someone-elses-worker' }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/services/); + expect(result.reason).toMatch(/injected/); + }); + + it('fails closed when the build injects a secrets_store_secrets or dispatch_namespaces entry', () => { + for (const field of ['secrets_store_secrets', 'dispatch_namespaces'] as const) { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + [field]: [{ binding: 'INJECTED' }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(new RegExp(field)); + } + }); + + it('passes a user hand-declared services binding present in the root snapshot, unchanged', () => { + // The legitimate-extra case: a user's own `services` binding was present before the build and + // the build left it untouched — completeness must NOT false-positive on a legit pass-through. + const rootConfig = { services: [{ binding: 'MY_SVC', service: 'my-other-worker' }] }; + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp', ...rootConfig }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + }); + expect(result).toEqual({ ok: true }); + }); + + // --- Reverse-presence: a root-declared pass-through binding the build DROPPED is rejected --- + + it('fails closed when the build DROPS a root-declared services binding from the emitted config', () => { + // The source declared a `services` binding pre-build; the emitted artifact no longer carries it + // (the build removed it). The forward loop only walks emitted entries, so it cannot see the + // omission — the reverse-presence loop must reject it. + const rootConfig = { services: [{ binding: 'MY_SVC', service: 'my-other-worker' }] }; + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/services/); + expect(result.reason).toMatch(/MY_SVC/); + }); + + it('fails closed when the build drops one of several pinned passthrough bindings', () => { + // Two root-declared bindings across two fields; the emitted config keeps `mtls_certificates` but + // drops the `services` one. The surviving binding passes the forward loop, but the reverse loop + // catches the dropped one. + const rootConfig = { + services: [{ binding: 'SVC', service: 'worker-a' }], + mtls_certificates: [{ binding: 'CERT', certificate_id: 'cert-1' }], + }; + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + mtls_certificates: [{ binding: 'CERT', certificate_id: 'cert-1' }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/services/); + expect(result.reason).toMatch(/SVC/); + }); + + it('passes when every pinned passthrough binding is still present in the emitted config', () => { + // No-regression for the reverse loop: all root-declared pass-through bindings survive the build + // byte-identical, so neither the forward nor the reverse loop objects. + const rootConfig = { + services: [{ binding: 'SVC', service: 'worker-a' }], + mtls_certificates: [{ binding: 'CERT', certificate_id: 'cert-1' }], + }; + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp', ...rootConfig }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + }); + expect(result).toEqual({ ok: true }); + }); + + it('fails closed when the build injects a queue producer not in desired.queues', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + queues: { producers: [{ binding: 'Q', queue: 'exfil' }], consumers: [] }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/queue/); + expect(result.reason).toMatch(/exfil/); + }); + + it('passes emitted queues whose producer AND consumer names are all in desired.queues', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + queues: { + producers: [{ binding: 'JOBS', queue: 'jobs' }], + consumers: [{ queue: 'jobs', max_batch_size: 10 }], + }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: ['jobs'] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + }); + expect(result).toEqual({ ok: true }); + }); + + it('fails closed when a desired queue is DROPPED (the emitted queues field is absent)', () => { + // `mergeBindings` emits every desired queue as both a producer AND a consumer, so a desired + // queue with NO emitted `queues` field at all is a queue binding the build dropped — the worker + // would lose message processing and leave `QUEUE_jobs` undefined. The emitted ⊆ desired check + // waves this through (nothing emitted); the per-group presence check must reject it. + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: ['jobs'] }, + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/jobs/); + expect(result.reason).toMatch(/producer and a consumer/); + }); + + it('fails closed when a desired queue keeps only the producer (consumer dropped)', () => { + // The artifact retains the producer but drops the consumer — the worker can still enqueue but + // never processes. Per-group presence is why the merged reader cannot catch this: `jobs` is + // still in the merged name set, so only checking each group separately rejects it. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + queues: { producers: [{ binding: 'JOBS', queue: 'jobs' }], consumers: [] }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: ['jobs'] }, + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/jobs/); + expect(result.reason).toMatch(/producer and a consumer/); + }); + + it('passes when there are no desired queues and none are emitted', () => { + // No-regression: an app with no queues emits no `queues` field, and the per-group check has + // nothing to require — the empty desired set passes. + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + }); + expect(result).toEqual({ ok: true }); + }); + + it('does NOT reject an unknown/future resource field the emitted artifact declares (fail-open)', () => { + // A field not in the assert-set must never false-positive — a future wrangler resource field + // Void does not know about is simply not asserted. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + some_future_resource: [{ binding: 'NEW_THING' }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + }); + expect(result).toEqual({ ok: true }); + }); + + it('does NOT reject durable_objects / containers in the emitted artifact (DO/sandbox out of scope)', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + durable_objects: { bindings: [{ name: 'WS', class_name: 'ChatRoom' }] }, + containers: [{ class_name: 'Sandbox', image: './Dockerfile' }], + migrations: [{ tag: 'v1', new_sqlite_classes: ['Sandbox'] }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + }); + expect(result).toEqual({ ok: true }); + }); + + // --- Pass-through TARGET pin: same binding name, rewritten target/config --- + + it('fails closed when the build keeps a services binding NAME but rewrites its target service', () => { + // Root pinned MY_SVC → my-worker; the build kept the name but retargeted it to evil-worker. + // Name-only pinning would pass this; the exact entry pin catches the rewrite. + const rootConfig = { services: [{ binding: 'MY_SVC', service: 'my-worker' }] }; + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + services: [{ binding: 'MY_SVC', service: 'evil-worker' }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/services/); + expect(result.reason).toMatch(/MY_SVC/); + expect(result.reason).toMatch(/rewrote|differs/); + }); + + it('fails closed when the build ADDS an entrypoint/environment to a pinned services entry', () => { + // A real `vite build` adds NOTHING to a present pass-through entry (verified against the real + // toolchain), so an added `entrypoint`/`environment` — which wrangler uploads, retargeting the + // service to a named entrypoint/environment — is a build rewrite the exact entry pin catches. + const rootConfig = { services: [{ binding: 'MY_SVC', service: 'my-worker' }] }; + for (const added of [{ entrypoint: 'EvilEntry' }, { environment: 'staging' }]) { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + services: [{ binding: 'MY_SVC', service: 'my-worker', ...added }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/services/); + expect(result.reason).toMatch(/MY_SVC/); + expect(result.reason).toMatch(/rewrote|differs/); + } + }); + + it('passes a byte-identical pass-through entry (no normalizer key is added to a present entry)', () => { + // The real toolchain leaves a present pass-through entry byte-identical, so exact equality holds + // for a legitimate build — no regression from the exact-compare tightening. + const rootConfig = { services: [{ binding: 'MY_SVC', service: 'my-worker' }] }; + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + services: [{ binding: 'MY_SVC', service: 'my-worker' }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + }); + expect(result).toEqual({ ok: true }); + }); + + // --- Capability singletons + the `unsafe` escape hatch (previously unguarded fields) --- + + it('fails closed when the build injects a capability singleton absent from the root', () => { + for (const field of ['browser', 'images', 'stream', 'media', 'websearch'] as const) { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + [field]: { binding: 'INJECTED' }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(new RegExp(field)); + expect(result.reason).toMatch(/injected/); + } + }); + + it('fails closed when the build injects an `unsafe` object absent from the root (metadata bindings)', () => { + // `unsafe.metadata.bindings` is never entry-iterated, yet wrangler applies it LAST at upload, + // OVERWRITING the whole uploaded bindings array. The whole-object pin catches an injected + // `unsafe` even when its capability lives under `.metadata`, not `.bindings`. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + unsafe: { metadata: { bindings: [{ name: 'X', type: 'service', service: 'evil' }] } }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/unsafe/); + }); + + it('fails closed when the build rewrites a root-declared `unsafe` object', () => { + // The whole `unsafe` object is pinned, so any rewrite (here the nested binding `.type`) fails. + const rootConfig = { unsafe: { bindings: [{ name: 'X', type: 'secret_text' }] } }; + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + unsafe: { bindings: [{ name: 'X', type: 'plain_text' }] }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/unsafe/); + }); + + it('passes a root-declared `unsafe` object emitted byte-identical', () => { + const rootConfig = { + unsafe: { metadata: { bindings: [{ name: 'X', type: 'service', service: 'my-worker' }] } }, + }; + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp', ...rootConfig }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result).toEqual({ ok: true }); + }); + + it('passes when no `unsafe` is in the root or the emitted artifact', () => { + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result).toEqual({ ok: true }); + }); + + // --- FALSE-POSITIVE LOCKS: the normalizer defaults that MUST keep passing --- + + it('passes empty-array assert fields the normalizer default-fills (services:[] / vectorize:[] / …)', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + services: [], + vectorize: [], + dispatch_namespaces: [], + analytics_engine_datasets: [], + send_email: [], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + }); + expect(result).toEqual({ ok: true }); + }); + + it('passes logfwdr:{bindings:[]} (always emitted, empty bindings) with none in root', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + logfwdr: { bindings: [] }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + }); + expect(result).toEqual({ ok: true }); + }); + + it('passes when unsafe / tail_consumers / streaming_tail_consumers are absent (the clean-build shape)', () => { + // A real `vite build` (root declaring none) leaves all three ABSENT from the emitted artifact, + // so the whole-object pin must wave the absent shape through. + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result).toEqual({ ok: true }); + }); + + // --- Traffic attachment (routes / route / workers_dev / preview_urls — Void emits none) --- + + it('passes when routes / route / workers_dev / preview_urls are all absent (the clean-build shape)', () => { + // A real `vite build` (root declaring none) leaves all four ABSENT from the emitted artifact + // (verified against a real build), so the whole-object pin must wave the absent shape through. + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result).toEqual({ ok: true }); + }); + + it('fails closed when the build injects a routes entry absent from the root (traffic hijack)', () => { + // The build attaches the worker to a production hostname/path the account controls by injecting + // `routes`. The root declared none, so the whole-object pin rejects it. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + routes: [{ pattern: 'victim.example.com/*', zone_name: 'victim.example.com' }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/routes/); + }); + + it('fails closed when the build injects a single `route` string absent from the root', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + route: 'victim.example.com/*', + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/route/); + }); + + it('passes a root-declared routes emitted byte-identical', () => { + // A user who legitimately declared `routes` in wrangler.jsonc: the build spreads it verbatim, so + // the emitted value is byte-identical and passes. (Verified against a real build.) + const rootConfig = { + routes: [{ pattern: 'app.example.com/*', zone_name: 'app.example.com' }], + }; + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp', ...rootConfig }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result).toEqual({ ok: true }); + }); + + it('fails closed when the build flips a root-declared workers_dev from false to true', () => { + // `workers_dev:false` is non-empty (isEmptyPassthroughValue(false) is false), so a root that + // declared it false and an emitted `true` are deepEqual-compared — flipping the public exposure + // is rejected. (A real build emits a declared `workers_dev:false` byte-identical.) + const rootConfig = { workers_dev: false }; + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + workers_dev: true, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/workers_dev/); + }); + + it('fails closed when the build injects workers_dev:true on a root that declared none', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + workers_dev: true, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/workers_dev/); + }); + + it('passes a root-declared workers_dev:false emitted byte-identical', () => { + const rootConfig = { workers_dev: false, preview_urls: false }; + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp', ...rootConfig }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result).toEqual({ ok: true }); + }); + + // --- Tail consumers (log/request forwarding — not "bindings", so the assert-set can't see them) --- + + for (const field of ['tail_consumers', 'streaming_tail_consumers'] as const) { + it(`fails closed when the build injects a ${field} entry absent from the root`, () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + [field]: [{ service: 'evil-log-collector' }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(new RegExp(field)); + }); + + it(`passes a root-declared ${field} emitted byte-identical`, () => { + const rootConfig = { [field]: [{ service: 'my-log-collector' }] }; + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp', ...rootConfig }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result).toEqual({ ok: true }); + }); + + it(`passes an emitted ${field} default-filled to an empty array`, () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + [field]: [], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result).toEqual({ ok: true }); + }); + } + + // --- Symmetric empty check: a non-empty root emitted back EMPTY is a REMOVAL (rejected) --- + + it('fails closed when the build empties a root-declared tail_consumers (disabling audit forwarding)', () => { + // The source forwarded logs to an audit collector; the build rewrote it to `[]`, which wrangler + // uploads as an explicit empty array — silently disabling the forwarding. The symmetric check + // rejects an emptied emitted value whenever the root declared a non-empty one. + const rootConfig = { tail_consumers: [{ service: 'audit-collector' }] }; + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + tail_consumers: [], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/tail_consumers/); + }); + + it('fails closed when the build empties a root-declared streaming_tail_consumers', () => { + const rootConfig = { streaming_tail_consumers: [{ service: 'audit-collector' }] }; + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + streaming_tail_consumers: [], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/streaming_tail_consumers/); + }); + + it('fails closed when the build empties a root-declared unsafe object', () => { + const rootConfig = { unsafe: { bindings: [{ name: 'X', type: 'plain_text' }] } }; + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + unsafe: { bindings: [] }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/unsafe/); + }); + + // --- External Durable Object (`script_name` targets another worker's DOs) --- + + it('fails closed when the build injects an external durable_object (script_name) absent from root', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + durable_objects: { + bindings: [{ name: 'EXT', class_name: 'Other', script_name: 'someone-elses-worker' }], + }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/durable_objects/); + expect(result.reason).toMatch(/EXT/); + }); + + it('fails closed when the build adds an `environment` to a durable_object binding', () => { + // `environment` narrows a cross-script DO to a named environment — external-targeting. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + durable_objects: { + bindings: [{ name: 'EXT', class_name: 'Other', environment: 'staging' }], + }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/durable_objects/); + }); + + it('passes Void-style LOCAL durable_objects (`{name, class_name}`, no script_name)', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + durable_objects: { + bindings: [ + { name: 'SANDBOX', class_name: 'Sandbox' }, + { name: 'WS', class_name: 'ChatRoom' }, + ], + }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result).toEqual({ ok: true }); + }); + + it('passes a root-declared external durable_object emitted byte-identical', () => { + const rootConfig = { + durable_objects: { + bindings: [{ name: 'EXT', class_name: 'Other', script_name: 'my-other-worker' }], + }, + }; + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp', ...rootConfig }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result).toEqual({ ok: true }); + }); + + // --- AI binding (must be Void's own {binding:'AI'}, the root's, or absent) --- + + it("passes Void's own emitted ai binding {binding:'AI'} with none in root when the source uses AI", () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + ai: { binding: 'AI' }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + needsAI: true, + }); + expect(result).toEqual({ ok: true }); + }); + + it("fails closed when the build injects Void's own {binding:'AI'} but the source does not use AI", () => { + // No root ai + needsAI false: a real build never emits `{binding:'AI'}` here, so an emitted one + // is a build-injected billable Workers AI binding. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + ai: { binding: 'AI' }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + needsAI: false, + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/ai binding/); + }); + + it('passes an emitted ai binding byte-identical to the root ai binding', () => { + const rootConfig = { ai: { binding: 'AI_EXTRA' } }; + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp', ...rootConfig }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result).toEqual({ ok: true }); + }); + + it('fails closed when the build rewrites a custom root ai binding down to the default', () => { + // The root declared a custom ai binding; the emitted one must equal it EXACTLY. Rewriting it + // to `{binding:'AI'}` (or any other shape) is a build tampering with the ai target — reject + // even when needsAI is true, since the root's own binding is the ground truth. + const rootConfig = { ai: { binding: 'AI', gateway: { id: 'my-own-gateway' } } }; + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + ai: { binding: 'AI' }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + needsAI: true, + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/ai binding/); + }); + + it('fails closed when the build injects an ai binding that is neither Void’s own nor the root’s', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + ai: { binding: 'AI', gateway: { id: 'exfil-gateway' } }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/ai binding/); + }); + + it('passes an absent ai binding', () => { + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result).toEqual({ ok: true }); + }); + + it('fails closed when the build REMOVES a root-declared ai binding (emitted ai absent)', () => { + // The source declared its own `ai` binding pre-build; the emitted config drops it entirely. The + // check reads the root ai FIRST, so a removed root ai — not just a rewrite — is rejected. Without + // this, the deploy would ship a worker missing the configured AI binding. + const rootConfig = { ai: { binding: 'CUSTOM_AI' } }; + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/ai binding/); + }); + + // --- Destructive Durable Object migrations (a build-injected delete/rename/transfer) --- + + it('fails closed when the build injects a migration with deleted_classes absent from root', () => { + // A build appends a `{tag, deleted_classes:["ProdDO"]}` migration; `wrangler deploy` would run it + // and DELETE the production Durable Object's data. The root declared no such migration. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + migrations: [ + { tag: 'v1', new_sqlite_classes: ['Sandbox'] }, + { tag: 'evil', deleted_classes: ['ProdDO'] }, + ], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/destructive/); + expect(result.reason).toMatch(/evil/); + }); + + it('fails closed for each destructive migration key the build could inject', () => { + for (const key of [ + 'deleted_classes', + 'renamed_classes', + 'transferred_classes', + 'transferred_classes_from_script', + ] as const) { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + migrations: [{ tag: 'x', [key]: ['ProdDO'] }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/destructive/); + } + }); + + it("passes Void's own creation-only migrations (new_sqlite_classes) — never inspected", () => { + // Void emits migrations carrying only creation keys; those are never checked against root, so an + // app whose root declared no `migrations` still deploys cleanly. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + migrations: [{ tag: 'void-sandbox-v1', new_sqlite_classes: ['Sandbox'] }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(result).toEqual({ ok: true }); + }); + + it('passes a root-declared destructive migration emitted byte-identical (a legit rename)', () => { + // A user's own checked-in destructive migration is pinned from the pre-build root and survives + // the build byte-for-byte, so it passes — only an entry ABSENT from root is rejected. + const rootConfig = { + migrations: [ + { tag: 'v1', new_sqlite_classes: ['Room'] }, + { tag: 'v2', renamed_classes: [{ from: 'Room', to: 'ChatRoom' }] }, + ], + }; + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp', ...rootConfig }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + }); + expect(result).toEqual({ ok: true }); + }); + + // --- FALSE-POSITIVE LOCK: the full kitchen-sink shape passes WITH the raw-field pin active --- + + it('passes the full kitchen-sink shape with the whole-object + DO + ai pins active', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + ...KITCHEN_SINK_NON_ID_BINDINGS, + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { d1: { binding: 'DB', name: 'myapp-db' }, kvNamespaces: [], queues: ['jobs'] }, + bindingIdentitySnapshot: snapshotBindingIdentities({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }), + passthroughIdentitySnapshot: snapshotPassthroughIdentities(KITCHEN_SINK_NON_ID_BINDINGS), + passthroughRawFields: snapshotPassthroughRawFields(KITCHEN_SINK_NON_ID_BINDINGS), + }); + expect(result).toEqual({ ok: true }); + }); + + // --- Container applications: a bare `wrangler deploy` PROVISIONS a container application from the + // emitted `containers` array (billable, runs the image), so an injected / dropped / retargeted + // container must fail closed. The expected set is reconstructed pre-build and passed in. --- + + const SANDBOX_CONTAINER = { + name: 'void-sandbox', + class_name: 'Sandbox', + image: '/abs/node_modules/@cloudflare/sandbox/Dockerfile', + }; + + it('fails closed when the build RETARGETS the sandbox container image', () => { + // The Void `Sandbox` DO + migration are legit, but the emitted container's `image` was rewritten + // to an attacker image — `wrangler deploy` would roll out the attacker image. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + containers: [{ ...SANDBOX_CONTAINER, image: '/attacker/Dockerfile' }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + expectedContainers: [SANDBOX_CONTAINER], + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/Sandbox/); + expect(result.reason).toMatch(/retargeted/); + }); + + it('fails closed when the build INJECTS a container into a no-sandbox app', () => { + // The source needs no sandbox (expected set empty), yet the artifact injects a container + // application alongside an otherwise-accepted local DO + creation-only migration. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + containers: [{ name: 'evil', class_name: 'Evil', image: '/attacker/Dockerfile' }], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + expectedContainers: [], + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/Evil/); + expect(result.reason).toMatch(/injected/); + }); + + it('fails closed when the build DROPS the expected sandbox container', () => { + // The source needs the sandbox (expected set has one container), but the emitted `containers` + // field is absent — read defensively as empty, so the drop is caught. + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + expectedContainers: [SANDBOX_CONTAINER], + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/Sandbox/); + expect(result.reason).toMatch(/dropped/); + }); + + it('passes when the emitted sandbox container matches the reconstruction exactly', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + containers: [SANDBOX_CONTAINER], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + expectedContainers: [SANDBOX_CONTAINER], + }); + expect(result).toEqual({ ok: true }); + }); + + it('passes a no-sandbox app whose emitted containers field is absent (both-empty)', () => { + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + expectedContainers: [], + }); + expect(result).toEqual({ ok: true }); + }); + + it('passes a root-declared container preserved verbatim in the emitted config', () => { + // A user's own container (present in the root wrangler config, spread verbatim by the build) + // survives — including an optional field — while the drift checks stay quiet. + const rootContainer = { + name: 'my-app', + class_name: 'MyApp', + image: './Dockerfile', + instance_type: 'standard', + }; + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + containers: [rootContainer], + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + expectedContainers: [rootContainer], + }); + expect(result).toEqual({ ok: true }); + }); + + // --- Cron Triggers: a bare `wrangler deploy` reading `dist/ssr/wrangler.json` INSTALLS the + // worker's Cron Triggers from the emitted `triggers.crons` array, so an injected / dropped / + // retargeted schedule must fail closed. The expected schedules are reconstructed pre-build from + // the SAME `scanJobsSync` scan and compared as an order-independent, count-aware multiset. --- + + it('fails closed when the build INJECTS a cron trigger schedule the source never declared', () => { + // A no-cron app (expected empty) whose emitted config carries a schedule — attacker code would + // run on a billable schedule the credentialed `wrangler deploy` installs. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + triggers: { crons: ['* * * * *'] }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + expectedCrons: [], + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/\* \* \* \* \*/); + expect(result.reason).toMatch(/injected/); + }); + + it('fails closed when the build DROPS a cron trigger schedule the source declared', () => { + // The source declares a schedule (expected has one), but the emitted `triggers` is absent — the + // worker would silently lose the scheduled behavior the source asked for. + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + expectedCrons: ['0 0 * * *'], + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/0 0 \* \* \*/); + expect(result.reason).toMatch(/dropped/); + }); + + it('fails closed when the build RETARGETS a cron trigger schedule', () => { + // The source declares `0 0 * * *` (daily), but the emitted schedule is `* * * * *` (every + // minute) — caught as a drop of the old string plus an injection of the new one. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + triggers: { crons: ['* * * * *'] }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + expectedCrons: ['0 0 * * *'], + }); + expect(result.ok).toBe(false); + // The unexpected new schedule is reported first (the injection check runs before the drop check). + expect(result.reason).toMatch(/injected/); + }); + + it('passes when the emitted schedules multiset-equal the expected set in a different order', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + triggers: { crons: ['0 12 * * *', '0 0 * * *', '*/5 * * * *'] }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + expectedCrons: ['*/5 * * * *', '0 0 * * *', '0 12 * * *'], + }); + expect(result).toEqual({ ok: true }); + }); + + it('passes a no-cron app whose emitted triggers field is absent (both-empty)', () => { + const root = deployConfigProject({ account_id: 'acc123', name: 'myapp' }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + expectedCrons: [], + }); + expect(result).toEqual({ ok: true }); + }); + + it('passes a no-cron app whose emitted triggers is the empty object the CF plugin materializes', () => { + // A real no-cron build emits `triggers: {}` (the CF plugin materializes the empty object) rather + // than omitting the field — the defensive read treats a `crons`-less object as no schedules, so + // it stays symmetric-empty instead of false-positiving. + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + triggers: {}, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + expectedCrons: [], + }); + expect(result).toEqual({ ok: true }); + }); +}); + +// --------------------------------------------------------------------------- +// Binding-identity pin: the id-bearing binding identities are snapshotted from the +// root config AFTER provision/drift resolves them and BEFORE the (untrusted) build. +// A compromised build dependency can only edit FILES (its credentials are scrubbed), +// so it could rewrite `wrangler.jsonc`'s `d1_databases[DB].database_id` — keeping the +// binding + database_name — to redirect the privileged remote migrate / deploy to a +// DIFFERENT same-account resource. The migrate assert (step 9) and the emitted-config +// check (step 10) both compare against the pre-build snapshot, mirroring the account_id pin. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — id-bearing binding identity pin', () => { + /** The deploy pointer + emitted top-level config the CF plugin leaves behind after a build. */ + function writeEmittedDeployArtifact(root: string, generated: Record): void { + mkdirSync(join(root, '.wrangler', 'deploy'), { recursive: true }); + mkdirSync(join(root, 'dist', 'ssr'), { recursive: true }); + writeFileSync( + join(root, '.wrangler', 'deploy', 'config.json'), + JSON.stringify({ configPath: '../../dist/ssr/wrangler.json' }), + ); + writeFileSync(join(root, 'dist', 'ssr', 'wrangler.json'), JSON.stringify(generated)); + } + + /** Read → mutate → rewrite the root wrangler.jsonc, standing in for a tampering build plugin. */ + function rewriteRootConfig( + root: string, + mutate: (config: Record) => void, + ): void { + const configPath = join(root, 'wrangler.jsonc'); + const config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; + mutate(config); + writeFileSync(configPath, JSON.stringify(config, null, 2)); + } + + /** The emitted top-level config a non-tampered build of PROVISIONED_D1 would leave. */ + const EMITTED_D1 = { + account_id: 'acc123', + name: 'myapp', + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }; + + it('passes a normal deploy where the build leaves the pinned binding ids unchanged', async () => { + // The critical no-regression case: provision-written id present pre-build, unchanged by the + // build → the migrate assert AND the emitted-config check both match the snapshot. + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => writeEmittedDeployArtifact(root, EMITTED_D1), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + // The remote migrate ran (identity pin matched) and the deploy proceeded. + expect(wranglerArgs.some((a) => a[0] === 'd1' && a[2] === 'apply')).toBe(true); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); + }); + + it('fails closed BEFORE the remote migrate when the build rewrites the D1 database_id', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => { + // Keep binding + database_name + migrations_dir; swap ONLY the id — the token still + // resolves, so wrangler would migrate a DIFFERENT same-account database. + rewriteRootConfig(root, (c) => { + (c.d1_databases as Array>)[0].database_id = OTHER_D1_ID; + }); + writeEmittedDeployArtifact(root, { + ...EMITTED_D1, + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: OTHER_D1_ID }], + }); + }, + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The migrate seam was NOT invoked — no `d1 migrations apply`, no `d1 migrations list` — and + // nothing deployed. (Reverting the pin lets `d1 migrations apply` run against the wrong DB.) + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('fails closed at the emitted-config check when the build rewrites the D1 id in BOTH the root config and the artifact', async () => { + // No checked-in migrations → the remote-migrate assert is skipped, so the tamper must be + // caught at step 10. A CONSISTENT rewrite (root + emitted both carry the new id) would defeat + // a post-build root re-read; the pre-build snapshot still catches it. (Reverting the pin — + // which compared the artifact against a post-build root re-read — lets this deploy.) + const root = d1Project(PROVISIONED_D1); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => { + rewriteRootConfig(root, (c) => { + (c.d1_databases as Array>)[0].database_id = OTHER_D1_ID; + }); + writeEmittedDeployArtifact(root, { + ...EMITTED_D1, + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: OTHER_D1_ID }], + }); + }, + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('fails closed at the emitted-config check when the build consistently rewrites a KV namespace id', async () => { + // A non-D1 id-bearing binding (KV) rewritten in BOTH root + artifact — caught at step 10 + // against the snapshot, exactly as D1 is. No migrations → the D1 migrate seam is skipped. + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.KV.get('k') ?? c.env.DB.prepare('SELECT 1').first();`, + 'wrangler.jsonc': JSON.stringify( + { + account_id: 'acc123', + name: 'myapp', + d1_databases: [ + { + binding: 'DB', + database_name: 'myapp-db', + database_id: REAL_D1_ID, + migrations_dir: './db/migrations', + }, + ], + kv_namespaces: [{ binding: 'KV', id: REAL_KV_ID }], + }, + null, + 2, + ), + }); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => { + rewriteRootConfig(root, (c) => { + (c.kv_namespaces as Array>)[0].id = OTHER_KV_ID; + }); + writeEmittedDeployArtifact(root, { + account_id: 'acc123', + name: 'myapp', + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + kv_namespaces: [{ binding: 'KV', id: OTHER_KV_ID }], + }); + }, + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('still rejects a build that swaps the account_id in the emitted config (account pin intact)', async () => { + // The account_id defense must survive the refactor: the real verifier still fails closed on a + // mismatched emitted account_id, before the binding-identity block runs. + const root = d1Project(PROVISIONED_D1); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => writeEmittedDeployArtifact(root, { ...EMITTED_D1, account_id: 'other-acc' }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('passes a normal deploy whose artifact carries the full non-id binding set (ai/queues/DO/…)', async () => { + // No-regression at the full-flow level: the pinned DB id is unchanged AND the emitted artifact + // carries every non-id binding a real Class-A build emits. The completeness check must wave the + // ai/queues/DO/vars/empty-array bindings through and let the deploy proceed. The `queues/jobs.ts` + // source makes the emitted `jobs` queue a Void-derived binding (desired ⊇ emitted), and the + // `routes/ai.ts` source makes the pre-build inference report needsAI=true — the SAME signal that + // authorizes the emitted `{binding:'AI'}` ai binding, exactly as a real AI-using build's does. + const root = d1Project(PROVISIONED_D1, { + ...INIT_MIGRATION, + 'queues/jobs.ts': 'export default {};', + 'routes/ai.ts': + "import { ai } from 'void/ai';\nexport const GET = () => ai.run('@cf/x', {});", + }); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + // This project imports no `void/sandbox`, so a real build emits no container application; + // drop the kitchen-sink fixture's sandbox container (JSON.stringify omits `undefined`) so + // the reconstructed (empty) container set matches the emitted (absent) one. It also has no + // `crons/` dir, so a real build emits no `triggers.crons`; drop the fixture's schedule so the + // reconstructed (empty) cron set matches the emitted (absent) one. + writeEmittedDeployArtifact(root, { + ...KITCHEN_SINK_NON_ID_BINDINGS, + containers: undefined, + triggers: undefined, + ...EMITTED_D1, + }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(wranglerArgs.some((a) => a[0] === 'd1' && a[2] === 'apply')).toBe(true); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); + }); + + it('passes when a user-declared EXTRA id-bearing binding is present pre-build and unchanged', async () => { + // A hand-declared, non-desired EXTRA_KV is in the root config BEFORE the build (so the widened + // snapshot pins it) and the build leaves it untouched — the deploy must NOT be blocked. + const root = d1Project({ + ...PROVISIONED_D1, + kv_namespaces: [{ binding: 'EXTRA_KV', id: REAL_KV_ID }], + }); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + writeEmittedDeployArtifact(root, { + ...EMITTED_D1, + kv_namespaces: [{ binding: 'EXTRA_KV', id: REAL_KV_ID }], + }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); + }); + + it('fails closed at the emitted-config check when the build ADDS an id-bearing binding absent from the snapshot', async () => { + // The root config (and thus the pre-build snapshot) has ONLY DB. The build injects an EXTRA_KV + // into the emitted artifact — a same-account data store the deployed worker would gain access + // to. The per-desired loop only walks {DB}; completeness is what rejects the injected binding. + // (Reverting the completeness check lets EXTRA_KV through and the deploy runs.) + const root = d1Project(PROVISIONED_D1); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + writeEmittedDeployArtifact(root, { + ...EMITTED_D1, + kv_namespaces: [{ binding: 'EXTRA_KV', id: OTHER_KV_ID }], + }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The deploy seam was NOT invoked — the injected binding never reached `wrangler deploy`. + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('fails closed at the emitted-config check when the build rewrites a NON-desired binding id', async () => { + // A hand-declared, non-desired EXTRA_DB is pinned pre-build (widened snapshot). The build + // rewrites its id in BOTH the root config and the emitted artifact — a consistent rewrite a + // post-build root re-read could not catch, and one the per-desired {DB} loop never inspects. + // Only the pre-build snapshot + completeness catch it. (Reverting the check lets the deploy run.) + const root = d1Project({ + ...PROVISIONED_D1, + d1_databases: [ + ...PROVISIONED_D1.d1_databases, + { binding: 'EXTRA_DB', database_name: 'extra', database_id: REAL_KV_ID }, + ], + }); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => { + rewriteRootConfig(root, (c) => { + (c.d1_databases as Array>)[1].database_id = OTHER_D1_ID; + }); + writeEmittedDeployArtifact(root, { + ...EMITTED_D1, + d1_databases: [ + { binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }, + { binding: 'EXTRA_DB', database_name: 'extra', database_id: OTHER_D1_ID }, + ], + }); + }, + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('fails closed at the emitted-config check when the build injects a services binding', async () => { + // The root config (and pre-build passthrough snapshot) has NO services. The build injects a + // services binding to another same-account worker into the emitted artifact — a capability the + // deployed worker would gain that the source never asked for. No checked-in migrations, so the + // catch happens at step 10. (Reverting assertion A lets EVIL through and the deploy runs.) + const root = d1Project(PROVISIONED_D1); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + writeEmittedDeployArtifact(root, { + ...EMITTED_D1, + services: [{ binding: 'EVIL', service: 'someone-elses-worker' }], + }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The deploy seam was NOT invoked — the injected binding never reached `wrangler deploy`. + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('fails closed at the emitted-config check when the build injects a queue not in the desired set', async () => { + // No queues/ source → desired.queues is empty. The build injects a queue producer into the + // emitted artifact. (Reverting assertion B lets the injected queue deploy.) + const root = d1Project(PROVISIONED_D1); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + writeEmittedDeployArtifact(root, { + ...EMITTED_D1, + queues: { producers: [{ binding: 'Q', queue: 'exfil' }], consumers: [] }, + }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('passes when a user hand-declared passthrough binding (services) is present pre-build and unchanged', async () => { + // A hand-declared services binding is in the root config BEFORE the build (so the passthrough + // snapshot pins it) and the build leaves it untouched — the deploy must NOT be blocked. + const root = d1Project({ + ...PROVISIONED_D1, + services: [{ binding: 'MY_SVC', service: 'my-other-worker' }], + }); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + writeEmittedDeployArtifact(root, { + ...EMITTED_D1, + services: [{ binding: 'MY_SVC', service: 'my-other-worker' }], + }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); + }); + + it('fails closed at the emitted-config check when the build injects an unsafe object', async () => { + // The root has no `unsafe`, so the whole-object pin (populated end-to-end from the pre-build + // root config) rejects an injected `unsafe.metadata` before the credentialed deploy binds it. + const root = d1Project(PROVISIONED_D1); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + writeEmittedDeployArtifact(root, { + ...EMITTED_D1, + unsafe: { metadata: { bindings: [{ name: 'X', type: 'service', service: 'evil' }] } }, + }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('fails closed at the emitted-config check when the build injects a tail_consumers entry', async () => { + const root = d1Project(PROVISIONED_D1); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + writeEmittedDeployArtifact(root, { + ...EMITTED_D1, + tail_consumers: [{ service: 'evil-log-collector' }], + }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('fails closed at the emitted-config check when the build injects an external durable_object', async () => { + // Void's own DOs are always LOCAL; an emitted DO carrying `script_name` binds another worker's + // Durable Objects. The end-to-end raw-field pin rejects it before the deploy. + const root = d1Project(PROVISIONED_D1); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + writeEmittedDeployArtifact(root, { + ...EMITTED_D1, + durable_objects: { + bindings: [{ name: 'EXT', class_name: 'Other', script_name: 'someone-elses-worker' }], + }, + }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('passes a normal deploy whose artifact carries Void’s own ai + local DO bindings', async () => { + // No-regression: the emitted `ai:{binding:'AI'}` and LOCAL DOs a real build emits must pass the + // raw-field pins end-to-end. The `queues/jobs.ts` source makes `jobs` a Void-derived queue, and + // the `routes/ai.ts` source makes the pre-build inference report needsAI=true — the SAME signal + // that authorizes the emitted `{binding:'AI'}` ai binding. + const root = d1Project(PROVISIONED_D1, { + ...INIT_MIGRATION, + 'queues/jobs.ts': 'export default {};', + 'routes/ai.ts': + "import { ai } from 'void/ai';\nexport const GET = () => ai.run('@cf/x', {});", + }); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + // This project imports no `void/sandbox`, so a real build emits no container application; + // drop the kitchen-sink fixture's sandbox container (JSON.stringify omits `undefined`) so + // the reconstructed (empty) container set matches the emitted (absent) one. It also has no + // `crons/` dir, so a real build emits no `triggers.crons`; drop the fixture's schedule so the + // reconstructed (empty) cron set matches the emitted (absent) one. + writeEmittedDeployArtifact(root, { + ...KITCHEN_SINK_NON_ID_BINDINGS, + containers: undefined, + triggers: undefined, + ...EMITTED_D1, + }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); + }); + + it('fails closed when the build modifies the wrangler uploader (before any credentialed run)', async () => { + // The build overwrote the wrangler bundle the post-build remote migrate / deploy execute, so + // the post-build hash differs from the pre-build snapshot. The deploy refuses before running + // the uploader with the Cloudflare token — no `wrangler deploy` is reached. + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + let built = false; + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => { + built = true; + writeEmittedDeployArtifact(root, EMITTED_D1); + }, + verifyDeployConfig: verifyGeneratedDeployConfig, + hashWranglerUploader: () => (built ? 'after-build-tampered' : 'before-build-clean'), + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('fails closed when the wrangler uploader cannot be hashed (unverifiable binary)', async () => { + // The uploader could not be resolved/read (the hash seam returns undefined) — refuse to deploy + // a binary that cannot be verified rather than skipping the check. + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => writeEmittedDeployArtifact(root, EMITTED_D1), + verifyDeployConfig: verifyGeneratedDeployConfig, + hashWranglerUploader: () => undefined, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('fails closed BEFORE the remote D1 migrate when a post-build step mutates the uploader', async () => { + // The early post-build check passes (the build left the uploader clean), then a POST-build + // project step — modelled by the migration gate, which runs after the secret gate and before the + // remote migrate — overwrites the bundle. The re-check immediately before `d1 migrations apply` + // catches it, so no credentialed D1 run and no deploy happen. (A single early check would miss + // this interleaving.) + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + let uploaderTampered = false; + const { deps, wranglerArgs, exitCalls } = harness({ + hashWranglerUploader: () => (uploaderTampered ? 'tampered' : 'clean'), + assertMigrationsDeployable: () => { + // Stands in for post-build project code (drizzle-kit loading db/schema.ts) overwriting the + // hashed wrangler bundle after the build and after the secret-list check. + uploaderTampered = true; + }, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Neither the credentialed remote migrate nor the deploy ran. + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('fails closed BEFORE the deploy when a post-build step mutates the uploader (no-migration app)', async () => { + // No checked-in migrations → the remote-migrate path is skipped, so the deploy is the first + // credentialed run after the build. A post-build project step mutates the uploader; the re-check + // immediately before `wrangler deploy` fails closed. + const root = d1Project(PROVISIONED_D1); + let uploaderTampered = false; + const { deps, wranglerArgs, exitCalls } = harness({ + hashWranglerUploader: () => (uploaderTampered ? 'tampered' : 'clean'), + assertMigrationsDeployable: () => { + uploaderTampered = true; + }, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('proceeds through every credentialed spawn when the uploader stays unchanged (no regression)', async () => { + // The uploader hash is stable across the build and every post-build step, so all four per-spawn + // re-checks (secret list, D1 apply, D1 list, deploy) pass and the deploy runs to completion. + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, wranglerArgs, exitCalls } = harness({ + // Default constant-hash stub keeps the uploader unchanged across all spawns. + runBuild: () => writeEmittedDeployArtifact(root, EMITTED_D1), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(wranglerArgs.some((a) => a[0] === 'd1' && a[2] === 'apply')).toBe(true); + expect(wranglerArgs.some((a) => a[0] === 'd1' && a[2] === 'list')).toBe(true); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); + }); + + it('fails closed BEFORE whoami when the pre-build env-schema probe mutates the uploader', async () => { + // The uploader baseline is captured at the START of the deploy — before the env-schema probe, + // which runs project code (the user's Vite config + env.ts). A probe that overwrites the hashed + // wrangler bundle is caught by the re-check immediately before `whoami`, the FIRST credentialed + // run — so no credentialed wrangler call ever executes the tampered bundle. Proves the baseline + // is snapshotted before the first project-code execution. + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + let probed = false; + const { deps, trace, wranglerArgs, exitCalls } = harness({ + // The env-schema probe stands in for pre-build project code overwriting the bundle. + resolveSchemaFacts: (() => { + probed = true; + return okSchemaFacts(); + }) as CloudflareDeployDeps['resolveSchemaFacts'], + hashWranglerUploader: () => (probed ? 'tampered-by-probe' : 'clean-at-start'), + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // whoami never ran — the deploy refused before the first credentialed call. + expect(trace).not.toContain('wrangler:whoami'); + expect(wranglerArgs.some((a) => a[0] === 'whoami')).toBe(false); + }); + + it('fails closed BEFORE the provisioner when the uploader changes after whoami (--provision)', async () => { + // Defense-in-depth for the pre-build sequence: the baseline holds through `whoami`, then the + // uploader changes before the credentialed provisioner runs. The re-check immediately before the + // provision call fails closed against the same baseline, so `wrangler create` never + // executes the tampered bundle. + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + let tampered = false; + const { deps, trace, exitCalls } = harness({ + hashWranglerUploader: () => (tampered ? 'tampered' : 'clean'), + // whoami is the last step before provision; flip the bundle as it returns so the assert + // immediately before the provision call catches it. + runWrangler: (args) => { + trace.push(`wrangler:${args[0]}`); + if (args[0] === 'whoami') { + tampered = true; + return { ...cleanExit, stdout: '{"loggedIn":true}' }; + } + return cleanExit; + }, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare', provision: true }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Reached whoami, but refused before provisioning ran. + expect(trace).toContain('wrangler:whoami'); + expect(trace).not.toContain('provision'); + }); +}); + +// --------------------------------------------------------------------------- +// Config laundering by a probe that runs project code around the build: +// • the PRE-BUILD env-schema probe (step 3c) rewriting the root bindings, which +// would otherwise be captured as the step-6 "trusted" baseline (no-provision only); +// • the POST-build validate-emitted probe (step 8b) rewriting the root worker `name`, +// which the secrets gate resolves the worker from. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — config laundering by a build-adjacent probe', () => { + /** Read → mutate → rewrite the root wrangler.jsonc, standing in for a tampering probe. */ + function rewriteRootConfig( + root: string, + mutate: (config: Record) => void, + ): void { + const configPath = join(root, 'wrangler.jsonc'); + const config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; + mutate(config); + writeFileSync(configPath, JSON.stringify(config, null, 2)); + } + + // --- FIX 2: a PRE-BUILD probe that laundered the root bindings (no-provision only) --- + + it('no-provision: fails closed at step 6 when the pre-build probe SWAPS a binding id', async () => { + // The env-schema probe (step 3c) runs BEFORE the step-6 binding snapshot. It rewrites the + // provisioned DB id in the root config; without the pre-probe comparison the laundered id would + // be captured as the trusted baseline. Fails closed BEFORE the build (step 7) even runs. + const root = d1Project(PROVISIONED_D1); + const { deps, trace, wranglerArgs, exitCalls } = harness({ + resolveSchemaFacts: (() => { + rewriteRootConfig(root, (c) => { + (c.d1_databases as Array>)[0].database_id = OTHER_D1_ID; + }); + return okSchemaFacts(); + }) as CloudflareDeployDeps['resolveSchemaFacts'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Caught at step 6 — before the build ran and before anything deployed. + expect(trace).not.toContain('build'); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('no-provision: fails closed at step 6 when the pre-build probe ADDS an id-bearing binding', async () => { + // The probe injects an EXTRA_KV the source never declared — a same-account data store the + // deployed worker would gain. The desired loop only walks {DB}; the pre-probe snapshot is what + // rejects the injected binding. + const root = d1Project(PROVISIONED_D1); + const { deps, trace, wranglerArgs, exitCalls } = harness({ + resolveSchemaFacts: (() => { + rewriteRootConfig(root, (c) => { + c.kv_namespaces = [{ binding: 'EXTRA_KV', id: OTHER_KV_ID }]; + }); + return okSchemaFacts(); + }) as CloudflareDeployDeps['resolveSchemaFacts'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).not.toContain('build'); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('no-provision: fails closed at step 6 when the pre-build probe INJECTS a services binding', async () => { + // A pass-through binding Void never emits — the probe grants the deployed worker a service + // binding to another same-account worker. Caught by the pass-through snapshot comparison. + const root = d1Project(PROVISIONED_D1); + const { deps, trace, wranglerArgs, exitCalls } = harness({ + resolveSchemaFacts: (() => { + rewriteRootConfig(root, (c) => { + c.services = [{ binding: 'EVIL', service: 'someone-elses-worker' }]; + }); + return okSchemaFacts(); + }) as CloudflareDeployDeps['resolveSchemaFacts'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).not.toContain('build'); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('no-provision: passes when the pre-build probe leaves the root config untouched', async () => { + // The critical no-regression case: an inert probe means the pre-probe and step-6 snapshots are + // identical, so the new assertion never fires and the deploy proceeds normally. + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, wranglerArgs, exitCalls } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); + }); + + it('--provision: the pre-build snapshot assertion is SKIPPED (guard scoped to no-provision)', async () => { + // Under --provision the provisioner legitimately writes real ids into the config AFTER the + // probe, so pre-probe ≠ step-6 by design and the new assertion must NOT run (that path is the + // residual tracked in #283). Prove it by making the probe swap the DB id — the SAME mutation + // the no-provision case rejects — and confirming a --provision run proceeds to deploy instead. + const root = d1Project(PROVISIONED_D1); + const { deps, wranglerArgs, exitCalls } = harness({ + resolveSchemaFacts: (() => { + rewriteRootConfig(root, (c) => { + (c.d1_databases as Array>)[0].database_id = OTHER_D1_ID; + }); + return okSchemaFacts(); + }) as CloudflareDeployDeps['resolveSchemaFacts'], + }); + await runCloudflareDeploy(root, { backend: 'cloudflare', provision: true }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); + }); + + // --- FIX 1: a POST-build probe that rewrote the pinned worker NAME --- + + // Schema facts declaring one required secret that is ABSENT from the (empty) emitted vars, so the + // real gate reaches the worker-name reconcile + the (stubbed) remote secret list. + function requiredSecretFacts(): EnvSchemaProbeResult { + return okSchemaFacts({ hasSchema: true, requiredKeys: ['STRIPE_KEY'], secretKeys: [] }); + } + + // The real gate, but with the remote `wrangler secret list` stubbed to report the secret EXISTS. + // If the name reconcile is ever removed, this stub would discharge the missing secret against the + // WRONG worker and the deploy would proceed — so these tests fail loudly on a regression. + const dischargeWithSecretPresent = (( + ctx: SelfHostEnvContext, + opts: Parameters[1], + ) => + dischargeSelfHostEnvContext(ctx, { + ...opts, + runWrangler: () => ({ + stdout: JSON.stringify([{ name: 'STRIPE_KEY', type: 'secret_text' }]), + stderr: '', + status: 0, + }), + })) as CloudflareDeployDeps['dischargeSelfHostEnv']; + + it('fails closed BEFORE deploy when the post-build probe rewrites the pinned worker name', async () => { + // The deploy pins name "myapp" at step 5. The emitted-var probe (step 8b, project code) rewrites + // the root config `name` to "evil-worker". The secrets gate resolves the worker from that name, + // so it would list secrets for the WRONG worker; the reconcile fails it closed before any deploy. + const root = d1Project(PROVISIONED_D1); + const { deps, trace, wranglerArgs, exitCalls } = harness({ + resolveSchemaFacts: (() => + requiredSecretFacts()) as CloudflareDeployDeps['resolveSchemaFacts'], + validateEmittedVars: (async () => { + rewriteRootConfig(root, (c) => { + c.name = 'evil-worker'; + }); + return { kind: 'invalid-keys', invalidKeys: [] }; + }) as CloudflareDeployDeps['validateEmittedVars'], + dischargeSelfHostEnv: dischargeWithSecretPresent, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The build ran (the rewrite is post-build) but nothing deployed. + expect(trace).toContain('build'); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('passes when the post-build probe leaves the pinned worker name intact', async () => { + // No-regression: the emitted-var probe does not touch the name, so the config name still equals + // the pinned "myapp"; the gate discharges the (stubbed-present) remote secret and the deploy runs. + const root = d1Project(PROVISIONED_D1); + const { deps, wranglerArgs, exitCalls } = harness({ + resolveSchemaFacts: (() => + requiredSecretFacts()) as CloudflareDeployDeps['resolveSchemaFacts'], + dischargeSelfHostEnv: dischargeWithSecretPresent, + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Managed deploy must NOT eagerly load the provision lifecycle (proper-lockfile +// → signal-exit patches process.emit / reallyExit ON IMPORT). deploy.ts is +// imported by cli.ts for every deploy, so its module-scope import of +// ./provision.ts must be TYPE-ONLY; the runtime values load via a dynamic +// import() inside the Cloudflare fork only. Assert that at the source level so a +// regression re-introducing a static runtime import fails fast (a real +// module-registry check would be polluted by this test file's own provision +// import, and a subprocess check would be slow/flaky). +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — managed path does not import the provision module', () => { + const deploySource = readFileSync(join(import.meta.dirname, '../../src/cli/deploy.ts'), 'utf-8'); + + it('imports ./provision.ts as TYPE-ONLY at module scope (dynamic import inside the fork)', () => { + // Every static `... from './provision.ts'` at module scope must be `import type`. + // `[^']*?` keeps each clause from spanning across a previous `from '...'`. + const staticImports = [ + ...deploySource.matchAll(/^import\s+([^']*?)\s+from\s+'\.\/provision\.ts';/gm), + ]; + expect(staticImports.length).toBeGreaterThan(0); + for (const [, clause] of staticImports) { + expect(clause.startsWith('type')).toBe(true); + } + }); + + it('loads the provision module via a dynamic import()', () => { + expect(deploySource).toMatch(/await import\('\.\/provision\.ts'\)/); + }); + + it('loads the env-schema probe via a dynamic import() (never a static module-scope import)', () => { + // deploy.ts must only `import type` from the probe at module scope; the runtime helper + // loads via dynamic import() inside the fork, so a managed deploy never pulls the probe in. + const staticImports = [ + ...deploySource.matchAll(/^import\s+([^']*?)\s+from\s+'\.\/env-schema-probe\.ts';/gm), + ]; + for (const [, clause] of staticImports) { + expect(clause.startsWith('type')).toBe(true); + } + expect(deploySource).toMatch(/await import\('\.\/env-schema-probe\.ts'\)/); + }); + + it('the env-schema probe imports env-validation only dynamically and no backend statically', () => { + // The probe is imported (dynamically) during a self-host deploy, but a plain LIBRARY import of + // it (the parent-side helper) must not eagerly evaluate env-validation (which pulls Vite / the + // env stack), and it must never statically pull Vite / provision / Better Auth / proper-lockfile + // into any bundle. + const probeSource = readFileSync( + join(import.meta.dirname, '../../src/cli/env-schema-probe.ts'), + 'utf-8', + ); + // env-validation is loaded dynamically only — its static import is TYPE-only. + const staticEnvValidation = new RegExp( + "^import\\s+(?!type\\b)[^;]*?\\s+from\\s+'\\./env-validation\\.ts';", + 'gm', + ); + expect(probeSource).not.toMatch(staticEnvValidation); + expect(probeSource).toContain("await import('./env-validation.ts')"); + // No static backend imports at all (Vite / provision / Better Auth / proper-lockfile). + expect(probeSource).not.toMatch(/from\s+'vite'/); + expect(probeSource).not.toMatch(/from\s+'\.\/provision\.ts'/); + expect(probeSource).not.toMatch(/better-auth/); + expect(probeSource).not.toMatch(/proper-lockfile/); + }); +}); + +// --------------------------------------------------------------------------- +// Framework-class guard: only Class-A framework SSR coheres with +// this backend (full Void plugin stack + CF Vite plugin). Class B/C get a +// lighter Void composition that emits no worker artifact here — reject early. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — framework guard (all framework SSR deferred in v1)', () => { + it('rejects a Class B/C framework SSR app before any account operation', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ + name: 'skapp', + dependencies: { '@sveltejs/kit': '2.0.0' }, + }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'skapp' }, null, 2), + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Rejected before preflight — no whoami, no build, no deploy. + expect(trace).toEqual([]); + }); + + it('rejects a Class-A framework SSR app before any account operation (narrowed in v1)', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ + name: 'tsapp', + dependencies: { '@tanstack/react-start': '1.0.0' }, + }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'tsapp' }, null, 2), + }); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The whole framework path is deferred — rejected before any account op. + expect(trace).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Stale deploy pointer: the pointer is deleted before building, so a +// build that emits no fresh one (auto-static / Class B/C) can never upload old +// code — the verification fails closed on the missing pointer. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — stale deploy pointer', () => { + it('fails closed (no deploy) when the build emits no fresh pointer over a stale one', async () => { + const root = d1Project(PROVISIONED_D1); + // Plant a STALE pointer + target that WOULD pass the account/name identity check. + mkdirSync(join(root, '.wrangler', 'deploy'), { recursive: true }); + mkdirSync(join(root, 'dist', 'ssr'), { recursive: true }); + writeFileSync( + join(root, 'dist', 'ssr', 'wrangler.json'), + JSON.stringify({ account_id: 'acc123', name: 'myapp' }), + ); + writeFileSync( + join(root, '.wrangler', 'deploy', 'config.json'), + JSON.stringify({ configPath: '../../dist/ssr/wrangler.json' }), + ); + // A build that does NOT re-create the pointer (a non-CF/static build class). + const { deps, trace, exitCalls } = harness({ + runBuild: () => { + trace.push('build'); + }, + // Use the REAL verifier so the deleted-then-not-recreated pointer fails closed. + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).not.toContain('wrangler:deploy'); + }); +}); + +// --------------------------------------------------------------------------- +// Env-only account pin: the CF plugin copies account_id only from the +// root config, so the reconciled account is persisted there before the build — +// making the documented CLOUDFLARE_ACCOUNT_ID pin actually reach deploy. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — env-only account pin', () => { + it('persists CLOUDFLARE_ACCOUNT_ID into the root config and reaches deploy without --provision', async () => { + const saved = process.env.CLOUDFLARE_ACCOUNT_ID; + process.env.CLOUDFLARE_ACCOUNT_ID = 'env-acc-999'; + try { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + // Root config has NO account_id — only the env var pins the account. + 'wrangler.jsonc': JSON.stringify( + { + name: 'myapp', + d1_databases: [ + { + binding: 'DB', + database_name: 'myapp-db', + database_id: REAL_D1_ID, + migrations_dir: './db/migrations', + }, + ], + }, + null, + 2, + ), + }); + const { deps, trace } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + // Reaches deploy (does not false-stop on a missing generated account_id)... + expect(trace).toContain('wrangler:deploy'); + // ...because the env pin was persisted into the root config the build reads. + const written = JSON.parse(readFileSync(join(root, 'wrangler.jsonc'), 'utf-8')) as { + account_id?: string; + }; + expect(written.account_id).toBe('env-acc-999'); + } finally { + if (saved === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = saved; + } + } + }); +}); + +// --------------------------------------------------------------------------- +// Migrations dir/intent + auth tables +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — migrations dir / count / auth', () => { + it('carries the sourceDir-aware config-relative migrations_dir into the desired set', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({ sourceDir: 'src' }), + 'src/routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + 'wrangler.jsonc': JSON.stringify({ name: 'myapp' }, null, 2), + }); + const { desired } = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(desired.d1?.migrationsDir).toBe('./src/db/migrations'); + }); + + it('runs the drift/journal gate but skips the wrangler apply when a D1 app has no migrations', async () => { + const root = d1Project(PROVISIONED_D1); // migrations_dir set, but NO .sql files + const { deps, wranglerArgs, trace } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + // B2: the managed drift/journal gate runs for EVERY D1 app (a schema.ts with no + // generated migration is drift), but wrangler 4.107 hard-fails on an empty/absent + // folder — so the apply/list is skipped. + expect(trace).toContain('migrate-gate'); + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(trace).toContain('wrangler:deploy'); + }); + + it('fails closed for an auth app whose auth tables are not in checked-in migrations', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'authapp' }), + 'void.json': JSON.stringify({ auth: {} }), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'wrangler.jsonc': JSON.stringify( + { + account_id: 'acc123', + name: 'authapp', + d1_databases: [ + { + binding: 'DB', + database_name: 'authapp-db', + database_id: REAL_D1_ID, + migrations_dir: './db/migrations', + }, + ], + }, + null, + 2, + ), + }); + // Simulate the build's generated auth schema (ground truth) requiring a `user` table, with + // NO checked-in migration to create it → the REAL post-build auth check fails closed + // (applying the migrations to in-memory SQLite leaves `user` absent). + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['email'] }]); + const { deps, trace, wranglerArgs, exitCalls } = harness({ + validateAuthMigrations: validateAuthMigrationSchema, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The auth check runs POST-build (against ground truth); it blocks the remote migrate + deploy. + expect(trace).toContain('build'); + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(trace).not.toContain('wrangler:deploy'); + }); + + it('lets an auth app proceed when its migrations produce the required auth schema', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'authapp' }), + 'void.json': JSON.stringify({ auth: {} }), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'db/migrations/0000_auth.sql': `CREATE TABLE "user" (id text primary key);`, + 'wrangler.jsonc': JSON.stringify( + { + account_id: 'acc123', + name: 'authapp', + d1_databases: [ + { + binding: 'DB', + database_name: 'authapp-db', + database_id: REAL_D1_ID, + migrations_dir: './db/migrations', + }, + ], + }, + null, + 2, + ), + }); + // The schema validator is stubbed (harness default) — dedicated tests exercise the real + // schema check; here we assert the guard lets a satisfied auth app proceed to deploy. + const { deps, trace } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(trace).toContain('wrangler:deploy'); + }); +}); + +// --------------------------------------------------------------------------- +// B5: auth schema derived from resolveVoidAuthMigrationSchema, validated by +// applying migrations to an in-memory SQLite DB (not a CREATE TABLE token search) +// --------------------------------------------------------------------------- + +describe('assertMigrationsProduceAuthSchema', () => { + /** A required column with no constraint attached — presence is all that is asserted. */ + const col = (column: string): { column: string; unique: boolean } => ({ column, unique: false }); + + const required = [ + { table: 'user', columns: [col('id'), col('email'), col('name')] }, + { table: 'session', columns: [col('id'), col('userId')] }, + ]; + + it('passes when every required table + column is produced by the migrations', async () => { + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: 'CREATE TABLE user (id text primary key, email text, name text);\nCREATE TABLE session (id text primary key, userId text);', + }, + ], + required, + ); + // no throw + }); + + it('rejects when a required table is missing (token search would miss a renamed/commented one)', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [{ name: '0000.sql', sql: 'CREATE TABLE user (id text, email text, name text);' }], + required, + ), + ).rejects.toThrow(/session/); + }); + + it('rejects when a required column is missing (only presence of the table is not enough)', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: 'CREATE TABLE user (id text, email text, name text);\nCREATE TABLE session (id text);', + }, + ], + required, + ), + ).rejects.toThrow(/session.*userId|userId/); + }); + + it('is not fooled by a CREATE TABLE inside a comment or string (real apply, not a regex)', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: `CREATE TABLE user (id text, email text, name text);\n-- CREATE TABLE session (id text, userId text);`, + }, + ], + required, + ), + ).rejects.toThrow(/session/); + }); + + it('rejects when a migration cannot be applied to a fresh SQLite database', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [{ name: '0000.sql', sql: 'THIS IS NOT VALID SQL;' }], + required, + ), + ).rejects.toThrow(/could not be applied/); + }); + + // ------------------------------------------------------------------------- + // Constraints, not just names. Void ships no auth migration scaffolder, so a + // hand-written `db/schema.ts` + `void db generate` can produce every correct + // NAME while omitting Drizzle's opt-in `.unique()` / `.references()`. + // ------------------------------------------------------------------------- + + /** `user.email` unique + `session.userId` → `user.id`, as the real build emits them. */ + const constrained = [ + { + table: 'user', + columns: [col('id'), { column: 'email', unique: true }, col('name')], + }, + { + table: 'session', + columns: [ + col('id'), + { column: 'userId', unique: false, references: { table: 'user', column: 'id' } }, + ], + }, + ]; + + const USER_WITH_UNIQUE_EMAIL = + 'CREATE TABLE "user" (id text primary key, email text UNIQUE, name text);'; + const SESSION_WITH_FK = + 'CREATE TABLE "session" (id text primary key, userId text REFERENCES "user"(id) ON DELETE CASCADE);'; + + it('rejects when the migrations create the right names but no UNIQUE constraint on user.email', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text primary key, email text, name text);\n' + + SESSION_WITH_FK, + }, + ], + constrained, + ), + ).rejects.toThrow(/UNIQUE.*email|email/); + }); + + it('names the offending table and column when the UNIQUE constraint is missing', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text primary key, email text, name text);\n' + + SESSION_WITH_FK, + }, + ], + constrained, + ), + ).rejects.toThrow(/table "user" is missing a UNIQUE constraint on column\(s\): email/); + }); + + it('accepts a column-level UNIQUE (the spelling Drizzle emits inline)', async () => { + await assertMigrationsProduceAuthSchema( + [{ name: '0000.sql', sql: `${USER_WITH_UNIQUE_EMAIL}\n${SESSION_WITH_FK}` }], + constrained, + ); + }); + + it('accepts a separate CREATE UNIQUE INDEX (the spelling the managed path emits)', async () => { + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text primary key, email text, name text);\n' + + 'CREATE UNIQUE INDEX "user_email_unique_idx" ON "user" (email);\n' + + SESSION_WITH_FK, + }, + ], + constrained, + ); + }); + + it('does not require a particular unique index name', async () => { + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text primary key, email text, name text);\n' + + 'CREATE UNIQUE INDEX "totally_unrelated_name" ON "user" (email);\n' + + SESSION_WITH_FK, + }, + ], + constrained, + ); + }); + + it('does not accept a NON-unique index as satisfying a UNIQUE requirement', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text primary key, email text, name text);\n' + + 'CREATE INDEX "user_email_idx" ON "user" (email);\n' + + SESSION_WITH_FK, + }, + ], + constrained, + ), + ).rejects.toThrow(/UNIQUE constraint on column\(s\): email/); + }); + + it('does not accept a COMPOSITE unique index as making the column unique on its own', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text primary key, email text, name text);\n' + + 'CREATE UNIQUE INDEX "user_email_name_idx" ON "user" (email, name);\n' + + SESSION_WITH_FK, + }, + ], + constrained, + ), + ).rejects.toThrow(/UNIQUE constraint on column\(s\): email/); + }); + + it('rejects when the session foreign key to user is missing', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + `${USER_WITH_UNIQUE_EMAIL}\n` + + 'CREATE TABLE "session" (id text primary key, userId text);', + }, + ], + constrained, + ), + ).rejects.toThrow( + /table "session" column "userId" is missing a foreign key referencing "user"\."id"/, + ); + }); + + it('rejects a foreign key that points at the wrong parent table', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + `${USER_WITH_UNIQUE_EMAIL}\n` + + 'CREATE TABLE "account" (id text primary key);\n' + + 'CREATE TABLE "session" (id text primary key, userId text REFERENCES "account"(id));', + }, + ], + constrained, + ), + ).rejects.toThrow(/table "session" column "userId" is missing a foreign key/); + }); + + it('accepts an implicit REFERENCES to the parent primary key (no column named)', async () => { + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + `${USER_WITH_UNIQUE_EMAIL}\n` + + 'CREATE TABLE "session" (id text primary key, userId text REFERENCES "user");', + }, + ], + constrained, + ); + }); + + it('does not assert the onDelete action (ON DELETE SET NULL still satisfies the reference)', async () => { + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + `${USER_WITH_UNIQUE_EMAIL}\n` + + 'CREATE TABLE "session" (id text primary key, userId text REFERENCES "user"(id) ON DELETE SET NULL);', + }, + ], + constrained, + ); + }); + + it('does not assert column TYPE (SQLite is dynamically typed — a valid schema must not be rejected)', async () => { + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text primary key, email blob UNIQUE, name numeric);\n' + + SESSION_WITH_FK, + }, + ], + constrained, + ); + }); + + it('does not require a non-unique index the emitted schema marks as index: true', async () => { + // `session.userId` is `index: true` in the real artifact, but that is a performance + // property — migrations without the index are still correct. + await assertMigrationsProduceAuthSchema( + [{ name: '0000.sql', sql: `${USER_WITH_UNIQUE_EMAIL}\n${SESSION_WITH_FK}` }], + constrained, + ); + }); + + it('reports every constraint problem at once, naming each table.column', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text primary key, email text, name text);\n' + + 'CREATE TABLE "session" (id text primary key, userId text);', + }, + ], + constrained, + ), + ).rejects.toThrow(/user.*email[\s\S]*session.*userId|session.*userId[\s\S]*user.*email/); + }); + + // ------------------------------------------------------------------------- + // Identifier CASING. SQLite resolves identifiers case-insensitively, but + // `sqlite_master.name` and every PRAGMA echo back the casing the migration + // DECLARED. A migration written as `CREATE TABLE "User" ("Email" TEXT UNIQUE)` + // is fully functional — lowercase SELECTs resolve, the UNIQUE constraint + // enforces — so the gate must not reject it as missing. + // ------------------------------------------------------------------------- + + const CASED_USER = 'CREATE TABLE "User" ("Id" text primary key, "Email" text, "Name" text);'; + const CASED_USER_UNIQUE_EMAIL = + 'CREATE TABLE "User" ("Id" text primary key, "Email" text UNIQUE, "Name" text);'; + const CASED_SESSION_WITH_FK = + 'CREATE TABLE "Session" ("Id" text primary key, "UserId" text REFERENCES "User"("Id"));'; + + it('accepts tables and columns declared in a different case than the emitted schema', async () => { + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: `${CASED_USER}\nCREATE TABLE "Session" ("Id" text primary key, "UserId" text);`, + }, + ], + required, + ); + }); + + it('accepts a column-level UNIQUE declared on a differently-cased column', async () => { + await assertMigrationsProduceAuthSchema( + [{ name: '0000.sql', sql: `${CASED_USER_UNIQUE_EMAIL}\n${CASED_SESSION_WITH_FK}` }], + constrained, + ); + }); + + it('accepts a separate CREATE UNIQUE INDEX over a differently-cased column', async () => { + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + `${CASED_USER}\n` + + 'CREATE UNIQUE INDEX "User_Email_Unique" ON "User" ("Email");\n' + + `${CASED_SESSION_WITH_FK}`, + }, + ], + constrained, + ); + }); + + it('accepts a differently-cased single-column PRIMARY KEY as satisfying UNIQUE', async () => { + // `Email` carries the PRIMARY KEY here — that is what makes it satisfy the `unique` + // requirement, and it is the point of this test. `Id` is then declared UNIQUE so the + // session foreign key still has a valid parent key to point at; without that the + // migration would produce a schema SQLite rejects at runtime, which is a different + // failure and is covered by its own tests below. + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "User" ("Id" text UNIQUE, "Email" text primary key, "Name" text);\n' + + `${CASED_SESSION_WITH_FK}`, + }, + ], + constrained, + ); + }); + + it('still fails closed on a genuinely missing table, naming it as the schema declares it', async () => { + await expect( + assertMigrationsProduceAuthSchema([{ name: '0000.sql', sql: CASED_USER }], required), + ).rejects.toThrow(/table "session" is not created by any checked-in migration/); + }); + + it('still fails closed on a genuinely missing column, naming it as the schema declares it', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: `${CASED_USER}\nCREATE TABLE "Session" ("Id" text primary key);`, + }, + ], + required, + ), + ).rejects.toThrow(/table "session" is missing column\(s\): userId/); + }); + + it('still fails closed on a missing UNIQUE constraint when only the casing differs', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [{ name: '0000.sql', sql: `${CASED_USER}\n${CASED_SESSION_WITH_FK}` }], + constrained, + ), + ).rejects.toThrow(/table "user" is missing a UNIQUE constraint on column\(s\): email/); + }); + + it('still fails closed on a missing foreign key when only the casing differs', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + `${CASED_USER_UNIQUE_EMAIL}\n` + + 'CREATE TABLE "Session" ("Id" text primary key, "UserId" text);', + }, + ], + constrained, + ), + ).rejects.toThrow( + /table "session" column "userId" is missing a foreign key referencing "user"\."id"/, + ); + }); + + // ------------------------------------------------------------------------- + // PARENT KEY VALIDITY, decided by SQLite itself (`PRAGMA foreign_key_check`) + // rather than by re-deriving its rules here. A parent key must be a PRIMARY + // KEY, or a UNIQUE index that MATCHES the reference (collation included); an + // omitted target column resolves only to the parent's PRIMARY KEY. Anything + // else yields a foreign key that `CREATE TABLE` accepts, that + // `PRAGMA foreign_key_list` echoes back as valid, and that fails at PREPARE + // time under `foreign_keys=ON` — i.e. auth broken in production after a + // green deploy. + // ------------------------------------------------------------------------- + + /** The parent key is a UNIQUE index, but declared under a DIFFERENT collation. */ + it('fails closed when the parent id is only unique under a differing COLLATE', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text NOT NULL, email text UNIQUE, name text);\n' + + 'CREATE UNIQUE INDEX "user_id_nocase" ON "user" (id COLLATE NOCASE);\n' + + SESSION_WITH_FK, + }, + ], + constrained, + ), + ).rejects.toThrow(/foreign key mismatch/); + }); + + it('fails closed when the parent id is an ordinary, non-unique column', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: 'CREATE TABLE "user" (id text, email text UNIQUE, name text);\n' + SESSION_WITH_FK, + }, + ], + constrained, + ), + ).rejects.toThrow(/foreign key mismatch/); + }); + + it('fails closed on an implicit REFERENCES to a parent that is UNIQUE but not the PRIMARY KEY', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text NOT NULL UNIQUE, email text UNIQUE, name text);\n' + + 'CREATE TABLE "session" (id text primary key, userId text REFERENCES "user");', + }, + ], + constrained, + ), + ).rejects.toThrow(/foreign key mismatch/); + }); + + it('still fails closed when the child declares no foreign key at all (the pragma alone would pass it)', async () => { + // `PRAGMA foreign_key_check` validates only foreign keys that EXIST, so a child with + // none comes back clean. The presence check has to stand on its own. + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + `${USER_WITH_UNIQUE_EMAIL}\n` + + 'CREATE TABLE "session" (id text primary key, userId text);', + }, + ], + constrained, + ), + ).rejects.toThrow( + /table "session" column "userId" is missing a foreign key referencing "user"\."id"/, + ); + }); + + it('accepts a parent PRIMARY KEY with an explicit reference', async () => { + await assertMigrationsProduceAuthSchema( + [{ name: '0000.sql', sql: `${USER_WITH_UNIQUE_EMAIL}\n${SESSION_WITH_FK}` }], + constrained, + ); + }); + + it('accepts a parent that is UNIQUE (not PRIMARY KEY) with an explicit reference', async () => { + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text NOT NULL UNIQUE, email text UNIQUE, name text);\n' + + SESSION_WITH_FK, + }, + ], + constrained, + ); + }); + + it('accepts an INTEGER PRIMARY KEY rowid alias as the parent key', async () => { + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id integer primary key, email text UNIQUE, name text);\n' + + 'CREATE TABLE "session" (id text primary key, userId integer REFERENCES "user"(id));', + }, + ], + constrained, + ); + }); + + // ------------------------------------------------------------------------- + // An auth reference must be its OWN single-column foreign key. SQLite applies + // MATCH SIMPLE: a COMPOSITE foreign key is skipped ENTIRELY whenever any of + // its child columns is NULL, so a composite constraint that merely happens to + // include the required column still lets an orphaned auth row in. `PRAGMA + // foreign_key_list` returns one row per COLUMN (rows of one constraint share + // an `id`), so a per-row match would accept it through a single member, and + // `PRAGMA foreign_key_check` cannot catch it either — it reports orphaned + // DATA, and the freshly-migrated database holds no rows. + // ------------------------------------------------------------------------- + + const COMPOSITE_PARENT = + 'CREATE TABLE "user" (id text NOT NULL, tenant text, email text UNIQUE, name text, ' + + 'PRIMARY KEY (id, tenant));'; + + it('fails closed when the reference is only covered by a multi-column foreign key', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + `${COMPOSITE_PARENT}\n` + + 'CREATE TABLE "session" (id text primary key, userId text NOT NULL, tenant text, ' + + 'FOREIGN KEY (userId, tenant) REFERENCES "user"(id, tenant));', + }, + ], + constrained, + ), + ).rejects.toThrow( + /table "session" column "userId" references "user"\."id" only as part of a MULTI-COLUMN foreign key/, + ); + }); + + it('explains that a composite foreign key is skipped when a child column is NULL', async () => { + await expect( + assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + `${COMPOSITE_PARENT}\n` + + 'CREATE TABLE "session" (id text primary key, userId text NOT NULL, tenant text, ' + + 'FOREIGN KEY (userId, tenant) REFERENCES "user"(id, tenant));', + }, + ], + constrained, + ), + ).rejects.toThrow( + /skips a composite foreign key entirely when any of its child columns is NULL[\s\S]*needs its own single-column foreign key on "userId"/, + ); + }); + + it('accepts a single-column foreign key on a table that also declares a composite one', async () => { + // The composite constraint is unrelated to the auth reference; the single-column foreign + // key on `userId` is what satisfies it, and grouping by constraint id must not lose it. + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + `${USER_WITH_UNIQUE_EMAIL}\n` + + 'CREATE TABLE "tenant" (org text NOT NULL, region text NOT NULL, PRIMARY KEY (org, region));\n' + + 'CREATE TABLE "session" (id text primary key, userId text REFERENCES "user"(id), ' + + 'org text, region text, FOREIGN KEY (org, region) REFERENCES "tenant"(org, region));', + }, + ], + constrained, + ); + }); + + it('does not fail on an unrelated application table with its own broken foreign key', async () => { + // The gate is scoped to the auth tables; a user's own schema problem elsewhere is not + // this check's business and must not block a deploy on the auth path. + await assertMigrationsProduceAuthSchema( + [ + { + name: '0000.sql', + sql: + `${USER_WITH_UNIQUE_EMAIL}\n${SESSION_WITH_FK}\n` + + 'CREATE TABLE "widget" (id text primary key, tag text);\n' + + 'CREATE TABLE "widget_tag" (id text primary key, tag text REFERENCES "widget"(tag));', + }, + ], + constrained, + ); + }); +}); + +// --------------------------------------------------------------------------- +// The post-build auth-schema check reads the schema the BUILD generated +// (`.void/better-auth-schema.ts`, ground truth) and asserts the checked-in +// migrations produce it — applied to in-memory SQLite, not a token search. +// --------------------------------------------------------------------------- + +describe("validateAuthMigrationSchema — reads the build's generated auth schema", () => { + // The real build emits every auth column NOT NULL except a handful of optional ones (see + // `playground/kitchen-sink/.void/better-auth-schema.ts`), and always + // `.primaryKey().notNull()` for `id` — so these fixtures spell that out. + const USER = + 'CREATE TABLE "user" (id text primary key not null, email text not null UNIQUE, name text not null);'; + const SESSION_EXPLICIT_FK = + 'CREATE TABLE "session" (id text primary key not null, userId text not null REFERENCES "user"(id));'; + const SESSION_IMPLICIT_FK = + 'CREATE TABLE "session" (id text primary key not null, userId text not null REFERENCES "user");'; + + it('passes when the checked-in migrations produce the emitted schema', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['email', 'name'] }]); + // Applying the migration to in-memory SQLite yields `user(id, email, name)` — the exact + // emitted schema — so the check resolves (no throw). + await validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: 'CREATE TABLE "user" (id text primary key not null, email text not null, name text not null);', + }, + ]); + }); + + it('fails closed when a required column is missing from the checked-in migrations', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['email', 'name'] }]); + await expect( + // Creates `user` WITHOUT the required `name` column → fails closed against ground truth. + validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: 'CREATE TABLE "user" (id text primary key not null, email text not null);', + }, + ]), + ).rejects.toThrow(/table "user" is missing column\(s\): name/); + }); + + it('fails closed when the build did not emit the auth-schema artifact', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + // No writeAuthSchemaArtifact — the build produced no schema to read as ground truth. + await expect(validateAuthMigrationSchema(root, [])).rejects.toThrow( + /did not emit|better-auth-schema/, + ); + }); + + /** The emitted artifact as the real build writes it: unique email + session → user FK. */ + function writeConstrainedAuthArtifact(root: string): void { + writeAuthSchemaArtifact(root, [ + { name: 'user', fields: [{ name: 'email', unique: true }, 'name'] }, + { + name: 'session', + fields: [ + { name: 'userId', references: { table: 'user', field: 'id', onDelete: 'cascade' } }, + ], + }, + ]); + } + + it('fails closed when the migrations create every name but drop the unique email constraint', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeConstrainedAuthArtifact(root); + await expect( + validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + // Every table and column name is correct — only `.unique()` was never applied. + sql: + 'CREATE TABLE "user" (id text primary key not null, email text not null, name text not null);\n' + + SESSION_EXPLICIT_FK, + }, + ]), + ).rejects.toThrow(/table "user" is missing a UNIQUE constraint on column\(s\): email/); + }); + + // ------------------------------------------------------------------------- + // The implicit `id`. It is absent from the emitted artifact's `fields` (see + // `playground/kitchen-sink/.void/better-auth-schema.ts` — `user` starts at `name`), + // so the synthesized descriptor is the only thing representing it. SQLite only + // accepts a UNIQUELY CONSTRAINED parent key: point a foreign key at an ordinary + // column and `CREATE TABLE` succeeds, `PRAGMA foreign_key_list` still reports the + // constraint, and then every statement touching the child fails at prepare time + // with `foreign key mismatch` under `foreign_keys=ON` — which D1 always enforces. + // ------------------------------------------------------------------------- + + const ID_MUST_BE_UNIQUE = /table "user" column "id" must be the PRIMARY KEY/; + + it('fails closed when an auth table id is an ordinary column (its foreign keys would be dead)', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeConstrainedAuthArtifact(root); + await expect( + validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + // Every name, the unique email and the session FK are all present — only `user.id` + // was never marked `.primaryKey()`, which SQLite accepts at DDL time and rejects + // at runtime. + sql: + 'CREATE TABLE "user" (id text not null, email text not null UNIQUE, name text not null);\n' + + SESSION_EXPLICIT_FK, + }, + ]), + ).rejects.toThrow(ID_MUST_BE_UNIQUE); + }); + + it("names SQLite's runtime failure mode so the message is actionable", async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['name'] }]); + await expect( + validateAuthMigrationSchema(root, [ + { name: '0000.sql', sql: 'CREATE TABLE "user" (id text not null, name text not null);' }, + ]), + ).rejects.toThrow(/foreign key mismatch/); + }); + + it('accepts `id text primary key not null` (the spelling the real build emits)', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeConstrainedAuthArtifact(root); + await validateAuthMigrationSchema(root, [ + { name: '0000.sql', sql: `${USER}\n${SESSION_EXPLICIT_FK}` }, + ]); + }); + + it('accepts a UNIQUE id — SQLite takes any unique parent key, not only a PRIMARY KEY', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeConstrainedAuthArtifact(root); + await validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text not null unique, email text not null UNIQUE, name text not null);\n' + + SESSION_EXPLICIT_FK, + }, + ]); + }); + + it('accepts the `integer primary key` rowid alias (which materializes no index_list row)', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['name'] }]); + await validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: 'CREATE TABLE "user" (id integer primary key, name text not null);', + }, + ]); + }); + + it('does not accept a COMPOSITE unique index as making id a usable parent key', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['name'] }]); + await expect( + validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text not null, name text not null);\n' + + 'CREATE UNIQUE INDEX "user_id_name_idx" ON "user" (id, name);', + }, + ]), + ).rejects.toThrow(ID_MUST_BE_UNIQUE); + }); + + it('does not accept a PARTIAL unique index as making id a usable parent key', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['name'] }]); + await expect( + validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text not null, name text not null);\n' + + 'CREATE UNIQUE INDEX "user_id_idx" ON "user" (id) WHERE name IS NOT NULL;', + }, + ]), + ).rejects.toThrow(ID_MUST_BE_UNIQUE); + }); + + it('fails closed when the migrations create every name but drop the session foreign key', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeConstrainedAuthArtifact(root); + await expect( + validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: `${USER}\nCREATE TABLE "session" (id text primary key not null, userId text not null);`, + }, + ]), + ).rejects.toThrow( + /table "session" column "userId" is missing a foreign key referencing "user"\."id"/, + ); + }); + + it('passes when the migrations reproduce the emitted constraints', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeConstrainedAuthArtifact(root); + await validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: + `${USER}\n` + + 'CREATE TABLE "session" (id text primary key not null, userId text not null REFERENCES "user"(id) ON DELETE CASCADE);', + }, + ]); + }); + + // ------------------------------------------------------------------------- + // An omitted foreign-key target column (`REFERENCES "user"`). SQLite resolves it + // ONLY to the parent's single-column PRIMARY KEY — a merely UNIQUE parent key works + // for an EXPLICIT `REFERENCES "user"(id)` but not for the shorthand, and a composite + // PRIMARY KEY cannot serve as the target either. `PRAGMA foreign_key_list` reports + // `to: null` in every one of those cases with no indication that anything is wrong, + // so the parent's own `table_info` is the only place the answer lives. + // ------------------------------------------------------------------------- + + const IMPLICIT_FK_NOT_PK = /references "user" without naming a target column/; + + it('fails closed on an implicit REFERENCES when the parent id is UNIQUE but not the PRIMARY KEY', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeConstrainedAuthArtifact(root); + await expect( + validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + // `user.id` is a perfectly good parent key for an explicit reference, but the + // shorthand below resolves to a PRIMARY KEY that does not exist. + sql: + 'CREATE TABLE "user" (id text not null unique, email text not null UNIQUE, name text not null);\n' + + SESSION_IMPLICIT_FK, + }, + ]), + ).rejects.toThrow(IMPLICIT_FK_NOT_PK); + }); + + it('accepts an implicit REFERENCES when the parent id IS the primary key', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeConstrainedAuthArtifact(root); + await validateAuthMigrationSchema(root, [ + { name: '0000.sql', sql: `${USER}\n${SESSION_IMPLICIT_FK}` }, + ]); + }); + + it('still accepts an EXPLICIT REFERENCES to a UNIQUE-only parent id', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeConstrainedAuthArtifact(root); + await validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id text not null unique, email text not null UNIQUE, name text not null);\n' + + SESSION_EXPLICIT_FK, + }, + ]); + }); + + it('fails closed on an implicit REFERENCES to a parent with a COMPOSITE primary key', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeConstrainedAuthArtifact(root); + await expect( + validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + // `id` carries its own UNIQUE constraint, so it passes the identity check — the + // only problem left is that the shorthand cannot resolve to `PRIMARY KEY (id, name)`. + sql: + 'CREATE TABLE "user" (id text not null unique, email text not null UNIQUE, ' + + 'name text not null, PRIMARY KEY (id, name));\n' + + SESSION_IMPLICIT_FK, + }, + ]), + ).rejects.toThrow(IMPLICIT_FK_NOT_PK); + }); + + // ------------------------------------------------------------------------- + // NOT NULL. SQLite permits NULL in any PRIMARY KEY column that is not the INTEGER + // rowid alias — a file-format compatibility quirk — so `id text primary key` alone + // accepts rows with no identity, and more than one of them, because NULLs never + // collide under a unique constraint. + // ------------------------------------------------------------------------- + + const ID_MUST_BE_NOT_NULL = /table "user" column "id" must be declared NOT NULL/; + + it('fails closed when the id is a nullable PRIMARY KEY (SQLite accepts NULL identities)', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['name'] }]); + await expect( + validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: 'CREATE TABLE "user" (id text primary key, name text not null);', + }, + ]), + ).rejects.toThrow(ID_MUST_BE_NOT_NULL); + }); + + it('accepts `id text primary key not null`', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['name'] }]); + await validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: 'CREATE TABLE "user" (id text primary key not null, name text not null);', + }, + ]); + }); + + it('accepts `id integer primary key` — the rowid alias can never hold NULL', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['name'] }]); + // `PRAGMA table_info` reports `notnull = 0` here, but SQLite assigns a rowid on a NULL + // insert, so the column is effectively NOT NULL — rejecting it would break a common, + // legitimate schema. + await validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: 'CREATE TABLE "user" (id integer primary key, name text not null);', + }, + ]); + }); + + it('accepts `id integer primary key asc` — also a true rowid alias', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['name'] }]); + // `ASC` on an INTEGER PRIMARY KEY is the plain alias spelled out; SQLite materializes no + // primary-key index for it and assigns a rowid on a NULL insert. + await validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: 'CREATE TABLE "user" (id integer primary key asc, name text not null);', + }, + ]); + }); + + it('fails closed on `id integer primary key desc` — declared INTEGER, but not the rowid alias', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['name'] }]); + // A SQLite quirk: `DESC` makes this an ORDINARY primary key, so a NULL insert stores NULL + // and the table accepts rows with no identity. `PRAGMA table_info` reports it exactly like + // the real alias (`type = INTEGER`, `pk = 1`, `notnull = 0`) — only the `origin = 'pk'` + // row SQLite materializes in `PRAGMA index_list` tells them apart. + await expect( + validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: 'CREATE TABLE "user" (id integer primary key desc, name text not null);', + }, + ]), + ).rejects.toThrow(ID_MUST_BE_NOT_NULL); + }); + + it('does not extend the rowid-alias exception to `int primary key` (which really does store NULL)', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['name'] }]); + // Only an exact `INTEGER` declared type makes a primary key the rowid alias. + await expect( + validateAuthMigrationSchema(root, [ + { name: '0000.sql', sql: 'CREATE TABLE "user" (id int primary key, name text not null);' }, + ]), + ).rejects.toThrow(ID_MUST_BE_NOT_NULL); + }); + + it('does not extend the rowid-alias exception to `bigint primary key`', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [{ name: 'user', fields: ['name'] }]); + await expect( + validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: 'CREATE TABLE "user" (id bigint primary key, name text not null);', + }, + ]), + ).rejects.toThrow(ID_MUST_BE_NOT_NULL); + }); + + it('still treats `id integer primary key` as the alias when the table carries other unique indexes', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [ + { name: 'user', fields: [{ name: 'email', unique: true }, 'name'] }, + ]); + // The discriminator is the index ORIGIN, not the index COUNT: a column-level UNIQUE adds an + // `origin = 'u'` row and a `CREATE UNIQUE INDEX` an `origin = 'c'` row, neither of which + // stops the primary key from being the rowid alias. + await validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: + 'CREATE TABLE "user" (id integer primary key, email text not null UNIQUE, name text not null);\n' + + 'CREATE UNIQUE INDEX "user_name_idx" ON "user" (name);', + }, + ]); + }); + + it('fails closed when a required emitted field is declared nullable', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + writeAuthSchemaArtifact(root, [ + { name: 'user', fields: [{ name: 'email', unique: true }, 'name'] }, + ]); + await expect( + validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + // `email` keeps its UNIQUE constraint and its name — only NOT NULL was dropped. + sql: 'CREATE TABLE "user" (id text primary key not null, email text UNIQUE, name text not null);', + }, + ]), + ).rejects.toThrow( + /table "user" is missing a NOT NULL constraint on column\(s\): email \(the emitted auth schema marks them required\)/, + ); + }); + + it('does not require NOT NULL for a field the emitted schema marks optional', async () => { + const root = makeProject({ 'package.json': JSON.stringify({ name: 'authapp' }) }); + // `user.image` is `required: false` in the real artifact. + writeAuthSchemaArtifact(root, [ + { name: 'user', fields: ['name', { name: 'image', required: false }] }, + ]); + await validateAuthMigrationSchema(root, [ + { + name: '0000.sql', + sql: 'CREATE TABLE "user" (id text primary key not null, name text not null, image text);', + }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Remote D1 migration safety gates +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — remote migration gates', () => { + it('fails closed before applying when the deploy migration gate throws (drift/journal/destructive)', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, wranglerArgs, trace, exitCalls } = harness({ + assertMigrationsDeployable: () => { + throw new Error('Schema drift detected between your Drizzle schema and db/migrations/.'); + }, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The gate runs POST-build now (against the live files), so the build ran — but it fires + // BEFORE the remote apply: no d1 call, no deploy. + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(trace).toContain('build'); + expect(trace).not.toContain('wrangler:deploy'); + }); + + it('runs the real gate: a destructive migration without the pragma fails closed', async () => { + const root = d1Project(PROVISIONED_D1, { + 'db/migrations/0000_init.sql': 'DROP TABLE notes;', + }); + // No db/schema.ts → the drift check short-circuits (no drizzle-kit spawn); the real + // destructive-op validation runs and rejects the un-pragma'd DROP. + const { deps, wranglerArgs, exitCalls } = harness({ assertMigrationsDeployable: undefined }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + }); + + it('fails closed when remote still has pending migrations after apply (declined prompt)', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const localTrace: Array = []; + const { deps, exitCalls } = harness({ + runWrangler: (args) => { + localTrace.push(`wrangler:${args[0]}:${args[2] ?? ''}`); + if (args[0] === 'whoami') { + return { ...cleanExit, stdout: '{"loggedIn":true}' }; + } + // `apply` exits 0 (a declined no-op); `list` still reports pending. + if (args[0] === 'd1' && args[2] === 'list') { + return { ...cleanExit, stdout: 'Migrations to be applied:\n0000_init' }; + } + return cleanExit; + }, + runBuild: () => { + localTrace.push('build'); + }, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The remote apply/list runs AFTER the build now; reached the list-verify, then failed + // closed before DEPLOYING (the build already ran, but the deploy never does). + expect(localTrace).toContain('wrangler:d1:list'); + expect(localTrace).toContain('build'); + expect(localTrace).not.toContain('wrangler:deploy'); + }); + + it('fails closed when the pending-verify prints NO recognizable marker (B3, reduced log level)', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const localTrace: Array = []; + const { deps, exitCalls } = harness({ + runWrangler: (args) => { + localTrace.push(`wrangler:${args[0]}:${args[2] ?? ''}`); + if (args[0] === 'whoami') { + return { ...cleanExit, stdout: '{"loggedIn":true}' }; + } + // Both `apply` and `list` exit 0 with EMPTY stdout — the failure mode when an + // ambient WRANGLER_LOG suppressed the sentinel. Require the affirmative marker. + return cleanExit; + }, + runBuild: () => { + localTrace.push('build'); + }, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Never trusts an absent marker — fails closed before DEPLOY (the build already ran). + expect(localTrace).toContain('wrangler:d1:list'); + expect(localTrace).toContain('build'); + expect(localTrace).not.toContain('wrangler:deploy'); + }); + + it('fails closed (B1) when wrangler would apply a stray seed.sql not in the validated set', async () => { + const root = d1Project(PROVISIONED_D1, { + 'db/migrations/0000_init.sql': `CREATE TABLE notes (id integer primary key);`, + // `collectMigrations` skips seed.sql, but wrangler's `*.sql` glob would apply it. + 'db/migrations/seed.sql': `INSERT INTO notes VALUES (1);`, + }); + // Use the REAL apply-set gate (override the harness stub) so it enumerates the dir. + const { deps, wranglerArgs, trace, exitCalls } = harness({ + checkMigrationApplySet: checkWranglerMigrationApplySet, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The apply-set gate runs POST-build now (it enumerates the live dir), so the build ran — + // but it rejects before any `d1 migrations apply`. + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(trace).toContain('build'); + }); +}); + +// --------------------------------------------------------------------------- +// B1: the exact set wrangler will apply must equal the gated source set +// --------------------------------------------------------------------------- + +describe('checkWranglerMigrationApplySet', () => { + function configFor(dir: string, pattern?: string): Record { + const entry: Record = { binding: 'DB', migrations_dir: dir }; + // `!== undefined` (not truthiness) so an explicit EMPTY pattern (`""`) is set — + // a present-but-empty pattern is still custom and must be rejected. + if (pattern !== undefined) { + entry.migrations_pattern = pattern; + } + return { d1_databases: [entry] }; + } + + it('passes when the configured dir + files equal the gated set', async () => { + const root = makeProject({ + 'wrangler.jsonc': '{}', + 'db/migrations/0000_init.sql': 'CREATE TABLE a (id integer);', + 'db/migrations/0001_more.sql': 'CREATE TABLE b (id integer);', + }); + const result = await checkWranglerMigrationApplySet( + join(root, 'wrangler.jsonc'), + configFor('./db/migrations'), + 'DB', + './db/migrations', + [ + { name: '0000_init.sql', sql: 'CREATE TABLE a (id integer);' }, + { name: '0001_more.sql', sql: 'CREATE TABLE b (id integer);' }, + ], + ); + expect(result.ok).toBe(true); + }); + + it('rejects an extra seed.sql wrangler would apply that the gated set excludes', async () => { + const root = makeProject({ + 'wrangler.jsonc': '{}', + 'db/migrations/0000_init.sql': 'CREATE TABLE a (id integer);', + 'db/migrations/seed.sql': 'INSERT INTO a VALUES (1);', + }); + const result = await checkWranglerMigrationApplySet( + join(root, 'wrangler.jsonc'), + configFor('./db/migrations'), + 'DB', + './db/migrations', + [{ name: '0000_init.sql', sql: 'CREATE TABLE a (id integer);' }], + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain('seed.sql'); + } + }); + + it('rejects when the configured migrations_dir does not exist', async () => { + const root = makeProject({ 'wrangler.jsonc': '{}' }); + const result = await checkWranglerMigrationApplySet( + join(root, 'wrangler.jsonc'), + configFor('./nope'), + 'DB', + './nope', + [{ name: '0000_init.sql', sql: 'CREATE TABLE a (id integer);' }], + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain('does not exist'); + } + }); + + it('rejects when a file content differs between the config dir and the gated copy', async () => { + const root = makeProject({ + 'wrangler.jsonc': '{}', + 'db/migrations/0000_init.sql': 'CREATE TABLE a (id integer);', + }); + const result = await checkWranglerMigrationApplySet( + join(root, 'wrangler.jsonc'), + configFor('./db/migrations'), + 'DB', + './db/migrations', + [{ name: '0000_init.sql', sql: 'CREATE TABLE a (id text);' }], + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain('content differs'); + } + }); + + it('A1: rejects when wrangler would apply in a DIFFERENT order (unpadded numeric prefix)', async () => { + // `2_*.sql` and `10_*.sql` sort DIFFERENTLY for wrangler (numeric: 2 < 10) than for Void's + // collectMigrations (lexicographic: "10" < "2"). The set is equal but the apply ORDER + // diverges, so remote D1 would receive them out of the validated order — reject. + const root = makeProject({ + 'wrangler.jsonc': '{}', + 'db/migrations/2_early.sql': 'CREATE TABLE a (id integer);', + 'db/migrations/10_late.sql': 'CREATE TABLE b (id integer);', + }); + const result = await checkWranglerMigrationApplySet( + join(root, 'wrangler.jsonc'), + configFor('./db/migrations'), + 'DB', + './db/migrations', + // collectMigrations' lexicographic order: 10_late before 2_early. + [ + { name: '10_late.sql', sql: 'CREATE TABLE b (id integer);' }, + { name: '2_early.sql', sql: 'CREATE TABLE a (id integer);' }, + ], + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain('DIFFERENT order'); + } + }); + + it('A1: passes with zero-padded prefixes (numeric order == lexicographic order)', async () => { + const root = makeProject({ + 'wrangler.jsonc': '{}', + 'db/migrations/0002_early.sql': 'CREATE TABLE a (id integer);', + 'db/migrations/0010_late.sql': 'CREATE TABLE b (id integer);', + }); + const result = await checkWranglerMigrationApplySet( + join(root, 'wrangler.jsonc'), + configFor('./db/migrations'), + 'DB', + './db/migrations', + [ + { name: '0002_early.sql', sql: 'CREATE TABLE a (id integer);' }, + { name: '0010_late.sql', sql: 'CREATE TABLE b (id integer);' }, + ], + ); + expect(result.ok).toBe(true); + }); + + it('A1: fails closed (defense in depth) if a custom migrations_pattern slips through', async () => { + const root = makeProject({ + 'wrangler.jsonc': '{}', + 'db/migrations/0000_init.sql': 'CREATE TABLE a (id integer);', + }); + const result = await checkWranglerMigrationApplySet( + join(root, 'wrangler.jsonc'), + configFor('./db/migrations', 'db/migrations/**/*.sql'), + 'DB', + './db/migrations', + [{ name: '0000_init.sql', sql: 'CREATE TABLE a (id integer);' }], + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain('custom migrations_pattern'); + } + }); + + it('A1: fails closed (defense in depth) on a PRESENT-but-EMPTY migrations_pattern ("")', async () => { + const root = makeProject({ + 'wrangler.jsonc': '{}', + 'db/migrations/0000_init.sql': 'CREATE TABLE a (id integer);', + }); + const result = await checkWranglerMigrationApplySet( + join(root, 'wrangler.jsonc'), + configFor('./db/migrations', ''), + 'DB', + './db/migrations', + [{ name: '0000_init.sql', sql: 'CREATE TABLE a (id integer);' }], + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain('custom migrations_pattern'); + } + }); +}); + +describe('compareWranglerMigrationPaths (wrangler 4.107 numeric-prefix comparator)', () => { + it('sorts by leading numeric prefix (2 before 10), matching wrangler', () => { + const sorted = ['10_b.sql', '2_a.sql', '1_z.sql'].sort(compareWranglerMigrationPaths); + expect(sorted).toEqual(['1_z.sql', '2_a.sql', '10_b.sql']); + }); + + it('places a non-numeric segment (seed.sql) after all numeric-prefixed ones', () => { + const sorted = ['seed.sql', '0001_a.sql', '0000_b.sql'].sort(compareWranglerMigrationPaths); + expect(sorted).toEqual(['0000_b.sql', '0001_a.sql', 'seed.sql']); + }); +}); + +// --------------------------------------------------------------------------- +// Post-build ground-truth secret gate: the parent builds the values-free context +// from the REAL emitted `vars` + the env-insensitive required names, then +// discharges it. A required secret shipped EMPTY fails closed post-build; a +// pre-build schema-probe failure fails closed before any account op. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — post-build ground-truth secret gate', () => { + it('discharges a context DERIVED from the REAL emitted vars + env-insensitive required names', async () => { + const root = d1Project(PROVISIONED_D1); + let captured: SelfHostEnvContext | undefined; + const { deps, trace } = harness({ + resolveSchemaFacts: (() => + okSchemaFacts({ + hasSchema: true, + requiredKeys: ['STRIPE_KEY'], + secretKeys: ['STRIPE_KEY'], + })) as CloudflareDeployDeps['resolveSchemaFacts'], + readEmittedWorkerVars: (() => + emittedVars({ APP_NAME: 'demo' })) as CloudflareDeployDeps['readEmittedWorkerVars'], + dischargeSelfHostEnv: (async (ctx: SelfHostEnvContext) => { + trace.push('gate'); + captured = ctx; + return { hasSchema: true, remoteSecretNames: ['STRIPE_KEY'] }; + }) as CloudflareDeployDeps['dischargeSelfHostEnv'], + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + // STRIPE_KEY is absent from the emitted vars → the gate sees it as absentRequired (the + // remote secret discharges it); nothing is present-empty/invalid. + expect(captured).toEqual({ + hasSchema: true, + presentEmptyRequired: [], + presentEmptySources: {}, + absentRequired: ['STRIPE_KEY'], + invalidKeys: [], + }); + expect(trace).toContain('wrangler:deploy'); + }); + + it('fails closed when a required secret is shipped EMPTY in the REAL vars (shadows the remote secret)', async () => { + const root = d1Project(PROVISIONED_D1); + // Use the REAL discharge (dischargeSelfHostEnv undefined) so the present-empty fence runs. + const { deps, trace, wranglerArgs, exitCalls } = harness({ + resolveSchemaFacts: (() => + okSchemaFacts({ + hasSchema: true, + requiredKeys: ['STRIPE_KEY'], + secretKeys: ['STRIPE_KEY'], + })) as CloudflareDeployDeps['resolveSchemaFacts'], + readEmittedWorkerVars: (() => + emittedVars({ STRIPE_KEY: '' })) as CloudflareDeployDeps['readEmittedWorkerVars'], + dischargeSelfHostEnv: undefined, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The gate runs POST-build (ground truth), so the build already ran; a shipped-empty + // required secret fails closed BEFORE any wrangler secret list and before deploy. + expect(trace).toContain('build'); + expect(trace).not.toContain('wrangler:deploy'); + expect(wranglerArgs.some((a) => a[0] === 'secret')).toBe(false); + }); + + it('fails closed when a required secret is shipped NULL in the REAL vars (shadows like empty)', async () => { + const root = d1Project(PROVISIONED_D1); + // A parsed wrangler.json var can be `null`; the schema validators bucket null as "missing" + // (not "invalid"), so without the empty-shadow fence it would slip past every gate while + // still shadowing the remote secret. Use the REAL discharge so the fence runs. + const { deps, trace, wranglerArgs, exitCalls } = harness({ + resolveSchemaFacts: (() => + okSchemaFacts({ + hasSchema: true, + requiredKeys: ['STRIPE_KEY'], + secretKeys: ['STRIPE_KEY'], + })) as CloudflareDeployDeps['resolveSchemaFacts'], + readEmittedWorkerVars: (() => + emittedVars({ STRIPE_KEY: null })) as CloudflareDeployDeps['readEmittedWorkerVars'], + dischargeSelfHostEnv: undefined, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toContain('build'); + expect(wranglerArgs.some((a) => a[0] === 'secret')).toBe(false); + expect(trace).not.toContain('wrangler:deploy'); + }); + + it('fails closed when the build emits a MALFORMED vars field (array/scalar)', async () => { + const root = d1Project(PROVISIONED_D1); + const { deps, trace, exitCalls } = harness({ + // A present-but-non-object `vars` must fail closed, never normalize to an empty map. + readEmittedWorkerVars: (() => ({ + ok: false, + reason: 'the generated wrangler config has a malformed "vars" field (expected an object).', + })) as CloudflareDeployDeps['readEmittedWorkerVars'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).not.toContain('wrangler:deploy'); + }); + + it('fails closed pre-build when the env-schema probe throws (never validates a wrong schema)', async () => { + const root = d1Project(PROVISIONED_D1); + const { deps, trace, exitCalls } = harness({ + resolveSchemaFacts: (() => { + throw new Error('cannot import env.ts'); + }) as CloudflareDeployDeps['resolveSchemaFacts'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Derived pre-build — before whoami / provision / build. + expect(trace).toEqual([]); + }); + + it('fails closed (values-free) pre-build when the probe reports a schema-load failure', async () => { + const root = d1Project(PROVISIONED_D1); + const { deps, trace, exitCalls } = harness({ + resolveSchemaFacts: (() => ({ + kind: 'schema-error', + reason: 'schema-load-failed', + })) as CloudflareDeployDeps['resolveSchemaFacts'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// envDir / mode narrowing — a custom Vite envDir (the build reads `.env*` from a +// non-root dir but generates the auth schema from root) or a non-production build +// mode is unsupported in v1, REJECTED pre-account by the facts probe. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — envDir / mode narrowing (rejected pre-account)', () => { + it('rejects a custom Vite envDir pre-account — no whoami / provision / build (REAL probe)', async () => { + const savedNodeOptions = process.env.NODE_OPTIONS; + process.env.NODE_OPTIONS = [process.env.NODE_OPTIONS, '--experimental-transform-types'] + .filter(Boolean) + .join(' '); + try { + // A REAL vite.config sets a custom envDir; the facts probe resolves the config in the + // isolated subprocess, reads the effective envDir, and rejects before any account op. + const root = d1Project(PROVISIONED_D1, { + 'package.json': JSON.stringify({ name: 'myapp', type: 'module' }), + 'vite.config.ts': `export default { envDir: './cfg' };`, + 'cfg/.gitkeep': '', + }); + const { deps, trace, wranglerArgs, exitCalls } = harness({ resolveSchemaFacts: undefined }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Pre-account: nothing ran — no whoami, no provision, no build, no deploy. + expect(trace).toEqual([]); + expect(wranglerArgs).toEqual([]); + } finally { + if (savedNodeOptions === undefined) { + delete process.env.NODE_OPTIONS; + } else { + process.env.NODE_OPTIONS = savedNodeOptions; + } + } + }); + + it('rejects an ABSOLUTE envDir equal to the project root pre-account — the build mis-loads it (REAL probe)', async () => { + const savedNodeOptions = process.env.NODE_OPTIONS; + process.env.NODE_OPTIONS = [process.env.NODE_OPTIONS, '--experimental-transform-types'] + .filter(Boolean) + .join(' '); + try { + // Vite normalizes `envDir` via `path.resolve(root, envDir)`, so an ABSOLUTE envDir equal + // to the root collapses to `resolved.root` and Vite's own view would ACCEPT. But the build + // loads worker vars with `join(root, rawEnvDir)`, which DUPLICATES an absolute path + // (`/proj/proj`) → the build reads vars from the wrong dir. The probe now reads the RAW + // envDir the `void:env` hook captured and rejects via the build's own computation, before + // any account op. `import.meta.dirname` in the config === the project root (absolute). + const root = d1Project(PROVISIONED_D1, { + 'package.json': JSON.stringify({ name: 'myapp', type: 'module' }), + 'vite.config.ts': viteConfigWithVoidEnvStub('import.meta.dirname'), + }); + const { deps, trace, wranglerArgs, exitCalls } = harness({ resolveSchemaFacts: undefined }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Pre-account: nothing ran — no whoami, no provision, no build, no deploy. + expect(trace).toEqual([]); + expect(wranglerArgs).toEqual([]); + } finally { + if (savedNodeOptions === undefined) { + delete process.env.NODE_OPTIONS; + } else { + process.env.NODE_OPTIONS = savedNodeOptions; + } + } + }); + + it('rejects a non-production build mode pre-account — no whoami / provision / build', async () => { + const root = d1Project(PROVISIONED_D1); + const { deps, trace, wranglerArgs, exitCalls } = harness({ + // The probe resolves mode from the config; a non-production mode selects a different + // `.env.` set than this backend validates → reject before any account op. Only the + // fixed reason code crosses the boundary (no raw mode/envDir string). + resolveSchemaFacts: (() => ({ + kind: 'reject', + reason: 'non-production-mode', + })) as CloudflareDeployDeps['resolveSchemaFacts'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + expect(wranglerArgs).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// NODE_ENV pin — the facts probe evaluates env.ts under the build's environment +// (NODE_ENV=production), so a required set conditioned on NODE_ENV matches the +// built worker's, and the parent passes that pinned env to the probe. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — schema probe runs under the build NODE_ENV', () => { + it('passes NODE_ENV=production to the probe env', async () => { + const root = d1Project(PROVISIONED_D1); + let capturedNodeEnv: string | undefined = 'UNSET'; + const { deps } = harness({ + resolveSchemaFacts: ((_input, env) => { + capturedNodeEnv = env.NODE_ENV; + return okSchemaFacts(); + }) as CloudflareDeployDeps['resolveSchemaFacts'], + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + // The build sets NODE_ENV=production; the probe env is pinned to match so a schema + // conditioned on NODE_ENV derives the SAME required set the build would. + expect(capturedNodeEnv).toBe('production'); + }); + + it('pins NODE_ENV=production for the BUILD too (vite only defaults it when unset)', async () => { + const savedNodeEnv = process.env.NODE_ENV; + // An ambient NODE_ENV=development would otherwise make `vite build` evaluate a development + // schema while the probe (pinned production) derives a production one. + process.env.NODE_ENV = 'development'; + let buildNodeEnv: string | undefined = 'UNSET'; + try { + const root = d1Project(PROVISIONED_D1); + const { deps } = harness({ + runBuild: (env) => { + buildNodeEnv = env.NODE_ENV; + }, + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(buildNodeEnv).toBe('production'); + } finally { + if (savedNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = savedNodeEnv; + } + } + }); +}); + +// --------------------------------------------------------------------------- +// `vite build` runs UNTRUSTED project code (the user's config, every plugin, env.ts) +// and authenticates against nothing, so it gets the same credential scrub as the +// isolated probes. The wrangler runner must keep them — whoami / d1 migrations apply +// / deploy are the calls that actually need an account credential. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — wrangler credentials reach only the wrangler runner', () => { + it('the BUILD child env carries none of the wrangler auth credentials', async () => { + let buildEnvSeen: NodeJS.ProcessEnv = {}; + await withWranglerCredentials(async () => { + const root = d1Project(PROVISIONED_D1); + const { deps } = harness({ + runBuild: (env) => { + buildEnvSeen = { ...env }; + }, + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + }); + for (const name of WRANGLER_AUTH_VARS) { + expect(buildEnvSeen[name]).toBeUndefined(); + } + }); + + it('the wrangler runner env DOES still carry them (whoami / migrate / deploy need them)', async () => { + await withWranglerCredentials(async () => { + // Run a deploy first so the fork's memoized provision module is loaded — the sync + // `deployChildEnv` reads it, exactly as `createDeployWranglerRunner` does. + const root = d1Project(PROVISIONED_D1); + const { deps } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + + const runnerEnv = deployChildEnv('acc123', 'https://api.cloudflare.com/client/v4'); + for (const name of WRANGLER_AUTH_VARS) { + expect(runnerEnv[name]).toBe(`secret-${name}`); + } + }); + }); +}); + +// --------------------------------------------------------------------------- +// Emitted-value validation (invalid required value) — a required var shipped with +// a MALFORMED value fails the deploy closed BEFORE any remote mutation, not only at +// worker boot. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — invalid emitted value fails closed pre-remote', () => { + const invalidPortHarness = (overrides: Partial = {}) => + harness({ + resolveSchemaFacts: (() => + okSchemaFacts({ + hasSchema: true, + requiredKeys: ['PORT'], + secretKeys: [], + })) as CloudflareDeployDeps['resolveSchemaFacts'], + readEmittedWorkerVars: (() => + emittedVars({ PORT: 'not-a-number' })) as CloudflareDeployDeps['readEmittedWorkerVars'], + // Use the REAL discharge so the invalidKeys folded in by the probe actually fail closed. + dischargeSelfHostEnv: undefined, + ...overrides, + }); + + it('fails closed (no remote apply / deploy) when a required emitted value is schema-invalid', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, trace, wranglerArgs, exitCalls } = invalidPortHarness({ + // The probe reports PORT invalid — it ships a present, non-empty, malformed value. + validateEmittedVars: (async () => ({ + kind: 'invalid-keys', + invalidKeys: ['PORT'], + })) as CloudflareDeployDeps['validateEmittedVars'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Caught POST-build (ground truth) but BEFORE any remote D1 apply, secret list, or deploy. + expect(trace).toContain('build'); + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(wranglerArgs.some((a) => a[0] === 'secret')).toBe(false); + expect(trace).not.toContain('wrangler:deploy'); + }); + + it('proceeds when the required emitted value is schema-valid', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, trace } = invalidPortHarness({ + readEmittedWorkerVars: (() => + emittedVars({ PORT: '8080' })) as CloudflareDeployDeps['readEmittedWorkerVars'], + // No key is invalid; PORT is present & non-empty so the gate makes no wrangler call. + validateEmittedVars: (async () => ({ + kind: 'invalid-keys', + invalidKeys: [], + })) as CloudflareDeployDeps['validateEmittedVars'], + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(trace).toContain('wrangler:deploy'); + }); + + it('fails closed when the emitted-value validation probe reports a schema error', async () => { + const root = d1Project(PROVISIONED_D1); + const { deps, trace, exitCalls } = invalidPortHarness({ + validateEmittedVars: (async () => ({ + kind: 'schema-error', + reason: 'schema-validation-error', + })) as CloudflareDeployDeps['validateEmittedVars'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).not.toContain('wrangler:deploy'); + }); +}); + +// --------------------------------------------------------------------------- +// Post-build migration re-check — the local migration gates run AFTER the build, +// against the LIVE post-build files, so a build that adds/rewrites migration SQL +// can never reach remote D1 ungated. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — migration gates re-checked post-build', () => { + it('catches a migration the build REWRITES to be destructive (fails closed before remote apply)', async () => { + const root = d1Project(PROVISIONED_D1, { + // Pre-build the migration is a clean, non-destructive CREATE TABLE — it would pass a + // pre-build gate. No db/schema.ts, so the drift check short-circuits (no drizzle-kit). + 'db/migrations/0000_init.sql': `CREATE TABLE notes (id integer primary key);`, + }); + // Use the REAL migration gate (not the harness stub) so the post-build destructive check runs. + const { deps, wranglerArgs, trace, exitCalls } = harness({ + assertMigrationsDeployable: undefined, + runBuild: () => { + trace.push('build'); + // A build plugin rewrites the migration to a destructive, un-pragma'd DROP AFTER the + // pre-build point — exactly the window the post-build re-check closes. + writeFileSync(join(root, 'db/migrations/0000_init.sql'), 'DROP TABLE notes;'); + }, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // The build ran, but the post-build gate rejected the destructive migration before the + // remote D1 apply and before the deploy. + expect(trace).toContain('build'); + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(trace).not.toContain('wrangler:deploy'); + }); + + it('catches a stray .sql the build ADDS to migrations_dir (apply-set mismatch, fails closed)', async () => { + const root = d1Project(PROVISIONED_D1, { + 'db/migrations/0000_init.sql': `CREATE TABLE notes (id integer primary key);`, + }); + // Use the REAL apply-set gate so it enumerates the LIVE dir after the build. + const { deps, wranglerArgs, trace, exitCalls } = harness({ + checkMigrationApplySet: checkWranglerMigrationApplySet, + runBuild: () => { + trace.push('build'); + // The build adds a stray seed.sql: collectMigrations skips it but wrangler's *.sql glob + // would apply it — the post-build apply-set check catches the extra file. + writeFileSync(join(root, 'db/migrations/seed.sql'), 'INSERT INTO notes VALUES (1);'); + }, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toContain('build'); + expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); + expect(trace).not.toContain('wrangler:deploy'); + }); + + it('applies the post-build migrations in the normal case (build leaves them untouched)', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + // REAL migration gate; the untouched migration passes post-build and is applied. + const { deps, wranglerArgs, trace } = harness({ assertMigrationsDeployable: undefined }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(trace).toContain('build'); + const d1Calls = wranglerArgs.filter((a) => a[0] === 'd1'); + expect(d1Calls[0]?.slice(0, 3)).toEqual(['d1', 'migrations', 'apply']); + expect(trace).toContain('wrangler:deploy'); + }); +}); + +// --------------------------------------------------------------------------- +// The REAL env-schema probe subprocess. The parent runs NO project code — +// importing the user's env.ts happens in the child, whose framed result is +// written to a DEDICATED fd (fd 3) so noisy stdout can't truncate it, and the +// child sees ONLY the passed (scrubbed) env — never the parent's credentials. +// --------------------------------------------------------------------------- + +describe('env-schema probe (real subprocess)', () => { + // The probe spawns a plain `node` on THIS repo's SOURCE `.ts`. `env-public.ts` (imported at + // runtime to read the registered schema) uses a TS parameter property, which Node's default + // strip-only mode rejects — so enable full type transform for the child, mirroring how + // production runs the compiled `dist`. `provisionEnv`/`deployChildEnv` never scrub NODE_OPTIONS. + const withTransform = (base: NodeJS.ProcessEnv): NodeJS.ProcessEnv => ({ + ...base, + NODE_OPTIONS: [base.NODE_OPTIONS, '--experimental-transform-types'].filter(Boolean).join(' '), + }); + + it('derives required + `.secret()` facts and the fd-3 result survives noisy stdout', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), + 'env.ts': [ + `import { defineEnv } from ${JSON.stringify(srcEnvPublic)};`, + `import { string } from ${JSON.stringify(srcEnvHelpers)};`, + // A huge stdout write proves the fd-3 result channel is not truncated by env.ts noise. + `console.log('x'.repeat(200000));`, + `export default defineEnv({ STRIPE_KEY: string().secret(), API_URL: string() });`, + ].join('\n'), + }); + const r = await resolveEnvSchemaFactsViaSubprocess({ root }, withTransform({ ...process.env })); + expect(r.kind).toBe('ok'); + if (r.kind === 'ok') { + expect(r.facts.hasSchema).toBe(true); + expect(r.facts.requiredKeys).toContain('STRIPE_KEY'); + expect(r.facts.requiredKeys).toContain('API_URL'); + expect(r.facts.secretKeys).toEqual(['STRIPE_KEY']); + } + }); + + it('runs env.ts under the PASSED env — a scrubbed env hides CF Access from project code', async () => { + const leakFile = join(tmpDir, 'env-leak.json'); + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), + 'env.ts': [ + `import { defineEnv } from ${JSON.stringify(srcEnvPublic)};`, + `import { string } from ${JSON.stringify(srcEnvHelpers)};`, + `import { writeFileSync } from 'node:fs';`, + `writeFileSync(${JSON.stringify(leakFile)}, JSON.stringify({`, + ` token: process.env.CLOUDFLARE_API_TOKEN ?? null,`, + ` id: process.env.CF_ACCESS_CLIENT_ID ?? null,`, + `}));`, + `export default defineEnv({ FOO: string() });`, + ].join('\n'), + }); + const env = withTransform({ ...process.env }); + delete env.CLOUDFLARE_API_TOKEN; + delete env.CF_ACCESS_CLIENT_ID; + await resolveEnvSchemaFactsViaSubprocess({ root }, env); + const leaked = JSON.parse(readFileSync(leakFile, 'utf-8')) as { + token: string | null; + id: string | null; + }; + // The child only ever sees the PASSED env — the scrubbed keys read as null. + expect(leaked.token).toBeNull(); + expect(leaked.id).toBeNull(); + }); + + it('the orchestrator scrubs CLOUDFLARE_API_TOKEN + CF Access before spawning the probe', async () => { + const savedToken = process.env.CLOUDFLARE_API_TOKEN; + const savedId = process.env.CF_ACCESS_CLIENT_ID; + const savedSecret = process.env.CF_ACCESS_CLIENT_SECRET; + const savedNodeOptions = process.env.NODE_OPTIONS; + process.env.CLOUDFLARE_API_TOKEN = 'super-secret-token'; + process.env.CF_ACCESS_CLIENT_ID = 'super-secret-id'; + process.env.CF_ACCESS_CLIENT_SECRET = 'super-secret-secret'; + // The orchestrator copies process.env (unscrubbed NODE_OPTIONS) into the probe child env, so + // enable full type transform for the plain-node child running SOURCE .ts (see withTransform). + process.env.NODE_OPTIONS = [process.env.NODE_OPTIONS, '--experimental-transform-types'] + .filter(Boolean) + .join(' '); + const leakFile = join(tmpDir, 'orchestrator-leak.json'); + try { + // The env.ts LEAKS the three parent creds to a file; the orchestrator spawns the REAL + // probe (resolveSchemaFacts undefined → default subprocess seam) under a scrubbed env. + const root = d1Project(PROVISIONED_D1, { + 'package.json': JSON.stringify({ name: 'myapp', type: 'module' }), + 'env.ts': [ + `import { defineEnv } from ${JSON.stringify(srcEnvPublic)};`, + `import { string } from ${JSON.stringify(srcEnvHelpers)};`, + `import { writeFileSync } from 'node:fs';`, + `writeFileSync(${JSON.stringify(leakFile)}, JSON.stringify({`, + ` token: process.env.CLOUDFLARE_API_TOKEN ?? null,`, + ` id: process.env.CF_ACCESS_CLIENT_ID ?? null,`, + ` secret: process.env.CF_ACCESS_CLIENT_SECRET ?? null,`, + `}));`, + `export default defineEnv({ FOO: string() });`, + ].join('\n'), + }); + await runCloudflareDeploy( + root, + { backend: 'cloudflare' }, + silentCliLog, + harness({ resolveSchemaFacts: undefined }).deps, + ); + const leaked = JSON.parse(readFileSync(leakFile, 'utf-8')) as { + token: string | null; + id: string | null; + secret: string | null; + }; + // The child (project env.ts) ran under the scrubbed env — none of the parent's creds. + expect(leaked.token).toBeNull(); + expect(leaked.id).toBeNull(); + expect(leaked.secret).toBeNull(); + } finally { + if (savedToken === undefined) { + delete process.env.CLOUDFLARE_API_TOKEN; + } else { + process.env.CLOUDFLARE_API_TOKEN = savedToken; + } + if (savedId === undefined) { + delete process.env.CF_ACCESS_CLIENT_ID; + } else { + process.env.CF_ACCESS_CLIENT_ID = savedId; + } + if (savedSecret === undefined) { + delete process.env.CF_ACCESS_CLIENT_SECRET; + } else { + process.env.CF_ACCESS_CLIENT_SECRET = savedSecret; + } + if (savedNodeOptions === undefined) { + delete process.env.NODE_OPTIONS; + } else { + process.env.NODE_OPTIONS = savedNodeOptions; + } + } + }); + + it('a broken env.ts fails closed with a VALUES-FREE classification — no raw child output echoed', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), + 'env.ts': `throw new Error('SUPER_SECRET_LEAK_9999');\nexport default {};`, + }); + const r = await resolveEnvSchemaFactsViaSubprocess({ root }, withTransform({ ...process.env })); + expect(r.kind).toBe('schema-error'); + // The values-free boundary drops the original message, so the leak never reaches the result. + expect(JSON.stringify(r)).not.toContain('SUPER_SECRET_LEAK_9999'); + }); + + it('parses ONLY the dedicated fd-3 result channel (injected spawn)', async () => { + const facts = { hasSchema: true, requiredKeys: ['X'], secretKeys: [] }; + const okSpawn = (): EnvSchemaProbeSpawnResult => ({ + status: 0, + fd3: JSON.stringify({ kind: 'ok', facts }), + error: null, + signal: null, + }); + const r = await resolveEnvSchemaFactsViaSubprocess( + { root: tmpDir }, + { ...process.env }, + okSpawn, + ); + expect(r.kind).toBe('ok'); + if (r.kind === 'ok') { + expect(r.facts).toEqual(facts); + } + // A nonzero child exit fails closed (values-free) — the parent never trusts a partial result. + const failSpawn = (): EnvSchemaProbeSpawnResult => ({ + status: 1, + fd3: '', + error: null, + signal: null, + }); + await expect( + resolveEnvSchemaFactsViaSubprocess({ root: tmpDir }, { ...process.env }, failSpawn), + ).rejects.toThrow(/exited with code/); + }); + + it('rejects a custom Vite envDir (resolves the config in the child, reads the effective envDir)', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), + 'vite.config.ts': `export default { envDir: './cfg' };`, + 'cfg/.gitkeep': '', + }); + const r = await resolveEnvSchemaFactsViaSubprocess({ root }, withTransform({ ...process.env })); + expect(r.kind).toBe('reject'); + if (r.kind === 'reject') { + // Only the fixed reason code crosses the boundary — never the raw resolved envDir string. + expect(r.reason).toBe('custom-envdir'); + expect(JSON.stringify(r)).not.toContain('cfg'); + } + }); + + it('rejects an ABSOLUTE envDir equal to the root (build`s join(root, rawEnvDir) duplicates it)', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), + // `import.meta.dirname` in the config === the project root (absolute). Vite normalizes it + // back to `resolved.root` (would ACCEPT), but the build's `join(root, )` + // duplicates the path — the probe reads the RAW envDir and rejects via that computation. + 'vite.config.ts': viteConfigWithVoidEnvStub('import.meta.dirname'), + }); + const r = await resolveEnvSchemaFactsViaSubprocess({ root }, withTransform({ ...process.env })); + expect(r.kind).toBe('reject'); + if (r.kind === 'reject') { + expect(r.reason).toBe('custom-envdir'); + // Only the fixed reason code crosses the boundary — never the raw (absolute) envDir string. + expect(JSON.stringify(r)).not.toContain(root); + } + }); + + it('accepts envDir: "." (the root-relative dot resolves to the project root)', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), + 'env.ts': [ + `import { defineEnv } from ${JSON.stringify(srcEnvPublic)};`, + `import { string } from ${JSON.stringify(srcEnvHelpers)};`, + `export default defineEnv({ API_URL: string() });`, + ].join('\n'), + // `join(root, '.') === root` and `resolve(root, '.') === root` — both views accept. + 'vite.config.ts': viteConfigWithVoidEnvStub(`'.'`), + }); + const r = await resolveEnvSchemaFactsViaSubprocess({ root }, withTransform({ ...process.env })); + expect(r.kind).toBe('ok'); + }); + + it('accepts the default (root) envDir + production mode', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), + 'env.ts': [ + `import { defineEnv } from ${JSON.stringify(srcEnvPublic)};`, + `import { string } from ${JSON.stringify(srcEnvHelpers)};`, + `export default defineEnv({ API_URL: string() });`, + ].join('\n'), + }); + const r = await resolveEnvSchemaFactsViaSubprocess({ root }, withTransform({ ...process.env })); + expect(r.kind).toBe('ok'); + }); + + it('derives the required set under the PASSED NODE_ENV (matches the production build)', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), + 'env.ts': [ + `import { defineEnv } from ${JSON.stringify(srcEnvPublic)};`, + `import { string } from ${JSON.stringify(srcEnvHelpers)};`, + // PROD_SECRET is required only when NODE_ENV === 'production' — exactly the vector the + // pinned probe env closes. + `const prod = process.env.NODE_ENV === 'production' ? { PROD_SECRET: string() } : {};`, + `export default defineEnv({ API_URL: string(), ...prod });`, + ].join('\n'), + }); + const under = async (nodeEnv: string): Promise> => { + const env = withTransform({ ...process.env, NODE_ENV: nodeEnv }); + const r = await resolveEnvSchemaFactsViaSubprocess({ root }, env); + return r.kind === 'ok' ? [...r.facts.requiredKeys].sort() : [`<${r.kind}>`]; + }; + // Under production (the pinned build env), PROD_SECRET is required — matching the worker. + expect(await under('production')).toEqual(['API_URL', 'PROD_SECRET']); + // Under a non-production env it would NOT be — proving the pin is what makes them agree. + expect(await under('development')).toEqual(['API_URL']); + }); + + it('validate-emitted: reports the schema-INVALID emitted values by key name only', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), + 'env.ts': [ + `import { defineEnv } from ${JSON.stringify(srcEnvPublic)};`, + `import { number, string } from ${JSON.stringify(srcEnvHelpers)};`, + `export default defineEnv({ PORT: number(), API_URL: string() });`, + ].join('\n'), + // The build's emitted worker vars: PORT is present but malformed, API_URL is fine. + '.wrangler/deploy/config.json': JSON.stringify({ + configPath: '../../dist/ssr/wrangler.json', + }), + 'dist/ssr/wrangler.json': JSON.stringify({ + vars: { PORT: 'not-a-number', API_URL: 'https://example.com' }, + }), + }); + const r = await validateEmittedVarsViaSubprocess({ root }, withTransform({ ...process.env })); + expect(r.kind).toBe('invalid-keys'); + if (r.kind === 'invalid-keys') { + expect(r.invalidKeys).toEqual(['PORT']); + } + }); + + it('validate-emitted: no invalid keys when every emitted value validates', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), + 'env.ts': [ + `import { defineEnv } from ${JSON.stringify(srcEnvPublic)};`, + `import { number } from ${JSON.stringify(srcEnvHelpers)};`, + `export default defineEnv({ PORT: number() });`, + ].join('\n'), + '.wrangler/deploy/config.json': JSON.stringify({ + configPath: '../../dist/ssr/wrangler.json', + }), + 'dist/ssr/wrangler.json': JSON.stringify({ vars: { PORT: '8080' } }), + }); + const r = await validateEmittedVarsViaSubprocess({ root }, withTransform({ ...process.env })); + expect(r.kind).toBe('invalid-keys'); + if (r.kind === 'invalid-keys') { + expect(r.invalidKeys).toEqual([]); + } + }); + + it('the child env carries NONE of the full wrangler-auth credential set (all aliases scrubbed)', async () => { + const savedNodeOptions = process.env.NODE_OPTIONS; + // The FULL wrangler-auth credential set + aliases set on the parent; a d1 app whose + // env.ts leaks them. + const authVars = WRANGLER_AUTH_VARS; + const saved: Record = {}; + for (const name of authVars) { + saved[name] = process.env[name]; + process.env[name] = `secret-${name}`; + } + process.env.NODE_OPTIONS = [process.env.NODE_OPTIONS, '--experimental-transform-types'] + .filter(Boolean) + .join(' '); + const leakFile = join(tmpDir, 'auth-scrub-leak.json'); + try { + const root = d1Project(PROVISIONED_D1, { + 'package.json': JSON.stringify({ name: 'myapp', type: 'module' }), + 'env.ts': [ + `import { defineEnv } from ${JSON.stringify(srcEnvPublic)};`, + `import { string } from ${JSON.stringify(srcEnvHelpers)};`, + `import { writeFileSync } from 'node:fs';`, + `writeFileSync(${JSON.stringify(leakFile)}, JSON.stringify({`, + ...authVars.map((name) => ` ${name}: process.env[${JSON.stringify(name)}] ?? null,`), + `}));`, + `export default defineEnv({ FOO: string() });`, + ].join('\n'), + }); + await runCloudflareDeploy( + root, + { backend: 'cloudflare' }, + silentCliLog, + harness({ resolveSchemaFacts: undefined }).deps, + ); + const leaked = JSON.parse(readFileSync(leakFile, 'utf-8')) as Record; + // The probe needs no credentials — the child sees none of them, canonical OR alias. + for (const name of authVars) { + expect(leaked[name]).toBeNull(); + } + } finally { + for (const name of authVars) { + if (saved[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = saved[name]; + } + } + if (savedNodeOptions === undefined) { + delete process.env.NODE_OPTIONS; + } else { + process.env.NODE_OPTIONS = savedNodeOptions; + } + } + }); + + it('a child that prints secrets to stdout AND stderr cannot leak them — the fd-3 result still parses', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'envapp', type: 'module' }), + 'env.ts': [ + `import { defineEnv } from ${JSON.stringify(srcEnvPublic)};`, + `import { string } from ${JSON.stringify(srcEnvHelpers)};`, + // Both channels are IGNORED by the parent (stdio fds 1+2), so this noise reaches nothing. + `console.log('STDOUT_SECRET_7777');`, + `console.error('STDERR_SECRET_8888');`, + `export default defineEnv({ API_URL: string() });`, + ].join('\n'), + }); + const r = await resolveEnvSchemaFactsViaSubprocess({ root }, withTransform({ ...process.env })); + // fd 3 is a separate pipe, so the result parses cleanly despite the stdout/stderr noise… + expect(r.kind).toBe('ok'); + if (r.kind === 'ok') { + expect(r.facts.requiredKeys).toContain('API_URL'); + } + // …and neither printed secret rides along in the framed result. + expect(JSON.stringify(r)).not.toContain('STDOUT_SECRET_7777'); + expect(JSON.stringify(r)).not.toContain('STDERR_SECRET_8888'); + }); +}); + +// --------------------------------------------------------------------------- +// wrangler framework autoconfig / OpenNext delegation disabled +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// `--skip-build` — every gate on this path reads the artifact `vite build` +// emits, so there is nothing to validate without a build. Reject the combination +// instead of silently ignoring the flag. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — --skip-build is rejected', () => { + it('rejects --skip-build pre-account — no whoami / provision / build / migrate / deploy', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, trace, wranglerArgs, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare', skipBuild: true }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + // Pre-account: no seam ran at all. + expect(trace).toEqual([]); + expect(wranglerArgs).toEqual([]); + }); + + it('rejects --skip-build even with --provision (nothing is provisioned)', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, trace, wranglerArgs, exitCalls } = harness(); + await expect( + runCloudflareDeploy( + root, + { backend: 'cloudflare', skipBuild: true, provision: true }, + silentCliLog, + deps, + ), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + expect(wranglerArgs).toEqual([]); + }); + + it('explains that this backend validates the build artifact', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const messages: Array = []; + const restore = log.error; + log.error = (msg: string) => { + messages.push(msg); + }; + try { + const { deps } = harness(); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare', skipBuild: true }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + } finally { + log.error = restore; + } + const joined = messages.join('\n'); + expect(joined).toContain('--skip-build'); + expect(joined).toContain('--backend cloudflare'); + expect(joined).toMatch(/validat/i); + }); + + it('leaves the build running when --skip-build is absent', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, trace } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(trace).toContain('build'); + }); +}); + +describe('runCloudflareDeploy — wrangler autoconfig disabled', () => { + it('always passes --autoconfig=false so a framework marker cannot delegate the deploy', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'onext' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + // An OpenNext-style marker: wrangler's autoconfig would otherwise delegate the + // deploy to another tool. --autoconfig=false makes that impossible. + 'open-next.config.ts': `export default {};`, + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'onext' }, null, 2), + }); + const { deps, wranglerArgs } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + const deployArgs = wranglerArgs.find((a) => a[0] === 'deploy'); + expect(deployArgs).toContain('--autoconfig=false'); + }); +}); + +// --------------------------------------------------------------------------- +// Wrangler-uploader hash pin: the credentialed remote migrate / deploy execute the +// resolved `wrangler-dist/cli.js` bundle (plus the sibling chunks it loads). The build +// runs untrusted project code that can only edit FILES — including that bundle — so the +// deploy snapshots its content hash before the build and re-checks it after, failing +// closed if it changed or cannot be verified. +// --------------------------------------------------------------------------- + +describe('hashWranglerUploader — content hash of the wrangler uploader bundle dir', () => { + const distDir = join(tmpDir, 'fake-wrangler-dist'); + + /** Build a fake `wrangler-dist` dir and a resolver whose LAST argv element is its cli.js. */ + function makeFakeUploader(files: Record): (root: string) => Array { + rmSync(distDir, { recursive: true, force: true }); + mkdirSync(distDir, { recursive: true }); + for (const [name, content] of Object.entries(files)) { + const abs = join(distDir, name); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + return () => [process.execPath, join(distDir, 'cli.js')]; + } + + it('is stable when nothing changes', () => { + const resolveArgv = makeFakeUploader({ 'cli.js': 'entry', 'ProxyWorker.js': 'sibling' }); + const first = hashWranglerUploader(tmpDir, resolveArgv); + expect(typeof first).toBe('string'); + expect(hashWranglerUploader(tmpDir, resolveArgv)).toBe(first); + }); + + it('changes when cli.js itself is overwritten', () => { + const resolveArgv = makeFakeUploader({ 'cli.js': 'original' }); + const before = hashWranglerUploader(tmpDir, resolveArgv); + writeFileSync(join(distDir, 'cli.js'), 'overwritten-by-build'); + expect(hashWranglerUploader(tmpDir, resolveArgv)).not.toBe(before); + }); + + it('changes when a SIBLING chunk cli.js loads is overwritten (whole-dir surface)', () => { + const resolveArgv = makeFakeUploader({ 'cli.js': 'entry', 'ProxyWorker.js': 'sibling' }); + const before = hashWranglerUploader(tmpDir, resolveArgv); + // cli.js is untouched; only the sibling changes — still caught. + writeFileSync(join(distDir, 'ProxyWorker.js'), 'tampered-sibling'); + expect(hashWranglerUploader(tmpDir, resolveArgv)).not.toBe(before); + }); + + it('returns undefined when the resolver throws (wrangler unresolvable → fail closed)', () => { + const result = hashWranglerUploader(tmpDir, () => { + throw new Error('cannot resolve wrangler'); + }); + expect(result).toBeUndefined(); + }); + + it('returns undefined when the resolved entry does not exist', () => { + const result = hashWranglerUploader(tmpDir, () => [ + process.execPath, + join(tmpDir, 'nonexistent', 'cli.js'), + ]); + expect(result).toBeUndefined(); + }); +}); + +describe('assertWranglerUploaderUnchanged', () => { + it('passes when the before/after hashes match', () => { + expect(assertWranglerUploaderUnchanged('abc123', 'abc123')).toEqual({ ok: true }); + }); + + it('fails closed when the hash changed across the build', () => { + const result = assertWranglerUploaderUnchanged('abc123', 'xyz789'); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/modified the wrangler uploader/); + }); + + it('fails closed when the pre-build hash is undefined (unverifiable binary)', () => { + const result = assertWranglerUploaderUnchanged(undefined, 'abc123'); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/cannot be verified/); + }); + + it('fails closed when the post-build hash is undefined', () => { + const result = assertWranglerUploaderUnchanged('abc123', undefined); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/cannot be verified/); + }); +}); diff --git a/packages/void/test/unit/deploy-args.test.ts b/packages/void/test/unit/deploy-args.test.ts index 7cdf59399..c4fd887b5 100644 --- a/packages/void/test/unit/deploy-args.test.ts +++ b/packages/void/test/unit/deploy-args.test.ts @@ -92,4 +92,93 @@ describe('parseDeployArgs', () => { it('throws for unexpected positional arguments', () => { expect(() => parseDeployArgs(['my-app'])).toThrow("deploy: Unexpected argument 'my-app'."); }); + + it('parses --backend cloudflare', () => { + const result = parseDeployArgs(['--backend', 'cloudflare']); + expect(result.backend).toBe('cloudflare'); + expect(result.provision).toBeUndefined(); + }); + + it('parses --backend cloudflare --provision', () => { + const result = parseDeployArgs(['--backend', 'cloudflare', '--provision']); + expect(result.backend).toBe('cloudflare'); + expect(result.provision).toBe(true); + }); + + it('throws when --provision is used without --backend cloudflare', () => { + expect(() => parseDeployArgs(['--provision'])).toThrow( + 'deploy: --provision requires --backend cloudflare.', + ); + }); + + it('throws for an invalid --backend value', () => { + expect(() => parseDeployArgs(['--backend', 'node'])).toThrow( + "deploy: --backend must be 'cloudflare'.", + ); + }); + + it('throws when --backend is repeated even if the final value is cloudflare', () => { + expect(() => parseDeployArgs(['--backend', 'node', '--backend', 'cloudflare'])).toThrow( + 'deploy: --backend can only be specified once.', + ); + }); + + it('throws for duplicate --backend cloudflare flags', () => { + expect(() => parseDeployArgs(['--backend', 'cloudflare', '--backend', 'cloudflare'])).toThrow( + 'deploy: --backend can only be specified once.', + ); + }); + + it('throws a backend-specific message when --backend has no value', () => { + expect(() => parseDeployArgs(['--backend'])).toThrow( + "deploy: --backend requires a value ('cloudflare').", + ); + }); + + it('parses --backend cloudflare together with --skip-build (rejected later, in deploy)', () => { + const result = parseDeployArgs(['--backend', 'cloudflare', '--skip-build']); + expect(result.backend).toBe('cloudflare'); + expect(result.skipBuild).toBe(true); + }); + + // The Cloudflare backend deploys to the user's own account via wrangler — it resolves no + // Void project and takes no static-site path, so these flags cannot be honored. Accepting + // and ignoring `--project` in particular looked like it chose a destination. + it('throws when --project is used with --backend cloudflare', () => { + expect(() => parseDeployArgs(['--backend', 'cloudflare', '--project', 'foo'])).toThrow( + /--project is not supported with --backend cloudflare/, + ); + }); + + it('explains that no Void project is involved when rejecting --project', () => { + expect(() => parseDeployArgs(['--backend', 'cloudflare', '--project', 'foo'])).toThrow( + /no Void project is involved/, + ); + }); + + it('throws when --dir is used with --backend cloudflare', () => { + expect(() => parseDeployArgs(['--backend', 'cloudflare', '--dir', 'dist'])).toThrow( + /--dir is not supported with --backend cloudflare/, + ); + }); + + it('throws when --spa is used with --backend cloudflare', () => { + expect(() => parseDeployArgs(['--backend', 'cloudflare', '--spa'])).toThrow( + /--spa is not supported with --backend cloudflare/, + ); + }); + + it('still parses --project, --dir and --spa without --backend (managed path unaffected)', () => { + const result = parseDeployArgs(['--project', 'foo', '--dir', 'dist', '--spa']); + expect(result.backend).toBeUndefined(); + expect(result.projectSlug).toBe('foo'); + expect(result.dir).toBe('dist'); + expect(result.spa).toBe(true); + }); + + it('leaves backend undefined when no --backend is given (managed path)', () => { + const result = parseDeployArgs([]); + expect(result.backend).toBeUndefined(); + expect(result.provision).toBeUndefined(); + }); }); diff --git a/packages/void/test/unit/deploy-drift.test.ts b/packages/void/test/unit/deploy-drift.test.ts index 528b67f14..18dba2b39 100644 --- a/packages/void/test/unit/deploy-drift.test.ts +++ b/packages/void/test/unit/deploy-drift.test.ts @@ -18,6 +18,24 @@ const packageRoot = fileURLToPath(new URL('../..', import.meta.url)); const drizzleKitBin = join(fileURLToPath(import.meta.resolve('drizzle-kit')), '..', 'bin.cjs'); const tempDirs: Array = []; +/** + * Wrangler's FULL credential env-var set (canonical names + deprecated `CF_*` aliases), + * mirroring `WRANGLER_AUTH_CREDENTIAL_ENV_VARS` in deploy.ts. + */ +const WRANGLER_AUTH_VARS = [ + 'CLOUDFLARE_API_TOKEN', + 'CF_API_TOKEN', + 'CLOUDFLARE_API_KEY', + 'CF_API_KEY', + 'CLOUDFLARE_EMAIL', + 'CF_EMAIL', + 'CLOUDFLARE_API_USER_SERVICE_KEY', + 'WRANGLER_CF_AUTHORIZATION_TOKEN', + 'WRANGLER_R2_SQL_AUTH_TOKEN', + 'CLOUDFLARE_ACCESS_CLIENT_ID', + 'CLOUDFLARE_ACCESS_CLIENT_SECRET', +]; + describe('hasDeploySchemaDrift', () => { afterEach(() => { while (tempDirs.length > 0) { @@ -52,6 +70,56 @@ describe('hasDeploySchemaDrift', () => { expect(existsSync(join(root, '.void', 'deploy-drift-check'))).toBe(false); }); + it('spawns drizzle-kit without any wrangler auth credential (it loads user schema code)', () => { + const root = createTempApp(); + const leakFile = join(root, 'leaked-env.json'); + // drizzle-kit imports the user's schema module, so this side effect runs inside the + // child and records exactly what it can read. Generating migrations authenticates + // against nothing, so none of these may be visible. + writeFileSync( + join(root, 'db', 'schema.ts'), + [ + 'import { writeFileSync } from "node:fs";', + 'import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";', + '', + `writeFileSync(${JSON.stringify(leakFile)}, JSON.stringify({`, + ...WRANGLER_AUTH_VARS.map( + (name) => ` ${name}: process.env[${JSON.stringify(name)}] ?? null,`, + ), + '}));', + '', + 'export const users = sqliteTable("users", {', + ' id: integer("id").primaryKey({ autoIncrement: true }),', + ' name: text("name").notNull(),', + '});', + '', + ].join('\n'), + ); + + const saved: Record = {}; + for (const name of WRANGLER_AUTH_VARS) { + saved[name] = process.env[name]; + process.env[name] = `secret-${name}`; + } + try { + // The managed path reaches this helper too, and never loads the provision module — + // so this also proves the scrub does not depend on that module being present. + hasDeploySchemaDrift(root, 'sqlite'); + const leaked = JSON.parse(readFileSync(leakFile, 'utf-8')) as Record; + for (const name of WRANGLER_AUTH_VARS) { + expect(leaked[name]).toBeNull(); + } + } finally { + for (const name of WRANGLER_AUTH_VARS) { + if (saved[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = saved[name]; + } + } + } + }); + it('throws when drizzle-kit cannot evaluate the schema', () => { const root = createTempApp(); writeFileSync( diff --git a/packages/void/test/unit/env-validation-selfhost.test.ts b/packages/void/test/unit/env-validation-selfhost.test.ts new file mode 100644 index 000000000..36702de89 --- /dev/null +++ b/packages/void/test/unit/env-validation-selfhost.test.ts @@ -0,0 +1,1202 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + collectPlaintextVarWarningsFromVars, + computeInvalidEmittedKeys, + createDefaultSecretListRunner, + deriveSelfHostEnvContextFromVars, + deriveSelfHostSchemaFacts, + dischargeSelfHostEnvContext, + readEmittedWorkerVars, + secretListEnv, + SelfHostEnvValidationError, + SelfHostFirstDeployError, + SelfHostSchemaError, + SelfHostSecretListError, + type SecretListRunResult, + type SelfHostSchemaFacts, +} from '../../src/cli/env-validation.ts'; +import { number, string } from '../../src/runtime/env-helpers.ts'; +import { defineEnv } from '../../src/runtime/env-public.ts'; +import { _resetUnmaskCacheForTests } from '../../src/shared/env-mask.ts'; +import type { StandardSchemaV1 } from '../../src/standard-schema.ts'; + +/** + * Unit coverage for the redesigned self-host (`void deploy --backend cloudflare`) + * secret gate. The gate now reads GROUND TRUTH — the REAL worker `vars` the build + * emitted into `dist/ssr/wrangler.json` — plus the env-insensitive schema facts, + * instead of a pre-build `loadEnv` facsimile. No real wrangler and no network + * anywhere: the gate tests inject a stub `RunSecretList` seam; the default-runner + * tests launch tiny Node scripts through the launcher seam (same convention as + * provision.test.ts). + * + * Schema registration for `deriveSelfHostSchemaFacts` is done directly via + * `defineEnv` (a process-wide singleton) with an empty `env.ts` on disk, exactly + * like test/integration/env-prod-validation.test.ts. + */ + +const roots: Array = []; + +function createTempProject(opts?: { withEnvTs?: boolean }): string { + const root = mkdtempSync(join(tmpdir(), 'void-selfhost-env-')); + roots.push(root); + if (opts?.withEnvTs !== false) { + // Empty module body is enough — schema is pre-registered via defineEnv. + writeFileSync(join(root, 'env.ts'), 'export default {};\n'); + } + return root; +} + +beforeEach(() => { + // Clear any schema that leaked from a prior test. `defineEnv({})` resets + // the registry (the presence flag stays on, which is fine — an empty + // schema always validates clean). + defineEnv({}); +}); + +afterEach(() => { + for (const root of roots.splice(0, roots.length)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +// A stub `RunSecretList` seam that records its argv and returns a fixed result. +function stubRunner(result: SecretListRunResult): { + run: (args: Array) => SecretListRunResult; + calls: Array>; +} { + const calls: Array> = []; + return { + calls, + run: (args: Array) => { + calls.push(args); + return result; + }, + }; +} + +// The stdout `wrangler secret list --format=json` prints for a given name set. +function okList(names: Array): SecretListRunResult { + return { + stdout: JSON.stringify( + names.map((name) => ({ name, type: 'secret_text' })), + null, + ' ', + ), + stderr: '', + status: 0, + }; +} + +const CONFIG_PATH = '/tmp/proj/wrangler.jsonc'; + +// A schema requiring exactly one non-secret key, reused across the gate tests. +const requiredStripe: SelfHostSchemaFacts = { + hasSchema: true, + requiredKeys: ['STRIPE_KEY'], + secretKeys: [], +}; + +// --------------------------------------------------------------------------- +// PURE unit — deriveSelfHostEnvContextFromVars (emitted vars + schema facts) +// --------------------------------------------------------------------------- + +describe('deriveSelfHostEnvContextFromVars — ground-truth context from the emitted worker vars', () => { + it('a required key PRESENT-and-EMPTY in the emitted vars is present-empty (shadows a remote secret)', () => { + const ctx = deriveSelfHostEnvContextFromVars({ STRIPE_KEY: '' }, requiredStripe); + + expect(ctx.hasSchema).toBe(true); + expect(ctx.presentEmptyRequired).toEqual(['STRIPE_KEY']); + expect(ctx.presentEmptySources).toEqual({ STRIPE_KEY: { kind: 'emitted' } }); + expect(ctx.absentRequired).toEqual([]); + expect(ctx.invalidKeys).toEqual([]); + }); + + it('a required key ABSENT from the emitted vars is absent (only a remote secret can discharge it)', () => { + const ctx = deriveSelfHostEnvContextFromVars({}, requiredStripe); + + expect(ctx.absentRequired).toEqual(['STRIPE_KEY']); + expect(ctx.presentEmptyRequired).toEqual([]); + expect(ctx.presentEmptySources).toEqual({}); + expect(ctx.invalidKeys).toEqual([]); + }); + + it('a required key PRESENT-and-NON-EMPTY is neither empty nor absent', () => { + const ctx = deriveSelfHostEnvContextFromVars({ STRIPE_KEY: 'sk_live' }, requiredStripe); + + expect(ctx.presentEmptyRequired).toEqual([]); + expect(ctx.absentRequired).toEqual([]); + }); + + it('a required key PRESENT-and-NULL is present-empty too (null shadows like an empty string)', () => { + // A parsed wrangler.json var can be null; the validators bucket null as "missing", so + // without this it would be neither invalid, empty, nor absent — yet still shadow the secret. + const ctx = deriveSelfHostEnvContextFromVars({ STRIPE_KEY: null }, requiredStripe); + + expect(ctx.presentEmptyRequired).toEqual(['STRIPE_KEY']); + expect(ctx.presentEmptySources).toEqual({ STRIPE_KEY: { kind: 'emitted' } }); + expect(ctx.absentRequired).toEqual([]); + }); + + it('hasSchema:false yields the empty context shape (nothing to gate)', () => { + const ctx = deriveSelfHostEnvContextFromVars( + { STRIPE_KEY: '' }, + { hasSchema: false, requiredKeys: [], secretKeys: [] }, + ); + + expect(ctx).toEqual({ + hasSchema: false, + presentEmptyRequired: [], + presentEmptySources: {}, + absentRequired: [], + invalidKeys: [], + }); + }); +}); + +// --------------------------------------------------------------------------- +// PURE unit — collectPlaintextVarWarningsFromVars (emitted vars + secret flags) +// --------------------------------------------------------------------------- + +describe('collectPlaintextVarWarningsFromVars — plaintext worker-vars classification', () => { + it('classifies .secret()-flagged, heuristic, and benign emitted keys; sorted, keys only', () => { + // APP_PEPPER is `.secret()`-flagged (it does NOT trip the name heuristic on + // its own). API_TOKEN trips the heuristic but is not flagged. GREETING is benign. + const result = collectPlaintextVarWarningsFromVars( + { APP_PEPPER: 'super-pepper-value', API_TOKEN: 'heuristic-value', GREETING: 'hello' }, + ['APP_PEPPER'], + ); + + // Every emitted key is counted… + expect(result.totalVarKeys).toBe(3); + // …and the warnings are sorted by key (API_TOKEN < APP_PEPPER). + expect(result.secretKeys).toEqual([ + { key: 'API_TOKEN', reason: 'heuristic' }, + { key: 'APP_PEPPER', reason: 'schema-secret' }, + ]); + // Keys only — no emitted value ever rides along on the result. + const serialized = JSON.stringify(result); + expect(serialized).not.toContain('super-pepper-value'); + expect(serialized).not.toContain('heuristic-value'); + expect(serialized).not.toContain('hello'); + }); + + it('warns nothing for a benign-only set but still counts every key', () => { + const result = collectPlaintextVarWarningsFromVars({ GREETING: 'hi', REGION: 'us' }, []); + + expect(result.secretKeys).toEqual([]); + expect(result.totalVarKeys).toBe(2); + }); + + it('warns on a credential-bearing connection URL whose KEY name looks benign', () => { + // DATABASE_URL trips NO name heuristic (SECRET_KEY_RE has no URL/DATABASE branch) + // and no shared value heuristic either (HIGH_ENTROPY_RE excludes `@`), so before + // the value-shape check this shipped as plaintext with no warning at all. + const result = collectPlaintextVarWarningsFromVars( + { DATABASE_URL: 'postgres://admin:hunter2@db.example.com:5432/app' }, + [], + ); + + expect(result.secretKeys).toEqual([{ key: 'DATABASE_URL', reason: 'value-shape' }]); + // The value decided the classification but must never ride along on the result. + const serialized = JSON.stringify(result); + expect(serialized).not.toContain('hunter2'); + expect(serialized).not.toContain('db.example.com'); + expect(serialized).not.toContain('admin'); + }); + + it('flags the credential URL shape across schemes, but not a benign URL value', () => { + const result = collectPlaintextVarWarningsFromVars( + { + // Credential-bearing → flagged regardless of scheme. Every key name here is + // deliberately one the NAME heuristic does NOT match, so only the value can + // have caused the warning. + MYSQL_ENDPOINT: 'mysql://root:s3cr3t@10.0.0.4/prod', + REDIS_ENDPOINT: 'rediss://default:abc123@cache.internal:6379', + // Benign URLs → NOT flagged: no userinfo, or userinfo with no password. + PUBLIC_SITE: 'https://example.com/a/b?c=d', + USERINFO_ONLY: 'https://someuser@example.com/path', + NO_USERINFO: 'postgres://db.example.com:5432/app', + // A bare value containing an @ is not a URL at all. + CONTACT: 'ops@example.com', + }, + [], + ); + + expect(result.secretKeys).toEqual([ + { key: 'MYSQL_ENDPOINT', reason: 'value-shape' }, + { key: 'REDIS_ENDPOINT', reason: 'value-shape' }, + ]); + expect(result.totalVarKeys).toBe(6); + }); + + it('keeps the KEY-name heuristic and .secret() flags winning over the value shape', () => { + // Regression guard: the value-shape check is additive — an existing secret-looking + // KEY is still reported as `heuristic`, and a flagged key still as `schema-secret`, + // even when its value ALSO happens to be a credential URL. + const result = collectPlaintextVarWarningsFromVars( + { + API_TOKEN: 'plain-token-value', + APP_PEPPER: 'postgres://u:p@h/db', + DB_URI: 'postgres://u:p@h/db', + }, + ['APP_PEPPER'], + ); + + expect(result.secretKeys).toEqual([ + { key: 'API_TOKEN', reason: 'heuristic' }, + { key: 'APP_PEPPER', reason: 'schema-secret' }, + { key: 'DB_URI', reason: 'value-shape' }, + ]); + }); + + it('ignores non-string emitted values without throwing', () => { + const result = collectPlaintextVarWarningsFromVars( + { PORT: 8080, FLAG: true, NOTHING: null, NESTED: { url: 'postgres://u:p@h/db' } }, + [], + ); + + expect(result.secretKeys).toEqual([]); + expect(result.totalVarKeys).toBe(4); + }); +}); + +// --------------------------------------------------------------------------- +// deriveSelfHostSchemaFacts — env-insensitive schema facts (imports env.ts) +// --------------------------------------------------------------------------- + +describe('deriveSelfHostSchemaFacts — env-insensitive schema facts', () => { + it('derives required key names + .secret() flags; a `.default()` key is NOT required', async () => { + const root = createTempProject(); + defineEnv({ + STRIPE_KEY: string().secret(), + API_URL: string(), + GREETING: string().default('hi'), + }); + + const facts = await deriveSelfHostSchemaFacts(root); + + expect(facts.hasSchema).toBe(true); + // STRIPE_KEY + API_URL reject `undefined` → required; GREETING has a default → not required. + expect([...facts.requiredKeys].sort()).toEqual(['API_URL', 'STRIPE_KEY']); + expect(facts.requiredKeys).not.toContain('GREETING'); + // Only the explicitly `.secret()`-flagged key is a schema secret. + expect(facts.secretKeys).toEqual(['STRIPE_KEY']); + }); + + it('a project with no env.ts has no schema facts', async () => { + const root = createTempProject({ withEnvTs: false }); + + const facts = await deriveSelfHostSchemaFacts(root); + + expect(facts).toEqual({ hasSchema: false, requiredKeys: [], secretKeys: [] }); + }); + + // --- schema load / validator throws never leak a value, even under VOID_ENV_UNMASK --- + const SENTINEL = 'SENTINEL-schema-sk_live_leak-QQZ'; + + /** Everything an observer could read off the thrown schema error. */ + function serializeSchemaError(error: unknown): string { + const e = error as SelfHostSchemaError & Record; + return JSON.stringify({ + name: e.name, + message: e.message, + stack: e.stack ?? '', + reason: e.reason, + own: e, + ownStr: String(e), + }); + } + + it('a validator that THROWS while probing requiredness becomes a values-free SelfHostSchemaError', async () => { + const root = createTempProject(); + defineEnv({ + FOO: { + '~standard': { + version: 1, + vendor: 'test', + validate() { + throw new Error(`validator rejected ${SENTINEL}`); + }, + }, + } satisfies StandardSchemaV1, + }); + + vi.stubEnv('VOID_ENV_UNMASK', '1'); + _resetUnmaskCacheForTests(); + try { + const error = await deriveSelfHostSchemaFacts(root).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(SelfHostSchemaError); + expect((error as SelfHostSchemaError).reason).toBe('schema-validation-error'); + expect((error as Error).message).not.toContain(SENTINEL); + expect(serializeSchemaError(error)).not.toContain(SENTINEL); + } finally { + vi.unstubAllEnvs(); + _resetUnmaskCacheForTests(); + } + }); + + it('an env.ts that THROWS at evaluation becomes a values-free SelfHostSchemaError', async () => { + const root = createTempProject({ withEnvTs: false }); + // A module that throws at evaluation, embedding the sentinel. Node's import() + // rejects; loadUserEnvFile would fold that message into its wrapper — the + // boundary must drop it. + writeFileSync(join(root, 'env.ts'), `throw new Error('env boom ${SENTINEL}');\n`); + + vi.stubEnv('VOID_ENV_UNMASK', '1'); + _resetUnmaskCacheForTests(); + try { + const error = await deriveSelfHostSchemaFacts(root).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(SelfHostSchemaError); + expect((error as SelfHostSchemaError).reason).toBe('schema-load-failed'); + expect((error as Error).message).not.toContain(SENTINEL); + expect(serializeSchemaError(error)).not.toContain(SENTINEL); + } finally { + vi.unstubAllEnvs(); + _resetUnmaskCacheForTests(); + } + }); +}); + +// --------------------------------------------------------------------------- +// computeInvalidEmittedKeys — VALUE-validate the REAL emitted worker vars against +// the schema validators, returning the FAILING key NAMES only (values-free). +// --------------------------------------------------------------------------- + +describe('computeInvalidEmittedKeys — schema-invalid emitted values', () => { + it('flags a required var whose emitted value fails the validator (present, non-empty, malformed)', async () => { + const root = createTempProject(); + defineEnv({ PORT: number(), API_URL: string() }); + // PORT ships a non-numeric value; API_URL is fine. + const invalid = await computeInvalidEmittedKeys(root, { + PORT: 'not-a-number', + API_URL: 'https://example.com', + }); + expect(invalid).toEqual(['PORT']); + }); + + it('returns no invalid keys when every emitted value validates', async () => { + const root = createTempProject(); + defineEnv({ PORT: number() }); + expect(await computeInvalidEmittedKeys(root, { PORT: '8080' })).toEqual([]); + }); + + it('does NOT count a genuinely-ABSENT required value as invalid (a remote secret can still discharge it)', async () => { + const root = createTempProject(); + defineEnv({ PORT: number(), TOKEN: string() }); + // Neither key is present in the emitted map — both required keys are ABSENT, the only + // state a same-named remote `wrangler secret list` secret can discharge. So neither is a + // hard invalid here (the context's absent fence handles them), even though the runtime + // buckets both as "missing". + expect(await computeInvalidEmittedKeys(root, {})).toEqual([]); + }); + + it('counts a required value SHIPPED empty (present in the emitted map but rejected)', async () => { + const root = createTempProject(); + defineEnv({ TOKEN: string() }); + // TOKEN was baked into the worker vars as "" — shipped-but-rejected. The runtime buckets + // it as "missing", but because it is PRESENT in the emitted map it fails closed here too + // (the context's present-empty fence also covers it; the two agree, discharge fences first). + expect(await computeInvalidEmittedKeys(root, { TOKEN: '' })).toEqual(['TOKEN']); + }); + + it('flags an OPTIONAL validator key SHIPPED as "" or null (rejected but bucketed "missing")', async () => { + const root = createTempProject(); + // A validator that rejects `""`/`null` but accepts `undefined` — exactly `z.url().optional()`. + // The runtime buckets a rejected empty/null as "missing" (not "invalid"), and the + // required-empty fence covers only `requiredKeys`, so an OPTIONAL key shipped empty would + // slip EVERY gate unless the present-in-emitted union catches it here. + const optionalRejectingEmpty: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + validate(value) { + if (value === undefined) { + return { value: undefined }; + } + if (typeof value === 'string' && value.length > 0) { + return { value }; + } + return { issues: [{ message: 'invalid' }] }; + }, + }, + }; + defineEnv({ OPT_URL: optionalRejectingEmpty }); + // Both empty spellings ship a present-but-rejected value → flagged. + expect(await computeInvalidEmittedKeys(root, { OPT_URL: '' })).toEqual(['OPT_URL']); + expect(await computeInvalidEmittedKeys(root, { OPT_URL: null })).toEqual(['OPT_URL']); + // A valid optional value validates clean; an absent optional key (accepted undefined) is + // never flagged. + expect(await computeInvalidEmittedKeys(root, { OPT_URL: 'https://ok.dev' })).toEqual([]); + expect(await computeInvalidEmittedKeys(root, {})).toEqual([]); + }); + + it('returns [] when the project registered no schema', async () => { + const root = createTempProject(); + // beforeEach reset the registry to an empty schema — nothing to validate. + expect(await computeInvalidEmittedKeys(root, { ANYTHING: 'x' })).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// readEmittedWorkerVars — follow the CF deploy pointer to the emitted vars. +// --------------------------------------------------------------------------- + +describe('readEmittedWorkerVars — ground-truth worker vars via the deploy pointer', () => { + it('reads the vars the build baked into the generated wrangler config', () => { + const root = createTempProject(); + // The pointer lives at `.wrangler/deploy/config.json` and redirects to the generated config. + mkdirSync(join(root, '.wrangler', 'deploy'), { recursive: true }); + writeFileSync( + join(root, '.wrangler', 'deploy', 'config.json'), + JSON.stringify({ configPath: '../../dist/ssr/wrangler.json' }), + ); + mkdirSync(join(root, 'dist', 'ssr'), { recursive: true }); + writeFileSync( + join(root, 'dist', 'ssr', 'wrangler.json'), + JSON.stringify({ vars: { APP_NAME: 'demo', PORT: '8080' } }), + ); + const result = readEmittedWorkerVars(root); + expect(result).toEqual({ ok: true, vars: { APP_NAME: 'demo', PORT: '8080' } }); + }); + + it('fails closed when the build wrote no deploy pointer', () => { + const root = createTempProject(); + const result = readEmittedWorkerVars(root); + expect(result.ok).toBe(false); + }); + + it('fails closed on a present-but-malformed vars field (array/scalar), never normalizes to {}', () => { + for (const badVars of [[], 'nope', 42, null] as Array) { + const root = createTempProject(); + mkdirSync(join(root, '.wrangler', 'deploy'), { recursive: true }); + writeFileSync( + join(root, '.wrangler', 'deploy', 'config.json'), + JSON.stringify({ configPath: '../../dist/ssr/wrangler.json' }), + ); + mkdirSync(join(root, 'dist', 'ssr'), { recursive: true }); + writeFileSync(join(root, 'dist', 'ssr', 'wrangler.json'), JSON.stringify({ vars: badVars })); + const result = readEmittedWorkerVars(root); + expect(result.ok).toBe(false); + } + }); + + it('treats an ABSENT vars field as an empty map (a worker with no vars)', () => { + const root = createTempProject(); + mkdirSync(join(root, '.wrangler', 'deploy'), { recursive: true }); + writeFileSync( + join(root, '.wrangler', 'deploy', 'config.json'), + JSON.stringify({ configPath: '../../dist/ssr/wrangler.json' }), + ); + mkdirSync(join(root, 'dist', 'ssr'), { recursive: true }); + writeFileSync(join(root, 'dist', 'ssr', 'wrangler.json'), JSON.stringify({ name: 'x' })); + expect(readEmittedWorkerVars(root)).toEqual({ ok: true, vars: {} }); + }); +}); + +// --------------------------------------------------------------------------- +// GATE — dischargeSelfHostEnvContext (pure parent work + one wrangler secret list) +// --------------------------------------------------------------------------- + +describe('dischargeSelfHostEnvContext — ground-truth secret gate', () => { + // Minimal wrangler.jsonc fixture (account_id + name) — the pinned `-c` target. + function writeConfig(root: string, config: Record): string { + const path = join(root, 'wrangler.jsonc'); + writeFileSync(path, JSON.stringify(config)); + return path; + } + + it('a required key ABSENT from the emitted vars AND from the remote list fails CLOSED naming it', async () => { + const root = createTempProject({ withEnvTs: false }); + const configPath = writeConfig(root, { name: 'app', account_id: 'acct-1' }); + // STRIPE_KEY is absent from the emitted vars — only a remote secret could discharge it. + const ctx = deriveSelfHostEnvContextFromVars({}, requiredStripe); + const { run } = stubRunner(okList([])); // no remote secret + + const error = await dischargeSelfHostEnvContext(ctx, { + root, + configPath, + accountId: 'acct-1', + runWrangler: run, + }).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(SelfHostEnvValidationError); + const envError = error as SelfHostEnvValidationError; + expect(envError.report.missing).toEqual(['STRIPE_KEY']); + expect(envError.message).toContain('STRIPE_KEY'); + expect(envError.message).toContain('wrangler secret put'); + }); + + it('a required key ABSENT locally but PRESENT as a remote secret is discharged (passes, pinned argv)', async () => { + const root = createTempProject({ withEnvTs: false }); + const configPath = writeConfig(root, { name: 'app', account_id: 'acct-1' }); + const ctx = deriveSelfHostEnvContextFromVars({}, requiredStripe); + const { run, calls } = stubRunner(okList(['STRIPE_KEY'])); + + const result = await dischargeSelfHostEnvContext(ctx, { + root, + configPath, + accountId: 'acct-1', + runWrangler: run, + }); + + expect(result).toEqual({ hasSchema: true, remoteSecretNames: ['STRIPE_KEY'] }); + // The exact wrangler invocation: subcommand, pinned JSON format, pinned config, pinned empty env. + expect(calls).toEqual([['secret', 'list', '--format=json', '-c', configPath, '--env=']]); + }); + + it('a required key PRESENT-and-EMPTY in the emitted vars fails CLOSED before any wrangler spawn', async () => { + const root = createTempProject({ withEnvTs: false }); + // The build baked `STRIPE_KEY=""` into the emitted worker vars, which wrangler + // gives precedence over a same-named remote secret — so it fails closed ALWAYS. + const ctx = deriveSelfHostEnvContextFromVars({ STRIPE_KEY: '' }, requiredStripe); + const { run, calls } = stubRunner(okList(['STRIPE_KEY'])); + + const error = await dischargeSelfHostEnvContext(ctx, { + root, + configPath: CONFIG_PATH, + runWrangler: run, + }).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(SelfHostEnvValidationError); + const envError = error as SelfHostEnvValidationError; + expect(envError.report.missing).toEqual(['STRIPE_KEY']); + expect(envError.message).toContain('Remove the empty assignment'); + // Names the GROUND-TRUTH source (the emitted worker config), never a .env file. + expect(envError.message).toContain('dist/ssr/wrangler.json'); + expect(envError.message).not.toContain('wrangler secret put'); + // Rejected before any wrangler spawn — remote membership is irrelevant. + expect(calls).toEqual([]); + }); + + it('a folded INVALID key (an optional value the validator rejected) fails CLOSED with no wrangler spawn', async () => { + const root = createTempProject({ withEnvTs: false }); + // No required key is absent, so the only failure is the invalid-value set the emitted-var + // probe folds in — here an optional key shipped empty that the runtime bucketed "missing" + // but `computeInvalidEmittedKeys` caught (present in the emitted map). It must fail closed + // BEFORE any remote apply: a remote secret cannot rescue an invalid SHIPPED value. + const ctx = deriveSelfHostEnvContextFromVars( + {}, + { hasSchema: true, requiredKeys: [], secretKeys: [] }, + ); + ctx.invalidKeys = ['OPT_URL']; + const { run, calls } = stubRunner(okList(['OPT_URL'])); + + const error = await dischargeSelfHostEnvContext(ctx, { + root, + configPath: CONFIG_PATH, + runWrangler: run, + }).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(SelfHostEnvValidationError); + const envError = error as SelfHostEnvValidationError; + expect(envError.report.invalid.map((entry) => entry.key)).toEqual(['OPT_URL']); + // No required key is absent → no wrangler spawn; the invalid value fails closed on its own. + expect(calls).toEqual([]); + }); + + it('a no-schema context passes without consulting wrangler', async () => { + const root = createTempProject({ withEnvTs: false }); + const ctx = deriveSelfHostEnvContextFromVars( + {}, + { hasSchema: false, requiredKeys: [], secretKeys: [] }, + ); + const { run, calls } = stubRunner(okList([])); + + const result = await dischargeSelfHostEnvContext(ctx, { + root, + configPath: CONFIG_PATH, + runWrangler: run, + }); + + expect(result).toEqual({ hasSchema: false, remoteSecretNames: null }); + expect(calls).toEqual([]); + }); + + // --- account reconciled against the config BEFORE any spawn --- + + describe('account reconciliation before spawn', () => { + it('hard-errors (naming both accounts) and NEVER spawns wrangler on a config-vs-option mismatch', async () => { + const root = createTempProject({ withEnvTs: false }); + const configPath = writeConfig(root, { name: 'app', account_id: 'account-X' }); + // Absent required key → the gate would spawn wrangler, but reconciliation fails first. + const ctx = deriveSelfHostEnvContextFromVars({}, requiredStripe); + const { run, calls } = stubRunner(okList(['STRIPE_KEY'])); + + const error = await dischargeSelfHostEnvContext(ctx, { + root, + configPath, + accountId: 'account-Y', + runWrangler: run, + }).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(SelfHostSecretListError); + expect(error).not.toBeInstanceOf(SelfHostEnvValidationError); + const msg = (error as Error).message; + expect(msg).toContain('account-X'); + expect(msg).toContain('account-Y'); + // The seam was never consulted — reconciliation failed before any spawn. + expect(calls).toEqual([]); + }); + + it('proceeds when the config account matches the option account', async () => { + const root = createTempProject({ withEnvTs: false }); + const configPath = writeConfig(root, { name: 'app', account_id: 'account-X' }); + const ctx = deriveSelfHostEnvContextFromVars({}, requiredStripe); + const { run, calls } = stubRunner(okList(['STRIPE_KEY'])); + + const result = await dischargeSelfHostEnvContext(ctx, { + root, + configPath, + accountId: 'account-X', + runWrangler: run, + }); + + expect(result).toEqual({ hasSchema: true, remoteSecretNames: ['STRIPE_KEY'] }); + expect(calls.length).toBe(1); + }); + }); + + // --- worker NAME reconciled against the config BEFORE any spawn --- + + describe('worker-name reconciliation before spawn', () => { + it('hard-errors (naming both worker names) and NEVER spawns wrangler on a config-vs-pinned name mismatch', async () => { + const root = createTempProject({ withEnvTs: false }); + // The config's name was rewritten to "evil-worker" after the deploy pinned "app" — the gate + // would list secrets for the WRONG worker, so it fails closed before any spawn. + const configPath = writeConfig(root, { name: 'evil-worker', account_id: 'acct-1' }); + // Absent required key → the gate would spawn wrangler, but name reconciliation fails first. + const ctx = deriveSelfHostEnvContextFromVars({}, requiredStripe); + const { run, calls } = stubRunner(okList(['STRIPE_KEY'])); + + const error = await dischargeSelfHostEnvContext(ctx, { + root, + configPath, + accountId: 'acct-1', + workerName: 'app', + runWrangler: run, + }).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(SelfHostSecretListError); + expect(error).not.toBeInstanceOf(SelfHostEnvValidationError); + const msg = (error as Error).message; + expect(msg).toContain('evil-worker'); + expect(msg).toContain('app'); + expect((error as SelfHostSecretListError).reason).toBe('name-mismatch'); + // The seam was never consulted — reconciliation failed before any spawn. + expect(calls).toEqual([]); + }); + + it('proceeds when the config name matches the pinned worker name', async () => { + const root = createTempProject({ withEnvTs: false }); + const configPath = writeConfig(root, { name: 'app', account_id: 'acct-1' }); + const ctx = deriveSelfHostEnvContextFromVars({}, requiredStripe); + const { run, calls } = stubRunner(okList(['STRIPE_KEY'])); + + const result = await dischargeSelfHostEnvContext(ctx, { + root, + configPath, + accountId: 'acct-1', + workerName: 'app', + runWrangler: run, + }); + + expect(result).toEqual({ hasSchema: true, remoteSecretNames: ['STRIPE_KEY'] }); + expect(calls.length).toBe(1); + }); + + it('does not gate when no pinned worker name is passed (undefined never trips)', async () => { + const root = createTempProject({ withEnvTs: false }); + // Config declares a name, but no pinned name is supplied — mirrors the account guard's + // "both set" rule, so a legitimate caller that omits the pin is never false-positived. + const configPath = writeConfig(root, { name: 'whatever', account_id: 'acct-1' }); + const ctx = deriveSelfHostEnvContextFromVars({}, requiredStripe); + const { run, calls } = stubRunner(okList(['STRIPE_KEY'])); + + const result = await dischargeSelfHostEnvContext(ctx, { + root, + configPath, + accountId: 'acct-1', + runWrangler: run, + }); + + expect(result).toEqual({ hasSchema: true, remoteSecretNames: ['STRIPE_KEY'] }); + expect(calls.length).toBe(1); + }); + }); + + // --- first-deploy (worker-not-found) surfaces a structured, actionable error --- + + describe('first-deploy worker-not-found', () => { + // The EXACT UserError wrangler 4.107 emits from `secret list` when the worker + // does not exist yet — the full fixed block rethrown after isWorkerNotFoundError. + const WORKER_NOT_FOUND_STDERR = + 'Worker "app" not found.\n\n' + + 'If this is a new Worker, run `wrangler deploy` first to create it.\n' + + "Otherwise, check that the Worker name is correct and you're logged into the right account."; + + it('throws a structured first-deploy error listing the missing names + secret-put guidance', async () => { + const root = createTempProject({ withEnvTs: false }); + const ctx = deriveSelfHostEnvContextFromVars( + {}, + { hasSchema: true, requiredKeys: ['FOO', 'API_TOKEN'], secretKeys: [] }, + ); + const { run } = stubRunner({ stdout: '', stderr: WORKER_NOT_FOUND_STDERR, status: 1 }); + + const error = await dischargeSelfHostEnvContext(ctx, { + root, + configPath: CONFIG_PATH, + runWrangler: run, + }).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(SelfHostFirstDeployError); + const fde = error as SelfHostFirstDeployError; + expect(fde.workerNotFound).toBe(true); + expect([...fde.missingKeys].sort()).toEqual(['API_TOKEN', 'FOO']); + expect(fde.message).toContain('wrangler secret put FOO'); + expect(fde.message).toContain('wrangler secret put API_TOKEN'); + // Still a fail-closed list error at heart (subclass). + expect(error).toBeInstanceOf(SelfHostSecretListError); + }); + + it('a generic nonzero exit (auth/network) stays a GENERIC list failure, not first-deploy', async () => { + const root = createTempProject({ withEnvTs: false }); + const ctx = deriveSelfHostEnvContextFromVars({}, requiredStripe); + const { run } = stubRunner({ + stdout: '', + stderr: 'A request to the Cloudflare API failed. [code: 10000]', + status: 1, + }); + + const error = await dischargeSelfHostEnvContext(ctx, { + root, + configPath: CONFIG_PATH, + runWrangler: run, + }).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(SelfHostSecretListError); + expect(error).not.toBeInstanceOf(SelfHostFirstDeployError); + expect((error as SelfHostSecretListError).reason).toBe('nonzero-exit'); + expect((error as Error).message).toContain('status 1'); + }); + }); + + // --- hard wrangler failures are DISTINCT from missing-secret errors, fail closed --- + + describe('fail-closed wrangler failures', () => { + async function expectSecretListError( + result: SecretListRunResult, + ): Promise { + const root = createTempProject({ withEnvTs: false }); + const ctx = deriveSelfHostEnvContextFromVars({}, requiredStripe); + const { run } = stubRunner(result); + const error = await dischargeSelfHostEnvContext(ctx, { + root, + configPath: CONFIG_PATH, + runWrangler: run, + }).then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(SelfHostSecretListError); + expect(error).not.toBeInstanceOf(SelfHostEnvValidationError); + return error as SelfHostSecretListError; + } + + it('a nonzero exit is a hard failure classified structurally (never echoes stderr)', async () => { + const error = await expectSecretListError({ + stdout: '', + stderr: 'A request to the Cloudflare API failed.', + status: 1, + }); + expect(error.reason).toBe('nonzero-exit'); + expect(error.detail.exitCode).toBe(1); + expect(error.message).toContain('status 1'); + expect(error.message).not.toContain('A request to the Cloudflare API failed.'); + }); + + it('a signal-killed child is a hard failure even with status 0', async () => { + const error = await expectSecretListError({ + stdout: '[]', + stderr: '', + status: 0, + signal: 'SIGKILL', + }); + expect(error.reason).toBe('signal'); + expect(error.detail.signal).toBe('SIGKILL'); + expect(error.message).toContain('SIGKILL'); + }); + + it('a spawn error is a hard failure classified structurally (never echoes the spawn text)', async () => { + const error = await expectSecretListError({ + stdout: '[]', + stderr: '', + status: 0, + error: 'spawn ENOENT', + }); + expect(error.reason).toBe('spawn-error'); + expect(error.message).not.toContain('spawn ENOENT'); + }); + + it('unparseable stdout is a hard failure', async () => { + const error = await expectSecretListError({ + stdout: 'not json at all', + stderr: '', + status: 0, + }); + expect(error.reason).toBe('malformed-output'); + }); + + it('a non-array JSON payload is a hard failure', async () => { + await expectSecretListError({ stdout: '{"secrets":[]}', stderr: '', status: 0 }); + }); + + it('ANY malformed list member fails the whole list (fail closed)', async () => { + await expectSecretListError({ + stdout: JSON.stringify([{ name: 'STRIPE_KEY' }, { oops: true }]), + stderr: '', + status: 0, + }); + }); + + it('a non-string name is a malformed member', async () => { + await expectSecretListError({ + stdout: JSON.stringify([{ name: 123 }]), + stderr: '', + status: 0, + }); + }); + + it('a rejecting seam is wrapped as a hard failure (never echoes the rejection message)', async () => { + const root = createTempProject({ withEnvTs: false }); + const ctx = deriveSelfHostEnvContextFromVars({}, requiredStripe); + const error = await dischargeSelfHostEnvContext(ctx, { + root, + configPath: CONFIG_PATH, + runWrangler: () => Promise.reject(new Error('boom')), + }).then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(SelfHostSecretListError); + expect((error as SelfHostSecretListError).reason).toBe('spawn-error'); + expect((error as Error).message).not.toContain('boom'); + }); + }); + + // --- wrangler failure diagnostics never carry a value, even under VOID_ENV_UNMASK --- + + describe('wrangler diagnostics are values-free even under VOID_ENV_UNMASK', () => { + // A sentinel "secret" a hostile/leaky wrangler could echo into stderr/stdout or + // a spawn/rejection message. It must appear NOWHERE in the thrown error. + const SENTINEL = 'SENTINEL-sk_live_51H8xLeak-abcXYZ'; + + /** Everything an observer could read off the thrown error. */ + function fullySerialize(error: unknown): string { + const e = error as SelfHostSecretListError & { + missingKeys?: Array; + workerNotFound?: boolean; + }; + return JSON.stringify({ + name: e.name, + message: e.message, + stack: e.stack ?? '', + reason: e.reason, + detail: e.detail, + missingKeys: e.missingKeys, + workerNotFound: e.workerNotFound, + own: e, + ownStr: String(e), + }); + } + + it.each([ + { + label: 'nonzero exit (sentinel in stderr)', + result: { stdout: '', stderr: `boom ${SENTINEL}`, status: 1 } as SecretListRunResult, + reason: 'nonzero-exit', + }, + { + label: 'signal (sentinel in stderr)', + result: { + stdout: '', + stderr: `killed ${SENTINEL}`, + status: 0, + signal: 'SIGKILL', + } as SecretListRunResult, + reason: 'signal', + }, + { + label: 'spawn error (sentinel in the error text)', + result: { + stdout: '', + stderr: '', + status: 0, + error: `spawn failed leaking ${SENTINEL}`, + } as SecretListRunResult, + reason: 'spawn-error', + }, + { + label: 'malformed stdout (sentinel in stdout)', + result: { stdout: `not-json ${SENTINEL}`, stderr: '', status: 0 } as SecretListRunResult, + reason: 'malformed-output', + }, + ])( + 'the thrown error is sentinel-free in message/fields/JSON — $label', + async ({ result, reason }) => { + const root = createTempProject({ withEnvTs: false }); + const ctx = deriveSelfHostEnvContextFromVars({}, requiredStripe); + const { run } = stubRunner(result); + vi.stubEnv('VOID_ENV_UNMASK', '1'); + _resetUnmaskCacheForTests(); + try { + const error = (await dischargeSelfHostEnvContext(ctx, { + root, + configPath: CONFIG_PATH, + runWrangler: run, + }).then( + () => null, + (e: unknown) => e, + )) as SelfHostSecretListError; + + expect(error).toBeInstanceOf(SelfHostSecretListError); + expect(error.reason).toBe(reason); + expect(error.message).not.toContain(SENTINEL); + expect(fullySerialize(error)).not.toContain(SENTINEL); + } finally { + vi.unstubAllEnvs(); + _resetUnmaskCacheForTests(); + } + }, + ); + + it('first-deploy classification STILL fires from the (internal-only) output, sentinel-free', async () => { + const root = createTempProject({ withEnvTs: false }); + const ctx = deriveSelfHostEnvContextFromVars( + {}, + { hasSchema: true, requiredKeys: ['FOO', 'API_TOKEN'], secretKeys: [] }, + ); + // The worker-not-found signal AND a sentinel share the child's stderr: the + // signal must be read (classification), the sentinel must not leak. + const { run } = stubRunner({ + stdout: '', + stderr: + `Worker "app" not found. ${SENTINEL}\n\n` + + 'If this is a new Worker, run `wrangler deploy` first to create it.\n' + + "Otherwise, check that the Worker name is correct and you're logged into the right account.", + status: 1, + }); + + const error = (await dischargeSelfHostEnvContext(ctx, { + root, + configPath: CONFIG_PATH, + runWrangler: run, + }).then( + () => null, + (e: unknown) => e, + )) as SelfHostFirstDeployError; + + expect(error).toBeInstanceOf(SelfHostFirstDeployError); + expect(error.workerNotFound).toBe(true); + expect([...error.missingKeys].sort()).toEqual(['API_TOKEN', 'FOO']); + expect(error.message).toContain('wrangler secret put FOO'); + expect(error.message).not.toContain(SENTINEL); + expect(fullySerialize(error)).not.toContain(SENTINEL); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Default runner — createDefaultSecretListRunner (launcher seam, no wrangler) +// --------------------------------------------------------------------------- + +describe('createDefaultSecretListRunner — async spawn with fail-closed provenance', () => { + function writeScript(root: string, name: string, source: string): string { + const file = join(root, name); + writeFileSync(file, source); + return file; + } + + it('captures stdout/stderr and a clean exit 0', async () => { + const root = createTempProject({ withEnvTs: false }); + const script = writeScript( + root, + 'ok.mjs', + `process.stdout.write(JSON.stringify([{ name: 'FOO', type: 'secret_text' }]));\n` + + `process.stderr.write('note');\n`, + ); + const run = createDefaultSecretListRunner(root, { + launcher: [process.execPath, script], + timeoutMs: 30_000, + }); + + const result = await run(['secret', 'list', '--format=json', '-c', CONFIG_PATH, '--env=']); + + expect(result.status).toBe(0); + expect(result.signal ?? null).toBeNull(); + expect(result.error ?? null).toBeNull(); + expect(JSON.parse(result.stdout)).toEqual([{ name: 'FOO', type: 'secret_text' }]); + expect(result.stderr).toBe('note'); + }); + + it('reports a nonzero exit as-is (the gate classifies it as a hard failure)', async () => { + const root = createTempProject({ withEnvTs: false }); + const script = writeScript(root, 'fail.mjs', `process.exit(3);\n`); + const run = createDefaultSecretListRunner(root, { + launcher: [process.execPath, script], + timeoutMs: 30_000, + }); + + const result = await run(['secret', 'list']); + + expect(result.status).toBe(3); + }); + + it('SIGKILLs a hung child at the hard timeout and reports the signal', async () => { + const root = createTempProject({ withEnvTs: false }); + const script = writeScript(root, 'hang.mjs', `setInterval(() => {}, 1 << 30);\n`); + const run = createDefaultSecretListRunner(root, { + launcher: [process.execPath, script], + timeoutMs: 300, + }); + + const result = await run(['secret', 'list']); + + expect(result.signal).toBe('SIGKILL'); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('timeout'); + }, 15_000); + + it('reports an unspawnable launcher as an error (never a clean exit)', async () => { + const root = createTempProject({ withEnvTs: false }); + const run = createDefaultSecretListRunner(root, { + launcher: [join(root, 'does-not-exist-binary')], + timeoutMs: 5_000, + }); + + const result = await run(['secret', 'list']); + + expect(result.error).toBeTruthy(); + expect(result.status).not.toBe(0); + }); + + describe('secretListEnv — child env pinning', () => { + it('scrubs the CF Access pair, pins sanitized logging, and pins the account id', () => { + vi.stubEnv('CF_ACCESS_CLIENT_ID', 'id'); + vi.stubEnv('CF_ACCESS_CLIENT_SECRET', 'secret'); + vi.stubEnv('WRANGLER_LOG_SANITIZE', 'false'); + try { + const env = secretListEnv('acct-1'); + expect(env.CF_ACCESS_CLIENT_ID).toBeUndefined(); + expect(env.CF_ACCESS_CLIENT_SECRET).toBeUndefined(); + expect(env.WRANGLER_LOG_SANITIZE).toBe('true'); + expect(env.CLOUDFLARE_ACCOUNT_ID).toBe('acct-1'); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('leaves ambient transport env (CLOUDFLARE_API_BASE_URL) untouched', () => { + vi.stubEnv('CLOUDFLARE_API_BASE_URL', 'https://api.staging.example/client/v4'); + try { + const env = secretListEnv('acct-1'); + expect(env.CLOUDFLARE_API_BASE_URL).toBe('https://api.staging.example/client/v4'); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('scrubs every case-variant of scrubbed/pinned keys on win32 so no ambient spelling can shadow a pin', () => { + vi.stubEnv('Cloudflare_Account_Id', 'ambient-other-account'); + vi.stubEnv('Wrangler_Log_Sanitize', 'false'); + vi.stubEnv('Cf_Access_Client_Id', 'id'); + try { + const env = secretListEnv('acct-1', 'win32'); + expect(env.Cloudflare_Account_Id).toBeUndefined(); + expect(env.Wrangler_Log_Sanitize).toBeUndefined(); + expect(env.Cf_Access_Client_Id).toBeUndefined(); + expect(env.CLOUDFLARE_ACCOUNT_ID).toBe('acct-1'); + expect(env.WRANGLER_LOG_SANITIZE).toBe('true'); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('keeps case-variants on POSIX (genuinely different variables there)', () => { + vi.stubEnv('Cloudflare_Account_Id', 'lowercase-cousin'); + try { + const env = secretListEnv('acct-1', 'linux'); + expect(env.Cloudflare_Account_Id).toBe('lowercase-cousin'); + expect(env.CLOUDFLARE_ACCOUNT_ID).toBe('acct-1'); + } finally { + vi.unstubAllEnvs(); + } + }); + }); + + it('pins CLOUDFLARE_ACCOUNT_ID and scrubs the CF Access pair in the child env', async () => { + const root = createTempProject({ withEnvTs: false }); + const script = writeScript( + root, + 'env.mjs', + `process.stdout.write(JSON.stringify({\n` + + ` account: process.env.CLOUDFLARE_ACCOUNT_ID ?? null,\n` + + ` accessId: process.env.CF_ACCESS_CLIENT_ID ?? null,\n` + + ` accessSecret: process.env.CF_ACCESS_CLIENT_SECRET ?? null,\n` + + ` sanitize: process.env.WRANGLER_LOG_SANITIZE ?? null,\n` + + `}));\n`, + ); + vi.stubEnv('CF_ACCESS_CLIENT_ID', 'service-token-id'); + vi.stubEnv('CF_ACCESS_CLIENT_SECRET', 'service-token-secret'); + try { + const run = createDefaultSecretListRunner(root, { + accountId: 'acct-123', + launcher: [process.execPath, script], + timeoutMs: 30_000, + }); + + const result = await run(['secret', 'list']); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + account: 'acct-123', + accessId: null, + accessSecret: null, + sanitize: 'true', + }); + } finally { + vi.unstubAllEnvs(); + } + }); +}); diff --git a/packages/void/test/unit/preset.test.ts b/packages/void/test/unit/preset.test.ts index 61006c2c3..e2deeb94b 100644 --- a/packages/void/test/unit/preset.test.ts +++ b/packages/void/test/unit/preset.test.ts @@ -1,7 +1,14 @@ -import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { chmodSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join, resolve } from 'pathe'; import { describe, expect, it, beforeEach, afterEach } from 'vite-plus/test'; -import { detectPreset, formatProjectCommand, resolveProjectCommand } from '../../src/cli/preset.ts'; +import { + detectPreset, + formatProjectCommand, + resolveProjectCommand, + resolveWranglerArgv, + resolveWranglerCommand, +} from '../../src/cli/preset.ts'; const TMP = join(import.meta.dirname, '.tmp-preset'); @@ -240,3 +247,206 @@ describe('project Vite commands', () => { expect(resolveProjectCommand(root, 'vite build')).toBe('vite build'); }); }); + +describe('resolveWranglerCommand', () => { + it('resolves to the local wrangler binary with the subcommand', () => { + const root = setupProject({ + pkg: { dependencies: { wrangler: '^4.0.0' } }, + files: { 'node_modules/.bin/wrangler': '' }, + }); + + expect(resolveWranglerCommand(root, 'deploy')).toBe( + `${join(root, 'node_modules', '.bin', 'wrangler')} deploy`, + ); + }); + + it('preserves multi-argument subcommands', () => { + const root = setupProject({ + pkg: { dependencies: { wrangler: '^4.0.0' } }, + files: { 'node_modules/.bin/wrangler': '' }, + }); + + expect(resolveWranglerCommand(root, 'd1 create foo --binding DB --update-config')).toBe( + `${join(root, 'node_modules', '.bin', 'wrangler')} d1 create foo --binding DB --update-config`, + ); + }); + + it('falls back to a bare wrangler command when no local binary exists', () => { + const root = setupProject({ + pkg: { dependencies: { wrangler: '^4.0.0' } }, + }); + + expect(resolveWranglerCommand(root, 'deploy')).toBe('wrangler deploy'); + }); +}); + +describe('resolveWranglerArgv (module-resolution launcher)', () => { + it('resolves wrangler via module resolution to [execPath, cli.js] even when the app root has no wrangler (strict pnpm)', () => { + // wrangler is a dependency of the `void` package, NOT the app root under strict + // pnpm. Module resolution (relative to preset.ts) still finds it. + const root = setupProject({ pkg: { name: 'app' } }); + const argv = resolveWranglerArgv(root); + expect(argv).toHaveLength(2); + expect(argv[0]).toBe(process.execPath); + // The resolved entry is wrangler's REAL CLI (`wrangler-dist/cli.js`, its + // package `main`), NOT the signal-swallowing `bin/wrangler.js` wrapper. + expect(argv[1].replace(/\\/g, '/')).toMatch(/wrangler\/wrangler-dist\/cli\.js$/); + expect(argv[1].replace(/\\/g, '/')).not.toMatch(/bin\/wrangler\.js$/); + }); + + it("prefers void's own wrangler over an incompatible app-root wrangler, resolving its REAL cli.js", () => { + // The app root pins its OWN (potentially older/incompatible) wrangler, and a + // separate "void-nested" wrangler is resolvable via module resolution. The + // provisioner targets wrangler 4.107's exact flags/JSON/errors, so void's own + // wrangler MUST win — the app-root one must not shadow it. The resolved entry + // is the `main` CLI (`wrangler-dist/cli.js`), NOT the `bin.wrangler` wrapper. + const root = setupProject({ + pkg: { dependencies: { wrangler: '^3.0.0' } }, + files: { + 'node_modules/wrangler/package.json': JSON.stringify({ + main: './wrangler-dist/cli.js', + bin: { wrangler: './bin/wrangler.js' }, + }), + 'node_modules/wrangler/wrangler-dist/cli.js': "'use strict';\n", + 'node_modules/wrangler/bin/wrangler.js': '#!/usr/bin/env node\n', + 'void_modules/wrangler/package.json': JSON.stringify({ + main: './wrangler-dist/cli.js', + bin: { wrangler: './bin/wrangler.js' }, + }), + 'void_modules/wrangler/wrangler-dist/cli.js': "'use strict';\n", + 'void_modules/wrangler/bin/wrangler.js': '#!/usr/bin/env node\n', + }, + }); + const voidWranglerPkg = join(root, 'void_modules', 'wrangler', 'package.json'); + const argv = resolveWranglerArgv(root, () => voidWranglerPkg); + // Resolved to void's nested wrangler's REAL CLI, NOT the app-root one, NOT the wrapper. + expect(argv).toEqual([ + process.execPath, + join(root, 'void_modules', 'wrangler', 'wrangler-dist', 'cli.js'), + ]); + }); + + it('resolves the CLI via `exports["."].default` when `main` is absent', () => { + // A manifest that exposes its CLI only through the `.` export condition still + // resolves to the real cli.js (never the wrapper). + const root = setupProject({ + pkg: { name: 'app' }, + files: { + 'void_modules/wrangler/package.json': JSON.stringify({ + exports: { '.': { types: './cli.d.ts', default: './wrangler-dist/cli.js' } }, + bin: { wrangler: './bin/wrangler.js' }, + }), + 'void_modules/wrangler/wrangler-dist/cli.js': "'use strict';\n", + 'void_modules/wrangler/bin/wrangler.js': '#!/usr/bin/env node\n', + }, + }); + const voidWranglerPkg = join(root, 'void_modules', 'wrangler', 'package.json'); + const argv = resolveWranglerArgv(root, () => voidWranglerPkg); + expect(argv).toEqual([ + process.execPath, + join(root, 'void_modules', 'wrangler', 'wrangler-dist', 'cli.js'), + ]); + }); + + it('falls back to the app-root wrangler only when void resolution fails, resolving its real cli.js', () => { + const root = setupProject({ + pkg: { dependencies: { wrangler: '^4.0.0' } }, + files: { + 'node_modules/wrangler/package.json': JSON.stringify({ + main: './wrangler-dist/cli.js', + bin: { wrangler: './bin/wrangler.js' }, + }), + 'node_modules/wrangler/wrangler-dist/cli.js': "'use strict';\n", + 'node_modules/wrangler/bin/wrangler.js': '#!/usr/bin/env node\n', + }, + }); + // void's own resolution returns undefined → fall back to the app-root probe. + const argv = resolveWranglerArgv(root, () => undefined); + expect(argv).toEqual([ + process.execPath, + join(root, 'node_modules', 'wrangler', 'wrangler-dist', 'cli.js'), + ]); + }); + + it('never yields a .bin shim argv and throws clearly when wrangler cannot be resolved', () => { + // A shim/`.cmd`-only layout with module resolution failing must THROW — never + // degrade to a bare `wrangler` (ENOENT in CI) or a shim Node cannot spawn with + // shell:false. + const root = setupProject({ + pkg: { name: 'app' }, + files: { 'node_modules/.bin/wrangler': '#!/bin/sh\n' }, + }); + expect(() => resolveWranglerArgv(root, () => undefined)).toThrow( + /could not resolve the "wrangler" package/, + ); + }); +}); + +describe('resolveProjectCommand shell safety (project root with spaces)', () => { + // A dedicated root whose path contains a space + a single quote — both are + // shell-significant and must be quoted before the string reaches execSync. + const SPACED_TMP = join(import.meta.dirname, ".tmp preset o'space"); + + beforeEach(() => { + rmSync(SPACED_TMP, { recursive: true, force: true }); + }); + + afterEach(() => { + rmSync(SPACED_TMP, { recursive: true, force: true }); + }); + + function setupSpacedProject(binName: string): { root: string; binPath: string } { + mkdirSync(join(SPACED_TMP, 'node_modules', '.bin'), { recursive: true }); + writeFileSync(join(SPACED_TMP, 'package.json'), JSON.stringify({ dependencies: {} })); + const binPath = join(SPACED_TMP, 'node_modules', '.bin', binName); + // Executable that prints a sentinel + its args so we can confirm the shell + // launched THIS binary (not a truncated `/…/.tmp` prefix from word-splitting). + writeFileSync(binPath, `#!/bin/sh\necho "VOID_BIN_OK args=$*"\n`); + chmodSync(binPath, 0o755); + return { root: SPACED_TMP, binPath }; + } + + it('quotes the resolved executable path so the shell does not word-split it', () => { + const { root, binPath } = setupSpacedProject('wrangler'); + const cmd = resolveWranglerCommand(root, 'deploy'); + + // The bin path (which contains a space and a single quote) must be quoted + // as a single token, not left as a bare substring the shell would split. + expect(cmd).not.toBe(`${binPath} deploy`); + expect(cmd.endsWith(' deploy')).toBe(true); + if (process.platform === 'win32') { + expect(cmd).toBe(`"${binPath}" deploy`); + } else { + // POSIX single-quote wrap, embedded `'` escaped as '\''. + expect(cmd).toBe(`'${binPath.replace(/'/g, "'\\''")}' deploy`); + } + }); + + it.skipIf(process.platform === 'win32')( + 'executes the correct binary through a shell for a spaced root', + () => { + const { root } = setupSpacedProject('wrangler'); + const cmd = resolveWranglerCommand(root, 'deploy --dry-run'); + const out = execSync(cmd, { encoding: 'utf-8' }); + expect(out).toContain('VOID_BIN_OK'); + expect(out).toContain('args=deploy --dry-run'); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'shell-quotes the vite build path for a spaced root', + () => { + const { binPath } = setupSpacedProject('vp'); + // vite-plus present → `vite build` resolves to the local `vp` binary. + writeFileSync( + join(SPACED_TMP, 'package.json'), + JSON.stringify({ devDependencies: { 'vite-plus': '^1.0.0' } }), + ); + const cmd = resolveProjectCommand(SPACED_TMP, 'vite build'); + expect(cmd).toBe(`'${binPath.replace(/'/g, "'\\''")}' build`); + const out = execSync(cmd, { encoding: 'utf-8' }); + expect(out).toContain('VOID_BIN_OK'); + expect(out).toContain('args=build'); + }, + ); +}); diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts new file mode 100644 index 000000000..f58cfea16 --- /dev/null +++ b/packages/void/test/unit/provision.test.ts @@ -0,0 +1,7585 @@ +import { + existsSync, + mkdir, + mkdirSync, + readdirSync, + readFileSync, + realpath, + realpathSync, + rmdir, + rmdirSync, + rmSync, + stat, + symlinkSync, + utimes, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { types as nodeUtilTypes } from 'node:util'; +import { parse as parseJsoncRaw } from 'jsonc-parser'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { + acquireProvisionLock, + assertNoTransportRedirectInProjectDotenv, + checkCloudflareProvisionDrift, + createDefaultRunWrangler, + isAlreadyGoneKillError, + normalizeRunWranglerResult, + provisionCloudflareResources, + provisionEnv, + resolvePinnedApiBaseUrl, + WranglerQuarantineError, + type DesiredResourceSet, + type RunWrangler, + type RunWranglerResult, +} from '../../src/cli/provision.ts'; + +/** + * A passthrough of proper-lockfile's callback-style fs seam (mkdir/stat/utimes/rmdir/ + * realpath + a sync rmdir). The unlock-failure test overrides `rmdir` on top of this. + */ +const passthruFs = { mkdir, stat, utimes, rmdir, realpath, rmdirSync }; + +const tmpDir = join(import.meta.dirname, '.tmp-provision-test'); +const wranglerPath = join(tmpDir, 'wrangler.jsonc'); + +function setup(content: string): void { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + writeFileSync(wranglerPath, content); +} + +/** + * The provisioner requires a resolvable Cloudflare account whenever `desired` + * implies remote work (the boundary account fence), regardless of injected vs + * default seams. Real deploys always have one (`CLOUDFLARE_ACCOUNT_ID` or a config + * `account_id`); mirror that by pinning an ambient account for every test, so the + * fence is satisfied without threading `accountId` through each call. Tests that + * exercise the NO-account / reconciliation / different-account paths delete or + * override it locally (and restore in their own finally / via `afterEach`). + */ +const AMBIENT_ACCOUNT = 'ambient-test-account'; +let savedAmbientAccount: string | undefined; + +beforeEach(() => { + savedAmbientAccount = process.env.CLOUDFLARE_ACCOUNT_ID; + process.env.CLOUDFLARE_ACCOUNT_ID = AMBIENT_ACCOUNT; +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + if (savedAmbientAccount === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = savedAmbientAccount; + } +}); + +function readConfig(): Record { + return parseJsoncRaw(readFileSync(wranglerPath, 'utf-8')) as Record; +} + +/** A logger that swallows output (the default one prints to console). */ +const silentLog = { info: () => {}, warn: () => {} }; + +type Handler = { match: RegExp; reply: (sub: string) => RunWranglerResult }; + +/** + * Build an injectable runWrangler stub that records every argv ARRAY. Handlers + * match on the space-joined argv (regex convenience), but `calls` holds the exact + * argv arrays so tests assert that untrusted values stay single, literal tokens. + */ +function makeRunner(handlers: Array): { run: RunWrangler; calls: Array> } { + const calls: Array> = []; + const run: RunWrangler = (args) => { + calls.push(args); + const sub = args.join(' '); + for (const handler of handlers) { + if (handler.match.test(sub)) { + return handler.reply(sub); + } + } + return { stdout: '', stderr: '', status: 0 }; + }; + return { run, calls }; +} + +/** True when an argv array is a `wrangler …` invocation of a given kind. */ +function isCall(argv: Array, ...prefix: Array): boolean { + return prefix.every((token, i) => argv[i] === token); +} + +const ok = (stdout = ''): RunWranglerResult => ({ stdout, stderr: '', status: 0 }); +const fail = (stdout: string, stderr = ''): RunWranglerResult => ({ stdout, stderr, status: 1 }); + +// Valid-FORMAT synthetic ids for fixtures. The validators require REAL shapes: +// D1 database_id is a hyphenated UUID; KV + Hyperdrive ids are 32-char lowercase hex. +// ASCII-hex of the seed, zero-padded to 32 — deterministic, distinct, and ORDER-PRESERVING +// on equal-length seeds (so lexicographic tie-break assertions stay valid). +function hex32(seed: string): string { + const raw = Buffer.from(seed, 'utf8').toString('hex').slice(0, 32); + return (raw + '0'.repeat(32)).slice(0, 32); +} +function uuid(seed: string): string { + const h = hex32(seed); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`; +} + +/** Mimic `wrangler create --update-config`: upsert a binding entry by binding. */ +function simulateUpdateConfig( + field: string, + entry: Record, + path = wranglerPath, +): void { + const cfg = parseJsoncRaw(readFileSync(path, 'utf-8')) as Record; + const list = Array.isArray(cfg[field]) ? (cfg[field] as Array>) : []; + const index = list.findIndex((e) => e.binding === entry.binding); + if (index >= 0) { + list[index] = { ...list[index], ...entry }; + } else { + list.push(entry); + } + cfg[field] = list; + writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`); +} + +const emptyDesired: DesiredResourceSet = { kvNamespaces: [], queues: [] }; + +describe('provisionCloudflareResources — read-first', () => { + it('skips D1 create when the binding already has a real database_id (records reused, no wrangler call)', async () => { + setup( + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: uuid('db1') }], + }), + ); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + // Read-first: no wrangler invocation at all (migrations_dir is filled via the writer). + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'reused', id: uuid('db1') }, + ]); + }); + + it('does NOT treat a placeholder database_id ("local") as real — proceeds to provision', async () => { + setup( + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'default', database_id: 'local' }], + }), + ); + const { run, calls } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: uuid('created'), + }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls[0]).toEqual(['d1', 'list', '--json', '-c', wranglerPath, '--env=']); + expect(result.resources[0].action).toBe('created'); + }); +}); + +describe('provisionCloudflareResources — argv injection safety', () => { + it('passes a Hyperdrive connection string with shell metacharacters as ONE literal argv element', async () => { + setup(JSON.stringify({ name: 'app' })); + // & $ ; and a space would all split/inject under a shell — must stay one token. + const conn = 'postgres://u:p@h/db?sslmode=require&opt=$X;DROP TABLE t'; + const { run, calls } = makeRunner([ + { + match: /^hyperdrive create /, + reply: () => { + simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: hex32('newhd') }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: conn }, + }, + { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, + ); + const createCall = calls.find((c) => isCall(c, 'hyperdrive', 'create')); + expect(createCall).toEqual([ + 'hyperdrive', + 'create', + 'myapp-hd', + `--connection-string=${conn}`, + '--binding', + 'HYPERDRIVE', + '--update-config', + '-c', + wranglerPath, + '--env=', + ]); + // The whole connection string (with & $ ; and a space) is a SINGLE element. + expect(createCall!.filter((t) => t.includes('DROP TABLE t'))).toHaveLength(1); + expect(result.resources[0].action).toBe('created'); + }); + + it('passes a config path containing spaces as ONE literal argv element (no shell splitting)', async () => { + const spacedDir = join(import.meta.dirname, '.tmp provision space'); + const spacedPath = join(spacedDir, 'wrangler.jsonc'); + rmSync(spacedDir, { recursive: true, force: true }); + mkdirSync(spacedDir, { recursive: true }); + writeFileSync(spacedPath, JSON.stringify({ name: 'app' })); + try { + const { run, calls } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig( + 'd1_databases', + { binding: 'DB', database_name: 'myapp-db', database_id: uuid('created') }, + spacedPath, + ); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + spacedDir, + spacedPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + const createCall = calls.find((c) => isCall(c, 'd1', 'create')); + expect(spacedPath).toContain(' '); + // The spaced path is the FINAL single element (a shell would have split it). + expect(createCall).toEqual([ + 'd1', + 'create', + 'myapp-db', + '--binding', + 'DB', + '--update-config', + '-c', + spacedPath, + '--env=', + ]); + expect(result.resources[0].action).toBe('created'); + } finally { + rmSync(spacedDir, { recursive: true, force: true }); + } + }); +}); + +describe('provisionCloudflareResources — config discovery (fail closed, single file)', () => { + it('reads a BOM-prefixed config correctly — ids found, no re-provision', async () => { + setup( + '' + + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: uuid('db1') }], + }), + ); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + // A BOM must NOT defeat read-first (a fail-open-to-{} parse would re-provision). + expect(calls.some((c) => c[0] === 'd1')).toBe(false); + expect(result.resources[0]).toMatchObject({ kind: 'd1', action: 'reused', id: uuid('db1') }); + // BOM survives the writer's migrations_dir fill. + expect(readFileSync(wranglerPath, 'utf-8').charCodeAt(0)).toBe(0xfeff); + }); + + it('fails closed on a malformed root config — throws, no wrangler calls, no write', async () => { + const malformed = '{ "name": "x" "d1_databases": [] }'; // missing comma + setup(malformed); + const { run, calls } = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/not valid JSON/); + expect(calls).toEqual([]); + expect(readFileSync(wranglerPath, 'utf-8')).toBe(malformed); + }); + + it('reads and writes the SAME single resolved file when both wrangler.json and wrangler.jsonc exist', async () => { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + const jsoncPath = join(tmpDir, 'wrangler.jsonc'); + const jsonPath = join(tmpDir, 'wrangler.json'); + // The caller pins wrangler.json; the OLD writer rediscovered .jsonc → split-brain. + writeFileSync(jsonPath, JSON.stringify({ kv_namespaces: [{ binding: 'KV', id: 'local' }] })); + writeFileSync(jsoncPath, JSON.stringify({ kv_namespaces: [{ binding: 'KV', id: 'local' }] })); + const { run } = makeRunner([ + { + match: /^kv namespace list/, + reply: () => ok(JSON.stringify([{ id: hex32('kv'), title: 'myapp-kv' }])), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + jsonPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ); + expect(result.configPath).toBe(jsonPath); + // The adopted id landed in the passed file (wrangler.json) — NOT split into .jsonc. + const jsonAfter = parseJsoncRaw(readFileSync(jsonPath, 'utf-8')) as Record; + expect((jsonAfter.kv_namespaces as Array>)[0].id).toBe(hex32('kv')); + // wrangler.jsonc is untouched (still the placeholder). + const jsoncAfter = parseJsoncRaw(readFileSync(jsoncPath, 'utf-8')) as Record; + expect((jsoncAfter.kv_namespaces as Array>)[0].id).toBe('local'); + }); +}); + +describe('provisionCloudflareResources — D1', () => { + it('creates a missing D1 with --update-config then writes migrations_dir', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: uuid('created'), + }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + ['d1', 'list', '--json', '-c', wranglerPath, '--env='], + [ + 'd1', + 'create', + 'myapp-db', + '--binding', + 'DB', + '--update-config', + '-c', + wranglerPath, + '--env=', + ], + ]); + const d1 = readConfig().d1_databases as Array>; + expect(d1).toEqual([ + { + binding: 'DB', + database_name: 'myapp-db', + database_id: uuid('created'), + migrations_dir: './db/migrations', + }, + ]); + expect(result.resources).toEqual([ + { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'created' }, + ]); + }); + + it('repairs a missing migrations_dir on the read-first reused path (retry convergence, no duplicate D1)', async () => { + // Simulates a prior run that created the D1 (real id written) then crashed + // before migrations_dir. The retry takes read-first → reused and must still + // fill migrations_dir — without re-creating (no duplicate D1). + setup( + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: uuid('db1') }], + }), + ); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([]); // read-first — no create, no list + const d1 = readConfig().d1_databases as Array>; + expect(d1).toHaveLength(1); // no duplicate D1 minted + expect(d1[0]).toEqual({ + binding: 'DB', + database_name: 'myapp-db', + database_id: uuid('db1'), + migrations_dir: './db/migrations', + }); + expect(result.resources).toEqual([ + { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'reused', id: uuid('db1') }, + ]); + }); + + it('adopts an existing D1 found in the account (list hit) — no create, writes id + migrations_dir', async () => { + setup( + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'default', database_id: 'local' }], + }), + ); + const { run, calls } = makeRunner([ + { + match: /^d1 list --json/, + reply: () => ok(JSON.stringify([{ uuid: uuid('existing'), name: 'myapp-db' }])), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([['d1', 'list', '--json', '-c', wranglerPath, '--env=']]); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + const d1 = readConfig().d1_databases as Array>; + expect(d1).toEqual([ + { + binding: 'DB', + database_name: 'myapp-db', + database_id: uuid('existing'), + migrations_dir: './db/migrations', + }, + ]); + expect(result.resources).toEqual([ + { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'adopted', id: uuid('existing') }, + ]); + }); + + it('treats a create-time 7502 (already exists) as adopt — captures the id via d1 info', async () => { + setup( + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'default', database_id: 'local' }], + }), + ); + const { run, calls } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => fail('', 'X [ERROR] A database with that name already exists [code: 7502]'), + }, + { + match: /^d1 info myapp-db --json/, + reply: () => ok(JSON.stringify({ uuid: uuid('raced'), name: 'myapp-db' })), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + ['d1', 'list', '--json', '-c', wranglerPath, '--env='], + [ + 'd1', + 'create', + 'myapp-db', + '--binding', + 'DB', + '--update-config', + '-c', + wranglerPath, + '--env=', + ], + ['d1', 'info', 'myapp-db', '--json', '-c', wranglerPath, '--env='], + ]); + const d1 = readConfig().d1_databases as Array>; + expect(d1[0]).toMatchObject({ binding: 'DB', database_id: uuid('raced') }); + expect(result.resources[0]).toEqual({ + kind: 'd1', + binding: 'DB', + name: 'myapp-db', + action: 'adopted', + id: uuid('raced'), + }); + }); + + it('throws on a non-7502 create failure', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/d1 create myapp-db.*failed/s); + }); +}); + +describe('provisionCloudflareResources — D1 discovery gate + strict validation', () => { + const d1Desired: DesiredResourceSet = { + ...emptyDesired, + d1: { binding: 'DB', name: 'myapp-db' }, + }; + + it('fails closed (no create) when `d1 list` exits non-zero', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^d1 list --json/, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, d1Desired, { + runWrangler: run, + log: silentLog, + }), + ).rejects.toThrow(/d1 list.*failed/s); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + expect(readConfig().d1_databases).toBeUndefined(); + }); + + it('fails closed (no create) on a SIGTERM-killed `d1 list` (normalized to non-zero)', async () => { + setup(JSON.stringify({ name: 'app' })); + const killed = normalizeRunWranglerResult({ + stdout: '', + stderr: '', + status: null, + signal: 'SIGTERM', + }); + const { run, calls } = makeRunner([{ match: /^d1 list --json/, reply: () => killed }]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, d1Desired, { + runWrangler: run, + log: silentLog, + }), + ).rejects.toThrow(/d1 list.*failed/s); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + }); + + it('fails closed (no create) on a spawn-error `d1 list` (normalized to non-zero)', async () => { + setup(JSON.stringify({ name: 'app' })); + const errored = normalizeRunWranglerResult({ + status: null, + error: new Error('spawn wrangler ENOENT'), + }); + const { run, calls } = makeRunner([{ match: /^d1 list --json/, reply: () => errored }]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, d1Desired, { + runWrangler: run, + log: silentLog, + }), + ).rejects.toThrow(/d1 list.*failed/s); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + }); + + it('fails closed (no create) when `d1 list` returns truncated JSON (status 0)', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[{ "uuid": "a", "name": "myapp-db"') }, + ]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, d1Desired, { + runWrangler: run, + log: silentLog, + }), + ).rejects.toThrow(/did not return a JSON array/); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + }); + + it('fails closed (no create) when a `d1 list` member is malformed — even when it IS the target name', async () => { + // The target-named member carries NO id → it must NOT be filtered to a false + // "absent" that mints a duplicate D1. + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok(JSON.stringify([{ name: 'myapp-db' }])) }, + ]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, d1Desired, { + runWrangler: run, + log: silentLog, + }), + ).rejects.toThrow(/D1 database named "myapp-db" with no valid id/); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + }); + + it('fails closed (no adopt/write) when the collision `d1 info` exits non-zero', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { match: /^d1 create /, reply: () => fail('', 'X [ERROR] already exists [code: 7502]') }, + { + match: /^d1 info myapp-db --json/, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, d1Desired, { + runWrangler: run, + log: silentLog, + }), + ).rejects.toThrow(/d1 info myapp-db.*failed/s); + // The 7502 create wrote nothing, and the failed info produced no adopt/write. + expect(readConfig().d1_databases).toBeUndefined(); + }); +}); + +describe('provisionCloudflareResources — KV', () => { + it('adopts when the namespace list shows a title match (writes id, no create)', async () => { + setup(JSON.stringify({ kv_namespaces: [{ binding: 'KV', id: 'local' }] })); + const { run, calls } = makeRunner([ + { + match: /^kv namespace list/, + reply: () => ok(JSON.stringify([{ id: hex32('kv'), title: 'myapp-kv' }])), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([['kv', 'namespace', 'list', '-c', wranglerPath, '--env=']]); + const kv = readConfig().kv_namespaces as Array>; + expect(kv).toEqual([{ binding: 'KV', id: hex32('kv') }]); + expect(result.resources).toEqual([ + { kind: 'kv', binding: 'KV', name: 'myapp-kv', action: 'adopted', id: hex32('kv') }, + ]); + }); + + it('creates when the namespace list is empty', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^kv namespace list/, reply: () => ok('[]') }, + { + match: /^kv namespace create /, + reply: () => { + simulateUpdateConfig('kv_namespaces', { binding: 'ISR_CACHE', id: hex32('newkv') }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'ISR_CACHE', title: 'myapp-isr' }] }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + ['kv', 'namespace', 'list', '-c', wranglerPath, '--env='], + [ + 'kv', + 'namespace', + 'create', + 'myapp-isr', + '--binding', + 'ISR_CACHE', + '--update-config', + '-c', + wranglerPath, + '--env=', + ], + ]); + expect(result.resources).toEqual([ + { kind: 'kv', binding: 'ISR_CACHE', name: 'myapp-isr', action: 'created' }, + ]); + }); + + it('fails closed (no create) when `kv namespace list` exits non-zero', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^kv namespace list/, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/kv namespace list.*failed/s); + expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(false); + }); + + it('fails closed (no create) when `kv namespace list` returns non-JSON output', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^kv namespace list/, reply: () => ok('not a json list at all') }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/did not return a JSON array/); + expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(false); + }); + + it('fails closed (no create) when `kv namespace list` output is truncated', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^kv namespace list/, reply: () => ok('[{ "id": "a", "title": "myapp-kv"') }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/did not return a JSON array/); + expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(false); + }); + + it('picks the lexicographically-smallest id and warns on multiple title matches', async () => { + setup(JSON.stringify({ name: 'app' })); + const warnings: Array = []; + const { run } = makeRunner([ + { + match: /^kv namespace list/, + reply: () => + ok( + JSON.stringify([ + { id: hex32('zzz'), title: 'myapp-kv' }, + { id: hex32('aaa'), title: 'myapp-kv' }, + { id: hex32('mmm'), title: 'other' }, + ]), + ), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: { info: () => {}, warn: (m) => warnings.push(m) } }, + ); + // hex32 is order-preserving on equal-length seeds, so hex32('aaa') < hex32('zzz'). + expect(result.resources[0]).toMatchObject({ action: 'adopted', id: hex32('aaa') }); + expect((readConfig().kv_namespaces as Array>)[0].id).toBe(hex32('aaa')); + expect( + warnings.some((w) => w.includes('multiple KV namespaces') && w.includes(hex32('aaa'))), + ).toBe(true); + }); + + it('provisions each KV binding independently (ISR_CACHE + app KV)', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^kv namespace list/, reply: () => ok('[]') }, + { + match: /^kv namespace create /, + reply: (sub) => { + const binding = sub.includes('ISR_CACHE') ? 'ISR_CACHE' : 'KV'; + simulateUpdateConfig('kv_namespaces', { binding, id: hex32(binding) }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + kvNamespaces: [ + { binding: 'KV', title: 'myapp-kv' }, + { binding: 'ISR_CACHE', title: 'myapp-isr' }, + ], + }, + { runWrangler: run, log: silentLog }, + ); + expect(result.resources.map((r) => r.action)).toEqual(['created', 'created']); + const kv = readConfig().kv_namespaces as Array>; + expect(kv.map((e) => e.binding).sort()).toEqual(['ISR_CACHE', 'KV']); + expect(calls.filter((c) => isCall(c, 'kv', 'namespace', 'create'))).toHaveLength(2); + }); +}); + +describe('provisionCloudflareResources — account_id', () => { + it('writes top-level account_id via the writer when provided', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([]); + const result = await provisionCloudflareResources(tmpDir, wranglerPath, emptyDesired, { + runWrangler: run, + accountId: 'acc-123', + log: silentLog, + }); + expect(readConfig().account_id).toBe('acc-123'); + expect(result.accountIdWritten).toBe(true); + }); + + it('does not write account_id when not provided (and none resolvable)', async () => { + setup(JSON.stringify({ name: 'app' })); + // Truly no account anywhere: clear the ambient the harness pins. emptyDesired + // implies NO remote work, so the fence does not fire and nothing is persisted. + const prev = process.env.CLOUDFLARE_ACCOUNT_ID; + delete process.env.CLOUDFLARE_ACCOUNT_ID; + try { + const { run } = makeRunner([]); + const result = await provisionCloudflareResources(tmpDir, wranglerPath, emptyDesired, { + runWrangler: run, + log: silentLog, + }); + expect(readConfig().account_id).toBeUndefined(); + expect(result.accountIdWritten).toBe(false); + } finally { + if (prev === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = prev; + } + } + }); +}); + +describe('provisionCloudflareResources — idempotent re-run', () => { + it('a second run after adoption is a read-first no-op (byte-identical config, no wrangler calls)', async () => { + setup(JSON.stringify({ kv_namespaces: [{ binding: 'KV', id: 'local' }] })); + const first = makeRunner([ + { + match: /^kv namespace list/, + reply: () => ok(JSON.stringify([{ id: hex32('kv'), title: 'myapp-kv' }])), + }, + ]); + const desired: DesiredResourceSet = { + ...emptyDesired, + kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }], + }; + await provisionCloudflareResources(tmpDir, wranglerPath, desired, { + runWrangler: first.run, + log: silentLog, + }); + const afterFirst = readFileSync(wranglerPath, 'utf-8'); + + const second = makeRunner([]); + const result = await provisionCloudflareResources(tmpDir, wranglerPath, desired, { + runWrangler: second.run, + log: silentLog, + }); + expect(second.calls).toEqual([]); + expect(result.resources).toEqual([ + { kind: 'kv', binding: 'KV', name: 'myapp-kv', action: 'reused', id: hex32('kv') }, + ]); + expect(readFileSync(wranglerPath, 'utf-8')).toBe(afterFirst); + }); +}); + +describe('provisionCloudflareResources — R2', () => { + it('reuses when the bucket_name is already real (no wrangler call)', async () => { + setup(JSON.stringify({ r2_buckets: [{ binding: 'STORAGE', bucket_name: 'myapp-uploads' }] })); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { kind: 'r2', binding: 'STORAGE', name: 'myapp-uploads', action: 'reused' }, + ]); + }); + + it('adopts an existing bucket via `r2 bucket info` (status 0) — writes the entry, no create', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^r2 bucket info myapp-uploads --json/, + reply: () => ok(JSON.stringify({ name: 'myapp-uploads', created: 'x' })), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + ['r2', 'bucket', 'info', 'myapp-uploads', '--json', '-c', wranglerPath, '--env='], + ]); + expect(readConfig().r2_buckets).toEqual([{ binding: 'STORAGE', bucket_name: 'myapp-uploads' }]); + expect(result.resources).toEqual([ + { kind: 'r2', binding: 'STORAGE', name: 'myapp-uploads', action: 'adopted' }, + ]); + }); + + it('creates the bucket when `r2 bucket info` reports it does not exist', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^r2 bucket info /, + reply: () => fail('', 'X [ERROR] The specified bucket does not exist [code: 10006]'), + }, + { + match: /^r2 bucket create /, + reply: () => { + simulateUpdateConfig('r2_buckets', { + binding: 'STORAGE', + bucket_name: 'myapp-uploads', + }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + ['r2', 'bucket', 'info', 'myapp-uploads', '--json', '-c', wranglerPath, '--env='], + [ + 'r2', + 'bucket', + 'create', + 'myapp-uploads', + '--binding', + 'STORAGE', + '--update-config', + '-c', + wranglerPath, + '--env=', + ], + ]); + expect(result.resources).toEqual([ + { kind: 'r2', binding: 'STORAGE', name: 'myapp-uploads', action: 'created' }, + ]); + }); + + it('creates the bucket on a confirmed not-found signal (CF code 10006)', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^r2 bucket info /, + reply: () => + fail('', 'X [ERROR] The bucket you tried to access is not present [code: 10006]'), + }, + { + match: /^r2 bucket create /, + reply: () => { + simulateUpdateConfig('r2_buckets', { binding: 'STORAGE', bucket_name: 'myapp-uploads' }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls.some((c) => isCall(c, 'r2', 'bucket', 'create'))).toBe(true); + expect(result.resources[0].action).toBe('created'); + }); + + it('does NOT create on an R2 info failure that is not a confirmed not-found (auth/metrics) — surfaces the error', async () => { + // wrangler 4.107 `r2 bucket info` also runs a metrics/GraphQL query, so an + // EXISTING bucket can exit non-zero on an auth/metrics failure. The prior + // "auth error → create" behavior is REVERSED: never create on such a signal. + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^r2 bucket info /, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/r2 bucket info myapp-uploads.*(failed|refusing)/s); + // No create attempted (the existing bucket must not be duplicated/errored on). + expect(calls).toEqual([ + ['r2', 'bucket', 'info', 'myapp-uploads', '--json', '-c', wranglerPath, '--env='], + ]); + }); +}); + +describe('provisionCloudflareResources — Hyperdrive', () => { + it('reuses when the hyperdrive binding already has a real id (no wrangler call)', async () => { + setup(JSON.stringify({ hyperdrive: [{ binding: 'HYPERDRIVE', id: hex32('hd') }] })); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { + kind: 'hyperdrive', + binding: 'HYPERDRIVE', + name: 'myapp-hd', + action: 'reused', + id: hex32('hd'), + }, + ]); + }); + + it('adopts an existing Hyperdrive config found by name — writes id via the writer, no create', async () => { + setup(JSON.stringify({ hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] })); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { + runWrangler: run, + log: silentLog, + listHyperdriveConfigs: async () => [ + { id: hex32('other-hd'), name: 'someone-else' }, + { id: hex32('existing'), name: 'myapp-hd' }, + ], + }, + ); + // No wrangler create — existence via the injected REST seam, id via the writer. + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + const hd = readConfig().hyperdrive as Array>; + expect(hd).toEqual([{ binding: 'HYPERDRIVE', id: hex32('existing') }]); + expect(result.resources).toEqual([ + { + kind: 'hyperdrive', + binding: 'HYPERDRIVE', + name: 'myapp-hd', + action: 'adopted', + id: hex32('existing'), + }, + ]); + }); + + it('creates the config when confirmed absent and a connection string is present', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^hyperdrive create /, + reply: () => { + simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: hex32('newhd') }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://u:p@h/db', + }, + }, + { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, + ); + expect(calls).toEqual([ + [ + 'hyperdrive', + 'create', + 'myapp-hd', + '--connection-string=postgres://u:p@h/db', + '--binding', + 'HYPERDRIVE', + '--update-config', + '-c', + wranglerPath, + '--env=', + ], + ]); + expect(result.resources).toEqual([ + { kind: 'hyperdrive', binding: 'HYPERDRIVE', name: 'myapp-hd', action: 'created' }, + ]); + }); + + it('fails closed when existence is ambiguous (check throws) and a connection string is present — no create', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://u:p@h/db', + }, + }, + { + runWrangler: run, + log: silentLog, + listHyperdriveConfigs: async () => { + throw new Error('network down'); + }, + }, + ), + ).rejects.toThrow(/cannot verify whether Hyperdrive config "myapp-hd"/); + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + }); + + it('still skips (structured, never creates) when a lister can look but finds nothing', async () => { + // A lister IS available, so this run COULD have adopted a same-named config; it + // just did not find one. Nothing can be minted without a connection string, so the + // structured skip — not a throw — remains the right report for that outcome. + setup(JSON.stringify({ name: 'app' })); + const warnings: Array = []; + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { + accountId: 'acct', + runWrangler: run, + listHyperdriveConfigs: async () => [], + log: { info: () => {}, warn: (m) => warnings.push(m) }, + }, + ); + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { + kind: 'hyperdrive', + binding: 'HYPERDRIVE', + name: 'myapp-hd', + action: 'skipped', + reason: 'no connection string provided', + }, + ]); + expect(warnings.some((w) => w.includes('skipping Hyperdrive'))).toBe(true); + }); + + it('adopts a same-named config with NO connection string (adoption never needs one)', async () => { + // Guards the stringless fail-closed fence from over-reaching: a run with no + // connection string whose lister DOES find a match must still adopt, not throw. + setup(JSON.stringify({ hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] })); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { + accountId: 'acct', + runWrangler: run, + listHyperdriveConfigs: async () => [{ id: hex32('adopted'), name: 'myapp-hd' }], + log: silentLog, + }, + ); + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { + kind: 'hyperdrive', + binding: 'HYPERDRIVE', + name: 'myapp-hd', + action: 'adopted', + id: hex32('adopted'), + }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Hyperdrive needs a Cloudflare API token for its existence check, because +// wrangler exposes no machine-readable Hyperdrive list. `wrangler login` (OAuth) +// covers every OTHER resource, so the missing token has to be reported UP FRONT +// with the exact remedy — not discovered after D1/KV/R2 were already created. +// --------------------------------------------------------------------------- + +describe('provisionCloudflareResources — Hyperdrive API token requirement', () => { + let savedToken: string | undefined; + beforeEach(() => { + savedToken = process.env.CLOUDFLARE_API_TOKEN; + delete process.env.CLOUDFLARE_API_TOKEN; + }); + afterEach(() => { + if (savedToken === undefined) { + delete process.env.CLOUDFLARE_API_TOKEN; + } else { + process.env.CLOUDFLARE_API_TOKEN = savedToken; + } + }); + + it('fails with an actionable token error BEFORE any account mutation when the config has no id', async () => { + // A first-time Postgres provision under `wrangler login` (OAuth, no token): the + // Hyperdrive id is not in the config yet and there is no way to check the account, + // so the run must stop before the D1 database beside it is created. + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + d1: { binding: 'DB', name: 'myapp-db' }, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://u:p@h/db', + }, + }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/CLOUDFLARE_API_TOKEN/); + // Nothing ran: no `d1 create`, no Hyperdrive call — the account is untouched. + expect(calls).toEqual([]); + // The config was not touched either (no account_id pin, no new entries). + expect(readConfig()).toEqual({ name: 'app' }); + }); + + it('names the two remedies: export a token, or put an existing Hyperdrive id in the config', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([]); + const error = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://u:p@h/db', + }, + }, + { runWrangler: run, log: silentLog }, + ).catch((e: unknown) => e as Error); + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain('CLOUDFLARE_API_TOKEN'); + expect(message).toContain('Hyperdrive'); + // Explains WHY a token is needed where `wrangler login` suffices elsewhere. + expect(message).toMatch(/no machine-readable/i); + // Remedy 2: create the config yourself and paste its id into wrangler.jsonc. + expect(message).toContain(wranglerPath); + expect(message).toMatch(/wrangler login|OAuth/i); + // The connection string is never echoed into the error. + expect(message).not.toContain('postgres://'); + }); + + it('proceeds with NO token when the Hyperdrive id is already in the config (no REST call)', async () => { + // The read-first path is pure config I/O: an already-provisioned Postgres app + // deploys fine under `wrangler login` alone. + setup(JSON.stringify({ hyperdrive: [{ binding: 'HYPERDRIVE', id: hex32('hd') }] })); + const { run, calls } = makeRunner([]); + const fetchUrls: Array = []; + const origFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL) => { + fetchUrls.push(String(url)); + throw new Error('the read-first path must not reach the network'); + }) as typeof fetch; + let result; + try { + result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://u:p@h/db', + }, + }, + { runWrangler: run, log: silentLog }, + ); + } finally { + globalThis.fetch = origFetch; + } + expect(fetchUrls).toEqual([]); + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { + kind: 'hyperdrive', + binding: 'HYPERDRIVE', + name: 'myapp-hd', + action: 'reused', + id: hex32('hd'), + }, + ]); + }); + + it('runs the existence check unchanged when a token IS resolvable', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'ambient-tok'; + setup(JSON.stringify({ account_id: 'config-acct', name: 'app' })); + const urls: Array = []; + const origFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL) => { + urls.push(String(url)); + return new Response( + JSON.stringify({ success: true, result: [], result_info: { total_count: 0 } }), + { status: 200 }, + ); + }) as typeof fetch; + const { run, calls } = makeRunner([ + { + match: /^hyperdrive create /, + reply: () => { + simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: hex32('newhd') }); + return ok(); + }, + }, + ]); + let result; + try { + result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://u:p@h/db', + }, + }, + { runWrangler: run, log: silentLog }, + ); + } finally { + globalThis.fetch = origFetch; + } + expect(urls[0]).toContain('/accounts/config-acct/hyperdrive/configs'); + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(true); + expect(result.resources).toEqual([ + { kind: 'hyperdrive', binding: 'HYPERDRIVE', name: 'myapp-hd', action: 'created' }, + ]); + }); + + it('needs no token when a listHyperdriveConfigs seam is injected', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^hyperdrive create /, + reply: () => { + simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: hex32('newhd') }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://u:p@h/db', + }, + }, + { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, + ); + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(true); + expect(result.resources).toEqual([ + { kind: 'hyperdrive', binding: 'HYPERDRIVE', name: 'myapp-hd', action: 'created' }, + ]); + }); + + it('fails closed on a MISSING connection string when nothing can look the account up', async () => { + // No connection string AND no token/lister → this run can neither create nor adopt, + // so its only reachable outcome is the structured `skipped`, which deploy treats as + // FATAL. Reaching that costs the user a half-provisioned account, so it stops here. + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/no DATABASE_URL to create it from/); + // Nothing ran: no wrangler call, so no resource was created before the rejection. + expect(calls).toEqual([]); + }); + + it('names DATABASE_URL and the manual adopt route, and does not suggest a dotenv file', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([]); + const err = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { runWrangler: run, log: silentLog }, + ).catch((e: unknown) => e as Error); + expect(err).toBeInstanceOf(Error); + const message = (err as Error).message; + expect(message).toContain('DATABASE_URL'); + expect(message).toContain('wrangler hyperdrive create'); + expect(message).toContain('HYPERDRIVE'); + // The remedy must point at the shell, never at a dotenv file — project-derived env + // is read only inside the build subprocess, and a shipped .env value would leak. + expect(message).toMatch(/shell/); + expect(message).toMatch(/NOT used here/); + }); + + it('fails closed BEFORE the account pin and before any other resource is created', async () => { + // The whole point of fencing early: a run that also wants KV/R2 must not create + // them (and must not stamp account_id) only to abort at the Hyperdrive block. + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }], + r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' }, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { runWrangler: run, accountId: 'acct-A', log: silentLog }, + ), + ).rejects.toThrow(/no DATABASE_URL to create it from/); + // No create seam was invoked at all — KV and R2 were never touched. + expect(calls).toEqual([]); + // …and the account was never pinned, so a retry under a different account is still free. + expect(readConfig().account_id).toBeUndefined(); + }); + + it('a hyperdrive already provisioned with a real id needs neither a string nor a token', async () => { + // The read-first path is pure config I/O — the stringless fence must not touch it. + setup(JSON.stringify({ hyperdrive: [{ binding: 'HYPERDRIVE', id: hex32('hd') }] })); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { + kind: 'hyperdrive', + binding: 'HYPERDRIVE', + name: 'myapp-hd', + action: 'reused', + id: hex32('hd'), + }, + ]); + }); +}); + +describe('provisionCloudflareResources — Queues (resource only)', () => { + it('reuses a queue that `queues info` finds (no create, never writes queue config)', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^queues info jobs/, reply: () => ok('Queue Name: jobs') }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([['queues', 'info', 'jobs', '-c', wranglerPath, '--env=']]); + expect(result.resources).toEqual([{ kind: 'queue', name: 'jobs', action: 'reused' }]); + // Task 4 must NOT write queue producer/consumer entries into the config. + expect(readConfig().queues).toBeUndefined(); + }); + + it('creates a queue that `queues info` reports missing; verifies with a follow-up info probe', async () => { + setup(JSON.stringify({ name: 'app' })); + // After `queues create`, the provisioner RE-RUNS `queues info` and only + // records `created` if it now POSITIVELY confirms the queue (queues write no + // config, so this is the only backstop against a masked-signal create). + let created = false; + const { run, calls } = makeRunner([ + { + match: /^queues info emails/, + reply: () => + created ? ok('Queue Name: emails') : fail('', 'X [ERROR] Queue "emails" does not exist.'), + }, + { + match: /^queues create emails/, + reply: () => { + created = true; + return ok('Created queue emails'); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['emails'] }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + ['queues', 'info', 'emails', '-c', wranglerPath, '--env='], + ['queues', 'create', 'emails', '-c', wranglerPath, '--env='], + ['queues', 'info', 'emails', '-c', wranglerPath, '--env='], + ]); + expect(result.resources).toEqual([{ kind: 'queue', name: 'emails', action: 'created' }]); + expect(readConfig().queues).toBeUndefined(); + }); + + it('handles a mix of existing and missing queues', async () => { + setup(JSON.stringify({ name: 'app' })); + let created = false; + const { run, calls } = makeRunner([ + { match: /^queues info jobs/, reply: () => ok('Queue Name: jobs') }, + { + match: /^queues info emails/, + reply: () => + created ? ok('Queue Name: emails') : fail('', 'Queue "emails" does not exist.'), + }, + { + match: /^queues create emails/, + reply: () => { + created = true; + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs', 'emails'] }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + ['queues', 'info', 'jobs', '-c', wranglerPath, '--env='], + ['queues', 'info', 'emails', '-c', wranglerPath, '--env='], + ['queues', 'create', 'emails', '-c', wranglerPath, '--env='], + ['queues', 'info', 'emails', '-c', wranglerPath, '--env='], + ]); + expect(result.resources).toEqual([ + { kind: 'queue', name: 'jobs', action: 'reused' }, + { kind: 'queue', name: 'emails', action: 'created' }, + ]); + }); + + it('throws (does NOT create) when `queues info` fails for a non-missing reason', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^queues info jobs/, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/queues info jobs.*failed/s); + expect(calls.some((c) => isCall(c, 'queues', 'create'))).toBe(false); + }); + + it('does NOT create on a generic/unrelated "not found" (e.g. Account not found) — fails closed', async () => { + // The OLD broad `/not found|no such/` regex misread "Account not found" as + // queue absence and blindly created. It must fail closed instead. + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^queues info jobs/, + reply: () => fail('', 'X [ERROR] Account not found [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/queues info jobs.*failed/s); + expect(calls.some((c) => isCall(c, 'queues', 'create'))).toBe(false); + }); + + it('is name-aware: a "does not exist" for a DIFFERENT queue name is NOT absence for this one', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^queues info jobs/, + reply: () => fail('', 'X [ERROR] Queue "some-other-queue" does not exist.'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/queues info jobs.*failed/s); + expect(calls.some((c) => isCall(c, 'queues', 'create'))).toBe(false); + }); + + it('creates ONLY on the exact `Queue "" does not exist` signal for THAT name', async () => { + setup(JSON.stringify({ name: 'app' })); + let created = false; + const { run, calls } = makeRunner([ + { + match: /^queues info jobs/, + reply: () => + created ? ok('Queue Name: jobs') : fail('', 'X [ERROR] Queue "jobs" does not exist.'), + }, + { + match: /^queues create jobs/, + reply: () => { + created = true; + return ok('Created queue jobs'); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + ['queues', 'info', 'jobs', '-c', wranglerPath, '--env='], + ['queues', 'create', 'jobs', '-c', wranglerPath, '--env='], + ['queues', 'info', 'jobs', '-c', wranglerPath, '--env='], + ]); + expect(result.resources).toEqual([{ kind: 'queue', name: 'jobs', action: 'created' }]); + }); + + it('fails closed when a masked-signal `queues create` (wrapper exit 0) did NOT create the queue', async () => { + // wrangler's bin wrapper maps a signal-killed inner CLI to exit 0. Queues write + // no config, so the ONLY backstop is the post-create verify probe: a create that + // reports clean 0 but whose follow-up `queues info` still does not confirm the + // queue must fail closed (never a false `created`). + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + // Both the initial probe AND the verify probe report the queue absent — the + // masked-kill create never actually created it. + match: /^queues info jobs/, + reply: () => fail('', 'X [ERROR] Queue "jobs" does not exist.'), + }, + // A signal-killed inner CLI surfaced as a clean exit 0 by the wrapper. + { match: /^queues create jobs/, reply: () => ok('') }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/did not confirm the queue exists afterward/); + // The verify probe ran (info, create, info) but the queue was never recorded. + expect(calls).toEqual([ + ['queues', 'info', 'jobs', '-c', wranglerPath, '--env='], + ['queues', 'create', 'jobs', '-c', wranglerPath, '--env='], + ['queues', 'info', 'jobs', '-c', wranglerPath, '--env='], + ]); + }); +}); + +describe('provisionCloudflareResources — account reconciliation + config pin', () => { + it('fails closed BEFORE any wrangler/REST call when config account_id differs from opts.accountId', async () => { + setup(JSON.stringify({ account_id: 'config-acct', name: 'app' })); + let restCalled = false; + const { run, calls } = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { + runWrangler: run, + accountId: 'requested-acct', + log: silentLog, + listHyperdriveConfigs: async () => { + restCalled = true; + return []; + }, + }, + ), + ).rejects.toThrow(/account mismatch.*config-acct.*requested-acct/s); + // No wrangler call and no REST call happened before the fail-closed throw. + expect(calls).toEqual([]); + expect(restCalled).toBe(false); + }); + + it('persists account_id BEFORE any remote op so a later different-account retry fails closed', async () => { + // Run 1 under account A: D1 create writes a real id, THEN a later op fails. + setup(JSON.stringify({ name: 'app' })); + const run1 = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: uuid('createdA'), + }); + return ok(); + }, + }, + // A later op fails → the run throws AFTER the D1 id was already written. + { + match: /^queues info jobs/, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' }, queues: ['jobs'] }, + { runWrangler: run1.run, accountId: 'acct-A', log: silentLog }, + ), + ).rejects.toThrow(/queues info jobs.*failed/s); + // account_id was pinned to A UP FRONT (before D1), alongside the real D1 id. + const after1 = readConfig(); + expect(after1.account_id).toBe('acct-A'); + expect((after1.d1_databases as Array>)[0].database_id).toBe( + uuid('createdA'), + ); + + // Run 2 retries under account B → must fail closed at reconciliation with NO + // wrangler/REST calls (never rebinds account-A's D1 id under account B). + let restCalled = false; + const run2 = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' }, queues: ['jobs'] }, + { + runWrangler: run2.run, + accountId: 'acct-B', + log: silentLog, + listHyperdriveConfigs: async () => { + restCalled = true; + return []; + }, + }, + ), + ).rejects.toThrow(/account mismatch.*acct-A.*acct-B/s); + expect(run2.calls).toEqual([]); + expect(restCalled).toBe(false); + }); + + it('passes -c to EVERY wrangler invocation (list, info, create)', async () => { + setup(JSON.stringify({ name: 'app' })); + let queueCreated = false; + const { run, calls } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: uuid('created'), + }); + return ok(); + }, + }, + { match: /^kv namespace list/, reply: () => ok('[]') }, + { + match: /^kv namespace create /, + reply: () => { + simulateUpdateConfig('kv_namespaces', { binding: 'KV', id: hex32('newkv') }); + return ok(); + }, + }, + { + match: /^r2 bucket info /, + reply: () => fail('', 'The specified bucket does not exist. [code: 10006]'), + }, + { + match: /^r2 bucket create /, + reply: () => { + simulateUpdateConfig('r2_buckets', { binding: 'STORAGE', bucket_name: 'bucket-b' }); + return ok(); + }, + }, + { + match: /^queues info /, + reply: () => + queueCreated ? ok('Queue Name: jobs') : fail('', 'Queue "jobs" does not exist.'), + }, + { + match: /^queues create /, + reply: () => { + queueCreated = true; + return ok(); + }, + }, + ]); + await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + d1: { binding: 'DB', name: 'myapp-db' }, + kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }], + r2: { binding: 'STORAGE', bucketName: 'bucket-b' }, + queues: ['jobs'], + }, + { runWrangler: run, log: silentLog }, + ); + // Every wrangler argv threads the ONE resolved config via `-c ` AND + // the top-level `--env=` pin. + expect(calls.length).toBeGreaterThan(0); + for (const argv of calls) { + const i = argv.indexOf('-c'); + expect(i).toBeGreaterThanOrEqual(0); + expect(argv[i + 1]).toBe(wranglerPath); + expect(argv[i + 2]).toBe('--env='); + } + }); + + it('the default Hyperdrive existence REST call targets the effective (config) account id', async () => { + setup( + JSON.stringify({ + account_id: 'config-acct', + hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }], + }), + ); + const urls: Array = []; + const origFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL) => { + urls.push(String(url)); + return new Response( + JSON.stringify({ success: true, result: [], result_info: { total_count: 0 } }), + { status: 200 }, + ); + }) as typeof fetch; + try { + const { run } = makeRunner([]); + await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + // Empty connection string → skip AFTER the existence check, so ONLY the + // REST call runs (no create). + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { runWrangler: run, apiToken: 'tok', log: silentLog }, + ); + } finally { + globalThis.fetch = origFetch; + } + expect(urls.length).toBeGreaterThan(0); + expect(urls[0]).toContain('/accounts/config-acct/hyperdrive/configs'); + // Default (no ambient redirect) → public prod base. + expect(urls[0]).toMatch(/^https:\/\/api\.cloudflare\.com\/client\/v4\//); + }); +}); + +describe('provisionCloudflareResources — pinned transport (one base for wrangler + REST)', () => { + it('resolvePinnedApiBaseUrl mirrors wrangler: default prod, direct override, staging, fedramp', () => { + expect(resolvePinnedApiBaseUrl({})).toBe('https://api.cloudflare.com/client/v4'); + expect(resolvePinnedApiBaseUrl({ CLOUDFLARE_API_BASE_URL: 'https://api.example/v9' })).toBe( + 'https://api.example/v9', + ); + // Deprecated alias honored when the primary is absent. + expect(resolvePinnedApiBaseUrl({ CF_API_BASE_URL: 'https://cf.example/v4' })).toBe( + 'https://cf.example/v4', + ); + expect(resolvePinnedApiBaseUrl({ WRANGLER_API_ENVIRONMENT: 'staging' })).toBe( + 'https://api.staging.cloudflare.com/client/v4', + ); + expect(resolvePinnedApiBaseUrl({ CLOUDFLARE_COMPLIANCE_REGION: 'fedramp_high' })).toBe( + 'https://api.fed.cloudflare.com/client/v4', + ); + }); + + it('the default Hyperdrive REST lister honors a pinned CLOUDFLARE_API_BASE_URL (not hardcoded prod)', async () => { + const prev = process.env.CLOUDFLARE_API_BASE_URL; + process.env.CLOUDFLARE_API_BASE_URL = 'https://api.staging.cloudflare.com/client/v4'; + setup( + JSON.stringify({ + account_id: 'config-acct', + hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }], + }), + ); + const urls: Array = []; + const origFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL) => { + urls.push(String(url)); + return new Response( + JSON.stringify({ success: true, result: [], result_info: { total_count: 0 } }), + { status: 200 }, + ); + }) as typeof fetch; + try { + const { run } = makeRunner([]); + await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { runWrangler: run, apiToken: 'tok', log: silentLog }, + ); + } finally { + globalThis.fetch = origFetch; + if (prev === undefined) { + delete process.env.CLOUDFLARE_API_BASE_URL; + } else { + process.env.CLOUDFLARE_API_BASE_URL = prev; + } + } + // The REST existence check hit the SAME pinned endpoint wrangler would (staging), + // not the hardcoded public-prod base. + expect(urls[0]).toMatch(/^https:\/\/api\.staging\.cloudflare\.com\/client\/v4\//); + expect(urls[0]).toContain('/accounts/config-acct/hyperdrive/configs'); + }); + + it('the lister URL uses the pinned base with wrangler-EXACT concatenation (no trailing-slash trim)', async () => { + // wrangler 4.107's `performApiFetchBase` fetches + // `${getCloudflareApiBaseUrl(...)}${resource}${queryString}` with the direct env + // base used VERBATIM and the resource `/accounts/${accountId}/hyperdrive/configs` + // interpolated RAW. A base ending in `/` therefore yields `…//accounts/…` for + // wrangler; the lister must produce the SAME bytes (the old one-sided trim made the + // existence check and the create hit different endpoints on a custom proxy base). + const prev = process.env.CLOUDFLARE_API_BASE_URL; + const pinnedBase = 'https://proxy.example/api/'; // note the trailing slash + process.env.CLOUDFLARE_API_BASE_URL = pinnedBase; + setup(JSON.stringify({ account_id: 'config-acct', name: 'app' })); + const urls: Array = []; + const origFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL) => { + urls.push(String(url)); + return new Response( + JSON.stringify({ success: true, result: [], result_info: { total_count: 0 } }), + { status: 200 }, + ); + }) as typeof fetch; + try { + const { run } = makeRunner([]); + await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { runWrangler: run, apiToken: 'tok', log: silentLog }, + ); + } finally { + globalThis.fetch = origFetch; + if (prev === undefined) { + delete process.env.CLOUDFLARE_API_BASE_URL; + } else { + process.env.CLOUDFLARE_API_BASE_URL = prev; + } + } + // Byte-for-byte wrangler's concatenation: `${base}${resource}${queryString}` — + // the double slash included. + expect(urls[0]).toBe( + `${pinnedBase}/accounts/config-acct/hyperdrive/configs?page=1&per_page=100`, + ); + expect(urls[0]).toContain('/api//accounts/'); + }); + + it('the injected lister receives the pinned apiBaseUrl + a deadline AbortSignal in its transport context', async () => { + setup(JSON.stringify({ account_id: 'config-acct', name: 'app' })); + let seen: { accountId: string; apiBaseUrl: string; signal: AbortSignal } | undefined; + const { run } = makeRunner([]); + await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { + runWrangler: run, + log: silentLog, + listHyperdriveConfigs: async (ctx) => { + seen = ctx; + return []; + }, + }, + ); + expect(seen?.accountId).toBe('config-acct'); + expect(seen?.apiBaseUrl).toBe('https://api.cloudflare.com/client/v4'); + // The lister is handed the overall-deadline signal to thread into every fetch. + expect(seen?.signal).toBeInstanceOf(AbortSignal); + }); + + it('FAILS CLOSED when a project .env sets a transport-redirect var (no wrangler/REST)', async () => { + setup(JSON.stringify({ account_id: 'config-acct', name: 'app' })); + // A repo .env that would redirect wrangler's transport on every command. + writeFileSync( + join(tmpDir, '.env'), + 'CLOUDFLARE_API_BASE_URL=https://api.staging.cloudflare.com/client/v4\n', + ); + let listerCalled = false; + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + d1: { binding: 'DB', name: 'myapp-db' }, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { + runWrangler: run, + log: silentLog, + listHyperdriveConfigs: async () => { + listerCalled = true; + return []; + }, + }, + ), + ).rejects.toThrow(/refusing to provision.*\.env.*CLOUDFLARE_API_BASE_URL.*redirect/s); + // Failed closed BEFORE any remote work. + expect(calls.length).toBe(0); + expect(listerCalled).toBe(false); + }); + + it('does NOT fail on a benign project .env without transport-redirect vars', async () => { + setup(JSON.stringify({ account_id: 'config-acct', name: 'app' })); + writeFileSync(join(tmpDir, '.env'), 'DATABASE_URL=postgres://localhost/app\nFOO=bar\n'); + const { run } = makeRunner([ + { match: /^d1 list/, reply: () => ok('[]') }, + { + match: /^d1 create/, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: uuid('db'), + }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(result.resources).toEqual([ + { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'created' }, + ]); + }); +}); + +describe('provisionCloudflareResources — Hyperdrive discovery (pagination + fail closed)', () => { + function stubFetch(pages: Array<{ status?: number; body: unknown }>): { + restore: () => void; + urls: Array; + } { + const urls: Array = []; + const origFetch = globalThis.fetch; + let call = 0; + globalThis.fetch = (async (url: string | URL) => { + urls.push(String(url)); + const page = pages[Math.min(call, pages.length - 1)]; + call++; + return new Response(typeof page.body === 'string' ? page.body : JSON.stringify(page.body), { + status: page.status ?? 200, + }); + }) as typeof fetch; + return { + restore: () => { + globalThis.fetch = origFetch; + }, + urls, + }; + } + + it('follows result_info pagination and adopts a match found on page 2 (no create)', async () => { + setup( + JSON.stringify({ account_id: 'acct', hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] }), + ); + const { restore, urls } = stubFetch([ + { + body: { + success: true, + result: [{ id: hex32('p1a'), name: 'other-1' }], + result_info: { total_count: 2 }, + }, + }, + { + body: { + success: true, + result: [{ id: hex32('p2hd'), name: 'myapp-hd' }], + result_info: { total_count: 2 }, + }, + }, + ]); + try { + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { runWrangler: run, apiToken: 'tok', log: silentLog }, + ); + expect(urls.length).toBe(2); // both pages fetched + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + const hd = readConfig().hyperdrive as Array>; + expect(hd).toEqual([{ binding: 'HYPERDRIVE', id: hex32('p2hd') }]); + expect(result.resources[0]).toMatchObject({ action: 'adopted', id: hex32('p2hd') }); + } finally { + restore(); + } + }); + + it('fails closed on a non-2xx envelope (never treats it as absence, no create)', async () => { + setup(JSON.stringify({ account_id: 'acct', name: 'app' })); + const { restore } = stubFetch([{ status: 500, body: { success: false } }]); + try { + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://x', + }, + }, + { runWrangler: run, apiToken: 'tok', log: silentLog }, + ), + ).rejects.toThrow(/cannot verify whether Hyperdrive config "myapp-hd"/); + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + } finally { + restore(); + } + }); + + it('fails closed on success:false and on a malformed entry (missing id) — no create', async () => { + setup(JSON.stringify({ account_id: 'acct', name: 'app' })); + // success:false first, then a malformed entry — both must fail closed. + for (const body of [ + { success: false, result: [] }, + { success: true, result: [{ name: 'myapp-hd' }], result_info: { total_count: 1 } }, + ]) { + const { restore } = stubFetch([{ body }]); + try { + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://x', + }, + }, + { runWrangler: run, apiToken: 'tok', log: silentLog }, + ), + ).rejects.toThrow(/cannot verify whether Hyperdrive config "myapp-hd"/); + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + } finally { + restore(); + } + } + }); + + it('fails closed when two configs share the requested name (ambiguous) — no create', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { + runWrangler: run, + log: silentLog, + listHyperdriveConfigs: async () => [ + { id: hex32('hd-1'), name: 'myapp-hd' }, + { id: hex32('hd-2'), name: 'myapp-hd' }, + ], + }, + ), + ).rejects.toThrow(/cannot adopt Hyperdrive config "myapp-hd".*share that name/s); + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + }); + + it('fails closed on malformed pagination (missing/premature-empty/duplicate/inconsistent) — never declares absence, no create', async () => { + // Each case would let the OLD loop stop early and declare "absent" without + // proving completeness → a same-named config on an unread page becomes a + // duplicate. All must fail closed BEFORE any create. + const cases: Array> = [ + // 1. missing result_info.total_count metadata (cannot prove completeness) + [{ body: { success: true, result: [{ id: 'a', name: 'other' }] } }], + // 2. premature empty page before all configs are listed + [ + { + body: { + success: true, + result: [{ id: 'a', name: 'other' }], + result_info: { total_count: 3 }, + }, + }, + { body: { success: true, result: [], result_info: { total_count: 3 } } }, + ], + // 3. repeated/duplicate id across pages (padding to satisfy a count) + [ + { + body: { + success: true, + result: [{ id: 'a', name: 'x' }], + result_info: { total_count: 2 }, + }, + }, + { + body: { + success: true, + result: [{ id: 'a', name: 'x' }], + result_info: { total_count: 2 }, + }, + }, + ], + // 4. inconsistent total_count between pages + [ + { + body: { + success: true, + result: [{ id: 'a', name: 'x' }], + result_info: { total_count: 2 }, + }, + }, + { + body: { + success: true, + result: [{ id: 'b', name: 'y' }], + result_info: { total_count: 5 }, + }, + }, + ], + ]; + for (const pages of cases) { + setup(JSON.stringify({ account_id: 'acct', name: 'app' })); + const { restore } = stubFetch(pages); + try { + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://x', + }, + }, + { runWrangler: run, apiToken: 'tok', log: silentLog }, + ), + ).rejects.toThrow(/cannot verify whether Hyperdrive config "myapp-hd"/); + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + } finally { + restore(); + } + } + }); +}); + +describe('provisionCloudflareResources — KV/R2 fail closed on schema-valid ambiguity', () => { + it('KV: fails closed (no create) when the target title matches but the entry has no id', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^kv namespace list/, reply: () => ok(JSON.stringify([{ title: 'myapp-kv' }])) }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/namespace titled "myapp-kv" with no valid id/); + expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(false); + }); + + it('KV: fails closed (no create) when the list has a malformed member (null) — never filtered away', async () => { + // The OLD strict parser filtered `null` out, leaving a false "absent" match → + // duplicate. The malformed member must reject the WHOLE list instead. + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^kv namespace list/, + reply: () => ok(JSON.stringify([null, { id: 'other', title: 'someone-else' }])), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/malformed KV namespace entry/); + expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(false); + }); + + it('R2: does NOT adopt when `r2 bucket info` exits 0 but the payload names a different bucket', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^r2 bucket info /, + reply: () => ok(JSON.stringify({ name: 'a-different-bucket' })), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/did not identify the requested bucket/); + // Neither adopted nor created — the config is untouched. + expect(calls.some((c) => isCall(c, 'r2', 'bucket', 'create'))).toBe(false); + expect(readConfig().r2_buckets).toBeUndefined(); + }); +}); + +describe('normalizeRunWranglerResult — signal / spawn-error handling', () => { + it('treats a SIGTERM-killed child (status null, signal set) as FAILURE, not success', () => { + const r = normalizeRunWranglerResult({ + stdout: '', + stderr: '', + status: null, + signal: 'SIGTERM', + }); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('SIGTERM'); + }); + + it('treats a spawn error as FAILURE and surfaces its message', () => { + const r = normalizeRunWranglerResult({ + status: null, + error: new Error('spawn wrangler ENOENT'), + }); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('ENOENT'); + }); + + it('passes a clean exit through unchanged (0 stays success, a real non-zero is preserved)', () => { + expect(normalizeRunWranglerResult({ status: 0, stdout: 'ok' }).status).toBe(0); + expect(normalizeRunWranglerResult({ status: 2, stderr: 'boom' }).status).toBe(2); + }); + + it('carries structured signal/error provenance', () => { + const killed = normalizeRunWranglerResult({ status: null, signal: 'SIGTERM' }); + expect(killed.signal).toBe('SIGTERM'); + expect(killed.error).toBeNull(); + const errored = normalizeRunWranglerResult({ status: null, error: new Error('ENOENT') }); + expect(errored.error).toContain('ENOENT'); + const clean = normalizeRunWranglerResult({ status: 0, stdout: 'ok' }); + expect(clean.signal).toBeNull(); + expect(clean.error).toBeNull(); + }); +}); + +describe('createDefaultRunWrangler — hard timeout kills the REAL process', () => { + const TIMEOUT_TMP = join(import.meta.dirname, '.tmp-timeout-kill'); + beforeEach(() => { + rmSync(TIMEOUT_TMP, { recursive: true, force: true }); + mkdirSync(TIMEOUT_TMP, { recursive: true }); + }); + afterEach(() => { + rmSync(TIMEOUT_TMP, { recursive: true, force: true }); + }); + + /** Poll `process.kill(pid, 0)` until the pid is dead (ESRCH) or the deadline passes. */ + async function waitForDead(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); // still alive + } catch { + return true; // ESRCH → dead + } + await new Promise((r) => setTimeout(r, 50)); + } + return false; + } + + it('a DIRECT hung CLI is SIGKILLed and the runner REJECTS-as-hard-failure within the timeout', async () => { + // Simulate the post-fix launcher: a direct CLI entry (no wrapper) that ignores SIGTERM + // and hangs forever, writing its pid so the test can confirm it was actually killed. + const pidFile = join(TIMEOUT_TMP, 'cli.pid'); + const hungCli = join(TIMEOUT_TMP, 'hung-cli.cjs'); + writeFileSync( + hungCli, + `require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));\n` + + `process.on('SIGTERM', () => {});\n` + // survives a plain SIGTERM (like the wrapper sends) + `setInterval(() => {}, 1 << 30);\n`, + ); + const run = createDefaultRunWrangler(TIMEOUT_TMP, { + launcher: [process.execPath, hungCli], + timeoutMs: 400, + }); + const start = Date.now(); + const result = await run(['d1', 'list', '--json'], { accountId: 'acct' }); + const elapsed = Date.now() - start; + // Settled in bounded time — the runner did NOT hang. The tight ceiling here is a + // load-tolerant sanity bound (the 400ms internal timer can be delayed on a busy + // machine); the real "did not hang forever" guard is the outer vitest timeout. + expect(elapsed).toBeLessThan(15_000); + // HARD failure: a non-null signal, folded to a non-zero status. + expect(result.signal).toBe('SIGKILL'); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/exceeded the 400ms command timeout/); + // No lingering process — the SIGKILL reached the real CLI. Poll for the OUTCOME + // (dead pid) with a generous bound rather than racing a tight deadline. + const pid = Number(readFileSync(pidFile, 'utf-8')); + expect(await waitForDead(pid, 10_000)).toBe(true); + }, 20_000); + + it('a WRAPPER that re-spawns a hung grandchild: runner still settles in time AND the grandchild is reaped', async () => { + // The exact scenario: a `bin/wrangler.js`-style wrapper that + // spawns an inner CLI with inherited stdio and forwards only SIGTERM (never SIGKILL), + // mapping a signal-killed child to exit 0. Pre-fix this defeated the timeout (the + // grandchild survived holding the pipes so `close` never fired). The runner now spawns + // the launcher in its OWN process group and SIGKILLs the WHOLE group on timeout, then + // settles immediately — so even a wrapper-plus-hung-grandchild rejects within the + // timeout with NO lingering process. + const gcPidFile = join(TIMEOUT_TMP, 'grandchild.pid'); + const grandchild = join(TIMEOUT_TMP, 'grandchild.cjs'); + writeFileSync( + grandchild, + `require('fs').writeFileSync(${JSON.stringify(gcPidFile)}, String(process.pid));\n` + + `process.on('SIGTERM', () => {});\n` + // ignore the wrapper's forwarded SIGTERM + `setInterval(() => {}, 1 << 30);\n`, + ); + const wrapper = join(TIMEOUT_TMP, 'wrapper.cjs'); + writeFileSync( + wrapper, + `const { spawn } = require('child_process');\n` + + // Under heavy full-suite fork pressure a single spawn can transiently fail + // (EAGAIN). Retry so the grandchild reliably establishes — otherwise the wrapper + // would exit early and the runner would settle via the NORMAL close path (signal + // null), making the timeout-kill assertion flake. On a hard failure keep the + // wrapper ALIVE so the runner's timeout+group-SIGKILL path is still exercised. + `let child;\n` + + `function launch() {\n` + + ` try {\n` + + ` child = spawn(process.execPath, [${JSON.stringify(grandchild)}], { stdio: ['inherit','inherit','inherit'] });\n` + + ` child.on('error', () => { child = undefined; setTimeout(launch, 50); });\n` + + ` child.on('exit', (code) => process.exit(code === null ? 0 : code));\n` + // mask signal death as exit 0 + ` } catch { setTimeout(launch, 50); }\n` + + `}\n` + + `launch();\n` + + `process.on('SIGTERM', () => { if (child) child.kill(); });\n` + // forward only SIGTERM (default) + `process.on('SIGINT', () => { if (child) child.kill(); });\n` + + `setInterval(() => {}, 1 << 30);\n`, // keep the wrapper alive until the group is killed + ); + const run = createDefaultRunWrangler(TIMEOUT_TMP, { + launcher: [process.execPath, wrapper], + // Generous internal timeout so the wrapper + grandchild reliably establish (and hang) + // before the timer fires even under load — the timeout-kill path is what we assert. + timeoutMs: 1_500, + }); + const start = Date.now(); + const result = await run(['d1', 'list', '--json'], { accountId: 'acct' }); + const elapsed = Date.now() - start; + // Bounded settle (the vitest timeout is the real "did not hang" guard). + expect(elapsed).toBeLessThan(20_000); + expect(result.signal).toBe('SIGKILL'); + expect(result.status).not.toBe(0); + // The grandchild wrote its pid, then must have been reaped by the process-group kill. + // Poll for the OUTCOME (dead pgid) with a generous, load-tolerant bound. + const gcPid = Number(readFileSync(gcPidFile, 'utf-8')); + expect(Number.isInteger(gcPid)).toBe(true); + expect(await waitForDead(gcPid, 10_000)).toBe(true); + }, 25_000); +}); + +describe('provisionCloudflareResources — account gate + env scrub', () => { + it('provisionEnv scrubs CLOUDFLARE_ENV + the CF Access token pair and pins the account', () => { + const saved: Record = {}; + const set = (k: string, v: string): void => { + saved[k] = process.env[k]; + process.env[k] = v; + }; + set('CLOUDFLARE_ENV', 'staging'); + set('CF_ACCESS_CLIENT_ID', 'id'); + set('CF_ACCESS_CLIENT_SECRET', 'secret'); + try { + const env = provisionEnv({ accountId: 'acct-X' }); + // CLOUDFLARE_ENV would let wrangler select an env-specific account block. + expect(env.CLOUDFLARE_ENV).toBeUndefined(); + expect(env.CF_ACCESS_CLIENT_ID).toBeUndefined(); + expect(env.CF_ACCESS_CLIENT_SECRET).toBeUndefined(); + expect(env.CLOUDFLARE_ACCOUNT_ID).toBe('acct-X'); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + } + }); + + it('provisionEnv scrubs EVERY transport-redirect var and pins the ONE resolved API base', () => { + const saved: Record = {}; + const set = (k: string, v: string): void => { + saved[k] = process.env[k]; + process.env[k] = v; + }; + // Ambient redirect vars that (via wrangler's per-command dotenv reload) could point the + // transport elsewhere — all must be scrubbed from the child env. + set('CLOUDFLARE_API_BASE_URL', 'https://evil.example'); + set('CF_API_BASE_URL', 'https://evil2.example'); + set('WRANGLER_API_ENVIRONMENT', 'staging'); + set('CLOUDFLARE_COMPLIANCE_REGION', 'fedramp_high'); + try { + const env = provisionEnv({ + accountId: 'acct-X', + apiBaseUrl: 'https://api.cloudflare.com/client/v4', + }); + expect(env.CF_API_BASE_URL).toBeUndefined(); + expect(env.WRANGLER_API_ENVIRONMENT).toBeUndefined(); + expect(env.CLOUDFLARE_COMPLIANCE_REGION).toBeUndefined(); + // The ONE pinned base overrides any ambient/derived value. + expect(env.CLOUDFLARE_API_BASE_URL).toBe('https://api.cloudflare.com/client/v4'); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + } + }); + + it('fails closed (no wrangler/REST) when no account is resolvable on the default path', async () => { + setup(JSON.stringify({ name: 'app' })); // no account_id + const prev = process.env.CLOUDFLARE_ACCOUNT_ID; + delete process.env.CLOUDFLARE_ACCOUNT_ID; // no ambient account either + try { + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + // NO runWrangler injected, NO accountId → the DEFAULT (real) path. + { log: silentLog }, + ), + ).rejects.toThrow(/no Cloudflare account is resolvable/); + // The gate throws BEFORE any wrangler subprocess / REST — nothing persisted. + expect(readConfig().d1_databases).toBeUndefined(); + expect(readConfig().account_id).toBeUndefined(); + } finally { + if (prev === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = prev; + } + } + }); + + it('persists the AMBIENT account before remote work; a later different-account retry fails closed', async () => { + setup(JSON.stringify({ name: 'app' })); + const prev = process.env.CLOUDFLARE_ACCOUNT_ID; + process.env.CLOUDFLARE_ACCOUNT_ID = 'ambient-A'; + try { + // Run 1: ambient A (no opts.accountId). D1 create writes a real id, THEN a + // later op fails → the run throws AFTER account_id was pinned to A up front. + const run1 = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: uuid('createdA'), + }); + return ok(); + }, + }, + { + match: /^queues info jobs/, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' }, queues: ['jobs'] }, + { runWrangler: run1.run, log: silentLog }, + ), + ).rejects.toThrow(/queues info jobs.*failed/s); + const after1 = readConfig(); + expect(after1.account_id).toBe('ambient-A'); + expect((after1.d1_databases as Array>)[0].database_id).toBe( + uuid('createdA'), + ); + + // Run 2 retries under account B → reconciliation A≠B fails closed, NO + // wrangler/REST (never rebinds account-A's real D1 id under account B). + let restCalled = false; + const run2 = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' }, queues: ['jobs'] }, + { + runWrangler: run2.run, + accountId: 'acct-B', + log: silentLog, + listHyperdriveConfigs: async () => { + restCalled = true; + return []; + }, + }, + ), + ).rejects.toThrow(/account mismatch.*ambient-A.*acct-B/s); + expect(run2.calls).toEqual([]); + expect(restCalled).toBe(false); + } finally { + if (prev === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = prev; + } + } + }); +}); + +describe('provisionCloudflareResources — resource-name validation', () => { + const badNames = ['--help', '--config', '-h']; + const kinds: Array<{ + label: RegExp; + desired: (name: string) => DesiredResourceSet; + createPrefix: Array; + }> = [ + { + // badNames all start with "-", so the universal option-injection guard fires FIRST + // with "refusing to pass an unsafe