OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / scripts / generate-origin-isolation-smoke-plan.mjs

346 lines · javascript ref:
#!/usr/bin/env node

import { readFileSync, writeFileSync } from 'node:fs'

const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
const FEATURE_FLAG = 'PEARBROWSER_PER_DRIVE_ORIGINS=1'
const PROOF_KEY = 'pear-origin-isolation-proof'

const args = parseArgs(process.argv.slice(2))
const appA = parseHyperApp(args.appA, '--app-a')
const appB = parseHyperApp(args.appB, '--app-b')

if (appA.driveKey === appB.driveKey) {
  usage(2, '--app-a and --app-b must use different drive keys')
}

const report = buildPlan({
  appA: { ...appA, label: args.labelA || 'App A' },
  appB: { ...appB, label: args.labelB || 'App B' },
  runtimeCommand: args.runtimeCommand || `${FEATURE_FLAG} npm start`,
  sourcePlan: args.out || ''
})

if (args.out) writeFileSync(args.out, JSON.stringify(report, null, 2) + '\n')
if (args.json) console.log(JSON.stringify(report, null, 2))
else printMarkdown(report)

function parseArgs (argv) {
  const parsed = {
    appA: '',
    appB: '',
    labelA: '',
    labelB: '',
    runtimeCommand: '',
    out: '',
    json: false
  }

  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === '--app-a') parsed.appA = requireValue(argv, ++i, arg)
    else if (arg === '--app-b') parsed.appB = requireValue(argv, ++i, arg)
    else if (arg === '--label-a') parsed.labelA = requireValue(argv, ++i, arg)
    else if (arg === '--label-b') parsed.labelB = requireValue(argv, ++i, arg)
    else if (arg === '--runtime-command') parsed.runtimeCommand = requireValue(argv, ++i, arg)
    else if (arg === '--out') parsed.out = requireValue(argv, ++i, arg)
    else if (arg === '--json') parsed.json = true
    else if (arg === '-h' || arg === '--help') usage(0)
    else usage(2, `unknown option: ${arg}`)
  }

  if (!parsed.appA) usage(2, '--app-a is required')
  if (!parsed.appB) usage(2, '--app-b is required')
  return parsed
}

function requireValue (argv, index, flag) {
  const value = argv[index] || ''
  if (!value || value.startsWith('--')) usage(2, `${flag} requires a value`)
  return value
}

function usage (code, message = '') {
  if (message) console.error(`error: ${message}`)
  console.error('usage: node scripts/generate-origin-isolation-smoke-plan.mjs --app-a hyper://<64-hex>/ --app-b hyper://<64-hex>/ [--label-a name] [--label-b name] [--runtime-command command] [--out report.json] [--json]')
  process.exit(code)
}

function parseHyperApp (value, label) {
  const url = String(value || '').trim()
  const match = url.match(/^hyper:\/\/([0-9a-f]{64})(?:\/.*)?$/i)
  if (!match) usage(2, `${label} must be a hyper:// URL with a 64-hex drive key`)
  return {
    url,
    driveKey: match[1].toLowerCase()
  }
}

