diff --git a/.env.example b/.env.example deleted file mode 100644 index 2c87418..0000000 --- a/.env.example +++ /dev/null @@ -1,20 +0,0 @@ -# Copy this file to ".env.local" and set values for local development. - -OIDC_ISSUER=https://your-issuer.example.com -OIDC_CLIENT_ID=your-client-id -OIDC_REDIRECT_URI=http://127.0.0.1:42813/callback -OIDC_SCOPES=openid profile email offline_access -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 - -# 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/workflows/ci.yml b/.github/workflows/ci.yml index b49f19c..fb84d32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,6 +170,36 @@ jobs: - run: pnpm install --frozen-lockfile=false - run: pnpm i18n-check + docs-lint: + name: docs-lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.14.0 + - uses: actions/setup-node@v4 + with: + node-version: 24.13.0 + cache: pnpm + - run: pnpm install --frozen-lockfile=false + - run: pnpm docs-lint + + no-dotenv-files: + name: no-dotenv-files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.14.0 + - uses: actions/setup-node@v4 + with: + node-version: 24.13.0 + cache: pnpm + - run: pnpm install --frozen-lockfile=false + - run: pnpm no-dotenv-files + build-renderer: name: build-renderer runs-on: ubuntu-latest @@ -263,6 +293,8 @@ jobs: - e2e-smoke - a11y-e2e - i18n-check + - docs-lint + - no-dotenv-files - build-renderer - build-desktop - dependency-audit @@ -271,6 +303,7 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' env: RELEASE_CHANNEL: dev + PYTHON_RUNTIME_TARGET: win32-x64 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -281,8 +314,7 @@ jobs: node-version: 24.13.0 cache: pnpm - run: pnpm install --frozen-lockfile=false - - run: pnpm build - - run: pnpm forge:make + - run: pnpm forge:make:ci:windows - name: Upload packaged desktop artifacts uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 763ebd2..90d5ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -57,8 +57,6 @@ TASK.md # local environment configuration .env* -!.env.example -!.env.*.example runtime-config.env runtime-config.json @@ -72,3 +70,7 @@ CONTEXT.md build/python-runtime/* !build/python-runtime/README.md !build/python-runtime/.gitkeep + +# Claude Code temporary files and plans +.claude/ +tmpclaude-* diff --git a/.husky/pre-commit b/.husky/pre-commit index 04a1715..65d777c 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,4 @@ pnpm lint-staged pnpm i18n-check pnpm docs-lint +pnpm no-dotenv-files diff --git a/AGENTS.md b/AGENTS.md index ec27f83..e79d2ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,3 +12,93 @@ - For Nx plugin best practices, check `node_modules/@nx//PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable. + + + +# CONTEXT + +Operational context and working conventions for this repository. + +## Project Intent + +- Build quality over speed. Prefer robust, maintainable implementation over quick patches. +- Keep OIDC/IdP integration provider-agnostic. Clerk is an example configuration, not a product dependency. + +## Non-Negotiables + +- Frontend work should prioritize strict Angular v21 features/syntax. +- Use Nx commands for repo tasks (`pnpm nx ...`) instead of underlying tools directly. +- Preserve privileged-boundary security posture between renderer, preload, and desktop-main. + +## Git / PR Discipline + +- Use conventional commit messages. +- Keep commits logically grouped and reviewable. +- Never push to GitHub, open PRs, or merge PRs without explicit user approval in the current session. +- Default mode is local-only work (local edits, local commits, local validation) until push is explicitly approved. +- Before every commit, run a secret/sensitive-data sweep on staged and recent changed files (examples: `git diff --cached`, targeted `rg` for tenant IDs/domains/client IDs/tokens/keys) and sanitize to placeholders for tracked examples/docs/tests. +- Before any approved push, present branch, commit list, validation results, and proposed PR scope. +- Use the full PR template and complete checklist sections. +- For security-sensitive changes, PR body must include exact checked lines: + - `- [x] Security review completed` + - `- [x] Threat model updated or N/A explained` +- Include concrete validation evidence in PR body. +- Preferred GitHub flow: + - Run local CI-parity checks before push. + - Push only when explicitly approved. + - Create/update PR from `tools/templates/pr-draft.md` with checklist lines completed. + - Monitor GitHub checks directly (`gh pr checks --watch`) and report status. + - Merge on user approval once required checks are green. + +## Validation Baseline Before PR + +- `pnpm unit-test` +- `pnpm integration-test` +- `pnpm runtime:smoke` + +Add targeted checks relevant to changed areas (for example: `renderer:test`, `desktop-main:test`, `renderer:build`). + +## Repository-Specific Conventions + +- `FR*.md` files are transient feature-request artifacts and should not be treated as long-lived docs. +- Root task/feedback handoff docs may be intentionally blank until user provides content. +- `FILE_INDEX.txt` is transient and gitignored; do not wire into repo-wide CI checks. +- Agent working context is maintained in `AGENTS.md` inside the knowledgebase markers; `CONTEXT.md` is not used. + +## Architecture Direction (Current) + +- Desktop main IPC handlers are modularized under `apps/desktop-main/src/ipc/`. +- Shared validated IPC handler path is the standard for authz + schema validation + envelope consistency. +- Preload bridge is modularized under `apps/desktop-preload/src/api/` with shared invoke client in `apps/desktop-preload/src/invoke-client.ts`. +- Renderer auth session is hydrated through shared state service (`apps/renderer/src/app/services/auth-session-state.service.ts`) and consumed by auth UI/guards. + +## Known Good Behaviors + +- Keep IdP interaction in external browser for native OIDC flow. +- Keep app UI stable during sign-in and reflect pending/cancelled states clearly. +- Ensure auth-sensitive UI controls are gated on initialized session state (avoid stale default button enablement). +- For Python sidecar packaging, treat packaged builds as deterministic runtime-only: + - bundle interpreter + pinned dependencies into `python-runtime/-` + - disable system Python fallback when `app.isPackaged` + - expose interpreter path diagnostics (`pythonExecutable`) so packaged runtime use is provable. +- Before packaging, run runtime preflight checks: + - `pnpm run python-runtime:prepare-local` + - `pnpm run python-runtime:assert` +- For CI targeted formatting checks, use `FETCH_HEAD` as base to avoid non-fast-forward ref update failures when base branch moves during CI. + +## Known Pitfalls To Avoid + +- Opening PRs without fully completed checklist sections (especially security checklist lines) causes avoidable CI failures. +- Treating provider-specific behavior (for example Clerk redirect routing) as product behavior leads to incorrect refactors. +- Assuming renderer initial auth state without hydration causes misleading UI states. +- On Windows, staging app processes can lock `out/@electron-foundation-source-win32-x64` and break forge clean/package with `EBUSY`; close/kill running packaged app instances before rerunning `forge:make:*`. +- Naive string replacement of `app.asar` can corrupt already-unpacked paths (`app.asar.unpacked.unpacked`); use guarded replacement logic. + +## Maintenance Rule + +- Update this file as new patterns emerge: + - Add successful implementation patterns worth repeating. + - Add failure modes and anti-patterns observed in CI/runtime/review. + - Keep entries short, concrete, and action-oriented. + + diff --git a/PR_DRAFT.md b/PR_DRAFT.md deleted file mode 100644 index 918ad79..0000000 --- a/PR_DRAFT.md +++ /dev/null @@ -1,89 +0,0 @@ -## Summary - -- What changed: - - Stabilized auth/session lifecycle behavior in Auth Session Lab and preload/main refresh timing. - - Refactored OIDC provider HTTP/discovery concerns into a dedicated provider client module. - - Hardened production frontend surface by excluding lab routes/navigation from production bundles. - - Added a deterministic bundled demo update cycle (`v1` -> `v2` patch) for end-to-end update model proof. - - Established renderer i18n migration pattern on Home using feature-local locale assets with merged transloco loading. - - Consolidated renderer route + nav metadata into a single typed registry (`BL-021`). - - Improved shell sidenav UX with adaptive width and interaction-driven scrollbar visibility. - - Updated governance backlog statuses to reflect completed sprint work and newly delivered hardening items. -- Why this change is needed: - - Remove auth startup inconsistencies/timeouts and incorrect auth-lab redirect behavior. - - Ensure production does not expose hidden lab routes/features in bundle/runtime UI. - - Provide a provable update mechanism demo path independent of installer-native updater infrastructure. - - Reduce frontend duplication/drift between router and nav shell configuration. - - Prove i18n migration mechanics before real feature-page rollout. -- Risk level (low/medium/high): - - Medium (touches desktop main/preload/contracts/renderer and IPC channels) - -## Change Groups - -- Docs / Governance: - - Backlog updated to mark completed items (`BL-003`, `BL-012`, `BL-015`, `BL-016`, `BL-017`, `BL-018`, `BL-023`, `BL-025`) and add `BL-026`/`BL-027`. - - Backlog updated to mark `BL-021` complete and sprint log updated with delivery notes. -- Frontend / UX: - - Auth Session Lab now reports real initialization failures and preserves in-place navigation when launched directly. - - Updates page now shows source/version diagnostics and supports `Apply Demo Patch` when source is `demo`. - - Production build now excludes lab routes/nav entries and hides labs toggle behavior. - - Home page now consumes i18n keys with component-local `i18n/en-US.json` and runtime-safe string lookups. - - Shell menu now scales wider on large breakpoints and hides scrollbars unless hover/focus interaction is present. -- Desktop Main / Preload / Contracts: - - Extracted OIDC discovery/timeout request behavior from `oidc-service.ts` into `oidc-provider-client.ts` (behavior-preserving refactor for `BL-019` first slice). - - Added `DemoUpdater` with deterministic baseline seeding on launch and SHA-256 validated patch apply. - - Added IPC channel `updates:apply-demo-patch`. - - Extended update contracts and desktop API typing with source/version/demo path metadata. - - Updates handler falls back to bundled demo updater when `app-update.yml` is not present. -- CI / Tooling: - - No workflow changes in this batch. - -## Validation - -- [x] `pnpm nx run contracts:test` -- [x] `pnpm nx run desktop-main:test` -- [x] `pnpm nx run renderer:build` -- [x] `pnpm nx run desktop-main:build` -- [x] Additional checks run: - - `pnpm nx run desktop-preload:test` - - `pnpm nx run desktop-preload:build` - - `pnpm nx run contracts:build` - - `pnpm nx run renderer:test` - - `pnpm i18n-check` - - `pnpm nx run renderer:build:development` (post-i18n and shell/nav changes) - - `pnpm nx run renderer:build:production` (post-`BL-021` route/nav registry refactor) - - `pnpm nx run renderer:lint` (existing unrelated warning only) - - `pnpm nx run desktop-main:test` (post-`BL-019` extraction) - - `pnpm nx run desktop-main:build` (post-`BL-019` extraction) - - Manual smoke: update check verified from Home and Updates page. - - Manual smoke: demo patch apply verified (`1.0.0-demo` -> `1.0.1-demo`) and deterministic reset after restart verified. - - Manual smoke: auth login lifecycle verified after OIDC provider-client extraction. - - Manual smoke: sidenav routing verified and scrollbar hidden-state behavior validated. - -## Engineering Checklist - -- [x] Conventional Commit title used -- [x] Unit/integration tests added or updated -- [x] A11y impact reviewed -- [x] I18n impact reviewed -- [x] IPC contract changes documented -- [ ] ADR added/updated for architecture-level decisions - -## Security (Required For Sensitive Changes) - -IMPORTANT: - -- If this PR touches `apps/desktop-main/**`, `apps/desktop-preload/**`, `libs/shared/contracts/**`, `.github/workflows/**`, or `docs/02-architecture/security-architecture.md`, the three items below MUST be checked to pass CI. - -- [x] Security review completed -- [x] Threat model updated or N/A explained -- [x] Confirmed no secrets/sensitive data present in committed files - -### Security Notes - -- Threat model link/update: - - N/A for this increment (no new external network trust boundary introduced; demo update feed/artifact are local bundled files under app-managed userData path). -- N/A rationale (when no threat model update is needed): - - New functionality remains behind existing privileged IPC boundary checks. - - Demo patch path validates artifact integrity (sha256) and writes only to deterministic local demo file path. - - No executable code loading or dynamic plugin hot-swap introduced. diff --git a/README.md b/README.md index be40afd..5865274 100644 --- a/README.md +++ b/README.md @@ -132,23 +132,26 @@ Flavor behavior: ## OIDC Configuration (Desktop) -Required environment variables: +Primary setup path: -- `OIDC_ISSUER` -- `OIDC_CLIENT_ID` -- `OIDC_REDIRECT_URI` (loopback, example: `http://127.0.0.1:42813/callback`) -- `OIDC_SCOPES` (must include `openid`) +1. Start the app with `pnpm desktop:dev:win`. +2. Open `Settings > Auth`. +3. Enter OIDC values and save. +4. Optionally import `examples/config/runtime-config.auth.example.json`. -Optional: +Required Auth settings: -- `OIDC_AUDIENCE` -- `OIDC_ALLOW_INSECURE_TOKEN_STORAGE=1` (development fallback only) +- `issuer` +- `clientId` +- `redirectUri` (loopback, example: `http://127.0.0.1:42813/callback`) +- `scopes` (must include `openid`) -Local setup: +Optional Auth settings: -1. Copy `.env.example` to `.env.local`. -2. Add OIDC values. -3. Run `pnpm desktop:dev:win`. +- `audience` +- `allowedSignOutOrigins` +- `sendAudienceInAuthorize` +- `apiBearerTokenSource` Token persistence behavior: @@ -158,49 +161,35 @@ Token persistence behavior: ### Packaged Runtime Config File (Windows) -Packaged builds do not read `.env.local` automatically. +Packaged builds read JSON runtime configuration only. Instead, place one runtime config file at: -- `%APPDATA%\Angulectron\config\runtime-config.env` -- or `%APPDATA%\Angulectron\config\runtime-config.json` +- `%APPDATA%\Angulectron\config\runtime-config.json` -Supported keys (allowlisted): +Supported shapes: -- `OIDC_ISSUER` -- `OIDC_CLIENT_ID` -- `OIDC_REDIRECT_URI` -- `OIDC_SCOPES` (must include `openid`) +- flat allowlisted env-style keys (for compatibility), or +- nested feature config shape (`app`, `auth`, `api`) used by Settings. -Optional: - -- `OIDC_AUDIENCE` -- `OIDC_ALLOWED_SIGNOUT_ORIGINS` (space/comma separated allowlist for global sign-out redirects) -- `API_SECURE_ENDPOINT_URL_TEMPLATE` -- `API_SECURE_ENDPOINT_CLAIM_MAP` - -Example `runtime-config.env`: - -```env -OIDC_ISSUER=https://your-issuer.example.com -OIDC_CLIENT_ID=your-client-id -OIDC_REDIRECT_URI=http://127.0.0.1:42813/callback -OIDC_SCOPES=openid profile email offline_access -OIDC_AUDIENCE=api.your-domain.example -API_SECURE_ENDPOINT_URL_TEMPLATE=https://api.your-domain.example/users/{{user_id}}/portfolio -API_SECURE_ENDPOINT_CLAIM_MAP={"user_id":"sub"} -``` - -Example `runtime-config.json`: +Example `runtime-config.json` (nested): ```json { - "OIDC_ISSUER": "https://your-issuer.example.com", - "OIDC_CLIENT_ID": "your-client-id", - "OIDC_REDIRECT_URI": "http://127.0.0.1:42813/callback", - "OIDC_SCOPES": "openid profile email offline_access", - "OIDC_AUDIENCE": "api.your-domain.example", - "API_SECURE_ENDPOINT_URL_TEMPLATE": "https://api.your-domain.example/users/{{user_id}}/portfolio", - "API_SECURE_ENDPOINT_CLAIM_MAP": "{\"user_id\":\"sub\"}" + "version": 1, + "app": {}, + "auth": { + "issuer": "https://your-issuer.example.com", + "clientId": "your-client-id", + "redirectUri": "http://127.0.0.1:42813/callback", + "scopes": "openid profile email offline_access", + "audience": "api.your-domain.example" + }, + "api": { + "secureEndpointUrlTemplate": "https://api.your-domain.example/resources/{{resource_id}}", + "secureEndpointClaimMap": { + "resource_id": "sub" + } + } } ``` @@ -209,15 +198,17 @@ Notes: - Restart the app after changing runtime config files. - Process environment variables still take precedence over file values if both are set. - Do not put client secrets into renderer or checked-in files; this flow assumes desktop public-client PKCE. +- In desktop runtime, use the `Settings` page for guided per-feature and full-config import/export flows (`App`, `Auth`, and `API` settings). +- Example import files are provided under `examples/config/`. ## Bring Your Own Secure API Endpoint The `call.secure-endpoint` API operation is endpoint-configurable and does not rely on a hardcoded private URL. -Set in `.env.local`: +Set in `Settings > API` (or import `examples/config/runtime-config.api.example.json`): -- `API_SECURE_ENDPOINT_URL_TEMPLATE` -- `API_SECURE_ENDPOINT_CLAIM_MAP` (optional JSON map of placeholder -> JWT claim path) +- `secureEndpointUrlTemplate` +- `secureEndpointClaimMap` (optional map of placeholder -> JWT claim path) Requirements: @@ -227,8 +218,8 @@ Requirements: Examples: -- `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"}` +- `secureEndpointUrlTemplate=https://your-api.example.com/resources/{{resource_id}}` +- `secureEndpointClaimMap={"resource_id":"sub","tenant_id":"org.id"}` If not configured, calling `call.secure-endpoint` returns a typed `API/OPERATION_NOT_CONFIGURED` failure. @@ -339,7 +330,7 @@ Delivery + governance: - `docs/04-delivery/release-management.md` - `docs/05-governance/definition-of-done.md` - `docs/05-governance/backlog.md` -- `CURRENT-SPRINT.md` +- `docs/05-governance/current-sprint.md` ## Contribution Policy (Critical) diff --git a/TASK.md b/TASK.md deleted file mode 100644 index e69de29..0000000 diff --git a/apps/desktop-main/src/api-gateway.spec.ts b/apps/desktop-main/src/api-gateway.spec.ts index 2df8599..a8686f2 100644 --- a/apps/desktop-main/src/api-gateway.spec.ts +++ b/apps/desktop-main/src/api-gateway.spec.ts @@ -630,9 +630,8 @@ describe('getApiOperationDiagnostics', () => { expect(result.ok).toBe(true); if (result.ok) { expect(result.data.configured).toBe(false); - expect(result.data.configurationHint).toContain( - 'API_SECURE_ENDPOINT_URL_TEMPLATE', - ); + expect(result.data.configurationHint).toContain('Settings'); + expect(result.data.configurationHint).toContain('runtime-config.json'); } }); }); diff --git a/apps/desktop-main/src/api-gateway.ts b/apps/desktop-main/src/api-gateway.ts index aa93d79..e110052 100644 --- a/apps/desktop-main/src/api-gateway.ts +++ b/apps/desktop-main/src/api-gateway.ts @@ -77,40 +77,49 @@ const resolveConfiguredSecureEndpointClaimMap = (): Record => { const operationConfigurationIssues: Partial> = { 'call.secure-endpoint': - 'Set API_SECURE_ENDPOINT_URL_TEMPLATE in .env.local to enable this operation.', + 'Set API secure endpoint configuration in Settings or runtime-config.json to enable this operation.', }; -const configuredSecureEndpointUrl = resolveConfiguredSecureEndpointUrl(); -const configuredSecureEndpointClaimMap = - resolveConfiguredSecureEndpointClaimMap(); - -export const defaultApiOperations: Partial< +const resolveDefaultApiOperations = (): Partial< Record -> = { - 'status.github': { - method: 'GET', - url: 'https://api.github.com/rate_limit', - timeoutMs: 8_000, - maxResponseBytes: 256_000, - concurrencyLimit: 2, - minIntervalMs: 300, - auth: { type: 'none' }, - }, - ...(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 }, - }, - } - : {}), +> => { + const configuredSecureEndpointUrl = resolveConfiguredSecureEndpointUrl(); + const configuredSecureEndpointClaimMap = + resolveConfiguredSecureEndpointClaimMap(); + + return { + 'status.github': { + method: 'GET', + url: 'https://api.github.com/rate_limit', + timeoutMs: 8_000, + maxResponseBytes: 256_000, + concurrencyLimit: 2, + minIntervalMs: 300, + auth: { type: 'none' }, + }, + ...(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 }, + }, + } + : {}), + }; +}; + +export let defaultApiOperations: Partial> = + resolveDefaultApiOperations(); + +export const refreshDefaultApiOperationsFromEnv = () => { + defaultApiOperations = resolveDefaultApiOperations(); }; type InvokeApiDeps = { diff --git a/apps/desktop-main/src/ipc/file-handlers.spec.ts b/apps/desktop-main/src/ipc/file-handlers.spec.ts index 0f10219..e07be86 100644 --- a/apps/desktop-main/src/ipc/file-handlers.spec.ts +++ b/apps/desktop-main/src/ipc/file-handlers.spec.ts @@ -92,6 +92,9 @@ describe('registerFileIpcHandlers', () => { getApiOperationDiagnostics: vi.fn(), getDemoUpdater: vi.fn(() => null), getPythonSidecar: vi.fn(() => null), + getRuntimeSettingsStore: vi.fn(() => { + throw new Error('not-used'); + }), logEvent: vi.fn(), }); @@ -110,7 +113,8 @@ describe('registerFileIpcHandlers', () => { ], ]); - const handlers = registerHandlers(createContext(selectedFileTokens)); + const context = createContext(selectedFileTokens); + const handlers = registerHandlers(context); const readHandler = handlers.get(IPC_CHANNELS.fsReadTextFile); expect(readHandler).toBeDefined(); @@ -144,7 +148,8 @@ describe('registerFileIpcHandlers', () => { ], ]); - const handlers = registerHandlers(createContext(selectedFileTokens)); + const context = createContext(selectedFileTokens); + const handlers = registerHandlers(context); const readHandler = handlers.get(IPC_CHANNELS.fsReadTextFile); expect(readHandler).toBeDefined(); @@ -163,6 +168,17 @@ describe('registerFileIpcHandlers', () => { correlationId: 'corr-fs-read-bad-ext', }, }); + expect(context.logEvent).toHaveBeenCalledWith( + 'warn', + 'security.file_ingress_rejected', + 'corr-fs-read-bad-ext', + expect.objectContaining({ + channel: IPC_CHANNELS.fsReadTextFile, + policy: 'textRead', + reason: 'unsupported-extension', + extension: '.pdf', + }), + ); expect(selectedFileTokens.has('token-2')).toBe(false); }); }); diff --git a/apps/desktop-main/src/ipc/file-handlers.ts b/apps/desktop-main/src/ipc/file-handlers.ts index dc1eff5..60103f9 100644 --- a/apps/desktop-main/src/ipc/file-handlers.ts +++ b/apps/desktop-main/src/ipc/file-handlers.ts @@ -83,6 +83,26 @@ export const registerFileIpcHandlers = ( 'textRead', ); if (policy.kind !== 'ok') { + context.logEvent( + 'warn', + 'security.file_ingress_rejected', + request.correlationId, + { + channel: IPC_CHANNELS.fsReadTextFile, + policy: 'textRead', + reason: policy.kind, + fileName: policy.fileName, + ...(policy.kind === 'unsupported-extension' + ? { + extension: policy.extension, + allowedExtensions: policy.allowedExtensions, + } + : { + headerHex: policy.headerHex, + expectedHex: policy.expectedHex, + }), + }, + ); if (policy.kind === 'unsupported-extension') { return asFailure( 'FS/UNSUPPORTED_FILE_TYPE', diff --git a/apps/desktop-main/src/ipc/file-ingress-policy.ts b/apps/desktop-main/src/ipc/file-ingress-policy.ts index 050290f..00af9a7 100644 --- a/apps/desktop-main/src/ipc/file-ingress-policy.ts +++ b/apps/desktop-main/src/ipc/file-ingress-policy.ts @@ -22,6 +22,9 @@ const FILE_INGRESS_POLICIES: Record = { offset: 0, }, }, + settingsJsonImport: { + allowedExtensions: ['.json'], + }, }; export type FileIngressPolicyName = keyof typeof FILE_INGRESS_POLICIES; @@ -44,6 +47,11 @@ export type FileIngressPolicyResult = expectedHex: string; }; +export type FileIngressPolicyRejection = Exclude< + FileIngressPolicyResult, + { kind: 'ok' } +>; + const readSignatureWindowHex = async ( filePath: string, offset: number, diff --git a/apps/desktop-main/src/ipc/handler-context.ts b/apps/desktop-main/src/ipc/handler-context.ts index 699a398..b2eafa7 100644 --- a/apps/desktop-main/src/ipc/handler-context.ts +++ b/apps/desktop-main/src/ipc/handler-context.ts @@ -9,6 +9,7 @@ import type { OidcService } from '../oidc-service'; import type { StorageGateway } from '../storage-gateway'; import type { DemoUpdater } from '../demo-updater'; import type { PythonSidecar } from '../python-sidecar'; +import type { RuntimeSettingsStore } from '../runtime-settings-store'; export type FileSelectionToken = { filePath: string; @@ -36,6 +37,7 @@ export type MainIpcContext = { ) => DesktopResult; getDemoUpdater: () => DemoUpdater | null; getPythonSidecar: () => PythonSidecar | null; + getRuntimeSettingsStore: () => RuntimeSettingsStore; logEvent: ( level: 'debug' | 'info' | 'warn' | 'error', event: string, diff --git a/apps/desktop-main/src/ipc/python-handlers.spec.ts b/apps/desktop-main/src/ipc/python-handlers.spec.ts index 96983d1..acd5f9f 100644 --- a/apps/desktop-main/src/ipc/python-handlers.spec.ts +++ b/apps/desktop-main/src/ipc/python-handlers.spec.ts @@ -90,6 +90,9 @@ describe('registerPythonIpcHandlers', () => { getApiOperationDiagnostics: vi.fn(), getDemoUpdater: vi.fn(() => null), getPythonSidecar: options.sidecar ?? vi.fn(() => null), + getRuntimeSettingsStore: vi.fn(() => { + throw new Error('not-used'); + }), logEvent: vi.fn(), }); @@ -118,14 +121,13 @@ describe('registerPythonIpcHandlers', () => { message: 'PDF inspected by python sidecar.', })); - const handlers = registerHandlers( - createContext({ - selectedFileTokens, - sidecar: vi.fn(() => ({ - inspectPdf, - })) as MainIpcContext['getPythonSidecar'], - }), - ); + const context = createContext({ + selectedFileTokens, + sidecar: vi.fn(() => ({ + inspectPdf, + })) as MainIpcContext['getPythonSidecar'], + }); + const handlers = registerHandlers(context); const inspectHandler = handlers.get(IPC_CHANNELS.pythonInspectPdf); expect(inspectHandler).toBeDefined(); @@ -163,14 +165,13 @@ describe('registerPythonIpcHandlers', () => { const inspectPdf = vi.fn(); - const handlers = registerHandlers( - createContext({ - selectedFileTokens, - sidecar: vi.fn(() => ({ - inspectPdf, - })) as MainIpcContext['getPythonSidecar'], - }), - ); + const context = createContext({ + selectedFileTokens, + sidecar: vi.fn(() => ({ + inspectPdf, + })) as MainIpcContext['getPythonSidecar'], + }); + const handlers = registerHandlers(context); const inspectHandler = handlers.get(IPC_CHANNELS.pythonInspectPdf); const response = await inspectHandler!( @@ -185,6 +186,16 @@ describe('registerPythonIpcHandlers', () => { correlationId: 'corr-pdf-bad-sig', }, }); + expect(context.logEvent).toHaveBeenCalledWith( + 'warn', + 'security.file_ingress_rejected', + 'corr-pdf-bad-sig', + expect.objectContaining({ + channel: IPC_CHANNELS.pythonInspectPdf, + policy: 'pdfInspect', + reason: 'signature-mismatch', + }), + ); expect(inspectPdf).not.toHaveBeenCalled(); expect(selectedFileTokens.has('token-2')).toBe(false); }); diff --git a/apps/desktop-main/src/ipc/python-handlers.ts b/apps/desktop-main/src/ipc/python-handlers.ts index 38080d3..8bce3d4 100644 --- a/apps/desktop-main/src/ipc/python-handlers.ts +++ b/apps/desktop-main/src/ipc/python-handlers.ts @@ -69,6 +69,26 @@ export const registerPythonIpcHandlers = ( 'pdfInspect', ); if (policy.kind !== 'ok') { + context.logEvent( + 'warn', + 'security.file_ingress_rejected', + request.correlationId, + { + channel: IPC_CHANNELS.pythonInspectPdf, + policy: 'pdfInspect', + reason: policy.kind, + fileName: policy.fileName, + ...(policy.kind === 'unsupported-extension' + ? { + extension: policy.extension, + allowedExtensions: policy.allowedExtensions, + } + : { + headerHex: policy.headerHex, + expectedHex: policy.expectedHex, + }), + }, + ); if (policy.kind === 'unsupported-extension') { return asFailure( 'PYTHON/UNSUPPORTED_FILE_TYPE', diff --git a/apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts b/apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts index 4ed95b4..b0f9cb6 100644 --- a/apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts +++ b/apps/desktop-main/src/ipc/register-ipc-handlers.spec.ts @@ -76,6 +76,9 @@ describe('registerIpcHandlers unauthorized sender integration', () => { getApiOperationDiagnostics, getDemoUpdater: vi.fn(() => null), getPythonSidecar: vi.fn(() => null), + getRuntimeSettingsStore: vi.fn(() => { + throw new Error('not-used'); + }), logEvent: vi.fn(), }; @@ -118,3 +121,101 @@ describe('registerIpcHandlers unauthorized sender integration', () => { expect(getApiOperationDiagnostics).not.toHaveBeenCalled(); }); }); + +describe('registerIpcHandlers unhandled exception integration', () => { + it('returns IPC/HANDLER_FAILED with correlation id when a real handler throws', async () => { + const handlers = new Map< + string, + (event: IpcMainInvokeEvent, payload: unknown) => Promise + >(); + + const ipcMain = { + handle: (channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set( + channel, + handler as ( + event: IpcMainInvokeEvent, + payload: unknown, + ) => Promise, + ); + }, + } as unknown as IpcMain; + + const thrown = new Error('storage exploded'); + const getStorageGateway = vi.fn(() => ({ + setItem: vi.fn(), + getItem: vi.fn(() => { + throw thrown; + }), + deleteItem: vi.fn(), + clearDomain: vi.fn(), + })); + + const logEvent = vi.fn(); + + const context: MainIpcContext = { + appVersion: '0.0.0-test', + appEnvironment: 'development', + fileTokenTtlMs: 5 * 60_000, + selectedFileTokens: new Map(), + getCorrelationId: (payload: unknown) => + payload && + typeof payload === 'object' && + 'correlationId' in payload && + typeof (payload as { correlationId?: unknown }).correlationId === + 'string' + ? (payload as { correlationId: string }).correlationId + : undefined, + assertAuthorizedSender: () => null as DesktopResult | null, + getOidcService: vi.fn(() => null), + getStorageGateway, + invokeApiOperation: vi.fn(async () => asSuccess({ status: 200 })), + getApiOperationDiagnostics: vi.fn(() => + asSuccess({ + operationId: 'call.secure-endpoint', + configured: false, + }), + ), + getDemoUpdater: vi.fn(() => null), + getPythonSidecar: vi.fn(() => null), + getRuntimeSettingsStore: vi.fn(() => { + throw new Error('not-used'); + }), + logEvent, + }; + + registerIpcHandlers(ipcMain, context); + + const channel = IPC_CHANNELS.storageGetItem; + const correlationId = 'corr-storage-throw'; + const handler = handlers.get(channel); + expect(handler).toBeDefined(); + + const response = await handler!({} as IpcMainInvokeEvent, { + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { + domain: 'settings', + key: 'demo', + }, + }); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'IPC/HANDLER_FAILED', + correlationId, + }, + }); + expect(logEvent).toHaveBeenCalledWith( + 'error', + 'ipc.handler_unhandled_exception', + correlationId, + expect.objectContaining({ + channel, + message: 'storage exploded', + }), + ); + expect(getStorageGateway).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop-main/src/ipc/register-ipc-handlers.ts b/apps/desktop-main/src/ipc/register-ipc-handlers.ts index ead16f7..29477cf 100644 --- a/apps/desktop-main/src/ipc/register-ipc-handlers.ts +++ b/apps/desktop-main/src/ipc/register-ipc-handlers.ts @@ -5,6 +5,7 @@ import { registerAppIpcHandlers } from './app-handlers'; import { registerAuthIpcHandlers } from './auth-handlers'; import { registerFileIpcHandlers } from './file-handlers'; import { registerStorageIpcHandlers } from './storage-handlers'; +import { registerSettingsIpcHandlers } from './settings-handlers'; import { registerTelemetryIpcHandlers } from './telemetry-handlers'; import { registerUpdatesIpcHandlers } from './updates-handlers'; import { registerPythonIpcHandlers } from './python-handlers'; @@ -18,6 +19,7 @@ export const registerIpcHandlers = ( registerFileIpcHandlers(ipcMain, context); registerApiIpcHandlers(ipcMain, context); registerStorageIpcHandlers(ipcMain, context); + registerSettingsIpcHandlers(ipcMain, context); registerUpdatesIpcHandlers(ipcMain, context); registerPythonIpcHandlers(ipcMain, context); registerTelemetryIpcHandlers(ipcMain, context); diff --git a/apps/desktop-main/src/ipc/register-validated-handler.spec.ts b/apps/desktop-main/src/ipc/register-validated-handler.spec.ts index a16183c..9ac2e83 100644 --- a/apps/desktop-main/src/ipc/register-validated-handler.spec.ts +++ b/apps/desktop-main/src/ipc/register-validated-handler.spec.ts @@ -34,6 +34,9 @@ describe('registerValidatedHandler', () => { getApiOperationDiagnostics: vi.fn(), getDemoUpdater: vi.fn(() => null), getPythonSidecar: vi.fn(() => null), + getRuntimeSettingsStore: vi.fn(() => { + throw new Error('not-used'); + }), logEvent: vi.fn(), }); diff --git a/apps/desktop-main/src/ipc/settings-handlers.spec.ts b/apps/desktop-main/src/ipc/settings-handlers.spec.ts new file mode 100644 index 0000000..f5410b0 --- /dev/null +++ b/apps/desktop-main/src/ipc/settings-handlers.spec.ts @@ -0,0 +1,189 @@ +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; +import { + CONTRACT_VERSION, + IPC_CHANNELS, + type DesktopResult, +} from '@electron-foundation/contracts'; +import type { RuntimeSettingsStore } from '../runtime-settings-store'; +import type { MainIpcContext } from './handler-context'; +import { registerSettingsIpcHandlers } from './settings-handlers'; + +vi.mock('electron', () => ({ + BrowserWindow: { + fromWebContents: vi.fn(() => ({ id: 42 })), + }, + dialog: { + showOpenDialog: vi.fn(), + showSaveDialog: vi.fn(), + }, +})); + +describe('registerSettingsIpcHandlers', () => { + const createRequest = (correlationId: string, payload: unknown) => ({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload, + }); + + const createEvent = () => + ({ + sender: {}, + }) as IpcMainInvokeEvent; + + const registerHandlers = ( + context: MainIpcContext, + ): Map< + string, + (event: IpcMainInvokeEvent, payload: unknown) => Promise + > => { + const handlers = new Map< + string, + (event: IpcMainInvokeEvent, payload: unknown) => Promise + >(); + + const ipcMain = { + handle: (channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set( + channel, + handler as ( + event: IpcMainInvokeEvent, + payload: unknown, + ) => Promise, + ); + }, + } as unknown as IpcMain; + + registerSettingsIpcHandlers(ipcMain, context); + return handlers; + }; + + const createRuntimeSettingsStore = () => + ({ + getState: vi.fn(async () => ({ + sourcePath: 'config.json', + exists: false, + config: { version: 1 }, + })), + saveFeature: vi.fn(async () => ({ version: 1 })), + resetFeature: vi.fn(async () => ({ version: 1 })), + importFeatureConfigFromFile: vi.fn(async () => ({ version: 1 })), + importRuntimeConfigFromFile: vi.fn(async () => ({ version: 1 })), + exportFeatureConfigToFile: vi.fn(async () => undefined), + exportRuntimeConfigToFile: vi.fn(async () => undefined), + }) as unknown as RuntimeSettingsStore; + + const createContext = ( + runtimeSettingsStore: RuntimeSettingsStore, + ): MainIpcContext => ({ + appVersion: '0.0.0-test', + appEnvironment: 'development', + fileTokenTtlMs: 5 * 60_000, + selectedFileTokens: new Map(), + getCorrelationId: (payload: unknown) => + payload && + typeof payload === 'object' && + 'correlationId' in payload && + typeof (payload as { correlationId?: unknown }).correlationId === 'string' + ? (payload as { correlationId: string }).correlationId + : undefined, + assertAuthorizedSender: () => null as DesktopResult | null, + getOidcService: vi.fn(() => null), + getStorageGateway: vi.fn(), + invokeApiOperation: vi.fn(), + getApiOperationDiagnostics: vi.fn(), + getDemoUpdater: vi.fn(() => null), + getPythonSidecar: vi.fn(() => null), + getRuntimeSettingsStore: vi.fn(() => runtimeSettingsStore), + logEvent: vi.fn(), + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('rejects non-json file for feature settings import', async () => { + const { dialog } = await import('electron'); + vi.mocked(dialog.showOpenDialog).mockResolvedValue({ + canceled: false, + filePaths: ['C:/tmp/settings.txt'], + } as never); + + const runtimeSettingsStore = createRuntimeSettingsStore(); + const context = createContext(runtimeSettingsStore); + const handlers = registerHandlers(context); + const importHandler = handlers.get( + IPC_CHANNELS.settingsImportFeatureConfig, + ); + expect(importHandler).toBeDefined(); + + const response = await importHandler!( + createEvent(), + createRequest('corr-settings-import-feature', { feature: 'api' }), + ); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'SETTINGS/UNSUPPORTED_FILE_TYPE', + correlationId: 'corr-settings-import-feature', + }, + }); + expect(context.logEvent).toHaveBeenCalledWith( + 'warn', + 'security.file_ingress_rejected', + 'corr-settings-import-feature', + expect.objectContaining({ + channel: IPC_CHANNELS.settingsImportFeatureConfig, + policy: 'settingsJsonImport', + reason: 'unsupported-extension', + extension: '.txt', + }), + ); + expect( + vi.mocked(runtimeSettingsStore.importFeatureConfigFromFile), + ).not.toHaveBeenCalled(); + }); + + it('rejects non-json file for runtime settings import', async () => { + const { dialog } = await import('electron'); + vi.mocked(dialog.showOpenDialog).mockResolvedValue({ + canceled: false, + filePaths: ['C:/tmp/runtime-config.txt'], + } as never); + + const runtimeSettingsStore = createRuntimeSettingsStore(); + const context = createContext(runtimeSettingsStore); + const handlers = registerHandlers(context); + const importHandler = handlers.get( + IPC_CHANNELS.settingsImportRuntimeConfig, + ); + expect(importHandler).toBeDefined(); + + const response = await importHandler!( + createEvent(), + createRequest('corr-settings-import-runtime', {}), + ); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'SETTINGS/UNSUPPORTED_FILE_TYPE', + correlationId: 'corr-settings-import-runtime', + }, + }); + expect(context.logEvent).toHaveBeenCalledWith( + 'warn', + 'security.file_ingress_rejected', + 'corr-settings-import-runtime', + expect.objectContaining({ + channel: IPC_CHANNELS.settingsImportRuntimeConfig, + policy: 'settingsJsonImport', + reason: 'unsupported-extension', + extension: '.txt', + }), + ); + expect( + vi.mocked(runtimeSettingsStore.importRuntimeConfigFromFile), + ).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop-main/src/ipc/settings-handlers.ts b/apps/desktop-main/src/ipc/settings-handlers.ts new file mode 100644 index 0000000..b4485ee --- /dev/null +++ b/apps/desktop-main/src/ipc/settings-handlers.ts @@ -0,0 +1,379 @@ +import { + dialog, + BrowserWindow, + type IpcMain, + type IpcMainInvokeEvent, + type OpenDialogOptions, +} from 'electron'; +import { + asFailure, + asSuccess, + IPC_CHANNELS, + settingsExportFeatureConfigRequestSchema, + settingsExportRuntimeConfigRequestSchema, + settingsGetRuntimeConfigRequestSchema, + settingsImportFeatureConfigRequestSchema, + settingsImportRuntimeConfigRequestSchema, + settingsResetFeatureConfigRequestSchema, + settingsSaveFeatureConfigRequestSchema, +} from '@electron-foundation/contracts'; +import type { MainIpcContext } from './handler-context'; +import { evaluateFileIngressPolicy } from './file-ingress-policy'; +import { registerValidatedHandler } from './register-validated-handler'; +import { syncRuntimeConfigDocumentToEnv } from '../runtime-user-config'; +import { refreshDefaultApiOperationsFromEnv } from '../api-gateway'; + +const settingsImportDialogOptions: OpenDialogOptions = { + title: 'Import runtime settings', + filters: [ + { + name: 'JSON files', + extensions: ['json'], + }, + ], + properties: ['openFile'], +}; + +const getSenderWindow = (event: IpcMainInvokeEvent) => + BrowserWindow.fromWebContents(event.sender) ?? undefined; + +export const registerSettingsIpcHandlers = ( + ipcMain: IpcMain, + context: MainIpcContext, +) => { + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.settingsGetRuntimeConfig, + schema: settingsGetRuntimeConfigRequestSchema, + context, + handler: async () => { + const state = await context.getRuntimeSettingsStore().getState(); + return asSuccess(state); + }, + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.settingsSaveFeatureConfig, + schema: settingsSaveFeatureConfigRequestSchema, + context, + handler: async (_event, request) => { + await context + .getRuntimeSettingsStore() + .saveFeature(request.payload.feature, request.payload.config); + + const state = await context.getRuntimeSettingsStore().getState(); + syncRuntimeConfigDocumentToEnv(state.config); + refreshDefaultApiOperationsFromEnv(); + return asSuccess(state); + }, + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.settingsResetFeatureConfig, + schema: settingsResetFeatureConfigRequestSchema, + context, + handler: async (_event, request) => { + await context + .getRuntimeSettingsStore() + .resetFeature(request.payload.feature); + + const state = await context.getRuntimeSettingsStore().getState(); + syncRuntimeConfigDocumentToEnv(state.config); + refreshDefaultApiOperationsFromEnv(); + return asSuccess(state); + }, + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.settingsImportFeatureConfig, + schema: settingsImportFeatureConfigRequestSchema, + context, + handler: async (event, request) => { + const openResult = await dialog.showOpenDialog( + getSenderWindow(event), + settingsImportDialogOptions, + ); + + const sourcePath = openResult.filePaths[0]; + if (openResult.canceled || !sourcePath) { + return asSuccess({ + canceled: true, + imported: false, + feature: request.payload.feature, + }); + } + + const ingress = await evaluateFileIngressPolicy( + sourcePath, + 'settingsJsonImport', + ); + if (ingress.kind !== 'ok') { + context.logEvent( + 'warn', + 'security.file_ingress_rejected', + request.correlationId, + { + channel: IPC_CHANNELS.settingsImportFeatureConfig, + policy: 'settingsJsonImport', + reason: ingress.kind, + fileName: ingress.fileName, + ...(ingress.kind === 'unsupported-extension' + ? { + extension: ingress.extension, + allowedExtensions: ingress.allowedExtensions, + } + : { + headerHex: ingress.headerHex, + expectedHex: ingress.expectedHex, + }), + }, + ); + return asFailure( + 'SETTINGS/UNSUPPORTED_FILE_TYPE', + 'Only JSON files are supported for settings import.', + { + fileName: ingress.fileName, + ...(ingress.kind === 'unsupported-extension' + ? { + extension: ingress.extension, + allowedExtensions: ingress.allowedExtensions, + } + : { + headerHex: ingress.headerHex, + expectedHex: ingress.expectedHex, + }), + }, + false, + request.correlationId, + ); + } + + try { + const config = await context + .getRuntimeSettingsStore() + .importFeatureConfigFromFile(request.payload.feature, sourcePath); + syncRuntimeConfigDocumentToEnv(config); + refreshDefaultApiOperationsFromEnv(); + + return asSuccess({ + canceled: false, + imported: true, + feature: request.payload.feature, + sourcePath, + config, + }); + } catch (error) { + return asFailure( + 'SETTINGS/IMPORT_FAILED', + 'Unable to import feature settings from selected file.', + { + feature: request.payload.feature, + sourcePath, + message: error instanceof Error ? error.message : String(error), + }, + false, + request.correlationId, + ); + } + }, + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.settingsExportFeatureConfig, + schema: settingsExportFeatureConfigRequestSchema, + context, + handler: async (event, request) => { + const saveResult = await dialog.showSaveDialog(getSenderWindow(event), { + title: `Export ${request.payload.feature} settings`, + defaultPath: `runtime-config.${request.payload.feature}.json`, + filters: [ + { + name: 'JSON files', + extensions: ['json'], + }, + ], + }); + + if (saveResult.canceled || !saveResult.filePath) { + return asSuccess({ + canceled: true, + exported: false, + feature: request.payload.feature, + }); + } + + try { + await context + .getRuntimeSettingsStore() + .exportFeatureConfigToFile( + request.payload.feature, + saveResult.filePath, + ); + + return asSuccess({ + canceled: false, + exported: true, + feature: request.payload.feature, + targetPath: saveResult.filePath, + }); + } catch (error) { + return asFailure( + 'SETTINGS/EXPORT_FAILED', + 'Unable to export feature settings to selected file.', + { + feature: request.payload.feature, + targetPath: saveResult.filePath, + message: error instanceof Error ? error.message : String(error), + }, + false, + request.correlationId, + ); + } + }, + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.settingsImportRuntimeConfig, + schema: settingsImportRuntimeConfigRequestSchema, + context, + handler: async (event, request) => { + const openResult = await dialog.showOpenDialog( + getSenderWindow(event), + settingsImportDialogOptions, + ); + + const sourcePath = openResult.filePaths[0]; + if (openResult.canceled || !sourcePath) { + return asSuccess({ + canceled: true, + imported: false, + }); + } + + const ingress = await evaluateFileIngressPolicy( + sourcePath, + 'settingsJsonImport', + ); + if (ingress.kind !== 'ok') { + context.logEvent( + 'warn', + 'security.file_ingress_rejected', + request.correlationId, + { + channel: IPC_CHANNELS.settingsImportRuntimeConfig, + policy: 'settingsJsonImport', + reason: ingress.kind, + fileName: ingress.fileName, + ...(ingress.kind === 'unsupported-extension' + ? { + extension: ingress.extension, + allowedExtensions: ingress.allowedExtensions, + } + : { + headerHex: ingress.headerHex, + expectedHex: ingress.expectedHex, + }), + }, + ); + return asFailure( + 'SETTINGS/UNSUPPORTED_FILE_TYPE', + 'Only JSON files are supported for settings import.', + { + fileName: ingress.fileName, + ...(ingress.kind === 'unsupported-extension' + ? { + extension: ingress.extension, + allowedExtensions: ingress.allowedExtensions, + } + : { + headerHex: ingress.headerHex, + expectedHex: ingress.expectedHex, + }), + }, + false, + request.correlationId, + ); + } + + try { + const config = await context + .getRuntimeSettingsStore() + .importRuntimeConfigFromFile(sourcePath); + syncRuntimeConfigDocumentToEnv(config); + refreshDefaultApiOperationsFromEnv(); + + return asSuccess({ + canceled: false, + imported: true, + sourcePath, + config, + }); + } catch (error) { + return asFailure( + 'SETTINGS/IMPORT_FAILED', + 'Unable to import runtime settings from selected file.', + { + sourcePath, + message: error instanceof Error ? error.message : String(error), + }, + false, + request.correlationId, + ); + } + }, + }); + + registerValidatedHandler({ + ipcMain, + channel: IPC_CHANNELS.settingsExportRuntimeConfig, + schema: settingsExportRuntimeConfigRequestSchema, + context, + handler: async (event, request) => { + const saveResult = await dialog.showSaveDialog(getSenderWindow(event), { + title: 'Export runtime settings', + defaultPath: 'runtime-config.backup.json', + filters: [ + { + name: 'JSON files', + extensions: ['json'], + }, + ], + }); + + if (saveResult.canceled || !saveResult.filePath) { + return asSuccess({ + canceled: true, + exported: false, + }); + } + + try { + await context + .getRuntimeSettingsStore() + .exportRuntimeConfigToFile(saveResult.filePath); + + return asSuccess({ + canceled: false, + exported: true, + targetPath: saveResult.filePath, + }); + } catch (error) { + return asFailure( + 'SETTINGS/EXPORT_FAILED', + 'Unable to export runtime settings to selected file.', + { + targetPath: saveResult.filePath, + message: error instanceof Error ? error.message : String(error), + }, + false, + request.correlationId, + ); + } + }, + }); +}; diff --git a/apps/desktop-main/src/main.ts b/apps/desktop-main/src/main.ts index 768ac60..858fe3a 100644 --- a/apps/desktop-main/src/main.ts +++ b/apps/desktop-main/src/main.ts @@ -13,6 +13,7 @@ import { promises as fs } from 'node:fs'; import { getApiOperationDiagnostics, invokeApiOperation, + refreshDefaultApiOperationsFromEnv, setOidcAccessTokenResolver, } from './api-gateway'; import { @@ -34,6 +35,7 @@ import { DemoUpdater } from './demo-updater'; import { PythonSidecar } from './python-sidecar'; import { asFailure } from '@electron-foundation/contracts'; import { toStructuredLogLine } from '@electron-foundation/common'; +import { RuntimeSettingsStore } from './runtime-settings-store'; const { runtimeSmokeEnabled, @@ -53,6 +55,7 @@ let oidcService: OidcService | null = null; let mainWindow: BrowserWindow | null = null; let demoUpdater: DemoUpdater | null = null; let pythonSidecar: PythonSidecar | null = null; +let runtimeSettingsStore: RuntimeSettingsStore | null = null; const APP_VERSION = resolveAppMetadataVersion(); const logEvent = ( @@ -180,6 +183,14 @@ const getStorageGateway = () => { return storageGateway; }; +const getRuntimeSettingsStore = () => { + if (!runtimeSettingsStore) { + throw new Error('Runtime settings store is not initialized'); + } + + return runtimeSettingsStore; +}; + type PythonRuntimeManifest = { executableRelativePath: string; pythonVersion?: string; @@ -355,6 +366,8 @@ const bootstrap = async () => { : undefined, }); + runtimeSettingsStore = new RuntimeSettingsStore(app.getPath('userData')); + const runtimeConfig = loadUserRuntimeConfig(app.getPath('userData')); if (runtimeConfig.parseError) { logEvent('warn', 'runtime.config_file_parse_failed', undefined, { @@ -368,6 +381,7 @@ const bootstrap = async () => { skippedExistingKeys: runtimeConfig.skippedExistingKeys, }); } + refreshDefaultApiOperationsFromEnv(); const oidcConfig = loadOidcConfig(); demoUpdater = new DemoUpdater(app.getPath('userData')); @@ -441,6 +455,7 @@ const bootstrap = async () => { getApiOperationDiagnostics(operationId), getDemoUpdater: () => demoUpdater, getPythonSidecar: () => pythonSidecar, + getRuntimeSettingsStore, logEvent, }); diff --git a/apps/desktop-main/src/runtime-settings-store.spec.ts b/apps/desktop-main/src/runtime-settings-store.spec.ts new file mode 100644 index 0000000..145a837 --- /dev/null +++ b/apps/desktop-main/src/runtime-settings-store.spec.ts @@ -0,0 +1,129 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { RuntimeSettingsStore } from './runtime-settings-store'; + +describe('RuntimeSettingsStore', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = path.join( + os.tmpdir(), + `runtime-settings-store-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('returns default config when runtime config file does not exist', async () => { + const store = new RuntimeSettingsStore(tempDir); + + const state = await store.getState(); + expect(state.exists).toBe(false); + expect(state.config).toEqual({ version: 1 }); + }); + + it('saves and resets feature config', async () => { + const store = new RuntimeSettingsStore(tempDir); + + await store.saveFeature('auth', { + issuer: 'https://issuer.example.com', + clientId: 'desktop-client', + }); + + const stateAfterSave = await store.getState(); + expect(stateAfterSave.exists).toBe(true); + expect(stateAfterSave.config.auth?.issuer).toBe( + 'https://issuer.example.com', + ); + + await store.resetFeature('auth'); + + const stateAfterReset = await store.getState(); + expect(stateAfterReset.config.auth).toBeUndefined(); + }); + + it('imports feature config from full runtime config document', async () => { + const store = new RuntimeSettingsStore(tempDir); + const importFile = path.join(tempDir, 'import.auth.json'); + writeFileSync( + importFile, + JSON.stringify( + { + version: 1, + auth: { + issuer: 'https://issuer.example.com', + clientId: 'imported-client', + redirectUri: 'http://127.0.0.1:42813/callback', + scopes: 'openid profile email', + }, + }, + null, + 2, + ), + 'utf8', + ); + + const saved = await store.importFeatureConfigFromFile('auth', importFile); + expect(saved.auth?.clientId).toBe('imported-client'); + }); + + it('migrates legacy app secure endpoint config into api config', async () => { + const store = new RuntimeSettingsStore(tempDir); + const runtimeConfigPath = store.getConfigFilePath(); + mkdirSync(path.dirname(runtimeConfigPath), { recursive: true }); + writeFileSync( + runtimeConfigPath, + JSON.stringify( + { + version: 1, + app: { + secureEndpointUrlTemplate: + 'https://api.example.com/resources/{{resource_id}}', + secureEndpointClaimMap: { + resource_id: 'sub', + }, + }, + }, + null, + 2, + ), + 'utf8', + ); + + const state = await store.getState(); + expect(state.config.api?.secureEndpointUrlTemplate).toContain( + '{{resource_id}}', + ); + expect(state.config.app).toEqual({}); + }); + + it('decodes URL-encoded placeholder braces in api secure endpoint template', async () => { + const store = new RuntimeSettingsStore(tempDir); + const runtimeConfigPath = store.getConfigFilePath(); + mkdirSync(path.dirname(runtimeConfigPath), { recursive: true }); + writeFileSync( + runtimeConfigPath, + JSON.stringify( + { + version: 1, + api: { + secureEndpointUrlTemplate: + 'https://api.example.com/resources/%7B%7Bresource_id%7D%7D', + }, + }, + null, + 2, + ), + 'utf8', + ); + + const state = await store.getState(); + expect(state.config.api?.secureEndpointUrlTemplate).toBe( + 'https://api.example.com/resources/{{resource_id}}', + ); + }); +}); diff --git a/apps/desktop-main/src/runtime-settings-store.ts b/apps/desktop-main/src/runtime-settings-store.ts new file mode 100644 index 0000000..f1f901c --- /dev/null +++ b/apps/desktop-main/src/runtime-settings-store.ts @@ -0,0 +1,278 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { access } from 'node:fs/promises'; +import path from 'node:path'; +import { + appFeatureConfigSchema, + apiFeatureConfigSchema, + authFeatureConfigSchema, + runtimeConfigDocumentSchema, + type AppFeatureConfig, + type ApiFeatureConfig, + type AuthFeatureConfig, + type RuntimeConfigDocument, + type RuntimeConfigFeatureKey, +} from '@electron-foundation/contracts'; + +const defaultRuntimeConfigDocument: RuntimeConfigDocument = { + version: 1, +}; + +const runtimeConfigFileName = 'runtime-config.json'; + +type RuntimeConfigFeatureConfigByKey = { + app: AppFeatureConfig; + auth: AuthFeatureConfig; + api: ApiFeatureConfig; +}; + +const featureParsers = { + app: appFeatureConfigSchema, + auth: authFeatureConfigSchema, + api: apiFeatureConfigSchema, +} as const; + +const decodeUrlTemplateBraces = (value: string): string => + value.replace(/%7B/gi, '{').replace(/%7D/gi, '}'); + +const normalizeApiConfig = ( + config: ApiFeatureConfig | undefined, +): { config: ApiFeatureConfig | undefined; migrated: boolean } => { + if (!config?.secureEndpointUrlTemplate) { + return { config, migrated: false }; + } + + const decoded = decodeUrlTemplateBraces(config.secureEndpointUrlTemplate); + if (decoded === config.secureEndpointUrlTemplate) { + return { config, migrated: false }; + } + + return { + config: { + ...config, + secureEndpointUrlTemplate: decoded, + }, + migrated: true, + }; +}; + +const normalizeRuntimeConfigDocument = ( + document: RuntimeConfigDocument, +): { document: RuntimeConfigDocument; migrated: boolean } => { + const next = { ...document }; + const legacyApp = next.app; + let migrated = false; + + if (next.api) { + const normalizedApi = normalizeApiConfig(next.api); + if (normalizedApi.migrated) { + next.api = normalizedApi.config; + migrated = true; + } + } + + if (legacyApp?.secureEndpointUrlTemplate) { + const decodedLegacyTemplate = decodeUrlTemplateBraces( + legacyApp.secureEndpointUrlTemplate, + ); + if (decodedLegacyTemplate !== legacyApp.secureEndpointUrlTemplate) { + next.app = { + ...legacyApp, + secureEndpointUrlTemplate: decodedLegacyTemplate, + }; + migrated = true; + } + } + + if ( + !next.api && + next.app && + (legacyApp.secureEndpointUrlTemplate || legacyApp.secureEndpointClaimMap) + ) { + next.api = { + secureEndpointUrlTemplate: next.app.secureEndpointUrlTemplate, + secureEndpointClaimMap: legacyApp.secureEndpointClaimMap, + }; + next.app = {}; + migrated = true; + } + + if (next.api) { + const normalizedApi = normalizeApiConfig(next.api); + if (normalizedApi.migrated) { + next.api = normalizedApi.config; + migrated = true; + } + } + + return migrated ? { document: next, migrated: true } : { document, migrated }; +}; + +const cloneDefaultDocument = (): RuntimeConfigDocument => ({ + ...defaultRuntimeConfigDocument, +}); + +const parseRuntimeConfigDocument = (raw: string): RuntimeConfigDocument => { + const parsedJson = JSON.parse(raw) as unknown; + return runtimeConfigDocumentSchema.parse(parsedJson); +}; + +const parseFeatureConfig = ( + feature: TFeature, + data: unknown, +): RuntimeConfigFeatureConfigByKey[TFeature] => { + const parser = featureParsers[feature]; + return parser.parse(data) as RuntimeConfigFeatureConfigByKey[TFeature]; +}; + +export class RuntimeSettingsStore { + private readonly configDirPath: string; + private readonly configFilePath: string; + + constructor(userDataPath: string) { + this.configDirPath = path.join(userDataPath, 'config'); + this.configFilePath = path.join(this.configDirPath, runtimeConfigFileName); + } + + getConfigFilePath(): string { + return this.configFilePath; + } + + async getState(): Promise<{ + sourcePath: string; + exists: boolean; + config: RuntimeConfigDocument; + }> { + const exists = await this.exists(); + if (!exists) { + return { + sourcePath: this.configFilePath, + exists: false, + config: cloneDefaultDocument(), + }; + } + + return { + sourcePath: this.configFilePath, + exists: true, + config: await this.loadNormalized(), + }; + } + + async saveFeature( + feature: TFeature, + config: RuntimeConfigFeatureConfigByKey[TFeature], + ): Promise { + const validated = parseFeatureConfig(feature, config); + const document = await this.loadOrDefault(); + + if (Object.keys(validated).length === 0) { + delete document[feature]; + } else { + document[feature] = validated; + } + + await this.save(document); + return document; + } + + async resetFeature( + feature: RuntimeConfigFeatureKey, + ): Promise { + const document = await this.loadOrDefault(); + delete document[feature]; + await this.save(document); + return document; + } + + async importRuntimeConfigFromFile( + filePath: string, + ): Promise { + const imported = parseRuntimeConfigDocument( + await readFile(filePath, 'utf8'), + ); + await this.save(imported); + return imported; + } + + async importFeatureConfigFromFile( + feature: TFeature, + filePath: string, + ): Promise { + const raw = await readFile(filePath, 'utf8'); + const parsedJson = JSON.parse(raw) as unknown; + + const documentPayload = runtimeConfigDocumentSchema.safeParse(parsedJson); + const featurePayload = documentPayload.success + ? documentPayload.data[feature] + : parsedJson; + + if (!featurePayload || typeof featurePayload !== 'object') { + throw new Error( + `Imported file does not contain a valid "${feature}" feature payload.`, + ); + } + + const validatedFeature = parseFeatureConfig(feature, featurePayload); + return this.saveFeature(feature, validatedFeature); + } + + async exportRuntimeConfigToFile(filePath: string): Promise { + const document = await this.loadOrDefault(); + await this.writeFile(filePath, JSON.stringify(document, null, 2)); + } + + async exportFeatureConfigToFile( + feature: RuntimeConfigFeatureKey, + filePath: string, + ): Promise { + const document = await this.loadOrDefault(); + const featureConfig = document[feature] ?? {}; + await this.writeFile(filePath, JSON.stringify(featureConfig, null, 2)); + } + + private async loadOrDefault(): Promise { + if (!(await this.exists())) { + return cloneDefaultDocument(); + } + + return this.loadNormalized(); + } + + private async load(): Promise { + const raw = await readFile(this.configFilePath, 'utf8'); + return parseRuntimeConfigDocument(raw); + } + + private async loadNormalized(): Promise { + const loaded = await this.load(); + const normalized = normalizeRuntimeConfigDocument(loaded); + if (normalized.migrated) { + await this.save(normalized.document); + } + + return normalized.document; + } + + private async save(document: RuntimeConfigDocument): Promise { + const validated = runtimeConfigDocumentSchema.parse(document); + await this.writeFile( + this.configFilePath, + JSON.stringify(validated, null, 2), + ); + } + + private async exists(): Promise { + try { + await access(this.configFilePath, fsConstants.F_OK); + return true; + } catch { + return false; + } + } + + private async writeFile(filePath: string, content: string): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${content}\n`, 'utf8'); + } +} diff --git a/apps/desktop-main/src/runtime-user-config.spec.ts b/apps/desktop-main/src/runtime-user-config.spec.ts index 1bb0c8d..e02c31b 100644 --- a/apps/desktop-main/src/runtime-user-config.spec.ts +++ b/apps/desktop-main/src/runtime-user-config.spec.ts @@ -1,7 +1,10 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { loadUserRuntimeConfig } from './runtime-user-config'; +import { + loadUserRuntimeConfig, + syncRuntimeConfigDocumentToEnv, +} from './runtime-user-config'; describe('loadUserRuntimeConfig', () => { let tempDir: string; @@ -26,15 +29,20 @@ describe('loadUserRuntimeConfig', () => { expect(result.skippedExistingKeys).toEqual([]); }); - it('loads allowed values from runtime-config.env', () => { + it('loads allowed values from runtime-config.json', () => { writeFileSync( - path.join(tempDir, 'config', 'runtime-config.env'), - [ - '# comment', - 'OIDC_ISSUER=https://issuer.example.com', - 'OIDC_CLIENT_ID=desktop-client', - 'UNSAFE_KEY=ignored', - ].join('\n'), + path.join(tempDir, 'config', 'runtime-config.json'), + JSON.stringify( + { + OIDC_ISSUER: 'https://issuer.example.com', + OIDC_CLIENT_ID: 'json-client', + API_SECURE_ENDPOINT_URL_TEMPLATE: + 'https://api.example.com/users/{{user_id}}/portfolio', + UNSAFE_KEY: 'ignored', + }, + null, + 2, + ), 'utf8', ); @@ -42,15 +50,22 @@ describe('loadUserRuntimeConfig', () => { const result = loadUserRuntimeConfig(tempDir, env); expect(result.parseError).toBeUndefined(); expect(env.OIDC_ISSUER).toBe('https://issuer.example.com'); - expect(env.OIDC_CLIENT_ID).toBe('desktop-client'); + expect(env.OIDC_CLIENT_ID).toBe('json-client'); + expect(env.API_SECURE_ENDPOINT_URL_TEMPLATE).toContain('{{user_id}}'); expect(env.UNSAFE_KEY).toBeUndefined(); - expect(result.appliedKeys).toEqual(['OIDC_ISSUER', 'OIDC_CLIENT_ID']); }); it('respects existing env values over file values', () => { writeFileSync( - path.join(tempDir, 'config', 'runtime-config.env'), - 'OIDC_CLIENT_ID=file-client\nOIDC_SCOPES=openid profile', + path.join(tempDir, 'config', 'runtime-config.json'), + JSON.stringify( + { + OIDC_CLIENT_ID: 'file-client', + OIDC_SCOPES: 'openid profile', + }, + null, + 2, + ), 'utf8', ); @@ -64,16 +79,29 @@ describe('loadUserRuntimeConfig', () => { expect(result.appliedKeys).toEqual(['OIDC_SCOPES']); }); - it('loads allowed values from runtime-config.json', () => { + it('loads values from nested runtime-config.json document shape', () => { writeFileSync( path.join(tempDir, 'config', 'runtime-config.json'), JSON.stringify( { - OIDC_ISSUER: 'https://issuer.example.com', - OIDC_CLIENT_ID: 'json-client', - API_SECURE_ENDPOINT_URL_TEMPLATE: - 'https://api.example.com/users/{{user_id}}/portfolio', - UNSAFE_KEY: 'ignored', + version: 1, + api: { + secureEndpointUrlTemplate: + 'https://api.example.com/resources/{{resource_id}}', + secureEndpointClaimMap: { + resource_id: 'sub', + }, + }, + app: { + secureEndpointUrlTemplate: 'https://legacy.example.com/{{id}}', + }, + auth: { + issuer: 'https://issuer.example.com', + clientId: 'desktop-client', + redirectUri: 'http://127.0.0.1:42813/callback', + scopes: 'openid profile email', + sendAudienceInAuthorize: true, + }, }, null, 2, @@ -84,10 +112,13 @@ describe('loadUserRuntimeConfig', () => { const env: NodeJS.ProcessEnv = {}; const result = loadUserRuntimeConfig(tempDir, env); expect(result.parseError).toBeUndefined(); + expect(env.API_SECURE_ENDPOINT_URL_TEMPLATE).toBe( + 'https://api.example.com/resources/{{resource_id}}', + ); + expect(env.API_SECURE_ENDPOINT_CLAIM_MAP).toBe('{"resource_id":"sub"}'); expect(env.OIDC_ISSUER).toBe('https://issuer.example.com'); - expect(env.OIDC_CLIENT_ID).toBe('json-client'); - expect(env.API_SECURE_ENDPOINT_URL_TEMPLATE).toContain('{{user_id}}'); - expect(env.UNSAFE_KEY).toBeUndefined(); + expect(env.OIDC_CLIENT_ID).toBe('desktop-client'); + expect(env.OIDC_SEND_AUDIENCE_IN_AUTHORIZE).toBe('1'); }); it('returns parseError for invalid json config', () => { @@ -103,3 +134,41 @@ describe('loadUserRuntimeConfig', () => { expect(result.appliedKeys).toEqual([]); }); }); + +describe('syncRuntimeConfigDocumentToEnv', () => { + it('applies and clears managed keys to match runtime settings document', () => { + const env: NodeJS.ProcessEnv = { + OIDC_ISSUER: 'https://old.example.com', + OIDC_SCOPES: 'openid', + API_SECURE_ENDPOINT_URL_TEMPLATE: 'https://old.example.com/{{id}}', + API_SECURE_ENDPOINT_CLAIM_MAP: '{"id":"sub"}', + }; + + const result = syncRuntimeConfigDocumentToEnv( + { + version: 1, + auth: { + issuer: 'https://issuer.example.com', + clientId: 'desktop-client', + redirectUri: 'http://127.0.0.1:42813/callback', + scopes: 'openid profile email', + }, + api: { + secureEndpointUrlTemplate: + 'https://api.example.com/resources/{{resource_id}}', + }, + }, + env, + ); + + expect(env.OIDC_ISSUER).toBe('https://issuer.example.com'); + expect(env.OIDC_CLIENT_ID).toBe('desktop-client'); + expect(env.OIDC_REDIRECT_URI).toBe('http://127.0.0.1:42813/callback'); + expect(env.OIDC_SCOPES).toBe('openid profile email'); + expect(env.API_SECURE_ENDPOINT_URL_TEMPLATE).toBe( + 'https://api.example.com/resources/{{resource_id}}', + ); + expect(env.API_SECURE_ENDPOINT_CLAIM_MAP).toBeUndefined(); + expect(result.clearedKeys).toContain('API_SECURE_ENDPOINT_CLAIM_MAP'); + }); +}); diff --git a/apps/desktop-main/src/runtime-user-config.ts b/apps/desktop-main/src/runtime-user-config.ts index 91125ba..ff1ad9e 100644 --- a/apps/desktop-main/src/runtime-user-config.ts +++ b/apps/desktop-main/src/runtime-user-config.ts @@ -1,9 +1,10 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; +import { runtimeConfigDocumentSchema } from '@electron-foundation/contracts'; -const runtimeConfigFileNames = ['runtime-config.json', 'runtime-config.env']; +const runtimeConfigFileNames = ['runtime-config.json']; -const allowedRuntimeConfigKeys = new Set([ +export const runtimeManagedEnvKeys = [ 'OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_REDIRECT_URI', @@ -14,39 +15,61 @@ const allowedRuntimeConfigKeys = new Set([ 'OIDC_ALLOWED_SIGNOUT_ORIGINS', 'API_SECURE_ENDPOINT_URL_TEMPLATE', 'API_SECURE_ENDPOINT_CLAIM_MAP', -]); +] as const; -const stripWrappingQuotes = (value: string): string => { - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - return value.slice(1, -1); - } - - return value; -}; +const allowedRuntimeConfigKeys = new Set(runtimeManagedEnvKeys); -const parseEnvConfig = (raw: string): Record => { +const toEnvEntriesFromRuntimeConfigDocument = ( + document: ReturnType, +): Record => { const entries: Record = {}; - for (const line of raw.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { - continue; - } - - const separator = trimmed.indexOf('='); - if (separator <= 0) { - continue; - } + const apiConfig = document.api; + const appConfig = document.app; + const authConfig = document.auth; + + if (apiConfig?.secureEndpointUrlTemplate) { + entries.API_SECURE_ENDPOINT_URL_TEMPLATE = + apiConfig.secureEndpointUrlTemplate; + } else if (appConfig?.secureEndpointUrlTemplate) { + entries.API_SECURE_ENDPOINT_URL_TEMPLATE = + appConfig.secureEndpointUrlTemplate; + } - const key = trimmed.slice(0, separator).trim(); - const value = stripWrappingQuotes(trimmed.slice(separator + 1).trim()); - if (!key || !allowedRuntimeConfigKeys.has(key)) { - continue; - } + if (apiConfig?.secureEndpointClaimMap) { + entries.API_SECURE_ENDPOINT_CLAIM_MAP = JSON.stringify( + apiConfig.secureEndpointClaimMap, + ); + } else if (appConfig?.secureEndpointClaimMap) { + entries.API_SECURE_ENDPOINT_CLAIM_MAP = JSON.stringify( + appConfig.secureEndpointClaimMap, + ); + } - entries[key] = value; + if (authConfig?.issuer) { + entries.OIDC_ISSUER = authConfig.issuer; + } + if (authConfig?.clientId) { + entries.OIDC_CLIENT_ID = authConfig.clientId; + } + if (authConfig?.redirectUri) { + entries.OIDC_REDIRECT_URI = authConfig.redirectUri; + } + if (authConfig?.scopes) { + entries.OIDC_SCOPES = authConfig.scopes; + } + if (authConfig?.audience) { + entries.OIDC_AUDIENCE = authConfig.audience; + } + if (typeof authConfig?.sendAudienceInAuthorize === 'boolean') { + entries.OIDC_SEND_AUDIENCE_IN_AUTHORIZE = authConfig.sendAudienceInAuthorize + ? '1' + : '0'; + } + if (authConfig?.apiBearerTokenSource) { + entries.OIDC_API_BEARER_TOKEN_SOURCE = authConfig.apiBearerTokenSource; + } + if (authConfig?.allowedSignOutOrigins) { + entries.OIDC_ALLOWED_SIGNOUT_ORIGINS = authConfig.allowedSignOutOrigins; } return entries; @@ -54,6 +77,11 @@ const parseEnvConfig = (raw: string): Record => { const parseJsonConfig = (raw: string): Record => { const parsed = JSON.parse(raw) as Record; + const nestedConfig = runtimeConfigDocumentSchema.safeParse(parsed); + if (nestedConfig.success) { + return toEnvEntriesFromRuntimeConfigDocument(nestedConfig.data); + } + const entries: Record = {}; for (const [key, value] of Object.entries(parsed)) { if (!allowedRuntimeConfigKeys.has(key) || typeof value !== 'string') { @@ -66,6 +94,31 @@ const parseJsonConfig = (raw: string): Record => { return entries; }; +export const syncRuntimeConfigDocumentToEnv = ( + document: ReturnType, + env: NodeJS.ProcessEnv = process.env, +): { appliedKeys: string[]; clearedKeys: string[] } => { + const entries = toEnvEntriesFromRuntimeConfigDocument(document); + const entryKeys = new Set(Object.keys(entries)); + const appliedKeys: string[] = []; + const clearedKeys: string[] = []; + + for (const key of runtimeManagedEnvKeys) { + if (!entryKeys.has(key)) { + if (typeof env[key] === 'string') { + delete env[key]; + clearedKeys.push(key); + } + continue; + } + + env[key] = entries[key]!; + appliedKeys.push(key); + } + + return { appliedKeys, clearedKeys }; +}; + const findRuntimeConfigPath = (userDataPath: string): string | null => { const configDir = path.join(userDataPath, 'config'); for (const fileName of runtimeConfigFileNames) { @@ -100,9 +153,7 @@ export const loadUserRuntimeConfig = ( try { const raw = readFileSync(sourcePath, 'utf8'); - const entries = sourcePath.endsWith('.json') - ? parseJsonConfig(raw) - : parseEnvConfig(raw); + const entries = parseJsonConfig(raw); const appliedKeys: string[] = []; const skippedExistingKeys: string[] = []; for (const [key, value] of Object.entries(entries)) { diff --git a/apps/desktop-preload/src/api/settings-api.ts b/apps/desktop-preload/src/api/settings-api.ts new file mode 100644 index 0000000..c92bcdd --- /dev/null +++ b/apps/desktop-preload/src/api/settings-api.ts @@ -0,0 +1,149 @@ +import type { DesktopSettingsApi } from '@electron-foundation/desktop-api'; +import { + CONTRACT_VERSION, + IPC_CHANNELS, + settingsExportFeatureConfigRequestSchema, + settingsExportFeatureConfigResponseSchema, + settingsExportRuntimeConfigRequestSchema, + settingsExportRuntimeConfigResponseSchema, + settingsGetRuntimeConfigRequestSchema, + settingsImportFeatureConfigRequestSchema, + settingsImportFeatureConfigResponseSchema, + settingsImportRuntimeConfigRequestSchema, + settingsImportRuntimeConfigResponseSchema, + settingsResetFeatureConfigRequestSchema, + settingsRuntimeConfigStateResponseSchema, + settingsSaveFeatureConfigRequestSchema, + type AppFeatureConfig, + type ApiFeatureConfig, + type AuthFeatureConfig, + type RuntimeConfigFeatureKey, +} from '@electron-foundation/contracts'; +import { createCorrelationId, invokeIpc } from '../invoke-client'; + +type RuntimeConfigFeatureConfigByKey = { + app: AppFeatureConfig; + auth: AuthFeatureConfig; + api: ApiFeatureConfig; +}; + +export const createSettingsApi = (): DesktopSettingsApi => ({ + async getRuntimeConfig() { + const correlationId = createCorrelationId(); + const request = settingsGetRuntimeConfigRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + + return invokeIpc( + IPC_CHANNELS.settingsGetRuntimeConfig, + request, + correlationId, + settingsRuntimeConfigStateResponseSchema, + ); + }, + + async saveFeatureConfig( + feature: TFeature, + config: RuntimeConfigFeatureConfigByKey[TFeature], + ) { + const correlationId = createCorrelationId(); + const request = settingsSaveFeatureConfigRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { feature, config }, + }); + + return invokeIpc( + IPC_CHANNELS.settingsSaveFeatureConfig, + request, + correlationId, + settingsRuntimeConfigStateResponseSchema, + ); + }, + + async resetFeatureConfig(feature) { + const correlationId = createCorrelationId(); + const request = settingsResetFeatureConfigRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { feature }, + }); + + return invokeIpc( + IPC_CHANNELS.settingsResetFeatureConfig, + request, + correlationId, + settingsRuntimeConfigStateResponseSchema, + ); + }, + + async importFeatureConfig(feature) { + const correlationId = createCorrelationId(); + const request = settingsImportFeatureConfigRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { feature }, + }); + + return invokeIpc( + IPC_CHANNELS.settingsImportFeatureConfig, + request, + correlationId, + settingsImportFeatureConfigResponseSchema, + 120_000, + ); + }, + + async exportFeatureConfig(feature) { + const correlationId = createCorrelationId(); + const request = settingsExportFeatureConfigRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: { feature }, + }); + + return invokeIpc( + IPC_CHANNELS.settingsExportFeatureConfig, + request, + correlationId, + settingsExportFeatureConfigResponseSchema, + 120_000, + ); + }, + + async importRuntimeConfig() { + const correlationId = createCorrelationId(); + const request = settingsImportRuntimeConfigRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + + return invokeIpc( + IPC_CHANNELS.settingsImportRuntimeConfig, + request, + correlationId, + settingsImportRuntimeConfigResponseSchema, + 120_000, + ); + }, + + async exportRuntimeConfig() { + const correlationId = createCorrelationId(); + const request = settingsExportRuntimeConfigRequestSchema.parse({ + contractVersion: CONTRACT_VERSION, + correlationId, + payload: {}, + }); + + return invokeIpc( + IPC_CHANNELS.settingsExportRuntimeConfig, + request, + correlationId, + settingsExportRuntimeConfigResponseSchema, + 120_000, + ); + }, +}); diff --git a/apps/desktop-preload/src/invoke-client.spec.ts b/apps/desktop-preload/src/invoke-client.spec.ts index d9c66b2..9177db6 100644 --- a/apps/desktop-preload/src/invoke-client.spec.ts +++ b/apps/desktop-preload/src/invoke-client.spec.ts @@ -91,4 +91,35 @@ describe('invokeIpc', () => { expect(result.error.correlationId).toBe('corr-4'); } }); + + it('preserves IPC/HANDLER_FAILED envelope from main process without remapping', async () => { + vi.mocked(ipcRenderer.invoke).mockResolvedValue({ + ok: false, + error: { + code: 'IPC/HANDLER_FAILED', + message: 'IPC handler execution failed.', + details: { channel: 'storage:get-item' }, + retryable: false, + correlationId: 'corr-5', + }, + }); + + const result = await invokeIpc( + 'storage:get-item', + { contractVersion: '1.0.0', correlationId: 'corr-5', payload: {} }, + 'corr-5', + responseSchema, + ); + + expect(result).toEqual({ + ok: false, + error: { + code: 'IPC/HANDLER_FAILED', + message: 'IPC handler execution failed.', + details: { channel: 'storage:get-item' }, + retryable: false, + correlationId: 'corr-5', + }, + }); + }); }); diff --git a/apps/desktop-preload/src/main.ts b/apps/desktop-preload/src/main.ts index e9a0060..dbd3c0f 100644 --- a/apps/desktop-preload/src/main.ts +++ b/apps/desktop-preload/src/main.ts @@ -9,6 +9,7 @@ import { createStorageApi } from './api/storage-api'; import { createTelemetryApi } from './api/telemetry-api'; import { createUpdatesApi } from './api/updates-api'; import { createPythonApi } from './api/python-api'; +import { createSettingsApi } from './api/settings-api'; const desktopApi: DesktopApi = { app: createAppApi(), @@ -20,6 +21,7 @@ const desktopApi: DesktopApi = { updates: createUpdatesApi(), python: createPythonApi(), telemetry: createTelemetryApi(), + settings: createSettingsApi(), }; contextBridge.exposeInMainWorld('desktop', desktopApi); diff --git a/apps/renderer-e2e/src/example.spec.ts b/apps/renderer-e2e/src/example.spec.ts index 9b59cae..b082039 100644 --- a/apps/renderer-e2e/src/example.spec.ts +++ b/apps/renderer-e2e/src/example.spec.ts @@ -24,12 +24,66 @@ test('home shell renders toolbar and action controls', async ({ page }) => { await expect(page.getByRole('button', { name: 'Open file' })).toBeVisible(); }); +test('labs toggle controls lab navigation visibility and persists on reload', async ({ + page, +}) => { + await page.goto('/'); + + const labsToggle = page.getByRole('button', { name: /Labs Mode:/ }); + await expect(labsToggle).toBeVisible(); + await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Material Showcase' }), + ).toHaveCount(0); + + await labsToggle.click(); + await expect( + page.getByRole('link', { name: 'Material Showcase' }), + ).toBeVisible(); + + await page.reload(); + await expect( + page.getByRole('link', { name: 'Material Showcase' }), + ).toBeVisible(); +}); + +test('settings routes navigate between app, api, and auth panels', async ({ + page, +}) => { + await page.goto('/settings'); + + await expect( + page.getByRole('heading', { name: 'Settings', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'App Settings' }), + ).toBeVisible(); + + await page.getByRole('link', { name: 'API' }).click(); + await expect(page).toHaveURL(/\/settings\/api$/); + await expect( + page.getByRole('heading', { name: 'API Settings' }), + ).toBeVisible(); + await expect(page.getByLabel('Secure endpoint URL template')).toBeVisible(); + + await page.getByRole('link', { name: 'Auth' }).click(); + await expect(page).toHaveURL(/\/settings\/auth$/); + await expect( + page.getByRole('heading', { name: 'Auth Settings' }), + ).toBeVisible(); + await expect(page.getByLabel('Issuer URL')).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); + await page.getByRole('link', { name: 'Settings' }).click(); + await expect(page).toHaveURL(/\/settings\/app$/); + await page.getByRole('link', { name: 'API' }).click(); + await expect(page).toHaveURL(/\/settings\/api$/); + await page.waitForTimeout(300); expect(runtimeErrors).toEqual([]); }); diff --git a/apps/renderer/project.json b/apps/renderer/project.json index 5ef0752..1f73b3e 100644 --- a/apps/renderer/project.json +++ b/apps/renderer/project.json @@ -74,6 +74,7 @@ "outputHashing": "all" }, "development": { + "index": "apps/renderer/src/index.development.html", "baseHref": "/", "optimization": false, "extractLicenses": false, diff --git a/apps/renderer/src/app/app-route-registry.ts b/apps/renderer/src/app/app-route-registry.ts index fb2472e..e22bb09 100644 --- a/apps/renderer/src/app/app-route-registry.ts +++ b/apps/renderer/src/app/app-route-registry.ts @@ -49,6 +49,53 @@ const routeRegistry: ReadonlyArray = [ import('./features/home/home-page').then((m) => m.HomePage), }), }, + { + path: 'settings', + label: 'Settings', + icon: 'settings', + lab: false, + nav: true, + toRoute: () => ({ + path: 'settings', + children: [ + { + path: '', + pathMatch: 'full', + redirectTo: 'app', + }, + { + path: '', + loadComponent: () => + import('./features/settings/settings-page').then( + (m) => m.SettingsPage, + ), + children: [ + { + path: 'app', + loadComponent: () => + import( + './features/settings/settings-app/settings-app-page' + ).then((m) => m.SettingsAppPage), + }, + { + path: 'api', + loadComponent: () => + import( + './features/settings/settings-api/settings-api-page' + ).then((m) => m.SettingsApiPage), + }, + { + path: 'auth', + loadComponent: () => + import( + './features/settings/settings-auth/settings-auth-page' + ).then((m) => m.SettingsAuthPage), + }, + ], + }, + ], + }), + }, { path: 'material-showcase', label: 'Material Showcase', diff --git a/apps/renderer/src/app/app-shell.config.prod.ts b/apps/renderer/src/app/app-shell.config.prod.ts index 5ad29c0..125934e 100644 --- a/apps/renderer/src/app/app-shell.config.prod.ts +++ b/apps/renderer/src/app/app-shell.config.prod.ts @@ -19,5 +19,8 @@ export const APP_SHELL_CONFIG: AppShellConfig = { labsToggleLabel: '', labsToggleOnLabel: '', labsToggleOffLabel: '', - navLinks: [{ path: '/', label: 'Home', icon: 'home', exact: true }], + navLinks: [ + { path: '/', label: 'Home', icon: 'home', exact: true }, + { path: '/settings', label: 'Settings', icon: 'settings' }, + ], }; diff --git a/apps/renderer/src/app/app.routes.prod.ts b/apps/renderer/src/app/app.routes.prod.ts index ee6aa44..966bb43 100644 --- a/apps/renderer/src/app/app.routes.prod.ts +++ b/apps/renderer/src/app/app.routes.prod.ts @@ -13,6 +13,46 @@ export const appRoutes: Route[] = [ loadComponent: () => import('./features/home/home-page').then((m) => m.HomePage), }, + { + path: 'settings', + children: [ + { + path: '', + pathMatch: 'full', + redirectTo: 'app', + }, + { + path: '', + loadComponent: () => + import('./features/settings/settings-page').then( + (m) => m.SettingsPage, + ), + children: [ + { + path: 'app', + loadComponent: () => + import('./features/settings/settings-app/settings-app-page').then( + (m) => m.SettingsAppPage, + ), + }, + { + path: 'api', + loadComponent: () => + import('./features/settings/settings-api/settings-api-page').then( + (m) => m.SettingsApiPage, + ), + }, + { + path: 'auth', + loadComponent: () => + import( + './features/settings/settings-auth/settings-auth-page' + ).then((m) => m.SettingsAuthPage), + }, + ], + }, + ], + }, { path: '**', redirectTo: '', 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 7fa1407..2f963f5 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 @@ -50,8 +50,8 @@

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. + or mapped JWT claims configured in Settings. If both exist, params take + precedence over JWT claim mapping.

