diff --git a/packages/runtime-cloudflare/README.md b/packages/runtime-cloudflare/README.md index cb26a2bc..0111d3f3 100644 --- a/packages/runtime-cloudflare/README.md +++ b/packages/runtime-cloudflare/README.md @@ -54,7 +54,7 @@ The D1 profile registers the configured site identity and creates or converges o Before canonical promotion, the operation checkpoints the exact prepared R2 pointer and expected coordinator version. Recovery skips SSI only when the coordinator's immutable commit receipt matches that complete pointer; a version match alone cannot claim another mutation's commit. The operation remains `publication-pending` after canonical commit until the corresponding immutable R2 publication receipt records promotion, supersession, or orphaning. Its terminal receipt identifies the artifact digest, SSI report, canonical pointer/version, publication identity, site URL, and canonical and terminal completion times. Profiles without a D1 operation repository retain the direct import transaction. -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 persist compact provenance, enqueue affected publication routes, and store at most 20 idempotency receipts in canonical options as a second mutation fence behind the D1 operation record. Exact replay converges without a new revision; reuse with different input returns a conflict. +The import transaction boots a dedicated runtime generated from the pinned Static Site Importer v1.3.4 release archive (`8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29`) and stored content-addressably in R2. The generated runtime retains SSI, its website-artifact dependencies, and the PHP transformer while excluding Figma-only code, fixtures, tests, tools, and documentation that the Cloudflare website-import contract cannot execute. It invokes SSI's 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 persist compact provenance, enqueue affected publication routes, and store at most 20 idempotency receipts in canonical options as a second mutation fence behind the D1 operation record. Exact replay converges without a new revision; reuse with different input returns a conflict. SSI is extracted only for import requests, so normal browser, mutation, publication, and cron boots retain their existing memory and latency profile. The pinned normal plugin archive bundles Blocks Engine and supports website artifacts without the optional Figma zstd extension; compressed `.fig` import is outside this runtime contract. diff --git a/packages/runtime-cloudflare/assets/static-site-importer-artifact.json b/packages/runtime-cloudflare/assets/static-site-importer-artifact.json deleted file mode 100644 index a99e6d8f..00000000 --- a/packages/runtime-cloudflare/assets/static-site-importer-artifact.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "schema": "wp-codebox/runtime-archive-artifact/v1", - "name": "static-site-importer", - "key": "runtime/archives/static-site-importer/8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29.zip", - "archive": { - "sha256": "8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29", - "size": 13056371 - }, - "source": { - "url": "https://github.com/Automattic/static-site-importer/releases/download/v1.3.4/static-site-importer.zip", - "version": "1.3.4", - "identity": "08b9dd650f3c3161c5b350796a5db6ef083516ae" - } -} diff --git a/packages/runtime-cloudflare/assets/website-importer-artifact.json b/packages/runtime-cloudflare/assets/website-importer-artifact.json new file mode 100644 index 00000000..451aefb6 --- /dev/null +++ b/packages/runtime-cloudflare/assets/website-importer-artifact.json @@ -0,0 +1,35 @@ +{ + "schema": "wp-codebox/runtime-archive-artifact/v1", + "name": "website-importer", + "key": "runtime/archives/website-importer/c5aa5c2ef67503ed94c7b249b2bf102630f279f985ece44245df83169fa75e27.zip", + "archive": { + "sha256": "c5aa5c2ef67503ed94c7b249b2bf102630f279f985ece44245df83169fa75e27", + "size": 1739484 + }, + "source": { + "url": "https://github.com/Automattic/static-site-importer/releases/download/v1.3.4/static-site-importer.zip", + "version": "1.3.4", + "identity": "08b9dd650f3c3161c5b350796a5db6ef083516ae" + }, + "component": { + "schema": "wp-codebox/runtime-archive-component/v1", + "id": "website-importer", + "package": { + "profile": "website-artifact-import", + "root": "static-site-importer" + }, + "wordpress": { + "install_path": "plugins/static-site-importer", + "bootstrap_file": "static-site-importer.php", + "loader_path": "mu-plugins/wp-codebox-runtime-component-website-importer.php", + "version_constant": "STATIC_SITE_IMPORTER_VERSION" + }, + "abilities": { + "website-artifact-import": "static-site-importer/import-website-artifact" + }, + "limits": { + "files": 10000, + "bytes": 67108864 + } + } +} diff --git a/packages/runtime-cloudflare/components/website-importer.json b/packages/runtime-cloudflare/components/website-importer.json new file mode 100644 index 00000000..8c977623 --- /dev/null +++ b/packages/runtime-cloudflare/components/website-importer.json @@ -0,0 +1,53 @@ +{ + "schema": "wp-codebox/runtime-archive-component-source/v1", + "source": { + "url": "https://github.com/Automattic/static-site-importer/releases/download/v1.3.4/static-site-importer.zip", + "version": "1.3.4", + "identity": "08b9dd650f3c3161c5b350796a5db6ef083516ae", + "sha256": "8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29" + }, + "component": { + "schema": "wp-codebox/runtime-archive-component/v1", + "id": "website-importer", + "package": { + "profile": "website-artifact-import", + "root": "static-site-importer" + }, + "wordpress": { + "install_path": "plugins/static-site-importer", + "bootstrap_file": "static-site-importer.php", + "loader_path": "mu-plugins/wp-codebox-runtime-component-website-importer.php", + "version_constant": "STATIC_SITE_IMPORTER_VERSION" + }, + "abilities": { + "website-artifact-import": "static-site-importer/import-website-artifact" + }, + "limits": { + "files": 10000, + "bytes": 67108864 + } + }, + "migration": { + "source_sha256": "8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29", + "selectors": [ + { "type": "file", "path": "static-site-importer.php" }, + { "type": "prefix", "path": "blocks/" }, + { "type": "prefix", "path": "includes/" }, + { "type": "prefix", "path": "lib/" }, + { "type": "prefix", "path": "registry/" }, + { "type": "file", "path": "vendor/autoload.php" }, + { "type": "prefix", "path": "vendor/composer/" }, + { "type": "prefix", "path": "vendor/automattic/blocks-engine-php-transformer/php-transformer/" }, + { "type": "prefix", "path": "vendor/dflydev/" }, + { "type": "prefix", "path": "vendor/league/" }, + { "type": "prefix", "path": "vendor/nette/" }, + { "type": "prefix", "path": "vendor/psr/" }, + { "type": "prefix", "path": "vendor/symfony/" } + ], + "required_files": [ + "static-site-importer.php", + "vendor/autoload.php", + "vendor/automattic/blocks-engine-php-transformer/php-transformer/php-transformer.php" + ] + } +} diff --git a/packages/runtime-cloudflare/src/publication-response.ts b/packages/runtime-cloudflare/src/publication-response.ts new file mode 100644 index 00000000..ce86412c --- /dev/null +++ b/packages/runtime-cloudflare/src/publication-response.ts @@ -0,0 +1,5 @@ +export function isCacheablePublicationResponse(response: Response): boolean { + if (response.headers.has("set-cookie")) return false + if (response.status === 200) return response.headers.get("content-type")?.includes("text/html") ?? false + return (response.status === 301 || response.status === 308) && response.headers.has("location") +} diff --git a/packages/runtime-cloudflare/src/runtime-archive-artifact.ts b/packages/runtime-cloudflare/src/runtime-archive-artifact.ts index cc5b9e89..b904688e 100644 --- a/packages/runtime-cloudflare/src/runtime-archive-artifact.ts +++ b/packages/runtime-cloudflare/src/runtime-archive-artifact.ts @@ -7,6 +7,7 @@ export interface RuntimeArchiveArtifactManifest { key: string archive: { sha256: string; size: number } source: { url: string; version?: string; identity?: string } + component?: RuntimeArchiveComponent } export function runtimeArchiveArtifactKey(name: string, sha256: string): string { @@ -20,6 +21,7 @@ export function validateRuntimeArchiveArtifactManifest(manifest: RuntimeArchiveA if (manifest.key !== runtimeArchiveArtifactKey(manifest.name, manifest.archive.sha256)) throw new Error("Runtime archive artifact key is not content addressed.") if (!Number.isSafeInteger(manifest.archive.size) || manifest.archive.size < 1 || manifest.archive.size > RUNTIME_ARCHIVE_MAX_BYTES) throw new Error("Runtime archive artifact size is outside the allowed budget.") if (!manifest.source.url.startsWith("https://")) throw new Error("Runtime archive artifact source URL is invalid.") + if (manifest.component) runtimeArchiveComponent(manifest.component) } export async function readRuntimeArchiveArtifact(bucket: R2Bucket, manifest: RuntimeArchiveArtifactManifest): Promise { @@ -33,6 +35,7 @@ export async function readRuntimeArchiveArtifact(bucket: R2Bucket, manifest: Run } async function sha256Hex(bytes: Uint8Array): Promise { - const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer) + const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array) return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") } +import { runtimeArchiveComponent, type RuntimeArchiveComponent } from "../../runtime-core/src/runtime-archive-component.js" diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index 33fd9819..d37f9387 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -10,6 +10,7 @@ import { leaseRetryDelayMs } from "./lease-retry.js" import { logMutationPhase, MutationRetainedBytes } from "./mutation-memory.js" import { selectOperatorCoordinator } from "./operator-coordinator.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 { isCacheablePublicationResponse } from "./publication-response.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" @@ -23,7 +24,8 @@ import { isWordPressRuntimeFile, wordpressStaticArchivePath, wordpressStaticCont 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, validateWpContentDeletedPaths, validateWpContentManifestFiles, validateWpContentMetadata } from "./wp-content-persistence.js" +import { runtimeArchiveComponentOwnedWpContentPaths, type RuntimeArchiveComponent } from "../../runtime-core/src/runtime-archive-component.js" +import { isCanonicalWpContentPath, MAX_WP_CONTENT_FILES, MAX_WP_CONTENT_FILE_BYTES, MAX_WP_CONTENT_TOTAL_BYTES, runtimeOwnedWpContentPaths, 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" } @@ -32,9 +34,19 @@ import wordpressInstallSeed from "../assets/wordpress-install-seed.sqlite" import wordpressRuntimeArtifactManifest from "../assets/wordpress-runtime-artifact.json" with { type: "json" } import wordpressStaticArtifactManifest from "../assets/wordpress-static-artifact.json" with { type: "json" } import sqliteIntegrationArtifactManifest from "../assets/sqlite-database-integration-artifact.json" with { type: "json" } -import staticSiteImporterArtifactManifest from "../assets/static-site-importer-artifact.json" with { type: "json" } +import websiteImporterArtifactManifest from "../assets/website-importer-artifact.json" with { type: "json" } const PHP_VERSION = "8.5.8" +const websiteImporterArtifact = websiteImporterArtifactManifest as RuntimeArchiveArtifactManifest +if (!websiteImporterArtifact.component) throw new Error("Website importer runtime artifact is missing its component descriptor.") +const WEBSITE_IMPORTER_COMPONENT = websiteImporterArtifact.component +const WEBSITE_IMPORT_ABILITY = WEBSITE_IMPORTER_COMPONENT.abilities["website-artifact-import"] +if (!WEBSITE_IMPORT_ABILITY) throw new Error("Website importer runtime component does not provide the website-artifact-import ability alias.") +const WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS = runtimeArchiveComponentOwnedWpContentPaths(WEBSITE_IMPORTER_COMPONENT) +const RUNTIME_OWNED_WP_CONTENT_PATHS = runtimeOwnedWpContentPaths(WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) +const WEBSITE_IMPORTER_VERSION_EXPRESSION = WEBSITE_IMPORTER_COMPONENT.wordpress.version_constant + ? `defined('${WEBSITE_IMPORTER_COMPONENT.wordpress.version_constant}') ? constant('${WEBSITE_IMPORTER_COMPONENT.wordpress.version_constant}') : ''` + : "''" const wordpressStaticArtifact = wordpressStaticArtifactManifest as WordPressStaticArtifactManifest validateWordPressStaticArtifactManifest(wordpressStaticArtifact) const wordpressStaticFiles = new Map(wordpressStaticArtifact.files.map((file) => [file.path, file])) @@ -43,19 +55,15 @@ const wordpressWpContentBaselineHashes = new Map([ ...wordpressStaticArtifact.files, ].filter((file) => file.path.startsWith("wordpress/wp-content/")) .map((file) => [file.path.slice("wordpress/wp-content/".length), file.sha256] as const) - .filter(([path]) => isCanonicalWpContentPath(path))) + .filter(([path]) => isCanonicalWpContentPath(path, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS))) const wordpressWpContentRuntimeBaselinePaths = new Set((wordpressRuntimeArtifactManifest as WordPressRuntimeArtifactManifest).files .filter((file) => file.path.startsWith("wordpress/wp-content/")) .map((file) => file.path.slice("wordpress/wp-content/".length)) - .filter(isCanonicalWpContentPath)) + .filter((path) => isCanonicalWpContentPath(path, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS))) const MARKDOWN_DATABASE_INTEGRATION_REVISION = "bf6d434d1673fdd86d777501f7eaec292d32ad1f" 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" -const STATIC_SITE_IMPORTER_MU_LOADER = "/wordpress/wp-content/mu-plugins/wp-codebox-static-site-importer.php" -const MAX_STATIC_SITE_IMPORTER_FILES = 10_000 -const MAX_STATIC_SITE_IMPORTER_BYTES = 64 * 1024 * 1024 const UPLOADS_ROOT = "/wordpress/wp-content/uploads" const MARKDOWN_INDEX_PATH = "/tmp/markdown-index.sqlite" const MARKDOWN_RESOLVED_INDEX_PATH = "/tmp/markdown-index-8133b4cf3c66.sqlite" @@ -156,9 +164,10 @@ if (count($records) >= 20) { echo wp_json_encode(array('status' => 'failed', 'error' => array('code' => 'idempotency_capacity', 'message' => 'Static artifact import idempotency history is full.')), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); return; } -$ability = function_exists('wp_get_ability') ? wp_get_ability('static-site-importer/import-website-artifact') : null; +$ability_name = ${JSON.stringify(WEBSITE_IMPORT_ABILITY)}; +$ability = function_exists('wp_get_ability') ? wp_get_ability($ability_name) : null; if (!$ability) { - echo wp_json_encode(array('status' => 'failed', 'error' => array('code' => 'ability_unavailable', 'message' => 'Static Site Importer ability is unavailable.')), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + echo wp_json_encode(array('status' => 'failed', 'error' => array('code' => 'ability_unavailable', 'message' => 'The configured website importer ability is unavailable.')), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); return; } $ability_input = array( @@ -174,7 +183,7 @@ $ability_input = array( $result = $ability->execute($ability_input); if (is_wp_error($result)) $result = array('success' => false, 'error' => array('code' => $result->get_error_code(), 'message' => $result->get_error_message())); if (!is_array($result) || empty($result['success']) || !isset($result['result']) || !is_array($result['result'])) { - $error = is_array($result) && isset($result['error']) && is_array($result['error']) ? $result['error'] : array('code' => 'import_failed', 'message' => 'Static Site Importer failed without a structured error.'); + $error = is_array($result) && isset($result['error']) && is_array($result['error']) ? $result['error'] : array('code' => 'import_failed', 'message' => 'The configured website importer failed without a structured error.'); echo wp_json_encode(array('status' => 'failed', 'error' => $error), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); return; } @@ -198,8 +207,9 @@ $record = array( 'pages' => isset($import['pages']) && is_array($import['pages']) ? $import['pages'] : array(), 'quality' => $counts, 'importedAt' => gmdate('c'), - 'ability' => 'static-site-importer/import-website-artifact', - 'staticSiteImporterVersion' => defined('STATIC_SITE_IMPORTER_VERSION') ? STATIC_SITE_IMPORTER_VERSION : '', + 'ability' => $ability_name, + 'componentId' => ${JSON.stringify(WEBSITE_IMPORTER_COMPONENT.id)}, + 'importerVersion' => ${WEBSITE_IMPORTER_VERSION_EXPRESSION}, ); $records[$key] = $record; update_option('wp_codebox_static_artifact_imports', $records, false); @@ -1037,7 +1047,7 @@ async function compilePublicationRoutes(runtime: Runtime, routes: string[], orig for (const route of routes) { const request = new Request(new URL(route, origin)) const response = toFetchResponse(request, await runtime.requestHandler.request(await toPHPRequest(request))) - if (response.status !== 200 || !response.headers.get("content-type")?.includes("text/html") || response.headers.has("set-cookie")) { + if (!isCacheablePublicationResponse(response)) { throw new Error(`Affected publication route did not render cacheable HTML: ${route}.`) } const body = await response.text() @@ -1115,11 +1125,11 @@ async function runCoordinatedWordPressRequest(request: Request, env: RuntimeEnv, let publicationChanges: PublicationChanges | undefined if (route === "static-artifact-import") { if (!staticArtifactImport) throw new Error("Static artifact import input is unavailable.") - await heartbeat?.("executing-ssi", 45) + await heartbeat?.("executing-importer", 45) initializePublicationChanges(runtime.php) runtime.php.writeFile(MARKDOWN_CHANGES_PATH, new TextEncoder().encode(JSON.stringify({ created: [], changed: [], deleted: [] }))) const imported = await runStaticArtifactImport(runtime, staticArtifactImport) - await heartbeat?.("ssi-completed", 60) + await heartbeat?.("importer-completed", 60) if (imported.status !== "imported") { await discardRuntime(runtime, site) runtime = undefined @@ -1328,14 +1338,14 @@ function commitLease(coordinator: RevisionCoordinator, _requestUrl: string, leas return coordinator.commit(lease, pointer) } -async function getRuntime(env: RuntimeEnv, pointer: MarkdownPointer, site: SiteContext, includeStaticSiteImporter = false): Promise { +async function getRuntime(env: RuntimeEnv, pointer: MarkdownPointer, site: SiteContext, includeWebsiteImporter = false): Promise { let cachedRuntime = cachedRuntimes.get(site.id) - if (cachedRuntime && (cachedRuntime.baseRevision !== pointer.revision || includeStaticSiteImporter)) { + if (cachedRuntime && (cachedRuntime.baseRevision !== pointer.revision || includeWebsiteImporter)) { await discardCachedRuntime(site.id) cachedRuntime = undefined } if (!cachedRuntime) { - const promise = bootRuntime(env.WORDPRESS_STATE_BUCKET, pointer, site.origin, await canonicalWordPressAuthConstants(env, site), includeStaticSiteImporter, site) + const promise = bootRuntime(env.WORDPRESS_STATE_BUCKET, pointer, site.origin, await canonicalWordPressAuthConstants(env, site), includeWebsiteImporter, site) cachedRuntime = { baseRevision: pointer.revision, promise } cachedRuntimes.set(site.id, cachedRuntime) promise.catch(() => { @@ -1378,9 +1388,9 @@ async function disposeRequestHandler(requestHandler: PHPRequestHandler): Promise await dispose.call(requestHandler) } -async function bootRuntime(bucket: R2Bucket, pointer: MarkdownPointer, origin: string, authConstants: Record, includeStaticSiteImporter = false, site: SiteContext = DEFAULT_SITE_CONTEXT): Promise { +async function bootRuntime(bucket: R2Bucket, pointer: MarkdownPointer, origin: string, authConstants: Record, includeWebsiteImporter = 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 } + 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, includeWebsiteImporter), pointer } } async function bootstrapCanonicalRuntime(env: RuntimeEnv, coordinator: RevisionCoordinator, site: SiteContext, requestUrl: string, lease: Lease): Promise { @@ -1441,8 +1451,8 @@ async function persistRuntime(bucket: R2Bucket, runtime: Runtime, changes: Markd const currentManifest = await readMarkdownManifest(bucket, runtime.pointer, site) if (!currentManifest) throw new Error(`R2 Markdown manifest is missing: ${runtime.pointer.manifestKey}`) validateUploadManifestFiles(currentManifest.uploads ?? [], site) - validateWpContentManifestFiles(currentManifest.wpContent ?? [], site) - validateWpContentDeletedPaths(currentManifest.wpContentDeleted ?? []) + validateWpContentManifestFiles(currentManifest.wpContent ?? [], site, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) + validateWpContentDeletedPaths(currentManifest.wpContentDeleted ?? [], WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) 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) }) @@ -1504,7 +1514,8 @@ interface StaticArtifactRuntimeResult { quality?: { fallbackBlocks: number; coreHtmlBlocks: number; freeformBlocks: number; invalidBlocks: number } importedAt?: string ability?: string - staticSiteImporterVersion?: string + componentId?: string + importerVersion?: string error?: { code?: string; message?: string } } @@ -1520,7 +1531,7 @@ async function runStaticArtifactImport(runtime: Runtime, input: StaticArtifactIm }))) const output = (await runtime.php.run({ code: STATIC_ARTIFACT_IMPORT_CODE })).text.trim() const result = JSON.parse(output) as StaticArtifactRuntimeResult - if (!["imported", "duplicate", "conflict", "failed", "quality-failed"].includes(result.status)) throw new Error("Static Site Importer returned an invalid status envelope.") + if (!["imported", "duplicate", "conflict", "failed", "quality-failed"].includes(result.status)) throw new Error("Website importer returned an invalid status envelope.") return result } @@ -1616,8 +1627,8 @@ async function readCanonicalRevision(bucket: R2Bucket, pointer: MarkdownPointer, 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 ?? []) + validateWpContentManifestFiles(manifest.wpContent ?? [], site, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) + validateWpContentDeletedPaths(manifest.wpContentDeleted ?? [], WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) const [markdown, uploads, wpContent] = await Promise.all([ readManifestFiles(bucket, manifest.files, "Markdown"), readManifestFiles(bucket, manifest.uploads ?? [], "upload"), @@ -1640,9 +1651,9 @@ async function persistMarkdownRevision(bucket: R2Bucket, files: RuntimeFile[], s 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 ?? [], site) - validateWpContentManifestFiles(currentManifest?.wpContent ?? [], site) - validateWpContentDeletedPaths(currentManifest?.wpContentDeleted ?? []) - validateWpContentDeletedPaths(wpContentDeleted) + validateWpContentManifestFiles(currentManifest?.wpContent ?? [], site, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) + validateWpContentDeletedPaths(currentManifest?.wpContentDeleted ?? [], WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) + validateWpContentDeletedPaths(wpContentDeleted, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) 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) @@ -1699,7 +1710,7 @@ async function persistUploadObjects(bucket: R2Bucket, files: RuntimeFile[], curr } async function persistWpContentObjects(bucket: R2Bucket, files: RuntimeFile[], current: MarkdownManifestFile[], site: SiteContext): Promise { - validateWpContentMetadata(files.map((file) => ({ path: file.path, size: file.bytes.byteLength }))) + validateWpContentMetadata(files.map((file) => ({ path: file.path, size: file.bytes.byteLength })), WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) const currentFiles = new Map(current.map((file) => [file.path, file])) const persisted: MarkdownManifestFile[] = [] for (const file of files) { @@ -1740,8 +1751,8 @@ async function readMarkdownManifest(bucket: R2Bucket, pointer: MarkdownPointer, 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 ?? []) + validateWpContentManifestFiles(manifest.wpContent ?? [], site, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) + validateWpContentDeletedPaths(manifest.wpContentDeleted ?? [], WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) return manifest } @@ -2020,12 +2031,12 @@ async function bootWordPressRuntime( uploadFiles?: RuntimeFile[], wpContentFiles?: RuntimeFile[], wpContentDeleted: string[] = [], - includeStaticSiteImporter = false, + includeWebsiteImporter = false, ): Promise<{ php: PHP; requestHandler: PHPRequestHandler; wordpressVersion: string }> { if (includeSqlite && !runtimeBucket) throw new Error("SQLite integration artifact requires WORDPRESS_STATE_BUCKET.") - validateWpContentDeletedPaths(wpContentDeleted) + validateWpContentDeletedPaths(wpContentDeleted, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) const sqliteIntegrationPluginZip = includeSqlite ? readSqliteIntegrationArtifact(runtimeBucket!) : undefined - const staticSiteImporterZip = includeStaticSiteImporter ? readStaticSiteImporterArtifact(runtimeBucket!) : undefined + const websiteImporterZip = includeWebsiteImporter ? readWebsiteImporterArtifact(runtimeBucket!) : undefined const requestHandler = await bootWordPressAndRequestHandler({ createPhpRuntime, constants: { @@ -2048,10 +2059,10 @@ async function bootWordPressRuntime( // Browser cookies are carried by the Worker Fetch request; Playground's // internal store would overwrite that header after an isolate restart. cookieStore: false, - hooks: streamWordPressFiles || databaseSeed || markdownFiles || uploadFiles?.length || wpContentFiles?.length || wpContentDeleted.length || includeStaticSiteImporter ? { - beforeWordPressFiles: streamWordPressFiles || markdownFiles || uploadFiles?.length || wpContentFiles?.length || wpContentDeleted.length || includeStaticSiteImporter ? async (php: PHP) => { + hooks: streamWordPressFiles || databaseSeed || markdownFiles || uploadFiles?.length || wpContentFiles?.length || wpContentDeleted.length || includeWebsiteImporter ? { + beforeWordPressFiles: streamWordPressFiles || markdownFiles || uploadFiles?.length || wpContentFiles?.length || wpContentDeleted.length || includeWebsiteImporter ? async (php: PHP) => { if (streamWordPressFiles) await materializeWordPressServerFiles(php, runtimeBucket) - if (staticSiteImporterZip) await materializeStaticSiteImporter(php, await staticSiteImporterZip) + if (websiteImporterZip) await materializeRuntimeArchiveComponent(php, await websiteImporterZip, WEBSITE_IMPORTER_COMPONENT) if (markdownFiles) { await materializeMarkdownDatabaseIntegration(php) materializeCanonicalChangeAdapter(php) @@ -2105,7 +2116,7 @@ function materializeRuntimeFiles(php: PHP, root: string, files: RuntimeFile[]): } async function materializeWpContentTombstones(php: PHP, paths: string[]): Promise { - validateWpContentDeletedPaths(paths) + validateWpContentDeletedPaths(paths, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) const encodedPaths = JSON.stringify(paths).replace(/ { + const runtimeOwnedPaths = JSON.stringify(RUNTIME_OWNED_WP_CONTENT_PATHS) const output = (await php.run({ code: `isFile()) continue; $absolute = str_replace('\\\\', '/', $file->getPathname()); $path = $root . '/' . substr($absolute, strlen($directory) + 1); - if (str_starts_with($path, 'plugins/markdown-database-integration/') || str_starts_with($path, 'plugins/sqlite-database-integration/') || str_starts_with($path, 'plugins/static-site-importer/') || str_starts_with($path, 'mu-plugins/wp-codebox-cloudflare-canonical-changes.php') || str_starts_with($path, 'mu-plugins/wp-codebox-static-site-importer.php')) continue; + foreach ($runtime_owned_paths as $runtime_owned_path) { + if (str_ends_with($runtime_owned_path, '/') ? str_starts_with($path, $runtime_owned_path) : ($path === $runtime_owned_path || str_starts_with($path, $runtime_owned_path . '/'))) continue 2; + } $size = $file->getSize(); $total += $size; if ($size > ${MAX_WP_CONTENT_FILE_BYTES} || $total > ${MAX_WP_CONTENT_TOTAL_BYTES} || count($files) >= ${MAX_WP_CONTENT_FILES}) { @@ -2238,7 +2253,7 @@ foreach (array('plugins', 'themes', 'languages', 'mu-plugins') as $root) { usort($files, static fn($left, $right) => strcmp($left['path'], $right['path'])); echo json_encode($files, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);` })).text.trim() const metadata: unknown = JSON.parse(output) - validateWpContentMetadata(metadata) + validateWpContentMetadata(metadata, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) validateRuntimeFileHashes(metadata) const persistent = metadata.filter((file) => wordpressWpContentBaselineHashes.get(file.path) !== file.sha256) const currentPaths = new Set(metadata.map((file) => file.path)) @@ -2253,7 +2268,7 @@ echo json_encode($files, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);` })).tex } } const sortedDeleted = [...deleted].sort((left, right) => left.localeCompare(right)) - validateWpContentDeletedPaths(sortedDeleted) + validateWpContentDeletedPaths(sortedDeleted, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) return { files: persistent, deleted: sortedDeleted } } @@ -2308,32 +2323,35 @@ async function materializeMarkdownDatabaseIntegration(php: PHP): Promise { } } -async function materializeStaticSiteImporter(php: PHP, archive: Uint8Array): Promise { - const required = new Set([ - "static-site-importer/static-site-importer.php", - "static-site-importer/vendor/autoload.php", - ]) +async function materializeRuntimeArchiveComponent(php: PHP, archive: Uint8Array, component: RuntimeArchiveComponent): Promise { + const packageRoot = `${component.package.root}/` + const installRoot = `/wordpress/wp-content/${component.wordpress.install_path}` + const loaderPath = component.wordpress.loader_path ? `/wordpress/wp-content/${component.wordpress.loader_path}` : undefined + const required = new Set([`${packageRoot}${component.wordpress.bootstrap_file}`]) let count = 0 let total = 0 - for await (const entry of decodeZip(new Blob([Uint8Array.from(archive).buffer]).stream())) { + for await (const entry of decodeZip(new Blob([archive as BlobPart]).stream())) { if (entry.name.endsWith("/")) continue - if (!entry.name.startsWith("static-site-importer/") || entry.name.includes("\\") || entry.name.split("/").some((segment: string) => !segment || segment === "." || segment === "..")) { - throw new Error("Static Site Importer archive contains an invalid path.") + if (!entry.name.startsWith(packageRoot) || entry.name.includes("\\") || entry.name.split("/").some((segment: string) => !segment || segment === "." || segment === "..")) { + throw new Error(`Runtime archive component ${component.id} contains an invalid path.`) } const bytes = new Uint8Array(await entry.arrayBuffer()) count++ total += bytes.byteLength - if (count > MAX_STATIC_SITE_IMPORTER_FILES || total > MAX_STATIC_SITE_IMPORTER_BYTES) throw new Error("Static Site Importer archive exceeds its extraction budget.") - const relative = entry.name.slice("static-site-importer/".length) - const destination = `${STATIC_SITE_IMPORTER_ROOT}/${relative}` + if (count > component.limits.files || total > component.limits.bytes) throw new Error(`Runtime archive component ${component.id} exceeds its extraction budget.`) + const relative = entry.name.slice(packageRoot.length) + const destination = `${installRoot}/${relative}` php.mkdir(destination.slice(0, destination.lastIndexOf("/"))) php.writeFile(destination, bytes) required.delete(entry.name) } - if (required.size) throw new Error(`Static Site Importer archive is missing required files: ${[...required].join(", ")}.`) - php.mkdir(STATIC_SITE_IMPORTER_MU_LOADER.slice(0, STATIC_SITE_IMPORTER_MU_LOADER.lastIndexOf("/"))) - php.writeFile(STATIC_SITE_IMPORTER_MU_LOADER, new TextEncoder().encode(` { return new File([Uint8Array.from(bytes).buffer], "sqlite-database-integration.zip", { type: "application/zip" }) } -async function readStaticSiteImporterArtifact(bucket: R2Bucket): Promise { - return readRuntimeArchiveArtifact(bucket, staticSiteImporterArtifactManifest as RuntimeArchiveArtifactManifest) +async function readWebsiteImporterArtifact(bucket: R2Bucket): Promise { + return readRuntimeArchiveArtifact(bucket, websiteImporterArtifact) } async function serveWordPressWpContent(request: Request, bucket: R2Bucket, coordinator: RevisionCoordinator, site: SiteContext): Promise { @@ -2480,7 +2498,7 @@ async function serveWordPressWpContent(request: Request, bucket: R2Bucket, coord } catch { return new Response("Invalid WordPress content path.", { status: 400 }) } - if (!isCanonicalWpContentPath(path)) return null + if (!isCanonicalWpContentPath(path, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS)) return null const state = await coordinator.state() if (!state.pointer) return null const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default @@ -2494,8 +2512,8 @@ async function serveWordPressWpContent(request: Request, bucket: R2Bucket, coord } } const manifest = await readMarkdownManifest(bucket, state.pointer, site) - validateWpContentManifestFiles(manifest?.wpContent ?? [], site) - validateWpContentDeletedPaths(manifest?.wpContentDeleted ?? []) + validateWpContentManifestFiles(manifest?.wpContent ?? [], site, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) + validateWpContentDeletedPaths(manifest?.wpContentDeleted ?? [], WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS) if (manifest?.wpContentDeleted?.includes(path)) return new Response("WordPress content not found.", { status: 404 }) const file = manifest?.wpContent?.find((candidate) => candidate.path === path) if (!file) return null diff --git a/packages/runtime-cloudflare/src/wp-content-persistence.ts b/packages/runtime-cloudflare/src/wp-content-persistence.ts index 67037210..8d39fb9c 100644 --- a/packages/runtime-cloudflare/src/wp-content-persistence.ts +++ b/packages/runtime-cloudflare/src/wp-content-persistence.ts @@ -15,12 +15,18 @@ export interface WpContentManifestFile extends WpContentFileMetadata { sha256: string } -export function validateWpContentMetadata(value: unknown): asserts value is WpContentFileMetadata[] { +const DEFAULT_RUNTIME_OWNED_PATHS = [ + "plugins/markdown-database-integration/", + "plugins/sqlite-database-integration/", + "mu-plugins/wp-codebox-cloudflare-canonical-changes.php", +] + +export function validateWpContentMetadata(value: unknown, runtimeOwnedPaths: string[] = []): asserts value is WpContentFileMetadata[] { if (!Array.isArray(value) || value.length > MAX_WP_CONTENT_FILES) throw new Error("Canonical wp-content file count is invalid.") let total = 0 const paths = new Set() for (const file of value) { - if (!file || typeof file !== "object" || typeof file.path !== "string" || !isCanonicalWpContentPath(file.path) || paths.has(file.path) + if (!file || typeof file !== "object" || typeof file.path !== "string" || !isCanonicalWpContentPath(file.path, runtimeOwnedPaths) || paths.has(file.path) || !Number.isSafeInteger(file.size) || file.size < 0 || file.size > MAX_WP_CONTENT_FILE_BYTES) { throw new Error("Canonical wp-content metadata contains an invalid file.") } @@ -30,8 +36,8 @@ 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, site: SiteContext = DEFAULT_SITE_CONTEXT): asserts value is WpContentManifestFile[] { - validateWpContentMetadata(value) +export function validateWpContentManifestFiles(value: unknown, site: SiteContext = DEFAULT_SITE_CONTEXT, runtimeOwnedPaths: string[] = []): asserts value is WpContentManifestFile[] { + validateWpContentMetadata(value, runtimeOwnedPaths) for (const file of value as WpContentManifestFile[]) { 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.") @@ -39,20 +45,18 @@ export function validateWpContentManifestFiles(value: unknown, site: SiteContext } } -export function validateWpContentDeletedPaths(value: unknown): asserts value is string[] { - if (!Array.isArray(value) || value.length > MAX_WP_CONTENT_FILES || value.some((path) => typeof path !== "string" || !isCanonicalWpContentPath(path))) { +export function validateWpContentDeletedPaths(value: unknown, runtimeOwnedPaths: string[] = []): asserts value is string[] { + if (!Array.isArray(value) || value.length > MAX_WP_CONTENT_FILES || value.some((path) => typeof path !== "string" || !isCanonicalWpContentPath(path, runtimeOwnedPaths))) { throw new Error("Canonical wp-content tombstones are invalid.") } if (value.some((path, index) => index > 0 && value[index - 1] >= path)) throw new Error("Canonical wp-content tombstones are non-deterministic.") } -export function isCanonicalWpContentPath(path: string): boolean { +export function isCanonicalWpContentPath(path: string, runtimeOwnedPaths: string[] = []): boolean { if (!/^(?:plugins|themes|languages|mu-plugins)\//.test(path) || path.includes("\\") || path.split("/").some((segment) => !segment || segment === "." || segment === "..")) return false - return !path.startsWith("plugins/markdown-database-integration/") - && !path.startsWith("plugins/sqlite-database-integration/") - && !path.startsWith("plugins/static-site-importer/") - && path !== "mu-plugins/wp-codebox-cloudflare-canonical-changes.php" - && !path.startsWith("mu-plugins/wp-codebox-cloudflare-canonical-changes.php/") - && path !== "mu-plugins/wp-codebox-static-site-importer.php" - && !path.startsWith("mu-plugins/wp-codebox-static-site-importer.php/") + return !runtimeOwnedWpContentPaths(runtimeOwnedPaths).some((owned) => owned.endsWith("/") ? path.startsWith(owned) : path === owned || path.startsWith(`${owned}/`)) +} + +export function runtimeOwnedWpContentPaths(additional: string[] = []): string[] { + return [...new Set([...DEFAULT_RUNTIME_OWNED_PATHS, ...additional])] } diff --git a/packages/runtime-core/src/runtime-archive-component.ts b/packages/runtime-core/src/runtime-archive-component.ts new file mode 100644 index 00000000..7b70a9d9 --- /dev/null +++ b/packages/runtime-core/src/runtime-archive-component.ts @@ -0,0 +1,66 @@ +export const RUNTIME_ARCHIVE_COMPONENT_SCHEMA = "wp-codebox/runtime-archive-component/v1" as const +export const RUNTIME_ARCHIVE_COMPONENT_SOURCE_SCHEMA = "wp-codebox/runtime-archive-component-source/v1" as const + +export interface RuntimeArchiveComponent { + schema: typeof RUNTIME_ARCHIVE_COMPONENT_SCHEMA + id: string + package: { profile: string; root: string } + wordpress: { install_path: string; bootstrap_file: string; loader_path?: string; version_constant?: string } + abilities: Record + limits: { files: number; bytes: number } +} + +export interface RuntimeArchiveComponentSource { + schema: typeof RUNTIME_ARCHIVE_COMPONENT_SOURCE_SCHEMA + source: { url: string; version?: string; identity?: string; sha256: string } + component: RuntimeArchiveComponent + migration?: { + source_sha256: string + selectors: Array<{ type: "file" | "prefix"; path: string }> + required_files: string[] + } +} + +export function runtimeArchiveComponent(value: unknown): RuntimeArchiveComponent { + if (!isRecord(value) || value.schema !== RUNTIME_ARCHIVE_COMPONENT_SCHEMA || !isSlug(value.id)) throw new Error("Runtime archive component identity is invalid.") + if (!isRecord(value.package) || !isSlug(value.package.profile) || !isSlug(value.package.root)) throw new Error("Runtime archive component package is invalid.") + if (!isRecord(value.wordpress) || !isWpContentPath(value.wordpress.install_path) || !isSafeRelativePath(value.wordpress.bootstrap_file) || value.wordpress.bootstrap_file.endsWith("/")) throw new Error("Runtime archive component WordPress contract is invalid.") + if (value.wordpress.loader_path !== undefined && (!isWpContentPath(value.wordpress.loader_path) || !String(value.wordpress.loader_path).startsWith("mu-plugins/") || String(value.wordpress.loader_path).endsWith("/"))) throw new Error("Runtime archive component loader path is invalid.") + if (value.wordpress.version_constant !== undefined && (typeof value.wordpress.version_constant !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(value.wordpress.version_constant))) throw new Error("Runtime archive component version constant is invalid.") + if (!isRecord(value.abilities) || !Object.keys(value.abilities).length || Object.entries(value.abilities).some(([name, ability]) => !isSlug(name) || typeof ability !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(ability))) throw new Error("Runtime archive component abilities are invalid.") + if (!isRecord(value.limits) || !Number.isSafeInteger(value.limits.files) || Number(value.limits.files) < 1 || Number(value.limits.files) > 100_000 || !Number.isSafeInteger(value.limits.bytes) || Number(value.limits.bytes) < 1 || Number(value.limits.bytes) > 256 * 1024 * 1024) throw new Error("Runtime archive component limits are invalid.") + return value as unknown as RuntimeArchiveComponent +} + +export function runtimeArchiveComponentSource(value: unknown): RuntimeArchiveComponentSource { + if (!isRecord(value) || value.schema !== RUNTIME_ARCHIVE_COMPONENT_SOURCE_SCHEMA || !isRecord(value.source) || typeof value.source.url !== "string" || !value.source.url.startsWith("https://") || !isSha256(value.source.sha256)) throw new Error("Runtime archive component source is invalid.") + const component = runtimeArchiveComponent(value.component) + if (value.migration !== undefined) { + if (!isRecord(value.migration) || !isSha256(value.migration.source_sha256) || value.migration.source_sha256 !== value.source.sha256 || !Array.isArray(value.migration.selectors) || !value.migration.selectors.length || !Array.isArray(value.migration.required_files) || !value.migration.required_files.length) throw new Error("Runtime archive component migration is invalid.") + } + return { ...(value as unknown as RuntimeArchiveComponentSource), component } +} + +export function runtimeArchiveComponentOwnedWpContentPaths(component: RuntimeArchiveComponent): string[] { + return [component.wordpress.install_path.endsWith("/") ? component.wordpress.install_path : `${component.wordpress.install_path}/`, component.wordpress.loader_path].filter((path): path is string => Boolean(path)) +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function isSlug(value: unknown): value is string { + return typeof value === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) +} + +function isSha256(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{64}$/.test(value) +} + +function isSafeRelativePath(value: unknown): value is string { + return typeof value === "string" && Boolean(value) && !value.startsWith("/") && !value.includes("\\") && value.split("/").every((segment) => Boolean(segment) && segment !== "." && segment !== "..") +} + +function isWpContentPath(value: unknown): value is string { + return isSafeRelativePath(value) && /^(?:plugins|mu-plugins)\//.test(value) +} diff --git a/packages/runtime-core/src/runtime-package-profile.ts b/packages/runtime-core/src/runtime-package-profile.ts new file mode 100644 index 00000000..64fe85dd --- /dev/null +++ b/packages/runtime-core/src/runtime-package-profile.ts @@ -0,0 +1,67 @@ +export interface RuntimePackageManifest { + schema: string + package: string + package_root: string + profiles: Record + required_files: string[] + }> +} + +export interface RuntimePackageFileSelection { + sourcePath: string + targetPath: string +} + +export function parseRuntimePackageManifest(value: string): RuntimePackageManifest { + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + throw new Error("Runtime package manifest is not valid JSON.") + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Runtime package manifest must be an object.") + const manifest = parsed as Partial + if (typeof manifest.schema !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*\/runtime-package-manifest\/v1$/.test(manifest.schema)) throw new Error("Runtime package manifest schema is invalid.") + if (!isSafeSegment(manifest.package) || !isSafeSegment(manifest.package_root)) throw new Error("Runtime package manifest identity is invalid.") + if (!manifest.profiles || typeof manifest.profiles !== "object" || Array.isArray(manifest.profiles)) throw new Error("Runtime package manifest profiles are invalid.") + return manifest as RuntimePackageManifest +} + +export function selectRuntimePackageProfileFiles(manifest: RuntimePackageManifest, profileName: string, archivePaths: string[], manifestPath: string): RuntimePackageFileSelection[] { + const profile = manifest.profiles[profileName] + if (!profile || !Array.isArray(profile.abilities) || !Array.isArray(profile.selectors) || !Array.isArray(profile.required_files)) throw new Error(`Runtime package profile is unavailable: ${profileName}`) + if (!profile.abilities.length || profile.abilities.some((ability) => typeof ability !== "string" || !ability.trim())) throw new Error(`Runtime package profile abilities are invalid: ${profileName}`) + if (!profile.selectors.length) throw new Error(`Runtime package profile selectors are empty: ${profileName}`) + const selectors = profile.selectors.map((selector) => { + if (!selector || !["file", "prefix"].includes(selector.type) || !isSafeRelativePath(selector.path)) throw new Error(`Runtime package profile selector is invalid: ${profileName}`) + if ((selector.type === "prefix") !== selector.path.endsWith("/")) throw new Error(`Runtime package profile selector type does not match its path: ${profileName}`) + return selector + }) + if (!manifestPath.endsWith("/runtime-package-manifest.json") || !isSafeRelativePath(manifestPath)) throw new Error("Runtime package manifest archive path is invalid.") + const archiveRoot = manifestPath.slice(0, -"runtime-package-manifest.json".length) + const selected = archivePaths + .filter((path) => path.startsWith(archiveRoot) && isSafeRelativePath(path)) + .map((sourcePath) => ({ sourcePath, relativePath: sourcePath.slice(archiveRoot.length) })) + .filter(({ relativePath }) => selectors.some((selector) => selector.type === "file" ? relativePath === selector.path : relativePath.startsWith(selector.path))) + .map(({ sourcePath, relativePath }) => ({ sourcePath, targetPath: `${manifest.package_root}/${relativePath}` })) + .sort((left, right) => left.targetPath.localeCompare(right.targetPath)) + const selectedRelative = new Set(selected.map(({ targetPath }) => targetPath.slice(manifest.package_root.length + 1))) + for (const required of profile.required_files) { + if (!isSafeRelativePath(required) || required.endsWith("/") || !selectedRelative.has(required)) throw new Error(`Runtime package profile is missing required file: ${required}`) + } + if (new Set(selected.map(({ targetPath }) => targetPath)).size !== selected.length) throw new Error("Runtime package profile produced duplicate target paths.") + return selected +} + +function isSafeSegment(value: unknown): value is string { + return typeof value === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) +} + +function isSafeRelativePath(value: unknown): value is string { + if (typeof value !== "string" || !value || value.startsWith("/") || value.includes("\\") || !/^[A-Za-z0-9._/-]+$/.test(value)) return false + const segments = value.split("/") + return segments.every((segment, index) => (segment !== "" || index === segments.length - 1) && segment !== "." && segment !== "..") +} diff --git a/scripts/cloudflare-local-gate.mjs b/scripts/cloudflare-local-gate.mjs index 346d58c2..c5d3586c 100644 --- a/scripts/cloudflare-local-gate.mjs +++ b/scripts/cloudflare-local-gate.mjs @@ -217,7 +217,7 @@ async function assertPublicProvisioning(input) { await runScheduledCron() } const imported = operation?.receipt?.ssiResult - if (operation?.state !== "succeeded" || imported?.status !== "imported" || imported.staticSiteImporterVersion !== "1.3.4" || !imported.themeSlug + if (operation?.state !== "succeeded" || imported?.status !== "imported" || imported.componentId !== "website-importer" || imported.importerVersion !== "1.3.4" || !imported.themeSlug || !imported.pages || Object.keys(imported.pages).length !== expectedPageCount || Object.values(imported.quality ?? {}).some((count) => count !== 0) || operation.receipt?.publication?.status !== "promoted") { throw new Error(`Public provisioning did not produce a terminal import receipt: ${JSON.stringify(operation)}.`) @@ -420,7 +420,7 @@ async function importStaticArtifact(input, expectedStatus = coordinator === "d1" if (operation.state === "failed") throw new Error(`Queued static artifact operation failed: ${operationBody}.`) } } - if (expectedStatus !== 409 && (!result || typeof result !== "object" || result.status !== "imported" || result.staticSiteImporterVersion !== "1.3.4" || !result.themeSlug + if (expectedStatus !== 409 && (!result || typeof result !== "object" || result.status !== "imported" || result.componentId !== "website-importer" || result.importerVersion !== "1.3.4" || !result.themeSlug || !result.pages || !Object.keys(result.pages).length || Object.values(result.quality ?? {}).some((count) => count !== 0) || (expectedStatus === 201 && (!response.headers.get("x-wp-codebox-canonical-revision") || !response.headers.get("x-wp-codebox-canonical-version"))))) { throw new Error(`Static artifact import returned invalid evidence: ${body}.`) diff --git a/scripts/generate-cloudflare-wordpress-runtime-corpus.ts b/scripts/generate-cloudflare-wordpress-runtime-corpus.ts index 182e81bf..e15cd504 100644 --- a/scripts/generate-cloudflare-wordpress-runtime-corpus.ts +++ b/scripts/generate-cloudflare-wordpress-runtime-corpus.ts @@ -6,6 +6,9 @@ import { isWordPressRuntimeFile, isWordPressStaticAsset } from "../packages/runt import { WORDPRESS_RUNTIME_ARTIFACT_SCHEMA, wordpressRuntimeArtifactKey, type WordPressRuntimeArtifactManifest } from "../packages/runtime-cloudflare/src/wordpress-runtime-artifact.js" import { WORDPRESS_STATIC_ARTIFACT_SCHEMA, validateWordPressStaticArtifactManifest, wordpressStaticArtifactKey, type WordPressStaticArtifactManifest } from "../packages/runtime-cloudflare/src/wordpress-static-artifact.js" import { RUNTIME_ARCHIVE_ARTIFACT_SCHEMA, runtimeArchiveArtifactKey, validateRuntimeArchiveArtifactManifest, type RuntimeArchiveArtifactManifest } from "../packages/runtime-cloudflare/src/runtime-archive-artifact.js" +import { parseRuntimePackageManifest, selectRuntimePackageProfileFiles } from "../packages/runtime-core/src/runtime-package-profile.js" +import { runtimeArchiveComponentSource } from "../packages/runtime-core/src/runtime-archive-component.js" +import websiteImporterSourceContract from "../packages/runtime-cloudflare/components/website-importer.json" with { type: "json" } const sourceUrl = process.env.WORDPRESS_RUNTIME_ARCHIVE_URL ?? "https://downloads.wordpress.org/release/wordpress-7.0.2.zip" const sourceVersion = process.env.WORDPRESS_RUNTIME_VERSION ?? "7.0.2" @@ -16,10 +19,9 @@ const staticManifestOutput = resolve("packages/runtime-cloudflare/assets/wordpre const sqliteSourceUrl = "https://github.com/WordPress/sqlite-database-integration/releases/download/v2.2.23/plugin-sqlite-database-integration.zip" const sqliteOutput = resolve("artifacts/cloudflare-sqlite-database-integration.zip") const sqliteManifestOutput = resolve("packages/runtime-cloudflare/assets/sqlite-database-integration-artifact.json") -const staticSiteImporterSourceUrl = "https://github.com/Automattic/static-site-importer/releases/download/v1.3.4/static-site-importer.zip" -const staticSiteImporterSha256 = "8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29" -const staticSiteImporterOutput = resolve("artifacts/cloudflare-static-site-importer.zip") -const staticSiteImporterManifestOutput = resolve("packages/runtime-cloudflare/assets/static-site-importer-artifact.json") +const websiteImporterSource = runtimeArchiveComponentSource(websiteImporterSourceContract) +const websiteImporterOutput = resolve(`artifacts/cloudflare-${websiteImporterSource.component.id}.zip`) +const websiteImporterManifestOutput = resolve(`packages/runtime-cloudflare/assets/${websiteImporterSource.component.id}-artifact.json`) const response = await fetch(sourceUrl) if (!response.ok || !response.body) throw new Error(`Unable to download WordPress archive: ${response.status}.`) const identity = response.headers.get("etag") ?? response.headers.get("last-modified") ?? undefined @@ -84,29 +86,77 @@ validateRuntimeArchiveArtifactManifest(sqliteManifest) await writeFile(sqliteOutput, sqliteArchive) await writeFile(sqliteManifestOutput, `${JSON.stringify(sqliteManifest, null, 2)}\n`) -const staticSiteImporterResponse = await fetch(staticSiteImporterSourceUrl) -if (!staticSiteImporterResponse.ok) throw new Error(`Unable to download Static Site Importer archive: ${staticSiteImporterResponse.status}.`) -const staticSiteImporterArchive = new Uint8Array(await staticSiteImporterResponse.arrayBuffer()) -const actualStaticSiteImporterSha256 = sha256Hex(staticSiteImporterArchive) -if (actualStaticSiteImporterSha256 !== staticSiteImporterSha256) throw new Error("Static Site Importer release archive does not match its pinned digest.") -const staticSiteImporterManifest: RuntimeArchiveArtifactManifest = { +const websiteImporterResponse = await fetch(websiteImporterSource.source.url) +if (!websiteImporterResponse.ok) throw new Error(`Unable to download runtime archive component ${websiteImporterSource.component.id}: ${websiteImporterResponse.status}.`) +const websiteImporterSourceArchive = new Uint8Array(await websiteImporterResponse.arrayBuffer()) +if (sha256Hex(websiteImporterSourceArchive) !== websiteImporterSource.source.sha256) throw new Error(`Runtime archive component ${websiteImporterSource.component.id} does not match its pinned digest.`) +const websiteImporterSourceFiles: File[] = [] +for await (const file of decodeZip(new Blob([websiteImporterSourceArchive]).stream())) { + if (!file.name.endsWith("/")) websiteImporterSourceFiles.push(file) +} +const websiteImporterPackageManifest = websiteImporterSourceFiles.find((file) => file.name.endsWith("/runtime-package-manifest.json")) +const websiteImporterFiles = websiteImporterPackageManifest + ? await selectProfileFiles(websiteImporterSourceFiles, websiteImporterPackageManifest, websiteImporterSource.component.package.profile) + : selectMigrationProfileFiles(websiteImporterSourceFiles) +websiteImporterFiles.sort((left, right) => left.name.localeCompare(right.name)) +const bootstrapPath = `${websiteImporterSource.component.package.root}/${websiteImporterSource.component.wordpress.bootstrap_file}` +if (!websiteImporterFiles.some((file) => file.name === bootstrapPath)) throw new Error(`Runtime archive component ${websiteImporterSource.component.id} is missing its bootstrap file: ${bootstrapPath}`) +const websiteImporterArchive = new Uint8Array(await new Response(encodeZip(websiteImporterFiles)).arrayBuffer()) +const websiteImporterSha256 = sha256Hex(websiteImporterArchive) +const websiteImporterManifest: RuntimeArchiveArtifactManifest = { schema: RUNTIME_ARCHIVE_ARTIFACT_SCHEMA, - name: "static-site-importer", - key: runtimeArchiveArtifactKey("static-site-importer", staticSiteImporterSha256), - archive: { sha256: staticSiteImporterSha256, size: staticSiteImporterArchive.byteLength }, - source: { url: staticSiteImporterSourceUrl, version: "1.3.4", identity: "08b9dd650f3c3161c5b350796a5db6ef083516ae" }, + name: websiteImporterSource.component.id, + key: runtimeArchiveArtifactKey(websiteImporterSource.component.id, websiteImporterSha256), + archive: { sha256: websiteImporterSha256, size: websiteImporterArchive.byteLength }, + source: { url: websiteImporterSource.source.url, ...(websiteImporterSource.source.version ? { version: websiteImporterSource.source.version } : {}), ...(websiteImporterSource.source.identity ? { identity: websiteImporterSource.source.identity } : {}) }, + component: websiteImporterSource.component, } -validateRuntimeArchiveArtifactManifest(staticSiteImporterManifest) -await writeFile(staticSiteImporterOutput, staticSiteImporterArchive) -await writeFile(staticSiteImporterManifestOutput, `${JSON.stringify(staticSiteImporterManifest, null, 2)}\n`) +validateRuntimeArchiveArtifactManifest(websiteImporterManifest) +await writeFile(websiteImporterOutput, websiteImporterArchive) +await writeFile(websiteImporterManifestOutput, `${JSON.stringify(websiteImporterManifest, null, 2)}\n`) console.log(JSON.stringify({ runtime: { key: manifest.key, bytes: archive.byteLength, files: manifest.files.length, sha256: archiveSha256 }, static: { key: staticManifest.key, bytes: staticBlob.byteLength, files: staticManifest.files.length, sha256: staticSha256 }, sqlite: { key: sqliteManifest.key, bytes: sqliteArchive.byteLength, sha256: sqliteSha256 }, - staticSiteImporter: { key: staticSiteImporterManifest.key, bytes: staticSiteImporterArchive.byteLength, sha256: staticSiteImporterSha256 }, + [websiteImporterSource.component.id]: { key: websiteImporterManifest.key, bytes: websiteImporterArchive.byteLength, sha256: websiteImporterSha256 }, source: manifest.source, })) function sha256Hex(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex") } + +async function selectProfileFiles(files: File[], manifestFile: File, profile: string): Promise { + const manifest = parseRuntimePackageManifest(await manifestFile.text()) + if (manifest.package_root !== websiteImporterSource.component.package.root) throw new Error(`Runtime package profile root does not match component ${websiteImporterSource.component.id}.`) + return materializeSelectedFiles(files, selectRuntimePackageProfileFiles(manifest, profile, files.map((file) => file.name), manifestFile.name)) +} + +function selectMigrationProfileFiles(files: File[]): File[] { + const migration = websiteImporterSource.migration + if (!migration || migration.source_sha256 !== websiteImporterSource.source.sha256) throw new Error(`Runtime archive component ${websiteImporterSource.component.id} does not declare package profile ${websiteImporterSource.component.package.profile}.`) + const root = `${websiteImporterSource.component.package.root}/` + const manifestPath = `${root}runtime-package-manifest.json` + const syntheticManifest = parseRuntimePackageManifest(JSON.stringify({ + schema: `${websiteImporterSource.component.id}/runtime-package-manifest/v1`, + package: websiteImporterSource.component.id, + package_root: websiteImporterSource.component.package.root, + profiles: { + [websiteImporterSource.component.package.profile]: { + abilities: Object.values(websiteImporterSource.component.abilities), + selectors: migration.selectors, + required_files: migration.required_files, + }, + }, + })) + return materializeSelectedFiles(files, selectRuntimePackageProfileFiles(syntheticManifest, websiteImporterSource.component.package.profile, files.map((file) => file.name), manifestPath)) +} + +function materializeSelectedFiles(files: File[], selected: Array<{ sourcePath: string; targetPath: string }>): File[] { + const filesByPath = new Map(files.map((file) => [file.name, file])) + return selected.map(({ sourcePath, targetPath }) => { + const file = filesByPath.get(sourcePath) + if (!file) throw new Error(`Runtime package profile selected an unavailable archive file: ${sourcePath}`) + return new File([file], targetPath, { lastModified: 0 }) + }) +} diff --git a/scripts/provision-cloudflare-wordpress-runtime-corpus.mjs b/scripts/provision-cloudflare-wordpress-runtime-corpus.mjs index 9cfdff59..22210180 100644 --- a/scripts/provision-cloudflare-wordpress-runtime-corpus.mjs +++ b/scripts/provision-cloudflare-wordpress-runtime-corpus.mjs @@ -21,16 +21,16 @@ const sqliteManifest = JSON.parse(await readFile("packages/runtime-cloudflare/as const sqliteArchive = await readFile("artifacts/cloudflare-sqlite-database-integration.zip") const sqliteActual = createHash("sha256").update(sqliteArchive).digest("hex") if (sqliteActual !== sqliteManifest.archive.sha256 || sqliteArchive.byteLength !== sqliteManifest.archive.size || sqliteManifest.key !== `runtime/archives/sqlite-database-integration/${sqliteActual}.zip`) throw new Error("Local SQLite integration artifact does not match its content-addressed manifest.") -const staticSiteImporterManifest = JSON.parse(await readFile("packages/runtime-cloudflare/assets/static-site-importer-artifact.json", "utf8")) -const staticSiteImporterArchive = await readFile("artifacts/cloudflare-static-site-importer.zip") -const staticSiteImporterActual = createHash("sha256").update(staticSiteImporterArchive).digest("hex") -if (staticSiteImporterActual !== staticSiteImporterManifest.archive.sha256 || staticSiteImporterArchive.byteLength !== staticSiteImporterManifest.archive.size || staticSiteImporterManifest.key !== `runtime/archives/static-site-importer/${staticSiteImporterActual}.zip`) throw new Error("Local Static Site Importer artifact does not match its content-addressed manifest.") +const websiteImporterManifest = JSON.parse(await readFile("packages/runtime-cloudflare/assets/website-importer-artifact.json", "utf8")) +const websiteImporterArchive = await readFile("artifacts/cloudflare-website-importer.zip") +const websiteImporterActual = createHash("sha256").update(websiteImporterArchive).digest("hex") +if (websiteImporterActual !== websiteImporterManifest.archive.sha256 || websiteImporterArchive.byteLength !== websiteImporterManifest.archive.size || websiteImporterManifest.key !== `runtime/archives/${websiteImporterManifest.name}/${websiteImporterActual}.zip`) throw new Error("Local website importer artifact does not match its content-addressed manifest.") for (const artifact of [ { key: manifest.key, size: manifest.archive.size, file: "artifacts/cloudflare-wordpress-runtime-corpus.zip" }, { key: staticManifest.key, size: staticManifest.blob.size, file: "artifacts/cloudflare-wordpress-static-corpus.bin" }, { key: sqliteManifest.key, size: sqliteManifest.archive.size, file: "artifacts/cloudflare-sqlite-database-integration.zip" }, - { key: staticSiteImporterManifest.key, size: staticSiteImporterManifest.archive.size, file: "artifacts/cloudflare-static-site-importer.zip" }, + { key: websiteImporterManifest.key, size: websiteImporterManifest.archive.size, file: "artifacts/cloudflare-website-importer.zip" }, ]) { const command = ["exec", "--", "wrangler", "r2", "object", "put", `wp-codebox-runtime-chubes/${artifact.key}`, "--file", artifact.file, local ? "--local" : "--remote"] if (persistTo) command.push("--persist-to", persistTo) diff --git a/tests/cloudflare-runtime.test.ts b/tests/cloudflare-runtime.test.ts index 0ded8aab..8936a199 100644 --- a/tests/cloudflare-runtime.test.ts +++ b/tests/cloudflare-runtime.test.ts @@ -6,11 +6,14 @@ import { promisify } from "node:util" import test from "node:test" import { decodeZip, encodeZip } from "@php-wasm/stream-compression" import { RUNTIME_COMMAND_RESULT_SCHEMA } from "../packages/runtime-core/src/runtime-contracts.js" +import { parseRuntimePackageManifest, selectRuntimePackageProfileFiles } from "../packages/runtime-core/src/runtime-package-profile.js" +import { runtimeArchiveComponentOwnedWpContentPaths, runtimeArchiveComponentSource } from "../packages/runtime-core/src/runtime-archive-component.js" import { CLOUDFLARE_RUNTIME_HEALTH_MARKER, CLOUDFLARE_RUNTIME_HEALTH_SCHEMA, cloudflareRuntimeHealthResponse } from "../packages/runtime-cloudflare/src/health-envelope.js" import { leaseRetryDelayMs } from "../packages/runtime-cloudflare/src/lease-retry.js" import { MUTATION_DIAGNOSTIC_SCHEMA, mutationRetentionContract } from "../packages/runtime-cloudflare/src/mutation-memory.js" import { selectOperatorCoordinator } from "../packages/runtime-cloudflare/src/operator-coordinator.js" import { canonicalPublicRoute, normalizePublishedRoutes, PUBLISHED_REVISION_SCHEMA, publishedPageObjectKey, publishedRevisionObjectKey, R2_PUBLISHED_CURRENT_KEY, validatePublishedRevision } from "../packages/runtime-cloudflare/src/published-reader.js" +import { isCacheablePublicationResponse } from "../packages/runtime-cloudflare/src/publication-response.js" import { routeWorkerRequest } from "../packages/runtime-cloudflare/src/request-routing.js" import { toFetchResponse, toPHPRequest } from "../packages/runtime-cloudflare/src/request-translation.js" import { readStaticArtifactImport, STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA } from "../packages/runtime-cloudflare/src/static-artifact-import.js" @@ -24,6 +27,45 @@ import { MAX_WP_CONTENT_FILE_BYTES, R2_WP_CONTENT_OBJECT_PREFIX, validateWpConte const execFileAsync = promisify(execFile) +test("Cloudflare publications accept only cache-safe HTML and permanent redirects", () => { + assert.equal(isCacheablePublicationResponse(new Response("