function buildPlan ({ appA, appB, runtimeCommand, sourcePlan = '' }) {
  const writeSnippet = storageWriteSnippet(appA.label)
  const readSnippet = storageReadSnippet()
  const verifierArgs = sourcePlan
    ? `--plan ${shellQuote(sourcePlan)}`
    : `--app-a ${shellQuote(appA.url)} --app-b ${shellQuote(appB.url)} --label-a ${shellQuote(appA.label)} --label-b ${shellQuote(appB.label)}`
  const nodeVerifierCommand = `node scripts/generate-origin-isolation-smoke-evidence.mjs ${verifierArgs} --out origin-isolation-smoke-evidence.json --json`
  const npmVerifierCommand = `npm run -s generate:origin-isolation-smoke-evidence -- ${verifierArgs} --out origin-isolation-smoke-evidence.json --json`

  return {
    ok: true,
    schemaVersion: 1,
    kind: 'pearbrowser-origin-isolation-smoke-plan',
    generatedAt: new Date().toISOString(),
    package: {
      name: pkg.name,
      version: pkg.version
    },
    featureFlag: FEATURE_FLAG,
    proofKey: PROOF_KEY,
    apps: [appA, appB],
    commands: [
      {
        id: 'launch-feature-flagged-desktop',
        command: runtimeCommand,
        evidence: `PearBrowser is launched with ${FEATURE_FLAG}.`
      },
      {
        id: 'runtime-readiness',
        command: 'node scripts/runtime-rpc-smoke.mjs --timeout 20000 --max-storage-percent 100 --json',
        evidence: 'Runtime smoke JSON shows backend readiness, proxy port, and relay status.'
      },
      {
        id: 'nonvisual-app-a-navigation',
        command: `node scripts/release-rpc-story-smoke.mjs --homepage-url ${shellQuote(appA.url)} --json`,
        evidence: `${appA.label} navigates through the production RPC/proxy path without approving a third-party trust prompt.`
      },
      {
        id: 'automated-origin-isolation-evidence',
        command: nodeVerifierCommand,
        evidence: 'Automated verifier emits a pearbrowser-origin-isolation-smoke-evidence artifact that passes check:origin-isolation-smoke-evidence.'
      }
    ],
    automatedVerifier: {
      command: npmVerifierCommand,
      validatesWith: 'npm run check:origin-isolation-smoke-evidence -- --file origin-isolation-smoke-evidence.json --json',
      notes: [
        'Runs a local HyperProxy/HttpBridge harness with per-drive origins enabled.',
        'Uses the plan apps as the two drive identities and exercises origin split, strict-CSP injection, storage isolation, listener release, and bridge routes.'
      ]
    },
    manualSteps: [
      {
        id: 'open-both-apps',
        action: `Open ${appA.label} (${appA.url}) and ${appB.label} (${appB.url}) in separate PearBrowser tabs while the feature flag is enabled.`,
        evidence: 'Screenshots or console output show both tabs loaded.'
      },
      {
        id: 'origin-split',
        action: 'In each tab, record `location.origin` from DevTools.',
        evidence: 'The two origins are different loopback origins, normally different `127.0.0.1:<port>` values.'
      },
      {
        id: 'storage-write-a',
        action: `Run the write snippet in ${appA.label}.`,
        evidence: `${appA.label} reports its origin and writes localStorage, cookie, and IndexedDB proof values.`
      },
      {
        id: 'storage-read-b',
        action: `Run the read snippet in ${appB.label}.`,
        evidence: `${appB.label} reports a different origin and reads no ${PROOF_KEY} value from localStorage, cookie, or IndexedDB.`
      },
      {
        id: 'strict-csp-real-app',
        action: 'Load at least one strict-CSP real app under the feature flag.',
        evidence: 'The app loads its assets and Pear bridge without CSP console failures caused by per-drive origin mode.'
      },
      {
        id: 'tab-lifecycle',
        action: `Close ${appA.label}, or navigate its tab to ${appB.label}, then keep ${appB.label} open.`,
        evidence: 'PearBrowser stays responsive and subsequent app navigation still works, confirming the release path did not break tab-origin lifecycle behavior.'
      },
      {
        id: 'real-app-bridge',
        action: 'Run a real app bridge flow such as Peerit identity/sync under the feature flag.',
        evidence: '`/api/identity`, `/api/sync/*`, `/api/swarm/ticket`, and `/api/swarm/events?ticket=` continue to work from the per-drive origin.'
      }
    ],
    snippets: {
      writeStorageInAppA: writeSnippet,
      readStorageInAppB: readSnippet
    },
    evidenceTemplate: buildEvidenceTemplate({ appA, appB, automatedVerifierCommand: npmVerifierCommand }),
    acceptance: [
      'Feature flag is enabled for the launched desktop process.',
      'Two app tabs report different `location.origin` values.',
      'Data written in App A localStorage, cookie, and IndexedDB is not visible in App B.',
      'Strict-CSP real app compatibility is recorded.',
      'Tab close/navigation-away does not leave the browser in a broken listener state.',
      'At least one real app bridge flow still passes under the feature flag.'
    ],
    remainingIfPassed: [
      'Decide LRU/default-on listener policy.',
      'Record the smoke evidence in the release evidence log before flipping the feature flag default-on.'
    ]
  }
}

