diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml
index e83f10bae..486c1be92 100644
--- a/.github/workflows/desktop-release-guard.yml
+++ b/.github/workflows/desktop-release-guard.yml
@@ -41,10 +41,37 @@ jobs:
node -e 'if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(process.argv[1])) process.exit(1)' "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
+ renderer-axe-boundary:
+ name: Prove stock Chromium existing-target axe boundary
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ steps:
+ - name: Checkout pull-request validation source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+
+ - name: Set up Node.js
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version-file: '.nvmrc'
+ cache: npm
+ cache-dependency-path: package-lock.json
+
+ - name: Install locked dependencies and real Chromium
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium
+
+ - name: Run focused stock Chromium existing-target axe regressions
+ # This required predecessor step exclusively owns the real-browser opt-in.
+ env:
+ PROPR_DESKTOP_REAL_CHROMIUM_BOUNDARY: '1'
+ run: node --test --test-name-pattern='packaged acceptance renderer variants' apps/desktop/scripts/packaged-acceptance-renderer.test.mjs
+
package:
name: Validate unsigned ${{ matrix.platform }}-${{ matrix.arch }} package
if: github.event_name == 'pull_request'
- needs: validation-version
+ needs: [validation-version, renderer-axe-boundary]
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
@@ -142,7 +169,7 @@ jobs:
if: matrix.platform == 'linux'
run: |
sudo apt-get update
- sudo apt-get install --yes cpio dbus-x11 fakeroot gnome-keyring libsecret-1-0 rpm zip
+ sudo apt-get install --yes cpio dbus-x11 fakeroot fonts-liberation gnome-keyring libsecret-1-0 rpm tesseract-ocr zip
- name: Package desktop app from clean checkout
shell: bash
@@ -207,6 +234,31 @@ jobs:
xvfb-run --auto-servernum npm run desktop:smoke
' bash "$keyring_root"
+ - name: Capture packaged Linux x64 visual and accessibility acceptance evidence
+ if: matrix.platform == 'linux' && matrix.arch == 'x64'
+ shell: bash
+ env:
+ PROPR_DESKTOP_ACCEPTANCE_OUTPUT: ${{ github.workspace }}/desktop-acceptance-artifacts
+ run: |
+ keyring_root="$(mktemp -d)"
+ trap 'rm -rf -- "$keyring_root"' EXIT
+ dbus-run-session -- bash -euo pipefail -c '
+ export PROPR_DESKTOP_SMOKE_KEYRING_ROOT="$1"
+ export XDG_DATA_HOME="$1"
+ eval "$(printf "%s\n" "propr-packaged-acceptance" | gnome-keyring-daemon --unlock --components=secrets)"
+ xvfb-run --auto-servernum npm run desktop:acceptance
+ ' bash "$keyring_root"
+ npm run desktop:acceptance:verify
+
+ - name: Publish mandatory packaged desktop acceptance evidence
+ if: matrix.platform == 'linux' && matrix.arch == 'x64'
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: propr-desktop-acceptance-linux-x64-${{ github.run_id }}-${{ github.run_attempt }}
+ path: desktop-acceptance-artifacts
+ if-no-files-found: error
+ retention-days: 14
+
- name: Launch packaged Windows application and exercise MVP desktop flows
if: matrix.platform == 'win32'
shell: bash
@@ -474,7 +526,7 @@ jobs:
if: matrix.platform == 'linux'
run: |
sudo apt-get update
- sudo apt-get install --yes cpio dbus-x11 fakeroot gnome-keyring libsecret-1-0 rpm zip
+ sudo apt-get install --yes cpio dbus-x11 fakeroot fonts-liberation gnome-keyring libsecret-1-0 rpm tesseract-ocr zip
- name: Configure required macOS signing and notarization
if: matrix.platform == 'darwin'
@@ -653,6 +705,31 @@ jobs:
xvfb-run --auto-servernum npm run desktop:smoke
' bash "$keyring_root"
+ - name: Capture signed packaged Linux x64 visual and accessibility acceptance evidence
+ if: matrix.platform == 'linux' && matrix.arch == 'x64'
+ shell: bash
+ env:
+ PROPR_DESKTOP_ACCEPTANCE_OUTPUT: ${{ github.workspace }}/desktop-acceptance-artifacts
+ run: |
+ keyring_root="$(mktemp -d)"
+ trap 'rm -rf -- "$keyring_root"' EXIT
+ dbus-run-session -- bash -euo pipefail -c '
+ export PROPR_DESKTOP_SMOKE_KEYRING_ROOT="$1"
+ export XDG_DATA_HOME="$1"
+ eval "$(printf "%s\n" "propr-packaged-acceptance" | gnome-keyring-daemon --unlock --components=secrets)"
+ xvfb-run --auto-servernum npm run desktop:acceptance
+ ' bash "$keyring_root"
+ npm run desktop:acceptance:verify
+
+ - name: Publish mandatory signed packaged desktop acceptance evidence
+ if: matrix.platform == 'linux' && matrix.arch == 'x64'
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: propr-desktop-production-acceptance-linux-x64-${{ github.run_id }}-${{ github.run_attempt }}
+ path: desktop-acceptance-artifacts
+ if-no-files-found: error
+ retention-days: 30
+
- name: Launch signed packaged Windows application and exercise MVP desktop flows
if: matrix.platform == 'win32'
shell: bash
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index c268125ec..421dc9f8a 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -13,6 +13,7 @@ npm run desktop:typecheck
npm run desktop:test
npm run desktop:package
npm run desktop:smoke # Run under xvfb-run on a headless Linux host.
+npm run desktop:acceptance # Linux x64 package; run under Xvfb in a D-Bus/keyring session.
npm run desktop:make
npm run desktop:audit
# On Linux hosts with the corresponding native packaging tools installed:
@@ -43,6 +44,30 @@ exit. `desktop:smoke:inspect` performs executable and fuse inspection without la
both Linux architectures under Xvfb, inspects macOS and Windows packages on their native runners, validates
DMG/ZIP/DEB/RPM/MSI packages, and validates configured OS signatures.
+## Packaged visual and accessibility acceptance
+
+Linux x64 is the canonical visual runtime. `desktop:acceptance` launches the real packaged executable with a fresh
+private Electron profile and drives it over Chromium's debugging protocol with Playwright. A separately authorized
+acceptance mode supplies deterministic local API, Socket.IO, browser-pairing, ProPR Connect, and local-setup fixtures
+while preserving the production main/preload/renderer boundary, renderer sandbox, context isolation, navigation
+policy, and credential service. It requires independent command-line and environment triggers, accepts only a
+packaged Linux binary, and refuses the default Electron profile.
+
+The mandatory artifact contains screenshots for first run, endpoint and Connect confirmation, pairing, local setup
+prerequisites/progress/recovery/completion, dashboard profile management, offline, revoked, and incompatible states.
+Every state is captured at standard, narrow, high-DPI, 200% zoom, and reduced-motion configurations. The manifest
+records fixed locale/time/theme inputs, dimensions, hashes, and native coverage. The 200% variant uses an
+acceptance-only, main-authorized preload bridge to Electron `webFrame` zoom and records its read-back alongside raw
+CDP/renderer viewports, independently measured geometry, DPR, and physical PNG dimensions. Accessibility evidence
+fails for any serious or critical axe finding or missing keyboard order, visible focus, dialog trap/restore,
+accessible name, or live announcement proof. Finalization rejects missing, duplicate, unexpected, incorrectly sized, or secret-bearing
+output. Sentinel coverage includes renderer DOM, process output, URLs, local/session storage, persisted profile/config
+data, screenshots and metadata, and every decompressed Playwright trace entry.
+
+The existing six-target package matrix is unchanged. Linux x64 produces the visual/accessibility runtime evidence.
+Linux arm64, macOS x64/arm64, and Windows x64/arm64 retain native package inspection and platform runtime smoke
+coverage; their acceptance classification is structural/runtime-only.
+
The first-release Windows MVP packages only the normal desktop application. Native self-update installation authority
is deferred to issue #2000: no broker, bootstrap, launcher, service, or authority custom action is built, copied into
`resources`, or installed by the MSI. Both Windows architectures remain mandatory release targets, and package/MSI
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 3e8032849..a2b16c0ca 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -23,6 +23,8 @@
"package": "electron-forge package",
"smoke:package": "node scripts/smoke-packaged.mjs",
"smoke:inspect": "node scripts/smoke-packaged.mjs --inspect-only",
+ "acceptance:package": "node scripts/run-packaged-acceptance.mjs",
+ "acceptance:verify": "node -e \"import('./scripts/acceptance-artifacts.mjs').then(m => m.verifyAcceptanceArtifacts(process.env.PROPR_DESKTOP_ACCEPTANCE_OUTPUT || m.defaultAcceptanceOutputDirectory()))\"",
"premake": "npm run prepare:renderer",
"make": "electron-forge make",
"make:dmg": "node scripts/make-dmg.mjs",
@@ -46,11 +48,13 @@
"@electron-forge/maker-zip": "8.0.0-alpha.10",
"@electron-forge/plugin-vite": "8.0.0-alpha.10",
"@electron-forge/shared-types": "8.0.0-alpha.10",
- "@electron/windows-sign": "2.0.6",
"@electron/fuses": "^2.1.3",
+ "@electron/windows-sign": "2.0.6",
"@types/node": "^22.10.0",
"@vitejs/plugin-react": "^4.6.0",
+ "axe-core": "4.10.3",
"electron": "^44.0.0",
+ "playwright": "^1.62.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.5"
diff --git a/apps/desktop/renderer.html b/apps/desktop/renderer.html
index 374512fdf..555db588a 100644
--- a/apps/desktop/renderer.html
+++ b/apps/desktop/renderer.html
@@ -4,7 +4,7 @@
diff --git a/apps/desktop/scripts/acceptance-artifacts.mjs b/apps/desktop/scripts/acceptance-artifacts.mjs
new file mode 100644
index 000000000..b6031363b
--- /dev/null
+++ b/apps/desktop/scripts/acceptance-artifacts.mjs
@@ -0,0 +1,679 @@
+import { createHash } from 'node:crypto';
+import { spawnSync } from 'node:child_process';
+import { lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { inflateRawSync } from 'node:zlib';
+import { PACKAGED_ACCEPTANCE_TIME } from './packaged-acceptance-clock.mjs';
+
+export const FIXED_TIME = PACKAGED_ACCEPTANCE_TIME;
+export const ACCEPTANCE_ARTIFACT_LEAF = 'desktop-acceptance-artifacts';
+export const ACCEPTANCE_PROFILE_PREFIX = 'propr-desktop-acceptance-';
+export const ACCEPTANCE_SURFACES_PREFIX = 'propr-acceptance-surfaces-';
+export const DESKTOP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+export const WORKSPACE_ROOT = resolve(DESKTOP_ROOT, '../..');
+
+export const ACCEPTANCE_JOURNEYS = Object.freeze([
+ 'first-run-chooser',
+ 'manual-endpoint-confirmation',
+ 'connect-confirmation',
+ 'remote-pairing',
+ 'local-setup-prerequisites',
+ 'local-setup-progress',
+ 'local-setup-error',
+ 'local-setup-completion',
+ 'dashboard-profile-manager',
+ 'offline',
+ 'revoked',
+ 'incompatible-instance',
+]);
+
+export const ACCEPTANCE_VARIANTS = Object.freeze({
+ standard: { viewport: { width: 1280, height: 820 }, deviceScaleFactor: 1, zoom: 1, reducedMotion: false },
+ narrow: { viewport: { width: 880, height: 620 }, deviceScaleFactor: 1, zoom: 1, reducedMotion: false },
+ 'high-dpi': { viewport: { width: 1280, height: 820 }, deviceScaleFactor: 2, zoom: 1, reducedMotion: false },
+ 'zoom-200': { viewport: { width: 1280, height: 820 }, deviceScaleFactor: 1, zoom: 2, reducedMotion: false },
+ 'reduced-motion': { viewport: { width: 1280, height: 820 }, deviceScaleFactor: 1, zoom: 1, reducedMotion: true },
+});
+
+export const FIXED_ACCEPTANCE_ORIGINS = Object.freeze({
+ // Canonical loopback aliases keep the fixture private while exercising the
+ // renderer's real remote-pairing path instead of its localhost setup lane.
+ ready: 'http://127.0.0.2:41731',
+ revoked: 'http://127.0.0.3:41732',
+ incompatible: 'http://127.0.0.4:41733',
+ offline: 'http://127.0.0.1:9',
+});
+
+export const DETERMINISTIC_INPUTS = Object.freeze({
+ originPolicy: 'fixed-loopback-fixtures',
+ origins: Object.values(FIXED_ACCEPTANCE_ORIGINS),
+ rendererClock: FIXED_TIME,
+ timestamps: FIXED_TIME,
+ visibleData: 'fixed-acceptance-fixtures-v1',
+ locale: 'en-US',
+ timezone: 'UTC',
+ font: 'Liberation Sans',
+ colorScheme: 'light',
+ viewports: 'manifest-per-screenshot',
+ scales: 'manifest-per-screenshot',
+ zoom: 'authenticated-electron-web-frame-per-screenshot',
+ reducedMotion: 'manifest-per-screenshot',
+ animations: 'disabled',
+ repeatability: 'byte-identical-double-capture',
+});
+
+export const defaultAcceptanceOutputDirectory = () => join(WORKSPACE_ROOT, ACCEPTANCE_ARTIFACT_LEAF);
+export const screenshotName = (journey, variant) => `${journey}--${variant}.png`;
+export const expectedScreenshotNames = () => ACCEPTANCE_JOURNEYS.flatMap(journey =>
+ Object.keys(ACCEPTANCE_VARIANTS).map(variant => screenshotName(journey, variant)));
+
+const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
+const canonicalRelative = (root, path) => relative(root, path).split(sep).join('/');
+const isSha256 = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
+const isInteger = value => Number.isInteger(value) && value >= 0;
+const MAX_VIEWPORT_INSET_CSS_PIXELS = 64;
+
+const assertExactKeys = (value, expected, description) => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${description} must be an object`);
+ const actual = Object.keys(value).sort();
+ const wanted = [...expected].sort();
+ if (actual.join('\n') !== wanted.join('\n')) throw new Error(`${description} schema changed`);
+};
+
+const assertExactJson = (actual, expected, description) => {
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${description} changed`);
+};
+
+const assertViewportMetricEvidence = (evidence, config, description) => {
+ const expectedEffective = {
+ width: config.viewport.width / config.zoom,
+ height: config.viewport.height / config.zoom,
+ };
+ const expectedPhysical = {
+ width: config.viewport.width * config.deviceScaleFactor,
+ height: config.viewport.height * config.deviceScaleFactor,
+ };
+ assertExactKeys(evidence.requestedViewport, ['width', 'height'], `${description} requested viewport`);
+ assertExactKeys(evidence.playwrightViewport, ['width', 'height'], `${description} Playwright viewport`);
+ assertExactKeys(evidence.rendererViewport, ['width', 'height'], `${description} renderer viewport`);
+ assertExactKeys(evidence.documentClientViewport, ['width', 'height'], `${description} document client viewport`);
+ assertExactKeys(evidence.scrollbarInsets, ['width', 'height'], `${description} scrollbar insets`);
+ assertExactKeys(evidence.visualViewportInsets, ['width', 'height'], `${description} visual viewport insets`);
+ assertExactKeys(evidence.layoutViewport, ['width', 'height'], `${description} layout viewport`);
+ assertExactKeys(evidence.cdpVisualViewport, ['width', 'height', 'scale'], `${description} CDP visual viewport`);
+ assertExactKeys(evidence.rendererVisualViewport, ['width', 'height', 'scale'], `${description} renderer visual viewport`);
+ assertExactKeys(evidence.effectiveVisibleCssSpan, ['width', 'height'], `${description} effective visible CSS span`);
+ assertExactKeys(evidence.geometryZoom, ['width', 'height'], `${description} renderer geometry zoom`);
+ assertExactKeys(evidence.physicalPngDimensions, ['width', 'height'], `${description} physical PNG dimensions`);
+ const scrollbarInsetsAreValid = ['width', 'height'].every(axis => (
+ Number.isFinite(evidence.scrollbarInsets[axis])
+ && evidence.scrollbarInsets[axis] >= 0
+ && evidence.scrollbarInsets[axis] <= MAX_VIEWPORT_INSET_CSS_PIXELS
+ && evidence.scrollbarInsets[axis]
+ === evidence.rendererViewport[axis] - evidence.documentClientViewport[axis]
+ ));
+ const visualViewportInsetsAreValid = ['width', 'height'].every(axis => (
+ Number.isFinite(evidence.visualViewportInsets[axis])
+ && evidence.visualViewportInsets[axis] >= 0
+ && evidence.visualViewportInsets[axis] <= MAX_VIEWPORT_INSET_CSS_PIXELS
+ && evidence.visualViewportInsets[axis]
+ === evidence.rendererViewport[axis] - evidence.rendererVisualViewport[axis]
+ ));
+ if (JSON.stringify(evidence.requestedViewport) !== JSON.stringify(config.viewport)
+ || JSON.stringify(evidence.playwrightViewport) !== JSON.stringify(config.viewport)
+ || JSON.stringify(evidence.rendererViewport) !== JSON.stringify(expectedEffective)
+ || !scrollbarInsetsAreValid
+ || !visualViewportInsetsAreValid
+ || JSON.stringify(evidence.layoutViewport) !== JSON.stringify(evidence.documentClientViewport)
+ || JSON.stringify(evidence.rendererVisualViewport) !== JSON.stringify(evidence.cdpVisualViewport)
+ || evidence.rendererVisualViewport.scale !== 1
+ || JSON.stringify(evidence.effectiveVisibleCssSpan) !== JSON.stringify(expectedEffective)
+ || evidence.geometryZoom.width !== config.zoom || evidence.geometryZoom.height !== config.zoom
+ || evidence.requestedDeviceScaleFactor !== config.deviceScaleFactor
+ || evidence.rendererDevicePixelRatio !== config.deviceScaleFactor * config.zoom
+ || evidence.requestedZoomFactor !== config.zoom || evidence.appliedZoomFactor !== config.zoom
+ || evidence.zoomResetFactor !== 1 || evidence.zoomMechanism !== 'electron-web-frame'
+ || JSON.stringify(evidence.physicalPngDimensions) !== JSON.stringify(expectedPhysical)) {
+ throw new Error(`${description} viewport metric evidence changed`);
+ }
+};
+
+export const readPngDimensions = bytes => {
+ if (bytes.length < 24 || !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
+ throw new Error('Acceptance screenshot is not a PNG');
+ }
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
+};
+
+const walkFiles = async root => {
+ const files = [];
+ const visit = async directory => {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const path = join(directory, entry.name);
+ const stats = await lstat(path);
+ if (stats.isSymbolicLink()) throw new Error(`Acceptance artifact contains a symbolic link: ${entry.name}`);
+ if (stats.isDirectory()) await visit(path);
+ else if (stats.isFile()) files.push(path);
+ else throw new Error(`Acceptance artifact is not a regular file: ${entry.name}`);
+ }
+ };
+ await visit(root);
+ return files.sort();
+};
+
+const genericSecretPatterns = [
+ /propr_it_[A-Za-z0-9_-]{20,}/g,
+ /gh[pousr]_[A-Za-z0-9_]{20,}/g,
+ /Bearer\s+[A-Za-z0-9._~-]{16,}/gi,
+ /(?:token|secret|password|private[_-]?key)\s*[=:]\s*[^\s"']{8,}/gi,
+];
+
+// Keep generic matches inside plaintext runs so binary bytes cannot join otherwise separate metadata.
+const printableTextRuns = function* (bytes) {
+ let start = 0;
+ for (let index = 0; index < bytes.length; index += 1) {
+ const byte = bytes[index];
+ if (byte === 0x09 || byte === 0x0a || byte === 0x0d || (byte >= 0x20 && byte <= 0x7e)) continue;
+ if (index > start) yield bytes.subarray(start, index).toString('ascii');
+ start = index + 1;
+ }
+ if (start < bytes.length) yield bytes.subarray(start).toString('ascii');
+};
+
+const scanBytes = (bytes, description, sentinels) => {
+ for (const sentinel of sentinels) {
+ if (sentinel && bytes.includes(Buffer.from(sentinel))) throw new Error(`Secret sentinel found in ${description}`);
+ }
+ for (const text of printableTextRuns(bytes)) {
+ for (const pattern of genericSecretPatterns) {
+ pattern.lastIndex = 0;
+ if (pattern.test(text)) throw new Error(`Secret-shaped value found in ${description}`);
+ }
+ }
+};
+
+const crc32 = bytes => {
+ let crc = 0xffffffff;
+ for (const byte of bytes) {
+ crc ^= byte;
+ for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+};
+
+const defaultOcr = async bytes => {
+ const result = spawnSync('tesseract', ['stdin', 'stdout', '--dpi', '96', '--psm', '11'], {
+ input: bytes,
+ encoding: 'utf8',
+ maxBuffer: 32 * 1024 * 1024,
+ });
+ if (result.error?.code === 'ENOENT') throw new Error('Acceptance screenshot OCR requires the tesseract executable');
+ if (result.error || result.status !== 0) {
+ throw new Error(`Acceptance screenshot OCR failed: ${result.error?.message || result.stderr || `status ${result.status}`}`);
+ }
+ return result.stdout;
+};
+
+const renderedImageExtension = description => /\.(?:png|jpe?g|webp)$/i.test(description);
+
+export const scanRenderedScreenshot = async (bytes, description, sentinels = [], ocr = defaultOcr) => {
+ const recognized = await ocr(bytes, description);
+ if (typeof recognized !== 'string') throw new Error('Acceptance screenshot OCR returned invalid data');
+ scanBytes(Buffer.from(recognized), `${description} (rendered pixels)`, sentinels);
+};
+
+const readZipEntries = bytes => {
+ const minimumEndOffset = Math.max(0, bytes.length - 65_557);
+ let endOffset = -1;
+ for (let offset = bytes.length - 22; offset >= minimumEndOffset; offset -= 1) {
+ if (bytes.readUInt32LE(offset) === 0x06054b50) { endOffset = offset; break; }
+ }
+ if (endOffset < 0) throw new Error('Acceptance trace ZIP end record is missing');
+ const diskNumber = bytes.readUInt16LE(endOffset + 4);
+ const centralDisk = bytes.readUInt16LE(endOffset + 6);
+ const entriesOnDisk = bytes.readUInt16LE(endOffset + 8);
+ const entryCount = bytes.readUInt16LE(endOffset + 10);
+ const centralSize = bytes.readUInt32LE(endOffset + 12);
+ const centralOffset = bytes.readUInt32LE(endOffset + 16);
+ const commentLength = bytes.readUInt16LE(endOffset + 20);
+ if (diskNumber !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount || entryCount > 10_000
+ || centralOffset + centralSize > endOffset || endOffset + 22 + commentLength !== bytes.length) {
+ throw new Error('Acceptance trace ZIP directory is invalid or unsupported');
+ }
+ const entries = [];
+ let offset = centralOffset;
+ let totalUncompressed = 0;
+ for (let index = 0; index < entryCount; index += 1) {
+ if (offset + 46 > bytes.length || bytes.readUInt32LE(offset) !== 0x02014b50) throw new Error('Acceptance trace ZIP entry is invalid');
+ const flags = bytes.readUInt16LE(offset + 8);
+ const method = bytes.readUInt16LE(offset + 10);
+ const checksum = bytes.readUInt32LE(offset + 16);
+ const compressedSize = bytes.readUInt32LE(offset + 20);
+ const uncompressedSize = bytes.readUInt32LE(offset + 24);
+ const filenameLength = bytes.readUInt16LE(offset + 28);
+ const extraLength = bytes.readUInt16LE(offset + 30);
+ const commentLength = bytes.readUInt16LE(offset + 32);
+ const externalAttributes = bytes.readUInt32LE(offset + 38);
+ const localOffset = bytes.readUInt32LE(offset + 42);
+ const nextOffset = offset + 46 + filenameLength + extraLength + commentLength;
+ if (nextOffset > bytes.length || compressedSize === 0xffffffff || uncompressedSize === 0xffffffff
+ || (flags & 1) !== 0 || ![0, 8].includes(method) || uncompressedSize > 64 * 1024 * 1024) {
+ throw new Error('Acceptance trace ZIP entry uses an unsafe or unsupported encoding');
+ }
+ const name = bytes.subarray(offset + 46, offset + 46 + filenameLength).toString('utf8');
+ if (!name || name.startsWith('/') || name.split('/').includes('..') || ((externalAttributes >>> 16) & 0o170000) === 0o120000) {
+ throw new Error('Acceptance trace contains an unsafe entry');
+ }
+ if (localOffset + 30 > bytes.length || bytes.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('Acceptance trace ZIP local entry is invalid');
+ const localNameLength = bytes.readUInt16LE(localOffset + 26);
+ const localExtraLength = bytes.readUInt16LE(localOffset + 28);
+ const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
+ const localName = bytes.subarray(localOffset + 30, localOffset + 30 + localNameLength).toString('utf8');
+ if (localName !== name || bytes.readUInt16LE(localOffset + 6) !== flags
+ || bytes.readUInt16LE(localOffset + 8) !== method || dataOffset + compressedSize > bytes.length) {
+ throw new Error('Acceptance trace ZIP entry is truncated or inconsistent');
+ }
+ const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize);
+ const value = method === 0 ? Buffer.from(compressed) : inflateRawSync(compressed, { maxOutputLength: 64 * 1024 * 1024 });
+ if (value.length !== uncompressedSize || crc32(value) !== checksum) throw new Error('Acceptance trace ZIP entry integrity failed');
+ totalUncompressed += value.length;
+ if (totalUncompressed > 256 * 1024 * 1024) throw new Error('Acceptance trace ZIP expands beyond its scan limit');
+ entries.push({ name, value });
+ offset = nextOffset;
+ }
+ if (offset !== centralOffset + centralSize) throw new Error('Acceptance trace ZIP directory size changed');
+ return entries;
+};
+
+const scanZip = async (file, sentinels, ocr) => {
+ let entries;
+ try { entries = readZipEntries(await readFile(file)); } catch (error) {
+ throw new Error(`Acceptance trace is not a readable safe ZIP: ${basename(file)}`, { cause: error });
+ }
+ for (const { name, value } of entries) {
+ scanBytes(value, `${file}:${name}`, sentinels);
+ if (renderedImageExtension(name)) await scanRenderedScreenshot(value, `${file}:${name}`, sentinels, ocr);
+ }
+};
+
+export const scanAcceptancePaths = async (paths, sentinels = [], { ocr = defaultOcr } = {}) => {
+ for (const input of paths) {
+ let stats;
+ try { stats = await lstat(input); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
+ if (stats.isSymbolicLink()) throw new Error(`Acceptance scan input is a symbolic link: ${input}`);
+ const files = stats.isDirectory() ? await walkFiles(input) : [input];
+ for (const file of files) {
+ const bytes = await readFile(file);
+ scanBytes(bytes, file, sentinels);
+ if (renderedImageExtension(file)) await scanRenderedScreenshot(bytes, file, sentinels, ocr);
+ if (file.endsWith('.zip')) await scanZip(file, sentinels, ocr);
+ }
+ }
+};
+
+const allowedCleanup = (kind, allowedWorkspaceParents) => {
+ if (kind === 'artifact') {
+ return {
+ parents: new Set((allowedWorkspaceParents ?? [WORKSPACE_ROOT, DESKTOP_ROOT, tmpdir()]).map(path => resolve(path))),
+ name: value => value === ACCEPTANCE_ARTIFACT_LEAF,
+ };
+ }
+ if (kind === 'profile') {
+ return { parents: new Set([resolve(tmpdir())]), name: value => new RegExp(`^${ACCEPTANCE_PROFILE_PREFIX}[A-Za-z0-9]+$`).test(value) };
+ }
+ if (kind === 'surfaces') {
+ return { parents: new Set([resolve(tmpdir())]), name: value => new RegExp(`^${ACCEPTANCE_SURFACES_PREFIX}[A-Za-z0-9]+$`).test(value) };
+ }
+ throw new Error('Unknown acceptance cleanup kind');
+};
+
+export const safeRemoveAcceptanceLeaf = async (input, { kind = 'artifact', allowedWorkspaceParents } = {}) => {
+ if (!isAbsolute(input)) throw new Error('Acceptance cleanup requires an absolute path');
+ const target = resolve(input);
+ const policy = allowedCleanup(kind, allowedWorkspaceParents);
+ const parent = dirname(target);
+ if (target === parse(target).root || !policy.name(basename(target))) {
+ throw new Error('Acceptance cleanup target is not a dedicated allowlisted leaf');
+ }
+ const canonicalAllowedParents = new Set();
+ for (const allowedParent of policy.parents) {
+ const allowedStats = await lstat(allowedParent);
+ if (allowedStats.isSymbolicLink() || !allowedStats.isDirectory()) {
+ throw new Error('Acceptance cleanup allowed parent must be an existing non-link directory');
+ }
+ canonicalAllowedParents.add(await realpath(allowedParent));
+ }
+ const parentStats = await lstat(parent);
+ if (parentStats.isSymbolicLink() || !parentStats.isDirectory()) {
+ throw new Error('Acceptance cleanup parent must be an existing non-link directory');
+ }
+ const canonicalParent = await realpath(parent);
+ if (!canonicalAllowedParents.has(canonicalParent)) {
+ throw new Error('Acceptance cleanup target parent is not the canonical allowlisted parent');
+ }
+ let stats;
+ try { stats = await lstat(target); } catch (error) { if (error?.code === 'ENOENT') return; throw error; }
+ if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error('Acceptance cleanup target must be a non-link directory');
+ await rm(target, { recursive: true, force: false });
+};
+
+export const prepareAcceptanceArtifactDirectory = async outputDirectory => {
+ const root = resolve(outputDirectory);
+ await safeRemoveAcceptanceLeaf(root, { kind: 'artifact' });
+ await mkdir(join(root, 'screenshots'), { recursive: true, mode: 0o700 });
+ return root;
+};
+
+const expectedArtifactFiles = () => new Set([
+ ...expectedScreenshotNames().map(name => `screenshots/${name}`),
+ 'accessibility.json',
+ 'sanitized-summary.json',
+ 'sanitized-log.json',
+ 'sanitized-trace.zip',
+ 'manifest.json',
+]);
+
+const validateScreenshotEntry = (entry, expectedName) => {
+ assertExactKeys(entry, [
+ 'name', 'journey', 'variant', 'width', 'height', 'physicalPngDimensions', 'reducedMotion',
+ 'requestedViewport', 'playwrightViewport', 'rendererViewport', 'documentClientViewport',
+ 'scrollbarInsets', 'visualViewportInsets', 'layoutViewport',
+ 'cdpVisualViewport', 'rendererVisualViewport', 'effectiveVisibleCssSpan', 'geometryZoom',
+ 'requestedDeviceScaleFactor', 'rendererDevicePixelRatio', 'requestedZoomFactor',
+ 'appliedZoomFactor', 'zoomResetFactor', 'zoomMechanism',
+ 'locale', 'timezone', 'font', 'colorScheme', 'rendererTime', 'originPolicy', 'visibleData',
+ 'animations', 'bytes', 'sha256', 'repeatabilitySha256',
+ ], `Acceptance screenshot ${expectedName}`);
+ const [journey, variantWithExtension] = expectedName.split('--');
+ const variant = variantWithExtension.slice(0, -4);
+ const config = ACCEPTANCE_VARIANTS[variant];
+ if (entry.name !== expectedName || entry.journey !== journey || entry.variant !== variant
+ || entry.width !== config.viewport.width * config.deviceScaleFactor
+ || entry.height !== config.viewport.height * config.deviceScaleFactor
+ || entry.reducedMotion !== config.reducedMotion || entry.locale !== DETERMINISTIC_INPUTS.locale
+ || entry.timezone !== DETERMINISTIC_INPUTS.timezone || entry.font !== DETERMINISTIC_INPUTS.font
+ || entry.colorScheme !== DETERMINISTIC_INPUTS.colorScheme || entry.rendererTime !== FIXED_TIME
+ || entry.originPolicy !== DETERMINISTIC_INPUTS.originPolicy || entry.visibleData !== DETERMINISTIC_INPUTS.visibleData
+ || entry.animations !== DETERMINISTIC_INPUTS.animations || !isInteger(entry.bytes) || entry.bytes === 0
+ || !isSha256(entry.sha256) || entry.repeatabilitySha256 !== entry.sha256) {
+ throw new Error(`Acceptance screenshot metadata changed for ${expectedName}`);
+ }
+ assertViewportMetricEvidence(entry, config, `Acceptance screenshot ${expectedName}`);
+};
+
+const validateAccessibility = accessibility => {
+ assertExactKeys(accessibility, [
+ 'schemaVersion', 'generatedAt', 'serious', 'critical', 'findings', 'checks', 'keyboardOrder',
+ 'visibleFocus', 'modalFocusTrap', 'modalFocusRestore', 'accessibleNames', 'liveAnnouncements',
+ ], 'Acceptance accessibility report');
+ if (accessibility.schemaVersion !== 6 || accessibility.generatedAt !== FIXED_TIME
+ || accessibility.serious !== 0 || accessibility.critical !== 0 || accessibility.findings?.length !== 0
+ || accessibility.keyboardOrder !== true || accessibility.visibleFocus !== true
+ || accessibility.modalFocusTrap !== true || accessibility.modalFocusRestore !== true
+ || accessibility.accessibleNames !== true || accessibility.checks?.length !== expectedScreenshotNames().length) {
+ throw new Error('Acceptance accessibility thresholds were not met');
+ }
+ assertExactKeys(accessibility.liveAnnouncements, ['status', 'error'], 'Acceptance live announcements');
+ for (const [kind, evidence] of Object.entries(accessibility.liveAnnouncements)) {
+ assertExactKeys(evidence, ['journey', 'beforeHash', 'afterHash', 'mutated'], `Acceptance ${kind} live announcement`);
+ if (!ACCEPTANCE_JOURNEYS.includes(evidence.journey) || evidence.mutated !== true
+ || !isSha256(evidence.beforeHash) || !isSha256(evidence.afterHash) || evidence.beforeHash === evidence.afterHash) {
+ throw new Error(`Acceptance ${kind} live region did not mutate`);
+ }
+ }
+ accessibility.checks.forEach((check, index) => {
+ const name = expectedScreenshotNames()[index];
+ assertExactKeys(check, [
+ 'name', 'journey', 'variant', 'serious', 'critical', 'accessibleNames', 'locale', 'timezone',
+ 'fontLoaded', 'reducedMotion', 'animationsDisabled', 'rendererTime', 'physicalPngDimensions',
+ 'requestedViewport', 'playwrightViewport', 'rendererViewport', 'documentClientViewport',
+ 'scrollbarInsets', 'visualViewportInsets', 'layoutViewport',
+ 'cdpVisualViewport', 'rendererVisualViewport', 'effectiveVisibleCssSpan', 'geometryZoom',
+ 'requestedDeviceScaleFactor', 'rendererDevicePixelRatio', 'requestedZoomFactor',
+ 'appliedZoomFactor', 'zoomResetFactor', 'zoomMechanism',
+ ], `Acceptance accessibility check ${name}`);
+ const [journey, variantWithExtension] = name.split('--');
+ const variant = variantWithExtension.slice(0, -4);
+ const config = ACCEPTANCE_VARIANTS[variant];
+ if (check.name !== name || check.journey !== journey || check.variant !== variant
+ || check.reducedMotion !== config.reducedMotion || check.locale !== DETERMINISTIC_INPUTS.locale
+ || check.timezone !== DETERMINISTIC_INPUTS.timezone || check.rendererTime !== FIXED_TIME) {
+ throw new Error(`Acceptance accessibility metadata changed for ${name}`);
+ }
+ assertViewportMetricEvidence(check, config, `Acceptance accessibility check ${name}`);
+ if (check.serious !== 0 || check.critical !== 0 || check.accessibleNames !== true
+ || check.fontLoaded !== true || check.animationsDisabled !== true) {
+ throw new Error(`Acceptance accessibility check failed for ${name}`);
+ }
+ });
+};
+
+const validateSummary = summary => {
+ assertExactKeys(summary, ['schemaVersion', 'generatedAt', 'status', 'journeys', 'screenshots', 'boundary', 'console', 'services', 'redaction'], 'Acceptance summary');
+ if (summary.schemaVersion !== 5 || summary.generatedAt !== FIXED_TIME || summary.status !== 'passed'
+ || summary.journeys !== ACCEPTANCE_JOURNEYS.length || summary.screenshots !== expectedScreenshotNames().length
+ || summary.redaction !== 'Full raw surfaces were scanned; published logs retain only source, level, byte count, and digest.') {
+ throw new Error('Acceptance sanitized summary is invalid');
+ }
+ assertExactKeys(summary.boundary, ['packagedExecutable', 'rendererOrigin', 'preloadBridge', 'journeys'], 'Acceptance boundary summary');
+ if (summary.boundary.packagedExecutable !== true || summary.boundary.rendererOrigin !== 'propr-app://renderer'
+ || summary.boundary.preloadBridge !== true
+ || summary.boundary.journeys?.join('\n') !== ACCEPTANCE_JOURNEYS.join('\n')) throw new Error('Acceptance executable boundary was not observed');
+ assertExactKeys(summary.console, ['records', 'errors'], 'Acceptance console summary');
+ if (!isInteger(summary.console.records) || !isInteger(summary.console.errors) || summary.console.errors > summary.console.records) {
+ throw new Error('Acceptance console summary is invalid');
+ }
+ assertExactKeys(summary.services, ['rest', 'socketIo', 'pairing', 'connect'], 'Acceptance service summary');
+ const { rest, socketIo, pairing, connect } = summary.services;
+ assertExactKeys(rest, ['requestCount', 'authenticatedRequestCount', 'journeys', 'currentUserValidation'], 'Acceptance REST summary');
+ assertExactKeys(rest.currentUserValidation, [
+ 'correlation', 'rendererRequestIssued', 'rendererScopeGeneration', 'mainProxyObserved', 'mainActiveScopeGeneration',
+ 'activeScopeAccepted', 'mainOnlyBearerInjection', 'upstreamRequestArrived', 'responseStatus',
+ 'responseClassification', 'parsedUserSchemaAccepted', 'rendererActiveScopeAccepted',
+ 'firstFailedPredicate',
+ ], 'Acceptance current-user validation summary');
+ assertExactKeys(socketIo, ['authenticatedConnections', 'events', 'journeys', 'handshake'], 'Acceptance Socket.IO summary');
+ assertExactKeys(pairing, ['started', 'polled', 'activated', 'journeys'], 'Acceptance pairing summary');
+ assertExactKeys(connect, ['confirmedRequests', 'journeys'], 'Acceptance Connect summary');
+ assertExactKeys(socketIo.handshake, [
+ 'mainAttempts', 'fixtureAttempts', 'scopeQueryPresent', 'scopeQueryCount',
+ 'scopeEqualsActivatedBinding', 'activeBindingPresent', 'profileGenerationCurrent',
+ 'originEqualsActivatedBinding', 'path', 'transport', 'resource',
+ 'rendererBearerPresent', 'rendererCookiePresent', 'authorizationHeaderPresent',
+ 'authorizationHeaderExactlyMainInjected', 'rendererObservedApplicationEvent', 'rendererLifecycle',
+ 'rejectionCategory',
+ ], 'Acceptance Socket.IO handshake summary');
+ const handshake = socketIo.handshake;
+ assertExactKeys(handshake.rendererLifecycle, [
+ 'phase', 'profileActivationPublished', 'socketProviderMounted', 'providerDisabled',
+ 'disabledByDemoModeLoading', 'disabledByDemoMode',
+ 'disabledByCurrentUserLoading', 'disabledByCurrentUserAbsent',
+ 'desktopRuntime', 'connectionScope', 'socketConstructionInvocations', 'socketConstructions',
+ 'connectInvocations',
+ ], 'Acceptance renderer Socket.IO lifecycle summary');
+ const currentUserValidation = rest.currentUserValidation;
+ if (rest.requestCount <= 0 || rest.authenticatedRequestCount <= 0 || !rest.journeys.includes('dashboard-profile-manager')
+ || currentUserValidation.correlation !== 'current-scope-user-validation'
+ || currentUserValidation.rendererRequestIssued !== true
+ || !isInteger(currentUserValidation.rendererScopeGeneration) || currentUserValidation.rendererScopeGeneration <= 0
+ || currentUserValidation.mainProxyObserved !== true
+ || !isInteger(currentUserValidation.mainActiveScopeGeneration) || currentUserValidation.mainActiveScopeGeneration < 0
+ || currentUserValidation.activeScopeAccepted !== true
+ || currentUserValidation.mainOnlyBearerInjection !== true
+ || currentUserValidation.upstreamRequestArrived !== true
+ || currentUserValidation.responseStatus !== 200
+ || currentUserValidation.responseClassification !== 'success'
+ || currentUserValidation.parsedUserSchemaAccepted !== true
+ || currentUserValidation.rendererActiveScopeAccepted !== true
+ || currentUserValidation.firstFailedPredicate !== 'none'
+ || socketIo.authenticatedConnections !== 1 || socketIo.events <= 0 || !socketIo.journeys.includes('dashboard-profile-manager')
+ || handshake.mainAttempts !== 1 || handshake.fixtureAttempts !== 1
+ || handshake.scopeQueryPresent !== true || handshake.scopeQueryCount !== 1
+ || handshake.scopeEqualsActivatedBinding !== true
+ || handshake.activeBindingPresent !== true || handshake.profileGenerationCurrent !== true
+ || handshake.originEqualsActivatedBinding !== true
+ || handshake.path !== 'socket-io' || handshake.transport !== 'websocket' || handshake.resource !== 'websocket'
+ || handshake.rendererBearerPresent !== false || handshake.rendererCookiePresent !== false
+ || handshake.authorizationHeaderPresent !== true || handshake.authorizationHeaderExactlyMainInjected !== true
+ || handshake.rendererObservedApplicationEvent !== true
+ || handshake.rendererLifecycle.phase !== 'socket-connect-invoked'
+ || handshake.rendererLifecycle.profileActivationPublished !== true
+ || handshake.rendererLifecycle.socketProviderMounted !== true
+ || handshake.rendererLifecycle.providerDisabled !== false
+ || handshake.rendererLifecycle.disabledByDemoModeLoading !== false
+ || handshake.rendererLifecycle.disabledByDemoMode !== false
+ || handshake.rendererLifecycle.disabledByCurrentUserLoading !== false
+ || handshake.rendererLifecycle.disabledByCurrentUserAbsent !== false
+ || handshake.rendererLifecycle.desktopRuntime !== true
+ || handshake.rendererLifecycle.connectionScope !== 'available'
+ || handshake.rendererLifecycle.socketConstructionInvocations !== 1
+ || handshake.rendererLifecycle.socketConstructions !== 1
+ || handshake.rendererLifecycle.connectInvocations !== 1
+ || handshake.rejectionCategory !== 'none'
+ || pairing.started <= 0 || pairing.polled <= 0 || pairing.activated <= 0 || !pairing.journeys.includes('dashboard-profile-manager')
+ || connect.confirmedRequests <= 0 || connect.journeys.join('\n') !== 'connect-confirmation') {
+ throw new Error('Acceptance claimed service journey was not observed');
+ }
+};
+
+const validateSanitizedLog = log => {
+ assertExactKeys(log, ['schemaVersion', 'generatedAt', 'records'], 'Acceptance sanitized log');
+ if (log.schemaVersion !== 1 || log.generatedAt !== FIXED_TIME || !Array.isArray(log.records)) throw new Error('Acceptance sanitized log is invalid');
+ for (const record of log.records) {
+ assertExactKeys(record, ['journey', 'source', 'level', 'bytes', 'sha256'], 'Acceptance sanitized log record');
+ if (!ACCEPTANCE_JOURNEYS.includes(record.journey) || !['renderer-console', 'renderer-page-error', 'packaged-process'].includes(record.source)
+ || typeof record.level !== 'string' || !isInteger(record.bytes) || !isSha256(record.sha256)) {
+ throw new Error('Acceptance sanitized log record is invalid');
+ }
+ }
+ const processJourneys = log.records.filter(record => record.source === 'packaged-process').map(record => record.journey);
+ if (processJourneys.join('\n') !== ACCEPTANCE_JOURNEYS.join('\n')) throw new Error('Acceptance sanitized process log coverage is incomplete');
+};
+
+export const validateAcceptanceEvidence = (accessibility, manifest, summary, sanitizedLog) => {
+ assertExactKeys(manifest, [
+ 'schemaVersion', 'generatedAt', 'platform', 'arch', 'executableBoundary', 'deterministicInputs',
+ 'nativePackageCoverage', 'screenshots', 'supporting',
+ ], 'Acceptance manifest');
+ if (manifest.schemaVersion !== 6 || manifest.platform !== 'linux' || manifest.arch !== 'x64'
+ || manifest.generatedAt !== FIXED_TIME || manifest.executableBoundary !== 'packaged-electron-main-preload-renderer'
+ || manifest.screenshots?.length !== expectedScreenshotNames().length) {
+ throw new Error('Acceptance manifest is incomplete or non-deterministic');
+ }
+ assertExactJson(manifest.deterministicInputs, DETERMINISTIC_INPUTS, 'Acceptance deterministic inputs');
+ assertExactJson(manifest.nativePackageCoverage, {
+ 'linux-x64': 'visual-accessibility-runtime',
+ 'linux-arm64': 'structural-runtime-only',
+ 'darwin-x64': 'structural-runtime-only',
+ 'darwin-arm64': 'structural-runtime-only',
+ 'win32-x64': 'structural-runtime-only',
+ 'win32-arm64': 'structural-runtime-only',
+ }, 'Acceptance native package coverage');
+ manifest.screenshots.forEach((entry, index) => validateScreenshotEntry(entry, expectedScreenshotNames()[index]));
+ for (const journey of ACCEPTANCE_JOURNEYS) {
+ const entries = Object.fromEntries(manifest.screenshots
+ .filter(entry => entry.journey === journey)
+ .map(entry => [entry.variant, entry]));
+ if (entries['zoom-200']?.sha256 === entries.standard?.sha256
+ || entries['zoom-200']?.sha256 === entries['high-dpi']?.sha256) {
+ throw new Error(`Acceptance zoom-200 screenshot is not visually distinct for ${journey}`);
+ }
+ }
+ if (!Array.isArray(manifest.supporting) || manifest.supporting.length !== 4) throw new Error('Acceptance supporting manifest is incomplete');
+ const expectedSupporting = ['accessibility.json', 'sanitized-summary.json', 'sanitized-log.json', 'sanitized-trace.zip'];
+ manifest.supporting.forEach((entry, index) => {
+ assertExactKeys(entry, ['name', 'bytes', 'sha256'], `Acceptance supporting artifact ${expectedSupporting[index]}`);
+ if (entry.name !== expectedSupporting[index] || !isInteger(entry.bytes) || entry.bytes === 0 || !isSha256(entry.sha256)) {
+ throw new Error('Acceptance supporting artifact metadata changed');
+ }
+ });
+ validateAccessibility(accessibility);
+ validateSummary(summary);
+ validateSanitizedLog(sanitizedLog);
+ const rendererLogs = sanitizedLog.records.filter(record => record.source !== 'packaged-process');
+ if (rendererLogs.length !== summary.console.records
+ || rendererLogs.filter(record => record.level === 'error').length !== summary.console.errors) {
+ throw new Error('Acceptance sanitized log and summary counts disagree');
+ }
+};
+
+export const verifyAcceptanceArtifacts = async (outputDirectory, { sentinels = [], ocr = defaultOcr } = {}) => {
+ const root = resolve(outputDirectory);
+ const rootStats = await lstat(root);
+ if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) throw new Error('Acceptance artifact root must be a non-link directory');
+ const files = await walkFiles(root);
+ const relativeFiles = files.map(path => canonicalRelative(root, path));
+ const found = new Set(relativeFiles);
+ if (found.size !== relativeFiles.length) throw new Error('Duplicate acceptance artifact path');
+ const expected = expectedArtifactFiles();
+ const missing = [...expected].filter(path => !found.has(path));
+ const unexpected = [...found].filter(path => !expected.has(path));
+ if (missing.length || unexpected.length) {
+ throw new Error(`Acceptance artifact set mismatch (missing: ${missing.join(', ') || 'none'}; unexpected: ${unexpected.join(', ') || 'none'})`);
+ }
+
+ const accessibility = JSON.parse(await readFile(join(root, 'accessibility.json'), 'utf8'));
+ const summary = JSON.parse(await readFile(join(root, 'sanitized-summary.json'), 'utf8'));
+ const sanitizedLog = JSON.parse(await readFile(join(root, 'sanitized-log.json'), 'utf8'));
+ const manifest = JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8'));
+ validateAcceptanceEvidence(accessibility, manifest, summary, sanitizedLog);
+
+ for (const entry of manifest.screenshots) {
+ const bytes = await readFile(join(root, 'screenshots', entry.name));
+ const dimensions = readPngDimensions(bytes);
+ if (dimensions.width !== entry.width || dimensions.height !== entry.height
+ || bytes.length !== entry.bytes || sha256(bytes) !== entry.sha256) {
+ throw new Error(`Acceptance screenshot bytes, digest, or dimensions changed for ${entry.name}`);
+ }
+ }
+ for (const entry of manifest.supporting) {
+ const bytes = await readFile(join(root, entry.name));
+ if (bytes.length !== entry.bytes || sha256(bytes) !== entry.sha256) throw new Error(`Acceptance supporting artifact changed for ${entry.name}`);
+ }
+ await scanAcceptancePaths([root], sentinels, { ocr });
+ return manifest;
+};
+
+export const writeAcceptanceManifest = async (outputDirectory, screenshotMetadata) => {
+ const root = resolve(outputDirectory);
+ await mkdir(root, { recursive: true });
+ const screenshotMap = new Map(screenshotMetadata.map(entry => [entry.name, entry]));
+ if (screenshotMap.size !== screenshotMetadata.length) throw new Error('Duplicate acceptance screenshot metadata');
+ const expectedNames = new Set(expectedScreenshotNames());
+ const unexpectedMetadata = [...screenshotMap.keys()].filter(name => !expectedNames.has(name));
+ if (unexpectedMetadata.length) throw new Error(`Unexpected acceptance screenshot metadata: ${unexpectedMetadata.join(', ')}`);
+ const screenshots = [];
+ for (const name of expectedScreenshotNames()) {
+ if (!screenshotMap.has(name)) throw new Error(`Acceptance screenshot metadata is missing ${name}`);
+ const path = join(root, 'screenshots', name);
+ const bytes = await readFile(path);
+ screenshots.push({ ...screenshotMap.get(name), name, bytes: bytes.length, sha256: sha256(bytes) });
+ }
+ const supporting = [];
+ for (const name of ['accessibility.json', 'sanitized-summary.json', 'sanitized-log.json', 'sanitized-trace.zip']) {
+ const bytes = await readFile(join(root, name));
+ supporting.push({ name, bytes: bytes.length, sha256: sha256(bytes) });
+ }
+ const manifest = {
+ schemaVersion: 6,
+ generatedAt: FIXED_TIME,
+ platform: 'linux',
+ arch: 'x64',
+ executableBoundary: 'packaged-electron-main-preload-renderer',
+ deterministicInputs: DETERMINISTIC_INPUTS,
+ nativePackageCoverage: {
+ 'linux-x64': 'visual-accessibility-runtime',
+ 'linux-arm64': 'structural-runtime-only',
+ 'darwin-x64': 'structural-runtime-only',
+ 'darwin-arm64': 'structural-runtime-only',
+ 'win32-x64': 'structural-runtime-only',
+ 'win32-arm64': 'structural-runtime-only',
+ },
+ screenshots,
+ supporting,
+ };
+ await writeFile(join(root, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
+ return manifest;
+};
diff --git a/apps/desktop/scripts/acceptance-artifacts.test.mjs b/apps/desktop/scripts/acceptance-artifacts.test.mjs
new file mode 100644
index 000000000..18984ac67
--- /dev/null
+++ b/apps/desktop/scripts/acceptance-artifacts.test.mjs
@@ -0,0 +1,524 @@
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { describe, it } from 'node:test';
+import {
+ ACCEPTANCE_ARTIFACT_LEAF,
+ ACCEPTANCE_JOURNEYS,
+ ACCEPTANCE_VARIANTS,
+ DETERMINISTIC_INPUTS,
+ FIXED_TIME,
+ expectedScreenshotNames,
+ readPngDimensions,
+ safeRemoveAcceptanceLeaf,
+ scanAcceptancePaths,
+ scanRenderedScreenshot,
+ screenshotName,
+ validateAcceptanceEvidence,
+ verifyAcceptanceArtifacts,
+ writeAcceptanceManifest,
+} from './acceptance-artifacts.mjs';
+
+const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
+const crc32 = bytes => {
+ let crc = 0xffffffff;
+ for (const byte of bytes) {
+ crc ^= byte;
+ for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+};
+const zipFixture = (name, contents) => {
+ const filename = Buffer.from(name);
+ const data = Buffer.from(contents);
+ const checksum = crc32(data);
+ const local = Buffer.alloc(30 + filename.length);
+ local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt32LE(checksum, 14);
+ local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(filename.length, 26);
+ filename.copy(local, 30);
+ const central = Buffer.alloc(46 + filename.length);
+ central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6);
+ central.writeUInt32LE(checksum, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24);
+ central.writeUInt16LE(filename.length, 28); filename.copy(central, 46);
+ const end = Buffer.alloc(22);
+ end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10);
+ end.writeUInt32LE(central.length, 12); end.writeUInt32LE(local.length + data.length, 16);
+ return Buffer.concat([local, data, central, end]);
+};
+const pngHeader = (width, height) => {
+ const bytes = Buffer.alloc(24);
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes);
+ bytes.write('IHDR', 12, 'ascii');
+ bytes.writeUInt32BE(width, 16);
+ bytes.writeUInt32BE(height, 20);
+ return bytes;
+};
+
+const viewportMetricEvidenceFor = config => {
+ const rendererViewport = {
+ width: config.viewport.width / config.zoom,
+ height: config.viewport.height / config.zoom,
+ };
+ const scrollbarInsets = config.zoom === 2 ? { width: 8, height: 0 } : { width: 0, height: 0 };
+ const visualViewportInsets = config.zoom === 2 ? { width: 7.5, height: 0 } : { width: 0, height: 0 };
+ const documentClientViewport = {
+ width: rendererViewport.width - scrollbarInsets.width,
+ height: rendererViewport.height - scrollbarInsets.height,
+ };
+ const rendererVisualViewport = {
+ width: rendererViewport.width - visualViewportInsets.width,
+ height: rendererViewport.height - visualViewportInsets.height,
+ scale: 1,
+ };
+ return {
+ requestedViewport: { ...config.viewport },
+ playwrightViewport: { ...config.viewport },
+ rendererViewport,
+ documentClientViewport,
+ scrollbarInsets,
+ visualViewportInsets,
+ layoutViewport: { ...documentClientViewport },
+ cdpVisualViewport: { ...rendererVisualViewport },
+ rendererVisualViewport,
+ effectiveVisibleCssSpan: { ...rendererViewport },
+ geometryZoom: { width: config.zoom, height: config.zoom },
+ requestedDeviceScaleFactor: config.deviceScaleFactor,
+ rendererDevicePixelRatio: config.deviceScaleFactor * config.zoom,
+ requestedZoomFactor: config.zoom,
+ appliedZoomFactor: config.zoom,
+ zoomResetFactor: 1,
+ zoomMechanism: 'electron-web-frame',
+ physicalPngDimensions: {
+ width: config.viewport.width * config.deviceScaleFactor,
+ height: config.viewport.height * config.deviceScaleFactor,
+ },
+ };
+};
+
+const accessibilityFor = () => ({
+ schemaVersion: 6,
+ generatedAt: FIXED_TIME,
+ serious: 0,
+ critical: 0,
+ findings: [],
+ checks: ACCEPTANCE_JOURNEYS.flatMap(journey => Object.entries(ACCEPTANCE_VARIANTS).map(([variant, config]) => ({
+ name: screenshotName(journey, variant), journey, variant, serious: 0, critical: 0, accessibleNames: true,
+ locale: DETERMINISTIC_INPUTS.locale, timezone: DETERMINISTIC_INPUTS.timezone, fontLoaded: true,
+ reducedMotion: config.reducedMotion, animationsDisabled: true, rendererTime: FIXED_TIME,
+ ...viewportMetricEvidenceFor(config),
+ }))),
+ keyboardOrder: true,
+ visibleFocus: true,
+ modalFocusTrap: true,
+ modalFocusRestore: true,
+ accessibleNames: true,
+ liveAnnouncements: {
+ status: { journey: 'remote-pairing', beforeHash: '1'.repeat(64), afterHash: '2'.repeat(64), mutated: true },
+ error: { journey: 'offline', beforeHash: '3'.repeat(64), afterHash: '4'.repeat(64), mutated: true },
+ },
+});
+
+const summaryFor = () => ({
+ schemaVersion: 5,
+ generatedAt: FIXED_TIME,
+ status: 'passed',
+ journeys: ACCEPTANCE_JOURNEYS.length,
+ screenshots: expectedScreenshotNames().length,
+ boundary: { packagedExecutable: true, rendererOrigin: 'propr-app://renderer', preloadBridge: true, journeys: ACCEPTANCE_JOURNEYS },
+ console: { records: 3, errors: 0 },
+ services: {
+ rest: {
+ requestCount: 12,
+ authenticatedRequestCount: 3,
+ journeys: ['dashboard-profile-manager'],
+ currentUserValidation: {
+ correlation: 'current-scope-user-validation',
+ rendererRequestIssued: true,
+ rendererScopeGeneration: 1,
+ mainProxyObserved: true,
+ mainActiveScopeGeneration: 0,
+ activeScopeAccepted: true,
+ mainOnlyBearerInjection: true,
+ upstreamRequestArrived: true,
+ responseStatus: 200,
+ responseClassification: 'success',
+ parsedUserSchemaAccepted: true,
+ rendererActiveScopeAccepted: true,
+ firstFailedPredicate: 'none',
+ },
+ },
+ socketIo: {
+ authenticatedConnections: 1,
+ events: 1,
+ journeys: ['dashboard-profile-manager'],
+ handshake: {
+ mainAttempts: 1,
+ fixtureAttempts: 1,
+ scopeQueryPresent: true,
+ scopeQueryCount: 1,
+ scopeEqualsActivatedBinding: true,
+ activeBindingPresent: true,
+ profileGenerationCurrent: true,
+ originEqualsActivatedBinding: true,
+ path: 'socket-io',
+ transport: 'websocket',
+ resource: 'websocket',
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ authorizationHeaderPresent: true,
+ authorizationHeaderExactlyMainInjected: true,
+ rendererObservedApplicationEvent: true,
+ rendererLifecycle: {
+ phase: 'socket-connect-invoked',
+ profileActivationPublished: true,
+ socketProviderMounted: true,
+ providerDisabled: false,
+ disabledByDemoModeLoading: false,
+ disabledByDemoMode: false,
+ disabledByCurrentUserLoading: false,
+ disabledByCurrentUserAbsent: false,
+ desktopRuntime: true,
+ connectionScope: 'available',
+ socketConstructionInvocations: 1,
+ socketConstructions: 1,
+ connectInvocations: 1,
+ },
+ rejectionCategory: 'none',
+ },
+ },
+ pairing: { started: 1, polled: 1, activated: 1, journeys: ['dashboard-profile-manager'] },
+ connect: { confirmedRequests: 1, journeys: ['connect-confirmation'] },
+ },
+ redaction: 'Full raw surfaces were scanned; published logs retain only source, level, byte count, and digest.',
+});
+
+const sanitizedLogFor = () => ({
+ schemaVersion: 1,
+ generatedAt: FIXED_TIME,
+ records: [
+ ...Array.from({ length: 3 }, (_, index) => ({
+ journey: 'first-run-chooser', source: 'renderer-console', level: 'info', bytes: index, sha256: '5'.repeat(64),
+ })),
+ ...ACCEPTANCE_JOURNEYS.map(journey => ({
+ journey, source: 'packaged-process', level: 'combined', bytes: 0, sha256: '6'.repeat(64),
+ })),
+ ],
+});
+
+const createCompleteArtifactSet = async root => {
+ await mkdir(join(root, 'screenshots'), { recursive: true });
+ const metadata = [];
+ for (const journey of ACCEPTANCE_JOURNEYS) {
+ for (const [variant, config] of Object.entries(ACCEPTANCE_VARIANTS)) {
+ const name = screenshotName(journey, variant);
+ const bytes = Buffer.concat([
+ pngHeader(config.viewport.width * config.deviceScaleFactor, config.viewport.height * config.deviceScaleFactor),
+ Buffer.from(`${journey}:${variant}`),
+ ]);
+ await writeFile(join(root, 'screenshots', name), bytes);
+ metadata.push({
+ name, journey, variant,
+ width: config.viewport.width * config.deviceScaleFactor,
+ height: config.viewport.height * config.deviceScaleFactor,
+ reducedMotion: config.reducedMotion,
+ ...viewportMetricEvidenceFor(config),
+ locale: DETERMINISTIC_INPUTS.locale,
+ timezone: DETERMINISTIC_INPUTS.timezone,
+ font: DETERMINISTIC_INPUTS.font,
+ colorScheme: DETERMINISTIC_INPUTS.colorScheme,
+ rendererTime: FIXED_TIME,
+ originPolicy: DETERMINISTIC_INPUTS.originPolicy,
+ visibleData: DETERMINISTIC_INPUTS.visibleData,
+ animations: DETERMINISTIC_INPUTS.animations,
+ repeatabilitySha256: sha256(bytes),
+ });
+ }
+ }
+ const accessibility = accessibilityFor();
+ const summary = summaryFor();
+ const sanitizedLog = sanitizedLogFor();
+ await writeFile(join(root, 'accessibility.json'), `${JSON.stringify(accessibility)}\n`);
+ await writeFile(join(root, 'sanitized-summary.json'), `${JSON.stringify(summary)}\n`);
+ await writeFile(join(root, 'sanitized-log.json'), `${JSON.stringify(sanitizedLog)}\n`);
+ await writeFile(join(root, 'sanitized-trace.zip'), zipFixture('trace-entry.txt', 'sanitized trace'));
+ const manifest = await writeAcceptanceManifest(root, metadata);
+ return { accessibility, manifest, summary, sanitizedLog };
+};
+
+describe('packaged acceptance artifact contract', () => {
+ it('has exactly 60 deterministic unique names and physical dimensions', () => {
+ const names = ACCEPTANCE_JOURNEYS.flatMap(journey => Object.entries(ACCEPTANCE_VARIANTS).map(([variant, config]) => {
+ assert.deepEqual(readPngDimensions(pngHeader(config.viewport.width * config.deviceScaleFactor, config.viewport.height * config.deviceScaleFactor)), {
+ width: config.viewport.width * config.deviceScaleFactor,
+ height: config.viewport.height * config.deviceScaleFactor,
+ });
+ return screenshotName(journey, variant);
+ }));
+ assert.equal(names.length, 60);
+ assert.deepEqual(names, expectedScreenshotNames());
+ assert.equal(new Set(names).size, 60);
+ assert.ok(names.every(name => /^[a-z0-9-]+--[a-z0-9-]+\.png$/.test(name)));
+ });
+
+ it('writes and verifies the complete strict 60-entry manifest and supporting set', async () => {
+ const parent = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-full-'));
+ const root = join(parent, ACCEPTANCE_ARTIFACT_LEAF);
+ try {
+ const evidence = await createCompleteArtifactSet(root);
+ assert.equal(evidence.manifest.screenshots.length, 60);
+ assert.equal(evidence.manifest.schemaVersion, 6);
+ assert.equal(evidence.accessibility.schemaVersion, 6);
+ const zoomEvidence = evidence.manifest.screenshots.find(entry => entry.variant === 'zoom-200');
+ assert.deepEqual(zoomEvidence.rendererViewport, { width: 640, height: 410 });
+ assert.deepEqual(zoomEvidence.documentClientViewport, { width: 632, height: 410 });
+ assert.deepEqual(zoomEvidence.layoutViewport, { width: 632, height: 410 });
+ assert.deepEqual(zoomEvidence.rendererVisualViewport, { width: 632.5, height: 410, scale: 1 });
+ assert.deepEqual(zoomEvidence.cdpVisualViewport, { width: 632.5, height: 410, scale: 1 });
+ assert.deepEqual(zoomEvidence.scrollbarInsets, { width: 8, height: 0 });
+ assert.deepEqual(zoomEvidence.visualViewportInsets, { width: 7.5, height: 0 });
+ assert.deepEqual(evidence.manifest.screenshots.map(entry => entry.name), expectedScreenshotNames());
+ assert.ok(evidence.manifest.screenshots.every(entry => entry.bytes > 24 && entry.sha256 === entry.repeatabilitySha256));
+ assert.deepEqual(evidence.manifest.supporting.map(entry => entry.name), [
+ 'accessibility.json', 'sanitized-summary.json', 'sanitized-log.json', 'sanitized-trace.zip',
+ ]);
+ assert.doesNotThrow(() => validateAcceptanceEvidence(evidence.accessibility, evidence.manifest, evidence.summary, evidence.sanitizedLog));
+ const verified = await verifyAcceptanceArtifacts(root, { ocr: async () => '' });
+ assert.deepEqual(verified, JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8')));
+ } finally { await rm(parent, { recursive: true, force: true }); }
+ });
+
+ it('scans full persisted surfaces and OCR-recognized rendered pixels', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-scan-'));
+ try {
+ await mkdir(join(root, 'storage'));
+ await writeFile(join(root, 'storage', 'safe.json'), '{"status":"ready"}');
+ await scanAcceptancePaths([root], ['acceptance-exact-sentinel']);
+ await assert.rejects(
+ scanRenderedScreenshot(Buffer.from('not-used-by-mock'), 'screenshot.png', ['acceptance-exact-sentinel'], async () => 'acceptance-exact-sentinel'),
+ /rendered pixels/,
+ );
+ await writeFile(join(root, 'storage', 'unsafe.log'), 'acceptance-exact-sentinel');
+ await assert.rejects(scanAcceptancePaths([root], ['acceptance-exact-sentinel']), /Secret sentinel/);
+ } finally { await rm(root, { recursive: true, force: true }); }
+ });
+
+ it('keeps generic matching within printable binary runs and exact sentinel scanning raw', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-binary-scan-'));
+ const database = join(root, 'key4.db');
+ const binaryAssignmentBridge = Buffer.concat([
+ Buffer.from('SQLite format 3\0NSS metadata password=', 'ascii'),
+ Buffer.from([0x00, 0x80, 0xff, 0x01]),
+ Buffer.from('binary-record-body', 'ascii'),
+ Buffer.from([0x00, 0x02]),
+ ]);
+ try {
+ assert.match(binaryAssignmentBridge.toString('utf8'), /password\s*[=:]\s*[^\s"']{8,}/i);
+ await writeFile(database, binaryAssignmentBridge);
+ await scanAcceptancePaths([database]);
+
+ for (const credential of [
+ `propr_it_${'a'.repeat(20)}`,
+ `ghp_${'b'.repeat(20)}`,
+ `Bearer ${'c'.repeat(16)}`,
+ 'token=printable-value',
+ 'secret: printable-value',
+ 'password = printable-value',
+ 'private-key=printable-value',
+ ]) {
+ await writeFile(database, Buffer.concat([
+ binaryAssignmentBridge,
+ Buffer.from([0x00, 0xff]),
+ Buffer.from(credential, 'ascii'),
+ Buffer.from([0x00]),
+ ]));
+ await assert.rejects(scanAcceptancePaths([database]), /Secret-shaped value/);
+ }
+
+ const sentinel = 'acceptance-exact-sentinel';
+ await writeFile(database, Buffer.concat([
+ Buffer.from([0x00, 0x80, 0xff]),
+ Buffer.from(sentinel, 'ascii'),
+ Buffer.from([0x01, 0xfe, 0x00]),
+ ]));
+ await assert.rejects(scanAcceptancePaths([database], [sentinel]), /Secret sentinel/);
+ } finally { await rm(root, { recursive: true, force: true }); }
+ });
+
+ it('fails closed on missing, mutated, and unexpected artifact data', async () => {
+ const parent = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-invalid-'));
+ const root = join(parent, ACCEPTANCE_ARTIFACT_LEAF);
+ try {
+ await mkdir(join(root, 'screenshots'), { recursive: true });
+ await assert.rejects(verifyAcceptanceArtifacts(root, { ocr: async () => '' }), /artifact set mismatch.*missing/i);
+ const evidence = await createCompleteArtifactSet(root);
+ assert.throws(
+ () => validateAcceptanceEvidence({ ...evidence.accessibility, extra: true }, evidence.manifest, evidence.summary, evidence.sanitizedLog),
+ /schema changed/,
+ );
+ assert.throws(
+ () => validateAcceptanceEvidence(evidence.accessibility, evidence.manifest, {
+ ...evidence.summary,
+ services: { ...evidence.summary.services, connect: { confirmedRequests: 0, journeys: [] } },
+ }, evidence.sanitizedLog),
+ /not observed/,
+ );
+ for (const mutateHandshake of [
+ handshake => { handshake.scopeQueryPresent = false; handshake.scopeQueryCount = 0; },
+ handshake => { handshake.scopeQueryCount = 2; },
+ handshake => { handshake.scopeEqualsActivatedBinding = false; handshake.rejectionCategory = 'stale-scope'; },
+ handshake => { handshake.rejectionCategory = 'wrong-origin'; },
+ handshake => { handshake.rendererBearerPresent = true; },
+ ]) {
+ const invalidSummary = structuredClone(evidence.summary);
+ mutateHandshake(invalidSummary.services.socketIo.handshake);
+ assert.throws(
+ () => validateAcceptanceEvidence(
+ evidence.accessibility, evidence.manifest, invalidSummary, evidence.sanitizedLog,
+ ),
+ /not observed/,
+ );
+ }
+ for (const mutateLifecycle of [
+ lifecycle => { lifecycle.profileActivationPublished = false; },
+ lifecycle => { lifecycle.socketProviderMounted = false; },
+ lifecycle => { lifecycle.providerDisabled = true; },
+ lifecycle => { lifecycle.disabledByDemoModeLoading = true; },
+ lifecycle => { lifecycle.disabledByDemoMode = true; },
+ lifecycle => { lifecycle.disabledByCurrentUserLoading = true; },
+ lifecycle => { lifecycle.disabledByCurrentUserAbsent = true; },
+ lifecycle => { lifecycle.connectionScope = 'unavailable'; },
+ lifecycle => { lifecycle.socketConstructionInvocations = 0; },
+ lifecycle => { lifecycle.socketConstructions = 0; },
+ lifecycle => { lifecycle.connectInvocations = 0; },
+ lifecycle => { lifecycle.connectInvocations = 2; },
+ ]) {
+ const invalidSummary = structuredClone(evidence.summary);
+ mutateLifecycle(invalidSummary.services.socketIo.handshake.rendererLifecycle);
+ assert.throws(
+ () => validateAcceptanceEvidence(
+ evidence.accessibility, evidence.manifest, invalidSummary, evidence.sanitizedLog,
+ ),
+ /not observed/,
+ );
+ }
+ const relabeledManifest = structuredClone(evidence.manifest);
+ const zoomEntry = relabeledManifest.screenshots.find(entry => entry.variant === 'zoom-200');
+ zoomEntry.appliedZoomFactor = 1;
+ zoomEntry.rendererDevicePixelRatio = 1;
+ zoomEntry.geometryZoom = { width: 1, height: 1 };
+ zoomEntry.effectiveVisibleCssSpan = { width: 1280, height: 820 };
+ assert.throws(
+ () => validateAcceptanceEvidence(evidence.accessibility, relabeledManifest, evidence.summary, evidence.sanitizedLog),
+ /zoom-200.*viewport metric evidence changed/,
+ );
+ for (const mutate of [
+ entry => { entry.layoutViewport.width += 1; },
+ entry => { entry.scrollbarInsets.width += 1; },
+ entry => { entry.visualViewportInsets.width += 1; },
+ entry => { entry.rendererVisualViewport.width += 0.5; },
+ entry => { entry.cdpVisualViewport.width += 0.5; },
+ entry => {
+ entry.documentClientViewport.width = entry.rendererViewport.width - 65;
+ entry.layoutViewport.width = entry.documentClientViewport.width;
+ entry.scrollbarInsets.width = 65;
+ },
+ entry => {
+ entry.visualViewportInsets.width = 65;
+ entry.rendererVisualViewport.width = entry.rendererViewport.width - 65;
+ entry.cdpVisualViewport.width = entry.rendererVisualViewport.width;
+ },
+ entry => { entry.visualViewportInsets.width = Number.NaN; },
+ ]) {
+ const invalidScrollbarManifest = structuredClone(evidence.manifest);
+ mutate(invalidScrollbarManifest.screenshots.find(entry => entry.variant === 'zoom-200'));
+ assert.throws(
+ () => validateAcceptanceEvidence(evidence.accessibility, invalidScrollbarManifest, evidence.summary, evidence.sanitizedLog),
+ /zoom-200.*viewport metric evidence changed/,
+ );
+ }
+ const invalidAccessibility = structuredClone(evidence.accessibility);
+ invalidAccessibility.checks.find(check => check.variant === 'zoom-200').visualViewportInsets.width = 8;
+ assert.throws(
+ () => validateAcceptanceEvidence(invalidAccessibility, evidence.manifest, evidence.summary, evidence.sanitizedLog),
+ /zoom-200.*viewport metric evidence changed/,
+ );
+ const duplicateManifest = structuredClone(evidence.manifest);
+ const duplicateZoom = duplicateManifest.screenshots.find(entry => entry.variant === 'zoom-200');
+ const matchingHighDpi = duplicateManifest.screenshots.find(entry => (
+ entry.journey === duplicateZoom.journey && entry.variant === 'high-dpi'
+ ));
+ duplicateZoom.sha256 = matchingHighDpi.sha256;
+ duplicateZoom.repeatabilitySha256 = matchingHighDpi.sha256;
+ assert.throws(
+ () => validateAcceptanceEvidence(evidence.accessibility, duplicateManifest, evidence.summary, evidence.sanitizedLog),
+ /zoom-200 screenshot is not visually distinct/,
+ );
+ const screenshotPath = join(root, 'screenshots', expectedScreenshotNames()[0]);
+ await writeFile(screenshotPath, pngHeader(1, 1));
+ await assert.rejects(verifyAcceptanceArtifacts(root, { ocr: async () => '' }), /bytes, digest, or dimensions/);
+ } finally { await rm(parent, { recursive: true, force: true }); }
+ });
+
+ it('only recursively removes a dedicated non-link allowlisted leaf', async () => {
+ const parent = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-cleanup-'));
+ try {
+ const leaf = join(parent, ACCEPTANCE_ARTIFACT_LEAF);
+ await mkdir(join(leaf, 'nested'), { recursive: true });
+ await writeFile(join(leaf, 'nested', 'artifact'), 'safe');
+ await safeRemoveAcceptanceLeaf(resolve(leaf), { kind: 'artifact', allowedWorkspaceParents: [parent] });
+ await safeRemoveAcceptanceLeaf(resolve(leaf), { kind: 'artifact', allowedWorkspaceParents: [parent] });
+ await assert.rejects(safeRemoveAcceptanceLeaf(resolve(parent), { kind: 'artifact', allowedWorkspaceParents: [parent] }), /dedicated allowlisted leaf/);
+ const tooDeep = join(parent, 'nested', ACCEPTANCE_ARTIFACT_LEAF);
+ await mkdir(tooDeep, { recursive: true });
+ await assert.rejects(safeRemoveAcceptanceLeaf(resolve(tooDeep), { kind: 'artifact', allowedWorkspaceParents: [parent] }), /canonical allowlisted parent/);
+ const realParent = join(parent, 'real-parent');
+ const linkedParent = join(parent, 'linked-parent');
+ await mkdir(join(realParent, ACCEPTANCE_ARTIFACT_LEAF), { recursive: true });
+ await symlink(realParent, linkedParent);
+ await assert.rejects(
+ safeRemoveAcceptanceLeaf(resolve(linkedParent, ACCEPTANCE_ARTIFACT_LEAF), { kind: 'artifact', allowedWorkspaceParents: [linkedParent] }),
+ /allowed parent must be an existing non-link directory/,
+ );
+ const outside = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-escape-'));
+ try {
+ const escapedParent = join(parent, 'escaped-parent');
+ await symlink(outside, escapedParent);
+ await mkdir(join(outside, 'nested', ACCEPTANCE_ARTIFACT_LEAF), { recursive: true });
+ await assert.rejects(
+ safeRemoveAcceptanceLeaf(join(escapedParent, 'nested', ACCEPTANCE_ARTIFACT_LEAF), { kind: 'artifact', allowedWorkspaceParents: [parent] }),
+ /canonical allowlisted parent/,
+ );
+ } finally {
+ await rm(outside, { recursive: true, force: true });
+ }
+ const linkedLeaf = join(parent, ACCEPTANCE_ARTIFACT_LEAF);
+ await symlink(join(realParent, ACCEPTANCE_ARTIFACT_LEAF), linkedLeaf);
+ await assert.rejects(
+ safeRemoveAcceptanceLeaf(resolve(linkedLeaf), { kind: 'artifact', allowedWorkspaceParents: [parent] }),
+ /non-link directory/,
+ );
+ } finally { await rm(parent, { recursive: true, force: true }); }
+ });
+
+ it('authorizes the standard macOS temporary-directory alias by canonical parent identity', {
+ skip: process.platform !== 'darwin' ? 'macOS-only /var alias regression' : false,
+ }, async t => {
+ const aliasedParent = await mkdtemp(join(resolve(tmpdir()), 'propr-acceptance-macos-alias-'));
+ const canonicalParent = await realpath(aliasedParent);
+ if (aliasedParent === canonicalParent) {
+ await rm(aliasedParent, { recursive: true, force: true });
+ t.skip('the configured macOS temporary directory does not use an alias');
+ return;
+ }
+ const leaf = join(aliasedParent, ACCEPTANCE_ARTIFACT_LEAF);
+ try {
+ await mkdir(leaf);
+ await safeRemoveAcceptanceLeaf(leaf, { kind: 'artifact', allowedWorkspaceParents: [canonicalParent] });
+ await safeRemoveAcceptanceLeaf(leaf, { kind: 'artifact', allowedWorkspaceParents: [canonicalParent] });
+ } finally {
+ await rm(aliasedParent, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/apps/desktop/scripts/packaged-acceptance-axe.mjs b/apps/desktop/scripts/packaged-acceptance-axe.mjs
new file mode 100644
index 000000000..b12bd225d
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-axe.mjs
@@ -0,0 +1,171 @@
+import { createHash } from 'node:crypto';
+import { createRequire } from 'node:module';
+import axeCore from 'axe-core';
+
+const require = createRequire(import.meta.url);
+const axePackage = require('axe-core/package.json');
+
+export const PACKAGED_AXE_VERSION = '4.10.3';
+export const PACKAGED_AXE_SOURCE_SHA256 = '5248a3ce82beae19e2bbd73dc209055747cda599e59a02f3272045206904254f';
+export const PACKAGED_AXE_FRAME_POLICY = 'reject-all-child-frames';
+export const PACKAGED_AXE_CLEANUP_TIMEOUT_MS = 5_000;
+
+const axeSource = axeCore?.source;
+const axeSourceSha256 = typeof axeSource === 'string'
+ ? createHash('sha256').update(axeSource).digest('hex')
+ : '';
+
+if (axePackage?.name !== 'axe-core' || axePackage?.version !== PACKAGED_AXE_VERSION
+ || axeCore?.version !== PACKAGED_AXE_VERSION || typeof axeSource !== 'string'
+ || axeSource.length === 0 || axeSourceSha256 !== PACKAGED_AXE_SOURCE_SHA256) {
+ throw new Error('Packaged accessibility axe-core source identity or version is invalid');
+}
+
+const validateProjectedViolations = violations => {
+ if (!Array.isArray(violations)) {
+ throw new Error('Packaged accessibility result schema is invalid');
+ }
+ for (const violation of violations) {
+ if (!violation || typeof violation !== 'object'
+ || Object.keys(violation).length !== 3
+ || typeof violation.id !== 'string' || violation.id.length === 0
+ || (violation.impact !== 'serious' && violation.impact !== 'critical')
+ || !Number.isInteger(violation.nodes) || violation.nodes < 1) {
+ throw new Error('Packaged accessibility result schema is invalid');
+ }
+ }
+ return violations;
+};
+
+/**
+ * Run the pinned axe-core engine in the already-authenticated renderer target.
+ *
+ * The acceptance UI is intentionally single-frame. Child frames are rejected
+ * instead of being silently skipped or aggregated in a new page. Within that
+ * document, axe.run(document) performs axe-core's native composed-tree walk,
+ * including all open shadow roots supported by the engine.
+ */
+export const analyzeExistingElectronRenderer = async page => {
+ if (!page || typeof page.evaluate !== 'function' || typeof page.frames !== 'function'
+ || typeof page.mainFrame !== 'function') {
+ throw new Error('Packaged accessibility renderer target is invalid');
+ }
+
+ const frames = page.frames();
+ if (PACKAGED_AXE_FRAME_POLICY !== 'reject-all-child-frames'
+ || frames.length !== 1 || frames[0] !== page.mainFrame()) {
+ throw new Error('Packaged accessibility does not permit child frames');
+ }
+
+ let injected = false;
+ let primaryError;
+ try {
+ const injectionBoundary = await page.evaluate(() => ({
+ childFrames: window.frames.length,
+ existingAxe: typeof globalThis.axe !== 'undefined',
+ }));
+ if (!injectionBoundary || injectionBoundary.childFrames !== 0 || injectionBoundary.existingAxe !== false) {
+ throw new Error('Packaged accessibility injection boundary is invalid');
+ }
+
+ try {
+ injected = true;
+ await page.evaluate(axeSource);
+ } catch (error) {
+ throw new Error('Packaged accessibility axe-core injection failed', { cause: error });
+ }
+
+ const identity = await page.evaluate(() => ({
+ version: globalThis.axe?.version,
+ run: typeof globalThis.axe?.run,
+ cleanup: typeof globalThis.axe?.cleanup,
+ }));
+ if (!identity || identity.version !== PACKAGED_AXE_VERSION
+ || identity.run !== 'function' || identity.cleanup !== 'function') {
+ throw new Error('Packaged accessibility injected engine identity or version is invalid');
+ }
+
+ let violations;
+ try {
+ violations = await page.evaluate(async expectedVersion => {
+ const engine = globalThis.axe;
+ const results = await engine.run(document, { resultTypes: ['violations'] });
+ const validImpact = new Set([null, 'minor', 'moderate', 'serious', 'critical']);
+ if (!results || typeof results !== 'object'
+ || results.testEngine?.name !== 'axe-core' || results.testEngine?.version !== expectedVersion
+ || !Array.isArray(results.violations)) {
+ throw new Error('axe-core returned an invalid result envelope');
+ }
+ const projected = [];
+ for (const violation of results.violations) {
+ if (!violation || typeof violation !== 'object'
+ || typeof violation.id !== 'string' || violation.id.length === 0
+ || !validImpact.has(violation.impact) || !Array.isArray(violation.nodes)
+ || violation.nodes.some(node => !node || typeof node !== 'object')) {
+ throw new Error('axe-core returned an invalid violation');
+ }
+ if (violation.impact === 'serious' || violation.impact === 'critical') {
+ projected.push({ id: violation.id, impact: violation.impact, nodes: violation.nodes.length });
+ }
+ }
+ return projected;
+ }, PACKAGED_AXE_VERSION);
+ } catch (error) {
+ throw new Error('Packaged accessibility axe-core execution or result validation failed', { cause: error });
+ }
+ return validateProjectedViolations(violations);
+ } catch (error) {
+ primaryError = error;
+ throw error;
+ } finally {
+ if (injected) {
+ let cleaned = false;
+ let cleanupError;
+ try {
+ cleaned = await page.evaluate(async cleanupTimeoutMs => {
+ const engine = globalThis.axe;
+ if (!engine || typeof engine.cleanup !== 'function') {
+ throw new Error('axe-core cleanup is unavailable');
+ }
+ // axe-core 4.10.3 cleanup completes through these callbacks after
+ // its plugin/frame queue drains; its direct return value is not completion.
+ await new Promise((resolve, reject) => {
+ let settled = false;
+ let timeout;
+ const settle = callback => value => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ callback(value);
+ };
+ const resolveCleanup = settle(resolve);
+ const rejectCleanup = settle(reject);
+ timeout = setTimeout(
+ () => rejectCleanup(new Error(`axe-core cleanup timed out after ${cleanupTimeoutMs}ms`)),
+ cleanupTimeoutMs,
+ );
+ try {
+ engine.cleanup(resolveCleanup, rejectCleanup);
+ } catch (error) {
+ rejectCleanup(error);
+ }
+ });
+ return Reflect.deleteProperty(globalThis, 'axe') && typeof globalThis.axe === 'undefined';
+ }, PACKAGED_AXE_CLEANUP_TIMEOUT_MS);
+ } catch (error) {
+ cleanupError = new Error('Packaged accessibility axe-core cleanup failed', { cause: error });
+ }
+ if (!cleaned && !cleanupError) {
+ cleanupError = new Error('Packaged accessibility axe-core cleanup failed');
+ }
+ if (cleanupError) {
+ if (!primaryError) throw cleanupError;
+ throw new AggregateError(
+ [primaryError, cleanupError],
+ primaryError instanceof Error ? primaryError.message : String(primaryError),
+ { cause: primaryError },
+ );
+ }
+ }
+ }
+};
diff --git a/apps/desktop/scripts/packaged-acceptance-clock.d.mts b/apps/desktop/scripts/packaged-acceptance-clock.d.mts
new file mode 100644
index 000000000..f4dc875f6
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-clock.d.mts
@@ -0,0 +1,2 @@
+export const PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS: number;
+export const PACKAGED_ACCEPTANCE_TIME: string;
diff --git a/apps/desktop/scripts/packaged-acceptance-clock.mjs b/apps/desktop/scripts/packaged-acceptance-clock.mjs
new file mode 100644
index 000000000..bd554adfd
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-clock.mjs
@@ -0,0 +1,8 @@
+/**
+ * One epoch owns every deterministic packaged-acceptance clock. Production
+ * code must consume it only through the dual-authorized acceptance boundary.
+ */
+export const PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS = 1_767_323_045_000;
+export const PACKAGED_ACCEPTANCE_TIME = new Date(
+ PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS,
+).toISOString();
diff --git a/apps/desktop/scripts/packaged-acceptance-current-user.mjs b/apps/desktop/scripts/packaged-acceptance-current-user.mjs
new file mode 100644
index 000000000..d9f86f946
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-current-user.mjs
@@ -0,0 +1,183 @@
+import { DESKTOP_RENDERER_ORIGIN } from '@propr/shared';
+
+export const CURRENT_USER_SCOPE_GENERATION_QUERY = 'proprDesktopScopeGeneration';
+
+export const scopedCurrentUserRequestGeneration = (method, url) => {
+ if (method !== 'GET' || typeof url !== 'string') return null;
+ let parsed;
+ try {
+ parsed = new URL(url, 'http://fixture.invalid');
+ } catch {
+ return null;
+ }
+ if (parsed.pathname !== '/api/auth/user') return null;
+ const entries = [...parsed.searchParams];
+ if (entries.length !== 1 || entries[0][0] !== CURRENT_USER_SCOPE_GENERATION_QUERY) return null;
+ const value = entries[0][1];
+ if (!/^(?:0|[1-9]\d{0,15})$/.test(value)
+ || parsed.search !== `?${CURRENT_USER_SCOPE_GENERATION_QUERY}=${value}`) return null;
+ const generation = Number(value);
+ return Number.isSafeInteger(generation) ? generation : null;
+};
+
+/** Classify only the two authenticated current-user request shapes used by desktop. */
+export const classifyCurrentUserRequestShape = (method, url, origin) => {
+ if (method !== 'GET' || typeof url !== 'string') return null;
+ let parsed;
+ try {
+ parsed = new URL(url, 'http://fixture.invalid');
+ } catch {
+ return null;
+ }
+ if (parsed.pathname !== '/api/auth/user') return null;
+
+ if (origin === DESKTOP_RENDERER_ORIGIN) {
+ const scopeGeneration = scopedCurrentUserRequestGeneration(method, url);
+ return scopeGeneration === null ? null : { source: 'renderer', scopeGeneration };
+ }
+
+ return url === '/api/auth/user' && parsed.search === '' && [...parsed.searchParams].length === 0
+ ? { source: 'main', scopeGeneration: null }
+ : null;
+};
+
+const boundedCount = records => Math.min(records.length, 9);
+
+/** Fixed, bounded, secret-free counts for Local Network Access decisions. */
+export const networkPermissionDecisionSummary = ({ journey, records, invalidCount = 0 }) => {
+ const selected = records.filter(record => record.journey === journey);
+ const count = predicate => boundedCount(selected.filter(predicate));
+ return {
+ schemaVersion: 1,
+ records: boundedCount(selected),
+ check: count(record => record.decision === 'check'),
+ request: count(record => record.decision === 'request'),
+ allowed: count(record => record.allowed === true),
+ denied: count(record => record.allowed === false),
+ localNetworkAccess: count(record => record.permissionCategory === 'local-network-access'),
+ localNetwork: count(record => record.permissionCategory === 'local-network'),
+ loopbackNetwork: count(record => record.permissionCategory === 'loopback-network'),
+ activeBindingCurrent: count(record => record.activeBindingCurrent === true),
+ webContentsPresent: count(record => record.webContentsPresent === true),
+ webContentsEqualsMainWindow: count(record => record.webContentsEqualsMainWindow === true),
+ mainWindowPresent: count(record => record.mainWindowPresent === true),
+ mainFrame: count(record => record.isMainFrame === true),
+ requestingUrlPresent: count(record => record.requestingUrlPresent === true),
+ requestingUrlTrusted: count(record => record.requestingUrlTrusted === true),
+ rendererDocumentUrlTrusted: count(record => record.rendererDocumentUrlTrusted === true),
+ requestingOriginAuthorityValid: count(record => record.requestingOriginAuthorityValid === true),
+ requestingOriginAuthorityEqual: count(record => record.requestingOriginAuthorityEqual === true),
+ invalid: Math.min(invalidCount, 9),
+ };
+};
+
+/** Fixed, bounded, secret-free diagnostics for the strict hosted boundary. */
+export const currentUserValidationPhaseSummary = ({
+ journey,
+ rendererRecords,
+ mainRecords,
+ fixtureRecords,
+ requestRecords,
+}) => {
+ const renderer = rendererRecords.filter(record => record.journey === journey
+ && record.activeScopePresent && record.scopeGeneration > 0);
+ const main = mainRecords.filter(record => record.journey === journey);
+ const fixture = fixtureRecords.filter(record => record.journey === journey
+ && record.source === 'renderer');
+ const currentUserRequests = requestRecords.filter(record => record.journey === journey
+ && record.origin === DESKTOP_RENDERER_ORIGIN
+ && scopedCurrentUserRequestGeneration('GET', record.url) !== null);
+ const phaseCount = phase => boundedCount(renderer.filter(record => record.phase === phase));
+ return {
+ schemaVersion: 1,
+ options: boundedCount(currentUserRequests.filter(record => record.method === 'OPTIONS')),
+ get: boundedCount(currentUserRequests.filter(record => record.method === 'GET')),
+ main: boundedCount(main),
+ mainAccepted: boundedCount(main.filter(record => record.accepted === true)),
+ fixture: boundedCount(fixture),
+ fixture200: boundedCount(fixture.filter(record => record.responseStatus === 200)),
+ requestIssued: phaseCount('request-issued'),
+ responseCompleted: phaseCount('response-completed'),
+ parsedUserAccepted: phaseCount('parsed-user-accepted'),
+ activeScopeAccepted: phaseCount('active-scope-accepted'),
+ rejected: boundedCount(renderer.filter(record => record.phase.endsWith('-rejected'))),
+ };
+};
+
+export const currentUserValidationFailureCategory = ({
+ journey,
+ evidenceInvalid,
+ rendererRecords,
+ mainRecords,
+ fixtureRecords,
+}) => {
+ if (evidenceInvalid) return 'current-user-evidence-invalid';
+ const renderer = rendererRecords.filter(record => record.journey === journey
+ && record.activeScopePresent && record.scopeGeneration > 0);
+ const issued = renderer.filter(record => record.phase === 'request-issued');
+ if (issued.length === 0) return 'current-user-renderer-request-not-issued';
+ if (issued.length !== 1) return 'current-user-renderer-request-duplicate';
+ const generation = issued[0].scopeGeneration;
+
+ // Correlate the exact journey first. Security predicates below must diagnose
+ // the selected request rather than making rejected evidence unobservable.
+ const main = mainRecords.filter(record => record.journey === journey);
+ if (main.length === 0) return 'current-user-main-proxy-not-observed';
+ if (main.length !== 1) return 'current-user-main-proxy-duplicate';
+ const observed = main[0];
+ if (!observed.scopeGenerationQueryValid || observed.scopeGenerationQueryCount !== 1
+ || observed.rendererScopeGeneration !== generation) {
+ return 'current-user-main-generation-correlation-invalid';
+ }
+ if (observed.scopeHeaderCount !== 1) {
+ if (observed.scopeHeaderCount === 0 && !observed.accepted
+ && observed.rejectionCategory === 'scope-missing') return 'current-user-main-scope-missing';
+ if (observed.scopeHeaderCount === 2 && !observed.accepted
+ && observed.rejectionCategory === 'scope-duplicate') return 'current-user-main-scope-duplicate';
+ return 'current-user-main-scope-header-count-invalid';
+ }
+ if (!observed.activeBindingPresent) {
+ return !observed.accepted && observed.rejectionCategory === 'no-active-binding'
+ ? 'current-user-main-no-active-binding'
+ : 'current-user-main-active-binding-missing';
+ }
+ if (!Number.isSafeInteger(observed.activeScopeGeneration) || observed.activeScopeGeneration < 0) {
+ return 'current-user-main-active-generation-invalid';
+ }
+ if (!observed.accepted) return `current-user-main-${observed.rejectionCategory}`;
+ if (observed.rejectionCategory !== 'none') return 'current-user-main-rejection-category-invalid';
+ if (!observed.profileGenerationCurrent || !observed.scopeEqualsActive || !observed.originEqualsActive) {
+ return 'current-user-main-active-scope-rejected';
+ }
+ if (observed.rendererBearerPresent || observed.rendererCookiePresent
+ || !observed.outboundBearerPresent || !observed.bearerMainInjected) {
+ return 'current-user-main-bearer-custody-invalid';
+ }
+ // Select the journey's exact renderer-originated upstream observation before
+ // asserting bearer custody so a malformed request cannot look unobserved.
+ const fixture = fixtureRecords.filter(record => record.journey === journey
+ && record.source === 'renderer');
+ if (fixture.length === 0) return 'current-user-upstream-request-not-arrived';
+ if (fixture.length !== 1) return 'current-user-upstream-request-duplicate';
+ if (fixture[0].scopeGeneration !== generation) return 'current-user-upstream-generation-correlation-invalid';
+ if (!fixture[0].authorizationPresent || !fixture[0].authorizationMatchesActivatedBearer
+ || fixture[0].cookiePresent) return 'current-user-upstream-bearer-custody-invalid';
+ if (fixture[0].responseStatus !== 200) return `current-user-response-http-${fixture[0].responseStatus}`;
+ const completed = renderer.filter(record => record.scopeGeneration === generation
+ && record.phase === 'response-completed');
+ if (completed.length === 0) return 'current-user-renderer-response-not-completed';
+ if (completed.length !== 1 || completed[0].responseStatus !== 200
+ || completed[0].classification !== 'success') return 'current-user-renderer-response-rejected';
+ const parsed = renderer.filter(record => record.scopeGeneration === generation
+ && (record.phase === 'parsed-user-accepted' || record.phase === 'parsed-user-rejected'));
+ if (parsed.length === 0) return 'current-user-parsed-schema-not-observed';
+ if (parsed.length !== 1 || parsed[0].phase !== 'parsed-user-accepted' || !parsed[0].schemaAccepted) {
+ return 'current-user-parsed-schema-rejected';
+ }
+ const accepted = renderer.filter(record => record.scopeGeneration === generation
+ && record.phase === 'active-scope-accepted');
+ if (accepted.length !== 1) return accepted.length === 0
+ ? 'current-user-active-scope-not-accepted'
+ : 'current-user-active-scope-accepted-duplicate';
+ return 'none';
+};
diff --git a/apps/desktop/scripts/packaged-acceptance-current-user.test.mjs b/apps/desktop/scripts/packaged-acceptance-current-user.test.mjs
new file mode 100644
index 000000000..888829385
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-current-user.test.mjs
@@ -0,0 +1,284 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import {
+ classifyCurrentUserRequestShape,
+ currentUserValidationPhaseSummary,
+ currentUserValidationFailureCategory,
+ networkPermissionDecisionSummary,
+ scopedCurrentUserRequestGeneration,
+} from './packaged-acceptance-current-user.mjs';
+
+const journey = 'dashboard-profile-manager';
+const rendererRecords = [
+ { journey, activeScopePresent: true, scopeGeneration: 1, phase: 'request-issued' },
+ {
+ journey, activeScopePresent: true, scopeGeneration: 1, phase: 'response-completed',
+ responseStatus: 200, classification: 'success',
+ },
+ {
+ journey, activeScopePresent: true, scopeGeneration: 1, phase: 'parsed-user-accepted', schemaAccepted: true,
+ },
+ { journey, activeScopePresent: true, scopeGeneration: 1, phase: 'active-scope-accepted' },
+];
+const acceptedMain = {
+ journey,
+ rendererScopeGeneration: 1,
+ scopeGenerationQueryCount: 1,
+ scopeGenerationQueryValid: true,
+ scopeHeaderCount: 1,
+ activeBindingPresent: true,
+ activeScopeGeneration: 0,
+ profileGenerationCurrent: true,
+ scopeEqualsActive: true,
+ originEqualsActive: true,
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ outboundBearerPresent: true,
+ bearerMainInjected: true,
+ accepted: true,
+ rejectionCategory: 'none',
+};
+const fixtureRecords = [{
+ journey,
+ source: 'renderer',
+ scopeGeneration: 1,
+ requestArrived: true,
+ authorizationPresent: true,
+ authorizationMatchesActivatedBearer: true,
+ cookiePresent: false,
+ responseStatus: 200,
+}];
+const classify = mainRecord => currentUserValidationFailureCategory({
+ journey,
+ evidenceInvalid: false,
+ rendererRecords,
+ mainRecords: [mainRecord],
+ fixtureRecords,
+});
+
+describe('packaged scoped current-user request URL', () => {
+ it('accepts one canonical bounded desktop generation parameter', () => {
+ assert.equal(scopedCurrentUserRequestGeneration(
+ 'GET', '/api/auth/user?proprDesktopScopeGeneration=0',
+ ), 0);
+ assert.equal(scopedCurrentUserRequestGeneration(
+ 'GET', `/api/auth/user?proprDesktopScopeGeneration=${Number.MAX_SAFE_INTEGER}`,
+ ), Number.MAX_SAFE_INTEGER);
+ });
+
+ it('rejects hosted, non-GET, duplicate, unrelated, and unbounded query forms', () => {
+ for (const [method, url] of [
+ ['GET', '/api/auth/user'],
+ ['POST', '/api/auth/user?proprDesktopScopeGeneration=1'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=1&proprDesktopScopeGeneration=2'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=1&credential=secret'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=01'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=9007199254740992'],
+ ['GET', '/api/smoke/rest?proprDesktopScopeGeneration=1'],
+ ]) {
+ assert.equal(scopedCurrentUserRequestGeneration(method, url), null, `${method} ${url}`);
+ }
+ });
+});
+
+describe('packaged current-user fixture request shapes', () => {
+ it('distinguishes the exact main probe from renderer-scoped validation', () => {
+ assert.deepEqual(classifyCurrentUserRequestShape(
+ 'GET', '/api/auth/user', undefined,
+ ), { source: 'main', scopeGeneration: null });
+ assert.deepEqual(classifyCurrentUserRequestShape(
+ 'GET', '/api/auth/user', 'https://non-renderer.example.test',
+ ), { source: 'main', scopeGeneration: null });
+ assert.deepEqual(classifyCurrentUserRequestShape(
+ 'GET', '/api/auth/user?proprDesktopScopeGeneration=7', 'propr-app://renderer',
+ ), { source: 'renderer', scopeGeneration: 7 });
+ });
+
+ it('rejects cross-assigned, duplicate, extra, and noncanonical encoded forms', () => {
+ for (const [method, url, origin] of [
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=7', undefined],
+ ['GET', '/api/auth/user', 'propr-app://renderer'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=7&proprDesktopScopeGeneration=8', 'propr-app://renderer'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=7&extra=1', 'propr-app://renderer'],
+ ['GET', '/api/auth/user?%70roprDesktopScopeGeneration=7', 'propr-app://renderer'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=%37', 'propr-app://renderer'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=07', 'propr-app://renderer'],
+ ['GET', '/api/auth/user?', undefined],
+ ['POST', '/api/auth/user', undefined],
+ ]) {
+ assert.equal(classifyCurrentUserRequestShape(method, url, origin), null, `${method} ${url}`);
+ }
+ });
+});
+
+describe('packaged current-user acceptance correlation', () => {
+ it('diagnoses a correlated zero-scope record', () => {
+ assert.equal(classify({
+ ...acceptedMain, scopeHeaderCount: 0, accepted: false, rejectionCategory: 'scope-missing',
+ outboundBearerPresent: false, bearerMainInjected: false,
+ }), 'current-user-main-scope-missing');
+ });
+
+ it('diagnoses a correlated duplicate-scope record', () => {
+ assert.equal(classify({
+ ...acceptedMain, scopeHeaderCount: 2, accepted: false, rejectionCategory: 'scope-duplicate',
+ outboundBearerPresent: false, bearerMainInjected: false,
+ }), 'current-user-main-scope-duplicate');
+ });
+
+ it('diagnoses a correlated no-binding record', () => {
+ assert.equal(classify({
+ ...acceptedMain, activeBindingPresent: false, accepted: false, rejectionCategory: 'no-active-binding',
+ profileGenerationCurrent: false, scopeEqualsActive: false, originEqualsActive: false,
+ outboundBearerPresent: false, bearerMainInjected: false,
+ }), 'current-user-main-no-active-binding');
+ });
+
+ it('diagnoses a correlated stale-scope record', () => {
+ assert.equal(classify({
+ ...acceptedMain, accepted: false, rejectionCategory: 'stale-scope', scopeEqualsActive: false,
+ outboundBearerPresent: false, bearerMainInjected: false,
+ }), 'current-user-main-stale-scope');
+ });
+
+ it('accepts the current scope with main-only bearer custody', () => {
+ assert.equal(classify(acceptedMain), 'none');
+ });
+
+ it('rejects a main or upstream record from another renderer generation', () => {
+ assert.equal(classify({
+ ...acceptedMain, rendererScopeGeneration: 2,
+ }), 'current-user-main-generation-correlation-invalid');
+ assert.equal(currentUserValidationFailureCategory({
+ journey,
+ evidenceInvalid: false,
+ rendererRecords,
+ mainRecords: [acceptedMain],
+ fixtureRecords: [{ ...fixtureRecords[0], scopeGeneration: 2 }],
+ }), 'current-user-upstream-generation-correlation-invalid');
+ });
+
+ it('correlates main evidence by journey and upstream evidence by renderer request origin', () => {
+ assert.equal(currentUserValidationFailureCategory({
+ journey,
+ evidenceInvalid: false,
+ rendererRecords,
+ mainRecords: [
+ { ...acceptedMain, journey: 'another-journey', accepted: false, rejectionCategory: 'stale-scope' },
+ acceptedMain,
+ ],
+ fixtureRecords: [
+ { ...fixtureRecords[0], source: 'main', authorizationMatchesActivatedBearer: false },
+ fixtureRecords[0],
+ ],
+ }), 'none');
+ });
+
+ it('diagnoses upstream bearer custody after correlating the fixture record', () => {
+ assert.equal(currentUserValidationFailureCategory({
+ journey,
+ evidenceInvalid: false,
+ rendererRecords,
+ mainRecords: [acceptedMain],
+ fixtureRecords: [{ ...fixtureRecords[0], authorizationMatchesActivatedBearer: false }],
+ }), 'current-user-upstream-bearer-custody-invalid');
+ });
+});
+
+describe('packaged current-user strict failure diagnostics', () => {
+ it('reports fixed bounded Local Network Access decision counts', () => {
+ const base = {
+ journey,
+ permissionCategory: 'loopback-network',
+ decision: 'check',
+ allowed: true,
+ activeBindingCurrent: true,
+ webContentsPresent: false,
+ webContentsEqualsMainWindow: false,
+ mainWindowPresent: true,
+ isMainFrame: true,
+ requestingUrlPresent: false,
+ requestingUrlTrusted: false,
+ rendererDocumentUrlTrusted: true,
+ requestingOriginAuthorityValid: true,
+ requestingOriginAuthorityEqual: true,
+ };
+ const summary = networkPermissionDecisionSummary({
+ journey,
+ records: [
+ base,
+ {
+ ...base,
+ permissionCategory: 'local-network-access',
+ decision: 'request',
+ allowed: false,
+ webContentsPresent: true,
+ webContentsEqualsMainWindow: true,
+ requestingUrlPresent: true,
+ requestingUrlTrusted: true,
+ },
+ ...Array.from({ length: 12 }, () => ({ ...base, journey: 'another-journey' })),
+ ],
+ invalidCount: 12,
+ });
+
+ assert.deepEqual(summary, {
+ schemaVersion: 1,
+ records: 2,
+ check: 1,
+ request: 1,
+ allowed: 1,
+ denied: 1,
+ localNetworkAccess: 1,
+ localNetwork: 0,
+ loopbackNetwork: 1,
+ activeBindingCurrent: 2,
+ webContentsPresent: 1,
+ webContentsEqualsMainWindow: 1,
+ mainWindowPresent: 2,
+ mainFrame: 2,
+ requestingUrlPresent: 1,
+ requestingUrlTrusted: 1,
+ rendererDocumentUrlTrusted: 2,
+ requestingOriginAuthorityValid: 2,
+ requestingOriginAuthorityEqual: 2,
+ invalid: 9,
+ });
+ assert.doesNotMatch(JSON.stringify(summary), /example\.test|propr_it_|Bearer|renderer\.html/);
+ });
+
+ it('reports fixed bounded phase counts without retaining request secrets', () => {
+ const summary = currentUserValidationPhaseSummary({
+ journey,
+ rendererRecords: [
+ { journey, activeScopePresent: true, scopeGeneration: 1, phase: 'request-issued' },
+ { journey, activeScopePresent: true, scopeGeneration: 1, phase: 'active-scope-rejected' },
+ ],
+ mainRecords: [],
+ fixtureRecords: [],
+ requestRecords: Array.from({ length: 12 }, () => ({
+ journey,
+ method: 'OPTIONS',
+ origin: 'propr-app://renderer',
+ url: '/api/auth/user?proprDesktopScopeGeneration=1',
+ authorization: 'Bearer must-not-appear',
+ })),
+ });
+
+ assert.deepEqual(summary, {
+ schemaVersion: 1,
+ options: 9,
+ get: 0,
+ main: 0,
+ mainAccepted: 0,
+ fixture: 0,
+ fixture200: 0,
+ requestIssued: 1,
+ responseCompleted: 0,
+ parsedUserAccepted: 0,
+ activeScopeAccepted: 0,
+ rejected: 1,
+ });
+ assert.equal(JSON.stringify(summary).includes('must-not-appear'), false);
+ });
+});
diff --git a/apps/desktop/scripts/packaged-acceptance-renderer.mjs b/apps/desktop/scripts/packaged-acceptance-renderer.mjs
new file mode 100644
index 000000000..3fd3df3f7
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-renderer.mjs
@@ -0,0 +1,340 @@
+const defaultSleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
+
+const closeEnough = (actual, expected) => Number.isFinite(actual) && Math.abs(actual - expected) < 0.01;
+const ELECTRON_ZOOM_MECHANISM = 'electron-web-frame';
+const MAX_VIEWPORT_INSET_CSS_PIXELS = 64;
+
+const diagnosticSuffix = diagnostics => `; measurements=${JSON.stringify(diagnostics)}`;
+
+const assertRendererMetrics = (actual, expected, description, diagnostics) => {
+ if (!closeEnough(actual, expected)) {
+ throw new Error(`Packaged Electron ${description} changed: expected ${expected}, received ${actual}${diagnosticSuffix(diagnostics)}`);
+ }
+};
+
+const assertExactRendererMetric = (actual, expected, description, diagnostics) => {
+ if (actual !== expected) {
+ throw new Error(`Packaged Electron ${description} changed: expected exactly ${expected}, received ${actual}${diagnosticSuffix(diagnostics)}`);
+ }
+};
+
+const assertExactKeys = (value, keys, description) => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)
+ || Object.keys(value).sort().join('\n') !== [...keys].sort().join('\n')) {
+ throw new Error(`Packaged Electron ${description} acknowledgement is malformed`);
+ }
+};
+
+const measureRenderer = async (page, cdp) => {
+ const [layout, renderer] = await Promise.all([
+ cdp.send('Page.getLayoutMetrics'),
+ page.evaluate(() => ({
+ width: window.innerWidth,
+ height: window.innerHeight,
+ documentClientViewport: {
+ width: document.documentElement.clientWidth,
+ height: document.documentElement.clientHeight,
+ },
+ devicePixelRatio: window.devicePixelRatio,
+ reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
+ visualViewport: window.visualViewport && {
+ width: window.visualViewport.width,
+ height: window.visualViewport.height,
+ scale: window.visualViewport.scale,
+ },
+ })),
+ ]);
+ return { layout, renderer };
+};
+
+const waitForStableRendererMeasurement = async (
+ page,
+ cdp,
+ { settle, settleMs, measurementTimeoutMs, stableMeasurements },
+) => {
+ const deadline = Date.now() + measurementTimeoutMs;
+ let previous;
+ let stable = 0;
+ let measurement;
+ do {
+ measurement = await measureRenderer(page, cdp);
+ const serialized = JSON.stringify(measurement);
+ stable = serialized === previous ? stable + 1 : 1;
+ previous = serialized;
+ if (stable >= stableMeasurements) return measurement;
+ await settle(settleMs);
+ } while (Date.now() <= deadline);
+ throw new Error(`Packaged Electron renderer zoom metrics did not stabilize within ${measurementTimeoutMs}ms`);
+};
+
+/**
+ * Configure the visible Electron renderer without relying on Browser-domain
+ * commands on a target-scoped CDP session. Playwright owns the viewport/window
+ * resize and target DPR. The authenticated context-isolated preload bridge is
+ * the sole zoom authority; CDP page scale is intentionally not used.
+ */
+export const configureElectronRendererVariant = async (
+ page,
+ cdp,
+ config,
+ {
+ colorScheme = 'light',
+ settle = defaultSleep,
+ settleMs = 25,
+ measurementTimeoutMs = 3_000,
+ stableMeasurements = 3,
+ } = {},
+) => {
+ const { viewport, deviceScaleFactor, zoom, reducedMotion } = config || {};
+ if (!Number.isInteger(viewport?.width) || viewport.width <= 0
+ || !Number.isInteger(viewport?.height) || viewport.height <= 0
+ || !Number.isFinite(deviceScaleFactor) || deviceScaleFactor <= 0
+ || (zoom !== 1 && zoom !== 2) || typeof reducedMotion !== 'boolean'
+ || !Number.isInteger(measurementTimeoutMs) || measurementTimeoutMs <= 0
+ || !Number.isInteger(stableMeasurements) || stableMeasurements < 2) {
+ throw new Error('Packaged Electron renderer variant configuration is invalid');
+ }
+
+ await page.emulateMedia({
+ reducedMotion: reducedMotion ? 'reduce' : 'no-preference',
+ colorScheme,
+ });
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
+ await cdp.send('Emulation.setDeviceMetricsOverride', {
+ width: viewport.width,
+ height: viewport.height,
+ deviceScaleFactor,
+ mobile: false,
+ screenWidth: viewport.width,
+ screenHeight: viewport.height,
+ });
+ const zoomAcknowledgement = await page.evaluate(async requestedFactor => {
+ const bridge = globalThis.__PROPR_PACKAGED_ACCEPTANCE__;
+ if (!bridge || typeof bridge !== 'object' || typeof bridge.setZoomFactor !== 'function') {
+ throw new Error('Packaged acceptance Electron zoom bridge is unavailable');
+ }
+ return bridge.setZoomFactor(requestedFactor);
+ }, zoom);
+ assertExactKeys(
+ zoomAcknowledgement,
+ ['requestedFactor', 'resetFactor', 'appliedFactor', 'mechanism'],
+ 'zoom bridge',
+ );
+ if (zoomAcknowledgement.requestedFactor !== zoom || zoomAcknowledgement.resetFactor !== 1
+ || zoomAcknowledgement.appliedFactor !== zoom || zoomAcknowledgement.mechanism !== ELECTRON_ZOOM_MECHANISM) {
+ throw new Error(`Packaged Electron zoom bridge acknowledgement changed: ${JSON.stringify(zoomAcknowledgement)}`);
+ }
+
+ const playwrightViewport = page.viewportSize();
+ const { layout, renderer } = await waitForStableRendererMeasurement(page, cdp, {
+ settle, settleMs, measurementTimeoutMs, stableMeasurements,
+ });
+ const effectiveVisibleCssSpan = {
+ width: renderer?.width,
+ height: renderer?.height,
+ };
+ const geometryZoom = {
+ width: playwrightViewport?.width / effectiveVisibleCssSpan.width,
+ height: playwrightViewport?.height / effectiveVisibleCssSpan.height,
+ };
+ const expectedEffective = {
+ width: viewport.width / zoom,
+ height: viewport.height / zoom,
+ };
+ const layoutViewport = {
+ width: layout?.cssLayoutViewport?.clientWidth,
+ height: layout?.cssLayoutViewport?.clientHeight,
+ };
+ const cdpVisualViewport = {
+ width: layout?.cssVisualViewport?.clientWidth,
+ height: layout?.cssVisualViewport?.clientHeight,
+ scale: layout?.cssVisualViewport?.scale,
+ };
+ const rendererVisualViewport = {
+ width: renderer?.visualViewport?.width,
+ height: renderer?.visualViewport?.height,
+ scale: renderer?.visualViewport?.scale,
+ };
+ const documentClientViewport = {
+ width: renderer?.documentClientViewport?.width,
+ height: renderer?.documentClientViewport?.height,
+ };
+ const scrollbarInsets = {
+ width: renderer?.width - documentClientViewport.width,
+ height: renderer?.height - documentClientViewport.height,
+ };
+ const visualViewportInsets = {
+ width: renderer?.width - rendererVisualViewport.width,
+ height: renderer?.height - rendererVisualViewport.height,
+ };
+ // Keep failure output limited to explicitly selected numeric geometry. This
+ // makes a native CDP mismatch actionable without serializing page content.
+ const measurementDiagnostics = {
+ requestedViewport: viewport,
+ playwrightViewport,
+ rendererViewport: effectiveVisibleCssSpan,
+ documentClientViewport,
+ layoutViewport,
+ cdpVisualViewport,
+ rendererVisualViewport,
+ scrollbarInsets,
+ visualViewportInsets,
+ geometryZoom,
+ };
+
+ assertExactRendererMetric(playwrightViewport?.width, viewport.width, 'Playwright viewport width', measurementDiagnostics);
+ assertExactRendererMetric(playwrightViewport?.height, viewport.height, 'Playwright viewport height', measurementDiagnostics);
+ assertExactRendererMetric(renderer?.width, expectedEffective.width, 'renderer viewport width', measurementDiagnostics);
+ assertExactRendererMetric(renderer?.height, expectedEffective.height, 'renderer viewport height', measurementDiagnostics);
+ assertRendererMetrics(renderer?.devicePixelRatio, deviceScaleFactor * zoom, 'renderer device pixel ratio', measurementDiagnostics);
+ assertRendererMetrics(geometryZoom.width, zoom, 'measured renderer geometry width ratio', measurementDiagnostics);
+ assertRendererMetrics(geometryZoom.height, zoom, 'measured renderer geometry height ratio', measurementDiagnostics);
+ for (const [kind, insets] of Object.entries({ scrollbar: scrollbarInsets, 'visual viewport': visualViewportInsets })) {
+ for (const [axis, inset] of Object.entries(insets)) {
+ if (!Number.isFinite(inset) || inset < 0 || inset > MAX_VIEWPORT_INSET_CSS_PIXELS) {
+ throw new Error(`Packaged Electron ${axis} ${kind} inset is invalid: expected 0-${MAX_VIEWPORT_INSET_CSS_PIXELS}, received ${inset}${diagnosticSuffix(measurementDiagnostics)}`);
+ }
+ }
+ }
+ assertExactRendererMetric(layoutViewport.width, documentClientViewport.width, 'layout viewport width versus document client width', measurementDiagnostics);
+ assertExactRendererMetric(layoutViewport.height, documentClientViewport.height, 'layout viewport height versus document client height', measurementDiagnostics);
+ assertExactRendererMetric(rendererVisualViewport.width, cdpVisualViewport.width, 'renderer visual viewport width versus raw CDP visual width', measurementDiagnostics);
+ assertExactRendererMetric(rendererVisualViewport.height, cdpVisualViewport.height, 'renderer visual viewport height versus raw CDP visual height', measurementDiagnostics);
+ assertExactRendererMetric(rendererVisualViewport.scale, cdpVisualViewport.scale, 'renderer visual viewport scale versus raw CDP visual scale', measurementDiagnostics);
+ assertExactRendererMetric(rendererVisualViewport.scale, 1, 'raw renderer page scale', measurementDiagnostics);
+ if (renderer?.reducedMotion !== reducedMotion) {
+ throw new Error(`Packaged Electron reduced-motion emulation changed: expected ${reducedMotion}, received ${renderer?.reducedMotion}${diagnosticSuffix(measurementDiagnostics)}`);
+ }
+
+ return {
+ requestedViewport: { ...viewport },
+ playwrightViewport: { ...playwrightViewport },
+ rendererViewport: { width: renderer.width, height: renderer.height },
+ documentClientViewport,
+ scrollbarInsets,
+ visualViewportInsets,
+ layoutViewport,
+ cdpVisualViewport,
+ rendererVisualViewport,
+ effectiveVisibleCssSpan,
+ geometryZoom,
+ requestedDeviceScaleFactor: deviceScaleFactor,
+ rendererDevicePixelRatio: renderer.devicePixelRatio,
+ requestedZoomFactor: zoom,
+ appliedZoomFactor: zoomAcknowledgement.appliedFactor,
+ zoomResetFactor: zoomAcknowledgement.resetFactor,
+ zoomMechanism: zoomAcknowledgement.mechanism,
+ reducedMotion: renderer.reducedMotion,
+ };
+};
+
+export const forEachElectronRendererVariant = async (
+ page,
+ cdp,
+ variants,
+ capture,
+ options,
+) => {
+ if (!variants || typeof variants !== 'object' || typeof capture !== 'function') {
+ throw new Error('Packaged Electron renderer variant capture configuration is invalid');
+ }
+ for (const [variant, config] of Object.entries(variants)) {
+ const metrics = await configureElectronRendererVariant(page, cdp, config, options);
+ await capture({ variant, config, metrics });
+ }
+};
+
+export const captureElectronRendererScreenshot = async cdp => {
+ const result = await cdp.send('Page.captureScreenshot', {
+ format: 'png',
+ fromSurface: true,
+ captureBeyondViewport: false,
+ });
+ if (typeof result?.data !== 'string' || result.data.length === 0) {
+ throw new Error('Packaged Electron renderer screenshot capture returned invalid data');
+ }
+ return Buffer.from(result.data, 'base64');
+};
+
+const processExitError = (code, signal) => new Error(
+ `Packaged Electron exited before its renderer target was usable (${code ?? signal ?? 'unknown'})`,
+);
+
+/**
+ * CDP can become reachable before Electron publishes its renderer target.
+ * Keep this wait bounded independently from later renderer initialization and
+ * reject immediately if either side of the CDP connection goes away.
+ */
+export const waitForUsableElectronRenderer = async (
+ browser,
+ child,
+ {
+ expectedUrlPrefix,
+ timeoutMs = 15_000,
+ pollIntervalMs = 25,
+ sleep = defaultSleep,
+ } = {},
+) => {
+ if (!expectedUrlPrefix || timeoutMs < 0 || pollIntervalMs < 0) {
+ throw new Error('Packaged Electron renderer wait configuration is invalid');
+ }
+
+ let resolveTermination;
+ const termination = new Promise(resolve => { resolveTermination = resolve; });
+ let terminated = false;
+ let terminationError;
+ const terminate = error => {
+ if (terminated) return;
+ terminated = true;
+ terminationError = error;
+ resolveTermination(error);
+ };
+ const onClose = (code, signal) => terminate(processExitError(code, signal));
+ const onError = error => terminate(new Error('Packaged Electron failed before its renderer target was usable', { cause: error }));
+ const onDisconnected = () => terminate(new Error('Packaged Electron CDP disconnected before its renderer target was usable'));
+
+ child.once('close', onClose);
+ child.once('error', onError);
+ browser.once('disconnected', onDisconnected);
+ const timeout = setTimeout(
+ () => terminate(new Error(`Packaged Electron renderer target did not become usable within ${timeoutMs}ms`)),
+ timeoutMs,
+ );
+
+ if (child.exitCode !== null || child.signalCode !== null) onClose(child.exitCode, child.signalCode);
+ else if (!browser.isConnected()) onDisconnected();
+
+ const bounded = async promise => {
+ if (terminationError) throw terminationError;
+ const result = await Promise.race([
+ Promise.resolve(promise).then(
+ value => ({ value }),
+ error => ({ probeError: error }),
+ ),
+ termination.then(error => ({ terminationError: error })),
+ ]);
+ if (terminationError) throw terminationError;
+ if (result.terminationError) throw result.terminationError;
+ return result;
+ };
+
+ try {
+ while (true) {
+ for (const context of browser.contexts()) {
+ for (const page of context.pages()) {
+ const pageUrl = page.url();
+ if (page.isClosed() || (pageUrl !== expectedUrlPrefix && !pageUrl.startsWith(`${expectedUrlPrefix}/`))) continue;
+ const probe = await bounded(page.evaluate(() => ({ rendererTargetUsable: true })));
+ if (probe.probeError || probe.value?.rendererTargetUsable !== true || page.isClosed()) continue;
+ return { context, page };
+ }
+ }
+ await bounded(sleep(pollIntervalMs));
+ }
+ } finally {
+ clearTimeout(timeout);
+ child.off('close', onClose);
+ child.off('error', onError);
+ browser.off('disconnected', onDisconnected);
+ }
+};
diff --git a/apps/desktop/scripts/packaged-acceptance-renderer.test.mjs b/apps/desktop/scripts/packaged-acceptance-renderer.test.mjs
new file mode 100644
index 000000000..3bbce38e0
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-renderer.test.mjs
@@ -0,0 +1,519 @@
+import assert from 'node:assert/strict';
+import { EventEmitter } from 'node:events';
+import { existsSync } from 'node:fs';
+import { after, before, describe, it } from 'node:test';
+import { chromium } from 'playwright';
+import { ACCEPTANCE_VARIANTS } from './acceptance-artifacts.mjs';
+import { analyzeExistingElectronRenderer } from './packaged-acceptance-axe.mjs';
+import {
+ captureElectronRendererScreenshot,
+ configureElectronRendererVariant,
+ forEachElectronRendererVariant,
+ waitForUsableElectronRenderer,
+} from './packaged-acceptance-renderer.mjs';
+
+const processFixture = () => Object.assign(new EventEmitter(), { exitCode: null, signalCode: null });
+const browserFixture = context => Object.assign(new EventEmitter(), {
+ contexts: () => [context],
+ isConnected: () => true,
+});
+const REAL_CHROMIUM_BOUNDARY_ENVIRONMENT = 'PROPR_DESKTOP_REAL_CHROMIUM_BOUNDARY';
+const REAL_CHROMIUM_BOUNDARY_OPT_IN = '1';
+const realChromiumBoundaryEnabled = value => {
+ if (value === undefined) return false;
+ if (value !== REAL_CHROMIUM_BOUNDARY_OPT_IN) {
+ throw new Error(`${REAL_CHROMIUM_BOUNDARY_ENVIRONMENT} must be unset or exactly "${REAL_CHROMIUM_BOUNDARY_OPT_IN}"`);
+ }
+ return true;
+};
+const runRealChromiumBoundary = realChromiumBoundaryEnabled(
+ process.env[REAL_CHROMIUM_BOUNDARY_ENVIRONMENT],
+);
+const installedChromium = () => [
+ chromium.executablePath(),
+ '/usr/bin/chromium',
+ '/usr/bin/google-chrome',
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
+].find(candidate => existsSync(candidate));
+
+const rendererVariantFixture = ({
+ acknowledgement,
+ appliedFactor,
+ cdpVisualWidthAdjustment = 0,
+ layoutWidthAdjustment = 0,
+ rendererVisualWidthAdjustment = 0,
+ rendererSpan = 'effective',
+ scrollbarInsets = { width: 0, height: 0 },
+ visualViewportInsets = { width: 0, height: 0 },
+ unstable = false,
+} = {}) => {
+ let viewport;
+ let metrics;
+ let reducedMotion = false;
+ let measurement = 0;
+ const commands = [];
+ const dimensionsFor = span => {
+ if (span === 'invalid') return { width: viewport.width - 17, height: viewport.height - 11 };
+ if (unstable) measurement += 1;
+ const adjustment = unstable ? measurement % 2 : 0;
+ const effective = { width: viewport.width / metrics.zoom + adjustment, height: viewport.height / metrics.zoom };
+ if (span === 'document') {
+ return { width: effective.width - scrollbarInsets.width, height: effective.height - scrollbarInsets.height };
+ }
+ if (span === 'visual') {
+ return { width: effective.width - visualViewportInsets.width, height: effective.height - visualViewportInsets.height };
+ }
+ return effective;
+ };
+ const page = {
+ emulateMedia: async media => { reducedMotion = media.reducedMotion === 'reduce'; },
+ setViewportSize: async value => { viewport = { ...value }; },
+ viewportSize: () => ({ ...viewport }),
+ evaluate: async (_fn, argument) => {
+ if (typeof argument === 'number') {
+ metrics.zoom = appliedFactor ?? argument;
+ return acknowledgement ?? {
+ requestedFactor: argument,
+ resetFactor: 1,
+ appliedFactor: metrics.zoom,
+ mechanism: 'electron-web-frame',
+ };
+ }
+ const dimensions = dimensionsFor(rendererSpan);
+ const visualDimensions = dimensionsFor('visual');
+ return {
+ ...dimensions,
+ documentClientViewport: dimensionsFor('document'),
+ devicePixelRatio: metrics.deviceScaleFactor * metrics.zoom + (unstable ? measurement / 1_000 : 0),
+ reducedMotion,
+ visualViewport: {
+ ...visualDimensions,
+ width: visualDimensions.width + rendererVisualWidthAdjustment,
+ scale: 1,
+ },
+ };
+ },
+ };
+ const cdp = {
+ send: async (method, params) => {
+ commands.push({ method, params });
+ if (method === 'Browser.getWindowForTarget') throw new Error('Browser.getWindowForTarget is unavailable');
+ if (method === 'Emulation.setDeviceMetricsOverride') {
+ metrics = { deviceScaleFactor: params.deviceScaleFactor, zoom: metrics?.zoom || 1 };
+ return {};
+ }
+ if (method === 'Page.getLayoutMetrics') {
+ const documentClientViewport = dimensionsFor('document');
+ const rawVisualViewport = dimensionsFor('visual');
+ return {
+ cssLayoutViewport: {
+ clientWidth: documentClientViewport.width + layoutWidthAdjustment,
+ clientHeight: documentClientViewport.height,
+ },
+ cssVisualViewport: {
+ clientWidth: rawVisualViewport.width + cdpVisualWidthAdjustment,
+ clientHeight: rawVisualViewport.height,
+ scale: 1,
+ },
+ };
+ }
+ if (method === 'Page.captureScreenshot') {
+ return { data: Buffer.from(`${viewport.width}x${viewport.height}@${metrics.deviceScaleFactor}/${metrics.zoom}/${reducedMotion}`).toString('base64') };
+ }
+ throw new Error(`Unexpected CDP command: ${method}`);
+ },
+ };
+ return { commands, cdp, page };
+};
+
+describe('packaged acceptance renderer discovery', () => {
+ it('waits deterministically when CDP precedes the first Electron renderer page', async () => {
+ const pages = [];
+ const context = { pages: () => pages };
+ const child = processFixture();
+ const browser = browserFixture(context);
+ let polls = 0;
+ const page = {
+ evaluate: async () => ({ rendererTargetUsable: true }),
+ isClosed: () => false,
+ url: () => 'propr-app://renderer/index.html',
+ };
+
+ const renderer = await waitForUsableElectronRenderer(browser, child, {
+ expectedUrlPrefix: 'propr-app://renderer',
+ timeoutMs: 1_000,
+ sleep: async () => {
+ polls += 1;
+ pages.push(page);
+ },
+ });
+
+ assert.equal(polls, 1);
+ assert.deepEqual(renderer, { context, page });
+ assert.equal(child.listenerCount('close'), 0);
+ assert.equal(child.listenerCount('error'), 0);
+ assert.equal(browser.listenerCount('disconnected'), 0);
+ });
+
+ it('rejects process exit while waiting and removes its listeners', async () => {
+ const context = { pages: () => [] };
+ const child = processFixture();
+ const browser = browserFixture(context);
+ await assert.rejects(waitForUsableElectronRenderer(browser, child, {
+ expectedUrlPrefix: 'propr-app://renderer',
+ timeoutMs: 1_000,
+ sleep: async () => child.emit('close', 17, null),
+ }), /exited.*17/);
+ assert.equal(child.listenerCount('close'), 0);
+ assert.equal(browser.listenerCount('disconnected'), 0);
+ });
+
+ it('rejects a strictly bounded timeout when no renderer target appears', async () => {
+ const context = { pages: () => [] };
+ const child = processFixture();
+ const browser = browserFixture(context);
+ await assert.rejects(waitForUsableElectronRenderer(browser, child, {
+ expectedUrlPrefix: 'propr-app://renderer',
+ timeoutMs: 1,
+ sleep: async () => new Promise(() => undefined),
+ }), /within 1ms/);
+ assert.equal(child.listenerCount('close'), 0);
+ assert.equal(browser.listenerCount('disconnected'), 0);
+ });
+});
+
+describe('packaged acceptance renderer variants', () => {
+ it('fails closed unless the real Chromium boundary has its exact explicit opt-in', () => {
+ assert.equal(realChromiumBoundaryEnabled(undefined), false);
+ assert.equal(realChromiumBoundaryEnabled(REAL_CHROMIUM_BOUNDARY_OPT_IN), true);
+ for (const malformed of ['', '0', 'true', ' 1', '1 ']) {
+ assert.throws(
+ () => realChromiumBoundaryEnabled(malformed),
+ new RegExp(`${REAL_CHROMIUM_BOUNDARY_ENVIRONMENT} must be unset or exactly`),
+ );
+ }
+ });
+
+ it('configures and captures all five variants when Browser.getWindowForTarget is unavailable', async () => {
+ const { commands, cdp, page } = rendererVariantFixture();
+ const captures = [];
+
+ await forEachElectronRendererVariant(page, cdp, ACCEPTANCE_VARIANTS, async ({ variant, config, metrics: actual }) => {
+ captures.push({ variant, bytes: await captureElectronRendererScreenshot(cdp), actual });
+ const effective = { width: config.viewport.width / config.zoom, height: config.viewport.height / config.zoom };
+ assert.deepEqual(actual.requestedViewport, config.viewport);
+ assert.deepEqual(actual.playwrightViewport, config.viewport);
+ assert.deepEqual(actual.rendererViewport, effective);
+ assert.deepEqual(actual.documentClientViewport, effective);
+ assert.deepEqual(actual.scrollbarInsets, { width: 0, height: 0 });
+ assert.deepEqual(actual.visualViewportInsets, { width: 0, height: 0 });
+ assert.deepEqual(actual.layoutViewport, effective);
+ assert.deepEqual(actual.cdpVisualViewport, {
+ width: config.viewport.width / config.zoom,
+ height: config.viewport.height / config.zoom,
+ scale: 1,
+ });
+ assert.deepEqual(actual.rendererVisualViewport, actual.cdpVisualViewport);
+ assert.deepEqual(actual.effectiveVisibleCssSpan, effective);
+ assert.deepEqual(actual.geometryZoom, { width: config.zoom, height: config.zoom });
+ assert.equal(actual.requestedDeviceScaleFactor, config.deviceScaleFactor);
+ assert.equal(actual.rendererDevicePixelRatio, config.deviceScaleFactor * config.zoom);
+ assert.equal(actual.requestedZoomFactor, config.zoom);
+ assert.equal(actual.appliedZoomFactor, config.zoom);
+ assert.equal(actual.zoomResetFactor, 1);
+ assert.equal(actual.zoomMechanism, 'electron-web-frame');
+ assert.equal(actual.reducedMotion, config.reducedMotion);
+ }, { settle: async () => undefined });
+
+ assert.deepEqual(captures.map(capture => capture.variant), Object.keys(ACCEPTANCE_VARIANTS));
+ assert.equal(captures.length, 5);
+ assert.equal(new Set(captures.map(capture => capture.bytes.toString())).size, 5);
+ assert.equal(commands.some(command => command.method.startsWith('Browser.')), false);
+ assert.equal(commands.filter(command => command.method === 'Emulation.setDeviceMetricsOverride').length, 5);
+ assert.equal(commands.filter(command => command.method === 'Emulation.setPageScaleFactor').length, 0);
+ assert.equal(commands.filter(command => command.method === 'Page.getLayoutMetrics').length, 15);
+ assert.equal(commands.filter(command => command.method === 'Page.captureScreenshot').length, 5);
+ });
+
+ it('proves packaged Electron zoom-200 from webFrame acknowledgement and independent renderer geometry', async () => {
+ const { commands, cdp, page } = rendererVariantFixture({
+ scrollbarInsets: { width: 8, height: 0 },
+ visualViewportInsets: { width: 7.5, height: 0 },
+ });
+ const config = ACCEPTANCE_VARIANTS['zoom-200'];
+
+ const actual = await configureElectronRendererVariant(page, cdp, config, { settle: async () => undefined });
+
+ assert.deepEqual(actual.requestedViewport, { width: 1280, height: 820 });
+ assert.deepEqual(actual.rendererViewport, { width: 640, height: 410 });
+ assert.deepEqual(actual.documentClientViewport, { width: 632, height: 410 });
+ assert.deepEqual(actual.scrollbarInsets, { width: 8, height: 0 });
+ assert.deepEqual(actual.visualViewportInsets, { width: 7.5, height: 0 });
+ assert.deepEqual(actual.layoutViewport, { width: 632, height: 410 });
+ assert.deepEqual(actual.cdpVisualViewport, { width: 632.5, height: 410, scale: 1 });
+ assert.deepEqual(actual.rendererVisualViewport, { width: 632.5, height: 410, scale: 1 });
+ assert.deepEqual(actual.effectiveVisibleCssSpan, { width: 640, height: 410 });
+ assert.deepEqual(actual.geometryZoom, { width: 2, height: 2 });
+ assert.equal(actual.rendererDevicePixelRatio, 2);
+ assert.equal(actual.appliedZoomFactor, 2);
+ assert.equal(actual.zoomMechanism, 'electron-web-frame');
+ assert.equal(commands.some(command => command.method === 'Emulation.setPageScaleFactor'), false);
+ });
+
+ it('fails closed on a wrong or malformed Electron zoom acknowledgement', async () => {
+ const unavailable = rendererVariantFixture();
+ unavailable.page.evaluate = async () => { throw new Error('Packaged acceptance Electron zoom bridge is unavailable'); };
+ await assert.rejects(
+ configureElectronRendererVariant(unavailable.page, unavailable.cdp, ACCEPTANCE_VARIANTS.standard, { settle: async () => undefined }),
+ /zoom bridge is unavailable/,
+ );
+ const wrong = rendererVariantFixture({ appliedFactor: 1 });
+ await assert.rejects(
+ configureElectronRendererVariant(wrong.page, wrong.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /zoom bridge acknowledgement changed/,
+ );
+ const malformed = rendererVariantFixture({ acknowledgement: { appliedFactor: 2 } });
+ await assert.rejects(
+ configureElectronRendererVariant(malformed.page, malformed.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /acknowledgement is malformed/,
+ );
+ });
+
+ it('fails closed on renderer geometry mismatch and an unstable measurement timeout', async () => {
+ const mismatch = rendererVariantFixture({ rendererSpan: 'invalid' });
+ await assert.rejects(
+ configureElectronRendererVariant(mismatch.page, mismatch.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /renderer viewport width changed/,
+ );
+ const unstable = rendererVariantFixture({ unstable: true });
+ await assert.rejects(
+ configureElectronRendererVariant(unstable.page, unstable.cdp, ACCEPTANCE_VARIANTS['zoom-200'], {
+ settle: async () => undefined, measurementTimeoutMs: 1,
+ }),
+ /did not stabilize within 1ms/,
+ );
+ });
+
+ it('fails closed on arbitrary layout spans, invalid insets, and exact renderer/CDP visual mismatches', async () => {
+ const arbitraryLayout = rendererVariantFixture({
+ layoutWidthAdjustment: 1,
+ scrollbarInsets: { width: 8, height: 0 },
+ });
+ await assert.rejects(
+ configureElectronRendererVariant(arbitraryLayout.page, arbitraryLayout.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /layout viewport width versus document client width changed: expected exactly 632, received 633/,
+ );
+
+ for (const width of [-1, 65]) {
+ const invalidScrollbar = rendererVariantFixture({ scrollbarInsets: { width, height: 0 } });
+ await assert.rejects(
+ configureElectronRendererVariant(invalidScrollbar.page, invalidScrollbar.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /width scrollbar inset is invalid/,
+ );
+ }
+
+ for (const width of [-1, 65, Number.NaN]) {
+ const invalidVisualInset = rendererVariantFixture({ visualViewportInsets: { width, height: 0 } });
+ await assert.rejects(
+ configureElectronRendererVariant(invalidVisualInset.page, invalidVisualInset.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /width visual viewport inset is invalid/,
+ );
+ }
+
+ const visualMismatch = rendererVariantFixture({
+ cdpVisualWidthAdjustment: -0.5,
+ scrollbarInsets: { width: 8, height: 0 },
+ visualViewportInsets: { width: 7.5, height: 0 },
+ });
+ await assert.rejects(
+ configureElectronRendererVariant(visualMismatch.page, visualMismatch.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ error => /renderer visual viewport width versus raw CDP visual width changed: expected exactly 632, received 632.5/.test(error?.message)
+ && /"documentClientViewport":\{"width":632,"height":410\}/.test(error.message)
+ && /"cdpVisualViewport":\{"width":632,"height":410,"scale":1\}/.test(error.message)
+ && /"rendererVisualViewport":\{"width":632.5,"height":410,"scale":1\}/.test(error.message)
+ && !error.message.includes('propr-app://'),
+ );
+
+ const rendererVisualMismatch = rendererVariantFixture({ rendererVisualWidthAdjustment: -0.5 });
+ await assert.rejects(
+ configureElectronRendererVariant(rendererVisualMismatch.page, rendererVisualMismatch.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /renderer visual viewport width versus raw CDP visual width changed/,
+ );
+ });
+
+ describe('real Chromium existing-target axe boundary', {
+ skip: !runRealChromiumBoundary
+ ? `Real Chromium is owned by the required predecessor (${REAL_CHROMIUM_BOUNDARY_ENVIRONMENT}=1)`
+ : process.platform !== 'linux' || process.arch !== 'x64'
+ ? 'Linux x64 packaged acceptance boundary'
+ : false,
+ }, () => {
+ let browser;
+ let cdp;
+ let context;
+ let createTargetAttempts = 0;
+ let evaluateRenderer;
+ let injectionMode = 'success';
+ let newPageAttempts = 0;
+ let page;
+ let session;
+
+ before(async () => {
+ const executablePath = installedChromium();
+ assert.ok(executablePath, 'A Chromium executable is required for the in-renderer axe boundary regression');
+ browser = await chromium.launch({ executablePath, headless: true });
+ context = await browser.newContext();
+ page = await context.newPage();
+ await page.setContent(`
+
Acceptance boundary
+ Acceptance boundary
+ `);
+
+ Object.defineProperty(context, 'newPage', {
+ configurable: true,
+ value: async () => {
+ newPageAttempts += 1;
+ throw new Error('Target.createTarget is unsupported');
+ },
+ });
+ session = await context.newCDPSession(page);
+ cdp = {
+ send: async (method, params) => {
+ if (method === 'Target.createTarget') {
+ createTargetAttempts += 1;
+ throw new Error('Target.createTarget is unsupported');
+ }
+ return session.send(method, params);
+ },
+ };
+
+ evaluateRenderer = page.evaluate.bind(page);
+ await evaluateRenderer(() => {
+ globalThis.__acceptanceAxeCleanup = { completed: 0, started: 0, callbackBased: 0 };
+ });
+ page.evaluate = async (pageFunction, argument) => {
+ const result = await evaluateRenderer(pageFunction, argument);
+ if (typeof pageFunction === 'string') {
+ await evaluateRenderer(mode => {
+ if (mode === 'timeout') {
+ globalThis.axe.cleanup = () => undefined;
+ return;
+ }
+ if (mode === 'combined-failure') {
+ globalThis.axe.run = async () => { throw new Error('specific axe execution failure'); };
+ globalThis.axe.cleanup = (_resolve, reject) => {
+ setTimeout(() => reject(new Error('asynchronous axe cleanup failure')), 25);
+ };
+ return;
+ }
+ const cleanupState = globalThis.__acceptanceAxeCleanup;
+ const cleanup = globalThis.axe.cleanup.bind(globalThis.axe);
+ globalThis.axe.cleanup = (resolve, reject) => {
+ cleanupState.started += 1;
+ if (typeof resolve === 'function' && typeof reject === 'function') {
+ cleanupState.callbackBased += 1;
+ }
+ cleanup(
+ value => setTimeout(() => {
+ cleanupState.completed += 1;
+ if (typeof resolve === 'function') resolve(value);
+ }, 25),
+ error => setTimeout(() => {
+ cleanupState.completed += 1;
+ if (typeof reject === 'function') reject(error);
+ }, 25),
+ );
+ };
+ }, injectionMode);
+ }
+ return result;
+ };
+ }, { timeout: 30_000 });
+
+ after(async () => {
+ try {
+ if (session) await session.detach();
+ } finally {
+ if (browser) await browser.close();
+ }
+ }, { timeout: 15_000 });
+
+ it('proves five-variant axe success and reinjection without claiming stock Chromium proves Electron zoom', {
+ timeout: 45_000,
+ }, async () => {
+ await assert.rejects(context.newPage(), /Target\.createTarget is unsupported/);
+ await assert.rejects(cdp.send('Target.createTarget', { url: 'about:blank' }), /Target\.createTarget is unsupported/);
+
+ const checks = [];
+ assert.equal(await page.evaluate(() => typeof globalThis.__PROPR_PACKAGED_ACCEPTANCE__), 'undefined');
+ for (const [variant, config] of Object.entries(ACCEPTANCE_VARIANTS)) {
+ await page.setViewportSize(config.viewport);
+ await cdp.send('Emulation.setDeviceMetricsOverride', {
+ width: config.viewport.width,
+ height: config.viewport.height,
+ deviceScaleFactor: config.deviceScaleFactor,
+ mobile: false,
+ screenWidth: config.viewport.width,
+ screenHeight: config.viewport.height,
+ });
+ const violations = await analyzeExistingElectronRenderer(page);
+ const cleanupState = await evaluateRenderer(() => ({ ...globalThis.__acceptanceAxeCleanup }));
+ checks.push({ variant, violations, cleanupState });
+ assert.equal(cleanupState.started, checks.length);
+ assert.equal(cleanupState.callbackBased, checks.length);
+ assert.equal(cleanupState.completed, checks.length);
+ assert.equal(await page.evaluate(() => typeof globalThis.axe), 'undefined');
+ }
+
+ assert.deepEqual(checks.map(check => check.variant), Object.keys(ACCEPTANCE_VARIANTS));
+ assert.equal(checks.length, 5);
+ for (const check of checks) {
+ assert.ok(check.violations.some(violation => violation.id === 'button-name'
+ && violation.impact === 'critical' && violation.nodes === 1));
+ }
+ assert.equal(newPageAttempts, 1);
+ assert.equal(createTargetAttempts, 1);
+ assert.equal(page.isClosed(), false);
+ });
+
+ it('fails closed at the production axe cleanup timeout', {
+ timeout: 10_000,
+ }, async () => {
+ injectionMode = 'timeout';
+ await assert.rejects(
+ analyzeExistingElectronRenderer(page),
+ error => error?.message === 'Packaged accessibility axe-core cleanup failed'
+ && /cleanup timed out after 5000ms/.test(error.cause?.message),
+ );
+ assert.equal(await evaluateRenderer(() => typeof globalThis.axe), 'object');
+ await evaluateRenderer(() => Reflect.deleteProperty(globalThis, 'axe'));
+ assert.equal(page.isClosed(), false);
+ });
+
+ it('preserves the primary axe error alongside asynchronous cleanup failure', {
+ timeout: 10_000,
+ }, async () => {
+ injectionMode = 'combined-failure';
+ let combinedFailure;
+ try {
+ await analyzeExistingElectronRenderer(page);
+ } catch (error) {
+ combinedFailure = error;
+ }
+ assert.ok(combinedFailure instanceof AggregateError);
+ assert.equal(combinedFailure.message, 'Packaged accessibility axe-core execution or result validation failed');
+ assert.equal(combinedFailure.errors.length, 2);
+ assert.match(combinedFailure.errors[0].message, /execution or result validation failed/);
+ assert.match(combinedFailure.errors[1].message, /axe-core cleanup failed/);
+ assert.match(combinedFailure.errors[1].cause?.message, /asynchronous axe cleanup failure/);
+ assert.equal(await evaluateRenderer(() => typeof globalThis.axe), 'object');
+ assert.equal(newPageAttempts, 1);
+ assert.equal(createTargetAttempts, 1);
+ assert.equal(page.isClosed(), false);
+ });
+ });
+});
diff --git a/apps/desktop/scripts/packaged-acceptance-socket.test.mjs b/apps/desktop/scripts/packaged-acceptance-socket.test.mjs
new file mode 100644
index 000000000..a835aba3c
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-socket.test.mjs
@@ -0,0 +1,108 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { describe, it } from 'node:test';
+
+const readSource = relativePath => readFileSync(
+ fileURLToPath(new URL(relativePath, import.meta.url)),
+ 'utf8',
+);
+
+const runnerSource = readSource('./run-packaged-acceptance.mjs');
+const socketProviderSource = readSource('../../../propr-ui/src/contexts/SocketProvider.tsx');
+
+const sourceBetween = (source, startMarker, endMarker) => {
+ const start = source.indexOf(startMarker);
+ assert.notEqual(start, -1, `Missing source marker: ${startMarker}`);
+ const end = source.indexOf(endMarker, start);
+ assert.notEqual(end, -1, `Missing source marker: ${endMarker}`);
+ return source.slice(start, end);
+};
+
+const occurrences = (source, value) => source.split(value).length - 1;
+
+describe('packaged acceptance Socket.IO application synchronization', () => {
+ it('uses the exact production subscription event and emits only from its one-shot handler', () => {
+ assert.match(socketProviderSource, /socket\.emit\('subscribe:queue:stats'\)/);
+ assert.match(runnerSource, /const QUEUE_STATS_SUBSCRIBE_EVENT = 'subscribe:queue:stats';/);
+
+ const connectionCallback = sourceBetween(
+ runnerSource,
+ "io.on('connection', socket => {",
+ ' server.listen(',
+ );
+ const connectionRecord = connectionCallback.indexOf(
+ "socketRecords.push({ journey, mode, event: 'connection', authenticated });",
+ );
+ const subscriptionHandler = connectionCallback.indexOf(
+ 'socket.once(QUEUE_STATS_SUBSCRIBE_EVENT, () => {',
+ );
+ const subscriptionReceived = connectionCallback.indexOf(
+ 'recordApplicationSubscription();',
+ subscriptionHandler,
+ );
+ const serverEmit = connectionCallback.indexOf(
+ 'socket.emit(QUEUE_STATS_UPDATE, {',
+ subscriptionReceived,
+ );
+ const fixtureEventRecord = connectionCallback.indexOf(
+ "direction: 'fixture-to-renderer',",
+ serverEmit,
+ );
+
+ assert.ok(connectionRecord < subscriptionHandler);
+ assert.ok(subscriptionHandler < subscriptionReceived);
+ assert.ok(subscriptionReceived < serverEmit);
+ assert.ok(serverEmit < fixtureEventRecord);
+ assert.equal(occurrences(connectionCallback, 'socket.emit(QUEUE_STATS_UPDATE, {'), 1);
+ assert.doesNotMatch(connectionCallback.slice(0, subscriptionHandler), /socket\.emit\(QUEUE_STATS_UPDATE/);
+ assert.doesNotMatch(connectionCallback, /setTimeout|sleep\(/);
+ assert.match(connectionCallback, /timestamp: FIXED_TIME/);
+ assert.match(connectionCallback, /completed: 12, failed: 0, delayed: 0, total: 12/);
+ });
+
+ it('records a bounded duplicate subscription without producing another fixture event', () => {
+ const connectionCallback = sourceBetween(
+ runnerSource,
+ "io.on('connection', socket => {",
+ ' server.listen(',
+ );
+
+ assert.equal(
+ occurrences(connectionCallback, 'socket.once(QUEUE_STATS_SUBSCRIBE_EVENT'),
+ 2,
+ );
+ assert.match(
+ connectionCallback,
+ /recordApplicationSubscription\(\);\s+socket\.once\(QUEUE_STATS_SUBSCRIBE_EVENT, recordApplicationSubscription\);\s+socket\.emit\(QUEUE_STATS_UPDATE/,
+ );
+ assert.equal(occurrences(connectionCallback, 'genuineApplicationSubscription: true'), 1);
+ assert.equal(occurrences(connectionCallback, 'genuineApplicationEvent: true'), 1);
+ });
+
+ it('requires exact connection, subscription, emit, and renderer-observation counts', () => {
+ const waitSource = sourceBetween(
+ runnerSource,
+ 'const waitForAuthenticatedSocket = async journey => {',
+ '\nconst observedServiceSummary = () => {',
+ );
+ const summarySource = sourceBetween(
+ runnerSource,
+ 'const observedServiceSummary = () => {',
+ '\ntry {\n readyOrigin = await createFixture',
+ );
+
+ assert.match(waitSource, /connected\.length === 1 && applicationSubscriptions\.length === 1/);
+ assert.match(waitSource, /applicationEvents\.length === 1 && rendererObservedApplicationEvents\.length === 1/);
+ assert.match(waitSource, /record\.event === QUEUE_STATS_SUBSCRIBE_EVENT && record\.authenticated/);
+ assert.match(waitSource, /record\.event === QUEUE_STATS_UPDATE && record\.authenticated/);
+ assert.match(runnerSource, /if \(subscriptions\.length > 1\) return 'duplicate-application-subscription';/);
+ assert.match(runnerSource, /if \(applicationEvents\.length > 1\) return 'duplicate-application-event';/);
+ assert.match(runnerSource, /return 'application-subscription-not-observed';/);
+ assert.match(runnerSource, /return 'renderer-application-event-not-observed';/);
+ assert.match(summarySource, /socketSubscriptions\.length !== 1/);
+ assert.match(summarySource, /services\.socketIo\.events !== 1/);
+ assert.match(summarySource, /rendererObservedApplicationEvents\.length !== 1/);
+ assert.doesNotMatch(waitSource, /applicationEvents\.length >= 1/);
+ });
+});
diff --git a/apps/desktop/scripts/packaged-acceptance-stats.test.mjs b/apps/desktop/scripts/packaged-acceptance-stats.test.mjs
new file mode 100644
index 000000000..d267e9237
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-stats.test.mjs
@@ -0,0 +1,106 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { runInNewContext } from 'node:vm';
+import { describe, it } from 'node:test';
+
+const readSource = relativePath => readFileSync(
+ fileURLToPath(new URL(relativePath, import.meta.url)),
+ 'utf8',
+);
+
+const runnerSource = readSource('./run-packaged-acceptance.mjs');
+const taskStatsApiSource = readSource('../../../propr-ui/src/api/taskStatsApi.ts');
+const dashboardSource = readSource('../../../propr-ui/src/components/Dashboard.tsx');
+const taskStatsChartSource = readSource('../../../propr-ui/src/components/TaskStatsChart.tsx');
+const topModelsSource = readSource('../../../propr-ui/src/components/TopModels.tsx');
+const repositoryBreakdownSource = readSource('../../../propr-ui/src/components/RepositoryBreakdown.tsx');
+
+const fixturePayload = pathname => {
+ const route = `if (requestUrl.pathname === '${pathname}')`;
+ const routeStart = runnerSource.indexOf(route);
+ assert.notEqual(routeStart, -1, `${pathname} exact route is missing`);
+ const responseStart = runnerSource.indexOf('return json(response, 200, ', routeStart);
+ assert.notEqual(responseStart, -1, `${pathname} response is missing`);
+ const valueStart = responseStart + 'return json(response, 200, '.length;
+ const valueEnd = runnerSource.indexOf(');', valueStart);
+ assert.notEqual(valueEnd, -1, `${pathname} response terminator is missing`);
+ const value = runInNewContext(`(${runnerSource.slice(valueStart, valueEnd)})`);
+ return JSON.parse(JSON.stringify(value));
+};
+
+const interfaceFields = name => {
+ const match = taskStatsApiSource.match(new RegExp(`export interface ${name} \\{([\\s\\S]*?)\\n\\}`));
+ assert.ok(match, `${name} interface is missing`);
+ return [...match[1].matchAll(/^\s{2}([A-Za-z_][A-Za-z0-9_]*):/gm)].map(field => field[1]);
+};
+
+describe('packaged acceptance stats fixtures', () => {
+ it('dispatches exact stats pathnames before the unchanged generic fallback', () => {
+ const generatingPlans = runnerSource.indexOf("requestUrl.pathname === '/api/stats/generating-plans'");
+ const tasks = runnerSource.indexOf("requestUrl.pathname === '/api/stats/tasks'");
+ const overview = runnerSource.indexOf("requestUrl.pathname === '/api/stats/overview'");
+ const repositories = runnerSource.indexOf("requestUrl.pathname === '/api/stats/repositories'");
+ const genericFallback = runnerSource.indexOf("request.url?.startsWith('/api/stats/')");
+
+ assert.ok(generatingPlans < tasks && tasks < overview && overview < repositories);
+ assert.ok(repositories < genericFallback);
+ assert.deepEqual(fixturePayload('/api/stats/generating-plans'), { count: 0 });
+ });
+
+ it('matches TaskStatsResponse and the fields synchronously mapped by Dashboard and TaskStatsChart', () => {
+ const payload = fixturePayload('/api/stats/tasks');
+
+ assert.deepEqual(Object.keys(payload), interfaceFields('TaskStatsResponse'));
+ assert.deepEqual(payload, {
+ dailyCounts: [],
+ statusDistribution: [
+ { status: 'completed', count: 12 },
+ { status: 'failed', count: 0 },
+ ],
+ avgProcessingTime: [],
+ summary: { total: 12, completed: 12, failed: 0 },
+ });
+ assert.match(dashboardSource, /taskStats\?\.dailyCounts\?\.map/);
+ assert.match(dashboardSource, /const \{ completed, total \} = taskStats\.summary/);
+ assert.match(dashboardSource, /taskStats\?\.summary\?\.total\?\.toLocaleString/);
+ assert.match(dashboardSource, /taskStats\?\.summary\?\.failed/);
+ for (const field of ['dailyCounts', 'statusDistribution', 'avgProcessingTime']) {
+ assert.match(taskStatsChartSource, new RegExp(`stats\\.${field}\\.map`));
+ assert.ok(Array.isArray(payload[field]), `${field} must be synchronously mappable`);
+ }
+ assert.equal(payload.summary.total, 12);
+ assert.equal(payload.summary.completed, 12);
+ assert.equal(payload.summary.failed, 0);
+ });
+
+ it('matches StatsOverviewResponse and RepositoryStatsResponse consumer contracts', () => {
+ const overview = fixturePayload('/api/stats/overview');
+ const repositories = fixturePayload('/api/stats/repositories');
+
+ assert.deepEqual(Object.keys(overview), interfaceFields('StatsOverviewResponse'));
+ assert.deepEqual(Object.keys(overview.tasks), interfaceFields('StatsOverviewTasks'));
+ assert.deepEqual(Object.keys(overview.usage), interfaceFields('StatsOverviewUsage'));
+ assert.deepEqual(Object.keys(overview.system), interfaceFields('StatsOverviewSystem'));
+ assert.deepEqual(overview, {
+ tasks: {
+ completed: 12,
+ planned: 0,
+ pr_iterations_avg: 0,
+ merged_prs: 0,
+ total_followups: 0,
+ },
+ usage: { total_tokens: 0, total_cost_usd: 0, models: {} },
+ system: { repos_indexed: 0 },
+ });
+ assert.deepEqual(Object.keys(repositories), interfaceFields('RepositoryStatsResponse'));
+ assert.deepEqual(repositories, { repositories: [] });
+ assert.match(topModelsSource, /Object\.keys\(metrics\.usage\.models\)/);
+ assert.match(topModelsSource, /Object\.entries\(metrics\.usage\.models\)/);
+ assert.match(dashboardSource, /overviewStats\?\.usage\?\.total_cost_usd/);
+ assert.equal(typeof overview.usage.models, 'object');
+ assert.equal(Array.isArray(overview.usage.models), false);
+ assert.match(repositoryBreakdownSource, /setRepositories\(data\.repositories \|\| \[\]\)/);
+ assert.ok(Array.isArray(repositories.repositories));
+ });
+});
diff --git a/apps/desktop/scripts/run-native-durability.mjs b/apps/desktop/scripts/run-native-durability.mjs
index fa2eecff5..288b1838d 100644
--- a/apps/desktop/scripts/run-native-durability.mjs
+++ b/apps/desktop/scripts/run-native-durability.mjs
@@ -1,8 +1,20 @@
import { spawn } from 'node:child_process';
+import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
+const expectedTestsFor = (relativePath) => {
+ const source = readFileSync(new URL(`../${relativePath}`, import.meta.url), 'utf8');
+ const matches = [...source.matchAll(
+ /^export const NATIVE_DURABILITY_EXPECTED_TESTS = ([1-9]\d*) as const;$/gm,
+ )];
+ if (matches.length !== 1) {
+ throw new Error(`${relativePath} must export exactly one native durability expected-test contract`);
+ }
+ return Number(matches[0][1]);
+};
+
const EXPECTED = Object.freeze({
- 'credential-service': 69,
+ 'credential-service': expectedTestsFor('src/credential-service.test.ts'),
'profile-store': 39,
'pairing-shutdown': 10,
});
diff --git a/apps/desktop/scripts/run-packaged-acceptance.mjs b/apps/desktop/scripts/run-packaged-acceptance.mjs
new file mode 100644
index 000000000..647662f75
--- /dev/null
+++ b/apps/desktop/scripts/run-packaged-acceptance.mjs
@@ -0,0 +1,1576 @@
+import { createHash } from 'node:crypto';
+import { spawn } from 'node:child_process';
+import { once } from 'node:events';
+import { createServer } from 'node:http';
+import { constants as fsConstants } from 'node:fs';
+import { access, lstat, mkdtemp, readFile, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { chromium } from 'playwright';
+import { Server as SocketIOServer } from 'socket.io';
+import {
+ DESKTOP_RENDERER_ORIGIN,
+ DESKTOP_TRANSPORT_SCOPE_QUERY,
+ PROPR_API_COMPATIBILITY,
+ PROPR_UI_COMPATIBILITY,
+ QUEUE_STATS_UPDATE,
+} from '@propr/shared';
+import {
+ ACCEPTANCE_JOURNEYS,
+ ACCEPTANCE_PROFILE_PREFIX,
+ ACCEPTANCE_SURFACES_PREFIX,
+ ACCEPTANCE_VARIANTS,
+ DETERMINISTIC_INPUTS,
+ FIXED_ACCEPTANCE_ORIGINS,
+ FIXED_TIME,
+ defaultAcceptanceOutputDirectory,
+ prepareAcceptanceArtifactDirectory,
+ readPngDimensions,
+ safeRemoveAcceptanceLeaf,
+ scanAcceptancePaths,
+ screenshotName,
+ verifyAcceptanceArtifacts,
+ writeAcceptanceManifest,
+} from './acceptance-artifacts.mjs';
+import { PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS } from './packaged-acceptance-clock.mjs';
+import { analyzeExistingElectronRenderer } from './packaged-acceptance-axe.mjs';
+import {
+ classifyCurrentUserRequestShape,
+ currentUserValidationPhaseSummary,
+ currentUserValidationFailureCategory as classifyCurrentUserValidation,
+ networkPermissionDecisionSummary,
+} from './packaged-acceptance-current-user.mjs';
+import {
+ captureElectronRendererScreenshot,
+ forEachElectronRendererVariant,
+ waitForUsableElectronRenderer,
+} from './packaged-acceptance-renderer.mjs';
+
+if (process.platform !== 'linux' || process.arch !== 'x64') {
+ throw new Error('Packaged desktop visual acceptance must run on Linux x64');
+}
+
+const outputDirectory = resolve(process.env.PROPR_DESKTOP_ACCEPTANCE_OUTPUT || defaultAcceptanceOutputDirectory());
+const binaryPath = resolve('out', 'propr-desktop-linux-x64', 'propr-desktop');
+const DEVICE_SECRET = 'D'.repeat(43);
+const INSTANCE_TOKEN = `propr_it_${'T'.repeat(43)}`;
+const ACTIVATION_TICKET = 'A'.repeat(43);
+const PAIRING_ID = `dpr_${'P'.repeat(22)}`;
+const RECEIPT = 'R'.repeat(22);
+const SENTINELS = [DEVICE_SECRET, INSTANCE_TOKEN, ACTIVATION_TICKET];
+const FIXED_MILLIS = PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS;
+const consoleRecords = [];
+const pendingConsoleRecords = [];
+const pageErrorRecords = [];
+const processRecords = [];
+const requestRecords = [];
+const socketRecords = [];
+const fixtureHandshakeRecords = [];
+const mainHandshakeRecords = [];
+const rendererLifecycleRecords = [];
+const rendererCurrentUserRecords = [];
+const mainCurrentUserRecords = [];
+const fixtureCurrentUserRecords = [];
+const networkPermissionRecords = [];
+const screenshotMetadata = [];
+const accessibilityChecks = [];
+const axeFindings = [];
+const liveAnnouncements = {};
+const surfaces = [];
+const boundaryJourneys = new Set();
+let activeJourney = 'startup';
+let keyboardOrder = false;
+let visibleFocus = false;
+let modalFocusTrap = false;
+let modalFocusRestore = false;
+let traceWritten = false;
+let rendererLifecycleEvidenceInvalid = false;
+const rendererLifecycleInvalidCategories = new Set();
+let currentUserEvidenceInvalid = false;
+let networkPermissionEvidenceInvalidCount = 0;
+
+const digest = value => createHash('sha256').update(value).digest('hex');
+const unique = values => [...new Set(values)].sort((a, b) => ACCEPTANCE_JOURNEYS.indexOf(a) - ACCEPTANCE_JOURNEYS.indexOf(b));
+const sleep = milliseconds => new Promise(resolveValue => setTimeout(resolveValue, milliseconds));
+const MAIN_HANDSHAKE_EVENT = 'desktop.acceptance.websocket_handshake';
+const MAIN_CURRENT_USER_EVENT = 'desktop.acceptance.current_user_proxy';
+const NETWORK_PERMISSION_EVENT = 'desktop.acceptance.network_permission';
+const RENDERER_LIFECYCLE_PREFIX = '[ProPR Acceptance Renderer Lifecycle]';
+const RENDERER_CURRENT_USER_PREFIX = '[ProPR Acceptance Current User]';
+const QUEUE_STATS_SUBSCRIBE_EVENT = 'subscribe:queue:stats';
+const RENDERER_LIFECYCLE_PHASES = new Set([
+ 'profile-activation-published', 'socket-provider-mounted', 'socket-effect-disabled',
+ 'socket-effect-scope-unavailable', 'socket-effect-ready', 'socket-construction-invoked', 'socket-constructed',
+ 'socket-connect-invoked',
+]);
+const RENDERER_LIFECYCLE_KEYS = [
+ 'schemaVersion', 'phase', 'profileActivationPublished', 'socketProviderMounted',
+ 'providerDisabled', 'disabledByDemoModeLoading', 'disabledByDemoMode',
+ 'disabledByCurrentUserLoading', 'disabledByCurrentUserAbsent', 'desktopRuntime',
+ 'connectionScope', 'socketConstructionInvocations', 'socketConstructions', 'connectInvocations',
+].sort();
+const MAIN_HANDSHAKE_CATEGORIES = new Set([
+ 'none', 'untrusted-http-origin', 'wrong-path', 'wrong-transport', 'wrong-resource-type',
+ 'scope-missing', 'scope-duplicate', 'scope-malformed', 'no-active-binding',
+ 'stale-generation', 'wrong-origin', 'stale-scope',
+]);
+const CURRENT_USER_PROXY_CATEGORIES = new Set([
+ 'none', 'scope-missing', 'scope-duplicate', 'scope-malformed', 'no-active-binding',
+ 'stale-generation', 'wrong-origin', 'stale-scope',
+]);
+const CURRENT_USER_RENDERER_PHASES = new Set([
+ 'request-issued', 'response-completed', 'parsed-user-accepted', 'parsed-user-rejected',
+ 'active-scope-accepted', 'stale-scope-rejected', 'revoked-scope-rejected', 'active-scope-rejected',
+]);
+const CURRENT_USER_CLASSIFICATIONS = new Set([
+ 'pending', 'success', 'unauthenticated', 'revoked', 'forbidden', 'server-error',
+ 'network-error', 'invalid-schema',
+]);
+const NETWORK_PERMISSION_CATEGORIES = new Set([
+ 'local-network-access', 'local-network', 'loopback-network',
+]);
+
+const captureMainHandshakeEvidence = (line, journey) => {
+ if (!line.includes(MAIN_HANDSHAKE_EVENT)) return;
+ try {
+ const record = JSON.parse(line.slice(line.indexOf('{')));
+ const evidence = {
+ schemaVersion: record.schemaVersion,
+ path: record.path,
+ transport: record.transport,
+ resource: record.resource,
+ scopeQueryPresent: record.scopeQueryPresent,
+ scopeQueryCount: record.scopeQueryCount,
+ scopeEqualsActive: record.scopeEqualsActive,
+ activeBindingPresent: record.activeBindingPresent,
+ profileGenerationCurrent: record.profileGenerationCurrent,
+ originEqualsActive: record.originEqualsActive,
+ rendererBearerPresent: record.rendererBearerPresent,
+ rendererCookiePresent: record.rendererCookiePresent,
+ outboundBearerPresent: record.outboundBearerPresent,
+ bearerMainInjected: record.bearerMainInjected,
+ accepted: record.accepted,
+ rejectionCategory: record.rejectionCategory,
+ };
+ const valid = record.event === MAIN_HANDSHAKE_EVENT
+ && evidence.schemaVersion === 1
+ && ['socket-io', 'other'].includes(evidence.path)
+ && ['websocket', 'other'].includes(evidence.transport)
+ && ['websocket', 'other'].includes(evidence.resource)
+ && Number.isSafeInteger(evidence.scopeQueryCount) && evidence.scopeQueryCount >= 0
+ && MAIN_HANDSHAKE_CATEGORIES.has(evidence.rejectionCategory)
+ && [
+ evidence.scopeQueryPresent,
+ evidence.scopeEqualsActive,
+ evidence.activeBindingPresent,
+ evidence.profileGenerationCurrent,
+ evidence.originEqualsActive,
+ evidence.rendererBearerPresent,
+ evidence.rendererCookiePresent,
+ evidence.outboundBearerPresent,
+ evidence.bearerMainInjected,
+ evidence.accepted,
+ ].every(value => typeof value === 'boolean');
+ mainHandshakeRecords.push(valid
+ ? { journey, ...evidence }
+ : { journey, accepted: false, rejectionCategory: 'invalid-main-evidence' });
+ } catch {
+ mainHandshakeRecords.push({ journey, accepted: false, rejectionCategory: 'invalid-main-evidence' });
+ }
+};
+
+const captureMainCurrentUserEvidence = (line, journey) => {
+ if (!line.includes(MAIN_CURRENT_USER_EVENT)) return;
+ try {
+ const record = JSON.parse(line.slice(line.indexOf('{')));
+ const evidence = {
+ schemaVersion: record.schemaVersion,
+ correlation: record.correlation,
+ requestObserved: record.requestObserved,
+ method: record.method,
+ rendererScopeGeneration: record.rendererScopeGeneration,
+ scopeGenerationQueryCount: record.scopeGenerationQueryCount,
+ scopeGenerationQueryValid: record.scopeGenerationQueryValid,
+ scopeHeaderCount: record.scopeHeaderCount,
+ activeBindingPresent: record.activeBindingPresent,
+ activeScopeGeneration: record.activeScopeGeneration,
+ profileGenerationCurrent: record.profileGenerationCurrent,
+ scopeEqualsActive: record.scopeEqualsActive,
+ originEqualsActive: record.originEqualsActive,
+ rendererBearerPresent: record.rendererBearerPresent,
+ rendererCookiePresent: record.rendererCookiePresent,
+ outboundBearerPresent: record.outboundBearerPresent,
+ bearerMainInjected: record.bearerMainInjected,
+ accepted: record.accepted,
+ rejectionCategory: record.rejectionCategory,
+ };
+ const valid = record.event === MAIN_CURRENT_USER_EVENT
+ && evidence.schemaVersion === 2 && evidence.correlation === 'current-scope-user-validation'
+ && evidence.requestObserved === true && evidence.method === 'get'
+ && (evidence.rendererScopeGeneration === null
+ || (Number.isSafeInteger(evidence.rendererScopeGeneration) && evidence.rendererScopeGeneration >= 0))
+ && Number.isSafeInteger(evidence.scopeGenerationQueryCount)
+ && evidence.scopeGenerationQueryCount >= 0 && evidence.scopeGenerationQueryCount <= 2
+ && typeof evidence.scopeGenerationQueryValid === 'boolean'
+ && Number.isSafeInteger(evidence.scopeHeaderCount) && evidence.scopeHeaderCount >= 0 && evidence.scopeHeaderCount <= 2
+ && Number.isSafeInteger(evidence.activeScopeGeneration) && evidence.activeScopeGeneration >= 0
+ && CURRENT_USER_PROXY_CATEGORIES.has(evidence.rejectionCategory)
+ && [
+ evidence.activeBindingPresent, evidence.profileGenerationCurrent, evidence.scopeEqualsActive,
+ evidence.originEqualsActive, evidence.rendererBearerPresent, evidence.rendererCookiePresent,
+ evidence.outboundBearerPresent, evidence.bearerMainInjected, evidence.accepted,
+ ].every(value => typeof value === 'boolean')
+ && mainCurrentUserRecords.filter(item => item.journey === journey).length < 4;
+ if (!valid) currentUserEvidenceInvalid = true;
+ else mainCurrentUserRecords.push({ journey, ...evidence });
+ } catch {
+ currentUserEvidenceInvalid = true;
+ }
+};
+
+const captureNetworkPermissionEvidence = (line, journey) => {
+ if (!line.includes(NETWORK_PERMISSION_EVENT)) return;
+ try {
+ const record = JSON.parse(line.slice(line.indexOf('{')));
+ const evidence = {
+ schemaVersion: record.schemaVersion,
+ permissionCategory: record.permissionCategory,
+ decision: record.decision,
+ allowed: record.allowed,
+ activeBindingCurrent: record.activeBindingCurrent,
+ webContentsPresent: record.webContentsPresent,
+ webContentsEqualsMainWindow: record.webContentsEqualsMainWindow,
+ mainWindowPresent: record.mainWindowPresent,
+ isMainFrame: record.isMainFrame,
+ requestingUrlPresent: record.requestingUrlPresent,
+ requestingUrlTrusted: record.requestingUrlTrusted,
+ rendererDocumentUrlTrusted: record.rendererDocumentUrlTrusted,
+ requestingOriginAuthorityValid: record.requestingOriginAuthorityValid,
+ requestingOriginAuthorityEqual: record.requestingOriginAuthorityEqual,
+ };
+ const valid = record.event === NETWORK_PERMISSION_EVENT
+ && evidence.schemaVersion === 1
+ && NETWORK_PERMISSION_CATEGORIES.has(evidence.permissionCategory)
+ && ['check', 'request'].includes(evidence.decision)
+ && [
+ evidence.allowed,
+ evidence.activeBindingCurrent,
+ evidence.webContentsPresent,
+ evidence.webContentsEqualsMainWindow,
+ evidence.mainWindowPresent,
+ evidence.isMainFrame,
+ evidence.requestingUrlPresent,
+ evidence.requestingUrlTrusted,
+ evidence.rendererDocumentUrlTrusted,
+ evidence.requestingOriginAuthorityValid,
+ evidence.requestingOriginAuthorityEqual,
+ ].every(value => typeof value === 'boolean');
+ if (!valid || networkPermissionRecords.filter(item => item.journey === journey).length >= 24) {
+ networkPermissionEvidenceInvalidCount = Math.min(networkPermissionEvidenceInvalidCount + 1, 9);
+ return;
+ }
+ networkPermissionRecords.push({ journey, ...evidence });
+ } catch {
+ networkPermissionEvidenceInvalidCount = Math.min(networkPermissionEvidenceInvalidCount + 1, 9);
+ }
+};
+
+const captureRendererLifecycleEvidence = (argumentsValue, journey) => {
+ if (argumentsValue[0] !== RENDERER_LIFECYCLE_PREFIX) return;
+ const evidence = argumentsValue[1];
+ const exactKeys = evidence && typeof evidence === 'object' && !Array.isArray(evidence)
+ && Object.keys(evidence).sort().join('\n') === RENDERER_LIFECYCLE_KEYS.join('\n');
+ const booleans = exactKeys && [
+ 'profileActivationPublished', 'socketProviderMounted', 'providerDisabled', 'desktopRuntime',
+ 'disabledByDemoModeLoading', 'disabledByDemoMode',
+ 'disabledByCurrentUserLoading', 'disabledByCurrentUserAbsent',
+ ].every(key => typeof evidence[key] === 'boolean');
+ const counts = exactKeys && [
+ 'socketConstructionInvocations', 'socketConstructions', 'connectInvocations',
+ ].every(key => Number.isInteger(evidence[key]) && evidence[key] >= 0 && evidence[key] <= 2);
+ const disableReasonsConsistent = booleans && evidence.providerDisabled === [
+ evidence.disabledByDemoModeLoading,
+ evidence.disabledByDemoMode,
+ evidence.disabledByCurrentUserLoading,
+ evidence.disabledByCurrentUserAbsent,
+ ].some(Boolean);
+ const schemaOrShapeInvalid = !exactKeys || evidence.schemaVersion !== 2 || !RENDERER_LIFECYCLE_PHASES.has(evidence.phase)
+ || !['unknown', 'available', 'unavailable'].includes(evidence.connectionScope)
+ || !booleans || !counts || !disableReasonsConsistent;
+ const overflow = rendererLifecycleRecords.filter(record => record.journey === journey).length >= 12;
+ if (schemaOrShapeInvalid || overflow) {
+ rendererLifecycleEvidenceInvalid = true;
+ if (schemaOrShapeInvalid) rendererLifecycleInvalidCategories.add('schema-shape');
+ if (overflow) rendererLifecycleInvalidCategories.add('overflow');
+ return;
+ }
+ rendererLifecycleRecords.push({ journey, ...evidence });
+};
+
+const captureRendererCurrentUserEvidence = (argumentsValue, journey) => {
+ if (argumentsValue[0] !== RENDERER_CURRENT_USER_PREFIX) return;
+ const evidence = argumentsValue[1];
+ const expectedKeys = [
+ 'activeScopePresent', 'classification', 'correlation', 'phase', 'responseStatus', 'schemaAccepted',
+ 'schemaVersion', 'scopeGeneration',
+ ].sort();
+ const valid = evidence && typeof evidence === 'object' && !Array.isArray(evidence)
+ && Object.keys(evidence).sort().join('\n') === expectedKeys.join('\n')
+ && evidence.schemaVersion === 1
+ && evidence.correlation === 'current-scope-user-validation'
+ && CURRENT_USER_RENDERER_PHASES.has(evidence.phase)
+ && CURRENT_USER_CLASSIFICATIONS.has(evidence.classification)
+ && Number.isSafeInteger(evidence.scopeGeneration) && evidence.scopeGeneration >= 0
+ && Number.isSafeInteger(evidence.responseStatus) && evidence.responseStatus >= 0 && evidence.responseStatus <= 599
+ && typeof evidence.activeScopePresent === 'boolean'
+ && typeof evidence.schemaAccepted === 'boolean'
+ && rendererCurrentUserRecords.filter(item => item.journey === journey).length < 10;
+ if (!valid) currentUserEvidenceInvalid = true;
+ else rendererCurrentUserRecords.push({ journey, ...evidence });
+};
+
+await access(binaryPath, fsConstants.X_OK);
+const binaryStats = await lstat(binaryPath);
+if (!binaryStats.isFile() || binaryStats.isSymbolicLink()) throw new Error('Packaged acceptance binary must be an executable non-link file');
+await prepareAcceptanceArtifactDirectory(outputDirectory);
+
+const json = (response, status, value, extra = {}) => {
+ response.writeHead(status, {
+ 'Access-Control-Allow-Credentials': 'true',
+ 'Access-Control-Allow-Headers': 'Content-Type, X-ProPR-Desktop-Transport-Scope',
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
+ 'Access-Control-Allow-Origin': DESKTOP_RENDERER_ORIGIN,
+ 'Access-Control-Allow-Private-Network': 'true',
+ 'Cache-Control': 'no-store',
+ 'Content-Type': 'application/json',
+ ...extra,
+ });
+ response.end(JSON.stringify(value));
+};
+
+const fixtures = [];
+const fixtureHandshakeCategory = evidence => {
+ if (evidence.path !== 'socket-io') return 'wrong-path';
+ if (evidence.transport !== 'websocket') return 'wrong-transport';
+ if (evidence.resource !== 'websocket-upgrade') return 'wrong-resource-type';
+ if (evidence.scopeQueryCount === 0) return 'scope-missing';
+ if (evidence.scopeQueryCount !== 1) return 'scope-duplicate';
+ if (!evidence.scopeQueryWellFormed) return 'scope-malformed';
+ if (evidence.cookiePresent) return 'cookie-present';
+ if (!evidence.authorizationPresent) return 'authorization-missing';
+ if (!evidence.authorizationMatchesActivatedBearer) return 'authorization-mismatch';
+ return 'none';
+};
+
+const createFixture = async (mode, fixedOrigin) => {
+ const expectedUrl = new URL(fixedOrigin);
+ let binding = null;
+ let authChecks = 0;
+ const recordCurrentUserFixture = record => {
+ if (fixtureCurrentUserRecords.filter(item => item.journey === activeJourney).length >= 4) {
+ currentUserEvidenceInvalid = true;
+ return;
+ }
+ fixtureCurrentUserRecords.push(record);
+ };
+ const server = createServer(async (request, response) => {
+ const chunks = [];
+ for await (const chunk of request) chunks.push(chunk);
+ const bodyText = Buffer.concat(chunks).toString('utf8');
+ const requestUrl = new URL(request.url || '/', fixedOrigin);
+ let body = {};
+ try { body = bodyText ? JSON.parse(bodyText) : {}; } catch { body = {}; }
+ requestRecords.push({
+ journey: activeJourney,
+ mode,
+ method: request.method || '',
+ url: request.url || '',
+ fullUrl: `${fixedOrigin}${request.url || ''}`,
+ origin: request.headers.origin || '',
+ hasAuthorization: Boolean(request.headers.authorization),
+ bodyBytes: Buffer.byteLength(bodyText),
+ });
+ if (request.method === 'OPTIONS') return json(response, 204, {});
+ if (request.url === '/api/desktop/discovery') {
+ return json(response, 200, {
+ product: 'ProPR', version: mode === 'incompatible' ? '99.0.0' : '0.8.15',
+ apiCompatibility: mode === 'incompatible' ? 'desktop-incompatible-v99' : PROPR_API_COMPATIBILITY,
+ uiCompatibility: PROPR_UI_COMPATIBILITY,
+ desktopAuthentication: { protocolVersion: 2, browserPairing: true, instanceBearerTokens: true, socketIoBearerAuthentication: true },
+ });
+ }
+ if (request.method === 'POST' && request.url === '/api/desktop/pairings') {
+ binding = body;
+ return json(response, 200, {
+ pairingId: PAIRING_ID,
+ deviceSecret: DEVICE_SECRET,
+ approvalUrl: `${fixedOrigin}/api/desktop/pairings/${PAIRING_ID}/browser`,
+ expiresAt: new Date(FIXED_MILLIS + 60_000).toISOString(),
+ interval: 1,
+ });
+ }
+ if (request.method === 'POST' && request.url === `/api/desktop/pairings/${PAIRING_ID}/poll`) {
+ return json(response, 200, {
+ status: 'provisional', token: INSTANCE_TOKEN, tokenType: 'Bearer', activationTicket: ACTIVATION_TICKET,
+ activationExpiresAt: new Date(FIXED_MILLIS + 60_000).toISOString(), instanceId: binding?.instanceId,
+ origin: binding?.origin, scope: binding?.scope, credentialGeneration: binding?.credentialGeneration,
+ });
+ }
+ if (request.method === 'POST' && request.url === `/api/desktop/pairings/${PAIRING_ID}/activate`) {
+ return json(response, 200, { status: 'active', receipt: RECEIPT, activatedAt: FIXED_TIME, expiresAt: null });
+ }
+ if (request.method === 'POST' && request.url === `/api/desktop/pairings/${PAIRING_ID}/cancel`) {
+ return json(response, 200, { status: 'cancelled', cancelledAt: FIXED_TIME });
+ }
+ if (request.method === 'DELETE' && request.url === '/api/desktop/tokens/current') return json(response, 204, {});
+ const currentUserShape = classifyCurrentUserRequestShape(
+ request.method,
+ request.url,
+ request.headers.origin,
+ );
+ if (currentUserShape !== null) {
+ if (currentUserShape.source === 'renderer') authChecks += 1;
+ const source = currentUserShape.source;
+ const record = {
+ journey: activeJourney,
+ correlation: 'current-scope-user-validation',
+ source,
+ scopeGeneration: currentUserShape.scopeGeneration,
+ requestArrived: true,
+ authorizationPresent: typeof request.headers.authorization === 'string',
+ authorizationMatchesActivatedBearer: request.headers.authorization === `Bearer ${INSTANCE_TOKEN}`,
+ cookiePresent: typeof request.headers.cookie === 'string',
+ responseStatus: 200,
+ classification: 'success',
+ };
+ if (source === 'renderer' && mode === 'revoked' && authChecks >= 1) {
+ record.responseStatus = 401;
+ record.classification = 'revoked';
+ recordCurrentUserFixture(record);
+ return json(response, 401, { code: 'INSTANCE_TOKEN_REVOKED' });
+ }
+ if (request.headers.authorization !== `Bearer ${INSTANCE_TOKEN}`) {
+ record.responseStatus = 401;
+ record.classification = 'unauthenticated';
+ recordCurrentUserFixture(record);
+ return json(response, 401, { code: 'INVALID_INSTANCE_TOKEN' });
+ }
+ recordCurrentUserFixture(record);
+ return json(response, 200, {
+ id: 'acceptance-user', login: 'acceptance-admin', username: 'acceptance-admin', displayName: 'Acceptance Admin',
+ email: null, avatarUrl: null, role: 'admin',
+ permissions: ['instance.manage_agents', 'instance.manage_members', 'instance.manage_runtime', 'instance.manage_settings'],
+ authorizationSource: 'local',
+ });
+ }
+ if (request.url === '/api/compatibility') return json(response, 200, { apiCompatibility: PROPR_API_COMPATIBILITY, uiCompatibility: PROPR_UI_COMPATIBILITY });
+ if (request.url?.startsWith('/api/status')) return json(response, 200, { daemon: 'Running', redis: 'Connected', githubAuth: 'Authenticated', claudeAuth: 'Ready', agents: [], githubEventIntake: 'ProPR Connect', githubEventIntakeStatus: 'Connected' });
+ if (request.url?.startsWith('/api/queue/stats')) return json(response, 200, { active: 0, waiting: 0, completed: 12, failed: 0, delayed: 0, paused: 0 });
+ if (requestUrl.pathname === '/api/stats/generating-plans') return json(response, 200, { count: 0 });
+ if (requestUrl.pathname === '/api/stats/tasks') {
+ return json(response, 200, {
+ dailyCounts: [],
+ statusDistribution: [
+ { status: 'completed', count: 12 },
+ { status: 'failed', count: 0 },
+ ],
+ avgProcessingTime: [],
+ summary: { total: 12, completed: 12, failed: 0 },
+ });
+ }
+ if (requestUrl.pathname === '/api/stats/overview') {
+ return json(response, 200, {
+ tasks: {
+ completed: 12,
+ planned: 0,
+ pr_iterations_avg: 0,
+ merged_prs: 0,
+ total_followups: 0,
+ },
+ usage: { total_tokens: 0, total_cost_usd: 0, models: {} },
+ system: { repos_indexed: 0 },
+ });
+ }
+ if (requestUrl.pathname === '/api/stats/repositories') {
+ return json(response, 200, { repositories: [] });
+ }
+ if (request.url?.startsWith('/api/stats/')) return json(response, 200, { total: 12, pending: 0, inProgress: 0, completed: 12, failed: 0, dailyCounts: [] });
+ if (request.url?.startsWith('/api/tasks')) return json(response, 200, { tasks: [], total: 0 });
+ if (request.url?.startsWith('/api/')) return json(response, 200, { agents: [], repositories: [], items: [], count: 0 });
+ return json(response, 404, { code: 'NOT_FOUND' });
+ });
+ const io = new SocketIOServer(server, {
+ path: '/socket.io/',
+ transports: ['websocket'],
+ cors: { origin: DESKTOP_RENDERER_ORIGIN, credentials: false },
+ });
+ io.engine.use((request, _response, next) => {
+ const requestUrl = new URL(request.url || '/', 'http://fixture.invalid');
+ const queryScopes = requestUrl.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY);
+ const evidence = {
+ journey: activeJourney,
+ mode,
+ path: requestUrl.pathname === '/socket.io/' ? 'socket-io' : 'other',
+ transport: requestUrl.searchParams.getAll('transport').length === 1
+ && requestUrl.searchParams.get('transport') === 'websocket' ? 'websocket' : 'other',
+ resource: request.method === 'GET' && request.headers.upgrade?.toLowerCase() === 'websocket'
+ ? 'websocket-upgrade' : 'other',
+ scopeQueryPresent: queryScopes.length > 0,
+ scopeQueryCount: queryScopes.length,
+ scopeQueryWellFormed: queryScopes.length === 1 && /^[A-Za-z0-9_-]{22}$/.test(queryScopes[0]),
+ authorizationPresent: typeof request.headers.authorization === 'string',
+ authorizationMatchesActivatedBearer: request.headers.authorization === `Bearer ${INSTANCE_TOKEN}`,
+ cookiePresent: typeof request.headers.cookie === 'string',
+ accepted: false,
+ rejectionCategory: 'none',
+ };
+ evidence.rejectionCategory = fixtureHandshakeCategory(evidence);
+ evidence.accepted = evidence.rejectionCategory === 'none';
+ fixtureHandshakeRecords.push(evidence);
+ if (!evidence.accepted) {
+ next(new Error('PACKAGED_ACCEPTANCE_SOCKET_REJECTED'));
+ return;
+ }
+ next();
+ });
+ io.use((socket, next) => {
+ const queryScopes = new URL(socket.handshake.url, 'http://fixture.invalid').searchParams
+ .getAll(DESKTOP_TRANSPORT_SCOPE_QUERY);
+ if (socket.handshake.headers.authorization !== `Bearer ${INSTANCE_TOKEN}` || queryScopes.length !== 1) {
+ next(new Error('PACKAGED_ACCEPTANCE_SOCKET_REJECTED'));
+ return;
+ }
+ next();
+ });
+ io.on('connection', socket => {
+ const journey = activeJourney;
+ const authenticated = socket.handshake.headers.authorization === `Bearer ${INSTANCE_TOKEN}`;
+ const recordApplicationSubscription = () => socketRecords.push({
+ journey,
+ mode,
+ event: QUEUE_STATS_SUBSCRIBE_EVENT,
+ authenticated,
+ direction: 'client-to-fixture',
+ genuineApplicationSubscription: true,
+ });
+ socketRecords.push({ journey, mode, event: 'connection', authenticated });
+ socket.once(QUEUE_STATS_SUBSCRIBE_EVENT, () => {
+ recordApplicationSubscription();
+ socket.once(QUEUE_STATS_SUBSCRIBE_EVENT, recordApplicationSubscription);
+ socket.emit(QUEUE_STATS_UPDATE, {
+ eventType: QUEUE_STATS_UPDATE,
+ stats: { waiting: 0, active: 0, completed: 12, failed: 0, delayed: 0, total: 12 },
+ timestamp: FIXED_TIME,
+ });
+ socketRecords.push({
+ journey,
+ mode,
+ event: QUEUE_STATS_UPDATE,
+ authenticated,
+ direction: 'fixture-to-renderer',
+ genuineApplicationEvent: true,
+ });
+ });
+ });
+ server.listen(Number(expectedUrl.port), expectedUrl.hostname);
+ await Promise.race([
+ once(server, 'listening'),
+ once(server, 'error').then(([error]) => { throw error; }),
+ ]);
+ const address = server.address();
+ fixtures.push({ server, io });
+ const observedOrigin = `http://${address.address}:${address.port}`;
+ if (observedOrigin !== fixedOrigin) throw new Error(`Acceptance fixture origin changed: ${observedOrigin}`);
+ return fixedOrigin;
+};
+
+let readyOrigin;
+let revokedOrigin;
+let incompatibleOrigin;
+
+const isolatedEnvironment = (userData, scenario) => {
+ const allowed = {};
+ for (const name of ['DBUS_SESSION_BUS_ADDRESS', 'GNOME_KEYRING_CONTROL', 'DISPLAY', 'XAUTHORITY', 'PATH']) {
+ if (process.env[name]) allowed[name] = process.env[name];
+ }
+ return {
+ ...allowed,
+ HOME: userData,
+ LANG: 'C.UTF-8',
+ LANGUAGE: 'en_US:en',
+ LC_ALL: 'C.UTF-8',
+ TZ: 'UTC',
+ SOURCE_DATE_EPOCH: String(Math.floor(FIXED_MILLIS / 1000)),
+ XDG_CONFIG_HOME: join(userData, 'xdg-config'),
+ XDG_DATA_HOME: join(userData, 'xdg-data'),
+ XDG_CACHE_HOME: join(userData, 'xdg-cache'),
+ PROPR_DESKTOP_ACCEPTANCE_TEST: '1',
+ PROPR_DESKTOP_ACCEPTANCE_SCENARIO: scenario,
+ };
+};
+
+const terminateProcessTree = async (child, childClosed) => {
+ if (child.exitCode !== null || child.signalCode !== null) return;
+ if (!Number.isInteger(child.pid)) { await childClosed.catch(() => undefined); return; }
+ try { process.kill(-child.pid, 'SIGTERM'); } catch (error) { if (error?.code !== 'ESRCH') throw error; }
+ await Promise.race([childClosed, sleep(5_000)]).catch(() => undefined);
+ if (child.exitCode === null && child.signalCode === null) {
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { if (error?.code !== 'ESRCH') throw error; }
+ await childClosed.catch(() => undefined);
+ }
+};
+
+const launchApplication = async (scenario, initialDeepLink) => {
+ const userData = await mkdtemp(join(tmpdir(), ACCEPTANCE_PROFILE_PREFIX));
+ let child;
+ let childClosed;
+ let browser;
+ let cleaned = false;
+ let output = '';
+ const cleanup = async () => {
+ if (cleaned) return;
+ cleaned = true;
+ const failures = [];
+ if (browser) await browser.close().catch(error => failures.push(error));
+ if (child && childClosed) await terminateProcessTree(child, childClosed).catch(error => failures.push(error));
+ await scanAcceptancePaths([userData], SENTINELS).catch(error => failures.push(error));
+ surfaces.push({ kind: 'packaged-process-output', journey: activeJourney, value: output });
+ processRecords.push({ journey: activeJourney, source: 'packaged-process', level: 'combined', bytes: Buffer.byteLength(output), sha256: digest(output) });
+ await safeRemoveAcceptanceLeaf(userData, { kind: 'profile' }).catch(error => failures.push(error));
+ if (failures.length) throw new AggregateError(failures, 'Packaged acceptance cleanup failed');
+ };
+
+ try {
+ const args = [
+ '--disable-background-networking', '--disable-gpu', '--force-device-scale-factor=1', '--lang=en-US',
+ '--remote-debugging-port=0', '--propr-acceptance-test', `--user-data-dir=${userData}`,
+ '--password-store=gnome-libsecret', ...(initialDeepLink ? [initialDeepLink] : []),
+ ];
+ child = spawn(binaryPath, args, {
+ cwd: userData,
+ detached: true,
+ env: isolatedEnvironment(userData, scenario),
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ childClosed = once(child, 'close');
+ let resolveEndpoint;
+ let rejectEndpoint;
+ const endpoint = new Promise((resolveValue, rejectValue) => { resolveEndpoint = resolveValue; rejectEndpoint = rejectValue; });
+ const capture = () => {
+ let remainder = '';
+ return chunk => {
+ const text = chunk.toString();
+ output += text;
+ const match = text.match(/DevTools listening on (ws:\/\/[^\s]+)/);
+ if (match) resolveEndpoint(match[1]);
+ const lines = `${remainder}${text}`.split(/\r?\n/);
+ remainder = lines.pop() || '';
+ lines.forEach(line => {
+ captureMainHandshakeEvidence(line, activeJourney);
+ captureMainCurrentUserEvidence(line, activeJourney);
+ captureNetworkPermissionEvidence(line, activeJourney);
+ });
+ };
+ };
+ child.stdout.on('data', capture());
+ child.stderr.on('data', capture());
+ child.once('error', rejectEndpoint);
+ child.once('close', code => rejectEndpoint(new Error(`Packaged Electron exited before CDP startup (${code ?? 'signal'})`)));
+ const timeout = setTimeout(() => rejectEndpoint(new Error('Packaged Electron CDP endpoint did not start')), 20_000);
+ const wsEndpoint = await endpoint.finally(() => clearTimeout(timeout));
+ browser = await chromium.connectOverCDP(wsEndpoint);
+ const { context, page } = await waitForUsableElectronRenderer(browser, child, {
+ expectedUrlPrefix: DESKTOP_RENDERER_ORIGIN,
+ timeoutMs: 15_000,
+ });
+ page.on('console', message => {
+ const journey = activeJourney;
+ const pending = Promise.all(message.args().map(async argument => {
+ try { return await argument.jsonValue(); } catch { return { unserializable: argument.toString() }; }
+ })).then(argumentsValue => {
+ const record = {
+ journey,
+ type: message.type(),
+ text: message.text(),
+ arguments: argumentsValue,
+ location: message.location(),
+ };
+ consoleRecords.push(record);
+ captureRendererLifecycleEvidence(record.arguments, journey);
+ captureRendererCurrentUserEvidence(record.arguments, journey);
+ });
+ pendingConsoleRecords.push(pending);
+ });
+ page.on('pageerror', error => pageErrorRecords.push({ journey: activeJourney, name: error.name, message: error.message, stack: error.stack || '' }));
+ await page.evaluate(({ fixedTime, fixedMillis }) => {
+ const NativeDate = Date;
+ class AcceptanceDate extends NativeDate {
+ constructor(...args) { super(...(args.length ? args : [fixedMillis])); }
+ static now() { return fixedMillis; }
+ }
+ Object.defineProperty(window, 'Date', { configurable: false, value: AcceptanceDate });
+ let uuidCounter = 0;
+ Object.defineProperty(Crypto.prototype, 'randomUUID', {
+ configurable: false,
+ value: () => `00000000-0000-4000-8000-${String(++uuidCounter).padStart(12, '0')}`,
+ });
+ document.documentElement.dataset.acceptanceFixedTime = fixedTime;
+ }, { fixedTime: FIXED_TIME, fixedMillis: FIXED_MILLIS });
+ await page.waitForFunction(() => Boolean(document.querySelector('.desktop-entry, .desktop-app')), null, { timeout: 15_000 });
+ await page.addStyleTag({ content: `
+ *, *::before, *::after { animation: none !important; caret-color: transparent !important; transition: none !important; scroll-behavior: auto !important; }
+ html body, html body button, html body input, html body select, html body textarea { font-family: "Liberation Sans", sans-serif !important; }
+ ` });
+ const initialization = await page.evaluate(fixedTime => ({
+ rendererOrigin: `${location.protocol}//${location.host}`,
+ preloadBridge: typeof window.proprDesktop === 'object' && window.proprDesktop !== null
+ && typeof window.__PROPR_DESKTOP__ === 'object' && window.__PROPR_DESKTOP__ !== null,
+ acceptanceZoomBridge: typeof window.__PROPR_PACKAGED_ACCEPTANCE__ === 'object'
+ && window.__PROPR_PACKAGED_ACCEPTANCE__ !== null
+ && Object.keys(window.__PROPR_PACKAGED_ACCEPTANCE__).join('\n') === 'setZoomFactor'
+ && typeof window.__PROPR_PACKAGED_ACCEPTANCE__.setZoomFactor === 'function',
+ fontLoaded: document.fonts.check('12px "Liberation Sans"'),
+ locale: navigator.language,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ rendererTime: new Date().toISOString(),
+ fixedMarker: document.documentElement.dataset.acceptanceFixedTime === fixedTime,
+ }), FIXED_TIME);
+ if (initialization.rendererOrigin !== 'propr-app://renderer' || !initialization.preloadBridge
+ || !initialization.acceptanceZoomBridge
+ || !initialization.fontLoaded || initialization.locale !== DETERMINISTIC_INPUTS.locale
+ || initialization.timezone !== DETERMINISTIC_INPUTS.timezone || initialization.rendererTime !== FIXED_TIME
+ || !initialization.fixedMarker) {
+ throw new Error(`Packaged acceptance deterministic boundary initialization failed: ${JSON.stringify(initialization)}`);
+ }
+ boundaryJourneys.add(activeJourney);
+ return { context, page, close: cleanup };
+ } catch (error) {
+ try { await cleanup(); } catch (cleanupError) {
+ throw new AggregateError([error, cleanupError], 'Packaged acceptance launch and cleanup failed');
+ }
+ throw error;
+ }
+};
+
+const fillEndpoint = async (page, origin, name = 'Acceptance ProPR') => {
+ await page.getByRole('button', { name: /Connect to an existing instance/ }).click();
+ await page.getByLabel('Display name').fill(name);
+ await page.getByLabel('Instance URL').fill(origin);
+ await page.getByRole('button', { name: 'Connect', exact: true }).click();
+};
+
+const pair = async (page, origin, name = 'Acceptance ProPR') => {
+ await fillEndpoint(page, origin, name);
+ await page.getByRole('button', { name: 'Sign in in browser' }).waitFor();
+ await page.getByRole('button', { name: 'Sign in in browser' }).click();
+};
+
+const liveRegionSnapshot = () => [...document.querySelectorAll('[aria-live], [role="status"], [role="alert"]')].map(element => ({
+ ariaLive: element.getAttribute('aria-live') || '',
+ role: element.getAttribute('role') || '',
+ text: element.textContent || '',
+}));
+
+const observeLiveMutation = async (page, kind, journey, action) => {
+ const before = await page.evaluate(liveRegionSnapshot);
+ await action();
+ await page.waitForFunction(({ beforeJson, kindName }) => {
+ const snapshot = [...document.querySelectorAll('[aria-live], [role="status"], [role="alert"]')].map(element => ({
+ ariaLive: element.getAttribute('aria-live') || '', role: element.getAttribute('role') || '', text: element.textContent || '',
+ }));
+ const changed = JSON.stringify(snapshot) !== beforeJson && snapshot.some(entry => entry.text.trim());
+ if (!changed) return false;
+ return kindName === 'status' || snapshot.some(entry => entry.role === 'alert' || /could not|unavailable|error|attention/i.test(entry.text));
+ }, { beforeJson: JSON.stringify(before), kindName: kind }, { timeout: 15_000 });
+ const after = await page.evaluate(liveRegionSnapshot);
+ const beforeJson = JSON.stringify(before);
+ const afterJson = JSON.stringify(after);
+ if (beforeJson === afterJson) throw new Error(`Acceptance ${kind} live region did not mutate`);
+ liveAnnouncements[kind] = { journey, beforeHash: digest(beforeJson), afterHash: digest(afterJson), mutated: true };
+};
+
+const collectRendererSurface = async (page, journey, name) => {
+ const renderer = await page.evaluate(async ({ journeyName, screenshot }) => {
+ const attributes = element => Object.fromEntries([...element.attributes].map(attribute => [attribute.name, attribute.value]));
+ const forms = [...document.querySelectorAll('input, select, textarea, button')].map(element => ({
+ tagName: element.tagName,
+ attributes: attributes(element),
+ value: 'value' in element ? element.value : '',
+ checked: 'checked' in element ? element.checked : false,
+ selectedValues: element instanceof HTMLSelectElement ? [...element.selectedOptions].map(option => option.value) : [],
+ }));
+ const databases = [];
+ if (typeof indexedDB.databases === 'function') {
+ for (const databaseInfo of await indexedDB.databases()) {
+ if (!databaseInfo.name) continue;
+ const contents = await new Promise((resolveValue, rejectValue) => {
+ const request = indexedDB.open(databaseInfo.name);
+ request.onerror = () => rejectValue(request.error);
+ request.onsuccess = () => {
+ const database = request.result;
+ const stores = [...database.objectStoreNames];
+ if (stores.length === 0) { database.close(); resolveValue({ name: databaseInfo.name, version: database.version, stores: {} }); return; }
+ const transaction = database.transaction(stores, 'readonly');
+ const values = {};
+ transaction.onerror = () => rejectValue(transaction.error);
+ transaction.oncomplete = () => { database.close(); resolveValue({ name: databaseInfo.name, version: database.version, stores: values }); };
+ stores.forEach(store => {
+ const all = transaction.objectStore(store).getAll();
+ all.onsuccess = () => { values[store] = all.result; };
+ });
+ };
+ });
+ databases.push(contents);
+ }
+ }
+ const cachesData = [];
+ if ('caches' in window) {
+ for (const cacheName of await caches.keys()) {
+ const cache = await caches.open(cacheName);
+ const entries = [];
+ for (const request of await cache.keys()) {
+ const response = await cache.match(request);
+ entries.push({ requestUrl: request.url, responseUrl: response?.url || '', body: response ? await response.clone().text() : '' });
+ }
+ cachesData.push({ name: cacheName, entries });
+ }
+ }
+ return {
+ kind: 'renderer', journey: journeyName, screenshot, url: location.href, title: document.title,
+ html: document.documentElement.outerHTML, forms,
+ localStorage: Object.fromEntries(Object.entries(localStorage)),
+ sessionStorage: Object.fromEntries(Object.entries(sessionStorage)),
+ indexedDB: databases, caches: cachesData,
+ };
+ }, { journeyName: journey, screenshot: name });
+ renderer.cookies = await page.context().cookies();
+ surfaces.push(renderer);
+};
+
+const inspectAccessibility = async (page, journey, variant, config, metrics, name) => {
+ const seriousFindings = await analyzeExistingElectronRenderer(page);
+ for (const violation of seriousFindings) {
+ axeFindings.push({ name, journey, variant, id: violation.id, impact: violation.impact, nodes: violation.nodes });
+ }
+ const deterministic = await page.evaluate(({ expectedReducedMotion, fixedTime }) => {
+ const visibleControls = [...document.querySelectorAll('button, a[href], input, select, textarea')].filter(element => {
+ const style = getComputedStyle(element);
+ const bounds = element.getBoundingClientRect();
+ return style.display !== 'none' && style.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
+ });
+ const unnamed = visibleControls.filter(element => {
+ const label = element.getAttribute('aria-label') || element.getAttribute('title')
+ || (element instanceof HTMLInputElement ? element.labels?.[0]?.textContent : '') || element.textContent;
+ return !label?.trim();
+ }).length;
+ return {
+ accessibleNames: unnamed === 0,
+ locale: navigator.language,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ fontLoaded: document.fonts.check('12px "Liberation Sans"'),
+ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
+ rendererTime: new Date().toISOString(),
+ animationsDisabled: document.getAnimations({ subtree: true }).every(animation => animation.playState !== 'running'),
+ expectedReducedMotion,
+ };
+ }, { expectedReducedMotion: config.reducedMotion, fixedTime: FIXED_TIME });
+ if (deterministic.locale !== DETERMINISTIC_INPUTS.locale || deterministic.timezone !== DETERMINISTIC_INPUTS.timezone
+ || deterministic.rendererTime !== FIXED_TIME || deterministic.reducedMotion !== config.reducedMotion
+ || deterministic.reducedMotion !== deterministic.expectedReducedMotion || !deterministic.fontLoaded
+ || !deterministic.animationsDisabled) {
+ throw new Error(`Acceptance deterministic renderer inputs changed for ${name}: ${JSON.stringify(deterministic)}`);
+ }
+ return {
+ name, journey, variant,
+ serious: seriousFindings.filter(finding => finding.impact === 'serious').length,
+ critical: seriousFindings.filter(finding => finding.impact === 'critical').length,
+ accessibleNames: deterministic.accessibleNames,
+ locale: deterministic.locale,
+ timezone: deterministic.timezone,
+ fontLoaded: deterministic.fontLoaded,
+ reducedMotion: deterministic.reducedMotion,
+ requestedViewport: metrics.requestedViewport,
+ playwrightViewport: metrics.playwrightViewport,
+ rendererViewport: metrics.rendererViewport,
+ documentClientViewport: metrics.documentClientViewport,
+ scrollbarInsets: metrics.scrollbarInsets,
+ visualViewportInsets: metrics.visualViewportInsets,
+ layoutViewport: metrics.layoutViewport,
+ cdpVisualViewport: metrics.cdpVisualViewport,
+ rendererVisualViewport: metrics.rendererVisualViewport,
+ effectiveVisibleCssSpan: metrics.effectiveVisibleCssSpan,
+ geometryZoom: metrics.geometryZoom,
+ requestedDeviceScaleFactor: metrics.requestedDeviceScaleFactor,
+ rendererDevicePixelRatio: metrics.rendererDevicePixelRatio,
+ requestedZoomFactor: metrics.requestedZoomFactor,
+ appliedZoomFactor: metrics.appliedZoomFactor,
+ zoomResetFactor: metrics.zoomResetFactor,
+ zoomMechanism: metrics.zoomMechanism,
+ animationsDisabled: deterministic.animationsDisabled,
+ rendererTime: deterministic.rendererTime,
+ };
+};
+
+const captureVariants = async (page, journey) => {
+ const cdp = await page.context().newCDPSession(page);
+ try {
+ await forEachElectronRendererVariant(page, cdp, ACCEPTANCE_VARIANTS, async ({ variant, config, metrics }) => {
+ const name = screenshotName(journey, variant);
+ const accessibilityCheck = await inspectAccessibility(page, journey, variant, config, metrics, name);
+ await collectRendererSurface(page, journey, name);
+ const first = await captureElectronRendererScreenshot(cdp);
+ const second = await captureElectronRendererScreenshot(cdp);
+ const firstDigest = digest(first);
+ if (!first.equals(second)) throw new Error(`Acceptance screenshot was not repeatable for ${name}`);
+ const dimensions = readPngDimensions(first);
+ const expectedDimensions = {
+ width: metrics.requestedViewport.width * metrics.requestedDeviceScaleFactor,
+ height: metrics.requestedViewport.height * metrics.requestedDeviceScaleFactor,
+ };
+ if (dimensions.width !== expectedDimensions.width || dimensions.height !== expectedDimensions.height) {
+ throw new Error(`Acceptance screenshot dimensions changed for ${name}: ${JSON.stringify(dimensions)}`);
+ }
+ const physicalPngDimensions = { ...dimensions };
+ accessibilityChecks.push({ ...accessibilityCheck, physicalPngDimensions });
+ await writeFile(join(outputDirectory, 'screenshots', name), first, { mode: 0o600 });
+ screenshotMetadata.push({
+ name, journey, variant,
+ width: dimensions.width,
+ height: dimensions.height,
+ physicalPngDimensions,
+ requestedDeviceScaleFactor: metrics.requestedDeviceScaleFactor,
+ rendererDevicePixelRatio: metrics.rendererDevicePixelRatio,
+ requestedZoomFactor: metrics.requestedZoomFactor,
+ appliedZoomFactor: metrics.appliedZoomFactor,
+ zoomResetFactor: metrics.zoomResetFactor,
+ zoomMechanism: metrics.zoomMechanism,
+ reducedMotion: metrics.reducedMotion,
+ requestedViewport: metrics.requestedViewport,
+ playwrightViewport: metrics.playwrightViewport,
+ rendererViewport: metrics.rendererViewport,
+ documentClientViewport: metrics.documentClientViewport,
+ scrollbarInsets: metrics.scrollbarInsets,
+ visualViewportInsets: metrics.visualViewportInsets,
+ layoutViewport: metrics.layoutViewport,
+ cdpVisualViewport: metrics.cdpVisualViewport,
+ rendererVisualViewport: metrics.rendererVisualViewport,
+ effectiveVisibleCssSpan: metrics.effectiveVisibleCssSpan,
+ geometryZoom: metrics.geometryZoom,
+ locale: DETERMINISTIC_INPUTS.locale,
+ timezone: DETERMINISTIC_INPUTS.timezone,
+ font: DETERMINISTIC_INPUTS.font,
+ colorScheme: DETERMINISTIC_INPUTS.colorScheme,
+ rendererTime: FIXED_TIME,
+ originPolicy: DETERMINISTIC_INPUTS.originPolicy,
+ visibleData: DETERMINISTIC_INPUTS.visibleData,
+ animations: DETERMINISTIC_INPUTS.animations,
+ repeatabilitySha256: firstDigest,
+ });
+ }, { colorScheme: DETERMINISTIC_INPUTS.colorScheme });
+ } finally {
+ await cdp.detach();
+ }
+};
+
+const runJourney = async (journey, scenario, initialDeepLink, prepare) => {
+ activeJourney = journey;
+ const application = await launchApplication(scenario, initialDeepLink);
+ try {
+ const afterCapture = await prepare(application.page, application);
+ await captureVariants(application.page, journey);
+ if (typeof afterCapture === 'function') await afterCapture();
+ } finally {
+ await application.close();
+ }
+};
+
+const waitForObserved = async (predicate, description) => {
+ const deadline = Date.now() + 15_000;
+ while (!predicate()) {
+ if (Date.now() >= deadline) throw new Error(`Acceptance did not observe ${description}`);
+ await sleep(50);
+ }
+};
+
+const currentUserValidationFailureCategory = journey => {
+ return classifyCurrentUserValidation({
+ journey,
+ evidenceInvalid: currentUserEvidenceInvalid,
+ rendererRecords: rendererCurrentUserRecords,
+ mainRecords: mainCurrentUserRecords,
+ fixtureRecords: fixtureCurrentUserRecords,
+ });
+};
+
+const currentUserPhaseSummary = journey => currentUserValidationPhaseSummary({
+ journey,
+ rendererRecords: rendererCurrentUserRecords,
+ mainRecords: mainCurrentUserRecords,
+ fixtureRecords: fixtureCurrentUserRecords,
+ requestRecords,
+});
+
+const networkPermissionSummary = journey => networkPermissionDecisionSummary({
+ journey,
+ records: networkPermissionRecords,
+ invalidCount: networkPermissionEvidenceInvalidCount,
+});
+
+const settlePendingRendererConsoleCaptures = async () => {
+ let settledCount = 0;
+ while (settledCount < pendingConsoleRecords.length) {
+ const unsettled = pendingConsoleRecords.slice(settledCount);
+ settledCount += unsettled.length;
+ await Promise.allSettled(unsettled);
+ }
+};
+
+const boundedAcceptanceDiagnosticCount = records => Math.min(records.length, 9);
+
+const rendererConsoleErrorCategory = record => {
+ if (record.text.startsWith('Failed to refresh instance authorization:')
+ || record.text.startsWith('Failed to synchronize instance authorization:')) return 'currentUserSync';
+ if (record.text.startsWith('[SocketContext]')) return 'socketContext';
+ if (record.text.startsWith('Failed to load or render a route:')
+ || record.text.startsWith('Root container missing in index.html')
+ || record.text.startsWith('Uncaught ')
+ || record.text.startsWith('Error: Minified React error')
+ || record.text.startsWith('Warning: React')) return 'reactRuntime';
+ if (record.text.startsWith('Failed to fetch ')
+ || record.text.startsWith('Failed to load ')
+ || record.text.startsWith('Error fetching ')
+ || record.text.startsWith('Silent refresh failed:')
+ || record.text.startsWith('[useGenerationPolling] Poll error:')) return 'apiLoad';
+ return 'other';
+};
+
+const rendererConsoleErrorCategoryCounts = records => {
+ const counts = { currentUserSync: 0, apiLoad: 0, socketContext: 0, reactRuntime: 0, other: 0 };
+ for (const record of records) {
+ const category = rendererConsoleErrorCategory(record);
+ counts[category] = Math.min(counts[category] + 1, 9);
+ }
+ return counts;
+};
+
+const rendererErrorCountSummary = journey => {
+ const rendererConsole = consoleRecords.filter(record => record.journey === journey);
+ const rendererPageErrors = pageErrorRecords.filter(record => record.journey === journey);
+ const rendererConsoleErrors = rendererConsole.filter(record => record.type === 'error');
+ return {
+ schemaVersion: 2,
+ console: boundedAcceptanceDiagnosticCount(rendererConsole),
+ consoleErrors: boundedAcceptanceDiagnosticCount(rendererConsoleErrors),
+ pageErrors: boundedAcceptanceDiagnosticCount(rendererPageErrors),
+ consoleErrorCategoryCounts: rendererConsoleErrorCategoryCounts(rendererConsoleErrors),
+ };
+};
+
+const rendererLifecycleInvalidCategory = () => {
+ const schemaShape = rendererLifecycleInvalidCategories.has('schema-shape');
+ const overflow = rendererLifecycleInvalidCategories.has('overflow');
+ if (schemaShape && overflow) return 'multiple';
+ if (schemaShape) return 'schema-shape';
+ if (overflow) return 'overflow';
+ return 'none';
+};
+
+const rendererLifecycleDiagnosticSummary = journey => ({
+ schemaVersion: 1,
+ recordCount: rendererLifecycleRecords.filter(record => record.journey === journey).length,
+ evidenceInvalid: rendererLifecycleEvidenceInvalid,
+ invalidCategory: rendererLifecycleInvalidCategory(),
+});
+
+const rendererUiStateSummary = async page => {
+ const absent = {
+ schemaVersion: 1,
+ connectionPillPresent: false,
+ connectionStatus: 'absent',
+ accessibleLabelCategory: 'other',
+ navigatorOnline: false,
+ desktopTitleBarPresent: false,
+ routeLayoutPresent: false,
+ loadingSpinnerPresent: false,
+ validatedCurrentUserMarkerPresent: false,
+ dashboardMarkerPresent: false,
+ };
+ try {
+ return await page.evaluate(() => {
+ const pill = document.querySelector('.desktop-connection-pill');
+ const classStatuses = ['ready', 'offline', 'incompatible'].filter(status =>
+ pill?.classList.contains(`desktop-connection-${status}`));
+ const connectionStatus = pill === null
+ ? 'absent'
+ : classStatuses.length === 1 ? classStatuses[0] : 'unknown';
+ const label = pill?.getAttribute('aria-label');
+ const accessibleLabelCategory = label === 'Connected: Operations'
+ ? 'Connected: Operations'
+ : label === 'Offline: Operations'
+ ? 'Offline: Operations'
+ : label === 'Update required: Operations' ? 'Update required: Operations' : 'other';
+ const routeLayout = document.querySelector(
+ '.desktop-shell .desktop-shell-content main.mobile-content-clearance',
+ );
+ const loadingSpinner = document.querySelector(
+ '[class~="h-screen"][class~="w-full"] [class~="h-12"][class~="w-12"][class~="animate-spin"]',
+ );
+ const validatedCurrentUserMarker = document.querySelector('a[href="/admin/members"]');
+ const dashboardMarker = [...document.querySelectorAll('main h3')]
+ .some(element => element.textContent?.trim() === 'Recent Activity');
+ return {
+ schemaVersion: 1,
+ connectionPillPresent: pill !== null,
+ connectionStatus,
+ accessibleLabelCategory,
+ navigatorOnline: navigator.onLine,
+ desktopTitleBarPresent: document.querySelector('.desktop-titlebar') !== null,
+ routeLayoutPresent: routeLayout !== null,
+ loadingSpinnerPresent: loadingSpinner !== null,
+ validatedCurrentUserMarkerPresent: validatedCurrentUserMarker !== null,
+ dashboardMarkerPresent: dashboardMarker,
+ };
+ });
+ } catch {
+ return absent;
+ }
+};
+
+const rendererSurfacePhase = async page => {
+ try {
+ return await page.evaluate(() => {
+ if (document.querySelector('.desktop-app')) return 'app';
+ if (document.querySelector('.desktop-entry')) return 'entry';
+ return 'loading';
+ });
+ } catch {
+ return 'loading';
+ }
+};
+
+const socketHandshakeFailureCategory = journey => {
+ const currentUserCategory = currentUserValidationFailureCategory(journey);
+ if (currentUserCategory !== 'none') return currentUserCategory;
+ const rejectedMain = mainHandshakeRecords.find(record => record.journey === journey && !record.accepted);
+ if (rejectedMain) return `main-${rejectedMain.rejectionCategory}`;
+ const rejectedFixture = fixtureHandshakeRecords.find(record => record.journey === journey && !record.accepted);
+ if (rejectedFixture) return `fixture-${rejectedFixture.rejectionCategory}`;
+ const mainAccepted = mainHandshakeRecords.filter(record => record.journey === journey && record.accepted);
+ const fixtureAccepted = fixtureHandshakeRecords.filter(record => record.journey === journey && record.accepted);
+ const connected = socketRecords.filter(record => record.journey === journey
+ && record.event === 'connection' && record.authenticated);
+ const subscriptions = socketRecords.filter(record => record.journey === journey
+ && record.event === QUEUE_STATS_SUBSCRIBE_EVENT && record.authenticated
+ && record.genuineApplicationSubscription === true && record.direction === 'client-to-fixture');
+ const applicationEvents = socketRecords.filter(record => record.journey === journey
+ && record.event === QUEUE_STATS_UPDATE && record.authenticated
+ && record.genuineApplicationEvent === true && record.direction === 'fixture-to-renderer');
+ const rendererObservedApplicationEvents = consoleRecords.filter(record => record.journey === journey
+ && record.text.startsWith('[SocketContext] Received queue stats update:'));
+ if (mainAccepted.length > 1) return 'duplicate-main-upgrade';
+ if (fixtureAccepted.length > 1) return 'duplicate-fixture-upgrade';
+ if (connected.length > 1) return 'duplicate-authenticated-connection';
+ if (subscriptions.length > 1) return 'duplicate-application-subscription';
+ if (applicationEvents.length > 1) return 'duplicate-application-event';
+ if (rendererObservedApplicationEvents.length > 1) return 'duplicate-renderer-application-event';
+ if (mainAccepted.length === 1 && fixtureAccepted.length === 0) return 'fixture-not-reached';
+ if (fixtureAccepted.length === 1 && connected.length === 0) return 'namespace-not-connected';
+ if (connected.length === 1 && subscriptions.length === 0) return 'application-subscription-not-observed';
+ if (subscriptions.length === 1 && applicationEvents.length === 0) return 'application-event-not-produced';
+ if (applicationEvents.length === 1 && rendererObservedApplicationEvents.length === 0) {
+ return 'renderer-application-event-not-observed';
+ }
+ const lifecycleCategory = rendererLifecycleCategory(journey);
+ return lifecycleCategory === 'none'
+ ? 'renderer-connect-invoked-upgrade-not-produced'
+ : lifecycleCategory;
+};
+
+const rendererLifecycleCategory = journey => {
+ if (rendererLifecycleEvidenceInvalid) return 'renderer-lifecycle-evidence-invalid';
+ const records = rendererLifecycleRecords.filter(record => record.journey === journey);
+ const latest = records.at(-1);
+ if (!latest?.profileActivationPublished) return 'renderer-profile-activation-not-propagated';
+ if (!latest.socketProviderMounted) return 'renderer-socket-provider-not-mounted';
+ if (latest.providerDisabled) {
+ if (latest.disabledByDemoModeLoading) return 'renderer-socket-provider-disabled-demo-mode-loading';
+ if (latest.disabledByDemoMode) return 'renderer-socket-provider-disabled-demo-mode';
+ if (latest.disabledByCurrentUserLoading) return 'renderer-socket-provider-disabled-current-user-loading';
+ if (latest.disabledByCurrentUserAbsent) return 'renderer-socket-provider-disabled-current-user-absent';
+ return 'renderer-socket-provider-disabled-unattributed';
+ }
+ if (!latest.desktopRuntime) return 'renderer-desktop-runtime-unavailable';
+ if (latest.connectionScope !== 'available') return 'renderer-connection-scope-unavailable';
+ if (latest.socketConstructionInvocations === 0) return 'renderer-socket-construction-not-invoked';
+ if (latest.socketConstructions === 0) return 'renderer-socket-construction-not-produced';
+ if (latest.connectInvocations === 0) return 'renderer-socket-connect-not-invoked';
+ if (latest.socketConstructionInvocations !== 1 || latest.socketConstructions !== 1
+ || latest.connectInvocations !== 1) return 'renderer-socket-lifecycle-duplicate';
+ return 'none';
+};
+
+const waitForAuthenticatedSocket = async journey => {
+ const deadline = Date.now() + 15_000;
+ while (true) {
+ const mainAccepted = mainHandshakeRecords.filter(record => record.journey === journey && record.accepted);
+ const fixtureAccepted = fixtureHandshakeRecords.filter(record => record.journey === journey && record.accepted);
+ const connected = socketRecords.filter(record => record.journey === journey
+ && record.event === 'connection' && record.authenticated);
+ const applicationSubscriptions = socketRecords.filter(record => record.journey === journey
+ && record.event === QUEUE_STATS_SUBSCRIBE_EVENT && record.authenticated
+ && record.genuineApplicationSubscription === true && record.direction === 'client-to-fixture');
+ const applicationEvents = socketRecords.filter(record => record.journey === journey
+ && record.event === QUEUE_STATS_UPDATE && record.authenticated
+ && record.genuineApplicationEvent === true && record.direction === 'fixture-to-renderer');
+ const rendererObservedApplicationEvents = consoleRecords.filter(record => record.journey === journey
+ && record.text.startsWith('[SocketContext] Received queue stats update:'));
+ const rendererLifecycle = rendererLifecycleCategory(journey);
+ if (mainAccepted.length === 1 && fixtureAccepted.length === 1
+ && connected.length === 1 && applicationSubscriptions.length === 1
+ && applicationEvents.length === 1 && rendererObservedApplicationEvents.length === 1
+ && rendererLifecycle === 'none') return;
+ const category = socketHandshakeFailureCategory(journey);
+ if (category.startsWith('main-') || category.startsWith('fixture-') || category.startsWith('duplicate-')) {
+ throw new Error(`Acceptance Socket.IO handshake failed: ${category}; current-user-phases=${JSON.stringify(currentUserPhaseSummary(journey))}; network-permissions=${JSON.stringify(networkPermissionSummary(journey))}`);
+ }
+ if (Date.now() >= deadline) {
+ throw new Error(`Acceptance Socket.IO handshake failed: ${category}; current-user-phases=${JSON.stringify(currentUserPhaseSummary(journey))}; network-permissions=${JSON.stringify(networkPermissionSummary(journey))}`);
+ }
+ await sleep(50);
+ }
+};
+
+const observedServiceSummary = () => {
+ const restRequests = requestRecords.filter(record => record.method !== 'OPTIONS' && record.url.startsWith('/api/'));
+ const authenticatedRequests = restRequests.filter(record => record.hasAuthorization);
+ const socketConnections = socketRecords.filter(record => record.event === 'connection' && record.authenticated);
+ const socketSubscriptions = socketRecords.filter(record => record.event === QUEUE_STATS_SUBSCRIBE_EVENT
+ && record.authenticated && record.genuineApplicationSubscription === true
+ && record.direction === 'client-to-fixture');
+ const socketEvents = socketRecords.filter(record => record.event === QUEUE_STATS_UPDATE
+ && record.authenticated && record.genuineApplicationEvent === true
+ && record.direction === 'fixture-to-renderer');
+ const mainHandshakes = mainHandshakeRecords.filter(record => record.journey === 'dashboard-profile-manager' && record.accepted);
+ const fixtureHandshakes = fixtureHandshakeRecords.filter(record => record.journey === 'dashboard-profile-manager' && record.accepted);
+ const mainHandshake = mainHandshakes[0];
+ const fixtureHandshake = fixtureHandshakes[0];
+ const rendererObservedApplicationEvents = consoleRecords.filter(record => record.journey === 'dashboard-profile-manager'
+ && record.text.startsWith('[SocketContext] Received queue stats update:'));
+ const rendererLifecycle = rendererLifecycleRecords
+ .filter(record => record.journey === 'dashboard-profile-manager').at(-1);
+ const pairingStarted = requestRecords.filter(record => record.method === 'POST' && record.url === '/api/desktop/pairings');
+ const pairingPolled = requestRecords.filter(record => record.method === 'POST' && record.url === `/api/desktop/pairings/${PAIRING_ID}/poll`);
+ const pairingActivated = requestRecords.filter(record => record.method === 'POST' && record.url === `/api/desktop/pairings/${PAIRING_ID}/activate`);
+ const connectConfirmed = requestRecords.filter(record => record.journey === 'connect-confirmation' && record.url === '/api/desktop/discovery');
+ const currentUserCategory = currentUserValidationFailureCategory('dashboard-profile-manager');
+ const currentUserIssued = rendererCurrentUserRecords.find(record => record.journey === 'dashboard-profile-manager'
+ && record.activeScopePresent && record.phase === 'request-issued');
+ const currentUserMain = mainCurrentUserRecords.find(record => record.journey === 'dashboard-profile-manager');
+ const currentUserFixture = fixtureCurrentUserRecords.find(record => record.journey === 'dashboard-profile-manager'
+ && record.source === 'renderer');
+ const services = {
+ rest: {
+ requestCount: restRequests.length,
+ authenticatedRequestCount: authenticatedRequests.length,
+ journeys: unique(restRequests.map(record => record.journey)),
+ currentUserValidation: {
+ correlation: 'current-scope-user-validation',
+ rendererRequestIssued: Boolean(currentUserIssued),
+ rendererScopeGeneration: currentUserIssued?.scopeGeneration ?? 0,
+ mainProxyObserved: Boolean(currentUserMain),
+ mainActiveScopeGeneration: currentUserMain?.activeScopeGeneration ?? 0,
+ activeScopeAccepted: currentUserMain?.accepted === true,
+ mainOnlyBearerInjection: currentUserMain?.rendererBearerPresent === false
+ && currentUserMain?.rendererCookiePresent === false
+ && currentUserMain?.bearerMainInjected === true,
+ upstreamRequestArrived: currentUserFixture?.requestArrived === true,
+ responseStatus: currentUserFixture?.responseStatus ?? 0,
+ responseClassification: currentUserFixture?.classification ?? 'not-observed',
+ parsedUserSchemaAccepted: rendererCurrentUserRecords.some(record => record.journey === 'dashboard-profile-manager'
+ && record.activeScopePresent && record.phase === 'parsed-user-accepted' && record.schemaAccepted),
+ rendererActiveScopeAccepted: rendererCurrentUserRecords.some(record => record.journey === 'dashboard-profile-manager'
+ && record.activeScopePresent && record.phase === 'active-scope-accepted'),
+ firstFailedPredicate: currentUserCategory,
+ },
+ },
+ socketIo: {
+ authenticatedConnections: socketConnections.length,
+ events: socketEvents.length,
+ journeys: unique(socketRecords.map(record => record.journey)),
+ handshake: {
+ mainAttempts: mainHandshakes.length,
+ fixtureAttempts: fixtureHandshakes.length,
+ scopeQueryPresent: mainHandshake?.scopeQueryPresent === true && fixtureHandshake?.scopeQueryPresent === true,
+ scopeQueryCount: mainHandshake?.scopeQueryCount ?? 0,
+ scopeEqualsActivatedBinding: mainHandshake?.scopeEqualsActive === true,
+ activeBindingPresent: mainHandshake?.activeBindingPresent === true,
+ profileGenerationCurrent: mainHandshake?.profileGenerationCurrent === true,
+ originEqualsActivatedBinding: mainHandshake?.originEqualsActive === true,
+ path: mainHandshake?.path ?? 'other',
+ transport: mainHandshake?.transport ?? 'other',
+ resource: mainHandshake?.resource ?? 'other',
+ rendererBearerPresent: mainHandshake?.rendererBearerPresent ?? false,
+ rendererCookiePresent: mainHandshake?.rendererCookiePresent ?? false,
+ authorizationHeaderPresent: fixtureHandshake?.authorizationPresent ?? false,
+ authorizationHeaderExactlyMainInjected: mainHandshake?.bearerMainInjected === true
+ && fixtureHandshake?.authorizationMatchesActivatedBearer === true,
+ rendererObservedApplicationEvent: rendererObservedApplicationEvents.length === 1,
+ rendererLifecycle: rendererLifecycle ? {
+ phase: rendererLifecycle.phase,
+ profileActivationPublished: rendererLifecycle.profileActivationPublished,
+ socketProviderMounted: rendererLifecycle.socketProviderMounted,
+ providerDisabled: rendererLifecycle.providerDisabled,
+ disabledByDemoModeLoading: rendererLifecycle.disabledByDemoModeLoading,
+ disabledByDemoMode: rendererLifecycle.disabledByDemoMode,
+ disabledByCurrentUserLoading: rendererLifecycle.disabledByCurrentUserLoading,
+ disabledByCurrentUserAbsent: rendererLifecycle.disabledByCurrentUserAbsent,
+ desktopRuntime: rendererLifecycle.desktopRuntime,
+ connectionScope: rendererLifecycle.connectionScope,
+ socketConstructionInvocations: rendererLifecycle.socketConstructionInvocations,
+ socketConstructions: rendererLifecycle.socketConstructions,
+ connectInvocations: rendererLifecycle.connectInvocations,
+ } : null,
+ rejectionCategory: mainHandshake?.rejectionCategory ?? socketHandshakeFailureCategory('dashboard-profile-manager'),
+ },
+ },
+ pairing: { started: pairingStarted.length, polled: pairingPolled.length, activated: pairingActivated.length, journeys: unique([...pairingStarted, ...pairingPolled, ...pairingActivated].map(record => record.journey)) },
+ connect: { confirmedRequests: connectConfirmed.length, journeys: unique(connectConfirmed.map(record => record.journey)) },
+ };
+ if (services.rest.requestCount <= 0 || services.rest.authenticatedRequestCount <= 0
+ || currentUserCategory !== 'none'
+ || services.socketIo.authenticatedConnections !== 1 || socketSubscriptions.length !== 1
+ || services.socketIo.events !== 1 || rendererObservedApplicationEvents.length !== 1
+ || services.socketIo.handshake.mainAttempts !== 1 || services.socketIo.handshake.fixtureAttempts !== 1
+ || !services.socketIo.handshake.scopeQueryPresent || services.socketIo.handshake.scopeQueryCount !== 1
+ || !services.socketIo.handshake.scopeEqualsActivatedBinding
+ || !services.socketIo.handshake.activeBindingPresent
+ || !services.socketIo.handshake.profileGenerationCurrent
+ || !services.socketIo.handshake.originEqualsActivatedBinding
+ || services.socketIo.handshake.path !== 'socket-io'
+ || services.socketIo.handshake.transport !== 'websocket'
+ || services.socketIo.handshake.resource !== 'websocket'
+ || services.socketIo.handshake.rendererBearerPresent
+ || services.socketIo.handshake.rendererCookiePresent
+ || !services.socketIo.handshake.authorizationHeaderPresent
+ || !services.socketIo.handshake.authorizationHeaderExactlyMainInjected
+ || !services.socketIo.handshake.rendererObservedApplicationEvent
+ || services.socketIo.handshake.rendererLifecycle?.phase !== 'socket-connect-invoked'
+ || services.socketIo.handshake.rendererLifecycle?.profileActivationPublished !== true
+ || services.socketIo.handshake.rendererLifecycle?.socketProviderMounted !== true
+ || services.socketIo.handshake.rendererLifecycle?.providerDisabled !== false
+ || services.socketIo.handshake.rendererLifecycle?.disabledByDemoModeLoading !== false
+ || services.socketIo.handshake.rendererLifecycle?.disabledByDemoMode !== false
+ || services.socketIo.handshake.rendererLifecycle?.disabledByCurrentUserLoading !== false
+ || services.socketIo.handshake.rendererLifecycle?.disabledByCurrentUserAbsent !== false
+ || services.socketIo.handshake.rendererLifecycle?.desktopRuntime !== true
+ || services.socketIo.handshake.rendererLifecycle?.connectionScope !== 'available'
+ || services.socketIo.handshake.rendererLifecycle?.socketConstructionInvocations !== 1
+ || services.socketIo.handshake.rendererLifecycle?.socketConstructions !== 1
+ || services.socketIo.handshake.rendererLifecycle?.connectInvocations !== 1
+ || services.socketIo.handshake.rejectionCategory !== 'none'
+ || services.pairing.started <= 0 || services.pairing.polled <= 0 || services.pairing.activated <= 0
+ || services.connect.confirmedRequests <= 0) {
+ throw new Error(`Acceptance packaged-renderer service journeys were incomplete: ${JSON.stringify(services)}`);
+ }
+ return services;
+};
+
+try {
+ readyOrigin = await createFixture('ready', FIXED_ACCEPTANCE_ORIGINS.ready);
+ revokedOrigin = await createFixture('revoked', FIXED_ACCEPTANCE_ORIGINS.revoked);
+ incompatibleOrigin = await createFixture('incompatible', FIXED_ACCEPTANCE_ORIGINS.incompatible);
+ await runJourney('first-run-chooser', 'default', null, async page => {
+ await page.getByRole('heading', { name: 'Let’s set up this computer' }).waitFor();
+ const seen = [];
+ for (let index = 0; index < 3; index += 1) {
+ await page.keyboard.press('Tab');
+ seen.push(await page.evaluate(() => document.activeElement?.textContent?.trim() || document.activeElement?.getAttribute('aria-label')));
+ }
+ keyboardOrder = seen.every(Boolean) && new Set(seen).size === seen.length
+ && seen[0].includes('Set up this computer')
+ && seen[1].includes('Connect to an existing instance')
+ && seen[2].includes('Search for instances on this network');
+ visibleFocus = await page.evaluate(() => {
+ const element = document.activeElement;
+ if (!(element instanceof HTMLElement)) return false;
+ const style = getComputedStyle(element);
+ return style.outlineStyle !== 'none' || style.boxShadow !== 'none';
+ });
+ });
+ await runJourney('manual-endpoint-confirmation', 'default', null, async page => {
+ await page.getByRole('button', { name: /Connect to an existing instance/ }).click();
+ await page.getByLabel('Display name').fill('Design Review');
+ await page.getByLabel('Instance URL').fill(readyOrigin);
+ });
+ await runJourney('connect-confirmation', 'default', `propr://connect?api=${encodeURIComponent(readyOrigin)}`, async page => {
+ await page.getByText('Review this untrusted instance address').waitFor();
+ return async () => {
+ await page.getByRole('button', { name: 'Connect', exact: true }).click();
+ await page.getByRole('button', { name: 'Sign in in browser' }).waitFor();
+ await waitForObserved(() => requestRecords.some(record => record.journey === 'connect-confirmation' && record.url === '/api/desktop/discovery'), 'the confirmed Connect request');
+ };
+ });
+ await runJourney('remote-pairing', 'default', null, async page => {
+ await observeLiveMutation(page, 'status', 'remote-pairing', () => fillEndpoint(page, readyOrigin, 'Remote Team'));
+ await page.getByRole('button', { name: 'Sign in in browser' }).waitFor();
+ });
+ await runJourney('local-setup-prerequisites', 'default', null, async page => {
+ await page.getByRole('button', { name: /Set up this computer/ }).click();
+ await page.getByRole('heading', { name: 'Check the essentials' }).waitFor();
+ });
+ await runJourney('local-setup-progress', 'default', null, async page => {
+ await page.getByRole('button', { name: /Set up this computer/ }).click();
+ for (let index = 0; index < 5; index += 1) await page.getByRole('button', { name: /Continue/ }).click();
+ await page.getByRole('button', { name: /Install ProPR/ }).click();
+ await page.getByRole('heading', { name: 'Setting up ProPR' }).waitFor();
+ });
+ await runJourney('local-setup-error', 'setup-error', null, async page => {
+ await page.getByRole('button', { name: /Set up this computer/ }).click();
+ await page.getByRole('heading', { name: 'Setup needs attention' }).waitFor();
+ });
+ await runJourney('local-setup-completion', 'setup-complete', null, async page => {
+ await page.getByRole('button', { name: /Set up this computer/ }).click();
+ await page.getByRole('heading', { name: 'ProPR is ready' }).waitFor();
+ });
+ await runJourney('dashboard-profile-manager', 'default', null, async (page, application) => {
+ await pair(page, readyOrigin, 'Operations');
+ const opener = page.getByRole('button', { name: /Connected: Operations/ });
+ try {
+ await opener.waitFor({ timeout: 15_000 });
+ } catch (error) {
+ if (!(error instanceof Error) || error.name !== 'TimeoutError') throw error;
+ await settlePendingRendererConsoleCaptures();
+ const journey = 'dashboard-profile-manager';
+ const diagnostic = {
+ schemaVersion: 2,
+ currentUserPhases: currentUserPhaseSummary(journey),
+ networkPermissions: networkPermissionSummary(journey),
+ currentUserCategory: currentUserValidationFailureCategory(journey),
+ rendererLifecycleCategory: rendererLifecycleCategory(journey),
+ rendererLifecycle: rendererLifecycleDiagnosticSummary(journey),
+ surfacePhase: await rendererSurfacePhase(page),
+ uiState: await rendererUiStateSummary(page),
+ rendererErrors: rendererErrorCountSummary(journey),
+ };
+ throw new Error(`Acceptance dashboard Connected control timed out: ${JSON.stringify(diagnostic)}`);
+ }
+ await waitForObserved(() => {
+ const journeyRequests = requestRecords.filter(record => record.journey === 'dashboard-profile-manager');
+ return journeyRequests.some(record => record.method === 'POST' && record.url === '/api/desktop/pairings')
+ && journeyRequests.some(record => record.method === 'POST' && record.url === `/api/desktop/pairings/${PAIRING_ID}/poll`)
+ && journeyRequests.some(record => record.method === 'POST' && record.url === `/api/desktop/pairings/${PAIRING_ID}/activate`);
+ }, `the complete pairing flow for ${PAIRING_ID}`);
+ await waitForAuthenticatedSocket('dashboard-profile-manager');
+ await application.context.tracing.start({ screenshots: true, snapshots: true, sources: false });
+ await opener.focus();
+ await opener.click();
+ const dialog = page.getByRole('dialog', { name: 'Manage instances' });
+ await dialog.waitFor();
+ const focusables = dialog.locator('button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])');
+ const focusableCount = await focusables.count();
+ if (focusableCount < 2) throw new Error('Acceptance modal has insufficient focus targets');
+ await focusables.nth(focusableCount - 1).focus();
+ await page.keyboard.press('Tab');
+ const wrappedForward = await focusables.first().evaluate(element => element === document.activeElement);
+ await focusables.first().focus();
+ await page.keyboard.press('Shift+Tab');
+ const wrappedBackward = await focusables.nth(focusableCount - 1).evaluate(element => element === document.activeElement);
+ modalFocusTrap = wrappedForward && wrappedBackward;
+ await page.keyboard.press('Escape');
+ modalFocusRestore = await opener.evaluate(element => element === document.activeElement);
+ await opener.click();
+ await application.context.tracing.stop({ path: join(outputDirectory, 'sanitized-trace.zip') });
+ traceWritten = true;
+ });
+ await runJourney('offline', 'default', null, async page => {
+ await observeLiveMutation(page, 'error', 'offline', () => fillEndpoint(page, FIXED_ACCEPTANCE_ORIGINS.offline, 'Offline Lab'));
+ await page.getByText(/could not discover|could not check|unavailable/i).first().waitFor({ timeout: 15_000 });
+ });
+ await runJourney('revoked', 'default', null, async page => {
+ await pair(page, revokedOrigin, 'Revoked Lab');
+ await page.getByText(/revoked or expired/i).waitFor({ timeout: 15_000 });
+ });
+ await runJourney('incompatible-instance', 'default', null, async page => {
+ await fillEndpoint(page, incompatibleOrigin, 'Future Instance');
+ await page.getByText(/Update required/i).waitFor({ timeout: 15_000 });
+ });
+
+ if (!traceWritten) throw new Error('Playwright trace was not produced');
+ await Promise.all(pendingConsoleRecords);
+ if (boundaryJourneys.size !== ACCEPTANCE_JOURNEYS.length
+ || ACCEPTANCE_JOURNEYS.some(journey => !boundaryJourneys.has(journey))) throw new Error('Packaged main/preload/renderer boundary was not observed for every journey');
+ const serious = axeFindings.filter(finding => finding.impact === 'serious').length;
+ const critical = axeFindings.filter(finding => finding.impact === 'critical').length;
+ const accessibility = {
+ schemaVersion: 6,
+ generatedAt: FIXED_TIME,
+ serious,
+ critical,
+ findings: axeFindings,
+ checks: accessibilityChecks,
+ keyboardOrder,
+ visibleFocus,
+ modalFocusTrap,
+ modalFocusRestore,
+ accessibleNames: accessibilityChecks.every(check => check.accessibleNames),
+ liveAnnouncements: { status: liveAnnouncements.status, error: liveAnnouncements.error },
+ };
+ await writeFile(join(outputDirectory, 'accessibility.json'), `${JSON.stringify(accessibility, null, 2)}\n`, { mode: 0o600 });
+
+ const services = observedServiceSummary();
+ const sanitizedSummary = {
+ schemaVersion: 5,
+ generatedAt: FIXED_TIME,
+ status: 'passed',
+ journeys: ACCEPTANCE_JOURNEYS.length,
+ screenshots: screenshotMetadata.length,
+ boundary: { packagedExecutable: true, rendererOrigin: 'propr-app://renderer', preloadBridge: true, journeys: ACCEPTANCE_JOURNEYS },
+ console: { records: consoleRecords.length + pageErrorRecords.length, errors: consoleRecords.filter(record => record.type === 'error').length + pageErrorRecords.length },
+ services,
+ redaction: 'Full raw surfaces were scanned; published logs retain only source, level, byte count, and digest.',
+ };
+ await writeFile(join(outputDirectory, 'sanitized-summary.json'), `${JSON.stringify(sanitizedSummary, null, 2)}\n`, { mode: 0o600 });
+
+ const sanitizedLogRecords = [
+ ...consoleRecords.map(record => ({
+ journey: record.journey, source: 'renderer-console', level: record.type,
+ bytes: Buffer.byteLength(JSON.stringify(record)), sha256: digest(JSON.stringify(record)),
+ })),
+ ...pageErrorRecords.map(record => ({
+ journey: record.journey, source: 'renderer-page-error', level: 'error',
+ bytes: Buffer.byteLength(JSON.stringify(record)), sha256: digest(JSON.stringify(record)),
+ })),
+ ...processRecords,
+ ];
+ await writeFile(join(outputDirectory, 'sanitized-log.json'), `${JSON.stringify({
+ schemaVersion: 1, generatedAt: FIXED_TIME, records: sanitizedLogRecords,
+ }, null, 2)}\n`, { mode: 0o600 });
+
+ const scanRoot = await mkdtemp(join(tmpdir(), ACCEPTANCE_SURFACES_PREFIX));
+ try {
+ surfaces.push({ kind: 'renderer-console', records: consoleRecords });
+ surfaces.push({ kind: 'renderer-page-errors', records: pageErrorRecords });
+ surfaces.push({ kind: 'service-urls', records: requestRecords });
+ surfaces.push({ kind: 'socket-events', records: socketRecords });
+ surfaces.push({ kind: 'socket-main-handshakes', records: mainHandshakeRecords });
+ surfaces.push({ kind: 'socket-fixture-handshakes', records: fixtureHandshakeRecords });
+ surfaces.push({ kind: 'current-user-renderer-validation', records: rendererCurrentUserRecords });
+ surfaces.push({ kind: 'current-user-main-proxy', records: mainCurrentUserRecords });
+ surfaces.push({ kind: 'current-user-fixture-responses', records: fixtureCurrentUserRecords });
+ surfaces.push({ kind: 'screenshot-metadata', records: screenshotMetadata });
+ surfaces.push({ kind: 'accessibility-metadata', value: accessibility });
+ await writeFile(join(scanRoot, 'complete-renderer-process-network-and-metadata-surfaces.json'), JSON.stringify(surfaces), { mode: 0o600 });
+ await scanAcceptancePaths([scanRoot], SENTINELS);
+ } finally {
+ await safeRemoveAcceptanceLeaf(scanRoot, { kind: 'surfaces' });
+ }
+ await writeAcceptanceManifest(outputDirectory, screenshotMetadata);
+ await verifyAcceptanceArtifacts(outputDirectory, { sentinels: SENTINELS });
+ console.log(`Packaged desktop acceptance produced ${screenshotMetadata.length} deterministic screenshots with zero serious/critical findings.`);
+} finally {
+ activeJourney = 'fixture-cleanup';
+ for (const fixture of fixtures) {
+ await new Promise(resolveValue => fixture.io.close(resolveValue));
+ if (fixture.server.listening) await new Promise(resolveValue => fixture.server.close(resolveValue));
+ }
+}
diff --git a/apps/desktop/scripts/smoke-packaged.mjs b/apps/desktop/scripts/smoke-packaged.mjs
index 98962219a..46a739933 100644
--- a/apps/desktop/scripts/smoke-packaged.mjs
+++ b/apps/desktop/scripts/smoke-packaged.mjs
@@ -18,12 +18,14 @@ import {
createSmokeChildEnvironment,
removePrivateSmokeProfile,
} from './packaged-smoke-support.mjs';
+import { classifyCurrentUserRequestShape } from './packaged-acceptance-current-user.mjs';
const READY_EVENT = 'desktop.renderer.ready';
const PRELOAD_BRIDGE_PROOF = '"preloadBridgeExposed":true';
const PROFILE_API_PROOF = 'desktop.renderer.profile_api.ready';
const MVP_FLOWS_PROOF = 'desktop.renderer.mvp_flows.ready';
const TRANSPORT_PROOF = 'desktop.renderer.transport_smoke.ready';
+const CURRENT_USER_BOUNDARY_PROOF = 'desktop.renderer.transport_smoke.current_user';
const LAYOUT_READY_EVENT = 'desktop.renderer.layout.ready';
const REDUCED_NATIVE_WINDOW_READY_EVENT = 'desktop.native.reduced_window.ready';
const MAIN_PROCESS_ERROR_MARKERS = [
@@ -51,6 +53,20 @@ const parseEventLayout = (output, expectedEvent) => {
return undefined;
};
+const parseEventRecords = (output, expectedEvent) => {
+ const records = [];
+ for (const line of output.split(/\r?\n/)) {
+ if (!line.includes(expectedEvent)) continue;
+ try {
+ const record = JSON.parse(line.slice(line.indexOf('{')));
+ if (record.event === expectedEvent) records.push(record);
+ } catch {
+ // Chromium may emit unrelated non-JSON diagnostics containing an event name.
+ }
+ }
+ return records;
+};
+
await access(binaryPath);
const expectedFuses = new Map([
[FuseV1Options.RunAsNode, FuseState.DISABLE],
@@ -109,10 +125,32 @@ const discovery = JSON.stringify({
socketIoBearerAuthentication: true,
},
});
+const currentUser = JSON.stringify({
+ id: 'packaged-smoke-user',
+ login: 'packaged-smoke',
+ username: 'packaged-smoke',
+ displayName: 'Packaged Smoke',
+ email: null,
+ avatarUrl: null,
+ role: 'admin',
+ permissions: [
+ 'instance.manage_agents',
+ 'instance.manage_members',
+ 'instance.manage_runtime',
+ 'instance.manage_settings',
+ ],
+ authorizationSource: 'local',
+});
const requests = [];
const fixtures = [];
const listenTransportFixture = async name => {
+ const socketBoundary = { authenticationAttempts: 0, rejectedAuthenticationAttempts: 0, connections: 0 };
const server = createServer((request, response) => {
+ const currentUserShape = classifyCurrentUserRequestShape(
+ request.method,
+ request.url,
+ request.headers.origin,
+ );
const record = {
fixture: name,
method: request.method,
@@ -121,6 +159,8 @@ const listenTransportFixture = async name => {
cookie: request.headers.cookie ?? null,
origin: request.headers.origin ?? null,
socketIo: false,
+ currentUserSource: currentUserShape?.source ?? null,
+ currentUserScopeGeneration: currentUserShape?.scopeGeneration ?? null,
};
requests.push(record);
if (request.method === 'OPTIONS') {
@@ -148,10 +188,10 @@ const listenTransportFixture = async name => {
response.end();
return;
}
- if ((request.url === '/api/auth/user' || request.url === '/api/smoke/rest')
+ if ((currentUserShape !== null || request.url === '/api/smoke/rest')
&& /^Bearer propr_it_[A-Za-z0-9_-]{43}$/.test(record.authorization ?? '')) {
response.writeHead(200, { ...corsHeaders, 'Set-Cookie': 'remote=must-not-persist; HttpOnly; SameSite=None' });
- response.end(request.url === '/api/auth/user' ? '{"username":"packaged-smoke"}' : '{"ok":true}');
+ response.end(currentUserShape !== null ? currentUser : '{"ok":true}');
return;
}
response.writeHead(401, corsHeaders);
@@ -161,9 +201,9 @@ const listenTransportFixture = async name => {
path: '/socket.io/', transports: ['websocket'], cors: { origin: DESKTOP_RENDERER_ORIGIN, credentials: false },
});
io.of('/').use((socket, next) => {
+ socketBoundary.authenticationAttempts += 1;
const queryScopes = new URL(socket.handshake.url, 'http://fixture.invalid').searchParams
.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY);
- const activationScope = socket.handshake.auth?.[DESKTOP_TRANSPORT_SCOPE_QUERY];
const record = {
fixture: name,
method: 'SOCKET.IO',
@@ -177,7 +217,9 @@ const listenTransportFixture = async name => {
};
requests.push(record);
if (!/^Bearer propr_it_[A-Za-z0-9_-]{43}$/.test(record.authorization ?? '')
- || queryScopes.length !== 1 || typeof activationScope !== 'string' || activationScope !== queryScopes[0]) {
+ || queryScopes.length !== 1 || !/^[A-Za-z0-9_-]{22}$/.test(queryScopes[0])
+ || Object.keys(socket.handshake.auth).length !== 0) {
+ socketBoundary.rejectedAuthenticationAttempts += 1;
const error = new Error(INVALID_INSTANCE_TOKEN);
error.data = { code: INVALID_INSTANCE_TOKEN };
next(error);
@@ -185,12 +227,15 @@ const listenTransportFixture = async name => {
}
next();
});
- io.of('/').on('connection', socket => socket.emit('packaged-smoke:connected', { ok: true }));
+ io.of('/').on('connection', socket => {
+ socketBoundary.connections += 1;
+ socket.emit('packaged-smoke:connected', { ok: true });
+ });
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
if (!address || typeof address === 'string') throw new Error(`Packaged ${name} fixture did not bind`);
- const fixture = { server, io, origin: `http://127.0.0.1:${address.port}` };
+ const fixture = { server, io, origin: `http://127.0.0.1:${address.port}`, socketBoundary };
fixtures.push(fixture);
return fixture;
};
@@ -269,6 +314,7 @@ const shutdownSteps = [
const launch = async mode => {
const smokeProfile = await createPrivateSmokeProfile();
const requestStart = requests.length;
+ const socketBoundaryStart = new Map(fixtures.map(fixture => [fixture, { ...fixture.socketBoundary }]));
let output = '';
try {
const launchArguments = [
@@ -323,6 +369,41 @@ const launch = async mode => {
if (!output.includes(`"storageBackend":"${expectedBackend}"`)) {
throw new Error(`Packaged desktop did not use ${expectedBackend} production credential protection`);
}
+ const staleBoundaryRecords = parseEventRecords(output, 'desktop.transport_smoke.stale_socket_boundary');
+ if (staleBoundaryRecords.length !== 1) {
+ throw new Error(`Packaged ${mode} smoke missed exact main stale-socket boundary evidence`);
+ }
+ const staleBoundary = staleBoundaryRecords[0];
+ if (staleBoundary.schemaVersion !== 1 || staleBoundary.mainAttempts !== 2
+ || staleBoundary.staleRejectedByMain !== true || staleBoundary.staleRejectionCategory !== 'stale-scope'
+ || staleBoundary.freshAcceptedByMain !== true || staleBoundary.exactPath !== true
+ || staleBoundary.exactTransport !== true || staleBoundary.exactResource !== true
+ || staleBoundary.queryCount !== 1 || staleBoundary.activeBindingPresent !== true
+ || staleBoundary.profileGenerationCurrent !== true || staleBoundary.originEqualsActive !== true
+ || staleBoundary.rendererBearerPresent !== false || staleBoundary.rendererCookiePresent !== false
+ || staleBoundary.staleOutboundBearerPresent !== false || staleBoundary.staleBearerMainInjected !== false
+ || staleBoundary.freshBearerMainInjected !== true) {
+ throw new Error(`Packaged ${mode} smoke main stale-socket boundary evidence was invalid`);
+ }
+ const currentUserBoundaryRecords = parseEventRecords(output, CURRENT_USER_BOUNDARY_PROOF);
+ if (currentUserBoundaryRecords.length !== 1) {
+ throw new Error(`Packaged ${mode} smoke missed exact current-user main-boundary evidence`);
+ }
+ const currentUserBoundary = currentUserBoundaryRecords[0];
+ if (currentUserBoundary.schemaVersion !== 2 || currentUserBoundary.rendererValidations !== 3
+ || JSON.stringify(currentUserBoundary.rendererScopeGenerations) !== '[1,2,3]'
+ || currentUserBoundary.exactGet !== true || currentUserBoundary.scopeHeaderCount !== 1
+ || currentUserBoundary.activeBindingPresent !== true
+ || currentUserBoundary.profileGenerationCurrent !== true
+ || currentUserBoundary.scopeEqualsActive !== true
+ || currentUserBoundary.originEqualsActive !== true
+ || currentUserBoundary.rendererBearerPresent !== false
+ || currentUserBoundary.rendererCookiePresent !== false
+ || currentUserBoundary.outboundBearerPresent !== true
+ || currentUserBoundary.bearerMainInjected !== true
+ || currentUserBoundary.accepted !== true) {
+ throw new Error(`Packaged ${mode} smoke current-user main-boundary evidence was invalid`);
+ }
let previousStep = -1;
for (const step of shutdownSteps) {
const marker = `"step":"${step}"`;
@@ -346,21 +427,84 @@ const launch = async mode => {
const authenticated = runRequests.filter(request => request.authorization?.startsWith('Bearer propr_it_'));
const secrets = [...new Set(authenticated.map(request => request.authorization.slice('Bearer '.length)))];
if (secrets.length !== 2) throw new Error(`Expected two ${mode} activation credentials, observed ${secrets.length}`);
+ const rendererScopeGenerations = [];
for (const name of ['first', 'second']) {
+ const fixture = name === 'first' ? first : second;
+ const boundaryStart = socketBoundaryStart.get(fixture);
+ const fixtureBoundaryAttempts = boundaryStart
+ ? fixture.socketBoundary.authenticationAttempts - boundaryStart.authenticationAttempts
+ : -1;
+ const fixtureBoundaryRejections = boundaryStart
+ ? fixture.socketBoundary.rejectedAuthenticationAttempts - boundaryStart.rejectedAuthenticationAttempts
+ : -1;
+ const fixtureBoundaryConnections = boundaryStart
+ ? fixture.socketBoundary.connections - boundaryStart.connections
+ : -1;
+ if (!boundaryStart
+ || fixtureBoundaryAttempts < (name === 'second' ? 2 : 1)
+ || fixtureBoundaryRejections !== 0
+ || fixtureBoundaryConnections !== fixtureBoundaryAttempts) {
+ throw new Error(`Packaged ${mode} ${name} fixture observed an unexpected Socket.IO boundary count`);
+ }
const fixtureRequests = authenticated.filter(request => request.fixture === name);
+ const currentUserRequests = runRequests.filter(request => {
+ if (request.fixture !== name || request.method === 'OPTIONS') return false;
+ try {
+ return new URL(request.url, 'http://fixture.invalid').pathname === '/api/auth/user';
+ } catch {
+ return false;
+ }
+ });
+ const expectedActivations = name === 'first' ? 2 : 1;
+ const mainCurrentUserRequests = currentUserRequests.filter(request => request.currentUserSource === 'main');
+ const rendererCurrentUserRequests = currentUserRequests.filter(request => request.currentUserSource === 'renderer');
+ rendererScopeGenerations.push(...rendererCurrentUserRequests.map(request => request.currentUserScopeGeneration));
+ const currentUserSequence = currentUserRequests.map(request => request.currentUserSource);
+ const expectedCurrentUserSequence = Array.from(
+ { length: expectedActivations },
+ () => ['main', 'renderer'],
+ ).flat();
const namespaceConnections = fixtureRequests.filter(request => request.socketIo);
- if (!fixtureRequests.some(request => request.url === '/api/auth/user')
+ const allNamespaceAttempts = runRequests.filter(request => request.fixture === name && request.socketIo);
+ if (mainCurrentUserRequests.length !== expectedActivations
+ || rendererCurrentUserRequests.length !== expectedActivations
+ || currentUserRequests.some(request => request.currentUserSource === null)
+ || currentUserSequence.some((source, index) => source !== expectedCurrentUserSequence[index])
+ || currentUserRequests.some(request => request.authorization === null || request.cookie !== null)
+ || rendererCurrentUserRequests.some(request => request.origin !== DESKTOP_RENDERER_ORIGIN)
+ || mainCurrentUserRequests.some(request => request.origin === DESKTOP_RENDERER_ORIGIN)
+ || new Set(rendererCurrentUserRequests.map(request => request.currentUserScopeGeneration)).size !== expectedActivations
+ || rendererCurrentUserRequests.some(request => !Number.isSafeInteger(request.currentUserScopeGeneration)
+ || request.currentUserScopeGeneration < 1)
+ || expectedCurrentUserSequence.length !== currentUserSequence.length
|| !fixtureRequests.some(request => request.url === '/api/smoke/rest')
|| namespaceConnections.length < (name === 'second' ? 2 : 1)
+ || allNamespaceAttempts.length !== namespaceConnections.length
+ || allNamespaceAttempts.length !== fixtureBoundaryAttempts
|| namespaceConnections.some(request => request.namespace !== '/' || request.engineProtocol !== 4)) {
throw new Error(`Packaged ${mode} ${name} fixture missed REST, Engine.IO, namespace auth, or reconnect proof`);
}
if (new Set(fixtureRequests.map(request => request.authorization)).size !== 1) {
throw new Error(`Packaged ${mode} ${name} fixture observed cross-generation bearer use`);
}
+ if (currentUserRequests.some(request => request.authorization !== fixtureRequests[0]?.authorization)) {
+ throw new Error(`Packaged ${mode} ${name} current-user proof lost main-only bearer custody`);
+ }
+ }
+ if (rendererScopeGenerations.length !== 3
+ || rendererScopeGenerations.some((generation, index) => generation !== index + 1)) {
+ throw new Error(`Packaged ${mode} current-user validation generations were not exact and activation-scoped`);
}
if (runRequests.some(request => request.cookie !== null)
- || runRequests.some(request => secrets.some(secret => request.url?.includes(secret)))) {
+ || runRequests.some(request => secrets.some(secret => {
+ if (request.url?.includes(secret)) return true;
+ try {
+ return [...new URL(request.url, 'http://fixture.invalid').searchParams]
+ .some(([key, value]) => key.includes(secret) || value.includes(secret));
+ } catch {
+ return false;
+ }
+ }))) {
throw new Error('Packaged renderer transport sent cookies or placed a credential in a URL');
}
if (secrets.some(secret => output.includes(secret) || launchArguments.some(argument => argument.includes(secret)))) {
diff --git a/apps/desktop/src/acceptance-setup-controller.test.ts b/apps/desktop/src/acceptance-setup-controller.test.ts
new file mode 100644
index 000000000..bca5a8308
--- /dev/null
+++ b/apps/desktop/src/acceptance-setup-controller.test.ts
@@ -0,0 +1,44 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { AcceptanceSetupController } from './acceptance-setup-controller';
+
+const requestFor = (sessionId: string) => ({
+ sessionId,
+ root: { mode: 'default' as const },
+ reinitialize: false,
+ agents: ['codex'],
+ github: { mode: 'demo' as const },
+ intake: { mode: 'keep' as const },
+ whitelist: null,
+ repository: null,
+});
+
+describe('packaged acceptance setup fixture', () => {
+ it('publishes deterministic progress through the production setup shape', async () => {
+ const published: string[] = [];
+ const controller = new AcceptanceSetupController({
+ rootDir: '/tmp/private/local-stack',
+ emit: snapshot => published.push(snapshot.phase),
+ });
+ const initial = await controller.status();
+ const running = await controller.start(requestFor(initial.sessionId));
+ assert.equal(running.phase, 'running');
+ assert.equal(running.state?.steps.find(step => step.id === 'pull-images')?.status, 'active');
+ assert.deepEqual(published, ['running']);
+ await controller.shutdown();
+ });
+
+ it('provides fixed recovery and completion cold-start evidence without secrets', async () => {
+ const recovery = await new AcceptanceSetupController({
+ rootDir: '/tmp/private/local-stack', scenario: 'setup-error', emit: () => undefined,
+ }).status();
+ const completion = await new AcceptanceSetupController({
+ rootDir: '/tmp/private/local-stack', scenario: 'setup-complete', emit: () => undefined,
+ }).status();
+ assert.equal(recovery.phase, 'failed');
+ assert.match(recovery.errors?.[0]?.nextAction ?? '', /Start Docker Engine/);
+ assert.equal(completion.phase, 'completed');
+ assert.equal(completion.profile?.name, 'This computer');
+ assert.doesNotMatch(JSON.stringify({ recovery, completion }), /token|password|secret/i);
+ });
+});
diff --git a/apps/desktop/src/acceptance-setup-controller.ts b/apps/desktop/src/acceptance-setup-controller.ts
new file mode 100644
index 000000000..a47e179c1
--- /dev/null
+++ b/apps/desktop/src/acceptance-setup-controller.ts
@@ -0,0 +1,123 @@
+import { randomUUID } from 'node:crypto';
+import { createSetupState, updateStep } from '@propr/local-setup';
+import type { DesktopSetupRequest, DesktopSetupSnapshot } from './shared/contract';
+
+type AcceptanceSetupScenario = 'default' | 'setup-error' | 'setup-complete';
+
+const capability = { supported: true, kind: 'local', platform: 'linux' } as const;
+
+export class AcceptanceSetupController {
+ readonly #sessionId = randomUUID();
+ readonly #rootDir: string;
+ readonly #emit: (snapshot: DesktopSetupSnapshot) => void;
+ readonly #scenario: AcceptanceSetupScenario;
+ #timers = new Set>();
+ #snapshot: DesktopSetupSnapshot;
+
+ constructor(options: {
+ rootDir: string;
+ scenario?: string;
+ emit(snapshot: DesktopSetupSnapshot): void;
+ }) {
+ this.#rootDir = options.rootDir;
+ this.#emit = options.emit;
+ this.#scenario = options.scenario === 'setup-error' || options.scenario === 'setup-complete'
+ ? options.scenario
+ : 'default';
+ this.#snapshot = this.#initialSnapshot();
+ }
+
+ #initialSnapshot(): DesktopSetupSnapshot {
+ const common = { capability, sessionId: this.#sessionId, rootDir: this.#rootDir, logs: [] };
+ if (this.#scenario === 'setup-error') {
+ return {
+ ...common,
+ phase: 'failed',
+ state: updateStep(createSetupState(this.#rootDir), 'check', {
+ status: 'failed',
+ detail: 'Docker Engine is not reachable.',
+ nextAction: 'Start Docker Engine, then retry setup.',
+ }),
+ error: 'The local prerequisites need attention.',
+ errors: [{
+ code: 'step-failed',
+ message: 'Docker Engine is not reachable.',
+ stepId: 'check',
+ retryable: true,
+ nextAction: 'Start Docker Engine, then retry setup.',
+ }],
+ resumeAvailable: true,
+ };
+ }
+ if (this.#scenario === 'setup-complete') {
+ return {
+ ...common,
+ phase: 'completed',
+ profile: {
+ id: 'acceptance-local',
+ name: 'This computer',
+ baseUrl: 'http://127.0.0.1:41771',
+ kind: 'local',
+ lastConnectedAt: '2026-01-02T03:04:05.000Z',
+ },
+ };
+ }
+ return { ...common, phase: 'idle', resumeAvailable: false };
+ }
+
+ async status(): Promise { return structuredClone(this.#snapshot); }
+
+ async start(request: DesktopSetupRequest): Promise {
+ if (request.sessionId !== this.#sessionId) throw new Error('Acceptance setup session expired');
+ let state = createSetupState(this.#rootDir);
+ state = updateStep(state, 'check', { status: 'done', detail: 'Docker Engine 27.5 is ready.' });
+ state = updateStep(state, 'init-stack', { status: 'done', detail: 'Private local directory is ready.' });
+ state = updateStep(state, 'pull-images', { status: 'active', detail: 'Preparing deterministic service images…' });
+ this.#snapshot = {
+ phase: 'running', capability, sessionId: this.#sessionId, rootDir: this.#rootDir,
+ state, logs: ['Environment checks passed.', 'Preparing local services…'], resumeAvailable: false,
+ };
+ this.#publish();
+ // Keep the progress view stable for the complete screenshot/a11y variant
+ // set. Completion has its own deterministic cold-start scenario.
+ this.#later(60_000, () => {
+ let completed = createSetupState(this.#rootDir);
+ completed = { ...completed, steps: completed.steps.map(step => ({ ...step, status: step.optional ? 'skipped' : 'done', detail: step.optional ? 'Not requested' : 'Ready' })) };
+ this.#snapshot = {
+ phase: 'completed', capability, sessionId: this.#sessionId, rootDir: this.#rootDir,
+ state: completed, logs: ['Environment checks passed.', 'Local services are healthy.'],
+ profile: {
+ id: 'acceptance-local', name: 'This computer', baseUrl: 'http://127.0.0.1:41771', kind: 'local',
+ lastConnectedAt: '2026-01-02T03:04:05.000Z',
+ },
+ };
+ this.#publish();
+ });
+ return structuredClone(this.#snapshot);
+ }
+
+ retry(request?: DesktopSetupRequest): Promise {
+ if (request) return this.start(request);
+ this.#snapshot = { ...this.#snapshot, phase: 'idle', error: undefined, errors: undefined };
+ this.#publish();
+ return this.status();
+ }
+
+ async cancel(): Promise {
+ this.#clearTimers();
+ this.#snapshot = { ...this.#snapshot, phase: 'cancelled', error: 'Setup was cancelled.' };
+ this.#publish();
+ return this.status();
+ }
+
+ async selectPrivateKey() { return { capability: 'A'.repeat(43), label: 'acceptance-private-key.pem' }; }
+ async acquireWebhookSecret() { return { capability: 'B'.repeat(43), label: 'Secret entered' as const }; }
+ async shutdown(): Promise { this.#clearTimers(); }
+
+ #publish(): void { this.#emit(structuredClone(this.#snapshot)); }
+ #later(delay: number, callback: () => void): void {
+ const timer = setTimeout(() => { this.#timers.delete(timer); callback(); }, delay);
+ this.#timers.add(timer);
+ }
+ #clearTimers(): void { for (const timer of this.#timers) clearTimeout(timer); this.#timers.clear(); }
+}
diff --git a/apps/desktop/src/acceptance-test-authorization.test.ts b/apps/desktop/src/acceptance-test-authorization.test.ts
new file mode 100644
index 000000000..f09e00357
--- /dev/null
+++ b/apps/desktop/src/acceptance-test-authorization.test.ts
@@ -0,0 +1,232 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, it } from 'node:test';
+import { parseDesktopPairingStart, ProprClientError } from '@propr/client';
+import {
+ PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS,
+ PACKAGED_ACCEPTANCE_TIME,
+} from '../scripts/packaged-acceptance-clock.mjs';
+import {
+ authorizePackagedAcceptanceTest,
+ packagedAcceptancePairingTiming,
+} from './acceptance-test-authorization';
+import { redactDesktopValue } from './secret-redaction';
+
+const acceptanceUserData = resolve('/tmp/propr-desktop-acceptance-a1');
+const defaultUserData = resolve('/tmp/default');
+const input = {
+ argv: ['app', '--propr-acceptance-test', `--user-data-dir=${acceptanceUserData}`],
+ defaultUserDataDirectory: defaultUserData,
+ environmentTriggered: true,
+ isPackaged: true,
+ platform: 'linux' as const,
+};
+
+describe('packaged acceptance authorization', () => {
+ it('accepts only the dual-trigger packaged Linux launch with an isolated profile', () => {
+ assert.equal(authorizePackagedAcceptanceTest(input), acceptanceUserData);
+ assert.equal(authorizePackagedAcceptanceTest({ ...input, argv: ['app'], environmentTriggered: false }), null);
+ });
+
+ it('fails closed for partial triggers, other platforms, and the default profile', () => {
+ assert.throws(() => authorizePackagedAcceptanceTest({ ...input, environmentTriggered: false }), /both/);
+ assert.throws(() => authorizePackagedAcceptanceTest({ ...input, platform: 'darwin' }), /Linux/);
+ assert.throws(() => authorizePackagedAcceptanceTest({
+ ...input,
+ argv: ['app', '--propr-acceptance-test', `--user-data-dir=${defaultUserData}`],
+ }), /must use/);
+ assert.throws(() => authorizePackagedAcceptanceTest({
+ ...input,
+ argv: ['app', '--propr-acceptance-test', `--user-data-dir=${join(acceptanceUserData, '..', 'default')}`],
+ }), /must use/);
+ assert.throws(() => authorizePackagedAcceptanceTest({
+ ...input,
+ argv: ['app', '--propr-acceptance-test', `--user-data-dir=${join(dirname(acceptanceUserData), 'propr-desktop-acceptance-a1', '..', 'escaped')}`],
+ }), /must use/);
+ });
+
+ it('supplies sleep and the shared clock only through the dual-authorized acceptance result', async () => {
+ const authorizedDirectory = authorizePackagedAcceptanceTest(input);
+ const timing = packagedAcceptancePairingTiming(authorizedDirectory);
+
+ assert.ok(timing);
+ assert.deepEqual(Object.keys(timing).sort(), ['now', 'sleep']);
+ assert.equal(timing.now(), PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS);
+ assert.equal(new Date(timing.now()).toISOString(), PACKAGED_ACCEPTANCE_TIME);
+ assert.equal(PACKAGED_ACCEPTANCE_TIME, '2026-01-02T03:04:05.000Z');
+ await timing.sleep(60_000);
+
+ for (const mode of ['production', 'packaged smoke', 'ordinary runtime']) {
+ assert.equal(packagedAcceptancePairingTiming(null), undefined, mode);
+ }
+ const untriggered = authorizePackagedAcceptanceTest({
+ ...input,
+ argv: ['app'],
+ environmentTriggered: false,
+ });
+ assert.equal(packagedAcceptancePairingTiming(untriggered), undefined);
+
+ const main = readFileSync(fileURLToPath(new URL('./main.ts', import.meta.url)), 'utf8');
+ assert.match(
+ main,
+ /packagedAcceptancePairingTiming\(packagedAcceptanceUserDataDirectory\)/,
+ );
+ assert.match(main, /acceptancePairingTiming \? \{ pairingTiming: acceptancePairingTiming \} : \{\}/);
+ assert.match(main, /packagedAcceptanceTest \? \{\s*reportWebSocketHandshake:/);
+ assert.match(main, /desktop\.acceptance\.websocket_handshake/);
+ assert.match(main, /desktop\.acceptance\.current_user_proxy/);
+ assert.match(main, /packagedAcceptanceTest \? \{\s*reportNetworkPermissionDecision:/);
+ assert.match(main, /desktop\.acceptance\.network_permission/);
+ const acceptanceRunner = readFileSync(fileURLToPath(new URL('../scripts/run-packaged-acceptance.mjs', import.meta.url)), 'utf8');
+ const currentUserClassifier = readFileSync(fileURLToPath(new URL(
+ '../scripts/packaged-acceptance-current-user.mjs', import.meta.url,
+ )), 'utf8');
+ assert.match(acceptanceRunner, /'Access-Control-Allow-Private-Network': 'true'/);
+ assert.match(acceptanceRunner, /network-permissions=/);
+ assert.match(currentUserClassifier, /networkPermissionDecisionSummary/);
+ assert.match(currentUserClassifier, /current-user-upstream-request-not-arrived/);
+ assert.match(currentUserClassifier, /current-user-parsed-schema-rejected/);
+ });
+
+ it('bounds the dashboard Connected timeout diagnostic after flushing renderer evidence', () => {
+ const acceptanceRunner = readFileSync(fileURLToPath(new URL('../scripts/run-packaged-acceptance.mjs', import.meta.url)), 'utf8');
+ const dashboardStart = acceptanceRunner.indexOf("await runJourney('dashboard-profile-manager'");
+ const dashboardEnd = acceptanceRunner.indexOf("await runJourney('offline'", dashboardStart);
+ assert.notEqual(dashboardStart, -1);
+ assert.notEqual(dashboardEnd, -1);
+ const dashboardJourney = acceptanceRunner.slice(dashboardStart, dashboardEnd);
+
+ assert.match(dashboardJourney, /await opener\.waitFor\(\{ timeout: 15_000 \}\)/);
+ assert.match(dashboardJourney, /error instanceof Error\) \|\| error\.name !== 'TimeoutError'/);
+ const flush = dashboardJourney.indexOf('await settlePendingRendererConsoleCaptures()');
+ const diagnostic = dashboardJourney.indexOf('const diagnostic = {');
+ const rethrow = dashboardJourney.indexOf('Acceptance dashboard Connected control timed out:');
+ assert.ok(flush >= 0 && flush < diagnostic && diagnostic < rethrow);
+ const diagnosticCatch = dashboardJourney.slice(
+ dashboardJourney.indexOf('} catch (error)'),
+ dashboardJourney.indexOf(' await waitForObserved'),
+ );
+
+ for (const boundedField of [
+ 'currentUserPhases: currentUserPhaseSummary(journey)',
+ 'networkPermissions: networkPermissionSummary(journey)',
+ 'currentUserCategory: currentUserValidationFailureCategory(journey)',
+ 'rendererLifecycleCategory: rendererLifecycleCategory(journey)',
+ 'rendererLifecycle: rendererLifecycleDiagnosticSummary(journey)',
+ 'surfacePhase: await rendererSurfacePhase(page)',
+ 'uiState: await rendererUiStateSummary(page)',
+ 'rendererErrors: rendererErrorCountSummary(journey)',
+ ]) assert.ok(dashboardJourney.includes(boundedField), boundedField);
+ assert.doesNotMatch(diagnosticCatch, /error\.(?:message|stack)|record\.(?:text|arguments|location|url)/);
+
+ assert.match(
+ acceptanceRunner,
+ /const settlePendingRendererConsoleCaptures = async \(\) => \{[\s\S]*?await Promise\.allSettled\(unsettled\);[\s\S]*?\n\};/,
+ );
+ assert.match(acceptanceRunner, /const boundedAcceptanceDiagnosticCount = records => Math\.min\(records\.length, 9\)/);
+ assert.match(acceptanceRunner, /if \(document\.querySelector\('\.desktop-app'\)\) return 'app'/);
+ assert.match(acceptanceRunner, /if \(document\.querySelector\('\.desktop-entry'\)\) return 'entry'/);
+ assert.match(acceptanceRunner, /return 'loading'/);
+
+ const uiSummaryStart = acceptanceRunner.indexOf('const rendererUiStateSummary = async page =>');
+ const uiSummaryEnd = acceptanceRunner.indexOf('\nconst rendererSurfacePhase', uiSummaryStart);
+ assert.notEqual(uiSummaryStart, -1);
+ assert.notEqual(uiSummaryEnd, -1);
+ const uiSummary = acceptanceRunner.slice(uiSummaryStart, uiSummaryEnd);
+ for (const status of ["'ready'", "'offline'", "'incompatible'", "'unknown'", "'absent'"]) {
+ assert.ok(uiSummary.includes(status), status);
+ }
+ for (const labelCategory of [
+ "'Connected: Operations'",
+ "'Offline: Operations'",
+ "'Update required: Operations'",
+ "'other'",
+ ]) assert.ok(uiSummary.includes(labelCategory), labelCategory);
+ for (const booleanField of [
+ 'connectionPillPresent',
+ 'navigatorOnline',
+ 'desktopTitleBarPresent',
+ 'routeLayoutPresent',
+ 'loadingSpinnerPresent',
+ 'validatedCurrentUserMarkerPresent',
+ 'dashboardMarkerPresent',
+ ]) assert.ok(uiSummary.includes(booleanField), booleanField);
+ assert.match(uiSummary, /document\.querySelector\('\.desktop-connection-pill'\)/);
+ assert.match(uiSummary, /document\.querySelector\('\.desktop-titlebar'\)/);
+ assert.match(uiSummary, /main\.mobile-content-clearance/);
+ assert.match(uiSummary, /a\[href="\/admin\/members"\]/);
+ assert.doesNotMatch(uiSummary, /outerHTML|innerHTML|document\.body\.textContent/);
+
+ assert.match(
+ acceptanceRunner,
+ /const rendererLifecycleDiagnosticSummary = journey => \(\{[\s\S]*?recordCount:[\s\S]*?evidenceInvalid:[\s\S]*?invalidCategory:/,
+ );
+ assert.match(
+ acceptanceRunner,
+ /recordCount: rendererLifecycleRecords\.filter\(record => record\.journey === journey\)\.length/,
+ );
+ assert.match(acceptanceRunner, /rendererLifecycleInvalidCategories\.add\('schema-shape'\)/);
+ assert.match(acceptanceRunner, /rendererLifecycleInvalidCategories\.add\('overflow'\)/);
+ assert.match(acceptanceRunner, /if \(schemaShape && overflow\) return 'multiple'/);
+
+ const errorSummaryStart = acceptanceRunner.indexOf('const rendererConsoleErrorCategory = record =>');
+ const errorSummaryEnd = acceptanceRunner.indexOf('\nconst rendererLifecycleInvalidCategory', errorSummaryStart);
+ assert.notEqual(errorSummaryStart, -1);
+ assert.notEqual(errorSummaryEnd, -1);
+ const errorSummary = acceptanceRunner.slice(errorSummaryStart, errorSummaryEnd);
+ for (const category of ['currentUserSync', 'apiLoad', 'socketContext', 'reactRuntime', 'other']) {
+ assert.ok(errorSummary.includes(category), category);
+ }
+ assert.match(errorSummary, /consoleErrorCategoryCounts: rendererConsoleErrorCategoryCounts\(rendererConsoleErrors\)/);
+ assert.match(errorSummary, /counts\[category\] = Math\.min\(counts\[category\] \+ 1, 9\)/);
+ assert.doesNotMatch(errorSummary, /arguments|location|stack|url/);
+ });
+
+ it('keeps the shared clock subject to exact pairing expiry validation', () => {
+ const timing = packagedAcceptancePairingTiming(authorizePackagedAcceptanceTest(input));
+ assert.ok(timing);
+ const response = {
+ pairingId: `dpr_${'P'.repeat(22)}`,
+ deviceSecret: 'D'.repeat(43),
+ approvalUrl: 'https://propr.example.test/approve',
+ interval: 1,
+ };
+
+ assert.equal(parseDesktopPairingStart({
+ ...response,
+ expiresAt: new Date(PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS + 60_000).toISOString(),
+ }, 'https://propr.example.test', timing.now).expiresAt,
+ '2026-01-02T03:05:05.000Z');
+ assert.throws(() => parseDesktopPairingStart({
+ ...response,
+ expiresAt: PACKAGED_ACCEPTANCE_TIME,
+ }, 'https://propr.example.test', timing.now), (error: unknown) =>
+ error instanceof ProprClientError && error.kind === 'invalid_response');
+ });
+
+ it('preserves only fixed secret-free handshake booleans through protected logging', () => {
+ const evidence = {
+ schemaVersion: 1,
+ path: 'socket-io',
+ transport: 'websocket',
+ resource: 'websocket',
+ scopeQueryPresent: true,
+ scopeQueryCount: 1,
+ scopeEqualsActive: true,
+ activeBindingPresent: true,
+ profileGenerationCurrent: true,
+ originEqualsActive: true,
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ outboundBearerPresent: true,
+ bearerMainInjected: true,
+ accepted: true,
+ rejectionCategory: 'none',
+ };
+ assert.deepEqual(redactDesktopValue(evidence), evidence);
+ assert.equal(JSON.stringify(evidence).includes('http'), false);
+ assert.equal(JSON.stringify(evidence).includes('Bearer '), false);
+ });
+});
diff --git a/apps/desktop/src/acceptance-test-authorization.ts b/apps/desktop/src/acceptance-test-authorization.ts
new file mode 100644
index 000000000..a80936101
--- /dev/null
+++ b/apps/desktop/src/acceptance-test-authorization.ts
@@ -0,0 +1,66 @@
+import { basename, isAbsolute, resolve } from 'node:path';
+import { PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS } from '../scripts/packaged-acceptance-clock.mjs';
+
+export const PACKAGED_ACCEPTANCE_USER_DATA_PREFIX = 'propr-desktop-acceptance-';
+const ACCEPTANCE_USER_DATA_LEAF = /^propr-desktop-acceptance-[A-Za-z0-9]+$/;
+
+interface AcceptanceAuthorizationInput {
+ argv: readonly string[];
+ defaultUserDataDirectory: string;
+ environmentTriggered: boolean;
+ isPackaged: boolean;
+ platform: NodeJS.Platform;
+}
+
+interface PackagedAcceptancePairingTiming {
+ now(): number;
+ sleep(milliseconds: number, signal?: AbortSignal): Promise;
+}
+
+/**
+ * Acceptance-only main-process fakes are deliberately unreachable from an
+ * ordinary application launch. They require a packaged Linux binary, two
+ * independent triggers, and a fresh, explicitly scoped profile directory.
+ */
+export const authorizePackagedAcceptanceTest = ({
+ argv,
+ defaultUserDataDirectory,
+ environmentTriggered,
+ isPackaged,
+ platform,
+}: AcceptanceAuthorizationInput): string | null => {
+ const argumentTriggered = argv.includes('--propr-acceptance-test');
+ if (!argumentTriggered && !environmentTriggered) return null;
+ if (!argumentTriggered || !environmentTriggered) {
+ throw new Error('Packaged desktop acceptance requires both explicit authorization triggers');
+ }
+ if (!isPackaged || platform !== 'linux') {
+ throw new Error('Packaged desktop acceptance is supported only by the packaged Linux application');
+ }
+ const values = argv
+ .filter(argument => argument.startsWith('--user-data-dir='))
+ .map(argument => argument.slice('--user-data-dir='.length));
+ if (values.length !== 1 || !values[0] || !isAbsolute(values[0])) {
+ throw new Error('Packaged desktop acceptance requires one absolute --user-data-dir');
+ }
+ const requested = resolve(values[0]);
+ if (requested === resolve(defaultUserDataDirectory)
+ || !ACCEPTANCE_USER_DATA_LEAF.test(basename(requested))) {
+ throw new Error(`Packaged desktop acceptance userData must use ${PACKAGED_ACCEPTANCE_USER_DATA_PREFIX}`);
+ }
+ return requested;
+};
+
+/**
+ * Keep the deterministic protocol clock behind the same authorization result
+ * as the other packaged-acceptance-only behavior. A null result covers normal
+ * production, packaged smoke, and ordinary development launches.
+ */
+export const packagedAcceptancePairingTiming = (
+ authorizedUserDataDirectory: string | null,
+): PackagedAcceptancePairingTiming | undefined => authorizedUserDataDirectory === null
+ ? undefined
+ : {
+ now: () => PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS,
+ sleep: async (_milliseconds, _signal) => undefined,
+ };
diff --git a/apps/desktop/src/acceptance-zoom.test.ts b/apps/desktop/src/acceptance-zoom.test.ts
new file mode 100644
index 000000000..cf2080591
--- /dev/null
+++ b/apps/desktop/src/acceptance-zoom.test.ts
@@ -0,0 +1,166 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import {
+ createPackagedAcceptanceZoomBridge,
+ PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL,
+ PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL,
+ registerPackagedAcceptanceZoomIpc,
+ type PackagedAcceptancePreloadIpc,
+} from './acceptance-zoom';
+
+class FakeIpcMain {
+ readonly listeners = new Map void>();
+ readonly handlers = new Map unknown>();
+
+ on(channel: string, listener: (event: any, request: unknown) => void): void { this.listeners.set(channel, listener); }
+ removeListener(channel: string, listener: (event: any, request: unknown) => void): void {
+ if (this.listeners.get(channel) === listener) this.listeners.delete(channel);
+ }
+ handle(channel: string, listener: (event: any, factor: unknown) => unknown): void { this.handlers.set(channel, listener); }
+ removeHandler(channel: string): void { this.handlers.delete(channel); }
+}
+
+const webContents = () => ({ isDestroyed: () => false });
+
+describe('packaged acceptance Electron zoom authorization', () => {
+ it('registers no IPC capability outside the already-authorized acceptance mode', () => {
+ const ipcMain = new FakeIpcMain();
+ const dispose = registerPackagedAcceptanceZoomIpc({
+ authorized: false,
+ ipcMain: ipcMain as never,
+ webContents: webContents() as never,
+ });
+ assert.equal(ipcMain.listeners.size, 0);
+ assert.equal(ipcMain.handlers.size, 0);
+ dispose();
+ });
+
+ it('binds fixed channels, sender, protocol, and the exact finite factor allowlist', async () => {
+ const ipcMain = new FakeIpcMain();
+ const trusted = webContents();
+ const untrusted = webContents();
+ const dispose = registerPackagedAcceptanceZoomIpc({
+ authorized: true,
+ ipcMain: ipcMain as never,
+ webContents: trusted as never,
+ });
+ assert.deepEqual([...ipcMain.listeners.keys()], [PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL]);
+ assert.deepEqual([...ipcMain.handlers.keys()], [PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL]);
+
+ const authorize = ipcMain.listeners.get(PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL)!;
+ const rejected: any = { sender: untrusted, returnValue: undefined };
+ authorize(rejected, { protocolVersion: 1 });
+ assert.equal(rejected.returnValue, null);
+ const malformed: any = { sender: trusted, returnValue: undefined };
+ authorize(malformed, { protocolVersion: 1, extra: true });
+ assert.equal(malformed.returnValue, null);
+ const admitted: any = { sender: trusted, returnValue: undefined };
+ authorize(admitted, { protocolVersion: 1 });
+ assert.deepEqual(admitted.returnValue, { authorized: true, protocolVersion: 1 });
+
+ const apply = ipcMain.handlers.get(PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL)!;
+ assert.deepEqual(await apply({ sender: trusted }, 1), { authorized: true, requestedFactor: 1 });
+ assert.deepEqual(await apply({ sender: trusted }, 2), { authorized: true, requestedFactor: 2 });
+ for (const rejectedFactor of [0, 1.5, 3, '2', null, { factor: 2 }]) {
+ assert.throws(() => apply({ sender: trusted }, rejectedFactor), /not allowlisted/);
+ }
+ assert.throws(() => apply({ sender: untrusted }, 2), /sender is not authorized/);
+
+ dispose();
+ dispose();
+ assert.equal(ipcMain.listeners.size, 0);
+ assert.equal(ipcMain.handlers.size, 0);
+ });
+});
+
+describe('packaged acceptance Electron zoom preload bridge', () => {
+ it('does not attest or expose a bridge without the exact acceptance environment', () => {
+ let syncCalls = 0;
+ const ipc: PackagedAcceptancePreloadIpc = {
+ sendSync: () => { syncCalls += 1; return { authorized: true, protocolVersion: 1 }; },
+ invoke: async () => ({ authorized: true, requestedFactor: 1 }),
+ };
+ const frame = { setZoomFactor() {}, getZoomFactor: () => 1 };
+ for (const environmentValue of [undefined, '', '0', 'true', '01', '1 ']) {
+ assert.equal(createPackagedAcceptanceZoomBridge(ipc, frame, environmentValue), null);
+ }
+ assert.equal(syncCalls, 0);
+ });
+
+ it('fails closed on an absent or malformed main acknowledgement', () => {
+ const frame = { setZoomFactor() {}, getZoomFactor: () => 1 };
+ for (const acknowledgement of [
+ undefined,
+ null,
+ false,
+ { authorized: false, protocolVersion: 1 },
+ { authorized: true, protocolVersion: 2 },
+ { authorized: true, protocolVersion: 1, extra: true },
+ ]) {
+ const ipc: PackagedAcceptancePreloadIpc = {
+ sendSync: () => acknowledgement,
+ invoke: async () => undefined,
+ };
+ assert.equal(createPackagedAcceptanceZoomBridge(ipc, frame, '1'), null);
+ }
+ });
+
+ it('accepts exact environment plus main attestation with real preload argv lacking the main switch', async () => {
+ let current = 2;
+ const calls: number[] = [];
+ const ipc: PackagedAcceptancePreloadIpc = {
+ sendSync: () => ({ authorized: true, protocolVersion: 1 }),
+ invoke: async (_channel, factor) => ({ authorized: true, requestedFactor: factor }),
+ };
+ const originalArgv = process.argv;
+ const originalEnvironment = process.env.PROPR_DESKTOP_ACCEPTANCE_TEST;
+ let bridge: ReturnType;
+ try {
+ process.argv = ['/opt/ProPR Desktop/propr-desktop', '--type=renderer'];
+ process.env.PROPR_DESKTOP_ACCEPTANCE_TEST = '1';
+ bridge = createPackagedAcceptanceZoomBridge(ipc, {
+ setZoomFactor(factor) { calls.push(factor); current = factor; },
+ getZoomFactor: () => current,
+ });
+ } finally {
+ process.argv = originalArgv;
+ if (originalEnvironment === undefined) delete process.env.PROPR_DESKTOP_ACCEPTANCE_TEST;
+ else process.env.PROPR_DESKTOP_ACCEPTANCE_TEST = originalEnvironment;
+ }
+ assert.ok(bridge);
+ assert.equal(Object.isFrozen(bridge), true);
+ assert.deepEqual(Object.keys(bridge), ['setZoomFactor']);
+ assert.deepEqual(await bridge.setZoomFactor(2), {
+ requestedFactor: 2, resetFactor: 1, appliedFactor: 2, mechanism: 'electron-web-frame',
+ });
+ assert.deepEqual(calls, [1, 2]);
+ await assert.rejects(bridge.setZoomFactor(1.5 as never), /not allowlisted/);
+ assert.deepEqual(calls, [1, 2]);
+
+ const malformedApply = createPackagedAcceptanceZoomBridge({
+ sendSync: () => ({ authorized: true, protocolVersion: 1 }),
+ invoke: async () => ({ authorized: true, requestedFactor: 2, extra: true }),
+ }, { setZoomFactor() {}, getZoomFactor: () => 1 }, '1');
+ assert.ok(malformedApply);
+ await assert.rejects(malformedApply.setZoomFactor(2), /acknowledgement is malformed/);
+ });
+
+ it('fails closed when reset or application reads back the wrong real webFrame factor', async () => {
+ const ipc: PackagedAcceptancePreloadIpc = {
+ sendSync: () => ({ authorized: true, protocolVersion: 1 }),
+ invoke: async (_channel, factor) => ({ authorized: true, requestedFactor: factor }),
+ };
+ const resetFailure = createPackagedAcceptanceZoomBridge(ipc, {
+ setZoomFactor() {}, getZoomFactor: () => 2,
+ }, '1');
+ assert.ok(resetFailure);
+ await assert.rejects(resetFailure.setZoomFactor(2), /zoom reset failed/);
+
+ let reads = 0;
+ const applyFailure = createPackagedAcceptanceZoomBridge(ipc, {
+ setZoomFactor() {}, getZoomFactor: () => (++reads === 1 ? 1 : 1),
+ }, '1');
+ assert.ok(applyFailure);
+ await assert.rejects(applyFailure.setZoomFactor(2), /zoom application failed/);
+ });
+});
diff --git a/apps/desktop/src/acceptance-zoom.ts b/apps/desktop/src/acceptance-zoom.ts
new file mode 100644
index 000000000..29285a8b6
--- /dev/null
+++ b/apps/desktop/src/acceptance-zoom.ts
@@ -0,0 +1,144 @@
+import type { IpcMainInvokeEvent, IpcMainEvent, WebContents } from 'electron';
+
+export const PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL = 'desktop:packaged-acceptance-zoom-authorize';
+export const PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL = 'desktop:packaged-acceptance-zoom-apply';
+export const PACKAGED_ACCEPTANCE_ZOOM_MECHANISM = 'electron-web-frame';
+export const PACKAGED_ACCEPTANCE_ZOOM_FACTORS = Object.freeze([1, 2] as const);
+
+export type PackagedAcceptanceZoomFactor = typeof PACKAGED_ACCEPTANCE_ZOOM_FACTORS[number];
+
+type ZoomAuthorizationRequest = { protocolVersion: 1 };
+type ZoomAuthorizationAcknowledgement = { authorized: true; protocolVersion: 1 };
+type ZoomApplyAcknowledgement = { authorized: true; requestedFactor: PackagedAcceptanceZoomFactor };
+
+type IpcMainLike = {
+ on(channel: string, listener: (event: IpcMainEvent, request: unknown) => void): void;
+ removeListener(channel: string, listener: (event: IpcMainEvent, request: unknown) => void): void;
+ handle(channel: string, listener: (event: IpcMainInvokeEvent, factor: unknown) => unknown): void;
+ removeHandler(channel: string): void;
+};
+
+export type PackagedAcceptancePreloadIpc = {
+ sendSync(channel: string, request: unknown): unknown;
+ invoke(channel: string, factor: unknown): Promise;
+};
+
+export type PackagedAcceptanceWebFrame = {
+ setZoomFactor(factor: number): void;
+ getZoomFactor(): number;
+};
+
+export type PackagedAcceptanceZoomResult = {
+ requestedFactor: PackagedAcceptanceZoomFactor;
+ resetFactor: 1;
+ appliedFactor: PackagedAcceptanceZoomFactor;
+ mechanism: typeof PACKAGED_ACCEPTANCE_ZOOM_MECHANISM;
+};
+
+const exactKeys = (value: object, expected: string[]): boolean => (
+ Object.keys(value).sort().join('\n') === [...expected].sort().join('\n')
+);
+
+const isAuthorizationRequest = (value: unknown): value is ZoomAuthorizationRequest => (
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+ && exactKeys(value, ['protocolVersion'])
+ && (value as ZoomAuthorizationRequest).protocolVersion === 1
+);
+
+const isAuthorizationAcknowledgement = (value: unknown): value is ZoomAuthorizationAcknowledgement => (
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+ && exactKeys(value, ['authorized', 'protocolVersion'])
+ && (value as ZoomAuthorizationAcknowledgement).authorized === true
+ && (value as ZoomAuthorizationAcknowledgement).protocolVersion === 1
+);
+
+const isApplyAcknowledgement = (
+ value: unknown,
+ requestedFactor: PackagedAcceptanceZoomFactor,
+): value is ZoomApplyAcknowledgement => (
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+ && exactKeys(value, ['authorized', 'requestedFactor'])
+ && (value as ZoomApplyAcknowledgement).authorized === true
+ && (value as ZoomApplyAcknowledgement).requestedFactor === requestedFactor
+);
+
+export const isPackagedAcceptanceZoomFactor = (value: unknown): value is PackagedAcceptanceZoomFactor => (
+ value === 1 || value === 2
+);
+
+const preloadHasAcceptanceEnvironment = (environmentValue: string | undefined): boolean => environmentValue === '1';
+
+/**
+ * Register the two fixed authorization routes only after the main process has
+ * completed packaged-acceptance authorization, and bind them to one renderer.
+ */
+export const registerPackagedAcceptanceZoomIpc = ({
+ authorized,
+ ipcMain,
+ webContents,
+}: {
+ authorized: boolean;
+ ipcMain: IpcMainLike;
+ webContents: WebContents;
+}): (() => void) => {
+ if (!authorized) return () => undefined;
+
+ const trustedSender = (sender: WebContents): boolean => sender === webContents && !webContents.isDestroyed();
+ const authorize = (event: IpcMainEvent, request: unknown): void => {
+ if (!trustedSender(event.sender) || !isAuthorizationRequest(request)) {
+ event.returnValue = null;
+ return;
+ }
+ event.returnValue = { authorized: true, protocolVersion: 1 } satisfies ZoomAuthorizationAcknowledgement;
+ };
+ ipcMain.on(PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL, authorize);
+ ipcMain.handle(PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL, (event, factor) => {
+ if (!trustedSender(event.sender)) throw new Error('Packaged acceptance zoom sender is not authorized');
+ if (!isPackagedAcceptanceZoomFactor(factor)) throw new Error('Packaged acceptance zoom factor is not allowlisted');
+ return { authorized: true, requestedFactor: factor } satisfies ZoomApplyAcknowledgement;
+ });
+
+ let disposed = false;
+ return () => {
+ if (disposed) return;
+ disposed = true;
+ ipcMain.removeListener(PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL, authorize);
+ ipcMain.removeHandler(PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL);
+ };
+};
+
+/** Build the only acceptance-only renderer capability after a synchronous main-process attestation. */
+export const createPackagedAcceptanceZoomBridge = (
+ ipc: PackagedAcceptancePreloadIpc,
+ frame: PackagedAcceptanceWebFrame,
+ environmentValue: string | undefined = process.env.PROPR_DESKTOP_ACCEPTANCE_TEST,
+): Readonly<{ setZoomFactor(factor: PackagedAcceptanceZoomFactor): Promise }> | null => {
+ if (!preloadHasAcceptanceEnvironment(environmentValue)) return null;
+ const authorization = ipc.sendSync(PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL, { protocolVersion: 1 });
+ if (!isAuthorizationAcknowledgement(authorization)) return null;
+
+ return Object.freeze({
+ async setZoomFactor(factor: PackagedAcceptanceZoomFactor): Promise {
+ if (!isPackagedAcceptanceZoomFactor(factor)) throw new Error('Packaged acceptance zoom factor is not allowlisted');
+ const acknowledgement = await ipc.invoke(PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL, factor);
+ if (!isApplyAcknowledgement(acknowledgement, factor)) {
+ throw new Error('Packaged acceptance zoom authorization acknowledgement is malformed');
+ }
+
+ frame.setZoomFactor(1);
+ const resetFactor = frame.getZoomFactor();
+ if (resetFactor !== 1) throw new Error(`Packaged acceptance zoom reset failed: received ${resetFactor}`);
+ frame.setZoomFactor(factor);
+ const appliedFactor = frame.getZoomFactor();
+ if (appliedFactor !== factor) {
+ throw new Error(`Packaged acceptance zoom application failed: expected ${factor}, received ${appliedFactor}`);
+ }
+ return {
+ requestedFactor: factor,
+ resetFactor: 1,
+ appliedFactor: factor,
+ mechanism: PACKAGED_ACCEPTANCE_ZOOM_MECHANISM,
+ };
+ },
+ });
+};
diff --git a/apps/desktop/src/credential-service.test.ts b/apps/desktop/src/credential-service.test.ts
index 5db0b2c28..28cf08025 100644
--- a/apps/desktop/src/credential-service.test.ts
+++ b/apps/desktop/src/credential-service.test.ts
@@ -7,15 +7,22 @@ import { afterEach, describe, it } from 'node:test';
import {
DESKTOP_RENDERER_ORIGIN,
DESKTOP_REVOCATION_BINDING_HEADER,
+ DESKTOP_TRANSPORT_SCOPE_HEADER,
DESKTOP_TOKEN_REVOCATION_ENDPOINT,
DESKTOP_TOKEN_REVOCATION_SCHEMA,
DESKTOP_TOKEN_REVOCATION_VERSION,
PROPR_API_COMPATIBILITY,
PROPR_UI_COMPATIBILITY,
} from '@propr/shared';
-import { DesktopCredentialService } from './credential-service';
+import {
+ DesktopCredentialService,
+ type DesktopCurrentUserProxyEvidence,
+ type DesktopWebSocketHandshakeEvidence,
+} from './credential-service';
import { ProfileStore, type EncryptionProvider, type StoredCredential } from './profile-store';
+export const NATIVE_DURABILITY_EXPECTED_TESTS = 72 as const;
+
const temporaryDirectories: string[] = [];
const credentialServices: DesktopCredentialService[] = [];
const encryption: EncryptionProvider = {
@@ -108,10 +115,26 @@ const createStore = async (): Promise => {
return new ProfileStore(directory, encryption);
};
+class MainRendererCredentialService extends DesktopCredentialService {
+ override prepareRequest(
+ url: Parameters[0],
+ originalHeaders: Parameters[1],
+ details: Parameters[2] = {},
+ ) {
+ return super.prepareRequest(url, originalHeaders, {
+ rendererOwned: true,
+ ...details,
+ });
+ }
+}
+
const createCredentialService = (
dependencies: ConstructorParameters[0],
): DesktopCredentialService => {
- const service = new DesktopCredentialService(dependencies);
+ // Existing service tests exercise requests already proven to belong to the
+ // live main renderer. Ownership-source behavior is covered through the real
+ // session interceptor in session-security.test.ts.
+ const service = new MainRendererCredentialService(dependencies);
credentialServices.push(service);
return service;
};
@@ -191,7 +214,7 @@ describe('main-process desktop credential service', () => {
// Simulate a session cookie Electron might otherwise append after the
// main-process fetch has applied its unforgeable request marker.
requestHeaders.Cookie = 'main-process=session';
- const decision = service.prepareRequest(url, requestHeaders);
+ const decision = service.prepareRequest(url, requestHeaders, { rendererOwned: false });
assert.equal(decision.cancel, undefined);
wireRequests.push({ url, headers: decision.requestHeaders ?? {} });
return url.endsWith('/api/desktop/discovery') ? json(discovery) : json({ username: 'octocat' });
@@ -624,13 +647,24 @@ describe('main-process desktop credential service', () => {
});
});
- it('passes through a realistic packaged-origin CORS preflight without renderer identity or bearer injection', () => {
+ it('passes through an active-origin CORS preflight without renderer identity or bearer injection', async () => {
+ const store = await createStore();
+ const profile = await store.save({
+ id: 'profile-a', label: 'A', apiBaseUrl: 'https://same.example.test',
+ });
+ await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A'));
const service = createCredentialService({
- profiles: { awaitIdle: async () => undefined } as unknown as ProfileStore,
+ profiles: store,
clientName: 'Test desktop',
openExternal: async () => undefined,
- fetch: async () => { throw new Error('Network is not expected'); },
+ fetch: async input => input.toString().endsWith('/api/desktop/discovery')
+ ? json(discovery)
+ : json({ username: 'octocat' }),
});
+ const ready = await service.probe(profile);
+ assert.equal(ready.status, 'ready');
+ if (ready.status !== 'ready') return;
+ await service.activate(ready.activationTicket);
assert.deepEqual(service.prepareRequest('https://same.example.test/api/tasks', {
Origin: DESKTOP_RENDERER_ORIGIN,
@@ -647,6 +681,256 @@ describe('main-process desktop credential service', () => {
});
});
+ it('does not let a cached renderer grant outlive discard or revocation', async () => {
+ const store = await createStore();
+ const profile = await store.save({
+ id: 'profile-a', label: 'A', apiBaseUrl: 'https://same.example.test',
+ });
+ await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A'));
+ const service = createCredentialService({
+ profiles: store,
+ clientName: 'Test desktop',
+ openExternal: async () => undefined,
+ fetch: async input => input.toString().endsWith('/api/desktop/discovery')
+ ? json(discovery)
+ : json({ username: 'octocat' }),
+ });
+ const targets = [
+ 'http://127.0.0.1:41731/api/side-effect',
+ 'https://192.168.1.10/api/side-effect',
+ 'https://same.example.test.evil.invalid/api/side-effect',
+ 'https://same.example.test:444/api/side-effect',
+ ];
+ const assertCancelled = (label: string) => {
+ for (const url of [...targets, `${profile.apiBaseUrl}/api/side-effect`]) {
+ assert.deepEqual(service.prepareRequest(url, {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ }), { cancel: true }, `${label}: ${url}`);
+ }
+ };
+
+ assertCancelled('no active binding');
+ const firstReady = await service.probe(profile);
+ assert.equal(firstReady.status, 'ready');
+ if (firstReady.status !== 'ready') return;
+ const first = await service.activate(firstReady.activationTicket);
+ for (const url of targets) {
+ assert.deepEqual(service.prepareRequest(url, {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ }), { cancel: true }, `active foreign origin: ${url}`);
+ }
+ assert.deepEqual(service.prepareRequest(`${profile.apiBaseUrl}/api/side-effect`, {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ }).requestHeaders, { Origin: DESKTOP_RENDERER_ORIGIN });
+
+ assert.deepEqual(await service.discardActivation(first), { discarded: true });
+ assertCancelled('discarded binding');
+
+ const secondReady = await service.probe(profile);
+ assert.equal(secondReady.status, 'ready');
+ if (secondReady.status !== 'ready') return;
+ const second = await service.activate(secondReady.activationTicket);
+ assert.deepEqual(await service.invalidate({
+ profileId: profile.id,
+ transportScope: second.transportScope,
+ code: 'INSTANCE_TOKEN_REVOKED',
+ }), { invalidated: true });
+ assertCancelled('revoked binding');
+ });
+
+ it('reports every exact current-user proxy decision with bounded secret-free evidence', async () => {
+ const store = await createStore();
+ const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' });
+ await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A'));
+ const evidence: DesktopCurrentUserProxyEvidence[] = [];
+ const service = createCredentialService({
+ profiles: store,
+ clientName: 'Test desktop',
+ openExternal: async () => undefined,
+ fetch: async input => input.toString().endsWith('/api/desktop/discovery')
+ ? json(discovery)
+ : json({ username: 'octocat' }),
+ reportCurrentUserValidation: record => evidence.push(record),
+ });
+
+ const currentUserUrl = `${profile.apiBaseUrl}/api/auth/user?proprDesktopScopeGeneration=7`;
+ assert.deepEqual(service.prepareRequest(currentUserUrl, {
+ ...transportHeaders('N'.repeat(22)),
+ }, { method: 'GET', resourceType: 'xhr' }), { cancel: true });
+ assert.deepEqual(evidence.at(-1), {
+ schemaVersion: 2,
+ correlation: 'current-scope-user-validation',
+ requestObserved: true,
+ method: 'get',
+ rendererScopeGeneration: 7,
+ scopeGenerationQueryCount: 1,
+ scopeGenerationQueryValid: true,
+ scopeHeaderCount: 1,
+ activeBindingPresent: false,
+ activeScopeGeneration: 0,
+ profileGenerationCurrent: false,
+ scopeEqualsActive: false,
+ originEqualsActive: false,
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ outboundBearerPresent: false,
+ bearerMainInjected: false,
+ accepted: false,
+ rejectionCategory: 'no-active-binding',
+ });
+
+ const ready = await service.probe(profile);
+ assert.equal(ready.status, 'ready');
+ if (ready.status !== 'ready') return;
+ const activated = await service.activate(ready.activationTicket);
+
+ assert.deepEqual(service.prepareRequest(currentUserUrl, {}, {
+ method: 'GET', resourceType: 'xhr',
+ }).requestHeaders, {});
+ assert.deepEqual({
+ count: evidence.at(-1)?.scopeHeaderCount,
+ accepted: evidence.at(-1)?.accepted,
+ rejectionCategory: evidence.at(-1)?.rejectionCategory,
+ outboundBearerPresent: evidence.at(-1)?.outboundBearerPresent,
+ }, { count: 0, accepted: false, rejectionCategory: 'scope-missing', outboundBearerPresent: false });
+
+ assert.deepEqual(service.prepareRequest(currentUserUrl, {
+ [DESKTOP_TRANSPORT_SCOPE_HEADER]: [activated.transportScope, activated.transportScope, activated.transportScope],
+ }, { method: 'GET', resourceType: 'xhr' }), { cancel: true });
+ assert.deepEqual({
+ count: evidence.at(-1)?.scopeHeaderCount,
+ accepted: evidence.at(-1)?.accepted,
+ rejectionCategory: evidence.at(-1)?.rejectionCategory,
+ bearerMainInjected: evidence.at(-1)?.bearerMainInjected,
+ }, { count: 2, accepted: false, rejectionCategory: 'scope-duplicate', bearerMainInjected: false });
+
+ assert.deepEqual(service.prepareRequest(currentUserUrl, {
+ ...transportHeaders('Z'.repeat(22)),
+ }, { method: 'GET', resourceType: 'xhr' }), { cancel: true });
+ assert.deepEqual({
+ count: evidence.at(-1)?.scopeHeaderCount,
+ accepted: evidence.at(-1)?.accepted,
+ rejectionCategory: evidence.at(-1)?.rejectionCategory,
+ bearerMainInjected: evidence.at(-1)?.bearerMainInjected,
+ }, { count: 1, accepted: false, rejectionCategory: 'stale-scope', bearerMainInjected: false });
+
+ assert.deepEqual(service.prepareRequest(currentUserUrl, {
+ ...transportHeaders(activated.transportScope),
+ }, { method: 'GET', resourceType: 'xhr' }).requestHeaders, {
+ Authorization: `Bearer ${token('A')}`,
+ });
+ assert.deepEqual(evidence.at(-1), {
+ schemaVersion: 2,
+ correlation: 'current-scope-user-validation',
+ requestObserved: true,
+ method: 'get',
+ rendererScopeGeneration: 7,
+ scopeGenerationQueryCount: 1,
+ scopeGenerationQueryValid: true,
+ scopeHeaderCount: 1,
+ activeBindingPresent: true,
+ activeScopeGeneration: 0,
+ profileGenerationCurrent: true,
+ scopeEqualsActive: true,
+ originEqualsActive: true,
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ outboundBearerPresent: true,
+ bearerMainInjected: true,
+ accepted: true,
+ rejectionCategory: 'none',
+ });
+ assert.equal(evidence.length, 5);
+ assert.doesNotMatch(JSON.stringify(evidence), /profile-a|example\.test|propr_it_/);
+ });
+
+ it('reports deterministic secret-free WebSocket decisions for positive and adversarial handshakes', async () => {
+ const store = await createStore();
+ const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' });
+ await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A'));
+ const evidence: DesktopWebSocketHandshakeEvidence[] = [];
+ const service = createCredentialService({
+ profiles: store,
+ clientName: 'Test desktop',
+ openExternal: async () => undefined,
+ fetch: async input => input.toString().endsWith('/api/desktop/discovery')
+ ? json(discovery)
+ : json({ username: 'octocat' }),
+ reportWebSocketHandshake: record => evidence.push(record),
+ });
+ const ready = await service.probe(profile);
+ assert.equal(ready.status, 'ready');
+ if (ready.status !== 'ready') return;
+ const activated = await service.activate(ready.activationTicket);
+ const socketUrl = (scope = activated.transportScope, origin = profile.apiBaseUrl) =>
+ `${origin.replace(/^http/, 'ws')}/socket.io/?EIO=4&transport=websocket&proprDesktopTransportScope=${scope}`;
+
+ assert.deepEqual(service.prepareRequest(socketUrl(), {
+ Authorization: 'Bearer renderer-controlled',
+ Cookie: 'renderer=session',
+ }, { resourceType: 'websocket' }).requestHeaders, { Authorization: `Bearer ${token('A')}` });
+ assert.deepEqual(evidence.at(-1), {
+ schemaVersion: 1,
+ path: 'socket-io',
+ transport: 'websocket',
+ resource: 'websocket',
+ scopeQueryPresent: true,
+ scopeQueryCount: 1,
+ scopeEqualsActive: true,
+ activeBindingPresent: true,
+ profileGenerationCurrent: true,
+ originEqualsActive: true,
+ rendererBearerPresent: true,
+ rendererCookiePresent: true,
+ outboundBearerPresent: true,
+ bearerMainInjected: true,
+ accepted: true,
+ rejectionCategory: 'none',
+ });
+
+ assert.deepEqual(service.prepareRequest(
+ `${profile.apiBaseUrl.replace(/^http/, 'ws')}/socket.io/?EIO=4&transport=websocket`,
+ {}, { resourceType: 'webSocket' },
+ ), { cancel: true });
+ assert.deepEqual({
+ present: evidence.at(-1)?.scopeQueryPresent,
+ count: evidence.at(-1)?.scopeQueryCount,
+ equal: evidence.at(-1)?.scopeEqualsActive,
+ category: evidence.at(-1)?.rejectionCategory,
+ }, { present: false, count: 0, equal: false, category: 'scope-missing' });
+ assert.deepEqual(service.prepareRequest(`${socketUrl()}&proprDesktopTransportScope=${activated.transportScope}`, {}, {
+ resourceType: 'webSocket',
+ }), { cancel: true });
+ assert.deepEqual({
+ present: evidence.at(-1)?.scopeQueryPresent,
+ count: evidence.at(-1)?.scopeQueryCount,
+ equal: evidence.at(-1)?.scopeEqualsActive,
+ category: evidence.at(-1)?.rejectionCategory,
+ }, { present: true, count: 2, equal: false, category: 'scope-duplicate' });
+ assert.deepEqual(service.prepareRequest(socketUrl('S'.repeat(22)), {}, { resourceType: 'webSocket' }), { cancel: true });
+ assert.deepEqual({
+ count: evidence.at(-1)?.scopeQueryCount,
+ equal: evidence.at(-1)?.scopeEqualsActive,
+ category: evidence.at(-1)?.rejectionCategory,
+ }, { count: 1, equal: false, category: 'stale-scope' });
+ assert.deepEqual(service.prepareRequest(socketUrl(activated.transportScope, 'https://b.example.test'), {}, {
+ resourceType: 'webSocket',
+ }), { cancel: true });
+ assert.deepEqual({
+ equal: evidence.at(-1)?.scopeEqualsActive,
+ originEqual: evidence.at(-1)?.originEqualsActive,
+ injected: evidence.at(-1)?.bearerMainInjected,
+ category: evidence.at(-1)?.rejectionCategory,
+ }, { equal: true, originEqual: false, injected: false, category: 'wrong-origin' });
+ assert.deepEqual(service.prepareRequest(socketUrl(), {}, { resourceType: 'xhr' }), { cancel: true });
+ assert.equal(evidence.at(-1)?.rejectionCategory, 'wrong-resource-type');
+
+ assert.equal(JSON.stringify(evidence).includes(activated.transportScope), false);
+ assert.equal(JSON.stringify(evidence).includes(token('A')), false);
+ assert.equal(JSON.stringify(evidence).includes('renderer-controlled'), false);
+ assert.equal(JSON.stringify(evidence).includes(profile.apiBaseUrl), false);
+ });
+
it('rotates scope on every same-profile reprobe and rejects a cold reconnect from the old activation', async () => {
const store = await createStore();
const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'http://localhost:3000' });
@@ -708,8 +992,26 @@ describe('main-process desktop credential service', () => {
});
assert.equal(result.status, 'authentication-required');
- assert.equal(requests.filter(request => request.url.startsWith('https://attacker.example.test'))
- .every(request => request.authorization === null), true);
+ assert.equal(requests.length, 1);
+ const request = requests.at(0);
+ assert.ok(request);
+ const requestUrl = new URL(request.url);
+ assert.deepEqual({
+ origin: requestUrl.origin,
+ hostname: requestUrl.hostname,
+ pathname: requestUrl.pathname,
+ search: requestUrl.search,
+ authorization: request.authorization,
+ }, {
+ origin: 'https://attacker.example.test',
+ hostname: 'attacker.example.test',
+ pathname: '/api/desktop/discovery',
+ search: '',
+ authorization: null,
+ });
+ const lookalikeUrl = new URL('https://attacker.example.test.evil.invalid/api/desktop/discovery');
+ assert.notEqual(lookalikeUrl.origin, requestUrl.origin);
+ assert.notEqual(lookalikeUrl.hostname, requestUrl.hostname);
assert.equal(requests.some(request => request.url === 'https://a.example.test/api/desktop/tokens/current'), false);
assert.deepEqual(await store.readCredential(profile.id), credential(profile.id, profile.apiBaseUrl, 'A'));
});
diff --git a/apps/desktop/src/credential-service.ts b/apps/desktop/src/credential-service.ts
index 55c180cf6..b1ed7c3dc 100644
--- a/apps/desktop/src/credential-service.ts
+++ b/apps/desktop/src/credential-service.ts
@@ -51,6 +51,78 @@ export interface CredentialServiceDependencies {
code: 'network' | 'http' | 'local-cleanup';
status?: number;
}): void;
+ /** Fixed, secret-free evidence for the packaged acceptance boundary. */
+ reportWebSocketHandshake?(evidence: DesktopWebSocketHandshakeEvidence): void;
+ /** Fixed, secret-free evidence for scoped renderer current-user validation. */
+ reportCurrentUserValidation?(evidence: DesktopCurrentUserProxyEvidence): void;
+}
+
+export type DesktopWebSocketRejectionCategory =
+ | 'none'
+ | 'untrusted-http-origin'
+ | 'wrong-path'
+ | 'wrong-transport'
+ | 'wrong-resource-type'
+ | 'scope-missing'
+ | 'scope-duplicate'
+ | 'scope-malformed'
+ | 'no-active-binding'
+ | 'stale-generation'
+ | 'wrong-origin'
+ | 'stale-scope';
+
+export interface DesktopWebSocketHandshakeEvidence {
+ schemaVersion: 1;
+ path: 'socket-io' | 'other';
+ transport: 'websocket' | 'other';
+ resource: 'websocket' | 'other';
+ scopeQueryPresent: boolean;
+ scopeQueryCount: number;
+ scopeEqualsActive: boolean;
+ activeBindingPresent: boolean;
+ profileGenerationCurrent: boolean;
+ originEqualsActive: boolean;
+ rendererBearerPresent: boolean;
+ rendererCookiePresent: boolean;
+ outboundBearerPresent: boolean;
+ bearerMainInjected: boolean;
+ accepted: boolean;
+ rejectionCategory: DesktopWebSocketRejectionCategory;
+}
+
+export type DesktopCurrentUserProxyRejectionCategory =
+ | 'none'
+ | 'scope-missing'
+ | 'scope-duplicate'
+ | 'scope-malformed'
+ | 'no-active-binding'
+ | 'stale-generation'
+ | 'wrong-origin'
+ | 'stale-scope';
+
+export interface DesktopCurrentUserProxyEvidence {
+ schemaVersion: 2;
+ correlation: 'current-scope-user-validation';
+ requestObserved: true;
+ method: 'get';
+ /** Renderer cache-boundary generation parsed from the one canonical query. */
+ rendererScopeGeneration: number | null;
+ /** Exact for zero/one; two means two or more, keeping adversarial evidence bounded. */
+ scopeGenerationQueryCount: 0 | 1 | 2;
+ scopeGenerationQueryValid: boolean;
+ /** Exact for zero/one; two means two or more, keeping adversarial evidence bounded. */
+ scopeHeaderCount: 0 | 1 | 2;
+ activeBindingPresent: boolean;
+ activeScopeGeneration: number;
+ profileGenerationCurrent: boolean;
+ scopeEqualsActive: boolean;
+ originEqualsActive: boolean;
+ rendererBearerPresent: boolean;
+ rendererCookiePresent: boolean;
+ outboundBearerPresent: boolean;
+ bearerMainInjected: boolean;
+ accepted: boolean;
+ rejectionCategory: DesktopCurrentUserProxyRejectionCategory;
}
export interface CredentialServiceInitialization {
@@ -183,6 +255,25 @@ const requestOrigin = (value: string): { origin: string; pathname: string; url:
}
};
+const CURRENT_USER_SCOPE_GENERATION_QUERY = 'proprDesktopScopeGeneration';
+
+const currentUserScopeGeneration = (url: URL): {
+ count: 0 | 1 | 2;
+ generation: number | null;
+ valid: boolean;
+} => {
+ const values = url.searchParams.getAll(CURRENT_USER_SCOPE_GENERATION_QUERY);
+ const count = Math.min(values.length, 2) as 0 | 1 | 2;
+ if (values.length !== 1 || [...url.searchParams].length !== 1) {
+ return { count, generation: null, valid: false };
+ }
+ const value = values[0];
+ const generation = /^(?:0|[1-9]\d{0,15})$/.test(value) ? Number(value) : NaN;
+ const valid = Number.isSafeInteger(generation)
+ && url.search === `?${CURRENT_USER_SCOPE_GENERATION_QUERY}=${value}`;
+ return { count, generation: valid ? generation : null, valid };
+};
+
const parseCode = async (response: Response): Promise => {
try {
const value = await response.clone().json() as { code?: unknown };
@@ -338,6 +429,8 @@ export class DesktopCredentialService {
readonly #pairingTiming: Pick;
readonly #pairingProtocol: PairingProtocolRequestOptions;
readonly #reportRevocationFailure: NonNullable;
+ readonly #reportWebSocketHandshake: NonNullable;
+ readonly #reportCurrentUserValidation: NonNullable;
readonly #revocationDeadlines: RevocationDeadlines;
readonly #internalRequestKey = randomBytes(32).toString('base64url');
readonly #lifecycleController = new AbortController();
@@ -369,6 +462,8 @@ export class DesktopCredentialService {
this.#pairingTiming = dependencies.pairingTiming ?? {};
this.#pairingProtocol = dependencies.pairingProtocol ?? {};
this.#reportRevocationFailure = dependencies.reportRevocationFailure ?? (() => undefined);
+ this.#reportWebSocketHandshake = dependencies.reportWebSocketHandshake ?? (() => undefined);
+ this.#reportCurrentUserValidation = dependencies.reportCurrentUserValidation ?? (() => undefined);
this.#revocationDeadlines = boundedRevocationDeadlines(dependencies.revocationDeadlines);
}
@@ -1045,17 +1140,21 @@ export class DesktopCredentialService {
}
}
+ /** Whether main currently owns the credential binding required by renderer transport. */
+ hasActiveRendererBinding(): boolean {
+ const active = this.#active;
+ return active !== null
+ && this.#generation(active.profileId) === active.profileGeneration
+ && this.#selectionGeneration === active.selectionGeneration;
+ }
+
prepareRequest(
url: string,
originalHeaders: RequestHeaders,
- details: { method?: string; resourceType?: string } = {},
+ details: { method?: string; rendererOwned?: boolean; resourceType?: string } = {},
): DesktopRequestDecision {
if (this.#closed) return { cancel: true };
const headers = { ...originalHeaders };
- if (/^(?:https?|wss?):/i.test(url)) {
- const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:');
- if (!canonicalProprHttpUrlOrigin(httpUrl)) return { cancel: true };
- }
const internalHeader = headerName(headers, 'x-propr-desktop-main-request');
const trustedMainRequest = internalHeader !== undefined
&& headers[internalHeader] === this.#internalRequestKey;
@@ -1067,47 +1166,215 @@ export class DesktopCredentialService {
// The packaged renderer has no cookie identity on any remote HTTP(S) or
// WS(S) origin. It also cannot supply its own bearer. Main-process bearer
// requests are distinguished by the per-process secret marker above.
+ const target = requestOrigin(url);
+ const rendererAuthorizationPresent = !trustedMainRequest && headerValues(originalHeaders, 'authorization').length > 0;
+ const rendererCookiePresent = !trustedMainRequest && headerValues(originalHeaders, 'cookie').length > 0;
+ const path = target?.pathname === '/socket.io/' ? 'socket-io' : 'other';
+ const transportValues = target?.url.searchParams.getAll('transport') ?? [];
+ const transport = transportValues.length === 1 && transportValues[0] === 'websocket' ? 'websocket' : 'other';
+ // Electron documents `webSocket`; normalizing the comparison also covers
+ // Chromium's lower-case serialization without accepting another resource.
+ const resource = details.resourceType?.toLowerCase() === 'websocket'
+ || headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket')
+ ? 'websocket'
+ : 'other';
+ const queryScopes = target?.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY) ?? [];
+ const active = this.#active;
+ const activeGenerationCurrent = active !== null
+ && this.#generation(active.profileId) === active.profileGeneration
+ && this.#selectionGeneration === active.selectionGeneration;
+ const scopeEqualsActive = queryScopes.length === 1
+ && active !== null
+ && queryScopes[0] === active.transportScope;
+ const isHandshakeCandidate = path === 'socket-io' || queryScopes.length > 0;
+ const isCurrentUserRequest = !trustedMainRequest
+ && target?.pathname === '/api/auth/user'
+ && (details.method ?? 'GET').toUpperCase() === 'GET';
+ const rendererCurrentUserGeneration = target && isCurrentUserRequest
+ ? currentUserScopeGeneration(target.url)
+ : { count: 0 as const, generation: null, valid: false };
+ const reportHandshake = (
+ accepted: boolean,
+ rejectionCategory: DesktopWebSocketRejectionCategory,
+ authorizationMainInjected = false,
+ ): void => {
+ if (!isHandshakeCandidate || trustedMainRequest) return;
+ try {
+ this.#reportWebSocketHandshake({
+ schemaVersion: 1,
+ path,
+ transport,
+ resource,
+ scopeQueryPresent: queryScopes.length > 0,
+ scopeQueryCount: queryScopes.length,
+ scopeEqualsActive,
+ activeBindingPresent: active !== null,
+ profileGenerationCurrent: activeGenerationCurrent,
+ originEqualsActive: target !== null && active !== null && target.origin === active.origin,
+ rendererBearerPresent: rendererAuthorizationPresent,
+ rendererCookiePresent,
+ outboundBearerPresent: headerValues(headers, 'authorization').length > 0,
+ bearerMainInjected: authorizationMainInjected,
+ accepted,
+ rejectionCategory,
+ });
+ } catch {
+ // Diagnostics cannot change the request authorization decision.
+ }
+ };
+ const reportCurrentUser = (
+ accepted: boolean,
+ rejectionCategory: DesktopCurrentUserProxyRejectionCategory,
+ authorizationMainInjected = false,
+ ): void => {
+ if (!isCurrentUserRequest) return;
+ try {
+ this.#reportCurrentUserValidation({
+ schemaVersion: 2,
+ correlation: 'current-scope-user-validation',
+ requestObserved: true,
+ method: 'get',
+ rendererScopeGeneration: rendererCurrentUserGeneration.generation,
+ scopeGenerationQueryCount: rendererCurrentUserGeneration.count,
+ scopeGenerationQueryValid: rendererCurrentUserGeneration.valid,
+ scopeHeaderCount: Math.min(scopeValues.length, 2) as 0 | 1 | 2,
+ activeBindingPresent: active !== null,
+ activeScopeGeneration: active?.profileGeneration ?? 0,
+ profileGenerationCurrent: activeGenerationCurrent,
+ scopeEqualsActive: scopeValues.length === 1 && active !== null
+ && scopeValues[0] === active.transportScope,
+ originEqualsActive: target !== null && active !== null && target.origin === active.origin,
+ rendererBearerPresent: rendererAuthorizationPresent,
+ rendererCookiePresent: rendererCookiePresent,
+ outboundBearerPresent: headerValues(headers, 'authorization').length > 0,
+ bearerMainInjected: authorizationMainInjected,
+ accepted,
+ rejectionCategory,
+ });
+ } catch {
+ // Diagnostics cannot change the request authorization decision.
+ }
+ };
+
removeHeader(headers, 'cookie');
if (!trustedMainRequest) removeHeader(headers, 'authorization');
- const target = requestOrigin(url);
+ if (/^(?:https?|wss?):/i.test(url)) {
+ const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:');
+ if (!canonicalProprHttpUrlOrigin(httpUrl)) {
+ reportHandshake(false, 'untrusted-http-origin');
+ return { cancel: true };
+ }
+ }
if (target && target.url.protocol === 'http:' && !normalizeApiBaseUrl(target.origin)) {
+ reportHandshake(false, 'untrusted-http-origin');
return { cancel: true };
}
if (trustedMainRequest) return { requestHeaders: headers };
const markedRestRequest = scopeValues.length > 0;
if (markedRestRequest && (scopeValues.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(scopeValues[0]))) {
+ reportCurrentUser(false, scopeValues.length !== 1 ? 'scope-duplicate' : 'scope-malformed');
return { cancel: true };
}
if (!trustedMainRequest && target
&& (target.pathname.startsWith('/api/desktop/pairings')
|| target.pathname.startsWith('/api/desktop/tokens'))) return { cancel: true };
- const active = this.#active;
- const activeIsCurrent = active !== null
- && this.#generation(active.profileId) === active.profileGeneration
- && this.#selectionGeneration === active.selectionGeneration;
+ const activeIsCurrent = activeGenerationCurrent;
const isApiRequest = target?.pathname.startsWith('/api/') === true;
- const isSocketUpgrade = target?.pathname === '/socket.io/'
- && target.url.searchParams.get('transport') === 'websocket'
- && (details.resourceType === 'webSocket'
- || headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket'));
+ const isSocketUpgrade = path === 'socket-io' && transport === 'websocket' && resource === 'websocket';
+
+ // All WebContents in the desktop share this Electron session. Only the
+ // live main renderer, identified by session-security from webContentsId,
+ // may use the active credential binding. Foreign isolated fixtures may
+ // load ordinary credentialless resources, but no marker they supply can
+ // authorize REST or Socket.IO transport or trigger bearer injection.
+ if (details.rendererOwned !== true) {
+ if (markedRestRequest || isHandshakeCandidate) return { cancel: true };
+ return { requestHeaders: headers };
+ }
+
+ if (isHandshakeCandidate && !isSocketUpgrade) {
+ const category = path !== 'socket-io'
+ ? 'wrong-path'
+ : transport !== 'websocket'
+ ? 'wrong-transport'
+ : 'wrong-resource-type';
+ reportHandshake(false, category);
+ return { cancel: true };
+ }
if (isSocketUpgrade && target) {
- const queryScopes = target.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY);
- if (queryScopes.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(queryScopes[0])
- || !activeIsCurrent || target.origin !== active.origin
- || queryScopes[0] !== active.transportScope) return { cancel: true };
+ if (queryScopes.length === 0) {
+ reportHandshake(false, 'scope-missing');
+ return { cancel: true };
+ }
+ if (queryScopes.length !== 1) {
+ reportHandshake(false, 'scope-duplicate');
+ return { cancel: true };
+ }
+ if (!TRANSPORT_SCOPE_PATTERN.test(queryScopes[0])) {
+ reportHandshake(false, 'scope-malformed');
+ return { cancel: true };
+ }
+ if (!active) {
+ reportHandshake(false, 'no-active-binding');
+ return { cancel: true };
+ }
+ if (!activeGenerationCurrent) {
+ reportHandshake(false, 'stale-generation');
+ return { cancel: true };
+ }
+ if (target.origin !== active.origin) {
+ reportHandshake(false, 'wrong-origin');
+ return { cancel: true };
+ }
+ if (!scopeEqualsActive) {
+ reportHandshake(false, 'stale-scope');
+ return { cancel: true };
+ }
headers.Authorization = `Bearer ${active.token}`;
+ reportHandshake(true, 'none', true);
return { requestHeaders: headers };
}
- if (!markedRestRequest) return { requestHeaders: headers };
- if (!target || !isApiRequest || !activeIsCurrent || target.origin !== active.origin
- || scopeValues[0] !== active.transportScope) return { cancel: true };
+ // Electron's Local Network Access permission can remain cached after the
+ // main renderer binding is discarded or revoked. Every main-renderer
+ // HTTP(S)/WS(S) request therefore needs a current binding and the exact
+ // active origin before even sanitized, unmarked traffic may leave it.
+ if (target) {
+ if (!active) {
+ reportCurrentUser(false, 'no-active-binding');
+ return { cancel: true };
+ }
+ if (!activeIsCurrent) {
+ reportCurrentUser(false, 'stale-generation');
+ return { cancel: true };
+ }
+ if (target.origin !== active.origin) {
+ reportCurrentUser(false, 'wrong-origin');
+ return { cancel: true };
+ }
+ }
+
+ if (!markedRestRequest) {
+ reportCurrentUser(false, 'scope-missing');
+ return { requestHeaders: headers };
+ }
+ // `target` traffic was required to prove these immediately above.
+ if (!active || !activeIsCurrent) return { cancel: true };
+ if (!target || !isApiRequest || target.origin !== active.origin) {
+ reportCurrentUser(false, 'wrong-origin');
+ return { cancel: true };
+ }
+ if (scopeValues[0] !== active.transportScope) {
+ reportCurrentUser(false, 'stale-scope');
+ return { cancel: true };
+ }
if (details.method?.toUpperCase() === 'OPTIONS') return { requestHeaders: headers };
headers.Authorization = `Bearer ${active.token}`;
+ reportCurrentUser(true, 'none', true);
return { requestHeaders: headers };
}
diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts
index 975bcb4d9..53718837a 100644
--- a/apps/desktop/src/ipc.ts
+++ b/apps/desktop/src/ipc.ts
@@ -15,7 +15,8 @@ interface RegisterIpcOptions {
profiles: ProfileStore;
credentials: DesktopCredentialService;
lifecycle: LocalLifecycleController;
- setup?: DesktopSetupController;
+ setup?: Pick;
logger: DesktopLogger;
desktopSession: Session;
devServerUrl: string | undefined;
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index 2bdb7f961..751473740 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -6,7 +6,11 @@ import type { Rectangle } from 'electron';
import { DESKTOP_RENDERER_ORIGIN } from '@propr/shared';
import type { SetupActions } from '@propr/local-setup';
import { DeepLinkDelivery } from './deep-link-delivery';
-import { DesktopCredentialService } from './credential-service';
+import {
+ DesktopCredentialService,
+ type DesktopCurrentUserProxyEvidence,
+ type DesktopWebSocketHandshakeEvidence,
+} from './credential-service';
import { createDesktopLocalHost } from './desktop-host';
import { registerIpcHandlers } from './ipc';
import { LocalLifecycleController } from './lifecycle';
@@ -34,7 +38,23 @@ import {
import { DESKTOP_PROTOCOL, IPC_CHANNELS } from './shared/contract';
import { checkForSignedUpdates } from './signed-updates';
import { authorizePackagedSmokeTest } from './smoke-test-authorization';
-import { createPackagedSmokeEvidenceSink } from './smoke-test-evidence';
+import {
+ createPackagedSmokeEvidenceSink,
+ PACKAGED_SMOKE_CURRENT_USER_EVIDENCE_LIMIT,
+ PACKAGED_SMOKE_HANDSHAKE_EVIDENCE_LIMIT,
+ type PackagedSmokeCurrentUserEvidenceBuffer,
+ type PackagedSmokeHandshakeEvidenceBuffer,
+} from './smoke-test-evidence';
+import {
+ authorizePackagedAcceptanceTest,
+ packagedAcceptancePairingTiming,
+} from './acceptance-test-authorization';
+import { registerPackagedAcceptanceZoomIpc } from './acceptance-zoom';
+import { AcceptanceSetupController } from './acceptance-setup-controller';
+import {
+ configureDesktopSessionSecurity,
+ type DesktopNetworkPermissionEvidence,
+} from './session-security';
import {
createBrowserWindowOptions,
MINIMUM_BROWSER_WINDOW_SIZE,
@@ -51,8 +71,16 @@ const PACKAGED_REDUCED_NATIVE_WINDOW_READY_EVENT = 'desktop.native.reduced_windo
const packagedRendererRoot = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`);
const packagedRendererUrl = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`;
let packagedSmokeUserDataDirectory: string | null = null;
+let packagedAcceptanceUserDataDirectory: string | null = null;
let packagedSmokeEvidence: ReturnType = null;
try {
+ packagedAcceptanceUserDataDirectory = authorizePackagedAcceptanceTest({
+ argv: process.argv,
+ defaultUserDataDirectory: join(app.getPath('appData'), app.name),
+ environmentTriggered: process.env.PROPR_DESKTOP_ACCEPTANCE_TEST === '1',
+ isPackaged: app.isPackaged,
+ platform: process.platform,
+ });
packagedSmokeUserDataDirectory = authorizePackagedSmokeTest({
argv: process.argv,
defaultUserDataDirectory: join(app.getPath('appData'), app.name),
@@ -69,10 +97,28 @@ try {
packagedSmokeEvidence = createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory);
packagedSmokeEvidence?.write('desktop.smoke.authorized');
}
+ if (packagedAcceptanceUserDataDirectory) {
+ if (packagedSmokeUserDataDirectory) throw new Error('Desktop test modes are mutually exclusive');
+ const stats = lstatSync(packagedAcceptanceUserDataDirectory);
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
+ throw new Error('Packaged desktop acceptance --user-data-dir must be an existing non-link directory');
+ }
+ app.setPath('userData', packagedAcceptanceUserDataDirectory);
+ }
} catch {
process.exit(1);
}
const packagedSmokeTest = packagedSmokeUserDataDirectory !== null;
+const packagedAcceptanceTest = packagedAcceptanceUserDataDirectory !== null;
+const packagedSmokeHandshakeEvidence: PackagedSmokeHandshakeEvidenceBuffer = {
+ records: [],
+ overflowed: false,
+};
+const packagedSmokeCurrentUserEvidence: PackagedSmokeCurrentUserEvidenceBuffer = {
+ records: [],
+ overflowed: false,
+};
+const acceptancePairingTiming = packagedAcceptancePairingTiming(packagedAcceptanceUserDataDirectory);
const transportSmoke = packagedTransportSmoke(packagedSmokeTest);
const inertSetupActions = new Proxy({} as SetupActions, {
get() {
@@ -87,7 +133,7 @@ const deepLinkDelivery = new DeepLinkDelivery(
);
let logger: DesktopLogger | null = null;
let shutdownStarted = false;
-let setupController: DesktopSetupController | null = null;
+let setupController: DesktopSetupController | AcceptanceSetupController | null = null;
const operationCoordinator = new DesktopOperationCoordinator();
if (process.platform === 'win32') {
@@ -129,41 +175,6 @@ const deliverDeepLink = (value: string): void => {
deepLinkDelivery.deliver(value);
};
-const configureSessionSecurity = (credentials: DesktopCredentialService): {
- close(): void;
- dispose(): void;
-} => {
- const desktopSession = session.defaultSession;
- desktopSession.setPermissionCheckHandler(() => false);
- desktopSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
- desktopSession.webRequest.onBeforeSendHeaders((details, callback) => {
- callback(credentials.prepareRequest(details.url, details.requestHeaders, {
- method: details.method,
- resourceType: details.resourceType,
- }));
- });
- desktopSession.webRequest.onHeadersReceived((details, callback) => {
- callback({
- responseHeaders: {
- ...credentials.sanitizeResponseHeaders(details.url, details.responseHeaders ?? {}),
- 'Content-Security-Policy': [rendererContentSecurityPolicy(!app.isPackaged)],
- },
- });
- });
- return {
- close() {
- desktopSession.webRequest.onBeforeSendHeaders((_details, callback) => callback({ cancel: true }));
- desktopSession.webRequest.onHeadersReceived((_details, callback) => callback({ cancel: true }));
- },
- dispose() {
- desktopSession.setPermissionCheckHandler(null);
- desktopSession.setPermissionRequestHandler(null);
- desktopSession.webRequest.onBeforeSendHeaders(null);
- desktopSession.webRequest.onHeadersReceived(null);
- },
- };
-};
-
const configurePackagedRendererProtocol = (): (() => void) => {
protocol.handle(PACKAGED_RENDERER_SCHEME, request => {
const requestUrl = new URL(request.url);
@@ -291,6 +302,12 @@ const createMainWindow = async (smoke: PackagedTransportSmoke | null = null): Pr
const window = new BrowserWindow(
createBrowserWindowOptions(join(__dirname, 'preload.cjs'), !app.isPackaged, workArea),
);
+ const disposeAcceptanceZoomIpc = registerPackagedAcceptanceZoomIpc({
+ authorized: packagedAcceptanceTest,
+ ipcMain,
+ webContents: window.webContents,
+ });
+ window.once('closed', disposeAcceptanceZoomIpc);
const readyToShow = new Promise(resolveReady => window.once('ready-to-show', resolveReady));
window.webContents.setWindowOpenHandler(({ url }) => {
@@ -451,13 +468,52 @@ if (!hasSingleInstanceLock) {
const credentials = new DesktopCredentialService({
profiles,
fetch: session.defaultSession.fetch.bind(session.defaultSession) as typeof globalThis.fetch,
- openExternal: async url => { await shell.openExternal(url); },
+ openExternal: async url => {
+ if (packagedAcceptanceTest) return;
+ await shell.openExternal(url);
+ },
clientName: `ProPR Desktop (${process.platform})`,
reportRevocationFailure: diagnostic => {
log('warn', 'desktop.credential_revocation.retry_pending', diagnostic);
},
+ ...(packagedAcceptanceTest ? {
+ reportWebSocketHandshake: (evidence: DesktopWebSocketHandshakeEvidence) => {
+ log('info', 'desktop.acceptance.websocket_handshake', { ...evidence });
+ },
+ reportCurrentUserValidation: (evidence: DesktopCurrentUserProxyEvidence) => {
+ log('info', 'desktop.acceptance.current_user_proxy', { ...evidence });
+ },
+ } : {}),
+ ...(packagedSmokeTest ? {
+ reportWebSocketHandshake: (evidence: DesktopWebSocketHandshakeEvidence) => {
+ if (packagedSmokeHandshakeEvidence.records.length >= PACKAGED_SMOKE_HANDSHAKE_EVIDENCE_LIMIT) {
+ packagedSmokeHandshakeEvidence.overflowed = true;
+ return;
+ }
+ packagedSmokeHandshakeEvidence.records.push(evidence);
+ },
+ reportCurrentUserValidation: (evidence: DesktopCurrentUserProxyEvidence) => {
+ if (packagedSmokeCurrentUserEvidence.records.length >= PACKAGED_SMOKE_CURRENT_USER_EVIDENCE_LIMIT) {
+ packagedSmokeCurrentUserEvidence.overflowed = true;
+ return;
+ }
+ packagedSmokeCurrentUserEvidence.records.push(evidence);
+ },
+ } : {}),
+ ...(acceptancePairingTiming ? { pairingTiming: acceptancePairingTiming } : {}),
+ });
+ const sessionSecurity = configureDesktopSessionSecurity({
+ contentSecurityPolicy: () => rendererContentSecurityPolicy(!app.isPackaged),
+ credentials,
+ desktopSession: session.defaultSession,
+ getMainRenderer: () => mainWindow?.webContents ?? null,
+ isTrustedRendererUrl: value => isTrustedRendererUrl(value, devServerUrl, packagedRendererUrl),
+ ...(packagedAcceptanceTest ? {
+ reportNetworkPermissionDecision: (evidence: DesktopNetworkPermissionEvidence) => {
+ log('info', 'desktop.acceptance.network_permission', { ...evidence });
+ },
+ } : {}),
});
- const sessionSecurity = configureSessionSecurity(credentials);
const credentialInitialization = await credentials.initialize();
if (credentialInitialization.status === 'degraded') {
log('warn', 'desktop.credential_revocation.startup_degraded', {
@@ -465,14 +521,24 @@ if (!hasSingleInstanceLock) {
});
}
const defaultRootDir = join(app.getPath('userData'), 'desktop', 'local-stack');
- const localHost = process.platform === 'linux' && !packagedSmokeTest
+ const localHost = process.platform === 'linux' && !packagedSmokeTest && !packagedAcceptanceTest
? await createDesktopLocalHost(app.isPackaged ? process.resourcesPath : undefined, defaultRootDir, app.getPath('userData'))
: null;
const lifecycle = new LocalLifecycleController(
localHost?.lifecycle,
(event, fields) => log('error', event, fields),
);
- setupController = new DesktopSetupController({
+ const emitSetupSnapshot = (snapshot: import('./shared/contract').DesktopSetupSnapshot) => {
+ const target = mainWindow;
+ if (target && !target.isDestroyed()) target.webContents.send(IPC_CHANNELS.setupProgress, snapshot);
+ };
+ setupController = packagedAcceptanceTest
+ ? new AcceptanceSetupController({
+ rootDir: defaultRootDir,
+ scenario: process.env.PROPR_DESKTOP_ACCEPTANCE_SCENARIO,
+ emit: emitSetupSnapshot,
+ })
+ : new DesktopSetupController({
actions: localHost?.actions ?? inertSetupActions,
platform: packagedSmokeTest ? 'darwin' : process.platform,
appDataDir: app.getPath('userData'),
@@ -504,10 +570,7 @@ if (!hasSingleInstanceLock) {
lastConnectedAt: saved.updatedAt,
};
},
- emit(snapshot) {
- const target = mainWindow;
- if (target && !target.isDestroyed()) target.webContents.send(IPC_CHANNELS.setupProgress, snapshot);
- },
+ emit: emitSetupSnapshot,
diagnose(event, fields) { log('error', event, fields); },
});
const registeredIpc = registerIpcHandlers({
@@ -522,7 +585,10 @@ if (!hasSingleInstanceLock) {
devServerUrl,
packagedRendererUrl,
coordinator: operationCoordinator,
- openExternal: async url => { await shell.openExternal(url); },
+ openExternal: async url => {
+ if (packagedAcceptanceTest) return;
+ await shell.openExternal(url);
+ },
});
const shutdownLifecycle = transportSmoke?.shutdownMode === 'forced-timeout'
@@ -552,6 +618,8 @@ if (!hasSingleInstanceLock) {
credentials,
desktopSession: session.defaultSession,
smoke: transportSmoke,
+ handshakeEvidence: packagedSmokeHandshakeEvidence,
+ currentUserEvidence: packagedSmokeCurrentUserEvidence,
log: (event, fields) => log('info', event, fields),
});
}
@@ -571,7 +639,7 @@ if (!hasSingleInstanceLock) {
windowsSignerPins: __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__,
}
: undefined;
- if (app.isPackaged && process.platform !== 'win32' && updateConfig && !packagedSmokeTest) {
+ if (app.isPackaged && process.platform !== 'win32' && updateConfig && !packagedSmokeTest && !packagedAcceptanceTest) {
const runUpdateCheck = () => {
void checkForSignedUpdates({
config: updateConfig,
diff --git a/apps/desktop/src/packaged-transport-smoke.test.ts b/apps/desktop/src/packaged-transport-smoke.test.ts
new file mode 100644
index 000000000..aa216e59b
--- /dev/null
+++ b/apps/desktop/src/packaged-transport-smoke.test.ts
@@ -0,0 +1,111 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { describe, it } from 'node:test';
+
+describe('packaged transport smoke sequencing', () => {
+ it('proves same-host cookie cleanup before credentialless reseed, rollback, and all-origin cleanup', () => {
+ const smoke = readFileSync(
+ fileURLToPath(new URL('./packaged-transport-smoke.ts', import.meta.url)),
+ 'utf8',
+ );
+ const perStorageTypeModel = smoke.indexOf(
+ "type OriginStorageExpectation = Readonly>;",
+ );
+ const perOriginStateSelection = smoke.indexOf(
+ 'const expectedState = expected[item.name];',
+ perStorageTypeModel,
+ );
+ const cookieExpectation = smoke.indexOf(
+ "const cookieExpectedPresent = expectedState.cookie === 'present';",
+ perOriginStateSelection,
+ );
+ const exactSessionCookieInspection = smoke.indexOf(
+ "cookie.name === 'packaged-smoke-cookie' && cookie.value === 'present'",
+ cookieExpectation,
+ );
+ const originScopedTypeExpectation = smoke.indexOf(
+ "rendererState[storageType] === (expectedState[storageType] === 'present')",
+ exactSessionCookieInspection,
+ );
+ const allStoragePresentDefinition = smoke.indexOf(` const allStoragePresent: OriginStorageExpectation = {
+ cookie: 'present',
+ localStorage: 'present',
+ indexedDB: 'present',
+ cacheStorage: 'present',
+ serviceWorker: 'present',
+ };`);
+ const allStorageAbsentDefinition = smoke.indexOf(` const allStorageAbsent: OriginStorageExpectation = {
+ cookie: 'absent',
+ localStorage: 'absent',
+ indexedDB: 'absent',
+ cacheStorage: 'absent',
+ serviceWorker: 'absent',
+ };`);
+ const activationCleanupSplitDefinition = smoke.indexOf(` const activationCleanupSplit: StorageExpectation = {
+ first: allStorageAbsent,
+ second: {
+ cookie: 'absent',
+ localStorage: 'present',
+ indexedDB: 'present',
+ cacheStorage: 'present',
+ serviceWorker: 'present',
+ },
+ };`);
+ const initialSeed = smoke.indexOf(' await seedStorage();');
+ const preactivationPresent = smoke.indexOf(
+ 'if (!await storageState(bothOriginsPresent))',
+ initialSeed,
+ );
+ const firstActivation = smoke.indexOf('const first = await smoke.activate(', preactivationPresent);
+ const staleSocketEvidence = smoke.indexOf(
+ "log('desktop.transport_smoke.stale_socket_boundary'",
+ firstActivation,
+ );
+ const activationCleanupSplit = smoke.indexOf(
+ 'if (!await storageState(activationCleanupSplit))',
+ staleSocketEvidence,
+ );
+ const credentiallessReseed = smoke.indexOf(' await seedStorage();', activationCleanupSplit);
+ const reseededPresent = smoke.indexOf(
+ 'if (!await storageState(bothOriginsPresent))',
+ credentiallessReseed,
+ );
+ const rollback = smoke.indexOf(
+ 'const rollback = await profiles.readProfileCredential(profileId);',
+ reseededPresent,
+ );
+ const rollbackPresent = smoke.indexOf(
+ '|| !await storageState(bothOriginsPresent)',
+ rollback,
+ );
+ const allOriginCleanup = smoke.indexOf('await clearDesktopInstanceCookies(', rollbackPresent);
+ const absentBeforeCommit = smoke.indexOf(
+ 'precommitStorageCleared = await storageState(bothOriginsAbsent);',
+ allOriginCleanup,
+ );
+ const absentAfterCommit = smoke.indexOf(
+ '!await storageState(bothOriginsAbsent)',
+ absentBeforeCommit,
+ );
+
+ assert.notEqual(perStorageTypeModel, -1);
+ assert.notEqual(allStoragePresentDefinition, -1);
+ assert.notEqual(allStorageAbsentDefinition, -1);
+ assert.notEqual(activationCleanupSplitDefinition, -1);
+ assert.ok(perStorageTypeModel < perOriginStateSelection);
+ assert.ok(perOriginStateSelection < cookieExpectation);
+ assert.ok(cookieExpectation < exactSessionCookieInspection);
+ assert.ok(exactSessionCookieInspection < originScopedTypeExpectation);
+ assert.ok(originScopedTypeExpectation < allStoragePresentDefinition);
+ assert.ok(allStoragePresentDefinition < allStorageAbsentDefinition);
+ assert.ok(allStorageAbsentDefinition < activationCleanupSplitDefinition);
+ assert.ok(activationCleanupSplitDefinition < initialSeed);
+ assert.ok(initialSeed < preactivationPresent && preactivationPresent < firstActivation);
+ assert.ok(firstActivation < staleSocketEvidence && staleSocketEvidence < activationCleanupSplit);
+ assert.ok(activationCleanupSplit < credentiallessReseed && credentiallessReseed < reseededPresent);
+ assert.ok(reseededPresent < rollback && rollback < rollbackPresent);
+ assert.ok(rollbackPresent < allOriginCleanup);
+ assert.ok(allOriginCleanup < absentBeforeCommit && absentBeforeCommit < absentAfterCommit);
+ });
+});
diff --git a/apps/desktop/src/packaged-transport-smoke.ts b/apps/desktop/src/packaged-transport-smoke.ts
index 9b7bb7072..e1b3c6291 100644
--- a/apps/desktop/src/packaged-transport-smoke.ts
+++ b/apps/desktop/src/packaged-transport-smoke.ts
@@ -5,6 +5,12 @@ import type { DesktopCredentialService } from './credential-service';
import { clearDesktopInstanceCookies } from './desktop-session';
import type { ProfileStore } from './profile-store';
import { normalizeApiBaseUrl } from './security';
+import {
+ validatePackagedCurrentUserBoundaryEvidence,
+ validatePackagedStaleSocketBoundaryEvidence,
+ type PackagedSmokeCurrentUserEvidenceBuffer,
+ type PackagedSmokeHandshakeEvidenceBuffer,
+} from './smoke-test-evidence';
export interface PackagedTransportSmoke {
firstOrigin: string;
@@ -40,6 +46,8 @@ interface RunPackagedTransportSmokeOptions {
credentials: DesktopCredentialService;
desktopSession: Session;
smoke: PackagedTransportSmoke;
+ handshakeEvidence: PackagedSmokeHandshakeEvidenceBuffer;
+ currentUserEvidence: PackagedSmokeCurrentUserEvidenceBuffer;
log(event: string, fields: Record): void;
}
@@ -50,6 +58,8 @@ export const runPackagedTransportSmoke = async ({
credentials,
desktopSession,
smoke,
+ handshakeEvidence,
+ currentUserEvidence,
log,
}: RunPackagedTransportSmokeOptions): Promise => {
const profileId = 'packaged-transport-smoke';
@@ -59,6 +69,15 @@ export const runPackagedTransportSmoke = async ({
if (!security.available || security.backend === 'basic_text') {
throw new Error('Packaged transport smoke requires the production OS credential backend');
}
+ const waitForCurrentUserEvidence = async (expected: number): Promise => {
+ const deadline = Date.now() + 5_000;
+ while (currentUserEvidence.records.length < expected && Date.now() < deadline) {
+ await new Promise(resolveWait => setTimeout(resolveWait, 20));
+ }
+ if (currentUserEvidence.records.length !== expected || currentUserEvidence.overflowed) {
+ throw new Error('Packaged current-user main-boundary evidence count was invalid');
+ }
+ };
const profileA = await profiles.save({
id: profileId, label: 'Packaged transport A', apiBaseUrl: smoke.firstOrigin,
});
@@ -67,17 +86,19 @@ export const runPackagedTransportSmoke = async ({
});
if (!storedA.stored) throw new Error('Production credential encryption was unavailable');
- const storageWindows = await Promise.all([smoke.firstOrigin, smoke.secondOrigin].map(async origin => {
+ const storageWindows = await Promise.all([
+ { name: 'first' as const, origin: smoke.firstOrigin },
+ { name: 'second' as const, origin: smoke.secondOrigin },
+ ].map(async fixture => {
const storageWindow = new BrowserWindow({
show: false,
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true },
});
- await storageWindow.loadURL(`${origin}/smoke-storage`);
- return { origin, window: storageWindow };
+ await storageWindow.loadURL(`${fixture.origin}/smoke-storage`);
+ return { ...fixture, window: storageWindow };
}));
const seedStorage = async (): Promise => {
await Promise.all(storageWindows.map(item => item.window.webContents.executeJavaScript(`(async () => {
- document.cookie = 'packaged-smoke-cookie=present; SameSite=Lax';
localStorage.setItem('packaged-smoke-local', 'present');
await new Promise((resolve, reject) => {
const request = indexedDB.open('packaged-smoke-indexeddb', 1);
@@ -87,12 +108,20 @@ export const runPackagedTransportSmoke = async ({
});
const cache = await caches.open('packaged-smoke-cache');
await cache.put('/packaged-smoke-cache-entry', new Response('present'));
- await navigator.serviceWorker.register('/smoke-sw.js');
- await navigator.serviceWorker.ready;
+ const registrations = await navigator.serviceWorker.getRegistrations();
+ if (!registrations.some(registration => registration.scope.startsWith(location.origin))) {
+ await navigator.serviceWorker.register('/smoke-sw.js');
+ await navigator.serviceWorker.ready;
+ }
+ document.cookie = 'packaged-smoke-cookie=present; SameSite=Lax';
return true;
})()`)));
};
- const storageState = async (expected: 'present' | 'absent'): Promise => {
+ type StoragePresence = 'present' | 'absent';
+ type StorageType = 'cookie' | 'localStorage' | 'indexedDB' | 'cacheStorage' | 'serviceWorker';
+ type OriginStorageExpectation = Readonly>;
+ type StorageExpectation = Readonly>;
+ const storageState = async (expected: StorageExpectation): Promise => {
const states = await Promise.all(storageWindows.map(async item => {
const rendererState = await item.window.webContents.executeJavaScript(`(async () => ({
cookie: document.cookie.includes('packaged-smoke-cookie=present'),
@@ -102,12 +131,62 @@ export const runPackagedTransportSmoke = async ({
serviceWorker: (await navigator.serviceWorker.getRegistrations()).some(registration => registration.scope.startsWith(location.origin)),
}))()`);
const cookies = await desktopSession.cookies.get({ url: item.origin });
- return { ...rendererState, cookie: rendererState.cookie || cookies.length > 0 } as Record;
+ const expectedState = expected[item.name];
+ const cookieExpectedPresent = expectedState.cookie === 'present';
+ const cookieMatches = cookieExpectedPresent
+ ? rendererState.cookie === true
+ && cookies.some(cookie => cookie.name === 'packaged-smoke-cookie' && cookie.value === 'present')
+ : rendererState.cookie === false && cookies.length === 0;
+ return cookieMatches
+ && (['localStorage', 'indexedDB', 'cacheStorage', 'serviceWorker'] as const)
+ .every(storageType => rendererState[storageType] === (expectedState[storageType] === 'present'));
}));
- return states.every(state => Object.values(state).every(value => value === (expected === 'present')));
+ return states.every(Boolean);
+ };
+ const allStoragePresent: OriginStorageExpectation = {
+ cookie: 'present',
+ localStorage: 'present',
+ indexedDB: 'present',
+ cacheStorage: 'present',
+ serviceWorker: 'present',
+ };
+ const allStorageAbsent: OriginStorageExpectation = {
+ cookie: 'absent',
+ localStorage: 'absent',
+ indexedDB: 'absent',
+ cacheStorage: 'absent',
+ serviceWorker: 'absent',
+ };
+ const bothOriginsPresent: StorageExpectation = {
+ first: allStoragePresent,
+ second: allStoragePresent,
+ };
+ // Chromium cookies are shared by host across these fixture ports; the
+ // remaining stores are scoped to each complete origin.
+ const activationCleanupSplit: StorageExpectation = {
+ first: allStorageAbsent,
+ second: {
+ cookie: 'absent',
+ localStorage: 'present',
+ indexedDB: 'present',
+ cacheStorage: 'present',
+ serviceWorker: 'present',
+ },
+ };
+ const bothOriginsAbsent: StorageExpectation = {
+ first: allStorageAbsent,
+ second: allStorageAbsent,
};
try {
+ // Both fixture origins must be populated while no renderer credential
+ // binding is active. Once activation publishes a binding, production
+ // correctly restricts renderer network traffic to that exact origin.
+ await seedStorage();
+ if (!await storageState(bothOriginsPresent)) {
+ throw new Error('Packaged preactivation origin storage fixture was incomplete');
+ }
+
await window.webContents.executeJavaScript(`new Promise((resolve, reject) => {
const started = Date.now();
const poll = () => {
@@ -134,10 +213,6 @@ export const runPackagedTransportSmoke = async ({
});
staleRestRejected = !response.ok;
} catch { staleRestRejected = true; }
- await smoke.expectSocketRejected(socketId);
- await smoke.rest();
- localStorage.setItem('packaged-smoke-local', 'non-secret sentinel');
- sessionStorage.setItem('packaged-smoke-session', 'non-secret sentinel');
return { first, rotated, socketId, staleRestRejected, rendererOrigin: location.origin };
})()`);
if (first?.rendererOrigin !== DESKTOP_RENDERER_ORIGIN || first?.first?.profileId !== profileId
@@ -146,8 +221,37 @@ export const runPackagedTransportSmoke = async ({
|| first?.staleRestRejected !== true) {
throw new Error('Packaged renderer protocol or first transport proof failed');
}
+ await waitForCurrentUserEvidence(2);
+ const staleAttemptEvidenceStart = handshakeEvidence.records.length;
+ const staleAttemptResult = await window.webContents.executeJavaScript(`
+ window.__proprPackagedTransportSmoke.expectSocketRejected(${JSON.stringify(first.socketId)})
+ `);
+ const staleAttemptEvidence = handshakeEvidence.records.slice(staleAttemptEvidenceStart);
+ const staleBoundarySummary = validatePackagedStaleSocketBoundaryEvidence(
+ staleAttemptEvidence,
+ handshakeEvidence.overflowed,
+ );
+ if (staleAttemptResult?.transportRejected !== true
+ || staleAttemptResult?.freshManagerConnected !== true) {
+ throw new Error('Packaged stale Socket.IO renderer-boundary evidence failed');
+ }
+ log('desktop.transport_smoke.stale_socket_boundary', { ...staleBoundarySummary });
+ await window.webContents.executeJavaScript(`(async () => {
+ const smoke = window.__proprPackagedTransportSmoke;
+ await smoke.rest();
+ localStorage.setItem('packaged-smoke-local', 'non-secret sentinel');
+ sessionStorage.setItem('packaged-smoke-session', 'non-secret sentinel');
+ })()`);
+ if (!await storageState(activationCleanupSplit)) {
+ throw new Error('Packaged activation did not clear only the activated origin storage');
+ }
+
+ // These isolated fixture WebContents are not the trusted main renderer, so
+ // they can reseed both foreign origins without receiving renderer credentials.
await seedStorage();
- if (!await storageState('present')) throw new Error('Packaged origin storage fixture was incomplete');
+ if (!await storageState(bothOriginsPresent)) {
+ throw new Error('Packaged credentialless origin storage reseed was incomplete');
+ }
let cleanupFailed = false;
try {
@@ -160,7 +264,7 @@ export const runPackagedTransportSmoke = async ({
const rollback = await profiles.readProfileCredential(profileId);
if (!cleanupFailed || rollback.profile?.apiBaseUrl !== smoke.firstOrigin
|| rollback.credential?.origin !== smoke.firstOrigin || rollback.credential.token !== tokenA
- || !await storageState('present')) {
+ || !await storageState(bothOriginsPresent)) {
throw new Error('Origin cleanup failure did not preserve complete durable A');
}
let precommitStorageCleared = false;
@@ -168,10 +272,10 @@ export const runPackagedTransportSmoke = async ({
id: profileId, label: 'Packaged transport B', apiBaseUrl: smoke.secondOrigin,
}, async (previousOrigin, nextOrigin) => {
await clearDesktopInstanceCookies(desktopSession, [previousOrigin, nextOrigin]);
- precommitStorageCleared = await storageState('absent');
+ precommitStorageCleared = await storageState(bothOriginsAbsent);
if (!precommitStorageCleared) throw new Error('Complete origin storage was not cleared before commit');
});
- if (!precommitStorageCleared || !await storageState('absent')) {
+ if (!precommitStorageCleared || !await storageState(bothOriginsAbsent)) {
throw new Error('Same-ID URL edit did not clear both complete Electron origin stores');
}
const storedB = await profiles.writeCredential({
@@ -210,6 +314,13 @@ export const runPackagedTransportSmoke = async ({
|| second?.rendererPersistenceContainsSecret !== false || secretInMainMetadata) {
throw new Error('Packaged replacement scope or secret-custody proof failed');
}
+ await waitForCurrentUserEvidence(3);
+ log('desktop.renderer.transport_smoke.current_user', {
+ ...validatePackagedCurrentUserBoundaryEvidence(
+ currentUserEvidence.records,
+ currentUserEvidence.overflowed,
+ ),
+ });
log('desktop.renderer.transport_smoke.ready', {
customProtocol: true,
restBearer: true,
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
index 60afc251d..e209c952d 100644
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -1,4 +1,5 @@
-import { contextBridge, ipcRenderer } from 'electron';
+import { contextBridge, ipcRenderer, webFrame } from 'electron';
+import { createPackagedAcceptanceZoomBridge } from './acceptance-zoom';
import { createDesktopBridge, createDesktopRendererBridge } from './preload-bridge';
const desktopBridge = createDesktopBridge(ipcRenderer);
@@ -7,3 +8,5 @@ contextBridge.exposeInMainWorld(
'__PROPR_DESKTOP__',
createDesktopRendererBridge(ipcRenderer, process.platform, undefined, desktopBridge.app.onDeepLink),
);
+const acceptanceZoom = createPackagedAcceptanceZoomBridge(ipcRenderer, webFrame);
+if (acceptanceZoom) contextBridge.exposeInMainWorld('__PROPR_PACKAGED_ACCEPTANCE__', acceptanceZoom);
diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts
index c59929645..cb06fff79 100644
--- a/apps/desktop/src/release-workflow.test.ts
+++ b/apps/desktop/src/release-workflow.test.ts
@@ -232,6 +232,33 @@ describe('desktop trusted release workflow', () => {
assert.equal(normalizedFixture, workflow);
});
+ test('gives only the required stock-Chromium axe predecessor the real-browser opt-in', () => {
+ const predecessor = job('renderer-axe-boundary', 'package');
+ const packages = job('package', 'finalize');
+ assert.match(
+ predecessor,
+ /name: Prove stock Chromium existing-target axe boundary[\s\S]*- name: Run focused stock Chromium existing-target axe regressions\n\s+# This required predecessor step exclusively owns the real-browser opt-in\.\n\s+env:\n\s+PROPR_DESKTOP_REAL_CHROMIUM_BOUNDARY: '1'\n\s+run: node --test --test-name-pattern='packaged acceptance renderer variants'/,
+ );
+ assert.equal(workflow.match(/^\s+PROPR_DESKTOP_REAL_CHROMIUM_BOUNDARY:/gm)?.length, 1);
+ assert.match(packages, /needs: \[validation-version, renderer-axe-boundary\]/);
+ assert.equal(packages.match(platformArchitecturePattern)?.length, 6);
+ assert.match(packages, /npm run desktop:test/);
+ assert.ok(!packages.includes('PROPR_DESKTOP_REAL_CHROMIUM_BOUNDARY'));
+ });
+
+ test('runs and uploads fail-closed visual acceptance only from Linux x64 packages', () => {
+ for (const section of [job('package', 'finalize'), job('release-package', 'release-finalize')]) {
+ assert.match(section, /if: matrix\.platform == 'linux' && matrix\.arch == 'x64'[\s\S]*npm run desktop:acceptance/);
+ assert.match(section, /npm run desktop:acceptance:verify/);
+ assert.match(section, /if-no-files-found: error/);
+ assert.match(section, /path: desktop-acceptance-artifacts/);
+ assert.match(section, /dbus-run-session[\s\S]*gnome-keyring-daemon[\s\S]*xvfb-run/);
+ }
+ assert.equal(workflow.match(/npm run desktop:acceptance$/gm)?.length, 2);
+ assert.equal(workflow.match(/npm run desktop:acceptance:verify/g)?.length, 2);
+ assert.equal(workflow.match(/path: desktop-acceptance-artifacts/g)?.length, 2);
+ });
+
test('runs the native DMG layout suite on both macOS architectures', () => {
for (const [jobName, section] of [
['unsigned validation', job('package', 'finalize')],
diff --git a/apps/desktop/src/security.test.ts b/apps/desktop/src/security.test.ts
index 437a8ab41..21392a7f7 100644
--- a/apps/desktop/src/security.test.ts
+++ b/apps/desktop/src/security.test.ts
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
import { describe, it } from 'node:test';
+import { fileURLToPath } from 'node:url';
import {
deepLinkFromArguments,
applyDevelopmentRendererCsp,
@@ -14,6 +16,13 @@ import {
validatedDevServerUrl,
} from './security';
+const directiveSources = (policy: string, name: string): string[] => {
+ const directives = policy.split(';').map(directive => directive.trim());
+ const matching = directives.filter(directive => directive.split(/\s+/, 1)[0] === name);
+ assert.equal(matching.length, 1, `expected exactly one ${name} directive`);
+ return matching[0].split(/\s+/).slice(1);
+};
+
describe('desktop URL security', () => {
it('only accepts HTTPS and loopback HTTP API endpoints', () => {
assert.equal(normalizeApiBaseUrl('https://propr.example.com///'), null);
@@ -158,6 +167,31 @@ describe('desktop URL security', () => {
assert.match(policy, /connect-src 'self' https: http: ws: wss:/);
});
+ it('keeps the renderer meta and response-header connect sources in exact scheme parity', () => {
+ const rendererHtml = readFileSync(
+ fileURLToPath(new URL('../renderer.html', import.meta.url)),
+ 'utf8',
+ );
+ const metaPolicies = [...rendererHtml.matchAll(
+ //g,
+ )];
+ assert.equal(metaPolicies.length, 1);
+
+ const expectedSources = ["'self'", 'https:', 'http:', 'ws:', 'wss:'];
+ const headerSources = directiveSources(rendererContentSecurityPolicy(), 'connect-src');
+ const metaSources = directiveSources(metaPolicies[0][1], 'connect-src');
+ assert.deepEqual(headerSources, expectedSources);
+ assert.deepEqual(metaSources, headerSources);
+
+ for (const target of [
+ 'http://127.0.0.2:41731/api/auth/user',
+ 'http://192.168.1.10:41731/api/auth/user',
+ 'ws://10.0.0.8:41731/socket.io/',
+ ]) {
+ assert.equal(metaSources.includes(new URL(target).protocol), true, target);
+ }
+ });
+
it('relaxes inline scripts only while Vite serves the development renderer', () => {
const packagedPolicy = rendererContentSecurityPolicy();
const source = ``;
diff --git a/apps/desktop/src/session-security.test.ts b/apps/desktop/src/session-security.test.ts
new file mode 100644
index 000000000..a4e4ae365
--- /dev/null
+++ b/apps/desktop/src/session-security.test.ts
@@ -0,0 +1,401 @@
+import assert from 'node:assert/strict';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { describe, it } from 'node:test';
+import type { Session, WebContents } from 'electron';
+import {
+ DESKTOP_RENDERER_ORIGIN,
+ DESKTOP_TRANSPORT_SCOPE_HEADER,
+ PROPR_API_COMPATIBILITY,
+ PROPR_UI_COMPATIBILITY,
+} from '@propr/shared';
+import {
+ DesktopCredentialService,
+ type DesktopCurrentUserProxyEvidence,
+} from './credential-service';
+import { ProfileStore, type EncryptionProvider } from './profile-store';
+import {
+ configureDesktopSessionSecurity,
+ desktopNetworkPermissionAllowed,
+ type DesktopNetworkPermissionEvidence,
+} from './session-security';
+
+const RENDERER_URL = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`;
+// Acceptance uses an address distinct from the standard smoke's 127.0.0.1.
+const REACT_FIXTURE_ORIGIN = 'http://127.0.0.2:41731';
+const TOKEN = `propr_it_${'T'.repeat(43)}`;
+
+const encryption: EncryptionProvider = {
+ isEncryptionAvailable: () => true,
+ backend: () => 'keychain',
+ encrypt: value => Buffer.from(value, 'utf8'),
+ decrypt: value => value.toString('utf8'),
+};
+
+describe('production desktop session security', () => {
+ it('denies every permission except active trusted-renderer local network access', () => {
+ const accepted = {
+ activeBindingCurrent: true,
+ decision: 'check' as const,
+ isMainFrame: true,
+ mainWindowPresent: true,
+ permission: 'loopback-network',
+ rendererDocumentUrlTrusted: true,
+ requestingOriginAuthorityEqual: true,
+ requestingOriginAuthorityValid: true,
+ requestingUrlAuthorityEqual: true,
+ requestingUrlPresent: false,
+ requestingUrlTrusted: false,
+ webContentsEqualsMainWindow: false,
+ webContentsPresent: false,
+ };
+ assert.equal(desktopNetworkPermissionAllowed(accepted), true);
+ assert.equal(desktopNetworkPermissionAllowed({ ...accepted, activeBindingCurrent: false }), false);
+ assert.equal(desktopNetworkPermissionAllowed({ ...accepted, permission: 'notifications' }), false);
+ assert.equal(desktopNetworkPermissionAllowed({ ...accepted, isMainFrame: false }), false);
+ assert.equal(desktopNetworkPermissionAllowed({ ...accepted, rendererDocumentUrlTrusted: false }), false);
+ assert.equal(desktopNetworkPermissionAllowed({ ...accepted, requestingOriginAuthorityEqual: false }), false);
+ assert.equal(desktopNetworkPermissionAllowed({ ...accepted, requestingOriginAuthorityValid: false }), false);
+ assert.equal(desktopNetworkPermissionAllowed({
+ ...accepted,
+ requestingUrlPresent: true,
+ requestingUrlTrusted: false,
+ }), false);
+ assert.equal(desktopNetworkPermissionAllowed({ ...accepted, permission: 'local-network' }), true);
+ assert.equal(desktopNetworkPermissionAllowed({ ...accepted, permission: 'local-network-access' }), true);
+ assert.equal(desktopNetworkPermissionAllowed({
+ ...accepted,
+ decision: 'request',
+ requestingUrlPresent: true,
+ requestingUrlTrusted: true,
+ webContentsEqualsMainWindow: true,
+ webContentsPresent: true,
+ }), true);
+ assert.equal(desktopNetworkPermissionAllowed({
+ ...accepted,
+ decision: 'request',
+ requestingUrlPresent: true,
+ requestingUrlTrusted: true,
+ }), false);
+ });
+
+ it('limits credential transport to the exact live main renderer and active origin', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'propr-session-security-'));
+ const store = new ProfileStore(directory, encryption);
+ const mainEvidence: DesktopCurrentUserProxyEvidence[] = [];
+ const service = new DesktopCredentialService({
+ profiles: store,
+ clientName: 'Session security test',
+ openExternal: async () => undefined,
+ reportCurrentUserValidation: evidence => mainEvidence.push(evidence),
+ fetch: async input => new URL(input.toString()).pathname === '/api/desktop/discovery'
+ ? new Response(JSON.stringify({
+ product: 'ProPR',
+ version: '0.8.15',
+ apiCompatibility: PROPR_API_COMPATIBILITY,
+ uiCompatibility: PROPR_UI_COMPATIBILITY,
+ desktopAuthentication: {
+ protocolVersion: 2,
+ browserPairing: true,
+ instanceBearerTokens: true,
+ socketIoBearerAuthentication: true,
+ },
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
+ : new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
+ });
+
+ try {
+ const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: REACT_FIXTURE_ORIGIN });
+ await store.writeCredential({ version: 1, profileId: profile.id, origin: REACT_FIXTURE_ORIGIN, token: TOKEN });
+ const probed = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: REACT_FIXTURE_ORIGIN });
+ assert.equal(probed.status, 'ready');
+ if (probed.status !== 'ready' || !probed.activationTicket) return;
+
+ type PermissionCheck = (
+ webContents: WebContents | null,
+ permission: string,
+ requestingOrigin: string,
+ details: { requestingUrl?: string; isMainFrame: boolean },
+ ) => boolean;
+ type PermissionRequest = (
+ webContents: WebContents,
+ permission: string,
+ callback: (allowed: boolean) => void,
+ details: { requestingUrl?: string; isMainFrame: boolean },
+ ) => void;
+ type BeforeSendHeaders = (
+ details: {
+ url: string;
+ method: string;
+ resourceType: string;
+ requestHeaders: Record;
+ webContentsId: number;
+ },
+ callback: (decision: Record) => void,
+ ) => void;
+ let permissionCheck: PermissionCheck = () => { throw new Error('Permission check was not installed'); };
+ let permissionRequest: PermissionRequest = () => { throw new Error('Permission request was not installed'); };
+ let beforeSendHeaders: BeforeSendHeaders = () => { throw new Error('Request interceptor was not installed'); };
+ const permissionEvidence: DesktopNetworkPermissionEvidence[] = [];
+ const desktopSession = {
+ setPermissionCheckHandler: (listener: PermissionCheck | null) => {
+ if (listener) permissionCheck = listener;
+ },
+ setPermissionRequestHandler: (listener: PermissionRequest | null) => {
+ if (listener) permissionRequest = listener;
+ },
+ webRequest: {
+ onBeforeSendHeaders: (listener: BeforeSendHeaders | null) => {
+ if (listener) beforeSendHeaders = listener;
+ },
+ onHeadersReceived: (_listener: unknown) => undefined,
+ },
+ } as unknown as Session;
+ let mainRendererDestroyed = false;
+ const mainRenderer = {
+ id: 41,
+ getURL: () => RENDERER_URL,
+ isDestroyed: () => mainRendererDestroyed,
+ } as unknown as WebContents;
+ const otherRenderer = { id: 42, getURL: () => RENDERER_URL } as unknown as WebContents;
+ const configured = configureDesktopSessionSecurity({
+ contentSecurityPolicy: () => "default-src 'self'",
+ credentials: service,
+ desktopSession,
+ getMainRenderer: () => mainRenderer,
+ isTrustedRendererUrl: value => value === RENDERER_URL,
+ reportNetworkPermissionDecision: evidence => permissionEvidence.push(evidence),
+ });
+
+ const beforeActivation = async (
+ url: string,
+ headers: Record,
+ webContentsId: number,
+ resourceType = 'xhr',
+ ) => await new Promise>(resolve => beforeSendHeaders({
+ url,
+ method: 'GET',
+ resourceType,
+ requestHeaders: headers,
+ webContentsId,
+ }, resolve));
+ assert.deepEqual(await beforeActivation(
+ 'http://127.0.0.1:41731/smoke-sw.js',
+ {
+ Accept: 'application/javascript',
+ Authorization: 'Bearer foreign-controlled',
+ Cookie: 'foreign=session',
+ },
+ otherRenderer.id,
+ 'script',
+ ), {
+ requestHeaders: { Accept: 'application/javascript' },
+ });
+ assert.deepEqual(await beforeActivation(`${REACT_FIXTURE_ORIGIN}/api/side-effect`, {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ }, mainRenderer.id), { cancel: true });
+ mainRendererDestroyed = true;
+ assert.deepEqual(await beforeActivation('http://127.0.0.1:41731/storage-fixture.html', {
+ Authorization: 'Bearer destroyed-renderer',
+ Cookie: 'destroyed=session',
+ }, mainRenderer.id), { requestHeaders: {} });
+ mainRendererDestroyed = false;
+
+ const activated = await service.activate(probed.activationTicket);
+
+ // Electron documents a nullable WebContents for permission checks. The
+ // trusted main-frame authority remains sufficient without fabricating it.
+ assert.equal(permissionCheck(
+ null,
+ 'loopback-network',
+ DESKTOP_RENDERER_ORIGIN,
+ { isMainFrame: true },
+ ), true);
+ assert.equal(permissionCheck(null, 'loopback-network', DESKTOP_RENDERER_ORIGIN, {
+ isMainFrame: false,
+ }), false);
+ assert.equal(permissionCheck(null, 'loopback-network', 'https://attacker.example.test', {
+ isMainFrame: true,
+ }), false);
+ assert.equal(permissionCheck(null, 'loopback-network', '', {
+ isMainFrame: true,
+ }), false);
+ assert.equal(permissionCheck(null, 'loopback-network', DESKTOP_RENDERER_ORIGIN, {
+ requestingUrl: 'https://attacker.example.test/frame', isMainFrame: true,
+ }), false);
+ assert.equal(permissionCheck(otherRenderer, 'loopback-network', DESKTOP_RENDERER_ORIGIN, {
+ requestingUrl: RENDERER_URL, isMainFrame: true,
+ }), false);
+ assert.equal(permissionCheck(null, 'notifications', DESKTOP_RENDERER_ORIGIN, {
+ isMainFrame: true,
+ }), false);
+ let requestedPermission = false;
+ permissionRequest(
+ mainRenderer,
+ 'loopback-network',
+ (allowed: boolean) => { requestedPermission = allowed; },
+ { requestingUrl: RENDERER_URL, isMainFrame: true },
+ );
+ assert.equal(requestedPermission, true);
+ permissionRequest(
+ otherRenderer,
+ 'loopback-network',
+ (allowed: boolean) => { requestedPermission = allowed; },
+ { requestingUrl: RENDERER_URL, isMainFrame: true },
+ );
+ assert.equal(requestedPermission, false);
+ permissionRequest(
+ mainRenderer,
+ 'loopback-network',
+ (allowed: boolean) => { requestedPermission = allowed; },
+ { isMainFrame: true },
+ );
+ assert.equal(requestedPermission, false);
+ permissionRequest(
+ mainRenderer,
+ 'loopback-network',
+ (allowed: boolean) => { requestedPermission = allowed; },
+ { requestingUrl: 'https://attacker.example.test/frame', isMainFrame: true },
+ );
+ assert.equal(requestedPermission, false);
+ permissionRequest(
+ mainRenderer,
+ 'loopback-network',
+ (allowed: boolean) => { requestedPermission = allowed; },
+ { requestingUrl: RENDERER_URL, isMainFrame: false },
+ );
+ assert.equal(requestedPermission, false);
+ assert.equal(permissionEvidence.length, 11);
+ assert.deepEqual(permissionEvidence[0], {
+ schemaVersion: 1,
+ permissionCategory: 'loopback-network',
+ decision: 'check',
+ allowed: true,
+ activeBindingCurrent: true,
+ webContentsPresent: false,
+ webContentsEqualsMainWindow: false,
+ mainWindowPresent: true,
+ isMainFrame: true,
+ requestingUrlPresent: false,
+ requestingUrlTrusted: false,
+ rendererDocumentUrlTrusted: true,
+ requestingOriginAuthorityValid: true,
+ requestingOriginAuthorityEqual: true,
+ });
+ assert.doesNotMatch(JSON.stringify(permissionEvidence), /attacker|renderer\.html|127\.0\.0\.2|propr_it_/);
+
+ const intercepted = async (
+ url: string,
+ method: 'OPTIONS' | 'GET',
+ headers: Record,
+ resourceType = 'xhr',
+ webContentsId = mainRenderer.id,
+ ) => {
+ return await new Promise>(resolve => beforeSendHeaders({
+ url,
+ method,
+ resourceType,
+ requestHeaders: headers,
+ webContentsId,
+ }, resolve));
+ };
+ const unrelatedTargets = [
+ 'http://127.0.0.1:41731/api/side-effect',
+ 'https://192.168.1.10/api/side-effect',
+ 'http://127.0.0.2:41732/api/side-effect',
+ 'https://127.0.0.2.evil.invalid:41731/api/side-effect',
+ ];
+ for (const url of unrelatedTargets) {
+ assert.deepEqual(await intercepted(url, 'GET', {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ }), { cancel: true }, url);
+ }
+
+ const currentUserUrl = `${REACT_FIXTURE_ORIGIN}/api/auth/user?proprDesktopScopeGeneration=1`;
+ assert.deepEqual(await intercepted(currentUserUrl, 'GET', {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ Referer: RENDERER_URL,
+ Authorization: 'Bearer foreign-controlled',
+ Cookie: 'foreign=session',
+ [DESKTOP_TRANSPORT_SCOPE_HEADER]: activated.transportScope,
+ }, 'xhr', otherRenderer.id), { cancel: true });
+ const preflight = await intercepted(currentUserUrl, 'OPTIONS', {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ 'Access-Control-Request-Method': 'GET',
+ 'Access-Control-Request-Headers': DESKTOP_TRANSPORT_SCOPE_HEADER,
+ });
+ assert.equal(preflight.cancel, undefined);
+ assert.equal(new Headers(preflight.requestHeaders as HeadersInit).has('Authorization'), false);
+
+ const get = await intercepted(currentUserUrl, 'GET', {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ Cookie: 'renderer=must-not-cross',
+ Authorization: 'Bearer renderer-controlled',
+ [DESKTOP_TRANSPORT_SCOPE_HEADER]: activated.transportScope,
+ });
+ const outbound = new Headers(get.requestHeaders as HeadersInit);
+ assert.equal(get.cancel, undefined);
+ assert.deepEqual(get.requestHeaders, {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ Authorization: `Bearer ${TOKEN}`,
+ });
+ assert.equal(outbound.get('Authorization'), `Bearer ${TOKEN}`);
+ assert.equal(outbound.has('Cookie'), false);
+ assert.equal(outbound.has(DESKTOP_TRANSPORT_SCOPE_HEADER), false);
+ assert.equal(mainEvidence.length, 1);
+ assert.equal(mainEvidence[0].accepted, true);
+ assert.equal(mainEvidence[0].rendererScopeGeneration, 1);
+ assert.equal(mainEvidence[0].scopeHeaderCount, 1);
+ assert.equal(mainEvidence[0].bearerMainInjected, true);
+
+ const socketUrl = `${REACT_FIXTURE_ORIGIN.replace(/^http/, 'ws')}/socket.io/`
+ + `?EIO=4&transport=websocket&proprDesktopTransportScope=${activated.transportScope}`;
+ assert.deepEqual(await intercepted(socketUrl, 'GET', {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ Referer: RENDERER_URL,
+ Authorization: 'Bearer foreign-controlled',
+ Cookie: 'foreign=session',
+ }, 'webSocket', otherRenderer.id), { cancel: true });
+ const socket = await intercepted(socketUrl, 'GET', {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ Cookie: 'renderer=must-not-cross',
+ Authorization: 'Bearer renderer-controlled',
+ }, 'webSocket');
+ assert.equal(socket.cancel, undefined);
+ assert.deepEqual(socket.requestHeaders, {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ Authorization: `Bearer ${TOKEN}`,
+ });
+ assert.deepEqual(await service.discardActivation(activated), { discarded: true });
+ assert.equal(permissionCheck(null, 'loopback-network', DESKTOP_RENDERER_ORIGIN, {
+ isMainFrame: true,
+ }), false);
+ for (const url of [...unrelatedTargets, `${REACT_FIXTURE_ORIGIN}/api/side-effect`]) {
+ assert.deepEqual(await intercepted(url, 'GET', {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ }), { cancel: true }, `cached grant after discard: ${url}`);
+ }
+ assert.deepEqual(await intercepted(socketUrl, 'GET', {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ }, 'webSocket'), { cancel: true });
+
+ const revokedReady = await service.probe(profile);
+ assert.equal(revokedReady.status, 'ready');
+ if (revokedReady.status !== 'ready') return;
+ const revoked = await service.activate(revokedReady.activationTicket);
+ assert.deepEqual(await service.invalidate({
+ profileId: profile.id,
+ transportScope: revoked.transportScope,
+ code: 'INSTANCE_TOKEN_REVOKED',
+ }), { invalidated: true });
+ assert.deepEqual(await intercepted(`${REACT_FIXTURE_ORIGIN}/api/side-effect`, 'GET', {
+ Origin: DESKTOP_RENDERER_ORIGIN,
+ }), { cancel: true });
+ configured.dispose();
+ } finally {
+ await service.dispose();
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/apps/desktop/src/session-security.ts b/apps/desktop/src/session-security.ts
new file mode 100644
index 000000000..48b21a3bb
--- /dev/null
+++ b/apps/desktop/src/session-security.ts
@@ -0,0 +1,214 @@
+import type { Session, WebContents } from 'electron';
+import type { DesktopCredentialService } from './credential-service';
+
+const DESKTOP_NETWORK_PERMISSIONS = new Set([
+ // Chromium 145 split the original permission into address-space-specific
+ // permissions. Keep the original spelling for older supported runtimes.
+ 'local-network-access',
+ 'local-network',
+ 'loopback-network',
+]);
+
+export type DesktopNetworkPermissionCategory =
+ | 'local-network-access'
+ | 'local-network'
+ | 'loopback-network';
+
+export interface DesktopNetworkPermissionEvidence {
+ schemaVersion: 1;
+ permissionCategory: DesktopNetworkPermissionCategory;
+ decision: 'check' | 'request';
+ allowed: boolean;
+ activeBindingCurrent: boolean;
+ webContentsPresent: boolean;
+ webContentsEqualsMainWindow: boolean;
+ mainWindowPresent: boolean;
+ isMainFrame: boolean;
+ requestingUrlPresent: boolean;
+ requestingUrlTrusted: boolean;
+ rendererDocumentUrlTrusted: boolean;
+ requestingOriginAuthorityValid: boolean;
+ requestingOriginAuthorityEqual: boolean;
+}
+
+const rendererAuthority = (value: string): string | null => {
+ try {
+ const url = new URL(value);
+ if (!url.protocol || !url.hostname || url.username || url.password) return null;
+ return `${url.protocol}//${url.host}`;
+ } catch {
+ return null;
+ }
+};
+
+export interface DesktopNetworkPermissionContext extends Omit {
+ permission: string;
+ requestingUrlAuthorityEqual: boolean;
+}
+
+/**
+ * Chromium may require Local Network Access before a renderer request reaches
+ * webRequest. This grant authenticates only the packaged application's current
+ * main renderer and only while main owns a current credential binding. The
+ * credential service still authorizes every concrete URL, scope, and header.
+ */
+export const desktopNetworkPermissionAllowed = ({
+ activeBindingCurrent,
+ decision,
+ isMainFrame,
+ mainWindowPresent,
+ permission,
+ rendererDocumentUrlTrusted,
+ requestingOriginAuthorityEqual,
+ requestingOriginAuthorityValid,
+ requestingUrlAuthorityEqual,
+ requestingUrlPresent,
+ requestingUrlTrusted,
+ webContentsEqualsMainWindow,
+ webContentsPresent,
+}: DesktopNetworkPermissionContext): boolean => DESKTOP_NETWORK_PERMISSIONS.has(permission)
+ && activeBindingCurrent
+ && mainWindowPresent
+ && isMainFrame
+ && rendererDocumentUrlTrusted
+ && (!requestingUrlPresent || (requestingUrlTrusted && requestingUrlAuthorityEqual))
+ && requestingOriginAuthorityValid
+ && requestingOriginAuthorityEqual
+ && (decision === 'check'
+ ? !webContentsPresent || webContentsEqualsMainWindow
+ : webContentsPresent && webContentsEqualsMainWindow && requestingUrlPresent);
+
+interface ConfigureDesktopSessionSecurityOptions {
+ contentSecurityPolicy(): string;
+ credentials: DesktopCredentialService;
+ desktopSession: Session;
+ getMainRenderer(): WebContents | null;
+ isTrustedRendererUrl(value: string): boolean;
+ reportNetworkPermissionDecision?(evidence: DesktopNetworkPermissionEvidence): void;
+}
+
+/** Install the production permission, request, and response boundary on one session. */
+export const configureDesktopSessionSecurity = ({
+ contentSecurityPolicy,
+ credentials,
+ desktopSession,
+ getMainRenderer,
+ isTrustedRendererUrl,
+ reportNetworkPermissionDecision = () => undefined,
+}: ConfigureDesktopSessionSecurityOptions): {
+ close(): void;
+ dispose(): void;
+} => {
+ const allowNetworkPermission = (
+ decision: 'check' | 'request',
+ webContents: WebContents | null,
+ permission: string,
+ requestingOrigin: string,
+ isMainFrame: boolean,
+ requestingUrl?: string,
+ ): boolean => {
+ const mainRenderer = getMainRenderer();
+ const rendererDocumentUrl = mainRenderer?.getURL() ?? '';
+ const rendererDocumentAuthority = rendererAuthority(rendererDocumentUrl);
+ const requestingUrlPresent = typeof requestingUrl === 'string' && requestingUrl.length > 0;
+ const requestingUrlAuthority = requestingUrlPresent ? rendererAuthority(requestingUrl) : null;
+ const requestingOriginAuthority = rendererAuthority(requestingOrigin);
+ const context: DesktopNetworkPermissionContext = {
+ activeBindingCurrent: credentials.hasActiveRendererBinding(),
+ decision,
+ isMainFrame: isMainFrame === true,
+ mainWindowPresent: mainRenderer !== null,
+ permission,
+ rendererDocumentUrlTrusted: mainRenderer !== null && isTrustedRendererUrl(rendererDocumentUrl),
+ requestingOriginAuthorityEqual: rendererDocumentAuthority !== null
+ && requestingOriginAuthority === rendererDocumentAuthority,
+ requestingOriginAuthorityValid: requestingOriginAuthority !== null
+ && requestingOrigin === requestingOriginAuthority,
+ requestingUrlAuthorityEqual: !requestingUrlPresent || (rendererDocumentAuthority !== null
+ && requestingUrlAuthority === rendererDocumentAuthority),
+ requestingUrlPresent,
+ requestingUrlTrusted: requestingUrlPresent && isTrustedRendererUrl(requestingUrl),
+ webContentsEqualsMainWindow: webContents !== null && webContents === mainRenderer,
+ webContentsPresent: webContents !== null,
+ };
+ const allowed = desktopNetworkPermissionAllowed(context);
+ if (DESKTOP_NETWORK_PERMISSIONS.has(permission)) {
+ try {
+ reportNetworkPermissionDecision({
+ schemaVersion: 1,
+ permissionCategory: permission as DesktopNetworkPermissionCategory,
+ decision,
+ allowed,
+ activeBindingCurrent: context.activeBindingCurrent,
+ webContentsPresent: context.webContentsPresent,
+ webContentsEqualsMainWindow: context.webContentsEqualsMainWindow,
+ mainWindowPresent: context.mainWindowPresent,
+ isMainFrame: context.isMainFrame,
+ requestingUrlPresent: context.requestingUrlPresent,
+ requestingUrlTrusted: context.requestingUrlTrusted,
+ rendererDocumentUrlTrusted: context.rendererDocumentUrlTrusted,
+ requestingOriginAuthorityValid: context.requestingOriginAuthorityValid,
+ requestingOriginAuthorityEqual: context.requestingOriginAuthorityEqual,
+ });
+ } catch {
+ // Diagnostics cannot change the permission decision.
+ }
+ }
+ return allowed;
+ };
+
+ desktopSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) =>
+ allowNetworkPermission(
+ 'check',
+ webContents,
+ String(permission),
+ requestingOrigin,
+ details.isMainFrame,
+ details.requestingUrl,
+ ));
+ desktopSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
+ const requestingUrl = 'requestingUrl' in details && typeof details.requestingUrl === 'string'
+ ? details.requestingUrl
+ : undefined;
+ callback(allowNetworkPermission(
+ 'request',
+ webContents,
+ String(permission),
+ requestingUrl ? rendererAuthority(requestingUrl) ?? '' : '',
+ details.isMainFrame,
+ requestingUrl,
+ ));
+ });
+ desktopSession.webRequest.onBeforeSendHeaders((details, callback) => {
+ const mainRenderer = getMainRenderer();
+ const rendererOwned = mainRenderer !== null
+ && !mainRenderer.isDestroyed()
+ && details.webContentsId === mainRenderer.id;
+ callback(credentials.prepareRequest(details.url, details.requestHeaders, {
+ method: details.method,
+ rendererOwned,
+ resourceType: details.resourceType,
+ }));
+ });
+ desktopSession.webRequest.onHeadersReceived((details, callback) => {
+ callback({
+ responseHeaders: {
+ ...credentials.sanitizeResponseHeaders(details.url, details.responseHeaders ?? {}),
+ 'Content-Security-Policy': [contentSecurityPolicy()],
+ },
+ });
+ });
+ return {
+ close() {
+ desktopSession.webRequest.onBeforeSendHeaders((_details, callback) => callback({ cancel: true }));
+ desktopSession.webRequest.onHeadersReceived((_details, callback) => callback({ cancel: true }));
+ },
+ dispose() {
+ desktopSession.setPermissionCheckHandler(null);
+ desktopSession.setPermissionRequestHandler(null);
+ desktopSession.webRequest.onBeforeSendHeaders(null);
+ desktopSession.webRequest.onHeadersReceived(null);
+ },
+ };
+};
diff --git a/apps/desktop/src/smoke-test-authorization.test.ts b/apps/desktop/src/smoke-test-authorization.test.ts
index d5ea9125e..07786d3ba 100644
--- a/apps/desktop/src/smoke-test-authorization.test.ts
+++ b/apps/desktop/src/smoke-test-authorization.test.ts
@@ -167,7 +167,7 @@ describe('packaged smoke profile authorization', () => {
const smokeFlowEnd = main.indexOf("log('info', 'desktop.renderer.mvp_flows.ready'", smokeFlowStart);
const smokeFlow = main.slice(smokeFlowStart, smokeFlowEnd);
- assert.match(main, /process\.platform === 'linux' && !packagedSmokeTest\s*\? await createDesktopLocalHost/);
+ assert.match(main, /process\.platform === 'linux' && !packagedSmokeTest && !packagedAcceptanceTest\s*\? await createDesktopLocalHost/);
assert.doesNotMatch(smokeFlow, /lifecycle\.(?:start|stop|restart)|localSetup\.(?:start|retry|cancel)/);
assert.match(smokeFlow, /stagedConnectCandidate/);
assert.match(smokeFlow, /window\.__PROPR_DESKTOP__/);
diff --git a/apps/desktop/src/smoke-test-evidence.test.ts b/apps/desktop/src/smoke-test-evidence.test.ts
index d0ff7beea..82e468ff2 100644
--- a/apps/desktop/src/smoke-test-evidence.test.ts
+++ b/apps/desktop/src/smoke-test-evidence.test.ts
@@ -5,8 +5,10 @@ import { join } from 'node:path';
import { describe, it } from 'node:test';
import {
createPackagedSmokeEvidenceSink,
+ validatePackagedCurrentUserBoundaryEvidence,
PACKAGED_SMOKE_EVIDENCE_EVENTS,
PACKAGED_SMOKE_EVIDENCE_FILE,
+ validatePackagedStaleSocketBoundaryEvidence,
} from './smoke-test-evidence';
const withSmokeDirectory = (run: (directory: string) => void): void => {
@@ -46,6 +48,132 @@ describe('packaged smoke evidence', () => {
{ event: 'desktop.renderer.ready' },
]);
assert.doesNotMatch(contents, /timestamp|path|url|error|exception|credential|secret|raw/i);
+
+ const base = {
+ schemaVersion: 1 as const,
+ path: 'socket-io' as const,
+ transport: 'websocket' as const,
+ resource: 'websocket' as const,
+ scopeQueryPresent: true,
+ scopeQueryCount: 1,
+ activeBindingPresent: true,
+ profileGenerationCurrent: true,
+ originEqualsActive: true,
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ };
+ const summary = validatePackagedStaleSocketBoundaryEvidence([
+ {
+ ...base,
+ scopeEqualsActive: false,
+ outboundBearerPresent: false,
+ bearerMainInjected: false,
+ accepted: false,
+ rejectionCategory: 'stale-scope',
+ },
+ {
+ ...base,
+ scopeEqualsActive: true,
+ outboundBearerPresent: true,
+ bearerMainInjected: true,
+ accepted: true,
+ rejectionCategory: 'none',
+ },
+ ], false);
+ assert.deepEqual(summary, {
+ schemaVersion: 1,
+ mainAttempts: 2,
+ staleRejectedByMain: true,
+ staleRejectionCategory: 'stale-scope',
+ freshAcceptedByMain: true,
+ exactPath: true,
+ exactTransport: true,
+ exactResource: true,
+ queryCount: 1,
+ activeBindingPresent: true,
+ profileGenerationCurrent: true,
+ originEqualsActive: true,
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ staleOutboundBearerPresent: false,
+ staleBearerMainInjected: false,
+ freshBearerMainInjected: true,
+ });
+ assert.throws(
+ () => validatePackagedStaleSocketBoundaryEvidence([], false),
+ /main-boundary evidence failed/,
+ );
+ assert.throws(
+ () => validatePackagedStaleSocketBoundaryEvidence([
+ {
+ ...base,
+ scopeEqualsActive: false,
+ outboundBearerPresent: false,
+ bearerMainInjected: false,
+ accepted: false,
+ rejectionCategory: 'stale-scope',
+ },
+ {
+ ...base,
+ scopeEqualsActive: true,
+ outboundBearerPresent: true,
+ bearerMainInjected: true,
+ accepted: true,
+ rejectionCategory: 'none',
+ },
+ ], true),
+ /main-boundary evidence failed/,
+ );
+
+ const currentUserEvidence = {
+ schemaVersion: 2 as const,
+ correlation: 'current-scope-user-validation' as const,
+ requestObserved: true as const,
+ method: 'get' as const,
+ rendererScopeGeneration: 1,
+ scopeGenerationQueryCount: 1 as const,
+ scopeGenerationQueryValid: true,
+ scopeHeaderCount: 1 as const,
+ activeBindingPresent: true,
+ activeScopeGeneration: 0,
+ profileGenerationCurrent: true,
+ scopeEqualsActive: true,
+ originEqualsActive: true,
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ outboundBearerPresent: true,
+ bearerMainInjected: true,
+ accepted: true,
+ rejectionCategory: 'none' as const,
+ };
+ assert.deepEqual(validatePackagedCurrentUserBoundaryEvidence([
+ currentUserEvidence,
+ { ...currentUserEvidence, rendererScopeGeneration: 2, activeScopeGeneration: 1 },
+ { ...currentUserEvidence, rendererScopeGeneration: 3, activeScopeGeneration: 2 },
+ ], false), {
+ schemaVersion: 2,
+ rendererValidations: 3,
+ rendererScopeGenerations: [1, 2, 3],
+ exactGet: true,
+ scopeHeaderCount: 1,
+ activeBindingPresent: true,
+ profileGenerationCurrent: true,
+ scopeEqualsActive: true,
+ originEqualsActive: true,
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ outboundBearerPresent: true,
+ bearerMainInjected: true,
+ accepted: true,
+ });
+ assert.throws(
+ () => validatePackagedCurrentUserBoundaryEvidence([
+ currentUserEvidence,
+ { ...currentUserEvidence, rendererScopeGeneration: 2 },
+ { ...currentUserEvidence, rendererScopeGeneration: 3, rendererBearerPresent: true },
+ ], false),
+ /current-user main-boundary evidence failed/,
+ );
});
});
diff --git a/apps/desktop/src/smoke-test-evidence.ts b/apps/desktop/src/smoke-test-evidence.ts
index a9d26bfb6..5e0a9515b 100644
--- a/apps/desktop/src/smoke-test-evidence.ts
+++ b/apps/desktop/src/smoke-test-evidence.ts
@@ -7,6 +7,10 @@ import {
writeSync,
} from 'node:fs';
import { join } from 'node:path';
+import type {
+ DesktopCurrentUserProxyEvidence,
+ DesktopWebSocketHandshakeEvidence,
+} from './credential-service';
export const PACKAGED_SMOKE_EVIDENCE_FILE = 'application.smoke-evidence.jsonl';
@@ -32,6 +36,157 @@ export interface PackagedSmokeEvidenceSink {
close(): void;
}
+export const PACKAGED_SMOKE_HANDSHAKE_EVIDENCE_LIMIT = 8;
+
+export interface PackagedSmokeHandshakeEvidenceBuffer {
+ records: DesktopWebSocketHandshakeEvidence[];
+ overflowed: boolean;
+}
+
+export const PACKAGED_SMOKE_CURRENT_USER_EVIDENCE_LIMIT = 4;
+
+export interface PackagedSmokeCurrentUserEvidenceBuffer {
+ records: DesktopCurrentUserProxyEvidence[];
+ overflowed: boolean;
+}
+
+export interface PackagedCurrentUserBoundarySummary {
+ schemaVersion: 2;
+ rendererValidations: 3;
+ rendererScopeGenerations: [1, 2, 3];
+ exactGet: true;
+ scopeHeaderCount: 1;
+ activeBindingPresent: true;
+ profileGenerationCurrent: true;
+ scopeEqualsActive: true;
+ originEqualsActive: true;
+ rendererBearerPresent: false;
+ rendererCookiePresent: false;
+ outboundBearerPresent: true;
+ bearerMainInjected: true;
+ accepted: true;
+}
+
+const exactCurrentUserEvidence = (evidence: DesktopCurrentUserProxyEvidence, generation: number): boolean =>
+ evidence.schemaVersion === 2
+ && evidence.correlation === 'current-scope-user-validation'
+ && evidence.requestObserved === true
+ && evidence.method === 'get'
+ && evidence.rendererScopeGeneration === generation
+ && evidence.scopeGenerationQueryCount === 1
+ && evidence.scopeGenerationQueryValid === true
+ && evidence.scopeHeaderCount === 1
+ && evidence.activeBindingPresent === true
+ && Number.isSafeInteger(evidence.activeScopeGeneration)
+ && evidence.activeScopeGeneration >= 0
+ && evidence.profileGenerationCurrent === true
+ && evidence.scopeEqualsActive === true
+ && evidence.originEqualsActive === true
+ && evidence.rendererBearerPresent === false
+ && evidence.rendererCookiePresent === false
+ && evidence.outboundBearerPresent === true
+ && evidence.bearerMainInjected === true
+ && evidence.accepted === true
+ && evidence.rejectionCategory === 'none';
+
+/** Validate the renderer request side of all three packaged smoke activations. */
+export const validatePackagedCurrentUserBoundaryEvidence = (
+ evidence: readonly DesktopCurrentUserProxyEvidence[],
+ overflowed: boolean,
+): PackagedCurrentUserBoundarySummary => {
+ if (overflowed || evidence.length !== 3
+ || evidence.some((record, index) => !exactCurrentUserEvidence(record, index + 1))) {
+ throw new Error('Packaged current-user main-boundary evidence failed');
+ }
+ return {
+ schemaVersion: 2,
+ rendererValidations: 3,
+ rendererScopeGenerations: [1, 2, 3],
+ exactGet: true,
+ scopeHeaderCount: 1,
+ activeBindingPresent: true,
+ profileGenerationCurrent: true,
+ scopeEqualsActive: true,
+ originEqualsActive: true,
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ outboundBearerPresent: true,
+ bearerMainInjected: true,
+ accepted: true,
+ };
+};
+
+export interface PackagedStaleSocketBoundarySummary {
+ schemaVersion: 1;
+ mainAttempts: 2;
+ staleRejectedByMain: true;
+ staleRejectionCategory: 'stale-scope';
+ freshAcceptedByMain: true;
+ exactPath: true;
+ exactTransport: true;
+ exactResource: true;
+ queryCount: 1;
+ activeBindingPresent: true;
+ profileGenerationCurrent: true;
+ originEqualsActive: true;
+ rendererBearerPresent: false;
+ rendererCookiePresent: false;
+ staleOutboundBearerPresent: false;
+ staleBearerMainInjected: false;
+ freshBearerMainInjected: true;
+}
+
+const exactHandshakeEvidence = (
+ evidence: DesktopWebSocketHandshakeEvidence | undefined,
+ expected: 'stale' | 'fresh',
+): boolean => evidence?.schemaVersion === 1
+ && evidence.path === 'socket-io'
+ && evidence.transport === 'websocket'
+ && evidence.resource === 'websocket'
+ && evidence.scopeQueryPresent === true
+ && evidence.scopeQueryCount === 1
+ && evidence.scopeEqualsActive === (expected === 'fresh')
+ && evidence.activeBindingPresent === true
+ && evidence.profileGenerationCurrent === true
+ && evidence.originEqualsActive === true
+ && evidence.rendererBearerPresent === false
+ && evidence.rendererCookiePresent === false
+ && evidence.outboundBearerPresent === (expected === 'fresh')
+ && evidence.bearerMainInjected === (expected === 'fresh')
+ && evidence.accepted === (expected === 'fresh')
+ && evidence.rejectionCategory === (expected === 'fresh' ? 'none' : 'stale-scope');
+
+/** Validate one renderer stale reconnect followed by its distinct fresh Manager. */
+export const validatePackagedStaleSocketBoundaryEvidence = (
+ evidence: readonly DesktopWebSocketHandshakeEvidence[],
+ overflowed: boolean,
+): PackagedStaleSocketBoundarySummary => {
+ if (overflowed || evidence.length !== 2
+ || !exactHandshakeEvidence(evidence[0], 'stale')
+ || !exactHandshakeEvidence(evidence[1], 'fresh')) {
+ throw new Error('Packaged stale Socket.IO main-boundary evidence failed');
+ }
+ return {
+ schemaVersion: 1,
+ mainAttempts: 2,
+ staleRejectedByMain: true,
+ staleRejectionCategory: 'stale-scope',
+ freshAcceptedByMain: true,
+ exactPath: true,
+ exactTransport: true,
+ exactResource: true,
+ queryCount: 1,
+ activeBindingPresent: true,
+ profileGenerationCurrent: true,
+ originEqualsActive: true,
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ staleOutboundBearerPresent: false,
+ staleBearerMainInjected: false,
+ freshBearerMainInjected: true,
+ };
+};
+
export const createPackagedSmokeEvidenceSink = (
authorizedUserDataDirectory: string | null,
): PackagedSmokeEvidenceSink | null => {
diff --git a/package-lock.json b/package-lock.json
index 431e758d0..d4690a3ec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -92,7 +92,9 @@
"@electron/windows-sign": "2.0.6",
"@types/node": "^22.10.0",
"@vitejs/plugin-react": "^4.6.0",
+ "axe-core": "4.10.3",
"electron": "^44.0.0",
+ "playwright": "^1.62.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.5"
@@ -4477,6 +4479,16 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/axe-core": {
+ "version": "4.10.3",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
+ "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/bail": {
"version": "2.0.2",
"license": "MIT",
@@ -4527,11 +4539,16 @@
}
},
"node_modules/baseline-browser-mapping": {
- "version": "2.9.7",
+ "version": "2.11.20",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
+ "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
- "baseline-browser-mapping": "dist/cli.js"
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
}
},
"node_modules/before-after-hook": {
@@ -4716,7 +4733,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.1",
+ "version": "4.28.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dev": true,
"funding": [
{
@@ -4734,11 +4753,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.9.0",
- "caniuse-lite": "^1.0.30001759",
- "electron-to-chromium": "^1.5.263",
- "node-releases": "^2.0.27",
- "update-browserslist-db": "^1.2.0"
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
@@ -4862,7 +4881,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001760",
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true,
"funding": [
{
@@ -6140,7 +6161,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.267",
+ "version": "1.5.418",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.418.tgz",
+ "integrity": "sha512-UzS26r3AEbG5wSoGVpJKqwHIU9zwQN7LHdVIThDrJpS0I5KdlXFMEb8543fhc9dVnIIAST6ar8rhwa00AL5MlA==",
"dev": true,
"license": "ISC"
},
@@ -7416,9 +7439,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
- "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+ "version": "3.1.6",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
+ "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
"funding": [
{
"type": "github",
@@ -11001,9 +11024,14 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.27",
+ "version": "2.0.54",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
+ "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
},
"node_modules/normalize-path": {
"version": "3.0.0",
@@ -12003,9 +12031,9 @@
}
},
"node_modules/qs": {
- "version": "6.15.3",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
- "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
@@ -14347,7 +14375,9 @@
}
},
"node_modules/update-browserslist-db": {
- "version": "1.2.2",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
+ "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"dev": true,
"funding": [
{
diff --git a/package.json b/package.json
index 087e1281a..52a99af33 100644
--- a/package.json
+++ b/package.json
@@ -79,6 +79,8 @@
"desktop:package": "npm run package -w @propr/desktop",
"desktop:smoke": "npm run smoke:package -w @propr/desktop",
"desktop:smoke:inspect": "npm run smoke:inspect -w @propr/desktop",
+ "desktop:acceptance": "npm run acceptance:package -w @propr/desktop",
+ "desktop:acceptance:verify": "npm run acceptance:verify -w @propr/desktop",
"desktop:make": "npm run make -w @propr/desktop",
"audit:runtime": "npm audit --package-lock-only --omit=dev --audit-level=low",
"desktop:audit:packaging": "npm audit --package-lock-only --workspace=@propr/desktop --include=dev --audit-level=high",
diff --git a/packages/client/src/desktopPairing.ts b/packages/client/src/desktopPairing.ts
index b799870ac..6311de3cd 100644
--- a/packages/client/src/desktopPairing.ts
+++ b/packages/client/src/desktopPairing.ts
@@ -278,13 +278,16 @@ export const completeDesktopPairing = async (
const remainingBeforeSleep = requireRemainingLifetime();
const delay = safeDelay(Math.min(intervalSeconds * 1000, remainingBeforeSleep));
await raceLifetime(sleep(delay, lifetimeController.signal));
- const remaining = requireRemainingLifetime();
+ requireRemainingLifetime();
let value: unknown;
try {
// The pairing reader owns cancellation through body drain/cancel. Do
// not race it with a faster outer rejection: completion here is the
- // operation's guarantee that no response task survives this poll.
+ // operation's guarantee that no response task survives this poll. The
+ // lifetime controller is the sole pairing-deadline owner; giving the
+ // transport its fixed per-request budget avoids a same-tick race that
+ // could misclassify pairing expiry as a request timeout.
value = await client.requestDesktopPairing(
`/api/desktop/pairings/${encodeURIComponent(start.pairingId)}/poll`,
{
@@ -294,7 +297,7 @@ export const completeDesktopPairing = async (
redirect: 'manual',
signal: lifetimeController.signal,
},
- Math.min(PAIRING_REQUEST_TIMEOUT_MS, safeDelay(remaining)),
+ PAIRING_REQUEST_TIMEOUT_MS,
);
} catch (error) {
if (terminal || remainingLifetime() <= 0) {
diff --git a/propr-ui/src/App.tsx b/propr-ui/src/App.tsx
index dd427691d..a56ffd3eb 100644
--- a/propr-ui/src/App.tsx
+++ b/propr-ui/src/App.tsx
@@ -1,4 +1,4 @@
-import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
+import React, { lazy, Suspense, useEffect, useState } from 'react'
import { BrowserRouter, HashRouter, Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom'
import Layout from './components/Layout'
import { ToastProvider } from './components/ui/Toast'
@@ -7,7 +7,6 @@ import { useDemoMode } from './contexts/DemoModeContext'
import { DemoModeProvider } from './contexts/DemoModeProvider'
import DemoModeBanner from './components/DemoModeBanner'
import './App.css'
-import { getCurrentUser, INSTANCE_AUTHORIZATION_CHANGED_EVENT } from './api/proprApi'
import { checkProprApiCompatibility, ProprCompatibilityCheckError } from './api/compatibility'
import {
hostedUiConnectionIssue,
@@ -16,13 +15,14 @@ import {
pathWithActiveHostedTunnelFlow,
} from './config/runtimeConfig'
import { AuthProvider, useCurrentUser, userHasPermission } from './contexts/AuthContext'
-import type { CurrentUser, InstancePermission } from './api/proprTypes'
+import type { InstancePermission } from './api/proprTypes'
import RouteChunkErrorBoundary from './components/RouteChunkErrorBoundary'
import { ConnectAccountProvider } from './contexts/ConnectAccountContext'
import { BrowserPushProvider } from './hooks/useBrowserPush'
import { NotificationCenterProvider } from './contexts/NotificationCenterContext'
-import { currentUiPathname, isDesktopRuntime, publicAssetUrl } from './config/runtimeMode'
+import { isDesktopRuntime, publicAssetUrl } from './config/runtimeMode'
import { DesktopPresentationBoundary } from './desktop/DesktopPresentationBoundary'
+import { useCurrentUserBootstrap } from './hooks/useCurrentUserBootstrap'
const Router = isDesktopRuntime() ? HashRouter : BrowserRouter;
@@ -44,8 +44,6 @@ const TasksPage = lazy(() => import('./pages/TasksPage'))
type CompatibilityState = { status: 'checking' } | { status: 'ready' }
| { status: 'blocked'; title: string; message: string };
-const AUTHORIZATION_REFRESH_INTERVAL_MS = 60_000;
-
const LoadingSpinner: React.FC = () => (
@@ -143,87 +141,17 @@ export const NotFoundRouteContent: React.FC<{ hostname?: string }> = ({ hostname
const AppContent: React.FC = () => {
const { isDemoMode, isLoading: isDemoModeLoading } = useDemoMode();
- // Auth check state - start loading unless already on login page
- const [isLoading, setIsLoading] = useState(currentUiPathname() !== '/login');
- const [currentUser, setCurrentUser] = useState
(null);
- const refreshPromiseRef = useRef | null>(null);
-
- const refreshCurrentUser = useCallback(async () => {
- if (refreshPromiseRef.current) return refreshPromiseRef.current;
- const request = getCurrentUser()
- .then(user => { setCurrentUser(user); })
- .finally(() => {
- if (refreshPromiseRef.current === request) refreshPromiseRef.current = null;
- });
- refreshPromiseRef.current = request;
- return request;
- }, []);
-
- // Perform initial auth check
- useEffect(() => {
- if (isDemoModeLoading) return;
-
- const checkSession = async () => {
- // Don't check if we are already on login page
- if (currentUiPathname() === '/login') {
- setIsLoading(false);
- return;
- }
-
- try {
- await refreshCurrentUser();
- // Session is valid
- setIsLoading(false);
- } catch (error) {
- // If error is NOT 'Authentication required', we let the app render (to show errors).
- // If it IS 'Authentication required', handleApiResponse handles the redirect,
- // so we keep isLoading=true to prevent UI flash.
- if (error instanceof Error && error.message !== 'Authentication required') {
- setIsLoading(false);
- }
- }
- };
-
- checkSession();
- }, [isDemoModeLoading, refreshCurrentUser]);
-
- useEffect(() => {
- const handleAuthorizationChanged = () => {
- void refreshCurrentUser().catch(error => {
- console.error('Failed to refresh instance authorization:', error);
- });
- };
- window.addEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, handleAuthorizationChanged);
- return () => window.removeEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, handleAuthorizationChanged);
- }, [refreshCurrentUser]);
+ const {
+ currentUser,
+ currentUserAbsent,
+ currentUserLoading,
+ isInitialLoading,
+ refreshCurrentUser,
+ } = useCurrentUserBootstrap({ isDemoMode, isDemoModeLoading });
- useEffect(() => {
- if (isDemoMode || currentUiPathname() === '/login') return;
- const refreshAuthorization = () => {
- if (document.visibilityState === 'hidden') return;
- void refreshCurrentUser().catch(error => {
- console.error('Failed to synchronize instance authorization:', error);
- });
- };
- const handleVisibilityChange = () => {
- if (document.visibilityState === 'visible') refreshAuthorization();
- };
- const interval = window.setInterval(refreshAuthorization, AUTHORIZATION_REFRESH_INTERVAL_MS);
- window.addEventListener('focus', refreshAuthorization);
- document.addEventListener('visibilitychange', handleVisibilityChange);
- return () => {
- window.clearInterval(interval);
- window.removeEventListener('focus', refreshAuthorization);
- document.removeEventListener('visibilitychange', handleVisibilityChange);
- };
- }, [isDemoMode, refreshCurrentUser]);
-
-
- // Render spinner while checking auth
- if (isDemoModeLoading || isLoading) return ;
-
- return (
-
+ // Mount with the connected desktop surface for lifecycle attribution, but keep it disabled until
+ // current-user bootstrap succeeds so a revoked activation never opens a second socket.
+ const content = isDemoModeLoading || isInitialLoading ? : (
@@ -360,6 +288,20 @@ const AppContent: React.FC = () => {
+ );
+
+ const disableReasons = {
+ demoModeLoading: isDemoModeLoading,
+ demoMode: isDemoMode,
+ currentUserLoading,
+ currentUserAbsent,
+ };
+ return (
+
+ {content}
);
};
diff --git a/propr-ui/src/api/currentUserResponse.test.ts b/propr-ui/src/api/currentUserResponse.test.ts
new file mode 100644
index 000000000..13c075798
--- /dev/null
+++ b/propr-ui/src/api/currentUserResponse.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX } from '../desktop/packagedAcceptanceCurrentUserValidation';
+import { getCurrentUser, isCurrentUserResponse } from './proprApi';
+
+const apiFetch = vi.hoisted(() => vi.fn());
+
+vi.mock('./apiClient', () => ({
+ API_BASE_URL: 'https://example.test',
+ apiFetch,
+ handleApiResponse: vi.fn(async (response: Response) => response),
+}));
+
+type AcceptanceWindow = Window & { __PROPR_PACKAGED_ACCEPTANCE__?: unknown };
+
+const validUser = {
+ id: 'user-1',
+ login: 'operator',
+ username: 'operator',
+ displayName: 'Operations',
+ email: null,
+ avatarUrl: null,
+ role: 'admin',
+ permissions: ['instance.manage_settings'],
+ authorizationSource: 'local',
+};
+
+describe('current-user response schema', () => {
+ afterEach(() => {
+ apiFetch.mockReset();
+ delete (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__;
+ vi.restoreAllMocks();
+ });
+
+ it('accepts the authenticated instance-user contract', () => {
+ expect(isCurrentUserResponse(validUser)).toBe(true);
+ });
+
+ it('rejects missing identity, unknown permissions, and unauthenticated bodies', () => {
+ expect(isCurrentUserResponse({ ...validUser, id: undefined })).toBe(false);
+ expect(isCurrentUserResponse({ ...validUser, permissions: ['instance.unknown'] })).toBe(false);
+ expect(isCurrentUserResponse({ code: 'INVALID_INSTANCE_TOKEN' })).toBe(false);
+ });
+
+ it('reports fixed accepted and rejected parse outcomes for an active scope', async () => {
+ (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ = {};
+ const info = vi.spyOn(console, 'info').mockImplementation(() => undefined);
+ apiFetch.mockResolvedValueOnce(new Response(JSON.stringify(validUser), {
+ status: 200, headers: { 'Content-Type': 'application/json' },
+ }));
+
+ await expect(getCurrentUser({ scopeGeneration: 3, activeScopePresent: true })).resolves.toEqual(validUser);
+ expect(info.mock.calls.map(call => call[1])).toEqual([
+ expect.objectContaining({ phase: 'request-issued', scopeGeneration: 3, classification: 'pending' }),
+ expect.objectContaining({ phase: 'response-completed', responseStatus: 200, classification: 'success' }),
+ expect.objectContaining({ phase: 'parsed-user-accepted', scopeGeneration: 3, schemaAccepted: true }),
+ ]);
+ expect(info.mock.calls.every(call => call[0] === PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX)).toBe(true);
+
+ info.mockClear();
+ apiFetch.mockResolvedValueOnce(new Response('{"authenticated":true}', {
+ status: 200, headers: { 'Content-Type': 'application/json' },
+ }));
+ await expect(getCurrentUser({ scopeGeneration: 3, activeScopePresent: true }))
+ .rejects.toThrow('Current-user response schema was invalid.');
+ expect(info.mock.calls.at(-1)?.[1]).toEqual(expect.objectContaining({
+ phase: 'parsed-user-rejected', classification: 'invalid-schema', schemaAccepted: false,
+ }));
+ });
+
+ it('preserves the exact hosted-web URL and no-store request contract', async () => {
+ apiFetch.mockResolvedValueOnce(new Response(JSON.stringify(validUser), {
+ status: 200, headers: { 'Content-Type': 'application/json' },
+ }));
+
+ await expect(getCurrentUser()).resolves.toEqual(validUser);
+
+ expect(apiFetch).toHaveBeenCalledOnce();
+ expect(apiFetch).toHaveBeenCalledWith('https://example.test/api/auth/user', {
+ credentials: 'include',
+ cache: 'no-store',
+ });
+ });
+
+ it('uses a bounded desktop generation cache key without fetch cache inputs', async () => {
+ apiFetch.mockImplementation(async () => new Response(JSON.stringify(validUser), {
+ status: 200, headers: { 'Content-Type': 'application/json' },
+ }));
+
+ await expect(getCurrentUser({ scopeGeneration: 4, activeScopePresent: true })).resolves.toEqual(validUser);
+ await expect(getCurrentUser({ scopeGeneration: 5, activeScopePresent: true })).resolves.toEqual(validUser);
+ await expect(getCurrentUser({
+ scopeGeneration: Number.MAX_SAFE_INTEGER + 1,
+ activeScopePresent: true,
+ })).resolves.toEqual(validUser);
+
+ expect(apiFetch).toHaveBeenCalledTimes(3);
+ expect(apiFetch.mock.calls.map(([url]) => url)).toEqual([
+ 'https://example.test/api/auth/user?proprDesktopScopeGeneration=4',
+ 'https://example.test/api/auth/user?proprDesktopScopeGeneration=5',
+ 'https://example.test/api/auth/user?proprDesktopScopeGeneration=0',
+ ]);
+ for (const [, init] of apiFetch.mock.calls) {
+ expect(init).toEqual({ credentials: 'include' });
+ expect(init).not.toHaveProperty('cache');
+ const headers = new Headers(init?.headers);
+ expect(headers.has('Cache-Control')).toBe(false);
+ expect(headers.has('Pragma')).toBe(false);
+ }
+ });
+});
diff --git a/propr-ui/src/api/demoMode.test.ts b/propr-ui/src/api/demoMode.test.ts
index 316a2ec0c..694ffc3dd 100644
--- a/propr-ui/src/api/demoMode.test.ts
+++ b/propr-ui/src/api/demoMode.test.ts
@@ -4,6 +4,7 @@ import {
apiFetch,
CommittedConfigWriteError,
getDemoModeStatus,
+ getCurrentUser,
handleApiResponse,
handleDesktopAccessCode,
INSTANCE_AUTHORIZATION_CHANGED_EVENT,
@@ -231,6 +232,60 @@ describe('demo mode API helpers', () => {
expect(fetchMock).toHaveBeenCalledOnce();
});
+ it('preserves hosted current-user cache bypass without a desktop query or scope header', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
+ id: 'user-1',
+ login: 'operator',
+ username: 'operator',
+ displayName: 'Operations',
+ email: null,
+ avatarUrl: null,
+ role: 'admin',
+ permissions: ['instance.manage_settings'],
+ authorizationSource: 'local',
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ await getCurrentUser();
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [input, init] = fetchMock.mock.calls[0];
+ expect(input).toBe('/api/auth/user');
+ expect(init).toEqual({ credentials: 'include', cache: 'no-store' });
+ expect(new URLSearchParams(String(input).split('?')[1])).toEqual(new URLSearchParams());
+ expect(new Headers(init?.headers).has('X-ProPR-Desktop-Transport-Scope')).toBe(false);
+ });
+
+ it('sends scoped current-user GETs without browser cache-control inputs', async () => {
+ setApiBaseUrl('https://example.test');
+ setDesktopConnectionScope({
+ bridge: {} as never,
+ profileId: 'profile-a',
+ transportScope: 'SSSSSSSSSSSSSSSSSSSSSS',
+ });
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
+ id: 'user-1',
+ login: 'operator',
+ username: 'operator',
+ displayName: 'Operations',
+ email: null,
+ avatarUrl: null,
+ role: 'admin',
+ permissions: ['instance.manage_settings'],
+ authorizationSource: 'local',
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ await getCurrentUser({ scopeGeneration: 7, activeScopePresent: true });
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [input, init] = fetchMock.mock.calls[0];
+ expect(input).toBe('https://example.test/api/auth/user?proprDesktopScopeGeneration=7');
+ expect(init?.cache).toBeUndefined();
+ const headers = new Headers(init?.headers);
+ expect(headers.get('X-ProPR-Desktop-Transport-Scope')).toBe('SSSSSSSSSSSSSSSSSSSSSS');
+ expect(headers.has('Cache-Control')).toBe(false);
+ expect(headers.has('Pragma')).toBe(false);
+ });
+
it('converts demo read-only 405 responses into a clear error', async () => {
const response = new Response(JSON.stringify({
code: DEMO_MODE_READ_ONLY_CODE,
diff --git a/propr-ui/src/api/proprApi.ts b/propr-ui/src/api/proprApi.ts
index d58d5ae1b..6a63e2561 100644
--- a/propr-ui/src/api/proprApi.ts
+++ b/propr-ui/src/api/proprApi.ts
@@ -2,6 +2,10 @@ import type { Task as ApiTask } from './tasks';
import { API_BASE_URL, apiFetch, handleApiResponse } from './apiClient';
import { isHostedUiOrigin, pathWithActiveHostedTunnelFlow } from '../config/runtimeConfig';
import { isAccountStatusTimestamp, isProprProxyUrl } from '@propr/shared';
+import {
+ reportPackagedAcceptanceCurrentUser,
+ type PackagedAcceptanceCurrentUserClassification,
+} from '../desktop/packagedAcceptanceCurrentUserValidation';
export * from './apiClient';
@@ -241,10 +245,103 @@ export const deleteTask = async (taskId: string, force?: boolean): Promise
await handleApiResponse(response);
};
-export const getCurrentUser = async (): Promise => {
- const response = await apiFetch(`${API_BASE_URL}/api/auth/user`, { credentials: 'include' });
+export interface CurrentUserValidationOptions {
+ scopeGeneration?: number;
+ activeScopePresent?: boolean;
+}
+
+const CURRENT_USER_SCOPE_GENERATION_QUERY = 'proprDesktopScopeGeneration';
+
+const isNullableString = (value: unknown): value is string | null => value === null || typeof value === 'string';
+
+export const isCurrentUserResponse = (value: unknown): value is CurrentUser => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const user = value as Partial;
+ const permissions = new Set(['instance.manage_agents', 'instance.manage_members', 'instance.manage_runtime', 'instance.manage_settings']);
+ return typeof user.id === 'string'
+ && typeof user.login === 'string'
+ && typeof user.username === 'string'
+ && typeof user.displayName === 'string'
+ && isNullableString(user.email)
+ && isNullableString(user.avatarUrl)
+ && (user.role === 'admin' || user.role === 'member')
+ && Array.isArray(user.permissions)
+ && user.permissions.every(permission => permissions.has(permission))
+ && ['bootstrap', 'local', 'managed', 'implicit', 'demo'].includes(user.authorizationSource ?? '');
+};
+
+const currentUserResponseClassification = async (
+ response: Response,
+): Promise => {
+ if (response.ok) return 'success';
+ if (response.status === 403) return 'forbidden';
+ if (response.status >= 500) return 'server-error';
+ if (response.status !== 401) return 'unauthenticated';
+ try {
+ const body = await response.clone().json() as { code?: unknown };
+ return ['INVALID_INSTANCE_TOKEN', 'INSTANCE_TOKEN_EXPIRED', 'INSTANCE_TOKEN_REVOKED'].includes(String(body.code))
+ ? 'revoked'
+ : 'unauthenticated';
+ } catch {
+ return 'unauthenticated';
+ }
+};
+
+export const getCurrentUser = async (options: CurrentUserValidationOptions = {}): Promise => {
+ const requestedScopeGeneration = options.scopeGeneration;
+ const scopeGeneration = typeof requestedScopeGeneration === 'number'
+ && Number.isSafeInteger(requestedScopeGeneration) && requestedScopeGeneration >= 0
+ ? requestedScopeGeneration
+ : 0;
+ const activeScopePresent = options.activeScopePresent === true;
+ const currentUserUrl = activeScopePresent
+ ? `${API_BASE_URL}/api/auth/user?${CURRENT_USER_SCOPE_GENERATION_QUERY}=${scopeGeneration}`
+ : `${API_BASE_URL}/api/auth/user`;
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'request-issued', scopeGeneration, activeScopePresent,
+ responseStatus: 0, classification: 'pending', schemaAccepted: false,
+ });
+ }
+ const response = await apiFetch(currentUserUrl, activeScopePresent
+ ? { credentials: 'include' }
+ : { credentials: 'include', cache: 'no-store' });
+ const classification = await currentUserResponseClassification(response);
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'response-completed', scopeGeneration, activeScopePresent,
+ responseStatus: response.status, classification, schemaAccepted: false,
+ });
+ }
await handleApiResponse(response);
- return response.json();
+ let body: unknown;
+ try {
+ body = await response.json();
+ } catch {
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'parsed-user-rejected', scopeGeneration, activeScopePresent,
+ responseStatus: response.status, classification: 'invalid-schema', schemaAccepted: false,
+ });
+ }
+ throw new Error('Current-user response schema was invalid.');
+ }
+ if (!isCurrentUserResponse(body)) {
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'parsed-user-rejected', scopeGeneration, activeScopePresent,
+ responseStatus: response.status, classification: 'invalid-schema', schemaAccepted: false,
+ });
+ }
+ throw new Error('Current-user response schema was invalid.');
+ }
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'parsed-user-accepted', scopeGeneration, activeScopePresent,
+ responseStatus: response.status, classification, schemaAccepted: true,
+ });
+ }
+ return body;
};
export const HOSTED_LOGOUT_FAILED_MESSAGE =
diff --git a/propr-ui/src/contexts/SocketProvider.test.tsx b/propr-ui/src/contexts/SocketProvider.test.tsx
index 9f3886e80..ceba509be 100644
--- a/propr-ui/src/contexts/SocketProvider.test.tsx
+++ b/propr-ui/src/contexts/SocketProvider.test.tsx
@@ -1,23 +1,32 @@
import { act, cleanup, render } from '@testing-library/react';
import { useEffect } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { DRAFT_UPDATE, INDEXING_UPDATE, QUEUE_STATS_UPDATE, TASK_LIVE_UPDATE, TASK_UPDATE } from '@propr/shared';
+import {
+ DESKTOP_TRANSPORT_SCOPE_QUERY,
+ DRAFT_UPDATE,
+ INDEXING_UPDATE,
+ QUEUE_STATS_UPDATE,
+ TASK_LIVE_UPDATE,
+ TASK_UPDATE,
+} from '@propr/shared';
import { SocketProvider } from './SocketProvider';
import { useSocket } from './useSocket';
type Handler = (value?: unknown) => void;
const sockets = vi.hoisted(() => [] as Array<{
handlers: Map;
+ options: Record;
connect: ReturnType;
disconnect: ReturnType;
emit: ReturnType;
on: ReturnType;
off: ReturnType;
}>);
-const connectSocketMock = vi.hoisted(() => vi.fn(() => {
+const connectSocketMock = vi.hoisted(() => vi.fn((options: Record) => {
const handlers = new Map();
const socket = {
handlers,
+ options,
connect: vi.fn(),
disconnect: vi.fn(),
emit: vi.fn(),
@@ -83,15 +92,42 @@ describe('SocketProvider', () => {
expect(connectSocketMock).not.toHaveBeenCalled();
});
+ it('constructs and explicitly connects once when bootstrap enables a published activation', () => {
+ state.scope = scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA');
+ const { rerender } = render(loading
);
+ expect(connectSocketMock).not.toHaveBeenCalled();
+
+ rerender(ready
);
+
+ expect(connectSocketMock).toHaveBeenCalledOnce();
+ expect(sockets).toHaveLength(1);
+ expect(sockets[0].connect).toHaveBeenCalledOnce();
+ });
+
it('creates one force-new scoped Manager on null-to-A activation', () => {
render(app
);
publish(scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA'));
expect(connectSocketMock).toHaveBeenCalledOnce();
expect(connectSocketMock).toHaveBeenCalledWith(expect.objectContaining({
+ autoConnect: false,
forceNew: true,
+ withCredentials: false,
query: { proprDesktopTransportScope: 'AAAAAAAAAAAAAAAAAAAAAA' },
}));
+ expect(sockets[0].connect).toHaveBeenCalledOnce();
+ expect(sockets[0].on.mock.invocationCallOrder.at(-1))
+ .toBeLessThan(sockets[0].connect.mock.invocationCallOrder[0]);
+ const options = sockets[0].options;
+ const query = options.query as Record;
+ expect(Object.keys(query)).toEqual([DESKTOP_TRANSPORT_SCOPE_QUERY]);
+ expect(query[DESKTOP_TRANSPORT_SCOPE_QUERY]).toBe('AAAAAAAAAAAAAAAAAAAAAA');
+ expect(Object.prototype.hasOwnProperty.call(options, 'auth')).toBe(false);
+ expect(Object.prototype.hasOwnProperty.call(options, 'extraHeaders')).toBe(false);
+ expect(Object.prototype.hasOwnProperty.call(options, 'bearer')).toBe(false);
+ expect(Object.prototype.hasOwnProperty.call(options, 'authorization')).toBe(false);
+ expect(Object.prototype.hasOwnProperty.call(options, 'cookie')).toBe(false);
+ expect(JSON.stringify(options)).not.toMatch(/bearer|authorization|cookie/i);
});
it.each([
@@ -173,7 +209,7 @@ describe('SocketProvider', () => {
await vi.waitFor(() => expect(handleDesktopAccessCode).toHaveBeenCalledWith(
'INVALID_INSTANCE_TOKEN', state.scope,
));
- expect(sockets[0].connect).not.toHaveBeenCalled();
+ expect(sockets[0].connect).toHaveBeenCalledOnce();
});
it('reconnects the current Manager when authorization changes without invalidating its token', async () => {
@@ -184,7 +220,7 @@ describe('SocketProvider', () => {
socketA.handlers.get('authentication:error')?.({ code: 'AUTHORIZATION_CHANGED' });
- await vi.waitFor(() => expect(socketA.connect).toHaveBeenCalledOnce());
+ await vi.waitFor(() => expect(socketA.connect).toHaveBeenCalledTimes(2));
expect(handleDesktopAccessCode).toHaveBeenCalledWith('AUTHORIZATION_CHANGED', state.scope);
expect(socketA.disconnect).toHaveBeenCalledOnce();
});
@@ -206,12 +242,12 @@ describe('SocketProvider', () => {
resolveClassification('authorization-changed');
await Promise.resolve();
- expect(socketA.connect).not.toHaveBeenCalled();
+ expect(socketA.connect).toHaveBeenCalledOnce();
expect(socketA.disconnect).toHaveBeenCalledOnce();
expect(socketA.off).toHaveBeenCalledWith('authentication:error', staleAuthenticationHandler);
expect(socketA.handlers.size).toBe(0);
expect(socketB.disconnect).not.toHaveBeenCalled();
- expect(socketB.connect).not.toHaveBeenCalled();
+ expect(socketB.connect).toHaveBeenCalledOnce();
});
it('drops every application event dispatched by a stale socket scope', () => {
diff --git a/propr-ui/src/contexts/SocketProvider.tsx b/propr-ui/src/contexts/SocketProvider.tsx
index b81ee0bf6..33d893fea 100644
--- a/propr-ui/src/contexts/SocketProvider.tsx
+++ b/propr-ui/src/contexts/SocketProvider.tsx
@@ -10,13 +10,38 @@ import {
subscribeDesktopConnectionScope,
} from '../api/apiClient';
import { isDesktopRuntime } from '../config/runtimeMode';
+import {
+ reportPackagedAcceptanceRendererLifecycle,
+ reportPackagedAcceptanceSocketConnectInvocation,
+ reportPackagedAcceptanceSocketConstructed,
+ reportPackagedAcceptanceSocketConstructionInvocation,
+} from '../desktop/packagedAcceptanceRendererLifecycle';
interface SocketProviderProps {
children: React.ReactNode;
disabled?: boolean;
+ disableReasons?: SocketProviderDisableReasons;
+}
+
+export interface SocketProviderDisableReasons {
+ demoModeLoading: boolean;
+ demoMode: boolean;
+ currentUserLoading: boolean;
+ currentUserAbsent: boolean;
}
-export const SocketProvider: React.FC = ({ children, disabled = false }) => {
+const noDisableReasons: SocketProviderDisableReasons = {
+ demoModeLoading: false,
+ demoMode: false,
+ currentUserLoading: false,
+ currentUserAbsent: false,
+};
+
+export const SocketProvider: React.FC = ({
+ children,
+ disabled = false,
+ disableReasons = noDisableReasons,
+}) => {
const [socket, setSocket] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const taskUpdateCallbacksRef = useRef void>>(new Set());
@@ -29,9 +54,31 @@ export const SocketProvider: React.FC = ({ children, disabl
getDesktopSocketConfigurationKey,
getDesktopSocketConfigurationKey,
);
+ const {
+ demoModeLoading,
+ demoMode,
+ currentUserLoading,
+ currentUserAbsent,
+ } = disableReasons;
+ useEffect(() => {
+ reportPackagedAcceptanceRendererLifecycle('socket-provider-mounted', {
+ socketProviderMounted: true,
+ });
+ }, []);
useEffect(() => {
+ const disableReasonEvidence = {
+ disabledByDemoModeLoading: demoModeLoading,
+ disabledByDemoMode: demoMode,
+ disabledByCurrentUserLoading: currentUserLoading,
+ disabledByCurrentUserAbsent: currentUserAbsent,
+ };
if (disabled) {
+ reportPackagedAcceptanceRendererLifecycle('socket-effect-disabled', {
+ providerDisabled: true,
+ desktopRuntime: Boolean(isDesktopRuntime()),
+ ...disableReasonEvidence,
+ });
setSocket(null);
setIsConnected(false);
return;
@@ -39,18 +86,38 @@ export const SocketProvider: React.FC = ({ children, disabl
const desktopScope = getDesktopConnectionScope();
if (isDesktopRuntime() && !desktopScope) {
+ reportPackagedAcceptanceRendererLifecycle('socket-effect-scope-unavailable', {
+ providerDisabled: false,
+ desktopRuntime: true,
+ connectionScope: 'unavailable',
+ ...disableReasonEvidence,
+ });
setSocket(null);
setIsConnected(false);
return;
}
setIsConnected(false);
+ reportPackagedAcceptanceRendererLifecycle('socket-effect-ready', {
+ providerDisabled: false,
+ desktopRuntime: Boolean(isDesktopRuntime()),
+ connectionScope: desktopScope ? 'available' : 'unavailable',
+ ...disableReasonEvidence,
+ });
+ reportPackagedAcceptanceSocketConstructionInvocation();
const newSocket = proprClient.connectSocket({
transports: ['websocket'],
- autoConnect: true,
+ autoConnect: false,
path: '/socket.io/',
forceNew: true,
- ...(desktopScope ? { query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: desktopScope.transportScope } } : {}),
+ ...(desktopScope ? {
+ // The renderer contributes only the opaque binding query. Browser
+ // cookies and Socket.IO auth payloads are never desktop credentials;
+ // Electron main adds the active bearer to the exact WS upgrade.
+ withCredentials: false,
+ query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: desktopScope.transportScope },
+ } : {}),
});
+ reportPackagedAcceptanceSocketConstructed();
let disposed = false;
const isCurrentScope = (): boolean => {
if (disposed) return false;
@@ -136,6 +203,14 @@ export const SocketProvider: React.FC = ({ children, disabl
newSocket.on(TASK_LIVE_UPDATE, taskLiveUpdated);
setSocket(newSocket);
+ reportPackagedAcceptanceRendererLifecycle('socket-constructed', {
+ providerDisabled: false,
+ desktopRuntime: Boolean(isDesktopRuntime()),
+ connectionScope: desktopScope ? 'available' : 'unavailable',
+ ...disableReasonEvidence,
+ });
+ reportPackagedAcceptanceSocketConnectInvocation();
+ newSocket.connect();
return () => {
console.log('[SocketContext] Cleaning up socket connection');
@@ -152,7 +227,14 @@ export const SocketProvider: React.FC = ({ children, disabl
newSocket.off(TASK_LIVE_UPDATE, taskLiveUpdated);
newSocket.disconnect();
};
- }, [disabled, socketConfigurationKey]);
+ }, [
+ currentUserAbsent,
+ currentUserLoading,
+ demoMode,
+ demoModeLoading,
+ disabled,
+ socketConfigurationKey,
+ ]);
const subscribeToTask = useCallback((taskId: string) => {
if (socket && isConnected) {
diff --git a/propr-ui/src/desktop/DesktopExperience.management.test.tsx b/propr-ui/src/desktop/DesktopExperience.management.test.tsx
index 105064d07..a7f0b2ca5 100644
--- a/propr-ui/src/desktop/DesktopExperience.management.test.tsx
+++ b/propr-ui/src/desktop/DesktopExperience.management.test.tsx
@@ -17,14 +17,38 @@ describe('DesktopExperience profile management', () => {
afterEach(() => vi.restoreAllMocks());
- it('opens instance management with the desktop shortcut and exposes connection status', async () => {
- const adapters = adaptersFor([localProfile], localProfile.id);
+ it('keeps Ctrl+, Cmd+, and reconnect immediately usable across connected-state rerenders with one listener', async () => {
+ const reconnect = deferred();
+ const probe = vi.fn()
+ .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' })
+ .mockImplementationOnce(() => reconnect.promise);
+ const addEventListener = vi.spyOn(document, 'addEventListener');
+ const removeEventListener = vi.spyOn(document, 'removeEventListener');
+ const adapters = adaptersFor([localProfile], localProfile.id, probe);
renderConnectedExperience(adapters);
expect(await screen.findByRole('button', { name: 'Connected: This computer' })).toBeInTheDocument();
+
+ const keyboardRegistrations = () => addEventListener.mock.calls.filter(([type]) => type === 'keydown');
+ const keyboardRemovals = () => removeEventListener.mock.calls.filter(([type]) => type === 'keydown');
+ expect(keyboardRegistrations()).toHaveLength(1);
+ expect(keyboardRemovals()).toHaveLength(0);
+
+ vi.mocked(adapters.connection.probe).mockClear();
+ fireEvent.keyDown(document, { key: 'r', ctrlKey: true, shiftKey: true });
+ expect(adapters.connection.probe).toHaveBeenCalledOnce();
+ expect(screen.getByRole('heading', { name: 'Connecting to This computer' })).toBeInTheDocument();
+ await act(async () => { reconnect.resolve({ status: 'ready', version: '0.8.15' }); });
+ expect(await screen.findByRole('button', { name: 'Connected: This computer' })).toBeInTheDocument();
+ expect(keyboardRegistrations()).toHaveLength(1);
+ expect(keyboardRemovals()).toHaveLength(0);
+
fireEvent.keyDown(document, { key: ',', ctrlKey: true });
- expect(await screen.findByRole('dialog', { name: 'Manage instances' })).toBeInTheDocument();
+ expect(screen.getByRole('dialog', { name: 'Manage instances' })).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+
+ fireEvent.keyDown(document, { key: ',', metaKey: true });
+ expect(screen.getByRole('dialog', { name: 'Manage instances' })).toBeInTheDocument();
});
it('traps modal focus, makes the app inert, and restores focus to the opener', async () => {
diff --git a/propr-ui/src/desktop/DesktopExperience.tsx b/propr-ui/src/desktop/DesktopExperience.tsx
index b130a0531..f196f2b33 100644
--- a/propr-ui/src/desktop/DesktopExperience.tsx
+++ b/propr-ui/src/desktop/DesktopExperience.tsx
@@ -245,18 +245,19 @@ export const DesktopExperience: React.FC = ({ adapters,
useEffect(() => {
const handleKeyboard = (event: KeyboardEvent) => {
- if (state.phase !== 'connected') return;
+ const currentState = stateRef.current;
+ if (currentState.phase !== 'connected') return;
if ((event.metaKey || event.ctrlKey) && event.key === ',') {
event.preventDefault();
openManager();
} else if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'r') {
event.preventDefault();
- void connect(state.profile);
+ void connect(currentState.profile);
}
};
document.addEventListener('keydown', handleKeyboard);
return () => document.removeEventListener('keydown', handleKeyboard);
- }, [connect, openManager, state]);
+ }, [connect, openManager]);
const removeProfile = async (profile: DesktopProfile) => {
if (!window.confirm(`Remove “${profile.name}” from this computer?`)) return;
diff --git a/propr-ui/src/desktop/electronAdapters.ts b/propr-ui/src/desktop/electronAdapters.ts
index a8c593b39..3b46be333 100644
--- a/propr-ui/src/desktop/electronAdapters.ts
+++ b/propr-ui/src/desktop/electronAdapters.ts
@@ -2,6 +2,7 @@ import { normalizeApiBaseUrl } from '@propr/client';
import type { DesktopRendererBridge } from '../../../apps/desktop/src/shared/contract';
import { getDesktopConnectionScope, setDesktopConnectionScope } from '../api/apiClient';
import type { DesktopAdapters } from './types';
+import { reportPackagedAcceptanceRendererLifecycle } from './packagedAcceptanceRendererLifecycle';
const snapshotStorage = (storage: Storage): [string, string][] => {
const snapshot: [string, string][] = [];
@@ -127,6 +128,10 @@ export const createElectronDesktopAdapters = (bridge: DesktopRendererBridge): De
profileId: result.profileId,
transportScope: result.transportScope,
}, profile.baseUrl);
+ reportPackagedAcceptanceRendererLifecycle('profile-activation-published', {
+ profileActivationPublished: true,
+ connectionScope: 'available',
+ });
publishedProfile = {
id: profile.id,
origin: normalizeApiBaseUrl(profile.baseUrl),
diff --git a/propr-ui/src/desktop/packagedAcceptanceCurrentUserValidation.ts b/propr-ui/src/desktop/packagedAcceptanceCurrentUserValidation.ts
new file mode 100644
index 000000000..d7d49fd34
--- /dev/null
+++ b/propr-ui/src/desktop/packagedAcceptanceCurrentUserValidation.ts
@@ -0,0 +1,55 @@
+export const PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX = '[ProPR Acceptance Current User]';
+
+export type PackagedAcceptanceCurrentUserPhase =
+ | 'request-issued'
+ | 'response-completed'
+ | 'parsed-user-accepted'
+ | 'parsed-user-rejected'
+ | 'active-scope-accepted'
+ | 'stale-scope-rejected'
+ | 'revoked-scope-rejected'
+ | 'active-scope-rejected';
+
+export type PackagedAcceptanceCurrentUserClassification =
+ | 'pending'
+ | 'success'
+ | 'unauthenticated'
+ | 'revoked'
+ | 'forbidden'
+ | 'server-error'
+ | 'network-error'
+ | 'invalid-schema';
+
+export interface PackagedAcceptanceCurrentUserEvidence {
+ schemaVersion: 1;
+ correlation: 'current-scope-user-validation';
+ phase: PackagedAcceptanceCurrentUserPhase;
+ scopeGeneration: number;
+ activeScopePresent: boolean;
+ responseStatus: number;
+ classification: PackagedAcceptanceCurrentUserClassification;
+ schemaAccepted: boolean;
+}
+
+type AcceptanceWindow = Window & {
+ __PROPR_PACKAGED_ACCEPTANCE__?: unknown;
+};
+
+const REPORT_LIMIT = 10;
+let reportCount = 0;
+
+const acceptanceEvidenceEnabled = (): boolean => typeof window !== 'undefined'
+ && typeof (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ === 'object'
+ && (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ !== null;
+
+export const reportPackagedAcceptanceCurrentUser = (
+ evidence: Omit,
+): void => {
+ if (!acceptanceEvidenceEnabled() || reportCount >= REPORT_LIMIT) return;
+ reportCount += 1;
+ console.info(PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX, {
+ schemaVersion: 1,
+ correlation: 'current-scope-user-validation',
+ ...evidence,
+ });
+};
diff --git a/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.test.ts b/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.test.ts
new file mode 100644
index 000000000..e9e1cbfdb
--- /dev/null
+++ b/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.test.ts
@@ -0,0 +1,95 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ PACKAGED_ACCEPTANCE_RENDERER_LIFECYCLE_PREFIX,
+ reportPackagedAcceptanceRendererLifecycle,
+ reportPackagedAcceptanceSocketConstructionInvocation,
+} from './packagedAcceptanceRendererLifecycle';
+import {
+ PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX,
+ reportPackagedAcceptanceCurrentUser,
+} from './packagedAcceptanceCurrentUserValidation';
+
+type AcceptanceWindow = Window & { __PROPR_PACKAGED_ACCEPTANCE__?: unknown };
+
+describe('packaged acceptance renderer lifecycle evidence', () => {
+ afterEach(() => {
+ delete (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__;
+ vi.restoreAllMocks();
+ });
+
+ it('is silent outside the authenticated packaged acceptance boundary', () => {
+ const info = vi.spyOn(console, 'info').mockImplementation(() => undefined);
+
+ reportPackagedAcceptanceRendererLifecycle('socket-provider-mounted', {
+ socketProviderMounted: true,
+ });
+
+ expect(info).not.toHaveBeenCalled();
+ });
+
+ it('emits only fixed bounded secret-free state', () => {
+ (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ = { setZoomFactor: vi.fn() };
+ const info = vi.spyOn(console, 'info').mockImplementation(() => undefined);
+
+ reportPackagedAcceptanceRendererLifecycle('profile-activation-published', {
+ profileActivationPublished: true,
+ connectionScope: 'available',
+ });
+ for (let index = 0; index < 20; index += 1) {
+ reportPackagedAcceptanceSocketConstructionInvocation();
+ }
+
+ const [prefix, evidence] = info.mock.calls.at(-1)!;
+ expect(info).toHaveBeenCalledTimes(12);
+ expect(prefix).toBe(PACKAGED_ACCEPTANCE_RENDERER_LIFECYCLE_PREFIX);
+ expect(Object.keys(evidence).sort()).toEqual([
+ 'connectInvocations', 'connectionScope', 'desktopRuntime', 'disabledByCurrentUserAbsent',
+ 'disabledByCurrentUserLoading', 'disabledByDemoMode', 'disabledByDemoModeLoading', 'phase',
+ 'profileActivationPublished', 'providerDisabled', 'schemaVersion', 'socketConstructionInvocations',
+ 'socketConstructions', 'socketProviderMounted',
+ ]);
+ expect(evidence).toMatchObject({
+ schemaVersion: 2,
+ phase: 'socket-construction-invoked',
+ profileActivationPublished: true,
+ connectionScope: 'available',
+ socketConstructionInvocations: 2,
+ disabledByDemoModeLoading: false,
+ disabledByDemoMode: false,
+ disabledByCurrentUserLoading: false,
+ disabledByCurrentUserAbsent: false,
+ });
+ expect(JSON.stringify(evidence)).not.toMatch(/scope-|https?:|profile-|bearer|authorization|cookie|path|error/i);
+ });
+
+ it('emits bounded current-user stages without identity or transport values', () => {
+ (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ = { setZoomFactor: vi.fn() };
+ const info = vi.spyOn(console, 'info').mockImplementation(() => undefined);
+
+ for (let index = 0; index < 20; index += 1) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'request-issued',
+ scopeGeneration: 1,
+ activeScopePresent: true,
+ responseStatus: 0,
+ classification: 'pending',
+ schemaAccepted: false,
+ });
+ }
+
+ expect(info).toHaveBeenCalledTimes(10);
+ const [prefix, evidence] = info.mock.calls.at(-1)!;
+ expect(prefix).toBe(PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX);
+ expect(evidence).toEqual({
+ schemaVersion: 1,
+ correlation: 'current-scope-user-validation',
+ phase: 'request-issued',
+ scopeGeneration: 1,
+ activeScopePresent: true,
+ responseStatus: 0,
+ classification: 'pending',
+ schemaAccepted: false,
+ });
+ expect(JSON.stringify(evidence)).not.toMatch(/https?:|profile-|bearer|authorization|cookie|user data|path/i);
+ });
+});
diff --git a/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.ts b/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.ts
new file mode 100644
index 000000000..3f3457131
--- /dev/null
+++ b/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.ts
@@ -0,0 +1,87 @@
+export const PACKAGED_ACCEPTANCE_RENDERER_LIFECYCLE_PREFIX = '[ProPR Acceptance Renderer Lifecycle]';
+
+export type PackagedAcceptanceRendererLifecyclePhase =
+ | 'profile-activation-published'
+ | 'socket-provider-mounted'
+ | 'socket-effect-disabled'
+ | 'socket-effect-scope-unavailable'
+ | 'socket-effect-ready'
+ | 'socket-construction-invoked'
+ | 'socket-constructed'
+ | 'socket-connect-invoked';
+
+export interface PackagedAcceptanceRendererLifecycleEvidence {
+ schemaVersion: 2;
+ phase: PackagedAcceptanceRendererLifecyclePhase;
+ profileActivationPublished: boolean;
+ socketProviderMounted: boolean;
+ providerDisabled: boolean;
+ disabledByDemoModeLoading: boolean;
+ disabledByDemoMode: boolean;
+ disabledByCurrentUserLoading: boolean;
+ disabledByCurrentUserAbsent: boolean;
+ desktopRuntime: boolean;
+ connectionScope: 'unknown' | 'available' | 'unavailable';
+ socketConstructionInvocations: number;
+ socketConstructions: number;
+ connectInvocations: number;
+}
+
+type AcceptanceWindow = Window & {
+ __PROPR_PACKAGED_ACCEPTANCE__?: unknown;
+};
+
+const initialEvidence: PackagedAcceptanceRendererLifecycleEvidence = {
+ schemaVersion: 2,
+ phase: 'profile-activation-published',
+ profileActivationPublished: false,
+ socketProviderMounted: false,
+ providerDisabled: false,
+ disabledByDemoModeLoading: false,
+ disabledByDemoMode: false,
+ disabledByCurrentUserLoading: false,
+ disabledByCurrentUserAbsent: false,
+ desktopRuntime: false,
+ connectionScope: 'unknown',
+ socketConstructionInvocations: 0,
+ socketConstructions: 0,
+ connectInvocations: 0,
+};
+
+let evidence = initialEvidence;
+let reportCount = 0;
+const REPORT_LIMIT = 12;
+
+const acceptanceEvidenceEnabled = (): boolean => typeof window !== 'undefined'
+ && typeof (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ === 'object'
+ && (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ !== null;
+
+const boundedCount = (value: number): number => Math.min(2, value + 1);
+
+export const reportPackagedAcceptanceRendererLifecycle = (
+ phase: PackagedAcceptanceRendererLifecyclePhase,
+ update: Partial> = {},
+): void => {
+ if (!acceptanceEvidenceEnabled() || reportCount >= REPORT_LIMIT) return;
+ reportCount += 1;
+ evidence = { ...evidence, ...update, schemaVersion: 2, phase };
+ console.info(PACKAGED_ACCEPTANCE_RENDERER_LIFECYCLE_PREFIX, evidence);
+};
+
+export const reportPackagedAcceptanceSocketConstructionInvocation = (): void => {
+ reportPackagedAcceptanceRendererLifecycle('socket-construction-invoked', {
+ socketConstructionInvocations: boundedCount(evidence.socketConstructionInvocations),
+ });
+};
+
+export const reportPackagedAcceptanceSocketConstructed = (): void => {
+ reportPackagedAcceptanceRendererLifecycle('socket-constructed', {
+ socketConstructions: boundedCount(evidence.socketConstructions),
+ });
+};
+
+export const reportPackagedAcceptanceSocketConnectInvocation = (): void => {
+ reportPackagedAcceptanceRendererLifecycle('socket-connect-invoked', {
+ connectInvocations: boundedCount(evidence.connectInvocations),
+ });
+};
diff --git a/propr-ui/src/desktop/packagedTransportSmoke.test.ts b/propr-ui/src/desktop/packagedTransportSmoke.test.ts
new file mode 100644
index 000000000..2b142ab33
--- /dev/null
+++ b/propr-ui/src/desktop/packagedTransportSmoke.test.ts
@@ -0,0 +1,138 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { DESKTOP_TRANSPORT_SCOPE_QUERY } from '@propr/shared';
+import type { DesktopRendererBridge } from '../../../apps/desktop/src/shared/contract';
+import {
+ createPackagedTransportSmokeHarness,
+ staleReconnectQuery,
+} from './packagedTransportSmoke';
+
+const smokeMocks = vi.hoisted(() => ({
+ activate: vi.fn(),
+ getCurrentUser: vi.fn(),
+ probe: vi.fn(),
+ publishActivation: vi.fn(),
+}));
+
+vi.mock('../api/proprApi', () => ({
+ getCurrentUser: smokeMocks.getCurrentUser,
+}));
+
+vi.mock('./electronAdapters', () => ({
+ createElectronDesktopAdapters: () => ({
+ connection: {
+ activate: smokeMocks.activate,
+ probe: smokeMocks.probe,
+ publishActivation: smokeMocks.publishActivation,
+ },
+ }),
+}));
+
+const profile = {
+ id: 'remote-1',
+ name: 'Operations',
+ baseUrl: 'https://operations.example.test',
+ kind: 'remote' as const,
+};
+
+const bridge = {} as DesktopRendererBridge;
+const validCurrentUser = {
+ id: 'user-1',
+ login: 'operator',
+ username: 'operator',
+ displayName: 'Operations',
+ email: null,
+ avatarUrl: null,
+ role: 'admin',
+ permissions: ['instance.manage_settings'],
+ authorizationSource: 'local',
+};
+
+describe('packaged transport smoke current-user validation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ smokeMocks.probe.mockResolvedValue({ status: 'ready', activationTicket: 'ticket' });
+ smokeMocks.activate.mockImplementation(async () => ({
+ status: 'ready',
+ profileId: profile.id,
+ transportScope: `scope-${smokeMocks.activate.mock.calls.length}`,
+ identityEpoch: `epoch-${smokeMocks.activate.mock.calls.length}`,
+ }));
+ smokeMocks.getCurrentUser.mockResolvedValue(validCurrentUser);
+ });
+
+ it('validates each published activation exactly once with canonical generations 1-3', async () => {
+ const order: string[] = [];
+ smokeMocks.probe.mockImplementation(async () => {
+ order.push('probe');
+ return { status: 'ready', activationTicket: 'ticket' };
+ });
+ smokeMocks.activate.mockImplementation(async () => {
+ order.push('activate');
+ const activation = smokeMocks.activate.mock.calls.length;
+ return {
+ status: 'ready',
+ profileId: profile.id,
+ transportScope: `scope-${activation}`,
+ identityEpoch: `epoch-${activation}`,
+ };
+ });
+ smokeMocks.publishActivation.mockImplementation(() => { order.push('publish'); });
+ smokeMocks.getCurrentUser.mockImplementation(async ({ scopeGeneration }) => {
+ order.push(`current-user-${scopeGeneration}`);
+ return validCurrentUser;
+ });
+ const harness = createPackagedTransportSmokeHarness(bridge);
+
+ await harness.activate(profile);
+ await harness.activate(profile);
+ await harness.activate(profile);
+
+ expect(smokeMocks.getCurrentUser.mock.calls.map(([options]) => options)).toEqual([
+ { scopeGeneration: 1, activeScopePresent: true },
+ { scopeGeneration: 2, activeScopePresent: true },
+ { scopeGeneration: 3, activeScopePresent: true },
+ ]);
+ expect(order).toEqual([
+ 'probe', 'activate', 'publish', 'current-user-1',
+ 'probe', 'activate', 'publish', 'current-user-2',
+ 'probe', 'activate', 'publish', 'current-user-3',
+ ]);
+ });
+
+ it('fails activation when production current-user schema validation rejects', async () => {
+ smokeMocks.getCurrentUser.mockRejectedValue(
+ new Error('Current-user response schema was invalid.'),
+ );
+ const harness = createPackagedTransportSmokeHarness(bridge);
+
+ await expect(harness.activate(profile))
+ .rejects.toThrow('Current-user response schema was invalid.');
+
+ expect(smokeMocks.publishActivation).toHaveBeenCalledOnce();
+ expect(smokeMocks.getCurrentUser).toHaveBeenCalledOnce();
+ expect(smokeMocks.getCurrentUser).toHaveBeenCalledWith({
+ scopeGeneration: 1,
+ activeScopePresent: true,
+ });
+ });
+});
+
+describe('packaged transport smoke scope query', () => {
+ it('preserves the recorded stale scope instead of substituting the current scope', () => {
+ const recordedStaleScope = 'AAAAAAAAAAAAAAAAAAAAAA';
+ const freshCurrentScope = 'BBBBBBBBBBBBBBBBBBBBBB';
+
+ const query = staleReconnectQuery(recordedStaleScope, freshCurrentScope);
+
+ expect(Object.keys(query)).toEqual([DESKTOP_TRANSPORT_SCOPE_QUERY]);
+ expect(query[DESKTOP_TRANSPORT_SCOPE_QUERY]).toBe(recordedStaleScope);
+ expect(query[DESKTOP_TRANSPORT_SCOPE_QUERY]).not.toBe(freshCurrentScope);
+ });
+
+ it('rejects a stale-boundary check without an actual scope rotation', () => {
+ expect(() => staleReconnectQuery(
+ 'AAAAAAAAAAAAAAAAAAAAAA',
+ 'AAAAAAAAAAAAAAAAAAAAAA',
+ )).toThrow('Packaged stale Socket.IO activation was not rotated');
+ });
+});
diff --git a/propr-ui/src/desktop/packagedTransportSmoke.ts b/propr-ui/src/desktop/packagedTransportSmoke.ts
index 043811aa7..a5a6f67ca 100644
--- a/propr-ui/src/desktop/packagedTransportSmoke.ts
+++ b/propr-ui/src/desktop/packagedTransportSmoke.ts
@@ -6,7 +6,9 @@ import {
handleDesktopAccessCode,
proprClient,
} from '../api/apiClient';
+import { getCurrentUser } from '../api/proprApi';
import { createElectronDesktopAdapters } from './electronAdapters';
+import type { DesktopRendererBridge } from '../../../apps/desktop/src/shared/contract';
import type { DesktopProfile } from './types';
interface SocketRecord {
@@ -15,10 +17,6 @@ interface SocketRecord {
transportScope: string;
}
-interface SocketConnectionError extends Error {
- data?: { code?: unknown };
-}
-
interface PackagedTransportSmokeHarness {
activate(profile: DesktopProfile): Promise<{
profileId: string;
@@ -29,7 +27,10 @@ interface PackagedTransportSmokeHarness {
rest(): Promise;
connectSocket(): Promise;
reconnectSocket(id: number): Promise;
- expectSocketRejected(id: number): Promise;
+ expectSocketRejected(id: number): Promise<{
+ transportRejected: true;
+ freshManagerConnected: true;
+ }>;
disconnectSocket(id: number): void;
handleStaleInvalidation(profileId: string, transportScope: string): Promise;
rendererEvidence(): unknown;
@@ -41,7 +42,24 @@ declare global {
}
}
-const INVALID_INSTANCE_TOKEN = 'INVALID_INSTANCE_TOKEN';
+const rendererSocketOptions = (transportScope: string) => ({
+ transports: ['websocket'] as ['websocket'],
+ forceNew: true,
+ reconnection: true,
+ withCredentials: false,
+ query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: transportScope },
+});
+
+/** Keep the rejected reconnect bound to the immutable scope recorded for that Manager. */
+export const staleReconnectQuery = (
+ recordedTransportScope: string,
+ currentTransportScope: string,
+): Record => {
+ if (recordedTransportScope === currentTransportScope) {
+ throw new Error('Packaged stale Socket.IO activation was not rotated');
+ }
+ return { [DESKTOP_TRANSPORT_SCOPE_QUERY]: recordedTransportScope };
+};
const waitForSocket = (socket: Socket, expected: 'connect' | 'connect_error'): Promise =>
new Promise((resolve, reject) => {
@@ -54,12 +72,10 @@ const waitForSocket = (socket: Socket, expected: 'connect' | 'connect_error'): P
if (expected === 'connect') resolve();
else reject(new Error('Stale Socket.IO scope unexpectedly connected'));
};
- const failed = (error: SocketConnectionError) => {
+ const failed = () => {
cleanup();
if (expected !== 'connect_error') {
- reject(new Error(`Packaged Socket.IO connection failed: ${error.message}`));
- } else if (error.message !== INVALID_INSTANCE_TOKEN || error.data?.code !== INVALID_INSTANCE_TOKEN) {
- reject(new Error('Packaged stale Socket.IO rejection was not INVALID_INSTANCE_TOKEN'));
+ reject(new Error('Packaged Socket.IO connection failed'));
} else {
resolve();
}
@@ -74,12 +90,13 @@ const waitForSocket = (socket: Socket, expected: 'connect' | 'connect_error'): P
});
/** Packaged-only E2E driver composed from the production renderer adapters. */
-export const installPackagedTransportSmokeHarness = (): void => {
- const bridge = window.__PROPR_DESKTOP__;
- if (!bridge) throw new Error('Packaged renderer bridge is unavailable');
+export const createPackagedTransportSmokeHarness = (
+ bridge: DesktopRendererBridge,
+): PackagedTransportSmokeHarness => {
const adapters = createElectronDesktopAdapters(bridge);
const sockets = new Map();
let nextSocketId = 1;
+ let nextCurrentUserScopeGeneration = 1;
const harness: PackagedTransportSmokeHarness = {
async activate(profile) {
@@ -93,6 +110,9 @@ export const installPackagedTransportSmokeHarness = (): void => {
throw new Error('Packaged desktop activation failed');
}
adapters.connection.publishActivation(profile, activated);
+ const scopeGeneration = nextCurrentUserScopeGeneration;
+ nextCurrentUserScopeGeneration += 1;
+ await getCurrentUser({ scopeGeneration, activeScopePresent: true });
return {
profileId: activated.profileId,
transportScope: activated.transportScope,
@@ -109,13 +129,7 @@ export const installPackagedTransportSmokeHarness = (): void => {
async connectSocket() {
const scope = getDesktopConnectionScope();
if (!scope) throw new Error('Packaged Socket.IO scope is unavailable');
- const socket = proprClient.connectSocket({
- transports: ['websocket'],
- forceNew: true,
- reconnection: true,
- auth: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: scope.transportScope },
- query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: scope.transportScope },
- });
+ const socket = proprClient.connectSocket(rendererSocketOptions(scope.transportScope));
const id = nextSocketId++;
sockets.set(id, { socket, profileId: scope.profileId, transportScope: scope.transportScope });
await waitForSocket(socket, 'connect');
@@ -137,7 +151,10 @@ export const installPackagedTransportSmokeHarness = (): void => {
throw new Error('Packaged stale Socket.IO activation was not rotated');
}
record.socket.disconnect();
- record.socket.io.opts.query = { [DESKTOP_TRANSPORT_SCOPE_QUERY]: currentScope.transportScope };
+ record.socket.io.opts.query = staleReconnectQuery(
+ record.transportScope,
+ currentScope.transportScope,
+ );
const rejected = waitForSocket(record.socket, 'connect_error');
try {
record.socket.connect();
@@ -145,6 +162,16 @@ export const installPackagedTransportSmokeHarness = (): void => {
} finally {
record.socket.disconnect();
}
+
+ // A distinct Manager carrying only the newly activated opaque scope must
+ // still succeed after main rejects the recorded stale scope above.
+ const freshSocket = proprClient.connectSocket(rendererSocketOptions(currentScope.transportScope));
+ try {
+ await waitForSocket(freshSocket, 'connect');
+ } finally {
+ freshSocket.disconnect();
+ }
+ return { transportRejected: true, freshManagerConnected: true };
},
disconnectSocket(id) { sockets.get(id)?.socket.disconnect(); },
handleStaleInvalidation(profileId, transportScope) {
@@ -161,6 +188,13 @@ export const installPackagedTransportSmokeHarness = (): void => {
};
},
};
+ return harness;
+};
+
+export const installPackagedTransportSmokeHarness = (): void => {
+ const bridge = window.__PROPR_DESKTOP__;
+ if (!bridge) throw new Error('Packaged renderer bridge is unavailable');
+ const harness = createPackagedTransportSmokeHarness(bridge);
Object.defineProperty(window, '__proprPackagedTransportSmoke', {
configurable: false,
enumerable: false,
diff --git a/propr-ui/src/hooks/useCurrentUserBootstrap.mainProxy.test.tsx b/propr-ui/src/hooks/useCurrentUserBootstrap.mainProxy.test.tsx
new file mode 100644
index 000000000..261ee06c9
--- /dev/null
+++ b/propr-ui/src/hooks/useCurrentUserBootstrap.mainProxy.test.tsx
@@ -0,0 +1,97 @@
+import { cleanup, renderHook, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DESKTOP_TRANSPORT_SCOPE_HEADER } from '@propr/shared';
+import { setApiBaseUrl, setDesktopConnectionScope } from '../api/apiClient';
+import { PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX } from '../desktop/packagedAcceptanceCurrentUserValidation';
+import { useCurrentUserBootstrap } from './useCurrentUserBootstrap';
+
+vi.mock('../config/runtimeMode', () => ({
+ currentUiPathname: () => '/',
+ isDesktopRuntime: () => true,
+}));
+
+type AcceptanceWindow = Window & { __PROPR_PACKAGED_ACCEPTANCE__?: unknown };
+
+const scope = 'SSSSSSSSSSSSSSSSSSSSSS';
+const user = {
+ id: 'acceptance-user',
+ login: 'acceptance-admin',
+ username: 'acceptance-admin',
+ displayName: 'Acceptance Admin',
+ email: null,
+ avatarUrl: null,
+ role: 'admin' as const,
+ permissions: ['instance.manage_settings' as const],
+ authorizationSource: 'local' as const,
+};
+
+describe('desktop current-user bootstrap main-proxy integration', () => {
+ afterEach(() => {
+ cleanup();
+ setDesktopConnectionScope(null);
+ setApiBaseUrl('');
+ delete (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__;
+ vi.restoreAllMocks();
+ });
+
+ it('correlates one fixed-alias React bootstrap request through apiFetch and main-only bearer custody', async () => {
+ (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ = {};
+ setDesktopConnectionScope({
+ bridge: {} as never,
+ profileId: 'profile-a',
+ transportScope: scope,
+ }, 'http://127.0.0.2:41731');
+
+ const mainProxyRecords: Array> = [];
+ const upstreamRecords: Array> = [];
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
+ const url = new URL(input.toString());
+ const rendererHeaders = new Headers(init?.headers);
+ const generation = url.searchParams.get('proprDesktopScopeGeneration');
+ const mainAccepted = (init?.method ?? 'GET') === 'GET'
+ && url.pathname === '/api/auth/user'
+ && generation === '1'
+ && [...url.searchParams].length === 1
+ && rendererHeaders.get(DESKTOP_TRANSPORT_SCOPE_HEADER) === scope
+ && !rendererHeaders.has('Authorization')
+ && !rendererHeaders.has('Cookie');
+ mainProxyRecords.push({ generation, mainAccepted, rendererBearerPresent: false, rendererCookiePresent: false });
+ if (!mainAccepted) return new Response(null, { status: 401 });
+
+ const outboundHeaders = new Headers(rendererHeaders);
+ outboundHeaders.delete(DESKTOP_TRANSPORT_SCOPE_HEADER);
+ outboundHeaders.set('Authorization', 'Bearer main-injected');
+ upstreamRecords.push({
+ generation,
+ authorization: outboundHeaders.get('Authorization'),
+ cookiePresent: outboundHeaders.has('Cookie'),
+ });
+ return new Response(JSON.stringify(user), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ });
+ const info = vi.spyOn(console, 'info').mockImplementation(() => undefined);
+
+ const { result } = renderHook(() => useCurrentUserBootstrap({
+ isDemoMode: false,
+ isDemoModeLoading: false,
+ }));
+
+ await waitFor(() => expect(result.current.currentUser).toEqual(user));
+ expect(fetchMock).toHaveBeenCalledOnce();
+ expect(mainProxyRecords).toEqual([{
+ generation: '1', mainAccepted: true, rendererBearerPresent: false, rendererCookiePresent: false,
+ }]);
+ expect(upstreamRecords).toEqual([{
+ generation: '1', authorization: 'Bearer main-injected', cookiePresent: false,
+ }]);
+ expect(info.mock.calls.filter(call => call[0] === PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX)
+ .map(call => call[1])).toEqual([
+ expect.objectContaining({ phase: 'request-issued', scopeGeneration: 1, activeScopePresent: true }),
+ expect.objectContaining({ phase: 'response-completed', scopeGeneration: 1, responseStatus: 200 }),
+ expect.objectContaining({ phase: 'parsed-user-accepted', scopeGeneration: 1, schemaAccepted: true }),
+ expect.objectContaining({ phase: 'active-scope-accepted', scopeGeneration: 1, schemaAccepted: true }),
+ ]);
+ });
+});
diff --git a/propr-ui/src/hooks/useCurrentUserBootstrap.test.tsx b/propr-ui/src/hooks/useCurrentUserBootstrap.test.tsx
new file mode 100644
index 000000000..5cda0f4ed
--- /dev/null
+++ b/propr-ui/src/hooks/useCurrentUserBootstrap.test.tsx
@@ -0,0 +1,349 @@
+import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react';
+import { StrictMode } from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { CurrentUser } from '../api/proprTypes';
+import { SocketProvider } from '../contexts/SocketProvider';
+import { useCurrentUserBootstrap } from './useCurrentUserBootstrap';
+
+interface Deferred {
+ promise: Promise;
+ resolve: (value: T) => void;
+ reject: (error: Error) => void;
+}
+
+const deferred = (): Deferred => {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
+const scopeListeners = vi.hoisted(() => new Set<() => void>());
+const state = vi.hoisted(() => ({
+ scope: null as null | { bridge: never; profileId: string; transportScope: string },
+}));
+const getCurrentUser = vi.hoisted(() => vi.fn());
+const sockets = vi.hoisted(() => [] as Array<{
+ connect: ReturnType;
+ disconnect: ReturnType;
+ on: ReturnType;
+ off: ReturnType;
+}>);
+const connectSocket = vi.hoisted(() => vi.fn(() => {
+ const socket = {
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ emit: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ };
+ sockets.push(socket);
+ return socket;
+}));
+
+vi.mock('../api/proprApi', () => ({
+ getCurrentUser,
+ INSTANCE_AUTHORIZATION_CHANGED_EVENT: 'propr:instance-authorization-changed',
+}));
+
+vi.mock('../api/apiClient', () => ({
+ getDesktopConnectionScope: () => state.scope,
+ getDesktopSocketConfigurationKey: () =>
+ `desktop\u0000https://example.test\u0000${state.scope?.profileId ?? ''}\u0000${state.scope?.transportScope ?? ''}`,
+ subscribeDesktopConnectionScope: (listener: () => void) => {
+ scopeListeners.add(listener);
+ return () => scopeListeners.delete(listener);
+ },
+ proprClient: { connectSocket },
+ handleDesktopAccessCode: vi.fn(async () => 'retryable'),
+}));
+
+vi.mock('../config/runtimeMode', () => ({
+ currentUiPathname: () => '/',
+ isDesktopRuntime: () => true,
+}));
+
+const user: CurrentUser = {
+ id: 'user-1',
+ login: 'operator',
+ username: 'operator',
+ displayName: 'Operations',
+ email: null,
+ avatarUrl: null,
+ role: 'admin',
+ permissions: ['instance.manage_settings'],
+ authorizationSource: 'local',
+};
+
+const publishScope = (scope: typeof state.scope): void => {
+ act(() => {
+ state.scope = scope;
+ scopeListeners.forEach(listener => listener());
+ });
+};
+
+const activeScope = {
+ bridge: {} as never,
+ profileId: 'profile-a',
+ transportScope: 'AAAAAAAAAAAAAAAAAAAAAA',
+};
+
+describe('desktop current-user bootstrap', () => {
+ afterEach(() => {
+ cleanup();
+ state.scope = null;
+ scopeListeners.clear();
+ getCurrentUser.mockReset();
+ connectSocket.mockClear();
+ sockets.splice(0);
+ vi.restoreAllMocks();
+ });
+
+ it('does not let a pre-scope bootstrap suppress or overwrite activation validation', async () => {
+ const initial = deferred();
+ const activated = deferred();
+ getCurrentUser.mockReturnValueOnce(initial.promise).mockReturnValueOnce(activated.promise);
+
+ const { result } = renderHook(() => useCurrentUserBootstrap({
+ isDemoMode: false,
+ isDemoModeLoading: false,
+ }));
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledOnce());
+ expect(getCurrentUser).toHaveBeenNthCalledWith(1, {
+ activeScopePresent: false,
+ scopeGeneration: 0,
+ });
+
+ publishScope(activeScope);
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(2));
+ expect(getCurrentUser).toHaveBeenNthCalledWith(2, {
+ activeScopePresent: true,
+ scopeGeneration: 1,
+ });
+ expect(result.current.currentUserLoading).toBe(true);
+
+ activated.resolve(user);
+ await waitFor(() => expect(result.current.currentUser).toEqual(user));
+ expect(result.current.currentUserAbsent).toBe(false);
+
+ initial.reject(new Error('Desktop authentication is required.'));
+ await act(async () => { await initial.promise.catch(() => undefined); });
+ expect(result.current.currentUser).toEqual(user);
+
+ publishScope(activeScope);
+ expect(getCurrentUser).toHaveBeenCalledTimes(2);
+ });
+
+ it('rejects an ABA response when the same scope key is republished at a newer generation', async () => {
+ const initial = deferred();
+ const oldA = deferred();
+ const scopeB = deferred();
+ const currentA = deferred();
+ getCurrentUser
+ .mockReturnValueOnce(initial.promise)
+ .mockReturnValueOnce(oldA.promise)
+ .mockReturnValueOnce(scopeB.promise)
+ .mockReturnValueOnce(currentA.promise);
+ const { result } = renderHook(() => useCurrentUserBootstrap({
+ isDemoMode: false,
+ isDemoModeLoading: false,
+ }));
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledOnce());
+
+ publishScope(activeScope);
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(2));
+ publishScope({ ...activeScope, profileId: 'profile-b', transportScope: 'BBBBBBBBBBBBBBBBBBBBBB' });
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(3));
+ publishScope(activeScope);
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(4));
+
+ oldA.resolve(user);
+ await act(async () => { await oldA.promise; });
+ expect(result.current.currentUser).toBeNull();
+
+ currentA.resolve(user);
+ await waitFor(() => expect(result.current.currentUser).toEqual(user));
+ initial.reject(new Error('Desktop authentication is required.'));
+ scopeB.reject(new Error('Desktop authentication is required.'));
+ await act(async () => { await Promise.allSettled([initial.promise, scopeB.promise]); });
+ expect(result.current.currentUser).toEqual(user);
+ });
+
+ it('mounts an active scope at generation one and enables exactly one stable Manager after current validation', async () => {
+ const mountedA = deferred();
+ const scopeB = deferred();
+ const currentA = deferred();
+ const refreshedA = deferred();
+ const staleMountedUser = { ...user, id: 'stale-mounted-a' };
+ const staleScopeBUser = { ...user, id: 'stale-scope-b' };
+ getCurrentUser
+ .mockReturnValueOnce(mountedA.promise)
+ .mockReturnValueOnce(scopeB.promise)
+ .mockReturnValueOnce(currentA.promise)
+ .mockReturnValueOnce(refreshedA.promise);
+ state.scope = activeScope;
+
+ let latestBootstrap: ReturnType | undefined;
+ const Harness = () => {
+ const bootstrap = useCurrentUserBootstrap({ isDemoMode: false, isDemoModeLoading: false });
+ latestBootstrap = bootstrap;
+ const disableReasons = {
+ demoModeLoading: false,
+ demoMode: false,
+ currentUserLoading: bootstrap.currentUserLoading,
+ currentUserAbsent: bootstrap.currentUserAbsent,
+ };
+ return (
+
+ app
+
+ );
+ };
+
+ render();
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledOnce());
+ expect(getCurrentUser).toHaveBeenNthCalledWith(1, {
+ activeScopePresent: true,
+ scopeGeneration: 1,
+ });
+ expect(connectSocket).not.toHaveBeenCalled();
+
+ publishScope({ ...activeScope, profileId: 'profile-b', transportScope: 'BBBBBBBBBBBBBBBBBBBBBB' });
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(2));
+ expect(getCurrentUser).toHaveBeenNthCalledWith(2, {
+ activeScopePresent: true,
+ scopeGeneration: 2,
+ });
+ publishScope(activeScope);
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(3));
+ expect(getCurrentUser).toHaveBeenNthCalledWith(3, {
+ activeScopePresent: true,
+ scopeGeneration: 3,
+ });
+
+ currentA.resolve(user);
+ await waitFor(() => expect(latestBootstrap?.currentUser).toEqual(user));
+ await waitFor(() => expect(connectSocket).toHaveBeenCalledOnce());
+ expect(sockets[0].connect).toHaveBeenCalledOnce();
+
+ mountedA.resolve(staleMountedUser);
+ scopeB.resolve(staleScopeBUser);
+ await act(async () => { await Promise.all([mountedA.promise, scopeB.promise]); });
+ expect(latestBootstrap?.currentUser).toEqual(user);
+ expect(connectSocket).toHaveBeenCalledOnce();
+ expect(sockets[0].connect).toHaveBeenCalledOnce();
+
+ act(() => { window.dispatchEvent(new Event('propr:instance-authorization-changed')); });
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(4));
+ expect(getCurrentUser).toHaveBeenNthCalledWith(4, {
+ activeScopePresent: true,
+ scopeGeneration: 3,
+ });
+ refreshedA.resolve(user);
+ await act(async () => { await refreshedA.promise; });
+ expect(latestBootstrap?.currentUser).toEqual(user);
+ expect(connectSocket).toHaveBeenCalledOnce();
+ expect(sockets[0].connect).toHaveBeenCalledOnce();
+ expect(sockets[0].disconnect).not.toHaveBeenCalled();
+ });
+
+ it('waits for demo-mode loading before one active-on-mount validation and Manager in StrictMode', async () => {
+ getCurrentUser.mockResolvedValue(user);
+ state.scope = activeScope;
+
+ const Harness = ({ isDemoModeLoading }: { isDemoModeLoading: boolean }) => {
+ const bootstrap = useCurrentUserBootstrap({ isDemoMode: false, isDemoModeLoading });
+ const disableReasons = {
+ demoModeLoading: isDemoModeLoading,
+ demoMode: false,
+ currentUserLoading: bootstrap.currentUserLoading,
+ currentUserAbsent: bootstrap.currentUserAbsent,
+ };
+ return (
+
+ app
+
+ );
+ };
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ // Let an immediately resolved local validation fully settle if an effect
+ // incorrectly owns active-scope bootstrap while demo mode is still loading.
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(getCurrentUser).not.toHaveBeenCalled();
+ expect(connectSocket).not.toHaveBeenCalled();
+
+ rerender(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(connectSocket).toHaveBeenCalledOnce());
+ expect(getCurrentUser).toHaveBeenCalledOnce();
+ expect(getCurrentUser).toHaveBeenCalledWith({
+ activeScopePresent: true,
+ scopeGeneration: 1,
+ });
+ expect(sockets).toHaveLength(1);
+ expect(sockets[0].connect).toHaveBeenCalledOnce();
+ });
+
+ it('constructs once after activated validation and removes that Manager when revalidation fails', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ getCurrentUser
+ .mockRejectedValueOnce(new Error('Desktop authentication is required.'))
+ .mockResolvedValueOnce(user)
+ .mockResolvedValueOnce(user)
+ .mockRejectedValueOnce(new Error('This desktop connection was revoked or expired.'));
+
+ let latestBootstrap: ReturnType | undefined;
+ const Harness = () => {
+ const bootstrap = useCurrentUserBootstrap({ isDemoMode: false, isDemoModeLoading: false });
+ latestBootstrap = bootstrap;
+ const disableReasons = {
+ demoModeLoading: false,
+ demoMode: false,
+ currentUserLoading: bootstrap.currentUserLoading,
+ currentUserAbsent: bootstrap.currentUserAbsent,
+ };
+ return (
+
+ app
+
+ );
+ };
+
+ render();
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledOnce());
+ await waitFor(() => expect(latestBootstrap?.isInitialLoading).toBe(false));
+ expect(latestBootstrap?.currentUserAbsent).toBe(true);
+ expect(connectSocket).not.toHaveBeenCalled();
+
+ publishScope(activeScope);
+ await waitFor(() => expect(connectSocket).toHaveBeenCalledOnce());
+ expect(sockets[0].connect).toHaveBeenCalledOnce();
+
+ act(() => { window.dispatchEvent(new Event('propr:instance-authorization-changed')); });
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(3));
+ expect(connectSocket).toHaveBeenCalledOnce();
+ expect(sockets[0].connect).toHaveBeenCalledOnce();
+ expect(sockets[0].disconnect).not.toHaveBeenCalled();
+
+ act(() => { window.dispatchEvent(new Event('propr:instance-authorization-changed')); });
+ await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(4));
+ await waitFor(() => expect(sockets[0].disconnect).toHaveBeenCalledOnce());
+ expect(connectSocket).toHaveBeenCalledOnce();
+ });
+});
diff --git a/propr-ui/src/hooks/useCurrentUserBootstrap.ts b/propr-ui/src/hooks/useCurrentUserBootstrap.ts
new file mode 100644
index 000000000..1b46e5d9c
--- /dev/null
+++ b/propr-ui/src/hooks/useCurrentUserBootstrap.ts
@@ -0,0 +1,210 @@
+import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
+import type { CurrentUser } from '../api/proprTypes';
+import { getCurrentUser, INSTANCE_AUTHORIZATION_CHANGED_EVENT } from '../api/proprApi';
+import {
+ getDesktopConnectionScope,
+ getDesktopSocketConfigurationKey,
+ subscribeDesktopConnectionScope,
+} from '../api/apiClient';
+import { currentUiPathname, isDesktopRuntime } from '../config/runtimeMode';
+import { reportPackagedAcceptanceCurrentUser } from '../desktop/packagedAcceptanceCurrentUserValidation';
+
+const AUTHORIZATION_REFRESH_INTERVAL_MS = 60_000;
+
+interface CurrentUserBootstrapOptions {
+ isDemoMode: boolean;
+ isDemoModeLoading: boolean;
+}
+
+interface CurrentUserBootstrapResult {
+ currentUser: CurrentUser | null;
+ currentUserAbsent: boolean;
+ currentUserLoading: boolean;
+ isInitialLoading: boolean;
+ refreshCurrentUser: () => Promise;
+}
+
+interface RefreshRequest {
+ configurationKey: string;
+ scopeGeneration: number;
+ promise: Promise;
+}
+
+/**
+ * Binds authenticated renderer state to the desktop scope that was actually
+ * validated. Scope publication can supersede one pre-activation request, while
+ * same-scope callers share exactly one validation request.
+ */
+export const useCurrentUserBootstrap = ({
+ isDemoMode,
+ isDemoModeLoading,
+}: CurrentUserBootstrapOptions): CurrentUserBootstrapResult => {
+ const desktopRuntime = isDesktopRuntime();
+ const socketConfigurationKey = useSyncExternalStore(
+ subscribeDesktopConnectionScope,
+ getDesktopSocketConfigurationKey,
+ getDesktopSocketConfigurationKey,
+ );
+ const currentConfigurationKey = desktopRuntime ? socketConfigurationKey : 'browser';
+ const currentConfigurationKeyRef = useRef(currentConfigurationKey);
+ const scopeGenerationRef = useRef({
+ configurationKey: currentConfigurationKey,
+ generation: desktopRuntime && getDesktopConnectionScope() !== null ? 1 : 0,
+ });
+ if (scopeGenerationRef.current.configurationKey !== currentConfigurationKey) {
+ scopeGenerationRef.current = {
+ configurationKey: currentConfigurationKey,
+ generation: scopeGenerationRef.current.generation + 1,
+ };
+ }
+ currentConfigurationKeyRef.current = currentConfigurationKey;
+
+ const [isInitialLoading, setIsInitialLoading] = useState(currentUiPathname() !== '/login');
+ const [currentUser, setCurrentUser] = useState(null);
+ const [validatedConfigurationKey, setValidatedConfigurationKey] = useState(null);
+ const [validatedScopeGeneration, setValidatedScopeGeneration] = useState(null);
+ const [refreshingScope, setRefreshingScope] = useState<{
+ configurationKey: string;
+ scopeGeneration: number;
+ } | null>(null);
+ const refreshRequestRef = useRef(null);
+
+ const refreshCurrentUser = useCallback((): Promise => {
+ const configurationKey = currentConfigurationKeyRef.current;
+ const scopeGeneration = scopeGenerationRef.current.generation;
+ const activeScopePresent = desktopRuntime && getDesktopConnectionScope() !== null;
+ const existing = refreshRequestRef.current;
+ if (existing?.configurationKey === configurationKey && existing.scopeGeneration === scopeGeneration) {
+ return existing.promise;
+ }
+
+ setRefreshingScope({ configurationKey, scopeGeneration });
+ // Start the production request while the configuration key, generation,
+ // and active desktop scope above are still one synchronous snapshot. The
+ // request boundary itself emits request-issued evidence; reporting it here
+ // before a deferred call could claim a renderer request that never entered
+ // apiFetch.
+ const currentUserRequest = getCurrentUser({ scopeGeneration, activeScopePresent });
+ const request = currentUserRequest
+ .then(user => {
+ if (currentConfigurationKeyRef.current !== configurationKey
+ || scopeGenerationRef.current.generation !== scopeGeneration) {
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'stale-scope-rejected', scopeGeneration, activeScopePresent,
+ responseStatus: 200, classification: 'success', schemaAccepted: true,
+ });
+ }
+ return;
+ }
+ setCurrentUser(user);
+ setValidatedConfigurationKey(configurationKey);
+ setValidatedScopeGeneration(scopeGeneration);
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'active-scope-accepted', scopeGeneration, activeScopePresent,
+ responseStatus: 200, classification: 'success', schemaAccepted: true,
+ });
+ }
+ })
+ .catch(error => {
+ const currentScope = currentConfigurationKeyRef.current === configurationKey
+ && scopeGenerationRef.current.generation === scopeGeneration;
+ if (currentScope) {
+ setCurrentUser(null);
+ setValidatedConfigurationKey(null);
+ setValidatedScopeGeneration(null);
+ }
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: currentScope
+ ? (getDesktopConnectionScope() === null ? 'revoked-scope-rejected' : 'active-scope-rejected')
+ : 'stale-scope-rejected',
+ scopeGeneration,
+ activeScopePresent,
+ responseStatus: 0,
+ classification: getDesktopConnectionScope() === null ? 'revoked' : 'network-error',
+ schemaAccepted: false,
+ });
+ }
+ throw error;
+ })
+ .finally(() => {
+ if (refreshRequestRef.current?.promise === request) refreshRequestRef.current = null;
+ setRefreshingScope(current => current?.configurationKey === configurationKey
+ && current.scopeGeneration === scopeGeneration ? null : current);
+ });
+ refreshRequestRef.current = { configurationKey, scopeGeneration, promise: request };
+ return request;
+ }, [desktopRuntime]);
+
+ useEffect(() => {
+ if (isDemoModeLoading) return;
+
+ const checkSession = async () => {
+ if (currentUiPathname() === '/login') {
+ setIsInitialLoading(false);
+ return;
+ }
+
+ try {
+ await refreshCurrentUser();
+ setIsInitialLoading(false);
+ } catch (error) {
+ // Browser authentication redirects remain responsible for releasing
+ // their own loading surface. Desktop authentication has no redirect.
+ if (error instanceof Error && error.message !== 'Authentication required') {
+ setIsInitialLoading(false);
+ }
+ }
+ };
+
+ void checkSession();
+ }, [currentConfigurationKey, isDemoModeLoading, refreshCurrentUser]);
+
+ useEffect(() => {
+ const handleAuthorizationChanged = () => {
+ void refreshCurrentUser().catch(error => {
+ console.error('Failed to refresh instance authorization:', error);
+ });
+ };
+ window.addEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, handleAuthorizationChanged);
+ return () => window.removeEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, handleAuthorizationChanged);
+ }, [refreshCurrentUser]);
+
+ useEffect(() => {
+ if (isDemoMode || currentUiPathname() === '/login') return;
+ const refreshAuthorization = () => {
+ if (document.visibilityState === 'hidden') return;
+ void refreshCurrentUser().catch(error => {
+ console.error('Failed to synchronize instance authorization:', error);
+ });
+ };
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible') refreshAuthorization();
+ };
+ const interval = window.setInterval(refreshAuthorization, AUTHORIZATION_REFRESH_INTERVAL_MS);
+ window.addEventListener('focus', refreshAuthorization);
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+ return () => {
+ window.clearInterval(interval);
+ window.removeEventListener('focus', refreshAuthorization);
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ };
+ }, [isDemoMode, refreshCurrentUser]);
+
+ const currentScopeGeneration = scopeGenerationRef.current.generation;
+ const validatedCurrentUser = validatedConfigurationKey === currentConfigurationKey
+ && validatedScopeGeneration === currentScopeGeneration ? currentUser : null;
+ const currentUserLoading = validatedCurrentUser === null
+ && (isInitialLoading || (refreshingScope?.configurationKey === currentConfigurationKey
+ && refreshingScope.scopeGeneration === currentScopeGeneration));
+
+ return {
+ currentUser: validatedCurrentUser,
+ currentUserAbsent: validatedCurrentUser === null && !currentUserLoading,
+ currentUserLoading,
+ isInitialLoading,
+ refreshCurrentUser,
+ };
+};