From c621e7e24f384aaff77838c342635f0eff2e5e60 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 23 Jul 2026 12:56:26 -0400 Subject: [PATCH] Add one-time administrator claims --- docs/cloudflare-provisioning-api.md | 5 +- package.json | 1 + packages/runtime-cloudflare/README.md | 6 ++ .../src/provisioning-api.ts | 79 +++++++++++++++++-- packages/runtime-cloudflare/src/worker.ts | 6 +- tests/cloudflare-provisioning-api.test.ts | 40 +++++++++- 6 files changed, 125 insertions(+), 12 deletions(-) diff --git a/docs/cloudflare-provisioning-api.md b/docs/cloudflare-provisioning-api.md index 1e5d1686..9144df33 100644 --- a/docs/cloudflare-provisioning-api.md +++ b/docs/cloudflare-provisioning-api.md @@ -1,4 +1,4 @@ -# Cloudflare provisioning API core +# Cloudflare provisioning API The D1 Worker exposes a versioned control-plane API before hostname-based WordPress routing. `/v1/*` never boots or falls through to WordPress. @@ -18,7 +18,8 @@ The Worker compares SHA-256 token digests and never accepts plaintext API creden - `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`. +- `POST /v1/sites/{siteId}/administrator-claim` exchanges its one-time bearer capability for the persistent site-scoped `admin` credential after provisioning succeeds. It does not accept an API bearer token. 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. +`POST /v1/sites` validates `WORDPRESS_ADMIN_CLAIM_SECRET` and `WORDPRESS_ADMIN_PASSWORD` before allocation. Its create/replay response includes the deterministic pending capability while the configured root still derives the stored digest. Site reads expose only claim state, expiry, and the fixed endpoint. D1 stores capability and derived-credential digests, never either plaintext value. Scheduled allocation recovery issues a missing claim before provisioning work can run. Redemption requires the current site-scoped credential to match the digest pinned before bootstrap, then atomically consumes the capability exactly once and transfers that persistent administrator credential; it is not a one-time browser session. Rotating either root preserves the pending record but requires restoring the matching root before replay or redemption. diff --git a/package.json b/package.json index f8755980..cbb0a47a 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "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-provisioning-api.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", + "test:cloudflare-administrator-claim": "tsx tests/cloudflare-provisioning-api.test.ts", "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/README.md b/packages/runtime-cloudflare/README.md index 413fb038..cb26a2bc 100644 --- a/packages/runtime-cloudflare/README.md +++ b/packages/runtime-cloudflare/README.md @@ -40,6 +40,12 @@ Use authenticated `GET ?phase=operator-fence-status` after acquisition to export For lossless D1-to-Durable-Object rollback, keep D1 deployed as the active Worker, acquire its fence, and capture its coherent status. Address the retained Durable Object only with `coordinator=durable-object` on authenticated `operator-fence-*` and `operator-adopt` requests. Fence that selected coordinator first, then adopt the D1 pointer and version with its matching `fenceToken`; selected-coordinator adoption fails unless that fence is active and matches. Require its selected status envelope to be coherent and identical, then promote the Durable Object Worker before the D1 fence expires and release its fence. Its first mutation must commit at `N+1`. The Durable Object binding and historical `WordPressStateCoordinator` export remain in the D1 profile for this procedure. Non-operator requests always retain the active coordinator, and selectors on unsupported or unknown operator routes fail closed. +## Administrator Claim + +`POST /v1/sites/{siteId}/administrator-claim` is intercepted before WordPress dispatch and uses only `Authorization: Bearer `. It is not an API-token endpoint. A successful provision create response includes its pending `administratorClaim` capability exactly for the create/replay window; site reads expose only its fixed endpoint URL, state, and expiry. The capability is a deterministic 256-bit HMAC derived from `WORDPRESS_ADMIN_CLAIM_SECRET` and immutable allocation identity. D1 retains capability and derived-credential digests, expiry, and lifecycle state, never plaintext values. Scheduled recovery creates a missing claim before provisioning can run, and redemption refuses to consume it when either configured root no longer matches the pinned derivation. + +Configure both `WORDPRESS_ADMIN_CLAIM_SECRET` and `WORDPRESS_ADMIN_PASSWORD` before provisioning. Redemption waits for the linked provision operation to succeed, atomically consumes the claim, and returns `{ "username": "admin", "password": "..." }`, where the password is the persistent site-scoped administrator credential derived from `WORDPRESS_ADMIN_PASSWORD`. This transfers that credential; it is not a one-time browser session. + ## Static Artifact Import An authenticated `POST ?phase=operator-static-artifact-import` materializes a verified website artifact into the existing canonical site. The request is limited to 16 KiB and references an immutable JSON object at `sites/{siteId}/import-artifacts/.json`; the Worker requires the declared R2 key, size, and SHA-256 to agree before creating work. The canonical `blocks-engine/php-transformer/site-artifact/v1` payload is limited to 4 MiB serialized, 500 safe unique files, 8 MiB per decoded file, and 32 MiB decoded in aggregate. diff --git a/packages/runtime-cloudflare/src/provisioning-api.ts b/packages/runtime-cloudflare/src/provisioning-api.ts index b6dc1cba..3b54043f 100644 --- a/packages/runtime-cloudflare/src/provisioning-api.ts +++ b/packages/runtime-cloudflare/src/provisioning-api.ts @@ -1,6 +1,7 @@ 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" +import { deriveSiteCredential } from "./wordpress-auth.js" export const PROVISIONING_API_SCHEMA = "wp-codebox/provisioning-api/v1" export const PROVISIONING_CREATE_REQUEST_SCHEMA = "wp-codebox/provisioning-create-request/v1" @@ -14,7 +15,7 @@ export interface ProvisioningAllocation { 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 interface ProvisioningEnv { WORDPRESS_STATE_DATABASE: D1Database; WORDPRESS_STATE_BUCKET: R2Bucket; WORDPRESS_SITE_CONTEXTS?: string; WORDPRESS_API_TOKENS?: string; WORDPRESS_ADMIN_CLAIM_SECRET?: string; WORDPRESS_ADMIN_PASSWORD?: string } export async function routeProvisioningApi(request: Request, env: ProvisioningEnv, operations: D1OperationRepository): Promise { const parts = new URL(request.url).pathname.split("/").filter(Boolean) @@ -23,6 +24,8 @@ export async function routeProvisioningApi(request: Request, env: ProvisioningEn 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] + // This capability endpoint must never fall through to API bearer authentication or WordPress. + if (parts.length === 4 && parts[3] === "administrator-claim") return method === "POST" ? claimAdministrator(request, env, operations, siteId) : methodNotAllowed("POST") 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") @@ -34,6 +37,7 @@ async function create(request: Request, env: ProvisioningEnv, operations: D1Oper 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) } + if (!claimConfiguration(env)) return apiError(503, "administrator_claim_unavailable", "Administrator claims are unavailable.") await operations.initialize() const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE) let allocation: ProvisioningAllocation @@ -48,8 +52,9 @@ async function create(request: Request, env: ProvisioningEnv, operations: D1Oper 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 } + const claim = await new AdministratorClaimStore(env.WORDPRESS_STATE_DATABASE).issue(allocation, env) + return siteResource(site, operation, 202, claim) + } catch (error) { if (error instanceof OperationConflict) return apiError(409, "idempotency_conflict", error.message); if (error instanceof AdministratorClaimError) return apiError(409, "administrator_claim_unavailable", "The administrator claim cannot continue provisioning."); throw error } } async function readSite(request: Request, env: ProvisioningEnv, operations: D1OperationRepository, siteId: string): Promise { @@ -57,7 +62,26 @@ async function readSite(request: Request, env: ProvisioningEnv, operations: D1Op 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) + const claim = await new AdministratorClaimStore(env.WORDPRESS_STATE_DATABASE).metadata(siteId) + return siteResource(site, allocation.operationId ? await operations.get(siteId, allocation.operationId) : null, 200, claim) +} + +async function claimAdministrator(request: Request, env: ProvisioningEnv, operations: D1OperationRepository, siteId: string): Promise { + const capability = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/)?.[1] + if (!capability || !claimConfiguration(env)) return claimUnavailable() + const store = new AdministratorClaimStore(env.WORDPRESS_STATE_DATABASE) + const claim = await store.bySite(siteId) + if (!claim || claim.state !== "pending") return claimUnavailable() + if (claim.expiresAt <= Date.now()) { await store.expire(siteId); return claimUnavailable() } + const digest = await shaText(capability) + if (!await equal(digest, claim.capabilityDigest)) return claimUnavailable() + const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId) + const operation = allocation?.operationId ? await operations.get(siteId, allocation.operationId) : null + if (operation?.state !== "succeeded") return apiError(409, "administrator_claim_not_ready", "The administrator credential is not ready.") + let password: string + try { password = await deriveSiteCredential(env.WORDPRESS_ADMIN_PASSWORD!, siteId, "admin-password") } catch { return claimUnavailable() } + if (!await equal(await shaText(password), claim.credentialDigest)) return claimUnavailable() + return (await store.consume(siteId, digest)) ? Response.json({ schema: PROVISIONING_API_SCHEMA, credential: { username: "admin", password } }) : claimUnavailable() } async function importSite(request: Request, env: ProvisioningEnv, operations: D1OperationRepository, siteId: string): Promise { @@ -89,6 +113,8 @@ export async function resumeProvisioningAllocation(env: ProvisioningEnv, site: S const store = new AllocationStore(env.WORDPRESS_STATE_DATABASE) const allocation = await store.bySite(site.id) if (!allocation) return null + if (!claimConfiguration(env)) throw new AdministratorClaimError() + const administratorClaim = await new AdministratorClaimStore(env.WORDPRESS_STATE_DATABASE).issue(allocation, env) if (allocation.operationId) { const existing = await operations.get(site.id, allocation.operationId) if (existing) { @@ -96,6 +122,7 @@ export async function resumeProvisioningAllocation(env: ProvisioningEnv, site: S if (["publication-pending", "succeeded", "failed"].includes(existing.state)) return existing } } + if (administratorClaim.state !== "pending") throw new AdministratorClaimError() 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) @@ -145,13 +172,15 @@ async function putVerifiedArtifact(bucket: R2Bucket, site: SiteContext, input: S 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 { +function siteResource(site: SiteContext, operation: StaticArtifactOperation | null, status = 200, claim: AdministratorClaimMetadata | AdministratorClaimIssued | null = null): 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 }) + const administratorClaim = claim ? { url: `/v1/sites/${site.id}/administrator-claim`, state: claim.state, expiresAt: new Date(claim.expiresAt).toISOString(), ...("token" in claim ? { token: claim.token } : {}) } : undefined + 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, ...(administratorClaim ? { administratorClaim } : {}) } }, { 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 claimUnavailable(): Response { return apiError(401, "administrator_claim_unavailable", "The administrator claim 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 } @@ -189,6 +218,7 @@ function record(value: unknown): value is Record { return !!val 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 } +function claimConfiguration(env: ProvisioningEnv): boolean { return typeof env.WORDPRESS_ADMIN_CLAIM_SECRET === "string" && env.WORDPRESS_ADMIN_CLAIM_SECRET.length >= 32 && typeof env.WORDPRESS_ADMIN_PASSWORD === "string" && env.WORDPRESS_ADMIN_PASSWORD.length > 0 } class AllocationError extends Error { constructor(readonly code: "quota_exceeded" | "capacity_exhausted") { super(code) } } class AllocationStore { @@ -234,3 +264,40 @@ class AllocationStore { } } 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 } } } + +interface AdministratorClaimMetadata { state: "pending" | "consumed" | "expired"; expiresAt: number } +interface AdministratorClaimIssued extends AdministratorClaimMetadata { token: string } +interface AdministratorClaimRecord extends AdministratorClaimMetadata { capabilityDigest: string; credentialDigest: string } +class AdministratorClaimError extends Error {} +class AdministratorClaimStore { + constructor(private readonly db: D1Database) {} + async issue(allocation: ProvisioningAllocation, env: ProvisioningEnv): Promise { + await this.schema() + const token = await claimCapability(env.WORDPRESS_ADMIN_CLAIM_SECRET!, allocation) + const digest = await shaText(token) + const credential = await deriveSiteCredential(env.WORDPRESS_ADMIN_PASSWORD!, allocation.siteId, "admin-password") + const credentialDigest = await shaText(credential) + const expiresAt = Date.now() + 24 * 60 * 60 * 1000 + await this.db.prepare("INSERT OR IGNORE INTO wp_codebox_api_admin_claims (site_id, capability_digest, credential_digest, expires_at, state, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)").bind(allocation.siteId, digest, credentialDigest, expiresAt, Date.now(), Date.now()).run() + const claim = await this.bySite(allocation.siteId) + if (!claim) throw new Error("Administrator claim was not persisted.") + if (claim.state === "pending" && claim.expiresAt <= Date.now()) { + await this.expire(allocation.siteId) + return { state: "expired", expiresAt: claim.expiresAt } + } + if (claim.state === "pending" && !await equal(credentialDigest, claim.credentialDigest)) throw new AdministratorClaimError() + return claim.state === "pending" && await equal(digest, claim.capabilityDigest) ? { state: claim.state, expiresAt: claim.expiresAt, token } : { state: claim.state, expiresAt: claim.expiresAt } + } + async metadata(siteId: string): Promise { const claim = await this.bySite(siteId); if (claim?.state === "pending" && claim.expiresAt <= Date.now()) { await this.expire(siteId); return { state: "expired", expiresAt: claim.expiresAt } } return claim && { state: claim.state, expiresAt: claim.expiresAt } } + async bySite(siteId: string): Promise { await this.schema(); const row = await this.db.prepare("SELECT capability_digest, credential_digest, expires_at, state FROM wp_codebox_api_admin_claims WHERE site_id = ?").bind(siteId).first<{ capability_digest: string; credential_digest: string; expires_at: number; state: AdministratorClaimMetadata["state"] }>(); return row ? { capabilityDigest: row.capability_digest, credentialDigest: row.credential_digest, expiresAt: row.expires_at, state: row.state } : null } + async expire(siteId: string): Promise { await this.db.prepare("UPDATE wp_codebox_api_admin_claims SET state = 'expired', updated_at = ? WHERE site_id = ? AND state = 'pending' AND expires_at <= ?").bind(Date.now(), siteId, Date.now()).run() } + async consume(siteId: string, digest: string): Promise { const result = await this.db.prepare("UPDATE wp_codebox_api_admin_claims SET state = 'consumed', updated_at = ? WHERE site_id = ? AND state = 'pending' AND capability_digest = ? AND expires_at > ?").bind(Date.now(), siteId, digest, Date.now()).run(); return result.meta.changes === 1 } + private async schema(): Promise { await this.db.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_api_admin_claims (site_id TEXT PRIMARY KEY, capability_digest TEXT NOT NULL, credential_digest TEXT NOT NULL, expires_at INTEGER NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending','consumed','expired')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)").run() } +} +async function claimCapability(root: string, allocation: ProvisioningAllocation): Promise { + const encoder = new TextEncoder() + const key = await crypto.subtle.importKey("raw", encoder.encode(root), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]) + const identity = `wp-codebox/administrator-claim/v1\0${allocation.siteId}\0${allocation.principal}\0${allocation.key}\0${allocation.fingerprint}` + const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(identity)) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") +} diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index 6df98d22..887c5ffc 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -275,10 +275,12 @@ export function createCloudflareRuntime( const site = sites[Math.floor(controller.scheduledTime / 60_000) % sites.length] const coordinator = resolveCoordinator(env, site) const operations = resolveOperations?.(env) + let provisioningReady = true if (operations && env.WORDPRESS_STATE_DATABASE) { try { await resumeProvisioningAllocation(env as Env & { WORDPRESS_STATE_DATABASE: D1Database }, site, operations) } catch (error) { + provisioningReady = false console.error("Provisioning allocation recovery failed.", error) } } @@ -286,9 +288,9 @@ export function createCloudflareRuntime( const siteCycle = Math.floor(controller.scheduledTime / (60_000 * sites.length)) const operationFirst = siteCycle % 2 === 0 // Alternate priority while allowing only one heavyweight runtime per invocation. - let operation = operationFirst && !fence.active ? await runNextStaticArtifactOperation(env, coordinator, site, operations) : null + let operation = operationFirst && provisioningReady && !fence.active ? await runNextStaticArtifactOperation(env, coordinator, site, operations) : null const publication = operation ? null : await drainNextPublicationJob(env, coordinator, site, operations) - if (!operationFirst && !publication && !fence.active) operation = await runNextStaticArtifactOperation(env, coordinator, site, operations) + if (!operationFirst && provisioningReady && !publication && !fence.active) operation = await runNextStaticArtifactOperation(env, coordinator, site, operations) const evidence = operation ?? publication ?? (fence.active ? { schema: "wp-codebox/cloudflare-publication/v1" as const, status: "fenced" as const, jobKey: "coordinator" } : await runScheduledWordPressCron(env, coordinator, site, controller.scheduledTime)) diff --git a/tests/cloudflare-provisioning-api.test.ts b/tests/cloudflare-provisioning-api.test.ts index 018503d4..bee2a7b6 100644 --- a/tests/cloudflare-provisioning-api.test.ts +++ b/tests/cloudflare-provisioning-api.test.ts @@ -22,7 +22,7 @@ class Bucket { 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) } + 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), WORDPRESS_ADMIN_CLAIM_SECRET: "claim-root-secret-with-at-least-thirty-two-bytes", WORDPRESS_ADMIN_PASSWORD: "admin-root-secret" }, 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 @@ -51,4 +51,40 @@ test("cross-principal and site-restricted reads and imports fail closed", async 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) }) +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"); const claim = await routeProvisioningApi(new Request("https://x.invalid/v1/sites/alpha/administrator-claim", { method: "GET" }), r.env, r.operations); assert.equal(claim.status, 405); assert.equal(claim.headers.get("allow"), "POST"); assert.equal((await routeProvisioningApi(new Request("https://x.invalid/not-v1"), r.env, r.operations)).status, 404) }) +test("administrator claims validate roots before allocation and persist only a digest", async () => { + for (const key of ["WORDPRESS_ADMIN_CLAIM_SECRET", "WORDPRESS_ADMIN_PASSWORD"] as const) { const r = runtime(); delete r.env[key]; assert.equal((await create(r)).status, 503); assert.equal(count(r.db, "wp_codebox_api_sites"), 0) } + const r = runtime(); const body = await (await create(r)).json() as { site: { administratorClaim: { token: string } } }; assert.match(body.site.administratorClaim.token, /^[a-f0-9]{64}$/) + const stored = JSON.stringify(r.db.sqlite.prepare("SELECT * FROM wp_codebox_api_admin_claims").all()); assert.doesNotMatch(stored, new RegExp(body.site.administratorClaim.token)); assert.doesNotMatch(stored, /claim-root-secret|admin-root-secret/) +}) +test("administrator claim create replay exposes its capability only while pending", async () => { + const r = runtime(); const first = await (await create(r)).json() as { site: { administratorClaim: { token: string } } }; const replay = await (await create(r)).json() as { site: { administratorClaim: { token: string } } }; assert.equal(replay.site.administratorClaim.token, first.site.administratorClaim.token); assert.equal((await routeProvisioningApi(createRequest("create-1", { title: "Changed" }), r.env, r.operations)).status, 409) +}) +test("administrator claim root rotation retains the pending digest without reconstructing its token", async () => { + const r = runtime(); const first = await (await create(r)).json() as { site: { id: string; administratorClaim: { token: string } } }; r.env.WORDPRESS_ADMIN_CLAIM_SECRET = "rotated-claim-root-secret-with-at-least-thirty-two-bytes"; const replay = await (await create(r)).text(); assert.doesNotMatch(replay, new RegExp(first.site.administratorClaim.token)); assert.doesNotMatch(replay, /"token"/); r.db.sqlite.prepare("UPDATE wp_codebox_operations SET state = 'succeeded' WHERE site_id = ?").run(first.site.id); const redeemed = await routeProvisioningApi(new Request(`https://control.invalid/v1/sites/${first.site.id}/administrator-claim`, { method: "POST", headers: { authorization: `Bearer ${first.site.administratorClaim.token}` } }), r.env, r.operations); assert.equal(redeemed.status, 200) +}) +test("administrator credential root rotation cannot consume a mismatched claim", async () => { + const r = runtime(); const originalRoot = r.env.WORDPRESS_ADMIN_PASSWORD; const created = await (await create(r)).json() as { site: { id: string; administratorClaim: { token: string } } }; r.db.sqlite.prepare("UPDATE wp_codebox_operations SET state = 'succeeded' WHERE site_id = ?").run(created.site.id); r.env.WORDPRESS_ADMIN_PASSWORD = "rotated-admin-root"; const request = () => new Request(`https://control.invalid/v1/sites/${created.site.id}/administrator-claim`, { method: "POST", headers: { authorization: `Bearer ${created.site.administratorClaim.token}` } }); assert.equal((await routeProvisioningApi(request(), r.env, r.operations)).status, 401); assert.equal((r.db.sqlite.prepare("SELECT state FROM wp_codebox_api_admin_claims").get() as { state: string }).state, "pending"); r.env.WORDPRESS_ADMIN_PASSWORD = originalRoot; assert.equal((await routeProvisioningApi(request(), r.env, r.operations)).status, 200) +}) +test("allocation recovery creates a missing claim before provisioning continues", async () => { + const r = runtime(); const created = await (await create(r)).json() as { site: { id: string } }; r.db.sqlite.prepare("DELETE FROM wp_codebox_api_admin_claims").run(); await resumeProvisioningAllocation(r.env, { id: created.site.id, hostname: `${created.site.id}.example`, origin: `https://${created.site.id}.example` }, r.operations); assert.equal(count(r.db, "wp_codebox_api_admin_claims"), 1) +}) +test("site resources expose administrator claim metadata without secrets", async () => { + const r = runtime(); const created = await (await create(r)).json() as { site: { id: string; administratorClaim: { token: string } } }; const response = await routeProvisioningApi(new Request(`https://control.invalid/v1/sites/${created.site.id}`, { headers: { authorization: "Bearer good" } }), r.env, r.operations); const text = await response.text(); assert.match(text, /administratorClaim/); assert.doesNotMatch(text, new RegExp(created.site.administratorClaim.token)); assert.doesNotMatch(text, /capability_digest|password/) +}) +test("administrator claim failures are generic, not-ready preserves pending, and expiry is terminal", async () => { + const r = runtime(); const created = await (await create(r)).json() as { site: { id: string; administratorClaim: { token: string } } }; const url = `https://control.invalid/v1/sites/${created.site.id}/administrator-claim` + for (const request of [new Request(url, { method: "POST" }), new Request(url, { method: "POST", headers: { authorization: "Bearer malformed" } }), new Request("https://control.invalid/v1/sites/beta/administrator-claim", { method: "POST", headers: { authorization: `Bearer ${created.site.administratorClaim.token}` } })]) { const response = await routeProvisioningApi(request, r.env, r.operations); assert.equal((await response.json() as { error: { code: string } }).error.code, "administrator_claim_unavailable") } + const pending = await routeProvisioningApi(new Request(url, { method: "POST", headers: { authorization: `Bearer ${created.site.administratorClaim.token}` } }), r.env, r.operations); assert.equal(pending.status, 409); assert.equal((r.db.sqlite.prepare("SELECT state FROM wp_codebox_api_admin_claims").get() as { state: string }).state, "pending") + r.db.sqlite.prepare("UPDATE wp_codebox_api_admin_claims SET expires_at = 0").run(); assert.equal((await routeProvisioningApi(new Request(url, { method: "POST", headers: { authorization: `Bearer ${created.site.administratorClaim.token}` } }), r.env, r.operations)).status, 401); assert.equal((r.db.sqlite.prepare("SELECT state FROM wp_codebox_api_admin_claims").get() as { state: string }).state, "expired"); const resource = await (await routeProvisioningApi(new Request(`https://control.invalid/v1/sites/${created.site.id}`, { headers: { authorization: "Bearer good" } }), r.env, r.operations)).json() as { site: { administratorClaim: { state: string } } }; assert.equal(resource.site.administratorClaim.state, "expired"); const replay = await create(r); assert.equal(replay.status, 409); assert.doesNotMatch(await replay.text(), /"token"/); await assert.rejects(() => resumeProvisioningAllocation(r.env, { id: created.site.id, hostname: `${created.site.id}.example`, origin: `https://${created.site.id}.example` }, r.operations)) +}) +test("administrator claim consumes once after publication and returns the persistent site credential", async () => { + const r = runtime(); const created = await (await create(r)).json() as { site: { id: string; operation: string; administratorClaim: { token: string } } }; r.db.sqlite.prepare("UPDATE wp_codebox_operations SET state = 'succeeded' WHERE site_id = ?").run(created.site.id); const url = `https://control.invalid/v1/sites/${created.site.id}/administrator-claim`; const responses = await Promise.all([1, 2].map(() => routeProvisioningApi(new Request(url, { method: "POST", headers: { authorization: `Bearer ${created.site.administratorClaim.token}` } }), r.env, r.operations))); assert.equal(responses.filter((response) => response.status === 200).length, 1); const success = responses.find((response) => response.status === 200)!; assert.deepEqual(await success.json(), { schema: "wp-codebox/provisioning-api/v1", credential: { username: "admin", password: await (await import("../packages/runtime-cloudflare/src/wordpress-auth.js")).deriveSiteCredential(r.env.WORDPRESS_ADMIN_PASSWORD, created.site.id, "admin-password") } }); assert.equal(responses.find((response) => response.status !== 200)!.status, 401) + const replay = await (await create(r)).text(); assert.doesNotMatch(replay, new RegExp(created.site.administratorClaim.token)); const read = await (await routeProvisioningApi(new Request(`https://control.invalid/v1/sites/${created.site.id}`, { headers: { authorization: "Bearer good" } }), r.env, r.operations)).text(); assert.doesNotMatch(read, new RegExp(created.site.administratorClaim.token)) +}) +test("consumed claims do not block allocation recovery after credential rotation", async () => { + const r = runtime(); const created = await (await create(r)).json() as { site: { id: string; administratorClaim: { token: string } } }; r.db.sqlite.prepare("UPDATE wp_codebox_operations SET state = 'succeeded' WHERE site_id = ?").run(created.site.id); const claim = new Request(`https://control.invalid/v1/sites/${created.site.id}/administrator-claim`, { method: "POST", headers: { authorization: `Bearer ${created.site.administratorClaim.token}` } }); assert.equal((await routeProvisioningApi(claim, r.env, r.operations)).status, 200); r.env.WORDPRESS_ADMIN_PASSWORD = "rotated-admin-root"; const operation = await resumeProvisioningAllocation(r.env, { id: created.site.id, hostname: `${created.site.id}.example`, origin: `https://${created.site.id}.example` }, r.operations); assert.equal(operation?.state, "succeeded") +}) +test("administrator claim credential configuration failures do not consume a ready claim", async () => { + const r = runtime(); const created = await (await create(r)).json() as { site: { id: string; administratorClaim: { token: string } } }; r.db.sqlite.prepare("UPDATE wp_codebox_operations SET state = 'succeeded' WHERE site_id = ?").run(created.site.id); delete r.env.WORDPRESS_ADMIN_PASSWORD; const response = await routeProvisioningApi(new Request(`https://control.invalid/v1/sites/${created.site.id}/administrator-claim`, { method: "POST", headers: { authorization: `Bearer ${created.site.administratorClaim.token}` } }), r.env, r.operations); assert.equal(response.status, 401); assert.equal((r.db.sqlite.prepare("SELECT state FROM wp_codebox_api_admin_claims").get() as { state: string }).state, "pending") +})