Skip to content

Commit 23afccb

Browse files
committed
Add authenticated provisioning artifact staging
1 parent 1edb477 commit 23afccb

4 files changed

Lines changed: 64 additions & 4 deletions

File tree

docs/cloudflare-provisioning-api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ The Worker compares SHA-256 token digests and never accepts plaintext API creden
1414

1515
## Resources
1616

17+
- `PUT /v1/artifacts/{sha256}` requires `Authorization: Bearer ...` with `sites:create`. Its body is the canonical `blocks-engine/php-transformer/site-artifact/v1` JSON artifact. The Worker enforces the artifact byte, file, path, encoding, and digest bounds before an immutable conditional write, then returns the exact reference accepted by site creation. Replays of identical bytes converge; conflicting content at the digest-addressed key fails closed.
1718
- `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/<sha256>.json`. The Worker verifies the bounded artifact before allocating a site, then copies it immutably to the selected site namespace.
1819
- `GET /v1/sites/{siteId}` requires `sites:read`.
1920
- `POST /v1/sites/{siteId}/imports` requires `sites:import` and uses the existing bounded static-artifact request.

packages/runtime-cloudflare/src/provisioning-api.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { deriveSiteCredential } from "./wordpress-auth.js"
66
export const PROVISIONING_API_SCHEMA = "wp-codebox/provisioning-api/v1"
77
export const PROVISIONING_CREATE_REQUEST_SCHEMA = "wp-codebox/provisioning-create-request/v1"
88
export const PROVISIONING_SITE_RESOURCE_SCHEMA = "wp-codebox/provisioning-site/v1"
9+
export const PROVISIONING_ARTIFACT_RESOURCE_SCHEMA = "wp-codebox/provisioning-artifact/v1"
910
export const PROVISIONING_ERROR_SCHEMA = "wp-codebox/provisioning-error/v1"
1011
const SCOPES = new Set(["sites:create", "sites:read", "sites:import", "operations:read"])
1112