diff --git a/apps/renderer/src/app/features/settings/runtime-settings.service.ts b/apps/renderer/src/app/features/settings/runtime-settings.service.ts new file mode 100644 index 0000000..64a4993 --- /dev/null +++ b/apps/renderer/src/app/features/settings/runtime-settings.service.ts @@ -0,0 +1,262 @@ +import { Injectable, computed, signal } from '@angular/core'; +import { + getDesktopApi, + type DesktopSettingsApi, +} from '@electron-foundation/desktop-api'; +import type { + AppFeatureConfig, + ApiFeatureConfig, + AuthFeatureConfig, + RuntimeConfigDocument, + RuntimeConfigFeatureKey, +} from '@electron-foundation/contracts'; + +const defaultRuntimeConfig: RuntimeConfigDocument = { + version: 1, +}; + +@Injectable({ providedIn: 'root' }) +export class RuntimeSettingsService { + private readonly desktopSettingsApi: DesktopSettingsApi | null = + getDesktopApi()?.settings ?? null; + + readonly desktopAvailable = signal(this.desktopSettingsApi !== null); + readonly busy = signal(false); + readonly status = signal('Idle.'); + readonly sourcePath = signal('N/A'); + readonly exists = signal(false); + readonly runtimeConfig = signal(defaultRuntimeConfig); + + readonly appConfig = computed(() => this.runtimeConfig().app ?? {}); + readonly apiConfig = computed( + () => this.runtimeConfig().api ?? this.runtimeConfig().app ?? {}, + ); + readonly authConfig = computed(() => this.runtimeConfig().auth ?? {}); + + async refresh() { + if (!this.desktopSettingsApi) { + this.status.set('Desktop bridge unavailable in browser mode.'); + return; + } + + this.busy.set(true); + this.status.set('Loading runtime config...'); + try { + const result = await this.desktopSettingsApi.getRuntimeConfig(); + if (!result.ok) { + this.status.set(result.error.message); + return; + } + + this.applyConfigState(result.data); + this.status.set( + result.data.exists + ? 'Runtime config loaded.' + : 'Runtime config not found yet.', + ); + } finally { + this.busy.set(false); + } + } + + async saveAppConfig(config: AppFeatureConfig) { + await this.saveFeatureConfig('app', config); + } + + async saveAuthConfig(config: AuthFeatureConfig) { + await this.saveFeatureConfig('auth', config); + } + + async saveApiConfig(config: ApiFeatureConfig) { + await this.saveFeatureConfig('api', config); + } + + async resetFeatureConfig(feature: RuntimeConfigFeatureKey) { + if (!this.desktopSettingsApi) { + this.status.set('Desktop bridge unavailable in browser mode.'); + return; + } + + this.busy.set(true); + this.status.set(`Resetting ${feature} settings...`); + try { + const result = await this.desktopSettingsApi.resetFeatureConfig(feature); + if (!result.ok) { + this.status.set(result.error.message); + return; + } + + this.applyConfigState(result.data); + this.status.set(`${feature} settings reset.`); + } finally { + this.busy.set(false); + } + } + + async importFeatureConfig(feature: RuntimeConfigFeatureKey) { + if (!this.desktopSettingsApi) { + this.status.set('Desktop bridge unavailable in browser mode.'); + return; + } + + this.busy.set(true); + this.status.set(`Importing ${feature} settings...`); + try { + const result = await this.desktopSettingsApi.importFeatureConfig(feature); + if (!result.ok) { + this.status.set(result.error.message); + return; + } + + if (result.data.canceled) { + this.status.set('Import canceled.'); + return; + } + + if (result.data.config) { + this.runtimeConfig.set(result.data.config); + this.exists.set(true); + } + if (result.data.sourcePath) { + this.sourcePath.set(result.data.sourcePath); + } + + this.status.set(`${feature} settings imported.`); + } finally { + this.busy.set(false); + } + } + + async exportFeatureConfig(feature: RuntimeConfigFeatureKey) { + if (!this.desktopSettingsApi) { + this.status.set('Desktop bridge unavailable in browser mode.'); + return; + } + + this.busy.set(true); + this.status.set(`Exporting ${feature} settings...`); + try { + const result = await this.desktopSettingsApi.exportFeatureConfig(feature); + if (!result.ok) { + this.status.set(result.error.message); + return; + } + + if (result.data.canceled) { + this.status.set('Export canceled.'); + return; + } + + this.status.set( + result.data.targetPath + ? `${feature} settings exported to ${result.data.targetPath}` + : `${feature} settings exported.`, + ); + } finally { + this.busy.set(false); + } + } + + async importRuntimeConfig() { + if (!this.desktopSettingsApi) { + this.status.set('Desktop bridge unavailable in browser mode.'); + return; + } + + this.busy.set(true); + this.status.set('Importing full runtime config...'); + try { + const result = await this.desktopSettingsApi.importRuntimeConfig(); + if (!result.ok) { + this.status.set(result.error.message); + return; + } + + if (result.data.canceled) { + this.status.set('Import canceled.'); + return; + } + + if (result.data.config) { + this.runtimeConfig.set(result.data.config); + this.exists.set(true); + } + + if (result.data.sourcePath) { + this.sourcePath.set(result.data.sourcePath); + } + + this.status.set('Full runtime config imported.'); + } finally { + this.busy.set(false); + } + } + + async exportRuntimeConfig() { + if (!this.desktopSettingsApi) { + this.status.set('Desktop bridge unavailable in browser mode.'); + return; + } + + this.busy.set(true); + this.status.set('Exporting full runtime config...'); + try { + const result = await this.desktopSettingsApi.exportRuntimeConfig(); + if (!result.ok) { + this.status.set(result.error.message); + return; + } + + if (result.data.canceled) { + this.status.set('Export canceled.'); + return; + } + + this.status.set( + result.data.targetPath + ? `Full runtime config exported to ${result.data.targetPath}` + : 'Full runtime config exported.', + ); + } finally { + this.busy.set(false); + } + } + + private async saveFeatureConfig( + feature: RuntimeConfigFeatureKey, + config: AppFeatureConfig | AuthFeatureConfig | ApiFeatureConfig, + ) { + if (!this.desktopSettingsApi) { + this.status.set('Desktop bridge unavailable in browser mode.'); + return; + } + + this.busy.set(true); + this.status.set(`Saving ${feature} settings...`); + try { + const result = await this.desktopSettingsApi.saveFeatureConfig( + feature, + config, + ); + if (!result.ok) { + this.status.set(result.error.message); + return; + } + + this.applyConfigState(result.data); + this.status.set(`${feature} settings saved.`); + } finally { + this.busy.set(false); + } + } + + private applyConfigState(state: { + sourcePath: string; + exists: boolean; + config: RuntimeConfigDocument; + }) { + this.sourcePath.set(state.sourcePath); + this.exists.set(state.exists); + this.runtimeConfig.set(state.config); + } +} diff --git a/apps/renderer/src/app/features/settings/settings-api/settings-api-page.css b/apps/renderer/src/app/features/settings/settings-api/settings-api-page.css new file mode 100644 index 0000000..7538657 --- /dev/null +++ b/apps/renderer/src/app/features/settings/settings-api/settings-api-page.css @@ -0,0 +1,64 @@ +.settings-feature-card { + padding: 1rem; +} + +.settings-feature-card h3, +.settings-feature-card h4 { + margin: 0; +} + +.settings-feature-card p { + margin: 0.25rem 0; +} + +.settings-form { + display: grid; + gap: 0.75rem; + margin-top: 0.75rem; +} + +.mapping-section, +.preview-section { + display: grid; + gap: 0.5rem; +} + +.mapping-grid { + display: grid; + gap: 0.5rem; +} + +.mapping-row { + display: grid; + grid-template-columns: 220px 1fr; + align-items: center; + gap: 0.75rem; +} + +.mapping-header { + font-weight: 600; +} + +.placeholder-chip { + display: inline-block; + border: 1px solid #9aa0a6; + border-radius: 999px; + padding: 0.25rem 0.6rem; + width: fit-content; +} + +.settings-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.status { + margin: 0; +} + +@media (max-width: 880px) { + .mapping-row { + grid-template-columns: 1fr; + } +} diff --git a/apps/renderer/src/app/features/settings/settings-api/settings-api-page.html b/apps/renderer/src/app/features/settings/settings-api/settings-api-page.html new file mode 100644 index 0000000..581c903 --- /dev/null +++ b/apps/renderer/src/app/features/settings/settings-api/settings-api-page.html @@ -0,0 +1,91 @@ + +

API Settings

+

+ Configure secure endpoint URL templates and map URL placeholders to JWT + claim paths. +

+ +
+ + Secure endpoint URL template + + + +
+

Detected placeholders

+ @if (mappingRows().length === 0) { +

No placeholders detected in URL template.

+ } @else { +
+
+ Placeholder + JWT claim path +
+ @for (row of mappingRows(); track row.placeholder) { +
+ {{ row.placeholder }} + + Claim path + + +
+ } +
+ } +
+ +
+

Resolved path preview

+

{{ resolvedPreview() }}

+
+ +
+ + + + +
+ + @if (localStatus()) { +

{{ localStatus() }}

+ } +
+
diff --git a/apps/renderer/src/app/features/settings/settings-api/settings-api-page.ts b/apps/renderer/src/app/features/settings/settings-api/settings-api-page.ts new file mode 100644 index 0000000..06e813b --- /dev/null +++ b/apps/renderer/src/app/features/settings/settings-api/settings-api-page.ts @@ -0,0 +1,144 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import type { ApiFeatureConfig } from '@electron-foundation/contracts'; +import { RuntimeSettingsService } from '../runtime-settings.service'; + +type MappingRow = { + placeholder: string; + claimPath: string; +}; + +const placeholderPattern = /\{\{([a-zA-Z0-9_.-]+)\}\}/g; + +const extractPlaceholders = (urlTemplate: string): string[] => { + const unique = new Set(); + for (const match of urlTemplate.matchAll(placeholderPattern)) { + const value = match[1]?.trim(); + if (value) { + unique.add(value); + } + } + + return Array.from(unique); +}; + +const toMappingRows = ( + placeholders: string[], + existingMap: Record, +): MappingRow[] => + placeholders.map((placeholder) => ({ + placeholder, + claimPath: existingMap[placeholder] ?? '', + })); + +@Component({ + selector: 'app-settings-api-page', + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + ], + templateUrl: './settings-api-page.html', + styleUrl: './settings-api-page.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SettingsApiPage { + protected readonly settings = inject(RuntimeSettingsService); + private readonly formBuilder = inject(FormBuilder); + + protected readonly localStatus = signal(''); + protected readonly mappingRows = signal([]); + + protected readonly form = this.formBuilder.nonNullable.group({ + secureEndpointUrlTemplate: [''], + }); + + protected readonly resolvedPreview = signal('N/A'); + + constructor() { + effect(() => { + const config = this.settings.apiConfig(); + const urlTemplate = config.secureEndpointUrlTemplate ?? ''; + const claimMap = config.secureEndpointClaimMap ?? {}; + const placeholders = extractPlaceholders(urlTemplate); + + this.form.patchValue( + { + secureEndpointUrlTemplate: urlTemplate, + }, + { emitEvent: false }, + ); + this.mappingRows.set(toMappingRows(placeholders, claimMap)); + this.updateResolvedPreview(); + }); + } + + protected onUrlTemplateChanged(value: string) { + const placeholders = extractPlaceholders(value); + const currentRows = this.mappingRows(); + const currentMap: Record = {}; + for (const row of currentRows) { + currentMap[row.placeholder] = row.claimPath; + } + + this.mappingRows.set(toMappingRows(placeholders, currentMap)); + this.updateResolvedPreview(value); + } + + protected async save() { + const secureEndpointUrlTemplate = + this.form.controls.secureEndpointUrlTemplate.value.trim(); + const claimMap: Record = {}; + + for (const row of this.mappingRows()) { + const claimPath = row.claimPath.trim(); + if (!claimPath) { + continue; + } + claimMap[row.placeholder] = claimPath; + } + + const config: ApiFeatureConfig = { + secureEndpointUrlTemplate: secureEndpointUrlTemplate || undefined, + secureEndpointClaimMap: + Object.keys(claimMap).length > 0 ? claimMap : undefined, + }; + + await this.settings.saveApiConfig(config); + this.localStatus.set('API settings saved.'); + } + + protected updateResolvedPreview(urlTemplateOverride?: string) { + const template = + ( + urlTemplateOverride ?? + this.form.controls.secureEndpointUrlTemplate.value + )?.trim() ?? ''; + if (!template) { + this.resolvedPreview.set('N/A'); + return; + } + + let resolved = template; + for (const row of this.mappingRows()) { + const replacement = row.claimPath.trim() || `claim:${row.placeholder}`; + resolved = resolved.replaceAll(`{{${row.placeholder}}}`, replacement); + } + this.resolvedPreview.set(resolved); + } +} diff --git a/apps/renderer/src/app/features/settings/settings-app/settings-app-page.css b/apps/renderer/src/app/features/settings/settings-app/settings-app-page.css new file mode 100644 index 0000000..7e61726 --- /dev/null +++ b/apps/renderer/src/app/features/settings/settings-app/settings-app-page.css @@ -0,0 +1,27 @@ +.settings-feature-card { + padding: 1rem; +} + +.settings-feature-card h3 { + margin: 0; +} + +.settings-feature-card p { + margin: 0.25rem 0; +} + +.settings-form { + display: grid; + gap: 0.75rem; + margin-top: 0.75rem; +} + +.settings-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.status { + margin: 0; +} diff --git a/apps/renderer/src/app/features/settings/settings-app/settings-app-page.html b/apps/renderer/src/app/features/settings/settings-app/settings-app-page.html new file mode 100644 index 0000000..6ee4659 --- /dev/null +++ b/apps/renderer/src/app/features/settings/settings-app/settings-app-page.html @@ -0,0 +1,38 @@ + +

App Settings

+

+ App-level runtime settings are reserved for global behavior flags and future + platform controls. +

+

+ Use API for secure endpoint configuration and + Auth for OIDC settings. +

+ +
+ + + +
+
diff --git a/apps/renderer/src/app/features/settings/settings-app/settings-app-page.ts b/apps/renderer/src/app/features/settings/settings-app/settings-app-page.ts new file mode 100644 index 0000000..7a4bb30 --- /dev/null +++ b/apps/renderer/src/app/features/settings/settings-app/settings-app-page.ts @@ -0,0 +1,16 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { RuntimeSettingsService } from '../runtime-settings.service'; + +@Component({ + selector: 'app-settings-app-page', + imports: [CommonModule, MatCardModule, MatButtonModule], + templateUrl: './settings-app-page.html', + styleUrl: './settings-app-page.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SettingsAppPage { + protected readonly settings = inject(RuntimeSettingsService); +} diff --git a/apps/renderer/src/app/features/settings/settings-auth/settings-auth-page.css b/apps/renderer/src/app/features/settings/settings-auth/settings-auth-page.css new file mode 100644 index 0000000..3126b8a --- /dev/null +++ b/apps/renderer/src/app/features/settings/settings-auth/settings-auth-page.css @@ -0,0 +1,23 @@ +.settings-feature-card { + padding: 1rem; +} + +.settings-feature-card h3 { + margin: 0; +} + +.settings-feature-card p { + margin: 0.25rem 0; +} + +.settings-form { + display: grid; + gap: 0.75rem; + margin-top: 0.75rem; +} + +.settings-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} diff --git a/apps/renderer/src/app/features/settings/settings-auth/settings-auth-page.html b/apps/renderer/src/app/features/settings/settings-auth/settings-auth-page.html new file mode 100644 index 0000000..fde42a8 --- /dev/null +++ b/apps/renderer/src/app/features/settings/settings-auth/settings-auth-page.html @@ -0,0 +1,94 @@ + +

Auth Settings

+

Configure OIDC runtime settings for desktop sign-in behavior.

+ +
+ + Issuer URL + + + + + Client ID + + + + + Redirect URI + + + + + Scopes + + + + + Audience + + + + + Send audience in authorize request + + + + API bearer token source + + access_token + id_token + + + + + Allowed sign-out origins + + + +
+ + + + +
+
+
diff --git a/apps/renderer/src/app/features/settings/settings-auth/settings-auth-page.ts b/apps/renderer/src/app/features/settings/settings-auth/settings-auth-page.ts new file mode 100644 index 0000000..e21cd5f --- /dev/null +++ b/apps/renderer/src/app/features/settings/settings-auth/settings-auth-page.ts @@ -0,0 +1,83 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + inject, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatSelectModule } from '@angular/material/select'; +import type { AuthFeatureConfig } from '@electron-foundation/contracts'; +import { RuntimeSettingsService } from '../runtime-settings.service'; + +@Component({ + selector: 'app-settings-auth-page', + imports: [ + CommonModule, + ReactiveFormsModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatCheckboxModule, + MatSelectModule, + ], + templateUrl: './settings-auth-page.html', + styleUrl: './settings-auth-page.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SettingsAuthPage { + protected readonly settings = inject(RuntimeSettingsService); + private readonly formBuilder = inject(FormBuilder); + + protected readonly form = this.formBuilder.nonNullable.group({ + issuer: [''], + clientId: [''], + redirectUri: [''], + scopes: ['openid profile email offline_access'], + audience: [''], + sendAudienceInAuthorize: [false], + apiBearerTokenSource: ['access_token' as 'access_token' | 'id_token'], + allowedSignOutOrigins: [''], + }); + + constructor() { + effect(() => { + const config = this.settings.authConfig(); + this.form.patchValue( + { + issuer: config.issuer ?? '', + clientId: config.clientId ?? '', + redirectUri: config.redirectUri ?? '', + scopes: config.scopes ?? 'openid profile email offline_access', + audience: config.audience ?? '', + sendAudienceInAuthorize: config.sendAudienceInAuthorize ?? false, + apiBearerTokenSource: config.apiBearerTokenSource ?? 'access_token', + allowedSignOutOrigins: config.allowedSignOutOrigins ?? '', + }, + { emitEvent: false }, + ); + }); + } + + protected async save() { + const config: AuthFeatureConfig = { + issuer: this.form.controls.issuer.value.trim() || undefined, + clientId: this.form.controls.clientId.value.trim() || undefined, + redirectUri: this.form.controls.redirectUri.value.trim() || undefined, + scopes: this.form.controls.scopes.value.trim() || undefined, + audience: this.form.controls.audience.value.trim() || undefined, + sendAudienceInAuthorize: this.form.controls.sendAudienceInAuthorize.value, + apiBearerTokenSource: this.form.controls.apiBearerTokenSource.value, + allowedSignOutOrigins: + this.form.controls.allowedSignOutOrigins.value.trim() || undefined, + }; + + await this.settings.saveAuthConfig(config); + } +} diff --git a/apps/renderer/src/app/features/settings/settings-page.css b/apps/renderer/src/app/features/settings/settings-page.css new file mode 100644 index 0000000..7810cfc --- /dev/null +++ b/apps/renderer/src/app/features/settings/settings-page.css @@ -0,0 +1,46 @@ +.settings-layout { + display: grid; + gap: 1rem; +} + +.settings-header, +.settings-nav { + padding: 1rem; +} + +.settings-header h2 { + margin: 0; +} + +.settings-header p { + margin: 0.25rem 0; +} + +.settings-meta { + margin-top: 0.75rem; +} + +.settings-actions { + margin-top: 0.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.status { + margin-top: 0.75rem; +} + +.warning { + color: #b3261e; +} + +.settings-nav nav { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.active-link { + font-weight: 600; +} diff --git a/apps/renderer/src/app/features/settings/settings-page.html b/apps/renderer/src/app/features/settings/settings-page.html new file mode 100644 index 0000000..c7e7171 --- /dev/null +++ b/apps/renderer/src/app/features/settings/settings-page.html @@ -0,0 +1,58 @@ +
+ +

Settings

+

+ Manage runtime configuration for this desktop app, including per-feature + import/export and full-config backup. +

+ +
+

Config path: {{ settings.sourcePath() }}

+

+ Config exists: {{ settings.exists() ? 'yes' : 'no' }} +

+
+ +
+ + + +
+ +

+ {{ settings.status() }} +

+
+ + + + + + +
diff --git a/apps/renderer/src/app/features/settings/settings-page.ts b/apps/renderer/src/app/features/settings/settings-page.ts new file mode 100644 index 0000000..48d123d --- /dev/null +++ b/apps/renderer/src/app/features/settings/settings-page.ts @@ -0,0 +1,28 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { RuntimeSettingsService } from './runtime-settings.service'; + +@Component({ + selector: 'app-settings-page', + imports: [ + CommonModule, + RouterLink, + RouterLinkActive, + RouterOutlet, + MatCardModule, + MatButtonModule, + ], + templateUrl: './settings-page.html', + styleUrl: './settings-page.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SettingsPage { + protected readonly settings = inject(RuntimeSettingsService); + + constructor() { + void this.settings.refresh(); + } +} diff --git a/apps/renderer/src/index.development.html b/apps/renderer/src/index.development.html new file mode 100644 index 0000000..8b5f58a --- /dev/null +++ b/apps/renderer/src/index.development.html @@ -0,0 +1,17 @@ + + + + + Electron Angular Foundation + + + + + + + + + diff --git a/docs/03-engineering/onboarding-guide.md b/docs/03-engineering/onboarding-guide.md index da6366f..2b5f7c3 100644 --- a/docs/03-engineering/onboarding-guide.md +++ b/docs/03-engineering/onboarding-guide.md @@ -170,6 +170,8 @@ Reference: `docs/05-governance/definition-of-done.md` - Preferred task execution: Nx (`pnpm nx ...`) - On Windows, use `pnpm desktop:dev:win` for one-command desktop launch - If Electron ever behaves like Node, clear `ELECTRON_RUN_AS_NODE` +- OIDC/API runtime setup is managed in-app via `Settings` (`Auth`, `API`) and can be imported from `examples/config/*.json` +- Packaged runtime configuration file path: `%APPDATA%\Angulectron\config\runtime-config.json` ## Troubleshooting diff --git a/docs/03-engineering/security-review-workflow.md b/docs/03-engineering/security-review-workflow.md index 26bc606..5f6c267 100644 --- a/docs/03-engineering/security-review-workflow.md +++ b/docs/03-engineering/security-review-workflow.md @@ -94,17 +94,18 @@ Security review is required for any change that introduces or modifies: ### 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 target must be configured through runtime settings/config, never hardcoded: + - `Settings > API > secureEndpointUrlTemplate` + - optional `Settings > API > secureEndpointClaimMap` (map of `placeholder -> jwt.claim.path`) + - packaged fallback file: `%APPDATA%\Angulectron\config\runtime-config.json` - 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` + - mapped JWT claim value from `secureEndpointClaimMap` - 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 runtime API config -> `API/OPERATION_NOT_CONFIGURED` - missing path placeholder values -> `API/INVALID_PARAMS` - unsafe headers -> `API/INVALID_HEADERS` - Review evidence for this pattern should include: diff --git a/docs/04-delivery/ci-cd-spec.md b/docs/04-delivery/ci-cd-spec.md index f25f4c9..3452458 100644 --- a/docs/04-delivery/ci-cd-spec.md +++ b/docs/04-delivery/ci-cd-spec.md @@ -19,6 +19,8 @@ Last reviewed: 2026-02-13 - `e2e-smoke` - `a11y-e2e` - `i18n-check` +- `docs-lint` +- `no-dotenv-files` - `build-renderer` - `build-desktop` - `dependency-audit` diff --git a/docs/05-governance/backlog.md b/docs/05-governance/backlog.md index b7e9a84..e1bfec6 100644 --- a/docs/05-governance/backlog.md +++ b/docs/05-governance/backlog.md @@ -2,48 +2,60 @@ Owner: Platform Engineering Review cadence: Weekly -Last reviewed: 2026-02-13 +Last reviewed: 2026-02-15 -| 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 | Done | Medium | Platform API | Transient FR document (API, archived) | Platform | Implemented via operation type maps and typed invoke signatures across contracts/preload/desktop API (baseline delivered and extended by `BL-025`). | -| 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 | Done | Medium | Testing + IPC Contracts | TASK.md | Platform | Delivered via real-handler unauthorized sender integration coverage and preload invoke timeout/correlation tests; superseded by scoped execution under `BL-023`. | -| 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 | Done | Medium | Security + Identity | OIDC integration | Platform + Security | Delivered with explicit `local` vs `global` sign-out mode, revocation/end-session capability reporting, and renderer-safe lifecycle messaging. | -| BL-016 | Refactor desktop-main composition root and IPC modularization | Done | High | Desktop Runtime + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; desktop-main composition root split and handler registration modularized. | -| BL-017 | Refactor preload bridge into domain modules with shared invoke client | Done | High | Preload + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; preload segmented into domain APIs with shared invoke/correlation/timeout client. | -| BL-018 | Introduce reusable validated IPC handler factory in desktop-main | Done | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; validated handler factory centralizes sender auth, schema validation, and envelope handling. | -| 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 | Done | Medium | Frontend Architecture | Fresh workspace review (2026-02-13) | Frontend | Delivered by introducing a typed renderer route registry that generates both router entries and shell nav links from one source while preserving production file-replacement exclusions. | -| 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 | Done | Medium | Testing + IPC Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered with real-handler unauthorized sender tests and preload invoke malformed/timeout/failure correlation assertions. | -| 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 | Done | Medium | Platform API + Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered by introducing operation-to-request/response type maps and consuming them in preload/desktop API invoke surfaces. | -| BL-026 | Exclude lab routes/features from production bundle surface | Done | High | Frontend + Security Posture | Sprint implementation (2026-02-13) | Frontend + Platform | Production route/shell config replacement now removes lab routes/nav/toggle from production artifacts to reduce discoverability/attack surface. | -| BL-027 | Provide deterministic bundled update demo patch cycle | Done | Medium | Delivery + Update Architecture | Sprint implementation (2026-02-13) | Platform | Added local bundled feed demo (`1.0.0-demo` -> `1.0.1-demo`) with hash validation and renderer diagnostics to prove end-to-end update model independent of installer updater infra. | -| BL-028 | Enforce robust file signature validation for privileged file ingress | In Progress | High | Security + File Handling | Python sidecar architecture spike | Platform + Security | Python sidecar ingress path now enforces `.pdf` extension + `%PDF-` header checks with fail-closed behavior. Remaining scope: align renderer fs bridge parity, centralize supported signatures, and add security event logging/coverage for all privileged ingress paths. | -| BL-029 | Standardize official Python runtime distribution for sidecar bundling | In Progress | High | Delivery + Runtime Determinism | Python sidecar packaging hardening | Platform + Security | `BL-029A` in progress: runtime prep now sources from pinned official artifact catalog (`tools/python-runtime-artifacts.json`) with SHA256 verification and manifest source metadata; remaining scope is CI artifact sourcing hardening and full reproducibility validation (`BL-029B`). | -| BL-030 | Deterministic packaged Python sidecar runtime baseline | Done | High | Delivery + Runtime Determinism | Python sidecar packaging hardening | Platform + Security | Delivered deterministic runtime manifest/assert/sync flow, pinned runtime dependency install (`requirements-runtime.txt`), packaged-build bundled-runtime enforcement (no system fallback), and runtime executable diagnostics proving packaged interpreter path. | -| BL-031 | Refactor desktop-main composition root into focused runtime modules | Proposed | High | Desktop Runtime + Architecture | Fresh workspace review (2026-02-14) | Platform | Split `main.ts` orchestration from lifecycle/app-window/auth-token/python-runtime concerns into focused modules with retained behavior. Acceptance: no behavior regressions in auth/session/update/python flows. Proof: `pnpm nx run desktop-main:test`, `pnpm nx run desktop-main:build`. | -| BL-032 | Standardize IPC handler failure envelope and correlation guarantees | In Progress | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-14) | Platform | Validated handler factory now catches unexpected sync/async handler failures and returns `IPC/HANDLER_FAILED` with correlation ID plus structured exception logging; remaining scope is preload-main real-handler integration assertions. Proof: `pnpm nx run desktop-main:test` plus integration assertions in preload/main harness. | -| BL-033 | Centralize privileged file ingress policy across all IPC file routes | In Progress | High | Security + File Handling | Fresh workspace review (2026-02-14) | Platform + Security | Shared token consumption and ingress policy module now enforce extension/signature checks across file and python handlers; remaining scope is parity audit for any additional privileged ingress surfaces and reject-event telemetry consistency. Proof: `pnpm nx run desktop-main:test`, `pnpm docs-lint`. | -| BL-034 | Route-driven i18n asset loading manifest for renderer features | Proposed | Medium | Frontend + I18n | Fresh workspace review (2026-02-14) | Frontend | Replace manual translation path list with route/feature manifest-based loading to remove per-feature loader edits. Acceptance: adding a feature locale requires no loader code change. Proof: `pnpm i18n-check`, `pnpm nx run renderer:test`, `pnpm nx run renderer:build`. | -| BL-035 | Replace route/nav literal labels with typed i18n keys | Proposed | Medium | Frontend + I18n | Fresh workspace review (2026-02-14) | Frontend | Update route registry metadata to use translation keys instead of literals and enforce key typing for nav labels/titles. Acceptance: no hardcoded nav labels in route registry. Proof: `pnpm i18n-check`, `pnpm nx run renderer:build`. | -| BL-036 | Promote Python runtime sync to first-class Nx target dependency | Proposed | Medium | Build System + Determinism | Fresh workspace review (2026-02-14) | Platform | Model runtime sync/prepare as explicit Nx targets with `dependsOn` instead of script chaining for cache/task graph correctness. Acceptance: graph reflects runtime-prep dependency for package/build targets. Proof: `pnpm nx graph` (visual verification), `pnpm nx run desktop-main:build`, `pnpm forge:make:staging`. | -| BL-037 | Consolidate duplicated CI setup into reusable workflow primitives | Proposed | Medium | Delivery + CI Maintainability | Fresh workspace review (2026-02-14) | Platform | Extract repeated node/pnpm/install/setup patterns into reusable workflow/composite action while preserving job isolation. Acceptance: parity with current checks and no coverage loss. Proof: PR CI green on all existing jobs after refactor. | -| BL-038 | Evaluate sidecar transport hardening path (loopback HTTP vs stdio) | Proposed | Low | Security + Runtime Architecture | Fresh workspace review (2026-02-14) | Platform + Security | Produce ADR comparing current loopback model with stdio/pipe RPC model, migration cost, and risk reduction; no implementation required in first pass. Acceptance: decision record approved with explicit threat tradeoffs. Proof: decision recorded in `docs/05-governance/decision-log.md`. | +| 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 | Done | Medium | Platform API | Transient FR document (API, archived) | Platform | Implemented via operation type maps and typed invoke signatures across contracts/preload/desktop API (baseline delivered and extended by `BL-025`). | +| 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 inbox (local, untracked) | 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 inbox (local, untracked) | Frontend | Implement documented toast/dialog/inline/offline patterns consistently across features. | +| BL-012 | IPC real-handler contract harness expansion | Done | Medium | Testing + IPC Contracts | Task inbox (local, untracked) | Platform | Delivered via real-handler unauthorized sender integration coverage and preload invoke timeout/correlation tests; superseded by scoped execution under `BL-023`. | +| BL-013 | OIDC auth platform (desktop PKCE + secure IPC) | Planned | High | Security + Identity | Task inbox (local, untracked) | 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 | Done | Medium | Security + Identity | OIDC integration | Platform + Security | Delivered with explicit `local` vs `global` sign-out mode, revocation/end-session capability reporting, and renderer-safe lifecycle messaging. | +| BL-016 | Refactor desktop-main composition root and IPC modularization | Done | High | Desktop Runtime + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; desktop-main composition root split and handler registration modularized. | +| BL-017 | Refactor preload bridge into domain modules with shared invoke client | Done | High | Preload + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; preload segmented into domain APIs with shared invoke/correlation/timeout client. | +| BL-018 | Introduce reusable validated IPC handler factory in desktop-main | Done | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; validated handler factory centralizes sender auth, schema validation, and envelope handling. | +| 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 | Done | Medium | Frontend Architecture | Fresh workspace review (2026-02-13) | Frontend | Delivered by introducing a typed renderer route registry that generates both router entries and shell nav links from one source while preserving production file-replacement exclusions. | +| 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 | Done | Medium | Testing + IPC Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered with real-handler unauthorized sender tests and preload invoke malformed/timeout/failure correlation assertions. | +| 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 | Done | Medium | Platform API + Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered by introducing operation-to-request/response type maps and consuming them in preload/desktop API invoke surfaces. | +| BL-026 | Exclude lab routes/features from production bundle surface | Done | High | Frontend + Security Posture | Sprint implementation (2026-02-13) | Frontend + Platform | Production route/shell config replacement now removes lab routes/nav/toggle from production artifacts to reduce discoverability/attack surface. | +| BL-027 | Provide deterministic bundled update demo patch cycle | Done | Medium | Delivery + Update Architecture | Sprint implementation (2026-02-13) | Platform | Added local bundled feed demo (`1.0.0-demo` -> `1.0.1-demo`) with hash validation and renderer diagnostics to prove end-to-end update model independent of installer updater infra. | +| BL-028 | Enforce robust file signature validation for privileged file ingress | Done | High | Security + File Handling | Python sidecar architecture spike | Platform + Security | Delivered parity for renderer-initiated privileged ingress paths (`fs` text read, `python` PDF inspect, settings JSON imports) with fail-closed extension/signature checks and uniform rejection telemetry (`security.file_ingress_rejected`). Proof: `pnpm nx run desktop-main:test`, manual smoke log verification. | +| BL-029 | Standardize official Python runtime distribution for sidecar bundling | In Progress | High | Delivery + Runtime Determinism | Python sidecar packaging hardening | Platform + Security | `BL-029A` in progress: runtime prep now sources from pinned official artifact catalog (`tools/python-runtime-artifacts.json`) with SHA256 verification and manifest source metadata; remaining scope is CI artifact sourcing hardening and full reproducibility validation (`BL-029B`). | +| BL-030 | Deterministic packaged Python sidecar runtime baseline | Done | High | Delivery + Runtime Determinism | Python sidecar packaging hardening | Platform + Security | Delivered deterministic runtime manifest/assert/sync flow, pinned runtime dependency install (`requirements-runtime.txt`), packaged-build bundled-runtime enforcement (no system fallback), and runtime executable diagnostics proving packaged interpreter path. | +| BL-031 | Refactor desktop-main composition root into focused runtime modules | Proposed | High | Desktop Runtime + Architecture | Fresh workspace review (2026-02-14) | Platform | Split `main.ts` orchestration from lifecycle/app-window/auth-token/python-runtime concerns into focused modules with retained behavior. Acceptance: no behavior regressions in auth/session/update/python flows. Proof: `pnpm nx run desktop-main:test`, `pnpm nx run desktop-main:build`. | +| BL-032 | Standardize IPC handler failure envelope and correlation guarantees | Done | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-14) | Platform | Delivered normalized `IPC/HANDLER_FAILED` behavior in validated handler path with correlation-preserving preload/main integration assertions (real-handler throw path and preload envelope preservation tests). Proof: `pnpm nx run desktop-main:test`, `pnpm nx run desktop-preload:test`. | +| BL-033 | Centralize privileged file ingress policy across all IPC file routes | Done | High | Security + File Handling | Fresh workspace review (2026-02-14) | Platform + Security | Delivered shared ingress policy usage across file, python, and settings import IPC handlers with consistent reject semantics and structured security telemetry. Proof: `pnpm nx run desktop-main:test`, manual smoke evidence for `security.file_ingress_rejected` across channels. | +| BL-034 | Route-driven i18n asset loading manifest for renderer features | Proposed | Medium | Frontend + I18n | Fresh workspace review (2026-02-14) | Frontend | Replace manual translation path list with route/feature manifest-based loading to remove per-feature loader edits. Acceptance: adding a feature locale requires no loader code change. Proof: `pnpm i18n-check`, `pnpm nx run renderer:test`, `pnpm nx run renderer:build`. | +| BL-035 | Replace route/nav literal labels with typed i18n keys | Proposed | Medium | Frontend + I18n | Fresh workspace review (2026-02-14) | Frontend | Update route registry metadata to use translation keys instead of literals and enforce key typing for nav labels/titles. Acceptance: no hardcoded nav labels in route registry. Proof: `pnpm i18n-check`, `pnpm nx run renderer:build`. | +| BL-036 | Promote Python runtime sync to first-class Nx target dependency | Proposed | Medium | Build System + Determinism | Fresh workspace review (2026-02-14) | Platform | Model runtime sync/prepare as explicit Nx targets with `dependsOn` instead of script chaining for cache/task graph correctness. Acceptance: graph reflects runtime-prep dependency for package/build targets. Proof: `pnpm nx graph` (visual verification), `pnpm nx run desktop-main:build`, `pnpm forge:make:staging`. | +| BL-037 | Consolidate duplicated CI setup into reusable workflow primitives | Proposed | Medium | Delivery + CI Maintainability | Fresh workspace review (2026-02-14) | Platform | Extract repeated node/pnpm/install/setup patterns into reusable workflow/composite action while preserving job isolation. Acceptance: parity with current checks and no coverage loss. Proof: PR CI green on all existing jobs after refactor. | +| BL-038 | Evaluate sidecar transport hardening path (loopback HTTP vs stdio) | Proposed | Low | Security + Runtime Architecture | Fresh workspace review (2026-02-14) | Platform + Security | Produce ADR comparing current loopback model with stdio/pipe RPC model, migration cost, and risk reduction; no implementation required in first pass. Acceptance: decision record approved with explicit threat tradeoffs. Proof: decision recorded in `docs/05-governance/decision-log.md`. | +| BL-039 | Replace placeholder CODEOWNERS entries with real maintainers | Planned | High | Governance + Repo Security | Independent refactor review (2026-02-15) | Platform | Update `.github/CODEOWNERS` to concrete users/teams for owned paths. Acceptance: no placeholder handles remain and branch protection-required checks continue passing. Proof: `rg \"@your-org\" .github/CODEOWNERS` returns no matches, PR CI green. | +| BL-040 | Add contributing guide for contributor workflow and guardrails | Proposed | Medium | Developer Experience | Independent refactor review (2026-02-15) | Platform | Add a repository-level contributing guide with setup, Nx task workflow, commit/PR rules, validation matrix, and security checklist expectations. Acceptance: contributor can complete setup and produce a compliant local PR without external guidance. Proof: `pnpm docs-lint`, peer dry-run against checklist. | +| BL-041 | Publish Windows setup guide for repeatable desktop development | Proposed | Medium | Developer Experience + Delivery | Independent refactor review (2026-02-15) | Platform | Add a dedicated engineering Windows setup guide consolidating toolchain requirements and known Windows failure modes (`EBUSY`, keytar/native build, path/line-ending pitfalls). Acceptance: clean workstation setup path documented end-to-end. Proof: `pnpm docs-lint`, validated by fresh-machine walkthrough notes. | +| BL-042 | Define and enforce TypeScript coverage thresholds in CI | Proposed | Medium | Testing + Quality Gates | Independent refactor review (2026-02-15) | Platform | Establish repo-level coverage thresholds for unit/integration suites and enforce in CI without destabilizing existing pipelines. Acceptance: CI fails on threshold regressions and publishes coverage artifacts. Proof: updated test target output plus CI gate pass/fail demonstration in PR evidence. | +| BL-043 | Establish TSDoc/TypeDoc standard for shared contracts and platform API | Proposed | Medium | Documentation + Contracts | Independent refactor review (2026-02-15) | Platform | Define doc standard for public exports, generate API docs for targeted shared surfaces (`libs/shared/contracts`, `libs/platform/desktop-api`) and document upkeep policy. Acceptance: documented standard adopted and generated artifacts reproducible. Proof: docs generation command + `pnpm docs-lint`. | +| BL-044 | Expand release runbook with operator-grade procedural detail | Proposed | Low | Delivery + Operations | Independent refactor review (2026-02-15) | Platform | Enhance release docs with deterministic validation/rollback steps and expected outputs for staging/production package verification. Acceptance: release can be executed by a non-author using only runbook docs. Proof: runbook dry-run record + `pnpm docs-lint`. | +| BL-045 | Centralize typed error-code registry across contracts and handlers | Proposed | Medium | Contracts + Reliability | Independent refactor review (2026-02-15) | Platform | Create shared typed error-code registry and migrate hardcoded handler codes incrementally without breaking wire compatibility. Acceptance: new/modified handlers import from registry and tests assert stable codes. Proof: `pnpm nx run shared-contracts:test`, `pnpm nx run desktop-main:test`. | +| BL-046 | Introduce provider-agnostic external execution gateway abstraction | Proposed | High | Architecture + Security | Architecture discussion (2026-02-15) | Platform + Security | Define execution provider model (`bundled`, `external`) behind a stable main-process IPC gateway so renderer cannot directly target backend runtimes. Acceptance: provider switching does not change renderer contract shape. Proof: `pnpm nx run shared-contracts:test`, `pnpm nx run desktop-main:test`. | +| BL-047 | Add typed operation registry for gateway invocation allowlists | Proposed | High | Contracts + Security | Architecture discussion (2026-02-15) | Platform + Security | Replace ad-hoc command expansion with operation registry entries (`operationId`, request/response schema, timeout, payload limits, provider mapping). Acceptance: unknown operation IDs fail closed with typed envelope. Proof: `pnpm nx run shared-contracts:test`, `pnpm nx run desktop-main:test`. | +| BL-048 | Implement trust-tier policy engine for local and remote providers | Proposed | High | Security + Policy | Architecture discussion (2026-02-15) | Platform + Security | Add trust regimes (for example `local-high`, `local-medium`, `remote-low`) that constrain allowable operations, headers, payload sizes, and retry behavior by provider class. Acceptance: policy violations are blocked and logged with correlation IDs. Proof: `pnpm nx run desktop-main:test`, targeted integration policy tests. | +| BL-049 | Add external provider adapter baseline (Docker-local first) | Proposed | Medium | Runtime Extensibility | Architecture discussion (2026-02-15) | Platform | Implement first external adapter using localhost bridge contract (Docker-hosted service), preserving existing main-process security controls and typed failure envelopes. Acceptance: bundled and external providers pass shared operation compatibility tests. Proof: `pnpm nx run desktop-main:test`, `pnpm runtime:smoke`. | +| BL-050 | Add sensitive-operation capability gating and confirmation controls | Proposed | Medium | Security + UX | Architecture discussion (2026-02-15) | Platform + Frontend | Introduce capability/confirmation gates for high-risk operations (for example local file/system-affecting calls), including deny-by-default policy metadata in operation registry. Acceptance: gated operations require explicit enablement and reject otherwise. Proof: `pnpm nx run desktop-main:test`, `pnpm nx run renderer:test`. | ## Status Definitions diff --git a/CURRENT-SPRINT.md b/docs/05-governance/current-sprint.md similarity index 51% rename from CURRENT-SPRINT.md rename to docs/05-governance/current-sprint.md index 029b522..7646074 100644 --- a/CURRENT-SPRINT.md +++ b/docs/05-governance/current-sprint.md @@ -1,7 +1,9 @@ # Current Sprint -Sprint window: 2026-02-14 onward (Sprint 4) Owner: Platform Engineering + Security +Review cadence: Weekly +Last reviewed: 2026-02-15 +Sprint window: 2026-02-14 onward (Sprint 4) Status: Active ## Sprint Goal @@ -21,6 +23,26 @@ Increase security and runtime determinism in privileged execution paths before a - `BL-034` / `BL-035` i18n architecture enhancements deferred. - `BL-038` sidecar transport ADR deferred unless risk profile changes. +## High-Value Intake From Backlog (Pass 2) + +- `BL-039` Replace placeholder CODEOWNERS entries with real maintainers. + - Rationale: highest governance/security leverage with low implementation risk; directly improves review accountability. + - Fit: can be completed in parallel with current sprint without changing runtime behavior. +- `BL-042` Define and enforce TypeScript coverage thresholds in CI. + - Rationale: prevents silent quality regression after current security/runtime hardening. + - Fit: best taken once `BL-028` / `BL-032` stabilization is complete to avoid noisy threshold churn. +- `BL-031` Refactor desktop-main composition root into focused runtime modules. + - Rationale: high architecture value and aligns with maintainability of privileged runtime paths. + - Fit: should start after current in-scope security/determinism items exit to avoid competing risk in `desktop-main`. + +## Delivery Status (Current Sprint Scope) + +- `BL-033` completed: parity audit for renderer-initiated privileged ingress paths closed and standardized policy telemetry verified. +- `BL-028` completed: fail-closed extension/signature enforcement now consistent across `fs` text read, python PDF inspect, and settings import flows. +- `BL-029` remains in progress (`BL-029A` implemented; `BL-029B` CI reproducibility path still pending completion proof). + - Update (2026-02-15): Windows artifact-publish path now enforces `python-runtime:prepare-local` + `python-runtime:assert` prior to packaging via `forge:make:ci:windows`; GitHub run confirmation pending next push. +- `BL-032` completed: validated handler normalization and preload-main integration assertions now verify `IPC/HANDLER_FAILED` envelope + correlation preservation. + ## Explicitly Completed (Do Not Re-Scope) - `BL-015` Add IdP global sign-out and token revocation flow. @@ -75,7 +97,7 @@ Increase security and runtime determinism in privileged execution paths before a ## Exit Criteria -- `BL-028` and `BL-033` moved to `Done`. +- `BL-028` and `BL-033` moved to `Done` with parity audit closure and shared reject-telemetry proof. - `BL-029` moved to `Done` or `In Progress` with artifact/checksum path merged and CI proof complete. - `BL-032` moved to `Done` with integration test coverage proving envelope consistency. - CI remains green on PR and post-merge paths. @@ -86,3 +108,13 @@ Increase security and runtime determinism in privileged execution paths before a - 2026-02-14: Implemented shared file token consumption and centralized ingress policy (`BL-033` / `BL-028` progress) with new handler tests and successful `desktop:dev:win` verification. - 2026-02-14: Implemented validated handler exception normalization (`BL-032` progress): unexpected sync/async handler failures now return `IPC/HANDLER_FAILED` with correlation IDs and structured logging. - 2026-02-14: Started `BL-029A` implementation: added pinned official Python artifact catalog + SHA256 verification flow in runtime prep/assert scripts, regenerated runtime bundle from official source, and validated `python-runtime:assert` + `build-desktop-main`. +- 2026-02-15: Backlog intake completed from independent refactor review; new governance/quality candidates (`BL-039` to `BL-045`) added to backlog and triaged for sprint-fit. +- 2026-02-15: Implemented centralized ingress rejection telemetry across `fs`, `python`, and `settings import` handlers (`security.file_ingress_rejected`) with correlation IDs and normalized policy/reason details. +- 2026-02-15: Added fail-closed settings import ingress enforcement via shared policy (`settingsJsonImport`, `.json` only) and test coverage for feature/runtime import rejection paths. +- 2026-02-15: Validation completed for this slice: `pnpm nx run desktop-main:test`, `pnpm nx run desktop-preload:build`, plus manual smoke logs confirming uniform reject-event shape across handlers. +- 2026-02-15: Implemented `BL-029B` CI packaging hardening by adding explicit Windows artifact pipeline runtime preparation and assertion (`forge:make:ci:windows` with `PYTHON_RUNTIME_TARGET=win32-x64`) before packaging. +- 2026-02-15: Local validation for `BL-029B` path completed: `pnpm run python-runtime:prepare-local`, `pnpm run python-runtime:assert`, `pnpm run build-desktop-main`, `pnpm docs-lint`. +- 2026-02-15: Closed `BL-032` integration gap by adding real-handler exception propagation assertion in `register-ipc-handlers.spec.ts` and preload invoke preservation assertion for `IPC/HANDLER_FAILED` in `invoke-client.spec.ts`. +- 2026-02-15: Validation completed for `BL-032` closure: `pnpm nx run desktop-main:test`, `pnpm nx run desktop-preload:test`. +- 2026-02-15: Marked `BL-028` and `BL-033` complete after parity audit closure and uniform `security.file_ingress_rejected` smoke verification across `settings`, `fs`, and `python` channels. +- 2026-02-15: Expanded Playwright smoke coverage with stable UI behavior checks (labs toggle/nav visibility persistence, settings route panel navigation, and console-clean navigation path); validated via `pnpm e2e-smoke`. diff --git a/docs/05-governance/oidc-auth-backlog.md b/docs/05-governance/oidc-auth-backlog.md index 9e01394..b772906 100644 --- a/docs/05-governance/oidc-auth-backlog.md +++ b/docs/05-governance/oidc-auth-backlog.md @@ -2,8 +2,8 @@ Owner: Platform Engineering + Security Review cadence: Weekly -Last reviewed: 2026-02-12 -Source: `TASK.md` +Last reviewed: 2026-02-14 +Source: Task inbox (local, untracked) ## Objective @@ -173,19 +173,20 @@ Priority: Medium Tasks: -- Add centralized auth config: - - `OIDC_ISSUER` - - `OIDC_CLIENT_ID` - - `OIDC_REDIRECT_URI` - - `OIDC_SCOPES` - - `OIDC_AUDIENCE` +- Add centralized auth settings and import/export support: + - `issuer` + - `clientId` + - `redirectUri` + - `scopes` + - `audience` + - optional `sendAudienceInAuthorize`, `apiBearerTokenSource`, `allowedSignOutOrigins` - Validate config on startup with explicit failure classification. - Document Clerk baseline and Cognito migration mapping. Acceptance tests: - App fails fast with actionable diagnostics for invalid/missing OIDC config. -- Same code path works with two config fixtures (Clerk-like and Cognito-like metadata). +- Same code path works with two runtime settings fixtures (Clerk-like and Cognito-like metadata). - No code changes required to switch provider in local environment. ## Phase 7: Test + CI Hardening @@ -223,4 +224,4 @@ Acceptance tests: - Removal condition: - Clerk issues access tokens with `aud` including `YOUR_API_AUDIENCE` (and required scopes when available). - AWS authorizer audience list is reduced to API audience only. - - Desktop flow validation passes with `OIDC_API_BEARER_TOKEN_SOURCE=access_token`. + - Desktop flow validation passes with `apiBearerTokenSource=access_token`. diff --git a/docs/docs-index.md b/docs/docs-index.md index e55950b..91998eb 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-13 +Last reviewed: 2026-02-14 ## Start Here @@ -42,6 +42,7 @@ Read these first, in order: | `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/current-sprint.md` | Sprint Plan | Platform Engineering + Security | 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 | diff --git a/examples/config/runtime-config.api.example.json b/examples/config/runtime-config.api.example.json new file mode 100644 index 0000000..e3886cf --- /dev/null +++ b/examples/config/runtime-config.api.example.json @@ -0,0 +1,6 @@ +{ + "secureEndpointUrlTemplate": "https://api.your-domain.example/resources/{{resource_id}}", + "secureEndpointClaimMap": { + "resource_id": "sub" + } +} diff --git a/examples/config/runtime-config.auth.example.json b/examples/config/runtime-config.auth.example.json new file mode 100644 index 0000000..fc0fa5a --- /dev/null +++ b/examples/config/runtime-config.auth.example.json @@ -0,0 +1,10 @@ +{ + "issuer": "https://your-issuer.example.com", + "clientId": "your-client-id", + "redirectUri": "http://127.0.0.1:42813/callback", + "scopes": "openid profile email offline_access", + "audience": "api.your-domain.example", + "sendAudienceInAuthorize": false, + "apiBearerTokenSource": "access_token", + "allowedSignOutOrigins": "https://app.example.com,https://localhost:4200" +} diff --git a/examples/config/runtime-config.full.example.json b/examples/config/runtime-config.full.example.json new file mode 100644 index 0000000..d83115f --- /dev/null +++ b/examples/config/runtime-config.full.example.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "app": {}, + "auth": { + "issuer": "https://your-issuer.example.com", + "clientId": "your-client-id", + "redirectUri": "http://127.0.0.1:42813/callback", + "scopes": "openid profile email offline_access", + "audience": "api.your-domain.example", + "sendAudienceInAuthorize": false, + "apiBearerTokenSource": "access_token", + "allowedSignOutOrigins": "https://app.example.com,https://localhost:4200" + }, + "api": { + "secureEndpointUrlTemplate": "https://api.your-domain.example/resources/{{resource_id}}", + "secureEndpointClaimMap": { + "resource_id": "sub" + } + } +} diff --git a/libs/platform/desktop-api/src/lib/desktop-api.ts b/libs/platform/desktop-api/src/lib/desktop-api.ts index 22120cf..b73dd0e 100644 --- a/libs/platform/desktop-api/src/lib/desktop-api.ts +++ b/libs/platform/desktop-api/src/lib/desktop-api.ts @@ -1,12 +1,22 @@ import type { ApiGetOperationDiagnosticsResponse, + ApiFeatureConfig, ApiOperationId, ApiOperationParamsById, ApiOperationResponseDataById, + AppFeatureConfig, AuthGetTokenDiagnosticsResponse, + AuthFeatureConfig, AuthSessionSummary, ContractVersion, DesktopResult, + RuntimeConfigDocument, + RuntimeConfigFeatureKey, + SettingsExportFeatureConfigResponse, + SettingsExportRuntimeConfigResponse, + SettingsImportFeatureConfigResponse, + SettingsImportRuntimeConfigResponse, + SettingsRuntimeConfigStateResponse, } from '@electron-foundation/contracts'; export interface DesktopAppApi { @@ -172,6 +182,37 @@ export interface DesktopTelemetryApi { ) => Promise>; } +type RuntimeConfigFeatureConfigByKey = { + app: AppFeatureConfig; + auth: AuthFeatureConfig; + api: ApiFeatureConfig; +}; + +export interface DesktopSettingsApi { + getRuntimeConfig: () => Promise< + DesktopResult + >; + saveFeatureConfig: ( + feature: TFeature, + config: RuntimeConfigFeatureConfigByKey[TFeature], + ) => Promise>; + resetFeatureConfig: ( + feature: RuntimeConfigFeatureKey, + ) => Promise>; + importFeatureConfig: ( + feature: RuntimeConfigFeatureKey, + ) => Promise>; + exportFeatureConfig: ( + feature: RuntimeConfigFeatureKey, + ) => Promise>; + importRuntimeConfig: () => Promise< + DesktopResult + >; + exportRuntimeConfig: () => Promise< + DesktopResult + >; +} + export interface DesktopApi { app: DesktopAppApi; auth: DesktopAuthApi; @@ -182,6 +223,7 @@ export interface DesktopApi { updates: DesktopUpdatesApi; python: DesktopPythonApi; telemetry: DesktopTelemetryApi; + settings: DesktopSettingsApi; } export const getDesktopApi = (): DesktopApi | null => { diff --git a/libs/shared/contracts/src/index.ts b/libs/shared/contracts/src/index.ts index 1e777a3..208b2f3 100644 --- a/libs/shared/contracts/src/index.ts +++ b/libs/shared/contracts/src/index.ts @@ -9,6 +9,7 @@ export * from './lib/auth.contract'; export * from './lib/dialog.contract'; export * from './lib/fs.contract'; export * from './lib/python.contract'; +export * from './lib/settings.contract'; export * from './lib/storage.contract'; export * from './lib/telemetry.contract'; export * from './lib/updates.contract'; diff --git a/libs/shared/contracts/src/lib/channels.ts b/libs/shared/contracts/src/lib/channels.ts index feb8347..2969151 100644 --- a/libs/shared/contracts/src/lib/channels.ts +++ b/libs/shared/contracts/src/lib/channels.ts @@ -19,6 +19,13 @@ export const IPC_CHANNELS = { pythonProbe: 'python:probe', pythonInspectPdf: 'python:inspect-pdf', pythonStop: 'python:stop', + settingsGetRuntimeConfig: 'settings:get-runtime-config', + settingsSaveFeatureConfig: 'settings:save-feature-config', + settingsResetFeatureConfig: 'settings:reset-feature-config', + settingsImportFeatureConfig: 'settings:import-feature-config', + settingsExportFeatureConfig: 'settings:export-feature-config', + settingsImportRuntimeConfig: 'settings:import-runtime-config', + settingsExportRuntimeConfig: 'settings:export-runtime-config', telemetryTrack: 'telemetry:track', } as const; diff --git a/libs/shared/contracts/src/lib/contracts.spec.ts b/libs/shared/contracts/src/lib/contracts.spec.ts index 7c902ec..ae1676f 100644 --- a/libs/shared/contracts/src/lib/contracts.spec.ts +++ b/libs/shared/contracts/src/lib/contracts.spec.ts @@ -23,6 +23,11 @@ import { pythonProbeResponseSchema, pythonStopResponseSchema, } from './python.contract'; +import { + settingsImportFeatureConfigResponseSchema, + settingsRuntimeConfigStateResponseSchema, + settingsSaveFeatureConfigRequestSchema, +} from './settings.contract'; describe('parseOrFailure', () => { it('should parse valid values', () => { @@ -367,3 +372,71 @@ describe('python contracts', () => { expect(parsed.success).toBe(true); }); }); + +describe('settings contracts', () => { + it('accepts save auth feature config requests', () => { + const parsed = settingsSaveFeatureConfigRequestSchema.safeParse({ + contractVersion: '1.0.0', + correlationId: 'corr-settings-1', + payload: { + feature: 'auth', + config: { + issuer: 'https://issuer.example.com', + clientId: 'desktop-client', + redirectUri: 'http://127.0.0.1:42813/callback', + scopes: 'openid profile email offline_access', + apiBearerTokenSource: 'access_token', + }, + }, + }); + + expect(parsed.success).toBe(true); + }); + + it('accepts runtime config state responses', () => { + const parsed = settingsRuntimeConfigStateResponseSchema.safeParse({ + sourcePath: + 'C:\\Users\\demo\\AppData\\Roaming\\Angulectron\\config\\runtime-config.json', + exists: true, + config: { + version: 1, + api: { + secureEndpointUrlTemplate: + 'https://api.example.com/resources/{{resource_id}}', + secureEndpointClaimMap: { + resource_id: 'sub', + }, + }, + auth: { + issuer: 'https://issuer.example.com', + clientId: 'desktop-client', + redirectUri: 'http://127.0.0.1:42813/callback', + scopes: 'openid profile email offline_access', + }, + }, + }); + + expect(parsed.success).toBe(true); + }); + + it('accepts import feature responses', () => { + const parsed = settingsImportFeatureConfigResponseSchema.safeParse({ + canceled: false, + imported: true, + feature: 'api', + sourcePath: 'C:\\backup\\runtime-config.api.json', + config: { + version: 1, + api: { + secureEndpointUrlTemplate: + 'https://api.example.com/resources/{{resource_id}}', + secureEndpointClaimMap: { + resource_id: 'sub', + }, + }, + }, + }); + + expect(parsed.success).toBe(true); + }); +}); diff --git a/libs/shared/contracts/src/lib/settings.contract.ts b/libs/shared/contracts/src/lib/settings.contract.ts new file mode 100644 index 0000000..00d9da6 --- /dev/null +++ b/libs/shared/contracts/src/lib/settings.contract.ts @@ -0,0 +1,194 @@ +import { z } from 'zod'; +import { emptyPayloadSchema, requestEnvelope } from './request-envelope'; + +export const runtimeConfigFeatureKeySchema = z.enum(['app', 'auth', 'api']); + +export const appFeatureConfigSchema = z + .object({ + // Legacy fields retained for backward compatibility with early settings JSON. + secureEndpointUrlTemplate: z.string().trim().min(1).optional(), + secureEndpointClaimMap: z.record(z.string(), z.string()).optional(), + }) + .strict(); + +export const apiFeatureConfigSchema = z + .object({ + secureEndpointUrlTemplate: z.string().trim().min(1).optional(), + secureEndpointClaimMap: z.record(z.string(), z.string()).optional(), + }) + .strict(); + +export const authFeatureConfigSchema = z + .object({ + issuer: z.string().trim().min(1).optional(), + clientId: z.string().trim().min(1).optional(), + redirectUri: z.string().trim().min(1).optional(), + scopes: z.string().trim().min(1).optional(), + audience: z.string().trim().min(1).optional(), + sendAudienceInAuthorize: z.boolean().optional(), + apiBearerTokenSource: z.enum(['access_token', 'id_token']).optional(), + allowedSignOutOrigins: z.string().trim().min(1).optional(), + }) + .strict(); + +export const runtimeConfigDocumentSchema = z + .object({ + version: z.literal(1), + app: appFeatureConfigSchema.optional(), + auth: authFeatureConfigSchema.optional(), + api: apiFeatureConfigSchema.optional(), + }) + .strict(); + +const saveFeaturePayloadSchema = z.discriminatedUnion('feature', [ + z + .object({ + feature: z.literal('app'), + config: appFeatureConfigSchema, + }) + .strict(), + z + .object({ + feature: z.literal('auth'), + config: authFeatureConfigSchema, + }) + .strict(), + z + .object({ + feature: z.literal('api'), + config: apiFeatureConfigSchema, + }) + .strict(), +]); + +const resetFeaturePayloadSchema = z + .object({ + feature: runtimeConfigFeatureKeySchema, + }) + .strict(); + +export const settingsGetRuntimeConfigRequestSchema = + requestEnvelope(emptyPayloadSchema); + +export const settingsRuntimeConfigStateResponseSchema = z + .object({ + sourcePath: z.string(), + exists: z.boolean(), + config: runtimeConfigDocumentSchema, + }) + .strict(); + +export const settingsSaveFeatureConfigRequestSchema = requestEnvelope( + saveFeaturePayloadSchema, +); + +export const settingsSaveFeatureConfigResponseSchema = + settingsRuntimeConfigStateResponseSchema; + +export const settingsResetFeatureConfigRequestSchema = requestEnvelope( + resetFeaturePayloadSchema, +); + +export const settingsResetFeatureConfigResponseSchema = + settingsRuntimeConfigStateResponseSchema; + +export const settingsImportFeatureConfigRequestSchema = requestEnvelope( + resetFeaturePayloadSchema, +); + +export const settingsImportFeatureConfigResponseSchema = z + .object({ + canceled: z.boolean(), + imported: z.boolean(), + feature: runtimeConfigFeatureKeySchema, + sourcePath: z.string().optional(), + config: runtimeConfigDocumentSchema.optional(), + }) + .strict(); + +export const settingsExportFeatureConfigRequestSchema = requestEnvelope( + resetFeaturePayloadSchema, +); + +export const settingsExportFeatureConfigResponseSchema = z + .object({ + canceled: z.boolean(), + exported: z.boolean(), + feature: runtimeConfigFeatureKeySchema, + targetPath: z.string().optional(), + }) + .strict(); + +export const settingsImportRuntimeConfigRequestSchema = + requestEnvelope(emptyPayloadSchema); + +export const settingsImportRuntimeConfigResponseSchema = z + .object({ + canceled: z.boolean(), + imported: z.boolean(), + sourcePath: z.string().optional(), + config: runtimeConfigDocumentSchema.optional(), + }) + .strict(); + +export const settingsExportRuntimeConfigRequestSchema = + requestEnvelope(emptyPayloadSchema); + +export const settingsExportRuntimeConfigResponseSchema = z + .object({ + canceled: z.boolean(), + exported: z.boolean(), + targetPath: z.string().optional(), + }) + .strict(); + +export type RuntimeConfigFeatureKey = z.infer< + typeof runtimeConfigFeatureKeySchema +>; +export type AppFeatureConfig = z.infer; +export type AuthFeatureConfig = z.infer; +export type ApiFeatureConfig = z.infer; +export type RuntimeConfigDocument = z.infer; + +export type SettingsGetRuntimeConfigRequest = z.infer< + typeof settingsGetRuntimeConfigRequestSchema +>; +export type SettingsRuntimeConfigStateResponse = z.infer< + typeof settingsRuntimeConfigStateResponseSchema +>; +export type SettingsSaveFeatureConfigRequest = z.infer< + typeof settingsSaveFeatureConfigRequestSchema +>; +export type SettingsSaveFeatureConfigResponse = z.infer< + typeof settingsSaveFeatureConfigResponseSchema +>; +export type SettingsResetFeatureConfigRequest = z.infer< + typeof settingsResetFeatureConfigRequestSchema +>; +export type SettingsResetFeatureConfigResponse = z.infer< + typeof settingsResetFeatureConfigResponseSchema +>; +export type SettingsImportFeatureConfigRequest = z.infer< + typeof settingsImportFeatureConfigRequestSchema +>; +export type SettingsImportFeatureConfigResponse = z.infer< + typeof settingsImportFeatureConfigResponseSchema +>; +export type SettingsExportFeatureConfigRequest = z.infer< + typeof settingsExportFeatureConfigRequestSchema +>; +export type SettingsExportFeatureConfigResponse = z.infer< + typeof settingsExportFeatureConfigResponseSchema +>; +export type SettingsImportRuntimeConfigRequest = z.infer< + typeof settingsImportRuntimeConfigRequestSchema +>; +export type SettingsImportRuntimeConfigResponse = z.infer< + typeof settingsImportRuntimeConfigResponseSchema +>; +export type SettingsExportRuntimeConfigRequest = z.infer< + typeof settingsExportRuntimeConfigRequestSchema +>; +export type SettingsExportRuntimeConfigResponse = z.infer< + typeof settingsExportRuntimeConfigResponseSchema +>; diff --git a/package.json b/package.json index 7809aeb..5721a79 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "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", + "no-dotenv-files": "node ./tools/scripts/no-dotenv-files.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 && pnpm run python-runtime:sync-dist", "build-desktop-preload": "cross-env NX_DAEMON=false TMPDIR=/tmp TMP=/tmp TEMP=/tmp pnpm nx build desktop-preload --skipNxCache", @@ -38,6 +39,7 @@ "forge:clean": "node ./tools/scripts/clean-forge-output.mjs", "forge:start": "pnpm run build-desktop && electron-forge start", "forge:make": "pnpm run forge:clean && pnpm run build && electron-forge make", + "forge:make:ci:windows": "cross-env PYTHON_RUNTIME_TARGET=win32-x64 pnpm run forge:clean && cross-env PYTHON_RUNTIME_TARGET=win32-x64 pnpm run python-runtime:prepare-local && cross-env PYTHON_RUNTIME_TARGET=win32-x64 pnpm run python-runtime:assert && cross-env PYTHON_RUNTIME_TARGET=win32-x64 pnpm run build && cross-env PYTHON_RUNTIME_TARGET=win32-x64 electron-forge make", "python-runtime:prepare-local": "node ./tools/scripts/prepare-local-python-runtime.mjs", "python-runtime:assert": "node ./tools/scripts/assert-python-runtime-bundle.mjs", "python-runtime:sync-dist": "node ./tools/scripts/sync-python-runtime-dist.mjs", diff --git a/runtime-config.env.example b/runtime-config.env.example deleted file mode 100644 index 714ec9a..0000000 --- a/runtime-config.env.example +++ /dev/null @@ -1,10 +0,0 @@ -# Runtime config file for packaged builds. -# Place at: %APPDATA%\Angulectron\config\runtime-config.env - -OIDC_ISSUER=https://your-issuer.example.com -OIDC_CLIENT_ID=your-client-id -OIDC_REDIRECT_URI=http://127.0.0.1:42813/callback -OIDC_SCOPES=openid profile email offline_access -OIDC_AUDIENCE=api.your-domain.example -API_SECURE_ENDPOINT_URL_TEMPLATE=https://api.your-domain.example/users/{{user_id}}/portfolio -API_SECURE_ENDPOINT_CLAIM_MAP={"user_id":"sub"} diff --git a/runtime-config.json.example b/runtime-config.json.example deleted file mode 100644 index cd84de7..0000000 --- a/runtime-config.json.example +++ /dev/null @@ -1,9 +0,0 @@ -{ - "OIDC_ISSUER": "https://your-issuer.example.com", - "OIDC_CLIENT_ID": "your-client-id", - "OIDC_REDIRECT_URI": "http://127.0.0.1:42813/callback", - "OIDC_SCOPES": "openid profile email offline_access", - "OIDC_AUDIENCE": "api.your-domain.example", - "API_SECURE_ENDPOINT_URL_TEMPLATE": "https://api.your-domain.example/users/{{user_id}}/portfolio", - "API_SECURE_ENDPOINT_CLAIM_MAP": "{\"user_id\":\"sub\"}" -} diff --git a/tools/scripts/desktop-dev-win.ps1 b/tools/scripts/desktop-dev-win.ps1 index c032590..cb4fc7d 100644 --- a/tools/scripts/desktop-dev-win.ps1 +++ b/tools/scripts/desktop-dev-win.ps1 @@ -4,44 +4,6 @@ $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") Set-Location $repoRoot -function Import-DotEnvFile { - param( - [Parameter(Mandatory = $true)] - [string]$Path - ) - - if (-not (Test-Path $Path)) { - return - } - - Get-Content -Path $Path | ForEach-Object { - $line = $_.Trim() - if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith('#')) { - return - } - - $match = [regex]::Match($line, '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$') - if (-not $match.Success) { - return - } - - $key = $match.Groups[1].Value - $value = $match.Groups[2].Value.Trim() - - if ( - ($value.StartsWith('"') -and $value.EndsWith('"')) -or - ($value.StartsWith("'") -and $value.EndsWith("'")) - ) { - $value = $value.Substring(1, $value.Length - 2) - } - - [System.Environment]::SetEnvironmentVariable($key, $value, 'Process') - } -} - -Import-DotEnvFile -Path (Join-Path $repoRoot ".env") -Import-DotEnvFile -Path (Join-Path $repoRoot ".env.local") - # Ensure Electron runs in GUI mode. Remove-Item Env:ELECTRON_RUN_AS_NODE -ErrorAction SilentlyContinue diff --git a/tools/scripts/docs-lint.mjs b/tools/scripts/docs-lint.mjs index e9e8271..92e1640 100644 --- a/tools/scripts/docs-lint.mjs +++ b/tools/scripts/docs-lint.mjs @@ -1,4 +1,4 @@ -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { resolve, dirname, relative } from 'node:path'; import { execSync } from 'node:child_process'; @@ -11,21 +11,54 @@ const fail = (message) => { const normalizePath = (value) => value.replaceAll('\\', '/'); -const getDocsFiles = () => { - const output = execSync('rg --files docs -g "*.md"', { - cwd: repoRoot, - encoding: 'utf8', - }).trim(); - - if (!output) { +const walkMarkdownFiles = (dir) => { + const absoluteDir = resolve(repoRoot, dir); + if (!existsSync(absoluteDir)) { return []; } - return output - .split(/\r?\n/) - .map((value) => normalizePath(value.trim())) - .filter(Boolean) - .sort(); + const files = []; + const stack = [absoluteDir]; + + while (stack.length > 0) { + const current = stack.pop(); + const entries = readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = resolve(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + + if (entry.isFile() && entry.name.endsWith('.md')) { + files.push(normalizePath(relative(repoRoot, fullPath))); + } + } + } + + return files.sort(); +}; + +const getDocsFiles = () => { + try { + const output = execSync('rg --files docs -g "*.md"', { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + + if (!output) { + return []; + } + + return output + .split(/\r?\n/) + .map((value) => normalizePath(value.trim())) + .filter(Boolean) + .sort(); + } catch { + return walkMarkdownFiles('docs'); + } }; const readText = (path) => readFileSync(resolve(repoRoot, path), 'utf8'); diff --git a/tools/scripts/no-dotenv-files.mjs b/tools/scripts/no-dotenv-files.mjs new file mode 100644 index 0000000..591eb7c --- /dev/null +++ b/tools/scripts/no-dotenv-files.mjs @@ -0,0 +1,29 @@ +import { execSync } from 'node:child_process'; +import path from 'node:path'; + +const trackedFilesOutput = execSync('git ls-files', { + encoding: 'utf8', +}).trim(); +const trackedFiles = trackedFilesOutput.length + ? trackedFilesOutput.split(/\r?\n/).filter(Boolean) + : []; + +const blockedFiles = trackedFiles.filter((file) => { + const normalized = file.replaceAll('\\', '/'); + const basename = path.posix.basename(normalized); + return ( + basename.startsWith('.env') || + basename === 'runtime-config.env' || + basename === 'runtime-config.env.example' + ); +}); + +if (blockedFiles.length > 0) { + console.error('Found blocked .env-style files in repository:'); + for (const file of blockedFiles) { + console.error(`- ${file}`); + } + process.exit(1); +} + +console.info('no-dotenv-files: OK'); diff --git a/tools/templates/pr-draft.md b/tools/templates/pr-draft.md new file mode 100644 index 0000000..ed71c9a --- /dev/null +++ b/tools/templates/pr-draft.md @@ -0,0 +1,73 @@ +## Summary + +- What changed: + - Added runtime settings management across renderer, preload, and main with feature-scoped and full-config import/export flows. + - Migrated runtime configuration guidance to JSON-based runtime settings and removed `.env`-style tracked workflow. + - Hardened privileged file ingress with centralized policy checks and uniform `security.file_ingress_rejected` telemetry. + - Closed IPC failure-envelope normalization with integration assertions proving `IPC/HANDLER_FAILED` behavior and correlation preservation. + - Hardened Windows artifact packaging path to enforce `python-runtime:prepare-local` + `python-runtime:assert` before Forge packaging. + - Expanded Playwright smoke coverage for key UI behavior paths (labs toggle persistence, settings navigation, console-clean navigation). + - Updated governance docs/backlog/sprint status to reflect delivered Sprint 4 items and added next extensibility/security architecture items. +- Why this change is needed: + - Improve deterministic runtime behavior and security posture at privileged boundaries. + - Reduce configuration drift and simplify user/operator setup for packaged builds. + - Increase regression detection for frontend behavior with non-fragile E2E checks. + - Keep governance artifacts aligned with implementation state. +- Risk level (low/medium/high): + - Medium (touches desktop-main IPC handling, preload invoke behavior, CI packaging workflow, and E2E coverage) + +## Change Groups + +- Runtime Settings + Config: + - Added settings IPC handlers/store integration and renderer settings panels (App/API/Auth). + - Standardized runtime config path/model around JSON runtime document and in-app settings management. +- Security + IPC: + - Added shared ingress policy coverage for settings imports and standardized rejection logging. + - Added integration tests for real-handler throw path and preload preservation of `IPC/HANDLER_FAILED`. +- CI / Packaging: + - Added `forge:make:ci:windows` script and wired artifact publish to explicit runtime prep/assert path. +- Frontend E2E: + - Added Playwright checks for labs toggle/nav behavior persistence and settings panel route behavior. + - Extended no-console-error smoke path beyond initial load. +- Governance: + - Updated backlog and current sprint to mark `BL-028`, `BL-032`, `BL-033` done and capture newly proposed architecture items (`BL-046`–`BL-050`). + +## Validation + +- [x] `pnpm docs-lint` +- [x] `pnpm nx run desktop-main:test` +- [x] `pnpm nx run desktop-preload:test` +- [x] `pnpm nx run desktop-preload:build` +- [x] `pnpm e2e-smoke` +- [x] `pnpm run python-runtime:prepare-local` +- [x] `pnpm run python-runtime:assert` +- [x] `pnpm run build-desktop-main` +- [ ] `pnpm forge:make:staging` (not run locally in this batch) + +## Engineering Checklist + +- [x] Conventional Commit title used +- [x] Unit/integration tests added or updated +- [x] A11y impact reviewed +- [x] I18n impact reviewed +- [x] IPC contract changes documented +- [ ] ADR added/updated for architecture-level decisions + +## Security (Required For Sensitive Changes) + +IMPORTANT: + +- If this PR touches `apps/desktop-main/**`, `apps/desktop-preload/**`, `libs/shared/contracts/**`, `.github/workflows/**`, or `docs/02-architecture/security-architecture.md`, the three items below MUST be checked to pass CI. + +- [x] Security review completed +- [x] Threat model updated or N/A explained +- [x] Confirmed no secrets/sensitive data present in committed files + +### Security Notes + +- Threat model link/update: + - N/A for this increment (no new external trust boundary introduced; hardening is within existing privileged IPC + packaging flow). +- N/A rationale (when no threat model update is needed): + - Changes strengthen fail-closed behavior and observability for existing trust boundaries. + - Runtime packaging enforcement validates pinned artifact provenance before packaging. + - No renderer expansion of privileged capabilities; all operations remain main-process mediated.