From 384daf329ff00c4bfb7bf874ea414f4f3cd121b1 Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 22 Sep 2026 21:15:03 +0000 Subject: [PATCH] feat: bound hosted publication windows and add HTTPS acceptance [skip ci] --- marketplace/cloudflare/local.mjs | 4 +- .../cloudflare/publication-window-test.mjs | 10 + marketplace/cloudflare/worker.ts | 10 +- .../validation/cloudflare-hosted-browser.cjs | 216 ++++++++++++++++++ .../cloudflare-hosted-compatibility.cjs | 193 ++++++++++++++++ .../validation/cloudflare-hosted-drupal.cjs | 36 +++ .../validation/cloudflare-hosted-packages.cjs | 28 +++ 7 files changed, 493 insertions(+), 4 deletions(-) create mode 100644 marketplace/cloudflare/publication-window-test.mjs create mode 100644 scripts/validation/cloudflare-hosted-browser.cjs create mode 100644 scripts/validation/cloudflare-hosted-compatibility.cjs create mode 100644 scripts/validation/cloudflare-hosted-drupal.cjs create mode 100644 scripts/validation/cloudflare-hosted-packages.cjs diff --git a/marketplace/cloudflare/local.mjs b/marketplace/cloudflare/local.mjs index 78c528faf..cde08fe97 100644 --- a/marketplace/cloudflare/local.mjs +++ b/marketplace/cloudflare/local.mjs @@ -3,10 +3,10 @@ import fs from 'node:fs'; 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,initialize=true}={}) { +export async function startLocal({state,testing=false,boundary,port=0,publishing=true,publishingUntil,initialize=true}={}) { 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:{ + bindings:{PUBLISHING_ENABLED:String(publishing),...(publishingUntil===undefined?{}:{PUBLISHING_UNTIL:publishingUntil})},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'}});}, ...(boundary?{TEST_BOUNDARY:boundary}:{})}}]}),resourcePersistencePath:state}); await mf.ready; diff --git a/marketplace/cloudflare/publication-window-test.mjs b/marketplace/cloudflare/publication-window-test.mjs new file mode 100644 index 000000000..5803f89b1 --- /dev/null +++ b/marketplace/cloudflare/publication-window-test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import {startLocal} from './local.mjs'; +const checks=[]; +for(const [label,deadline,enabled] of [['future',new Date(Date.now()+60000).toISOString(),true],['expired',new Date(0).toISOString(),false],['invalid','invalid',false]]){ + const s=await startLocal({publishingUntil:deadline}); + try{const base='http://127.0.0.1:'+s.port;assert.equal((await(await fetch(base+'/api/health')).json()).publishing,enabled);const r=await fetch(base+'/api/publish',{method:'POST',body:'{}'});assert.equal(r.status,enabled?400:503);checks.push(label+' deadline health and POST agree');}finally{await s.close();} +} +const s=await startLocal({publishingUntil:new Date(Date.now()+4000).toISOString()}); +try{const base='http://127.0.0.1:'+s.port;assert.equal((await(await fetch(base+'/api/health')).json()).publishing,true);await new Promise(r=>setTimeout(r,4100));assert.equal((await(await fetch(base+'/api/health')).json()).publishing,false);assert.equal((await fetch(base+'/api/publish',{method:'POST',body:'{}'})).status,503);checks.push('live window expires without deployment or process restart');}finally{await s.close();} +console.log(JSON.stringify({checks,success:true})); diff --git a/marketplace/cloudflare/worker.ts b/marketplace/cloudflare/worker.ts index a41424279..364256d59 100644 --- a/marketplace/cloudflare/worker.ts +++ b/marketplace/cloudflare/worker.ts @@ -15,6 +15,12 @@ function decodeBase64(value:string) { } function json(status:number,data:unknown) { return Response.json(data,{status,headers:{...headers,'Cache-Control':'no-store'}}); } class InputError extends Error {} +// Optional operator deadline makes a controlled acceptance window fail closed after interruption. +// Existing local fixtures may omit it; hosted acceptance always supplies an absolute UTC deadline. +function publishingEnabled(env:any) { + return env.PUBLISHING_ENABLED === 'true' && + (env.PUBLISHING_UNTIL === undefined || Date.now() < Date.parse(env.PUBLISHING_UNTIL)); +} async function readBody(request:Request) { const limit=MAX_PACKAGE_BYTES*1.5; if(Number(request.headers.get('content-length'))>limit) throw new InputError('Submission exceeds 12 MiB envelope limit'); @@ -58,7 +64,7 @@ export function createHandler(hook: (phase:string)=>Promise = async()=>{}) // All reads use the primary D1 endpoint. No eventually-consistent cache or replicas. const schema=await env.DB.prepare("SELECT value FROM registry_meta WHERE key='schema'").first('value'); if(schema!=='1')return json(503,{error:'Registry is not initialized or schema is unsupported; operator must apply migrations'}); - if(request.method==='GET'&&url.pathname==='/api/health')return json(200,{ok:true,publishing:env.PUBLISHING_ENABLED==='true',protocol:1,storage:'d1-r2',schema:1}); + if(request.method==='GET'&&url.pathname==='/api/health')return json(200,{ok:true,publishing:publishingEnabled(env),protocol:1,storage:'d1-r2',schema:1}); const releases=/^\/api\/extensions\/([a-z0-9-]+)$/.exec(url.pathname); if(request.method==='GET'&&(url.pathname==='/api/extensions'||releases)) { const query=releases?env.DB.prepare('SELECT listing FROM releases WHERE id=?').bind(releases[1]):env.DB.prepare('SELECT listing FROM releases'); @@ -78,7 +84,7 @@ export function createHandler(hook: (phase:string)=>Promise = async()=>{}) return new Response(bytes,{headers:{...headers,'Content-Type':'application/vnd.codegraph.extension+json','Content-Length':String(row.size),'Cache-Control':'public, max-age=31536000, immutable','Content-Disposition':`attachment; filename="${download[1]}-${encodeURIComponent(version)}.cgext"`}}); } if(request.method==='POST'&&url.pathname==='/api/publish') { - if(env.PUBLISHING_ENABLED!=='true')return json(503,{error:'Publishing is disabled; operator must verify storage and usage limits before enabling it'}); + if(!publishingEnabled(env))return json(503,{error:'Publishing is disabled; operator must verify storage and usage limits before enabling it'}); const origin=request.headers.get('origin');if(origin&&origin!==url.origin)return json(403,{error:'Publisher origin is not allowed'}); const minute=Math.floor(Date.now()/60000),ip=request.headers.get('CF-Connecting-IP')??'local'; const rateKey=await digest(utf8.encode(ip+':'+minute)); diff --git a/scripts/validation/cloudflare-hosted-browser.cjs b/scripts/validation/cloudflare-hosted-browser.cjs new file mode 100644 index 000000000..a016af39f --- /dev/null +++ b/scripts/validation/cloudflare-hosted-browser.cjs @@ -0,0 +1,216 @@ +// PLAYWRIGHT_MODULE=/absolute/path/to/playwright node scripts/validation/marketplace-browser.cjs +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const http = require('node:http'); +const assert = require('node:assert/strict'); +const { DatabaseSync } = require('node:sqlite'); +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const { startMarketplaceServer } = process.env.CLOUDFLARE_REGISTRY === '1' ? require('./cloudflare-registry.cjs') : require('../../dist/plugins/marketplace'); +const { startExtensionBridge } = require('../../dist/plugins/bridge'); +process.env.CODEGRAPH_TELEMETRY = '0'; +process.env.CODEGRAPH_PARSE_WORKERS = '2'; +const out = path.resolve(process.env.BROWSER_OUTPUT || '.qa/recovery/browser'); fs.mkdirSync(out, { recursive: true }); +const checks = []; +const safeError = error => String(error.stack || error).replace(/token=[a-f0-9]+/g, 'token=[redacted]'); +function artifact(version, broken = false) { + return Buffer.from(JSON.stringify({ format: 'codegraph-extension-1', package: { + name: '@browser/example', version, main: 'index.cjs', codegraph: { id: 'hosted-browser-20260922', apiVersion: 1, capabilities: ['frameworks'] }, + }, files: { 'index.cjs': `module.exports=()=>({frameworks:[{name:'route',languages:['typescript'],detect:()=>true,resolve:()=>null, + extract(file){${broken ? "throw Error('Rejected browser update');" : ''} return {nodes:[{id:'plugin:hosted-browser-20260922:'+file,kind:'route',name:'/browser-${version}',qualifiedName:file+'::browser',filePath:file,language:'typescript',startLine:1,endLine:1,startColumn:0,endColumn:0,updatedAt:0}],references:[]}}}]});` } })); +} +function routes(root) { + const file = path.join(root, '.codegraph/codegraph.db'); + if (!fs.existsSync(file)) return []; + const db = new DatabaseSync(file, { readOnly: true }); + try { return db.prepare("SELECT name FROM nodes WHERE kind='route' ORDER BY name").all().map(n => n.name); } finally { db.close(); } +} +(async () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-browser-recovery-')); + const roots = ['one', 'two'].map(n => path.join(temp, n)); + roots.forEach(r => { fs.mkdirSync(r); fs.writeFileSync(path.join(r, 'app.ts'), 'export function main() {}'); }); + let server, bridge, browser, attacker, debugPage, debugSnapshot, https; + try { + let origin = process.env.HOSTED_REGISTRY; + assert.match(origin || '', /^https:\/\/[a-z0-9.-]+\.workers\.dev$/); + assert.equal((await(await fetch(origin+'/api/extensions/hosted-browser-20260922')).json()).length,0,'Fixture already exists; use a fresh isolated target or rename this owned fixture'); + if (process.env.REGISTRY_TEST_HTTPS === '1') { https = await require('./https-marketplace.cjs').httpsMarketplace(server.port, out); origin = https.origin; } + bridge = await startExtensionBridge(roots, origin); + const token = new URLSearchParams(new URL(bridge.connectionUrl).hash.slice(1)).get('token'); + const snapshot = async () => (await fetch(bridge.url + '/status', { headers: { Authorization: 'Bearer ' + token } })).json(); + debugSnapshot = snapshot; + browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || '/opt/google/chrome/chrome', headless: true, args: ['--no-sandbox'] }); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + context.setDefaultTimeout(30000); + let lastPublication=0; + async function publicationSlot(route){await new Promise(r=>setTimeout(r,Math.max(0,lastPublication+7500-Date.now())));lastPublication=Date.now();await route.continue();} + await context.route('**/api/publish',publicationSlot); + context.setDefaultNavigationTimeout(20000); + const page = await context.newPage(); + debugPage = page; + const pageErrors = []; page.on('pageerror', e => pageErrors.push(e.message)); + let releaseCatalog, firstCatalog = true; + await page.route('**/api/extensions', async route => { + if (firstCatalog) { firstCatalog = false; await new Promise(resolve => { releaseCatalog = resolve; }); } + await route.continue(); + }); + await page.goto(bridge.connectionUrl, { waitUntil: 'domcontentloaded' }); + if (origin.startsWith('https:')) assert.equal(await page.evaluate(() => isSecureContext), true); + // Reproduce slow native file reads: metadata must never erase typed fields. + await page.evaluate(() => { const original=File.prototype.text; File.prototype.text=async function(){await new Promise(resolve=>setTimeout(resolve,500));return original.call(this);}; }); + console.log('Opened marketplace'); + await page.getByRole('link', { name: 'Publish an extension' }).click(); + async function publish(version, broken = false) { + console.log('Publishing', version); + if (new URL(page.url()).pathname !== '/publish') await page.getByRole('link', { name: 'Publish an extension' }).click(); + await page.locator('#artifact').setInputFiles({ name: 'example.cgext', mimeType: 'application/json', buffer: artifact(version, broken) }); + await page.locator('#name').fill('Browser example'); + await page.locator('#publisher').fill('Recovery publisher'); + await page.locator('#description').fill('A second framework used for end-to-end validation.'); + await page.locator('#source').fill('https://example.com/recovery-source'); + await page.locator('#readme').fill('Validation-only extension. Adds one explicit route to each TypeScript file.'); + await page.locator('#package-summary').filter({ hasText: 'v'+version+' ·' }).waitFor(); + if (releaseCatalog) { + const response = page.waitForResponse(r=>r.url().endsWith('/api/extensions')); + releaseCatalog(); releaseCatalog = undefined; await response; + await page.waitForTimeout(200); + assert.equal(await page.locator('#artifact').evaluate(input=>input.files[0]?.name),'example.cgext'); + checks.push('late catalog response preserves the entered publisher form and package'); + } + assert.equal(await page.locator('#description').inputValue(), 'A second framework used for end-to-end validation.'); + assert.equal(await page.locator('#source').inputValue(), 'https://example.com/recovery-source'); + await page.getByRole('button', { name: 'Publish release' }).click(); + console.log('Submitted', version); + await page.waitForURL('**/extensions/hosted-browser-20260922'); + await page.getByText(version, { exact: true }).waitFor(); + } + await publish('1.0.0'); checks.push('real browser signed publisher upload'); checks.push('slow package metadata preserves typed publisher fields'); + const first = (await (await fetch(origin + '/api/extensions')).json()).find(x=>x.id==='hosted-browser-20260922'); + assert.equal(first.official, false); + // A separate browser has a separate signing identity and cannot claim this id. + const otherContext = await browser.newContext(); + otherContext.setDefaultTimeout(30000); + await otherContext.route('**/api/publish',publicationSlot); + const other = await otherContext.newPage(); + await other.goto(origin + '/publish'); + await other.locator('#artifact').setInputFiles({ name: 'example.cgext', mimeType: 'application/json', buffer: artifact('9.0.0') }); + for (const [id, value] of Object.entries({ name: 'Attempted takeover', publisher: 'Other publisher', description: 'Test', source: 'https://example.com/other', readme: 'Test' })) await other.locator('#' + id).fill(value); + await other.getByRole('button', { name: 'Publish release' }).click(); + await other.getByText('This extension id belongs to another publisher', { exact: true }).waitFor(); + checks.push('browser publisher ownership rejection'); + // Send the real form submission to the real API with a corrupted signature. + await other.route('**/api/publish', async route => { + const body = route.request().postDataJSON(); body.signature = 'invalid'; + await route.continue({ postData: JSON.stringify(body) }); + }); + await other.getByRole('button', { name: 'Publish release' }).click(); + await other.locator('#publish-result').filter({ hasText: /signature/i }).waitFor(); + await other.unroute('**/api/publish'); + checks.push('browser invalid-signature failure state'); + const malformed = JSON.parse(artifact('9.0.0')); malformed.format = 'invalid-format'; + await other.locator('#artifact').setInputFiles({ name: 'invalid.cgext', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(malformed)) }); + await other.locator('#description').fill('Invalid package validation'); + await other.locator('#source').fill('https://example.com/invalid'); + await other.getByRole('button', { name: 'Publish release' }).click(); + await other.locator('#publish-result').filter({ hasText: /package/i }).waitFor(); + assert.equal((await (await fetch(origin + '/api/extensions/hosted-browser-20260922')).json()).length, 1); + await otherContext.close(); checks.push('browser malformed-package rejection without publication'); + await page.getByRole('link', { name: 'Marketplace', exact: true }).first().click(); + await page.getByRole('link', {name:'Browser example',exact:true}).click(); + const popupPromise = page.waitForEvent('popup'); + await page.locator('#connect').click(); + const popup = await popupPromise; + await popup.getByRole('button', { name: 'Allow connection' }).click(); + await page.getByText('CodeGraph connected', { exact: true }).waitFor(); + await page.locator('#project').selectOption('1'); + let completedJob = 0; + await page.getByRole('button', { name: 'Install extension' }).click(); + async function settle(expected) { + await page.waitForFunction(previous => state.snapshot && !state.snapshot.busy && state.snapshot.jobId > previous, completedJob); + const current = await snapshot(); + completedJob = current.jobId; + console.log('Companion result', JSON.stringify({ progress: current.progress, error: current.error, jobId: current.jobId })); + assert.ok(!current.error, current.error); + await page.getByText(expected, { exact: true }).first().waitFor({ timeout: 60000 }); + } + await settle('Extension activated and graph refreshed'); + assert.deepEqual(routes(roots[1]), ['/browser-1.0.0']); assert.deepEqual(routes(roots[0]), []); + checks.push('one-click selected-project install verified in SQLite'); + await page.screenshot({ path: path.join(out, 'desktop-installed.png'), fullPage: true }); + // Real same-origin message from an iframe: right origin, wrong source window. + const before = (await snapshot()).jobId; + await page.evaluate(() => { + window.__recoveryPopup = state.popup; + const iframe = document.createElement('iframe'); iframe.name = 'wrong-source'; document.body.append(iframe); + }); + await page.frame({ name: 'wrong-source' }).evaluate(() => parent.__recoveryPopup.postMessage({ channel: 'codegraph-extensions-v1', type: 'command', id: 'wrong-window', command: { action: 'remove', id: 'hosted-browser-20260922', project: '1' } }, '*')); + await page.waitForTimeout(300); assert.equal((await snapshot()).jobId, before); + await page.locator('iframe[name="wrong-source"]').evaluate(node => node.remove()); + checks.push('real postMessage wrong-window command ignored'); + await publish('2.0.0', true); + await page.getByRole('button', { name: 'Update to 2.0.0' }).click(); + await page.getByText(/Extension activation failed/).first().waitFor({ timeout: 60000 }); + completedJob = (await snapshot()).jobId; + assert.deepEqual(routes(roots[1]), ['/browser-1.0.0']); + assert.equal((await snapshot()).projects[1].extensions[0].version, '1.0.0'); + checks.push('failed browser update preserves working version and graph'); + await publish('3.0.0'); + await page.getByRole('button', { name: 'Update to 3.0.0' }).click(); + await settle('Extension activated and graph refreshed'); + assert.deepEqual(routes(roots[1]), ['/browser-3.0.0']); + checks.push('browser update replaces stale graph contributions'); + await page.getByRole('button', { name: 'Disable', exact: true }).click(); + await page.getByRole('button', { name: 'Enable', exact: true }).waitFor({ timeout: 60000 }); + assert.deepEqual(routes(roots[1]), []); + await page.getByRole('button', { name: 'Enable', exact: true }).click(); + await page.getByRole('button', { name: 'Disable', exact: true }).waitFor({ timeout: 60000 }); + assert.deepEqual(routes(roots[1]), ['/browser-3.0.0']); + await page.getByRole('button', { name: 'Remove', exact: true }).click(); + await page.getByRole('button', { name: 'Install extension' }).waitFor({ timeout: 60000 }); + await page.getByText('Extension removed and graph refreshed', { exact: true }).first().waitFor(); + assert.deepEqual(routes(roots[1]), []); assert.deepEqual(routes(roots[0]), []); + checks.push('browser disable/enable/remove verified in SQLite'); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByText('CodeGraph compatibility', { exact: true }).waitFor(); + await page.getByText('3.0.0', { exact: true }).waitFor(); + await page.screenshot({ path: path.join(out, 'mobile-detail.png'), fullPage: true }); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + checks.push('mobile detail fits viewport'); + // Bootstrap a companion from a hostile origin, even with a valid test token: + // its real message origin must still be refused after the local allow click. + attacker = http.createServer((req, res) => { res.setHeader('Content-Type', 'text/html'); res.end(''); }); + await new Promise(resolve => attacker.listen(0, '127.0.0.1', resolve)); + const evil = await context.newPage(); await evil.goto(`http://127.0.0.1:${attacker.address().port}`); + await evil.evaluate(({ url }) => { document.querySelector('#open').onclick = () => { window.target = window.open(url, '_blank'); }; }, { url: bridge.url + '/#token=' + token }); + const badPopupPromise = evil.waitForEvent('popup'); await evil.locator('#open').click(); const badPopup = await badPopupPromise; + await badPopup.getByRole('button', { name: 'Allow connection' }).click(); + const beforeOrigin = (await snapshot()).jobId; + await evil.evaluate(() => window.target.postMessage({ channel: 'codegraph-extensions-v1', type: 'command', id: 'wrong-origin', command: { action: 'enable', id: 'hosted-browser-20260922', project: '1' } }, '*')); + await evil.waitForTimeout(300); assert.equal((await snapshot()).jobId, beforeOrigin); + checks.push('real postMessage wrong-origin command ignored'); + await popup.close(); + await page.getByText('Reconnect CodeGraph', { exact: true }).waitFor(); + checks.push('closed local companion expires the connection visibly'); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.getByRole('link', { name: 'Publish an extension' }).click(); + await page.locator('#artifact').setInputFiles({ name: 'example.cgext', mimeType: 'application/json', buffer: artifact('4.0.0') }); + for (const [id, value] of Object.entries({ name: 'Browser example', publisher: 'Recovery publisher', description: 'Outage check', source: 'https://example.com/source', readme: 'Outage check' })) await page.locator('#'+id).fill(value); + await page.locator('#package-summary').filter({ hasText: 'v4.0.0' }).waitFor(); + await page.route('**/api/publish', route=>route.abort('connectionfailed')); + await page.getByRole('button', { name: 'Publish release' }).click(); + await page.locator('#publish-result').filter({ hasText: /marketplace registry/i }).waitFor(); + assert.equal(await page.getByRole('button', { name: 'Publish release' }).isEnabled(), true); + assert.deepEqual(routes(roots[1]), []); + checks.push('injected client transport outage shows actionable publisher failure (provider remains running) without changing local graph'); + assert.deepEqual(pageErrors, []); + fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ origin, browser: browser.version(), platform: process.platform, checks, pageErrors }, null, 2)); + console.log(JSON.stringify({ checks, browser: browser.version(), platform: process.platform })); + } catch (error) { + await debugPage?.screenshot({ path: path.join(out, 'failure.png'), fullPage: true }).catch(() => {}); + fs.writeFileSync(path.join(out, 'failure.json'), JSON.stringify({ checks, error: safeError(error), snapshot: await debugSnapshot?.() }, null, 2)); throw Error(safeError(error)); + } finally { + await browser?.close(); await https?.close(); await bridge?.close(); await server?.close(); + if (attacker) await new Promise(resolve => attacker.close(resolve)); + fs.rmSync(temp, { recursive: true, force: true }); + } +})().catch(error => { console.error(safeError(error)); if(error.cause) console.error(error.cause); process.exitCode = 1; }); diff --git a/scripts/validation/cloudflare-hosted-compatibility.cjs b/scripts/validation/cloudflare-hosted-compatibility.cjs new file mode 100644 index 000000000..afb684ba1 --- /dev/null +++ b/scripts/validation/cloudflare-hosted-compatibility.cjs @@ -0,0 +1,193 @@ +// Build first; PLAYWRIGHT_MODULE=/absolute/path/to/playwright node scripts/validation/extensions-compatibility.cjs +const fs = require('node:fs'), os = require('node:os'), path = require('node:path'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { generateKeyPairSync, sign, randomUUID, createHash } = require('node:crypto'); +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const { createExtensionProject, CodeGraph } = require('../../dist'); +const { packExtension } = require('../../dist/plugins/package'); +const { startMarketplaceServer } = process.env.CLOUDFLARE_REGISTRY === '1' ? require('./cloudflare-registry.cjs') : require('../../dist/plugins/marketplace'); +const { startExtensionBridge } = require('../../dist/plugins/bridge'); +const repo = path.resolve(__dirname, '../..'), out = path.resolve(process.env.COMPAT_OUTPUT || path.join(repo, '.qa/recovery/compatibility')); +fs.mkdirSync(out, { recursive: true }); +const lab = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-compatible-')); +const author = path.join(lab, 'author'), checks = [], commands = [], releases = []; +const env = { ...process.env, CODEGRAPH_TELEMETRY: '0', CODEGRAPH_PARSE_WORKERS: '2', CODEGRAPH_RESOLVE_WORKERS: '2', CODEGRAPH_PARALLEL_RESOLVE_MIN: '0' }; +Object.assign(process.env, env); +const roots = ['first-python-project', 'selected-python-project', 'headless-python-project'].map(n => path.join(lab, n)); +for (const root of roots) { + fs.mkdirSync(root); fs.writeFileSync(path.join(root, 'checkout.events.yaml'), 'order.created: send_receipt\n'); + fs.writeFileSync(path.join(root, 'handlers.py'), 'def send_receipt():\n return "sent"\n'); +} +createExtensionProject(author, 'hosted-compatible-20260922'); +const original = fs.readFileSync(path.join(author, 'index.cjs'), 'utf8'); +const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); +let server, bridge, browser, page, registry, https; +let lastPublication=0; +async function publicationSlot(){await new Promise(r=>setTimeout(r,Math.max(0,lastPublication+7500-Date.now())));lastPublication=Date.now();} +async function publish(version, engines = '>=1.6.0 <2', id = 'hosted-compatible-20260922', broken = false) { + const pkg = JSON.parse(fs.readFileSync(path.join(author, 'package.json'), 'utf8')); + pkg.version = version; pkg.codegraph.id = id; pkg.codegraph.engines = engines; + fs.writeFileSync(path.join(author, 'package.json'), JSON.stringify(pkg)); + fs.writeFileSync(path.join(author, 'index.cjs'), original.replace('Python event dispatch', 'Python event dispatch ' + version).replace('extract(file, source) {', broken ? "extract(file, source) { throw Error('Rejected compatible update');" : 'extract(file, source) {')); + const bytes = packExtension(author); + const payload = JSON.stringify({ name: id, description: 'Compatible generated Python event extension', publisher: 'Compatibility validation', + source: 'https://example.com/extension-source', readme: 'Generated author template; local release selection acceptance.', artifact: bytes.toString('base64'), timestamp: Date.now(), nonce: randomUUID() }); + const body = { payload, publicKey: keys.publicKey.export({ format: 'jwk' }), signature: sign('sha256', Buffer.from(payload), { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') }; + await publicationSlot(); + const res = await fetch(registry + '/api/publish', { method: 'POST', body: JSON.stringify(body) }); + assert.equal(res.status, 201, await res.clone().text()); + releases.push({ id, version, engines, integrity: createHash('sha256').update(bytes).digest('hex') }); + return { bytes, body }; +} +async function graph(root) { + if (!CodeGraph.isInitialized(root)) return []; + const g = await CodeGraph.open(root); + try { return g.getNodesByKind('route').flatMap(n => g.getOutgoingEdges(n.id).filter(e => e.metadata?.synthesizedBy === 'hosted-compatible-20260922').map(e => ({ source: n.name, target: g.getNode(e.target)?.name, label: e.metadata.label }))); } + finally { g.close(); } +} +async function expectGraph(root, version) { + assert.deepEqual(await graph(root), [{ source: 'event:order.created', target: 'send_receipt', label: 'Python event dispatch ' + version }]); +} +async function cli(args, expected = 0) { + const command = [path.join(repo, 'dist/bin/codegraph.js'), 'extensions', ...args]; + const start = Date.now(); + const result = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, command, { env, cwd: lab, timeout: 120000 }); + let stdout = '', stderr = ''; child.stdout.on('data', b => stdout += b); child.stderr.on('data', b => stderr += b); + child.on('error', reject); child.on('close', exit => resolve({ exit, stdout, stderr })); + }); + commands.push({ command: [process.execPath, ...command], exit: result.exit, seconds: (Date.now() - start) / 1000 }); + fs.writeFileSync(path.join(out, 'commands.json'), JSON.stringify(commands, null, 2)); + fs.writeFileSync(path.join(out, `command-${commands.length}.log`), result.stdout + result.stderr); + assert.equal(result.exit, expected, result.stderr); return result; +} +(async () => { + try { + registry = process.env.HOSTED_REGISTRY; + assert.match(registry || '', /^https:\/\/[a-z0-9.-]+\.workers\.dev$/); + assert.equal((await (await fetch(registry+'/api/extensions/hosted-compatible-20260922')).json()).length,0,'Use a fresh isolated target or explicitly rename this owned fixture before another run'); + await cli(['test', author]); + await cli(['pack', author, '--out', path.join(lab,'starter.cgext')]); + if (process.env.REGISTRY_TEST_HTTPS === '1') { https = await require('./https-marketplace.cjs').httpsMarketplace(server.port, out); registry = https.origin; } + // Publication order intentionally differs from semantic order; previews and + // a newer incompatible release must never displace the compatible stable one. + const pinned = await publish('1.10.0'); await publish('1.9.0'); await publish('0.5.0'); + await publish('20.0.0-beta.1'); await publish('9.0.0', '>=9'); await publish('1.0.0', '>=9', 'hosted-unavailable-20260922'); + await publicationSlot(); + const duplicate = await fetch(registry + '/api/publish', { method: 'POST', body: JSON.stringify(pinned.body) }); + assert.equal(duplicate.status, 400); + const all = await (await fetch(registry + '/api/extensions/hosted-compatible-20260922')).json(); + assert.deepEqual(all.map(r => r.version), ['9.0.0', '1.10.0', '1.9.0', '0.5.0', '20.0.0-beta.1']); + assert.equal(all[0].apiVersion, 1); checks.push('signed immutable versions; semantic catalog order independent of publication order'); + bridge = await startExtensionBridge(roots.slice(0, 2), registry); + browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || '/opt/google/chrome/chrome', headless: true, args: ['--no-sandbox'] }); + page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + page.setDefaultTimeout(60000); + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.goto(bridge.connectionUrl); + if (registry.startsWith('https:')) assert.equal(await page.evaluate(() => isSecureContext), true); + const popupWait = page.waitForEvent('popup'); await page.locator('#connect').click(); const popup = await popupWait; + await popup.getByRole('button', { name: 'Allow connection' }).click(); + await page.getByText('CodeGraph connected', { exact: true }).waitFor(); + await page.locator('#project').selectOption('1'); + await page.locator('.compatibility').filter({ hasText: 'Selected 1.10.0' }).waitFor(); + await page.getByRole('link', { name: 'hosted-compatible-20260922', exact: true }).click(); + await page.locator('.compatibility').filter({ hasText: 'selected-python-project' }).waitFor(); + await page.screenshot({ path: path.join(out, 'desktop-selected.png'), fullPage: true }); + // Exactly one Install after connection; wait for the real companion job. + await page.getByRole('button', { name: 'Install extension' }).click(); + await page.waitForFunction(() => state.snapshot?.jobId === 1 && !state.snapshot.busy); + assert.equal(await page.evaluate(() => state.snapshot.error), undefined); + await expectGraph(roots[1], '1.10.0'); assert.deepEqual(await graph(roots[0]), []); + checks.push('one browser Install selects 1.10.0 over incompatible 9.0.0 and preview; actual Python graph refreshed only in chosen destination'); + // Hold actual bridge resolution to inspect the transient destination state. + await page.evaluate(() => { + const originalRpc = rpc; + const gate = new Promise(resolve => window.releaseSelection = resolve); + rpc = (type, value) => type === 'command' && value?.action === 'resolve' ? gate.then(() => originalRpc(type, value)) : originalRpc(type, value); + window.pendingSelection = refreshSelections().finally(() => { rpc = originalRpc; }); + }); + await page.screenshot({ path: path.join(out, 'metadata-pending.png'), fullPage: true }); + assert.equal(await page.locator('[data-installed-version]').innerText(), '1.10.0'); + assert.equal(await page.locator('[data-selected-version]').innerText(), 'Checking compatibility…'); + assert.equal(await page.locator('a[href^="/api/download/"]').count(), 0); + await page.evaluate(async () => { releaseSelection(); await pendingSelection; }); + assert.equal(await page.locator('[data-selected-version]').innerText(), '1.10.0'); + assert.equal(await page.locator('[data-installed-version]').innerText(), '1.10.0'); + checks.push('pending compatibility refresh never shows incompatible catalog version as selected; installed and selected versions remain distinct'); + await page.screenshot({ path: path.join(out, 'desktop-installed.png'), fullPage: true }); + const config = fs.readFileSync(path.join(roots[1], 'codegraph.json'), 'utf8'); + const noChoice = await page.evaluate(async () => { + try { await rpc('command', { action: 'resolve', id: 'hosted-unavailable-20260922', project: '1' }); return ''; } catch (e) { return e.message; } + }); + assert.match(noChoice, /No compatible stable release/); + await page.evaluate(() => rpc('command', { action: 'install', id: 'hosted-unavailable-20260922', project: '1' })); + await page.waitForFunction(() => state.snapshot?.jobId === 2 && !state.snapshot.busy); + assert.match(await page.evaluate(() => state.snapshot.error), /No compatible stable release/); + assert.equal(fs.readFileSync(path.join(roots[1], 'codegraph.json'), 'utf8'), config); await expectGraph(roots[1], '1.10.0'); + await page.evaluate(() => navigate('/extensions/hosted-unavailable-20260922')); + await page.locator('.compatibility').filter({ hasText: 'No compatible stable release' }).waitFor(); + assert.equal(await page.getByRole('button', { name: 'Install extension' }).isDisabled(), true); + checks.push('no-compatible browser selection/command fails usefully and preserves existing config/graph'); + // Independent CLI destination resolves exactly the same release. + await cli(['install', 'hosted-compatible-20260922', '--registry', registry, '--path', roots[2]]); await expectGraph(roots[2], '1.10.0'); + const headlessConfig = fs.readFileSync(path.join(roots[2], 'codegraph.json'), 'utf8'); + const exactFailure = await cli(['install', 'hosted-compatible-20260922', '--registry', registry, '--version', '9.0.0', '--path', roots[2]], 1); + assert.match(exactFailure.stderr, /No compatible exact release 9.0.0/); + await cli(['update', 'hosted-unavailable-20260922', '--registry', registry, '--path', roots[2]], 1); + const none = await cli(['install', 'hosted-unavailable-20260922', '--registry', registry, '--path', roots[2]], 1); + assert.match(none.stderr, /No compatible stable release/); + assert.equal(fs.readFileSync(path.join(roots[2], 'codegraph.json'), 'utf8'), headlessConfig); await expectGraph(roots[2], '1.10.0'); + checks.push('headless automatic selection agrees; exact incompatible pin/no-compatible/missing update fail without mutation'); + await cli(['install', 'hosted-compatible-20260922', '--registry', registry, '--version', '20.0.0-beta.1', '--path', roots[2]]); + await expectGraph(roots[2], '20.0.0-beta.1'); + const previewConfig = fs.readFileSync(path.join(roots[2], 'codegraph.json'), 'utf8'); + const downgrade = await cli(['update', 'hosted-compatible-20260922', '--registry', registry, '--path', roots[2]], 1); + assert.match(downgrade.stderr, /would downgrade/); + assert.equal(fs.readFileSync(path.join(roots[2], 'codegraph.json'), 'utf8'), previewConfig); await expectGraph(roots[2], '20.0.0-beta.1'); + await cli(['install', 'hosted-compatible-20260922', '--registry', registry, '--version', '1.9.0', '--path', roots[2]]); await expectGraph(roots[2], '1.9.0'); + checks.push('prerelease requires explicit exact pin; automatic update refuses downgrade; explicit lower pin remains exact'); + await publish('2.0.0', '>=1.6.0 <2', 'hosted-compatible-20260922', true); + await page.reload(); const reconnect = page.waitForEvent('popup'); + // A named existing popup may be reused: reconnect using a fresh window. + await popup.close(); await page.locator('#connect').click(); const second = await reconnect; + await second.getByRole('button', { name: 'Allow connection' }).click(); + await page.getByText('CodeGraph connected', { exact: true }).waitFor(); + await page.evaluate(() => { state.project = '1'; navigate('/extensions/hosted-compatible-20260922'); return refreshSelections(); }); + await page.getByRole('button', { name: 'Update to 2.0.0' }).click(); + await page.waitForFunction(() => state.snapshot?.jobId === 3 && !state.snapshot.busy); + assert.match(await page.evaluate(() => state.snapshot.error), /Extension activation failed/); + await expectGraph(roots[1], '1.10.0'); assert.equal(fs.readFileSync(path.join(roots[1], 'codegraph.json'), 'utf8'), config); + checks.push('compatible but broken update retains previous version and graph'); + await publish('3.0.0'); + await page.evaluate(() => refresh()); + await page.getByRole('button', { name: 'Update to 3.0.0' }).click(); + await page.waitForFunction(() => state.snapshot?.jobId === 4 && !state.snapshot.busy); + assert.equal(await page.evaluate(() => state.snapshot.error), undefined); await expectGraph(roots[1], '3.0.0'); + await cli(['update', 'hosted-compatible-20260922', '--registry', registry, '--path', roots[2]]); await expectGraph(roots[2], '3.0.0'); + checks.push('browser and headless update to highest compatible stable; stale graph labels removed'); + // Exact URL and file install remain independent of automatic registry selection. + await cli(['install', registry + '/api/download/hosted-compatible-20260922/1.9.0', '--path', roots[2]]); await expectGraph(roots[2], '1.9.0'); + const file = path.join(lab, 'pinned.cgext'); fs.writeFileSync(file, pinned.bytes); + await cli(['install', file, '--path', roots[2]]); await expectGraph(roots[2], '1.10.0'); + await publish('1.10.0+fixture.1'); + await cli(['install', 'hosted-compatible-20260922', '--registry', registry, '--version', '1.10.0+fixture.1', '--path', roots[2]]); + await expectGraph(roots[2], '1.10.0+fixture.1'); + checks.push('exact artifact URL/file semantics and build-metadata pin identity preserved'); + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator('.compatibility').filter({ hasText: 'Selected 3.0.0' }).waitFor(); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + await page.screenshot({ path: path.join(out, 'mobile-selected.png'), fullPage: true }); + await page.getByRole('button', { name: 'Remove', exact: true }).click(); + await page.waitForFunction(() => state.snapshot?.jobId === 5 && !state.snapshot.busy); + await cli(['remove', 'hosted-compatible-20260922', '--path', roots[2]]); + assert.deepEqual(await graph(roots[1]), []); assert.deepEqual(await graph(roots[2]), []); + assert.deepEqual(errors, []); checks.push('removal cleans contributions; desktop/mobile display actual destination compatibility; no page errors'); + fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ registry, checks, releases, commands, engineVersion: require('../../package.json').version, platform: process.platform, browser: browser.version() }, null, 2)); + console.log(JSON.stringify({ checks, commands: commands.length })); + } catch (error) { + await page?.screenshot({ path: path.join(out, 'failure.png'), fullPage: true }).catch(() => {}); + fs.writeFileSync(path.join(out, 'failure.json'), JSON.stringify({ checks, error: String(error.stack).replace(/token=[a-f0-9]+/g, 'token=[redacted]') }, null, 2)); throw error; + } finally { await browser?.close(); await https?.close(); await bridge?.close(); await server?.close(); fs.rmSync(lab, { recursive: true, force: true }); } +})().catch(error => { console.error(String(error.stack).replace(/token=[a-f0-9]+/g, 'token=[redacted]')); if(error.cause) console.error(error.cause); process.exitCode = 1; }); diff --git a/scripts/validation/cloudflare-hosted-drupal.cjs b/scripts/validation/cloudflare-hosted-drupal.cjs new file mode 100644 index 000000000..66818a7dd --- /dev/null +++ b/scripts/validation/cloudflare-hosted-drupal.cjs @@ -0,0 +1,36 @@ +// Actual deployed Worker HTTPS -> browser -> compiled installer -> pinned Drupal corpus. +// Uses a previously reviewed/imported immutable artifact. Never mutates remote storage. +const fs=require('node:fs'),path=require('node:path'),os=require('node:os'),assert=require('node:assert/strict'),cp=require('node:child_process'),crypto=require('node:crypto'); +const {DatabaseSync}=require('node:sqlite'); +const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); +const {CodeGraph}=require('../../dist'); +const {ExtensionManager}=require('../../dist/plugins/manager'); +const {startExtensionBridge}=require('../../dist/plugins/bridge'); +Object.assign(process.env,{CODEGRAPH_TELEMETRY:'0',CODEGRAPH_PARSE_WORKERS:'2',CODEGRAPH_RESOLVE_WORKERS:'0'}); +const root=path.resolve(__dirname,'../..'),out=path.resolve(process.env.OFFICIAL_BROWSER_OUTPUT||'.qa/hosted/drupal-browser');fs.mkdirSync(out,{recursive:true}); +const lab=fs.mkdtempSync(path.join(os.tmpdir(),'cg-official-browser-')),corpus=path.resolve(process.env.PATHAUTO_CORPUS||'../validation/pathauto'),pin='b97aadf47a37cff25f7d6105c3dade6778fccb47'; +const project=path.join(lab,'pathauto'),other=path.join(lab,'untouched'),state=path.join(lab,'registry'),artifact=path.join(root,'dist/extensions/drupal.cgext'),bytes=fs.readFileSync(artifact),checks=[],commands=[]; +fs.mkdirSync(project);fs.mkdirSync(other);fs.writeFileSync(path.join(other,'example.php'),'crypto.createHash('sha256').update(b).digest('hex'); +function snapshot(){const db=new DatabaseSync(path.join(project,'.codegraph/codegraph.db'),{readOnly:true});try{const nodes=db.prepare('SELECT * FROM nodes ORDER BY id').all().map(({updated_at,...n})=>n),edges=db.prepare('SELECT source,target,kind,metadata,line,col,provenance FROM edges ORDER BY source,target,kind,line,col,metadata').all();return {nodes,edges,pluginEdges:edges.filter(e=>JSON.parse(e.metadata||'{}').synthesizedBy==='drupal'),hash:hash(JSON.stringify({nodes,edges})),config:fs.existsSync(path.join(project,'codegraph.json'))?fs.readFileSync(path.join(project,'codegraph.json'),'utf8'):null};}finally{db.close();}} +function verify(data){const definitions=[['/admin/config/search/path/settings','pathauto.routing.yml','buildForm','src/Form/PathautoSettingsForm.php','Drupal route handler'],['/admin/config/search/path/update_bulk','pathauto.routing.yml','buildForm','src/Form/PathautoBulkUpdateForm.php','Drupal route handler'],['getPunctuationCharacters','src/AliasCleaner.php','pathautoPunctuationCharsAlter','tests/modules/pathauto_custom_punctuation_test/src/Hook/PathautoCustomPunctuationTestHooks.php','Drupal hook pathauto_punctuation_chars_alter'],['PathautoGenerator','src/PathautoGenerator.php','AliasCleaner','src/AliasCleaner.php','Drupal service injection']];for(const [from,file,to,target,label]of definitions){const sources=new Set(data.nodes.filter(n=>n.name===from&&n.file_path===file).map(n=>n.id)),targets=new Set(data.nodes.filter(n=>n.name===to&&n.file_path===target).map(n=>n.id));assert.ok(data.pluginEdges.some(e=>sources.has(e.source)&&targets.has(e.target)&&JSON.parse(e.metadata).label===label),'Missing '+label+': '+from);}return definitions;} +function pass(name){checks.push(name);fs.writeFileSync(path.join(out,'progress.json'),JSON.stringify({checks,commands},null,2));console.log('PASS',name);} +async function cli(args){const command=[path.join(root,'marketplace/cloudflare/official-cli.mjs'),...args];const r=await new Promise((resolve,reject)=>{const c=cp.spawn(process.execPath,command,{timeout:60000});let log='';c.stdout.on('data',b=>log+=b);c.stderr.on('data',b=>log+=b);c.on('error',reject);c.on('close',exit=>resolve({exit,log}));});const log='operator-'+(commands.length+1)+'.log';fs.writeFileSync(path.join(out,log),r.log);commands.push({command:[process.execPath,...command],exit:r.exit,log,sha256:hash(r.log)});assert.equal(r.exit,0,r.log);return r.log;} +let server,bridge,browser,page; +(async()=>{try{ + const origin=process.env.HOSTED_REGISTRY;assert.ok(/^https:\/\/[-a-z0-9.]+\.workers\.dev$/.test(origin),'Explicit workers.dev origin required'); + const catalog=await(await fetch(origin+'/api/extensions')).json(),listing=catalog.find(x=>x.id==='drupal');assert.equal(listing.official,true);assert.equal(listing.publisherId,'codegraph');assert.equal(listing.integrity,hash(bytes));const downloaded=Buffer.from(await(await fetch(origin+'/api/download/drupal/'+extensionVersion)).arrayBuffer());assert.deepEqual(downloaded,bytes);pass('actual hosted catalog and immutable Drupal bytes match reviewed official policy'); + const g=await CodeGraph.init(project);try{const r=await g.indexAll();assert.equal(r.filesErrored,0);}finally{g.close();}assert.equal(snapshot().pluginEdges.length,0); + bridge=await startExtensionBridge([other,project],origin);browser=await chromium.launch({executablePath:process.env.CHROME_PATH||'/opt/google/chrome/chrome',headless:true,args:['--no-sandbox']});page=await browser.newPage({viewport:{width:1440,height:1000}});page.setDefaultTimeout(60000);let popups=0;page.on('popup',()=>popups++);const pageErrors=[];page.on('pageerror',e=>pageErrors.push(e.message)); + await page.goto(bridge.connectionUrl);assert.equal(await page.evaluate(()=>isSecureContext),true);await page.getByRole('button',{name:'Official',exact:true}).click();await page.locator('.extension-card .official').waitFor();assert.equal(await page.locator('.extension-card').count(),1);assert.match(await page.locator('.extension-card .byline').innerText(),/CodeGraph.*OFFICIAL/s);pass('official catalog filter displays trusted CodeGraph Drupal identity'); + const popupReady=page.waitForEvent('popup');await page.locator('#connect').click();const popup=await popupReady;await popup.getByRole('button',{name:'Allow connection'}).click();await page.getByText('CodeGraph connected',{exact:true}).waitFor();await page.locator('#project').selectOption('1');await page.getByRole('link',{name:'Drupal',exact:true}).click();await page.locator('.compatibility').filter({hasText:'Selected '+extensionVersion}).waitFor();assert.equal(await page.locator('.metadata dd.integrity').first().innerText(),'codegraph');await page.getByRole('button',{name:'Install extension'}).click();await page.waitForFunction(()=>state.snapshot?.jobId===1&&!state.snapshot.busy);assert.equal(await page.evaluate(()=>state.snapshot.error),undefined);assert.equal(popups,1);const installed=snapshot(),flows=verify(installed);assert.ok(!CodeGraph.isInitialized(other));pass('one connection and one Install refresh only selected pinned Drupal project; four real route hook and DI edges verified'); + fs.writeFileSync(path.join(out,'graph-proof.json'),JSON.stringify({corpus:'pathauto',revision:pin,artifactSHA:hash(bytes),nodes:installed.nodes.length,edges:installed.edges.length,pluginEdges:installed.pluginEdges.length,hash:installed.hash,expected:flows},null,2));await page.screenshot({path:path.join(out,'desktop-installed.png'),fullPage:true}); + const manager=new ExtensionManager(project),realFetch=global.fetch;let tampered=false;global.fetch=async(...args)=>{const r=await realFetch(...args);if(String(args[0]).includes('/api/download/drupal/')){tampered=true;return new Response(Buffer.concat([Buffer.from(await r.arrayBuffer()),Buffer.from(' ')]),{status:r.status,headers:r.headers});}return r;};try{await assert.rejects(()=>manager.installFromRegistry({registry:origin,id:'drupal',version:extensionVersion,replaces:['drupal']}),/integrity/);}finally{global.fetch=realFetch;}assert.equal(tampered,true);assert.equal(snapshot().hash,installed.hash);assert.equal(snapshot().config,installed.config);pass('test-owned transport corruption after real HTTPS download is rejected; hosted immutable object untouched'); + const incompatible=JSON.parse(bytes);incompatible.package.codegraph.engines='>=9';await assert.rejects(()=>manager.install({bytes:Buffer.from(JSON.stringify(incompatible)),replaces:['drupal']}),/requires/);await assert.rejects(()=>manager.install({bytes:Buffer.from('{}'),replaces:['drupal']}));assert.equal(snapshot().hash,installed.hash);assert.equal(snapshot().config,installed.config);pass('incompatible and malformed installs preserve previously working Drupal graph'); + await page.getByRole('button',{name:'Disable',exact:true}).click();await page.getByRole('button',{name:'Enable',exact:true}).waitFor();assert.equal(snapshot().pluginEdges.length,0);pass('browser disable removes actual extension contributions'); + await page.getByRole('button',{name:'Enable',exact:true}).click();await page.getByRole('button',{name:'Disable',exact:true}).waitFor();verify(snapshot());await page.setViewportSize({width:390,height:844});await page.screenshot({path:path.join(out,'mobile-official.png'),fullPage:true});pass('browser enable restores expected graph and mobile official details'); + await page.getByRole('button',{name:'Remove',exact:true}).click();await page.getByText('Extension removed and graph refreshed',{exact:true}).first().waitFor();assert.equal(snapshot().pluginEdges.length,0);assert.equal(new ExtensionManager(project).list().length,0);assert.ok(!CodeGraph.isInitialized(other));assert.deepEqual(pageErrors,[]);pass('browser removal clears extension config and SQLite contributions; no page errors'); + fs.writeFileSync(path.join(out,'result.json'),JSON.stringify({revision:cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(),dirty:!!cp.execFileSync('git',['status','--porcelain'],{encoding:'utf8'}).trim(),platform:process.platform,origin,browser:browser.version(),corpus:{name:'pathauto',revision:pin},checks,commands,success:true},null,2)); +}catch(error){if(page)await page.screenshot({path:path.join(out,'failure.png'),fullPage:true}).catch(()=>{});fs.writeFileSync(path.join(out,'failure.json'),JSON.stringify({checks,commands,error:String(error.stack)},null,2));throw error;}finally{await browser?.close();await bridge?.close();await server?.close();fs.rmSync(lab,{recursive:true,force:true});}})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/scripts/validation/cloudflare-hosted-packages.cjs b/scripts/validation/cloudflare-hosted-packages.cjs new file mode 100644 index 000000000..3ce35edb0 --- /dev/null +++ b/scripts/validation/cloudflare-hosted-packages.cjs @@ -0,0 +1,28 @@ +// Controlled writes only to a dedicated preview with a bounded publication window. +const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +const {generateKeyPairSync,sign,randomUUID,createHash}=require('node:crypto'); +const origin=process.env.HOSTED_REGISTRY,out=path.resolve(process.env.HOSTED_PACKAGE_OUTPUT||'.qa/hosted/packages'); +assert.match(origin||'',/^https:\/\/[a-z0-9.-]+\.workers\.dev$/);fs.mkdirSync(out,{recursive:true}); +const suffix=Date.now().toString(36),keys=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),checks=[],requests=[]; +const hash=b=>createHash('sha256').update(b).digest('hex'); +function artifact(id,version='1.0.0',size){const p={format:'codegraph-extension-1',package:{name:'@preview/'+id,version,main:'index.cjs',codegraph:{id,apiVersion:1,capabilities:['frameworks']}},files:{'index.cjs':'module.exports=()=>({frameworks:[]});'}};if(size){p.files['padding.txt']='';p.files['padding.txt']='x'.repeat(size-Buffer.byteLength(JSON.stringify(p)));}return Buffer.from(JSON.stringify(p));} +function signed(bytes,key=keys,extra={}){const payload=JSON.stringify({name:'Hosted acceptance fixture',description:'Disposable preview contract check',publisher:'Preview validation',source:'https://github.com/colbymchenry/codegraph',readme:'Test fixture. Not a recommended extension.',artifact:bytes.toString('base64'),timestamp:Date.now(),nonce:randomUUID(),...extra});return{payload,publicKey:key.publicKey.export({format:'jwk'}),signature:sign('sha256',Buffer.from(payload),{key:key.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64')};} +let last=0; +async function post(body,expected,headers={}){await new Promise(r=>setTimeout(r,Math.max(0,last+7500-Date.now())));last=Date.now();const start=new Date().toISOString(),t=performance.now(),r=await fetch(origin+'/api/publish',{method:'POST',headers,body:JSON.stringify(body)}),data=await r.json();requests.push({start,path:'/api/publish',status:r.status,wallMs:performance.now()-t,expected,result:data});fs.writeFileSync(path.join(out,'progress.json'),JSON.stringify({origin,checks,requests},null,2));assert.equal(r.status,expected,JSON.stringify(data));return data;} +const passed=x=>{checks.push(x);console.log('PASS',x);}; +(async()=>{ + assert.equal((await(await fetch(origin+'/api/health')).json()).publishing,true); + const id='hosted-max-'+suffix,large=artifact(id,'1.0.0',8*1024*1024);assert.equal(large.length,8*1024*1024);const published=await post(signed(large),201);assert.equal(published.integrity,hash(large)); + for(let i=0;i<3;i++){const start=new Date().toISOString(),t=performance.now(),r=await fetch(origin+'/api/download/'+id+'/1.0.0?sample='+i),bytes=Buffer.from(await r.arrayBuffer());assert.equal(r.status,200);assert.equal(bytes.length,large.length);assert.equal(hash(bytes),hash(large));requests.push({start,path:'/api/download/'+id+'/1.0.0?sample='+i,status:r.status,wallMs:performance.now()-t,bytes:bytes.length,sha256:hash(bytes),cacheControl:r.headers.get('cache-control')});}passed('full 8 MiB signed upload and three byte-identical provider downloads'); + await post(signed(Buffer.concat([large,Buffer.from(' ')])),400);passed('8 MiB plus one byte rejected by trusted package validator'); + await post(signed(artifact('spoof-'+suffix),keys,{official:true,publisherId:'codegraph'}),400);passed('signed community official/publisher spoof rejected'); + await post({...signed(artifact('signature-'+suffix)),signature:'AAAA'},400);await post(signed(Buffer.from('{}')),400);passed('invalid signature and malformed package rejected'); + await post(signed(artifact(id,'2.0.0'),generateKeyPairSync('ec',{namedCurve:'prime256v1'})),400);await post(signed(artifact(id,'1.0.0')),400);passed('ownership and immutable version reject without replacing bytes'); + await post(signed(artifact('origin-'+suffix)),403,{Origin:'https://attacker.example'});passed('foreign browser origin rejected by provider'); + // Simultaneous first claims through independent requests, without spoofing Cloudflare IP headers. + await new Promise(r=>setTimeout(r,6500)); + const race='race-'+suffix,one=signed(artifact(race)),two=signed(artifact(race),generateKeyPairSync('ec',{namedCurve:'prime256v1'})); + const rr=await Promise.all([one,two].map(async body=>{const start=new Date().toISOString(),r=await fetch(origin+'/api/publish',{method:'POST',body:JSON.stringify(body)}),data=await r.json();requests.push({start,path:'/api/publish',status:r.status,result:data});return r.status;}));assert.deepEqual(rr.sort(),[201,400]);passed('actual concurrent provider ownership/version claim has exactly one winner'); + const release=await(await fetch(origin+'/api/extensions/'+id)).json();assert.equal(release.length,1);assert.equal(release[0].integrity,hash(large));assert.equal(hash(Buffer.from(await(await fetch(origin+'/api/download/'+id+'/1.0.0')).arrayBuffer())),hash(large));passed('all rejected requests retain original catalog and bytes'); + fs.writeFileSync(path.join(out,'result.json'),JSON.stringify({origin,checks,requests,large:{id,bytes:large.length,sha256:hash(large)},success:true},null,2)); +})().catch(e=>{fs.writeFileSync(path.join(out,'failure.json'),JSON.stringify({origin,checks,requests,error:String(e.stack)},null,2));console.error(e);process.exitCode=1;});