Skip to content

Commit 4a3ae64

Browse files
authored
Merge pull request #2113 from Automattic/fix/2112-isolate-public-reader
Isolate immutable Cloudflare public reads from PHP-WASM
2 parents 2a8b019 + 63cb4c7 commit 4a3ae64

10 files changed

Lines changed: 358 additions & 11 deletions

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"build:release": "node ./node_modules/typescript/bin/tsc -b --force packages/runtime-core packages/runtime-playground packages/cli && node scripts/ensure-cli-bin-executable.mjs",
9090
"cloudflare:dry-run": "npm exec -- wrangler deploy --dry-run --config packages/runtime-cloudflare/wrangler.jsonc",
9191
"cloudflare:dry-run:d1": "npm exec -- wrangler deploy --dry-run --config packages/runtime-cloudflare/wrangler.d1.jsonc",
92+
"cloudflare:dry-run:public-reader": "npm exec -- wrangler deploy --dry-run --config packages/runtime-cloudflare/wrangler.public-reader.jsonc",
9293
"cloudflare:local-gate": "node scripts/cloudflare-local-gate.mjs",
9394
"cloudflare:local-gate:d1": "node scripts/cloudflare-local-gate.mjs --coordinator=d1",
9495
"cloudflare:local-gate:provisioning": "node scripts/cloudflare-local-gate.mjs --coordinator=d1 --public-provisioning",
@@ -110,7 +111,7 @@
110111
"test:redaction": "tsx tests/redaction.test.ts",
111112
"test:browser-preview-routing": "tsx --test tests/browser-preview-routing.test.ts",
112113
"test:browser-routed-command-security": "tsx --test tests/browser-routed-command-security.test.ts",
113-
"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-allocation-lifecycle.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",
114+
"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-allocation-lifecycle.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-runtime.test.ts && tsx tests/cloudflare-public-reader.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit",
114115
"test:cloudflare-administrator-claim": "tsx tests/cloudflare-provisioning-api.test.ts",
115116
"test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts",
116117
"test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts",

