From 52c1b38644e9c0833945ceabaa086ecbbd4aac57 Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 22 Sep 2026 07:00:25 +0000 Subject: [PATCH] test(marketplace): validate Cloudflare persistence and package boundaries --- CHANGELOG.md | 2 + __tests__/extension-package-portable.test.ts | 23 ++++++ docs/extensions/cloudflare-hosting.md | 77 +++++++++++++++++++ docs/extensions/hosting.md | 2 + marketplace/cloudflare/local.mjs | 2 +- .../cloudflare/migrations/0001_registry.sql | 1 + marketplace/cloudflare/ops.mjs | 17 ++++ marketplace/cloudflare/test.mjs | 17 ++-- marketplace/cloudflare/worker.ts | 7 +- src/plugins/package-validation.ts | 2 +- 10 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 __tests__/extension-package-portable.test.ts create mode 100644 docs/extensions/cloudflare-hosting.md create mode 100644 marketplace/cloudflare/ops.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e92fc50..88db93d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- Prepare a Cloudflare marketplace backend with signed publication, immutable packages, persistent metadata and isolated recovery validation. Hosted setup remains a preview prerequisite. + - Marketplace operators can preserve published extensions across service restarts and restore verified backups into an isolated registry. - **Codex and Astra read project guidance from `AGENTS.md`.** The canonical agent guide now lives in `AGENTS.md` (with a nested `docs/AGENTS.md` for long validation notes); `CLAUDE.md` is a thin `@AGENTS.md` wrapper for Claude Code. Codex/Astra no longer miss the old CLAUDE-only instructions. diff --git a/__tests__/extension-package-portable.test.ts b/__tests__/extension-package-portable.test.ts new file mode 100644 index 00000000..b3f1d609 --- /dev/null +++ b/__tests__/extension-package-portable.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { parsePackage, MAX_PACKAGE_BYTES } from '../src/plugins/package-validation'; +const fixture = () => ({format:'codegraph-extension-1',package:{name:'test',version:'1.0.0',main:'index.cjs',codegraph:{id:'portable',apiVersion:1,engines:'>=1.6 <2',capabilities:['frameworks']}},files:{'index.cjs':'module.exports=()=>({frameworks:[]});'} as Record}); +const bytes = (p: unknown) => new TextEncoder().encode(JSON.stringify(p)); +describe('portable registry and installer package contract', () => { + it('validates a plain byte array with the actual engine constraint', () => { + expect(parsePackage(bytes(fixture()),'1.6.0').package.codegraph.id).toBe('portable'); + expect(()=>parsePackage(bytes(fixture()),'2.0.0')).toThrow('requires CodeGraph'); + }); + it('preserves the Node parser rejection of a leading UTF-8 BOM', () => { + expect(()=>parsePackage(new Uint8Array([239,187,191,...bytes(fixture())]))).toThrow(); + }); + it('accepts exactly 8 MiB and rejects the next byte', () => { + const p=fixture();p.files['padding.txt']='';p.files['padding.txt']='x'.repeat(MAX_PACKAGE_BYTES-bytes(p).length); + const value=bytes(p);expect(value.length).toBe(MAX_PACKAGE_BYTES);expect(parsePackage(value).package.version).toBe('1.0.0'); + const oversized=new Uint8Array(MAX_PACKAGE_BYTES+1);oversized.set(value);oversized[MAX_PACKAGE_BYTES]=32; + expect(()=>parsePackage(oversized)).toThrow('exceeds 8 MiB'); + }); + it('rejects unsafe paths and unsupported API before execution', () => { + const p=fixture();p.files['../escape.cjs']='throw Error("must not execute")';expect(()=>parsePackage(bytes(p))).toThrow('unsafe path'); + delete p.files['../escape.cjs'];p.package.codegraph.apiVersion=9;expect(()=>parsePackage(bytes(p))).toThrow('unsupported API version'); + }); +}); diff --git a/docs/extensions/cloudflare-hosting.md b/docs/extensions/cloudflare-hosting.md new file mode 100644 index 00000000..070251fc --- /dev/null +++ b/docs/extensions/cloudflare-hosting.md @@ -0,0 +1,77 @@ +# Cloudflare marketplace preview + +Cloudflare is the preferred hosted target. The existing Node/SQLite service remains a portable reference implementation; it is not the Worker runtime. No Cloudflare account, resources, hostname, deployment or plan has been verified by this increment. `marketplace.getcodegraph.com` is proposed, not configured. Do not provision a Vercel marketplace for this delivery. + +## Architecture and publication contract + +`marketplace/cloudflare/worker.ts` uses Workers Web Crypto and the same portable package validator as the managed installer. The public frontend is served as Workers Static Assets; `/api/*` invokes the Worker. `_headers` preserves the browser security policy on assets, and the API applies it independently. No CORS wildcard, local-companion policy changes, native SQLite imports, Node filesystem calls, package execution or install scripts exist in the Worker bundle. + +D1 holds registry identity/schema, publisher ownership, immutable releases and replay nonces. R2 holds private content-addressed objects at `packages/sha256/`. Public buckets, direct object writes and object lifecycle deletion must remain disabled. The only public download is an existing D1 release; it verifies the actual R2 bytes before returning them. The managed installer independently revalidates identity, compatibility and integrity. + +Publication validates P-256 signatures (SPKI publisher IDs match Node), timestamp, fields, paths and the full package. It then conditionally writes the immutable R2 object with a checksum and `If-None-Match: *`. Only after that succeeds does one transactional D1 batch claim/check ownership and insert release plus replay nonce. Database uniqueness and ownership triggers resolve concurrent publishers. A failure before D1 commit exposes no release; a response lost after commit leaves a valid immutable release, discoverable by refreshing the catalog. Retrying a committed nonce/version fails clearly. Uncommitted R2 objects can remain after failure; they are never listed or downloadable. There is deliberately no online orphan collector that could race a publication. Cleanup needs a separate maintenance procedure with writers stopped and a verified catalog snapshot. + +This is staged publication, not a distributed transaction or a guarantee against an administrator deleting an object. Missing/corrupt storage fails downloads with an actionable 503. Reads use primary D1 bindings without replicas/cache. The preview caps the catalog at 1,000 releases to bound the existing array-based catalog protocol. Publication is disabled by default. An indexed D1 rate bucket permits 10 submissions per IP per minute; it mitigates abuse but is not a spending cap. Expired nonces are removed only after the signed-request validity window. + +## Local runtime and validation + +From the repository root, with Node 22.23.2: + +```sh +npm ci --no-audit --no-fund +npx tsc +npm ci --prefix marketplace/cloudflare --no-audit --no-fund +npm run build --prefix marketplace/cloudflare +npm test --prefix marketplace/cloudflare +CLOUDFLARE_REGISTRY=1 PLAYWRIGHT_MODULE=/absolute/path/to/playwright \ + node scripts/validation/extensions-compatibility.cjs +CLOUDFLARE_REGISTRY=1 PLAYWRIGHT_MODULE=/absolute/path/to/playwright \ + node scripts/validation/marketplace-browser.cjs +``` + +The exact Wrangler/Miniflare/workerd versions are pinned in the separate lockfile. Current Wrangler uses Miniflare 5 alpha; the local adapter uses its documented v4-options converter and explicit `resourcePersistencePath`. Default test state is disposable. Production configuration points to `worker.ts`; only `test-worker.ts` accepts failure/kill controls. Tests exercise real local D1/R2 bindings, Web Crypto, HTTP and test-owned subprocess kills, not JavaScript storage mocks. Static assets in integration tests are delivered through a local binding; the actual platform asset routing is separately checked by a Wrangler dry run and still needs hosted verification. + +For a retained local rehearsal (no Cloudflare account needed): + +```sh +cd marketplace/cloudflare +node ops.mjs serve /absolute/lab/registry +node ops.mjs backup /absolute/lab/registry /absolute/backups/unique-snapshot +node ops.mjs restore /absolute/new-lab/registry /absolute/backups/unique-snapshot +``` + +Stop the rehearsal server before using the operator CLI on that same local state. The binding-level backup reads identity/owners/releases/nonces in one D1 batch snapshot, then copies immutable R2 bytes. A completion manifest is written last. Restore verifies checksums, manifest/package/ownership correspondence, refuses an occupied target, copies objects first and publishes metadata in one D1 batch. Partial restores remain detached and must be retried into a new destination. Backups contain public packages and metadata, not publisher private keys. Protect the independent checksum against tampering; checksums alone are not authentication. Local process-kill proof does not establish provider disk/power-loss or off-host retention guarantees. + +## Prepare deployment after access is verified + +Required capabilities: authenticated Cloudflare account inventory, permission to deploy a preview Worker, an existing authorized D1 database and private R2 Standard bucket (or explicit approval to activate/create them), and authority for the selected hostname if attaching it. A browser login does not by itself authenticate Wrangler. Use the official `wrangler whoami` read; do not copy tokens into chat or inspect credential files. Do not use temporary accounts to bypass pending access. + +1. Inspect account/plan, D1/R2 inventory, existing Workers and `getcodegraph.com` zone ownership. Confirm included allowances and whether R2 activation requires billing. No paid upgrade, card entry, R2 activation, DNS or domain change is authorized by this guide. +2. Copy `wrangler.jsonc` to a private deployment config beside it. Replace the placeholder D1 ID and bucket name with the verified preview resources and set the verified account ID. Keep `PUBLISHING_ENABLED=false` until storage, budget and runtime checks complete. Keep the initial `workers.dev` preview; attach a custom domain only with authorization. Do not run a deploy against the checked-in placeholder config. +3. Apply `migrations/0001_registry.sql` to the dedicated empty preview D1 using `wrangler d1 migrations apply DB --remote --config wrangler.deploy.json`. Existing registry data must never be reset. No migration/provision/deploy has been run remotely by this increment. +4. Check `wrangler deploy --dry-run --config wrangler.deploy.json --experimental-provision=false --experimental-auto-create=false`, then deploy the reviewed preview using the same flags without `--dry-run` only once resource access/authorization are verified. Automatic resource creation is disabled. The build bundles semver and shared validation; D1/R2 storage outlives Worker code replacement. +5. Verify `/api/health`, exact deployed source, asset CSP and private bucket access. Enable publication only after measuring actual deployed CPU/memory and confirming usage controls. Publish disposable preview versions, replace the Worker code while keeping bindings, and verify unchanged identity, ownership and byte-identical downloads. Repeat browser signed publication, compatible one-Install into a separate Python project, actual graph refresh, update/disable/remove and origin/window/outage checks over the stable HTTPS origin. + +The community publish API always assigns `official=false`. An official Drupal listing must use a separately reviewed operator import; this Worker increment does not expose an unauthenticated official-seed endpoint. The existing local Node service's optional official seed is unchanged. + +## Remote backups and isolated restore + +A D1 Time Travel restore alone does not restore R2. Keep immutable objects and no expiration rules, and create an independent archive containing **both** metadata and all referenced objects. The binding-level format/tool is a local rehearsal; remote operator steps require authenticated Wrangler and are not claimed tested against a Cloudflare account: + +1. Export a consistent D1 SQL snapshot: `wrangler d1 export DB --remote --config wrangler.deploy.json --output /absolute/new-backup/registry.sql`. +2. Export the release object-key/hash/size inventory from that snapshot. With writes stopped, a D1 `SELECT object_key,integrity,size FROM releases` through `wrangler d1 execute DB --remote --json --command ...` also matches it. Do not combine unrelated snapshots. Fetch each private object using `wrangler r2 object get BUCKET/KEY --remote --file /absolute/new-backup/objects/HASH --config wrangler.deploy.json`. Verify SHA-256/size/package identity for every row, then write the archive manifest last. Copy the completed archive and a trusted checksum to an independent destination; define retention and recovery objectives. +3. Restore exclusively into a **new, detached, empty** D1/R2 pair with no serving Worker or writers. Verify the archive before writes; upload each verified object first with `wrangler r2 object put ... --remote --file ...`, then import the metadata SQL with `wrangler d1 execute DB --remote --file ...`. Never run restore SQL on the live database. Keep the pair detached if any import is incomplete. +4. Bind a separate read-only preview Worker to the restored pair and verify every listed artifact, identity and owner before cutover. Do not attach the original public domain until the operator approves a cutover. Provider backup, restore, retention and redeploy acceptance remain open until these commands actually run on verified resources. + +## Costs and limits: planning assumptions, not a bill estimate + +Sources checked September 22, 2026: [Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/), [Workers limits](https://developers.cloudflare.com/workers/platform/limits/), [D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/), [D1 limits](https://developers.cloudflare.com/d1/platform/limits/), [R2 pricing](https://developers.cloudflare.com/r2/pricing/). Account-wide usage by other applications must be included. + +Workers Free allows 100,000 dynamic requests/day and 10 ms CPU per invocation; static asset requests bypassing the Worker are free. Memory is 128 MB per isolate. Workers Paid starts at $5/month and includes 10 million requests and 30 million CPU-ms/month, with request/CPU overages. It is not an all-in marketplace price. No paid plan has been selected or activated. + +D1 Free includes 5 million rows read/day, 100,000 rows written/day and 5 GB total storage (500 MB per database). R2 Standard includes 10 GB-month, 1 million Class A and 10 million Class B operations/month. Beyond included R2 usage, Standard storage is $0.015/GB-month, Class A $4.50/million and Class B $0.36/million; egress is free. Backups, orphan objects, repeated checks and abuse consume storage/operations too. Free allowances do not authorize billable activation or guarantee a zero bill. + +Illustrative small preview, **not measured demand**: 100 extensions × 3 releases × 500 KiB ≈ 146 MiB of packages; seven full independent copies add ≈ 1 GiB. At 4,000 catalog queries/day scanning 300 rows plus 1,000 downloads/day, catalog reads are approximately 1.2 million rows/day; downloads add small indexed lookups. About 30,000 monthly object downloads and 300 daily backup reads remain below R2 read allowances. Twenty publications/day write tens to hundreds of metadata rows, plus rate/replay maintenance. This scenario fits request/row/storage allowances in isolation; it does **not** establish CPU eligibility. + +The 8 MiB package contract has a 12 MiB signed JSON/base64 envelope. Package/signature verification and hashing may exceed the Free 10 ms CPU budget, and concurrent large requests may pressure 128 MB memory. Local wall time includes I/O and is **not Cloudflare CPU billing evidence**. Measure representative and maximum-size deployed requests before choosing a plan; if Free cannot support the contract, return the measured limit and a priced option for approval rather than silently lowering the size limit or enabling a paid plan. D1 stores only bounded metadata, avoiding its 2 MB row limit. The 1,000-release preview cap requires a catalog-pagination redesign before larger scale. + +No hosted HTTPS, provider persistence, custom domain, cost ceiling, power-loss, multi-region behavior or production readiness is claimed by local runtime tests. Broader Drupal coverage/performance review remains separate; historical measured rebuild overhead is 22–30% with limited samples. diff --git a/docs/extensions/hosting.md b/docs/extensions/hosting.md index aca54403..4443306e 100644 --- a/docs/extensions/hosting.md +++ b/docs/extensions/hosting.md @@ -1,5 +1,7 @@ # Hosting the extension marketplace +Cloudflare is now the preferred hosted target. See [Cloudflare deployment and cost guidance](cloudflare-hosting.md). The Node/SQLite instructions below remain the portable backend reference; Vercel-specific instructions are historical alternatives, not the current delivery target. + This branch supplies a Node registry and browser frontend. It does not provision a host, disk, backup service, TLS endpoint or Vercel project. A running local server or temporary HTTPS tunnel is not a durable deployment. ## Persistence contract diff --git a/marketplace/cloudflare/local.mjs b/marketplace/cloudflare/local.mjs index c8e8f7b5..d2cfe8f5 100644 --- a/marketplace/cloudflare/local.mjs +++ b/marketplace/cloudflare/local.mjs @@ -4,7 +4,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root=path.dirname(fileURLToPath(import.meta.url)); export async function startLocal({state,testing=false,boundary,port=0,publishing=true}={}) { - const mf=new Miniflare({...convertV4MiniflareOptions({host:'127.0.0.1',port,d1Persist:path.join(state,'d1'),r2Persist:path.join(state,'r2'),workers:[{name:'registry',modules:true,scriptPath:path.join(root,'.build',testing?'test-worker.js':'worker.js'),compatibilityDate:'2026-09-21', + const mf=new Miniflare({...convertV4MiniflareOptions({host:'127.0.0.1',port,workers:[{name:'registry',modules:true,scriptPath:path.join(root,'.build',testing?'test-worker.js':'worker.js'),compatibilityDate:'2026-09-21', d1Databases:{DB:'registry'},r2Buckets:{PACKAGES:'packages'}, bindings:{PUBLISHING_ENABLED:String(publishing)},serviceBindings:{ ASSETS:async req=>{const name=path.basename(new URL(req.url).pathname);if(!['index.html','app.js','style.css'].includes(name))return new Response('Not found',{status:404});return new Response(fs.readFileSync(path.join(root,'../public',name)),{headers:{'Content-Type':name.endsWith('.html')?'text/html':name.endsWith('.js')?'text/javascript':'text/css'}});}, diff --git a/marketplace/cloudflare/migrations/0001_registry.sql b/marketplace/cloudflare/migrations/0001_registry.sql index 44043309..636af355 100644 --- a/marketplace/cloudflare/migrations/0001_registry.sql +++ b/marketplace/cloudflare/migrations/0001_registry.sql @@ -6,6 +6,7 @@ CREATE TABLE releases (id TEXT NOT NULL, version TEXT NOT NULL, publisher TEXT N CREATE TABLE submissions (nonce TEXT PRIMARY KEY, created INTEGER NOT NULL); CREATE INDEX submissions_created ON submissions(created); CREATE TABLE rate_limits (key TEXT PRIMARY KEY, expires INTEGER NOT NULL, count INTEGER NOT NULL); +CREATE INDEX rate_limits_expires ON rate_limits(expires); CREATE TRIGGER release_owner BEFORE INSERT ON releases BEGIN SELECT CASE WHEN (SELECT publisher FROM extensions WHERE id=NEW.id) != NEW.publisher THEN RAISE(ABORT,'Extension id belongs to another publisher') END; END; CREATE TRIGGER release_capacity BEFORE INSERT ON releases BEGIN SELECT CASE WHEN (SELECT count(*) FROM releases) >= 1000 THEN RAISE(ABORT,'Preview registry capacity reached; contact the operator') END; END; CREATE TRIGGER immutable_release BEFORE UPDATE ON releases BEGIN SELECT RAISE(ABORT,'Release versions are immutable'); END; diff --git a/marketplace/cloudflare/ops.mjs b/marketplace/cloudflare/ops.mjs new file mode 100644 index 00000000..83545bf8 --- /dev/null +++ b/marketplace/cloudflare/ops.mjs @@ -0,0 +1,17 @@ +// Local Worker-runtime operator rehearsal. Production remote backup steps are in the guide. +import path from 'node:path'; +import fs from 'node:fs'; +import {startLocal} from './local.mjs'; +import {backupRegistry,restoreRegistry} from './backup.mjs'; +const [action,stateArg,backupArg]=process.argv.slice(2); +if(!['serve','backup','restore'].includes(action)||!stateArg||(['backup','restore'].includes(action)&&!backupArg))throw Error('Usage: node ops.mjs serve|backup|restore ABSOLUTE_STATE [ABSOLUTE_BACKUP]'); +if(!path.isAbsolute(stateArg)||(backupArg&&!path.isAbsolute(backupArg)))throw Error('Use explicit absolute state and backup paths'); +if(action==='backup'&&!fs.existsSync(stateArg))throw Error('Source state directory does not exist'); +if(action==='restore'&&fs.existsSync(stateArg))throw Error('Restore requires a new state directory'); +const server=await startLocal({state:stateArg,publishing:action==='serve'}); +if(action==='serve'){ + console.log(JSON.stringify({local:true,origin:'http://127.0.0.1:'+server.port,state:stateArg})); + for(const signal of ['SIGTERM','SIGINT'])process.once(signal,async()=>{await server.close();process.exit(0);}); +}else try{ + console.log(JSON.stringify(await (action==='backup'?backupRegistry:restoreRegistry)(server.db,server.bucket,backupArg))); +}finally{await server.close();} diff --git a/marketplace/cloudflare/test.mjs b/marketplace/cloudflare/test.mjs index 6794d470..a0cf2090 100644 --- a/marketplace/cloudflare/test.mjs +++ b/marketplace/cloudflare/test.mjs @@ -6,7 +6,7 @@ import {generateKeyPairSync,sign,randomUUID,createHash} from 'node:crypto'; import {fork,execFileSync} from 'node:child_process'; import {startLocal} from './local.mjs'; import {backupRegistry,restoreRegistry} from './backup.mjs'; -const root=path.resolve('../..'),out=path.join(root,'.qa/cloudflare/runtime');fs.mkdirSync(out,{recursive:true}); +const root=path.resolve('../..'),out=path.resolve(process.env.CLOUDFLARE_OUTPUT||path.join(root,'.qa/cloudflare/runtime'));fs.mkdirSync(out,{recursive:true}); const lab=fs.mkdtempSync(path.join(os.tmpdir(),'cg-cloudflare-')),checks=[],receipts=[],keys=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),services=[]; const hash=b=>createHash('sha256').update(b).digest('hex'); function artifact(id='worker-demo',version='1.0.0',size){ @@ -25,7 +25,7 @@ async function request(server,body,extra={},expected=201){ } const list=async s=>(await fetch('http://127.0.0.1:'+s.port+'/api/extensions')).json(); const start=async state=>{const s=await startLocal({state,testing:true});services.push(s);return s;}; -function passed(name){checks.push(name);console.log('PASS',name);} +function passed(name){checks.push(name);fs.writeFileSync(path.join(out,'progress.json'),JSON.stringify({checks,receipts,updated:new Date().toISOString()},null,2));console.log('PASS',name);} async function child(state){ const c=fork(new URL('./test-child.mjs',import.meta.url),[state],{stdio:['ignore','pipe','pipe','ipc']});let stdout='',stderr='';c.stdout.on('data',b=>stdout+=b);c.stderr.on('data',b=>stderr+=b); const done=new Promise(resolve=>c.on('exit',(exit,signal)=>{const log='child-'+(receipts.length+1)+'.log';fs.writeFileSync(path.join(out,log),stdout+stderr);receipts.push({exit,signal,log,sha256:hash(stdout+stderr)});resolve();})); @@ -37,6 +37,7 @@ async function child(state){ const first=signed(artifact());const published=(await request(server,first)).result; assert.equal(published.publisherId,hash(keys.publicKey.export({format:'der',type:'spki'})));passed('Web Crypto signature and SPKI identity match Node publisher'); const bytes=Buffer.from(await(await fetch('http://127.0.0.1:'+server.port+'/api/download/worker-demo/1.0.0')).arrayBuffer());assert.deepEqual(bytes,artifact());passed('catalog and immutable R2 package bytes agree'); + await server.bucket.put('packages/sha256/'+published.integrity,Buffer.from('tampered'));assert.equal((await fetch('http://127.0.0.1:'+server.port+'/api/download/worker-demo/1.0.0')).status,503);await server.bucket.delete('packages/sha256/'+published.integrity);assert.equal((await fetch('http://127.0.0.1:'+server.port+'/api/download/worker-demo/1.0.0')).status,503);await server.bucket.put('packages/sha256/'+published.integrity,artifact());passed('missing and corrupt stored artifacts fail closed instead of serving bytes'); await request(server,first,{},400);await request(server,signed(artifact('worker-demo','1.0.0')),{},400);passed('replay and immutable version reject without replacement'); await request(server,signed(artifact('worker-demo','2.0.0'),generateKeyPairSync('ec',{namedCurve:'prime256v1'})),{},400);passed('existing publisher owns later versions'); await request(server,{...signed(artifact('bad-signature')),signature:'AAAA'}, {},400); @@ -50,21 +51,21 @@ async function child(state){ // A database-side abort after the object upload must roll back owner + nonce as well as release. await server.db.exec("CREATE TRIGGER fail_commit BEFORE INSERT ON releases WHEN NEW.id='d1-failure' BEGIN SELECT RAISE(ABORT,'test service failure'); END;");const dbfail=signed(artifact('d1-failure'));await request(server,dbfail,{},503);assert.equal(await server.db.prepare("SELECT publisher FROM extensions WHERE id='d1-failure'").first(),null);await server.db.exec('DROP TRIGGER fail_commit');await request(server,dbfail);passed('D1 failure rolls back release ownership and replay nonce'); const snapshot=await backupRegistry(server.db,server.bucket,path.join(lab,'backup'));const restored=await start(path.join(lab,'restored'));await restoreRegistry(restored.db,restored.bucket,path.join(lab,'backup'));assert.deepEqual(await list(restored),await list(server));await request(restored,first,{},400);await assert.rejects(()=>restoreRegistry(restored.db,restored.bucket,path.join(lab,'backup')),/empty detached/);passed('isolated D1/R2 backup restore retains listing ownership and nonce; refuses live overwrite'); - fs.appendFileSync(path.join(lab,'backup/objects',published.integrity),'corrupt');const empty=await start(path.join(lab,'corrupt-restore'));await assert.rejects(()=>restoreRegistry(empty.db,empty.bucket,path.join(lab,'backup')));assert.deepEqual(await list(empty),[]);passed('corrupt snapshot cannot expose a restored catalog'); + fs.appendFileSync(path.join(lab,'backup/objects',published.integrity),'corrupt');const empty=await start(path.join(lab,'corrupt-restore'));await assert.rejects(()=>restoreRegistry(empty.db,empty.bucket,path.join(lab,'backup')));assert.deepEqual(await list(empty),[]);passed('corrupt snapshot cannot expose a restored catalog');await restored.close();await empty.close(); const before=await list(server);await server.close();const replacement=await start(state);assert.deepEqual(await list(replacement),before);passed('replacement Worker runtime retains D1 metadata and R2 bytes'); - const large=artifact('max-size','1.0.0',8*1024*1024);assert.equal(large.length,8*1024*1024);const timing=await request(replacement,signed(large));const downloaded=Buffer.from(await(await fetch('http://127.0.0.1:'+replacement.port+'/api/download/max-size/1.0.0')).arrayBuffer());assert.equal(hash(downloaded),hash(large));passed('8 MiB package survives Worker crypto D1 metadata and R2 upload/download'); + const large=artifact('max-size','1.0.0',8*1024*1024);assert.equal(large.length,8*1024*1024);const timing=await request(replacement,signed(large));for(let attempt=0;attempt<3;attempt++){const response=await fetch('http://127.0.0.1:'+replacement.port+'/api/download/max-size/1.0.0');assert.equal(response.status,200);const downloaded=Buffer.from(await response.arrayBuffer());assert.equal(hash(downloaded),hash(large));}passed('8 MiB package survives Worker crypto D1 metadata and R2 upload/download'); await request(replacement,signed(Buffer.concat([large,Buffer.from(' ')])),{},400);passed('over-limit package rejected'); for(let i=0;i<11;i++)await request(replacement,signed(artifact('rate-'+i)),{'CF-Connecting-IP':'203.0.113.250'},i<10?201:429);passed('D1 rate limit survives distributed requests'); for(const phase of ['after-object','after-commit']){ const killstate=path.join(lab,'kill-'+phase),original=await child(killstate),body=signed(artifact('kill-demo')); - const reached=new Promise(resolve=>original.c.on('message',m=>{if(m.phase===phase)resolve();})); + let timer;const reached=new Promise((resolve,reject)=>{timer=setTimeout(()=>reject(Error('No kill checkpoint')),15000);original.c.on('message',m=>{if(m.phase===phase){clearTimeout(timer);resolve();}});}); const pending=fetch('http://127.0.0.1:'+original.port+'/api/publish',{method:'POST',headers:{'x-test-kill':phase},body:JSON.stringify(body)}).catch(()=>null); - await Promise.race([reached,new Promise((_,reject)=>setTimeout(()=>reject(Error('No kill checkpoint')),15000))]);original.c.kill('SIGKILL');await original.done;await pending; - const fresh=await start(killstate);assert.equal((await list(fresh)).length,phase==='after-commit'?1:0);await request(fresh,body,{},phase==='after-commit'?400:201); + await reached;original.c.kill('SIGKILL');await original.done;await pending; + const fresh=await start(killstate);assert.equal((await list(fresh)).length,phase==='after-commit'?1:0);assert.equal((await fetch('http://127.0.0.1:'+fresh.port+'/api/download/kill-demo/1.0.0')).status,phase==='after-commit'?200:404);await request(fresh,body,{},phase==='after-commit'?400:201);await fresh.close(); } passed('real test-owned process kills after R2 and D1 boundaries recover in fresh runtime'); // Production entry point ignores test-only fault headers. - const production=await startLocal({state:path.join(lab,'production')});services.push(production);await request(production,signed(artifact('prod')),{'x-test-failure':'before-object'});passed('production bundle has no test failure control'); + const production=await startLocal({state:path.join(lab,'production')});services.push(production);await request(production,signed(artifact('prod')),{'x-test-failure':'before-object'});passed('production bundle has no test failure control');const disabled=await startLocal({state:path.join(lab,'disabled'),publishing:false});services.push(disabled);await request(disabled,signed(artifact('disabled')),{},503);assert.deepEqual(await list(disabled),[]);passed('publication stays disabled until explicitly enabled'); fs.writeFileSync(path.join(out,'result.json'),JSON.stringify({revision:execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim(),dirty:!!execFileSync('git',['status','--porcelain'],{cwd:root,encoding:'utf8'}).trim(),platform:process.platform,node:process.version,checks,receipts,snapshot,largePackage:{bytes:large.length,wallMilliseconds:timing.ms,note:'Local wall time includes storage I/O; not Cloudflare CPU billing measurement'},success:true},null,2)); console.log('PASS',checks.length,'Worker-runtime checks'); }finally{await Promise.allSettled(services.map(s=>s.close()));fs.rmSync(lab,{recursive:true,force:true});}})().catch(error=>{fs.writeFileSync(path.join(out,'failure.json'),JSON.stringify({checks,receipts,error:String(error.stack)},null,2));console.error(error);process.exitCode=1;}); diff --git a/marketplace/cloudflare/worker.ts b/marketplace/cloudflare/worker.ts index 6a5c3f42..12f05690 100644 --- a/marketplace/cloudflare/worker.ts +++ b/marketplace/cloudflare/worker.ts @@ -7,7 +7,12 @@ const headers = { 'X-Content-Type-Options':'nosniff', 'Referrer-Policy':'no-refe 'Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" }; export const digest = async (bytes: BufferSource) => [...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(b=>b.toString(16).padStart(2,'0')).join(''); export const objectKey = (hash:string) => 'packages/sha256/'+hash; -function decodeBase64(value:string) { const raw=atob(value); return Uint8Array.from(raw,c=>c.charCodeAt(0)); } +function decodeBase64(value:string) { + const raw=atob(value),bytes=new Uint8Array(raw.length); + // Avoid the temporary per-character array created by Uint8Array.from for 8 MiB packages. + for(let i=0;i MAX_PACKAGE_BYTES) throw new Error('Extension package exceeds 8 MiB'); - const p = JSON.parse(new TextDecoder().decode(bytes)) as ExtensionPackage; + const p = JSON.parse(new TextDecoder('utf-8', { ignoreBOM: true }).decode(bytes)) as ExtensionPackage; if (p?.format !== 'codegraph-extension-1' || !p.package || typeof p.package.name !== 'string' || !semver.valid(p.package.version) || !p.files || Array.isArray(p.files) || typeof p.files !== 'object') throw new Error('Invalid extension package'); validateManifest(p.package.codegraph, engineVersion);