This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
release/
|
||||
@@ -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 <clone>\apps\desktop
|
||||
npm install
|
||||
npm run verify
|
||||
```
|
||||
|
||||
Start the API in another PowerShell window, then start Electron in development mode:
|
||||
|
||||
```powershell
|
||||
cd <clone>\apps\api
|
||||
python app\main.py --host 127.0.0.1 --port 8000 --db $env:TEMP\prospects.db
|
||||
|
||||
# second window
|
||||
cd <clone>\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 <clone>\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.
|
||||
@@ -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.
|
||||
@@ -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.'
|
||||
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ProspectOS connection</title>
|
||||
<style>
|
||||
:root { color-scheme: light; font-family: system-ui, sans-serif; }
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f5f3fb; color: #211d2d; }
|
||||
main { width: min(430px, calc(100% - 48px)); padding: 34px; background: white; border: 1px solid #e4def2; border-radius: 18px; box-shadow: 0 18px 55px #31205d18; }
|
||||
h1 { margin: 0 0 8px; font-size: 25px; } p { color: #6e6879; line-height: 1.45; }
|
||||
label { display: block; margin: 22px 0 6px; font-weight: 650; } input { box-sizing: border-box; width: 100%; padding: 12px; border: 1px solid #cfc7df; border-radius: 9px; font: inherit; }
|
||||
button { margin-top: 22px; width: 100%; padding: 12px; border: 0; border-radius: 9px; background: #5b42c5; color: white; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
#message { min-height: 22px; color: #b42318; } .hint { font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Connect to your workspace</h1>
|
||||
<p>Enter the non-secret HTTP(S) address of the ProspectOS backend. Your browser session stays in the app; only this address is saved locally.</p>
|
||||
<form id="connectionForm">
|
||||
<label for="backendUrl">Backend URL</label>
|
||||
<input id="backendUrl" name="backendUrl" type="url" required placeholder="https://prospect.example.com" autocomplete="url">
|
||||
<p id="message" role="alert" aria-live="polite"></p>
|
||||
<button type="submit">Connect</button>
|
||||
</form>
|
||||
<p class="hint">Examples: https://prospect.example.com or http://127.0.0.1:8000</p>
|
||||
</main>
|
||||
<script>
|
||||
const input = document.querySelector('#backendUrl');
|
||||
const message = document.querySelector('#message');
|
||||
window.prospectDesktop.getBackendUrl().then((value) => { input.value = value || ''; });
|
||||
document.querySelector('#connectionForm').addEventListener('submit', async (event) => {
|
||||
event.preventDefault(); message.textContent = 'Connecting…';
|
||||
const result = await window.prospectDesktop.setBackendUrl(input.value);
|
||||
message.textContent = result.valid ? '' : result.error;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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}" }
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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.`);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user