diff --git a/README.md b/README.md index d38bd2e..c75cd84 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,16 @@ Suppression is a tenant-scoped deny list for email, domain, phone, and other app Phase 12 remains pilot-grade until transition validation, immutable interaction/outcome history, suppression precedence, report definitions/timezones, retention/deletion jobs, export controls, idempotent writes, and cross-tenant regression tests are exercised end to end. The current Compose stack still has no durable CRM worker, scheduler, delivery provider, or outreach capability. +## Windows desktop client + +The Windows client is specified as a thin WebView/WebView2 shell over the same authenticated `apps/web` UI and remote API; it does not fork dashboard options or own a local database. The repository currently contains the desktop source contract and cross-platform asset/route smoke check, not a signed native installer. See [`apps/desktop/README.md`](apps/desktop/README.md) and run: + +```bash +node apps/desktop/scripts/smoke-desktop.mjs +``` + +The desktop uses the same server-side session cookie, tenant authorization, suppression/no-send boundaries, and `apiBase`/`assetVersion` runtime configuration as web. A future Windows release additionally requires a pinned shell/toolchain, clean-room staging checks, Authenticode signing with a hardware-backed or managed key, exact CORS configuration, artifact hashes, and a rollback/revocation owner. + ## Verification ```bash @@ -201,6 +211,7 @@ python3 -m unittest discover -v -s apps/api/tests -t apps/api python3 -m compileall -q apps/api apps/web git diff --check docker compose config --quiet +node apps/desktop/scripts/smoke-desktop.mjs ``` ## Phase 13 optional AI assistance boundary diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 0000000..976c75a --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +release/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000..2c603ce --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,155 @@ +# Windows desktop client + +## Status and architecture + +The desktop deliverable is a thin Electron shell that loads the unchanged `apps/web` bundle. In development it can load the bundled UI or a validated `--web-url=https://...`; in packaged builds the web assets are copied into `resources/web`. The shell provides first-run connection settings, safe backend URL persistence, reconnect/reload, external-link handling, and session/cache clearing without exposing secrets to renderer code. + +Build the Windows installer/portable executable on a Windows-capable build host after installing dependencies: + +```bash +npm install +npm run test +npm run verify +npm run build:win +``` + +Until one is selected and signed, do not describe an unsigned artifact as released. The source check is Chromium- and Windows-independent and is runnable from the repository root: + +```powershell +node apps\desktop\scripts\smoke-desktop.mjs +``` + +The check is Chromium- and Windows-independent, verifies that all referenced web assets exist, confirms the desktop manifest covers local HTML assets, rejects a second UI copy, and compares the desktop route list with the web smoke harness. It is also runnable on Linux/macOS: + +```bash +node apps/desktop/scripts/smoke-desktop.mjs +``` + +## Remote backend connection + +The desktop client is a remote API client, not a local database or API server. Configure the web runtime object before packaging or at deployment time: + +```js +window.__PROSPECT_CONFIG__ = Object.freeze({ + apiBase: 'https://api.example.com', + assetVersion: 'phase-15' +}); +``` + +- `apiBase` is the approved API origin; trailing `/` is accepted by the client and removed when building paths. An empty value uses the same origin, then the legacy `window.API_BASE`/`localStorage.prospect_api_base` fallback used by the web client. +- `assetVersion` is a cache-busting release label and is not a secret. +- Do not place API tokens, passwords, provider credentials, private keys, or session values in `config.js`, the manifest, the executable, logs, or installer metadata. +- The shell should provide network availability diagnostics and a retry path, but must not silently switch to another API origin. +- The API must be reachable over HTTPS in production. Local HTTP is suitable only for development (`http://127.0.0.1:8000` API and `http://127.0.0.1:8080` web server). + +## Authentication and session behavior + +The desktop uses the same login and session contract as web: + +1. `POST /api/v1/auth/login` receives the email/password form over the configured origin. +2. Requests include `credentials: 'include'`; the API sets a server-side session cookie. +3. Startup calls `GET /api/v1/auth/me`. A `401` shows the login screen; it does not expose cached workspace data. +4. `401` from a protected request clears the dashboard and asks the user to sign in again. `403` remains an authorization/workspace denial. +5. Log out calls `POST /api/v1/auth/logout`, invalidates the server session, resets the form, and returns to login. + +The shell must use the host's cookie jar/WebView profile, preserve cookies only for the configured origin, and provide a user-visible sign-out/clear-session operation. Never copy cookies into local storage, command-line arguments, crash reports, telemetry, or custom headers. A desktop session remains a bearer credential: lock the workstation, use OS account protection, and sign out on shared machines. Multi-factor authentication, password reset, and session administration are backend responsibilities; the current pilot does not claim those capabilities. + +## Supported configuration + +Supported desktop runtime configuration is intentionally limited to the two keys in the manifest: `apiBase` and `assetVersion`. Backend deployment configuration remains environment-only and is not desktop configuration: + +- `APP_ENV`, `LOG_LEVEL`, `CORS_ORIGINS`, `API_PORT`, `WEB_PORT`, and `DATA_DIR` are deployment settings. +- Production requires `SESSION_SECRET` of at least 32 characters, supplied through a secret manager/protected environment. +- `BOOTSTRAP_ADMIN_EMAIL` and `BOOTSTRAP_ADMIN_PASSWORD` are one-time provisioning inputs; remove and rotate them after bootstrap. +- `AUTOMATED_OUTREACH_ENABLED` is rejected when enabled; the enforced default is `false`. + +The desktop must not offer controls that imply it can override tenant authorization, source approval, rate limits, suppression, score/eligibility, AI policy, outreach, or backend safety settings. Those are server-enforced contracts. + +## Security model + +- The backend is the authority for authentication, authorization, tenant (`organization_id`) isolation, validation, audit records, suppression, pipeline transitions, job actions, and all safety gates. +- The desktop is an untrusted presentation client. Treat all responses, local files, clipboard content, and rendered prospect text as untrusted; preserve the web client's escaping and avoid adding privileged native bridges. +- The shell should expose only navigation and storage APIs required to render the web bundle. Disable arbitrary navigation, popups, downloads, file/system protocol access, script injection, and unrestricted native IPC unless separately reviewed. +- Do not grant the web content filesystem, process, registry, shell, camera, microphone, or credential-manager access by default. CSV preview is browser-local and is not an import or upload authority unless the server workflow explicitly confirms it. +- Keep the no-send boundary: no SMTP probing, provider calls, campaign creation, autonomous follow-up, or direct fetching of arbitrary target URLs from the desktop. A high score, AI suggestion, extracted contact, or approval does not authorize outreach. +- Production traffic must use HTTPS with certificate validation. Do not add a “trust all certificates” switch. Pinning, if considered, needs an operational rotation plan and is not currently required by this source contract. + +## Windows build and release prerequisites + +## Electron development and Windows packaging + +Install Node.js 20+ and npm on Windows, then run these exact commands from PowerShell: + +```powershell +cd \apps\desktop +npm install +npm run verify +``` + +Start the API in another PowerShell window, then start Electron in development mode: + +```powershell +cd \apps\api +python app\main.py --host 127.0.0.1 --port 8000 --db $env:TEMP\prospects.db + +# second window +cd \apps\desktop +npm run dev +``` + +`npm run dev` supplies the public runtime backend URL `http://127.0.0.1:8000`. For another environment, use `$env:PROSPECT_API_BASE="https://api.example.com"; npm start` (or the Connection settings menu). URLs are validated and credential/query/fragment-bearing values are rejected. + +Build both x64 Windows artifacts only on Windows: + +```powershell +cd \apps\desktop +npm install +npm run verify +npm run build:win +``` + +The NSIS installer and portable executable are written to `apps\desktop\release\`. This Linux checkout has not built, and does not claim to have built, an `.exe`. + +## Auto-update policy + +Auto-update is intentionally disabled until release signing, certificate custody, artifact publication, update-channel authorization, and rollback procedures are configured. There is no updater integration or publish provider in this package; do not add `electron-updater` or an update channel as part of a local build. + +## API/CORS requirements + +The renderer uses the existing web UI and sends credentialed requests to the configured API. Configure the API's `CORS_ORIGINS` for the exact origin emitted by the selected Electron loading strategy, with `Access-Control-Allow-Credentials: true`; never use `*` with credentials. Preserve server-side session, tenant authorization, CSRF, suppression, and outreach-disabled controls. CORS is not an authorization boundary. Verify preflight and authenticated login against staging before distribution. + +A native desktop build packages `apps/web` unchanged via electron-builder `extraResources`; it does not modify backend files or create a second UI implementation. + +A native release is blocked until the shell is selected and its toolchain is pinned. The release builder must provide: + +- Supported Windows 10/11 x64 baseline, a clean build VM, and a documented x64/arm64 decision. +- Pinned Node.js LTS and package-lock (if the selected shell uses Node), plus the selected shell's exact SDK/toolchain and WebView2 runtime policy. +- Reproducible web asset build, manifest/version update, route/asset smoke check, JSON parse, JavaScript syntax check, and a clean `git diff --check`. +- Clean-room install/run test with the real signed artifact, login/session expiry/logout checks, offline/API-unavailable behavior, HTTPS certificate failure behavior, and DPI/scaling/high-contrast/basic keyboard navigation checks. +- Release notes containing API compatibility, minimum Windows version, architecture, config origin, known limitations, and rollback/revocation instructions. +- Artifact hashes and the exact source commit recorded beside the installer/MSIX/portable artifact. Retain the previous known-good artifact for rollback. + +The API and web deployment still require their existing Docker/Compose, TLS, secret, backup, monitoring, and operational prerequisites. Building a Windows client does not deploy or upgrade the remote backend. + +## Code signing and distribution + +Every distributed `.exe`, `.msi`, MSIX package, and updater must be Authenticode-signed with an organization-controlled code-signing certificate. Prefer an EV/managed key or a hardware-backed/CI signing service; never commit a private key or export it into a developer workspace. Verify the signature and timestamp on a clean Windows host (for example with `Get-AuthenticodeSignature`) before publication. Sign each embedded executable and installer payload as required by the selected packaging technology, publish SHA-256 checksums, and retain signing/audit records. Unsigned developer builds must be clearly labeled and must never use the production API origin by default. + +Certificate rotation, revocation, compromised-builder response, SmartScreen reputation, update-channel authorization, and artifact rollback require an owner and runbook before release. Signing proves publisher integrity; it does not make the client trusted with tenant data or make a backend response authoritative. + +## Firewall and CORS + +The desktop makes outbound HTTPS connections to the configured API; it does not listen for inbound connections and should not require an inbound Windows Firewall rule. If a chosen shell starts a local callback/update server, bind it to loopback, use an ephemeral port, authenticate the callback, and document the narrowly scoped firewall exception. Never open the API or a development server to `0.0.0.0` for desktop distribution. + +For a remote API origin, configure `CORS_ORIGINS` to the exact desktop origin emitted by the selected shell/runtime and keep `Access-Control-Allow-Credentials: true`. Do not use `*` with credentialed requests. The API currently returns the configured `CORS_ORIGINS` value and allows `Content-Type`; verify the selected WebView's origin and preflight behavior in a staging environment. If the shell loads `file://` or a custom `app://` origin, do not guess a CORS value: choose a reviewed HTTPS/custom-origin strategy or package the UI behind the same approved origin, because cookie and CORS behavior differs by WebView host. + +CORS is not authentication or tenant isolation. The backend must continue to enforce sessions and organization scope even when a request appears to come from the desktop. + +## Limitations and support boundary + +- No native Windows shell, installer, update channel, signed binary, or Windows-specific telemetry is currently checked in. +- The desktop has the web client's pilot limitations: SQLite/in-process jobs are not durable or horizontally scalable; SSE, live external discovery, production DNS/availability, production egress isolation, and production outreach delivery are not implemented. +- The current password fallback is development-grade; production still requires Argon2id, MFA, CSRF protection, rate limiting, durable audit/retention, and tested backup/restore procedures. +- Network loss, API version skew, expired sessions, proxy policy, certificate interception, sleep/resume, and WebView runtime updates can affect the client. The desktop cannot repair backend data or bypass a blocked safety gate. +- Local UI assets may be cached by the selected shell; bump `assetVersion` and require a restart/refresh after a UI release. There is no service-worker migration or offline write queue. +- CSV remains preview-only, and no desktop feature changes the no-send default. See `apps/web/README.md`, `apps/api/README.md`, `docs/SECURITY.md`, and `docs/RELEASE_CHECKLIST.md` for the authoritative web/API safety and operations contracts. diff --git a/apps/desktop/build-windows.cmd b/apps/desktop/build-windows.cmd new file mode 100644 index 0000000..5da8753 --- /dev/null +++ b/apps/desktop/build-windows.cmd @@ -0,0 +1,7 @@ +@echo off +setlocal +cd /d "%~dp0" +npm install || exit /b 1 +npm run verify || exit /b 1 +npm run build:win || exit /b 1 +echo Windows artifacts are in apps\desktop\release. diff --git a/apps/desktop/build-windows.ps1 b/apps/desktop/build-windows.ps1 new file mode 100644 index 0000000..5ecc098 --- /dev/null +++ b/apps/desktop/build-windows.ps1 @@ -0,0 +1,6 @@ +$ErrorActionPreference = 'Stop' +Set-Location $PSScriptRoot +npm install +npm run verify +npm run build:win +Write-Host 'Windows artifacts are in apps\desktop\release.' diff --git a/apps/desktop/connection.html b/apps/desktop/connection.html new file mode 100644 index 0000000..6c827f1 --- /dev/null +++ b/apps/desktop/connection.html @@ -0,0 +1,40 @@ + + + + + + ProspectOS connection + + + +
+

