diff --git a/apps/reader/scripts/native-publisher.mjs b/apps/reader/scripts/native-publisher.mjs index 09ae3692..c5b8e8da 100644 --- a/apps/reader/scripts/native-publisher.mjs +++ b/apps/reader/scripts/native-publisher.mjs @@ -142,7 +142,11 @@ export function assertInventoryCoverage(inventory, sdkTrust) { if (!inventory.some((asset) => asset.sha256 === pin.sha256 && asset.bytes === pin.bytes)) throw new Error(`Missing exact SDK native/SQLite bytes for ${name}; refusing`); } - if (sdkTrust && !sdkTrust.extension && !inventory.some((asset) => asset.path === "_worker.js")) + if ( + sdkTrust && + !sdkTrust.extension && + !inventory.some((asset) => asset.path === "_worker.js" || asset.path === "_worker.js/index.js") + ) throw new Error("Missing precompiled Pages Worker; refusing"); const present = new Set(inventory.map((asset) => classifyAsset(asset.path))); const missing = requiredAssetCategories.filter((category) => !present.has(category)); diff --git a/scripts/lib/reader-hosting-policy.mjs b/scripts/lib/reader-hosting-policy.mjs index 5d8ed030..cdd4673d 100644 --- a/scripts/lib/reader-hosting-policy.mjs +++ b/scripts/lib/reader-hosting-policy.mjs @@ -80,7 +80,8 @@ export function coveredHostingControls(policy, inventory) { !parts.some((p) => ["_headers", "_redirects", "_routes.json", "_worker.js"].includes(p), )) || - ["_routes.json", "_redirects", "_worker.js"].includes(item.path), + ["_routes.json", "_redirects", "_worker.js"].includes(item.path) || + item.path.startsWith("_worker.js/"), "unknown hosting control requires reviewed source policy", ); } @@ -97,13 +98,18 @@ export function coveredHostingControls(policy, inventory) { "source policy + immutable upload + scoped deployment metadata + bounded behavior probes", }); } - const worker = inventory.find((a) => a.path === "_worker.js"); - check(worker?.bytes > 0, "missing precompiled capture Worker"); - covered.push({ - ...worker, - evidence: - "source/run-bound precompiled Worker + scoped deployment metadata + capture denial probes; NOT Worker-source GET", - }); + const workers = inventory.filter( + (a) => a.path === "_worker.js" || a.path.startsWith("_worker.js/"), + ); + const entry = workers.find((a) => a.path === "_worker.js" || a.path === "_worker.js/index.js"); + check(entry?.bytes > 0, "missing precompiled capture Worker"); + covered.push( + ...workers.map((worker) => ({ + ...worker, + evidence: + "source/run-bound precompiled Worker + scoped deployment metadata + capture denial probes; NOT Worker-source GET", + })), + ); return covered; } export function safeProject(project, environment) { diff --git a/scripts/postqualify-native-staging.mjs b/scripts/postqualify-native-staging.mjs index 5850b37e..909ac1e0 100644 --- a/scripts/postqualify-native-staging.mjs +++ b/scripts/postqualify-native-staging.mjs @@ -101,7 +101,10 @@ for (const dir of ["publish", "extension"]) { const files = dir === "publish" ? inventory : extensionInventory; const js = await Promise.all( files - .filter((f) => /\.m?js$/.test(f.path) && f.path !== "_worker.js") + .filter( + (f) => + /\.m?js$/.test(f.path) && f.path !== "_worker.js" && !f.path.startsWith("_worker.js/"), + ) .map((f) => readFile(join(root, dir, f.path), "utf8")), ); for (const literal of [ diff --git a/scripts/qualify-native-staging.mjs b/scripts/qualify-native-staging.mjs index 47bd17ce..01422ed6 100644 --- a/scripts/qualify-native-staging.mjs +++ b/scripts/qualify-native-staging.mjs @@ -151,7 +151,7 @@ try { "functions", "build", join(repo, "apps/reader/functions"), - "--outfile", + "--outdir", join(repo, "apps/reader/dist/_worker.js"), ], tools, @@ -189,7 +189,10 @@ try { const files = dir === "publish" ? inventory : extensionInventory; const js = await Promise.all( files - .filter((a) => /\.m?js$/.test(a.path) && a.path !== "_worker.js") + .filter( + (a) => + /\.m?js$/.test(a.path) && a.path !== "_worker.js" && !a.path.startsWith("_worker.js/"), + ) .map((a) => readFile(join(root, dir, a.path), "utf8")), ); // Exact original native/SQLite members plus compiled authenticated trust diff --git a/scripts/tests/reader-publisher.test.mjs b/scripts/tests/reader-publisher.test.mjs index a915bf37..eb9e48e4 100644 --- a/scripts/tests/reader-publisher.test.mjs +++ b/scripts/tests/reader-publisher.test.mjs @@ -430,7 +430,7 @@ async function servedFixture() { }; const transport = async (url) => { const path = new URL(url).pathname; - assert.notEqual(path, "/_worker.js"); + assert.ok(path !== "/_worker.js" && !path.startsWith("/_worker.js/")); if (path === "/api/capture") return new Response( JSON.stringify({ code: "method_not_allowed", message: "Use POST to capture a page." }), @@ -464,6 +464,82 @@ async function servedFixture() { transport, }; } +test("Pages Functions build emits advanced-mode modules, not a multipart outfile", async () => { + const build = await readFile("scripts/qualify-native-staging.mjs", "utf8"); + const flag = build.match( + /"(--out(?:file|dir))",\s*join\(repo, "apps\/reader\/dist\/_worker\.js"\)/, + )?.[1]; + // Official Wrangler 4.114 semantics already exercised by Writer's synthetic + // regression: outfile is a multipart upload body, outdir emits index.js. + assert.equal(flag, "--outdir"); +}); +test("advanced-mode Worker retains every module/map in inventory and hosting coverage", async () => { + const f = await servedFixture(); + try { + const pub = join(f.root, "publish"); + await rm(join(pub, "_worker.js")); + await mkdir(join(pub, "_worker.js")); + // SYNTHETIC module/map DATA only; never compiled or executed. + for (const [name, body] of Object.entries({ + "index.js": "export default {};", + "module.mjs": "export {};", + "index.js.map": "{}", + })) + await writeFile(join(pub, "_worker.js", name), body); + const inventory = await inventoryDirectory(pub); + assert.doesNotThrow(() => assertInventoryCoverage(inventory, { nativeAssets: {} })); + const v = await verifyServedAssets({ + origin, + inventory, + publication: f.publication, + fetch: f.transport, + attempts: 1, + }); + assert.equal(v.ok, true, JSON.stringify(v)); + assert.deepEqual(v.policyEvidence.coveredControls.map((a) => a.path).sort(), [ + "_redirects", + "_routes.json", + "_worker.js/index.js", + "_worker.js/index.js.map", + "_worker.js/module.mjs", + ]); + assert.ok(v.verified.every((a) => !a.path.startsWith("_worker.js/"))); + const args = { + target: "staging", + source: { sha: source, tree }, + releaseRef: "release/native-staging", + sdkTrust: {}, + inventory, + deploymentId: id, + verification: v, + }; + assert.equal(createReceipt(args).status, "verified"); + for (const mode of ["omitted-map", "changed-module"]) { + const broken = structuredClone(v); + if (mode === "omitted-map") + broken.policyEvidence.coveredControls = broken.policyEvidence.coveredControls.filter( + (a) => a.path !== "_worker.js/index.js.map", + ); + else + broken.policyEvidence.coveredControls.find( + (a) => a.path === "_worker.js/module.mjs", + ).sha256 = "0".repeat(64); + assert.equal(createReceipt({ ...args, verification: broken }).status, "failed-or-incomplete"); + } + const missingEntry = inventory.filter((a) => a.path !== "_worker.js/index.js"); + assert.throws( + () => assertInventoryCoverage(missingEntry, {}), + /Missing precompiled Pages Worker/, + ); + const policy = await sourceHostingPolicy(process.cwd()); + assert.throws( + () => coveredHostingControls(policy, missingEntry), + /missing precompiled capture Worker/, + ); + } finally { + await rm(f.root, { recursive: true, force: true }); + } +}); test("ALL bytes + .nojekyll GET + source controls + 9 bounded route probes can verify", async () => { const f = await servedFixture(); try {