test(marketplace): validate Cloudflare persistence and package boundaries

This commit is contained in:
Forge
2026-09-22 07:00:25 +00:00
parent 44aaa721b2
commit 52c1b38644
10 changed files with 139 additions and 11 deletions
+2
View File
@@ -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.
@@ -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<string,string>});
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');
});
});
+77
View File
@@ -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/<SHA-256>`. 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.
+2
View File
@@ -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
+1 -1
View File
@@ -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'}});},
@@ -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;
+17
View File
@@ -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();}
+9 -8
View File
@@ -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;});
+6 -1
View File
@@ -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<raw.length;i++)bytes[i]=raw.charCodeAt(i);
return bytes;
}
function json(status:number,data:unknown) { return Response.json(data,{status,headers:{...headers,'Cache-Control':'no-store'}}); }
class InputError extends Error {}
async function readBody(request:Request) {
+1 -1
View File
@@ -28,7 +28,7 @@ export function validateManifest(raw: unknown, engineVersion?: string): PluginMa
export function parsePackage(bytes: Uint8Array, engineVersion?: string): ExtensionPackage {
if (bytes.length > 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);