Connect to your workspace

+

Enter the non-secret HTTP(S) address of the ProspectOS backend. Your browser session stays in the app; only this address is saved locally.

+
+ + + + +
+

Examples: https://prospect.example.com or http://127.0.0.1:8000

+
+ + + diff --git a/apps/desktop/desktop-manifest.json b/apps/desktop/desktop-manifest.json new file mode 100644 index 0000000..de677a1 --- /dev/null +++ b/apps/desktop/desktop-manifest.json @@ -0,0 +1,44 @@ +{ + "schema": 1, + "product": "ProspectOS Windows desktop client", + "shell": "web-ui", + "status": "source-contract", + "entrypoint": "../web/index.html", + "assets": [ + "../web/index.html", + "../web/config.js", + "../web/app.js", + "../web/styles.css", + "../web/health.html", + "../web/error.html", + "../web/healthz", + "../web/smoke-test.html" + ], + "runtime_config_keys": ["apiBase", "assetVersion"], + "routes_source": "../web/scripts/smoke-frontend.mjs", + "routes": [ + "/api/v1/auth/me", + "/api/v1/auth/login", + "/api/v1/auth/logout", + "/api/v1/businesses", + "/api/v1/review-queue", + "/api/v1/saved-filters", + "/api/v1/businesses/bulk-review", + "/api/v1/jobs", + "/api/v1/sources", + "/api/v1/source-records", + "/api/v1/discovery-queries", + "/api/v1/merge-history", + "/api/v1/scoring/summary", + "/api/v1/score-rules", + "/api/v1/pipeline-entries", + "/api/v1/interactions", + "/api/v1/reports/pipeline", + "/api/v1/reports/outcomes", + "/api/v1/reports/activity", + "/api/v1/suppressions", + "/api/v1/ai-runs", + "/api/v1/outreach/drafts", + "/api/v1/outreach/provider-config" + ] +} diff --git a/apps/desktop/main.cjs b/apps/desktop/main.cjs new file mode 100644 index 0000000..7462ae5 --- /dev/null +++ b/apps/desktop/main.cjs @@ -0,0 +1,188 @@ +'use strict'; + +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { app, BrowserWindow, Menu, shell, session, ipcMain } = require('electron'); +const { validateBackendUrl } = require('./url-validation'); + +const BACKEND_URL_KEY = 'backendUrl'; +let backendUrl = ''; +let mainWindow = null; +let settingsWindow = null; + +function bundledUiPath() { + return app.isPackaged + ? path.join(process.resourcesPath, 'web', 'index.html') + : path.resolve(__dirname, '..', 'web', 'index.html'); +} + +function configuredWebUrl() { + const argument = process.argv.find((item) => item.startsWith('--web-url=')); + if (!argument) return null; + const result = validateBackendUrl(argument.slice('--web-url='.length)); + return result.valid ? result.value : null; +} + +function loadableUi() { + return configuredWebUrl() || pathToFileURL(bundledUiPath()).toString(); +} + +function createWindowOptions(width = 1440, height = 900) { + return { + width, + height, + minWidth: 960, + minHeight: 640, + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + navigateOnDragDrop: false + } + }; +} + +function openExternalLink(event, url) { + if (!/^https?:\/\//i.test(url)) return; + event.preventDefault(); + void shell.openExternal(url); +} + +function wireExternalNavigation(window) { + window.webContents.setWindowOpenHandler(({ url }) => { + openExternalLink({ preventDefault() {} }, url); + return { action: 'deny' }; + }); + window.webContents.on('will-navigate', (event, url) => { + const current = window.webContents.getURL(); + if (url.startsWith('file://')) return; + if (url.startsWith('http://') || url.startsWith('https://')) { + const currentOrigin = current.startsWith('http') ? new URL(current).origin : ''; + if (currentOrigin === new URL(url).origin) return; + } + openExternalLink(event, url); + }); +} + +function injectBackendConfig(window) { + const serialized = JSON.stringify(backendUrl); + return window.webContents.executeJavaScript( + `window.__PROSPECT_CONFIG__ = Object.freeze(Object.assign({}, window.__PROSPECT_CONFIG__ || {}, { apiBase: ${serialized} }));`, + true + ); +} + +async function loadMainUi() { + if (!mainWindow) return; + await mainWindow.loadURL(loadableUi()); + await injectBackendConfig(mainWindow); +} + +function createMainWindow() { + mainWindow = new BrowserWindow(createWindowOptions()); + wireExternalNavigation(mainWindow); + mainWindow.once('ready-to-show', () => mainWindow.show()); + mainWindow.on('closed', () => { mainWindow = null; }); + void loadMainUi().catch(() => mainWindow?.webContents.executeJavaScript('location.reload()')); + return mainWindow; +} + +function createSettingsWindow() { + if (settingsWindow && !settingsWindow.isDestroyed()) { + settingsWindow.focus(); + return settingsWindow; + } + settingsWindow = new BrowserWindow({ + ...createWindowOptions(560, 500), + resizable: false, + title: 'ProspectOS connection settings' + }); + wireExternalNavigation(settingsWindow); + settingsWindow.once('ready-to-show', () => settingsWindow.show()); + settingsWindow.on('closed', () => { settingsWindow = null; }); + void settingsWindow.loadFile(path.join(__dirname, 'connection.html')); + return settingsWindow; +} + +function installMenu() { + Menu.setApplicationMenu(Menu.buildFromTemplate([ + { label: 'ProspectOS', submenu: [ + { label: 'Connection settings…', click: () => createSettingsWindow() }, + { type: 'separator' }, + { label: 'Clear session and cache', click: () => clearSession() }, + { role: 'quit' } + ] }, + { label: 'View', submenu: [ + { label: 'Reload', accelerator: 'CmdOrCtrl+R', click: () => mainWindow?.reload() }, + { label: 'Reconnect', accelerator: 'CmdOrCtrl+Shift+R', click: () => reconnect() }, + { role: 'toggleDevTools' } + ] } + ])); +} + +async function clearSession() { + await session.defaultSession.clearStorageData(); + await session.defaultSession.clearCache(); + if (mainWindow && !mainWindow.isDestroyed()) await mainWindow.reload(); +} + +async function reconnect() { + if (!mainWindow || mainWindow.isDestroyed()) return; + await mainWindow.webContents.session.clearCache(); + await loadMainUi(); +} + +function registerIpc() { + ipcMain.handle('backend:get', () => backendUrl); + ipcMain.handle('backend:set', async (_event, value) => { + const result = validateBackendUrl(value); + if (!result.valid) return result; + backendUrl = result.value; + // This is the only persisted setting, and it is explicitly non-secret. + const storePath = path.join(app.getPath('userData'), 'connection.json'); + require('node:fs').writeFileSync(storePath, JSON.stringify({ backendUrl }), { mode: 0o600 }); + require('node:fs').chmodSync(storePath, 0o600); + if (mainWindow && !mainWindow.isDestroyed()) await loadMainUi(); + if (settingsWindow && !settingsWindow.isDestroyed()) settingsWindow.close(); + if (!mainWindow) createMainWindow(); + return { valid: true, value: backendUrl }; + }); + ipcMain.handle('window:reload', () => mainWindow?.reload()); + ipcMain.handle('window:reconnect', () => reconnect()); + ipcMain.handle('session:clear', () => clearSession()); + ipcMain.handle('settings:open', () => createSettingsWindow()); +} + +function readStoredBackendUrl() { + const argument = process.argv.find((item) => item.startsWith('--api-base=')); + const configured = argument ? argument.slice('--api-base='.length) : process.env.PROSPECT_API_BASE; + if (configured) { + const result = validateBackendUrl(configured); + if (result.valid) return result.value; + } + try { + const storePath = path.join(app.getPath('userData'), 'connection.json'); + const parsed = JSON.parse(require('node:fs').readFileSync(storePath, 'utf8')); + const result = validateBackendUrl(parsed.backendUrl); + return result.valid ? result.value : ''; + } catch { return ''; } +} + +app.whenReady().then(() => { + backendUrl = readStoredBackendUrl(); + registerIpc(); + installMenu(); + if (backendUrl) createMainWindow(); + else createSettingsWindow(); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + if (backendUrl) createMainWindow(); else createSettingsWindow(); + } + }); +}); + +app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); + +module.exports = { createWindowOptions, loadableUi }; diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..edf3691 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,49 @@ +{ + "name": "prospectos-desktop", + "productName": "ProspectOS", + "version": "1.0.0", + "description": "Secure Electron shell for the ProspectOS web UI", + "main": "main.cjs", + "private": true, + "author": "ProspectOS", + "license": "UNLICENSED", + "scripts": { + "verify": "node scripts/verify-packaging.mjs", + "test": "node --test test/*.test.js", + "dev": "cross-env ELECTRON_ENABLE_LOGGING=1 PROSPECT_DESKTOP_DEV=1 PROSPECT_API_BASE=http://127.0.0.1:8000 electron .", + "start": "electron .", + "build:win": "npm run verify && electron-builder --win nsis portable", + "dist:win": "npm run build:win" + }, + "devDependencies": { + "cross-env": "^7.0.3", + "electron": "^36.0.0", + "electron-builder": "^26.0.12" + }, + "build": { + "appId": "com.prospectos.desktop", + "productName": "ProspectOS", + "artifactName": "ProspectOS-${version}-${arch}.${ext}", + "directories": { "output": "release", "buildResources": "build-resources" }, + "files": [ + "main.cjs", "preload.cjs", "url-validation.js", "connection.html", + "desktop-manifest.json", "package.json", "README.md", "scripts/**/*" + ], + "extraResources": [{ "from": "../web", "to": "web", "filter": ["**/*"] }], + "win": { + "target": [ + { "target": "nsis", "arch": ["x64"] }, + { "target": "portable", "arch": ["x64"] } + ], + "publisherName": "ProspectOS" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "shortcutName": "ProspectOS" + }, + "portable": { "artifactName": "ProspectOS-${version}-portable.${ext}" } + } +} diff --git a/apps/desktop/preload.cjs b/apps/desktop/preload.cjs new file mode 100644 index 0000000..7d7dcd4 --- /dev/null +++ b/apps/desktop/preload.cjs @@ -0,0 +1,13 @@ +'use strict'; + +const { contextBridge, ipcRenderer } = require('electron'); + +// Deliberately expose only fixed, argument-checked operations; no Node or IPC primitive is leaked. +contextBridge.exposeInMainWorld('prospectDesktop', Object.freeze({ + getBackendUrl: () => ipcRenderer.invoke('backend:get'), + setBackendUrl: (url) => ipcRenderer.invoke('backend:set', String(url ?? '')), + reload: () => ipcRenderer.invoke('window:reload'), + reconnect: () => ipcRenderer.invoke('window:reconnect'), + clearSession: () => ipcRenderer.invoke('session:clear'), + openSettings: () => ipcRenderer.invoke('settings:open') +})); diff --git a/apps/desktop/scripts/smoke-desktop.mjs b/apps/desktop/scripts/smoke-desktop.mjs new file mode 100644 index 0000000..db53d13 --- /dev/null +++ b/apps/desktop/scripts/smoke-desktop.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +/** + * Cross-platform source smoke check for the desktop distribution contract. + * It deliberately does not require Windows, a native shell, or a browser. + * Run from the repository root: node apps/desktop/scripts/smoke-desktop.mjs + */ +import { readFile, stat } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = resolve(desktopRoot, '../..'); +const manifestPath = resolve(desktopRoot, 'desktop-manifest.json'); +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +const results = []; + +function check(id, description, pass, details = '') { + results.push({ id, description, pass: Boolean(pass), ...(details ? { details } : {}) }); +} +function unique(values) { return [...new Set(values)]; } +function sorted(values) { return [...values].sort(); } +function relativeToRepo(path) { return resolve(desktopRoot, path).replace(`${repoRoot}/`, ''); } + +check('manifest.schema', 'desktop manifest has the supported source-contract shape', + manifest.schema === 1 && manifest.shell === 'web-ui' && manifest.status === 'source-contract' && + manifest.entrypoint === '../web/index.html' && Array.isArray(manifest.assets) && + Array.isArray(manifest.runtime_config_keys) && typeof manifest.routes_source === 'string'); + +const assetPaths = manifest.assets.map(relativeToRepo); +const missingAssets = []; +for (const path of assetPaths) { + try { + if (!(await stat(resolve(repoRoot, path))).isFile()) missingAssets.push(path); + } catch { + missingAssets.push(path); + } +} +check('assets.present', 'all desktop-referenced web assets exist', missingAssets.length === 0, missingAssets.join(', ')); +check('assets.no-duplicates', 'desktop contract reuses web assets instead of maintaining a second UI copy', + manifest.assets.every(asset => asset.startsWith('../web/'))); + +const webConfig = await readFile(resolve(repoRoot, 'apps/web/config.js'), 'utf8'); +check('config.runtime-keys', 'desktop supports only the non-secret web runtime configuration keys', + manifest.runtime_config_keys.length === 2 && manifest.runtime_config_keys.includes('apiBase') && + manifest.runtime_config_keys.includes('assetVersion') && !/(token|password|secret|private.?key)\s*[:=]/i.test(webConfig)); + +const html = await readFile(resolve(repoRoot, 'apps/web/index.html'), 'utf8'); +const linkedAssets = unique([...html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)] + .map(match => match[1]).filter(asset => !asset.startsWith('http') && !asset.startsWith('data:')) + .map(asset => asset.replace(/^\.\//, ''))); +const manifestWebNames = new Set(manifest.assets.map(asset => asset.replace('../web/', ''))); +const missingLinkedAssets = linkedAssets.filter(asset => !manifestWebNames.has(asset)); +check('assets.html-parity', 'desktop manifest covers every local asset linked by web index.html', + missingLinkedAssets.length === 0, missingLinkedAssets.join(', ')); + +const routeSource = await readFile(resolve(desktopRoot, manifest.routes_source), 'utf8'); +const routesBlock = routeSource.match(/const routes = \[(.*?)];/s)?.[1] || ''; +const webRoutes = unique([...routesBlock.matchAll(/['"](\/api\/v1\/[^'"]+)['"]/g)].map(match => match[1])); +const desktopRoutes = Array.isArray(manifest.routes) ? unique(manifest.routes) : webRoutes; +check('routes.source', 'route source contains the web API contract', webRoutes.length > 0); +check('routes.parity', 'desktop route contract is exactly the web route contract', + JSON.stringify(sorted(desktopRoutes)) === JSON.stringify(sorted(webRoutes)), + `web=${webRoutes.length}, desktop=${desktopRoutes.length}`); + +const failed = results.filter(result => !result.pass); +const report = { + schema: 1, + harness: 'prospectos-desktop-source-smoke', + chromium_required: false, + windows_required: false, + pass: failed.length === 0, + totals: { checks: results.length, passed: results.length - failed.length, failed: failed.length }, + checks: results +}; +console.log(JSON.stringify(report, null, 2)); +if (failed.length) process.exitCode = 1; diff --git a/apps/desktop/scripts/verify-packaging.mjs b/apps/desktop/scripts/verify-packaging.mjs new file mode 100644 index 0000000..28f03c6 --- /dev/null +++ b/apps/desktop/scripts/verify-packaging.mjs @@ -0,0 +1,49 @@ +import { createHash } from 'node:crypto'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; + +const desktopDir = path.resolve(new URL('..', import.meta.url).pathname); +const repoDir = path.resolve(desktopDir, '..', '..'); +const webDir = path.join(repoDir, 'apps', 'web'); +const requiredDesktop = ['package.json', 'main.cjs', 'preload.cjs', 'url-validation.js', 'connection.html', 'desktop-manifest.json']; +const secretAssignment = /(?:api[_-]?key|secret|token|password|private[_-]?key)\s*[:=]\s*['"][^'"\n]{8,}['"]/i; +const fail = (message) => { throw new Error(message); }; +async function filesUnder(dir) { + const output = []; + async function walk(current) { + for (const entry of await readdir(current, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'release') continue; + const full = path.join(current, entry.name); + if (entry.isDirectory()) await walk(full); else output.push(full); + } + } + await walk(dir); return output.sort(); +} +const digest = (buffer) => `sha256-${createHash('sha256').update(buffer).digest('hex')}`; +for (const file of requiredDesktop) await stat(path.join(desktopDir, file)).catch(() => fail(`Missing desktop entrypoint: ${file}`)); +const webManifest = JSON.parse(await readFile(path.join(webDir, 'asset-manifest.json'), 'utf8')); +if (webManifest.schema !== 1 || !webManifest.version || !webManifest.integrity) fail('Invalid apps/web asset manifest'); +const listed = [...new Set([...(webManifest.entrypoints || []), ...(webManifest.publicAssets || [])])].sort(); +if (!listed.includes('index.html')) fail('Web manifest must include index.html'); +for (const [relative, expected] of Object.entries(webManifest.integrity)) { + const full = path.join(webDir, relative); + await stat(full).catch(() => fail(`Manifest asset is missing: ${relative}`)); + const actual = digest(await readFile(full)); + if (actual !== expected) fail(`Integrity mismatch for ${relative}`); +} +for (const relative of listed) if (!webManifest.integrity[relative]) fail(`Manifest asset lacks integrity: ${relative}`); +const desktopManifest = JSON.parse(await readFile(path.join(desktopDir, 'desktop-manifest.json'), 'utf8')); +for (const relative of desktopManifest.assets || []) await stat(path.resolve(desktopDir, relative)).catch(() => fail(`Desktop manifest asset is missing: ${relative}`)); +const files = [...await filesUnder(webDir), ...await filesUnder(desktopDir)]; +for (const file of files) { + const buffer = await readFile(file); + if (buffer.includes(0)) continue; + if (secretAssignment.test(buffer.toString('utf8'))) fail(`Secret-like assignment found in ${path.relative(repoDir, file)}`); +} +const packageJson = JSON.parse(await readFile(path.join(desktopDir, 'package.json'), 'utf8')); +if (packageJson.main !== 'main.cjs') fail('package.json main must be main.cjs'); +if (!packageJson.build?.extraResources?.some((item) => item.from === '../web' && item.to === 'web')) fail('Build must copy apps/web into packaged resources'); +const targets = packageJson.build?.win?.target || []; +if (!targets.some((target) => target.target === 'nsis')) fail('Windows NSIS target is missing'); +if (!targets.some((target) => target.target === 'portable')) fail('Windows portable target is missing'); +console.log(`Packaging verification passed: ${listed.length} web assets, ${files.length} scanned source files, NSIS + portable targets.`); diff --git a/apps/desktop/test/desktop-contract.test.js b/apps/desktop/test/desktop-contract.test.js new file mode 100644 index 0000000..707221d --- /dev/null +++ b/apps/desktop/test/desktop-contract.test.js @@ -0,0 +1,45 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const desktopDir = path.resolve(__dirname, '..'); +const mainSource = fs.readFileSync(path.join(desktopDir, 'main.cjs'), 'utf8'); +const preloadSource = fs.readFileSync(path.join(desktopDir, 'preload.cjs'), 'utf8'); + +test('validates only safe http(s) backend URLs', () => { + const { validateBackendUrl } = require(path.join(desktopDir, 'url-validation.js')); + for (const value of ['https://api.example.com', 'http://127.0.0.1:8000/api/v1']) { + assert.equal(validateBackendUrl(value).valid, true, value); + } + for (const value of [ + '', 'ftp://api.example.com', 'javascript:alert(1)', 'https://user:pass@api.example.com', + 'https://api.example.com/?token=secret', 'https://api.example.com/#secret', + 'https://', 'not a url' + ]) { + assert.equal(validateBackendUrl(value).valid, false, value); + } +}); + +test('main process uses hardened BrowserWindow defaults', () => { + assert.match(mainSource, /preload:.*preload\.cjs/); + assert.match(mainSource, /contextIsolation:\s*true/); + assert.match(mainSource, /sandbox:\s*true/); + assert.match(mainSource, /nodeIntegration:\s*false/); + assert.match(mainSource, /setWindowOpenHandler/); + assert.match(mainSource, /shell\.openExternal/); +}); + +test('preload exposes a narrow non-secret API', () => { + assert.match(preloadSource, /contextBridge\.exposeInMainWorld\(['"]prospectDesktop['"]/); + assert.match(preloadSource, /getBackendUrl/); + assert.match(preloadSource, /setBackendUrl/); + assert.match(preloadSource, /clearSession/); + assert.doesNotMatch(preloadSource, /process\.env|apiKey|password|token/i); +}); + +test('desktop source contains no embedded credentials or API keys', () => { + const files = fs.readdirSync(desktopDir).filter((file) => file.endsWith('.js') || file.endsWith('.html')); + const source = files.map((file) => fs.readFileSync(path.join(desktopDir, file), 'utf8')).join('\n'); + assert.doesNotMatch(source, /(sk-[A-Za-z0-9]|api[_-]?key\s*[:=]|password\s*[:=]|Bearer\s+)/i); +}); diff --git a/apps/desktop/url-validation.js b/apps/desktop/url-validation.js new file mode 100644 index 0000000..4a2fbee --- /dev/null +++ b/apps/desktop/url-validation.js @@ -0,0 +1,31 @@ +'use strict'; + +/** + * Validate a backend/UI URL without accepting credential-bearing or opaque URLs. + * Query strings and fragments are rejected so secrets cannot be persisted in the URL. + */ +function validateBackendUrl(value) { + if (typeof value !== 'string') return { valid: false, error: 'URL must be text.' }; + const input = value.trim(); + if (!input || input.length > 2048) return { valid: false, error: 'Enter a URL up to 2048 characters.' }; + + let parsed; + try { + parsed = new URL(input); + } catch { + return { valid: false, error: 'Enter a complete http:// or https:// URL.' }; + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + return { valid: false, error: 'Only http:// and https:// URLs are supported.' }; + } + if (!parsed.hostname || parsed.username || parsed.password || parsed.search || parsed.hash) { + return { valid: false, error: 'URL must not contain credentials, query parameters, or fragments.' }; + } + if (/\s/.test(parsed.hostname) || parsed.hostname.includes('..')) { + return { valid: false, error: 'Enter a valid hostname.' }; + } + return { valid: true, value: parsed.toString().replace(/\/$/, '') }; +} + +module.exports = { validateBackendUrl };