diff --git a/.github/workflows/deploy-writer.yml b/.github/workflows/deploy-writer.yml index 3c38eb5..2201c6b 100644 --- a/.github/workflows/deploy-writer.yml +++ b/.github/workflows/deploy-writer.yml @@ -200,7 +200,7 @@ jobs: mkdir -p "$RUNNER_TEMP/writer-tools" cp tools/native-publisher/{package.json,pnpm-lock.yaml} "$RUNNER_TEMP/writer-tools/" (cd "$RUNNER_TEMP/writer-tools" && NPM_CONFIG_USERCONFIG=/dev/null NPM_CONFIG_GLOBALCONFIG=/dev/null pnpm --ignore-workspace install --frozen-lockfile --ignore-scripts --ignore-pnpmfile) - "$RUNNER_TEMP/writer-tools/node_modules/.bin/wrangler" pages functions build apps/writer/functions --outfile apps/writer/dist/_worker.js + "$RUNNER_TEMP/writer-tools/node_modules/.bin/wrangler" pages functions build apps/writer/functions --outdir apps/writer/dist/_worker.js cp -a apps/writer/dist/. "$RUNNER_TEMP/writer-build/publish/" node apps/writer/scripts/native-publisher.mjs inventory --dist "$RUNNER_TEMP/writer-build/publish" --plan "$RUNNER_TEMP/writer-build/plan.json" > "$RUNNER_TEMP/writer-build/inventory.json" # Official npm ORIGINAL DATA, root-lock SRI verified; never R2 writes. diff --git a/apps/writer/scripts/native-publisher.mjs b/apps/writer/scripts/native-publisher.mjs index 832b7b6..fd01160 100644 --- a/apps/writer/scripts/native-publisher.mjs +++ b/apps/writer/scripts/native-publisher.mjs @@ -100,7 +100,7 @@ export function assertInventoryCoverage(inventory, sdkTrust) { for (const [name, pin] of Object.entries(sdkTrust?.nativeAssets ?? {})) { 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 && !inventory.some(asset => asset.path === "_worker.js")) throw new Error("Missing precompiled Pages Worker; refusing"); + if (sdkTrust && !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)); if (missing.length > 0) throw new Error(`Built assets are missing required categories: ${missing.join(", ")}; refusing to upload.`); @@ -122,7 +122,7 @@ export function parseDeploymentId(output) { export async function captureDeployment({ output, target, sha, accountId, apiToken, fetchImpl = fetch }) { const prefix = parseDeploymentId(output); if (!prefix || !accountId || !apiToken) throw new Error("Refusing: missing unique deployment URL or Cloudflare credentials"); - const response = await fetchImpl(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/pages/projects/mdbase-writer/deployments?per_page=100`, { + const response = await fetchImpl(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/pages/projects/mdbase-writer/deployments?per_page=20`, { headers: { Authorization: `Bearer ${apiToken}` }, redirect: "error", signal: AbortSignal.timeout(30000), }); if (!response.ok) throw new Error(`Deployment ID lookup failed: HTTP ${response.status}`); @@ -139,7 +139,7 @@ function publicationEvidenceComplete(inventory, verification, deploymentId, sour const policy = verification.policyEvidence; if (policy?.metadata?.deployment?.id !== deploymentId || policy.metadata.deployment.sourceSha !== sourceSha || policy.behavior?.length !== 4 || verification.runtimeVerified?.length !== 2 || !/^[0-9a-f]{64}$/.test(policy.sourceConfigSha256 ?? "")) return false; const covered = policy.coveredControls ?? []; - if (covered.length !== 2 || !covered.some(a => a.path === "_routes.json") || !covered.some(a => a.path === "_worker.js")) return false; + if (!covered.some(a => a.path === "_routes.json") || !covered.some(a => a.path === "_worker.js" || a.path === "_worker.js/index.js") || covered.some(a => a.path !== "_routes.json" && a.path !== "_worker.js" && !a.path.startsWith("_worker.js/"))) return false; const seen = [...(verification.verified ?? []), ...covered]; return seen.length === inventory.length && new Set(seen.map(a => a.path)).size === seen.length && inventory.every(asset => seen.some(item => item.path === asset.path && item.sha256 === asset.sha256 && item.bytes === asset.bytes)); } diff --git a/apps/writer/scripts/native-publisher.test.mjs b/apps/writer/scripts/native-publisher.test.mjs index d7e1841..5bbafea 100644 --- a/apps/writer/scripts/native-publisher.test.mjs +++ b/apps/writer/scripts/native-publisher.test.mjs @@ -280,7 +280,8 @@ test("receipt cannot pass without both a deployment ID and exact served bytes", test("API deployment ID must match the unique URL, source and Pages channel", async () => { const deployment = { id: "12345678-1234-1234-1234-123456789abc", url: "https://abcdef12.mdbase-writer.pages.dev", deployment_trigger: { metadata: { commit_hash: FULL_SHA, branch: "staging" } } }; const args = { output: deployment.url, target: "staging", sha: FULL_SHA, accountId: "fixture-account", apiToken: "fixture-not-a-secret" }; - const fetchImpl = async (_url, options) => { + const fetchImpl = async (url, options) => { + assert.equal(url, "https://api.cloudflare.com/client/v4/accounts/fixture-account/pages/projects/mdbase-writer/deployments?per_page=20"); assert.equal(options.redirect, "error"); return { ok: true, json: async () => ({ success: true, result: [deployment] }) }; }; diff --git a/scripts/lib/writer-hosting-policy.mjs b/scripts/lib/writer-hosting-policy.mjs index 55bac9e..26e974c 100644 --- a/scripts/lib/writer-hosting-policy.mjs +++ b/scripts/lib/writer-hosting-policy.mjs @@ -63,7 +63,8 @@ export async function readHostingMetadata({policy,plan,deployment,preflight,acco try{assertHostingMetadata(policy,result,plan,deployment,preflight);}catch(error){error.safeEvidence=result;throw error;}return result; } export function coveredHostingControls(policy,inventory) { - const routes=inventory.find(r=>r.path==='_routes.json'),worker=inventory.find(r=>r.path==='_worker.js'); - check(routes?.sha256===policy.routesSha256&&worker?.bytes>0,'immutable source routes/precompiled Worker coverage'); - return [routes,worker].map(row=>({...row,evidence:'immutable-upload-inventory + source-config + scoped-deployment/current-project-config + runtime-behavior (NOT live Worker-source GET)'})); + const routes=inventory.find(r=>r.path==='_routes.json'),workers=inventory.filter(r=>r.path==='_worker.js'||r.path.startsWith('_worker.js/')); + const entry=workers.find(r=>r.path==='_worker.js'||r.path==='_worker.js/index.js'); + check(routes?.sha256===policy.routesSha256&&entry?.bytes>0,'immutable source routes/precompiled Worker coverage'); + return [routes,...workers].map(row=>({...row,evidence:'immutable-upload-inventory + source-config + scoped-deployment/current-project-config + runtime-behavior (NOT live Worker-source GET)'})); } diff --git a/scripts/native-tool-compat.mjs b/scripts/native-tool-compat.mjs index 70c0f0d..97b5865 100644 --- a/scripts/native-tool-compat.mjs +++ b/scripts/native-tool-compat.mjs @@ -1,7 +1,7 @@ -// Offline compiler compatibility only; never deploy, no Cloudflare credentials. +// SYNTHETIC Functions compatibility only; never app/SDK build or deployment. import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { sourceHostingPolicy, uploadConfiguration } from './lib/writer-hosting-policy.mjs'; import { resolve, join } from 'node:path'; @@ -22,8 +22,21 @@ try { assert.deepEqual(parsed.compatibility_flags,[]); } console.log('PASS Wrangler4.114 documented config parser: isolated SOURCE binding/date in both Pages channels'); - execFileSync(join(root,'node_modules/.bin/wrangler'),['pages','functions','build',join(repo,'apps/writer/functions'),'--outfile',join(root,'_worker.js')],{cwd:root,env,stdio:'inherit'}); - const worker=await readFile(join(root,'_worker.js'),'utf8'); - assert.ok(worker.includes('WRITER_ASSETS') && worker.includes('application/wasm')); - console.log('PASS isolated integrity-locked Wrangler 4.114.0 --ignore-scripts + Pages Functions precompile (NO deploy)'); + // Exercise the SAME output mode selected by the real build workflow, but with + // a tiny non-product fixture. Parse only; NEVER execute its generated Worker. + const workflow=await readFile(join(repo,'.github/workflows/deploy-writer.yml'),'utf8'); + const flag=workflow.match(/pages functions build apps\/writer\/functions (--out(?:file|dir)) apps\/writer\/dist\/_worker\.js/)[1]; + const functions=join(root,'synthetic-functions'),output=join(root,'_worker.js'); + await mkdir(functions); + await writeFile(join(functions,'index.js'),'export const onRequestGet = ({env}) => new Response("SYNTHETIC_WRITER_FUNCTIONS", {headers:{"Content-Type":"application/wasm","X-Synthetic-Binding":typeof env.WRITER_ASSETS}});'); + execFileSync(join(root,'node_modules/.bin/wrangler'),['pages','functions','build','--help'],{cwd:root,env,stdio:'inherit'}); + execFileSync(join(root,'node_modules/.bin/wrangler'),['pages','functions','build',functions,flag,output],{cwd:root,env,stdio:'inherit'}); + const directory=(await stat(output)).isDirectory(); + const worker=await readFile(directory?join(output,'index.js'):output,'utf8'); + const syntax=spawnSync(process.execPath,['--input-type=module','--check'],{input:worker,env,encoding:'utf8'}); + console.log(JSON.stringify({kind:'SYNTHETIC UNIT DATA ONLY',officialWrangler:'4.114.0',flag,directory,multipartMetadata:worker.includes('Content-Disposition: form-data; name="metadata"'),moduleSyntaxExit:syntax.status})); + assert.equal(syntax.status,0,'compiled output must be JavaScript, NOT a multipart upload body'); + assert.ok(directory,'official advanced-mode _worker.js/index.js layout'); + assert.ok(worker.includes('SYNTHETIC_WRITER_FUNCTIONS') && worker.includes('WRITER_ASSETS') && worker.includes('application/wasm')); + console.log('PASS official advanced-mode JavaScript from SYNTHETIC Functions; NO product build/deploy'); } finally {await rm(root,{recursive:true,force:true});} diff --git a/scripts/qualify-native-staging.mjs b/scripts/qualify-native-staging.mjs index b8563ec..e2ee79d 100644 --- a/scripts/qualify-native-staging.mjs +++ b/scripts/qualify-native-staging.mjs @@ -52,7 +52,7 @@ try{ run('pnpm',['--ignore-workspace','install','--frozen-lockfile','--ignore-scripts','--ignore-pnpmfile'],tools); assert.equal(JSON.parse(await readFile(join(tools,'node_modules/wrangler/package.json'))).version,'4.114.0'); await writeFile(join(tools,'wrangler.json'),JSON.stringify(uploadConfiguration(await sourceHostingPolicy(repo)))); - run(join(tools,'node_modules/.bin/wrangler'),['pages','functions','build',join(repo,'apps/writer/functions'),'--outfile',join(dist,'_worker.js')],tools); + run(join(tools,'node_modules/.bin/wrangler'),['pages','functions','build',join(repo,'apps/writer/functions'),'--outdir',join(dist,'_worker.js')],tools); } // verify mode is POST-ONLY: never app rebuild or Functions recompilation. run('node',['apps/writer/scripts/validate-manifest.mjs',join(dist,'.well-known/mdbase-app.json')]); @@ -61,7 +61,7 @@ try{ const offlineShell=await assertWriterOfflineShell({directory:dist,repoRoot:repo,sdkTrust}); assert.deepEqual(offlineShell.inventory.map(({path,bytes,sha256})=>({path,bytes,sha256})),inventory.map(({path,bytes,sha256})=>({path,bytes,sha256}))); coveredHostingControls(await sourceHostingPolicy(repo),inventory); - const js=await Promise.all(inventory.filter(a=>/\.m?js$/.test(a.path)&&a.path!=='_worker.js').map(a=>readFile(join(dist,a.path),'utf8'))); + const js=await Promise.all(inventory.filter(a=>/\.m?js$/.test(a.path)&&a.path!=='_worker.js'&&!a.path.startsWith('_worker.js/')).map(a=>readFile(join(dist,a.path),'utf8'))); for(const literal of [sdkTrust.release.cpOrigin,sdkTrust.release.logOrigin,sdkTrust.release.assetSha256,sdkTrust.release.source.commit,sdkTrust.editorOrigin])assert.ok(js.some(s=>s.includes(literal)),'missing exact compiled staging trust/Editor binding'); await mkdir(join(repo,'test-results'),{recursive:true}); await writeFile(join(repo,'test-results/writer-staging-qualification.json'),JSON.stringify({...evidence,mode,offlineShell,inventory},null,2)); diff --git a/tests/writer-hosting-policy.test.mjs b/tests/writer-hosting-policy.test.mjs index b8ba831..22394fa 100644 --- a/tests/writer-hosting-policy.test.mjs +++ b/tests/writer-hosting-policy.test.mjs @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { inventoryDirectory } from '../scripts/lib/native-publish-bytes.mjs'; import { assertRuntimeOriginals, deriveRuntimeOriginals, runtimeSourcePins } from '../scripts/lib/writer-runtime-originals.mjs'; import { sourceHostingPolicy, uploadConfiguration, readHostingMetadata, assertHostingMetadata } from '../scripts/lib/writer-hosting-policy.mjs'; -import { createReceipt } from '../apps/writer/scripts/native-publisher.mjs'; +import { assertInventoryCoverage, createReceipt } from '../apps/writer/scripts/native-publisher.mjs'; import { verifyServedAssets } from '../apps/writer/scripts/verify-served.mjs'; import { hostingFixture } from './writer-hosting-fixture.mjs'; const origin='https://staging.mdbase-writer.pages.dev'; @@ -27,6 +27,24 @@ test('known uploaded routes and Worker clear ONLY with source config, actual met assert.equal(createReceipt(args).status,'verified'); assert.equal(createReceipt({...args,verification:{ok:true,policy_verified:true,verified:f.inventory,failures:[],unverified:[]}}).status,'failed-or-incomplete'); }); +test('official advanced-mode Worker directory retains EVERY module/map in inventory and hosting evidence',async t=>{ + const f=await hostingFixture(t); // SYNTHETIC data only, not compiled app output. + await rm(join(f.publish,'_worker.js')); + for(const [name,body] of Object.entries({'index.js':'export default {};','module.mjs':'export {};','index.js.map':'{}'}))await f.put(join(f.publish,'_worker.js',name),body); + const inventory=await inventoryDirectory(f.publish); + assert.doesNotThrow(()=>assertInventoryCoverage(inventory,{nativeAssets:{}})); + const result=await verifyServedAssets({origin,inventory,publication:f.publication,fetch:f.fetch,attempts:1}); + assert.equal(result.ok,true,JSON.stringify(result)); + assert.deepEqual(result.policyEvidence.coveredControls.map(a=>a.path).sort(),['_routes.json','_worker.js/index.js','_worker.js/index.js.map','_worker.js/module.mjs']); + assert.ok(result.verified.every(a=>!a.path.startsWith('_worker.js/'))); + const args={target:'staging',source:{sha:f.plan.sha,tree:f.plan.tree},releaseRef:'release/native-staging',sdkTrust:{},inventory,deploymentId:f.deployment.id,verification:result}; + assert.equal(createReceipt(args).status,'verified'); + const omitted=structuredClone(result);omitted.policyEvidence.coveredControls.pop(); + assert.equal(createReceipt({...args,verification:omitted}).status,'failed-or-incomplete'); + const changed=structuredClone(result);changed.policyEvidence.coveredControls[1].sha256='0'.repeat(64); + assert.equal(createReceipt({...args,verification:changed}).status,'failed-or-incomplete'); + assert.throws(()=>assertInventoryCoverage(inventory.filter(a=>a.path!=='_worker.js/index.js'),{nativeAssets:{}}),/Missing precompiled Pages Worker/); +}); for(const field of ['bucket','compatibility','deployment']) test('even exact static bytes refuse wrong '+field+' metadata',async t=>{ const f=await hostingFixture(t),metadata=structuredClone(f.metadata); if(field==='bucket') metadata.configuration.r2_buckets.WRITER_ASSETS.name='wrong-bucket';