@@ -21,6 +22,7 @@ export async function routeProvisioningApi(request: Request, env: ProvisioningEn
2122
const parts = new URL(request.url).pathname.split("/").filter(Boolean)
2223
const method = request.method
2324
if (parts[0] !== "v1") return notFound()
25+
if (parts.length === 3 && parts[1] === "artifacts" && /^[a-f0-9]{64}$/.test(parts[2])) return method === "PUT" ? stageArtifact(request, env, parts[2]) : methodNotAllowed("PUT")
2426
if (parts.length === 2 && parts[1] === "sites") return method === "POST" ? create(request, env, operations) : methodNotAllowed("POST")
2527
if (parts.length < 3 || parts[1] !== "sites" || !validSiteId(parts[2])) return notFound()
2628
const siteId = parts[2]
@@ -32,6 +34,35 @@ export async function routeProvisioningApi(request: Request, env: ProvisioningEn
3234
return notFound()
3335
}
3436

37+
async function stageArtifact(request: Request, env: ProvisioningEnv, expectedSha256: string): Promise<Response> {
38+
const token = await authenticate(request, env, "sites:create"); if (token instanceof Response) return token
39+
const declaredLength = request.headers.get("content-length")
40+
if (declaredLength && (!/^\d+$/.test(declaredLength) || Number(declaredLength) < 1 || Number(declaredLength) > MAX_STATIC_ARTIFACT_BYTES)) return apiError(413, "invalid_artifact", "Provisioning artifact exceeds its byte budget.")
41+
let bytes: Uint8Array
42+
try { bytes = await readBoundedRequestBytes(request, MAX_STATIC_ARTIFACT_BYTES) } catch (error) { return importError(error) }
43+
if (!bytes.byteLength) return apiError(400, "invalid_artifact", "Provisioning artifact is required.")
44+
if (await sha(bytes) !== expectedSha256) return apiError(409, "artifact_digest_mismatch", "Provisioning artifact does not match its digest.")
45+
try { await validateArtifact(bytes) } catch (error) { return importError(error) }
46+
const key = stagedKey(expectedSha256)
47+
const verify = async () => {
48+
const object = await env.WORDPRESS_STATE_BUCKET.get(key)
49+
if (!object) return false
50+
const stored = new Uint8Array(await object.arrayBuffer())
51+
if (object.size !== bytes.byteLength || stored.byteLength !== bytes.byteLength || await sha(stored) !== expectedSha256) throw new OperationConflict("Provisioning artifact staging conflicts with existing content.")
52+
return true
53+
}
54+
try {
55+
if (!await verify()) {
56+
await env.WORDPRESS_STATE_BUCKET.put(key, bytes, { onlyIf: { etagDoesNotMatch: "*" }, httpMetadata: { contentType: "application/json" } })
57+
if (!await verify()) throw new OperationConflict("Provisioning artifact staging did not produce a verified object.")
58+
}
59+
} catch (error) {
60+
if (error instanceof OperationConflict) return apiError(409, "artifact_conflict", error.message)
61+
throw error
62+
}
63+
return Response.json({ schema: PROVISIONING_ARTIFACT_RESOURCE_SCHEMA, artifact: { sha256: expectedSha256, size: bytes.byteLength, r2Key: key } }, { status: 200 })
64+
}
65+
3566
async function create(request: Request, env: ProvisioningEnv, operations: D1OperationRepository): Promise<Response> {
3667
const token = await authenticate(request, env, "sites:create"); if (token instanceof Response) return token
3768
const key = idempotencyKey(request); if (key instanceof Response) return key

packages/runtime-cloudflare/src/static-artifact-import.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export class StaticArtifactImportError extends Error {
7777
}
7878
}
7979

80-
export async function readBoundedRequestBytes(request: Request): Promise<Uint8Array> {
80+
export async function readBoundedRequestBytes(request: Request, maxBytes = MAX_STATIC_ARTIFACT_REQUEST_BYTES): Promise<Uint8Array> {
8181
if (!request.body) return new Uint8Array()
8282
const reader = request.body.getReader()
8383
const chunks: Uint8Array[] = []
@@ -86,7 +86,7 @@ export async function readBoundedRequestBytes(request: Request): Promise<Uint8Ar
8686
const { done, value } = await reader.read()
8787
if (done) break
8888
total += value.byteLength
89-
if (total > MAX_STATIC_ARTIFACT_REQUEST_BYTES) {
89+
if (total > maxBytes) {
9090
await reader.cancel()
9191
throw new StaticArtifactImportError("Static artifact import request exceeds its byte budget.", 413)
9292
}

tests/cloudflare-provisioning-api.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto"
33
import { DatabaseSync } from "node:sqlite"
44
import test from "node:test"
55
import { D1OperationRepository } from "../packages/runtime-cloudflare/src/d1-operation-repository.js"
6-
import { PROVISIONING_CREATE_REQUEST_SCHEMA, resumeProvisioningAllocation, routeProvisioningApi } from "../packages/runtime-cloudflare/src/provisioning-api.js"
6+
import { PROVISIONING_ARTIFACT_RESOURCE_SCHEMA, PROVISIONING_CREATE_REQUEST_SCHEMA, resumeProvisioningAllocation, routeProvisioningApi } from "../packages/runtime-cloudflare/src/provisioning-api.js"
77
import { STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA } from "../packages/runtime-cloudflare/src/static-artifact-import.js"
88

99
const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex")
@@ -28,6 +28,7 @@ function createRequest(key = "create-1", change: Partial<{ sha256: string; size:
2828
const sha256 = change.sha256 ?? digest; const size = change.size ?? artifact.byteLength
2929
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" } }) })
3030
}
31+
function stageRequest(body: Uint8Array = artifact, sha256 = digest, token = "good") { return new Request(`https://control.invalid/v1/artifacts/${sha256}`, { method: "PUT", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body }) }
3132
async function create(runtime: ReturnType<typeof runtime>, key = "create-1") { return routeProvisioningApi(createRequest(key), runtime.env, runtime.operations) }
3233
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 }
3334

@@ -39,6 +40,33 @@ test("auth failures, malformed config, expiry, and scope stop before body and R2
3940
test("invalid, missing, and oversized artifacts create zero allocations", async () => {
4041
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) }
4142
})
43+
test("authenticated artifact staging validates and converges immutable canonical bytes", async () => {
44+
const r = runtime(); r.bucket.objects.clear()
45+
const first = await routeProvisioningApi(stageRequest(), r.env, r.operations)
46+
assert.equal(first.status, 200)
47+
assert.deepEqual(await first.json(), { schema: PROVISIONING_ARTIFACT_RESOURCE_SCHEMA, artifact: { sha256: digest, size: artifact.byteLength, r2Key: `sites/provisioning/import-artifacts/${digest}.json` } })
48+
assert.equal((await routeProvisioningApi(stageRequest(), r.env, r.operations)).status, 200)
49+
assert.equal(r.bucket.puts, 1)
50+
assert.equal((await create(r)).status, 202)
51+
})
52+
test("artifact staging authenticates before reads and rejects mismatched or noncanonical bytes", async () => {
53+
const unauthorized = runtime(); unauthorized.bucket.objects.clear()
54+
assert.equal((await routeProvisioningApi(stageRequest(artifact, digest, "bad"), unauthorized.env, unauthorized.operations)).status, 401)
55+
assert.equal(unauthorized.bucket.puts, 0)
56+
const mismatch = runtime(); mismatch.bucket.objects.clear()
57+
assert.equal((await routeProvisioningApi(stageRequest(new TextEncoder().encode("{}")), mismatch.env, mismatch.operations)).status, 409)
58+
assert.equal(mismatch.bucket.puts, 0)
59+
const invalid = new TextEncoder().encode(JSON.stringify({ schema: "wp-build/website-artifact-bundle/v1", root: "website/", entrypoint: "website/index.html", files: [{ path: "website/index.html", content: "ok" }] }))
60+
const rejected = runtime(); rejected.bucket.objects.clear()
61+
assert.equal((await routeProvisioningApi(stageRequest(invalid, hash(invalid)), rejected.env, rejected.operations)).status, 422)
62+
assert.equal(rejected.bucket.puts, 0)
63+
})
64+
test("artifact staging verifies the winner of a conditional R2 race", async () => {
65+
const same = runtime(); same.bucket.objects.clear(); same.bucket.race = artifact
66+
assert.equal((await routeProvisioningApi(stageRequest(), same.env, same.operations)).status, 200)
67+
const conflicting = runtime(); conflicting.bucket.objects.clear(); conflicting.bucket.race = new TextEncoder().encode("bad")
68+
assert.equal((await routeProvisioningApi(stageRequest(), conflicting.env, conflicting.operations)).status, 409)
69+
})
4270
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") })
4371
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) })
4472
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) })
@@ -51,7 +79,7 @@ test("cross-principal and site-restricted reads and imports fail closed", async
5179
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) })
5280
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) })
5381
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/) })
54-
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) })
82+
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 artifactMethod = await routeProvisioningApi(new Request(`https://x.invalid/v1/artifacts/${digest}`, { method: "POST" }), r.env, r.operations); assert.equal(artifactMethod.status, 405); assert.equal(artifactMethod.headers.get("allow"), "PUT"); 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) })
5583
test("administrator claims validate roots before allocation and persist only a digest", async () => {
5684
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) }
5785
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}$/)

0 commit comments

Comments
 (0)