From ba1d94a86d3477ea317542bf37d9aabab3a750ca Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 23 Jul 2026 01:25:12 -0400 Subject: [PATCH] Scope Cloudflare state by site identity --- package.json | 2 +- packages/runtime-cloudflare/README.md | 16 +- .../src/d1-revision-coordinator.ts | 154 +++--- .../src/published-reader.ts | 23 +- .../runtime-cloudflare/src/site-context.ts | 108 ++++ .../src/state-coordinator.ts | 107 ++-- .../src/static-artifact-import.ts | 8 +- .../src/upload-persistence.ts | 8 +- .../runtime-cloudflare/src/wordpress-auth.ts | 11 + packages/runtime-cloudflare/src/worker-d1.ts | 5 +- packages/runtime-cloudflare/src/worker-do.ts | 5 +- packages/runtime-cloudflare/src/worker.ts | 489 ++++++++++-------- .../src/wp-content-persistence.ts | 8 +- scripts/cloudflare-local-gate.mjs | 84 ++- ...lare-coordinator-site-partitioning.test.ts | 99 ++++ tests/cloudflare-runtime.test.ts | 69 +-- tests/cloudflare-site-context.test.ts | 43 ++ tests/cloudflare-wordpress-auth.test.ts | 11 +- 18 files changed, 835 insertions(+), 415 deletions(-) create mode 100644 packages/runtime-cloudflare/src/site-context.ts create mode 100644 tests/cloudflare-coordinator-site-partitioning.test.ts create mode 100644 tests/cloudflare-site-context.test.ts diff --git a/package.json b/package.json index 74779eb76..de489038c 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "generate:cloudflare-canonical-mdi-seed": "npm run generate:cloudflare-mdi-runtime-bundle && php scripts/build-cloudflare-canonical-mdi-seed.php", "smoke": "tsx scripts/run-smoke.ts", "test:redaction": "tsx tests/redaction.test.ts", - "test:cloudflare-runtime": "tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", + "test:cloudflare-runtime": "tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", "test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts", "test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts", "test:agent-task-contracts": "tsx tests/agent-task-contracts.test.ts && tsx tests/agent-task-canonical-evidence.test.ts && npm run test:agent-task-workflow-interface && npm run test:runtime-sources-materialization && tsx tests/agent-task-reusable-workflow.test.ts", diff --git a/packages/runtime-cloudflare/README.md b/packages/runtime-cloudflare/README.md index ec169d5fe..ebfd56a63 100644 --- a/packages/runtime-cloudflare/README.md +++ b/packages/runtime-cloudflare/README.md @@ -4,20 +4,26 @@ This candidate integration for [wp-codebox#1838](https://github.com/Automattic/w ## Runtime Architecture -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. 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. +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. 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. -Canonical browser, health, and mutation boots require the separately managed `WORDPRESS_AUTH_SECRET` Worker secret. The entry Worker derives the eight WordPress auth keys and salts from that secret with a versioned, site-scoped (`default`) SHA-256 domain separator before `wp-load.php`. It never logs, persists, or returns the secret or derived values. Configure it independently from the bootstrap password with `wrangler secret put WORDPRESS_AUTH_SECRET --config packages/runtime-cloudflare/wrangler.jsonc`; rotating `WORDPRESS_ADMIN_PASSWORD` does not rotate authentication salts or invalidate sessions. +Canonical browser, health, and mutation boots require the separately managed `WORDPRESS_AUTH_SECRET` Worker secret. The entry Worker derives the eight WordPress auth keys and salts from that secret with a versioned, site-scoped SHA-256 domain separator before `wp-load.php`. It never logs, persists, or returns the secret or derived values. Configure it independently from the bootstrap password with `wrangler secret put WORDPRESS_AUTH_SECRET --config packages/runtime-cloudflare/wrangler.jsonc`; rotating `WORDPRESS_ADMIN_PASSWORD` does not rotate authentication salts or invalidate sessions. After each mutating HTTP request, the entry runtime invokes MDI's explicit request-boundary flush, collects canonical files, stores immutable content-addressed R2 objects and a revision manifest, then commits the new pointer through the held lease. GET, HEAD, and asset requests release without promotion. Failed requests abort their leases; stale leases recover by token/version/expiry checks. The entry isolate can cache one runtime only for the exact acquired pointer revision and exits it after promotion or when another isolate advances the pointer. It does not persist SQLite. Existing manifests are reused when canonical file hashes have not changed. Canonical mutations such as login session-token writes do not rebuild a publication unless WordPress reports explicit affected routes or a global publication change. -Anonymous HTML responses are stored as create-once, host-independent R2 artifacts under their canonical revision. An authorized `POST ?phase=operator-publish` establishes or replaces a bounded route set. Once that publication exists, WordPress hooks report the public routes affected by post, term, menu, theme, plugin, and relevant option mutations. The Worker renders only those routes in the already-mutated PHP runtime, stages immutable page artifacts and a version 2 publication descriptor, commits canonical state through the coordinator, then conditionally promotes `sites/default/publications/current.json` against its prior R2 ETag. Unchanged route entries retain their older canonical artifacts. A promotion conflict or post-commit R2 failure leaves the prior publication readable and is exposed through `x-wp-codebox-publication`; it never rolls back already-committed WordPress state. Published GET and HEAD requests check a 60-second edge entry and then R2 before coordinator construction, while authenticated, preview, admin, REST, and unpublished routes continue through WordPress. The version 2 reader accepts existing version 1 descriptors during migration. This provides bounded automatic route compilation and local edge invalidation, not a global CDN purge pipeline. +Anonymous HTML responses are stored as create-once, host-independent R2 artifacts under their canonical revision. An authorized `POST ?phase=operator-publish` establishes or replaces a bounded route set. Once that publication exists, WordPress hooks report the public routes affected by post, term, menu, theme, plugin, and relevant option mutations. The Worker renders only those routes in the already-mutated PHP runtime, stages immutable page artifacts and a version 2 publication descriptor, commits canonical state through the coordinator, then conditionally promotes `sites/{siteId}/publications/current.json` against its prior R2 ETag. Unchanged route entries retain their older canonical artifacts. A promotion conflict or post-commit R2 failure leaves the prior publication readable and is exposed through `x-wp-codebox-publication`; it never rolls back already-committed WordPress state. Published GET and HEAD requests check a 60-second site-scoped edge entry and then R2 before coordinator construction, while authenticated, preview, admin, REST, and unpublished routes continue through WordPress. The version 2 reader accepts existing version 1 descriptors during migration. This provides bounded automatic route compilation and local edge invalidation, not a global CDN purge pipeline. The same revision transaction persists bounded user-managed files under `wp-content/plugins`, `themes`, `languages`, and `mu-plugins`. Runtime-owned MDI, SQLite integration, and Codebox adapter files remain reconstructable artifacts and are excluded. Unchanged bundled-theme files are omitted by release hash, while modified files become canonical overrides. Public canonical plugin and theme assets serve directly from revision-addressed R2 cache entries before the immutable release corpus. Nonce-protected `wp-admin` GET actions such as plugin activation are classified as mutations so their filesystem and MDI option changes commit atomically. Existing revisions without `wpContent` remain valid. The Worker forwards browser cookies directly to Playground and disables Playground's internal cookie store, preventing an empty per-isolate store from replacing a valid browser session after cold restart. +## Site Identity + +The Worker resolves a validated `SiteContext` from the exact request hostname before reading a cache or constructing a coordinator. `WORDPRESS_SITE_CONTEXTS` is a JSON array of `{ "id", "hostname", "origin" }` records. Site IDs use lowercase letters, digits, and single hyphens; hostnames must be canonical lowercase DNS names or loopback addresses; origins must match the configured hostname and contain no path, query, or credentials. HTTPS is required except for loopback and `*.localhost` development origins. Unknown hostnames fail with `421` and never fall back to another site. + +Every mutable R2 key is rooted below `sites/{siteId}/`, D1 tables partition rows by site ID, Durable Object names include the site ID, and runtime and publication caches include it in their identity. The existing production hostname remains explicitly mapped to `default`, preserving its `sites/default/...` objects and credentials. Non-default sites derive distinct operator tokens and bootstrap admin passwords from the corresponding root Worker secrets with a versioned HMAC domain separator; the mapping itself contains no credentials. WordPress auth keys and salts are independently site-derived from `WORDPRESS_AUTH_SECRET`. + A minute Cloudflare Cron Trigger first drains at most one publication route, or otherwise drains at most five due WordPress events or 25 seconds of work. Canonical mutations persist immutable state, enqueue a revision- and coordinator-version-bound publication job, and return `x-wp-codebox-publication: queued` when public routes need work; they never render routes in the mutation request. A publication job boots only its committed canonical revision, writes one immutable page snapshot per scheduled invocation, then conditionally promotes a complete descriptor. A stale job cannot overwrite a later canonical version, and failed work leaves the prior publication readable for retry. Direct `/wp-cron.php` requests are disabled so callbacks cannot bypass that transaction boundary. The bundled MDI source is pinned to immutable commit `bf6d434d1673fdd86d777501f7eaec292d32ad1f`, including [MDI PR #141](https://github.com/Automattic/markdown-database-integration/pull/141). The bundle generator, worker provenance, and source-contract test use the same revision. @@ -34,7 +40,7 @@ Use authenticated `GET ?phase=operator-fence-status` after acquisition to export ## 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/default/import-artifacts/.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. +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 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. 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. @@ -46,6 +52,6 @@ SSI is extracted only for import requests, so normal browser, mutation, publicat 2. Run `npm run provision:cloudflare-wordpress-runtime-corpus -- --local --persist-to ` 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. 3. Run `npm run test:cloudflare-runtime` for routing, coordinator composition, canonical-state, artifact validation, source contract, and TypeScript coverage. 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. -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, while the remaining coverage includes login, concurrent canonical writes, media, representative frontend/admin/editor assets, PHP diagnostics, session recovery, and a fresh login after restart. +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. This document describes local candidate verification only. It does not claim remote deployment. diff --git a/packages/runtime-cloudflare/src/d1-revision-coordinator.ts b/packages/runtime-cloudflare/src/d1-revision-coordinator.ts index 1453d33a8..8a8817795 100644 --- a/packages/runtime-cloudflare/src/d1-revision-coordinator.ts +++ b/packages/runtime-cloudflare/src/d1-revision-coordinator.ts @@ -16,69 +16,68 @@ interface FenceRow { expires_at: number } -const SITE_ID = "default" const LEASE_MS = 90_000 const schemaReady = new WeakMap>() export class D1RevisionCoordinator implements RevisionCoordinator { - constructor(private readonly database: D1Database, private readonly leaseMs = LEASE_MS) {} + constructor(private readonly database: D1Database, private readonly siteId: string, private readonly leaseMs = LEASE_MS) {} state(): Promise { - return readWordPressState(this.database) + return readWordPressState(this.database, this.siteId) } acquire(ttlMs = this.leaseMs): Promise { - return beginStateLease(this.database, ttlMs) + return beginStateLease(this.database, this.siteId, ttlMs) } renew(lease: RevisionLease, ttlMs = this.leaseMs): Promise { - return renewStateLease(this.database, lease, ttlMs) + return renewStateLease(this.database, this.siteId, lease, ttlMs) } async release(lease: RevisionLease): Promise { - await releaseStateLease(this.database, lease) + await releaseStateLease(this.database, this.siteId, lease) } async abort(lease: RevisionLease): Promise { - await abortStateLease(this.database, lease) + await abortStateLease(this.database, this.siteId, lease) } commit(lease: RevisionLease, pointer: MarkdownPointer): Promise<{ pointer: MarkdownPointer; version: number }> { - return commitStateLease(this.database, lease, pointer) + return commitStateLease(this.database, this.siteId, lease, pointer) } committed(version: number): Promise { - return readCommittedPointer(this.database, version) + return readCommittedPointer(this.database, this.siteId, version) } fenceStatus(): Promise { - return readMutationFence(this.database) + return readMutationFence(this.database, this.siteId) } acquireFence(ttlMs: number): Promise { - return acquireMutationFence(this.database, ttlMs) + return acquireMutationFence(this.database, this.siteId, ttlMs) } renewFence(token: string, ttlMs: number): Promise { - return renewMutationFence(this.database, token, ttlMs) + return renewMutationFence(this.database, this.siteId, token, ttlMs) } async releaseFence(token: string): Promise { - await releaseMutationFence(this.database, token) + await releaseMutationFence(this.database, this.siteId, token) } adopt(pointer: MarkdownPointer, version: number): Promise<{ pointer: MarkdownPointer; version: number }> { - return adoptWordPressState(this.database, pointer, version) + return adoptWordPressState(this.database, this.siteId, pointer, version) } async reset(): Promise { - await resetWordPressState(this.database) + await resetWordPressState(this.database, this.siteId) } } -async function readWordPressState(database: D1Database): Promise { - await ensureSchema(database) - const row = await readRow(database) +async function readWordPressState(database: D1Database, siteId: string): Promise { + await ensureSchema(database, siteId) + const row = await readRow(database, siteId) return { schema: "wp-codebox/cloudflare-wordpress-state/v2", store: "d1", @@ -87,8 +86,8 @@ async function readWordPressState(database: D1Database): Promise } } -async function beginStateLease(database: D1Database, leaseMs = LEASE_MS): Promise { - await ensureSchema(database) +async function beginStateLease(database: D1Database, siteId: string, leaseMs = LEASE_MS): Promise { + await ensureSchema(database, siteId) const now = Date.now() const token = crypto.randomUUID() const expiresAt = revisionLeaseExpiresAt(leaseMs, now) @@ -96,43 +95,43 @@ async function beginStateLease(database: D1Database, leaseMs = LEASE_MS): Promis SET lease_token = ?, lease_base_revision = revision, lease_version = version, lease_expires_at = ? WHERE site_id = ? AND (lease_token IS NULL OR lease_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM wp_codebox_fences WHERE site_id = ? AND expires_at > ?)`) - .bind(token, expiresAt, SITE_ID, now, SITE_ID, now).run() + .bind(token, expiresAt, siteId, now, siteId, now).run() if (result.meta.changes !== 1) { - const active = await readRow(database) - const fence = await readFenceRow(database) + const active = await readRow(database, siteId) + const fence = await readFenceRow(database, siteId) if (fence && fence.expires_at > now) throw new RevisionConflict("Canonical WordPress mutations are fenced for coordinator cutover.", fence.expires_at) throw new RevisionConflict("A canonical WordPress lease is active.", active.lease_expires_at ?? undefined) } - const row = await readRow(database) + const row = await readRow(database, siteId) if (row.lease_token !== token || row.lease_version === null) throw new RevisionConflict("The canonical WordPress lease was not acquired.") return { token, pointer: pointerFromRow(row), version: row.lease_version, expiresAt } } -async function releaseStateLease(database: D1Database, lease: RevisionLease): Promise<{ released: true }> { - await finishLease(database, lease, "release") +async function releaseStateLease(database: D1Database, siteId: string, lease: RevisionLease): Promise<{ released: true }> { + await finishLease(database, siteId, lease, "release") return { released: true } } -async function renewStateLease(database: D1Database, lease: RevisionLease, leaseMs: number): Promise { - await ensureSchema(database) +async function renewStateLease(database: D1Database, siteId: string, lease: RevisionLease, leaseMs: number): Promise { + await ensureSchema(database, siteId) const now = Date.now() const expiresAt = revisionLeaseExpiresAt(leaseMs, now) const result = await database.prepare(`UPDATE wp_codebox_state SET lease_expires_at = ? WHERE site_id = ? AND lease_token = ? AND lease_expires_at > ? AND NOT EXISTS (SELECT 1 FROM wp_codebox_fences WHERE site_id = ? AND expires_at > ?)`) - .bind(expiresAt, SITE_ID, lease.token, now, SITE_ID, now).run() + .bind(expiresAt, siteId, lease.token, now, siteId, now).run() if (result.meta.changes !== 1) throw new RevisionConflict("The canonical WordPress lease cannot renew because it expired or changed.") return { ...lease, expiresAt } } -async function abortStateLease(database: D1Database, lease: RevisionLease): Promise<{ aborted: true }> { - await finishLease(database, lease, "abort") +async function abortStateLease(database: D1Database, siteId: string, lease: RevisionLease): Promise<{ aborted: true }> { + await finishLease(database, siteId, lease, "abort") return { aborted: true } } -async function commitStateLease(database: D1Database, lease: RevisionLease, pointer: MarkdownPointer): Promise<{ pointer: MarkdownPointer; version: number }> { +async function commitStateLease(database: D1Database, siteId: string, lease: RevisionLease, pointer: MarkdownPointer): Promise<{ pointer: MarkdownPointer; version: number }> { validatePointer(pointer) - await ensureSchema(database) + await ensureSchema(database, siteId) const baseRevision = lease.pointer?.revision ?? null const update = database.prepare(`UPDATE wp_codebox_state SET revision = ?, manifest_key = ?, persisted_at = ?, version = version + 1, @@ -140,34 +139,34 @@ async function commitStateLease(database: D1Database, lease: RevisionLease, poin WHERE site_id = ? AND lease_token = ? AND lease_expires_at > ? AND version = ? AND lease_version = ? AND ((revision IS NULL AND ? IS NULL) OR revision = ?) AND ((lease_base_revision IS NULL AND ? IS NULL) OR lease_base_revision = ?)`) - .bind(pointer.revision, pointer.manifestKey, pointer.persistedAt, SITE_ID, lease.token, Date.now(), lease.version, lease.version, + .bind(pointer.revision, pointer.manifestKey, pointer.persistedAt, siteId, lease.token, Date.now(), lease.version, lease.version, baseRevision, baseRevision, baseRevision, baseRevision) const receipt = database.prepare(`INSERT OR IGNORE INTO wp_codebox_commits (site_id, version, revision, manifest_key, persisted_at) SELECT ?, ?, ?, ?, ? FROM wp_codebox_state WHERE site_id = ? AND version = ? AND revision = ?`) - .bind(SITE_ID, lease.version + 1, pointer.revision, pointer.manifestKey, pointer.persistedAt, SITE_ID, lease.version + 1, pointer.revision) + .bind(siteId, lease.version + 1, pointer.revision, pointer.manifestKey, pointer.persistedAt, siteId, lease.version + 1, pointer.revision) const [result, receiptResult] = await database.batch([update, receipt]) if (result.meta.changes !== 1 || receiptResult.meta.changes !== 1) throw new RevisionConflict("The canonical pointer changed before D1 promotion.") return { pointer, version: lease.version + 1 } } -async function readCommittedPointer(database: D1Database, version: number): Promise { +async function readCommittedPointer(database: D1Database, siteId: string, version: number): Promise { if (!Number.isSafeInteger(version) || version < 1) throw new RevisionConflict("A canonical commit version is required.") - await ensureSchema(database) - const row = await database.prepare(`SELECT revision, manifest_key, persisted_at FROM wp_codebox_commits WHERE site_id = ? AND version = ?`).bind(SITE_ID, version).first<{ revision: string; manifest_key: string; persisted_at: string }>() + await ensureSchema(database, siteId) + const row = await database.prepare(`SELECT revision, manifest_key, persisted_at FROM wp_codebox_commits WHERE site_id = ? AND version = ?`).bind(siteId, version).first<{ revision: string; manifest_key: string; persisted_at: string }>() if (!row) return null const pointer = { revision: row.revision, manifestKey: row.manifest_key, persistedAt: row.persisted_at } validatePointer(pointer) return pointer } -async function readMutationFence(database: D1Database): Promise { - await ensureSchema(database) - const row = await readFenceRow(database) +async function readMutationFence(database: D1Database, siteId: string): Promise { + await ensureSchema(database, siteId) + const row = await readFenceRow(database, siteId) return row && row.expires_at > Date.now() ? { active: true, expiresAt: row.expires_at } : { active: false } } -async function acquireMutationFence(database: D1Database, ttlMs: number): Promise { - await ensureSchema(database) +async function acquireMutationFence(database: D1Database, siteId: string, ttlMs: number): Promise { + await ensureSchema(database, siteId) const now = Date.now() const fence = { token: crypto.randomUUID(), expiresAt: mutationFenceExpiresAt(ttlMs, now) } const result = await database.prepare(`INSERT INTO wp_codebox_fences (site_id, token, expires_at) @@ -176,37 +175,37 @@ async function acquireMutationFence(database: D1Database, ttlMs: number): Promis ) ON CONFLICT(site_id) DO UPDATE SET token = excluded.token, expires_at = excluded.expires_at WHERE wp_codebox_fences.expires_at <= ?`) - .bind(SITE_ID, fence.token, fence.expiresAt, SITE_ID, now, now).run() + .bind(siteId, fence.token, fence.expiresAt, siteId, now, now).run() if (result.meta.changes !== 1) { - const [state, active] = await Promise.all([readRow(database), readFenceRow(database)]) + const [state, active] = await Promise.all([readRow(database, siteId), readFenceRow(database, siteId)]) if (state.lease_token && state.lease_expires_at && state.lease_expires_at > now) throw new RevisionConflict("A canonical WordPress lease is active.", state.lease_expires_at) throw new RevisionConflict("A coordinator cutover fence is already active.", active?.expires_at) } return fence } -async function renewMutationFence(database: D1Database, token: string, ttlMs: number): Promise { - await ensureSchema(database) +async function renewMutationFence(database: D1Database, siteId: string, token: string, ttlMs: number): Promise { + await ensureSchema(database, siteId) const now = Date.now() const fence = { token, expiresAt: mutationFenceExpiresAt(ttlMs, now) } const result = await database.prepare(`UPDATE wp_codebox_fences SET expires_at = ? - WHERE site_id = ? AND token = ? AND expires_at > ?`).bind(fence.expiresAt, SITE_ID, token, now).run() + WHERE site_id = ? AND token = ? AND expires_at > ?`).bind(fence.expiresAt, siteId, token, now).run() if (result.meta.changes !== 1) throw new RevisionConflict("The coordinator cutover fence token is invalid or expired.") return fence } -async function releaseMutationFence(database: D1Database, token: string): Promise<{ released: true }> { - await ensureSchema(database) +async function releaseMutationFence(database: D1Database, siteId: string, token: string): Promise<{ released: true }> { + await ensureSchema(database, siteId) const result = await database.prepare(`DELETE FROM wp_codebox_fences - WHERE site_id = ? AND token = ? AND expires_at > ?`).bind(SITE_ID, token, Date.now()).run() + WHERE site_id = ? AND token = ? AND expires_at > ?`).bind(siteId, token, Date.now()).run() if (result.meta.changes !== 1) throw new RevisionConflict("The coordinator cutover fence token is invalid or expired.") return { released: true } } -async function adoptWordPressState(database: D1Database, pointer: MarkdownPointer, version: number): Promise<{ pointer: MarkdownPointer; version: number }> { +async function adoptWordPressState(database: D1Database, siteId: string, pointer: MarkdownPointer, version: number): Promise<{ pointer: MarkdownPointer; version: number }> { validatePointer(pointer) if (!Number.isSafeInteger(version) || version < 1) throw new RevisionConflict("A positive canonical version is required for D1 adoption.") - await ensureSchema(database) + await ensureSchema(database, siteId) const now = Date.now() await database.batch([ database.prepare(`UPDATE wp_codebox_state @@ -219,24 +218,24 @@ async function adoptWordPressState(database: D1Database, pointer: MarkdownPointe AND (NOT EXISTS (SELECT 1 FROM wp_codebox_commits WHERE site_id = ? AND version = ?) OR EXISTS (SELECT 1 FROM wp_codebox_commits WHERE site_id = ? AND version = ? AND revision = ? AND manifest_key = ? AND persisted_at = ?))`) - .bind(pointer.revision, pointer.manifestKey, pointer.persistedAt, version, SITE_ID, now, SITE_ID, now, + .bind(pointer.revision, pointer.manifestKey, pointer.persistedAt, version, siteId, now, siteId, now, version, pointer.revision, pointer.manifestKey, pointer.persistedAt, - SITE_ID, version, SITE_ID, version, pointer.revision, pointer.manifestKey, pointer.persistedAt), + siteId, version, siteId, version, pointer.revision, pointer.manifestKey, pointer.persistedAt), database.prepare(`INSERT OR IGNORE INTO wp_codebox_commits (site_id, version, revision, manifest_key, persisted_at) SELECT site_id, version, revision, manifest_key, persisted_at FROM wp_codebox_state WHERE site_id = ? AND version = ? AND revision = ? AND manifest_key = ? AND persisted_at = ? AND lease_token IS NULL AND NOT EXISTS (SELECT 1 FROM wp_codebox_fences WHERE site_id = ? AND expires_at > ?)`) - .bind(SITE_ID, version, pointer.revision, pointer.manifestKey, pointer.persistedAt, SITE_ID, now), + .bind(siteId, version, pointer.revision, pointer.manifestKey, pointer.persistedAt, siteId, now), ]) - const [state, committed] = await Promise.all([readRow(database), readCommittedPointer(database, version)]) + const [state, committed] = await Promise.all([readRow(database, siteId), readCommittedPointer(database, siteId, version)]) if (state.version !== version || !samePointer(pointerFromRow(state), pointer) || !samePointer(committed, pointer)) { throw new RevisionConflict("D1 coordinator adoption requires empty or exactly matching state without an active lease or fence.") } return { pointer, version } } -async function resetWordPressState(database: D1Database): Promise<{ reset: true }> { - await ensureSchema(database) +async function resetWordPressState(database: D1Database, siteId: string): Promise<{ reset: true }> { + await ensureSchema(database, siteId) const now = Date.now() const [reset] = await database.batch([ database.prepare(`UPDATE wp_codebox_state @@ -244,38 +243,38 @@ async function resetWordPressState(database: D1Database): Promise<{ reset: true lease_token = NULL, lease_base_revision = NULL, lease_version = NULL, lease_expires_at = NULL WHERE site_id = ? AND (lease_token IS NULL OR lease_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM wp_codebox_fences WHERE site_id = ? AND expires_at > ?)`) - .bind(SITE_ID, now, SITE_ID, now), + .bind(siteId, now, siteId, now), database.prepare(`DELETE FROM wp_codebox_commits WHERE site_id = ? AND EXISTS (SELECT 1 FROM wp_codebox_state WHERE site_id = ? AND (lease_token IS NULL OR lease_expires_at <= ?)) AND NOT EXISTS (SELECT 1 FROM wp_codebox_fences WHERE site_id = ? AND expires_at > ?)`) - .bind(SITE_ID, SITE_ID, now, SITE_ID, now), + .bind(siteId, siteId, now, siteId, now), ]) if (reset.meta.changes !== 1) { - const [state, fence] = await Promise.all([readRow(database), readFenceRow(database)]) + const [state, fence] = await Promise.all([readRow(database, siteId), readFenceRow(database, siteId)]) throw new RevisionConflict("Coordinator reset is blocked by an active canonical lease or cutover fence.", fence?.expires_at ?? state.lease_expires_at ?? undefined) } return { reset: true } } -async function finishLease(database: D1Database, lease: RevisionLease, action: "release" | "abort"): Promise { - await ensureSchema(database) +async function finishLease(database: D1Database, siteId: string, lease: RevisionLease, action: "release" | "abort"): Promise { + await ensureSchema(database, siteId) const result = await database.prepare(`UPDATE wp_codebox_state SET lease_token = NULL, lease_base_revision = NULL, lease_version = NULL, lease_expires_at = NULL WHERE site_id = ? AND lease_token = ? AND lease_expires_at > ?`) - .bind(SITE_ID, lease.token, Date.now()).run() + .bind(siteId, lease.token, Date.now()).run() if (result.meta.changes !== 1) throw new RevisionConflict(`The canonical WordPress lease cannot ${action} because it expired or changed.`) } -async function readRow(database: D1Database): Promise { +async function readRow(database: D1Database, siteId: string): Promise { const row = await database.prepare(`SELECT revision, manifest_key, persisted_at, version, lease_token, lease_base_revision, lease_version, lease_expires_at - FROM wp_codebox_state WHERE site_id = ?`).bind(SITE_ID).first() + FROM wp_codebox_state WHERE site_id = ?`).bind(siteId).first() if (!row) throw new Error("D1 WordPress state row is unavailable.") return row } -async function readFenceRow(database: D1Database): Promise { - return database.prepare(`SELECT token, expires_at FROM wp_codebox_fences WHERE site_id = ?`).bind(SITE_ID).first() +async function readFenceRow(database: D1Database, siteId: string): Promise { + return database.prepare(`SELECT token, expires_at FROM wp_codebox_fences WHERE site_id = ?`).bind(siteId).first() } function pointerFromRow(row: StateRow): MarkdownPointer | null { @@ -297,11 +296,11 @@ function samePointer(left: MarkdownPointer | null, right: MarkdownPointer): bool return !!left && left.revision === right.revision && left.manifestKey === right.manifestKey && left.persistedAt === right.persistedAt } -function ensureSchema(database: D1Database): Promise { +async function ensureSchema(database: D1Database, siteId: string): Promise { const key = database as object const existing = schemaReady.get(key) - if (existing) return existing - const pending = (async () => { + if (!existing) { + const pending = (async () => { await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_state ( site_id TEXT PRIMARY KEY, revision TEXT, @@ -313,11 +312,12 @@ function ensureSchema(database: D1Database): Promise { lease_version INTEGER, lease_expires_at INTEGER )`).run() - await database.prepare(`INSERT OR IGNORE INTO wp_codebox_state (site_id, version) VALUES (?, 0)`).bind(SITE_ID).run() await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_commits (site_id TEXT NOT NULL, version INTEGER NOT NULL, revision TEXT NOT NULL, manifest_key TEXT NOT NULL, persisted_at TEXT NOT NULL, PRIMARY KEY (site_id, version))`).run() await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_fences (site_id TEXT PRIMARY KEY, token TEXT NOT NULL, expires_at INTEGER NOT NULL)`).run() - })() - schemaReady.set(key, pending) - pending.catch(() => schemaReady.delete(key)) - return pending + })() + schemaReady.set(key, pending) + pending.catch(() => schemaReady.delete(key)) + } + await schemaReady.get(key) + await database.prepare(`INSERT OR IGNORE INTO wp_codebox_state (site_id, version) VALUES (?, 0)`).bind(siteId).run() } diff --git a/packages/runtime-cloudflare/src/published-reader.ts b/packages/runtime-cloudflare/src/published-reader.ts index 2b928ca93..b4e166e78 100644 --- a/packages/runtime-cloudflare/src/published-reader.ts +++ b/packages/runtime-cloudflare/src/published-reader.ts @@ -2,8 +2,8 @@ export const PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v3" as c const PREVIOUS_PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v2" const LEGACY_PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v1" export const PUBLISHED_PAGE_SCHEMA = "wp-codebox/wordpress-page/v2" as const -export const R2_PUBLISHED_CURRENT_KEY = "sites/default/publications/current.json" -export const R2_PUBLISHED_REVISION_PREFIX = "sites/default/publications/revisions" +export const R2_PUBLISHED_CURRENT_KEY = siteStorageKeys(DEFAULT_SITE_CONTEXT).publishedCurrent +export const R2_PUBLISHED_REVISION_PREFIX = siteStorageKeys(DEFAULT_SITE_CONTEXT).publishedRevisionPrefix export const MAX_PUBLISHED_ROUTES = 1_000 export const MAX_PUBLISHED_REVISION_BYTES = 512 * 1024 export const MAX_PUBLISHED_PAGE_BYTES = 8 * 1024 * 1024 @@ -29,19 +29,19 @@ export function canonicalPublicRoute(input: Request | URL | string): string { return `${url.pathname}${url.search}` } -export async function publishedPageObjectKey(canonicalRevision: string, route: string): Promise { +export async function publishedPageObjectKey(canonicalRevision: string, route: string, site: SiteContext = DEFAULT_SITE_CONTEXT): Promise { if (!isRevision(canonicalRevision) || !isCanonicalRoute(route)) throw new Error("Published page identity is invalid.") const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(route)) const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") - return `sites/default/pages/${canonicalRevision}/${hash}.json` + return `${siteStorageKeys(site).publishedPagePrefix}/${canonicalRevision}/${hash}.json` } -export function publishedRevisionObjectKey(revision: string): string { +export function publishedRevisionObjectKey(revision: string, site: SiteContext = DEFAULT_SITE_CONTEXT): string { if (!isRevision(revision)) throw new Error("Published revision identity is invalid.") - return `${R2_PUBLISHED_REVISION_PREFIX}/${revision}.json` + return `${siteStorageKeys(site).publishedRevisionPrefix}/${revision}.json` } -export function validatePublishedRevision(value: unknown): PublishedRevision { +export function validatePublishedRevision(value: unknown, site: SiteContext = DEFAULT_SITE_CONTEXT): PublishedRevision { if (!value || typeof value !== "object") throw new Error("Published revision is invalid.") const revision = value as Partial const legacy = (value as { schema?: unknown }).schema === LEGACY_PUBLISHED_REVISION_SCHEMA @@ -56,8 +56,8 @@ export function validatePublishedRevision(value: unknown): PublishedRevision { const routeRevision = legacy ? revision.canonicalRevision : route.canonicalRevision if (!route || typeof route !== "object" || !isCanonicalRoute(route.route) || route.route <= previous || !isRevision(routeRevision) - || route.objectKey !== `sites/default/pages/${routeRevision}/${route.objectKey.split("/").at(-1)}` - || !/^sites\/default\/pages\/[a-f0-9-]{36}\/[a-f0-9]{64}\.json$/.test(route.objectKey)) throw new Error("Published revision route is invalid.") + || route.objectKey !== `${siteStorageKeys(site).publishedPagePrefix}/${routeRevision}/${route.objectKey.split("/").at(-1)}` + || !new RegExp(`^${escapeRegExp(siteStorageKeys(site).publishedPagePrefix)}/[a-f0-9-]{36}/[a-f0-9]{64}\\.json$`).test(route.objectKey)) throw new Error("Published revision route is invalid.") previous = route.route normalizedRoutes.push({ route: route.route, objectKey: route.objectKey, canonicalRevision: routeRevision }) } @@ -86,3 +86,8 @@ function isCanonicalRoute(route: string): boolean { function isRevision(revision: unknown): revision is string { return typeof revision === "string" && /^[a-f0-9-]{36}$/.test(revision) } + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} +import { DEFAULT_SITE_CONTEXT, siteStorageKeys, type SiteContext } from "./site-context.js" diff --git a/packages/runtime-cloudflare/src/site-context.ts b/packages/runtime-cloudflare/src/site-context.ts new file mode 100644 index 000000000..98561def0 --- /dev/null +++ b/packages/runtime-cloudflare/src/site-context.ts @@ -0,0 +1,108 @@ +export interface SiteContext { + id: string + hostname: string + origin: string +} + +export interface SiteStorageKeys { + root: string + markdownCurrent: string + markdownRevisionPrefix: string + markdownObjectPrefix: string + publishedCurrent: string + publishedRevisionPrefix: string + publishedPagePrefix: string + publicationJobPrefix: string + publicationProgressPrefix: string + publicationClaimPrefix: string + publicationReceiptPrefix: string + uploadObjectPrefix: string + wpContentObjectPrefix: string + staticArtifactPrefix: string +} + +export const DEFAULT_SITE_CONTEXT: SiteContext = { + id: "default", + hostname: "wp-codebox-cloudflare-runtime.chubes.workers.dev", + origin: "https://wp-codebox-cloudflare-runtime.chubes.workers.dev", +} + +export function parseSiteContexts(value: string | undefined): SiteContext[] { + if (value === undefined || value.trim() === "") return [DEFAULT_SITE_CONTEXT] + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + throw new Error("WORDPRESS_SITE_CONTEXTS must be valid JSON.") + } + if (!Array.isArray(parsed) || parsed.length === 0) throw new Error("WORDPRESS_SITE_CONTEXTS must be a non-empty array.") + + const ids = new Set() + const hostnames = new Set() + return parsed.map((value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Site context is invalid.") + const context = value as Partial + if (!isSiteId(context.id) || typeof context.hostname !== "string" || typeof context.origin !== "string") throw new Error("Site context is invalid.") + const normalized = normalizeSiteContext(context.id, context.hostname, context.origin) + if (ids.has(normalized.id) || hostnames.has(normalized.hostname)) throw new Error("Site contexts must have unique ids and hostnames.") + ids.add(normalized.id) + hostnames.add(normalized.hostname) + return normalized + }) +} + +export function resolveSiteContext(hostname: string, contexts: readonly SiteContext[]): SiteContext { + const context = contexts.find((candidate) => candidate.hostname === hostname) + if (!context) throw new Error("Unknown site hostname.") + return context +} + +export function resolveSiteContextFromRequest(request: Request | URL, contexts: readonly SiteContext[]): SiteContext { + return resolveSiteContext(normalizeHostname(request instanceof Request ? new URL(request.url).hostname : request.hostname), contexts) +} + +export function siteStorageKeys(site: SiteContext): SiteStorageKeys { + const prefix = `sites/${site.id}` + return { + root: prefix, + markdownCurrent: `${prefix}/markdown/current.json`, + markdownRevisionPrefix: `${prefix}/markdown/revisions`, + markdownObjectPrefix: `${prefix}/markdown/objects`, + publishedCurrent: `${prefix}/publications/current.json`, + publishedRevisionPrefix: `${prefix}/publications/revisions`, + publishedPagePrefix: `${prefix}/pages`, + publicationJobPrefix: `${prefix}/publications/jobs`, + publicationProgressPrefix: `${prefix}/publications/job-progress`, + publicationClaimPrefix: `${prefix}/publications/job-claims`, + publicationReceiptPrefix: `${prefix}/publications/job-receipts`, + uploadObjectPrefix: `${prefix}/uploads/objects`, + wpContentObjectPrefix: `${prefix}/wp-content/objects`, + staticArtifactPrefix: `${prefix}/import-artifacts`, + } +} + +function normalizeSiteContext(id: string, hostname: string, origin: string): SiteContext { + if (hostname !== hostname.toLowerCase() || hostname.includes("/") || hostname.includes("\\") || hostname.includes("@") || (hostname.includes(":") && hostname !== "::1")) throw new Error("Site hostname is invalid.") + let url: URL + try { + url = new URL(origin) + } catch { + throw new Error("Site origin is invalid.") + } + const canonicalHostname = normalizeHostname(url.hostname) + if (hostname !== canonicalHostname || url.origin !== origin || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error("Site origin is not canonical.") + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalHostname(hostname))) throw new Error("Site origin must use HTTPS except for local hosts.") + return { id, hostname, origin } +} + +function isSiteId(value: unknown): value is string { + return typeof value === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && value.length <= 63 +} + +function normalizeHostname(hostname: string): string { + return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname +} + +function isLocalHostname(hostname: string): boolean { + return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "::1" +} diff --git a/packages/runtime-cloudflare/src/state-coordinator.ts b/packages/runtime-cloudflare/src/state-coordinator.ts index b8087c1d4..b8f0198db 100644 --- a/packages/runtime-cloudflare/src/state-coordinator.ts +++ b/packages/runtime-cloudflare/src/state-coordinator.ts @@ -9,6 +9,7 @@ interface StoredLease { interface CoordinatorRecord { initialized: boolean + siteId: string pointer: MarkdownPointer | null version: number lease?: StoredLease @@ -20,12 +21,11 @@ interface CoordinatorEnv { COORDINATOR_LEASE_MS?: number } -const POINTER_KEY = "sites/default/markdown/current.json" const STORAGE_KEY = "wordpress-state-coordinator" const LEASE_MS = 90_000 export class DurableObjectRevisionCoordinator implements RevisionCoordinator { - constructor(private readonly stub: DurableObjectStub) {} + constructor(private readonly stub: DurableObjectStub, private readonly siteId: string) {} state(): Promise { return this.call("state") @@ -82,6 +82,7 @@ export class DurableObjectRevisionCoordinator implements RevisionCoordinator { private async call(action: string, body?: Record): Promise { const url = new URL("https://wp-codebox-coordinator.invalid/") url.searchParams.set("__wp_codebox_coordinator", action) + url.searchParams.set("siteId", this.siteId) const response = await this.stub.fetch(new Request(url, body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : undefined)) if (!response.ok) { let payload: { message?: string; retryAt?: number } = {} @@ -119,33 +120,35 @@ export class WordPressStateCoordinator implements DurableObject { } private async handle(request: Request): Promise { - const action = new URL(request.url).searchParams.get("__wp_codebox_coordinator") - if (request.method === "GET" && action === "state") return Response.json(await this.current()) - if (request.method === "GET" && action === "fence-status") return Response.json(await this.fenceStatus()) + const url = new URL(request.url) + const action = url.searchParams.get("__wp_codebox_coordinator") + const siteId = url.searchParams.get("siteId") ?? "default" + if (request.method === "GET" && action === "state") return Response.json(await this.current(siteId)) + if (request.method === "GET" && action === "fence-status") return Response.json(await this.fenceStatus(siteId)) if (request.method !== "POST" || !action) return new Response("Coordinator requests require an internal action.", { status: 404 }) const body = await request.json>() - if (action === "begin") return Response.json(await this.begin(body)) - if (action === "renew") return Response.json(await this.renew(body)) - if (action === "release") return Response.json(await this.release(body)) - if (action === "abort") return Response.json(await this.abort(body)) - if (action === "commit") return Response.json(await this.commit(body)) - if (action === "committed") return Response.json(await this.committed(body)) - if (action === "fence-status") return Response.json(await this.fenceStatus()) - if (action === "fence-acquire") return Response.json(await this.acquireFence(body)) - if (action === "fence-renew") return Response.json(await this.renewFence(body)) - if (action === "fence-release") return Response.json(await this.releaseFence(body)) - if (action === "adopt") return Response.json(await this.adopt(body)) - if (action === "reset") return Response.json(await this.reset()) + if (action === "begin") return Response.json(await this.begin(siteId, body)) + if (action === "renew") return Response.json(await this.renew(siteId, body)) + if (action === "release") return Response.json(await this.release(siteId, body)) + if (action === "abort") return Response.json(await this.abort(siteId, body)) + if (action === "commit") return Response.json(await this.commit(siteId, body)) + if (action === "committed") return Response.json(await this.committed(siteId, body)) + if (action === "fence-status") return Response.json(await this.fenceStatus(siteId)) + if (action === "fence-acquire") return Response.json(await this.acquireFence(siteId, body)) + if (action === "fence-renew") return Response.json(await this.renewFence(siteId, body)) + if (action === "fence-release") return Response.json(await this.releaseFence(siteId, body)) + if (action === "adopt") return Response.json(await this.adopt(siteId, body)) + if (action === "reset") return Response.json(await this.reset(siteId)) return new Response("Unknown coordinator action.", { status: 404 }) } - private async current(): Promise { - const record = await this.record() + private async current(siteId: string): Promise { + const record = await this.record(siteId) return { schema: "wp-codebox/cloudflare-wordpress-state/v2", store: "durable-object", pointer: record.pointer, version: record.version } } - private async begin(body: Record): Promise { - const record = await this.record() + private async begin(siteId: string, body: Record): Promise { + const record = await this.record(siteId) const now = Date.now() const fence = await this.activeFence(record) if (fence) throw new RevisionConflict("Canonical WordPress mutations are fenced for coordinator cutover.", fence.expiresAt) @@ -157,32 +160,32 @@ export class WordPressStateCoordinator implements DurableObject { return { token: lease.token, pointer: lease.base, version: lease.version, expiresAt: lease.expiresAt } } - private async release(body: Record): Promise<{ released: true }> { - const record = await this.record() + private async release(siteId: string, body: Record): Promise<{ released: true }> { + const record = await this.record(siteId) this.requireLease(record, body) delete record.lease await this.save(record) return { released: true } } - private async renew(body: Record): Promise { - const record = await this.record() + private async renew(siteId: string, body: Record): Promise { + const record = await this.record(siteId) const lease = this.requireLease(record, body) lease.expiresAt = revisionLeaseExpiresAt((body.ttlMs as number | undefined) ?? this.env.COORDINATOR_LEASE_MS ?? LEASE_MS) await this.save(record) return { token: lease.token, pointer: lease.base, version: lease.version, expiresAt: lease.expiresAt } } - private async abort(body: Record): Promise<{ aborted: true }> { - const record = await this.record() + private async abort(siteId: string, body: Record): Promise<{ aborted: true }> { + const record = await this.record(siteId) this.requireLease(record, body) delete record.lease await this.save(record) return { aborted: true } } - private async commit(body: Record): Promise<{ pointer: MarkdownPointer; version: number }> { - const record = await this.record() + private async commit(siteId: string, body: Record): Promise<{ pointer: MarkdownPointer; version: number }> { + const record = await this.record(siteId) const lease = this.requireLease(record, body) const pointer = body.pointer as MarkdownPointer if (!pointer || typeof pointer.revision !== "string" || typeof pointer.manifestKey !== "string" || typeof pointer.persistedAt !== "string") { @@ -191,7 +194,7 @@ export class WordPressStateCoordinator implements DurableObject { if (body.baseRevision !== (lease.base?.revision ?? null) || body.version !== lease.version || record.version !== lease.version || record.pointer?.revision !== lease.base?.revision) { throw new RevisionConflict("The canonical pointer changed before promotion.") } - await this.env.WORDPRESS_STATE_BUCKET.put(POINTER_KEY, JSON.stringify(pointer), { httpMetadata: { contentType: "application/json" } }) + await this.env.WORDPRESS_STATE_BUCKET.put(pointerKey(siteId), JSON.stringify(pointer), { httpMetadata: { contentType: "application/json" } }) record.pointer = pointer record.version++ delete record.lease @@ -202,18 +205,19 @@ export class WordPressStateCoordinator implements DurableObject { return { pointer, version: record.version } } - private async committed(body: Record): Promise { + private async committed(siteId: string, body: Record): Promise { + await this.record(siteId) if (!Number.isSafeInteger(body.version) || (body.version as number) < 1) throw new RevisionConflict("A canonical commit version is required.") return await this.state.storage.get(`wordpress-state-commit/${body.version}`) ?? null } - private async fenceStatus(): Promise { - const fence = await this.activeFence(await this.record()) + private async fenceStatus(siteId: string): Promise { + const fence = await this.activeFence(await this.record(siteId)) return fence ? { active: true, expiresAt: fence.expiresAt } : { active: false } } - private async acquireFence(body: Record): Promise { - const record = await this.record() + private async acquireFence(siteId: string, body: Record): Promise { + const record = await this.record(siteId) const now = Date.now() const expiresAt = mutationFenceExpiresAt(body.ttlMs as number, now) if (record.lease && record.lease.expiresAt > now) throw new RevisionConflict("A canonical WordPress lease is active.", record.lease.expiresAt) @@ -226,8 +230,8 @@ export class WordPressStateCoordinator implements DurableObject { return fence } - private async renewFence(body: Record): Promise { - const record = await this.record() + private async renewFence(siteId: string, body: Record): Promise { + const record = await this.record(siteId) const active = await this.activeFence(record) if (!active || typeof body.token !== "string" || body.token !== active.token) throw new RevisionConflict("The coordinator cutover fence token is invalid or expired.") const fence = { token: active.token, expiresAt: mutationFenceExpiresAt(body.ttlMs as number) } @@ -236,8 +240,8 @@ export class WordPressStateCoordinator implements DurableObject { return fence } - private async releaseFence(body: Record): Promise<{ released: true }> { - const record = await this.record() + private async releaseFence(siteId: string, body: Record): Promise<{ released: true }> { + const record = await this.record(siteId) const active = await this.activeFence(record) if (!active || typeof body.token !== "string" || body.token !== active.token) throw new RevisionConflict("The coordinator cutover fence token is invalid or expired.") delete record.fence @@ -245,8 +249,8 @@ export class WordPressStateCoordinator implements DurableObject { return { released: true } } - private async adopt(body: Record): Promise<{ pointer: MarkdownPointer; version: number }> { - const record = await this.record() + private async adopt(siteId: string, body: Record): Promise<{ pointer: MarkdownPointer; version: number }> { + const record = await this.record(siteId) const fence = await this.activeFence(record) if (fence) throw new RevisionConflict("Coordinator adoption is blocked by an active cutover fence.", fence.expiresAt) const pointer = body.pointer as MarkdownPointer @@ -274,22 +278,25 @@ export class WordPressStateCoordinator implements DurableObject { return lease } - private async reset(): Promise<{ reset: true }> { - const record = await this.record() + private async reset(siteId: string): Promise<{ reset: true }> { + const record = await this.record(siteId) const now = Date.now() const fence = await this.activeFence(record, now) if (fence) throw new RevisionConflict("Coordinator reset is blocked by an active cutover fence.", fence.expiresAt) if (record.lease && record.lease.expiresAt > now) throw new RevisionConflict("Coordinator reset is blocked by an active canonical lease.", record.lease.expiresAt) - await this.env.WORDPRESS_STATE_BUCKET.delete(POINTER_KEY) + await this.env.WORDPRESS_STATE_BUCKET.delete(pointerKey(siteId)) await this.state.storage.delete(STORAGE_KEY) return { reset: true } } - private async record(): Promise { + private async record(siteId: string): Promise { const stored = await this.state.storage.get(STORAGE_KEY) - if (stored?.initialized) return stored - const object = await this.env.WORDPRESS_STATE_BUCKET.get(POINTER_KEY) - const record: CoordinatorRecord = { initialized: true, pointer: object ? await object.json() : null, version: 0 } + if (stored?.initialized) { + if ((stored.siteId ?? "default") !== siteId) throw new RevisionConflict("The Durable Object belongs to a different site.") + return stored + } + const object = await this.env.WORDPRESS_STATE_BUCKET.get(pointerKey(siteId)) + const record: CoordinatorRecord = { initialized: true, siteId, pointer: object ? await object.json() : null, version: 0 } await this.save(record) return record } @@ -307,6 +314,10 @@ export class WordPressStateCoordinator implements DurableObject { } } +function pointerKey(siteId: string): string { + return `sites/${siteId}/markdown/current.json` +} + function samePointer(left: MarkdownPointer | null, right: MarkdownPointer): boolean { return !!left && left.revision === right.revision && left.manifestKey === right.manifestKey && left.persistedAt === right.persistedAt } diff --git a/packages/runtime-cloudflare/src/static-artifact-import.ts b/packages/runtime-cloudflare/src/static-artifact-import.ts index 0f04c8784..b0f2ee739 100644 --- a/packages/runtime-cloudflare/src/static-artifact-import.ts +++ b/packages/runtime-cloudflare/src/static-artifact-import.ts @@ -1,7 +1,9 @@ export const STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA = "wp-codebox/cloudflare-static-artifact-import-request/v1" export const STATIC_ARTIFACT_IMPORT_RESULT_SCHEMA = "wp-codebox/cloudflare-static-artifact-import-result/v1" export const STATIC_ARTIFACT_SCHEMA = "blocks-engine/php-transformer/site-artifact/v1" -export const R2_STATIC_ARTIFACT_PREFIX = "sites/default/import-artifacts" +import { DEFAULT_SITE_CONTEXT, siteStorageKeys, type SiteContext } from "./site-context.js" + +export const R2_STATIC_ARTIFACT_PREFIX = siteStorageKeys(DEFAULT_SITE_CONTEXT).staticArtifactPrefix export const MAX_STATIC_ARTIFACT_REQUEST_BYTES = 16 * 1024 export const MAX_STATIC_ARTIFACT_BYTES = 4 * 1024 * 1024 export const MAX_STATIC_ARTIFACT_FILES = 500 @@ -16,7 +18,7 @@ export interface StaticArtifactImport { options: { slug: string; name: string; siteTitle: string } } -export async function readStaticArtifactImport(request: Request, bucket: R2Bucket): Promise { +export async function readStaticArtifactImport(request: Request, bucket: R2Bucket, site: SiteContext = DEFAULT_SITE_CONTEXT): Promise { const declaredLength = request.headers.get("content-length") if (declaredLength && (!/^\d+$/.test(declaredLength) || Number(declaredLength) > MAX_STATIC_ARTIFACT_REQUEST_BYTES)) throw new StaticArtifactImportError("Static artifact import request exceeds its byte budget.", 413) const requestBytes = await readBoundedRequestBytes(request) @@ -35,7 +37,7 @@ export async function readStaticArtifactImport(request: Request, bucket: R2Bucke if (!reference || typeof reference !== "object" || Array.isArray(reference)) throw new StaticArtifactImportError("Static artifact reference is required.", 400) const artifactReference = reference as Record if (typeof artifactReference.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(artifactReference.sha256) - || artifactReference.r2Key !== `${R2_STATIC_ARTIFACT_PREFIX}/${artifactReference.sha256}.json` + || artifactReference.r2Key !== `${siteStorageKeys(site).staticArtifactPrefix}/${artifactReference.sha256}.json` || !Number.isSafeInteger(artifactReference.size) || (artifactReference.size as number) < 1 || (artifactReference.size as number) > MAX_STATIC_ARTIFACT_BYTES) { throw new StaticArtifactImportError("Static artifact reference is invalid or outside its byte budget.", 400) } diff --git a/packages/runtime-cloudflare/src/upload-persistence.ts b/packages/runtime-cloudflare/src/upload-persistence.ts index 064d71b0e..d182a866a 100644 --- a/packages/runtime-cloudflare/src/upload-persistence.ts +++ b/packages/runtime-cloudflare/src/upload-persistence.ts @@ -1,4 +1,6 @@ -export const R2_UPLOAD_OBJECT_PREFIX = "sites/default/uploads/objects" +import { DEFAULT_SITE_CONTEXT, siteStorageKeys, type SiteContext } from "./site-context.js" + +export const R2_UPLOAD_OBJECT_PREFIX = siteStorageKeys(DEFAULT_SITE_CONTEXT).uploadObjectPrefix export const MAX_UPLOAD_FILES = 5000 export const MAX_UPLOAD_FILE_BYTES = 16 * 1024 * 1024 export const MAX_UPLOAD_TOTAL_BYTES = 64 * 1024 * 1024 @@ -28,10 +30,10 @@ export function validateUploadMetadata(value: unknown): asserts value is UploadF if (total > MAX_UPLOAD_TOTAL_BYTES) throw new Error("Canonical upload files exceed their byte budget.") } -export function validateUploadManifestFiles(value: unknown): asserts value is UploadManifestFile[] { +export function validateUploadManifestFiles(value: unknown, site: SiteContext = DEFAULT_SITE_CONTEXT): asserts value is UploadManifestFile[] { validateUploadMetadata(value) for (const file of value as UploadManifestFile[]) { - if (!/^[a-f0-9]{64}$/.test(file.sha256) || file.objectKey !== `${R2_UPLOAD_OBJECT_PREFIX}/${file.sha256}`) { + if (!/^[a-f0-9]{64}$/.test(file.sha256) || file.objectKey !== `${siteStorageKeys(site).uploadObjectPrefix}/${file.sha256}`) { throw new Error("Canonical upload manifest contains an invalid file.") } } diff --git a/packages/runtime-cloudflare/src/wordpress-auth.ts b/packages/runtime-cloudflare/src/wordpress-auth.ts index 6a08ae341..f5b7f23e2 100644 --- a/packages/runtime-cloudflare/src/wordpress-auth.ts +++ b/packages/runtime-cloudflare/src/wordpress-auth.ts @@ -2,6 +2,8 @@ export const WORDPRESS_AUTH_CONSTANTS = ["AUTH_KEY", "SECURE_AUTH_KEY", "LOGGED_ export type WordPressAuthConstant = (typeof WORDPRESS_AUTH_CONSTANTS)[number] +export type SiteCredentialPurpose = "admin-password" | "operator-token" + export async function deriveWordPressAuthConstants(rootSecret: string, site: string): Promise> { if (!rootSecret) throw new Error("WORDPRESS_AUTH_SECRET is required for canonical WordPress runtime boots.") const encoder = new TextEncoder() @@ -12,3 +14,12 @@ export async function deriveWordPressAuthConstants(rootSecret: string, site: str })) return Object.fromEntries(entries) as Record } + +export async function deriveSiteCredential(rootCredential: string, site: string, purpose: SiteCredentialPurpose): Promise { + if (!rootCredential) throw new Error(`WORDPRESS_${purpose === "admin-password" ? "ADMIN_PASSWORD" : "OPERATOR_TOKEN"} is required.`) + if (site === "default") return rootCredential + const encoder = new TextEncoder() + const key = await crypto.subtle.importKey("raw", encoder.encode(rootCredential), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]) + const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(`wp-codebox/site-credential/v1\0${site}\0${purpose}`)) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") +} diff --git a/packages/runtime-cloudflare/src/worker-d1.ts b/packages/runtime-cloudflare/src/worker-d1.ts index 1ab778a0a..7387e42fd 100644 --- a/packages/runtime-cloudflare/src/worker-d1.ts +++ b/packages/runtime-cloudflare/src/worker-d1.ts @@ -1,10 +1,11 @@ import { D1RevisionCoordinator } from "./d1-revision-coordinator.js" +import type { SiteContext } from "./site-context.js" import { createCloudflareRuntime, type RuntimeEnv } from "./worker.js" interface D1RuntimeEnv extends RuntimeEnv { WORDPRESS_STATE_DATABASE: D1Database } -export default createCloudflareRuntime((env) => ( - new D1RevisionCoordinator(env.WORDPRESS_STATE_DATABASE) +export default createCloudflareRuntime((env, site: SiteContext) => ( + new D1RevisionCoordinator(env.WORDPRESS_STATE_DATABASE, site.id) )) diff --git a/packages/runtime-cloudflare/src/worker-do.ts b/packages/runtime-cloudflare/src/worker-do.ts index bdc5c0bda..ffb607e12 100644 --- a/packages/runtime-cloudflare/src/worker-do.ts +++ b/packages/runtime-cloudflare/src/worker-do.ts @@ -1,4 +1,5 @@ import { DurableObjectRevisionCoordinator, WordPressStateCoordinator } from "./state-coordinator.js" +import type { SiteContext } from "./site-context.js" import { createCloudflareRuntime, type RuntimeEnv } from "./worker.js" interface DurableObjectRuntimeEnv extends RuntimeEnv { @@ -7,6 +8,6 @@ interface DurableObjectRuntimeEnv extends RuntimeEnv { export { WordPressStateCoordinator } -export default createCloudflareRuntime((env) => ( - new DurableObjectRevisionCoordinator(env.WORDPRESS_STATE.getByName("default")) +export default createCloudflareRuntime((env, site: SiteContext) => ( + new DurableObjectRevisionCoordinator(env.WORDPRESS_STATE.getByName(site.id), site.id) )) diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index c967cb53f..e27302f57 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -8,18 +8,19 @@ import phpWasmModule from "../../../node_modules/@php-wasm/web-8-5/asyncify/8_5_ import { CLOUDFLARE_RUNTIME_HEALTH_MARKER, CLOUDFLARE_RUNTIME_HEALTH_SCHEMA, cloudflareRuntimeHealthResponse } from "./health-envelope.js" import { leaseRetryDelayMs } from "./lease-retry.js" import { logMutationPhase, MutationRetainedBytes } from "./mutation-memory.js" -import { canonicalPublicRoute, MAX_PUBLISHED_PAGE_BYTES, MAX_PUBLISHED_REVISION_BYTES, MAX_PUBLISHED_ROUTES, normalizePublishedRoutes, PUBLISHED_PAGE_SCHEMA, PUBLISHED_REVISION_SCHEMA, publishedPageObjectKey, publishedRevisionObjectKey, R2_PUBLISHED_CURRENT_KEY, validatePublishedRevision, type PublishedRevision } from "./published-reader.js" +import { canonicalPublicRoute, MAX_PUBLISHED_PAGE_BYTES, MAX_PUBLISHED_REVISION_BYTES, MAX_PUBLISHED_ROUTES, normalizePublishedRoutes, PUBLISHED_PAGE_SCHEMA, PUBLISHED_REVISION_SCHEMA, publishedPageObjectKey, publishedRevisionObjectKey, validatePublishedRevision, type PublishedRevision } from "./published-reader.js" import { RevisionConflict, type MarkdownPointer, type MutationFence, type RevisionCoordinator, type RevisionLease } from "./revision-coordinator.js" import { routeWorkerRequest } from "./request-routing.js" import { readStaticArtifactImport, STATIC_ARTIFACT_IMPORT_RESULT_SCHEMA, StaticArtifactImportError, type StaticArtifactImport } from "./static-artifact-import.js" import { toFetchResponse, toPHPRequest } from "./request-translation.js" -import { R2_UPLOAD_OBJECT_PREFIX, validateUploadManifestFiles, validateUploadMetadata } from "./upload-persistence.js" -import { deriveWordPressAuthConstants, type WordPressAuthConstant } from "./wordpress-auth.js" +import { DEFAULT_SITE_CONTEXT, parseSiteContexts, resolveSiteContextFromRequest, siteStorageKeys, type SiteContext } from "./site-context.js" +import { validateUploadManifestFiles, validateUploadMetadata } from "./upload-persistence.js" +import { deriveSiteCredential, deriveWordPressAuthConstants, type WordPressAuthConstant } from "./wordpress-auth.js" import { isWordPressRuntimeFile, wordpressStaticArchivePath, wordpressStaticContentType } from "./wordpress-runtime-corpus.js" import { materializeWordPressRuntimeArtifact, type WordPressRuntimeArtifactManifest } from "./wordpress-runtime-artifact.js" import { validateWordPressStaticArtifactManifest, type WordPressStaticArtifactManifest } from "./wordpress-static-artifact.js" import { readRuntimeArchiveArtifact, type RuntimeArchiveArtifactManifest } from "./runtime-archive-artifact.js" -import { isCanonicalWpContentPath, MAX_WP_CONTENT_FILES, MAX_WP_CONTENT_FILE_BYTES, MAX_WP_CONTENT_TOTAL_BYTES, R2_WP_CONTENT_OBJECT_PREFIX, validateWpContentDeletedPaths, validateWpContentManifestFiles, validateWpContentMetadata } from "./wp-content-persistence.js" +import { isCanonicalWpContentPath, MAX_WP_CONTENT_FILES, MAX_WP_CONTENT_FILE_BYTES, MAX_WP_CONTENT_TOTAL_BYTES, validateWpContentDeletedPaths, validateWpContentManifestFiles, validateWpContentMetadata } from "./wp-content-persistence.js" import markdownDatabaseIntegrationRuntime from "../assets/markdown-database-integration-runtime.zip" import canonicalMarkdownSeed from "../assets/markdown-database-integration-canonical-seed.zip" import canonicalMarkdownSeedManifest from "../assets/markdown-database-integration-canonical-seed.json" with { type: "json" } @@ -45,7 +46,7 @@ const wordpressWpContentRuntimeBaselinePaths = new Set((wordpressRuntimeArtifact .map((file) => file.path.slice("wordpress/wp-content/".length)) .filter(isCanonicalWpContentPath)) const MARKDOWN_DATABASE_INTEGRATION_REVISION = "bf6d434d1673fdd86d777501f7eaec292d32ad1f" -const SITE_URL = "https://wp-codebox-runtime.invalid" +const PROBE_SITE_URL = "https://wp-codebox-runtime.invalid" const DATABASE_PATH = "/wordpress/wp-content/database/.ht.sqlite" const MARKDOWN_ROOT = "/wordpress/wp-content/markdown" const STATIC_SITE_IMPORTER_ROOT = "/wordpress/wp-content/plugins/static-site-importer" @@ -57,18 +58,12 @@ const MARKDOWN_INDEX_PATH = "/tmp/markdown-index.sqlite" const MARKDOWN_RESOLVED_INDEX_PATH = "/tmp/markdown-index-8133b4cf3c66.sqlite" const MARKDOWN_CHANGES_PATH = "/tmp/wp-codebox-canonical-changes.json" const PUBLICATION_CHANGES_PATH = "/tmp/wp-codebox-publication-changes.json" -const R2_MARKDOWN_REVISION_PREFIX = "sites/default/markdown/revisions" -const R2_MARKDOWN_OBJECT_PREFIX = "sites/default/markdown/objects" const WORDPRESS_PAGE_CACHE_SCHEMA = "v3" const PUBLIC_WP_CONTENT_EXTENSION = /\.(?:css|js|mjs|json|txt|xml|woff2?|ttf|otf|eot|svg|png|jpe?g|gif|webp|avif|ico)$/i const MAX_CRON_EVENTS_PER_INVOCATION = 5 const MAX_CRON_INVOCATION_MS = 25_000 const PUBLICATION_JOB_SCHEMA = "wp-codebox/publication-job/v1" const PUBLICATION_PROGRESS_SCHEMA = "wp-codebox/publication-progress/v1" -const R2_PUBLICATION_JOB_PREFIX = "sites/default/publications/jobs" -const R2_PUBLICATION_PROGRESS_PREFIX = "sites/default/publications/job-progress" -const R2_PUBLICATION_CLAIM_PREFIX = "sites/default/publications/job-claims" -const R2_PUBLICATION_RECEIPT_PREFIX = "sites/default/publications/job-receipts" const PUBLICATION_CLAIM_MS = 90_000 const SERIALIZED_MARKDOWN_MUTATION_CODE = `flush_canonical_writes(); echo wp_json_encode(array_merge(array('status' => 'imported'), $record), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);` export interface RuntimeEnv { WORDPRESS_STATE_BUCKET: R2Bucket + WORDPRESS_SITE_CONTEXTS?: string WORDPRESS_ADMIN_PASSWORD?: string WORDPRESS_AUTH_SECRET?: string WORDPRESS_OPERATOR_TOKEN?: string } -export function createCloudflareRuntime(resolveCoordinator: (env: Env) => RevisionCoordinator) { +export function createCloudflareRuntime(resolveCoordinator: (env: Env, site: SiteContext) => RevisionCoordinator) { return { async fetch(request: Request, env: Env): Promise { + let site: SiteContext + try { + site = resolveSiteContextFromRequest(request, parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS)) + } catch (error) { + if (error instanceof Error && error.message === "Unknown site hostname.") return new Response(error.message, { status: 421 }) + throw error + } if (new URL(request.url).pathname === "/wp-cron.php") return new Response("WordPress cron is managed by the Cloudflare scheduled handler.", { status: 404 }) - const publishedResponse = await servePublishedWordPressPage(request, env.WORDPRESS_STATE_BUCKET) + const publishedResponse = await servePublishedWordPressPage(request, env.WORDPRESS_STATE_BUCKET, site) if (publishedResponse) return publishedResponse - const coordinator = resolveCoordinator(env) - const wpContentResponse = await serveWordPressWpContent(request, env.WORDPRESS_STATE_BUCKET, coordinator) + const coordinator = resolveCoordinator(env, site) + const wpContentResponse = await serveWordPressWpContent(request, env.WORDPRESS_STATE_BUCKET, coordinator, site) if (wpContentResponse) return wpContentResponse const staticResponse = await serveWordPressStaticAsset(request, env.WORDPRESS_STATE_BUCKET) if (staticResponse) return staticResponse const route = routeWorkerRequest(request) - const uploadResponse = await serveWordPressUpload(request, env.WORDPRESS_STATE_BUCKET, coordinator) + const uploadResponse = await serveWordPressUpload(request, env.WORDPRESS_STATE_BUCKET, coordinator, site) if (uploadResponse) return uploadResponse - if (route.kind === "operator-reset") return resetCanonicalWordPress(request, env, coordinator) - if (route.kind === "operator-restore") return restoreCanonicalWordPress(request, env, coordinator) - if (route.kind === "operator-adopt") return adoptCanonicalWordPress(request, env, coordinator) - if (route.kind === "operator-fence") return operateCanonicalMutationFence(request, env, coordinator, route.action) - if (route.kind === "operator-static-artifact-import") return importCanonicalStaticArtifact(request, env, coordinator) - if (route.kind === "operator-publish") return publishCanonicalWordPressPages(request, env, coordinator) + if (route.kind === "operator-reset") return resetCanonicalWordPress(request, env, coordinator, site) + if (route.kind === "operator-restore") return restoreCanonicalWordPress(request, env, coordinator, site) + if (route.kind === "operator-adopt") return adoptCanonicalWordPress(request, env, coordinator, site) + if (route.kind === "operator-fence") return operateCanonicalMutationFence(request, env, coordinator, site, route.action) + if (route.kind === "operator-static-artifact-import") return importCanonicalStaticArtifact(request, env, coordinator, site) + if (route.kind === "operator-publish") return publishCanonicalWordPressPages(request, env, coordinator, site) if (route.kind === "probe") { return runBootProbe(route.phase, env.WORDPRESS_STATE_BUCKET) } @@ -242,21 +245,23 @@ export function createCloudflareRuntime(resolveCoordinat return Response.json(await coordinator.state()) } try { - return await runCoordinatedWordPressRequest(request, env, coordinator, route.kind) + return await runCoordinatedWordPressRequest(request, env, coordinator, site, route.kind) } catch (error) { if (error instanceof RevisionConflict) return coordinatorConflictResponse(error) throw error } }, async scheduled(controller: ScheduledController, env: Env): Promise { - const coordinator = resolveCoordinator(env) - const publication = await drainNextPublicationJob(env, coordinator) + const sites = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS).sort((left, right) => left.id.localeCompare(right.id)) + const site = sites[Math.floor(controller.scheduledTime / 60_000) % sites.length] + const coordinator = resolveCoordinator(env, site) + const publication = await drainNextPublicationJob(env, coordinator, site) // A publication boot is intentionally the only heavyweight runtime in this invocation. const fence = publication ? undefined : await coordinator.fenceStatus() const evidence = publication ?? (fence?.active ? { schema: "wp-codebox/cloudflare-publication/v1" as const, status: "fenced" as const, jobKey: "coordinator" } - : await runScheduledWordPressCron(env, coordinator, controller.scheduledTime)) - console.log(JSON.stringify(evidence)) + : await runScheduledWordPressCron(env, coordinator, site, controller.scheduledTime)) + console.log(JSON.stringify({ siteId: site.id, ...evidence })) }, } } @@ -364,13 +369,12 @@ interface PublicationInvocationEvidence { route?: string } -let cachedRuntime: { baseRevision: string; promise: Promise } | undefined +const cachedRuntimes = new Map }>() const LEASE_ACQUISITION_TIMEOUT_MS = 100_000 -async function resetCanonicalWordPress(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator): Promise { +async function resetCanonicalWordPress(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext): Promise { if (request.method !== "POST") return new Response("Canonical reset requires POST.", { status: 405 }) - const authorization = request.headers.get("authorization") - if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization || !await secretsMatch(authorization, `Bearer ${env.WORDPRESS_OPERATOR_TOKEN}`)) { + if (!await isAuthorizedOperator(request, env, site)) { return new Response("Canonical reset authorization failed.", { status: 401 }) } try { @@ -379,14 +383,13 @@ async function resetCanonicalWordPress(request: Request, env: RuntimeEnv, coordi if (!(error instanceof RevisionConflict)) throw error return coordinatorConflictResponse(error) } - await discardCachedRuntime() - return Response.json({ reset: true }) + await discardCachedRuntime(site.id) + return Response.json({ reset: true, siteId: site.id }) } -async function restoreCanonicalWordPress(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator): Promise { +async function restoreCanonicalWordPress(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext): Promise { if (request.method !== "POST") return new Response("Canonical restore requires POST.", { status: 405 }) - const authorization = request.headers.get("authorization") - if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization || !await secretsMatch(authorization, `Bearer ${env.WORDPRESS_OPERATOR_TOKEN}`)) { + if (!await isAuthorizedOperator(request, env, site)) { return new Response("Canonical restore authorization failed.", { status: 401 }) } let pointer: MarkdownPointer @@ -395,8 +398,8 @@ async function restoreCanonicalWordPress(request: Request, env: RuntimeEnv, coor } catch { return new Response("Canonical restore requires a JSON pointer.", { status: 400 }) } - if (!isCanonicalRestorePointer(pointer)) return new Response("Canonical restore pointer is invalid.", { status: 400 }) - const manifest = await readMarkdownManifest(env.WORDPRESS_STATE_BUCKET, pointer) + if (!isCanonicalRestorePointer(pointer, site)) return new Response("Canonical restore pointer is invalid.", { status: 400 }) + const manifest = await readMarkdownManifest(env.WORDPRESS_STATE_BUCKET, pointer, site) if (!manifest || manifest.revision !== pointer.revision || manifest.manifestKey !== pointer.manifestKey || manifest.persistedAt !== pointer.persistedAt || !Array.isArray(manifest.files)) { return new Response("Canonical restore manifest is unavailable or inconsistent.", { status: 409 }) } @@ -407,7 +410,7 @@ async function restoreCanonicalWordPress(request: Request, env: RuntimeEnv, coor return new Response("Canonical restore requires an empty current pointer.", { status: 409 }) } const restored = await commitLease(coordinator, request.url, lease, pointer) - await discardCachedRuntime() + await discardCachedRuntime(site.id) return Response.json({ restored: true, ...restored }) } catch (error) { await abortLease(coordinator, request.url, lease) @@ -415,10 +418,9 @@ async function restoreCanonicalWordPress(request: Request, env: RuntimeEnv, coor } } -async function adoptCanonicalWordPress(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator): Promise { +async function adoptCanonicalWordPress(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext): Promise { if (request.method !== "POST") return new Response("Canonical adoption requires POST.", { status: 405 }) - const authorization = request.headers.get("authorization") - if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization || !await secretsMatch(authorization, `Bearer ${env.WORDPRESS_OPERATOR_TOKEN}`)) { + if (!await isAuthorizedOperator(request, env, site)) { return new Response("Canonical adoption authorization failed.", { status: 401 }) } let pointer: MarkdownPointer @@ -430,8 +432,8 @@ async function adoptCanonicalWordPress(request: Request, env: RuntimeEnv, coordi } catch { return new Response("Canonical adoption requires JSON state.", { status: 400 }) } - if (!isCanonicalRestorePointer(pointer) || !Number.isSafeInteger(version) || version < 1) return new Response("Canonical adoption state is invalid.", { status: 400 }) - const manifest = await readMarkdownManifest(env.WORDPRESS_STATE_BUCKET, pointer) + if (!isCanonicalRestorePointer(pointer, site) || !Number.isSafeInteger(version) || version < 1) return new Response("Canonical adoption state is invalid.", { status: 400 }) + const manifest = await readMarkdownManifest(env.WORDPRESS_STATE_BUCKET, pointer, site) if (!manifest || manifest.revision !== pointer.revision || manifest.manifestKey !== pointer.manifestKey || manifest.persistedAt !== pointer.persistedAt || !Array.isArray(manifest.files)) { return new Response("Canonical adoption manifest is unavailable or inconsistent.", { status: 409 }) } @@ -442,20 +444,19 @@ async function adoptCanonicalWordPress(request: Request, env: RuntimeEnv, coordi if (!(error instanceof RevisionConflict)) throw error return coordinatorConflictResponse(error) } - await discardCachedRuntime() + await discardCachedRuntime(site.id) return Response.json({ adopted: true, ...adopted }) } -async function operateCanonicalMutationFence(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator, action: "status" | "acquire" | "renew" | "release"): Promise { +async function operateCanonicalMutationFence(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext, action: "status" | "acquire" | "renew" | "release"): Promise { if ((action === "status" && request.method !== "GET") || (action !== "status" && request.method !== "POST")) { return new Response(`Canonical mutation fence ${action} requires ${action === "status" ? "GET" : "POST"}.`, { status: 405 }) } - const authorization = request.headers.get("authorization") - if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization || !await secretsMatch(authorization, `Bearer ${env.WORDPRESS_OPERATOR_TOKEN}`)) { + if (!await isAuthorizedOperator(request, env, site)) { return new Response("Canonical mutation fence authorization failed.", { status: 401 }) } try { - if (action === "status") return cutoverStatus(env.WORDPRESS_STATE_BUCKET, coordinator) + if (action === "status") return cutoverStatus(env.WORDPRESS_STATE_BUCKET, coordinator, site) const body = await request.json<{ token?: unknown; ttlSeconds?: unknown }>() if (action === "release") { if (typeof body.token !== "string") return new Response("Canonical mutation fence release requires a token.", { status: 400 }) @@ -478,21 +479,21 @@ async function operateCanonicalMutationFence(request: Request, env: RuntimeEnv, } } -async function cutoverStatus(bucket: R2Bucket, coordinator: RevisionCoordinator): Promise { +async function cutoverStatus(bucket: R2Bucket, coordinator: RevisionCoordinator, site: SiteContext): Promise { const [state, fence] = await Promise.all([coordinator.state(), coordinator.fenceStatus()]) const receipt = state.pointer && state.version > 0 ? await coordinator.committed(state.version) : null - const manifest = state.pointer ? await readMarkdownManifest(bucket, state.pointer) : null + const manifest = state.pointer ? await readMarkdownManifest(bucket, state.pointer, site) : null let validationError: string | undefined let coherent = !state.pointer if (state.pointer && receipt && samePointer(receipt, state.pointer) && manifest && samePointer(manifest, state.pointer)) { try { - await readCanonicalRevision(bucket, state.pointer) + await readCanonicalRevision(bucket, state.pointer, site) coherent = true } catch (error) { validationError = error instanceof Error ? error.message : String(error) } } - return Response.json({ schema: "wp-codebox/cloudflare-cutover-status/v1", state, receipt, manifest: manifest ? { revision: manifest.revision, manifestKey: manifest.manifestKey, persistedAt: manifest.persistedAt } : null, fence, coherent, validationError }, { status: coherent ? 200 : 409 }) + return Response.json({ schema: "wp-codebox/cloudflare-cutover-status/v1", siteId: site.id, state, receipt, manifest: manifest ? { revision: manifest.revision, manifestKey: manifest.manifestKey, persistedAt: manifest.persistedAt } : null, fence, coherent, validationError }, { status: coherent ? 200 : 409 }) } function coordinatorConflictResponse(error: RevisionConflict): Response { @@ -505,21 +506,20 @@ function samePointer(left: MarkdownPointer, right: MarkdownPointer): boolean { return left.revision === right.revision && left.manifestKey === right.manifestKey && left.persistedAt === right.persistedAt } -async function importCanonicalStaticArtifact(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator): Promise { +async function importCanonicalStaticArtifact(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext): Promise { if (request.method !== "POST") return new Response("Static artifact import requires POST.", { status: 405 }) - const authorization = request.headers.get("authorization") - if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization || !await secretsMatch(authorization, `Bearer ${env.WORDPRESS_OPERATOR_TOKEN}`)) { + if (!await isAuthorizedOperator(request, env, site)) { return new Response("Static artifact import authorization failed.", { status: 401 }) } let input: StaticArtifactImport try { - input = await readStaticArtifactImport(request, env.WORDPRESS_STATE_BUCKET) + input = await readStaticArtifactImport(request, env.WORDPRESS_STATE_BUCKET, site) } catch (error) { if (error instanceof StaticArtifactImportError) return Response.json({ schema: STATIC_ARTIFACT_IMPORT_RESULT_SCHEMA, status: "rejected", error: error.message }, { status: error.status }) throw error } try { - return await runCoordinatedWordPressRequest(request, env, coordinator, "static-artifact-import", input) + return await runCoordinatedWordPressRequest(request, env, coordinator, site, "static-artifact-import", input) } catch (error) { if (!(error instanceof RevisionConflict)) throw error const headers = new Headers() @@ -528,11 +528,11 @@ async function importCanonicalStaticArtifact(request: Request, env: RuntimeEnv, } } -function isCanonicalRestorePointer(pointer: unknown): pointer is MarkdownPointer { +function isCanonicalRestorePointer(pointer: unknown, site: SiteContext): pointer is MarkdownPointer { if (!pointer || typeof pointer !== "object") return false const candidate = pointer as Partial return typeof candidate.revision === "string" && /^[a-f0-9-]{36}$/.test(candidate.revision) - && candidate.manifestKey === `sites/default/markdown/revisions/${candidate.revision}.json` + && candidate.manifestKey === `${siteStorageKeys(site).markdownRevisionPrefix}/${candidate.revision}.json` && typeof candidate.persistedAt === "string" && Number.isFinite(Date.parse(candidate.persistedAt)) } @@ -546,16 +546,23 @@ async function secretsMatch(left: string, right: string): Promise { return difference === 0 } -async function servePublishedWordPressPage(request: Request, bucket: R2Bucket): Promise { +async function isAuthorizedOperator(request: Request, env: RuntimeEnv, site: SiteContext): Promise { + const authorization = request.headers.get("authorization") + if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization) return false + const token = await deriveSiteCredential(env.WORDPRESS_OPERATOR_TOKEN, site.id, "operator-token") + return secretsMatch(authorization, `Bearer ${token}`) +} + +async function servePublishedWordPressPage(request: Request, bucket: R2Bucket, site: SiteContext): Promise { if (!isCacheableWordPressPageRequest(request) || new URL(request.url).searchParams.has("phase")) return null if (["/wp-content/", "/wp-includes/"].some((prefix) => new URL(request.url).pathname.startsWith(prefix))) return null const route = canonicalPublicRoute(request) const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default - const cacheRequest = publishedPageCacheRequest(request) + const cacheRequest = publishedPageCacheRequest(request, site) const cached = cache ? await cache.match(cacheRequest) : null if (cached) return publishedPageResponse(cached, request.method === "HEAD", "edge") - const current = await readCurrentPublication(bucket) + const current = await readCurrentPublication(bucket, site) if (!current) return null const publication = current.publication const publishedRoute = publication.routes.find((candidate) => candidate.route === route) @@ -577,10 +584,9 @@ async function servePublishedWordPressPage(request: Request, bucket: R2Bucket): return published } -async function publishCanonicalWordPressPages(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator): Promise { +async function publishCanonicalWordPressPages(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext): Promise { if (request.method !== "POST") return new Response("Canonical publication requires POST.", { status: 405 }) - const authorization = request.headers.get("authorization") - if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization || !await secretsMatch(authorization, `Bearer ${env.WORDPRESS_OPERATOR_TOKEN}`)) { + if (!await isAuthorizedOperator(request, env, site)) { return new Response("Canonical publication authorization failed.", { status: 401 }) } let routes: string[] @@ -601,7 +607,7 @@ async function publishCanonicalWordPressPages(request: Request, env: RuntimeEnv, if (!lease.pointer) return new Response("Canonical publication requires initialized state.", { status: 409 }) let publishedRoutes: PublishedRevision["routes"] publishedRoutes = await Promise.all(routes.map(async (route) => { - const objectKey = await publishedPageObjectKey(lease.pointer!.revision, route) + const objectKey = await publishedPageObjectKey(lease.pointer!.revision, route, site) const object = await env.WORDPRESS_STATE_BUCKET.get(objectKey) if (!object) throw new Error(`Canonical publication route has not been rendered: ${route}.`) if (object.size > MAX_PUBLISHED_PAGE_BYTES) throw new Error(`Canonical publication route exceeds its size budget: ${route}.`) @@ -620,11 +626,11 @@ async function publishCanonicalWordPressPages(request: Request, env: RuntimeEnv, const serialized = JSON.stringify(publication) if (new TextEncoder().encode(serialized).byteLength > MAX_PUBLISHED_REVISION_BYTES) return new Response("Canonical publication exceeds its size budget.", { status: 413 }) lease = await coordinator.renew(lease) - await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publishedRevisionObjectKey(publication.revision), serialized) + await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publishedRevisionObjectKey(publication.revision, site), serialized) lease = await coordinator.renew(lease) - await env.WORDPRESS_STATE_BUCKET.put(R2_PUBLISHED_CURRENT_KEY, serialized, { httpMetadata: { contentType: "application/json" } }) + await env.WORDPRESS_STATE_BUCKET.put(siteStorageKeys(site).publishedCurrent, serialized, { httpMetadata: { contentType: "application/json" } }) const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default - if (cache) await Promise.all(routes.map((route) => cache.delete(publishedPageCacheRequest(new Request(new URL(route, request.url)))))) + if (cache) await Promise.all(routes.map((route) => cache.delete(publishedPageCacheRequest(new Request(new URL(route, site.origin)), site)))) return Response.json(publication) } catch (error) { return new Response(error instanceof Error ? error.message : "Canonical publication artifacts are invalid.", { status: 409 }) @@ -633,11 +639,11 @@ async function publishCanonicalWordPressPages(request: Request, env: RuntimeEnv, } } -async function readCurrentPublication(bucket: R2Bucket): Promise { - const object = await bucket.get(R2_PUBLISHED_CURRENT_KEY) +async function readCurrentPublication(bucket: R2Bucket, site: SiteContext): Promise { + const object = await bucket.get(siteStorageKeys(site).publishedCurrent) if (!object) return null if (object.size > MAX_PUBLISHED_REVISION_BYTES) throw new Error("Published revision exceeds its size budget.") - return { publication: validatePublishedRevision(JSON.parse(await object.text())), etag: object.etag } + return { publication: validatePublishedRevision(JSON.parse(await object.text()), site), etag: object.etag } } function initializePublicationChanges(php: PHP): void { @@ -665,30 +671,30 @@ function publicationPlan(current: PublishedRevision, changes: PublicationChanges return { upsert, remove } } -function publicationJobObjectKey(version: number, revision: string): string { +function publicationJobObjectKey(version: number, revision: string, site: SiteContext): string { if (!Number.isSafeInteger(version) || version < 1) throw new Error("Publication job coordinator version is invalid.") if (!/^[a-f0-9-]{36}$/.test(revision)) throw new Error("Publication job canonical revision is invalid.") - return `${R2_PUBLICATION_JOB_PREFIX}/${String(version).padStart(20, "0")}-${revision}.json` + return `${siteStorageKeys(site).publicationJobPrefix}/${String(version).padStart(20, "0")}-${revision}.json` } -function publicationProgressObjectKey(job: PublicationJob): string { - return `${R2_PUBLICATION_PROGRESS_PREFIX}/${job.key.split("/").at(-1)}` +function publicationProgressObjectKey(job: PublicationJob, site: SiteContext): string { + return `${siteStorageKeys(site).publicationProgressPrefix}/${job.key.split("/").at(-1)}` } -function publicationClaimObjectKey(job: PublicationJob): string { - return `${R2_PUBLICATION_CLAIM_PREFIX}/${job.key.split("/").at(-1)}` +function publicationClaimObjectKey(job: PublicationJob, site: SiteContext): string { + return `${siteStorageKeys(site).publicationClaimPrefix}/${job.key.split("/").at(-1)}` } -function publicationReceiptObjectKey(job: PublicationJob): string { - return `${R2_PUBLICATION_RECEIPT_PREFIX}/${job.key.split("/").at(-1)}` +function publicationReceiptObjectKey(job: PublicationJob, site: SiteContext): string { + return `${siteStorageKeys(site).publicationReceiptPrefix}/${job.key.split("/").at(-1)}` } -async function enqueuePublicationJob(bucket: R2Bucket, lease: Lease, canonical: MarkdownPointer, current: CurrentPublication | null, changes: PublicationChanges): Promise { +async function enqueuePublicationJob(bucket: R2Bucket, lease: Lease, canonical: MarkdownPointer, current: CurrentPublication | null, changes: PublicationChanges, site: SiteContext): Promise { if (!current) return null const plan = publicationPlan(current.publication, changes) if (!plan.upsert.length && !plan.remove.length) return null const coordinatorVersion = lease.version + 1 - const key = publicationJobObjectKey(coordinatorVersion, canonical.revision) + const key = publicationJobObjectKey(coordinatorVersion, canonical.revision, site) const job: PublicationJob = { schema: PUBLICATION_JOB_SCHEMA, key, @@ -705,12 +711,12 @@ async function enqueuePublicationJob(bucket: R2Bucket, lease: Lease, canonical: return job } -async function readPublicationJob(bucket: R2Bucket, key: string): Promise { +async function readPublicationJob(bucket: R2Bucket, key: string, site: SiteContext): Promise { const object = await bucket.get(key) if (!object || object.size > MAX_PUBLISHED_REVISION_BYTES) throw new Error("Publication job is unavailable or exceeds its size budget.") const job = JSON.parse(await object.text()) as PublicationJob - if (job.schema !== PUBLICATION_JOB_SCHEMA || job.key !== key || job.key !== publicationJobObjectKey(job.coordinatorVersion, job.canonical?.revision ?? "") - || !isCanonicalRestorePointer(job.canonical) || !validatePublicationChanges(job.changes) + if (job.schema !== PUBLICATION_JOB_SCHEMA || job.key !== key || job.key !== publicationJobObjectKey(job.coordinatorVersion, job.canonical?.revision ?? "", site) + || !isCanonicalRestorePointer(job.canonical, site) || !validatePublicationChanges(job.changes) || !Number.isFinite(Date.parse(job.createdAt))) throw new Error("Publication job is invalid.") return job } @@ -729,8 +735,8 @@ function isPublicationPlan(plan: unknown): plan is { upsert: string[]; remove: s && (plan as { remove: unknown[] }).remove.every((route) => typeof route === "string" && canonicalPublicRoute(route) === route) } -async function readPublicationProgress(bucket: R2Bucket, job: PublicationJob): Promise { - const object = await bucket.get(publicationProgressObjectKey(job)) +async function readPublicationProgress(bucket: R2Bucket, job: PublicationJob, site: SiteContext): Promise { + const object = await bucket.get(publicationProgressObjectKey(job, site)) if (!object) return { schema: PUBLICATION_PROGRESS_SCHEMA, next: 0, rendered: [] } if (object.size > MAX_PUBLISHED_REVISION_BYTES) throw new Error("Publication progress exceeds its size budget.") const progress = JSON.parse(await object.text()) as PublicationProgress @@ -740,14 +746,14 @@ async function readPublicationProgress(bucket: R2Bucket, job: PublicationJob): P return progress } -async function writePublicationProgress(bucket: R2Bucket, job: PublicationJob, progress: PublicationProgress): Promise { +async function writePublicationProgress(bucket: R2Bucket, job: PublicationJob, progress: PublicationProgress, site: SiteContext): Promise { const serialized = JSON.stringify(progress) if (new TextEncoder().encode(serialized).byteLength > MAX_PUBLISHED_REVISION_BYTES) throw new Error("Publication progress exceeds its size budget.") - await bucket.put(publicationProgressObjectKey(job), serialized, { httpMetadata: { contentType: "application/json" } }) + await bucket.put(publicationProgressObjectKey(job, site), serialized, { httpMetadata: { contentType: "application/json" } }) } -async function claimPublicationJob(bucket: R2Bucket, job: PublicationJob, renewLease: () => Promise): Promise<{ token: string; etag?: string } | null> { - const key = publicationClaimObjectKey(job) +async function claimPublicationJob(bucket: R2Bucket, job: PublicationJob, site: SiteContext, renewLease: () => Promise): Promise<{ token: string; etag?: string } | null> { + const key = publicationClaimObjectKey(job, site) const existing = await bucket.get(key) if (existing && existing.size <= 1_024) { const claim = JSON.parse(await existing.text()) as { expiresAt?: unknown } @@ -760,12 +766,12 @@ async function claimPublicationJob(bucket: R2Bucket, job: PublicationJob, renewL return written ? { token, etag: written.etag } : null } -async function releasePublicationClaim(bucket: R2Bucket, job: PublicationJob, claim: { etag?: string }): Promise { +async function releasePublicationClaim(bucket: R2Bucket, job: PublicationJob, site: SiteContext, claim: { etag?: string }): Promise { // Conditional expiry cannot delete a newer claimant after an expired invocation resumes. - if (claim.etag) await bucket.put(publicationClaimObjectKey(job), JSON.stringify({ expiresAt: 0 }), { onlyIf: { etagMatches: claim.etag }, httpMetadata: { contentType: "application/json" } }) + if (claim.etag) await bucket.put(publicationClaimObjectKey(job, site), JSON.stringify({ expiresAt: 0 }), { onlyIf: { etagMatches: claim.etag }, httpMetadata: { contentType: "application/json" } }) } -async function drainNextPublicationJob(env: RuntimeEnv, coordinator: RevisionCoordinator): Promise { +async function drainNextPublicationJob(env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext): Promise { let publicationLease: RevisionLease try { publicationLease = await coordinator.acquire() @@ -775,21 +781,21 @@ async function drainNextPublicationJob(env: RuntimeEnv, coordinator: RevisionCoo return { schema: "wp-codebox/cloudflare-publication/v1", status: fence.active ? "fenced" : "pending", jobKey: "coordinator" } } try { - return await drainNextPublicationJobWhileLeased(env, coordinator, async () => { + return await drainNextPublicationJobWhileLeased(env, coordinator, site, async () => { publicationLease = await coordinator.renew(publicationLease) }) } finally { - await abortLease(coordinator, SITE_URL, publicationLease) + await abortLease(coordinator, site.origin, publicationLease) } } -async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator: RevisionCoordinator, renewLease: () => Promise): Promise { - const listed = await env.WORDPRESS_STATE_BUCKET.list({ prefix: `${R2_PUBLICATION_JOB_PREFIX}/`, limit: 16 }) +async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext, renewLease: () => Promise): Promise { + const listed = await env.WORDPRESS_STATE_BUCKET.list({ prefix: `${siteStorageKeys(site).publicationJobPrefix}/`, limit: 16 }) if (!listed.objects.length) return null const state = await coordinator.state() let job: PublicationJob | undefined for (const object of listed.objects) { - const candidate = await readPublicationJob(env.WORDPRESS_STATE_BUCKET, object.key) + const candidate = await readPublicationJob(env.WORDPRESS_STATE_BUCKET, object.key, site) const committed = await coordinator.committed(candidate.coordinatorVersion) if (committed?.revision === candidate.canonical.revision) { job = candidate @@ -797,39 +803,39 @@ async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator: } if (state.version > candidate.coordinatorVersion || Date.now() - Date.parse(candidate.createdAt) > PUBLICATION_CLAIM_MS) { await renewLease() - await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publicationReceiptObjectKey(candidate), JSON.stringify({ status: "orphaned", job: candidate.key, recordedAt: new Date().toISOString() })) + await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publicationReceiptObjectKey(candidate, site), JSON.stringify({ status: "orphaned", job: candidate.key, recordedAt: new Date().toISOString() })) await renewLease() await env.WORDPRESS_STATE_BUCKET.delete(candidate.key) } } if (!job) return listed.truncated ? { schema: "wp-codebox/cloudflare-publication/v1", status: "pending", jobKey: "scan" } : null - const claim = await claimPublicationJob(env.WORDPRESS_STATE_BUCKET, job, renewLease) + const claim = await claimPublicationJob(env.WORDPRESS_STATE_BUCKET, job, site, renewLease) if (!claim) return { schema: "wp-codebox/cloudflare-publication/v1", status: "pending", jobKey: job.key } let runtime: Runtime | undefined try { - let progress = await readPublicationProgress(env.WORDPRESS_STATE_BUCKET, job) - const current = await readCurrentPublication(env.WORDPRESS_STATE_BUCKET) + let progress = await readPublicationProgress(env.WORDPRESS_STATE_BUCKET, job, site) + const current = await readCurrentPublication(env.WORDPRESS_STATE_BUCKET, site) if (!current || current.publication.canonicalVersion >= job.coordinatorVersion) { await renewLease() - await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publicationReceiptObjectKey(job), JSON.stringify({ status: "superseded", job: job.key, recordedAt: new Date().toISOString() })) + await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publicationReceiptObjectKey(job, site), JSON.stringify({ status: "superseded", job: job.key, recordedAt: new Date().toISOString() })) await renewLease() - await env.WORDPRESS_STATE_BUCKET.delete([job.key, publicationProgressObjectKey(job)]) + await env.WORDPRESS_STATE_BUCKET.delete([job.key, publicationProgressObjectKey(job, site)]) return { schema: "wp-codebox/cloudflare-publication/v1", status: "stale", jobKey: job.key } } if (!progress.plan) { progress = { ...progress, plan: publicationPlan(current.publication, job.changes) } await renewLease() - await writePublicationProgress(env.WORDPRESS_STATE_BUCKET, job, progress) + await writePublicationProgress(env.WORDPRESS_STATE_BUCKET, job, progress, site) } const plan = progress.plan if (!plan) throw new Error("Publication progress plan is unavailable.") if (progress.next < plan.upsert.length) { const route = plan.upsert[progress.next] - const objectKey = await publishedPageObjectKey(job.canonical.revision, route) + const objectKey = await publishedPageObjectKey(job.canonical.revision, route, site) let snapshot = await readWordPressPageSnapshot(env.WORDPRESS_STATE_BUCKET, objectKey, job.canonical.revision, route) if (!snapshot) { - runtime = await bootRuntime(env.WORDPRESS_STATE_BUCKET, job.canonical, SITE_URL, await canonicalWordPressAuthConstants(env)) - const page = (await compilePublicationRoutes(runtime, [route], SITE_URL))[0] + runtime = await bootRuntime(env.WORDPRESS_STATE_BUCKET, job.canonical, site.origin, await canonicalWordPressAuthConstants(env, site), false, site) + const page = (await compilePublicationRoutes(runtime, [route], site.origin))[0] snapshot = { schema: PUBLISHED_PAGE_SCHEMA, canonicalRevision: job.canonical.revision, route: page.route, status: page.status, statusText: page.statusText, headers: page.headers, body: page.body } const serialized = JSON.stringify(snapshot) if (new TextEncoder().encode(serialized).byteLength > MAX_PUBLISHED_PAGE_BYTES) throw new Error(`Affected publication artifact exceeds its size budget: ${page.route}.`) @@ -841,36 +847,36 @@ async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator: } } await renewLease() - await writePublicationProgress(env.WORDPRESS_STATE_BUCKET, job, { ...progress, next: progress.next + 1, rendered: [...progress.rendered, route] }) + await writePublicationProgress(env.WORDPRESS_STATE_BUCKET, job, { ...progress, next: progress.next + 1, rendered: [...progress.rendered, route] }, site) return { schema: "wp-codebox/cloudflare-publication/v1", status: "rendered", jobKey: job.key, route } } const routes = new Map(current.publication.routes.map((route) => [route.route, route])) for (const route of plan.remove) routes.delete(route) - for (const route of plan.upsert) routes.set(route, { route, objectKey: await publishedPageObjectKey(job.canonical.revision, route), canonicalRevision: job.canonical.revision }) + for (const route of plan.upsert) routes.set(route, { route, objectKey: await publishedPageObjectKey(job.canonical.revision, route, site), canonicalRevision: job.canonical.revision }) const publication: PublishedRevision = { schema: PUBLISHED_REVISION_SCHEMA, revision: crypto.randomUUID(), canonicalRevision: job.canonical.revision, canonicalVersion: job.coordinatorVersion, publishedAt: new Date().toISOString(), routes: [...routes.values()].sort((left, right) => left.route.localeCompare(right.route)) } const serialized = JSON.stringify(publication) if (new TextEncoder().encode(serialized).byteLength > MAX_PUBLISHED_REVISION_BYTES) throw new Error("Incremental publication exceeds its size budget.") await renewLease() - await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publishedRevisionObjectKey(publication.revision), serialized) + await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publishedRevisionObjectKey(publication.revision, site), serialized) await renewLease() - if (!await promoteIncrementalPublication(env.WORDPRESS_STATE_BUCKET, current, { serialized, invalidatedRoutes: [...new Set([...plan.upsert, ...plan.remove])].sort() }, SITE_URL)) { + if (!await promoteIncrementalPublication(env.WORDPRESS_STATE_BUCKET, current, { serialized, invalidatedRoutes: [...new Set([...plan.upsert, ...plan.remove])].sort() }, site)) { return { schema: "wp-codebox/cloudflare-publication/v1", status: "stale", jobKey: job.key } } await renewLease() - await writePublicationProgress(env.WORDPRESS_STATE_BUCKET, job, { ...progress, completedAt: new Date().toISOString() }) + await writePublicationProgress(env.WORDPRESS_STATE_BUCKET, job, { ...progress, completedAt: new Date().toISOString() }, site) await renewLease() - await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publicationReceiptObjectKey(job), JSON.stringify({ status: "promoted", job: job.key, publication: publication.revision, recordedAt: new Date().toISOString() })) + await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publicationReceiptObjectKey(job, site), JSON.stringify({ status: "promoted", job: job.key, publication: publication.revision, recordedAt: new Date().toISOString() })) await renewLease() - await env.WORDPRESS_STATE_BUCKET.delete([job.key, publicationProgressObjectKey(job)]) + await env.WORDPRESS_STATE_BUCKET.delete([job.key, publicationProgressObjectKey(job, site)]) return { schema: "wp-codebox/cloudflare-publication/v1", status: "promoted", jobKey: job.key } } catch (error) { console.error("Publication job failed.", error) return { schema: "wp-codebox/cloudflare-publication/v1", status: "failed", jobKey: job.key } } finally { - if (runtime) await discardRuntime(runtime) + if (runtime) await discardRuntime(runtime, site) try { await renewLease() - await releasePublicationClaim(env.WORDPRESS_STATE_BUCKET, job, claim) + await releasePublicationClaim(env.WORDPRESS_STATE_BUCKET, job, site, claim) } catch (error) { if (!(error instanceof RevisionConflict)) throw error } @@ -892,16 +898,16 @@ async function compilePublicationRoutes(runtime: Runtime, routes: string[], orig return compiled } -async function promoteIncrementalPublication(bucket: R2Bucket, current: CurrentPublication, staged: { serialized: string; invalidatedRoutes: string[] }, origin: string): Promise { - const promoted = await bucket.put(R2_PUBLISHED_CURRENT_KEY, staged.serialized, { onlyIf: { etagMatches: current.etag }, httpMetadata: { contentType: "application/json" } }) +async function promoteIncrementalPublication(bucket: R2Bucket, current: CurrentPublication, staged: { serialized: string; invalidatedRoutes: string[] }, site: SiteContext): Promise { + const promoted = await bucket.put(siteStorageKeys(site).publishedCurrent, staged.serialized, { onlyIf: { etagMatches: current.etag }, httpMetadata: { contentType: "application/json" } }) if (!promoted) return false const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default - if (cache) await Promise.all(staged.invalidatedRoutes.map((route) => cache.delete(publishedPageCacheRequest(new Request(new URL(route, origin)))))) + if (cache) await Promise.all(staged.invalidatedRoutes.map((route) => cache.delete(publishedPageCacheRequest(new Request(new URL(route, site.origin)), site)))) return true } -function publishedPageCacheRequest(request: Request): Request { - return new Request(`https://wp-codebox-publication.invalid${canonicalPublicRoute(request)}`, { method: "GET" }) +function publishedPageCacheRequest(request: Request, site: SiteContext): Request { + return new Request(`https://wp-codebox-publication.invalid/${site.id}${canonicalPublicRoute(request)}`, { method: "GET" }) } function publishedPageResponse(response: Response, head: boolean, source: "edge" | "r2", revision?: string): Response { @@ -920,12 +926,12 @@ function validateWordPressPageSnapshot(snapshot: WordPressPageSnapshot, canonica || typeof snapshot.body !== "string") throw new Error("Published page artifact is invalid.") } -async function runCoordinatedWordPressRequest(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator, route: "wordpress" | "health" | "r2-mutate" | "static-artifact-import", staticArtifactImport?: StaticArtifactImport): Promise { +async function runCoordinatedWordPressRequest(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext, route: "wordpress" | "health" | "r2-mutate" | "static-artifact-import", staticArtifactImport?: StaticArtifactImport): Promise { if (route === "r2-mutate" && request.method !== "POST") return new Response("WordPress state mutation requires POST.", { status: 405 }) if (route === "wordpress" && isCacheableWordPressPageRequest(request)) { const state = await coordinator.state() if (state.pointer) { - const cachedPage = await matchWordPressPageCache(request, state.pointer, env.WORDPRESS_STATE_BUCKET) + const cachedPage = await matchWordPressPageCache(request, state.pointer, env.WORDPRESS_STATE_BUCKET, site) if (cachedPage) return cachedPage } } @@ -934,23 +940,23 @@ async function runCoordinatedWordPressRequest(request: Request, env: RuntimeEnv, let finalized = false try { if (!lease.pointer) { - const bootstrapped = await bootstrapCanonicalRuntime(env, coordinator, request.url, lease) + const bootstrapped = await bootstrapCanonicalRuntime(env, coordinator, site, request.url, lease) // Bootstrap promotion consumes its lease before login or any other request observes it. lease = await acquireLease(coordinator, request.url) if (!lease.pointer || lease.pointer.revision !== bootstrapped.pointer.revision) throw new Error("Canonical bootstrap promotion was not observed by its next lease.") - cacheRuntime(lease.pointer, bootstrapped) + cacheRuntime(lease.pointer, bootstrapped, site) } - const cachedPage = route === "wordpress" ? await matchWordPressPageCache(request, lease.pointer, env.WORDPRESS_STATE_BUCKET) : null + const cachedPage = route === "wordpress" ? await matchWordPressPageCache(request, lease.pointer, env.WORDPRESS_STATE_BUCKET, site) : null if (cachedPage) { await releaseLease(coordinator, request.url, lease) finalized = true return cachedPage } - runtime = await getRuntime(env, lease.pointer, new URL(request.url).origin, route === "static-artifact-import") + runtime = await getRuntime(env, lease.pointer, site, route === "static-artifact-import") const mutatesCanonicalState = isMutation(request, route) const diagnosticsStartedAt = Date.now() const retained = new MutationRetainedBytes() - const currentPublication = mutatesCanonicalState ? await readCurrentPublication(env.WORDPRESS_STATE_BUCKET) : null + const currentPublication = mutatesCanonicalState ? await readCurrentPublication(env.WORDPRESS_STATE_BUCKET, site) : null let response: Response | undefined let phpResponse: PHPResponseData | undefined let responseBodyBytes = 0 @@ -962,7 +968,7 @@ async function runCoordinatedWordPressRequest(request: Request, env: RuntimeEnv, runtime.php.writeFile(MARKDOWN_CHANGES_PATH, new TextEncoder().encode(JSON.stringify({ created: [], changed: [], deleted: [] }))) const imported = await runStaticArtifactImport(runtime, staticArtifactImport) if (imported.status !== "imported") { - await discardRuntime(runtime) + await discardRuntime(runtime, site) runtime = undefined await releaseLease(coordinator, request.url, lease) finalized = true @@ -998,16 +1004,16 @@ async function runCoordinatedWordPressRequest(request: Request, env: RuntimeEnv, if (mutatesCanonicalState) { if (!canonicalChanges || !publicationChanges) throw new Error("Canonical mutation completed without its persistence evidence.") logMutationPhase(diagnosticsStartedAt, "php-request", retained, { canonicalCreated: canonicalChanges.created.length, canonicalChanged: canonicalChanges.changed.length, canonicalDeleted: canonicalChanges.deleted.length }) - const next = await persistRuntime(env.WORDPRESS_STATE_BUCKET, runtime, canonicalChanges, diagnosticsStartedAt, retained) + const next = await persistRuntime(env.WORDPRESS_STATE_BUCKET, runtime, canonicalChanges, site, diagnosticsStartedAt, retained) if (!response) { if (!phpResponse) throw new Error("WordPress mutation completed without a PHP response.") retained.retain(responseBodyBytes) response = toFetchResponse(request, phpResponse) } - await discardRuntime(runtime) + await discardRuntime(runtime, site) runtime = undefined if (phpResponse) retained.release(responseBodyBytes) - const publicationJob = await enqueuePublicationJob(env.WORDPRESS_STATE_BUCKET, lease, next, currentPublication, publicationChanges) + const publicationJob = await enqueuePublicationJob(env.WORDPRESS_STATE_BUCKET, lease, next, currentPublication, publicationChanges, site) response.headers.set("x-wp-codebox-publication", publicationJob ? "queued" : "unchanged") if (publicationJob) response.headers.set("x-wp-codebox-publication-job", publicationJob.key) const committed = await commitLease(coordinator, request.url, lease, next) @@ -1018,12 +1024,12 @@ async function runCoordinatedWordPressRequest(request: Request, env: RuntimeEnv, await releaseLease(coordinator, request.url, lease) } finalized = true - if (!mutatesCanonicalState && route === "wordpress" && response) response = await cacheWordPressPage(request, lease.pointer, response, env.WORDPRESS_STATE_BUCKET) + if (!mutatesCanonicalState && route === "wordpress" && response) response = await cacheWordPressPage(request, lease.pointer, response, env.WORDPRESS_STATE_BUCKET, site) if (!response) throw new Error("WordPress request completed without a response.") return response } catch (error) { if (!finalized) await abortLease(coordinator, request.url, lease) - if (runtime) await discardRuntime(runtime) + if (runtime) await discardRuntime(runtime, site) throw error } } @@ -1036,18 +1042,18 @@ function isCacheableWordPressPageRequest(request: Request): boolean { return !url.searchParams.has("preview") && !url.searchParams.has("rest_route") } -async function matchWordPressPageCache(request: Request, pointer: MarkdownPointer, bucket: R2Bucket): Promise { +async function matchWordPressPageCache(request: Request, pointer: MarkdownPointer, bucket: R2Bucket, site: SiteContext): Promise { if (!isCacheableWordPressPageRequest(request)) return null const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default try { - const cached = cache ? await cache.match(wordPressPageCacheKey(request, pointer)) : null + const cached = cache ? await cache.match(wordPressPageCacheKey(request, pointer, site)) : null if (cached) return pageCacheResponse(cached, request.method === "HEAD", "hit", "edge") const route = canonicalPublicRoute(request) - const snapshot = await readWordPressPageSnapshot(bucket, await wordPressPageSnapshotKey(request, pointer), pointer.revision, route) + const snapshot = await readWordPressPageSnapshot(bucket, await wordPressPageSnapshotKey(request, pointer, site), pointer.revision, route) if (!snapshot) return null if (snapshot.status !== 200) return null const response = new Response(snapshot.body, { status: snapshot.status, statusText: snapshot.statusText, headers: snapshot.headers }) - if (cache) await cache.put(wordPressPageCacheKey(request, pointer), response.clone()) + if (cache) await cache.put(wordPressPageCacheKey(request, pointer, site), response.clone()) return pageCacheResponse(response, request.method === "HEAD", "hit", "r2") } catch { return null @@ -1063,7 +1069,7 @@ async function readWordPressPageSnapshot(bucket: R2Bucket, objectKey: string, ca return snapshot } -async function cacheWordPressPage(request: Request, pointer: MarkdownPointer, response: Response, bucket: R2Bucket): Promise { +async function cacheWordPressPage(request: Request, pointer: MarkdownPointer, response: Response, bucket: R2Bucket, site: SiteContext): Promise { if (!isCacheableWordPressPageRequest(request) || response.status !== 200 || !response.headers.get("content-type")?.includes("text/html") || response.headers.has("set-cookie")) return response if (request.method === "HEAD") return pageCacheResponse(response, true, "miss", "render") const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default @@ -1081,8 +1087,8 @@ async function cacheWordPressPage(request: Request, pointer: MarkdownPointer, re const serialized = JSON.stringify(snapshot) if (new TextEncoder().encode(serialized).byteLength > MAX_PUBLISHED_PAGE_BYTES) return cacheable await Promise.all([ - cache ? cache.put(wordPressPageCacheKey(request, pointer), cacheable.clone()) : Promise.resolve(), - putImmutableJson(bucket, await wordPressPageSnapshotKey(request, pointer), serialized), + cache ? cache.put(wordPressPageCacheKey(request, pointer, site), cacheable.clone()) : Promise.resolve(), + putImmutableJson(bucket, await wordPressPageSnapshotKey(request, pointer, site), serialized), ]) } catch { // Page caching is an optimization; canonical rendering remains authoritative. @@ -1097,15 +1103,16 @@ async function putImmutableJson(bucket: R2Bucket, key: string, serialized: strin if (!existing || await existing.text() !== serialized) throw new Error(`Immutable R2 object conflicts with existing content: ${key}.`) } -function wordPressPageCacheKey(request: Request, pointer: MarkdownPointer): Request { +function wordPressPageCacheKey(request: Request, pointer: MarkdownPointer, site: SiteContext): Request { const url = new URL(request.url) url.searchParams.set("__wp_codebox_revision", pointer.revision) url.searchParams.set("__wp_codebox_page_cache", WORDPRESS_PAGE_CACHE_SCHEMA) + url.searchParams.set("__wp_codebox_site", site.id) return new Request(url, { method: "GET" }) } -async function wordPressPageSnapshotKey(request: Request, pointer: MarkdownPointer): Promise { - return publishedPageObjectKey(pointer.revision, canonicalPublicRoute(request)) +async function wordPressPageSnapshotKey(request: Request, pointer: MarkdownPointer, site: SiteContext): Promise { + return publishedPageObjectKey(pointer.revision, canonicalPublicRoute(request), site) } function pageCacheResponse(response: Response, head: boolean, status: "hit" | "miss", source: "edge" | "r2" | "render"): Response { @@ -1159,25 +1166,30 @@ function commitLease(coordinator: RevisionCoordinator, _requestUrl: string, leas return coordinator.commit(lease, pointer) } -async function getRuntime(env: RuntimeEnv, pointer: MarkdownPointer, origin: string, includeStaticSiteImporter = false): Promise { - if (cachedRuntime && (cachedRuntime.baseRevision !== pointer.revision || includeStaticSiteImporter)) await discardCachedRuntime() +async function getRuntime(env: RuntimeEnv, pointer: MarkdownPointer, site: SiteContext, includeStaticSiteImporter = false): Promise { + let cachedRuntime = cachedRuntimes.get(site.id) + if (cachedRuntime && (cachedRuntime.baseRevision !== pointer.revision || includeStaticSiteImporter)) { + await discardCachedRuntime(site.id) + cachedRuntime = undefined + } if (!cachedRuntime) { - const promise = bootRuntime(env.WORDPRESS_STATE_BUCKET, pointer, origin, await canonicalWordPressAuthConstants(env), includeStaticSiteImporter) + const promise = bootRuntime(env.WORDPRESS_STATE_BUCKET, pointer, site.origin, await canonicalWordPressAuthConstants(env, site), includeStaticSiteImporter, site) cachedRuntime = { baseRevision: pointer.revision, promise } + cachedRuntimes.set(site.id, cachedRuntime) promise.catch(() => { - if (cachedRuntime?.promise === promise) cachedRuntime = undefined + if (cachedRuntimes.get(site.id)?.promise === promise) cachedRuntimes.delete(site.id) }) } return cachedRuntime.promise } -function cacheRuntime(pointer: MarkdownPointer, runtime: Runtime): void { - cachedRuntime = { baseRevision: pointer.revision, promise: Promise.resolve(runtime) } +function cacheRuntime(pointer: MarkdownPointer, runtime: Runtime, site: SiteContext): void { + cachedRuntimes.set(site.id, { baseRevision: pointer.revision, promise: Promise.resolve(runtime) }) } -async function discardCachedRuntime(): Promise { - const cached = cachedRuntime - cachedRuntime = undefined +async function discardCachedRuntime(siteId: string): Promise { + const cached = cachedRuntimes.get(siteId) + cachedRuntimes.delete(siteId) if (!cached) return try { await disposeRequestHandler((await cached.promise).requestHandler) @@ -1186,12 +1198,13 @@ async function discardCachedRuntime(): Promise { } } -async function discardRuntime(runtime: Runtime): Promise { +async function discardRuntime(runtime: Runtime, site: SiteContext): Promise { + const cachedRuntime = cachedRuntimes.get(site.id) if (cachedRuntime?.promise) { try { - if (await cachedRuntime.promise === runtime) cachedRuntime = undefined + if (await cachedRuntime.promise === runtime) cachedRuntimes.delete(site.id) } catch { - cachedRuntime = undefined + cachedRuntimes.delete(site.id) } } await disposeRequestHandler(runtime.requestHandler) @@ -1203,25 +1216,26 @@ async function disposeRequestHandler(requestHandler: PHPRequestHandler): Promise await dispose.call(requestHandler) } -async function bootRuntime(bucket: R2Bucket, pointer: MarkdownPointer, origin: string, authConstants: Record, includeStaticSiteImporter = false): Promise { - const revision = await readCanonicalRevision(bucket, pointer) +async function bootRuntime(bucket: R2Bucket, pointer: MarkdownPointer, origin: string, authConstants: Record, includeStaticSiteImporter = false, site: SiteContext = DEFAULT_SITE_CONTEXT): Promise { + const revision = await readCanonicalRevision(bucket, pointer, site) return { ...await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, revision.markdown, new Uint8Array(markdownPrimaryBootstrapIndex), origin, authConstants, bucket, true, revision.uploads, revision.wpContent, revision.wpContentDeleted, includeStaticSiteImporter), pointer } } -async function bootstrapCanonicalRuntime(env: RuntimeEnv, coordinator: RevisionCoordinator, requestUrl: string, lease: Lease): Promise { +async function bootstrapCanonicalRuntime(env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext, requestUrl: string, lease: Lease): Promise { if (!env.WORDPRESS_ADMIN_PASSWORD) throw new Error("WORDPRESS_ADMIN_PASSWORD is required to bootstrap a complete canonical WordPress revision.") - const origin = new URL(requestUrl).origin - const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, await packagedCanonicalMarkdownSeed(), new Uint8Array(markdownPrimaryBootstrapIndex), origin, await canonicalWordPressAuthConstants(env), env.WORDPRESS_STATE_BUCKET, true) + const origin = site.origin + const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, await packagedCanonicalMarkdownSeed(), new Uint8Array(markdownPrimaryBootstrapIndex), origin, await canonicalWordPressAuthConstants(env, site), env.WORDPRESS_STATE_BUCKET, true) try { const passwordFile = "/tmp/wordpress-admin-password" - runtime.php.writeFile(passwordFile, new TextEncoder().encode(env.WORDPRESS_ADMIN_PASSWORD)) + const adminPassword = await deriveSiteCredential(env.WORDPRESS_ADMIN_PASSWORD, site.id, "admin-password") + runtime.php.writeFile(passwordFile, new TextEncoder().encode(adminPassword)) const passwordOutput = (await runtime.php.run({ code: canonicalBootstrapPasswordCode(passwordFile) })).text.trim() if (passwordOutput !== "password-updated") throw new Error("Canonical bootstrap did not update the admin password.") const urlOutput = (await runtime.php.run({ code: canonicalBootstrapUrlCode(origin) })).text.trim() if (urlOutput !== "urls-updated") throw new Error("Canonical bootstrap did not update the site URLs.") const flushOutput = (await runtime.php.run({ code: canonicalBootstrapFlushCode() })).text.trim() if (flushOutput !== "flushed") throw new Error("MDI did not confirm canonical bootstrap flush.") - const pointer = await persistMarkdownRevision(env.WORDPRESS_STATE_BUCKET, collectRuntimeFiles(runtime.php, MARKDOWN_ROOT)) + const pointer = await persistMarkdownRevision(env.WORDPRESS_STATE_BUCKET, collectRuntimeFiles(runtime.php, MARKDOWN_ROOT), site) await commitLease(coordinator, requestUrl, lease, pointer) return { ...runtime, pointer } } catch (error) { @@ -1256,25 +1270,25 @@ require '/wordpress/wp-load.php'; $GLOBALS['wpdb']->flush_canonical_writes(); echo 'flushed';` } -async function canonicalWordPressAuthConstants(env: RuntimeEnv): Promise> { - return deriveWordPressAuthConstants(env.WORDPRESS_AUTH_SECRET ?? "", "default") +async function canonicalWordPressAuthConstants(env: RuntimeEnv, site: SiteContext): Promise> { + return deriveWordPressAuthConstants(env.WORDPRESS_AUTH_SECRET ?? "", site.id) } -async function persistRuntime(bucket: R2Bucket, runtime: Runtime, changes: MarkdownChanges, diagnosticsStartedAt = Date.now(), retained = new MutationRetainedBytes()): Promise { +async function persistRuntime(bucket: R2Bucket, runtime: Runtime, changes: MarkdownChanges, site: SiteContext, diagnosticsStartedAt = Date.now(), retained = new MutationRetainedBytes()): Promise { validateMarkdownChanges(changes) - const currentManifest = await readMarkdownManifest(bucket, runtime.pointer) + const currentManifest = await readMarkdownManifest(bucket, runtime.pointer, site) if (!currentManifest) throw new Error(`R2 Markdown manifest is missing: ${runtime.pointer.manifestKey}`) - validateUploadManifestFiles(currentManifest.uploads ?? []) - validateWpContentManifestFiles(currentManifest.wpContent ?? []) + validateUploadManifestFiles(currentManifest.uploads ?? [], site) + validateWpContentManifestFiles(currentManifest.wpContent ?? [], site) validateWpContentDeletedPaths(currentManifest.wpContentDeleted ?? []) const changedPaths = [...changes.created, ...changes.changed].sort((left, right) => left.localeCompare(right)) const uploads = await collectUploadFiles(runtime.php) logMutationPhase(diagnosticsStartedAt, "upload-inventory", retained, { files: uploads.length, bytes: sumMetadataBytes(uploads) }) - const uploadManifestFiles = await persistRuntimeObjects(bucket, runtime.php, UPLOADS_ROOT, uploads, currentManifest.uploads ?? [], R2_UPLOAD_OBJECT_PREFIX, retained) + const uploadManifestFiles = await persistRuntimeObjects(bucket, runtime.php, UPLOADS_ROOT, uploads, currentManifest.uploads ?? [], siteStorageKeys(site).uploadObjectPrefix, retained) logMutationPhase(diagnosticsStartedAt, "upload-persist", retained, { files: uploadManifestFiles.length }) const wpContent = await collectWpContentFiles(runtime.php) logMutationPhase(diagnosticsStartedAt, "wp-content-inventory", retained, { files: wpContent.files.length, bytes: sumMetadataBytes(wpContent.files), deleted: wpContent.deleted.length }) - const wpContentManifestFiles = await persistRuntimeObjects(bucket, runtime.php, "/wordpress/wp-content", wpContent.files, currentManifest.wpContent ?? [], R2_WP_CONTENT_OBJECT_PREFIX, retained) + const wpContentManifestFiles = await persistRuntimeObjects(bucket, runtime.php, "/wordpress/wp-content", wpContent.files, currentManifest.wpContent ?? [], siteStorageKeys(site).wpContentObjectPrefix, retained) logMutationPhase(diagnosticsStartedAt, "wp-content-persist", retained, { files: wpContentManifestFiles.length }) const manifestFiles = new Map(currentManifest.files.map((file) => [file.path, file])) @@ -1287,7 +1301,7 @@ async function persistRuntime(bucket: R2Bucket, runtime: Runtime, changes: Markd retained.retain(bytes.byteLength) try { const sha256 = await sha256Hex(bytes) - const objectKey = `${R2_MARKDOWN_OBJECT_PREFIX}/${sha256}` + const objectKey = `${siteStorageKeys(site).markdownObjectPrefix}/${sha256}` await bucket.put(objectKey, bytes) manifestFiles.set(path, { path, objectKey, sha256, size: bytes.byteLength }) } finally { @@ -1302,7 +1316,7 @@ async function persistRuntime(bucket: R2Bucket, runtime: Runtime, changes: Markd && JSON.stringify(currentManifest.wpContent ?? []) === JSON.stringify(wpContentManifestFiles) && JSON.stringify(currentManifest.wpContentDeleted ?? []) === JSON.stringify(wpContent.deleted) if (unchanged) return runtime.pointer - return persistMarkdownManifest(bucket, files, uploadManifestFiles, wpContentManifestFiles, wpContent.deleted) + return persistMarkdownManifest(bucket, files, site, uploadManifestFiles, wpContentManifestFiles, wpContent.deleted) } function readCanonicalChanges(php: PHP): MarkdownChanges { @@ -1348,7 +1362,7 @@ async function runStaticArtifactImport(runtime: Runtime, input: StaticArtifactIm return result } -async function runScheduledWordPressCron(env: RuntimeEnv, coordinator: RevisionCoordinator, scheduledTime: number): Promise { +async function runScheduledWordPressCron(env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext, scheduledTime: number): Promise { const started = Date.now() const evidence: CronInvocationEvidence = { schema: "wp-codebox/cloudflare-cron/v1", @@ -1358,7 +1372,7 @@ async function runScheduledWordPressCron(env: RuntimeEnv, coordinator: RevisionC events: [], status: "completed", } - const requestUrl = `${SITE_URL}/wp-cron.php?doing_wp_cron=${scheduledTime}` + const requestUrl = `${site.origin}/wp-cron.php?doing_wp_cron=${scheduledTime}` while (evidence.events.length < MAX_CRON_EVENTS_PER_INVOCATION && Date.now() - started < MAX_CRON_INVOCATION_MS) { const lease = await acquireLease(coordinator, requestUrl) if (!lease.pointer) { @@ -1369,25 +1383,25 @@ async function runScheduledWordPressCron(env: RuntimeEnv, coordinator: RevisionC let runtime: Runtime | undefined let finalized = false try { - runtime = await getRuntime(env, lease.pointer, SITE_URL) + runtime = await getRuntime(env, lease.pointer, site) const event = await runNextCronEvent(runtime) if (!event.executed) { await releaseLease(coordinator, requestUrl, lease) finalized = true - await discardRuntime(runtime) + await discardRuntime(runtime, site) break } - const next = await persistRuntime(env.WORDPRESS_STATE_BUCKET, runtime, event.canonicalChanges) - const currentPublication = await readCurrentPublication(env.WORDPRESS_STATE_BUCKET) - await discardRuntime(runtime) + const next = await persistRuntime(env.WORDPRESS_STATE_BUCKET, runtime, event.canonicalChanges, site) + const currentPublication = await readCurrentPublication(env.WORDPRESS_STATE_BUCKET, site) + await discardRuntime(runtime, site) runtime = undefined - const publicationJob = await enqueuePublicationJob(env.WORDPRESS_STATE_BUCKET, lease, next, currentPublication, event.publicationChanges) + const publicationJob = await enqueuePublicationJob(env.WORDPRESS_STATE_BUCKET, lease, next, currentPublication, event.publicationChanges, site) await commitLease(coordinator, requestUrl, lease, next) finalized = true evidence.events.push({ hook: event.hook, timestamp: event.timestamp, revision: next.revision, publication: publicationJob ? "queued" : "unchanged" }) } catch (error) { if (!finalized) await abortLease(coordinator, requestUrl, lease) - if (runtime) await discardRuntime(runtime) + if (runtime) await discardRuntime(runtime, site) throw error } } @@ -1432,12 +1446,15 @@ function isCanonicalRelativePath(path: string): boolean { return path.length > 0 && !path.startsWith("/") && !path.includes("\\") && !path.split("/").includes("..") } -async function readCanonicalRevision(bucket: R2Bucket, pointer: MarkdownPointer): Promise<{ markdown: RuntimeFile[]; uploads: RuntimeFile[]; wpContent: RuntimeFile[]; wpContentDeleted: string[] }> { +async function readCanonicalRevision(bucket: R2Bucket, pointer: MarkdownPointer, site: SiteContext): Promise<{ markdown: RuntimeFile[]; uploads: RuntimeFile[]; wpContent: RuntimeFile[]; wpContentDeleted: string[] }> { + if (!isCanonicalRestorePointer(pointer, site)) throw new Error("Canonical pointer belongs to a different site namespace.") const manifestObject = await bucket.get(pointer.manifestKey) if (!manifestObject) throw new Error(`R2 Markdown manifest is missing: ${pointer.manifestKey}`) const manifest = await manifestObject.json() - validateUploadManifestFiles(manifest.uploads ?? []) - validateWpContentManifestFiles(manifest.wpContent ?? []) + if (!samePointer(manifest, pointer) || !Array.isArray(manifest.files)) throw new Error("Canonical manifest identity is invalid.") + validateMarkdownManifestFiles(manifest.files, site) + validateUploadManifestFiles(manifest.uploads ?? [], site) + validateWpContentManifestFiles(manifest.wpContent ?? [], site) validateWpContentDeletedPaths(manifest.wpContentDeleted ?? []) const [markdown, uploads, wpContent] = await Promise.all([ readManifestFiles(bucket, manifest.files, "Markdown"), @@ -1457,15 +1474,15 @@ async function readManifestFiles(bucket: R2Bucket, files: MarkdownManifestFile[] })) } -async function persistMarkdownRevision(bucket: R2Bucket, files: RuntimeFile[], current?: MarkdownPointer, changes?: MarkdownChanges, uploads: RuntimeFile[] = [], wpContent: RuntimeFile[] = [], wpContentDeleted: string[] = []): Promise { - const currentManifest = current ? await readMarkdownManifest(bucket, current) : null +async function persistMarkdownRevision(bucket: R2Bucket, files: RuntimeFile[], site: SiteContext = DEFAULT_SITE_CONTEXT, current?: MarkdownPointer, changes?: MarkdownChanges, uploads: RuntimeFile[] = [], wpContent: RuntimeFile[] = [], wpContentDeleted: string[] = []): Promise { + const currentManifest = current ? await readMarkdownManifest(bucket, current, site) : null if (current && !currentManifest) throw new Error(`R2 Markdown manifest is missing: ${current.manifestKey}`) - validateUploadManifestFiles(currentManifest?.uploads ?? []) - validateWpContentManifestFiles(currentManifest?.wpContent ?? []) + validateUploadManifestFiles(currentManifest?.uploads ?? [], site) + validateWpContentManifestFiles(currentManifest?.wpContent ?? [], site) validateWpContentDeletedPaths(currentManifest?.wpContentDeleted ?? []) validateWpContentDeletedPaths(wpContentDeleted) - const uploadManifestFiles = await persistUploadObjects(bucket, uploads, currentManifest?.uploads ?? []) - const wpContentManifestFiles = await persistWpContentObjects(bucket, wpContent, currentManifest?.wpContent ?? []) + const uploadManifestFiles = await persistUploadObjects(bucket, uploads, currentManifest?.uploads ?? [], site) + const wpContentManifestFiles = await persistWpContentObjects(bucket, wpContent, currentManifest?.wpContent ?? [], site) const uploadsUnchanged = JSON.stringify(currentManifest?.uploads ?? []) === JSON.stringify(uploadManifestFiles) const wpContentUnchanged = JSON.stringify(currentManifest?.wpContent ?? []) === JSON.stringify(wpContentManifestFiles) const wpContentDeletedUnchanged = JSON.stringify(currentManifest?.wpContentDeleted ?? []) === JSON.stringify(wpContentDeleted) @@ -1479,18 +1496,18 @@ async function persistMarkdownRevision(bucket: R2Bucket, files: RuntimeFile[], c const file = filesByPath.get(path) if (!file) throw new Error(`MDI changed path is missing from the canonical runtime: ${path}`) const sha256 = await sha256Hex(file.bytes) - const objectKey = `${R2_MARKDOWN_OBJECT_PREFIX}/${sha256}` + const objectKey = `${siteStorageKeys(site).markdownObjectPrefix}/${sha256}` await bucket.put(objectKey, file.bytes) manifestFiles.set(path, { path, objectKey, sha256, size: file.bytes.byteLength }) } - return persistMarkdownManifest(bucket, [...manifestFiles.values()].sort((left, right) => left.path.localeCompare(right.path)), uploadManifestFiles, wpContentManifestFiles, wpContentDeleted) + return persistMarkdownManifest(bucket, [...manifestFiles.values()].sort((left, right) => left.path.localeCompare(right.path)), site, uploadManifestFiles, wpContentManifestFiles, wpContentDeleted) } const currentFiles = new Map(currentManifest?.files.map((file) => [file.path, file]) ?? []) const manifestFiles = await Promise.all(files.map(async (file): Promise => { const sha256 = await sha256Hex(file.bytes) const existing = currentFiles.get(file.path) if (existing?.sha256 === sha256 && existing.size === file.bytes.byteLength) return existing - const objectKey = `${R2_MARKDOWN_OBJECT_PREFIX}/${sha256}` + const objectKey = `${siteStorageKeys(site).markdownObjectPrefix}/${sha256}` if (current || !await bucket.head(objectKey)) await bucket.put(objectKey, file.bytes) return { path: file.path, objectKey, sha256, size: file.bytes.byteLength } })) @@ -1498,10 +1515,10 @@ async function persistMarkdownRevision(bucket: R2Bucket, files: RuntimeFile[], c if (JSON.stringify(currentManifest.files) === JSON.stringify(manifestFiles) && uploadsUnchanged && wpContentUnchanged && wpContentDeletedUnchanged) return current } - return persistMarkdownManifest(bucket, manifestFiles, uploadManifestFiles, wpContentManifestFiles, wpContentDeleted) + return persistMarkdownManifest(bucket, manifestFiles, site, uploadManifestFiles, wpContentManifestFiles, wpContentDeleted) } -async function persistUploadObjects(bucket: R2Bucket, files: RuntimeFile[], current: MarkdownManifestFile[]): Promise { +async function persistUploadObjects(bucket: R2Bucket, files: RuntimeFile[], current: MarkdownManifestFile[], site: SiteContext): Promise { validateUploadFiles(files) const currentFiles = new Map(current.map((file) => [file.path, file])) const persisted: MarkdownManifestFile[] = [] @@ -1512,14 +1529,14 @@ async function persistUploadObjects(bucket: R2Bucket, files: RuntimeFile[], curr persisted.push(existing) continue } - const objectKey = `${R2_UPLOAD_OBJECT_PREFIX}/${sha256}` + const objectKey = `${siteStorageKeys(site).uploadObjectPrefix}/${sha256}` if (!await bucket.head(objectKey)) await bucket.put(objectKey, file.bytes) persisted.push({ path: file.path, objectKey, sha256, size: file.bytes.byteLength }) } return persisted } -async function persistWpContentObjects(bucket: R2Bucket, files: RuntimeFile[], current: MarkdownManifestFile[]): Promise { +async function persistWpContentObjects(bucket: R2Bucket, files: RuntimeFile[], current: MarkdownManifestFile[], site: SiteContext): Promise { validateWpContentMetadata(files.map((file) => ({ path: file.path, size: file.bytes.byteLength }))) const currentFiles = new Map(current.map((file) => [file.path, file])) const persisted: MarkdownManifestFile[] = [] @@ -1530,7 +1547,7 @@ async function persistWpContentObjects(bucket: R2Bucket, files: RuntimeFile[], c persisted.push(existing) continue } - const objectKey = `${R2_WP_CONTENT_OBJECT_PREFIX}/${sha256}` + const objectKey = `${siteStorageKeys(site).wpContentObjectPrefix}/${sha256}` if (!await bucket.head(objectKey)) await bucket.put(objectKey, file.bytes) persisted.push({ path: file.path, objectKey, sha256, size: file.bytes.byteLength }) } @@ -1541,9 +1558,9 @@ function validateUploadFiles(files: RuntimeFile[]): void { validateUploadMetadata(files.map((file) => ({ path: file.path, size: file.bytes.byteLength }))) } -async function persistMarkdownManifest(bucket: R2Bucket, files: MarkdownManifestFile[], uploads: MarkdownManifestFile[] = [], wpContent: MarkdownManifestFile[] = [], wpContentDeleted: string[] = []): Promise { +async function persistMarkdownManifest(bucket: R2Bucket, files: MarkdownManifestFile[], site: SiteContext, uploads: MarkdownManifestFile[] = [], wpContent: MarkdownManifestFile[] = [], wpContentDeleted: string[] = []): Promise { const revision = crypto.randomUUID() - const manifestKey = `${R2_MARKDOWN_REVISION_PREFIX}/${revision}.json` + const manifestKey = `${siteStorageKeys(site).markdownRevisionPrefix}/${revision}.json` const persistedAt = new Date().toISOString() const pointer: MarkdownPointer = { revision, manifestKey, persistedAt } const manifest: MarkdownManifest = { ...pointer, files, uploads, wpContent, wpContentDeleted } @@ -1553,9 +1570,30 @@ async function persistMarkdownManifest(bucket: R2Bucket, files: MarkdownManifest return pointer } -async function readMarkdownManifest(bucket: R2Bucket, pointer: MarkdownPointer): Promise { +async function readMarkdownManifest(bucket: R2Bucket, pointer: MarkdownPointer, site: SiteContext): Promise { + if (!isCanonicalRestorePointer(pointer, site)) return null const object = await bucket.get(pointer.manifestKey) - return object ? object.json() : null + if (!object) return null + const manifest = await object.json() + if (!samePointer(manifest, pointer) || !Array.isArray(manifest.files)) throw new Error("Canonical manifest identity is invalid.") + validateMarkdownManifestFiles(manifest.files, site) + validateUploadManifestFiles(manifest.uploads ?? [], site) + validateWpContentManifestFiles(manifest.wpContent ?? [], site) + validateWpContentDeletedPaths(manifest.wpContentDeleted ?? []) + return manifest +} + +function validateMarkdownManifestFiles(files: MarkdownManifestFile[], site: SiteContext): void { + const prefix = `${siteStorageKeys(site).markdownObjectPrefix}/` + const paths = new Set() + for (const file of files) { + if (!file || typeof file !== "object" || !isCanonicalRelativePath(file.path) || paths.has(file.path) + || typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(file.sha256) + || file.objectKey !== `${prefix}${file.sha256}` || !Number.isSafeInteger(file.size) || file.size < 0) { + throw new Error("Canonical Markdown manifest files are invalid.") + } + paths.add(file.path) + } } function canonicalBootstrapSetupCode(passwordFile: string, origin: string): string { @@ -1653,7 +1691,7 @@ async function runBootProbe(phase: string, bucket: R2Bucket): Promise } if (phase === "mdi-shortinit") { - const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, initialMarkdownFiles(), new Uint8Array(markdownPrimaryBootstrapIndex), SITE_URL, {}, bucket) + const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, initialMarkdownFiles(), new Uint8Array(markdownPrimaryBootstrapIndex), PROBE_SITE_URL, {}, bucket) try { const evidence = (await runtime.php.run({ code: " $wp_version, 'markdownDropin' => defined('MARKDOWN_DB_DROPIN'), 'markdownMode' => defined('MARKDOWN_DB_MODE') ? MARKDOWN_DB_MODE : '']);", @@ -1665,7 +1703,7 @@ async function runBootProbe(phase: string, bucket: R2Bucket): Promise } if (phase === "mdi-wordpress" || phase === "mdi-option" || phase === "mdi-insert") { - const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, initialMarkdownFiles(), new Uint8Array(markdownPrimaryBootstrapIndex), SITE_URL, {}, bucket) + const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, initialMarkdownFiles(), new Uint8Array(markdownPrimaryBootstrapIndex), PROBE_SITE_URL, {}, bucket) try { const operation = phase === "mdi-option" ? "$updated = update_option('wp_codebox_mdi_probe', 1); $result = ['updated' => $updated];" @@ -1682,7 +1720,7 @@ async function runBootProbe(phase: string, bucket: R2Bucket): Promise } if (["mdi-includes", "mdi-embed", "mdi-textdomain", "mdi-ai-client", "mdi-plugin-constants", "mdi-muplugins", "mdi-plugins", "mdi-globals", "mdi-theme", "mdi-site-health-class", "mdi-site-health", "mdi-current-user", "mdi-init", "mdi-wp-loaded"].includes(phase)) { - const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, initialMarkdownFiles(), new Uint8Array(markdownPrimaryBootstrapIndex), SITE_URL, {}, bucket) + const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, initialMarkdownFiles(), new Uint8Array(markdownPrimaryBootstrapIndex), PROBE_SITE_URL, {}, bucket) try { const evidence = (await runtime.php.run({ code: wordpressProbeCode(phase) })).text.trim() return probeResponse(phase, JSON.parse(evidence) as Record) @@ -1699,7 +1737,7 @@ async function runBootProbe(phase: string, bucket: R2Bucket): Promise new Uint8Array(wordpressInstallSeed), undefined, undefined, - SITE_URL, + PROBE_SITE_URL, {}, bucket, ) @@ -1730,7 +1768,7 @@ async function runBootProbe(phase: string, bucket: R2Bucket): Promise undefined, await packagedCanonicalMarkdownSeed(), new Uint8Array(markdownPrimaryBootstrapIndex), - SITE_URL, + PROBE_SITE_URL, {}, bucket, true, @@ -1751,7 +1789,7 @@ async function runBootProbe(phase: string, bucket: R2Bucket): Promise undefined, undefined, undefined, - SITE_URL, + PROBE_SITE_URL, {}, bucket, ) @@ -1813,7 +1851,7 @@ async function bootWordPressRuntime( databaseSeed?: Uint8Array, markdownFiles?: RuntimeFile[], markdownIndexSeed?: Uint8Array, - siteUrl = SITE_URL, + siteUrl = PROBE_SITE_URL, authConstants: Partial> = {}, runtimeBucket?: R2Bucket, shouldPatchCanonicalRuntimePoliciesAtInit = false, @@ -1884,10 +1922,10 @@ async function bootWordPressRuntime( return { php, requestHandler, wordpressVersion } } -function initialMarkdownFiles(): RuntimeFile[] { +function initialMarkdownFiles(siteUrl = PROBE_SITE_URL): RuntimeFile[] { const options = [ - { option_id: 1, option_name: "siteurl", option_value: SITE_URL, autoload: "on" }, - { option_id: 2, option_name: "home", option_value: SITE_URL, autoload: "on" }, + { option_id: 1, option_name: "siteurl", option_value: siteUrl, autoload: "on" }, + { option_id: 2, option_name: "home", option_value: siteUrl, autoload: "on" }, { option_id: 3, option_name: "blogname", option_value: "WP Codebox Cloudflare Runtime", autoload: "on" }, ] return options.map((option) => ({ @@ -2270,7 +2308,7 @@ async function readStaticSiteImporterArtifact(bucket: R2Bucket): Promise { +async function serveWordPressWpContent(request: Request, bucket: R2Bucket, coordinator: RevisionCoordinator, site: SiteContext): Promise { if (request.method !== "GET" && request.method !== "HEAD") return null const url = new URL(request.url) if (!url.pathname.startsWith("/wp-content/") || !PUBLIC_WP_CONTENT_EXTENSION.test(url.pathname)) return null @@ -2284,7 +2322,7 @@ async function serveWordPressWpContent(request: Request, bucket: R2Bucket, coord const state = await coordinator.state() if (!state.pointer) return null const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default - const cacheKey = wordPressRevisionCacheKey(request, state.pointer) + const cacheKey = wordPressRevisionCacheKey(request, state.pointer, site) if (request.method === "GET" && cache) { try { const cached = await cache.match(cacheKey) @@ -2293,8 +2331,8 @@ async function serveWordPressWpContent(request: Request, bucket: R2Bucket, coord // R2 remains authoritative when the edge cache is unavailable. } } - const manifest = await readMarkdownManifest(bucket, state.pointer) - validateWpContentManifestFiles(manifest?.wpContent ?? []) + const manifest = await readMarkdownManifest(bucket, state.pointer, site) + validateWpContentManifestFiles(manifest?.wpContent ?? [], site) validateWpContentDeletedPaths(manifest?.wpContentDeleted ?? []) if (manifest?.wpContentDeleted?.includes(path)) return new Response("WordPress content not found.", { status: 404 }) const file = manifest?.wpContent?.find((candidate) => candidate.path === path) @@ -2325,7 +2363,7 @@ async function serveWordPressWpContent(request: Request, bucket: R2Bucket, coord return response } -async function serveWordPressUpload(request: Request, bucket: R2Bucket, coordinator: RevisionCoordinator): Promise { +async function serveWordPressUpload(request: Request, bucket: R2Bucket, coordinator: RevisionCoordinator, site: SiteContext): Promise { if (request.method !== "GET" && request.method !== "HEAD") return null const url = new URL(request.url) if (!url.pathname.startsWith("/wp-content/uploads/")) return null @@ -2339,7 +2377,7 @@ async function serveWordPressUpload(request: Request, bucket: R2Bucket, coordina const state = await coordinator.state() if (!state.pointer) return null const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default - const cacheKey = wordPressRevisionCacheKey(request, state.pointer) + const cacheKey = wordPressRevisionCacheKey(request, state.pointer, site) if (request.method === "GET" && cache) { try { const cached = await cache.match(cacheKey) @@ -2348,8 +2386,8 @@ async function serveWordPressUpload(request: Request, bucket: R2Bucket, coordina // R2 remains authoritative when the edge cache is unavailable. } } - const manifest = await readMarkdownManifest(bucket, state.pointer) - validateUploadManifestFiles(manifest?.uploads ?? []) + const manifest = await readMarkdownManifest(bucket, state.pointer, site) + validateUploadManifestFiles(manifest?.uploads ?? [], site) const file = manifest?.uploads?.find((candidate) => candidate.path === path) if (!file) return new Response("WordPress upload not found.", { status: 404 }) const object = await bucket.get(file.objectKey) @@ -2378,9 +2416,10 @@ async function serveWordPressUpload(request: Request, bucket: R2Bucket, coordina return response } -function wordPressRevisionCacheKey(request: Request, pointer: MarkdownPointer): Request { +function wordPressRevisionCacheKey(request: Request, pointer: MarkdownPointer, site: SiteContext): Request { const url = new URL(request.url) url.searchParams.set("__wp_codebox_revision", pointer.revision) + url.searchParams.set("__wp_codebox_site", site.id) return new Request(url, { method: "GET" }) } diff --git a/packages/runtime-cloudflare/src/wp-content-persistence.ts b/packages/runtime-cloudflare/src/wp-content-persistence.ts index 253018bdb..67037210a 100644 --- a/packages/runtime-cloudflare/src/wp-content-persistence.ts +++ b/packages/runtime-cloudflare/src/wp-content-persistence.ts @@ -1,4 +1,6 @@ -export const R2_WP_CONTENT_OBJECT_PREFIX = "sites/default/wp-content/objects" +import { DEFAULT_SITE_CONTEXT, siteStorageKeys, type SiteContext } from "./site-context.js" + +export const R2_WP_CONTENT_OBJECT_PREFIX = siteStorageKeys(DEFAULT_SITE_CONTEXT).wpContentObjectPrefix export const MAX_WP_CONTENT_FILES = 5000 export const MAX_WP_CONTENT_FILE_BYTES = 8 * 1024 * 1024 export const MAX_WP_CONTENT_TOTAL_BYTES = 64 * 1024 * 1024 @@ -28,10 +30,10 @@ export function validateWpContentMetadata(value: unknown): asserts value is WpCo if (total > MAX_WP_CONTENT_TOTAL_BYTES) throw new Error("Canonical wp-content files exceed their byte budget.") } -export function validateWpContentManifestFiles(value: unknown): asserts value is WpContentManifestFile[] { +export function validateWpContentManifestFiles(value: unknown, site: SiteContext = DEFAULT_SITE_CONTEXT): asserts value is WpContentManifestFile[] { validateWpContentMetadata(value) for (const file of value as WpContentManifestFile[]) { - if (!/^[a-f0-9]{64}$/.test(file.sha256) || file.objectKey !== `${R2_WP_CONTENT_OBJECT_PREFIX}/${file.sha256}`) { + if (!/^[a-f0-9]{64}$/.test(file.sha256) || file.objectKey !== `${siteStorageKeys(site).wpContentObjectPrefix}/${file.sha256}`) { throw new Error("Canonical wp-content manifest contains an invalid file.") } } diff --git a/scripts/cloudflare-local-gate.mjs b/scripts/cloudflare-local-gate.mjs index 961e57e79..2ebb2ee7b 100644 --- a/scripts/cloudflare-local-gate.mjs +++ b/scripts/cloudflare-local-gate.mjs @@ -1,5 +1,5 @@ import { spawn } from "node:child_process" -import { createHash } from "node:crypto" +import { createHash, createHmac } from "node:crypto" import { mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" @@ -15,9 +15,15 @@ const coordinator = process.argv.includes("--coordinator=d1") ? "d1" : "durable- const wranglerConfig = coordinator === "d1" ? "packages/runtime-cloudflare/wrangler.d1.jsonc" : "packages/runtime-cloudflare/wrangler.jsonc" const stateDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-cloudflare-gate-")) const cookies = [] +let siteContexts = [{ id: "default", hostname: "127.0.0.1", origin }] let child let output = "" +function siteCredential(rootCredential, siteId, purpose) { + if (siteId === "default") return rootCredential + return createHmac("sha256", rootCredential).update(`wp-codebox/site-credential/v1\0${siteId}\0${purpose}`).digest("hex") +} + try { await run("npm", ["run", "generate:cloudflare-wordpress-runtime-corpus"]) await run("npm", ["run", "provision:cloudflare-wordpress-runtime-corpus", "--", "--local", "--persist-to", stateDirectory]) @@ -105,6 +111,7 @@ try { const conflicting = await importStaticArtifact({ ...staticArtifactImport, import: { ...staticArtifactImport.import, slug: "different-artifact-site" } }, 409) const conflictStateAfter = await (await fetch(`${origin}/?phase=r2-state`)).json() if (conflicting.status !== "conflict" || conflictStateAfter.version !== duplicateStateBefore.version || conflictStateAfter.pointer?.revision !== duplicateStateBefore.pointer?.revision) throw new Error("Conflicting static artifact replay changed canonical state.") + await assertTwoSiteIsolation() console.log(`Cloudflare local runtime gate passed with ${coordinator} coordination: canonical full-boot probe, explanatory homepage, complete block styles, coordinator-free R2 publication reads, login, dashboard, post editor, concurrent canonical mutations, authenticated REST post and media creation, plugin ZIP installation and activation, direct R2 upload serving, frontend/admin/editor assets, cold-restart persistence, and bounded durable scheduled callback execution.`) } finally { await stopWorker() @@ -156,9 +163,82 @@ async function provisionStaticArtifact() { } } +async function assertTwoSiteIsolation() { + await stopWorker() + siteContexts = [ + { id: "alpha", hostname: "alpha.localhost", origin: `http://alpha.localhost:${port}` }, + { id: "beta", hostname: "beta.localhost", origin: `http://beta.localhost:${port}` }, + ] + await startWorker() + const alpha = siteContexts[0].origin + const beta = siteContexts[1].origin + const unknown = await fetch(`http://unknown.localhost:${port}/?phase=r2-state`) + if (unknown.status !== 421) throw new Error(`Unknown hostname reached the runtime: ${unknown.status}.`) + + const mutate = async (siteOrigin) => { + const response = await fetch(`${siteOrigin}/?phase=r2-mutate`, { method: "POST" }) + if (!response.ok) throw new Error(`Site mutation failed for ${siteOrigin}: ${response.status} ${await response.text()}`) + return response.json() + } + const [alphaFirst, betaFirst] = await Promise.all([mutate(alpha), mutate(beta)]) + const alphaSecond = await mutate(alpha) + if (alphaFirst.revisionValue !== 1 || alphaSecond.revisionValue !== 2 || betaFirst.revisionValue !== 1) throw new Error("Site mutations did not retain independent canonical histories.") + + const [alphaState, betaState] = await Promise.all([ + fetch(`${alpha}/?phase=r2-state`).then((response) => response.json()), + fetch(`${beta}/?phase=r2-state`).then((response) => response.json()), + ]) + if (alphaState.version !== 3 || betaState.version !== 2 || alphaState.pointer?.revision === betaState.pointer?.revision + || !alphaState.pointer?.manifestKey.startsWith("sites/alpha/markdown/revisions/") + || !betaState.pointer?.manifestKey.startsWith("sites/beta/markdown/revisions/")) { + throw new Error(`Site coordinator or R2 state crossed namespaces: ${JSON.stringify({ alphaState, betaState })}`) + } + + const [alphaPosts, betaPosts] = await Promise.all([ + fetch(`${alpha}/wp-json/wp/v2/posts?slug=cloudflare-r2-proof-2`).then((response) => response.json()), + fetch(`${beta}/wp-json/wp/v2/posts?slug=cloudflare-r2-proof-2`).then((response) => response.json()), + ]) + if (!Array.isArray(alphaPosts) || alphaPosts.length !== 1 || !Array.isArray(betaPosts) || betaPosts.length !== 0) throw new Error("Site REST collections crossed canonical namespaces.") + + const loginStatus = async (siteOrigin, candidatePassword) => fetch(`${siteOrigin}/wp-login.php`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ log: "admin", pwd: candidatePassword, redirect_to: `${siteOrigin}/wp-admin/`, "wp-submit": "Log In" }), + redirect: "manual", + }).then((response) => response.status) + if (await loginStatus(alpha, siteCredential(password, "beta", "admin-password")) !== 200) throw new Error("Alpha accepted Beta's admin credential.") + if (![301, 302].includes(await loginStatus(alpha, siteCredential(password, "alpha", "admin-password")))) throw new Error("Alpha rejected its site-scoped admin credential.") + await Promise.all([fetch(alpha), fetch(beta)]) + const [alphaPublishState, betaPublishState] = await Promise.all([ + fetch(`${alpha}/?phase=r2-state`).then((response) => response.json()), + fetch(`${beta}/?phase=r2-state`).then((response) => response.json()), + ]) + + const publish = async (siteOrigin, siteId) => { + const response = await fetch(`${siteOrigin}/?phase=operator-publish`, { + method: "POST", + headers: { authorization: `Bearer ${siteCredential(operatorToken, siteId, "operator-token")}`, "content-type": "application/json" }, + body: JSON.stringify({ routes: ["/"] }), + }) + if (!response.ok) throw new Error(`Site publication failed for ${siteOrigin}: ${response.status} ${await response.text()}`) + return response.json() + } + const rejectedCredentialReuse = await fetch(`${alpha}/?phase=operator-publish`, { + method: "POST", + headers: { authorization: `Bearer ${siteCredential(operatorToken, "beta", "operator-token")}`, "content-type": "application/json" }, + body: JSON.stringify({ routes: ["/"] }), + }) + if (rejectedCredentialReuse.status !== 401) throw new Error(`Cross-site operator credential reuse returned ${rejectedCredentialReuse.status} instead of 401.`) + const [alphaPublication, betaPublication] = await Promise.all([publish(alpha, "alpha"), publish(beta, "beta")]) + if (alphaPublication.revision === betaPublication.revision || alphaPublication.canonicalRevision !== alphaPublishState.pointer?.revision || betaPublication.canonicalRevision !== betaPublishState.pointer?.revision) throw new Error("Site publication receipts crossed namespaces.") + const [alphaPublished, betaPublished] = await Promise.all([fetch(alpha), fetch(beta)]) + if (!alphaPublished.headers.get("x-wp-codebox-page-cache-source")?.startsWith("publication-") || !betaPublished.headers.get("x-wp-codebox-page-cache-source")?.startsWith("publication-")) throw new Error("Site publications did not resolve through isolated publication caches.") + console.log(`Two-site isolation passed for ${coordinator}.`) +} + async function startWorker() { output = "" - child = spawn("npm", ["exec", "--", "wrangler", "dev", "--test-scheduled", "--config", wranglerConfig, "--port", String(port), "--persist-to", stateDirectory, "--var", `WORDPRESS_ADMIN_PASSWORD:${password}`, "--var", `WORDPRESS_AUTH_SECRET:${authSecret}`, "--var", `WORDPRESS_OPERATOR_TOKEN:${operatorToken}`], { + child = spawn("npm", ["exec", "--", "wrangler", "dev", "--test-scheduled", "--config", wranglerConfig, "--port", String(port), "--persist-to", stateDirectory, "--var", `WORDPRESS_ADMIN_PASSWORD:${password}`, "--var", `WORDPRESS_AUTH_SECRET:${authSecret}`, "--var", `WORDPRESS_OPERATOR_TOKEN:${operatorToken}`, "--var", `WORDPRESS_SITE_CONTEXTS:${JSON.stringify(siteContexts)}`], { cwd: process.cwd(), // The host PAC resolves these public archive hosts through an unavailable local proxy. env: { ...process.env, NO_PROXY: "wordpress.org,github.com,codeload.github.com", no_proxy: "wordpress.org,github.com,codeload.github.com" }, diff --git a/tests/cloudflare-coordinator-site-partitioning.test.ts b/tests/cloudflare-coordinator-site-partitioning.test.ts new file mode 100644 index 000000000..2873f79a9 --- /dev/null +++ b/tests/cloudflare-coordinator-site-partitioning.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict" +import { DatabaseSync } from "node:sqlite" +import test from "node:test" +import { D1RevisionCoordinator } from "../packages/runtime-cloudflare/src/d1-revision-coordinator.js" +import { WordPressStateCoordinator } from "../packages/runtime-cloudflare/src/state-coordinator.js" + +function d1Database(): D1Database { + const database = new DatabaseSync(":memory:") + return { + prepare(query: string) { + const statement = database.prepare(query) + let values: unknown[] = [] + return { + bind(...next: unknown[]) { + values = next + return this + }, + async run() { + return { meta: { changes: statement.run(...values).changes } } + }, + async first() { + return statement.get(...values) as T | null + }, + } + }, + async batch(statements: Array<{ run: () => Promise<{ meta: { changes: number } }> }>) { + return Promise.all(statements.map((statement) => statement.run())) + }, + } as unknown as D1Database +} + +function pointer(siteId: string, revision: string) { + return { + revision, + manifestKey: `sites/${siteId}/markdown/revisions/${revision}.json`, + persistedAt: "2026-07-23T00:00:00.000Z", + } +} + +test("D1 coordinator partitions state, leases, fences, and commit receipts by site ID", async () => { + const database = d1Database() + const defaultSite = new D1RevisionCoordinator(database, "default") + const alpha = new D1RevisionCoordinator(database, "alpha") + const beta = new D1RevisionCoordinator(database, "beta") + + const [alphaFence, betaFence] = await Promise.all([alpha.acquireFence(30_000), beta.acquireFence(30_000)]) + assert.notEqual(alphaFence.token, betaFence.token) + assert.deepEqual(await defaultSite.fenceStatus(), { active: false }) + await Promise.all([alpha.releaseFence(alphaFence.token), beta.releaseFence(betaFence.token)]) + + const [alphaLease, betaLease] = await Promise.all([alpha.acquire(), beta.acquire()]) + assert.equal(alphaLease.version, 0) + assert.equal(betaLease.version, 0) + await alpha.commit(alphaLease, pointer("alpha", "alpha-revision")) + await beta.commit(betaLease, pointer("beta", "beta-revision")) + + assert.deepEqual(await alpha.committed(1), pointer("alpha", "alpha-revision")) + assert.deepEqual(await beta.committed(1), pointer("beta", "beta-revision")) + assert.deepEqual(await defaultSite.state(), { + schema: "wp-codebox/cloudflare-wordpress-state/v2", + store: "d1", + pointer: null, + version: 0, + }) +}) + +test("Durable Object coordinator writes the matching site pointer and preserves default compatibility", async () => { + const objects = new Map() + const coordinator = (values: Map) => new WordPressStateCoordinator({ + id: { toString: () => "test-do" }, + storage: { + get: async (key: string) => values.get(key) as T | undefined, + put: async (key: string, value: unknown) => { values.set(key, value) }, + delete: async (key: string) => { values.delete(key) }, + transaction: async (callback: (transaction: { put: (key: string, value: unknown) => Promise }) => Promise) => callback({ put: async (key, value) => { values.set(key, value) } }), + }, + } as never, { + WORDPRESS_STATE_BUCKET: { + get: async (key: string) => objects.has(key) ? { json: async () => JSON.parse(objects.get(key)!) as T } : null, + put: async (key: string, value: string) => { objects.set(key, value) }, + delete: async (key: string) => { objects.delete(key) }, + } as never, + }) + const call = async (target: WordPressStateCoordinator, siteId: string | undefined, action: string, body: Record = {}) => { + const url = new URL(`https://worker.example/?__wp_codebox_coordinator=${action}`) + if (siteId) url.searchParams.set("siteId", siteId) + return target.fetch(new Request(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })) + } + const commit = async (target: WordPressStateCoordinator, siteId: string | undefined, value: ReturnType) => { + const lease = await (await call(target, siteId, "begin")).json() as { token: string; version: number } + assert.equal((await call(target, siteId, "commit", { token: lease.token, baseRevision: null, version: lease.version, pointer: value })).status, 200) + } + + await commit(coordinator(new Map()), undefined, pointer("default", "default-revision")) + await commit(coordinator(new Map()), "alpha", pointer("alpha", "alpha-revision")) + + assert.deepEqual(JSON.parse(objects.get("sites/default/markdown/current.json")!), pointer("default", "default-revision")) + assert.deepEqual(JSON.parse(objects.get("sites/alpha/markdown/current.json")!), pointer("alpha", "alpha-revision")) +}) diff --git a/tests/cloudflare-runtime.test.ts b/tests/cloudflare-runtime.test.ts index a1614a9db..0bd8bb018 100644 --- a/tests/cloudflare-runtime.test.ts +++ b/tests/cloudflare-runtime.test.ts @@ -342,16 +342,17 @@ test("Cloudflare runtime injects composable coordinators without moving PHP out const scheduledCron = worker.slice(worker.indexOf("async function runScheduledWordPressCron"), worker.indexOf("async function runNextCronEvent")) const corpus = await readFile(new URL("../packages/runtime-cloudflare/src/wordpress-runtime-corpus.ts", import.meta.url), "utf8") - assert.match(worker, /return await runCoordinatedWordPressRequest\(request, env, coordinator, route\.kind\)/) - assert.match(worker, /let cachedRuntime/) - assert.match(worker, /cachedRuntime\.baseRevision !== pointer\.revision/) + assert.match(worker, /return await runCoordinatedWordPressRequest\(request, env, coordinator, site, route\.kind\)/) + assert.match(worker, /const cachedRuntimes = new Map/) + assert.match(worker, /cachedRuntimes\.get\(site\.id\)/) assert.match(worker, /promise\.catch\(\(\) =>/) - assert.match(coordinatedRequest, /await discardRuntime\(runtime\)\n runtime = undefined[\s\S]{0,100}const publicationJob/) - assert.match(worker, /if \(runtime\) await discardRuntime\(runtime\)/) + assert.match(coordinatedRequest, /await discardRuntime\(runtime, site\)\n runtime = undefined[\s\S]{0,100}const publicationJob/) + assert.match(worker, /if \(runtime\) await discardRuntime\(runtime, site\)/) assert.doesNotMatch(worker, /runtime\.pointer = next/) assert.match(worker, /await abortLease\(coordinator, request\.url, lease\)/) assert.match(worker, /const LEASE_ACQUISITION_TIMEOUT_MS = 100_000/) - assert.match(worker, /const revision = await readCanonicalRevision\(bucket, pointer\)/) + assert.match(worker, /const revision = await readCanonicalRevision\(bucket, pointer, site\)/) + assert.match(worker, /validateMarkdownManifestFiles\(manifest\.files, site\)/) assert.match(worker, /bootWordPressRuntime\("do-not-attempt-installing", true, true, undefined, revision\.markdown/) const noPointerBoot = worker.slice(worker.indexOf("async function bootstrapCanonicalRuntime"), worker.indexOf("async function persistRuntime")) assert.match(noPointerBoot, /packagedCanonicalMarkdownSeed\(\)/) @@ -370,8 +371,8 @@ test("Cloudflare runtime injects composable coordinators without moving PHP out assert.match(contract, /interface RevisionCoordinator/) assert.match(contract, /acquire\(ttlMs\?: number\): Promise/) assert.match(contract, /committed\(version: number\): Promise/) - assert.match(durableObjectEntry, /new DurableObjectRevisionCoordinator\(env\.WORDPRESS_STATE\.getByName\("default"\)\)/) - assert.match(d1Entry, /new D1RevisionCoordinator\(env\.WORDPRESS_STATE_DATABASE\)/) + assert.match(durableObjectEntry, /new DurableObjectRevisionCoordinator\(env\.WORDPRESS_STATE\.getByName\(site\.id\), site\.id\)/) + assert.match(d1Entry, /new D1RevisionCoordinator\(env\.WORDPRESS_STATE_DATABASE, site\.id\)/) assert.doesNotMatch(d1Entry, /DurableObject|WORDPRESS_STATE\.getByName/) assert.match(coordinator, /token: crypto\.randomUUID\(\)/) assert.match(coordinator, /record\.version\+\+/) @@ -380,15 +381,15 @@ test("Cloudflare runtime injects composable coordinators without moving PHP out assert.match(worker, /maxPhpInstances: 1/) assert.match(worker, /WORDPRESS_AUTH_SECRET/) assert.match(worker, /Canonical restore requires an empty current pointer/) - assert.match(worker, /readMarkdownManifest\(env\.WORDPRESS_STATE_BUCKET, pointer\)/) - assert.match(worker, /sites\/default\/markdown\/revisions\/\$\{candidate\.revision\}\.json/) - assert.match(worker, /canonicalWordPressAuthConstants\(env\)/) + assert.match(worker, /readMarkdownManifest\(env\.WORDPRESS_STATE_BUCKET, pointer, site\)/) + assert.match(worker, /siteStorageKeys\(site\)\.markdownRevisionPrefix/) + assert.match(worker, /canonicalWordPressAuthConstants\(env, site\)/) assert.match(worker, /authConstants/) - assert.match(worker, /const wpContentResponse = await serveWordPressWpContent\(request, env\.WORDPRESS_STATE_BUCKET, coordinator\)/) - assert.match(worker, /const publishedResponse = await servePublishedWordPressPage\(request, env\.WORDPRESS_STATE_BUCKET\)/) - assert.ok(worker.indexOf("servePublishedWordPressPage(request, env.WORDPRESS_STATE_BUCKET)") < worker.indexOf("const coordinator = resolveCoordinator(env)")) + assert.match(worker, /const wpContentResponse = await serveWordPressWpContent\(request, env\.WORDPRESS_STATE_BUCKET, coordinator, site\)/) + assert.match(worker, /const publishedResponse = await servePublishedWordPressPage\(request, env\.WORDPRESS_STATE_BUCKET, site\)/) + assert.ok(worker.indexOf("resolveSiteContextFromRequest") < worker.indexOf("servePublishedWordPressPage(request, env.WORDPRESS_STATE_BUCKET, site)")) assert.match(worker, /const staticResponse = await serveWordPressStaticAsset\(request, env\.WORDPRESS_STATE_BUCKET\)/) - assert.ok(worker.indexOf("const wpContentResponse = await serveWordPressWpContent(request, env.WORDPRESS_STATE_BUCKET, coordinator)") < worker.indexOf("const staticResponse = await serveWordPressStaticAsset(request, env.WORDPRESS_STATE_BUCKET)")) + assert.ok(worker.indexOf("const wpContentResponse = await serveWordPressWpContent(request, env.WORDPRESS_STATE_BUCKET, coordinator, site)") < worker.indexOf("const staticResponse = await serveWordPressStaticAsset(request, env.WORDPRESS_STATE_BUCKET)")) assert.ok(worker.indexOf("const staticResponse = await serveWordPressStaticAsset(request, env.WORDPRESS_STATE_BUCKET)") < worker.indexOf("const route = routeWorkerRequest(request)")) assert.match(worker, /CONCATENATE_SCRIPTS: false/) assert.match(worker, /SCRIPT_DEBUG: false/) @@ -405,17 +406,17 @@ test("Cloudflare runtime injects composable coordinators without moving PHP out assert.match(worker, /cache\.put\(cacheRequest, response\.clone\(\)\)/) assert.match(worker, /const state = await coordinator\.state\(\)/) assert.ok(worker.indexOf("const state = await coordinator.state()") < worker.indexOf("let lease = await acquireLease")) - assert.match(worker, /matchWordPressPageCache\(request, lease\.pointer, env\.WORDPRESS_STATE_BUCKET\)/) - assert.match(worker, /cacheWordPressPage\(request, lease\.pointer, response, env\.WORDPRESS_STATE_BUCKET\)/) + assert.match(worker, /matchWordPressPageCache\(request, lease\.pointer, env\.WORDPRESS_STATE_BUCKET, site\)/) + assert.match(worker, /cacheWordPressPage\(request, lease\.pointer, response, env\.WORDPRESS_STATE_BUCKET, site\)/) assert.match(worker, /__wp_codebox_revision/) - assert.match(worker, /R2_PUBLISHED_CURRENT_KEY/) - assert.match(worker, /publishedPageObjectKey\(pointer\.revision, canonicalPublicRoute\(request\)\)/) + assert.match(worker, /siteStorageKeys\(site\)\.publishedCurrent/) + assert.match(worker, /publishedPageObjectKey\(pointer\.revision, canonicalPublicRoute\(request\), site\)/) assert.match(worker, /PUBLISHED_PAGE_SCHEMA/) assert.match(worker, /"x-wp-codebox-page-cache"/) assert.match(worker, /"x-wp-codebox-page-cache-source"/) assert.match(worker, /"public, max-age=60, s-maxage=31536000"/) assert.match(worker, /"public, max-age=60, s-maxage=60"/) - assert.match(worker, /publishCanonicalWordPressPages\(request, env, coordinator\)/) + assert.match(worker, /publishCanonicalWordPressPages\(request, env, coordinator, site\)/) assert.match(worker, /PUBLICATION_CHANGES_PATH/) assert.match(worker, /add_action\( 'save_post'/) assert.match(worker, /add_action\( 'set_object_terms'/) @@ -428,32 +429,32 @@ test("Cloudflare runtime injects composable coordinators without moving PHP out assert.match(worker, /PHP_INT_MAX, 3/) assert.match(worker, /staged\.serialized, \{ onlyIf: \{ etagMatches: current\.etag \}/) assert.match(worker, /PUBLICATION_JOB_SCHEMA/) - assert.match(worker, /R2_PUBLICATION_CLAIM_PREFIX/) - assert.match(worker, /R2_PUBLICATION_RECEIPT_PREFIX/) + assert.match(worker, /publicationClaimPrefix/) + assert.match(worker, /publicationReceiptPrefix/) assert.match(worker, /async function drainNextPublicationJob/) assert.match(worker, /coordinator\.committed\(candidate\.coordinatorVersion\)/) - assert.match(worker, /\.list\(\{ prefix: `\$\{R2_PUBLICATION_JOB_PREFIX\}\/`, limit: 16 \}\)/) + assert.match(worker, /\.list\(\{ prefix: `\$\{siteStorageKeys\(site\)\.publicationJobPrefix\}\/`, limit: 16 \}\)/) assert.match(worker, /canonicalVersion: job\.coordinatorVersion/) assert.match(worker, /readWordPressPageSnapshot\(env\.WORDPRESS_STATE_BUCKET, objectKey, job\.canonical\.revision, route\)/) assert.match(worker, /if \(!await readWordPressPageSnapshot\(env\.WORDPRESS_STATE_BUCKET, objectKey, job\.canonical\.revision, route\)\) throw error/) const publicationDrain = worker.slice(worker.indexOf("async function drainNextPublicationJob"), worker.indexOf("async function compilePublicationRoutes")) - assert.match(publicationDrain, /bootRuntime\(env\.WORDPRESS_STATE_BUCKET, job\.canonical, SITE_URL/) - assert.match(publicationDrain, /compilePublicationRoutes\(runtime, \[route\], SITE_URL\)/) + assert.match(publicationDrain, /bootRuntime\(env\.WORDPRESS_STATE_BUCKET, job\.canonical, site\.origin/) + assert.match(publicationDrain, /compilePublicationRoutes\(runtime, \[route\], site\.origin\)/) assert.doesNotMatch(worker, /runtimeSiteOrigin|require '\/wordpress\/wp-load\.php'; echo get_option\('home'\)/) - assert.match(worker, /await enqueuePublicationJob\(env\.WORDPRESS_STATE_BUCKET, lease, next, currentPublication, publicationChanges\)/) + assert.match(worker, /await enqueuePublicationJob\(env\.WORDPRESS_STATE_BUCKET, lease, next, currentPublication, publicationChanges, site\)/) assert.ok(coordinatedRequest.indexOf("enqueuePublicationJob") < coordinatedRequest.indexOf("commitLease")) assert.doesNotMatch(coordinatedRequest, /compilePublicationRoutes\(runtime/) assert.doesNotMatch(coordinatedRequest, /stageIncrementalPublication/) assert.match(worker, /const upsert = changes\.all \? \[\.\.\.new Set\(\[\.\.\.existing, \.\.\.changes\.upsert\]\)\]\.sort\(\) : changes\.upsert/) assert.doesNotMatch(worker, /fallbackAll|hasCanonicalChanges/) - assert.match(scheduledCron, /await enqueuePublicationJob\(env\.WORDPRESS_STATE_BUCKET, lease, next, currentPublication, event\.publicationChanges\)/) + assert.match(scheduledCron, /await enqueuePublicationJob\(env\.WORDPRESS_STATE_BUCKET, lease, next, currentPublication, event\.publicationChanges, site\)/) assert.doesNotMatch(scheduledCron, /compilePublicationRoutes\(runtime/) assert.match(worker, /https:\/\/wp-codebox-publication\.invalid/) assert.match(worker, /onlyIf: \{ etagDoesNotMatch: "\*" \}/) assert.match(worker, /Immutable R2 object conflicts with existing content/) - assert.match(worker, /serveWordPressUpload\(request, env\.WORDPRESS_STATE_BUCKET, coordinator\)/) - assert.ok(worker.indexOf("serveWordPressUpload(request, env.WORDPRESS_STATE_BUCKET, coordinator)") < worker.indexOf("runCoordinatedWordPressRequest(request, env, coordinator, route.kind)")) - assert.match(worker, /R2_UPLOAD_OBJECT_PREFIX/) + assert.match(worker, /serveWordPressUpload\(request, env\.WORDPRESS_STATE_BUCKET, coordinator, site\)/) + assert.ok(worker.indexOf("serveWordPressUpload(request, env.WORDPRESS_STATE_BUCKET, coordinator, site)") < worker.indexOf("runCoordinatedWordPressRequest(request, env, coordinator, site, route.kind)")) + assert.match(worker, /siteStorageKeys\(site\)\.uploadObjectPrefix/) assert.match(worker, /collectUploadFiles\(runtime\.php\)/) assert.match(worker, /hash_file\('sha256', \$path\)/) assert.match(worker, /persistRuntimeObjects\(bucket, runtime\.php, UPLOADS_ROOT/) @@ -464,13 +465,13 @@ test("Cloudflare runtime injects composable coordinators without moving PHP out const sha256 = worker.slice(worker.indexOf("async function sha256Hex"), worker.indexOf("async function materializeMarkdownDatabaseIntegration")) assert.doesNotMatch(sha256, /Uint8Array\.from\(bytes\)\.buffer/) assert.match(worker, /persistWpContentObjects\(bucket, wpContent/) - assert.match(worker, /validateWpContentManifestFiles\(manifest\.wpContent \?\? \[\]\)/) + assert.match(worker, /validateWpContentManifestFiles\(manifest\.wpContent \?\? \[\], site\)/) assert.match(worker, /validateWpContentDeletedPaths\(manifest\.wpContentDeleted \?\? \[\]\)/) assert.match(worker, /materializeWpContentTombstones\(php, wpContentDeleted\)/) assert.match(worker, /async scheduled\(controller: ScheduledController, env: Env\)/) - assert.match(worker, /const publication = await drainNextPublicationJob\(env, coordinator\)/) + assert.match(worker, /const publication = await drainNextPublicationJob\(env, coordinator, site\)/) assert.match(worker, /const fence = publication \? undefined : await coordinator\.fenceStatus\(\)/) - assert.match(worker, /: await runScheduledWordPressCron\(env, coordinator, controller\.scheduledTime\)/) + assert.match(worker, /: await runScheduledWordPressCron\(env, coordinator, site, controller\.scheduledTime\)/) assert.match(worker, /pathname === "\/wp-cron\.php"/) assert.match(worker, /MAX_CRON_EVENTS_PER_INVOCATION = 5/) assert.match(worker, /while \(evidence\.events\.length < MAX_CRON_EVENTS_PER_INVOCATION/) @@ -480,7 +481,7 @@ test("Cloudflare runtime injects composable coordinators without moving PHP out assert.match(worker, /"x-wp-codebox-static": "r2-wp-content"/) assert.match(worker, /url\.pathname\.startsWith\("\/wp-admin\/"\).*url\.searchParams\.get\("action"\)/) assert.match(worker, /if \(!file\) return null/) - assert.equal((worker.match(/validateUploadManifestFiles\(manifest\??\.uploads \?\? \[\]\)/g) ?? []).length, 2) + assert.equal((worker.match(/validateUploadManifestFiles\(manifest\??\.uploads \?\? \[\], site\)/g) ?? []).length, 3) assert.match(materializer, /materializeWordPressRuntimeArtifact\(php, bucket, wordpressRuntimeArtifactManifest/) assert.doesNotMatch(materializer, /decodeRemoteZip\(WORDPRESS_ARCHIVE_URL/) assert.match(corpus, /path\.endsWith\("\.map"\)/) diff --git a/tests/cloudflare-site-context.test.ts b/tests/cloudflare-site-context.test.ts new file mode 100644 index 000000000..b3bf82206 --- /dev/null +++ b/tests/cloudflare-site-context.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { DEFAULT_SITE_CONTEXT, parseSiteContexts, resolveSiteContext, resolveSiteContextFromRequest, siteStorageKeys } from "../packages/runtime-cloudflare/src/site-context.js" + +test("site contexts preserve default storage paths and isolate configured sites", () => { + assert.deepEqual(parseSiteContexts(undefined), [DEFAULT_SITE_CONTEXT]) + assert.equal(DEFAULT_SITE_CONTEXT.hostname, "wp-codebox-cloudflare-runtime.chubes.workers.dev") + assert.equal(DEFAULT_SITE_CONTEXT.origin, "https://wp-codebox-cloudflare-runtime.chubes.workers.dev") + assert.equal(siteStorageKeys(DEFAULT_SITE_CONTEXT).markdownCurrent, "sites/default/markdown/current.json") + assert.equal(siteStorageKeys(DEFAULT_SITE_CONTEXT).markdownRevisionPrefix, "sites/default/markdown/revisions") + assert.equal(siteStorageKeys(DEFAULT_SITE_CONTEXT).markdownObjectPrefix, "sites/default/markdown/objects") + assert.equal(siteStorageKeys(DEFAULT_SITE_CONTEXT).publishedCurrent, "sites/default/publications/current.json") + assert.equal(siteStorageKeys(DEFAULT_SITE_CONTEXT).publicationJobPrefix, "sites/default/publications/jobs") + assert.equal(siteStorageKeys(DEFAULT_SITE_CONTEXT).staticArtifactPrefix, "sites/default/import-artifacts") + const [first, second] = parseSiteContexts(JSON.stringify([ + { id: "alpha", hostname: "alpha.example.test", origin: "https://alpha.example.test" }, + { id: "beta", hostname: "beta.example.test", origin: "https://beta.example.test" }, + ])) + assert.notEqual(siteStorageKeys(first).uploadObjectPrefix, siteStorageKeys(second).uploadObjectPrefix) + assert.equal(resolveSiteContext("alpha.example.test", [first, second]), first) +}) + +test("site context parser rejects malformed and traversal-like configuration", () => { + for (const value of [ + "{", + "[]", + '[{"id":"../other","hostname":"example.test","origin":"https://example.test"}]', + '[{"id":"alpha","hostname":"example.test","origin":"https://example.test/path"}]', + '[{"id":"alpha","hostname":"example.test","origin":"http://example.test"}]', + '[{"id":"alpha","hostname":"example.test","origin":"https://example.test"},{"id":"beta","hostname":"example.test","origin":"https://example.test"}]', + ]) assert.throws(() => parseSiteContexts(value)) + assert.doesNotThrow(() => parseSiteContexts('[{"id":"local","hostname":"localhost","origin":"http://localhost"}]')) + assert.doesNotThrow(() => parseSiteContexts('[{"id":"local-subdomain","hostname":"alpha.localhost","origin":"http://alpha.localhost:8792"}]')) + assert.doesNotThrow(() => parseSiteContexts('[{"id":"loopback","hostname":"::1","origin":"http://[::1]"}]')) +}) + +test("site context resolution only accepts exact configured hosts", () => { + const contexts = parseSiteContexts('[{"id":"alpha","hostname":"alpha.example.test","origin":"https://alpha.example.test"}]') + assert.equal(resolveSiteContextFromRequest(new Request("https://alpha.example.test/path"), contexts).id, "alpha") + for (const hostname of ["alpha.example.test.evil.test", "evil-alpha.example.test", "alpha.example.test:443", "ALPHA.example.test"]) { + assert.throws(() => resolveSiteContext(hostname, contexts), /Unknown site hostname/) + } +}) diff --git a/tests/cloudflare-wordpress-auth.test.ts b/tests/cloudflare-wordpress-auth.test.ts index d69b4e576..8cfb778df 100644 --- a/tests/cloudflare-wordpress-auth.test.ts +++ b/tests/cloudflare-wordpress-auth.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import test from "node:test" -import { deriveWordPressAuthConstants, WORDPRESS_AUTH_CONSTANTS } from "../packages/runtime-cloudflare/src/wordpress-auth.js" +import { deriveSiteCredential, deriveWordPressAuthConstants, WORDPRESS_AUTH_CONSTANTS } from "../packages/runtime-cloudflare/src/wordpress-auth.js" test("WordPress auth constants are deterministic, site-scoped, and domain-separated", async () => { const first = await deriveWordPressAuthConstants("test-root-secret", "default") @@ -13,3 +13,12 @@ test("WordPress auth constants are deterministic, site-scoped, and domain-separa assert.notEqual(first.AUTH_KEY, otherSite.AUTH_KEY) await assert.rejects(deriveWordPressAuthConstants("", "default"), /WORDPRESS_AUTH_SECRET is required/) }) + +test("site credentials preserve default compatibility and cannot be reused across configured sites", async () => { + const root = "test-root-credential" + assert.equal(await deriveSiteCredential(root, "default", "operator-token"), root) + assert.equal(await deriveSiteCredential(root, "alpha", "operator-token"), await deriveSiteCredential(root, "alpha", "operator-token")) + assert.notEqual(await deriveSiteCredential(root, "alpha", "operator-token"), await deriveSiteCredential(root, "beta", "operator-token")) + assert.notEqual(await deriveSiteCredential(root, "alpha", "operator-token"), await deriveSiteCredential(root, "alpha", "admin-password")) + await assert.rejects(deriveSiteCredential("", "alpha", "operator-token"), /WORDPRESS_OPERATOR_TOKEN is required/) +})