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/.github/pull_request_template.md b/.github/pull_request_template.md index bb555a7..b8e780a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,25 @@ ## Summary -- +- What changed: +- Why this change is needed: +- Risk level (low/medium/high): -## Checklist +## Change Groups + +- Docs / Governance: +- Frontend / UX: +- Desktop Main / Preload / Contracts: +- CI / Tooling: + +## Validation + +- [ ] `pnpm nx run contracts:test` +- [ ] `pnpm nx run desktop-main:test` +- [ ] `pnpm nx run renderer:build` +- [ ] `pnpm nx run desktop-main:build` +- [ ] Additional checks run: + +## Engineering Checklist - [ ] Conventional Commit title used - [ ] Unit/integration tests added or updated @@ -13,9 +30,14 @@ ## Security (Required For Sensitive Changes) -- [x] Security review completed -- [x] Threat model updated or N/A explained +IMPORTANT: + +- If this PR touches `apps/desktop-main/**`, `apps/desktop-preload/**`, `libs/shared/contracts/**`, `.github/workflows/**`, or `docs/02-architecture/security-architecture.md`, the two items below MUST be checked to pass CI. + +- [ ] Security review completed +- [ ] Threat model updated or N/A explained ### Security Notes -- ## N/A rationale (when no threat model update is needed): +- Threat model link/update: +- N/A rationale (when no threat model update is needed): 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..66192f5 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,116 @@ 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` +- `tools/templates/pr-body-template.md` (local PR authoring template) 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-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, }) => { 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()) { + + }