Page

", { headers: { "content-type": "text/html; charset=UTF-8" } })), true) + assert.equal(isCacheablePublicationResponse(new Response(null, { status: 301, headers: { location: "/destination/" } })), true) + assert.equal(isCacheablePublicationResponse(new Response(null, { status: 308, headers: { location: "/destination/" } })), true) + assert.equal(isCacheablePublicationResponse(new Response("json", { headers: { "content-type": "application/json" } })), false) + assert.equal(isCacheablePublicationResponse(new Response(null, { status: 302, headers: { location: "/destination/" } })), false) + assert.equal(isCacheablePublicationResponse(new Response(null, { status: 301 })), false) + assert.equal(isCacheablePublicationResponse(new Response("

Private

", { headers: { "content-type": "text/html", "set-cookie": "session=secret" } })), false) +}) + +test("runtime package profiles select declared capabilities without consumer path knowledge", () => { + const manifest = parseRuntimePackageManifest(JSON.stringify({ + schema: "static-site-importer/runtime-package-manifest/v1", + package: "static-site-importer", + package_root: "static-site-importer", + profiles: { + "website-artifact-import": { + abilities: ["static-site-importer/import-website-artifact"], + selectors: [{ type: "file", path: "runtime-package-manifest.json" }, { type: "prefix", path: "includes/" }], + required_files: ["runtime-package-manifest.json", "includes/import.php"], + }, + }, + })) + const paths = ["release/runtime-package-manifest.json", "release/includes/import.php", "release/vendor/figma.php"] + assert.deepEqual(selectRuntimePackageProfileFiles(manifest, "website-artifact-import", paths, paths[0]), [ + { sourcePath: "release/includes/import.php", targetPath: "static-site-importer/includes/import.php" }, + { sourcePath: "release/runtime-package-manifest.json", targetPath: "static-site-importer/runtime-package-manifest.json" }, + ]) + assert.throws(() => selectRuntimePackageProfileFiles(manifest, "website-artifact-import", paths.filter((path) => !path.endsWith("import.php")), paths[0]), /missing required file/) +}) + +test("runtime archive components declare source, WordPress materialization, and ability aliases", async () => { + const descriptor = runtimeArchiveComponentSource(JSON.parse(await readFile(new URL("../packages/runtime-cloudflare/components/website-importer.json", import.meta.url), "utf8"))) + assert.equal(descriptor.component.id, "website-importer") + assert.equal(descriptor.component.abilities["website-artifact-import"], "static-site-importer/import-website-artifact") + assert.deepEqual(runtimeArchiveComponentOwnedWpContentPaths(descriptor.component), ["plugins/static-site-importer/", "mu-plugins/wp-codebox-runtime-component-website-importer.php"]) + assert.throws(() => runtimeArchiveComponentSource({ ...descriptor, component: { ...descriptor.component, wordpress: { ...descriptor.component.wordpress, install_path: "plugins/../escape" } } }), /WordPress contract is invalid/) +}) + test("Cloudflare static artifact imports require bounded content-addressed R2 input", async () => { const artifact = { schema: "blocks-engine/php-transformer/site-artifact/v1", root: "website", entrypoint: "website/index.html", files: [{ path: "website/index.html", content: "

