Skip to content

Commit f26ba6e

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/1976-cloudflare-cutover-fence
# Conflicts: # packages/runtime-cloudflare/README.md # packages/runtime-cloudflare/src/request-routing.ts # packages/runtime-cloudflare/src/worker.ts # tests/cloudflare-runtime.test.ts
2 parents b0525f3 + 0a4ffd6 commit f26ba6e

11 files changed

Lines changed: 564 additions & 26 deletions

packages/runtime-cloudflare/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ The authenticated mutation-fence endpoints provide a bounded cutover window with
3232

3333
Use authenticated `GET ?phase=operator-fence-status` after acquisition to export a coherent cutover envelope. It includes the coordinator store, pointer, version, matching commit receipt, validated R2 manifest identity, fence expiry, and a `coherent` verdict. Coherence also reads every referenced canonical object and verifies its declared size and SHA-256, so an incomplete target cannot be promoted. Adopt that exact pointer/version into the target coordinator, require the target status envelope to match, then promote the target Worker before the source fence expires. The first target mutation must commit version `N+1`. Rollback uses the same sequence in reverse after fencing the active target; relying on an unfenced status read is not a lossless cutover procedure.
3434

35+
## Static Artifact Import
36+
37+
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/default/import-artifacts/<sha256>.json`; the Worker requires the declared R2 key, size, and SHA-256 to agree before acquiring a coordinator lease. 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.
38+
39+
The import transaction boots a dedicated runtime with the pinned Static Site Importer v1.3.4 archive (`8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29`) from R2, invokes its public `static-site-importer/import-website-artifact` ability as the operator-authorized administrator, and requires the canonical quality gate plus zero fallback, core HTML, freeform, and invalid blocks. SSI and its MU loader are runtime-owned and excluded from canonical mutable `wp-content`; generated themes, pages, options, and assets persist normally through MDI/R2. Failed or partial imports discard the PHP runtime without committing. Successful imports return compact provenance and canonical revision/version headers, enqueue affected publication routes, and store at most 20 idempotency receipts in canonical options. Exact replay returns the existing receipt without a new revision; reuse with different input returns a conflict.
40+
41+
SSI is extracted only for import requests, so normal browser, mutation, publication, and cron boots retain their existing memory and latency profile. The pinned normal plugin archive bundles Blocks Engine and supports website artifacts without the optional Figma zstd extension; compressed `.fig` import is outside this runtime contract.
42+
3543
## Verification
3644

3745
1. Run `npm run generate:cloudflare-canonical-mdi-seed` and `npm run generate:cloudflare-wordpress-runtime-corpus` to regenerate deterministic runtime artifacts and manifests.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"schema": "wp-codebox/runtime-archive-artifact/v1",
3+
"name": "static-site-importer",
4+
"key": "runtime/archives/static-site-importer/8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29.zip",
5+
"archive": {
6+
"sha256": "8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29",
7+
"size": 13056371
8+
},
9+
"source": {
10+
"url": "https://github.com/Automattic/static-site-importer/releases/download/v1.3.4/static-site-importer.zip",
11+
"version": "1.3.4",
12+
"identity": "08b9dd650f3c3161c5b350796a5db6ef083516ae"
13+
}
14+
}

packages/runtime-cloudflare/src/request-routing.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type WorkerRequestRoute =
77
| { kind: "operator-restore" }
88
| { kind: "operator-adopt" }
99
| { kind: "operator-fence"; action: "status" | "acquire" | "renew" | "release" }
10+
| { kind: "operator-static-artifact-import" }
1011
| { kind: "operator-publish" }
1112
| { kind: "probe"; phase: string }
1213

@@ -23,6 +24,7 @@ export function routeWorkerRequest(request: Request): WorkerRequestRoute {
2324
if (phase === "operator-fence-acquire") return { kind: "operator-fence", action: "acquire" }
2425
if (phase === "operator-fence-renew") return { kind: "operator-fence", action: "renew" }
2526
if (phase === "operator-fence-release") return { kind: "operator-fence", action: "release" }
27+
if (phase === "operator-static-artifact-import") return { kind: "operator-static-artifact-import" }
2628
if (phase === "operator-publish") return { kind: "operator-publish" }
2729
return { kind: "probe", phase }
2830
}

packages/runtime-cloudflare/src/runtime-archive-artifact.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export const RUNTIME_ARCHIVE_ARTIFACT_SCHEMA = "wp-codebox/runtime-archive-artifact/v1"
2-
export const RUNTIME_ARCHIVE_MAX_BYTES = 8 * 1024 * 1024
2+
export const RUNTIME_ARCHIVE_MAX_BYTES = 16 * 1024 * 1024
33

44
export interface RuntimeArchiveArtifactManifest {
55
schema: typeof RUNTIME_ARCHIVE_ARTIFACT_SCHEMA
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
export const STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA = "wp-codebox/cloudflare-static-artifact-import-request/v1"
2+
export const STATIC_ARTIFACT_IMPORT_RESULT_SCHEMA = "wp-codebox/cloudflare-static-artifact-import-result/v1"
3+
export const STATIC_ARTIFACT_SCHEMA = "blocks-engine/php-transformer/site-artifact/v1"
4+
export const R2_STATIC_ARTIFACT_PREFIX = "sites/default/import-artifacts"
5+
export const MAX_STATIC_ARTIFACT_REQUEST_BYTES = 16 * 1024
6+
export const MAX_STATIC_ARTIFACT_BYTES = 4 * 1024 * 1024
7+
export const MAX_STATIC_ARTIFACT_FILES = 500
8+
export const MAX_STATIC_ARTIFACT_FILE_BYTES = 8 * 1024 * 1024
9+
export const MAX_STATIC_ARTIFACT_DECODED_BYTES = 32 * 1024 * 1024
10+
11+
export interface StaticArtifactImport {
12+
idempotencyKey: string
13+
fingerprint: string
14+
artifactReference: { r2Key: string; sha256: string; size: number }
15+
artifact: Record<string, unknown>
16+
options: { slug: string; name: string; siteTitle: string }
17+
}
18+
19+
export async function readStaticArtifactImport(request: Request, bucket: R2Bucket): Promise<StaticArtifactImport> {
20+
const declaredLength = request.headers.get("content-length")
21+
if (declaredLength && (!/^\d+$/.test(declaredLength) || Number(declaredLength) > MAX_STATIC_ARTIFACT_REQUEST_BYTES)) throw new StaticArtifactImportError("Static artifact import request exceeds its byte budget.", 413)
22+
const requestBytes = await readBoundedRequestBytes(request)
23+
let body: unknown
24+
try {
25+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(requestBytes))
26+
} catch {
27+
throw new StaticArtifactImportError("Static artifact import request must be valid UTF-8 JSON.", 400)
28+
}
29+
if (!body || typeof body !== "object" || Array.isArray(body)) throw new StaticArtifactImportError("Static artifact import request must be an object.", 400)
30+
const candidate = body as Record<string, unknown>
31+
if (candidate.schema !== STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA || typeof candidate.idempotencyKey !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(candidate.idempotencyKey)) {
32+
throw new StaticArtifactImportError("Static artifact import request identity is invalid.", 400)
33+
}
34+
const reference = candidate.artifact
35+
if (!reference || typeof reference !== "object" || Array.isArray(reference)) throw new StaticArtifactImportError("Static artifact reference is required.", 400)
36+
const artifactReference = reference as Record<string, unknown>
37+
if (typeof artifactReference.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(artifactReference.sha256)
38+
|| artifactReference.r2Key !== `${R2_STATIC_ARTIFACT_PREFIX}/${artifactReference.sha256}.json`
39+
|| !Number.isSafeInteger(artifactReference.size) || (artifactReference.size as number) < 1 || (artifactReference.size as number) > MAX_STATIC_ARTIFACT_BYTES) {
40+
throw new StaticArtifactImportError("Static artifact reference is invalid or outside its byte budget.", 400)
41+
}
42+
const options = candidate.import
43+
if (!options || typeof options !== "object" || Array.isArray(options)) throw new StaticArtifactImportError("Static artifact import options are required.", 400)
44+
const importOptions = options as Record<string, unknown>
45+
const slug = typeof importOptions.slug === "string" ? importOptions.slug : ""
46+
const name = typeof importOptions.name === "string" ? importOptions.name.trim() : ""
47+
const siteTitle = typeof importOptions.siteTitle === "string" ? importOptions.siteTitle.trim() : ""
48+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slug.length > 64 || !name || name.length > 120 || siteTitle.length > 120) throw new StaticArtifactImportError("Static artifact import options are invalid.", 400)
49+
50+
const object = await bucket.get(artifactReference.r2Key as string)
51+
if (!object) throw new StaticArtifactImportError("Static artifact is unavailable.", 404)
52+
if (object.size !== artifactReference.size) throw new StaticArtifactImportError("Static artifact size does not match its reference.", 409)
53+
const bytes = new Uint8Array(await object.arrayBuffer())
54+
if (await sha256Hex(bytes) !== artifactReference.sha256) throw new StaticArtifactImportError("Static artifact digest does not match its reference.", 409)
55+
let artifact: unknown
56+
try {
57+
artifact = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))
58+
} catch {
59+
throw new StaticArtifactImportError("Static artifact must be valid UTF-8 JSON.", 422)
60+
}
61+
await validateStaticArtifact(artifact)
62+
const fingerprint = await sha256Hex(new TextEncoder().encode(JSON.stringify({ sha256: artifactReference.sha256, slug, name, siteTitle })))
63+
return {
64+
idempotencyKey: candidate.idempotencyKey,
65+
fingerprint,
66+
artifactReference: { r2Key: artifactReference.r2Key as string, sha256: artifactReference.sha256, size: artifactReference.size as number },
67+
artifact: artifact as Record<string, unknown>,
68+
options: { slug, name, siteTitle },
69+
}
70+
}
71+
72+
export class StaticArtifactImportError extends Error {
73+
constructor(message: string, readonly status: number) {
74+
super(message)
75+
}
76+
}
77+
78+
async function readBoundedRequestBytes(request: Request): Promise<Uint8Array> {
79+
if (!request.body) return new Uint8Array()
80+
const reader = request.body.getReader()
81+
const chunks: Uint8Array[] = []
82+
let total = 0
83+
while (true) {
84+
const { done, value } = await reader.read()
85+
if (done) break
86+
total += value.byteLength
87+
if (total > MAX_STATIC_ARTIFACT_REQUEST_BYTES) {
88+
await reader.cancel()
89+
throw new StaticArtifactImportError("Static artifact import request exceeds its byte budget.", 413)
90+
}
91+
chunks.push(value)
92+
}
93+
const bytes = new Uint8Array(total)
94+
let offset = 0
95+
for (const chunk of chunks) {
96+
bytes.set(chunk, offset)
97+
offset += chunk.byteLength
98+
}
99+
return bytes
100+
}
101+
102+
async function validateStaticArtifact(value: unknown): Promise<void> {
103+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new StaticArtifactImportError("Static artifact must be an object.", 422)
104+
const artifact = value as Record<string, unknown>
105+
if (artifact.schema !== STATIC_ARTIFACT_SCHEMA || typeof artifact.root !== "string" || !isSafePath(artifact.root)
106+
|| typeof artifact.entrypoint !== "string" || !isSafePath(artifact.entrypoint) || !Array.isArray(artifact.files)
107+
|| artifact.files.length < 1 || artifact.files.length > MAX_STATIC_ARTIFACT_FILES) {
108+
throw new StaticArtifactImportError("Static artifact contract is invalid.", 422)
109+
}
110+
const paths = new Set<string>()
111+
let decodedBytes = 0
112+
for (const file of artifact.files) {
113+
if (!file || typeof file !== "object" || Array.isArray(file)) throw new StaticArtifactImportError("Static artifact contains an invalid file.", 422)
114+
const candidate = file as Record<string, unknown>
115+
if (typeof candidate.path !== "string" || !isSafePath(candidate.path) || paths.has(candidate.path) || !(candidate.path === artifact.root || candidate.path.startsWith(`${artifact.root}/`))) {
116+
throw new StaticArtifactImportError("Static artifact contains an invalid file path.", 422)
117+
}
118+
paths.add(candidate.path)
119+
let bytes: Uint8Array
120+
if (typeof candidate.content === "string" && candidate.content_base64 === undefined) bytes = new TextEncoder().encode(candidate.content)
121+
else if (typeof candidate.content_base64 === "string" && candidate.content === undefined) bytes = decodeBase64(candidate.content_base64)
122+
else throw new StaticArtifactImportError("Static artifact file content is invalid.", 422)
123+
if (bytes.byteLength > MAX_STATIC_ARTIFACT_FILE_BYTES) throw new StaticArtifactImportError("Static artifact file exceeds its byte budget.", 413)
124+
decodedBytes += bytes.byteLength
125+
if (decodedBytes > MAX_STATIC_ARTIFACT_DECODED_BYTES) throw new StaticArtifactImportError("Static artifact exceeds its decoded byte budget.", 413)
126+
if (candidate.bytes !== undefined && candidate.bytes !== bytes.byteLength) throw new StaticArtifactImportError("Static artifact file size does not match its metadata.", 422)
127+
if (candidate.sha256 !== undefined && (typeof candidate.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(candidate.sha256) || await sha256Hex(bytes) !== candidate.sha256)) throw new StaticArtifactImportError("Static artifact file digest does not match its content.", 422)
128+
}
129+
if (!paths.has(artifact.entrypoint)) throw new StaticArtifactImportError("Static artifact entrypoint is unavailable.", 422)
130+
}
131+
132+
function isSafePath(path: string): boolean {
133+
return path.length > 0 && !path.startsWith("/") && !path.includes("\\") && path.split("/").every((segment) => !!segment && segment !== "." && segment !== "..")
134+
}
135+
136+
function decodeBase64(value: string): Uint8Array {
137+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new StaticArtifactImportError("Static artifact contains invalid base64 content.", 422)
138+
const decoded = atob(value)
139+
return Uint8Array.from(decoded, (character) => character.charCodeAt(0))
140+
}
141+
142+
async function sha256Hex(bytes: Uint8Array): Promise<string> {
143+
const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>)
144+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
145+
}

0 commit comments

Comments
 (0)