From 5d7de84aacd3bee57750cddb2fb80ba2d8948957 Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Fri, 13 Feb 2026 09:28:12 +0000 Subject: [PATCH 1/4] chore(docs): refresh governance docs and enforce docs-lint pre-commit --- .env.example | 11 +- .gitignore | 1 + .husky/pre-commit | 1 + CURRENT-SPRINT.md | 97 ++++ README.md | 228 ++++---- .../01-charter/product-engineering-charter.md | 4 + .../02-architecture/a11y-and-i18n-standard.md | 4 + docs/02-architecture/ipc-contract-standard.md | 4 + docs/02-architecture/security-architecture.md | 4 + docs/02-architecture/solution-architecture.md | 4 + .../state-management-standard.md | 4 + docs/02-architecture/system-context.md | 4 + docs/02-architecture/ui-system-governance.md | 4 + docs/03-engineering/coding-standards.md | 4 + .../dependency-and-upgrade-policy.md | 4 + ...at_assessment_checklist_hardening_guide.md | 497 ------------------ docs/03-engineering/git-and-pr-policy.md | 4 + docs/03-engineering/onboarding-guide.md | 4 + .../security-review-workflow.md | 92 +++- docs/04-delivery/ci-cd-spec.md | 11 +- .../desktop-distribution-runbook.md | 4 + docs/04-delivery/release-management.md | 4 + docs/05-governance/adr-template.md | 4 + docs/05-governance/backlog.md | 46 +- docs/05-governance/decision-log.md | 28 +- docs/05-governance/risk-register.md | 4 + docs/docs-index.md | 50 +- package.json | 2 + tools/scripts/docs-lint.mjs | 133 +++++ 29 files changed, 615 insertions(+), 646 deletions(-) create mode 100644 CURRENT-SPRINT.md delete mode 100644 docs/03-engineering/electron_angular_nx_security_threat_assessment_checklist_hardening_guide.md create mode 100644 tools/scripts/docs-lint.mjs diff --git a/.env.example b/.env.example index 465af3c..2c87418 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,13 @@ OIDC_AUDIENCE=your-api-audience # Optional: # OIDC_SEND_AUDIENCE_IN_AUTHORIZE=1 # OIDC_API_BEARER_TOKEN_SOURCE=access_token -# OIDC_ALLOW_INSECURE_TOKEN_STORAGE=1 \ No newline at end of file +# OIDC_ALLOW_INSECURE_TOKEN_STORAGE=1 + +# Bring-your-own secured API endpoint template for the `call.secure-endpoint` operation. +# Must be HTTPS and include any required path placeholders. +# Example: https://your-api.example.com/users/{{user_id}}/profile +# API_SECURE_ENDPOINT_URL_TEMPLATE= +# +# Optional JSON mapping of endpoint placeholders to JWT claim paths. +# Example: {"user_id":"sub","tenant_id":"org.id"} +# API_SECURE_ENDPOINT_CLAIM_MAP= diff --git a/.gitignore b/.gitignore index 94d33df..84816ec 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ vitest.config.*.timestamp* FILE_INDEX.txt FEEDBACK.md +TASK.md # local environment configuration .env* diff --git a/.husky/pre-commit b/.husky/pre-commit index c621633..04a1715 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,3 @@ pnpm lint-staged pnpm i18n-check +pnpm docs-lint diff --git a/CURRENT-SPRINT.md b/CURRENT-SPRINT.md new file mode 100644 index 0000000..eda8de8 --- /dev/null +++ b/CURRENT-SPRINT.md @@ -0,0 +1,97 @@ +# Current Sprint + +Sprint window: 2026-02-13 onward +Owner: Platform Engineering + Frontend +Status: Active + +## Sprint Goal + +Reduce risk in privileged runtime boundaries by completing the P0 refactor set while preserving existing behavior. + +## In Scope (Committed) + +- `BL-016` Refactor desktop-main composition root and IPC modularization. +- `BL-017` Refactor preload bridge into domain modules with shared invoke client. +- `BL-018` Introduce reusable validated IPC handler factory in desktop-main. + +## Stretch Scope (If Capacity Allows) + +- `BL-023` Expand IPC integration harness for preload-main real handler paths. +- `BL-025` Strengthen compile-time typing for API operation contracts end-to-end. + +## Out Of Scope (This Sprint) + +- `BL-019`, `BL-020`, `BL-021`, `BL-022`, `BL-024`. + +## Execution Plan (Coherent + Individually Testable) + +### Workstream A: `BL-016` desktop-main modularization + +1. `BL-016A` Extract non-IPC concerns from `apps/desktop-main/src/main.ts`. + +- Scope: move window creation, navigation hardening, environment/version resolution, and runtime-smoke setup into dedicated modules. +- Done when: `main.ts` is composition-focused and behavior remains unchanged. +- Proof: + - `pnpm nx build desktop-main` + - `pnpm nx test desktop-main` + +2. `BL-016B` Extract IPC handlers into per-domain modules. + +- Scope: move handlers into `ipc/handlers/*` while retaining channel and response behavior. +- Done when: handler registration is centralized and each domain handler is isolated. +- Proof: + - `pnpm nx build desktop-main` + - `pnpm nx test desktop-main` + +### Workstream B: `BL-018` validated handler factory + +3. `BL-018A` Add reusable handler wrapper. + +- Scope: create shared factory for sender authorization + schema validation + typed failure envelope mapping. +- Done when: at least handshake/app/auth handlers use factory with no behavior drift. +- Proof: + - `pnpm nx test desktop-main` + - `pnpm nx build desktop-main` + +4. `BL-018B` Migrate remaining handlers to wrapper. + +- Scope: migrate dialog/fs/storage/api/updates/telemetry handlers. +- Done when: all privileged handlers use one validation/authorization path. +- Proof: + - `pnpm nx test desktop-main` + - `pnpm nx build desktop-main` + +### Workstream C: `BL-017` preload modularization + +5. `BL-017A` Extract invoke client core. + +- Scope: move correlation-id generation, timeout race handling, result parsing, and error mapping into shared `invoke` module. +- Done when: existing namespaces call shared invoke core. +- Proof: + - `pnpm nx build desktop-preload` + - `pnpm nx build desktop-main` + +6. `BL-017B` Split preload API by domain. + +- Scope: split app/auth/dialog/fs/storage/api/updates/telemetry methods into domain modules and compose into exported `desktopApi`. +- Done when: `apps/desktop-preload/src/main.ts` becomes thin composition only. +- Proof: + - `pnpm nx build desktop-preload` + - `pnpm nx build desktop-main` + +### Cross-cut verification gate (after each merged unit) + +- `pnpm unit-test` +- `pnpm integration-test` +- `pnpm runtime:smoke` + +## Exit Criteria + +- P0 items merged through PR workflow with no security model regressions. +- Existing CI quality gates remain green. +- Docs updated for any changed project structure or conventions. + +## Progress Log + +- 2026-02-13: Sprint initialized from governance backlog with P0 focus (`BL-016`, `BL-017`, `BL-018`). +- 2026-02-13: Added PR-sized execution breakdown with per-unit proof commands. diff --git a/README.md b/README.md index 1b03c30..f9d22ae 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,69 @@ # Angulectron Workspace -Angular + Electron desktop foundation built as an Nx monorepo. +Angular 21 + Electron desktop foundation built as an Nx monorepo. -## What This Repository Is +## Who This Is For -This repo provides a secure, typed desktop application baseline with: +- Human engineers onboarding to the desktop platform. +- AI coding agents operating inside this workspace. -- Angular 21 renderer (`apps/renderer`) -- Electron main process (`apps/desktop-main`) -- Electron preload bridge (`apps/desktop-preload`) -- Shared IPC contracts (`libs/shared/contracts`) -- Typed renderer desktop API (`libs/platform/desktop-api`) -- UI libraries for Material, primitives, and Carbon adapters (`libs/ui/*`) +## Repository Purpose -Core design goal: keep the renderer unprivileged and route all privileged operations through preload + main with validated contracts. +This repository is a secure, typed baseline for desktop applications with strict privilege boundaries: + +- Renderer UI: `apps/renderer` +- Electron main process: `apps/desktop-main` +- Electron preload bridge: `apps/desktop-preload` +- E2E tests: `apps/renderer-e2e` +- Shared IPC contracts: `libs/shared/contracts` +- Typed desktop API surface for renderer: `libs/platform/desktop-api` + +Core design principle: + +- Renderer is untrusted. +- Privileged capabilities terminate in preload/main. +- Contracts are validated and versioned. ## Runtime Model -1. Renderer calls `window.desktop.*` via `libs/platform/desktop-api`. -2. Preload validates and forwards requests over IPC. -3. Main process validates again and executes privileged operations. -4. Response returns as typed `DesktopResult` envelopes. +1. Renderer calls `window.desktop.*` via `@electron-foundation/desktop-api`. +2. Preload validates request/response envelopes and invokes IPC. +3. Main validates payloads again and performs privileged work. +4. Responses return as `DesktopResult`. -Security defaults in desktop runtime include: +Security defaults: - `contextIsolation: true` - `sandbox: true` - `nodeIntegration: false` +## Feature Surface (Current) + +Renderer routes include: + +- Home and diagnostics/lab flows (`/`, `/ipc-diagnostics`, `/telemetry-console`, `/auth-session-lab`) +- UI system showcases (`/material-showcase`, `/carbon-showcase`, `/tailwind-showcase`, `/material-carbon-lab`) +- Data/form/workbench flows (`/data-table-workbench`, `/form-validation-lab`, `/async-validation-lab`) +- File/storage/API/update tooling (`/file-tools`, `/file-workflow-studio`, `/storage-explorer`, `/api-playground`, `/updates-release`) + +Desktop API surface includes: + +- `desktop.app.*` (version/runtime diagnostics) +- `desktop.auth.*` (sign-in/out, session summary, token diagnostics) +- `desktop.dialog.openFile()` + `desktop.fs.readTextFile()` +- `desktop.storage.*` +- `desktop.api.invoke()` +- `desktop.updates.check()` +- `desktop.telemetry.track()` + +## Expected Behaviors + +- Renderer must never access Node/Electron APIs directly. +- All privileged IPC calls must be validated in preload and main. +- Error responses should use typed envelopes with correlation IDs. +- Auth tokens stay out of renderer; bearer handling occurs in main process. +- Frontend should use Angular v21 patterns by default unless explicitly agreed otherwise. + ## Prerequisites - Node.js `^24.13.0` @@ -37,37 +73,43 @@ Security defaults in desktop runtime include: ```bash pnpm install +pnpm exec playwright install chromium ``` -## Common Commands +Notes: + +- Playwright browser binaries are local environment artifacts, not tracked in git. +- E2E is configured to run against a clean test server on `http://localhost:4300`. +- On Windows, if you see `Keytar unavailable` warnings, run `pnpm native:rebuild:keytar`. -Core workflow: +## Day-One Commands + +Desktop development (Windows): ```bash -pnpm install pnpm desktop:dev:win ``` -Quality and CI-style checks: +Renderer-only development: + +```bash +pnpm renderer:serve +``` + +## Quality Commands ```bash pnpm lint pnpm unit-test pnpm integration-test pnpm e2e-smoke +pnpm a11y-e2e +pnpm i18n-check pnpm build pnpm ci:local ``` -Targeted dev commands: - -```bash -pnpm renderer:serve -pnpm desktop:serve-all -pnpm workspace:refresh:win -``` - -Packaging commands: +## Packaging Commands ```bash pnpm forge:make @@ -75,117 +117,115 @@ pnpm forge:make:staging pnpm forge:make:production ``` -Build flavor behavior: +Flavor behavior: - `forge:make:staging` - sets `APP_ENV=staging` - enables packaged DevTools (`DESKTOP_ENABLE_DEVTOOLS=1`) - - outputs a staging executable name (`Angulectron-Staging.exe`) - `forge:make:production` - sets `APP_ENV=production` - disables packaged DevTools (`DESKTOP_ENABLE_DEVTOOLS=0`) - - outputs locked-down production artifacts - -Packaging notes: - -- Packaging runs `forge:clean` first to remove stale outputs from `out/`. -- Windows distributable is ZIP-based (no interactive installer prompts). -- Output ZIP location: - - `out/make/zip/win32/x64/` - - filename pattern: `@electron-foundation-source-win32-x64-.zip` -- Extract the ZIP and run the generated executable from the extracted folder. -- Custom app icon source path: - - `build/icon.ico` - -## OIDC Authentication (Desktop) -OIDC support is implemented in main/preload with Authorization Code + PKCE. +## OIDC Configuration (Desktop) Required environment variables: - `OIDC_ISSUER` - `OIDC_CLIENT_ID` -- `OIDC_REDIRECT_URI` (loopback URI, for example `http://127.0.0.1:42813/callback`) +- `OIDC_REDIRECT_URI` (loopback, example: `http://127.0.0.1:42813/callback`) - `OIDC_SCOPES` (must include `openid`) Optional: - `OIDC_AUDIENCE` -- `OIDC_ALLOW_INSECURE_TOKEN_STORAGE=1` (development-only fallback when OS secure storage is unavailable) +- `OIDC_ALLOW_INSECURE_TOKEN_STORAGE=1` (development fallback only) -Recommended local setup: +Local setup: 1. Copy `.env.example` to `.env.local`. -2. Fill in your OIDC values. +2. Add OIDC values. 3. Run `pnpm desktop:dev:win`. -`desktop:dev:win` now auto-loads `.env` and `.env.local` (with `.env.local` taking precedence). +Token persistence behavior: -Runtime behavior: +- Windows preference order: `keytar` -> encrypted file store (Electron `safeStorage`) -> plaintext file store only when `OIDC_ALLOW_INSECURE_TOKEN_STORAGE=1`. +- If `keytar` native binding is missing, run: + - `pnpm native:rebuild:keytar` -- Refresh tokens are stored in OS secure storage on Windows (`keytar`) with encrypted file fallback. -- Renderer can only call `desktop.auth.signIn()`, `desktop.auth.signOut()`, and `desktop.auth.getSessionSummary()`. -- Access token attachment for secured API operations occurs in main process only. +## Bring Your Own Secure API Endpoint -Temporary compatibility note: +The `call.secure-endpoint` API operation is endpoint-configurable and does not rely on a hardcoded private URL. -- Current Clerk OAuth flow may issue JWT access tokens without API `aud` claim in this tenant. -- AWS JWT authorizer is temporarily configured to accept both: - - API audience (`YOUR_API_AUDIENCE`) - - OAuth client id (`YOUR_OAUTH_CLIENT_ID`) -- This is tracked for removal in `docs/05-governance/backlog.md` (`BL-014`) and `docs/05-governance/oidc-auth-backlog.md`. +Set in `.env.local`: -## Repository Layout +- `API_SECURE_ENDPOINT_URL_TEMPLATE` +- `API_SECURE_ENDPOINT_CLAIM_MAP` (optional JSON map of placeholder -> JWT claim path) + +Requirements: + +- Must be `https://`. +- Placeholder values can come from request params and/or mapped JWT claims. +- Endpoint should accept bearer JWT from the desktop OIDC flow. + +Examples: -Top-level: +- `API_SECURE_ENDPOINT_URL_TEMPLATE=https://your-api.example.com/users/{{user_id}}/portfolio` +- `API_SECURE_ENDPOINT_CLAIM_MAP={"user_id":"sub","tenant_id":"org.id"}` + +If not configured, calling `call.secure-endpoint` returns a typed `API/OPERATION_NOT_CONFIGURED` failure. + +## Repository Layout - `apps/` runnable applications - `libs/` reusable libraries -- `tools/` scripts and utilities -- `docs/` architecture, engineering standards, delivery, governance +- `tools/` scripts/utilities +- `docs/` architecture, standards, delivery, governance -Key projects: +## Human + Agent Working Rules -- `apps/renderer` Angular shell + routed feature pages -- `apps/desktop-main` Electron privileged runtime -- `apps/desktop-preload` secure renderer bridge -- `apps/renderer-e2e` Playwright E2E tests +- Use Nx-driven commands (`pnpm nx ...` or repo scripts wrapping Nx). +- Respect app/lib boundaries and platform tags. +- Use short-lived branches and PR workflow; do not push directly to `main`. +- Keep changes minimal and behavior-preserving unless explicitly changing behavior. +- For security-sensitive changes, include review artifacts and negative-path tests. ## Documentation Map Start here: -- Docs index: `docs/docs-index.md` -- Onboarding guide: `docs/03-engineering/onboarding-guide.md` +- `docs/docs-index.md` +- `docs/03-engineering/onboarding-guide.md` + +Architecture: -Architecture and standards: +- `docs/02-architecture/solution-architecture.md` +- `docs/02-architecture/repo-topology-and-boundaries.md` +- `docs/02-architecture/security-architecture.md` +- `docs/02-architecture/ipc-contract-standard.md` -- Solution architecture: `docs/02-architecture/solution-architecture.md` -- Repo topology and boundaries: `docs/02-architecture/repo-topology-and-boundaries.md` -- Security architecture: `docs/02-architecture/security-architecture.md` -- IPC contract standard: `docs/02-architecture/ipc-contract-standard.md` -- UI system governance: `docs/02-architecture/ui-system-governance.md` +Engineering: -Engineering rules: +- `docs/03-engineering/coding-standards.md` +- `docs/03-engineering/testing-strategy.md` +- `docs/03-engineering/reliability-and-error-handling.md` +- `docs/03-engineering/observability-and-diagnostics.md` +- `docs/03-engineering/security-review-workflow.md` -- Coding standards: `docs/03-engineering/coding-standards.md` -- Testing strategy: `docs/03-engineering/testing-strategy.md` -- Reliability and error handling: `docs/03-engineering/reliability-and-error-handling.md` -- Observability and diagnostics: `docs/03-engineering/observability-and-diagnostics.md` -- Security review workflow: `docs/03-engineering/security-review-workflow.md` +Delivery + governance: -Process and delivery: +- `docs/04-delivery/ci-cd-spec.md` +- `docs/04-delivery/release-management.md` +- `docs/05-governance/definition-of-done.md` +- `docs/05-governance/backlog.md` +- `CURRENT-SPRINT.md` -- Git and PR policy: `docs/03-engineering/git-and-pr-policy.md` -- CI/CD spec: `docs/04-delivery/ci-cd-spec.md` -- Release management: `docs/04-delivery/release-management.md` -- Definition of Done: `docs/05-governance/definition-of-done.md` +## Contribution Policy (Critical) -## Contribution Rules (Critical) +- No direct commits to `main`. +- Branch naming: `feat/*`, `fix/*`, `chore/*`. +- Merge by PR after required checks and approvals. +- Conventional Commits required. -- Do not push directly to `main`. -- Use short-lived branches (`feat/*`, `fix/*`, `chore/*`). -- Merge via PR only after required checks and approvals. -- Use Conventional Commits. +Canonical policy: -Source of truth: `docs/03-engineering/git-and-pr-policy.md`. +- `docs/03-engineering/git-and-pr-policy.md` diff --git a/docs/01-charter/product-engineering-charter.md b/docs/01-charter/product-engineering-charter.md index ba877f8..bf427f1 100644 --- a/docs/01-charter/product-engineering-charter.md +++ b/docs/01-charter/product-engineering-charter.md @@ -1,5 +1,9 @@ # Product Engineering Charter +Owner: Platform Engineering +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Mission Deliver a secure, maintainable, and accessible desktop application platform using Angular 21 + Electron with predictable delivery quality. diff --git a/docs/02-architecture/a11y-and-i18n-standard.md b/docs/02-architecture/a11y-and-i18n-standard.md index e788cc6..1be73e7 100644 --- a/docs/02-architecture/a11y-and-i18n-standard.md +++ b/docs/02-architecture/a11y-and-i18n-standard.md @@ -1,5 +1,9 @@ # A11y And I18n Standard +Owner: Frontend + Platform Engineering +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Accessibility - Target: WCAG 2.2 AA minimum. diff --git a/docs/02-architecture/ipc-contract-standard.md b/docs/02-architecture/ipc-contract-standard.md index e430ecc..2d8b047 100644 --- a/docs/02-architecture/ipc-contract-standard.md +++ b/docs/02-architecture/ipc-contract-standard.md @@ -1,5 +1,9 @@ # IPC Contract Standard +Owner: Platform Engineering +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Contract Requirements - Every request carries `contractVersion`. diff --git a/docs/02-architecture/security-architecture.md b/docs/02-architecture/security-architecture.md index b1a922c..d5e8b89 100644 --- a/docs/02-architecture/security-architecture.md +++ b/docs/02-architecture/security-architecture.md @@ -1,5 +1,9 @@ # Security Architecture +Owner: Platform Engineering + Security +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Electron Hardening Baseline - `contextIsolation: true` diff --git a/docs/02-architecture/solution-architecture.md b/docs/02-architecture/solution-architecture.md index 90fe71b..25a3136 100644 --- a/docs/02-architecture/solution-architecture.md +++ b/docs/02-architecture/solution-architecture.md @@ -1,5 +1,9 @@ # Solution Architecture +Owner: Platform Engineering +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## High-Level Model - `apps/renderer`: Angular standalone application shell. diff --git a/docs/02-architecture/state-management-standard.md b/docs/02-architecture/state-management-standard.md index bd2b7d9..1e90d55 100644 --- a/docs/02-architecture/state-management-standard.md +++ b/docs/02-architecture/state-management-standard.md @@ -1,5 +1,9 @@ # State Management Standard +Owner: Platform Engineering +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Default Pattern - Signals + feature-scoped services. diff --git a/docs/02-architecture/system-context.md b/docs/02-architecture/system-context.md index f574eee..5dd012c 100644 --- a/docs/02-architecture/system-context.md +++ b/docs/02-architecture/system-context.md @@ -1,5 +1,9 @@ # System Context +Owner: Platform Engineering +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Actors - End user operating a Windows desktop application. diff --git a/docs/02-architecture/ui-system-governance.md b/docs/02-architecture/ui-system-governance.md index 0320873..d04f39e 100644 --- a/docs/02-architecture/ui-system-governance.md +++ b/docs/02-architecture/ui-system-governance.md @@ -1,5 +1,9 @@ # UI System Governance +Owner: Platform Engineering + UI +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Primary Stack - Angular Material is the default component system. diff --git a/docs/03-engineering/coding-standards.md b/docs/03-engineering/coding-standards.md index 46323cb..685df39 100644 --- a/docs/03-engineering/coding-standards.md +++ b/docs/03-engineering/coding-standards.md @@ -1,5 +1,9 @@ # Coding Standards +Owner: Platform Engineering +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Angular - Standalone components only. diff --git a/docs/03-engineering/dependency-and-upgrade-policy.md b/docs/03-engineering/dependency-and-upgrade-policy.md index 0c54904..b07d859 100644 --- a/docs/03-engineering/dependency-and-upgrade-policy.md +++ b/docs/03-engineering/dependency-and-upgrade-policy.md @@ -1,5 +1,9 @@ # Dependency And Upgrade Policy +Owner: Dev Experience + Security +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Automation - Renovate runs weekly. diff --git a/docs/03-engineering/electron_angular_nx_security_threat_assessment_checklist_hardening_guide.md b/docs/03-engineering/electron_angular_nx_security_threat_assessment_checklist_hardening_guide.md deleted file mode 100644 index ff994dd..0000000 --- a/docs/03-engineering/electron_angular_nx_security_threat_assessment_checklist_hardening_guide.md +++ /dev/null @@ -1,497 +0,0 @@ -# Electron + Angular (Nx Workspace) Security Threat Assessment Checklist & Hardening Guide - -**Purpose:** This document is a prescriptive, agent-friendly checklist to (1) threat model an Electron app with an Angular renderer in an Nx workspace, (2) self-assess the current security posture, and (3) implement hardening improvements. - -**Scope:** Electron **main process**, **preload bridge**, **renderer (Angular v21)**, packaging/distribution, update mechanism, and OS integrations. - -**Assumptions:** - -- Angular runs in Electron renderer(s). -- Main process is trusted; renderer is treated as untrusted. -- IPC boundary is the primary trust boundary. -- Nx workspace contains apps/libs; Electron main/preload likely live in a dedicated app (e.g., `apps/desktop`) and Angular UI in another (e.g., `apps/ui`). - ---- - -## 0) Threat Model Baseline (Do This First) - -### 0.1 Trust Boundaries - -- **Main process (trusted):** full Node + OS capabilities. -- **Preload (high trust, minimal):** exposes a controlled API surface. -- **Renderer (untrusted):** assume XSS is possible. - -### 0.2 Primary Threats (Ranked) - -1. **Renderer compromise → privilege escalation** via Node integration, unsafe preload API, or permissive IPC. -2. **Arbitrary file/system access** via IPC endpoints (path traversal, shell injection, overly generic endpoints). -3. **Remote content / navigation attacks** (loading attacker-controlled URLs, `window.open`, redirects). -4. **Supply chain compromise** (npm deps, build pipeline, auto-update feed). -5. **Secrets leakage** (tokens in renderer/localStorage, logs, crash dumps). -6. **Unsafe updates** (no signing / insecure transport / downgrade attacks). - -### 0.3 Security Objective - -- Renderer may be compromised without granting attacker OS-level capabilities. -- Preload API is capability-based and minimal. -- IPC is validated, authorized, and least-privileged. - ---- - -## 1) Inventory & Architecture Map (Agent Must Produce) - -### 1.1 Identify files and entry points - -**Action:** Locate and list: - -- Electron main entry file (e.g., `main.ts`, `electron-main.ts`). -- Preload script entry file (e.g., `preload.ts`). -- BrowserWindow creation code. -- Any `ipcMain.handle/on` registrations. -- Any `ipcRenderer.invoke/send` usage. -- Any usage of `shell.openExternal`, `webContents`, `session`, `protocol`, `webview`, `remote`. - -**Output required:** A short architecture map with: - -- Window names and what they load (`http://localhost:*` or `file://...`). -- IPC channel list. -- Preload exposed API shape. - -### 1.2 Classify data flows - -**Action:** For each IPC endpoint, record: - -- Inputs (types, sources) -- Privileged operations performed (FS, network, OS, exec) -- Outputs returned - -**Output required:** A table-like list in plain text (safe for agents) with per-endpoint summary. - ---- - -## 2) BrowserWindow & Renderer Hardening (Highest Priority) - -### 2.1 Mandatory BrowserWindow webPreferences - -**Agent must enforce** the following in all window creation paths unless a documented exception exists: - -- `contextIsolation: true` ✅ REQUIRED -- `nodeIntegration: false` ✅ REQUIRED -- `sandbox: true` ✅ STRONGLY RECOMMENDED (verify compatibility) -- `enableRemoteModule: false` ✅ REQUIRED (remote is legacy; avoid) -- `webSecurity: true` ✅ REQUIRED -- `allowRunningInsecureContent: false` ✅ REQUIRED -- `experimentalFeatures: false` ✅ RECOMMENDED - -**Implementation pattern (main process):** - -- Centralize `createWindow()` with one canonical set of preferences. -- Disallow ad-hoc BrowserWindow creation. - -**Self-check:** - -- Search for `new BrowserWindow(` and verify all flags. -- Ensure no window sets `nodeIntegration: true` or `contextIsolation: false`. - -### 2.2 Disable/Constrain new windows and navigation - -**Agent must implement:** - -- `webContents.setWindowOpenHandler(() => ({ action: 'deny' }))` by default. -- Intercept `will-navigate` to block navigation to non-app origins. -- Route external links to OS browser using `shell.openExternal` ONLY after URL validation. - -**URL validation policy (required):** - -- Allowlist schemes: `https:` (and maybe `mailto:`) only. -- Deny: `javascript:`, `data:`, `file:`, `vbscript:`. -- Allowlist hostnames if applicable. - -### 2.3 Avoid `webview` - -- If `webview` is used, treat it as a high-risk surface. Prefer not to. -- If unavoidable, isolate to a dedicated window/session with strict policies. - -### 2.4 Content Security Policy (CSP) - -**Agent must ensure** CSP is present for production renderer content. - -**Preferred CSP (starting point; adjust for Angular build needs):** - -- Default: `default-src 'self'` -- Scripts: `script-src 'self'` (avoid `'unsafe-inline'` and `'unsafe-eval'`) -- Styles: `style-src 'self' 'unsafe-inline'` (Angular may require inline styles; minimize) -- Images: `img-src 'self' data:` -- Connect: `connect-src 'self' https:` (tighten to known endpoints) -- Frames: `frame-src 'none'` -- Base URI: `base-uri 'self'` -- Object: `object-src 'none'` - -**Self-check:** - -- Verify CSP in `index.html` or headers (for `file://` consider meta tag). -- Ensure no reliance on eval-based tooling in production. - ---- - -## 3) Preload Bridge Hardening (Second Highest Priority) - -### 3.1 Design rules (must follow) - -- Use `contextBridge.exposeInMainWorld` to expose a single namespace (e.g., `window.api`). -- Expose **capabilities**, not raw Electron/Node objects. -- No generic “execute” endpoints. -- No direct access to `ipcRenderer` in renderer. -- All inputs validated in preload and again in main. - -### 3.2 API surface constraints - -**Agent must enforce:** - -- Only explicit, named methods. -- No dynamic channel construction. -- No pass-through of user-provided channel names. -- Events: provide subscribe/unsubscribe wrappers; never expose unrestricted event buses. - -### 3.3 Allowed IPC channel list - -**Agent must implement:** - -- An allowlist array/enum of channel names in preload. -- Wrapper functions only call `ipcRenderer.invoke` / `ipcRenderer.on` for allowlisted channels. - -### 3.4 Serialization constraints - -- Only allow structured-clone safe data: JSON-like primitives, arrays, plain objects. -- Avoid passing functions, class instances, Buffers (unless explicitly handled). - ---- - -## 4) IPC Security: Validation, Authorization, Least Privilege (Critical) - -### 4.1 Use invoke/handle by default - -**Policy:** Prefer `ipcMain.handle` + `ipcRenderer.invoke` for request/response. - -- Use `send/on` only for narrow event cases. - -### 4.2 Input validation (required) - -**Agent must implement:** - -- Schema validation for every IPC handler. -- Reject unknown fields. -- Normalize and validate paths. - -**Recommended approach:** Use a schema library (e.g., zod) in main. - -### 4.3 Authorization / window identity (required) - -**Agent must implement** per handler: - -- Identify caller window (`event.senderFrame`, `event.sender`) and check it belongs to expected origin/window type. -- Optional: capability tokens per window instance if multiple privilege tiers exist. - -### 4.4 Filesystem access policy - -**Hard requirement:** Renderer must not be able to request arbitrary path reads/writes. - -**Agent must implement:** - -- Prefer dialogs initiated by main (`showOpenDialog`, `showSaveDialog`) over accepting paths. -- If paths are accepted, enforce: - - `path.resolve` normalization - - allowlist root directories (e.g., app-specific data dir) - - deny traversal (`..`) and symlinks if needed - -### 4.5 Process execution policy - -**Hard requirement:** Avoid `child_process.exec` with string concatenation. - -**Agent must implement:** - -- Use `spawn` with explicit argument arrays. -- Allowlist executables and arguments. -- Validate inputs strictly. - -### 4.6 Return data minimization - -- Return only what renderer needs. -- Avoid returning secrets or full file contents unless essential. - ---- - -## 5) Remote Content, Networking, and Sessions - -### 5.1 Remote content policy - -- Prefer loading local `file://` assets for app UI. -- If remote content is necessary: - - isolate to separate `BrowserWindow`/`session` - - strong CSP - - disable Node integration (still) - - strict navigation allowlist - -### 5.2 Session hardening - -**Agent should check** for: - -- custom protocols -- persistent partitions -- permission handlers - -**Required:** implement `session.setPermissionRequestHandler` to deny by default and allowlist specific permissions. - -### 5.3 Certificate / TLS considerations - -- Do not disable certificate verification. -- Avoid `app.commandLine.appendSwitch('ignore-certificate-errors')`. - ---- - -## 6) Secrets, Credentials, and Sensitive Data - -### 6.1 Secret storage - -**Policy:** secrets belong in main, stored using OS credential store if possible. - -**Agent must enforce:** - -- No tokens in renderer localStorage/sessionStorage. -- No secrets embedded in preload-exposed APIs. - -### 6.2 Logging - -**Agent must enforce:** - -- No secrets in logs. -- Sanitize IPC inputs before logging. -- Control log level in production. - -### 6.3 Crash dumps - -- Review crash reporting settings and ensure sensitive data is not included. - ---- - -## 7) Angular Renderer Security (XSS & DOM Safety) - -### 7.1 XSS prevention - -**Agent must audit:** - -- Any use of `[innerHTML]`, `DomSanitizer.bypassSecurityTrust*`. -- Markdown/HTML rendering libraries. -- Dynamic URL bindings. - -**Policy:** - -- Avoid bypass APIs; if used, document and constrain inputs. -- Sanitize untrusted HTML with a well-reviewed sanitizer. - -### 7.2 CSP alignment - -- Ensure Angular build does not require `unsafe-eval` in production. -- Avoid runtime template compilation. - -### 7.3 Dependency hygiene - -- Audit third-party UI components that manipulate DOM. - ---- - -## 8) Packaging, Updates, and Distribution - -### 8.1 Auto-update security (if used) - -**Agent must verify:** - -- Updates are delivered over HTTPS. -- Update artifacts are signed. -- No downgrade attacks (version pinning / checks). - -### 8.2 Code signing - -- Ensure builds are signed for target OS (Windows/macOS) as appropriate. - -### 8.3 ASAR and integrity - -- ASAR is not a security boundary, but reduces casual tampering. -- Consider integrity checks if threat model requires. - ---- - -## 9) Supply Chain & Build Pipeline (Nx) - -### 9.1 Dependency audit - -**Agent must do:** - -- Identify Electron-related deps (`electron`, `electron-builder`, `@electron/*`, etc.). -- Run dependency vulnerability scanning (tooling choice depends on environment). -- Pin versions for critical packages. - -### 9.2 Nx boundaries - -**Agent should enforce:** - -- Main/preload code in dedicated libs with strict lint rules. -- Renderer cannot import Node-only libs. -- Use Nx tagging + module boundary rules to prevent accidental cross-layer imports. - -### 9.3 Build separation - -- Separate build targets for main, preload, renderer. -- Ensure preload is bundled appropriately and not accidentally exposed as editable runtime script. - ---- - -## 10) Concrete Self-Assessment Checklist (Agent Must Output as PASS/FAIL) - -**Agent must produce a report** with each item marked PASS/FAIL and evidence (file path + snippet reference). Do not skip items. - -### A) Window Hardening - -- [ ] All BrowserWindow instances use `contextIsolation: true`. -- [ ] All BrowserWindow instances use `nodeIntegration: false`. -- [ ] `sandbox: true` is enabled (or exception documented). -- [ ] `enableRemoteModule` is false and remote is not used. -- [ ] `setWindowOpenHandler` denies by default. -- [ ] `will-navigate` prevents navigation to non-app origins. -- [ ] External link handling validates URL schemes. - -### B) Preload - -- [ ] Renderer does not import/use `ipcRenderer` directly. -- [ ] `contextBridge.exposeInMainWorld` exposes a minimal API. -- [ ] IPC channels are allowlisted. -- [ ] Preload does not expose raw Electron objects. - -### C) IPC Handlers (Main) - -- [ ] Every `ipcMain.handle/on` validates input schema. -- [ ] Every handler enforces caller authorization / expected origin. -- [ ] No handler provides arbitrary FS read/write. -- [ ] No handler runs shell commands with unvalidated user input. - -### D) Navigation/Remote Content - -- [ ] App UI loads only from expected origins (`file://` or dev localhost). -- [ ] Remote content (if any) is isolated and tightly allowlisted. -- [ ] Permissions are denied by default via session permission handler. - -### E) CSP - -- [ ] Production renderer has CSP. -- [ ] CSP avoids `unsafe-eval`. -- [ ] CSP avoids `unsafe-inline` for scripts. - -### F) Secrets - -- [ ] No secrets stored in renderer storage. -- [ ] Secrets stored in main using OS store (or documented alternative). -- [ ] Logs scrub secrets. - -### G) Angular/XSS - -- [ ] No unsafe `innerHTML` usage without sanitization. -- [ ] `bypassSecurityTrust*` is not used, or each use is documented and constrained. - -### H) Updates/Signing - -- [ ] Update channel is HTTPS and artifacts are signed. -- [ ] Code signing configured for release builds. - -### I) Supply Chain - -- [ ] Dependency scanning performed and critical findings addressed. -- [ ] Nx module boundaries prevent renderer importing main/preload internals. - ---- - -## 11) Prescriptive Remediation Playbook (Apply In This Order) - -### Step 1: Lock down BrowserWindow defaults - -- Create a single `createMainWindow()` function. -- Apply mandatory webPreferences. -- Remove/replace any divergent window configs. - -### Step 2: Implement navigation controls - -- Add `setWindowOpenHandler` deny-by-default. -- Add `will-navigate` guard. -- Add safe external link opener with URL validation. - -### Step 3: Replace ad-hoc IPC with a typed capability API - -- Define a `channels.ts` enum/const list shared by preload+main. -- Preload exposes `window.api` with narrow functions. -- Main implements `ipcMain.handle` per capability. - -### Step 4: Add schema validation for every IPC handler - -- Introduce schemas in main. -- Reject unknown keys. -- Normalize paths and enforce allowlists. - -### Step 5: Remove dangerous primitives - -- Eliminate any `remote` usage. -- Eliminate `nodeIntegration: true`. -- Replace `exec` with allowlisted `spawn` patterns. - -### Step 6: CSP + Angular alignment - -- Add CSP to production `index.html`. -- Ensure Angular build does not require eval in prod. -- Fix any violations by adjusting build config or libraries. - -### Step 7: Secrets hygiene - -- Move secrets from renderer to main. -- Implement OS credential store usage. -- Ensure logs and crash reporting avoid sensitive data. - -### Step 8: Updates and signing - -- Verify HTTPS update feeds. -- Ensure signing configured. -- Add downgrade protections. - -### Step 9: Nx enforcement - -- Add Nx tags and enforce module boundaries. -- Ensure renderer cannot import main/preload libs. - ---- - -## 12) Agent Output Requirements - -When run against the repository, the agent must output: - -1. **Architecture map** (windows, origins, preload API, IPC channels). -2. **PASS/FAIL checklist** with evidence pointers. -3. **Patch plan**: ordered list of changes, each referencing files and rationale. -4. **Implemented changes** (if allowed) with verification notes. - ---- - -## Appendix: “Good Defaults” Reference (Copy Into Code) - -### BrowserWindow baseline (conceptual) - -- Context isolation ON -- Node integration OFF -- Sandbox ON (if compatible) -- Web security ON -- No remote -- Strict navigation control - -### IPC baseline - -- invoke/handle -- channel allowlist -- schema validation -- caller authorization -- least privilege diff --git a/docs/03-engineering/git-and-pr-policy.md b/docs/03-engineering/git-and-pr-policy.md index bbe96a4..9f12824 100644 --- a/docs/03-engineering/git-and-pr-policy.md +++ b/docs/03-engineering/git-and-pr-policy.md @@ -1,5 +1,9 @@ # Git And PR Policy +Owner: Platform Engineering +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Branching Model - `main` is protected. diff --git a/docs/03-engineering/onboarding-guide.md b/docs/03-engineering/onboarding-guide.md index a3843bc..a468842 100644 --- a/docs/03-engineering/onboarding-guide.md +++ b/docs/03-engineering/onboarding-guide.md @@ -1,5 +1,9 @@ # Onboarding Guide (Human + AI) +Owner: Dev Experience +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Purpose This guide is the fastest way to understand and work safely in this repository. diff --git a/docs/03-engineering/security-review-workflow.md b/docs/03-engineering/security-review-workflow.md index 6698d26..f65d87b 100644 --- a/docs/03-engineering/security-review-workflow.md +++ b/docs/03-engineering/security-review-workflow.md @@ -2,7 +2,7 @@ Owner: Security + Platform Engineering Review cadence: Quarterly -Last reviewed: 2026-02-07 +Last reviewed: 2026-02-13 ## Trigger Conditions (Mandatory) @@ -13,6 +13,7 @@ Security review is required for any change that introduces or modifies: - new filesystem access paths - external API allowlist destinations - Electron `webPreferences`, sandbox settings, or CSP controls +- updater/signing/distribution controls ## Minimum Review Artifacts @@ -23,15 +24,6 @@ Security review is required for any change that introduces or modifies: - misuse/abuse cases - Verification checklist and test evidence. -## Review Checklist Gate - -- PR template must include a security section for triggered changes. -- Reviewer must explicitly confirm: - - least-privilege boundary is maintained - - error handling avoids sensitive leakage - - logs/telemetry redact sensitive data - - tests cover misuse/negative paths - ## Threat Model Template (Mini) - Asset: what is protected. @@ -40,3 +32,83 @@ Security review is required for any change that introduces or modifies: - Abuse case: what could go wrong. - Control: mitigation in code/config. - Verification: tests or review evidence. + +## Baseline Hardening Checklist + +### BrowserWindow and renderer boundary + +- `contextIsolation: true` +- `sandbox: true` +- `nodeIntegration: false` +- `enableRemoteModule: false` +- `webSecurity: true` +- `allowRunningInsecureContent: false` +- `experimentalFeatures: false` unless explicitly approved +- `setWindowOpenHandler` blocks untrusted popups by default +- `will-navigate` policy blocks non-allowlisted navigation + +### Preload bridge boundary + +- Expose minimal methods via `contextBridge.exposeInMainWorld` +- Do not expose raw `ipcRenderer` +- Use explicit allowlisted channels only +- Do not accept user-provided channel names +- Validate inputs in preload and re-validate in main + +### IPC and privileged operations + +- Use `ipcMain.handle`/`ipcRenderer.invoke` for request-response flows +- Validate every request payload with shared schemas +- Verify sender identity/authorized window for each privileged handler +- Avoid arbitrary filesystem path operations; enforce normalization and scoping +- Avoid unrestricted process execution; use explicit allowlists and argument validation +- Return minimal data needed by renderer + +### Logging and secret handling + +- Preserve `correlationId` across renderer -> preload -> main +- Use namespaced error codes (`IPC/*`, `FS/*`, `API/*`, `STORAGE/*`) +- Redact tokens, credentials, and sensitive identifiers from logs/telemetry +- Ensure failure envelopes are safe for user/operator visibility + +### Packaging and release + +- Validate signer/update channel configuration for affected release changes +- Confirm security-sensitive changes are covered by CI gates and evidence artifacts + +### BYO secure endpoint pattern (`call.secure-endpoint`) + +- Endpoint target must be configured through environment, never hardcoded: + - `API_SECURE_ENDPOINT_URL_TEMPLATE` + - optional `API_SECURE_ENDPOINT_CLAIM_MAP` (JSON map of `placeholder -> jwt.claim.path`) +- Endpoint URL must be `https://` only. +- URL placeholders use `{{placeholder}}` and resolve in this order: + - request params supplied by renderer + - mapped JWT claim value from `API_SECURE_ENDPOINT_CLAIM_MAP` +- Renderer-provided headers are allowlisted to `x-*` names only; privileged headers (for example `Authorization`) are not overridable. +- OIDC bearer token is attached in main process only; renderer never receives token material. +- Failure behavior must be typed and explicit: + - missing env config -> `API/OPERATION_NOT_CONFIGURED` + - missing path placeholder values -> `API/INVALID_PARAMS` + - unsafe headers -> `API/INVALID_HEADERS` +- Review evidence for this pattern should include: + - unit test for JWT claim mapping into path placeholders + - unit test for unsafe header rejection + - unit test for unconfigured operation behavior + - one runtime smoke/e2e check that launch has no console/page errors + +Reference implementation: + +- `apps/desktop-main/src/api-gateway.ts` +- `libs/shared/contracts/src/lib/api.contract.ts` +- `apps/desktop-preload/src/main.ts` +- `apps/renderer/src/app/features/api-playground/api-playground-page.ts` + +## Review Checklist Gate + +- PR template must include a security section for triggered changes. +- Reviewer must explicitly confirm: + - least-privilege boundary is maintained + - error handling avoids sensitive leakage + - logs/telemetry redact sensitive data + - tests cover misuse/negative paths diff --git a/docs/04-delivery/ci-cd-spec.md b/docs/04-delivery/ci-cd-spec.md index 7e510a6..df88998 100644 --- a/docs/04-delivery/ci-cd-spec.md +++ b/docs/04-delivery/ci-cd-spec.md @@ -1,11 +1,16 @@ # CI/CD Spec +Owner: Dev Experience +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Platform - GitHub Actions. ## Required Jobs +- `security-checklist-gate` (PR only) - `format:check` - `lint` - `typecheck` @@ -18,7 +23,8 @@ - `build-desktop` - `dependency-audit` - `license-compliance` -- `artifact-publish` +- `perf-check` +- `artifact-publish` (push to `main` only) ## Caching @@ -27,4 +33,5 @@ ## Artifacts -- Desktop build artifacts uploaded on `main` push. +- Performance report artifact uploaded by `perf-check`. +- Desktop build artifacts uploaded on `main` push by `artifact-publish`. diff --git a/docs/04-delivery/desktop-distribution-runbook.md b/docs/04-delivery/desktop-distribution-runbook.md index 89d423d..891daec 100644 --- a/docs/04-delivery/desktop-distribution-runbook.md +++ b/docs/04-delivery/desktop-distribution-runbook.md @@ -1,5 +1,9 @@ # Desktop Distribution Runbook +Owner: Release Engineering + Platform Engineering +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Packaging - Build renderer + desktop main + desktop preload. diff --git a/docs/04-delivery/release-management.md b/docs/04-delivery/release-management.md index 9aa534a..9ee014f 100644 --- a/docs/04-delivery/release-management.md +++ b/docs/04-delivery/release-management.md @@ -1,5 +1,9 @@ # Release Management +Owner: Release Engineering +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Versioning - Conventional Commits + Changesets. diff --git a/docs/05-governance/adr-template.md b/docs/05-governance/adr-template.md index 0884fd5..6602bf1 100644 --- a/docs/05-governance/adr-template.md +++ b/docs/05-governance/adr-template.md @@ -1,5 +1,9 @@ # ADR-XXXX: Title +Owner: Platform Engineering +Review cadence: Quarterly +Last reviewed: 2026-02-13 + ## Status Proposed | Accepted | Rejected | Superseded diff --git a/docs/05-governance/backlog.md b/docs/05-governance/backlog.md index 16ffe93..c336b16 100644 --- a/docs/05-governance/backlog.md +++ b/docs/05-governance/backlog.md @@ -2,25 +2,35 @@ Owner: Platform Engineering Review cadence: Weekly -Last reviewed: 2026-02-08 +Last reviewed: 2026-02-13 -| ID | Title | Status | Priority | Area | Source | Owner | Notes | -| ------ | ---------------------------------------------------------------- | -------- | -------- | ----------------------- | ---------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| BL-001 | Linux packaging strategy for desktop | Deferred | Low | Delivery + Packaging | FEEDBACK.md | Platform | Add Electron Forge Linux makers and release notes once Linux is in scope. | -| BL-002 | Enforce handshake contract-version mismatch path | Planned | Medium | IPC Contracts | FEEDBACK.md | Platform | Main process currently validates schema but does not return a dedicated mismatch error. | -| BL-003 | API operation compile-time typing hardening | Planned | Medium | Platform API | FR-APIs.md | Platform | Move from mostly string-based operation references to stricter compile-time typing. | -| BL-004 | Enterprise proxy/TLS support matrix documentation | Deferred | Low | Security + Networking | FR-APIs.md | Platform | Document expected behaviors for proxy auth, TLS interception, and certificate errors. | -| BL-005 | Offline API queue/replay capability | Proposed | Medium | Platform API | FR-APIs.md | Platform | Current behavior is fail-fast + retry classification; queue/replay not implemented. | -| BL-006 | Storage capability-scoped authorization model | Proposed | Medium | Storage + Security | FR-Storage.md | Platform | Add explicit capability/role rules per storage operation. | -| BL-007 | Expanded data-classification tiers | Deferred | Low | Storage + Governance | FR-Storage.md | Platform | Add `public`, `secret`, and `high-value secret` tiers beyond current baseline. | -| BL-008 | Local Vault phase-2 implementation | Proposed | Medium | Storage + Security | FR-Storage.md | Platform | Separate higher-assurance vault capability remains unimplemented. | -| BL-009 | Storage recovery UX flows | Proposed | Medium | UX + Reliability | FR-Storage.md | Frontend | Build user-facing reset/repair/recovery workflow for storage failures. | -| BL-010 | Renderer structured logging adoption | Proposed | Medium | Observability | TASK.md | Frontend | Apply shared structured logs in renderer with IPC correlation IDs for key user flows. | -| BL-011 | Failure UX pattern implementation | Planned | Medium | UX + Reliability | TASK.md | Frontend | Implement documented toast/dialog/inline/offline patterns consistently across features. | -| BL-012 | IPC real-handler contract harness expansion | Proposed | Medium | Testing + IPC Contracts | TASK.md | Platform | Extend contract tests to execute preload/main handlers incl. timeout and correlation checks. | -| BL-013 | OIDC auth platform (desktop PKCE + secure IPC) | Planned | High | Security + Identity | TASK.md | Platform | Phased backlog and acceptance tests tracked in `docs/05-governance/oidc-auth-backlog.md`. | -| BL-014 | Remove temporary JWT-authorizer client-id audience compatibility | Planned | High | Security + Identity | OIDC integration | Platform + Security | Clerk OAuth access token currently omits `aud`; AWS authorizer temporarily allows both `YOUR_API_AUDIENCE` and OAuth client id. Remove client-id audience once Clerk emits API audience/scopes as required. | -| BL-015 | Add IdP global sign-out and token revocation flow | Planned | Medium | Security + Identity | OIDC integration | Platform + Security | Current sign-out clears local session only. Add provider logout/end-session and refresh-token revocation (where supported), plus clear UX state for local vs global sign-out. | +| ID | Title | Status | Priority | Area | Source | Owner | Notes | +| ------ | --------------------------------------------------------------------- | -------- | -------- | ------------------------------ | ----------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| BL-001 | Linux packaging strategy for desktop | Deferred | Low | Delivery + Packaging | FEEDBACK.md | Platform | Add Electron Forge Linux makers and release notes once Linux is in scope. | +| BL-002 | Enforce handshake contract-version mismatch path | Planned | Medium | IPC Contracts | FEEDBACK.md | Platform | Main process currently validates schema but does not return a dedicated mismatch error. | +| BL-003 | API operation compile-time typing hardening | Planned | Medium | Platform API | Transient FR document (API, archived) | Platform | Move from mostly string-based operation references to stricter compile-time typing. | +| BL-004 | Enterprise proxy/TLS support matrix documentation | Deferred | Low | Security + Networking | Transient FR document (API, archived) | Platform | Document expected behaviors for proxy auth, TLS interception, and certificate errors. | +| BL-005 | Offline API queue/replay capability | Proposed | Medium | Platform API | Transient FR document (API, archived) | Platform | Current behavior is fail-fast + retry classification; queue/replay not implemented. | +| BL-006 | Storage capability-scoped authorization model | Proposed | Medium | Storage + Security | Transient FR document (Storage, archived) | Platform | Add explicit capability/role rules per storage operation. | +| BL-007 | Expanded data-classification tiers | Deferred | Low | Storage + Governance | Transient FR document (Storage, archived) | Platform | Add `public`, `secret`, and `high-value secret` tiers beyond current baseline. | +| BL-008 | Local Vault phase-2 implementation | Proposed | Medium | Storage + Security | Transient FR document (Storage, archived) | Platform | Separate higher-assurance vault capability remains unimplemented. | +| BL-009 | Storage recovery UX flows | Proposed | Medium | UX + Reliability | Transient FR document (Storage, archived) | Frontend | Build user-facing reset/repair/recovery workflow for storage failures. | +| BL-010 | Renderer structured logging adoption | Proposed | Medium | Observability | TASK.md | Frontend | Apply shared structured logs in renderer with IPC correlation IDs for key user flows. | +| BL-011 | Failure UX pattern implementation | Planned | Medium | UX + Reliability | TASK.md | Frontend | Implement documented toast/dialog/inline/offline patterns consistently across features. | +| BL-012 | IPC real-handler contract harness expansion | Proposed | Medium | Testing + IPC Contracts | TASK.md | Platform | Extend contract tests to execute preload/main handlers incl. timeout and correlation checks. | +| BL-013 | OIDC auth platform (desktop PKCE + secure IPC) | Planned | High | Security + Identity | TASK.md | Platform | Phased backlog and acceptance tests tracked in `docs/05-governance/oidc-auth-backlog.md`. | +| BL-014 | Remove temporary JWT-authorizer client-id audience compatibility | Planned | High | Security + Identity | OIDC integration | Platform + Security | Clerk OAuth access token currently omits `aud`; AWS authorizer temporarily allows both `YOUR_API_AUDIENCE` and OAuth client id. Remove client-id audience once Clerk emits API audience/scopes as required. | +| BL-015 | Add IdP global sign-out and token revocation flow | Planned | Medium | Security + Identity | OIDC integration | Platform + Security | Current sign-out clears local session only. Add provider logout/end-session and refresh-token revocation (where supported), plus clear UX state for local vs global sign-out. | +| BL-016 | Refactor desktop-main composition root and IPC modularization | Planned | High | Desktop Runtime + IPC | Fresh workspace review (2026-02-13) | Platform | Split `apps/desktop-main/src/main.ts` into focused modules (`window`, `security`, `ipc/handlers`, `updates`, `startup`) and retain a thin composition root. | +| BL-017 | Refactor preload bridge into domain modules with shared invoke client | Planned | High | Preload + IPC | Fresh workspace review (2026-02-13) | Platform | Split `apps/desktop-preload/src/main.ts` into shared invoke/correlation/timeout utility plus per-domain API modules. | +| BL-018 | Introduce reusable validated IPC handler factory in desktop-main | Planned | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-13) | Platform | Centralize sender authorization, schema validation, and error-envelope mapping to remove handler duplication. | +| BL-019 | Decompose OIDC service into smaller capability-focused modules | Proposed | Medium | Security + Identity | Fresh workspace review (2026-02-13) | Platform + Security | Split sign-in flow, discovery/provider client, token lifecycle, and diagnostics concerns currently concentrated in `oidc-service.ts`. | +| BL-020 | Complete renderer i18n migration for hardcoded user-facing strings | Proposed | Medium | Frontend + I18n | Fresh workspace review (2026-02-13) | Frontend | Replace hardcoded labels/messages in renderer feature pages with translation keys and locale entries. | +| BL-021 | Consolidate renderer route/nav metadata into a single typed registry | Proposed | Medium | Frontend Architecture | Fresh workspace review (2026-02-13) | Frontend | Create one source for route path, label, icon, and lab visibility used by router and nav shell. | +| BL-022 | Rationalize thin shell/core/repository libraries | Proposed | Low | Architecture + Maintainability | Fresh workspace review (2026-02-13) | Platform | Either consolidate low-value wrappers or expand with meaningful domain behavior to reduce packaging overhead and clarify boundaries. | +| BL-023 | Expand IPC integration harness for preload-main real handler paths | Planned | Medium | Testing + IPC Contracts | Fresh workspace review (2026-02-13) | Platform | Add integration coverage for unauthorized sender rejection, timeout behavior, and correlation-id propagation with real handlers. | +| BL-024 | Standardize structured renderer logging with shared helper adoption | Proposed | Medium | Observability | Fresh workspace review (2026-02-13) | Frontend | Apply structured logging in renderer flows with correlation IDs and redaction-safe details. | +| BL-025 | Strengthen compile-time typing for API operation contracts end-to-end | Planned | Medium | Platform API + Contracts | Fresh workspace review (2026-02-13) | Platform | Extend BL-003 by introducing operation-to-request/response type maps across contracts, preload API, and main gateway. | ## Status Definitions diff --git a/docs/05-governance/decision-log.md b/docs/05-governance/decision-log.md index 490ff15..921c524 100644 --- a/docs/05-governance/decision-log.md +++ b/docs/05-governance/decision-log.md @@ -1,8 +1,24 @@ # Decision Log -| ADR | Date | Status | Summary | -| -------- | ---------- | -------- | ------------------------------------------------- | -| ADR-0001 | 2026-02-06 | Accepted | Nx monorepo with Angular 21 + Electron baseline | -| ADR-0002 | 2026-02-06 | Accepted | Material-first UI with controlled Carbon adapters | -| ADR-0003 | 2026-02-06 | Accepted | Transloco runtime i18n strategy | -| ADR-0004 | 2026-02-06 | Accepted | Trunk-based workflow with PR-only protected main | +Owner: Platform Engineering +Review cadence: Weekly +Last reviewed: 2026-02-13 + +## Purpose + +Record accepted architecture/process decisions and maintain links to canonical policy documents. + +| ADR | Date | Status | Summary | Canonical Reference | +| -------- | ---------- | -------- | ------------------------------------------------------------------------------- | ------------------------------------------------ | +| ADR-0001 | 2026-02-06 | Accepted | Nx monorepo with Angular 21 + Electron baseline | `docs/02-architecture/solution-architecture.md` | +| ADR-0002 | 2026-02-06 | Accepted | Material-first UI with controlled Carbon adapters | `docs/02-architecture/ui-system-governance.md` | +| ADR-0003 | 2026-02-06 | Accepted | Transloco runtime i18n strategy | `docs/02-architecture/a11y-and-i18n-standard.md` | +| ADR-0004 | 2026-02-06 | Accepted | Trunk-based workflow with PR-only protected main | `docs/03-engineering/git-and-pr-policy.md` | +| ADR-0005 | 2026-02-07 | Accepted | Privileged-boundary contract policy (`DesktopResult`, Zod, versioned envelopes) | `docs/02-architecture/ipc-contract-standard.md` | +| ADR-0006 | 2026-02-07 | Accepted | Electron hardening baseline with preload-only capability bridge | `docs/02-architecture/security-architecture.md` | +| ADR-0007 | 2026-02-12 | Accepted | Desktop OIDC architecture: main-process PKCE and secure token handling | `docs/05-governance/oidc-auth-backlog.md` | +| ADR-0008 | 2026-02-13 | Accepted | CI release gating includes security checklist and performance regression checks | `docs/04-delivery/ci-cd-spec.md` | + +## Retrospective Note + +This log has been backfilled from established project standards and implemented workspace behavior. Future architecture decisions should be added at decision time and cross-linked from PRs. diff --git a/docs/05-governance/risk-register.md b/docs/05-governance/risk-register.md index 2a8be3e..568cba6 100644 --- a/docs/05-governance/risk-register.md +++ b/docs/05-governance/risk-register.md @@ -1,5 +1,9 @@ # Risk Register +Owner: Platform Engineering + Security +Review cadence: Weekly +Last reviewed: 2026-02-13 + | ID | Risk | Impact | Mitigation | Owner | | ----- | -------------------------------------------- | ------ | ----------------------------------------------- | -------- | | R-001 | Electron security misconfiguration | High | Enforce preload-only bridge and secure defaults | Platform | diff --git a/docs/docs-index.md b/docs/docs-index.md index 54ca8f5..e55950b 100644 --- a/docs/docs-index.md +++ b/docs/docs-index.md @@ -2,7 +2,7 @@ Owner: Platform Engineering Review cadence: Quarterly -Last reviewed: 2026-02-07 +Last reviewed: 2026-02-13 ## Start Here @@ -15,20 +15,34 @@ Read these first, in order: 5. `docs/03-engineering/git-and-pr-policy.md` 6. `docs/05-governance/definition-of-done.md` -| Document | Area | Owner | Review cadence | -| ------------------------------------------------------- | --------------------- | ------------------------------- | -------------- | -| `docs/01-charter/product-engineering-charter.md` | Charter | Platform Engineering | Quarterly | -| `docs/02-architecture/solution-architecture.md` | Architecture | Platform Engineering | Quarterly | -| `docs/02-architecture/repo-topology-and-boundaries.md` | Architecture | Platform Engineering | Quarterly | -| `docs/02-architecture/security-architecture.md` | Security Architecture | Platform Engineering + Security | Quarterly | -| `docs/02-architecture/ipc-contract-standard.md` | IPC | Platform Engineering | Quarterly | -| `docs/03-engineering/onboarding-guide.md` | Onboarding | Dev Experience | Quarterly | -| `docs/03-engineering/testing-strategy.md` | Testing | QA + Platform Engineering | Quarterly | -| `docs/03-engineering/performance-standards.md` | Performance | Platform Engineering | Quarterly | -| `docs/03-engineering/observability-and-diagnostics.md` | Observability | Platform Engineering + SRE | Quarterly | -| `docs/03-engineering/reliability-and-error-handling.md` | Reliability | Platform Engineering | Quarterly | -| `docs/03-engineering/security-review-workflow.md` | Security Review | Security + Platform Engineering | Quarterly | -| `docs/04-delivery/ci-cd-spec.md` | Delivery | Dev Experience | Quarterly | -| `docs/05-governance/definition-of-done.md` | Governance | Platform Engineering | Quarterly | -| `docs/05-governance/backlog.md` | Governance Backlog | Platform Engineering | Weekly | -| `docs/05-governance/oidc-auth-backlog.md` | Identity Backlog | Platform Engineering + Security | Weekly | +## Full Document Catalog + +| Document | Area | Owner | Review cadence | +| ------------------------------------------------------- | --------------------- | ------------------------------------------ | -------------- | +| `docs/01-charter/product-engineering-charter.md` | Charter | Platform Engineering | Quarterly | +| `docs/02-architecture/system-context.md` | Architecture | Platform Engineering | Quarterly | +| `docs/02-architecture/solution-architecture.md` | Architecture | Platform Engineering | Quarterly | +| `docs/02-architecture/repo-topology-and-boundaries.md` | Architecture | Platform Engineering | Quarterly | +| `docs/02-architecture/security-architecture.md` | Security Architecture | Platform Engineering + Security | Quarterly | +| `docs/02-architecture/ipc-contract-standard.md` | IPC | Platform Engineering | Quarterly | +| `docs/02-architecture/state-management-standard.md` | Architecture | Platform Engineering | Quarterly | +| `docs/02-architecture/ui-system-governance.md` | UI Architecture | Platform Engineering + UI | Quarterly | +| `docs/02-architecture/a11y-and-i18n-standard.md` | Accessibility + I18n | Frontend + Platform Engineering | Quarterly | +| `docs/03-engineering/onboarding-guide.md` | Onboarding | Dev Experience | Quarterly | +| `docs/03-engineering/coding-standards.md` | Engineering Standards | Platform Engineering | Quarterly | +| `docs/03-engineering/dependency-and-upgrade-policy.md` | Dependency Governance | Dev Experience + Security | Quarterly | +| `docs/03-engineering/git-and-pr-policy.md` | Collaboration Process | Platform Engineering | Quarterly | +| `docs/03-engineering/testing-strategy.md` | Testing | QA + Platform Engineering | Quarterly | +| `docs/03-engineering/performance-standards.md` | Performance | Platform Engineering | Quarterly | +| `docs/03-engineering/observability-and-diagnostics.md` | Observability | Platform Engineering + SRE | Quarterly | +| `docs/03-engineering/reliability-and-error-handling.md` | Reliability | Platform Engineering | Quarterly | +| `docs/03-engineering/security-review-workflow.md` | Security Review | Security + Platform Engineering | Quarterly | +| `docs/04-delivery/ci-cd-spec.md` | Delivery | Dev Experience | Quarterly | +| `docs/04-delivery/release-management.md` | Delivery | Release Engineering | Quarterly | +| `docs/04-delivery/desktop-distribution-runbook.md` | Delivery + Packaging | Release Engineering + Platform Engineering | Quarterly | +| `docs/05-governance/definition-of-done.md` | Governance | Platform Engineering | Quarterly | +| `docs/05-governance/backlog.md` | Governance Backlog | Platform Engineering | Weekly | +| `docs/05-governance/oidc-auth-backlog.md` | Identity Backlog | Platform Engineering + Security | Weekly | +| `docs/05-governance/risk-register.md` | Governance Risk | Platform Engineering + Security | Weekly | +| `docs/05-governance/decision-log.md` | Governance Decisions | Platform Engineering | Weekly | +| `docs/05-governance/adr-template.md` | Governance Template | Platform Engineering | Quarterly | diff --git a/package.json b/package.json index db17d2d..b055261 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "e2e-smoke": "cross-env NX_DAEMON=false TMPDIR=/tmp TMP=/tmp TEMP=/tmp pnpm nx e2e renderer-e2e", "a11y-e2e": "cross-env NX_DAEMON=false TMPDIR=/tmp TMP=/tmp TEMP=/tmp pnpm nx e2e renderer-e2e --grep @a11y", "i18n-check": "cross-env TMPDIR=/tmp TMP=/tmp TEMP=/tmp tsx tools/scripts/check-i18n.ts", + "docs-lint": "node ./tools/scripts/docs-lint.mjs", "build-renderer": "cross-env NX_DAEMON=false TMPDIR=/tmp TMP=/tmp TEMP=/tmp pnpm nx build renderer --skipNxCache", "build-desktop-main": "cross-env NX_DAEMON=false TMPDIR=/tmp TMP=/tmp TEMP=/tmp pnpm nx build desktop-main --skipNxCache", "build-desktop-preload": "cross-env NX_DAEMON=false TMPDIR=/tmp TMP=/tmp TEMP=/tmp pnpm nx build desktop-preload --skipNxCache", @@ -26,6 +27,7 @@ "desktop:integration": "pnpm nx run desktop-main:integration", "desktop:dev": "pnpm run build-desktop && (pnpm renderer:serve & pnpm wait-on http://localhost:4200 && cross-env NODE_ENV=development RENDERER_DEV_URL=http://localhost:4200 electron dist/apps/desktop-main/main.js)", "desktop:dev:win": "powershell -NoProfile -ExecutionPolicy Bypass -File ./tools/scripts/desktop-dev-win.ps1", + "native:rebuild:keytar": "pnpm rebuild keytar", "workspace:refresh:win": "powershell -NoProfile -ExecutionPolicy Bypass -File ./tools/scripts/workspace-refresh-win.ps1", "perf:start": "node ./tools/scripts/perf-start.mjs", "perf:ipc": "node ./tools/scripts/perf-ipc.mjs", diff --git a/tools/scripts/docs-lint.mjs b/tools/scripts/docs-lint.mjs new file mode 100644 index 0000000..e9e8271 --- /dev/null +++ b/tools/scripts/docs-lint.mjs @@ -0,0 +1,133 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname, relative } from 'node:path'; +import { execSync } from 'node:child_process'; + +const repoRoot = process.cwd(); +const docsIndexPath = 'docs/docs-index.md'; + +const fail = (message) => { + console.error(`docs-lint: ${message}`); +}; + +const normalizePath = (value) => value.replaceAll('\\', '/'); + +const getDocsFiles = () => { + const output = execSync('rg --files docs -g "*.md"', { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); + + if (!output) { + return []; + } + + return output + .split(/\r?\n/) + .map((value) => normalizePath(value.trim())) + .filter(Boolean) + .sort(); +}; + +const readText = (path) => readFileSync(resolve(repoRoot, path), 'utf8'); + +const toRepoRelative = (fromFile, target) => { + const fromDir = dirname(resolve(repoRoot, fromFile)); + const resolved = resolve(fromDir, target); + return normalizePath(relative(repoRoot, resolved)); +}; + +const collectMarkdownRefs = (file, text) => { + const refs = []; + + const linkRegex = /\[[^\]]*\]\(([^)]+)\)/g; + for (const match of text.matchAll(linkRegex)) { + const raw = match[1].trim(); + if ( + !raw || + raw.startsWith('#') || + raw.startsWith('http://') || + raw.startsWith('https://') || + raw.startsWith('mailto:') + ) { + continue; + } + + const noAnchor = raw.split('#')[0]; + if (!noAnchor || !noAnchor.endsWith('.md')) { + continue; + } + + const ref = normalizePath(noAnchor); + refs.push(ref.startsWith('.') ? toRepoRelative(file, ref) : ref); + } + + const inlineRegex = /`([^`]+\.md)`/g; + for (const match of text.matchAll(inlineRegex)) { + const raw = match[1].trim(); + if (!raw || raw.startsWith('http://') || raw.startsWith('https://')) { + continue; + } + + const ref = normalizePath(raw); + refs.push(ref.startsWith('.') ? toRepoRelative(file, ref) : ref); + } + + return refs; +}; + +const errors = []; +const docsFiles = getDocsFiles(); +const docsWithoutIndex = docsFiles.filter((file) => file !== docsIndexPath); + +if (!existsSync(resolve(repoRoot, docsIndexPath))) { + errors.push(`Missing required index file: ${docsIndexPath}`); +} else { + const indexText = readText(docsIndexPath); + const indexRefs = new Set( + [...indexText.matchAll(/`(docs\/[A-Za-z0-9_.\/-]+\.md)`/g)].map((match) => + normalizePath(match[1]), + ), + ); + + for (const doc of docsWithoutIndex) { + if (!indexRefs.has(doc)) { + errors.push(`docs-index missing entry for ${doc}`); + } + } +} + +for (const file of docsFiles) { + const text = readText(file); + + if (!/^Owner:\s+.+$/m.test(text)) { + errors.push(`${file}: missing Owner header`); + } + + if (!/^Review cadence:\s+.+$/m.test(text)) { + errors.push(`${file}: missing Review cadence header`); + } + + const reviewedMatch = text.match(/^Last reviewed:\s+(.+)$/m); + if (!reviewedMatch) { + errors.push(`${file}: missing Last reviewed header`); + } else if (!/^\d{4}-\d{2}-\d{2}$/.test(reviewedMatch[1].trim())) { + errors.push(`${file}: Last reviewed must use YYYY-MM-DD`); + } + + const refs = collectMarkdownRefs(file, text); + for (const ref of refs) { + const resolved = resolve(repoRoot, ref); + if (!existsSync(resolved)) { + errors.push(`${file}: broken markdown reference -> ${ref}`); + } + } +} + +if (errors.length > 0) { + for (const err of errors) { + fail(err); + } + process.exit(1); +} + +console.info(`docs-lint: OK (${docsFiles.length} docs checked)`); From 399edf5886752246041c65a9f33c7e917ae8443a Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Fri, 13 Feb 2026 09:28:26 +0000 Subject: [PATCH 2/4] test(e2e): stabilize renderer smoke checks and clean-port execution --- apps/renderer-e2e/playwright.config.ts | 8 ++++---- apps/renderer-e2e/src/example.spec.ts | 28 +++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/renderer-e2e/playwright.config.ts b/apps/renderer-e2e/playwright.config.ts index 2e6e767..4ff95fa 100644 --- a/apps/renderer-e2e/playwright.config.ts +++ b/apps/renderer-e2e/playwright.config.ts @@ -3,7 +3,7 @@ import { nxE2EPreset } from '@nx/playwright/preset'; import { workspaceRoot } from '@nx/devkit'; // For CI, you may want to set BASE_URL to the deployed application. -const baseURL = process.env['BASE_URL'] || 'http://localhost:4200'; +const baseURL = process.env['BASE_URL'] || 'http://localhost:4300'; /** * Read environment variables from file. @@ -24,9 +24,9 @@ export default defineConfig({ }, /* Run your local dev server before starting the tests */ webServer: { - command: 'pnpm exec nx run renderer:serve', - url: 'http://localhost:4200', - reuseExistingServer: true, + command: 'pnpm exec nx run renderer:serve --port=4300', + url: 'http://localhost:4300', + reuseExistingServer: false, cwd: workspaceRoot, }, projects: [ diff --git a/apps/renderer-e2e/src/example.spec.ts b/apps/renderer-e2e/src/example.spec.ts index 1d23c11..9b59cae 100644 --- a/apps/renderer-e2e/src/example.spec.ts +++ b/apps/renderer-e2e/src/example.spec.ts @@ -1,5 +1,21 @@ import AxeBuilder from '@axe-core/playwright'; -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; + +const attachRuntimeErrorCapture = (page: Page) => { + const runtimeErrors: string[] = []; + + page.on('console', (message) => { + if (message.type() === 'error') { + runtimeErrors.push(`[console] ${message.text()}`); + } + }); + + page.on('pageerror', (error) => { + runtimeErrors.push(`[pageerror] ${error.message}`); + }); + + return runtimeErrors; +}; test('home shell renders toolbar and action controls', async ({ page }) => { await page.goto('/'); @@ -8,6 +24,16 @@ test('home shell renders toolbar and action controls', async ({ page }) => { await expect(page.getByRole('button', { name: 'Open file' })).toBeVisible(); }); +test('launch has no renderer console or page errors', async ({ page }) => { + const runtimeErrors = attachRuntimeErrorCapture(page); + + await page.goto('/'); + await expect(page.getByRole('banner')).toBeVisible(); + await page.waitForTimeout(500); + + expect(runtimeErrors).toEqual([]); +}); + test('@a11y shell page has no serious accessibility violations', async ({ page, }) => { From ae14d8bc754751e96cd01232f123b15ee27fdcdd Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Fri, 13 Feb 2026 09:28:40 +0000 Subject: [PATCH 3/4] feat(desktop): harden auth lifecycle and add BYO secure-endpoint diagnostics --- apps/desktop-main/src/api-gateway.spec.ts | 167 +++++++- apps/desktop-main/src/api-gateway.ts | 320 ++++++++++++--- apps/desktop-main/src/desktop-window.ts | 268 +++++++++++++ apps/desktop-main/src/main.ts | 374 +++--------------- apps/desktop-main/src/oidc-service.spec.ts | 186 +++++++++ apps/desktop-main/src/oidc-service.ts | 27 +- apps/desktop-main/src/runtime-config.ts | 96 +++++ apps/desktop-main/src/secure-token-store.ts | 10 +- apps/desktop-preload/src/main.ts | 22 +- apps/renderer/src/app/app.html | 1 + apps/renderer/src/app/app.ts | 29 +- .../api-playground/api-playground-page.html | 48 ++- .../api-playground/api-playground-page.ts | 122 +++++- .../auth-session-lab-page.html | 12 +- .../auth-session-lab/auth-session-lab-page.ts | 42 +- .../desktop-api/src/lib/desktop-api.ts | 16 +- libs/shared/contracts/src/lib/api.contract.ts | 41 +- libs/shared/contracts/src/lib/app.contract.ts | 1 + libs/shared/contracts/src/lib/channels.ts | 1 + .../contracts/src/lib/contracts.spec.ts | 53 ++- 20 files changed, 1413 insertions(+), 423 deletions(-) create mode 100644 apps/desktop-main/src/desktop-window.ts create mode 100644 apps/desktop-main/src/oidc-service.spec.ts create mode 100644 apps/desktop-main/src/runtime-config.ts diff --git a/apps/desktop-main/src/api-gateway.spec.ts b/apps/desktop-main/src/api-gateway.spec.ts index 4428b90..2df8599 100644 --- a/apps/desktop-main/src/api-gateway.spec.ts +++ b/apps/desktop-main/src/api-gateway.spec.ts @@ -5,6 +5,7 @@ import type { DesktopResult, } from '@electron-foundation/contracts'; import { + getApiOperationDiagnostics, invokeApiOperation, setOidcAccessTokenResolver, type ApiOperation, @@ -72,6 +73,30 @@ describe('invokeApiOperation', () => { expect(error.correlationId).toBe('corr-test'); }); + it('returns operation-not-configured when BYO endpoint is not provided', async () => { + const original = process.env.API_SECURE_ENDPOINT_URL_TEMPLATE; + delete process.env.API_SECURE_ENDPOINT_URL_TEMPLATE; + + const result = await invokeApiOperation( + baseRequest('call.secure-endpoint'), + { + operations: { + 'status.github': { + method: 'GET', + url: 'https://api.github.com/rate_limit', + }, + }, + }, + ); + + const error = expectFailure(result); + expect(error.code).toBe('API/OPERATION_NOT_CONFIGURED'); + + if (typeof original === 'string') { + process.env.API_SECURE_ENDPOINT_URL_TEMPLATE = original; + } + }); + it('rejects insecure destinations', async () => { const operations: Partial> = { 'status.github': { method: 'GET', url: 'http://example.com' }, @@ -150,6 +175,7 @@ describe('invokeApiOperation', () => { if (result.ok) { expect(result.data.status).toBe(200); expect(result.data.data).toEqual({ hello: 'world' }); + expect(result.data.requestPath).toBe('/ok'); } }); @@ -270,9 +296,9 @@ describe('invokeApiOperation', () => { it('returns auth-required when backend rejects wrong-audience oidc token', async () => { const operations: Partial> = { - 'portfolio.user': { + 'call.secure-endpoint': { method: 'GET', - url: 'https://api.example.com/users/{{user_id}}/portfolio', + url: 'https://api.example.com/{{user_id}}', auth: { type: 'oidc', }, @@ -295,8 +321,9 @@ describe('invokeApiOperation', () => { ); const payload = JSON.parse(payloadJson) as { aud?: unknown }; const isValidAudience = - payload.aud === 'api.adopa.uk' || - (Array.isArray(payload.aud) && payload.aud.includes('api.adopa.uk')); + payload.aud === 'api://secure-endpoint' || + (Array.isArray(payload.aud) && + payload.aud.includes('api://secure-endpoint')); if (!isValidAudience) { return new Response(JSON.stringify({ message: 'Unauthorized' }), { @@ -316,7 +343,7 @@ describe('invokeApiOperation', () => { contractVersion: '1.0.0', correlationId: 'corr-test', payload: { - operationId: 'portfolio.user', + operationId: 'call.secure-endpoint', params: { user_id: 'user-1' }, }, }, @@ -332,9 +359,9 @@ describe('invokeApiOperation', () => { it('returns success when backend accepts valid-audience oidc token', async () => { const operations: Partial> = { - 'portfolio.user': { + 'call.secure-endpoint': { method: 'GET', - url: 'https://api.example.com/users/{{user_id}}/portfolio', + url: 'https://api.example.com/{{user_id}}', auth: { type: 'oidc', }, @@ -344,7 +371,7 @@ describe('invokeApiOperation', () => { createJwt({ iss: 'https://issuer.example.com', sub: 'user-1', - aud: ['api.adopa.uk'], + aud: ['api://secure-endpoint'], }), ); @@ -359,7 +386,7 @@ describe('invokeApiOperation', () => { contractVersion: '1.0.0', correlationId: 'corr-test', payload: { - operationId: 'portfolio.user', + operationId: 'call.secure-endpoint', params: { user_id: 'user-1' }, }, }, @@ -374,9 +401,9 @@ describe('invokeApiOperation', () => { it('injects path params into operation url and omits them from query string', async () => { const operations: Partial> = { - 'portfolio.user': { + 'call.secure-endpoint': { method: 'GET', - url: 'https://api.example.com/users/{{user_id}}/portfolio', + url: 'https://api.example.com/{{user_id}}', auth: { type: 'oidc', }, @@ -400,7 +427,7 @@ describe('invokeApiOperation', () => { contractVersion: '1.0.0', correlationId: 'corr-test', payload: { - operationId: 'portfolio.user', + operationId: 'call.secure-endpoint', params: { user_id: 'user-123', include: 'positions', @@ -414,16 +441,16 @@ describe('invokeApiOperation', () => { ); expect(result.ok).toBe(true); - expect(requestedUrl).toContain('/users/user-123/portfolio'); + expect(requestedUrl).toContain('/user-123'); expect(requestedUrl).toContain('include=positions'); expect(requestedUrl).not.toContain('user_id='); }); it('returns invalid-params when required path placeholders are missing', async () => { const operations: Partial> = { - 'portfolio.user': { + 'call.secure-endpoint': { method: 'GET', - url: 'https://api.example.com/users/{{user_id}}/portfolio', + url: 'https://api.example.com/{{user_id}}', }, }; @@ -432,7 +459,7 @@ describe('invokeApiOperation', () => { contractVersion: '1.0.0', correlationId: 'corr-test', payload: { - operationId: 'portfolio.user', + operationId: 'call.secure-endpoint', params: { include: 'positions', }, @@ -447,6 +474,77 @@ describe('invokeApiOperation', () => { expect(error.code).toBe('API/INVALID_PARAMS'); }); + it('maps configured jwt claim paths into endpoint placeholders', async () => { + const operations: Partial> = { + 'call.secure-endpoint': { + method: 'GET', + url: 'https://api.example.com/{{user_id}}/tenant/{{tenant_id}}', + claimMap: { + user_id: 'sub', + tenant_id: 'org.id', + }, + auth: { + type: 'oidc', + }, + }, + }; + setOidcAccessTokenResolver(() => + createJwt({ + sub: 'user-from-jwt', + org: { id: 'tenant-from-jwt' }, + }), + ); + + let requestedUrl = ''; + const fetchFn: typeof fetch = async (input) => { + requestedUrl = String(input); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + const result = await invokeApiOperation( + baseRequest('call.secure-endpoint'), + { + operations, + fetchFn, + }, + ); + + expect(result.ok).toBe(true); + expect(requestedUrl).toContain('/user-from-jwt/tenant/tenant-from-jwt'); + }); + + it('rejects unsafe request header names', async () => { + const operations: Partial> = { + 'call.secure-endpoint': { + method: 'GET', + url: 'https://api.example.com/{{user_id}}', + }, + }; + + const result = await invokeApiOperation( + { + contractVersion: '1.0.0', + correlationId: 'corr-test', + payload: { + operationId: 'call.secure-endpoint', + params: { user_id: 'user-1' }, + headers: { + authorization: 'bad-override', + }, + }, + }, + { + operations, + }, + ); + + const error = expectFailure(result); + expect(error.code).toBe('API/INVALID_HEADERS'); + }); + it('retries GET requests for retryable errors', async () => { const operations: Partial> = { 'status.github': { @@ -501,3 +599,40 @@ describe('invokeApiOperation', () => { expect(attempts).toBe(1); }); }); + +describe('getApiOperationDiagnostics', () => { + it('returns configured diagnostics for known operations', () => { + const result = getApiOperationDiagnostics('call.secure-endpoint', { + operations: { + 'call.secure-endpoint': { + method: 'GET', + url: 'https://api.example.com/users/{{user_id}}/tenant/{{tenant_id}}', + claimMap: { user_id: 'sub' }, + auth: { type: 'oidc' }, + }, + }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.configured).toBe(true); + expect(result.data.pathPlaceholders).toEqual(['user_id', 'tenant_id']); + expect(result.data.claimMap).toEqual({ user_id: 'sub' }); + expect(result.data.authType).toBe('oidc'); + } + }); + + it('returns unconfigured diagnostics when operation is not configured', () => { + const result = getApiOperationDiagnostics('call.secure-endpoint', { + operations: {}, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.configured).toBe(false); + expect(result.data.configurationHint).toContain( + 'API_SECURE_ENDPOINT_URL_TEMPLATE', + ); + } + }); +}); diff --git a/apps/desktop-main/src/api-gateway.ts b/apps/desktop-main/src/api-gateway.ts index 16bc324..aa93d79 100644 --- a/apps/desktop-main/src/api-gateway.ts +++ b/apps/desktop-main/src/api-gateway.ts @@ -19,6 +19,7 @@ export type ApiOperation = { maxResponseBytes?: number; concurrencyLimit?: number; minIntervalMs?: number; + claimMap?: Record; auth?: | { type: 'bearer'; @@ -36,7 +37,56 @@ export type ApiOperation = { }; }; -export const defaultApiOperations: Record = { +const SAFE_HEADER_NAME_PATTERN = /^x-[a-z0-9-]+$/i; +const JWT_CLAIM_PATH_PATTERN = /^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*$/; + +const resolveConfiguredSecureEndpointUrl = (): string | null => { + const configured = process.env.API_SECURE_ENDPOINT_URL_TEMPLATE?.trim(); + return configured && configured.length > 0 ? configured : null; +}; + +const resolveConfiguredSecureEndpointClaimMap = (): Record => { + const raw = process.env.API_SECURE_ENDPOINT_CLAIM_MAP?.trim(); + if (!raw) { + return {}; + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + const normalized: Record = {}; + for (const [placeholder, claimPath] of Object.entries(parsed)) { + if ( + typeof placeholder !== 'string' || + typeof claimPath !== 'string' || + !JWT_CLAIM_PATH_PATTERN.test(claimPath) + ) { + continue; + } + normalized[placeholder] = claimPath; + } + + return normalized; + } catch { + return {}; + } +}; + +const operationConfigurationIssues: Partial> = { + 'call.secure-endpoint': + 'Set API_SECURE_ENDPOINT_URL_TEMPLATE in .env.local to enable this operation.', +}; + +const configuredSecureEndpointUrl = resolveConfiguredSecureEndpointUrl(); +const configuredSecureEndpointClaimMap = + resolveConfiguredSecureEndpointClaimMap(); + +export const defaultApiOperations: Partial< + Record +> = { 'status.github': { method: 'GET', url: 'https://api.github.com/rate_limit', @@ -46,16 +96,21 @@ export const defaultApiOperations: Record = { minIntervalMs: 300, auth: { type: 'none' }, }, - 'portfolio.user': { - method: 'GET', - url: 'https://api.adopa.uk/users/{{user_id}}/portfolio', - timeoutMs: 10_000, - maxResponseBytes: 1_000_000, - concurrencyLimit: 2, - minIntervalMs: 300, - auth: { type: 'oidc' }, - retry: { maxAttempts: 2, baseDelayMs: 200 }, - }, + ...(configuredSecureEndpointUrl + ? { + 'call.secure-endpoint': { + method: 'GET', + url: configuredSecureEndpointUrl, + timeoutMs: 10_000, + maxResponseBytes: 1_000_000, + concurrencyLimit: 2, + minIntervalMs: 300, + claimMap: configuredSecureEndpointClaimMap, + auth: { type: 'oidc' }, + retry: { maxAttempts: 2, baseDelayMs: 200 }, + }, + } + : {}), }; type InvokeApiDeps = { @@ -63,10 +118,26 @@ type InvokeApiDeps = { operations?: Partial>; }; +type GetApiOperationDiagnosticsDeps = { + operations?: Partial>; +}; + type ApiSuccess = { status: number; data: unknown; contentType?: string; + requestPath?: string; +}; + +type ApiOperationDiagnostics = { + operationId: ApiOperationId; + configured: boolean; + configurationHint?: string; + method?: 'GET' | 'POST'; + urlTemplate?: string; + pathPlaceholders: string[]; + claimMap: Record; + authType?: 'none' | 'bearer' | 'oidc'; }; type OperationRuntimeState = { @@ -181,6 +252,37 @@ const decodeJwtPayload = (token: string): Record | null => { } }; +const readJwtClaimByPath = ( + payload: Record | null, + path: string, +): string | number | boolean | null | undefined => { + if (!payload || !path || !JWT_CLAIM_PATH_PATTERN.test(path)) { + return undefined; + } + + const segments = path.split('.'); + let current: unknown = payload; + + for (const segment of segments) { + if (!current || typeof current !== 'object' || Array.isArray(current)) { + return undefined; + } + + current = (current as Record)[segment]; + } + + if ( + typeof current === 'string' || + typeof current === 'number' || + typeof current === 'boolean' || + current === null + ) { + return current as string | number | boolean | null; + } + + return undefined; +}; + const pickTokenDebugClaims = (payload: Record | null) => { if (!payload) { return null; @@ -247,28 +349,104 @@ const invokeSingleAttempt = async ( const maxResponseBytes = operation.maxResponseBytes ?? API_DEFAULT_MAX_RESPONSE_BYTES; const providedParams = request.payload.params ?? {}; + + const auth = operation.auth ?? { type: 'none' as const }; + let oidcPayload: Record | null = null; + let oidcTokenClaims: ReturnType | undefined; + + const headers = new Headers(); + headers.set('Accept', 'application/json'); + + if (auth.type === 'bearer') { + const token = process.env[auth.tokenEnvVar]?.trim(); + if (!token) { + return asFailure( + 'API/CREDENTIALS_UNAVAILABLE', + 'Required API credentials are not available.', + { + operationId: request.payload.operationId, + tokenEnvVar: auth.tokenEnvVar, + }, + false, + correlationId, + ); + } + + headers.set('Authorization', `Bearer ${token}`); + } + + if (auth.type === 'oidc') { + const token = oidcAccessTokenResolver?.(); + if (!token) { + return asFailure( + 'API/AUTH_REQUIRED', + 'An active OIDC session is required for this API operation.', + { + operationId: request.payload.operationId, + }, + false, + correlationId, + ); + } + + headers.set('Authorization', `Bearer ${token}`); + oidcPayload = decodeJwtPayload(token); + oidcTokenClaims = pickTokenDebugClaims(oidcPayload); + } + + if (request.payload.headers) { + for (const [key, value] of Object.entries(request.payload.headers)) { + if (!SAFE_HEADER_NAME_PATTERN.test(key)) { + return asFailure( + 'API/INVALID_HEADERS', + 'Request headers contain unsupported header names.', + { + operationId: request.payload.operationId, + header: key, + }, + false, + correlationId, + ); + } + + headers.set(key, value); + } + } + const usedPathParams = new Set(); + const missingPathParams: string[] = []; const templatedUrl = operation.url.replace( PATH_PARAM_PATTERN, (_match, rawParamName: string) => { const paramName = String(rawParamName); - const paramValue = providedParams[paramName]; + const directValue = providedParams[paramName]; + if ( + typeof directValue === 'string' || + typeof directValue === 'number' || + typeof directValue === 'boolean' + ) { + usedPathParams.add(paramName); + return encodeURIComponent(String(directValue)); + } + + const mappedClaimPath = operation.claimMap?.[paramName]; + const claimValue = mappedClaimPath + ? readJwtClaimByPath(oidcPayload, mappedClaimPath) + : undefined; if ( - typeof paramValue !== 'string' && - typeof paramValue !== 'number' && - typeof paramValue !== 'boolean' + typeof claimValue === 'string' || + typeof claimValue === 'number' || + typeof claimValue === 'boolean' ) { - usedPathParams.add(`__missing__${paramName}`); - return ''; + usedPathParams.add(paramName); + return encodeURIComponent(String(claimValue)); } - usedPathParams.add(paramName); - return encodeURIComponent(String(paramValue)); + missingPathParams.push(paramName); + return ''; }, ); - const missingPathParams = Array.from(usedPathParams) - .filter((key) => key.startsWith('__missing__')) - .map((key) => key.replace('__missing__', '')); + if (missingPathParams.length > 0) { return asFailure( 'API/INVALID_PARAMS', @@ -276,6 +454,7 @@ const invokeSingleAttempt = async ( { operationId: request.payload.operationId, missingPathParams, + claimMap: operation.claimMap ?? {}, }, false, correlationId, @@ -294,6 +473,7 @@ const invokeSingleAttempt = async ( correlationId, ); } + if (operation.method === 'GET' && request.payload.params) { for (const [key, value] of Object.entries(request.payload.params)) { if (usedPathParams.has(key)) { @@ -303,47 +483,7 @@ const invokeSingleAttempt = async ( } } - const headers = new Headers(); - headers.set('Accept', 'application/json'); const requestUrlString = requestUrl.toString(); - let oidcTokenClaims: ReturnType | undefined; - - const auth = operation.auth ?? { type: 'none' as const }; - if (auth.type === 'bearer') { - const token = process.env[auth.tokenEnvVar]?.trim(); - if (!token) { - return asFailure( - 'API/CREDENTIALS_UNAVAILABLE', - 'Required API credentials are not available.', - { - operationId: request.payload.operationId, - tokenEnvVar: auth.tokenEnvVar, - }, - false, - correlationId, - ); - } - - headers.set('Authorization', `Bearer ${token}`); - } - - if (auth.type === 'oidc') { - const token = oidcAccessTokenResolver?.(); - if (!token) { - return asFailure( - 'API/AUTH_REQUIRED', - 'An active OIDC session is required for this API operation.', - { - operationId: request.payload.operationId, - }, - false, - correlationId, - ); - } - - headers.set('Authorization', `Bearer ${token}`); - oidcTokenClaims = pickTokenDebugClaims(decodeJwtPayload(token)); - } const abortController = new AbortController(); const timeoutHandle = setTimeout(() => abortController.abort(), timeoutMs); @@ -507,6 +647,7 @@ const invokeSingleAttempt = async ( status: response.status, data: responseData, contentType, + requestPath: `${requestUrl.pathname}${requestUrl.search}`, }); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { @@ -540,6 +681,46 @@ const getOperationState = (operationId: string): OperationRuntimeState => { return initial; }; +const extractPathPlaceholders = (urlTemplate: string): string[] => { + const matches = urlTemplate.matchAll(/\{\{([a-zA-Z0-9_]+)\}\}/g); + const values = new Set(); + for (const match of matches) { + if (match[1]) { + values.add(match[1]); + } + } + return Array.from(values.values()); +}; + +export const getApiOperationDiagnostics = ( + operationId: ApiOperationId, + deps: GetApiOperationDiagnosticsDeps = {}, +): DesktopResult => { + const operations = deps.operations ?? defaultApiOperations; + const operation = operations[operationId]; + + if (!operation) { + const configurationHint = operationConfigurationIssues[operationId]; + return asSuccess({ + operationId, + configured: false, + configurationHint, + pathPlaceholders: [], + claimMap: {}, + }); + } + + return asSuccess({ + operationId, + configured: true, + method: operation.method, + urlTemplate: operation.url, + pathPlaceholders: extractPathPlaceholders(operation.url), + claimMap: operation.claimMap ?? {}, + authType: operation.auth?.type ?? 'none', + }); +}; + export const invokeApiOperation = async ( request: ApiInvokeRequest, deps: InvokeApiDeps = {}, @@ -550,6 +731,21 @@ export const invokeApiOperation = async ( const operation = operations[request.payload.operationId]; if (!operation) { + const configIssue = + operationConfigurationIssues[request.payload.operationId]; + if (configIssue) { + return asFailure( + 'API/OPERATION_NOT_CONFIGURED', + 'Requested API operation is not configured in this environment.', + { + operationId: request.payload.operationId, + configurationHint: configIssue, + }, + false, + correlationId, + ); + } + return asFailure( 'API/OPERATION_NOT_ALLOWED', 'Requested API operation is not allowed.', diff --git a/apps/desktop-main/src/desktop-window.ts b/apps/desktop-main/src/desktop-window.ts new file mode 100644 index 0000000..10c86ad --- /dev/null +++ b/apps/desktop-main/src/desktop-window.ts @@ -0,0 +1,268 @@ +import { app, BrowserWindow, type WebContents } from 'electron'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +export type NavigationPolicy = { + isDevelopment: boolean; + rendererDevUrl: string; + allowedDevHosts?: ReadonlySet; +}; + +export type WindowLogger = ( + level: 'warn' | 'error', + event: string, + details?: Record, +) => void; + +export type CreateMainWindowOptions = { + isDevelopment: boolean; + runtimeSmokeEnabled: boolean; + shouldOpenDevTools: boolean; + rendererDevUrl: string; + onWindowClosed: (windowId: number) => void; + logger: WindowLogger; +}; + +const runtimeSmokeSettleMs = 4_000; + +const resolveExistingPath = ( + description: string, + candidates: string[], +): string => { + for (const candidate of candidates) { + const absolutePath = path.resolve(__dirname, candidate); + if (existsSync(absolutePath)) { + return absolutePath; + } + } + + throw new Error( + `Unable to resolve ${description}. Checked: ${candidates + .map((candidate) => path.resolve(__dirname, candidate)) + .join(', ')}`, + ); +}; + +const resolvePreloadPath = (): string => + resolveExistingPath('preload script', [ + '../desktop-preload/main.js', + '../apps/desktop-preload/main.js', + '../../desktop-preload/main.js', + '../../../desktop-preload/main.js', + '../../../../desktop-preload/main.js', + '../desktop-preload/src/main.js', + '../apps/desktop-preload/src/main.js', + '../../desktop-preload/src/main.js', + '../../../desktop-preload/src/main.js', + ]); + +const resolveRendererIndexPath = (): string => + resolveExistingPath('renderer index', [ + '../../../../renderer/browser/index.html', + '../renderer/browser/index.html', + '../../renderer/browser/index.html', + '../../../renderer/browser/index.html', + ]); + +const resolveWindowIconPath = (): string | undefined => { + const appPath = app.getAppPath(); + const candidates = [ + path.resolve(process.cwd(), 'build/icon.ico'), + path.resolve(process.cwd(), 'apps/renderer/public/favicon.ico'), + path.resolve(appPath, 'build/icon.ico'), + path.resolve(appPath, 'apps/renderer/public/favicon.ico'), + path.resolve(__dirname, '../../../../../build/icon.ico'), + path.resolve(__dirname, '../../../../../../build/icon.ico'), + path.resolve(__dirname, '../../../../../apps/renderer/public/favicon.ico'), + path.resolve( + __dirname, + '../../../../../../apps/renderer/public/favicon.ico', + ), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + return undefined; +}; + +export const resolveRendererDevUrl = ( + rendererDevUrl: string, + allowedDevHosts: ReadonlySet = new Set(['localhost', '127.0.0.1']), +): URL => { + const parsed = new URL(rendererDevUrl); + if (parsed.protocol !== 'http:' || !allowedDevHosts.has(parsed.hostname)) { + throw new Error( + `RENDERER_DEV_URL must use http://localhost or http://127.0.0.1. Received: ${rendererDevUrl}`, + ); + } + + return parsed; +}; + +export const isAllowedNavigation = ( + targetUrl: string, + navigationPolicy: NavigationPolicy, +): boolean => { + try { + const parsed = new URL(targetUrl); + if (navigationPolicy.isDevelopment) { + const allowedDevUrl = resolveRendererDevUrl( + navigationPolicy.rendererDevUrl, + navigationPolicy.allowedDevHosts, + ); + return parsed.origin === allowedDevUrl.origin; + } + + return parsed.protocol === 'file:'; + } catch { + return false; + } +}; + +const hardenWebContents = ( + contents: WebContents, + navigationPolicy: NavigationPolicy, + logger: WindowLogger, +) => { + contents.setWindowOpenHandler(({ url }) => { + logger('warn', 'security.window_open_blocked', { url }); + return { action: 'deny' }; + }); + + contents.on('will-navigate', (event, url) => { + if (!isAllowedNavigation(url, navigationPolicy)) { + event.preventDefault(); + logger('warn', 'security.navigation_blocked', { url }); + } + }); +}; + +const enableRuntimeSmokeMode = ( + window: BrowserWindow, + logger: WindowLogger, +) => { + const diagnostics: string[] = []; + const pushDiagnostic = (message: string) => { + diagnostics.push(message); + }; + + window.webContents.on('console-message', (details) => { + if (details.level === 'warning' || details.level === 'error') { + const label = details.level === 'warning' ? 'warn' : 'error'; + pushDiagnostic( + `${label} ${details.sourceId}:${details.lineNumber} ${details.message}`, + ); + } + }); + + window.webContents.on('render-process-gone', (_event, details) => { + pushDiagnostic(`render-process-gone: ${details.reason}`); + }); + + window.webContents.on('did-fail-load', (_event, code, description, url) => { + pushDiagnostic(`did-fail-load: ${code} ${description} ${url}`); + }); + + window.webContents.once('did-finish-load', () => { + setTimeout(async () => { + try { + await window.webContents.executeJavaScript(` + (() => { + const labels = ['Material', 'Carbon', 'Tailwind']; + let delay = 150; + for (const label of labels) { + setTimeout(() => { + const candidates = [...document.querySelectorAll('a,[role="link"],button')]; + const target = candidates.find((el) => + (el.textContent || '').toLowerCase().includes(label.toLowerCase()) + ); + target?.click(); + }, delay); + delay += 250; + } + })(); + `); + } catch (error) { + pushDiagnostic( + `route-probe-failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + setTimeout(() => { + if (diagnostics.length > 0) { + logger('error', 'runtime_smoke.failed', { diagnostics }); + app.exit(1); + return; + } + + console.info('Runtime smoke passed: no renderer warnings or errors.'); + app.exit(0); + }, runtimeSmokeSettleMs); + }, 250); + }); +}; + +export const createMainWindow = async ( + options: CreateMainWindowOptions, +): Promise => { + const navigationPolicy: NavigationPolicy = { + isDevelopment: options.isDevelopment, + rendererDevUrl: options.rendererDevUrl, + }; + + const windowIconPath = resolveWindowIconPath(); + const window = new BrowserWindow({ + width: 1440, + height: 900, + minWidth: 1080, + minHeight: 680, + show: false, + backgroundColor: '#f8f7f1', + autoHideMenuBar: true, + ...(windowIconPath ? { icon: windowIconPath } : {}), + webPreferences: { + preload: resolvePreloadPath(), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + experimentalFeatures: false, + }, + }); + + window.setMenuBarVisibility(false); + hardenWebContents(window.webContents, navigationPolicy, options.logger); + + if (options.runtimeSmokeEnabled) { + enableRuntimeSmokeMode(window, options.logger); + } + + window.on('closed', () => { + options.onWindowClosed(window.id); + }); + + window.once('ready-to-show', () => { + window.show(); + }); + + if (options.isDevelopment) { + await window.loadURL( + resolveRendererDevUrl(options.rendererDevUrl).toString(), + ); + } else { + await window.loadFile(resolveRendererIndexPath()); + } + + if (options.shouldOpenDevTools) { + window.webContents.openDevTools({ mode: 'detach' }); + } + + return window; +}; diff --git a/apps/desktop-main/src/main.ts b/apps/desktop-main/src/main.ts index 93fd706..49191ab 100644 --- a/apps/desktop-main/src/main.ts +++ b/apps/desktop-main/src/main.ts @@ -8,19 +8,31 @@ import { session, shell, type IpcMainInvokeEvent, - type WebContents, } from 'electron'; import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { autoUpdater } from 'electron-updater'; -import { invokeApiOperation, setOidcAccessTokenResolver } from './api-gateway'; +import { + getApiOperationDiagnostics, + invokeApiOperation, + setOidcAccessTokenResolver, +} from './api-gateway'; +import { + createMainWindow as createDesktopMainWindow, + isAllowedNavigation, +} from './desktop-window'; import { loadOidcConfig } from './oidc-config'; import { OidcService } from './oidc-service'; +import { + resolveAppMetadataVersion, + resolveRuntimeFlags, +} from './runtime-config'; import { createRefreshTokenStore } from './secure-token-store'; import { StorageGateway } from './storage-gateway'; import { + apiGetOperationDiagnosticsRequestSchema, apiInvokeRequestSchema, authGetSessionSummaryRequestSchema, authGetTokenDiagnosticsRequestSchema, @@ -44,162 +56,16 @@ import { } from '@electron-foundation/contracts'; import { toStructuredLogLine } from '@electron-foundation/common'; -const runtimeSmokeEnabled = process.env.RUNTIME_SMOKE === '1'; -const isDevelopment = !app.isPackaged && !runtimeSmokeEnabled; -const resolveAppEnvironment = (): 'development' | 'staging' | 'production' => { - const envValue = process.env.APP_ENV?.trim().toLowerCase(); - if ( - envValue === 'development' || - envValue === 'staging' || - envValue === 'production' - ) { - return envValue; - } - - const packageJsonCandidates = [ - '../../../../package.json', - '../../../package.json', - '../../package.json', - '../package.json', - ]; - - for (const candidate of packageJsonCandidates) { - try { - const absolutePath = path.resolve(__dirname, candidate); - if (!existsSync(absolutePath)) { - continue; - } - - const raw = require(absolutePath) as { appEnv?: unknown }; - if ( - raw.appEnv === 'development' || - raw.appEnv === 'staging' || - raw.appEnv === 'production' - ) { - return raw.appEnv; - } - } catch { - // Ignore and continue fallback chain. - } - } - - return app.isPackaged ? 'production' : 'development'; -}; - -const resolveIsStagingExecutable = (): boolean => { - const executableName = path.basename(process.execPath).toLowerCase(); - return executableName.includes('staging'); -}; - -const appEnvironment = resolveAppEnvironment(); -const packagedDevToolsOverride = process.env.DESKTOP_ENABLE_DEVTOOLS; -const allowPackagedDevTools = - app.isPackaged && - (appEnvironment === 'staging' || resolveIsStagingExecutable()) && - packagedDevToolsOverride !== '0'; -const shouldOpenDevTools = - !runtimeSmokeEnabled && (isDevelopment || allowPackagedDevTools); -const rendererDevUrl = process.env.RENDERER_DEV_URL ?? 'http://localhost:4200'; +const { + runtimeSmokeEnabled, + isDevelopment, + appEnvironment, + shouldOpenDevTools, + rendererDevUrl, +} = resolveRuntimeFlags(app); +const navigationPolicy = { isDevelopment, rendererDevUrl }; const fileTokenTtlMs = 5 * 60 * 1000; const fileTokenCleanupIntervalMs = 60 * 1000; -const runtimeSmokeSettleMs = 4_000; -const allowedDevHosts = new Set(['localhost', '127.0.0.1']); - -const resolveExistingPath = ( - description: string, - candidates: string[], -): string => { - for (const candidate of candidates) { - const absolutePath = path.resolve(__dirname, candidate); - if (existsSync(absolutePath)) { - return absolutePath; - } - } - - throw new Error( - `Unable to resolve ${description}. Checked: ${candidates - .map((candidate) => path.resolve(__dirname, candidate)) - .join(', ')}`, - ); -}; - -const resolveAppMetadataVersion = (): string => { - const envVersion = process.env.npm_package_version?.trim(); - if (envVersion) { - return envVersion; - } - - const packageJsonCandidates = [ - '../../../../package.json', - '../../../package.json', - '../../package.json', - '../package.json', - ]; - - for (const candidate of packageJsonCandidates) { - try { - const absolutePath = path.resolve(__dirname, candidate); - if (!existsSync(absolutePath)) { - continue; - } - - const raw = require(absolutePath) as { version?: unknown }; - if (typeof raw.version === 'string' && raw.version.trim().length > 0) { - return raw.version.trim(); - } - } catch { - // Ignore and continue fallback chain. - } - } - - return app.getVersion(); -}; - -const resolvePreloadPath = (): string => - resolveExistingPath('preload script', [ - '../desktop-preload/main.js', - '../apps/desktop-preload/main.js', - '../../desktop-preload/main.js', - '../../../desktop-preload/main.js', - '../../../../desktop-preload/main.js', - '../desktop-preload/src/main.js', - '../apps/desktop-preload/src/main.js', - '../../desktop-preload/src/main.js', - '../../../desktop-preload/src/main.js', - ]); - -const resolveRendererIndexPath = (): string => - resolveExistingPath('renderer index', [ - '../../../../renderer/browser/index.html', - '../renderer/browser/index.html', - '../../renderer/browser/index.html', - '../../../renderer/browser/index.html', - ]); - -const resolveWindowIconPath = (): string | undefined => { - const appPath = app.getAppPath(); - const candidates = [ - path.resolve(process.cwd(), 'build/icon.ico'), - path.resolve(process.cwd(), 'apps/renderer/public/favicon.ico'), - path.resolve(appPath, 'build/icon.ico'), - path.resolve(appPath, 'apps/renderer/public/favicon.ico'), - path.resolve(__dirname, '../../../../../build/icon.ico'), - path.resolve(__dirname, '../../../../../../build/icon.ico'), - path.resolve(__dirname, '../../../../../apps/renderer/public/favicon.ico'), - path.resolve( - __dirname, - '../../../../../../apps/renderer/public/favicon.ico', - ), - ]; - - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate; - } - } - - return undefined; -}; type FileSelectionToken = { filePath: string; @@ -242,45 +108,6 @@ const logEvent = ( console.info(line); }; -const resolveRendererDevUrl = (): URL => { - const parsed = new URL(rendererDevUrl); - if (parsed.protocol !== 'http:' || !allowedDevHosts.has(parsed.hostname)) { - throw new Error( - `RENDERER_DEV_URL must use http://localhost or http://127.0.0.1. Received: ${rendererDevUrl}`, - ); - } - - return parsed; -}; - -const isAllowedNavigation = (targetUrl: string): boolean => { - try { - const parsed = new URL(targetUrl); - if (isDevelopment) { - const allowedDevUrl = resolveRendererDevUrl(); - return parsed.origin === allowedDevUrl.origin; - } - - return parsed.protocol === 'file:'; - } catch { - return false; - } -}; - -const hardenWebContents = (contents: WebContents) => { - contents.setWindowOpenHandler(({ url }) => { - logEvent('warn', 'security.window_open_blocked', undefined, { url }); - return { action: 'deny' }; - }); - - contents.on('will-navigate', (event, url) => { - if (!isAllowedNavigation(url)) { - event.preventDefault(); - logEvent('warn', 'security.navigation_blocked', undefined, { url }); - } - }); -}; - const startFileTokenCleanup = () => { if (tokenCleanupTimer) { return; @@ -313,126 +140,23 @@ const clearFileTokensForWindow = (windowId: number) => { } }; -const enableRuntimeSmokeMode = (window: BrowserWindow) => { - const diagnostics: string[] = []; - const pushDiagnostic = (message: string) => { - diagnostics.push(message); - }; - - window.webContents.on('console-message', (details) => { - if (details.level === 'warning' || details.level === 'error') { - const label = details.level === 'warning' ? 'warn' : 'error'; - pushDiagnostic( - `${label} ${details.sourceId}:${details.lineNumber} ${details.message}`, - ); - } - }); - - window.webContents.on('render-process-gone', (_event, details) => { - pushDiagnostic(`render-process-gone: ${details.reason}`); - }); - - window.webContents.on('did-fail-load', (_event, code, description, url) => { - pushDiagnostic(`did-fail-load: ${code} ${description} ${url}`); - }); - - window.webContents.once('did-finish-load', () => { - setTimeout(async () => { - try { - await window.webContents.executeJavaScript(` - (() => { - const labels = ['Material', 'Carbon', 'Tailwind']; - let delay = 150; - for (const label of labels) { - setTimeout(() => { - const candidates = [...document.querySelectorAll('a,[role="link"],button')]; - const target = candidates.find((el) => - (el.textContent || '').toLowerCase().includes(label.toLowerCase()) - ); - target?.click(); - }, delay); - delay += 250; - } - })(); - `); - } catch (error) { - pushDiagnostic( - `route-probe-failed: ${ - error instanceof Error ? error.message : String(error) - }`, - ); +const createMainWindow = async (): Promise => + createDesktopMainWindow({ + isDevelopment, + runtimeSmokeEnabled, + shouldOpenDevTools, + rendererDevUrl, + onWindowClosed: (windowId) => { + clearFileTokensForWindow(windowId); + if (mainWindow?.id === windowId) { + mainWindow = null; } - - setTimeout(() => { - if (diagnostics.length > 0) { - console.error('Runtime smoke failed due to renderer diagnostics.'); - for (const message of diagnostics) { - console.error(`- ${message}`); - } - app.exit(1); - return; - } - - console.info('Runtime smoke passed: no renderer warnings or errors.'); - app.exit(0); - }, runtimeSmokeSettleMs); - }, 250); - }); -}; - -const createMainWindow = async (): Promise => { - const windowIconPath = resolveWindowIconPath(); - const window = new BrowserWindow({ - width: 1440, - height: 900, - minWidth: 1080, - minHeight: 680, - show: false, - backgroundColor: '#f8f7f1', - autoHideMenuBar: true, - ...(windowIconPath ? { icon: windowIconPath } : {}), - webPreferences: { - preload: resolvePreloadPath(), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - webSecurity: true, - allowRunningInsecureContent: false, - experimentalFeatures: false, + }, + logger: (level, event, details) => { + logEvent(level, event, undefined, details); }, }); - window.setMenuBarVisibility(false); - - hardenWebContents(window.webContents); - if (runtimeSmokeEnabled) { - enableRuntimeSmokeMode(window); - } - - window.on('closed', () => { - clearFileTokensForWindow(window.id); - if (mainWindow?.id === window.id) { - mainWindow = null; - } - }); - - window.once('ready-to-show', () => { - window.show(); - }); - - if (isDevelopment) { - await window.loadURL(resolveRendererDevUrl().toString()); - } else { - await window.loadFile(resolveRendererIndexPath()); - } - - if (shouldOpenDevTools) { - window.webContents.openDevTools({ mode: 'detach' }); - } - - return window; -}; - const getCorrelationId = (payload: unknown): string | undefined => { if ( payload && @@ -453,7 +177,8 @@ const assertAuthorizedSender = ( const senderWindow = BrowserWindow.fromWebContents(event.sender); const senderUrl = event.senderFrame?.url ?? event.sender.getURL(); const authorized = - senderWindow?.id === mainWindow?.id && isAllowedNavigation(senderUrl); + senderWindow?.id === mainWindow?.id && + isAllowedNavigation(senderUrl, navigationPolicy); if (!authorized) { return asFailure( @@ -555,6 +280,7 @@ const registerIpcHandlers = () => { electron: process.versions.electron, node: process.versions.node, chrome: process.versions.chrome, + appEnvironment, }); }); @@ -820,6 +546,30 @@ const registerIpcHandlers = () => { return invokeApiOperation(parsed.data); }); + ipcMain.handle( + IPC_CHANNELS.apiGetOperationDiagnostics, + async (event, payload) => { + const correlationId = getCorrelationId(payload); + const unauthorized = assertAuthorizedSender(event, correlationId); + if (unauthorized) { + return unauthorized; + } + + const parsed = apiGetOperationDiagnosticsRequestSchema.safeParse(payload); + if (!parsed.success) { + return asFailure( + 'IPC/VALIDATION_FAILED', + 'IPC payload failed validation.', + parsed.error.flatten(), + false, + correlationId, + ); + } + + return getApiOperationDiagnostics(parsed.data.payload.operationId); + }, + ); + ipcMain.handle(IPC_CHANNELS.storageSetItem, (event, payload) => { const correlationId = getCorrelationId(payload); const unauthorized = assertAuthorizedSender(event, correlationId); diff --git a/apps/desktop-main/src/oidc-service.spec.ts b/apps/desktop-main/src/oidc-service.spec.ts new file mode 100644 index 0000000..019be9f --- /dev/null +++ b/apps/desktop-main/src/oidc-service.spec.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { OidcConfig } from './oidc-config'; +import { OidcService } from './oidc-service'; +import type { RefreshTokenStore } from './secure-token-store'; + +const toBase64Url = (value: string): string => + Buffer.from(value, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + +const createJwt = (payload: Record): string => { + const header = toBase64Url(JSON.stringify({ alg: 'none', typ: 'JWT' })); + const body = toBase64Url(JSON.stringify(payload)); + return `${header}.${body}.sig`; +}; + +const baseConfig: OidcConfig = { + issuer: 'https://issuer.example.com', + clientId: 'desktop-client', + redirectUri: 'http://127.0.0.1:42813/callback', + scopes: ['openid', 'profile', 'email'], + audience: 'api://desktop', + sendAudienceInAuthorize: false, + apiBearerTokenSource: 'access_token', +}; + +const createStore = ( + overrides?: Partial, +): RefreshTokenStore => ({ + kind: 'file-encrypted', + get: async () => null, + set: async () => undefined, + clear: async () => undefined, + ...overrides, +}); + +describe('OidcService lifecycle', () => { + it('calls revocation endpoint and clears token store on sign out', async () => { + const store = createStore({ + get: vi.fn(async () => 'stored-refresh-token'), + clear: vi.fn(async () => undefined), + }); + + const fetchFn: typeof fetch = vi.fn(async (input, init) => { + const url = String(input); + if (url.endsWith('/.well-known/openid-configuration')) { + return new Response( + JSON.stringify({ + authorization_endpoint: 'https://issuer.example.com/authorize', + token_endpoint: 'https://issuer.example.com/oauth/token', + revocation_endpoint: 'https://issuer.example.com/oauth/revoke', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + if (url === 'https://issuer.example.com/oauth/revoke') { + const body = String(init?.body ?? ''); + expect(body).toContain('token=stored-refresh-token'); + expect(body).toContain('token_type_hint=refresh_token'); + return new Response('', { status: 200 }); + } + + return new Response('unexpected', { status: 500 }); + }); + + const service = new OidcService({ + config: baseConfig, + tokenStore: store, + openExternal: async () => undefined, + fetchFn, + }); + + const result = await service.signOut(); + + expect(result.ok).toBe(true); + expect(store.clear).toHaveBeenCalledTimes(1); + expect(fetchFn).toHaveBeenCalledWith( + 'https://issuer.example.com/oauth/revoke', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('still clears local token store when revocation fails', async () => { + const store = createStore({ + get: vi.fn(async () => 'stored-refresh-token'), + clear: vi.fn(async () => undefined), + }); + + const fetchFn: typeof fetch = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith('/.well-known/openid-configuration')) { + return new Response( + JSON.stringify({ + authorization_endpoint: 'https://issuer.example.com/authorize', + token_endpoint: 'https://issuer.example.com/oauth/token', + revocation_endpoint: 'https://issuer.example.com/oauth/revoke', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + if (url === 'https://issuer.example.com/oauth/revoke') { + return new Response('nope', { status: 500 }); + } + + return new Response('unexpected', { status: 500 }); + }); + + const service = new OidcService({ + config: baseConfig, + tokenStore: store, + openExternal: async () => undefined, + fetchFn, + }); + + const result = await service.signOut(); + + expect(result.ok).toBe(true); + expect(store.clear).toHaveBeenCalledTimes(1); + }); + + it('rehydrates active session from stored refresh token on startup', async () => { + const store = createStore({ + get: vi.fn(async () => 'stored-refresh-token'), + set: vi.fn(async () => undefined), + }); + + const idToken = createJwt({ + sub: 'user-123', + email: 'user@example.com', + name: 'Example User', + }); + + const fetchFn: typeof fetch = vi.fn(async (input, init) => { + const url = String(input); + if (url.endsWith('/.well-known/openid-configuration')) { + return new Response( + JSON.stringify({ + authorization_endpoint: 'https://issuer.example.com/authorize', + token_endpoint: 'https://issuer.example.com/oauth/token', + revocation_endpoint: 'https://issuer.example.com/oauth/revoke', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + if (url === 'https://issuer.example.com/oauth/token') { + const body = String(init?.body ?? ''); + expect(body).toContain('grant_type=refresh_token'); + expect(body).toContain('refresh_token=stored-refresh-token'); + return new Response( + JSON.stringify({ + access_token: 'access-token-123', + token_type: 'Bearer', + expires_in: 300, + id_token: idToken, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + return new Response('unexpected', { status: 500 }); + }); + + const service = new OidcService({ + config: baseConfig, + tokenStore: store, + openExternal: async () => undefined, + fetchFn, + }); + + const summary = await service.getSessionSummary(); + + expect(summary.ok).toBe(true); + if (summary.ok) { + expect(summary.data.state).toBe('active'); + expect(summary.data.userId).toBe('user-123'); + expect(summary.data.email).toBe('user@example.com'); + } + + expect(store.set).toHaveBeenCalledWith('stored-refresh-token'); + }); +}); diff --git a/apps/desktop-main/src/oidc-service.ts b/apps/desktop-main/src/oidc-service.ts index 9bc19f3..7cae2ff 100644 --- a/apps/desktop-main/src/oidc-service.ts +++ b/apps/desktop-main/src/oidc-service.ts @@ -247,6 +247,13 @@ export class OidcService { authorizationUrl.searchParams.set('audience', this.config.audience); } + this.logger?.('info', 'auth.signin.launch', { + authorizationHost: authorizationUrl.host, + authorizationPath: authorizationUrl.pathname, + redirectHost: new URL(redirectUriForRequest).host, + redirectPath: new URL(redirectUriForRequest).pathname, + }); + await this.openExternal(authorizationUrl.toString()); const code = await callbackResult.waitForCode(); @@ -274,7 +281,7 @@ export class OidcService { ); } - this.applyTokenResponse(tokenResult.data); + await this.applyTokenResponse(tokenResult.data); this.logger?.('info', 'auth.signin.success', { tokenStore: this.tokenStore.kind, }); @@ -331,6 +338,18 @@ export class OidcService { } async getSessionSummary(): Promise> { + if (this.signInInFlight) { + return asSuccess(this.summary); + } + + if (!this.tokens) { + const refreshed = await this.ensureRefreshAccessToken(); + if (!refreshed.ok) { + return refreshed; + } + return asSuccess(this.summary); + } + if (this.tokens && Date.now() >= this.tokens.accessTokenExpiresAt) { const refreshed = await this.ensureRefreshAccessToken(); if (!refreshed.ok) { @@ -577,7 +596,7 @@ export class OidcService { ); } - this.applyTokenResponse({ + await this.applyTokenResponse({ access_token: payload.access_token, token_type: payload.token_type, expires_in: payload.expires_in, @@ -615,7 +634,7 @@ export class OidcService { } } - private applyTokenResponse(tokenResponse: TokenResponse) { + private async applyTokenResponse(tokenResponse: TokenResponse) { const expiresInSeconds = typeof tokenResponse.expires_in === 'number' && Number.isFinite(tokenResponse.expires_in) @@ -663,7 +682,7 @@ export class OidcService { }; if (refreshToken) { - void this.tokenStore.set(refreshToken); + await this.tokenStore.set(refreshToken); } this.scheduleRefresh(); diff --git a/apps/desktop-main/src/runtime-config.ts b/apps/desktop-main/src/runtime-config.ts new file mode 100644 index 0000000..b908c31 --- /dev/null +++ b/apps/desktop-main/src/runtime-config.ts @@ -0,0 +1,96 @@ +import { app, type App } from 'electron'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +export type AppEnvironment = 'development' | 'staging' | 'production'; + +const packageJsonCandidates = [ + '../../../../package.json', + '../../../package.json', + '../../package.json', + '../package.json', +]; + +const resolvePackageJsonPath = (candidate: string): string => + path.resolve(__dirname, candidate); + +const readPackageJsonField = (field: string): T | null => { + for (const candidate of packageJsonCandidates) { + try { + const absolutePath = resolvePackageJsonPath(candidate); + if (!existsSync(absolutePath)) { + continue; + } + + const raw = require(absolutePath) as Record; + if (field in raw) { + return raw[field] as T; + } + } catch { + // Ignore and continue fallback chain. + } + } + + return null; +}; + +const resolveIsStagingExecutable = (): boolean => + path.basename(process.execPath).toLowerCase().includes('staging'); + +export const resolveAppEnvironment = ( + electronApp: App = app, +): AppEnvironment => { + const envValue = process.env.APP_ENV?.trim().toLowerCase(); + if ( + envValue === 'development' || + envValue === 'staging' || + envValue === 'production' + ) { + return envValue; + } + + const packageAppEnv = readPackageJsonField('appEnv'); + if ( + packageAppEnv === 'development' || + packageAppEnv === 'staging' || + packageAppEnv === 'production' + ) { + return packageAppEnv; + } + + return electronApp.isPackaged ? 'production' : 'development'; +}; + +export const resolveAppMetadataVersion = (electronApp: App = app): string => { + const envVersion = process.env.npm_package_version?.trim(); + if (envVersion) { + return envVersion; + } + + const packageVersion = readPackageJsonField('version'); + if (typeof packageVersion === 'string' && packageVersion.trim().length > 0) { + return packageVersion.trim(); + } + + return electronApp.getVersion(); +}; + +export const resolveRuntimeFlags = (electronApp: App = app) => { + const runtimeSmokeEnabled = process.env.RUNTIME_SMOKE === '1'; + const isDevelopment = !electronApp.isPackaged && !runtimeSmokeEnabled; + const appEnvironment = resolveAppEnvironment(electronApp); + const packagedDevToolsOverride = process.env.DESKTOP_ENABLE_DEVTOOLS; + const allowPackagedDevTools = + electronApp.isPackaged && + (appEnvironment === 'staging' || resolveIsStagingExecutable()) && + packagedDevToolsOverride !== '0'; + + return { + runtimeSmokeEnabled, + isDevelopment, + appEnvironment, + shouldOpenDevTools: + !runtimeSmokeEnabled && (isDevelopment || allowPackagedDevTools), + rendererDevUrl: process.env.RENDERER_DEV_URL ?? 'http://localhost:4200', + }; +}; diff --git a/apps/desktop-main/src/secure-token-store.ts b/apps/desktop-main/src/secure-token-store.ts index 161b277..2d2b576 100644 --- a/apps/desktop-main/src/secure-token-store.ts +++ b/apps/desktop-main/src/secure-token-store.ts @@ -120,12 +120,16 @@ export const createRefreshTokenStore = async ( try { return await createKeytarStore(); } catch (error) { + const fallbackStore = await createFileStore(options); + const fallbackLevel = + fallbackStore.kind === 'file-encrypted' ? 'info' : 'warn'; options.logger?.( - 'warn', - `Keytar unavailable; falling back to file-based token store: ${ + fallbackLevel, + `Keytar unavailable; using ${fallbackStore.kind} token store instead: ${ error instanceof Error ? error.message : String(error) - }`, + }. Run "pnpm native:rebuild:keytar" to restore keytar in local development.`, ); + return fallbackStore; } } diff --git a/apps/desktop-preload/src/main.ts b/apps/desktop-preload/src/main.ts index 2bc0236..7f7b31a 100644 --- a/apps/desktop-preload/src/main.ts +++ b/apps/desktop-preload/src/main.ts @@ -3,6 +3,8 @@ import { z, type ZodType } from 'zod'; import type { DesktopApi } from '@electron-foundation/desktop-api'; import { toStructuredLogLine } from '@electron-foundation/common'; import { + apiGetOperationDiagnosticsRequestSchema, + apiGetOperationDiagnosticsResponseSchema, apiInvokeRequestSchema, apiInvokeResponseSchema, appRuntimeVersionsRequestSchema, @@ -393,7 +395,7 @@ const desktopApi: DesktopApi = { }, }, api: { - async invoke(operationId, params) { + async invoke(operationId, params, options) { const correlationId = createCorrelationId(); const request = apiInvokeRequestSchema.parse({ contractVersion: CONTRACT_VERSION, @@ -401,6 +403,7 @@ const desktopApi: DesktopApi = { payload: { operationId, params, + headers: options?.headers, }, }); @@ -411,6 +414,23 @@ const desktopApi: DesktopApi = { apiInvokeResponseSchema, ); }, + async getOperationDiagnostics(operationId) { + const correlationId = createCorrelationId(); + const request = apiGetOperationDiagnosticsRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { + operationId, + }, + }); + + return invoke( + IPC_CHANNELS.apiGetOperationDiagnostics, + request, + correlationId, + apiGetOperationDiagnosticsResponseSchema, + ); + }, }, updates: { async check() { diff --git a/apps/renderer/src/app/app.html b/apps/renderer/src/app/app.html index 5f9c696..906dddd 100644 --- a/apps/renderer/src/app/app.html +++ b/apps/renderer/src/app/app.html @@ -44,6 +44,7 @@ type="button" class="labs-toggle" (click)="toggleLabsMode()" + [disabled]="labsModeLocked()" > Labs Mode: {{ labsMode() ? 'On' : 'Off' }} diff --git a/apps/renderer/src/app/app.ts b/apps/renderer/src/app/app.ts index 64c5dcd..677a4bf 100644 --- a/apps/renderer/src/app/app.ts +++ b/apps/renderer/src/app/app.ts @@ -15,6 +15,7 @@ import { MatToolbarModule } from '@angular/material/toolbar'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { distinctUntilChanged, map } from 'rxjs'; +import { getDesktopApi } from '@electron-foundation/desktop-api'; type NavLink = { path: string; @@ -153,12 +154,15 @@ export class App { protected readonly title = 'Angulectron'; protected readonly navOpen = signal(true); protected readonly mobileViewport = signal(false); - protected readonly labsMode = signal(this.loadLabsModePreference()); + protected readonly labsMode = signal(false); + protected readonly labsModeLocked = signal(false); protected readonly visibleNavLinks = computed(() => this.navLinks.filter((item) => this.labsMode() || !item.lab), ); constructor() { + void this.initializeLabsModePolicy(); + this.breakpointObserver .observe('(max-width: 1023px)') .pipe( @@ -198,6 +202,10 @@ export class App { } protected toggleLabsMode() { + if (this.labsModeLocked()) { + return; + } + this.labsMode.update((value) => { const next = !value; this.persistLabsModePreference(next); @@ -205,6 +213,25 @@ export class App { }); } + private async initializeLabsModePolicy() { + const desktop = getDesktopApi(); + if (!desktop) { + this.labsMode.set(this.loadLabsModePreference()); + return; + } + + const runtime = await desktop.app.getRuntimeVersions(); + if (!runtime.ok) { + this.labsMode.set(this.loadLabsModePreference()); + return; + } + + const forcedLabsMode = runtime.data.appEnvironment !== 'production'; + this.labsModeLocked.set(true); + this.labsMode.set(forcedLabsMode); + this.persistLabsModePreference(forcedLabsMode); + } + private loadLabsModePreference(): boolean { if (typeof localStorage === 'undefined') { return false; diff --git a/apps/renderer/src/app/features/api-playground/api-playground-page.html b/apps/renderer/src/app/features/api-playground/api-playground-page.html index cd1e5f0..7fa1407 100644 --- a/apps/renderer/src/app/features/api-playground/api-playground-page.html +++ b/apps/renderer/src/app/features/api-playground/api-playground-page.html @@ -17,7 +17,7 @@ Operation @for (operationId of operations; track operationId) { @@ -37,7 +37,33 @@ > + + Safe Headers JSON (x-*) + + + +

+ For secure-endpoint calls, path placeholders can be supplied from params + or mapped JWT claims configured in `.env.local`. If both exist, params + take precedence over JWT claim mapping. +

+
+ + @if (browserFlowPending()) { + + }