Verified

" }] } const bytes = new TextEncoder().encode(JSON.stringify(artifact)) @@ -60,6 +102,7 @@ test("Cloudflare upload manifests reject unbounded or non-canonical R2 files", ( test("Cloudflare wp-content manifests allow bounded user code and reject runtime-owned files", () => { const sha256 = "a".repeat(64) const valid = [{ path: "plugins/example/example.php", size: 128, sha256, objectKey: `${R2_WP_CONTENT_OBJECT_PREFIX}/${sha256}` }] + const componentOwnedPaths = ["plugins/example-runtime/", "mu-plugins/example-runtime-loader.php"] assert.doesNotThrow(() => validateWpContentManifestFiles(valid)) assert.doesNotThrow(() => validateWpContentMetadata([{ path: "themes/example/style.css", size: 0 }, { path: "languages/example.mo", size: 1 }, { path: "mu-plugins/example.php", size: 1 }])) assert.throws(() => validateWpContentMetadata([{ path: "plugins/../secret.php", size: 1 }]), /invalid file/) @@ -67,6 +110,8 @@ test("Cloudflare wp-content manifests allow bounded user code and reject runtime assert.throws(() => validateWpContentMetadata([{ path: "plugins/sqlite-database-integration/owned.php", size: 1 }]), /invalid file/) assert.throws(() => validateWpContentMetadata([{ path: "mu-plugins/wp-codebox-cloudflare-canonical-changes.php", size: 1 }]), /invalid file/) assert.throws(() => validateWpContentMetadata([{ path: "mu-plugins/wp-codebox-cloudflare-canonical-changes.php/child.php", size: 1 }]), /invalid file/) + assert.throws(() => validateWpContentMetadata([{ path: "plugins/example-runtime/owned.php", size: 1 }], componentOwnedPaths), /invalid file/) + assert.throws(() => validateWpContentMetadata([{ path: "mu-plugins/example-runtime-loader.php", size: 1 }], componentOwnedPaths), /invalid file/) assert.throws(() => validateWpContentMetadata([{ path: "themes/example/large.css", size: MAX_WP_CONTENT_FILE_BYTES + 1 }]), /invalid file/) assert.throws(() => validateWpContentManifestFiles([{ ...valid[0], objectKey: "sites/default/uploads/objects/foreign" }]), /invalid file/) assert.doesNotThrow(() => validateWpContentDeletedPaths(["themes/example/style.css"])) @@ -331,7 +376,7 @@ test("Cloudflare canonical runtime patches the unique init call with runtime per assert.doesNotMatch(patcher, /mu-plugins|add_action\(|WP_Site_Health|wp_schedule_site_health_cron|\$GLOBALS\['wp_filter'\]/) assert.doesNotMatch(worker, /materializeCanonicalCronAdapter|wp-codebox-canonical-cron-policy/) assert.match(worker, /runtimeBucket\?: R2Bucket,\n shouldPatchCanonicalRuntimePoliciesAtInit = false,\n uploadFiles\?: RuntimeFile\[\],\n wpContentFiles\?: RuntimeFile\[\],/) - assert.match(worker, /authConstants, bucket, true, revision\.uploads, revision\.wpContent, revision\.wpContentDeleted, includeStaticSiteImporter\), pointer/) + assert.match(worker, /authConstants, bucket, true, revision\.uploads, revision\.wpContent, revision\.wpContentDeleted, includeWebsiteImporter\), pointer/) assert.match(worker, /env\.WORDPRESS_STATE_BUCKET, true\)/) assert.match(worker, /canonicalBootstrapPasswordCode/) assert.match(worker, /canonicalBootstrapUrlCode/) @@ -476,8 +521,8 @@ 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 \?\? \[\], site\)/) - assert.match(worker, /validateWpContentDeletedPaths\(manifest\.wpContentDeleted \?\? \[\]\)/) + assert.match(worker, /validateWpContentManifestFiles\(manifest\.wpContent \?\? \[\], site, WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS\)/) + assert.match(worker, /validateWpContentDeletedPaths\(manifest\.wpContentDeleted \?\? \[\], WEBSITE_IMPORTER_OWNED_WP_CONTENT_PATHS\)/) assert.match(worker, /materializeWpContentTombstones\(php, wpContentDeleted\)/) assert.match(worker, /async scheduled\(controller: ScheduledController, env: Env\)/) assert.match(worker, /const publication = operation \? null : await drainNextPublicationJob\(env, coordinator, site, operations\)/) @@ -611,10 +656,13 @@ test("runtime archive dependencies are content-addressed and verified from R2", test("WordPress runtime corpus generator keeps the ZIP outside the Worker bundle", async () => { const generator = await readFile(new URL("../scripts/generate-cloudflare-wordpress-runtime-corpus.ts", import.meta.url), "utf8") const artifact = await readFile(new URL("../packages/runtime-cloudflare/src/wordpress-runtime-artifact.ts", import.meta.url), "utf8") + const worker = await readFile(new URL("../packages/runtime-cloudflare/src/worker.ts", import.meta.url), "utf8") + const runtimeArchive = await readFile(new URL("../packages/runtime-cloudflare/src/runtime-archive-artifact.ts", import.meta.url), "utf8") const manifest = JSON.parse(await readFile(new URL("../packages/runtime-cloudflare/assets/wordpress-runtime-artifact.json", import.meta.url), "utf8")) as WordPressRuntimeArtifactManifest const staticManifest = JSON.parse(await readFile(new URL("../packages/runtime-cloudflare/assets/wordpress-static-artifact.json", import.meta.url), "utf8")) as WordPressStaticArtifactManifest const sqliteManifest = JSON.parse(await readFile(new URL("../packages/runtime-cloudflare/assets/sqlite-database-integration-artifact.json", import.meta.url), "utf8")) as RuntimeArchiveArtifactManifest - const staticSiteImporterManifest = JSON.parse(await readFile(new URL("../packages/runtime-cloudflare/assets/static-site-importer-artifact.json", import.meta.url), "utf8")) as RuntimeArchiveArtifactManifest + const websiteImporterManifest = JSON.parse(await readFile(new URL("../packages/runtime-cloudflare/assets/website-importer-artifact.json", import.meta.url), "utf8")) as RuntimeArchiveArtifactManifest + const websiteImporterSource = runtimeArchiveComponentSource(JSON.parse(await readFile(new URL("../packages/runtime-cloudflare/components/website-importer.json", import.meta.url), "utf8"))) assert.match(generator, /const response = await fetch\(sourceUrl\)/) assert.match(generator, /decodeZip\(response\.body\)/) assert.doesNotMatch(generator, /decodeRemoteZip/) @@ -623,8 +671,12 @@ test("WordPress runtime corpus generator keeps the ZIP outside the Worker bundle assert.match(generator, /artifacts\/cloudflare-wordpress-runtime-corpus\.zip/) assert.match(generator, /artifacts\/cloudflare-wordpress-static-corpus\.bin/) assert.match(generator, /artifacts\/cloudflare-sqlite-database-integration\.zip/) - assert.match(generator, /artifacts\/cloudflare-static-site-importer\.zip/) - assert.match(generator, /8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29/) + assert.match(generator, /runtimeArchiveComponentSource/) + assert.match(generator, /selectRuntimePackageProfileFiles/) + assert.match(generator, /selectMigrationProfileFiles/) + assert.doesNotMatch(generator, /static-site-importer/i) + assert.doesNotMatch(worker, /Uint8Array\.from\(archive\)/) + assert.doesNotMatch(runtimeArchive, /Uint8Array\.from\(bytes\)/) assert.doesNotMatch(artifact, /decodeZip/) assert.match(artifact, /php\.writeFile\(WORDPRESS_RUNTIME_ARCHIVE_TEMP_PATH, archiveBytes\)/) assert.equal(manifest.key, wordpressRuntimeArtifactKey(manifest.archive.sha256)) @@ -633,9 +685,12 @@ test("WordPress runtime corpus generator keeps the ZIP outside the Worker bundle assert.ok(staticManifest.files.length > 0) assert.equal(sqliteManifest.key, runtimeArchiveArtifactKey(sqliteManifest.name, sqliteManifest.archive.sha256)) assert.equal(RUNTIME_ARCHIVE_MAX_BYTES, 16 * 1024 * 1024) - assert.doesNotThrow(() => validateRuntimeArchiveArtifactManifest(staticSiteImporterManifest)) - assert.equal(staticSiteImporterManifest.source.url, "https://github.com/Automattic/static-site-importer/releases/download/v1.3.4/static-site-importer.zip") - assert.equal(staticSiteImporterManifest.source.identity, "08b9dd650f3c3161c5b350796a5db6ef083516ae") + assert.doesNotThrow(() => validateRuntimeArchiveArtifactManifest(websiteImporterManifest)) + assert.ok(websiteImporterManifest.archive.size < 4 * 1024 * 1024) + assert.equal(websiteImporterManifest.name, websiteImporterSource.component.id) + assert.deepEqual(websiteImporterManifest.component, websiteImporterSource.component) + assert.equal(websiteImporterManifest.source.url, websiteImporterSource.source.url) + assert.equal(websiteImporterManifest.source.identity, websiteImporterSource.source.identity) }) test("Cloudflare coordinator serializes leases, promotes with CAS, and recovers stale leases", async () => {