From 6f58fe318fd9ce2393771d5a4de8e542cb56e20b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 23 Jul 2026 12:17:14 -0400 Subject: [PATCH] Add authenticated Cloudflare provisioning API core --- docs/cloudflare-provisioning-api.md | 24 ++ package.json | 2 +- .../src/d1-operation-repository.ts | 3 + .../src/provisioning-api.ts | 236 ++++++++++++++++++ .../src/static-artifact-import.ts | 4 +- packages/runtime-cloudflare/src/worker.ts | 16 ++ tests/cloudflare-provisioning-api.test.ts | 54 ++++ 7 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 docs/cloudflare-provisioning-api.md create mode 100644 packages/runtime-cloudflare/src/provisioning-api.ts create mode 100644 tests/cloudflare-provisioning-api.test.ts diff --git a/docs/cloudflare-provisioning-api.md b/docs/cloudflare-provisioning-api.md new file mode 100644 index 000000000..1e5d16860 --- /dev/null +++ b/docs/cloudflare-provisioning-api.md @@ -0,0 +1,24 @@ +# Cloudflare provisioning API core + +The D1 Worker exposes a versioned control-plane API before hostname-based WordPress routing. `/v1/*` never boots or falls through to WordPress. + +## Authentication + +`WORDPRESS_API_TOKENS` is a JSON array of bounded verifier records: + +```json +[{"id":"client-a","principal":"client-a","digest":"","scopes":["sites:create","sites:read","sites:import","operations:read"],"expiresAt":"2027-01-01T00:00:00.000Z","maxSites":3,"sites":["optional-existing-site"]}] +``` + +The Worker compares SHA-256 token digests and never accepts plaintext API credentials in configuration. `WORDPRESS_OPERATOR_TOKEN` remains exclusive to legacy `?phase=operator-*` endpoints. + +## Resources + +- `POST /v1/sites` requires `Authorization: Bearer ...` with `sites:create` and `Idempotency-Key`. Its body is `wp-codebox/provisioning-create-request/v1`; it references an immutable staged artifact at `sites/provisioning/import-artifacts/.json`. The Worker verifies the bounded artifact before allocating a site, then copies it immutably to the selected site namespace. +- `GET /v1/sites/{siteId}` requires `sites:read`. +- `POST /v1/sites/{siteId}/imports` requires `sites:import` and uses the existing bounded static-artifact request. +- `GET /v1/sites/{siteId}/operations/{operationId}` requires `operations:read`. + +All responses use versioned WP Codebox provisioning schemas. Site IDs are allocated only from `WORDPRESS_SITE_CONTEXTS`; caller hostnames, DNS, and Cloudflare control APIs are not inputs. The allocation transaction reserves the same shipped D1 site identity used by legacy/operator operations, so both interfaces contend on one hostname and an existing site cannot be taken over. D1 stores allocation ownership, immutable artifact identity and import options, and API operation links. The scheduler resumes incomplete allocations for its selected site before it runs an operation; it verifies staged bytes and converges the conditional destination copy, operation, and API link without blocking publication or cron when recovery fails. + +This is the API core portion of #1971. Administrator claims are intentionally a follow-up and are not represented by a route, secret, D1 field, resource field, documentation contract, or test in this PR. diff --git a/package.json b/package.json index 7677c998e..f8755980a 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "generate:cloudflare-canonical-mdi-seed": "npm run generate:cloudflare-mdi-runtime-bundle && php scripts/build-cloudflare-canonical-mdi-seed.php", "smoke": "tsx scripts/run-smoke.ts", "test:redaction": "tsx tests/redaction.test.ts", - "test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", + "test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", "test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts", "test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts", "test:agent-task-contracts": "tsx tests/agent-task-contracts.test.ts && tsx tests/agent-task-canonical-evidence.test.ts && npm run test:agent-task-workflow-interface && npm run test:runtime-sources-materialization && tsx tests/agent-task-reusable-workflow.test.ts", diff --git a/packages/runtime-cloudflare/src/d1-operation-repository.ts b/packages/runtime-cloudflare/src/d1-operation-repository.ts index 22482e456..17cdb15e0 100644 --- a/packages/runtime-cloudflare/src/d1-operation-repository.ts +++ b/packages/runtime-cloudflare/src/d1-operation-repository.ts @@ -33,6 +33,9 @@ export function shouldRecoverPreparedCommit(prepared: StaticArtifactOperation["p export class D1OperationRepository { constructor(private readonly database: D1Database, private readonly claimMs = 600_000) {} + /** Makes the shipped operation/site schema available to API allocation code. */ + async initialize(): Promise { await ensureSchema(this.database) } + async createOrConverge(site: SiteContext, input: StaticArtifactOperationInput): Promise<{ operation: StaticArtifactOperation; created: boolean }> { await ensureSchema(this.database) const now = Date.now() diff --git a/packages/runtime-cloudflare/src/provisioning-api.ts b/packages/runtime-cloudflare/src/provisioning-api.ts new file mode 100644 index 000000000..b6dc1cbab --- /dev/null +++ b/packages/runtime-cloudflare/src/provisioning-api.ts @@ -0,0 +1,236 @@ +import { D1OperationRepository, OperationConflict, type StaticArtifactOperation, type StaticArtifactOperationInput } from "./d1-operation-repository.js" +import { MAX_STATIC_ARTIFACT_BYTES, readBoundedRequestBytes, readStaticArtifactImport, StaticArtifactImportError, validateStaticArtifact } from "./static-artifact-import.js" +import { parseSiteContexts, siteStorageKeys, type SiteContext } from "./site-context.js" + +export const PROVISIONING_API_SCHEMA = "wp-codebox/provisioning-api/v1" +export const PROVISIONING_CREATE_REQUEST_SCHEMA = "wp-codebox/provisioning-create-request/v1" +export const PROVISIONING_SITE_RESOURCE_SCHEMA = "wp-codebox/provisioning-site/v1" +export const PROVISIONING_ERROR_SCHEMA = "wp-codebox/provisioning-error/v1" +const SCOPES = new Set(["sites:create", "sites:read", "sites:import", "operations:read"]) + +interface Token { id: string; principal: string; digest: string; scopes: string[]; expiresAt: string; sites?: string[]; maxSites: number } +export interface ProvisioningAllocation { + siteId: string; principal: string; key: string; fingerprint: string; operationId: string | null + artifactSha256: string; artifactSize: number; options: StaticArtifactOperationInput["options"] +} +interface CreateInput { key: string; fingerprint: string; artifactSha256: string; artifactSize: number; options: StaticArtifactOperationInput["options"] } +export interface ProvisioningEnv { WORDPRESS_STATE_DATABASE: D1Database; WORDPRESS_STATE_BUCKET: R2Bucket; WORDPRESS_SITE_CONTEXTS?: string; WORDPRESS_API_TOKENS?: string } + +export async function routeProvisioningApi(request: Request, env: ProvisioningEnv, operations: D1OperationRepository): Promise { + const parts = new URL(request.url).pathname.split("/").filter(Boolean) + const method = request.method + if (parts[0] !== "v1") return notFound() + if (parts.length === 2 && parts[1] === "sites") return method === "POST" ? create(request, env, operations) : methodNotAllowed("POST") + if (parts.length < 3 || parts[1] !== "sites" || !validSiteId(parts[2])) return notFound() + const siteId = parts[2] + if (parts.length === 3) return method === "GET" ? readSite(request, env, operations, siteId) : methodNotAllowed("GET") + if (parts.length === 4 && parts[3] === "imports") return method === "POST" ? importSite(request, env, operations, siteId) : methodNotAllowed("POST") + if (parts.length === 5 && parts[3] === "operations" && /^[0-9a-f-]{36}$/.test(parts[4])) return method === "GET" ? readOperation(request, env, operations, siteId, parts[4]) : methodNotAllowed("GET") + return notFound() +} + +async function create(request: Request, env: ProvisioningEnv, operations: D1OperationRepository): Promise { + const token = await authenticate(request, env, "sites:create"); if (token instanceof Response) return token + const key = idempotencyKey(request); if (key instanceof Response) return key + let input: CreateInput + try { input = await readCreate(request, env.WORDPRESS_STATE_BUCKET, key) } catch (error) { return importError(error) } + await operations.initialize() + const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE) + let allocation: ProvisioningAllocation + try { + const active = await env.WORDPRESS_STATE_DATABASE.prepare("SELECT site_id FROM wp_codebox_sites WHERE state = 'active'").all<{ site_id: string }>() + const occupied = new Set(active.results.map((row) => row.site_id)) + const candidates = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS).filter((site) => allowed(token, site.id) && !occupied.has(site.id)) + allocation = await store.allocate(token, input, candidates) + } catch (error) { return error instanceof OperationConflict ? apiError(409, "idempotency_conflict", error.message) : allocationError(error) } + if (!allowed(token, allocation.siteId)) return notFound() + const site = context(env, allocation.siteId) + if (!site) return notFound() + try { + const operation = await resumeProvisioningAllocation(env, site, operations) + return siteResource(site, operation, 202) + } catch (error) { if (error instanceof OperationConflict) return apiError(409, "idempotency_conflict", error.message); throw error } +} + +async function readSite(request: Request, env: ProvisioningEnv, operations: D1OperationRepository, siteId: string): Promise { + const token = await authenticate(request, env, "sites:read"); if (token instanceof Response) return token + const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId) + const site = context(env, siteId) + if (!allocation || !site || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound() + return siteResource(site, allocation.operationId ? await operations.get(siteId, allocation.operationId) : null) +} + +async function importSite(request: Request, env: ProvisioningEnv, operations: D1OperationRepository, siteId: string): Promise { + const token = await authenticate(request, env, "sites:import"); if (token instanceof Response) return token + const key = idempotencyKey(request); if (key instanceof Response) return key + const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE); const allocation = await store.bySite(siteId); const site = context(env, siteId) + if (!allocation || !site || allocation.principal !== token.principal || !allowed(token, siteId)) return notFound() + const provision = allocation.operationId ? await operations.get(siteId, allocation.operationId) : null + if (provision?.state !== "succeeded") return apiError(409, "site_not_ready", "The site is not ready for imports.") + try { + const input = await readStaticArtifactImport(request, env.WORDPRESS_STATE_BUCKET, site) + if (input.idempotencyKey !== key) return apiError(409, "idempotency_conflict", "Idempotency-Key must match the import request.") + const result = await operations.createOrConverge(site, { ...input, artifact: input.artifactReference }) + await store.linkOperation(token.principal, siteId, result.operation.operationId, "import", key) + return operationResource(siteId, result.operation, 202) + } catch (error) { return error instanceof OperationConflict ? apiError(409, "operation_conflict", error.message) : importError(error) } +} + +async function readOperation(request: Request, env: ProvisioningEnv, operations: D1OperationRepository, siteId: string, operationId: string): Promise { + const token = await authenticate(request, env, "operations:read"); if (token instanceof Response) return token + const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE); const allocation = await store.bySite(siteId) + if (!allocation || !context(env, siteId) || allocation.principal !== token.principal || !allowed(token, siteId) || !await store.ownsOperation(token.principal, siteId, operationId)) return notFound() + const operation = await operations.get(siteId, operationId) + return operation ? operationResource(siteId, operation) : notFound() +} + +/** Converges a durable allocation after an interrupted API request or scheduled turn. */ +export async function resumeProvisioningAllocation(env: ProvisioningEnv, site: SiteContext, operations: D1OperationRepository): Promise { + const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE) + const allocation = await store.bySite(site.id) + if (!allocation) return null + if (allocation.operationId) { + const existing = await operations.get(site.id, allocation.operationId) + if (existing) { + await store.linkOperation(allocation.principal, site.id, existing.operationId, "provision", allocation.key) + if (["publication-pending", "succeeded", "failed"].includes(existing.state)) return existing + } + } + const input = await verifiedAllocationInput(env.WORDPRESS_STATE_BUCKET, site, allocation) + await putVerifiedArtifact(env.WORDPRESS_STATE_BUCKET, site, input) + const result = await operations.createOrConverge(site, input) + await store.bindOperation(allocation, result.operation.operationId) + await store.linkOperation(allocation.principal, site.id, result.operation.operationId, "provision", allocation.key) + return result.operation +} + +async function readCreate(request: Request, bucket: R2Bucket, key: string): Promise { + const bytes = await readBoundedRequestBytes(request) + let body: Record; try { body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) } catch { throw new StaticArtifactImportError("Provisioning request must be valid UTF-8 JSON.", 400) } + if (!record(body) || body.schema !== PROVISIONING_CREATE_REQUEST_SCHEMA || body.idempotencyKey !== key || !record(body.artifact) || !record(body.import)) throw new StaticArtifactImportError("Provisioning request is invalid.", 400) + const sha256 = body.artifact.sha256; const size = body.artifact.size + if (typeof sha256 !== "string" || !/^[a-f0-9]{64}$/.test(sha256) || body.artifact.r2Key !== stagedKey(sha256) || !Number.isSafeInteger(size) || typeof size !== "number" || size < 1 || size > MAX_STATIC_ARTIFACT_BYTES) throw new StaticArtifactImportError("Provisioning artifact reference is invalid.", 400) + const options = optionsOf(body.import); const object = await bucket.get(stagedKey(sha256)) + if (!object) throw new StaticArtifactImportError("Provisioning artifact is unavailable.", 404) + const artifact = new Uint8Array(await object.arrayBuffer()) + if (object.size !== size || artifact.byteLength !== size || await sha(artifact) !== sha256) throw new StaticArtifactImportError("Provisioning artifact does not match its reference.", 409) + await validateArtifact(artifact) + return { key, artifactSha256: sha256, artifactSize: size, options, fingerprint: await shaText(JSON.stringify({ sha256, size, options })) } +} + +async function verifiedAllocationInput(bucket: R2Bucket, site: SiteContext, allocation: ProvisioningAllocation): Promise { + const staged = await bucket.get(stagedKey(allocation.artifactSha256)) + if (!staged) throw new Error("Provisioning staging artifact is unavailable during recovery.") + const bytes = new Uint8Array(await staged.arrayBuffer()) + if (staged.size !== allocation.artifactSize || bytes.byteLength !== allocation.artifactSize || await sha(bytes) !== allocation.artifactSha256) throw new Error("Provisioning staging artifact no longer matches its allocation.") + await validateArtifact(bytes) + return { idempotencyKey: allocation.key, fingerprint: allocation.fingerprint, artifact: { r2Key: destinationKey(site, allocation.artifactSha256), sha256: allocation.artifactSha256, size: allocation.artifactSize }, options: allocation.options } +} + +async function validateArtifact(bytes: Uint8Array): Promise { try { await validateStaticArtifact(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))) } catch (error) { if (error instanceof StaticArtifactImportError) throw error; throw new StaticArtifactImportError("Provisioning artifact must be valid UTF-8 JSON.", 422) } } +async function putVerifiedArtifact(bucket: R2Bucket, site: SiteContext, input: StaticArtifactOperationInput): Promise { + const verify = async () => { + const object = await bucket.get(input.artifact.r2Key) + if (!object) return false + const bytes = new Uint8Array(await object.arrayBuffer()) + if (object.size !== input.artifact.size || bytes.byteLength !== input.artifact.size || await sha(bytes) !== input.artifact.sha256) throw new OperationConflict("Allocated artifact destination conflicts with verified content.") + return true + } + if (await verify()) return + const staged = await bucket.get(stagedKey(input.artifact.sha256)) + if (!staged) throw new Error("Provisioning staging artifact is unavailable during copy.") + const bytes = new Uint8Array(await staged.arrayBuffer()) + if (staged.size !== input.artifact.size || bytes.byteLength !== input.artifact.size || await sha(bytes) !== input.artifact.sha256) throw new Error("Provisioning staging artifact changed during copy.") + const result = await bucket.put(input.artifact.r2Key, bytes, { onlyIf: { etagDoesNotMatch: "*" }, httpMetadata: { contentType: "application/json" } }) + if (!await verify()) throw new OperationConflict(result === null ? "Conditional artifact copy did not produce a destination object." : "Allocated artifact destination disappeared after its conditional write.") +} + +function siteResource(site: SiteContext, operation: StaticArtifactOperation | null, status = 200): Response { + const state = operation?.state === "succeeded" ? "ready" : operation?.state === "failed" ? "failed" : operation?.state === "publication-pending" ? "publication-pending" : "provisioning" + return Response.json({ schema: PROVISIONING_SITE_RESOURCE_SCHEMA, site: { id: site.id, status: state, url: site.origin, operation: operation ? `/v1/sites/${site.id}/operations/${operation.operationId}` : null } }, { status }) +} +function operationResource(siteId: string, operation: StaticArtifactOperation, status = 200): Response { return Response.json({ schema: PROVISIONING_API_SCHEMA, operation: { id: operation.operationId, siteId, state: operation.state, stage: operation.stage, progress: operation.progress, retryAt: operation.retryAt, error: operation.error, receipt: operation.receipt } }, { status }) } +function apiError(status: number, code: string, message: string, allow?: string): Response { return Response.json({ schema: PROVISIONING_ERROR_SCHEMA, error: { code, message } }, { status, headers: allow ? { Allow: allow } : undefined }) } +function notFound(): Response { return apiError(404, "not_found", "The API resource is unavailable.") } +function methodNotAllowed(allow: string): Response { return apiError(405, "method_not_allowed", "The API method is unsupported.", allow) } +function importError(error: unknown): Response { if (error instanceof StaticArtifactImportError) return apiError(error.status, "invalid_import", error.message); throw error } +function allocationError(error: unknown): Response { if (error instanceof AllocationError) return apiError(error.code === "quota_exceeded" ? 429 : 409, error.code, error.code === "quota_exceeded" ? "The principal site quota is exhausted." : "No configured site context is available."); throw error } +function idempotencyKey(request: Request): string | Response { const key = request.headers.get("idempotency-key"); return key && /^[A-Za-z0-9._:-]{1,128}$/.test(key) ? key : apiError(400, "invalid_idempotency_key", "Idempotency-Key is required.") } + +async function authenticate(request: Request, env: ProvisioningEnv, scope: string): Promise { + const bearer = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/)?.[1] + if (!bearer) return apiError(401, "unauthorized", "Bearer authentication is required.") + let tokens: Token[]; try { tokens = parseTokens(env.WORDPRESS_API_TOKENS) } catch { return apiError(401, "unauthorized", "Bearer authentication is unavailable.") } + const digest = await shaText(bearer) + for (const token of tokens) if (await equal(digest, token.digest)) { + if (Date.parse(token.expiresAt) <= Date.now()) return apiError(401, "unauthorized", "Bearer authentication failed.") + return token.scopes.includes(scope) ? token : apiError(403, "forbidden", "The bearer token lacks the required scope.") + } + return apiError(401, "unauthorized", "Bearer authentication failed.") +} +function parseTokens(value: string | undefined): Token[] { + const tokens: unknown = JSON.parse(value ?? "") + if (!Array.isArray(tokens) || !tokens.length || tokens.length > 64) throw new Error("Invalid token configuration.") + const ids = new Set(); const digests = new Set() + return tokens.map((raw) => { + if (!record(raw)) throw new Error("Invalid token configuration.") + const token = raw as unknown as Token + if (!/^[A-Za-z0-9._-]{1,64}$/.test(token.id) || !/^[A-Za-z0-9._:-]{1,128}$/.test(token.principal) || !/^[a-f0-9]{64}$/.test(token.digest) || ids.has(token.id) || digests.has(token.digest) || !Array.isArray(token.scopes) || !token.scopes.length || token.scopes.length > 4 || token.scopes.some((scope) => !SCOPES.has(scope)) || !Number.isInteger(token.maxSites) || token.maxSites < 0 || token.maxSites > 10_000 || typeof token.expiresAt !== "string" || new Date(token.expiresAt).toISOString() !== token.expiresAt || Date.parse(token.expiresAt) <= 0 || (token.sites !== undefined && (!Array.isArray(token.sites) || token.sites.length > 256 || token.sites.some((site) => !validSiteId(site))))) throw new Error("Invalid token configuration.") + ids.add(token.id); digests.add(token.digest); return token + }) +} +function optionsOf(value: Record): StaticArtifactOperationInput["options"] { const slug = value.slug; const name = typeof value.name === "string" ? value.name.trim() : ""; const siteTitle = typeof value.siteTitle === "string" ? value.siteTitle.trim() : ""; if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slug.length > 64 || !name || name.length > 120 || !siteTitle || siteTitle.length > 120) throw new StaticArtifactImportError("Provisioning import options are invalid.", 400); return { slug, name, siteTitle } } +function stagedKey(sha256: string): string { return `sites/provisioning/import-artifacts/${sha256}.json` } +function destinationKey(site: SiteContext, sha256: string): string { return `${siteStorageKeys(site).staticArtifactPrefix}/${sha256}.json` } +function context(env: ProvisioningEnv, id: string): SiteContext | undefined { return parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS).find((site) => site.id === id) } +function allowed(token: Token, siteId: string): boolean { return !token.sites || token.sites.includes(siteId) } +function validSiteId(value: string): boolean { return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && value.length <= 63 } +function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } +async function sha(bytes: Uint8Array): Promise { return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes as Uint8Array)), (byte) => byte.toString(16).padStart(2, "0")).join("") } +async function shaText(value: string): Promise { return sha(new TextEncoder().encode(value)) } +async function equal(left: string, right: string): Promise { if (left.length !== right.length) return false; let result = 0; for (let i = 0; i < left.length; i++) result |= left.charCodeAt(i) ^ right.charCodeAt(i); return result === 0 } + +class AllocationError extends Error { constructor(readonly code: "quota_exceeded" | "capacity_exhausted") { super(code) } } +class AllocationStore { + constructor(private readonly db: D1Database) {} + async allocate(token: Token, input: CreateInput, sites: SiteContext[]): Promise { + await this.schema(); const existing = await this.byRequest(token.principal, input.key) + if (existing) { if (existing.fingerprint !== input.fingerprint) throw new OperationConflict("The idempotency key is already bound to a different immutable input."); return existing } + for (const site of sites) { + const now = Date.now() + try { + const [allocation, reservation] = await this.db.batch([ + this.db.prepare("INSERT OR IGNORE INTO wp_codebox_api_sites (site_id, principal, idempotency_key, fingerprint, artifact_sha256, artifact_size, slug, name, site_title, operation_id, created_at) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ? WHERE (SELECT COUNT(*) FROM wp_codebox_api_sites WHERE principal = ?) < ? AND NOT EXISTS (SELECT 1 FROM wp_codebox_sites WHERE site_id = ?)").bind(site.id, token.principal, input.key, input.fingerprint, input.artifactSha256, input.artifactSize, input.options.slug, input.options.name, input.options.siteTitle, now, token.principal, token.maxSites, site.id), + this.db.prepare("INSERT INTO wp_codebox_sites (site_id, hostname, origin, state, created_at, activated_at, updated_at) SELECT ?, ?, ?, 'active', ?, ?, ? WHERE EXISTS (SELECT 1 FROM wp_codebox_api_sites WHERE site_id = ? AND principal = ? AND idempotency_key = ? AND fingerprint = ?)").bind(site.id, site.hostname, site.origin, now, now, now, site.id, token.principal, input.key, input.fingerprint), + ]) + if (allocation.meta.changes === 1 && reservation.meta.changes === 1) return { siteId: site.id, principal: token.principal, key: input.key, fingerprint: input.fingerprint, operationId: null, artifactSha256: input.artifactSha256, artifactSize: input.artifactSize, options: input.options } + } catch (error) { + if (!(error instanceof Error) || !/unique|constraint/i.test(error.message)) throw error + } + const raced = await this.byRequest(token.principal, input.key) + if (raced) { if (raced.fingerprint !== input.fingerprint) throw new OperationConflict("The idempotency key is already bound to a different immutable input."); return raced } + } + const count = await this.db.prepare("SELECT COUNT(*) AS count FROM wp_codebox_api_sites WHERE principal = ?").bind(token.principal).first<{ count: number }>() + throw new AllocationError((count?.count ?? 0) >= token.maxSites ? "quota_exceeded" : "capacity_exhausted") + } + async bySite(siteId: string): Promise { await this.schema(); const row = await this.db.prepare("SELECT site_id, principal, idempotency_key, fingerprint, artifact_sha256, artifact_size, slug, name, site_title, operation_id FROM wp_codebox_api_sites WHERE site_id = ?").bind(siteId).first>(); return row ? allocation(row) : null } + async bindOperation(value: ProvisioningAllocation, operationId: string): Promise { + await this.schema() + await this.db.prepare("UPDATE wp_codebox_api_sites SET operation_id = ? WHERE site_id = ? AND (operation_id IS NULL OR operation_id = ?)").bind(operationId, value.siteId, operationId).run() + const current = await this.bySite(value.siteId) + if (current?.operationId !== operationId) throw new OperationConflict("The provisioning allocation is already bound to another operation.") + } + async linkOperation(principal: string, siteId: string, operationId: string, kind: "provision" | "import", key: string): Promise { + await this.schema() + await this.db.prepare("INSERT OR IGNORE INTO wp_codebox_api_operation_links (principal, site_id, operation_id, kind, idempotency_key) VALUES (?, ?, ?, ?, ?)").bind(principal, siteId, operationId, kind, key).run() + const linked = await this.db.prepare("SELECT operation_id FROM wp_codebox_api_operation_links WHERE principal = ? AND site_id = ? AND kind = ? AND idempotency_key = ?").bind(principal, siteId, kind, key).first<{ operation_id: string }>() + if (linked?.operation_id !== operationId) throw new OperationConflict("The API idempotency key is already bound to another operation.") + } + async ownsOperation(principal: string, siteId: string, operationId: string): Promise { await this.schema(); return !!await this.db.prepare("SELECT operation_id FROM wp_codebox_api_operation_links WHERE principal = ? AND site_id = ? AND operation_id = ?").bind(principal, siteId, operationId).first() } + private async byRequest(principal: string, key: string): Promise { const row = await this.db.prepare("SELECT site_id, principal, idempotency_key, fingerprint, artifact_sha256, artifact_size, slug, name, site_title, operation_id FROM wp_codebox_api_sites WHERE principal = ? AND idempotency_key = ?").bind(principal, key).first>(); return row ? allocation(row) : null } + private async schema(): Promise { + await this.db.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_api_sites (site_id TEXT PRIMARY KEY, principal TEXT NOT NULL, idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL, artifact_sha256 TEXT NOT NULL, artifact_size INTEGER NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, site_title TEXT NOT NULL, operation_id TEXT, created_at INTEGER NOT NULL, UNIQUE(principal, idempotency_key))").run() + await this.db.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_api_operation_links (principal TEXT NOT NULL, site_id TEXT NOT NULL, operation_id TEXT NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('provision','import')), idempotency_key TEXT NOT NULL, PRIMARY KEY (principal, site_id, operation_id), UNIQUE(principal, site_id, kind, idempotency_key))").run() + } +} +function allocation(row: Record): ProvisioningAllocation { return { siteId: row.site_id as string, principal: row.principal as string, key: row.idempotency_key as string, fingerprint: row.fingerprint as string, operationId: row.operation_id as string | null, artifactSha256: row.artifact_sha256 as string, artifactSize: row.artifact_size as number, options: { slug: row.slug as string, name: row.name as string, siteTitle: row.site_title as string } } } diff --git a/packages/runtime-cloudflare/src/static-artifact-import.ts b/packages/runtime-cloudflare/src/static-artifact-import.ts index b0f2ee739..db6ee5cea 100644 --- a/packages/runtime-cloudflare/src/static-artifact-import.ts +++ b/packages/runtime-cloudflare/src/static-artifact-import.ts @@ -77,7 +77,7 @@ export class StaticArtifactImportError extends Error { } } -async function readBoundedRequestBytes(request: Request): Promise { +export async function readBoundedRequestBytes(request: Request): Promise { if (!request.body) return new Uint8Array() const reader = request.body.getReader() const chunks: Uint8Array[] = [] @@ -101,7 +101,7 @@ async function readBoundedRequestBytes(request: Request): Promise { return bytes } -async function validateStaticArtifact(value: unknown): Promise { +export async function validateStaticArtifact(value: unknown): Promise { if (!value || typeof value !== "object" || Array.isArray(value)) throw new StaticArtifactImportError("Static artifact must be an object.", 422) const artifact = value as Record if (artifact.schema !== STATIC_ARTIFACT_SCHEMA || typeof artifact.root !== "string" || !isSafePath(artifact.root) diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index a26c6e3fd..6df98d228 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -14,6 +14,7 @@ import { RevisionConflict, type MarkdownPointer, type MutationFence, type Revisi import { routeWorkerRequest } from "./request-routing.js" import { readStaticArtifactImport, STATIC_ARTIFACT_IMPORT_RESULT_SCHEMA, StaticArtifactImportError, type StaticArtifactImport } from "./static-artifact-import.js" import { D1OperationRepository, OperationConflict, STATIC_ARTIFACT_OPERATION_SCHEMA, shouldRecoverPreparedCommit } from "./d1-operation-repository.js" +import { resumeProvisioningAllocation, routeProvisioningApi } from "./provisioning-api.js" import { toFetchResponse, toPHPRequest } from "./request-translation.js" import { DEFAULT_SITE_CONTEXT, parseSiteContexts, resolveSiteContextFromRequest, siteStorageKeys, type SiteContext } from "./site-context.js" import { validateUploadManifestFiles, validateUploadMetadata } from "./upload-persistence.js" @@ -210,6 +211,8 @@ export interface RuntimeEnv { WORDPRESS_ADMIN_PASSWORD?: string WORDPRESS_AUTH_SECRET?: string WORDPRESS_OPERATOR_TOKEN?: string + WORDPRESS_API_TOKENS?: string + WORDPRESS_STATE_DATABASE?: D1Database } export function createCloudflareRuntime( @@ -219,6 +222,12 @@ export function createCloudflareRuntime( ) { return { async fetch(request: Request, env: Env): Promise { + // The versioned control API is host-independent and must never boot WordPress. + if (new URL(request.url).pathname === "/v1" || new URL(request.url).pathname.startsWith("/v1/")) { + const operations = resolveOperations?.(env) + if (!operations || !("WORDPRESS_STATE_DATABASE" in env)) return Response.json({ schema: "wp-codebox/provisioning-api/v1", error: { code: "not_found", message: "The API resource is unavailable." } }, { status: 404 }) + return routeProvisioningApi(request, env as Env & { WORDPRESS_STATE_DATABASE: D1Database }, operations) + } let site: SiteContext try { site = resolveSiteContextFromRequest(request, parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS)) @@ -266,6 +275,13 @@ export function createCloudflareRuntime( const site = sites[Math.floor(controller.scheduledTime / 60_000) % sites.length] const coordinator = resolveCoordinator(env, site) const operations = resolveOperations?.(env) + if (operations && env.WORDPRESS_STATE_DATABASE) { + try { + await resumeProvisioningAllocation(env as Env & { WORDPRESS_STATE_DATABASE: D1Database }, site, operations) + } catch (error) { + console.error("Provisioning allocation recovery failed.", error) + } + } const fence = await coordinator.fenceStatus() const siteCycle = Math.floor(controller.scheduledTime / (60_000 * sites.length)) const operationFirst = siteCycle % 2 === 0 diff --git a/tests/cloudflare-provisioning-api.test.ts b/tests/cloudflare-provisioning-api.test.ts new file mode 100644 index 000000000..018503d43 --- /dev/null +++ b/tests/cloudflare-provisioning-api.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict" +import { createHash } from "node:crypto" +import { DatabaseSync } from "node:sqlite" +import test from "node:test" +import { D1OperationRepository } from "../packages/runtime-cloudflare/src/d1-operation-repository.js" +import { PROVISIONING_CREATE_REQUEST_SCHEMA, resumeProvisioningAllocation, routeProvisioningApi } from "../packages/runtime-cloudflare/src/provisioning-api.js" +import { STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA } from "../packages/runtime-cloudflare/src/static-artifact-import.js" + +const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex") +const artifactText = JSON.stringify({ schema: "blocks-engine/php-transformer/site-artifact/v1", root: "website", entrypoint: "website/index.html", files: [{ path: "website/index.html", content: "ok" }] }) +const artifact = new TextEncoder().encode(artifactText); const digest = hash(artifact) +type Db = D1Database & { sqlite: DatabaseSync } +function database(): Db { + const sqlite = new DatabaseSync(":memory:") + return Object.assign({ prepare(query: string) { const statement = sqlite.prepare(query); let values: unknown[] = []; return { bind(...next: unknown[]) { values = next; return this }, async run() { return { meta: { changes: statement.run(...values).changes } } }, async first() { return statement.get(...values) as T | null }, async all() { return { results: statement.all(...values) as T[] } } } }, async batch(statements: Array<{ run: () => Promise<{ meta: { changes: number } }> }>) { sqlite.exec("BEGIN"); try { const results = []; for (const statement of statements) results.push(await statement.run()); sqlite.exec("COMMIT"); return results } catch (error) { sqlite.exec("ROLLBACK"); throw error } } } as unknown as D1Database, { sqlite }) +} +class Bucket { + objects = new Map(); gets = 0; puts = 0; race?: Uint8Array; dropSuccessfulPut = false + async get(key: string) { this.gets++; const value = this.objects.get(key); return value ? { size: value.byteLength, etag: hash(value), arrayBuffer: async () => value.slice().buffer } : null } + async put(key: string, value: string | Uint8Array, options?: { onlyIf?: { etagDoesNotMatch?: string } }) { this.puts++; const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value; if (options?.onlyIf?.etagDoesNotMatch === "*" && this.objects.has(key)) return null; if (this.race && options?.onlyIf) { this.objects.set(key, this.race); this.race = undefined; return null } if (!this.dropSuccessfulPut) this.objects.set(key, bytes.slice()); return { key } } +} +function runtime(db = database(), bucket = new Bucket(), tokens?: unknown, maxSites = 2) { + bucket.objects.set(`sites/provisioning/import-artifacts/${digest}.json`, artifact) + const records = tokens ?? [{ id: "a", principal: "a", digest: hash("good"), scopes: ["sites:create", "sites:read", "sites:import", "operations:read"], expiresAt: "2099-01-01T00:00:00.000Z", maxSites }] + return { env: { WORDPRESS_STATE_DATABASE: db, WORDPRESS_STATE_BUCKET: bucket as unknown as R2Bucket, WORDPRESS_SITE_CONTEXTS: JSON.stringify([{ id: "alpha", hostname: "alpha.example", origin: "https://alpha.example" }, { id: "beta", hostname: "beta.example", origin: "https://beta.example" }]), WORDPRESS_API_TOKENS: JSON.stringify(records) }, db, bucket, operations: new D1OperationRepository(db) } +} +function createRequest(key = "create-1", change: Partial<{ sha256: string; size: number; r2Key: string; title: string }> = {}, token = "good") { + const sha256 = change.sha256 ?? digest; const size = change.size ?? artifact.byteLength + return new Request("https://control.invalid/v1/sites", { method: "POST", headers: { authorization: `Bearer ${token}`, "idempotency-key": key }, body: JSON.stringify({ schema: PROVISIONING_CREATE_REQUEST_SCHEMA, idempotencyKey: key, artifact: { sha256, size, r2Key: change.r2Key ?? `sites/provisioning/import-artifacts/${sha256}.json` }, import: { slug: "site", name: "Site", siteTitle: change.title ?? "Site" } }) }) +} +async function create(runtime: ReturnType, key = "create-1") { return routeProvisioningApi(createRequest(key), runtime.env, runtime.operations) } +function count(db: Db, table: string) { const exists = db.sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table); return exists ? Number((db.sqlite.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count) : 0 } + +test("auth failures, malformed config, expiry, and scope stop before body and R2 reads", async () => { + for (const config of [undefined, [], [{ id: "a", principal: "a", digest: hash("good"), scopes: ["sites:create"], expiresAt: "2000-01-01T00:00:00.000Z", maxSites: 1 }], [{ id: "a", principal: "a", digest: hash("good"), scopes: ["sites:read"], expiresAt: "2099-01-01T00:00:00.000Z", maxSites: 1 }]]) { + const r = runtime(database(), new Bucket(), config); const response = await routeProvisioningApi(createRequest("key", {}, config === undefined ? "bad" : "good"), r.env, r.operations); assert.ok([401, 403].includes(response.status)); assert.equal(r.bucket.gets, 0) + } +}) +test("invalid, missing, and oversized artifacts create zero allocations", async () => { + for (const change of [{ sha256: "bad" }, { sha256: "f".repeat(64) }, { size: 5 * 1024 * 1024 }]) { const r = runtime(); assert.ok((await routeProvisioningApi(createRequest("key", change), r.env, r.operations)).status >= 400); assert.equal(count(r.db, "wp_codebox_api_sites"), 0) } +}) +test("registered active contexts are excluded", async () => { const r = runtime(); await r.operations.createOrConverge({ id: "alpha", hostname: "alpha.example", origin: "https://alpha.example" }, { idempotencyKey: "legacy", fingerprint: "x", artifact: { r2Key: "x", sha256: digest, size: artifact.byteLength }, options: { slug: "x", name: "x", siteTitle: "x" } }); const body = await (await create(r)).json() as { site: { id: string } }; assert.equal(body.site.id, "beta") }) +test("exact and concurrent same-key creates converge", async () => { const r = runtime(); const [one, two] = await Promise.all([create(r), create(r)]); assert.equal(one.status, 202); assert.deepEqual(await one.json(), await two.json()); assert.equal(count(r.db, "wp_codebox_api_sites"), 1) }) +test("changed fingerprints conflict without another allocation", async () => { const r = runtime(); await create(r); const response = await routeProvisioningApi(createRequest("create-1", { title: "Changed" }), r.env, r.operations); assert.equal(response.status, 409); assert.equal(count(r.db, "wp_codebox_api_sites"), 1) }) +test("create replay honors current site restrictions", async () => { const r = runtime(); await create(r); const records = JSON.parse(r.env.WORDPRESS_API_TOKENS) as Array>; records[0].sites = ["beta"]; r.env.WORDPRESS_API_TOKENS = JSON.stringify(records); assert.equal((await create(r)).status, 404); assert.equal(count(r.db, "wp_codebox_api_sites"), 1) }) +test("concurrent distinct keys allocate unique contexts", async () => { const r = runtime(); await Promise.all([create(r, "one"), create(r, "two")]); const ids = r.db.sqlite.prepare("SELECT site_id FROM wp_codebox_api_sites ORDER BY site_id").all() as Array<{ site_id: string }>; assert.deepEqual(ids.map((row) => row.site_id), ["alpha", "beta"]) }) +test("maxSites concurrency distinguishes quota from pool exhaustion", async () => { const quota = runtime(database(), new Bucket(), undefined, 1); const responses = await Promise.all([create(quota, "one"), create(quota, "two")]); assert.equal(responses.filter((response) => response.status === 202).length, 1); const rejected = responses.find((response) => response.status !== 202)!; assert.equal((await rejected.json() as { error: { code: string } }).error.code, "quota_exceeded"); assert.equal(count(quota.db, "wp_codebox_api_sites"), 1); const pool = runtime(database(), new Bucket(), undefined, 3); await Promise.all([create(pool, "one"), create(pool, "two")]); const pr = await create(pool, "three"); assert.equal((await pr.json() as { error: { code: string } }).error.code, "capacity_exhausted") }) +test("conditional R2 winner is re-read and verified, while missing or conflicting winners block operation", async () => { const r = runtime(); r.bucket.race = artifact; const ok = await create(r); assert.equal(ok.status, 202); const bad = runtime(); bad.bucket.race = new TextEncoder().encode("bad"); assert.equal((await create(bad)).status, 409); assert.equal(count(bad.db, "wp_codebox_operations"), 0); const missing = runtime(); missing.bucket.dropSuccessfulPut = true; assert.equal((await create(missing)).status, 409); assert.equal(count(missing.db, "wp_codebox_operations"), 0) }) +test("scheduler recovery helper converges allocation without operation, API link, and destination copy", async () => { const r = runtime(); r.db.sqlite.exec("CREATE TABLE wp_codebox_api_sites (site_id TEXT PRIMARY KEY, principal TEXT NOT NULL, idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL, artifact_sha256 TEXT NOT NULL, artifact_size INTEGER NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, site_title TEXT NOT NULL, operation_id TEXT, created_at INTEGER NOT NULL, UNIQUE(principal, idempotency_key)); CREATE TABLE wp_codebox_api_operation_links (principal TEXT NOT NULL, site_id TEXT NOT NULL, operation_id TEXT NOT NULL, kind TEXT NOT NULL, idempotency_key TEXT NOT NULL, PRIMARY KEY (principal,site_id,operation_id), UNIQUE(principal,site_id,kind,idempotency_key));"); r.db.sqlite.prepare("INSERT INTO wp_codebox_api_sites VALUES ('alpha','a','one','fingerprint',?,?, 'site','Site','Site',NULL,1)").run(digest, artifact.byteLength); const site = { id: "alpha", hostname: "alpha.example", origin: "https://alpha.example" }; const operation = await resumeProvisioningAllocation(r.env, site, r.operations); assert.ok(operation); r.db.sqlite.prepare("DELETE FROM wp_codebox_api_operation_links").run(); r.db.sqlite.prepare("UPDATE wp_codebox_api_sites SET operation_id = ? WHERE site_id = 'alpha'").run(operation!.operationId); await resumeProvisioningAllocation(r.env, site, r.operations); assert.equal(count(r.db, "wp_codebox_api_operation_links"), 1); assert.ok(r.bucket.objects.has(`sites/alpha/import-artifacts/${digest}.json`)) }) +test("cross-principal and site-restricted reads and imports fail closed", async () => { const tokens = [{ id: "a", principal: "a", digest: hash("good"), scopes: ["sites:create", "sites:read", "sites:import", "operations:read"], expiresAt: "2099-01-01T00:00:00.000Z", maxSites: 2 }, { id: "b", principal: "b", digest: hash("other"), scopes: ["sites:read", "sites:import"], expiresAt: "2099-01-01T00:00:00.000Z", maxSites: 2, sites: ["beta"] }]; const r = runtime(database(), new Bucket(), tokens); await create(r); for (const url of ["/v1/sites/alpha", "/v1/sites/alpha/imports"]) { const response = await routeProvisioningApi(new Request(`https://control.invalid${url}`, { method: url.endsWith("imports") ? "POST" : "GET", headers: { authorization: "Bearer other", "idempotency-key": "x" }, body: url.endsWith("imports") ? "{}" : undefined }), r.env, r.operations); assert.equal(response.status, 404) } }) +test("not-ready import is rejected and ready exact replay converges with an API link", async () => { const r = runtime(); const body = await (await create(r)).json() as { site: { id: string; operation: string } }; const url = `https://control.invalid/v1/sites/${body.site.id}/imports`; const importBody = { schema: STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA, idempotencyKey: "import-1", artifact: { r2Key: `sites/${body.site.id}/import-artifacts/${digest}.json`, sha256: digest, size: artifact.byteLength }, import: { slug: "import", name: "Import", siteTitle: "Import" } }; const pending = await routeProvisioningApi(new Request(url, { method: "POST", headers: { authorization: "Bearer good", "idempotency-key": "import-1" }, body: JSON.stringify(importBody) }), r.env, r.operations); assert.equal(pending.status, 409); r.db.sqlite.prepare("UPDATE wp_codebox_operations SET state = 'succeeded' WHERE site_id = ?").run(body.site.id); const one = await routeProvisioningApi(new Request(url, { method: "POST", headers: { authorization: "Bearer good", "idempotency-key": "import-1" }, body: JSON.stringify(importBody) }), r.env, r.operations); const two = await routeProvisioningApi(new Request(url, { method: "POST", headers: { authorization: "Bearer good", "idempotency-key": "import-1" }, body: JSON.stringify(importBody) }), r.env, r.operations); assert.deepEqual(await one.json(), await two.json()); assert.equal(count(r.db, "wp_codebox_api_operation_links"), 2) }) +test("unlinked same-site legacy operations return 404", async () => { const r = runtime(); const body = await (await create(r)).json() as { site: { id: string } }; r.db.sqlite.prepare("UPDATE wp_codebox_operations SET state = 'succeeded' WHERE site_id = ?").run(body.site.id); const legacy = await r.operations.createOrConverge({ id: body.site.id, hostname: `${body.site.id}.example`, origin: `https://${body.site.id}.example` }, { idempotencyKey: "legacy", fingerprint: "legacy", artifact: { r2Key: "legacy", sha256: digest, size: artifact.byteLength }, options: { slug: "legacy", name: "Legacy", siteTitle: "Legacy" } }); const response = await routeProvisioningApi(new Request(`https://control.invalid/v1/sites/${body.site.id}/operations/${legacy.operation.operationId}`, { headers: { authorization: "Bearer good" } }), r.env, r.operations); assert.equal(response.status, 404) }) +test("operation resources expose retry, error, and receipt without ownership tokens", async () => { const r = runtime(); const body = await (await create(r)).json() as { site: { id: string; operation: string } }; const id = body.site.operation.split("/").at(-1)!; r.db.sqlite.prepare("UPDATE wp_codebox_operations SET state='retryable', retry_at=1, error_code='x', error_message='y' WHERE site_id=? AND operation_id=?").run(body.site.id, id); const value = await (await routeProvisioningApi(new Request(`https://control.invalid/v1/sites/${body.site.id}/operations/${id}`, { headers: { authorization: "Bearer good" } }), r.env, r.operations)).text(); assert.match(value, /retryAt/); assert.match(value, /"error"/); assert.doesNotMatch(value, /claimToken|principal/) }) +test("route, method, Allow, and /v1 precedence behavior is explicit", async () => { const r = runtime(); assert.equal((await routeProvisioningApi(new Request("https://x.invalid/v1/wordpress"), r.env, r.operations)).status, 404); const response = await routeProvisioningApi(new Request("https://x.invalid/v1/sites", { method: "GET" }), r.env, r.operations); assert.equal(response.status, 405); assert.equal(response.headers.get("allow"), "POST"); assert.equal((await routeProvisioningApi(new Request("https://x.invalid/not-v1"), r.env, r.operations)).status, 404) })