packages/runtime-cloudflare/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ This candidate integration for [wp-codebox#1838](https://github.com/Automattic/w
44

55
## Runtime Architecture
66

7+
The optional `public-reader-worker.ts` entrypoint is an R2-only immutable publication reader. It never imports PHP-WASM, SQLite, D1, revision coordinators, or mutation execution. It serves only complete publication snapshots, WordPress core assets, and revision-bound `wp-content` or uploads; missing or building publication state returns a bounded response rather than falling through to dynamic WordPress. Build it without deployment using `npm run cloudflare:dry-run:public-reader`.
8+
79
The entry Worker executes PHP-WASM and WordPress. Runtime behavior depends on the typed `RevisionCoordinator` contract rather than a Cloudflare storage product. The standard `worker-do.ts` entrypoint injects the existing `WordPressStateCoordinator` Durable Object adapter. The ChatGPT Sites-compatible `worker-d1.ts` entrypoint injects a D1 adapter that stores only the current pointer, version, lease token, lease base, and expiry in one conditional-update row per site. Both adapters serialize the same bounded lease and CAS promotion semantics; neither imports or instantiates PHP-WASM. MDI, disposable SQLite, and canonical R2 storage are unchanged.
810

911
On cold start, the entry Worker uses the acquired pointer to rebuild PHP-WASM's disposable SQLite index from canonical MDI Markdown and JSON files. A missing pointer materializes the packaged canonical MDI seed and boots one PHP-WASM primary runtime. The build-time PHP CLI generator creates that archive from `wordpress-install-seed.sqlite` through MDI's public `bootstrap_existing_cache()` API, validates its pinned MDI revision and input digest, and never packages SQLite. The runtime updates `siteurl` and `home` through WordPress APIs using the request origin and sets the admin password from `WORDPRESS_ADMIN_PASSWORD`; only WordPress's password hash is canonical. Bootstrap persists and CAS-promotes this mutation before serving the next request.
@@ -63,7 +65,7 @@ SSI is extracted only for import requests, so normal browser, mutation, publicat
6365
1. Run `npm run generate:cloudflare-canonical-mdi-seed` and `npm run generate:cloudflare-wordpress-runtime-corpus` to regenerate deterministic runtime artifacts and manifests.
6466
2. Run `npm run provision:cloudflare-wordpress-runtime-corpus -- --local --persist-to <directory>` to verify and upload all exact content-addressed artifacts into isolated local R2 storage. For an authorized deployment, run the provisioner with `--remote` and require every upload to succeed before deploying the Worker that imports their manifests.
6567
3. Run `npm run test:cloudflare-runtime` for routing, coordinator composition, canonical-state, artifact validation, source contract, and TypeScript coverage.
66-
4. Run `npm run cloudflare:dry-run` and `npm run cloudflare:dry-run:d1` to compile the Durable Object and D1 profiles without creating Cloudflare resources. The placeholder D1 database ID is for local/dry-run verification; an implementation supplies its provisioned binding at deployment.
68+
4. Run `npm run cloudflare:dry-run`, `npm run cloudflare:dry-run:d1`, and `npm run cloudflare:dry-run:public-reader` to compile the Durable Object, D1, and R2-only public-reader profiles without creating Cloudflare resources. The public-reader dry run must report only its R2 binding. The placeholder D1 database ID is for local/dry-run verification; an implementation supplies its provisioned binding at deployment.
6769
Use `npm run provision:cloudflare-d1-coordinator -- --output <production-config>` to idempotently create or resolve the named production database and emit a mode-`0600` deployment config from the checked-in template. Relative entrypoints are resolved from that template so the emitted config remains deployable outside the checkout.
6870
5. Run `npm run cloudflare:local-gate` and `npm run cloudflare:local-gate:d1` for the same isolated workerd workflow through both coordinator implementations. Each gate generates and provisions all artifacts, verifies the selected backend through the state envelope, injects stable test-only admin-password, auth-secret, and operator-token values, uploads and activates a real plugin ZIP, establishes an anonymous homepage and canonical-permalink publication, updates a published post through authenticated REST without immediate rendering, restarts with pending publication work, and proves bounded scheduled draining eventually exposes the updated permalink through coordinator-free R2. The cron gate also proves that a scheduled post becomes readable from the publication path without an operator call. Restart verification requires the immutable R2 publication. A final two-host phase proves isolated mutations, coordinator versions, REST collections, credentials, publications, and caches plus fail-closed unknown-host routing. The remaining coverage includes login, concurrent canonical writes, media, representative frontend/admin/editor assets, PHP diagnostics, session recovery, and a fresh login after restart.
6971

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createCloudflarePublicReader, type PublicReaderEnv } from "./public-reader.js"
2+
3+
export default createCloudflarePublicReader<PublicReaderEnv>()
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { canonicalPublicRoute, MAX_PUBLISHED_PAGE_BYTES, MAX_PUBLISHED_REVISION_BYTES, PUBLISHED_PAGE_SCHEMA, validateCompletePublishedRevision } from "./published-reader.js"
2+
import { parseSiteContexts, previewDomain, resolvePreviewSiteContextFromRequest, resolveSiteContextFromRequest, siteStorageKeys, type SiteContext } from "./site-context.js"
3+
import { isCanonicalWpContentPath, validateWpContentManifestFiles } from "./wp-content-persistence.js"
4+
import { validateUploadManifestFiles } from "./upload-persistence.js"
5+
import { servePublicWordPressStaticAsset } from "./wordpress-static-reader.js"
6+
7+
export interface PublicReaderEnv {
8+
WORDPRESS_STATE_BUCKET: R2Bucket
9+
WORDPRESS_SITE_CONTEXTS?: string
10+
WORDPRESS_PREVIEW_DOMAIN?: string
11+
WORDPRESS_PREVIEW_HOST_SECRET?: string
12+
}
13+
14+
interface PageSnapshot {
15+
schema: typeof PUBLISHED_PAGE_SCHEMA
16+
canonicalRevision: string
17+
route: string
18+
status: number
19+
statusText: string
20+
headers: Array<[string, string]>
21+
body: string
22+
}
23+
24+
interface PublicationAsset {
25+
path: string
26+
objectKey: string
27+
sha256: string
28+
size: number
29+
}
30+
31+
/**
32+
* This is intentionally a complete request boundary: it has no mutation
33+
* fallback, so an absent or incomplete publication cannot boot WordPress.
34+
*/
35+
export function createCloudflarePublicReader<Env extends PublicReaderEnv>() {
36+
return {
37+
async fetch(request: Request, env: Env): Promise<Response> {
38+
let site: SiteContext
39+
try {
40+
const contexts = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS)
41+
try {
42+
site = resolveSiteContextFromRequest(request, contexts)
43+
} catch (error) {
44+
if (!(error instanceof Error) || error.message !== "Unknown site hostname.") throw error
45+
site = await resolvePreviewSiteContextFromRequest(request, previewDomain(env.WORDPRESS_PREVIEW_DOMAIN, env.WORDPRESS_PREVIEW_HOST_SECRET))
46+
}
47+
} catch (error) {
48+
if (error instanceof Error && error.message === "Unknown site hostname.") return response("Unknown site hostname.", 421)
49+
return response("Public publication configuration is invalid.", 503)
50+
}
51+
return servePublicPublication(request, env.WORDPRESS_STATE_BUCKET, site)
52+
},
53+
}
54+
}
55+
56+
export async function servePublicPublication(request: Request, bucket: R2Bucket, site: SiteContext): Promise<Response> {
57+
if (request.method !== "GET" && request.method !== "HEAD") return response("Public publication accepts GET and HEAD only.", 405)
58+
if (request.headers.has("authorization") || request.headers.has("cookie") || new URL(request.url).searchParams.has("phase")) return response("Dynamic WordPress requests are unavailable from the public reader.", 404)
59+
const staticAsset = await servePublicWordPressStaticAsset(request, bucket)
60+
if (staticAsset) return staticAsset
61+
const route = canonicalPublicRoute(request)
62+
let publication
63+
try {
64+
const current = await bucket.get(siteStorageKeys(site).publishedCurrent)
65+
if (!current) return response("Public publication is not ready.", 503)
66+
if (current.size > MAX_PUBLISHED_REVISION_BYTES) return response("Public publication is incomplete.", 503)
67+
publication = validateCompletePublishedRevision(JSON.parse(await current.text()), site)
68+
} catch {
69+
return response("Public publication is incomplete.", 503)
70+
}
71+
const asset = await servePublishedAsset(request, bucket, site, publication)
72+
if (asset) return asset
73+
const entry = publication.routes.find((candidate) => candidate.route === route)
74+
if (!entry) return response("Published route not found.", 404)
75+
76+
const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default
77+
const cacheKey = new Request(`https://wp-codebox-publication.invalid/${site.id}/${publication.revision}${route}`, { method: "GET" })
78+
const cached = cache ? await cache.match(cacheKey) : null
79+
if (cached) return publicationResponse(cached, request.method === "HEAD", publication.revision, "edge")
80+
try {
81+
const object = await bucket.get(entry.objectKey)
82+
if (!object || object.size > MAX_PUBLISHED_PAGE_BYTES) return response("Public publication is incomplete.", 503)
83+
const snapshot = JSON.parse(await object.text()) as PageSnapshot
84+
if (!isPageSnapshot(snapshot, entry.canonicalRevision, route)) return response("Public publication is incomplete.", 503)
85+
const published = publicationResponse(new Response(snapshot.body, { status: snapshot.status, statusText: snapshot.statusText, headers: snapshot.headers }), request.method === "HEAD", publication.revision, "r2")
86+
if (cache && request.method === "GET") await cache.put(cacheKey, published.clone()).catch(() => undefined)
87+
return published
88+
} catch {
89+
return response("Public publication is incomplete.", 503)
90+
}
91+
}
92+
93+
async function servePublishedAsset(request: Request, bucket: R2Bucket, site: SiteContext, publication: ReturnType<typeof validateCompletePublishedRevision>): Promise<Response | null> {
94+
const pathname = new URL(request.url).pathname
95+
const uploads = pathname.startsWith("/wp-content/uploads/") ? pathname.slice("/wp-content/uploads/".length) : null
96+
const wpContent = pathname.startsWith("/wp-content/") ? pathname.slice("/wp-content/".length) : null
97+
if (uploads === null && wpContent === null) return null
98+
let path: string
99+
try {
100+
path = decodeURIComponent(uploads ?? wpContent!)
101+
} catch {
102+
return response("Published asset path is invalid.", 400)
103+
}
104+
if ((uploads === null && !isCanonicalWpContentPath(path)) || (uploads !== null && !isCanonicalRelativePath(path))) return response("Published asset path is invalid.", 400)
105+
try {
106+
const manifestKey = `${siteStorageKeys(site).markdownRevisionPrefix}/${publication.canonicalRevision}.json`
107+
const manifestObject = await bucket.get(manifestKey)
108+
if (!manifestObject) return response("Public publication is incomplete.", 503)
109+
const manifest = await manifestObject.json<{ revision?: unknown; uploads?: PublicationAsset[]; wpContent?: PublicationAsset[] }>()
110+
if (manifest.revision !== publication.canonicalRevision) return response("Public publication is incomplete.", 503)
111+
const files = uploads === null ? manifest.wpContent ?? [] : manifest.uploads ?? []
112+
if (uploads === null) validateWpContentManifestFiles(files, site)
113+
else validateUploadManifestFiles(files, site)
114+
const file = files.find((candidate) => candidate.path === path)
115+
if (!file) return response("Published asset not found.", 404)
116+
const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default
117+
const cacheKey = new Request(`https://wp-codebox-publication.invalid/${site.id}/${publication.revision}/${file.sha256}/${pathname}`, { method: "GET" })
118+
const cached = cache ? await cache.match(cacheKey) : null
119+
if (cached) return assetResponse(cached, request.method === "HEAD", publication.revision, "edge")
120+
const object = await bucket.get(file.objectKey)
121+
if (!object || object.size !== file.size) return response("Public publication is incomplete.", 503)
122+
const body = request.method === "HEAD" ? null : new Uint8Array(await object.arrayBuffer())
123+
if (body && await sha256Hex(body) !== file.sha256) return response("Public publication is incomplete.", 503)
124+
const published = assetResponse(new Response(body ? Uint8Array.from(body).buffer : null, { headers: { "content-length": String(file.size), "content-type": object.httpMetadata?.contentType ?? "application/octet-stream", etag: `\"${file.sha256}\"` } }), request.method === "HEAD", publication.revision, "r2")
125+
if (cache && request.method === "GET") await cache.put(cacheKey, published.clone()).catch(() => undefined)
126+
return published
127+
} catch {
128+
return response("Public publication is incomplete.", 503)
129+
}
130+
}
131+
132+
function isPageSnapshot(snapshot: PageSnapshot, canonicalRevision: string, route: string): boolean {
133+
return snapshot.schema === PUBLISHED_PAGE_SCHEMA && snapshot.canonicalRevision === canonicalRevision && snapshot.route === route
134+
&& Number.isInteger(snapshot.status) && snapshot.status >= 100 && snapshot.status <= 599 && typeof snapshot.statusText === "string"
135+
&& Array.isArray(snapshot.headers) && snapshot.headers.every((header) => Array.isArray(header) && header.length === 2 && header.every((value) => typeof value === "string"))
136+
&& typeof snapshot.body === "string"
137+
}
138+
139+
function publicationResponse(response: Response, head: boolean, revision: string, source: "edge" | "r2"): Response {
140+
const headers = new Headers(response.headers)
141+
// The Worker cache key includes this immutable publication revision and site generation.
142+
headers.set("cache-control", "public, max-age=60, s-maxage=60")
143+
headers.set("x-wp-codebox-publication-revision", revision)
144+
headers.set("x-wp-codebox-publication-cache", source)
145+
return new Response(head ? null : response.body, { status: response.status, statusText: response.statusText, headers })
146+
}
147+
148+
function response(message: string, status: number): Response {
149+
return new Response(message, { status, headers: { "cache-control": "no-store", "x-wp-codebox-public-reader": "bounded" } })
150+
}
151+
152+
function isCanonicalRelativePath(path: string): boolean {
153+
return path.length > 0 && path.length <= 2_048 && !path.startsWith("/") && !path.includes("\\") && path.split("/").every((part) => part !== "" && part !== "." && part !== "..")
154+
}
155+
156+
async function sha256Hex(bytes: Uint8Array): Promise<string> {
157+
const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>)
158+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
159+
}
160+
161+
function assetResponse(response: Response, head: boolean, revision: string, source: "edge" | "r2"): Response {
162+
const headers = new Headers(response.headers)
163+
headers.set("cache-control", "public, max-age=60, s-maxage=60")
164+
headers.set("x-wp-codebox-publication-revision", revision)
165+
headers.set("x-wp-codebox-publication-asset", source)
166+
return new Response(head ? null : response.body, { status: response.status, headers })
167+
}

0 commit comments

Comments
 (0)