function buildEvidenceTemplate ({ appA, appB, automatedVerifierCommand }) {
  return {
    schemaVersion: 1,
    kind: 'pearbrowser-origin-isolation-smoke-evidence',
    featureFlag: FEATURE_FLAG,
    proofKey: PROOF_KEY,
    apps: [
      {
        label: appA.label,
        url: appA.url,
        driveKey: appA.driveKey,
        origin: '<record location.origin from app A>'
      },
      {
        label: appB.label,
        url: appB.url,
        driveKey: appB.driveKey,
        origin: '<record location.origin from app B>'
      }
    ],
    storage: {
      proofKey: PROOF_KEY,
      writtenValue: '<value returned by the app A write snippet>',
      appA: {
        localStorage: '<app A localStorage value>',
        cookie: '<app A document.cookie>',
        indexedDB: '<app A IndexedDB value>'
      },
      appB: {
        localStorage: null,
        cookie: '<app B document.cookie>',
        indexedDB: null
      }
    },
    strictCsp: {
      status: '<PASS>',
      evidence: '<screenshot/log path>'
    },
    tabLifecycle: {
      status: '<PASS>',
      evidence: '<screenshot/log path>'
    },
    realAppBridge: {
      status: '<PASS>',
      evidence: '<Peerit or real-app bridge log path>',
      routes: {
        identity: true,
        sync: true,
        swarmTicket: true,
        swarmEvents: true
      }
    },
    artifacts: [
      '<screenshot-or-log-path>'
    ],
    automatedVerifier: {
      command: automatedVerifierCommand,
      validatesWith: 'npm run check:origin-isolation-smoke-evidence -- --file origin-isolation-smoke-evidence.json --json'
    }
  }
}

function storageWriteSnippet (label) {
  const value = `${safeIdentifier(label)}-${Date.now()}`
  return [
    '(async () => {',
    `  const key = ${JSON.stringify(PROOF_KEY)}`,
    `  const value = ${JSON.stringify(value)}`,
    '  localStorage.setItem(key, value)',
    "  document.cookie = key + '=' + value + '; SameSite=Lax'",
    '  await new Promise((resolve, reject) => {',
    '    const request = indexedDB.open(key, 1)',
    "    request.onupgradeneeded = () => request.result.createObjectStore('proof')",
    '    request.onerror = () => reject(request.error)',
    '    request.onsuccess = () => {',
    '      const db = request.result',
    "      const tx = db.transaction('proof', 'readwrite')",
    "      tx.objectStore('proof').put(value, 'value')",
    '      tx.oncomplete = () => { db.close(); resolve() }',
    '      tx.onerror = () => reject(tx.error)',
    '    }',
    '  })',
    '  return { origin: location.origin, value, localStorage: localStorage.getItem(key), cookie: document.cookie }',
    '})()'
  ].join('\n')
}

function storageReadSnippet () {
  return [
    '(async () => {',
    `  const key = ${JSON.stringify(PROOF_KEY)}`,
    '  const indexedValue = await new Promise((resolve) => {',
    '    const request = indexedDB.open(key)',
    '    request.onerror = () => resolve(null)',
    '    request.onupgradeneeded = () => { request.transaction.abort(); resolve(null) }',
    '    request.onsuccess = () => {',
    '      const db = request.result',
    "      if (!db.objectStoreNames.contains('proof')) { db.close(); resolve(null); return }",
    "      const tx = db.transaction('proof', 'readonly')",
    "      const get = tx.objectStore('proof').get('value')",
    '      get.onsuccess = () => { db.close(); resolve(get.result || null) }',
    '      get.onerror = () => { db.close(); resolve(null) }',
    '    }',
    '  })',
    '  return { origin: location.origin, localStorage: localStorage.getItem(key), cookie: document.cookie, indexedDB: indexedValue }',
    '})()'
  ].join('\n')
}

function safeIdentifier (value) {
  return String(value || 'app').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'app'
}

function shellQuote (value) {
  return `'${String(value).replaceAll("'", "'\\''")}'`
}

function printMarkdown (report) {
  console.log('# PearBrowser Origin Isolation Smoke Plan\n')
  console.log(`Package: \`${report.package.name}@${report.package.version}\``)
  console.log(`Feature flag: \`${report.featureFlag}\`\n`)

  console.log('## Apps\n')
  for (const app of report.apps) {
    console.log(`- ${app.label}: \`${app.url}\` (${app.driveKey})`)
  }

  console.log('\n## Commands\n')
  for (const item of report.commands) {
    console.log(`### ${item.id}`)
    console.log('```sh')
    console.log(item.command)
    console.log('```')
    console.log(`${item.evidence}\n`)
  }

  console.log('## Manual Steps\n')
  for (const step of report.manualSteps) {
    console.log(`- ${step.id}: ${step.action} Evidence: ${step.evidence}`)
  }

  console.log('\n## App A Write Snippet\n')
  console.log('```js')
  console.log(report.snippets.writeStorageInAppA)
  console.log('```')

  console.log('\n## App B Read Snippet\n')
  console.log('```js')
  console.log(report.snippets.readStorageInAppB)
  console.log('```')

  console.log('\n## Evidence Template\n')
  console.log('```json')
  console.log(JSON.stringify(report.evidenceTemplate, null, 2))
  console.log('```')

  console.log('\n## Acceptance\n')
  for (const item of report.acceptance) console.log(`- ${item}`)
}