From f862ec8ce5da62663c1da8b56d7dee2de974151a Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 12:24:19 +0200 Subject: [PATCH 1/3] feat(platform): establish greenfield process boundaries --- .github/workflows/dashboard-checks.yml | 18 + .oxlintrc.json | 288 ++++++++- .../application-architecture.md | 50 +- .../greenfield-rewrite/progress.md | 80 ++- .../runtime-and-delivery.md | 41 +- docs/generated/README.md | 3 +- docs/generated/configuration.md | 21 + package.json | 8 +- scripts/buildFrontend.ts | 2 +- scripts/checkSourceBoundaries.ts | 155 +++++ scripts/documentation/artifacts.test.ts | 16 + scripts/documentation/artifacts.ts | 3 + .../configurationMarkdown.test.ts | 103 +++ scripts/documentation/markdown.ts | 173 ++++- scripts/frontendBuild.ts | 4 +- .../boundaryConfiguration.test.ts | 128 ++++ .../sourceBoundaries/boundaryConfiguration.ts | 199 ++++++ .../checkerIntegration.test.ts | 467 ++++++++++++++ .../externalAuthorityPolicy.ts | 314 ++++++++++ scripts/sourceBoundaries/importGraph.test.ts | 510 +++++++++++++++ scripts/sourceBoundaries/importGraph.ts | 421 +++++++++++++ .../importTargetValidation.test.ts | 273 ++++++++ .../importTargetValidation.ts | 150 +++++ .../lintConfiguration.test.ts | 124 ++++ scripts/sourceBoundaries/policy.test.ts | 592 ++++++++++++++++++ scripts/sourceBoundaries/policy.ts | 356 +++++++++++ scripts/sourceBoundaries/policyTypes.ts | 7 + .../runtimeAuthorityAnalysis.ts | 6 + .../runtimeCodeAuthorityAnalysis.ts | 556 ++++++++++++++++ .../sourceBoundaries/runtimeOwnerAnalysis.ts | 348 ++++++++++ scripts/sourceBoundaries/sourceAst.ts | 133 ++++ .../sourceBoundaries/sourceBoundaryPaths.ts | 41 ++ scripts/sourceBoundaries/sourceDirectives.ts | 70 +++ .../sourceBoundaries/sourceDiscovery.test.ts | 338 ++++++++++ scripts/sourceBoundaries/sourceDiscovery.ts | 229 +++++++ .../sourceBoundaries/sourceTopologyPolicy.ts | 199 ++++++ src/app/environmentSource.ts | 27 + src/app/server.ts | 156 ++++- src/app/trpcHttpHandler.test.ts | 80 ++- src/app/trpcHttpHandler.ts | 36 +- src/contracts/contractRegistry.test.ts | 31 + src/contracts/contractRegistry.ts | 13 +- src/contracts/registry.ts | 38 +- .../domains/realtime/procedures.test.ts | 14 +- .../authenticationLifecycle.rateLimit.test.ts | 5 + .../security/authenticationWorkGate.test.ts | 9 + .../authenticationWorkGate.webAuthn.test.ts | 10 + .../domains/security/mfa/totpSecretCipher.ts | 9 + .../applicationConfigurationError.ts | 49 ++ .../configurationRegistry.test.ts | 120 ++++ .../configuration/webConfiguration.test.ts | 411 ++++++++++++ .../configuration/webConfiguration.ts | 394 ++++++++++++ .../platform/errors/safeFailure.test.ts | 111 ++++ src/server/platform/errors/safeFailure.ts | 97 +++ .../observability/effectLogger.test.ts | 60 ++ .../platform/observability/effectLogger.ts | 122 ++++ .../observability/structuredLogger.test.ts | 330 ++++++++++ .../observability/structuredLogger.ts | 389 ++++++++++++ .../platform/realtime/eventPumpService.ts | 1 + .../runtime/applicationRuntime.test.ts | 99 ++- .../platform/runtime/applicationRuntime.ts | 11 +- src/server/test/contracts/trpcErrors.test.ts | 102 +-- src/server/test/support/requestContext.ts | 29 + .../system/serverAutomationSecurity.test.ts | 6 +- ...utomationSecurityLeaseInvalidation.test.ts | 2 + .../test/system/serverFoundation.test.ts | 277 +++++++- ...erverGatewayCredentialVerification.test.ts | 2 + src/server/test/system/serverShutdown.test.ts | 49 +- src/server/trpc/appRouter.test.ts | 5 + src/server/trpc/context.test.ts | 4 + src/server/trpc/context.ts | 3 +- src/server/trpc/procedureErrorPolicy.test.ts | 157 +++++ src/server/trpc/procedureErrorPolicy.ts | 286 +++++++++ src/server/trpc/trpc.test.ts | 14 +- src/server/trpc/trpc.ts | 18 +- .../applicationConfigurationRegistry.ts | 336 ++++++++++ src/shared/encoding.test.ts | 2 + src/shared/encoding.ts | 19 +- tsconfig.browser.json | 26 + tsconfig.contracts.json | 14 + tsconfig.json | 6 +- tsconfig.node.json | 1 - tsconfig.scripts.json | 13 + tsconfig.server.json | 17 +- tsconfig.worker.json | 19 + 85 files changed, 10298 insertions(+), 157 deletions(-) create mode 100644 docs/generated/configuration.md create mode 100644 scripts/checkSourceBoundaries.ts create mode 100644 scripts/documentation/configurationMarkdown.test.ts create mode 100644 scripts/sourceBoundaries/boundaryConfiguration.test.ts create mode 100644 scripts/sourceBoundaries/boundaryConfiguration.ts create mode 100644 scripts/sourceBoundaries/checkerIntegration.test.ts create mode 100644 scripts/sourceBoundaries/externalAuthorityPolicy.ts create mode 100644 scripts/sourceBoundaries/importGraph.test.ts create mode 100644 scripts/sourceBoundaries/importGraph.ts create mode 100644 scripts/sourceBoundaries/importTargetValidation.test.ts create mode 100644 scripts/sourceBoundaries/importTargetValidation.ts create mode 100644 scripts/sourceBoundaries/lintConfiguration.test.ts create mode 100644 scripts/sourceBoundaries/policy.test.ts create mode 100644 scripts/sourceBoundaries/policy.ts create mode 100644 scripts/sourceBoundaries/policyTypes.ts create mode 100644 scripts/sourceBoundaries/runtimeAuthorityAnalysis.ts create mode 100644 scripts/sourceBoundaries/runtimeCodeAuthorityAnalysis.ts create mode 100644 scripts/sourceBoundaries/runtimeOwnerAnalysis.ts create mode 100644 scripts/sourceBoundaries/sourceAst.ts create mode 100644 scripts/sourceBoundaries/sourceBoundaryPaths.ts create mode 100644 scripts/sourceBoundaries/sourceDirectives.ts create mode 100644 scripts/sourceBoundaries/sourceDiscovery.test.ts create mode 100644 scripts/sourceBoundaries/sourceDiscovery.ts create mode 100644 scripts/sourceBoundaries/sourceTopologyPolicy.ts create mode 100644 src/app/environmentSource.ts create mode 100644 src/contracts/contractRegistry.test.ts create mode 100644 src/server/platform/configuration/applicationConfigurationError.ts create mode 100644 src/server/platform/configuration/configurationRegistry.test.ts create mode 100644 src/server/platform/configuration/webConfiguration.test.ts create mode 100644 src/server/platform/configuration/webConfiguration.ts create mode 100644 src/server/platform/errors/safeFailure.test.ts create mode 100644 src/server/platform/errors/safeFailure.ts create mode 100644 src/server/platform/observability/effectLogger.test.ts create mode 100644 src/server/platform/observability/effectLogger.ts create mode 100644 src/server/platform/observability/structuredLogger.test.ts create mode 100644 src/server/platform/observability/structuredLogger.ts create mode 100644 src/server/trpc/procedureErrorPolicy.test.ts create mode 100644 src/server/trpc/procedureErrorPolicy.ts create mode 100644 src/shared/configuration/applicationConfigurationRegistry.ts create mode 100644 tsconfig.browser.json create mode 100644 tsconfig.contracts.json create mode 100644 tsconfig.scripts.json create mode 100644 tsconfig.worker.json diff --git a/.github/workflows/dashboard-checks.yml b/.github/workflows/dashboard-checks.yml index 199df293a..ee16cc973 100644 --- a/.github/workflows/dashboard-checks.yml +++ b/.github/workflows/dashboard-checks.yml @@ -31,12 +31,30 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Check source boundaries + run: bun run check:boundaries + + - name: Test source-boundary checker + run: bun run test:boundaries + + - name: Type-check browser source + run: bun run typecheck:browser + + - name: Type-check contracts and shared source + run: bun run typecheck:contracts + - name: Type-check qualification probes run: bun run typecheck:qualification + - name: Type-check repository scripts + run: bun run typecheck:scripts + - name: Type-check server source run: bun run typecheck:server + - name: Type-check worker source + run: bun run typecheck:worker + - name: Test qualification probes run: bun run test:qualification diff --git a/.oxlintrc.json b/.oxlintrc.json index 7c8d981b7..a29ae892a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -112,20 +112,72 @@ "backend/**/*.ts", "frontend/src/test/**/*.{ts,tsx}", "qualification/**/*.ts", - "scripts/**/*.ts", - "src/app/**/*.ts", - "src/server/**/*.ts", - "*.config.{js,mjs,cjs,ts}" + "scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/app/dashboardServer.ts", + "src/app/environmentSource.ts", + "src/app/server.ts", + "src/app/trpcHttpHandler.ts", + "src/app/trpcRequestPolicy.ts", + "src/app/worker.ts", + "src/server/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "*.config.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" ], "globals": { "Bun": "readonly" } }, + { + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**", + "src/app/environmentSource.ts" + ], + "files": [ + "src/app/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/server/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "rules": { + "no-restricted-properties": [ + "error", + { + "message": "Read the process environment only through the typed environment source.", + "object": "process", + "property": "env" + }, + { + "message": "Read the process environment only through the typed environment source.", + "object": "Bun", + "property": "env" + }, + { + "message": "Read the process environment only through the typed environment source.", + "object": "Deno", + "property": "env" + } + ] + } + }, { "env": { "browser": true }, - "files": ["frontend/src/**/*.{js,jsx,ts,tsx}"], + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**" + ], + "files": [ + "frontend/src/**/*.{js,jsx,ts,tsx}", + "src/app/browser.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/browser/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], "jsPlugins": ["oxlint-tailwindcss"], "rules": { "no-restricted-imports": [ @@ -161,7 +213,231 @@ } }, { - "files": ["backend/src/**/*.ts", "src/app/**/*.ts", "src/server/**/*.ts"], + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**" + ], + "files": [ + "src/contracts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/shared/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "rules": { + "no-restricted-globals": [ + "error", + { + "checkGlobalObject": true, + "globals": [ + "Bun", + "Buffer", + "Deno", + "document", + "navigator", + "process", + "window" + ] + } + ], + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/app/**", + "**/browser/**", + "**/qualification/**", + "**/scripts/**", + "**/server/**", + "**/worker/**", + "bun", + "bun:*", + "node:*" + ], + "message": "Contracts and shared source must remain environment-neutral." + } + ] + } + ] + } + }, + { + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**" + ], + "files": [ + "src/app/browser.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/browser/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "rules": { + "no-restricted-globals": ["error", "Bun", "Buffer", "process"], + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "importNames": ["memo", "useCallback", "useMemo"], + "message": "React Compiler owns routine memoization; keep explicit memoization out of application code.", + "name": "react" + } + ], + "patterns": [ + { + "group": [ + "**/app/**", + "**/qualification/**", + "**/scripts/**", + "**/server/**", + "**/worker/**", + "@simplewebauthn/server", + "@simplewebauthn/server/**", + "@trpc/server", + "@trpc/server/**", + "bun", + "bun:*", + "drizzle-orm", + "drizzle-orm/**", + "node:*" + ], + "message": "Browser source may import only browser, contract, and environment-neutral shared modules." + } + ] + } + ] + } + }, + { + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**" + ], + "files": ["src/server/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/app/**", + "**/browser/**", + "**/qualification/**", + "**/scripts/**", + "**/worker/**" + ], + "message": "Server source may import only server, contract, and environment-neutral shared modules." + } + ] + } + ] + } + }, + { + "files": [ + "src/app/dashboardServer.ts", + "src/app/server.ts", + "src/app/trpcHttpHandler.ts", + "src/app/trpcRequestPolicy.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/browser/**", + "**/qualification/**", + "**/scripts/**", + "**/worker/**" + ], + "message": "The web composition root may not import browser, worker, qualification, or script source." + } + ] + } + ] + } + }, + { + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**" + ], + "files": [ + "src/app/worker.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/app/**", + "**/browser/**", + "**/qualification/**", + "**/scripts/**", + "**/server/**" + ], + "message": "Worker source may import only worker, contract, and environment-neutral shared modules." + } + ] + } + ] + } + }, + { + "excludeFiles": [ + "**/*.spec.*", + "**/*.test.*", + "**/__tests__/**", + "**/test/**", + "**/testSupport/**" + ], + "files": [ + "*.config.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/qualification/**", + "**/src/app/**", + "**/src/browser/**", + "**/src/server/**", + "**/src/worker/**" + ], + "message": "Repository scripts may import only script, contract, and environment-neutral shared source." + } + ] + } + ] + } + }, + { + "files": [ + "backend/src/**/*.ts", + "src/app/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/server/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" + ], "rules": { "no-console": "error" } diff --git a/docs/architecture/greenfield-rewrite/application-architecture.md b/docs/architecture/greenfield-rewrite/application-architecture.md index a9c361455..c51877b88 100644 --- a/docs/architecture/greenfield-rewrite/application-architecture.md +++ b/docs/architecture/greenfield-rewrite/application-architecture.md @@ -176,11 +176,20 @@ Architectural dependency rules: imported only by Drizzle Kit or a database composition root. Domain modules import tables and validators directly. -The current server typecheck covers the complete `src/app`, `src/contracts`, `src/server`, and -`src/shared` graph, while Oxlint already enforces targeted restricted imports in the browser. -Focused composition and contract tests enforce selected runtime boundaries. Complete path-based -restricted-import rules (or equivalent project-reference partitions) remain a required cutover gate; -this document does not claim that every rule above is mechanically enforced before that gate lands. +The rewrite now has separate strict TypeScript graphs for contracts/shared, browser, server, +worker, and scripts. An authoritative Babel-AST policy check discovers JavaScript, JSX, ESM/CJS, +and TypeScript extension variants across `src`, repository scripts, and the reviewed root +configurations for Drizzle and Tailwind. It permits `.tsx` only in the strict browser graph and +`.ts` in every other scanned role, rejects unknown root executables and top-level source +directories, and requires reviewed relative extensions resolving to exact contained targets. The +same binding-aware analysis classifies every composition root, enforces the dependency directions +above, and rejects nonliteral production loads, unreviewed module schemes and aliases, runtime +environment escape paths, code/module loaders, and process-execution authorities outside their +explicit roles. Source-tree symlinks are prohibited and the temporary script edges into the legacy +tree are frozen exactly. Fast Oxlint restrictions provide earlier feedback for supported import +and global patterns; the AST check is the path-aware policy gate for the source surfaces it +explicitly scans, not a replacement for runtime sandboxing. The browser and worker graphs are ready +for their composition roots, which are not yet implemented. ## Application API @@ -342,8 +351,17 @@ the richer runtime type adds no value. ### Errors and context -The raw tRPC HTTP handler creates the request ID and resolves direct-client provenance against the -exact trusted-proxy allowlist before context construction. `createContext` then authenticates the +The Bun `fetch` boundary creates one request ID before URL routing so application-handled health, +readiness, not-found, tRPC, raw rejection, and sanitized defect responses share the same +correlation header. Every dispatch records exactly one outcome event: `http.response.created` for +a returned response, `http.request.failed` for a sanitized defect response, or +`http.request.cancelled` for client cancellation. For SSE the response-created event marks +successful dispatch, not stream termination; close/cancel/error observability remains part of the +browser/realtime lifecycle slice. Client cancellation is informational and carries neither a +failure fingerprint nor a server-error outcome. Bun's outer 64 KiB pre-dispatch body ceiling +remains a transport safeguard and may reject before application correlation exists. The raw tRPC +handler receives the generated ID and resolves direct-client provenance against the exact +trusted-proxy allowlist before context construction. `createContext` then authenticates the already parsed session or automation credential and establishes identity plus audit correlation once. Reusable procedure builders are limited to: @@ -354,17 +372,23 @@ once. Reusable procedure builders are limited to: Expected errors use a small stable code set such as `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`, `NOT_FOUND`, `PRECONDITION_FAILED`, `TOO_MANY_REQUESTS`, and `SERVICE_UNAVAILABLE` with safe -structured details. Stack traces, command output, filesystem paths, and upstream secrets never -enter client error shapes. +structured details. The `ContractErrorCode` union, all 36 actual router paths, the server-owned +runtime allowlist, and generated contract metadata must match exactly. The base procedure +middleware enforces that allowlist for immediate and deferred subscription failures; an +implemented procedure missing from the policy or an undeclared code becomes a redacted internal +defect. Framework-owned routing and input/transport validation remain implicit. Stack traces, +command output, filesystem paths, and upstream secrets never enter client error shapes. Server orchestration represents expected failures as tagged Effect errors in the typed error channel. The tRPC boundary exhaustively maps those internal tags to the stable client code set; unknown defects and internal `cause` values may be logged only through a redaction boundary and are -never serialized to clients. Until that logger bridge exists, the boundary records only safe, -constant failure markers. +never serialized to clients. One caller-supplied process logger is installed as the only logger on +the existing `ManagedRuntime` and exposed by `ApplicationRuntime` to ordinary TypeScript +boundaries. Event-specific allowlists drop unknown fields and Effect messages/annotations; runtime +disposal precedes the logger's idempotent flush. -The web `ApplicationRuntime` merges the realtime pump and one process-scoped authentication-work -service into the same `ManagedRuntime`. That authentication service owns separate bounded admission +The web `ApplicationRuntime` merges the structured logger, realtime pump, and one process-scoped +authentication-work service into the same `ManagedRuntime`. That authentication service owns separate bounded admission and active-work semaphores for Gateway verification, password/Argon2 work, TOTP AES/HMAC work, and WebAuthn parsing/signature verification, plus a scoped fiber set for work that outlives an interrupted caller. Queued cancellation releases admission immediately; active non-cooperative diff --git a/docs/architecture/greenfield-rewrite/progress.md b/docs/architecture/greenfield-rewrite/progress.md index 25ebeb4d3..e277f547c 100644 --- a/docs/architecture/greenfield-rewrite/progress.md +++ b/docs/architecture/greenfield-rewrite/progress.md @@ -7,15 +7,15 @@ This matrix is the living phase status. Update it in the same change that materially advances or closes a phase; dated entries below provide the evidence, not a second status source. -| Phase | Status | Current evidence and remaining gate | -| ----------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`, including build, transport, database/outbox, browser data, chat batching, shutdown, parity, OpenClaw source audit, and capped resource evidence. | -| 1 — Foundation | In progress | Server composition, migrations, contracts, raw HTTP policy, realtime outbox, and the current generated-doc subset exist; browser/worker roots, complete import enforcement, complete generated references, and release/rollback closure remain. | -| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | -| 3 — Core operator domains | Started | Monitoring transaction/schema foundations exist; task, agent, report, incident, notification, schedule/job, cache/metrics procedures and browser parity are not complete. | -| 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. | -| 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. | -| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | +| Phase | Status | Current evidence and remaining gate | +| ----------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`, including build, transport, database/outbox, browser data, chat batching, shutdown, parity, OpenClaw source audit, and capped resource evidence. | +| 1 — Foundation | In progress | Server composition, migrations, contracts, raw HTTP/realtime foundations, source-boundary enforcement, staged typed configuration, generated configuration reference, structured logging/request correlation, and procedure error policy exist; executable web/worker roots, database runtime, browser shell, complete generated references, and release/rollback closure remain. | +| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | +| 3 — Core operator domains | Started | Monitoring transaction/schema foundations exist; task, agent, report, incident, notification, schedule/job, cache/metrics procedures and browser parity are not complete. | +| 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. | +| 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. | +| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | ### 2026-08-03 — Phase 0 started @@ -367,9 +367,9 @@ closes a phase; dated entries below provide the evidence, not a second status so process-runtime reuse, abort propagation, and subscription cleanup. - No transport or utility dependency was added. Browser realtime continues to use tRPC SSE rather than Socket.IO; OpenClaw Gateway continues to use Bun's native outbound WebSocket. Authentication - retains revocable opaque credential validators rather than JWTs, configuration uses Bun plus - composition-root Valibot parsing rather than `dotenv`, and HTTP calls use tRPC/native `fetch` - rather than Axios. + retains revocable opaque credential validators rather than JWTs, configuration is staged as an + injected Valibot parser rather than `dotenv`, and HTTP calls use tRPC/native `fetch` rather than + Axios. Composition-root configuration wiring remains Phase 1 work. ### 2026-08-05 — Security core and fresh database baseline implemented @@ -649,6 +649,56 @@ closes a phase; dated entries below provide the evidence, not a second status so | Child-process cancel | 117,194,752 | 1,531 | 24 | - Phase 0 is complete, but the rewrite is not: Phase 1 remains in progress with browser/worker - roots, complete import enforcement, complete generated references, immutable release/rollback, - and end-to-end empty-database web/worker delivery still open. Final production load, restore, - cutover, and legacy-removal evidence remains in Phase 6. + roots, complete generated references, immutable release/rollback, and end-to-end empty-database + web/worker delivery still open. Final production load, restore, cutover, and legacy-removal + evidence remains in Phase 6. + +### 2026-08-06 — Source-boundary enforcement foundation + +- A Babel-AST gate now accounts for static imports, type imports, re-exports, literal and + nonliteral dynamic imports, and `require` calls. It discovers JavaScript/JSX, ESM/CJS, and + TypeScript extension variants across `src`, repository scripts, and the reviewed Drizzle and + Tailwind root configurations. It permits `.tsx` only in the strict browser graph and otherwise + fails closed unless source is `.ts`; unknown root executables and top-level source directories + also fail. Relative specifiers require an explicit reviewed extension and an exact contained + target. Production source additionally fails on unclassified process roots, repository aliases, + unreviewed URL schemes, repository escapes, source-tree symlinks, test imports, forbidden + cross-process directions, and binding-aware environment, module-loader, code-evaluation, or + process-execution authority outside its explicit role. This is source-policy enforcement rather + than a runtime sandbox. Coexistence scripts retain only their reviewed authorities and explicit + environment reads. +- The only script imports into the legacy backend/frontend are frozen as an exact 18-edge + coexistence allowlist. New legacy edges fail CI. +- Strict TypeScript graphs now isolate contracts/shared, browser, server, worker, and scripts. + Supported Oxlint restricted-import/global rules provide a fast guard, while the AST checker is + authoritative. The server-foundation job runs both checker tests and every greenfield typecheck. + +### 2026-08-06 — Typed configuration, errors, and observability boundary + +- One immutable registry now owns the 13 accepted web/worker environment names, value policy, + process roles, defaults, secret/browser exposure, restart semantics, and generated reference + text. The app-owned environment source projects only registered keys for the selected role; + server modules cannot import it or read runtime environment aliases directly. The injected + Valibot parser produces a deeply frozen web configuration with exact origin, trusted-proxy, + loopback Gateway, WebAuthn, duration, path, log-level, and redacted TOTP-keyring policy. A real + executable process root and realpath validation remain Phase 1 delivery work. +- `docs/generated/configuration.md` is derived from the same registry and fails closed on missing + metadata, duplicate fields, or a secret marked value-visible. Rejected values and Valibot issues + never enter configuration errors, inspection, JSON, logs, or documentation. +- The existing process `ManagedRuntime` now requires and installs exactly one structured logger, + and the HTTP/tRPC boundaries reuse that exact instance. Runtime-allowlisted event/component/ + field/outcome/correlation data produces bounded NDJSON; unknown messages and fields are dropped + or normalized, failures retain only coarse tags plus a bounded fingerprint, and sink faults emit + one constant stderr fallback. Sink writes and flushes must settle synchronously, and runtime + disposal precedes the idempotent flush. +- The Bun request boundary creates correlation before application routing. Application responses + receive `x-request-id`, and each dispatch emits exactly one response-created, sanitized-defect, + or client-cancellation event. Cancellation carries no defect fingerprint. SSE termination + observability remains assigned to the later realtime/browser lifecycle rather than being + overstated here. Bun's outer pre-dispatch body ceiling remains the documented exception. +- The actual 36-procedure router, public contract metadata, and a server-owned + `ContractErrorCode` allowlist now match mechanically. Immediate and deferred subscription errors + outside a route's declared set are internalized, as is any implemented procedure missing from + the policy; framework routing and input/transport validation remain implicit. Phase 1 is still + in progress: executable web/worker roots, database runtime, worker lifecycle, browser shell, and + release/rollback delivery remain open. diff --git a/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index 842816337..33daf8b47 100644 --- a/docs/architecture/greenfield-rewrite/runtime-and-delivery.md +++ b/docs/architecture/greenfield-rewrite/runtime-and-delivery.md @@ -142,8 +142,13 @@ build path: releases contain prebuilt assets and production never compiles the f ## Configuration From Scratch -Configuration is parsed once at each composition root through a Valibot schema. There are no -scattered `process.env` reads and no truthy-string parsing. Every field declares: +The greenfield web configuration parser accepts only its registered-key projection. The future +web and worker composition roots must invoke their role-specific parser exactly once; that startup +wiring is not implemented by this slice. App, server, and worker source has no scattered +runtime-environment reads and no truthy-string parsing. Existing repository scripts remain a +separately typed coexistence surface and retain their explicit environment reads until their target +composition flows replace them. +Every registered field declares: - name, type, allowed values, and default; - required process (`web`, `worker`, build, or script); @@ -157,13 +162,19 @@ non-secret settings, and encrypted secrets. A setting is not duplicated across e database with implicit precedence. If bootstrap requires a temporary precedence rule, it is explicitly modeled as a bootstrap state and disappears after completion. +The first web parser currently validates an injected registered-key projection. Its project-root +field is only a lexically normalized absolute staging value: the future process composition must +resolve its real path and enforce the managed-filesystem containment policy before opening host +paths. Startup wiring and that filesystem validation are not claimed by this slice. + The target repository uses a base TypeScript configuration plus strict browser, server/worker, and script project references so browser libraries are unavailable to server code and Bun/filesystem -types are unavailable to browser code. The current rewrite has a complete strict server graph and -selected restricted-import/composition tests, but it does not yet have every target project -reference or path boundary. Completing and mechanically enforcing those partitions remains a -cutover gate. `bunfig.toml` contains only shared Bun test and selected serve-plugin configuration; -operational policy lives in typed source, not hidden shell environment. +types are unavailable to browser code. The rewrite now has strict contracts/shared, browser, +server, worker, and script graphs plus an authoritative path-aware source-boundary check. Oxlint +also rejects supported import/global patterns as a fast feedback layer. Browser and worker +composition roots remain unimplemented, but adding an unclassified `src/app` root or a forbidden +edge fails the boundary gate. `bunfig.toml` contains only shared Bun test and selected serve-plugin +configuration; operational policy lives in typed source, not hidden shell environment. ## Generated Documentation @@ -176,7 +187,7 @@ Documentation generation is a product feature and a CI invariant, not an optiona | Procedure registry | tRPC names, kinds, auth/capabilities, input/output schemas, errors, examples, emitted events | | Raw HTTP registry | methods, paths, auth, content types, range/stream behavior, status codes | | Event registry | topic, event type, entity/operation, payload schema, retention, snapshot/resync procedure | -| Valibot config schema | environment/settings names, types, defaults, secret flags, process ownership | +| Application config registry | environment/settings names, types, defaults, secret flags, process ownership | | Drizzle schema | intended tables, columns, types, relations, constraints, and declared indexes | | Applied temporary SQLite schema | tables, columns, checks, foreign keys, indexes, partial predicates | | Browser route registry | URL, navigation label, feature owner, query/search schema, required procedures | @@ -246,6 +257,14 @@ Every request, job, Gateway call, and domain transaction receives a correlation logs use stable event names and include release identity, process role, duration, outcome, and safe identifiers. They do not serialize arbitrary request bodies or command environments. +The current web factory contract requires one process logger, installs it as the only Effect +logger on the existing `ManagedRuntime`, and reuses that exact instance at ordinary HTTP/tRPC +boundaries. Its serializer emits bounded NDJSON from event-specific allowlisted fields and flushes +the synchronous sink after runtime disposal; a sink failure emits one constant direct-stderr +fallback without recursive logging. The future executable web/worker composition roots still own creation of +the stdout/stderr sink, release/config identity, and startup/shutdown events; this slice does not +claim that absent process entrypoint. + Expose distinct probes: - **live:** the process event loop can answer; @@ -308,8 +327,10 @@ Additional safeguards: The exact naming may change. This is the **target** Bun command-role inventory, not a claim that the current `package.json` already exposes every alias. Today the rewrite uses separate strict -`typecheck:server` and `typecheck:qualification` graphs plus the existing frontend/backend lanes; -the complete project-reference partitions remain the future cutover gate described above. +browser, contracts/shared, server, worker, scripts, and qualification typecheck commands plus the +existing frontend/backend lanes. `check:boundaries` and `test:boundaries` are required by the +server-foundation CI lane. A single top-level target `typecheck` alias remains future command +consolidation, not missing boundary enforcement. ```text dev local Bun server + worker + frontend development diff --git a/docs/generated/README.md b/docs/generated/README.md index 5c82c01fa..c770d7cf3 100644 --- a/docs/generated/README.md +++ b/docs/generated/README.md @@ -7,9 +7,10 @@ - [tRPC procedures](procedures.md) - [Raw HTTP routes](raw-http.md) - [Realtime events](realtime-events.md) +- [Application configuration](configuration.md) - [Packages and runtime](packages-and-runtime.md) - [Transport schemas](schemas/) ## Required Before Cutover -The target generator must also emit database, configuration, and browser route/feature references plus OpenAPI 3.1 for true raw HTTP endpoints. The generated browser documentation route must render the complete checked-in set. These artifacts are future gates, not current generated outputs. +The target generator must also emit database and browser route/feature references plus OpenAPI 3.1 for true raw HTTP endpoints. The generated browser documentation route must render the complete checked-in set. These artifacts are future gates, not current generated outputs. diff --git a/docs/generated/configuration.md b/docs/generated/configuration.md new file mode 100644 index 000000000..40b6f15bb --- /dev/null +++ b/docs/generated/configuration.md @@ -0,0 +1,21 @@ +# Application Configuration + +> Generated by `bun run docs:generate`. Do not edit manually. + +Configuration metadata is generated from the immutable application registry. For secret fields, values, enumerated values, and defaults are never rendered. + +| Environment | Typed field | Type / enumerated values | Validation constraints | Default behavior | Process roles | Secret | Browser exposure | Operational effect | Restart | Development/test overrides | Description | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `MIRA_DASHBOARD_LOG_LEVEL` | `logLevel` | `log-level`; `debug`, `error`, `info`, `warn` | Exactly one enumerated structured-log level. | `info` | `web`, `worker`, `script` | No | Value | Changes structured diagnostic verbosity. | Required | Development and test | Minimum structured application log severity. | +| `MIRA_DASHBOARD_PROJECT_ROOT` | `projectRoot` | `absolute-path` | Non-root normalized absolute path, at most 4096 code units; realpath validation is staged for startup. | Required | `web`, `worker`, `build`, `script` | No | None | Selects the stable development, production-state, runtime, release, preview, and worktree hierarchy; it is not a checkout path. | Required | Development and test | Lexically normalized absolute Dashboard host-layout root; startup must resolve and validate its real directory before deriving managed paths. | +| `MIRA_DASHBOARD_PUBLIC_ORIGIN` | `publicOrigin` | `http-origin` | Canonical HTTP(S) origin at most 2048 code units; HTTPS is required in production. | Required | `web` | No | Value | Defines the browser trust boundary behind the reverse proxy. | Required | Development and test | Canonical browser origin used for cookies and request-origin checks. | +| `MIRA_DASHBOARD_RECENT_AUTH_MINUTES` | `recentAuthenticationWindowMs` | `duration-minutes` | Canonical whole minutes from 1 through 60. | `10` | `web` | No | Value | Controls step-up freshness for sensitive account operations. | Required | Development and test | Recent password or MFA verification window in whole minutes. | +| `MIRA_DASHBOARD_SESSION_IDLE_MINUTES` | `sessionIdleDurationMs` | `duration-minutes` | Canonical whole minutes from 5 through 1440. | `30` | `web` | No | Value | Controls when inactive browser sessions expire. | Required | Development and test | Browser-session idle lifetime in whole minutes. | +| `MIRA_DASHBOARD_TOTP_KEYRING` | `totpKeyring` | `json-secret`; values withheld | Version 1 JSON with one to eight unique AES-256 keys and one active key, at most 4096 code units. | Required; value withheld | `web` | Yes | Presence only | Selects active and retained TOTP encryption keys. | Required | Development and test | Versioned AES-256-GCM keyring for persisted TOTP secrets. | +| `MIRA_DASHBOARD_TRUSTED_PROXY_IPS` | `trustedProxyAddresses` | `ip-address-list` | Zero to 32 unique canonical IP addresses, comma-separated, at most 2048 code units. | Empty by default | `web` | No | None | Allows overwritten forwarding headers only from exact peers. | Required | Development and test | Canonical comma-separated proxy peer IP allowlist. | +| `MIRA_DASHBOARD_WEBAUTHN_ORIGINS` | `webAuthnRelyingParty.allowedOrigins` | `http-origin-list` | 1 to 8 unique canonical browser origins, comma-separated, at most 16384 code units. | Required | `web` | No | Value | Restricts WebAuthn ceremonies to reviewed HTTPS origins. | Required | Development and test | Canonical comma-separated WebAuthn browser-origin allowlist. | +| `MIRA_DASHBOARD_WEBAUTHN_RP_ID` | `webAuthnRelyingParty.rpId` | `domain-name` | Lowercase canonical domain name at most 253 code units. | Required | `web` | No | Value | Binds every WebAuthn credential and ceremony to one RP ID. | Required | Development and test | Stable WebAuthn relying-party domain identifier. | +| `MIRA_DASHBOARD_WEBAUTHN_RP_NAME` | `webAuthnRelyingParty.rpName` | `relying-party-name` | Trimmed NFC text without control characters, at most 128 code units. | `Mira Dashboard` | `web` | No | Value | Changes the relying-party label in registration ceremonies. | Required | Development and test | Human-readable relying-party name shown by authenticators. | +| `NODE_ENV` | `nodeEnvironment` | `environment-mode`; `development`, `production`, `test` | Exactly one enumerated runtime mode. | `production` | `web`, `worker`, `build`, `script` | No | Value | Controls production-only security and diagnostic behavior. | Required | Development and test | Runtime mode used for fail-closed production trust policy. | +| `OPENCLAW_GATEWAY_URL` | `gatewayUrl` | `websocket-url` | Canonical direct-loopback WebSocket URL at most 2048 code units. | `ws://127.0.0.1:18789` | `web` | No | None | Selects the one-shot native Gateway verification endpoint. | Required | Development and test | Direct-loopback OpenClaw Gateway endpoint for bootstrap verification. | +| `PORT` | `port` | `tcp-port` | Canonical decimal integer from 1 through 65535. | `3100` | `web` | No | None | Changes the local listener endpoint used by the reverse proxy. | Required | Development and test | Loopback HTTP listener port for the greenfield web process. | diff --git a/package.json b/package.json index c3cf3450d..16e30b69a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build": "bun run build:frontend && bun run build:backend", "build:frontend": "bun node_modules/typescript/bin/tsc -p tsconfig.app.json --noEmit && bun scripts/buildFrontend.ts", "build:backend": "bun node_modules/typescript/bin/tsc -p tsconfig.node.json --noEmit && bun scripts/buildBackend.ts", + "check:boundaries": "bun scripts/checkSourceBoundaries.ts", "deploy:bootstrap": "bash scripts/bootstrapProduction.sh", "deploy:prepare": "bun run build:frontend && bun run deploy:prepare:backend && bun run release:manifest", "deploy:prepare:backend": "bun run build:backend && bun --cwd backend dist/databasePreflight.js", @@ -39,12 +40,17 @@ "test:backend": "bun test --cwd backend --config ../bunfig.toml --preload ./test/setup.ts test", "test:backend:changed": "bun test --cwd backend --config ../bunfig.toml --preload ./test/setup.ts --changed test", "test:backend:coverage": "bun scripts/runCoverage.ts backend 85 src/", + "test:boundaries": "bun test scripts/sourceBoundaries", "test:server": "bun test src/app src/server src/shared src/contracts", "test:server:docs": "bun test scripts/documentation", "test:server:tooling": "bun test scripts/checkDatabaseSchema.test.ts", "test:qualification": "bun test qualification", + "typecheck:browser": "bun node_modules/typescript/bin/tsc -p tsconfig.browser.json --noEmit", + "typecheck:contracts": "bun node_modules/typescript/bin/tsc -p tsconfig.contracts.json --noEmit", + "typecheck:qualification": "bun node_modules/typescript/bin/tsc -p tsconfig.qualification.json --noEmit", + "typecheck:scripts": "bun node_modules/typescript/bin/tsc -p tsconfig.scripts.json --noEmit", "typecheck:server": "bun node_modules/typescript/bin/tsc -p tsconfig.server.json --noEmit", - "typecheck:qualification": "bun node_modules/typescript/bin/tsc -p tsconfig.qualification.json --noEmit" + "typecheck:worker": "bun node_modules/typescript/bin/tsc -p tsconfig.worker.json --noEmit" }, "dependencies": { "@daypicker/react": "10.0.1", diff --git a/scripts/buildFrontend.ts b/scripts/buildFrontend.ts index db6c7cac0..6da4c9998 100644 --- a/scripts/buildFrontend.ts +++ b/scripts/buildFrontend.ts @@ -1,3 +1,3 @@ -import { buildFrontend } from "./frontendBuild"; +import { buildFrontend } from "./frontendBuild.ts"; await buildFrontend({ mode: "production" }); diff --git a/scripts/checkSourceBoundaries.ts b/scripts/checkSourceBoundaries.ts new file mode 100644 index 000000000..f34aaf3d9 --- /dev/null +++ b/scripts/checkSourceBoundaries.ts @@ -0,0 +1,155 @@ +import path from "node:path"; + +import { readBoundaryConfiguration } from "./sourceBoundaries/boundaryConfiguration.ts"; +import { parseSourceAnalysis } from "./sourceBoundaries/importGraph.ts"; +import { + validateExactRelativeImportTarget, + validateLegacyAllowlistTarget, +} from "./sourceBoundaries/importTargetValidation.ts"; +import { + legacyScriptImportAllowlist, + legacyScriptImportKey, + type SourceBoundaryViolation, + validateDeclaredPackageImport, + validateSourceAmbientRuntimeDeclaration, + validateSourceEnvironmentAccess, + validateSourceFile, + validateSourceImport, + validateSourceReferenceDirective, + validateSourceRuntimeAuthorityEscape, + validateSourceTypeScriptSuppressionDirective, +} from "./sourceBoundaries/policy.ts"; +import { discoverSourceFiles } from "./sourceBoundaries/sourceDiscovery.ts"; + +/** + * Scans all greenfield and script source against the explicit process-boundary policy. + * @param projectRoot Absolute repository root. + * @returns Sorted actionable violations. + */ +export async function checkSourceBoundaries( + projectRoot: string +): Promise { + const discovery = await discoverSourceFiles(projectRoot); + const configuration = await readBoundaryConfiguration(projectRoot); + const violations: SourceBoundaryViolation[] = [ + ...discovery.violations, + ...configuration.violations, + ]; + const observedLegacyScriptImports = new Set(); + for (const importer of discovery.files) { + const fileViolation = validateSourceFile(importer); + if (fileViolation !== undefined) violations.push(fileViolation); + + const analysis = await parseSourceAnalysis( + await Bun.file(path.join(projectRoot, importer)).text(), + importer + ); + for (const declaration of analysis.ambientRuntimeDeclarations) { + const declarationViolation = validateSourceAmbientRuntimeDeclaration( + importer, + declaration.line + ); + if (declarationViolation !== undefined) { + violations.push(declarationViolation); + } + } + for (const referenceDirective of analysis.referenceDirectives) { + violations.push( + validateSourceReferenceDirective(importer, referenceDirective.line) + ); + } + for (const runtimeAuthorityEscape of analysis.runtimeAuthorityEscapes) { + const escapeViolation = validateSourceRuntimeAuthorityEscape( + importer, + runtimeAuthorityEscape.line + ); + if (escapeViolation !== undefined) violations.push(escapeViolation); + } + for (const suppression of analysis.typeScriptSuppressionDirectives) { + const suppressionViolation = validateSourceTypeScriptSuppressionDirective( + importer, + suppression.line + ); + if (suppressionViolation !== undefined) { + violations.push(suppressionViolation); + } + } + for (const environmentAccess of analysis.environmentAccesses) { + const environmentViolation = validateSourceEnvironmentAccess( + importer, + environmentAccess.line + ); + if (environmentViolation !== undefined) { + violations.push(environmentViolation); + } + } + for (const sourceImport of analysis.imports) { + const legacyImportKey = legacyScriptImportKey(importer, sourceImport); + if ( + legacyImportKey !== undefined && + legacyScriptImportAllowlist.has(legacyImportKey) + ) { + observedLegacyScriptImports.add(legacyImportKey); + const legacyTargetViolation = await validateLegacyAllowlistTarget( + projectRoot, + legacyImportKey + ); + if (legacyTargetViolation !== undefined) { + violations.push(legacyTargetViolation); + } + } + const importViolation = validateSourceImport(importer, sourceImport); + if (importViolation === undefined) { + const exactTargetViolation = await validateExactRelativeImportTarget( + projectRoot, + importer, + sourceImport + ); + if (exactTargetViolation !== undefined) { + violations.push(exactTargetViolation); + } + } else { + violations.push(importViolation); + } + const packageViolation = validateDeclaredPackageImport( + importer, + sourceImport, + configuration.declaredPackageNames + ); + if (packageViolation !== undefined) violations.push(packageViolation); + } + } + for (const allowlistedImport of legacyScriptImportAllowlist) { + if (observedLegacyScriptImports.has(allowlistedImport)) continue; + const separatorIndex = allowlistedImport.indexOf("\0"); + violations.push({ + importer: allowlistedImport.slice(0, separatorIndex), + line: 1, + message: "Legacy script allowlist entry is stale or no longer imported", + specifier: allowlistedImport.slice(separatorIndex + 1), + }); + } + return violations.toSorted( + (left, right) => + left.importer.localeCompare(right.importer) || left.line - right.line + ); +} + +function renderViolation(violation: SourceBoundaryViolation): string { + const specifier = + violation.specifier === undefined ? "" : ` (${violation.specifier})`; + return `${violation.importer}:${violation.line}: ${violation.message}${specifier}`; +} + +async function main(): Promise { + const projectRoot = path.resolve(import.meta.dir, ".."); + const violations = await checkSourceBoundaries(projectRoot); + if (violations.length > 0) { + throw new Error( + `Source-boundary check failed:\n${violations.map((current) => renderViolation(current)).join("\n")}` + ); + } + console.log("Source boundaries: ok"); +} + +if (import.meta.main) await main(); diff --git a/scripts/documentation/artifacts.test.ts b/scripts/documentation/artifacts.test.ts index 068461187..3e47dbb4c 100644 --- a/scripts/documentation/artifacts.test.ts +++ b/scripts/documentation/artifacts.test.ts @@ -28,6 +28,22 @@ describe("generated contract documentation", () => { expect([...first]).toEqual([...second]); expect(first.get("README.md")).toContain("[tRPC procedures](procedures.md)"); + expect(first.get("README.md")).toContain( + "[Application configuration](configuration.md)" + ); + expect(first.get("README.md")).not.toContain( + "database, configuration, and browser" + ); + const configurationDocumentation = first.get("configuration.md"); + expect(configurationDocumentation).toContain( + "| Environment | Typed field | Type / enumerated values | Validation constraints | Default behavior | Process roles | Secret | Browser exposure | Operational effect | Restart | Development/test overrides | Description |" + ); + expect(configurationDocumentation).toContain( + "| `MIRA_DASHBOARD_LOG_LEVEL` | `logLevel` | `log-level`; `debug`, `error`, `info`, `warn` | Exactly one enumerated structured-log level. | `info` | `web`, `worker`, `script` | No | Value |" + ); + expect(configurationDocumentation).toContain( + "| `MIRA_DASHBOARD_TOTP_KEYRING` | `totpKeyring` | `json-secret`; values withheld | Version 1 JSON with one to eight unique AES-256 keys and one active key, at most 4096 code units. | Required; value withheld | `web` | Yes | Presence only |" + ); const procedureDocumentation = first.get("procedures.md"); expect(procedureDocumentation).toContain("`auth.bootstrap`"); expect(procedureDocumentation).toContain("`auth.changePassword`"); diff --git a/scripts/documentation/artifacts.ts b/scripts/documentation/artifacts.ts index 801458ea4..48225e17e 100644 --- a/scripts/documentation/artifacts.ts +++ b/scripts/documentation/artifacts.ts @@ -5,9 +5,11 @@ import { } from "../../src/contracts/contractRegistry.ts"; import type { ContractSchema } from "../../src/contracts/registry.ts"; import { bunRuntimePolicy } from "../../src/shared/bunRuntimePolicy.ts"; +import { applicationConfigurationRegistry } from "../../src/shared/configuration/applicationConfigurationRegistry.ts"; import { convertContractSchema, type SchemaTypeMode } from "./jsonSchema.ts"; import { type PackageDocumentationInput, + renderConfiguration, renderGeneratedIndex, renderPackagesAndRuntime, renderProcedures, @@ -87,6 +89,7 @@ export function buildDocumentationArtifacts( }; const artifacts = new Map([ ["README.md", renderGeneratedIndex()], + ["configuration.md", renderConfiguration(applicationConfigurationRegistry)], ["packages-and-runtime.md", renderPackagesAndRuntime(packageInput)], ["procedures.md", renderProcedures(procedureContracts)], ["raw-http.md", renderRawHttp(rawHttpContracts)], diff --git a/scripts/documentation/configurationMarkdown.test.ts b/scripts/documentation/configurationMarkdown.test.ts new file mode 100644 index 000000000..516b626ee --- /dev/null +++ b/scripts/documentation/configurationMarkdown.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; + +import { type ConfigurationDocumentationInput, renderConfiguration } from "./markdown.ts"; + +const completeEntry = { + allowedValues: ["alpha", "beta"], + browserExposure: "none", + defaultValue: "alpha", + description: "Selects the documented behavior.", + environmentName: "MIRA_EXAMPLE", + field: "example", + operationalEffect: "Changes the documented behavior.", + overridePolicy: { + development: true, + test: false, + }, + restartRequired: true, + roles: ["web"], + secret: false, + validationConstraints: "One of the documented examples.", + valueType: "example-type", +} satisfies ConfigurationDocumentationInput; + +describe("application configuration Markdown", () => { + test("renders complete metadata without mutating the registry", () => { + const registry = Object.freeze([ + Object.freeze({ + ...completeEntry, + allowedValues: Object.freeze([...completeEntry.allowedValues]), + overridePolicy: Object.freeze({ ...completeEntry.overridePolicy }), + roles: Object.freeze([...completeEntry.roles]), + }), + ]); + + const documentation = renderConfiguration(registry); + + expect(documentation).toContain( + "| `MIRA_EXAMPLE` | `example` | `example-type`; `alpha`, `beta` | One of the documented examples. | `alpha` | `web` | No | None |" + ); + expect(documentation).toContain("| Required | Development only |"); + expect(Object.isFrozen(registry[0])).toBe(true); + }); + + test("never renders secret allowed values or defaults", () => { + const secretValue = "sentinel-secret-default"; + const documentation = renderConfiguration([ + { + ...completeEntry, + allowedValues: ["sentinel-secret-choice"], + browserExposure: "presence-only", + defaultValue: secretValue, + secret: true, + }, + ]); + + expect(documentation).toContain("`example-type`; values withheld"); + expect(documentation).toContain("Default value withheld"); + expect(documentation).not.toContain(secretValue); + expect(documentation).not.toContain("sentinel-secret-choice"); + }); + + test("fails closed when required metadata is missing", () => { + const requiredFields = [ + "allowedValues", + "browserExposure", + "defaultValue", + "description", + "environmentName", + "field", + "operationalEffect", + "overridePolicy", + "restartRequired", + "roles", + "secret", + "validationConstraints", + "valueType", + ] as const; + + for (const field of requiredFields) { + const incomplete = { ...completeEntry } as Record; + delete incomplete[field]; + expect(() => + renderConfiguration([ + incomplete as unknown as ConfigurationDocumentationInput, + ]) + ).toThrow(); + } + }); + + test("rejects browser value exposure for secrets", () => { + expect(() => + renderConfiguration([ + { + ...completeEntry, + browserExposure: "value", + secret: true, + }, + ]) + ).toThrow( + "Secret application configuration metadata permits browser value exposure" + ); + }); +}); diff --git a/scripts/documentation/markdown.ts b/scripts/documentation/markdown.ts index 3bc9c6787..20326304b 100644 --- a/scripts/documentation/markdown.ts +++ b/scripts/documentation/markdown.ts @@ -61,7 +61,178 @@ function errorReasonsLabel(contract: ProcedureContract): string { * @returns Generated Markdown index. */ export function renderGeneratedIndex(): string { - return `${documentHeader("Generated Dashboard Reference", "bun run docs:generate")}## Current Generated Subset\n\n- [tRPC procedures](procedures.md)\n- [Raw HTTP routes](raw-http.md)\n- [Realtime events](realtime-events.md)\n- [Packages and runtime](packages-and-runtime.md)\n- [Transport schemas](schemas/)\n\n## Required Before Cutover\n\nThe target generator must also emit database, configuration, and browser route/feature references plus OpenAPI 3.1 for true raw HTTP endpoints. The generated browser documentation route must render the complete checked-in set. These artifacts are future gates, not current generated outputs.\n`; + return `${documentHeader("Generated Dashboard Reference", "bun run docs:generate")}## Current Generated Subset\n\n- [tRPC procedures](procedures.md)\n- [Raw HTTP routes](raw-http.md)\n- [Realtime events](realtime-events.md)\n- [Application configuration](configuration.md)\n- [Packages and runtime](packages-and-runtime.md)\n- [Transport schemas](schemas/)\n\n## Required Before Cutover\n\nThe target generator must also emit database and browser route/feature references plus OpenAPI 3.1 for true raw HTTP endpoints. The generated browser documentation route must render the complete checked-in set. These artifacts are future gates, not current generated outputs.\n`; +} + +/** Registry metadata required to render one application configuration field. */ +export interface ConfigurationDocumentationInput { + readonly allowedValues: readonly string[] | null; + readonly browserExposure: "none" | "presence-only" | "value"; + readonly defaultValue: string | null; + readonly description: string; + readonly environmentName: string; + readonly field: string; + readonly operationalEffect: string; + readonly overridePolicy: { + readonly development: boolean; + readonly test: boolean; + }; + readonly restartRequired: boolean; + readonly roles: readonly string[]; + readonly secret: boolean; + readonly validationConstraints: string; + readonly valueType: string; +} + +function markdownTableCell(value: string): string { + const backslash = String.fromCodePoint(92); + return value + .replaceAll(backslash, String.raw`\\`) + .replaceAll("|", String.raw`\|`) + .replaceAll(/\r?\n/gu, " "); +} + +function assertNonEmptyString(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Application configuration metadata is missing ${label}`); + } +} + +function assertConfigurationMetadata( + entry: ConfigurationDocumentationInput, + index: number +): void { + const prefix = `entry ${index}`; + assertNonEmptyString(entry.environmentName, `${prefix}.environmentName`); + assertNonEmptyString(entry.field, `${prefix}.field`); + assertNonEmptyString(entry.valueType, `${prefix}.valueType`); + assertNonEmptyString(entry.description, `${prefix}.description`); + assertNonEmptyString(entry.operationalEffect, `${prefix}.operationalEffect`); + assertNonEmptyString(entry.validationConstraints, `${prefix}.validationConstraints`); + if (entry.defaultValue !== null && typeof entry.defaultValue !== "string") { + throw new Error( + `Application configuration metadata is missing ${prefix}.defaultValue` + ); + } + if ( + entry.allowedValues !== null && + (!Array.isArray(entry.allowedValues) || + entry.allowedValues.length === 0 || + entry.allowedValues.some( + (allowedValue) => + typeof allowedValue !== "string" || allowedValue.trim().length === 0 + )) + ) { + throw new Error( + `Application configuration metadata is missing ${prefix}.allowedValues` + ); + } + if ( + !Array.isArray(entry.roles) || + entry.roles.length === 0 || + entry.roles.some((role) => typeof role !== "string" || role.trim().length === 0) + ) { + throw new Error(`Application configuration metadata is missing ${prefix}.roles`); + } + if ( + entry.browserExposure !== "none" && + entry.browserExposure !== "presence-only" && + entry.browserExposure !== "value" + ) { + throw new Error( + `Application configuration metadata is missing ${prefix}.browserExposure` + ); + } + if ( + typeof entry.secret !== "boolean" || + typeof entry.restartRequired !== "boolean" || + typeof entry.overridePolicy !== "object" || + entry.overridePolicy === null || + typeof entry.overridePolicy.development !== "boolean" || + typeof entry.overridePolicy.test !== "boolean" + ) { + throw new Error(`Application configuration metadata is incomplete at ${prefix}`); + } + if (entry.secret && entry.browserExposure === "value") { + throw new Error( + `Secret application configuration metadata permits browser value exposure at ${prefix}` + ); + } +} + +function allowedValuesLabel(entry: ConfigurationDocumentationInput): string { + if (entry.secret) return `\`${entry.valueType}\`; values withheld`; + if (entry.allowedValues === null) return `\`${entry.valueType}\``; + const allowedValues = entry.allowedValues + .map((value) => `\`${markdownTableCell(value)}\``) + .join(", "); + return `\`${entry.valueType}\`; ${allowedValues}`; +} + +function defaultBehaviorLabel(entry: ConfigurationDocumentationInput): string { + if (entry.secret) { + return entry.defaultValue === null + ? "Required; value withheld" + : "Default value withheld"; + } + if (entry.defaultValue === null) return "Required"; + if (entry.defaultValue.length === 0) return "Empty by default"; + return `\`${markdownTableCell(entry.defaultValue)}\``; +} + +function browserExposureLabel( + exposure: ConfigurationDocumentationInput["browserExposure"] +): string { + const labels = { + none: "None", + "presence-only": "Presence only", + value: "Value", + } as const; + return labels[exposure]; +} + +function overridePolicyLabel( + policy: ConfigurationDocumentationInput["overridePolicy"] +): string { + if (policy.development && policy.test) return "Development and test"; + if (policy.development) return "Development only"; + if (policy.test) return "Test only"; + return "None"; +} + +/** + * Renders immutable application configuration metadata as Markdown. + * @param registry Authoritative application configuration registry. + * @returns Generated Markdown document with secret values omitted. + */ +export function renderConfiguration( + registry: readonly ConfigurationDocumentationInput[] +): string { + if (registry.length === 0) { + throw new Error("Application configuration registry is empty"); + } + + const environmentNames = new Set(); + const fields = new Set(); + for (const [index, entry] of registry.entries()) { + assertConfigurationMetadata(entry, index); + if (environmentNames.has(entry.environmentName) || fields.has(entry.field)) { + throw new Error("Application configuration registry contains duplicates"); + } + environmentNames.add(entry.environmentName); + fields.add(entry.field); + } + + const rows = registry + .toSorted((left, right) => + left.environmentName.localeCompare(right.environmentName) + ) + .map( + (entry) => + `| \`${markdownTableCell(entry.environmentName)}\` | \`${markdownTableCell(entry.field)}\` | ${allowedValuesLabel(entry)} | ${markdownTableCell(entry.validationConstraints)} | ${defaultBehaviorLabel(entry)} | ${entry.roles.map((role) => `\`${markdownTableCell(role)}\``).join(", ")} | ${entry.secret ? "Yes" : "No"} | ${browserExposureLabel(entry.browserExposure)} | ${markdownTableCell(entry.operationalEffect)} | ${entry.restartRequired ? "Required" : "Not required"} | ${overridePolicyLabel(entry.overridePolicy)} | ${markdownTableCell(entry.description)} |` + ); + + return `${documentHeader("Application Configuration", "bun run docs:generate")}Configuration metadata is generated from the immutable application registry. For secret fields, values, enumerated values, and defaults are never rendered.\n\n| Environment | Typed field | Type / enumerated values | Validation constraints | Default behavior | Process roles | Secret | Browser exposure | Operational effect | Restart | Development/test overrides | Description |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n${rows.join("\n")}\n`; } /** diff --git a/scripts/frontendBuild.ts b/scripts/frontendBuild.ts index bfc3a625f..fa4736839 100644 --- a/scripts/frontendBuild.ts +++ b/scripts/frontendBuild.ts @@ -13,8 +13,8 @@ import { measureFrontendBundle, writeFrontendHtmlAppEntrypoint, writePrecompressedFrontendAssets, -} from "./frontendBuildArtifacts"; -import reactCompilerPlugin from "./reactCompilerPlugin"; +} from "./frontendBuildArtifacts.ts"; +import reactCompilerPlugin from "./reactCompilerPlugin.ts"; type FrontendBuildMode = "development" | "production"; diff --git a/scripts/sourceBoundaries/boundaryConfiguration.test.ts b/scripts/sourceBoundaries/boundaryConfiguration.test.ts new file mode 100644 index 000000000..844155804 --- /dev/null +++ b/scripts/sourceBoundaries/boundaryConfiguration.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { checkSourceBoundaries } from "../checkSourceBoundaries.ts"; + +async function temporaryProject(): Promise { + const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-source-boundary-")); + await mkdir(path.join(projectRoot, "scripts")); + await mkdir(path.join(projectRoot, "src", "browser"), { recursive: true }); + await writeFile(path.join(projectRoot, "package.json"), "{}"); + return projectRoot; +} + +describe("source-boundary root configuration", () => { + test("rejects root aliases and undeclared bare package imports", async () => { + const projectRoot = await temporaryProject(); + try { + await writeFile( + path.join(projectRoot, "package.json"), + JSON.stringify({ + dependencies: { "internal-alias": "file:./src/server" }, + imports: { "#server": "./src/server/index.ts" }, + }) + ); + await writeFile( + path.join(projectRoot, "tsconfig.json"), + JSON.stringify({ compilerOptions: { paths: { "~/*": ["src/*"] } } }) + ); + await mkdir(path.join(projectRoot, "config")); + await writeFile( + path.join(projectRoot, "config", "base.json"), + JSON.stringify({ compilerOptions: { paths: { "hidden/*": ["src/*"] } } }) + ); + await writeFile( + path.join(projectRoot, "tsconfig.server.json"), + JSON.stringify({ extends: "./config/base.json" }) + ); + await writeFile( + path.join(projectRoot, "src", "browser", "undeclared.ts"), + 'import value from "unreviewed-alias"; void value;' + ); + + const violations = await checkSourceBoundaries(projectRoot); + + expect( + violations.some( + (violation) => + violation.importer === "package.json" && + violation.message.includes("package-import aliases") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "package.json" && + violation.message.includes("dependency aliases") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "tsconfig.json" && + violation.message.includes("baseUrl and paths aliases") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "tsconfig.server.json" && + violation.message.includes("exact reviewed ./tsconfig.json") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "src/browser/undeclared.ts" && + violation.message.includes("declared by the root manifest") + ) + ).toBe(true); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects root package browser mappings, exports, and workspace linkage", async () => { + const projectRoot = await temporaryProject(); + try { + await writeFile( + path.join(projectRoot, "package.json"), + JSON.stringify({ + browser: { + "./src/browser/reviewed.ts": "./src/server/private.ts", + }, + exports: { ".": "./src/server/private.ts" }, + workspaces: ["packages/*"], + }) + ); + + const violations = await checkSourceBoundaries(projectRoot); + + expect( + violations.some( + (violation) => + violation.importer === "package.json" && + violation.message.includes("Root package browser mappings") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "package.json" && + violation.message.includes("Root package exports") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "package.json" && + violation.message.includes("Root package workspaces") + ) + ).toBe(true); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); +}); diff --git a/scripts/sourceBoundaries/boundaryConfiguration.ts b/scripts/sourceBoundaries/boundaryConfiguration.ts new file mode 100644 index 000000000..e3d43d6d8 --- /dev/null +++ b/scripts/sourceBoundaries/boundaryConfiguration.ts @@ -0,0 +1,199 @@ +import { lstat, readdir, realpath } from "node:fs/promises"; +import path from "node:path"; + +import type { SourceBoundaryViolation } from "./policyTypes.ts"; +import { boundaryPathViolation, isContainedPath } from "./sourceBoundaryPaths.ts"; + +/** Root dependency names and configuration findings used by boundary validation. */ +export interface BoundaryConfiguration { + readonly declaredPackageNames: ReadonlySet; + readonly violations: readonly SourceBoundaryViolation[]; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null; +} + +async function readRootJson( + projectRoot: string, + relativePath: string, + violations: SourceBoundaryViolation[] +): Promise> | undefined> { + const absolutePath = path.join(projectRoot, relativePath); + const status = await lstat(absolutePath); + if (status.isSymbolicLink() || !status.isFile()) { + violations.push( + boundaryPathViolation( + relativePath, + "Boundary configuration must be a regular non-symbolic-link file" + ) + ); + return undefined; + } + const realProjectRoot = await realpath(projectRoot); + if (!isContainedPath(realProjectRoot, await realpath(absolutePath))) { + violations.push( + boundaryPathViolation( + relativePath, + "Boundary configuration real path escapes the repository" + ) + ); + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(await Bun.file(absolutePath).text()) as unknown; + } catch { + violations.push( + boundaryPathViolation( + relativePath, + "Boundary configuration must be valid JSON" + ) + ); + return undefined; + } + if (!isRecord(parsed)) { + violations.push( + boundaryPathViolation( + relativePath, + "Boundary configuration must be an object" + ) + ); + return undefined; + } + return parsed; +} + +function dependencyNames( + packageManifest: Readonly> +): ReadonlySet { + const names = new Set(); + for (const field of [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ] as const) { + const dependencies = packageManifest[field]; + if (!isRecord(dependencies)) continue; + for (const name of Object.keys(dependencies)) names.add(name); + } + return names; +} + +/** + * Reads and validates the root package and TypeScript resolver configuration. + * @param projectRoot Absolute repository root. + * @returns Declared package names and configuration findings. + */ +export async function readBoundaryConfiguration( + projectRoot: string +): Promise { + const lexicalProjectRoot = path.resolve(projectRoot); + const violations: SourceBoundaryViolation[] = []; + const packageManifest = await readRootJson( + lexicalProjectRoot, + "package.json", + violations + ); + if (packageManifest?.imports !== undefined) { + violations.push( + boundaryPathViolation( + "package.json", + "Root package-import aliases are forbidden by the source-boundary policy" + ) + ); + } + if (packageManifest?.browser !== undefined) { + violations.push( + boundaryPathViolation( + "package.json", + "Root package browser mappings are forbidden until an exact source-boundary policy is reviewed" + ) + ); + } + if (packageManifest?.exports !== undefined) { + violations.push( + boundaryPathViolation( + "package.json", + "Root package exports are forbidden until an exact source-boundary policy is reviewed" + ) + ); + } + if (packageManifest?.workspaces !== undefined) { + violations.push( + boundaryPathViolation( + "package.json", + "Root package workspaces are forbidden until an exact source-boundary policy is reviewed" + ) + ); + } + if (packageManifest !== undefined) { + for (const field of [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ] as const) { + const dependencies = packageManifest[field]; + if (!isRecord(dependencies)) continue; + if ( + Object.values(dependencies).some( + (value) => + typeof value === "string" && + (/^(?:file|link|workspace):/u.test(value) || + value.startsWith("./") || + value.startsWith("../")) + ) + ) { + violations.push( + boundaryPathViolation( + "package.json", + "Local and workspace dependency aliases are forbidden by the source-boundary policy" + ) + ); + break; + } + } + } + + const rootEntries = await readdir(lexicalProjectRoot, { withFileTypes: true }); + const tsconfigNames = rootEntries + .filter( + (entry) => entry.name.startsWith("tsconfig") && entry.name.endsWith(".json") + ) + .map((entry) => entry.name) + .toSorted(); + for (const tsconfigName of tsconfigNames) { + const tsconfig = await readRootJson(lexicalProjectRoot, tsconfigName, violations); + if (tsconfig === undefined) continue; + const compilerOptions = tsconfig.compilerOptions; + if ( + isRecord(compilerOptions) && + (compilerOptions.baseUrl !== undefined || compilerOptions.paths !== undefined) + ) { + violations.push( + boundaryPathViolation( + tsconfigName, + "TypeScript baseUrl and paths aliases are forbidden by the source-boundary policy" + ) + ); + } + if ( + tsconfig.extends !== undefined && + (tsconfigName === "tsconfig.json" || tsconfig.extends !== "./tsconfig.json") + ) { + violations.push( + boundaryPathViolation( + tsconfigName, + "Root TypeScript partitions may extend only the exact reviewed ./tsconfig.json configuration" + ) + ); + } + } + return { + declaredPackageNames: + packageManifest === undefined ? new Set() : dependencyNames(packageManifest), + violations, + }; +} diff --git a/scripts/sourceBoundaries/checkerIntegration.test.ts b/scripts/sourceBoundaries/checkerIntegration.test.ts new file mode 100644 index 000000000..4b2d5659e --- /dev/null +++ b/scripts/sourceBoundaries/checkerIntegration.test.ts @@ -0,0 +1,467 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { checkSourceBoundaries } from "../checkSourceBoundaries.ts"; + +async function temporaryProject(): Promise { + const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-source-boundary-")); + await mkdir(path.join(projectRoot, "scripts")); + await mkdir(path.join(projectRoot, "src", "browser"), { recursive: true }); + await writeFile(path.join(projectRoot, "package.json"), "{}"); + return projectRoot; +} + +describe("source-boundary checker integration", () => { + test("rejects triple-slash lib, types, and path authority directives", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "src", "shared")); + await writeFile( + path.join(projectRoot, "src", "shared", "authority.ts"), + `/// + /// + /// + export const network = globalThis.fetch;` + ); + + const violations = await checkSourceBoundaries(projectRoot); + const directiveViolations = violations.filter( + (violation) => + violation.importer === "src/shared/authority.ts" && + violation.message.includes("Triple-slash reference directives") + ); + + expect(directiveViolations.map(({ line }) => line)).toEqual([1, 2, 3]); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects indirect loaders, dynamic code, and runtime-root aliases", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "src", "server")); + await writeFile( + path.join(projectRoot, "src", "browser", "loader.ts"), + `import { createRequire as makeLoader } from "node:module"; + const load = makeLoader(import.meta.url); + const metaLoad = import.meta.require("../server/private.ts"); + const moduleLoad = module.require("../server/private.ts"); + void [load, metaLoad, moduleLoad];` + ); + await writeFile( + path.join(projectRoot, "src", "server", "authority.ts"), + `const root = globalThis; + const secret = root.process.env.SECRET; + const reflected = Reflect.get(globalThis, "process"); + const processOwner = process; + const bunOwner = Bun; + const denoOwner = Deno; + const metaOwner = import.meta; + const generated = Function("return import(name)"); + void [secret, reflected, processOwner, bunOwner, denoOwner, metaOwner, generated];` + ); + await writeFile( + path.join(projectRoot, "src", "browser", "computedLoader.ts"), + `const constructorKey = "con" + "structor"; + const execute = (() => {})[constructorKey as "constructor"]; + execute("return process.env.SECRET")(); + const requireKey = "requ" + "ire"; + const loaded = module[requireKey]("../server/private.ts"); + const reflected = Reflect.get(() => {}, ["con", "structor"].join(""))("return process.env.SECRET"); + const moduleAlias = module; + const unknownLoad = module[unknownKey]("../server/private.ts"); + void [loaded, reflected, moduleAlias, unknownLoad];` + ); + await writeFile( + path.join(projectRoot, "src", "server", "nativeLoader.ts"), + `process.binding("fs"); + process["_linked" + "Binding"]("fs"); + process.dlopen(nativeModule, filename); + Bun.plugin(plugin); + const bindingKey = "bin" + "ding"; + const escapedBinding = globalThis.process[bindingKey]; + const ffi = Bun["F" + "FI"]; + module["_com" + "pile"](source, filename); + void [escapedBinding, ffi];` + ); + await writeFile( + path.join(projectRoot, "src", "browser", "workerLoader.ts"), + `new Worker("../worker/entry.ts"); + const WorkerAlias = Worker; + new globalThis["Wor" + "ker"]("../worker/global.ts"); + new SharedWorker("../worker/shared.ts"); + importScripts("./bootstrap.ts"); + void WorkerAlias;` + ); + + const violations = await checkSourceBoundaries(projectRoot); + + expect( + violations.some( + (violation) => + violation.importer === "src/browser/loader.ts" && + violation.specifier === "node:module" && + violation.message.includes("dynamic module-loader") + ) + ).toBe(true); + for (const line of [3, 4]) { + expect( + violations.some( + (violation) => + violation.importer === "src/browser/loader.ts" && + violation.line === line && + violation.message.includes("browser may not import server") + ) + ).toBe(true); + } + for (const line of [1, 3, 4, 5, 6, 7]) { + expect( + violations.some( + (violation) => + violation.importer === "src/server/authority.ts" && + violation.line === line && + violation.message.includes("runtime/global authority") + ) + ).toBe(true); + } + expect( + violations.some( + (violation) => + violation.importer === "src/server/authority.ts" && + violation.line === 8 && + violation.message.includes("dynamic-code primitives") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "src/browser/computedLoader.ts" && + violation.line === 2 && + violation.message.includes("dynamic-code primitives") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "src/browser/computedLoader.ts" && + violation.line === 5 && + violation.message.includes("browser may not import server") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "src/browser/computedLoader.ts" && + violation.line === 6 && + violation.message.includes("module-loader primitive") + ) + ).toBe(true); + for (const line of [7, 8]) { + expect( + violations.some( + (violation) => + violation.importer === "src/browser/computedLoader.ts" && + violation.line === line && + violation.message.includes("runtime/global authority") + ) + ).toBe(true); + } + expect( + violations + .filter( + (violation) => + violation.importer === "src/server/nativeLoader.ts" && + violation.message.includes("module-loader primitive") + ) + .map(({ line }) => line) + ).toEqual([1, 2, 3, 4, 6, 7]); + expect( + violations.some( + (violation) => + violation.importer === "src/server/nativeLoader.ts" && + violation.line === 8 && + violation.message.includes("dynamic-code primitives") + ) + ).toBe(true); + expect( + violations + .filter( + (violation) => + violation.importer === "src/browser/workerLoader.ts" && + violation.message.includes("module-loader primitive") + ) + .map(({ line }) => line) + ).toEqual([1, 2, 3, 4, 5]); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects WebAssembly, browser loaders, string timers, and unsafe process execution", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "src", "server")); + await writeFile( + path.join(projectRoot, "src", "browser", "runtimeLoaders.ts"), + `WebAssembly.instantiate(bytes); + const compile = globalThis["Web" + "Assembly"].compile; + navigator.serviceWorker.register("./serviceWorker.ts"); + CSS.paintWorklet["add" + "Module"]("./paint.ts"); + const code = "do" + "Work()"; + setTimeout(code, 0); + globalThis.setInterval(\`tick()\`, 1000); + setTimeout(() => undefined, 0); + function local(WebAssembly: unknown, navigator: unknown, setTimeout: (callback: string) => void) { setTimeout("local", 0); return [WebAssembly, navigator]; } + void [compile, local];` + ); + await writeFile( + path.join(projectRoot, "src", "server", "processExecution.ts"), + `Bun.spawn(["true"]); + process.execve("/bin/true", ["true"], {}); + const reflected = Reflect.get(globalThis.process, "execve"); + Bun["$"]("echo blocked"); + void reflected;` + ); + await writeFile( + path.join(projectRoot, "scripts", "processExecution.ts"), + `Bun["spawn" + "Sync"](["true"]); + process.execve("/bin/true", ["true"], {}); + Bun.$\`echo blocked\`;` + ); + + const violations = await checkSourceBoundaries(projectRoot); + + expect( + violations + .filter( + (violation) => + violation.importer === "src/browser/runtimeLoaders.ts" && + violation.message.includes("dynamic-code primitives") + ) + .map(({ line }) => line) + ).toEqual([1, 2, 6, 7]); + expect( + violations + .filter( + (violation) => + violation.importer === "src/browser/runtimeLoaders.ts" && + violation.message.includes("module-loader primitive") + ) + .map(({ line }) => line) + ).toEqual([3, 4]); + expect( + violations + .filter( + (violation) => + violation.importer === "src/server/processExecution.ts" && + violation.message.includes("process-execution authority") + ) + .map(({ line }) => line) + ).toEqual([1, 2, 3]); + expect( + violations.some( + (violation) => + violation.importer === "src/server/processExecution.ts" && + violation.line === 4 && + violation.message.includes("Bun.$ shell-execution") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "scripts/processExecution.ts" && + violation.message.includes("process-execution authority") + ) + ).toBe(false); + expect( + violations.some( + (violation) => + violation.importer === "scripts/processExecution.ts" && + violation.line === 3 && + violation.message.includes("Bun.$ shell-execution") + ) + ).toBe(true); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects imported builtin and Bun authority before aliasing", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "src", "server")); + await mkdir(path.join(projectRoot, "src", "worker")); + await writeFile( + path.join(projectRoot, "package.json"), + JSON.stringify({ + dependencies: { + child_process: "1.0.0", + module: "1.0.0", + process: "1.0.0", + vm: "1.0.0", + wasi: "1.0.0", + worker_threads: "1.0.0", + }, + }) + ); + await writeFile( + path.join(projectRoot, "src", "server", "importedAuthority.ts"), + `import { createRequire as makeLoader } from "module"; + import { runInContext } from "vm"; + import { Worker as ThreadWorker } from "worker_threads"; + import { fork } from "node:child_process"; + import processAlias from "process"; + import test from "node:test"; + import { plugin, spawn, spawnSync, $ as shell } from "bun"; + import { dlopen } from "bun:ffi"; + import { WASI as NodeWasi } from "node:wasi"; + import { WASI as BareWasi } from "wasi"; + void [makeLoader, runInContext, ThreadWorker, fork, processAlias, test, plugin, spawn, spawnSync, shell, dlopen, NodeWasi, BareWasi];` + ); + await writeFile( + path.join(projectRoot, "scripts", "allowedProcess.ts"), + `import { spawn } from "child_process"; + Bun.spawn(["true"]); + void spawn;` + ); + await writeFile( + path.join(projectRoot, "src", "worker", "allowedProcess.ts"), + `import { spawn } from "node:child_process"; + Bun.spawn(["true"]); + void spawn;` + ); + + const violations = await checkSourceBoundaries(projectRoot); + + const rejectedSpecifiers = new Set( + violations + .filter( + (violation) => + violation.importer === "src/server/importedAuthority.ts" + ) + .map(({ specifier }) => specifier) + ); + for (const specifier of [ + "bun", + "bun:ffi", + "module", + "node:child_process", + "node:test", + "node:wasi", + "process", + "vm", + "wasi", + "worker_threads", + ] as const) { + expect(rejectedSpecifiers.has(specifier)).toBe(true); + } + for (const importer of [ + "scripts/allowedProcess.ts", + "src/worker/allowedProcess.ts", + ] as const) { + expect( + violations.some( + (violation) => + violation.importer === importer && + (violation.specifier?.includes("child_process") === true || + violation.message.includes("process-execution authority")) + ) + ).toBe(false); + } + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects ambient runtime declarations and declaration files", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "src", "contracts")); + await mkdir(path.join(projectRoot, "src", "server")); + await mkdir(path.join(projectRoot, "src", "shared")); + await writeFile( + path.join(projectRoot, "src", "contracts", "escape.d.ts"), + "declare function fetch(input: string): Promise;" + ); + await writeFile( + path.join(projectRoot, "src", "shared", "ambient.ts"), + `export {}; + declare const process: { env: Record }; + declare function fetch(input: string): Promise; + declare global { const injected: string; } + declare module "runtime-module" { export const value: string; } + declare interface SafeShape { readonly value: string; } + declare type SafeAlias = string;` + ); + await writeFile( + path.join(projectRoot, "src", "browser", "ambient.ts"), + `declare const process: { env: Record }; + export const secret = process.env.SECRET;` + ); + await writeFile( + path.join(projectRoot, "src", "server", "ambient.ts"), + `declare function eval(source: string): unknown; + export const result = eval("return process.env.SECRET");` + ); + await writeFile( + path.join(projectRoot, "src", "shared", "types.ts"), + "export interface process { readonly marker: string; }" + ); + await writeFile( + path.join(projectRoot, "src", "browser", "erasedBinding.ts"), + `import type { process } from "../shared/types.ts"; + // @ts-expect-error intentional authority probe + export const secret = process.env.SECRET;` + ); + + const violations = await checkSourceBoundaries(projectRoot); + + expect( + violations.some( + (violation) => + violation.importer === "src/contracts/escape.d.ts" && + violation.message.includes("declaration files are forbidden") + ) + ).toBe(true); + expect( + violations + .filter( + (violation) => + violation.importer === "src/shared/ambient.ts" && + violation.message.includes("ambient runtime values") + ) + .map(({ line }) => line) + ).toEqual([2, 3, 4, 5]); + for (const importer of ["src/browser/ambient.ts", "src/server/ambient.ts"]) { + expect( + violations.some( + (violation) => + violation.importer === importer && + violation.message.includes("ambient runtime values") + ) + ).toBe(true); + } + expect( + violations.some( + (violation) => + violation.importer === "src/browser/erasedBinding.ts" && + violation.line === 3 && + violation.message.includes("typed configuration") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "src/browser/erasedBinding.ts" && + violation.line === 2 && + violation.message.includes("suppress TypeScript diagnostics") + ) + ).toBe(true); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); +}); diff --git a/scripts/sourceBoundaries/externalAuthorityPolicy.ts b/scripts/sourceBoundaries/externalAuthorityPolicy.ts new file mode 100644 index 000000000..0b32bcd72 --- /dev/null +++ b/scripts/sourceBoundaries/externalAuthorityPolicy.ts @@ -0,0 +1,314 @@ +import type { SourceImport } from "./importGraph.ts"; +import type { SourceBoundaryViolation } from "./policyTypes.ts"; +import { + normalizeRepositoryPath, + sourceRole, + type SourceRole, +} from "./sourceTopologyPolicy.ts"; + +const reviewedBareBunImportSignatures: ReadonlyMap = new Map([ + ["scripts/developmentFrontend.ts", "type:Server"], + ["src/server/rawHttp/authenticationCredentials.ts", "value:CookieMap"], +]); +const policyHandledNodeBuiltinNames: ReadonlySet = new Set([ + "child_process", + "cluster", + "inspector", + "module", + "process", + "repl", + "test", + "vm", + "wasi", + "worker_threads", +]); + +function violation( + importer: string, + sourceImport: SourceImport, + message: string +): SourceBoundaryViolation { + return { + importer, + line: sourceImport.line, + message, + ...(sourceImport.specifier === undefined + ? {} + : { specifier: sourceImport.specifier }), + }; +} + +function isInternalBareSpecifier(specifier: string): boolean { + return /^(?:backend|frontend|qualification|scripts|src)\//u.test(specifier); +} + +function isInternalAliasSpecifier(specifier: string): boolean { + return ( + specifier.startsWith("#") || + specifier.startsWith("@/") || + specifier === "mira-dashboard" || + specifier.startsWith("mira-dashboard/") + ); +} + +function hasUnreviewedUrlScheme(specifier: string): boolean { + const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):/u.exec(specifier)?.[1]; + return scheme !== undefined && scheme !== "bun" && scheme !== "node"; +} + +function barePackageName(specifier: string): string | undefined { + if (specifier === "bun") return undefined; + if (specifier.startsWith("@")) { + const [scope, name] = specifier.split("/"); + return scope && name ? `${scope}/${name}` : specifier; + } + return specifier.split("/", 1)[0]; +} + +function isForbiddenBrowserPackage(specifier: string): boolean { + return ( + /^(?:bun|node)(?::|$)/u.test(specifier) || + /^(?:@simplewebauthn\/server|@trpc\/server)(?:\/|$)/u.test(specifier) || + /^drizzle-orm(?:\/|$)/u.test(specifier) + ); +} + +function isAllowedNeutralPackage(specifier: string): boolean { + return /^(?:date-fns(?:\/|$)|valibot(?:\/|$))/u.test(specifier); +} + +function importBindingSignature(sourceImport: SourceImport): string | undefined { + if (sourceImport.importedBindings === undefined) return undefined; + return sourceImport.importedBindings + .map((binding) => `${binding.typeOnly ? "type" : "value"}:${binding.imported}`) + .toSorted() + .join("\0"); +} + +function canonicalNodeBuiltinName(specifier: string): string | undefined { + if (specifier.startsWith("node:")) { + return specifier.slice("node:".length).split("/", 1)[0]; + } + return specifier.includes(":") ? undefined : specifier.split("/", 1)[0]; +} + +function isProcessExecutionRole(importerRole: SourceRole): boolean { + return ( + importerRole === "scripts" || + importerRole === "worker" || + importerRole === "worker-app" + ); +} + +/** + * Validates non-relative imports and runtime authority primitives for one role. + * @param importer Normalized repository-relative importer. + * @param importerRole Explicit importer process role. + * @param sourceImport Parsed external or runtime-authority edge. + * @returns Policy violation when the authority is not reviewed for the role. + */ +export function validateExternalImport( + importer: string, + importerRole: SourceRole, + sourceImport: SourceImport +): SourceBoundaryViolation | undefined { + const specifier = sourceImport.specifier; + if (sourceImport.kind === "dynamic-code") { + return violation( + importer, + sourceImport, + "Production source may not use eval, Function, constructor access, module compilation, WebAssembly compilation, or string-form timer dynamic-code primitives" + ); + } + if (sourceImport.kind === "shell-execution") { + return violation( + importer, + sourceImport, + "Production source may not invoke Bun.$ shell-execution authority" + ); + } + if (sourceImport.kind === "process-execution") { + return isProcessExecutionRole(importerRole) + ? undefined + : violation( + importer, + sourceImport, + "Only scripts and worker source may invoke reviewed process-execution authority" + ); + } + if (sourceImport.kind === "module-loader" && specifier === undefined) { + return violation( + importer, + sourceImport, + "Production source may not escape or invoke an unreviewed module-loader primitive" + ); + } + if (specifier === undefined) { + return violation( + importer, + sourceImport, + "Production dynamic imports and require calls must use a literal specifier" + ); + } + if (specifier.startsWith("/") || hasUnreviewedUrlScheme(specifier)) { + return violation( + importer, + sourceImport, + "Source imports may not use absolute filesystem or unreviewed URL specifiers" + ); + } + if (isInternalAliasSpecifier(specifier)) { + return violation( + importer, + sourceImport, + "Repository package, package-import, and path aliases are forbidden; use an explicit relative specifier" + ); + } + if (isInternalBareSpecifier(specifier)) { + return violation( + importer, + sourceImport, + "Repository source imports must use an explicit relative specifier" + ); + } + if (/^bun:test(?:\/|$)/u.test(specifier)) { + return violation( + importer, + sourceImport, + "Production source may not import Bun test-runner APIs" + ); + } + if (/^bun:ffi(?:\/|$)/u.test(specifier)) { + return violation( + importer, + sourceImport, + "Production source may not import Bun FFI APIs until an exact worker adapter is reviewed" + ); + } + if (specifier === "bun") { + const expectedSignature = reviewedBareBunImportSignatures.get(importer); + if ( + expectedSignature === undefined || + importBindingSignature(sourceImport) !== expectedSignature + ) { + return violation( + importer, + sourceImport, + "Bare Bun imports must match the exact reviewed importer and named binding allowlist" + ); + } + return undefined; + } + const nodeBuiltinName = canonicalNodeBuiltinName(specifier); + if (nodeBuiltinName === "test") { + return violation( + importer, + sourceImport, + "Production source may not import Node test-runner APIs" + ); + } + if (nodeBuiltinName === "process") { + return violation( + importer, + sourceImport, + "Production source may not import the process module; inject typed configuration and explicit runtime facts" + ); + } + if (nodeBuiltinName === "wasi") { + return violation( + importer, + sourceImport, + "Production source may not import WebAssembly System Interface APIs or host preopen authority" + ); + } + if ( + nodeBuiltinName === "module" || + nodeBuiltinName === "vm" || + /^bun:jsc(?:\/|$)/u.test(specifier) + ) { + return violation( + importer, + sourceImport, + "Production source may not import dynamic module-loader or code-evaluation APIs" + ); + } + if ( + nodeBuiltinName === "cluster" || + nodeBuiltinName === "inspector" || + nodeBuiltinName === "repl" || + nodeBuiltinName === "worker_threads" + ) { + return violation( + importer, + sourceImport, + "Production source may not import unreviewed process, inspector, REPL, cluster, or worker-thread APIs" + ); + } + if (nodeBuiltinName === "child_process" && !isProcessExecutionRole(importerRole)) { + return violation( + importer, + sourceImport, + "Only scripts and worker source may import child-process APIs" + ); + } + if ( + (importerRole === "browser" || importerRole === "browser-app") && + isForbiddenBrowserPackage(specifier) + ) { + return violation( + importer, + sourceImport, + "Browser source may not import Bun, Node, database, or server transport packages" + ); + } + if ( + (importerRole === "contracts" || importerRole === "shared") && + !isAllowedNeutralPackage(specifier) + ) { + return violation( + importer, + sourceImport, + "Contracts and shared source may import only reviewed environment-neutral packages" + ); + } + return undefined; +} + +/** + * Requires production bare imports to name a root-manifest dependency. + * @param importer Repository-relative importing source. + * @param sourceImport Parsed external module edge. + * @param declaredPackageNames Dependency names from the root manifest. + * @returns Violation for an undeclared bare package. + */ +export function validateDeclaredPackageImport( + importer: string, + sourceImport: SourceImport, + declaredPackageNames: ReadonlySet +): SourceBoundaryViolation | undefined { + const normalizedImporter = normalizeRepositoryPath(importer); + if (sourceRole(normalizedImporter) === "test") return undefined; + const specifier = sourceImport.specifier; + if ( + specifier === undefined || + specifier.startsWith(".") || + specifier.startsWith("/") || + isInternalAliasSpecifier(specifier) || + isInternalBareSpecifier(specifier) || + /^([A-Za-z][A-Za-z0-9+.-]*):/u.test(specifier) + ) { + return undefined; + } + if (policyHandledNodeBuiltinNames.has(canonicalNodeBuiltinName(specifier) ?? "")) { + return undefined; + } + const packageName = barePackageName(specifier); + if (packageName === undefined || declaredPackageNames.has(packageName)) { + return undefined; + } + return violation( + normalizedImporter, + sourceImport, + "Bare package imports must resolve to a dependency declared by the root manifest" + ); +} diff --git a/scripts/sourceBoundaries/importGraph.test.ts b/scripts/sourceBoundaries/importGraph.test.ts new file mode 100644 index 000000000..f3c43b254 --- /dev/null +++ b/scripts/sourceBoundaries/importGraph.test.ts @@ -0,0 +1,510 @@ +import { describe, expect, test } from "bun:test"; + +import { parseSourceAnalysis, parseSourceImports } from "./importGraph.ts"; + +describe("source-boundary import parsing", () => { + test("finds value, type-only, side-effect, re-export, and dynamic edges", async () => { + const imports = await parseSourceImports( + ` + import type { Contract } from "../contracts/type.ts"; + import { value } from "../shared/value.ts"; + import "./sideEffect.ts"; + import manifest from "./manifest.json" with { type: "json" }; + import alias = require("../shared/alias.ts"); + export type { Result } from "../contracts/result.ts"; + export * from "../shared/all.ts"; + const lazy = import("../browser/lazy.tsx"); + type Imported = import("../contracts/imported.ts").Imported; + `, + "src/browser/example.ts" + ); + + expect(imports.map(({ kind, specifier }) => ({ kind, specifier }))).toEqual([ + { kind: "import", specifier: "../contracts/type.ts" }, + { kind: "import", specifier: "../shared/value.ts" }, + { kind: "import", specifier: "./sideEffect.ts" }, + { kind: "import", specifier: "./manifest.json" }, + { kind: "require", specifier: "../shared/alias.ts" }, + { kind: "export", specifier: "../contracts/result.ts" }, + { kind: "export", specifier: "../shared/all.ts" }, + { kind: "dynamic-import", specifier: "../browser/lazy.tsx" }, + { kind: "import", specifier: "../contracts/imported.ts" }, + ]); + }); + + test("selects TypeScript or TSX grammar from the filename", async () => { + const typescriptImports = await parseSourceImports( + ` + const identity = (value: Value): Value => value; + export { identity } from "../shared/identity.ts"; + `, + "src/browser/identity.ts" + ); + const tsxImports = await parseSourceImports( + ` + import { Fragment } from "react"; + export const view = ; + `, + "src/browser/view.tsx" + ); + + expect(typescriptImports).toHaveLength(1); + expect(tsxImports).toHaveLength(1); + }); + + test("selects all supported JavaScript and TypeScript grammars", async () => { + for (const extension of ["cjs", "cts", "js", "jsx", "mjs", "mts", "ts", "tsx"]) { + const imports = await parseSourceImports( + 'const dependency = require("../shared/dependency.ts"); void dependency;', + `src/browser/example.${extension}` + ); + expect(imports).toEqual([ + { + kind: "require", + line: 1, + specifier: "../shared/dependency.ts", + }, + ]); + } + }); + + test("finds literal require calls and retains nonliteral loads", async () => { + const imports = await parseSourceImports( + ` + const literal = require("../shared/literal.ts"); + export const load = (name: string) => [literal, import(name), require(name)]; + `, + "src/browser/load.ts" + ); + + expect(imports).toEqual([ + { + kind: "require", + line: 2, + specifier: "../shared/literal.ts", + }, + { kind: "dynamic-import", line: 3 }, + { kind: "require", line: 3 }, + ]); + }); + + test("finds alternate direct runtime-environment access forms", async () => { + const analysis = await parseSourceAnalysis( + ` + const port = process.env.PORT; + const bunPort = Bun["env"].PORT; + const denoPort = Deno?.env.get("PORT"); + const mode = import.meta.env.MODE; + const globalPort = globalThis.process.env.PORT; + const { env: projected } = process; + ({ env: assigned } = Bun); + `, + "src/worker/environment.ts" + ); + + expect(analysis.environmentAccesses.map(({ line }) => line)).toEqual([ + 2, 3, 4, 5, 6, 7, 8, + ]); + }); + + test("fails closed on optional or escaped require and runtime-owner aliases", async () => { + const analysis = await parseSourceAnalysis( + `const runtime = process; + const loaded = require?.("../server/optional.ts"); + const loader = require; + const dynamic = Bun[propertyName]; + void [runtime, loaded, loader, dynamic];`, + "src/browser/escape.ts" + ); + + expect(analysis.environmentAccesses).toEqual([]); + expect(analysis.runtimeAuthorityEscapes).toEqual([{ line: 1 }, { line: 4 }]); + expect(analysis.imports).toEqual([ + { + kind: "require", + line: 2, + specifier: "../server/optional.ts", + }, + { kind: "module-loader", line: 3 }, + ]); + }); + + test("fails closed on global-root and runtime-owner alias variants", async () => { + const analysis = await parseSourceAnalysis( + `const root = globalThis; + const nestedRoot = globalThis["globalThis"]; + const processAlias = globalThis["process"]; + const reflectedProcess = Reflect.get(globalThis, "process"); + const processOwner = process; + const bunOwner = Bun; + const denoOwner = Deno; + const metaOwner = import.meta; + consume(process); + const wrapped = { process }; + const secret = globalThis["process"].env.SECRET; + void [root, nestedRoot, processAlias, reflectedProcess, processOwner, bunOwner, denoOwner, metaOwner, wrapped, secret];`, + "src/server/authorityEscape.ts" + ); + + expect(analysis.environmentAccesses).toEqual([{ line: 11 }]); + expect(analysis.runtimeAuthorityEscapes.map(({ line }) => line)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + ]); + }); + + test("treats erased type-only bindings as runtime globals", async () => { + const analysis = await parseSourceAnalysis( + `// @ts-nocheck + import type { process } from "./processTypes.ts"; + import { type Bun } from "./bunTypes.ts"; + import type globalThis from "./globalTypes.ts"; + import type * as Deno from "./denoTypes.ts"; + import type { RuntimeWindow as window, RuntimeFunction as Function } from "./runtimeTypes.ts"; + // @ts-expect-error intentional escape probe + process.env.SECRET; + // @ts-ignore intentional escape probe + Bun.env.SECRET; + Deno.env.SECRET; + globalThis.process.env.SECRET; + window.process.env.SECRET; + Function(source);`, + "src/browser/erasedBindings.ts" + ); + + expect(analysis.environmentAccesses.map(({ line }) => line)).toEqual([ + 8, 10, 11, 12, 13, + ]); + expect( + analysis.imports + .filter(({ kind }) => kind === "dynamic-code") + .map(({ line }) => line) + ).toEqual([14]); + expect(analysis.typeScriptSuppressionDirectives).toEqual([ + { line: 1 }, + { line: 7 }, + { line: 9 }, + ]); + }); + + test("finds createRequire sources and equivalent module-loader forms", async () => { + const analysis = await parseSourceAnalysis( + `import { createRequire as makeLoader } from "node:module"; + const load = makeLoader(import.meta.url); + const fromMeta = import.meta.require("../server/meta.ts"); + const fromModule = module["require"]("../server/module.ts"); + const escaped = module.require; + const builtin = process.getBuiltinModule("node:module"); + const dynamicBuiltin = process["getBuiltinModule"](moduleName); + const reflected = Reflect.get(module, "require"); + void [load, fromMeta, fromModule, escaped, builtin, dynamicBuiltin, reflected];`, + "src/browser/moduleEscape.ts" + ); + + expect(analysis.imports).toEqual([ + { + kind: "import", + importedBindings: [{ imported: "createRequire", typeOnly: false }], + line: 1, + specifier: "node:module", + }, + { + kind: "require", + line: 3, + specifier: "../server/meta.ts", + }, + { + kind: "require", + line: 4, + specifier: "../server/module.ts", + }, + { kind: "module-loader", line: 5 }, + { + kind: "module-loader", + line: 6, + specifier: "node:module", + }, + { kind: "module-loader", line: 7 }, + { kind: "module-loader", line: 8 }, + ]); + }); + + test("finds runtime-owned internal and native loader APIs", async () => { + const analysis = await parseSourceAnalysis( + `process.binding("fs"); + globalThis.process["_linked" + "Binding"]("fs"); + const bindingKey = "bin" + "ding"; + const escapedBinding = process[bindingKey]; + process.dlopen(nativeModule, filename); + Bun.plugin(plugin); + const pluginKey = \`plugin\` as const; + const escapedPlugin = globalThis.Bun[pluginKey]; + const ffiKey = "F" + "FI"; + const escapedFfi = globalThis.Bun[ffiKey]; + module["_com" + "pile"](source, filename); + const { env, ...processRest } = process; + function localOwners(process: { binding(): void }, Bun: { plugin(): void; FFI: unknown }, module: { _compile(): void }) { + process.binding(); + Bun.plugin(); + void Bun.FFI; + module._compile(); + } + void [escapedBinding, escapedPlugin, escapedFfi, processRest, localOwners];`, + "src/server/nativeLoader.ts" + ); + + expect(analysis.environmentAccesses).toEqual([{ line: 12 }]); + expect(analysis.imports).toEqual([ + { kind: "module-loader", line: 1 }, + { kind: "module-loader", line: 2 }, + { kind: "module-loader", line: 4 }, + { kind: "module-loader", line: 5 }, + { kind: "module-loader", line: 6 }, + { kind: "module-loader", line: 8 }, + { kind: "module-loader", line: 10 }, + { kind: "dynamic-code", line: 11 }, + ]); + expect(analysis.runtimeAuthorityEscapes).toEqual([{ line: 12 }]); + }); + + test("finds unbound worker entrypoint loaders", async () => { + const analysis = await parseSourceAnalysis( + `new Worker("../worker/entry.ts"); + const WorkerAlias = Worker; + new globalThis["Wor" + "ker"]("../worker/global.ts"); + new SharedWorker("../worker/shared.ts"); + importScripts("./bootstrap.ts"); + self["import" + "Scripts"]("./computed.ts"); + function localWorker(Worker: new () => unknown) { return new Worker(); } + void [WorkerAlias, localWorker];`, + "src/browser/workerLoader.ts" + ); + + expect(analysis.imports).toEqual([ + { kind: "module-loader", line: 1 }, + { kind: "module-loader", line: 2 }, + { kind: "module-loader", line: 3 }, + { kind: "module-loader", line: 4 }, + { kind: "module-loader", line: 5 }, + { kind: "module-loader", line: 6 }, + ]); + }); + + test("finds global WebAssembly authority while allowing local shadows", async () => { + const analysis = await parseSourceAnalysis( + `WebAssembly.instantiate(bytes); + const compile = WebAssembly["com" + "pile"]; + const ModuleAlias = globalThis["Web" + "Assembly"].Module; + const reflected = Reflect.get(globalThis, "WebAssembly"); + const { WebAssembly: destructured } = globalThis; + function local(WebAssembly: { instantiate(value: unknown): unknown }) { return WebAssembly.instantiate(bytes); } + void [compile, ModuleAlias, reflected, destructured, local];`, + "src/browser/webAssembly.ts" + ); + + expect( + analysis.imports + .filter(({ kind }) => kind === "dynamic-code") + .map(({ line }) => line) + ).toEqual([1, 2, 3, 4, 5]); + }); + + test("finds service-worker, worklet, and string-timer loaders", async () => { + const analysis = await parseSourceAnalysis( + `navigator.serviceWorker.register("./serviceWorker.ts"); + const serviceWorker = globalThis.navigator["service" + "Worker"]; + const reflectedWorker = Reflect.get(navigator, "serviceWorker"); + CSS.paintWorklet["add" + "Module"]("./paintWorklet.ts"); + const workletLoad = audioWorklet.addModule; + const timerAlias = setTimeout; + const code = "do" + "Work()"; + setTimeout(code, 0); + globalThis["set" + "Interval"](\`tick()\`, 1000); + setTimeout(() => undefined, 0); + setInterval(callback, 1000); + function local(navigator: { serviceWorker: unknown }, setTimeout: (callback: string) => void, setInterval: (callback: string) => void) { navigator.serviceWorker; setTimeout("local", 0); setInterval("local", 0); } + void [serviceWorker, reflectedWorker, workletLoad, timerAlias, local];`, + "src/browser/browserLoaders.ts" + ); + + expect( + analysis.imports + .filter(({ kind }) => kind === "module-loader") + .map(({ line }) => line) + ).toEqual([1, 2, 3, 4, 5]); + expect( + analysis.imports + .filter(({ kind }) => kind === "dynamic-code") + .map(({ line }) => line) + ).toEqual([6, 8, 9]); + }); + + test("finds Bun process and shell execution with binding awareness", async () => { + const analysis = await parseSourceAnalysis( + `Bun.spawn(["true"]); + globalThis.Bun["spawn" + "Sync"](["true"]); + const spawnAlias = Bun.spawn; + Bun.$\`echo blocked\`; + Bun["$"]("echo blocked"); + function local(Bun: { spawn(): void; $(): void }) { Bun.spawn(); Bun.$(); } + void [spawnAlias, local];`, + "scripts/processExecution.ts" + ); + + expect( + analysis.imports + .filter(({ kind }) => kind === "process-execution") + .map(({ line }) => line) + ).toEqual([1, 2, 3]); + expect( + analysis.imports + .filter(({ kind }) => kind === "shell-execution") + .map(({ line }) => line) + ).toEqual([4, 5]); + }); + + test("finds Bun process.execve authority with binding awareness", async () => { + const analysis = await parseSourceAnalysis( + `process.execve("/bin/true", ["true"], {}); + globalThis.process["exec" + "ve"]("/bin/true", ["true"], {}); + const execute = process.execve; + const reflected = Reflect.get(process, "execve"); + function local(process: { execve(): void }, Reflect: { get(owner: unknown, property: string): unknown }) { process.execve(); return Reflect.get(process, "execve"); } + void [execute, reflected, local];`, + "src/server/processExecve.ts" + ); + + expect( + analysis.imports + .filter(({ kind }) => kind === "process-execution") + .map(({ line }) => line) + ).toEqual([1, 2, 3, 4]); + }); + + test("retains exact imported bindings and type erasure for Bun imports", async () => { + const analysis = await parseSourceAnalysis( + `import type { Server } from "bun"; + import { CookieMap as Cookies, type Server as ServerType } from "bun"; + import BunDefault from "bun"; + import * as BunRuntime from "bun"; + import "bun"; + void [Cookies, BunDefault, BunRuntime];`, + "scripts/bunImports.ts" + ); + + expect(analysis.imports.map(({ importedBindings }) => importedBindings)).toEqual([ + [{ imported: "Server", typeOnly: true }], + [ + { imported: "CookieMap", typeOnly: false }, + { imported: "Server", typeOnly: true }, + ], + [{ imported: "default", typeOnly: false }], + [{ imported: "*", typeOnly: false }], + [], + ]); + }); + + test("finds eval, Function, reflected, and constructor code loaders", async () => { + const analysis = await parseSourceAnalysis( + `eval(source); + const execute = eval; + const generated = new Function("return import(name)"); + const globalGenerated = globalThis["Function"]("return 1"); + const AsyncFunction = (async () => {}).constructor; + const reflected = Reflect.get(globalThis, "eval"); + const inherited = Object.getPrototypeOf(() => {}).constructor; + const { constructor: Constructor } = handler; + void [execute, generated, globalGenerated, AsyncFunction, reflected, inherited, Constructor];`, + "src/server/dynamicCode.ts" + ); + + expect(analysis.imports).toEqual([ + { kind: "dynamic-code", line: 1 }, + { kind: "dynamic-code", line: 2 }, + { kind: "dynamic-code", line: 3 }, + { kind: "dynamic-code", line: 4 }, + { kind: "dynamic-code", line: 5 }, + { kind: "dynamic-code", line: 6 }, + { kind: "dynamic-code", line: 7 }, + { kind: "dynamic-code", line: 8 }, + ]); + }); + + test("folds bounded computed loader keys and rejects unresolved reflection", async () => { + const analysis = await parseSourceAnalysis( + `const constructorKey = "con" + "structor"; + const execute = (() => {})[constructorKey as "constructor"]; + execute("return process.env.SECRET")(); + const direct = (() => {})["con" + "structor"]; + const evalKey = (\`ev\` + ("al" as string)) as const; + const indirectEval = globalThis[evalKey]; + const requireKey = "requ" + "ire"; + const loaded = module[requireKey as "require"]("../server/secret.ts"); + const templateKey = \`require\` as const; + const templateLoaded = module[templateKey]("../server/template.ts"); + const reflected = Reflect.get(() => {}, ["con", "structor"].join(""))("return process.env.SECRET"); + const reflectedGet = Reflect.get; + const moduleAlias = module; + const unknownLoad = module[unknownKey]("../server/dynamic.ts"); + void [direct, indirectEval, loaded, templateLoaded, reflected, reflectedGet, moduleAlias, unknownLoad];`, + "src/browser/computedLoader.ts" + ); + + expect(analysis.environmentAccesses).toEqual([]); + expect(analysis.imports).toEqual([ + { kind: "dynamic-code", line: 2 }, + { kind: "dynamic-code", line: 4 }, + { kind: "dynamic-code", line: 6 }, + { + kind: "require", + line: 8, + specifier: "../server/secret.ts", + }, + { + kind: "require", + line: 10, + specifier: "../server/template.ts", + }, + { kind: "module-loader", line: 11 }, + { kind: "module-loader", line: 12 }, + ]); + expect(analysis.runtimeAuthorityEscapes).toEqual([{ line: 13 }, { line: 14 }]); + }); + + test("distinguishes runtime ambient declarations from pure types", async () => { + const analysis = await parseSourceAnalysis( + `export {}; + declare function fetch(input: string): Promise; + declare const process: { env: Record }; + declare class RuntimeClass {} + declare enum RuntimeEnum { Value } + declare namespace RuntimeNamespace { const value: string; } + declare global { const injected: string; } + declare module "runtime-module" { export const value: string; } + declare interface SafeShape { readonly value: string; } + declare type SafeAlias = string; + function overloaded(value: string): string; + function overloaded(value: string): string { return value; }`, + "src/shared/ambient.ts" + ); + + expect(analysis.ambientRuntimeDeclarations.map(({ line }) => line)).toEqual([ + 2, 3, 4, 5, 6, 7, 8, + ]); + }); + + test("finds triple-slash directives that can restore ambient authority", async () => { + const analysis = await parseSourceAnalysis( + `/// + /// + /// + export const value = true;`, + "src/shared/authority.ts" + ); + + expect(analysis.referenceDirectives).toEqual([ + { line: 1 }, + { line: 2 }, + { line: 3 }, + ]); + }); +}); diff --git a/scripts/sourceBoundaries/importGraph.ts b/scripts/sourceBoundaries/importGraph.ts new file mode 100644 index 000000000..d26296072 --- /dev/null +++ b/scripts/sourceBoundaries/importGraph.ts @@ -0,0 +1,421 @@ +import * as babel from "@babel/core"; + +import { + runtimeAuthorityIdentifierNames, + runtimeEnvironmentAccessFromNode, + runtimeImportsFromNode, + runtimeOwnerEscapeFromNode, +} from "./runtimeAuthorityAnalysis.ts"; +import { + callArguments, + identifierName, + isRecord, + memberPropertyName, + nodeType, + sourceLine, + staticStringValue, + stringLiteralValue, + type AstRecord, + type RuntimeIdentifierReferences, + type StaticStringValues, +} from "./sourceAst.ts"; +import { + ambientRuntimeDeclarationFromNode, + referenceDirectives, + typeScriptSuppressionDirectives, +} from "./sourceDirectives.ts"; + +/** Static module edge extracted from one JavaScript or TypeScript source file. */ +export interface SourceImportBinding { + readonly imported: string; + readonly typeOnly: boolean; +} + +/** Static module edge extracted from one JavaScript or TypeScript source file. */ +export interface SourceImport { + readonly kind: + | "dynamic-code" + | "dynamic-import" + | "export" + | "import" + | "module-loader" + | "process-execution" + | "require" + | "shell-execution"; + readonly importedBindings?: readonly SourceImportBinding[]; + readonly line: number; + readonly specifier?: string; +} + +/** Direct read of a runtime-owned process environment object. */ +export interface SourceEnvironmentAccess { + readonly line: number; +} + +/** Escape of a runtime/global authority object that prevents local access review. */ +export interface SourceRuntimeAuthorityEscape { + readonly line: number; +} + +/** Runtime-shaped ambient declaration that can reintroduce forbidden globals. */ +export interface SourceAmbientRuntimeDeclaration { + readonly line: number; +} + +/** TypeScript triple-slash reference that can alter one file's ambient authority. */ +export interface SourceReferenceDirective { + readonly line: number; +} + +/** TypeScript diagnostic suppression that can hide erased runtime references. */ +export interface SourceTypeScriptSuppressionDirective { + readonly line: number; +} + +/** Security-relevant syntax extracted from one production source file. */ +export interface SourceAnalysis { + readonly ambientRuntimeDeclarations: readonly SourceAmbientRuntimeDeclaration[]; + readonly environmentAccesses: readonly SourceEnvironmentAccess[]; + readonly imports: readonly SourceImport[]; + readonly referenceDirectives: readonly SourceReferenceDirective[]; + readonly runtimeAuthorityEscapes: readonly SourceRuntimeAuthorityEscape[]; + readonly typeScriptSuppressionDirectives: readonly SourceTypeScriptSuppressionDirective[]; +} + +type BabelParserPlugins = NonNullable< + NonNullable["plugins"] +>; + +function importFromCall( + node: AstRecord, + staticStringValues: StaticStringValues +): SourceImport | undefined { + if ( + (nodeType(node) !== "CallExpression" && + nodeType(node) !== "OptionalCallExpression") || + !isRecord(node.callee) + ) { + return undefined; + } + const calleeType = nodeType(node.callee); + const arguments_ = callArguments(node); + if (calleeType === "Import") { + const specifier = stringLiteralValue(arguments_[0]); + return { + kind: "dynamic-import", + line: sourceLine(node), + ...(specifier === undefined ? {} : { specifier }), + }; + } + if (calleeType === "Identifier" && node.callee.name === "require") { + const specifier = stringLiteralValue(arguments_[0]); + return { + kind: "require", + line: sourceLine(node), + ...(specifier === undefined ? {} : { specifier }), + }; + } + if ( + (calleeType === "MemberExpression" || + calleeType === "OptionalMemberExpression") && + memberPropertyName(node.callee, staticStringValues) === "require" + ) { + const specifier = stringLiteralValue(arguments_[0]); + return { + kind: "require", + line: sourceLine(node), + ...(specifier === undefined ? {} : { specifier }), + }; + } + return undefined; +} + +function importFromNode( + node: AstRecord, + staticStringValues: StaticStringValues +): SourceImport | undefined { + const type = nodeType(node); + if (type === "ImportDeclaration") { + const specifier = stringLiteralValue(node.source); + if (specifier === undefined) return undefined; + const declarationTypeOnly = node.importKind === "type"; + const importedBindings = Array.isArray(node.specifiers) + ? node.specifiers.flatMap((candidate): SourceImportBinding[] => { + if (!isRecord(candidate)) return []; + const candidateType = nodeType(candidate); + if (candidateType === "ImportDefaultSpecifier") { + return [{ imported: "default", typeOnly: declarationTypeOnly }]; + } + if (candidateType === "ImportNamespaceSpecifier") { + return [{ imported: "*", typeOnly: declarationTypeOnly }]; + } + if (candidateType !== "ImportSpecifier") return []; + const imported = + identifierName(candidate.imported) ?? + stringLiteralValue(candidate.imported); + return imported === undefined + ? [] + : [ + { + imported, + typeOnly: + declarationTypeOnly || + candidate.importKind === "type", + }, + ]; + }) + : []; + return { + kind: "import", + importedBindings, + line: sourceLine(node), + specifier, + }; + } + if (type === "ExportNamedDeclaration" || type === "ExportAllDeclaration") { + const specifier = stringLiteralValue(node.source); + return specifier === undefined + ? undefined + : { kind: "export", line: sourceLine(node), specifier }; + } + if (type === "ImportExpression") { + const specifier = stringLiteralValue(node.source); + return { + kind: "dynamic-import", + line: sourceLine(node), + ...(specifier === undefined ? {} : { specifier }), + }; + } + if (type === "TSImportType") { + const specifier = stringLiteralValue(node.source ?? node.argument); + return specifier === undefined + ? undefined + : { kind: "import", line: sourceLine(node), specifier }; + } + if (type === "TSExternalModuleReference") { + const specifier = stringLiteralValue(node.expression); + return specifier === undefined + ? undefined + : { kind: "require", line: sourceLine(node), specifier }; + } + return importFromCall(node, staticStringValues); +} + +function visitAst( + value: unknown, + seen: Set, + imports: SourceImport[], + environmentAccesses: SourceEnvironmentAccess[], + runtimeAuthorityEscapes: SourceRuntimeAuthorityEscape[], + ambientRuntimeDeclarations: SourceAmbientRuntimeDeclaration[], + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues, + parent?: AstRecord +): void { + if (!isRecord(value) || seen.has(value)) return; + seen.add(value); + + const sourceImport = importFromNode(value, staticStringValues); + if (sourceImport !== undefined) imports.push(sourceImport); + const environmentAccess = runtimeEnvironmentAccessFromNode( + value, + runtimeIdentifierReferences, + staticStringValues + ); + if (environmentAccess !== undefined) environmentAccesses.push(environmentAccess); + const runtimeOwnerEscape = runtimeOwnerEscapeFromNode( + value, + parent, + runtimeIdentifierReferences, + staticStringValues + ); + if (runtimeOwnerEscape !== undefined) { + runtimeAuthorityEscapes.push(runtimeOwnerEscape); + } + imports.push( + ...runtimeImportsFromNode( + value, + parent, + runtimeIdentifierReferences, + staticStringValues + ) + ); + const ambientRuntimeDeclaration = ambientRuntimeDeclarationFromNode(value); + if (ambientRuntimeDeclaration !== undefined) { + ambientRuntimeDeclarations.push(ambientRuntimeDeclaration); + } + + for (const child of Object.values(value)) { + if (Array.isArray(child)) { + for (const item of child) { + visitAst( + item, + seen, + imports, + environmentAccesses, + runtimeAuthorityEscapes, + ambientRuntimeDeclarations, + runtimeIdentifierReferences, + staticStringValues, + value + ); + } + } else { + visitAst( + child, + seen, + imports, + environmentAccesses, + runtimeAuthorityEscapes, + ambientRuntimeDeclarations, + runtimeIdentifierReferences, + staticStringValues, + value + ); + } + } +} + +function parserPlugins(filename: string): BabelParserPlugins { + const plugins: BabelParserPlugins = []; + if (/\.(?:cts|mts|ts|tsx)$/u.test(filename)) plugins.push("typescript"); + if (/\.(?:jsx|tsx)$/u.test(filename)) plugins.push("jsx"); + return plugins; +} + +/** + * Parses module edges and direct runtime-environment reads from supported source text. + * @param source JavaScript or TypeScript source text. + * @param filename Repository-relative filename used for grammar and diagnostics. + * @returns Security-relevant syntax in source order. + */ +export async function parseSourceAnalysis( + source: string, + filename: string +): Promise { + const result = await babel.parseAsync(source, { + babelrc: false, + configFile: false, + filename, + parserOpts: { + createImportExpressions: true, + plugins: parserPlugins(filename), + }, + sourceType: "unambiguous", + }); + if (result === null) { + throw new Error(`Babel did not return an AST for ${filename}`); + } + + const imports: SourceImport[] = []; + const environmentAccesses: SourceEnvironmentAccess[] = []; + const runtimeAuthorityEscapes: SourceRuntimeAuthorityEscape[] = []; + const ambientRuntimeDeclarations: SourceAmbientRuntimeDeclaration[] = []; + const runtimeIdentifierReferences = new Set(); + const staticStringValues = new Map(); + babel.traverse(result, { + Identifier(identifierPath) { + const name = identifierPath.node.name; + const binding = identifierPath.scope.getBinding(name); + const bindingPath = binding?.path; + const bindingNode = bindingPath?.node as + | { readonly importKind?: unknown } + | undefined; + const bindingParentNode = bindingPath?.parentPath?.node as + | { readonly importKind?: unknown } + | undefined; + const bindingIsTypeOnly = + bindingNode?.importKind === "type" || + bindingParentNode?.importKind === "type"; + if ( + runtimeAuthorityIdentifierNames.has(name) && + identifierPath.isReferencedIdentifier() && + (bindingPath === undefined || bindingIsTypeOnly) + ) { + runtimeIdentifierReferences.add(identifierPath.node); + } + if ( + identifierPath.isReferencedIdentifier() && + binding?.constant === true && + isRecord(bindingPath?.node) && + nodeType(bindingPath.node) === "VariableDeclarator" && + isRecord(bindingPath.parentPath?.node) && + nodeType(bindingPath.parentPath.node) === "VariableDeclaration" && + bindingPath.parentPath.node.kind === "const" + ) { + const value = staticStringValue( + bindingPath.node.init, + staticStringValues + ); + if (value !== undefined) { + staticStringValues.set(identifierPath.node, value); + } + } + }, + }); + visitAst( + result, + new Set(), + imports, + environmentAccesses, + runtimeAuthorityEscapes, + ambientRuntimeDeclarations, + runtimeIdentifierReferences, + staticStringValues + ); + const uniqueEnvironmentAccesses = [ + ...new Map( + environmentAccesses.map((environmentAccess) => [ + environmentAccess.line, + environmentAccess, + ]) + ).values(), + ]; + const uniqueRuntimeAuthorityEscapes = [ + ...new Map( + runtimeAuthorityEscapes.map((escape) => [escape.line, escape]) + ).values(), + ]; + const uniqueAmbientRuntimeDeclarations = [ + ...new Map( + ambientRuntimeDeclarations.map((declaration) => [ + declaration.line, + declaration, + ]) + ).values(), + ]; + return Object.freeze({ + ambientRuntimeDeclarations: Object.freeze( + uniqueAmbientRuntimeDeclarations.toSorted( + (left, right) => left.line - right.line + ) + ), + environmentAccesses: Object.freeze( + uniqueEnvironmentAccesses.toSorted((left, right) => left.line - right.line) + ), + imports: Object.freeze(imports.toSorted((left, right) => left.line - right.line)), + referenceDirectives: Object.freeze(referenceDirectives(result)), + runtimeAuthorityEscapes: Object.freeze( + uniqueRuntimeAuthorityEscapes.toSorted( + (left, right) => left.line - right.line + ) + ), + typeScriptSuppressionDirectives: Object.freeze( + typeScriptSuppressionDirectives(result) + ), + }); +} + +/** + * Parses every static module edge, including type-only imports and re-exports. + * @param source TypeScript or TSX source text. + * @param filename Repository-relative filename used for parser diagnostics. + * @returns Module edges in source order. + */ +export async function parseSourceImports( + source: string, + filename: string +): Promise { + const analysis = await parseSourceAnalysis(source, filename); + return analysis.imports; +} diff --git a/scripts/sourceBoundaries/importTargetValidation.test.ts b/scripts/sourceBoundaries/importTargetValidation.test.ts new file mode 100644 index 000000000..1e33767ea --- /dev/null +++ b/scripts/sourceBoundaries/importTargetValidation.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { checkSourceBoundaries } from "../checkSourceBoundaries.ts"; + +async function temporaryProject(): Promise { + const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-source-boundary-")); + await mkdir(path.join(projectRoot, "scripts")); + await mkdir(path.join(projectRoot, "src", "browser"), { recursive: true }); + await writeFile(path.join(projectRoot, "package.json"), "{}"); + return projectRoot; +} + +describe("source-boundary import target validation", () => { + test("rejects encoded path input before the runtime resolver normalizes it", async () => { + const projectRoot = await temporaryProject(); + try { + await writeFile( + path.join(projectRoot, "src", "browser", "encoded.ts"), + `import "./%2e%2e/server/private.ts"; + import "./%2E./server/private.ts"; + import "./.%2e/server/private.ts"; + import "./%2e%2E/server/private.ts"; + import "./safe%2f..%2fserver/private.ts"; + import "./safe%5C..%5Cserver/private.ts"; + import "./%00server/private.ts";` + ); + + const violations = await checkSourceBoundaries(projectRoot); + expect( + violations + .filter( + (violation) => + violation.importer === "src/browser/encoded.ts" && + violation.message.includes("percent-encoded resolver input") + ) + .map(({ line }) => line) + ).toEqual([1, 2, 3, 4, 5, 6, 7]); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects resolver queries and fragments before target classification", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "src", "server")); + await writeFile( + path.join(projectRoot, "src", "server", "queryEscape.ts"), + `const testModule = require("./fixture.test.ts?x"); + void import("../shared/encoding.ts?raw"); + export * from "../shared/encoding.ts#source"; + void testModule;` + ); + + const violations = await checkSourceBoundaries(projectRoot); + expect( + violations + .filter( + (violation) => + violation.importer === "src/server/queryEscape.ts" && + violation.message.includes("query or fragment suffixes") + ) + .map(({ line }) => line) + ).toEqual([1, 2, 3]); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects unscanned executable module extensions", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "src", "server")); + await writeFile( + path.join(projectRoot, "src", "server", "nativeArtifact.ts"), + `import "./addon.node"; + const addon = require("./addon.NoDe"); + void import("./module.wasm"); + export * from "./MODULE.WASM"; + import "./styles.css"; + void addon;` + ); + await writeFile( + path.join(projectRoot, "src", "server", "styles.css"), + ":root {}" + ); + + const violations = await checkSourceBoundaries(projectRoot); + expect( + violations + .filter( + (violation) => + violation.importer === "src/server/nativeArtifact.ts" && + violation.message.includes( + "native or WebAssembly executable module artifacts" + ) + ) + .map(({ line }) => line) + ).toEqual([1, 2, 3, 4]); + expect( + violations.some( + (violation) => + violation.importer === "src/server/nativeArtifact.ts" && + violation.line === 5 + ) + ).toBe(false); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects extensionless runtime resolver fallback", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "src", "server")); + await writeFile( + path.join(projectRoot, "src", "server", "extensionless.ts"), + `import "./module"; + const required = require("./required"); + void import("./dynamic"); + export * from "./reexported"; + import "./addon.safe"; + import "./explicit.css"; + void required;` + ); + await writeFile( + path.join(projectRoot, "src", "server", "addon.safe.node"), + "ignored native fixture" + ); + await writeFile( + path.join(projectRoot, "src", "server", "explicit.css"), + ":root {}" + ); + + const violations = await checkSourceBoundaries(projectRoot); + expect( + violations + .filter( + (violation) => + violation.importer === "src/server/extensionless.ts" && + violation.message.includes("explicit file extension") + ) + .map(({ line }) => line) + ).toEqual([1, 2, 3, 4]); + expect( + violations.some( + (violation) => + violation.importer === "src/server/extensionless.ts" && + violation.line === 5 && + violation.message.includes("reviewed explicit") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "src/server/extensionless.ts" && + violation.line === 6 + ) + ).toBe(false); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("requires exact regular non-symbolic relative targets", async () => { + const projectRoot = await temporaryProject(); + try { + const serverRoot = path.join(projectRoot, "src", "server"); + await mkdir(serverRoot); + await writeFile( + path.join(serverRoot, "exactTarget.ts"), + `import "./missing.ts"; + import "./directory.json"; + import "./linked.css"; + import "./exact.css";` + ); + await writeFile( + path.join(serverRoot, "missing.ts.node"), + "ignored native fixture" + ); + await mkdir(path.join(serverRoot, "directory.json")); + await writeFile( + path.join(serverRoot, "directory.json", "index.node"), + "ignored native fixture" + ); + await writeFile(path.join(serverRoot, "exact.css"), ":root {}"); + await symlink( + path.join(serverRoot, "exact.css"), + path.join(serverRoot, "linked.css") + ); + + const violations = await checkSourceBoundaries(projectRoot); + + expect( + violations.some( + (violation) => + violation.importer === "src/server/exactTarget.ts" && + violation.line === 1 && + violation.message.includes("existing exact target") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "src/server/exactTarget.ts" && + violation.line === 2 && + violation.message.includes("exact regular files") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "src/server/exactTarget.ts" && + violation.line === 3 && + violation.message.includes("symbolic links") + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "src/server/exactTarget.ts" && + violation.line === 4 + ) + ).toBe(false); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("validates exact legacy allowlist targets without following symlinks", async () => { + const projectRoot = await temporaryProject(); + const externalRoot = await mkdtemp(path.join(tmpdir(), "mira-legacy-external-")); + try { + await writeFile( + path.join(projectRoot, "scripts", "buildBackend.ts"), + 'import "../backend/src/services/releases/runtime.ts";' + ); + await mkdir( + path.join(projectRoot, "backend", "src", "services", "releases"), + { recursive: true } + ); + const externalTarget = path.join(externalRoot, "runtime.ts"); + await writeFile(externalTarget, "export const runtime = true;"); + await symlink( + externalTarget, + path.join( + projectRoot, + "backend", + "src", + "services", + "releases", + "runtime.ts" + ) + ); + + const violations = await checkSourceBoundaries(projectRoot); + + expect( + violations.some( + (violation) => + violation.importer === "scripts/buildBackend.ts" && + violation.message.includes("may not contain symbolic links") + ) + ).toBe(true); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + await rm(externalRoot, { force: true, recursive: true }); + } + }); +}); diff --git a/scripts/sourceBoundaries/importTargetValidation.ts b/scripts/sourceBoundaries/importTargetValidation.ts new file mode 100644 index 000000000..8e6a3b0ac --- /dev/null +++ b/scripts/sourceBoundaries/importTargetValidation.ts @@ -0,0 +1,150 @@ +import { lstat, realpath } from "node:fs/promises"; +import path from "node:path"; + +import type { SourceImport } from "./importGraph.ts"; +import type { SourceBoundaryViolation } from "./policyTypes.ts"; +import { isContainedPath, repositoryPath } from "./sourceBoundaryPaths.ts"; +import { isTestPath } from "./sourceTopologyPolicy.ts"; + +/** + * Validates that an exact legacy allowlist target remains a contained regular file. + * @param projectRoot Absolute repository root. + * @param allowlistKey Stable importer/target allowlist key. + * @returns Target violation when the reviewed target has drifted. + */ +export async function validateLegacyAllowlistTarget( + projectRoot: string, + allowlistKey: string +): Promise { + const separatorIndex = allowlistKey.indexOf("\0"); + const importer = allowlistKey.slice(0, separatorIndex); + const target = allowlistKey.slice(separatorIndex + 1); + const lexicalProjectRoot = path.resolve(projectRoot); + let currentPath = lexicalProjectRoot; + const components = target.split("/"); + for (const [index, component] of components.entries()) { + currentPath = path.join(currentPath, component); + let status; + try { + status = await lstat(currentPath); + } catch { + return { + importer, + line: 1, + message: "Legacy allowlisted target is missing or unreadable", + specifier: target, + }; + } + if (status.isSymbolicLink()) { + return { + importer, + line: 1, + message: "Legacy allowlisted target paths may not contain symbolic links", + specifier: target, + }; + } + const isTarget = index === components.length - 1; + if ((isTarget && !status.isFile()) || (!isTarget && !status.isDirectory())) { + return { + importer, + line: 1, + message: "Legacy allowlisted target must be a regular repository file", + specifier: target, + }; + } + } + const realProjectRoot = await realpath(lexicalProjectRoot); + if (!isContainedPath(realProjectRoot, await realpath(currentPath))) { + return { + importer, + line: 1, + message: "Legacy allowlisted target real path escapes the repository", + specifier: target, + }; + } + return undefined; +} + +function importTargetViolation( + importer: string, + sourceImport: SourceImport, + message: string +): SourceBoundaryViolation { + return { + importer, + line: sourceImport.line, + message, + ...(sourceImport.specifier === undefined + ? {} + : { specifier: sourceImport.specifier }), + }; +} + +/** + * Validates a production relative import without runtime resolver fallback. + * @param projectRoot Absolute repository root. + * @param importer Repository-relative importing source. + * @param sourceImport Parsed relative import edge. + * @returns Exact-target violation when the lexical target is unsafe. + */ +export async function validateExactRelativeImportTarget( + projectRoot: string, + importer: string, + sourceImport: SourceImport +): Promise { + const specifier = sourceImport.specifier; + if ( + isTestPath(importer) || + specifier === undefined || + !specifier.startsWith(".") || + /[%?#\\]/u.test(specifier) + ) { + return undefined; + } + const importerDirectory = path.posix.dirname(importer); + const joinedTarget = path.posix.join(importerDirectory, specifier); + const target = repositoryPath(path.posix.normalize(joinedTarget)); + if (target === ".." || target.startsWith("../")) return undefined; + + const lexicalProjectRoot = path.resolve(projectRoot); + let currentPath = lexicalProjectRoot; + const components = target.split("/"); + for (const [index, component] of components.entries()) { + currentPath = path.join(currentPath, component); + let status; + try { + status = await lstat(currentPath); + } catch (error) { + if ((error as { code?: unknown }).code !== "ENOENT") throw error; + return importTargetViolation( + importer, + sourceImport, + "Relative production imports must resolve to an existing exact target; runtime extension fallback is forbidden" + ); + } + if (status.isSymbolicLink()) { + return importTargetViolation( + importer, + sourceImport, + "Relative production import target paths may not contain symbolic links" + ); + } + const isTarget = index === components.length - 1; + if ((isTarget && !status.isFile()) || (!isTarget && !status.isDirectory())) { + return importTargetViolation( + importer, + sourceImport, + "Relative production import targets must be exact regular files" + ); + } + } + const realProjectRoot = await realpath(lexicalProjectRoot); + if (!isContainedPath(realProjectRoot, await realpath(currentPath))) { + return importTargetViolation( + importer, + sourceImport, + "Relative production import target real path escapes the repository" + ); + } + return undefined; +} diff --git a/scripts/sourceBoundaries/lintConfiguration.test.ts b/scripts/sourceBoundaries/lintConfiguration.test.ts new file mode 100644 index 000000000..060aeb2f0 --- /dev/null +++ b/scripts/sourceBoundaries/lintConfiguration.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { copyFile, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +interface LintResult { + readonly exitCode: number; + readonly output: string; +} + +async function runOxlint( + executable: string, + projectRoot: string, + files: readonly string[] +): Promise { + const process = Bun.spawn( + [executable, "--config", ".oxlintrc.json", "--format", "unix", ...files], + { + cwd: projectRoot, + env: { ...globalThis.process.env, NO_COLOR: "1" }, + stderr: "pipe", + stdout: "pipe", + } + ); + const [exitCode, stderr, stdout] = await Promise.all([ + process.exited, + new Response(process.stderr).text(), + new Response(process.stdout).text(), + ]); + return { exitCode, output: `${stdout}\n${stderr}` }; +} + +describe("effective source-boundary lint configuration", () => { + test("applies memoization, browser-boundary, and worker-console rules together", async () => { + const repositoryRoot = path.resolve(import.meta.dir, "../.."); + const fixtureRoot = await mkdtemp(path.join(tmpdir(), "mira-oxlint-boundary-")); + try { + await copyFile( + path.join(repositoryRoot, ".oxlintrc.json"), + path.join(fixtureRoot, ".oxlintrc.json") + ); + await symlink( + path.join(repositoryRoot, "node_modules"), + path.join(fixtureRoot, "node_modules"), + "dir" + ); + await mkdir(path.join(fixtureRoot, "src", "browser"), { + recursive: true, + }); + await mkdir(path.join(fixtureRoot, "frontend", "src"), { + recursive: true, + }); + await copyFile( + path.join(repositoryRoot, "frontend", "src", "index.css"), + path.join(fixtureRoot, "frontend", "src", "index.css") + ); + await mkdir(path.join(fixtureRoot, "src", "server"), { + recursive: true, + }); + await mkdir(path.join(fixtureRoot, "src", "worker"), { + recursive: true, + }); + await writeFile( + path.join(fixtureRoot, "src", "server", "privateServer.ts"), + "export const privateServerValue = 1;\n" + ); + await writeFile( + path.join(fixtureRoot, "src", "browser", "browserBoundary.ts"), + 'import { memo } from "react";\nimport { privateServerValue } from "../server/privateServer.ts";\nconst timerCode: string = "globalThis.compromised = true";\nsetTimeout(timerCode, 0);\nexport const browserBoundary = [memo, privateServerValue] as const;\n' + ); + await writeFile( + path.join(fixtureRoot, "src", "worker", "workerConsole.ts"), + 'console.log("forbidden");\n' + ); + + const result = await runOxlint( + path.join(repositoryRoot, "node_modules", ".bin", "oxlint"), + fixtureRoot, + ["src/browser/browserBoundary.ts", "src/worker/workerConsole.ts"] + ); + + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain("'memo' import from 'react' is restricted"); + expect(result.output).toContain( + "'../server/privateServer.ts' import is restricted" + ); + expect(result.output).toContain("no-implied-eval"); + expect(result.output).toContain("no-console"); + + const testFixtureSource = + 'import { memo } from "react";\nimport { privateServerValue } from "../server/privateServer.ts";\nexport const testBoundary = [memo, privateServerValue] as const;\n'; + await writeFile( + path.join(fixtureRoot, "src", "browser", "browserBoundary.spec.ts"), + testFixtureSource + ); + await mkdir(path.join(fixtureRoot, "src", "browser", "__tests__")); + await writeFile( + path.join( + fixtureRoot, + "src", + "browser", + "__tests__", + "browserBoundary.ts" + ), + testFixtureSource.replace( + '"../server/privateServer.ts"', + '"../../server/privateServer.ts"' + ) + ); + const testResult = await runOxlint( + path.join(repositoryRoot, "node_modules", ".bin", "oxlint"), + fixtureRoot, + [ + "src/browser/browserBoundary.spec.ts", + "src/browser/__tests__/browserBoundary.ts", + ] + ); + + expect(testResult).toEqual({ exitCode: 0, output: "\n" }); + } finally { + await rm(fixtureRoot, { force: true, recursive: true }); + } + }); +}); diff --git a/scripts/sourceBoundaries/policy.test.ts b/scripts/sourceBoundaries/policy.test.ts new file mode 100644 index 000000000..a40260dbe --- /dev/null +++ b/scripts/sourceBoundaries/policy.test.ts @@ -0,0 +1,592 @@ +import { describe, expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +import { checkSourceBoundaries } from "../checkSourceBoundaries.ts"; +import { + legacyScriptImportAllowlist, + validateDeclaredPackageImport, + validateSourceAmbientRuntimeDeclaration, + validateSourceEnvironmentAccess, + validateSourceFile, + validateSourceImport, + validateSourceReferenceDirective, + validateSourceRuntimeAuthorityEscape, + validateSourceTypeScriptSuppressionDirective, +} from "./policy.ts"; + +const staticImport = (specifier: string) => ({ + kind: "import" as const, + line: 1, + specifier, +}); + +describe("source-boundary policy", () => { + test("allows the intended production dependency directions", () => { + expect( + validateSourceImport( + "src/contracts/auth.ts", + staticImport("../shared/validation.ts") + ) + ).toBeUndefined(); + expect( + validateSourceImport( + "src/browser/auth.ts", + staticImport("../contracts/auth.ts") + ) + ).toBeUndefined(); + expect( + validateSourceImport( + "src/app/server.ts", + staticImport("../server/trpc/appRouter.ts") + ) + ).toBeUndefined(); + expect( + validateSourceImport( + "src/worker/jobs/run.ts", + staticImport("../../shared/dateTime.ts") + ) + ).toBeUndefined(); + expect(validateSourceFile("tailwind.config.ts")).toBeUndefined(); + expect(validateSourceFile("drizzle.config.ts")).toBeUndefined(); + expect( + validateSourceImport( + "tailwind.config.ts", + staticImport("./src/browser/client.ts") + )?.message + ).toContain("scripts may not import browser"); + }); + + test("rejects cross-process, reverse-composition, and test imports", () => { + expect( + validateSourceImport( + "src/browser/auth.ts", + staticImport("../server/trpc/appRouter.ts") + )?.message + ).toContain("browser may not import server"); + expect( + validateSourceImport( + "src/server/domains/task.ts", + staticImport("../../app/server.ts") + )?.message + ).toContain("server may not import web-app"); + expect( + validateSourceImport( + "src/app/server.ts", + staticImport("../worker/adapters/systemd.ts") + )?.message + ).toContain("web-app may not import worker"); + expect( + validateSourceImport("src/contracts/auth.ts", staticImport("./auth.test.ts")) + ?.message + ).toContain("may not import tests"); + expect( + validateSourceImport("src/contracts/auth.ts", staticImport("./auth.spec.ts")) + ?.message + ).toContain("may not import tests"); + expect( + validateSourceImport( + "src/contracts/auth.ts", + staticImport("./__tests__/auth.ts") + )?.message + ).toContain("may not import tests"); + }); + + test("fails closed for unclassified application roots and dynamic imports", () => { + expect(validateSourceFile("src/app/newRoot.ts")?.message).toContain( + "explicitly classified" + ); + for (const file of [ + "src/app/future.test.ts", + "src/app/future.spec.ts", + "src/app/__tests__/future.ts", + ]) { + expect(validateSourceFile(file)?.message).toContain("explicitly classified"); + } + for (const file of [ + "src/app/dashboardServer.test.ts", + "src/app/trpcHttpHandler.test.ts", + "src/app/trpcRequestPolicy.test.ts", + ]) { + expect(validateSourceFile(file)).toBeUndefined(); + } + expect(validateSourceFile("src/newRoot.ts")?.message).toContain( + "explicit process role" + ); + for (const file of [ + "evil.spec.ts", + "evil.test.ts", + "foo.ts", + "vite.config.js", + ] as const) { + expect(validateSourceFile(file)?.message).toContain( + "explicit reviewed process role" + ); + } + expect( + validateSourceImport("src/browser/load.ts", { + kind: "dynamic-import", + line: 4, + })?.message + ).toContain("literal specifier"); + expect(validateSourceFile("src/contracts/escape.d.ts")?.message).toContain( + "declaration files are forbidden" + ); + }); + + test("permits TSX only in the strict browser graph", () => { + expect(validateSourceFile("src/browser/view.tsx")).toBeUndefined(); + expect(validateSourceFile("src/app/browser.tsx")).toBeUndefined(); + for (const file of [ + "drizzle.config.tsx", + "scripts/generate.tsx", + "src/app/dashboardServer.tsx", + "src/contracts/auth.tsx", + "src/server/domains/task.tsx", + "src/shared/dateTime.tsx", + "src/worker/jobs/run.tsx", + ]) { + expect(validateSourceFile(file)?.message).toContain( + "Only browser source may use .tsx" + ); + } + }); + + test("allows only reviewed neutral packages in contracts and shared", () => { + expect( + validateSourceImport("src/contracts/auth.ts", staticImport("valibot")) + ).toBeUndefined(); + expect( + validateSourceImport( + "src/server/domains/task.ts", + staticImport("node:process") + )?.message + ).toContain("may not import the process module"); + expect( + validateSourceImport("src/shared/dateTime.ts", staticImport("date-fns")) + ).toBeUndefined(); + expect( + validateSourceImport("src/contracts/auth.ts", staticImport("node:fs")) + ?.message + ).toContain("environment-neutral packages"); + expect( + validateSourceReferenceDirective("src/shared/network.ts", 1).message + ).toContain("Triple-slash reference directives are forbidden"); + expect( + validateSourceAmbientRuntimeDeclaration("src/shared/network.ts", 2)?.message + ).toContain("may not declare ambient runtime values"); + expect( + validateSourceAmbientRuntimeDeclaration("src/server/runtime.ts", 2)?.message + ).toContain("may not declare ambient runtime values"); + expect( + validateSourceAmbientRuntimeDeclaration("scripts/runtime.ts", 2)?.message + ).toContain("may not declare ambient runtime values"); + expect( + validateSourceAmbientRuntimeDeclaration("src/server/runtime.test.ts", 2) + ).toBeUndefined(); + }); + + test("rejects module-loader and dynamic-code authority primitives", () => { + for (const specifier of [ + "bun:jsc", + "module", + "node:module", + "node:module/register", + "node:vm", + "vm", + ]) { + expect( + validateSourceImport( + "src/server/runtimeLoader.ts", + staticImport(specifier) + )?.message + ).toContain("dynamic module-loader or code-evaluation APIs"); + } + expect( + validateSourceImport("src/server/runtimeLoader.ts", { + kind: "module-loader", + line: 4, + })?.message + ).toContain("module-loader primitive"); + expect( + validateSourceImport("src/server/runtimeLoader.ts", { + kind: "dynamic-code", + line: 5, + })?.message + ).toContain("dynamic-code primitives"); + expect( + validateSourceImport("src/server/runtimeLoader.ts", { + kind: "shell-execution", + line: 6, + })?.message + ).toContain("Bun.$ shell-execution authority"); + for (const importer of [ + "src/app/server.ts", + "src/browser/runtimeLoader.ts", + "src/server/runtimeLoader.ts", + ] as const) { + expect( + validateSourceImport(importer, { + kind: "process-execution", + line: 7, + })?.message + ).toContain("scripts and worker"); + } + for (const importer of [ + "scripts/runtimeLoader.ts", + "src/app/worker.ts", + "src/worker/runtimeLoader.ts", + ] as const) { + expect( + validateSourceImport(importer, { + kind: "process-execution", + line: 8, + }) + ).toBeUndefined(); + } + expect( + validateSourceImport("src/server/runtimeLoader.ts", { + kind: "module-loader", + line: 9, + specifier: "node:fs", + }) + ).toBeUndefined(); + }); + + test("canonicalizes restricted Node and Bun runtime imports", () => { + for (const specifier of ["bun:test", "node:test", "test"] as const) { + expect( + validateSourceImport( + "src/server/runtimeImport.ts", + staticImport(specifier) + )?.message + ).toContain("test-runner APIs"); + } + for (const specifier of ["process", "node:process"] as const) { + expect( + validateSourceImport( + "src/server/runtimeImport.ts", + staticImport(specifier) + )?.message + ).toContain("process module"); + } + for (const specifier of [ + "cluster", + "inspector", + "node:cluster", + "node:inspector", + "node:repl", + "node:worker_threads", + "repl", + "worker_threads", + ] as const) { + expect( + validateSourceImport( + "src/server/runtimeImport.ts", + staticImport(specifier) + )?.message + ).toContain("unreviewed process"); + } + for (const specifier of ["child_process", "node:child_process"] as const) { + expect( + validateSourceImport( + "src/server/runtimeImport.ts", + staticImport(specifier) + )?.message + ).toContain("scripts and worker"); + expect( + validateSourceImport("scripts/runtimeImport.ts", staticImport(specifier)) + ).toBeUndefined(); + expect( + validateSourceImport( + "src/worker/runtimeImport.ts", + staticImport(specifier) + ) + ).toBeUndefined(); + } + expect( + validateSourceImport("src/server/runtimeImport.ts", staticImport("bun:ffi")) + ?.message + ).toContain("Bun FFI APIs"); + for (const specifier of ["node:wasi", "wasi"] as const) { + expect( + validateSourceImport( + "src/server/runtimeImport.ts", + staticImport(specifier) + )?.message + ).toContain("WebAssembly System Interface APIs"); + } + }); + + test("allows only exact reviewed bare Bun import bindings", () => { + expect( + validateSourceImport("scripts/developmentFrontend.ts", { + kind: "import", + importedBindings: [{ imported: "Server", typeOnly: true }], + line: 1, + specifier: "bun", + }) + ).toBeUndefined(); + expect( + validateSourceImport("src/server/rawHttp/authenticationCredentials.ts", { + kind: "import", + importedBindings: [{ imported: "CookieMap", typeOnly: false }], + line: 1, + specifier: "bun", + }) + ).toBeUndefined(); + for (const sourceImport of [ + staticImport("bun"), + { + kind: "import" as const, + importedBindings: [{ imported: "spawn", typeOnly: false }], + line: 1, + specifier: "bun", + }, + { + kind: "import" as const, + importedBindings: [ + { imported: "CookieMap", typeOnly: false }, + { imported: "plugin", typeOnly: false }, + ], + line: 1, + specifier: "bun", + }, + ]) { + expect( + validateSourceImport( + "src/server/rawHttp/authenticationCredentials.ts", + sourceImport + )?.message + ).toContain("exact reviewed importer and named binding allowlist"); + } + }); + + test("rejects escaped runtime authority even where direct script env reads coexist", () => { + expect( + validateSourceRuntimeAuthorityEscape("src/server/runtime.ts", 3)?.message + ).toContain("may not alias, pass, return, or dynamically index"); + expect( + validateSourceRuntimeAuthorityEscape("scripts/build.ts", 3)?.message + ).toContain("may not alias, pass, return, or dynamically index"); + expect( + validateSourceRuntimeAuthorityEscape("src/server/runtime.test.ts", 3) + ).toBeUndefined(); + expect(validateSourceEnvironmentAccess("scripts/build.ts", 4)).toBeUndefined(); + expect( + validateSourceTypeScriptSuppressionDirective("src/browser/escape.ts", 5) + ?.message + ).toContain("may not suppress TypeScript diagnostics"); + expect( + validateSourceTypeScriptSuppressionDirective("src/browser/escape.test.ts", 5) + ).toBeUndefined(); + }); + + test("rejects repository aliases, self-package edges, and unreviewed schemes", () => { + expect( + validateSourceImport("src/browser/auth.ts", staticImport("#server/auth")) + ?.message + ).toContain("query or fragment suffixes"); + for (const specifier of ["@/server/auth", "mira-dashboard/src/server/auth"]) { + expect( + validateSourceImport("src/browser/auth.ts", staticImport(specifier)) + ?.message + ).toContain("aliases are forbidden"); + } + for (const specifier of [ + "data:text/javascript,export default 1", + "file:///srv/private.ts", + "https://example.com/module.ts", + "npm:valibot", + ]) { + expect( + validateSourceImport( + "src/server/domains/task.ts", + staticImport(specifier) + )?.message + ).toContain("unreviewed URL specifiers"); + } + expect( + validateSourceImport("src/server/domains/task.ts", staticImport("node:path")) + ).toBeUndefined(); + expect( + validateSourceImport( + "src/server/domains/task.ts", + staticImport(String.raw`..\shared\task.ts`) + )?.message + ).toContain("canonical forward slashes"); + expect( + validateDeclaredPackageImport( + "src/browser/auth.ts", + staticImport("undeclared-browser-alias/module"), + new Set(["react"]) + )?.message + ).toContain("declared by the root manifest"); + expect( + validateDeclaredPackageImport( + "src/browser/auth.ts", + staticImport("react/jsx-runtime"), + new Set(["react"]) + ) + ).toBeUndefined(); + }); + + test("rejects percent-encoded resolver input before path classification", () => { + for (const specifier of [ + "./%2e%2e/server/private.ts", + "./%2E./server/private.ts", + "./.%2e/server/private.ts", + "./%2e%2E/server/private.ts", + "./safe%2f..%2fserver/private.ts", + "./safe%5C..%5Cserver/private.ts", + "./%00server/private.ts", + ]) { + expect( + validateSourceImport("src/browser/escape.ts", staticImport(specifier)) + ?.message + ).toContain("percent-encoded resolver input"); + } + }); + + test("rejects resolver queries and fragments before path classification", () => { + for (const sourceImport of [ + staticImport("./fixture.test.ts?x"), + { + kind: "require" as const, + line: 2, + specifier: "../shared/encoding.ts?raw", + }, + { + kind: "dynamic-import" as const, + line: 3, + specifier: "../shared/encoding.ts#source", + }, + { + kind: "export" as const, + line: 4, + specifier: "../shared/encoding.ts?module#source", + }, + ]) { + expect( + validateSourceImport("src/server/queryEscape.ts", sourceImport)?.message + ).toContain("query or fragment suffixes"); + } + }); + + test("rejects unscanned native and WebAssembly module artifacts", () => { + for (const sourceImport of [ + staticImport("./addon.node"), + { + kind: "require" as const, + line: 2, + specifier: "./addon.NoDe", + }, + { + kind: "dynamic-import" as const, + line: 3, + specifier: "./module.wasm", + }, + { + kind: "export" as const, + line: 4, + specifier: "./MODULE.WASM", + }, + ]) { + expect( + validateSourceImport("src/server/nativeArtifact.ts", sourceImport) + ?.message + ).toContain("native or WebAssembly executable module artifacts"); + } + expect( + validateSourceImport("src/browser/styles.ts", staticImport("./styles.css")) + ).toBeUndefined(); + expect( + validateSourceImport("src/server/fixture.ts", staticImport("./fixture.json")) + ).toBeUndefined(); + }); + + test("rejects extensionless relative runtime resolution", () => { + for (const sourceImport of [ + staticImport("./module"), + { + kind: "require" as const, + line: 2, + specifier: "../shared/module", + }, + { + kind: "dynamic-import" as const, + line: 3, + specifier: "./directory/entry", + }, + { + kind: "export" as const, + line: 4, + specifier: "./package", + }, + ]) { + expect( + validateSourceImport("src/server/extensionless.ts", sourceImport)?.message + ).toContain("explicit file extension"); + } + expect( + validateSourceImport("src/server/explicit.ts", staticImport("./module.ts")) + ).toBeUndefined(); + expect( + validateSourceImport( + "src/server/nativeFallback.ts", + staticImport("./addon.safe") + )?.message + ).toContain("reviewed explicit"); + expect( + validateSourceImport("scripts/page.ts", staticImport("./template.html")) + ).toBeUndefined(); + }); + + test("limits the runtime environment source to composition roots", () => { + expect( + validateSourceImport( + "src/app/dashboardServer.ts", + staticImport("./environmentSource.ts") + ) + ).toBeUndefined(); + expect( + validateSourceImport( + "src/app/worker.ts", + staticImport("./environmentSource.ts") + ) + ).toBeUndefined(); + expect( + validateSourceImport( + "src/app/server.ts", + staticImport("./environmentSource.ts") + )?.message + ).toContain("Only the web and worker composition roots"); + expect( + validateSourceEnvironmentAccess("src/worker/jobs/run.ts", 7)?.message + ).toContain("typed configuration"); + expect(validateSourceEnvironmentAccess("src/app/environmentSource.ts", 7)).toBe( + undefined + ); + }); + + test("freezes the exact legacy script coexistence allowlist", () => { + expect(legacyScriptImportAllowlist.size).toBe(18); + expect( + validateSourceImport( + "scripts/buildBackend.ts", + staticImport("../backend/src/services/releases/runtime.ts") + ) + ).toBeUndefined(); + expect( + validateSourceImport( + "scripts/newTool.ts", + staticImport("../backend/src/services/releases/runtime.ts") + )?.message + ).toContain("New script imports"); + }); + + test("accepts the complete current repository graph", async () => { + const projectRootUrl = new URL("../..", import.meta.url); + const violations = await checkSourceBoundaries(fileURLToPath(projectRootUrl)); + expect(violations).toEqual([]); + }, 30_000); +}); diff --git a/scripts/sourceBoundaries/policy.ts b/scripts/sourceBoundaries/policy.ts new file mode 100644 index 000000000..a9b07596f --- /dev/null +++ b/scripts/sourceBoundaries/policy.ts @@ -0,0 +1,356 @@ +import path from "node:path"; + +import { validateExternalImport } from "./externalAuthorityPolicy.ts"; +import type { SourceImport } from "./importGraph.ts"; +import type { SourceBoundaryViolation } from "./policyTypes.ts"; +import { + allowedTargets, + environmentSourceConsumers, + environmentSourceFile, + isTestPath, + legacyEdge, + legacyScriptImportAllowlist, + normalizeRepositoryPath, + relativeImportTarget, + sourceRole, +} from "./sourceTopologyPolicy.ts"; + +export { validateDeclaredPackageImport } from "./externalAuthorityPolicy.ts"; +export type { SourceBoundaryViolation } from "./policyTypes.ts"; +export { isTestPath, legacyScriptImportAllowlist } from "./sourceTopologyPolicy.ts"; + +const reviewedRelativeExtensions: ReadonlySet = new Set([ + ".css", + ".html", + ".json", + ".ts", + ".tsx", +]); + +function violation( + importer: string, + sourceImport: SourceImport, + message: string +): SourceBoundaryViolation { + return { + importer, + line: sourceImport.line, + message, + ...(sourceImport.specifier === undefined + ? {} + : { specifier: sourceImport.specifier }), + }; +} + +/** + * Validates that a source filename belongs to one explicitly classified process role. + * @param importer Repository-relative source path. + * @returns A violation for an unclassified application root, if present. + */ +export function validateSourceFile( + importer: string +): SourceBoundaryViolation | undefined { + const normalizedImporter = normalizeRepositoryPath(importer); + const importerRole = sourceRole(normalizedImporter); + if (!normalizedImporter.includes("/") && importerRole !== "scripts") { + return { + importer: normalizedImporter, + line: 1, + message: + "Every repository-root executable source file must belong to an explicit reviewed process role", + }; + } + if (normalizedImporter.endsWith(".d.ts")) { + return { + importer: normalizedImporter, + line: 1, + message: + "Greenfield and script declaration files are forbidden unless added to an exact reviewed allowlist", + }; + } + if (!/\.tsx?$/u.test(normalizedImporter)) { + return { + importer: normalizedImporter, + line: 1, + message: + "Production and test source must use .ts or .tsx so it remains in a strict TypeScript graph", + }; + } + if ( + normalizedImporter.endsWith(".tsx") && + importerRole !== "browser" && + importerRole !== "browser-app" + ) { + return { + importer: normalizedImporter, + line: 1, + message: + "Only browser source may use .tsx; every other scanned role must use .ts so it remains in its strict TypeScript graph", + }; + } + if (importerRole === "unclassified-app") { + return { + importer: normalizedImporter, + line: 1, + message: + "Every src/app file must be explicitly classified as web, browser, worker, or test composition", + }; + } + if (importerRole === "unknown") { + return { + importer: normalizedImporter, + line: 1, + message: "Every scanned source file must belong to an explicit process role", + }; + } + return undefined; +} + +/** + * Rejects direct runtime-environment reads outside the one composition-owned source. + * @param importer Repository-relative source path. + * @param line One-based source line containing the access. + * @returns A violation when production source bypasses typed configuration. + */ +export function validateSourceEnvironmentAccess( + importer: string, + line: number +): SourceBoundaryViolation | undefined { + const normalizedImporter = normalizeRepositoryPath(importer); + const importerRole = sourceRole(normalizedImporter); + if ( + normalizedImporter === environmentSourceFile || + importerRole === "scripts" || + importerRole === "test" + ) { + return undefined; + } + return { + importer: normalizedImporter, + line, + message: + "Production source must receive typed configuration instead of reading a runtime environment directly", + }; +} + +/** + * Rejects per-file TypeScript ambient-authority and path references. + * @param importer Repository-relative source path. + * @param line One-based directive line. + * @returns An unconditional violation for a triple-slash reference directive. + */ +export function validateSourceReferenceDirective( + importer: string, + line: number +): SourceBoundaryViolation { + return { + importer: normalizeRepositoryPath(importer), + line, + message: + "Triple-slash reference directives are forbidden; use reviewed explicit imports and project configuration", + }; +} + +/** + * Rejects runtime/global authority objects escaping direct reviewed property access. + * @param importer Repository-relative source path. + * @param line One-based escape line. + * @returns A violation outside test source. + */ +export function validateSourceRuntimeAuthorityEscape( + importer: string, + line: number +): SourceBoundaryViolation | undefined { + const normalizedImporter = normalizeRepositoryPath(importer); + if (sourceRole(normalizedImporter) === "test") return undefined; + return { + importer: normalizedImporter, + line, + message: + "Production source may not alias, pass, return, or dynamically index runtime/global authority objects", + }; +} + +/** + * Rejects TypeScript diagnostic suppression in production source. + * @param importer Repository-relative source path. + * @param line One-based directive line. + * @returns A violation outside test source. + */ +export function validateSourceTypeScriptSuppressionDirective( + importer: string, + line: number +): SourceBoundaryViolation | undefined { + const normalizedImporter = normalizeRepositoryPath(importer); + if (sourceRole(normalizedImporter) === "test") return undefined; + return { + importer: normalizedImporter, + line, + message: + "Production source may not suppress TypeScript diagnostics with @ts-ignore, @ts-expect-error, or @ts-nocheck", + }; +} + +/** + * Rejects runtime-shaped ambient declarations in production source. + * @param importer Repository-relative source path. + * @param line One-based declaration line. + * @returns A violation outside test source. + */ +export function validateSourceAmbientRuntimeDeclaration( + importer: string, + line: number +): SourceBoundaryViolation | undefined { + const normalizedImporter = normalizeRepositoryPath(importer); + if (sourceRole(normalizedImporter) === "test") return undefined; + return { + importer: normalizedImporter, + line, + message: + "Production source may not declare ambient runtime values, globals, namespaces, or modules", + }; +} + +/** + * Identifies a script edge into the legacy tree for exact allowlist accounting. + * @param importer Repository-relative importing file. + * @param sourceImport Parsed import or re-export. + * @returns Stable allowlist key, or `undefined` for a non-legacy edge. + */ +export function legacyScriptImportKey( + importer: string, + sourceImport: SourceImport +): string | undefined { + const normalizedImporter = normalizeRepositoryPath(importer); + const specifier = sourceImport.specifier; + if ( + sourceRole(normalizedImporter) !== "scripts" || + specifier === undefined || + !specifier.startsWith(".") + ) { + return undefined; + } + + const target = relativeImportTarget(normalizedImporter, specifier); + const targetRole = sourceRole(target); + return targetRole === "legacy-backend" || targetRole === "legacy-frontend" + ? legacyEdge(normalizedImporter, target) + : undefined; +} + +/** + * Applies the path and runtime policy to one parsed module edge. + * @param importer Repository-relative importing file. + * @param sourceImport Parsed import or re-export. + * @returns The boundary violation, if the edge is forbidden. + */ +export function validateSourceImport( + importer: string, + sourceImport: SourceImport +): SourceBoundaryViolation | undefined { + const normalizedImporter = normalizeRepositoryPath(importer); + const importerRole = sourceRole(normalizedImporter); + if (importerRole === "test") return undefined; + + const specifier = sourceImport.specifier; + if (specifier?.includes("%")) { + return violation( + normalizedImporter, + sourceImport, + "Production import specifiers may not contain percent-encoded resolver input" + ); + } + if (specifier !== undefined && /[?#]/u.test(specifier)) { + return violation( + normalizedImporter, + sourceImport, + "Production import specifiers may not contain resolver query or fragment suffixes" + ); + } + if (specifier?.includes("\\")) { + return violation( + normalizedImporter, + sourceImport, + "Source import specifiers must use canonical forward slashes" + ); + } + if (specifier === undefined || !specifier.startsWith(".")) { + return validateExternalImport(normalizedImporter, importerRole, sourceImport); + } + + const target = relativeImportTarget(normalizedImporter, specifier); + if (target === ".." || target.startsWith("../")) { + return violation( + normalizedImporter, + sourceImport, + "Source imports may not escape the repository" + ); + } + if (/\.(?:node|wasm)$/iu.test(target)) { + return violation( + normalizedImporter, + sourceImport, + "Production source may not import native or WebAssembly executable module artifacts" + ); + } + const targetExtension = path.posix.extname(target); + if (targetExtension === "" || targetExtension === ".") { + return violation( + normalizedImporter, + sourceImport, + "Production relative imports must include an explicit file extension to prevent runtime resolver fallback" + ); + } + if (!reviewedRelativeExtensions.has(targetExtension)) { + return violation( + normalizedImporter, + sourceImport, + "Production relative imports must use a reviewed explicit .ts, .tsx, .css, .html, or .json extension" + ); + } + if (isTestPath(target)) { + return violation( + normalizedImporter, + sourceImport, + "Production source may not import tests or test-support modules" + ); + } + + const targetRole = sourceRole(target); + if (targetRole === "environment-source") { + return environmentSourceConsumers.has(normalizedImporter) + ? undefined + : violation( + normalizedImporter, + sourceImport, + "Only the web and worker composition roots may import the runtime environment source" + ); + } + if ( + importerRole === "scripts" && + (targetRole === "legacy-backend" || targetRole === "legacy-frontend") + ) { + return legacyScriptImportAllowlist.has(legacyEdge(normalizedImporter, target)) + ? undefined + : violation( + normalizedImporter, + sourceImport, + "New script imports into the legacy backend or frontend are forbidden" + ); + } + if (targetRole === "unclassified-app") { + return violation( + normalizedImporter, + sourceImport, + "Imports may not target an unclassified src/app file" + ); + } + if (!allowedTargets[importerRole].has(targetRole)) { + return violation( + normalizedImporter, + sourceImport, + `Source role ${importerRole} may not import ${targetRole}` + ); + } + return undefined; +} diff --git a/scripts/sourceBoundaries/policyTypes.ts b/scripts/sourceBoundaries/policyTypes.ts new file mode 100644 index 000000000..c8f8e7e7e --- /dev/null +++ b/scripts/sourceBoundaries/policyTypes.ts @@ -0,0 +1,7 @@ +/** One actionable source-boundary failure. */ +export interface SourceBoundaryViolation { + readonly importer: string; + readonly line: number; + readonly message: string; + readonly specifier?: string; +} diff --git a/scripts/sourceBoundaries/runtimeAuthorityAnalysis.ts b/scripts/sourceBoundaries/runtimeAuthorityAnalysis.ts new file mode 100644 index 000000000..374b98db7 --- /dev/null +++ b/scripts/sourceBoundaries/runtimeAuthorityAnalysis.ts @@ -0,0 +1,6 @@ +export { runtimeImportsFromNode } from "./runtimeCodeAuthorityAnalysis.ts"; +export { + runtimeAuthorityIdentifierNames, + runtimeEnvironmentAccessFromNode, + runtimeOwnerEscapeFromNode, +} from "./runtimeOwnerAnalysis.ts"; diff --git a/scripts/sourceBoundaries/runtimeCodeAuthorityAnalysis.ts b/scripts/sourceBoundaries/runtimeCodeAuthorityAnalysis.ts new file mode 100644 index 000000000..1589ca028 --- /dev/null +++ b/scripts/sourceBoundaries/runtimeCodeAuthorityAnalysis.ts @@ -0,0 +1,556 @@ +import type { SourceImport } from "./importGraph.ts"; +import { + isRuntimeEnvironmentOwner, + isRuntimeGlobalRoot, + isRuntimeNamedOwner, + objectPatternReadsNamedProperty, +} from "./runtimeOwnerAnalysis.ts"; +import { + callArguments, + identifierName, + isRecord, + memberPropertyName, + nodeType, + sourceLine, + staticStringValue, + stringLiteralValue, + type AstRecord, + type RuntimeIdentifierReferences, + type StaticStringValues, +} from "./sourceAst.ts"; + +function isDirectCallee(node: AstRecord, parent: AstRecord | undefined): boolean { + if (parent === undefined) return false; + const parentType = nodeType(parent); + return ( + (parentType === "CallExpression" || + parentType === "OptionalCallExpression" || + parentType === "NewExpression") && + parent.callee === node + ); +} + +function isNonRuntimeIdentifierPosition( + node: AstRecord, + parent: AstRecord | undefined +): boolean { + if (parent === undefined) return false; + const parentType = nodeType(parent); + if ( + (parentType === "VariableDeclarator" && parent.id === node) || + ((parentType === "FunctionDeclaration" || + parentType === "FunctionExpression" || + parentType === "ArrowFunctionExpression") && + (parent.id === node || + (Array.isArray(parent.params) && parent.params.includes(node)))) || + ((parentType === "ClassDeclaration" || parentType === "ClassExpression") && + parent.id === node) || + (parentType === "CatchClause" && parent.param === node) || + parentType === "ImportSpecifier" || + parentType === "ImportDefaultSpecifier" || + parentType === "ImportNamespaceSpecifier" || + parentType === "ExportSpecifier" || + parentType === "LabeledStatement" || + parentType === "BreakStatement" || + parentType === "ContinueStatement" + ) { + return true; + } + if ( + (parentType === "MemberExpression" || + parentType === "OptionalMemberExpression") && + parent.property === node && + parent.computed !== true + ) { + return true; + } + if ( + (parentType === "ObjectProperty" || parentType === "ObjectMethod") && + parent.key === node && + parent.value !== node && + parent.computed !== true + ) { + return true; + } + return ( + parentType === "TSQualifiedName" || + parentType === "TSTypeQuery" || + parentType === "TSTypeReference" || + parentType === "TSExpressionWithTypeArguments" + ); +} + +interface ReflectedPropertyAccess { + readonly owner: unknown; + readonly property?: string; +} + +function reflectGetProperty( + node: AstRecord, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): ReflectedPropertyAccess | undefined { + if ( + nodeType(node) !== "CallExpression" && + nodeType(node) !== "OptionalCallExpression" + ) { + return undefined; + } + if (!isRecord(node.callee)) return undefined; + const calleeType = nodeType(node.callee); + if ( + (calleeType !== "MemberExpression" && + calleeType !== "OptionalMemberExpression") || + memberPropertyName(node.callee, staticStringValues) !== "get" || + !isRuntimeNamedOwner( + node.callee.object, + "Reflect", + runtimeIdentifierReferences, + staticStringValues + ) + ) { + return undefined; + } + const arguments_ = callArguments(node); + const owner = arguments_[0]; + const property = staticStringValue(arguments_[1], staticStringValues); + return property === undefined ? { owner } : { owner, property }; +} + +const moduleLoaderPropertyNames: ReadonlySet = new Set([ + "createRequire", + "getBuiltinModule", + "require", +]); +const globalLoaderIdentifierNames: ReadonlySet = new Set([ + "importScripts", + "SharedWorker", + "Worker", +]); +const processLoaderPropertyNames: ReadonlySet = new Set([ + "_linkedBinding", + "binding", + "dlopen", +]); +const processExecutionPropertyNames: ReadonlySet = new Set(["execve"]); +const bunLoaderPropertyNames: ReadonlySet = new Set(["plugin"]); +const bunProcessExecutionPropertyNames: ReadonlySet = new Set([ + "spawn", + "spawnSync", +]); + +function restrictedRuntimeLoaderKind( + owner: unknown, + property: string, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): SourceImport["kind"] | undefined { + if ( + globalLoaderIdentifierNames.has(property) && + isRuntimeGlobalRoot(owner, runtimeIdentifierReferences, staticStringValues) + ) { + return "module-loader"; + } + if ( + processLoaderPropertyNames.has(property) && + isRuntimeNamedOwner( + owner, + "process", + runtimeIdentifierReferences, + staticStringValues + ) + ) { + return "module-loader"; + } + if ( + processExecutionPropertyNames.has(property) && + isRuntimeNamedOwner( + owner, + "process", + runtimeIdentifierReferences, + staticStringValues + ) + ) { + return "process-execution"; + } + if ( + bunLoaderPropertyNames.has(property) && + isRuntimeNamedOwner(owner, "Bun", runtimeIdentifierReferences, staticStringValues) + ) { + return "module-loader"; + } + if ( + bunProcessExecutionPropertyNames.has(property) && + isRuntimeNamedOwner(owner, "Bun", runtimeIdentifierReferences, staticStringValues) + ) { + return "process-execution"; + } + if ( + property === "$" && + isRuntimeNamedOwner(owner, "Bun", runtimeIdentifierReferences, staticStringValues) + ) { + return "shell-execution"; + } + if ( + property === "FFI" && + isRuntimeNamedOwner(owner, "Bun", runtimeIdentifierReferences, staticStringValues) + ) { + return "module-loader"; + } + if ( + property === "_compile" && + isRuntimeNamedOwner( + owner, + "module", + runtimeIdentifierReferences, + staticStringValues + ) + ) { + return "dynamic-code"; + } + return undefined; +} + +function moduleLoaderCallFromNode( + node: AstRecord, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): SourceImport | undefined { + if ( + (nodeType(node) !== "CallExpression" && + nodeType(node) !== "OptionalCallExpression") || + !isRecord(node.callee) + ) { + return undefined; + } + const calleeType = nodeType(node.callee); + if ( + (calleeType !== "MemberExpression" && + calleeType !== "OptionalMemberExpression") || + memberPropertyName(node.callee, staticStringValues) !== "getBuiltinModule" || + !isRuntimeEnvironmentOwner( + node.callee.object, + runtimeIdentifierReferences, + staticStringValues + ) + ) { + return undefined; + } + const specifier = stringLiteralValue(callArguments(node)[0]); + return { + kind: "module-loader", + line: sourceLine(node), + ...(specifier === undefined ? {} : { specifier }), + }; +} + +function loaderPrimitiveFromNode( + node: AstRecord, + parent: AstRecord | undefined, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): SourceImport | undefined { + const type = nodeType(node); + const name = identifierName(node); + if ( + name !== undefined && + globalLoaderIdentifierNames.has(name) && + runtimeIdentifierReferences.has(node) && + !isNonRuntimeIdentifierPosition(node, parent) + ) { + return { kind: "module-loader", line: sourceLine(node) }; + } + if ( + name !== undefined && + moduleLoaderPropertyNames.has(name) && + !isNonRuntimeIdentifierPosition(node, parent) + ) { + if (name === "require" && isDirectCallee(node, parent)) return undefined; + return { kind: "module-loader", line: sourceLine(node) }; + } + if (type === "MemberExpression" || type === "OptionalMemberExpression") { + const property = memberPropertyName(node, staticStringValues); + const restrictedKind = + property === undefined + ? undefined + : restrictedRuntimeLoaderKind( + node.object, + property, + runtimeIdentifierReferences, + staticStringValues + ); + if (restrictedKind !== undefined) { + return { kind: restrictedKind, line: sourceLine(node) }; + } + if ( + property === "serviceWorker" && + isRuntimeNamedOwner( + node.object, + "navigator", + runtimeIdentifierReferences, + staticStringValues + ) + ) { + return { kind: "module-loader", line: sourceLine(node) }; + } + if (property === "addModule") { + return { kind: "module-loader", line: sourceLine(node) }; + } + if ( + property !== undefined && + moduleLoaderPropertyNames.has(property) && + (property !== "getBuiltinModule" || + isRuntimeEnvironmentOwner( + node.object, + runtimeIdentifierReferences, + staticStringValues + )) + ) { + return isDirectCallee(node, parent) + ? undefined + : { kind: "module-loader", line: sourceLine(node) }; + } + if ( + property === "get" && + isRuntimeNamedOwner( + node.object, + "Reflect", + runtimeIdentifierReferences, + staticStringValues + ) && + !isDirectCallee(node, parent) + ) { + return { kind: "module-loader", line: sourceLine(node) }; + } + } + if ( + type === "ObjectProperty" && + parent !== undefined && + nodeType(parent) === "ObjectPattern" + ) { + const property = + node.computed === true + ? staticStringValue(node.key, staticStringValues) + : identifierName(node.key); + if (property !== undefined && moduleLoaderPropertyNames.has(property)) { + return { kind: "module-loader", line: sourceLine(node) }; + } + if (property === "addModule") { + return { kind: "module-loader", line: sourceLine(node) }; + } + } + const reflectedProperty = reflectGetProperty( + node, + runtimeIdentifierReferences, + staticStringValues + ); + if (reflectedProperty === undefined) return undefined; + if ( + reflectedProperty.property !== undefined && + processExecutionPropertyNames.has(reflectedProperty.property) && + isRuntimeNamedOwner( + reflectedProperty.owner, + "process", + runtimeIdentifierReferences, + staticStringValues + ) + ) { + return { kind: "process-execution", line: sourceLine(node) }; + } + if ( + reflectedProperty.property === undefined || + reflectedProperty.property === "addModule" || + moduleLoaderPropertyNames.has(reflectedProperty.property) || + (reflectedProperty.property === "serviceWorker" && + isRuntimeNamedOwner( + reflectedProperty.owner, + "navigator", + runtimeIdentifierReferences, + staticStringValues + )) + ) { + return { kind: "module-loader", line: sourceLine(node) }; + } + return undefined; +} + +const dynamicCodePropertyNames: ReadonlySet = new Set([ + "constructor", + "eval", + "Function", +]); +const timerIdentifierNames: ReadonlySet = new Set(["setInterval", "setTimeout"]); + +function webAssemblyDynamicCodeFromNode( + node: AstRecord, + parent: AstRecord | undefined, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): SourceImport | undefined { + if ( + isRuntimeNamedOwner( + node, + "WebAssembly", + runtimeIdentifierReferences, + staticStringValues + ) && + !isNonRuntimeIdentifierPosition(node, parent) + ) { + return { kind: "dynamic-code", line: sourceLine(node) }; + } + const type = nodeType(node); + let pattern: unknown; + let owner: unknown; + if (type === "VariableDeclarator") { + pattern = node.id; + owner = node.init; + } else if (type === "AssignmentExpression") { + pattern = node.left; + owner = node.right; + } + if ( + objectPatternReadsNamedProperty(pattern, "WebAssembly", staticStringValues) && + isRuntimeGlobalRoot(owner, runtimeIdentifierReferences, staticStringValues) + ) { + return { kind: "dynamic-code", line: sourceLine(node) }; + } + const reflectedProperty = reflectGetProperty( + node, + runtimeIdentifierReferences, + staticStringValues + ); + return reflectedProperty?.property === "WebAssembly" && + isRuntimeGlobalRoot( + reflectedProperty.owner, + runtimeIdentifierReferences, + staticStringValues + ) + ? { kind: "dynamic-code", line: sourceLine(node) } + : undefined; +} + +function timerDynamicCodeFromNode( + node: AstRecord, + parent: AstRecord | undefined, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): SourceImport | undefined { + const name = identifierName(node); + const type = nodeType(node); + const isRuntimeTimer = + (name !== undefined && + timerIdentifierNames.has(name) && + runtimeIdentifierReferences.has(node)) || + ((type === "MemberExpression" || type === "OptionalMemberExpression") && + timerIdentifierNames.has( + memberPropertyName(node, staticStringValues) ?? "" + ) && + isRuntimeGlobalRoot( + node.object, + runtimeIdentifierReferences, + staticStringValues + )); + if (!isRuntimeTimer || isNonRuntimeIdentifierPosition(node, parent)) { + return undefined; + } + if (!isDirectCallee(node, parent)) { + return { kind: "dynamic-code", line: sourceLine(node) }; + } + if (parent === undefined) return undefined; + return staticStringValue(callArguments(parent)[0], staticStringValues) === undefined + ? undefined + : { kind: "dynamic-code", line: sourceLine(parent) }; +} + +function dynamicCodePrimitiveFromNode( + node: AstRecord, + parent: AstRecord | undefined, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): SourceImport | undefined { + const webAssemblyDynamicCode = webAssemblyDynamicCodeFromNode( + node, + parent, + runtimeIdentifierReferences, + staticStringValues + ); + if (webAssemblyDynamicCode !== undefined) return webAssemblyDynamicCode; + const timerDynamicCode = timerDynamicCodeFromNode( + node, + parent, + runtimeIdentifierReferences, + staticStringValues + ); + if (timerDynamicCode !== undefined) return timerDynamicCode; + const name = identifierName(node); + if ( + (name === "eval" || name === "Function") && + runtimeIdentifierReferences.has(node) && + !isNonRuntimeIdentifierPosition(node, parent) + ) { + return { kind: "dynamic-code", line: sourceLine(node) }; + } + const type = nodeType(node); + if (type === "MemberExpression" || type === "OptionalMemberExpression") { + const property = memberPropertyName(node, staticStringValues); + if (property !== undefined && dynamicCodePropertyNames.has(property)) { + return { kind: "dynamic-code", line: sourceLine(node) }; + } + } + if ( + type === "ObjectProperty" && + parent !== undefined && + nodeType(parent) === "ObjectPattern" + ) { + const property = + node.computed === true + ? staticStringValue(node.key, staticStringValues) + : identifierName(node.key); + if (property !== undefined && dynamicCodePropertyNames.has(property)) { + return { kind: "dynamic-code", line: sourceLine(node) }; + } + } + const reflectedProperty = reflectGetProperty( + node, + runtimeIdentifierReferences, + staticStringValues + ); + return reflectedProperty?.property !== undefined && + dynamicCodePropertyNames.has(reflectedProperty.property) + ? { kind: "dynamic-code", line: sourceLine(node) } + : undefined; +} + +/** + * Finds loader and dynamic-code authority carried by one AST node. + * @param node Babel AST record. + * @param parent Parent AST record when present. + * @param runtimeIdentifierReferences Binding-aware global runtime references. + * @param staticStringValues Bounded computed-key values. + * @returns Loader and dynamic-code findings for the node. + */ +export function runtimeImportsFromNode( + node: AstRecord, + parent: AstRecord | undefined, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): readonly SourceImport[] { + const imports = [ + moduleLoaderCallFromNode(node, runtimeIdentifierReferences, staticStringValues), + loaderPrimitiveFromNode( + node, + parent, + runtimeIdentifierReferences, + staticStringValues + ), + dynamicCodePrimitiveFromNode( + node, + parent, + runtimeIdentifierReferences, + staticStringValues + ), + ]; + return imports.filter((sourceImport): sourceImport is SourceImport => { + return sourceImport !== undefined; + }); +} diff --git a/scripts/sourceBoundaries/runtimeOwnerAnalysis.ts b/scripts/sourceBoundaries/runtimeOwnerAnalysis.ts new file mode 100644 index 000000000..ef54cc867 --- /dev/null +++ b/scripts/sourceBoundaries/runtimeOwnerAnalysis.ts @@ -0,0 +1,348 @@ +import type { + SourceEnvironmentAccess, + SourceRuntimeAuthorityEscape, +} from "./importGraph.ts"; +import { + identifierName, + isRecord, + memberPropertyName, + nodeType, + sourceLine, + staticStringValue, + type AstRecord, + type RuntimeIdentifierReferences, + type StaticStringValues, +} from "./sourceAst.ts"; + +function isImportMeta(node: unknown): boolean { + return ( + isRecord(node) && + nodeType(node) === "MetaProperty" && + identifierName(node.meta) === "import" && + identifierName(node.property) === "meta" + ); +} + +const runtimeGlobalRootNames: ReadonlySet = new Set([ + "global", + "globalThis", + "self", + "window", +]); + +/** Names whose unbound, runtime references carry process or loader authority. */ +export const runtimeAuthorityIdentifierNames: ReadonlySet = new Set([ + ...runtimeGlobalRootNames, + "Bun", + "Deno", + "eval", + "Function", + "importScripts", + "module", + "process", + "Reflect", + "SharedWorker", + "WebAssembly", + "Worker", + "navigator", + "setInterval", + "setTimeout", +]); + +/** + * Identifies a binding-aware runtime global-root expression. + * @param node Candidate Babel AST node. + * @param runtimeIdentifierReferences Binding-aware global runtime references. + * @param staticStringValues Bounded computed-key values. + * @returns Whether the node denotes a runtime global root. + */ +export function isRuntimeGlobalRoot( + node: unknown, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): boolean { + if (!isRecord(node)) return false; + if ( + runtimeGlobalRootNames.has(identifierName(node) ?? "") && + runtimeIdentifierReferences.has(node) + ) { + return true; + } + const type = nodeType(node); + return ( + (type === "MemberExpression" || type === "OptionalMemberExpression") && + runtimeGlobalRootNames.has(memberPropertyName(node, staticStringValues) ?? "") && + isRuntimeGlobalRoot(node.object, runtimeIdentifierReferences, staticStringValues) + ); +} + +/** + * Identifies a runtime owner that can expose process environment state. + * @param node Candidate Babel AST node. + * @param runtimeIdentifierReferences Binding-aware global runtime references. + * @param staticStringValues Bounded computed-key values. + * @returns Whether the node owns runtime environment state. + */ +export function isRuntimeEnvironmentOwner( + node: unknown, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): boolean { + if (!isRecord(node)) return false; + if ( + ["Bun", "Deno", "process"].includes(identifierName(node) ?? "") && + runtimeIdentifierReferences.has(node) + ) { + return true; + } + if (isRuntimeGlobalRoot(node, runtimeIdentifierReferences, staticStringValues)) { + return true; + } + if (isImportMeta(node)) return true; + const type = nodeType(node); + if (type !== "MemberExpression" && type !== "OptionalMemberExpression") { + return false; + } + if ( + !["Bun", "Deno", "process"].includes( + memberPropertyName(node, staticStringValues) ?? "" + ) + ) { + return false; + } + return isRuntimeGlobalRoot( + node.object, + runtimeIdentifierReferences, + staticStringValues + ); +} + +/** + * Identifies one binding-aware named runtime owner, directly or via a global root. + * @param node Candidate Babel AST node. + * @param ownerName Reviewed runtime owner name. + * @param runtimeIdentifierReferences Binding-aware global runtime references. + * @param staticStringValues Bounded computed-key values. + * @returns Whether the node denotes the named runtime owner. + */ +export function isRuntimeNamedOwner( + node: unknown, + ownerName: + | "Bun" + | "Deno" + | "Reflect" + | "WebAssembly" + | "module" + | "navigator" + | "process", + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): boolean { + if (!isRecord(node)) return false; + if (identifierName(node) === ownerName && runtimeIdentifierReferences.has(node)) { + return true; + } + const type = nodeType(node); + return ( + (type === "MemberExpression" || type === "OptionalMemberExpression") && + memberPropertyName(node, staticStringValues) === ownerName && + isRuntimeGlobalRoot(node.object, runtimeIdentifierReferences, staticStringValues) + ); +} + +function isRuntimeAuthorityOwner( + node: unknown, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): boolean { + return ( + isRuntimeEnvironmentOwner( + node, + runtimeIdentifierReferences, + staticStringValues + ) || + isRuntimeNamedOwner( + node, + "module", + runtimeIdentifierReferences, + staticStringValues + ) || + isRuntimeNamedOwner( + node, + "Reflect", + runtimeIdentifierReferences, + staticStringValues + ) || + isRuntimeNamedOwner( + node, + "navigator", + runtimeIdentifierReferences, + staticStringValues + ) + ); +} + +/** + * Identifies whether an object pattern reads one bounded property. + * @param node Candidate object-pattern node. + * @param propertyName Reviewed bounded property name. + * @param staticStringValues Bounded computed-key values. + * @returns Whether the pattern reads the property. + */ +export function objectPatternReadsNamedProperty( + node: unknown, + propertyName: string, + staticStringValues: StaticStringValues +): boolean { + if (!isRecord(node) || nodeType(node) !== "ObjectPattern") return false; + if (!Array.isArray(node.properties)) return false; + return node.properties.some( + (property) => + isRecord(property) && + (nodeType(property) === "ObjectProperty" || + nodeType(property) === "ObjectMethod") && + (property.computed === true + ? staticStringValue(property.key, staticStringValues) + : identifierName(property.key)) === propertyName + ); +} + +function objectPatternOnlyReadsEnvironment( + node: unknown, + staticStringValues: StaticStringValues +): boolean { + if (!isRecord(node) || nodeType(node) !== "ObjectPattern") return false; + if (!Array.isArray(node.properties) || node.properties.length === 0) return false; + return node.properties.every( + (property) => + isRecord(property) && + nodeType(property) === "ObjectProperty" && + (property.computed === true + ? staticStringValue(property.key, staticStringValues) + : identifierName(property.key)) === "env" + ); +} + +/** + * Finds one direct read of runtime-owned environment state. + * @param node Babel AST record. + * @param runtimeIdentifierReferences Binding-aware global runtime references. + * @param staticStringValues Bounded computed-key values. + * @returns Environment access finding when present. + */ +export function runtimeEnvironmentAccessFromNode( + node: AstRecord, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): SourceEnvironmentAccess | undefined { + const type = nodeType(node); + if ( + (type === "MemberExpression" || type === "OptionalMemberExpression") && + memberPropertyName(node, staticStringValues) === "env" && + isRuntimeEnvironmentOwner( + node.object, + runtimeIdentifierReferences, + staticStringValues + ) + ) { + return { line: sourceLine(node) }; + } + if ( + type === "VariableDeclarator" && + objectPatternReadsNamedProperty(node.id, "env", staticStringValues) && + isRuntimeEnvironmentOwner( + node.init, + runtimeIdentifierReferences, + staticStringValues + ) + ) { + return { line: sourceLine(node) }; + } + if ( + type === "AssignmentExpression" && + objectPatternReadsNamedProperty(node.left, "env", staticStringValues) && + isRuntimeEnvironmentOwner( + node.right, + runtimeIdentifierReferences, + staticStringValues + ) + ) { + return { line: sourceLine(node) }; + } + return undefined; +} + +/** + * Finds an alias, pass, return, or unresolved dynamic index of runtime authority. + * @param node Babel AST record. + * @param parent Parent AST record when present. + * @param runtimeIdentifierReferences Binding-aware global runtime references. + * @param staticStringValues Bounded computed-key values. + * @returns Runtime authority escape finding when present. + */ +export function runtimeOwnerEscapeFromNode( + node: AstRecord, + parent: AstRecord | undefined, + runtimeIdentifierReferences: RuntimeIdentifierReferences, + staticStringValues: StaticStringValues +): SourceRuntimeAuthorityEscape | undefined { + if ( + parent === undefined || + !isRuntimeAuthorityOwner(node, runtimeIdentifierReferences, staticStringValues) + ) { + return undefined; + } + const parentType = nodeType(parent); + if ( + identifierName(node) !== undefined && + (parentType === "MemberExpression" || + parentType === "OptionalMemberExpression") && + parent.object !== node && + parent.computed !== true + ) { + return undefined; + } + if ( + parentType === "VariableDeclarator" && + parent.init === node && + objectPatternOnlyReadsEnvironment(parent.id, staticStringValues) + ) { + return undefined; + } + if ( + parentType === "AssignmentExpression" && + parent.right === node && + objectPatternOnlyReadsEnvironment(parent.left, staticStringValues) + ) { + return undefined; + } + if ( + identifierName(node) !== undefined && + (parentType === "ObjectProperty" || parentType === "ObjectMethod") && + parent.key === node && + parent.value !== node && + parent.computed !== true + ) { + return undefined; + } + if ( + (parentType === "MemberExpression" || + parentType === "OptionalMemberExpression") && + parent.object === node + ) { + return parent.computed === true && + memberPropertyName(parent, staticStringValues) === undefined + ? { line: sourceLine(parent) } + : undefined; + } + if ( + parentType === "TSQualifiedName" || + parentType === "TSTypeQuery" || + parentType === "TSTypeReference" || + parentType === "TSExpressionWithTypeArguments" || + (parentType === "UnaryExpression" && parent.operator === "typeof") + ) { + return undefined; + } + return { line: sourceLine(node) }; +} diff --git a/scripts/sourceBoundaries/sourceAst.ts b/scripts/sourceBoundaries/sourceAst.ts new file mode 100644 index 000000000..8ce60b78e --- /dev/null +++ b/scripts/sourceBoundaries/sourceAst.ts @@ -0,0 +1,133 @@ +/** Minimal Babel AST record used by the source-boundary analyzers. */ +export type AstRecord = Record; + +/** Binding-aware references to runtime-owned global identifiers. */ +export type RuntimeIdentifierReferences = ReadonlySet; + +/** Bounded, immutable string values attached to identifier reference nodes. */ +export type StaticStringValues = ReadonlyMap; + +/** + * Returns whether an unknown value can be inspected as an AST record. + * @param value Unknown candidate value. + * @returns Whether the value is a non-null record. + */ +export function isRecord(value: unknown): value is AstRecord { + return typeof value === "object" && value !== null; +} + +/** + * Returns the Babel node discriminator when present. + * @param node Babel AST record. + * @returns Node type string when present. + */ +export function nodeType(node: AstRecord): string | undefined { + return typeof node.type === "string" ? node.type : undefined; +} + +/** + * Returns only a syntactic string-literal value. + * @param node Candidate Babel node. + * @returns Literal string value when the node is a string literal. + */ +export function stringLiteralValue(node: unknown): string | undefined { + if (!isRecord(node) || nodeType(node) !== "StringLiteral") return undefined; + return typeof node.value === "string" ? node.value : undefined; +} + +/** + * Returns an identifier name without treating arbitrary AST nodes as identifiers. + * @param node Candidate Babel node. + * @returns Identifier name when the node is an identifier. + */ +export function identifierName(node: unknown): string | undefined { + if (!isRecord(node) || nodeType(node) !== "Identifier") return undefined; + return typeof node.name === "string" ? node.name : undefined; +} + +const transparentExpressionTypes: ReadonlySet = new Set([ + "ParenthesizedExpression", + "TSAsExpression", + "TSInstantiationExpression", + "TSNonNullExpression", + "TSSatisfiesExpression", + "TSTypeAssertion", + "TypeCastExpression", +]); + +/** + * Folds only bounded string syntax used as a computed property key. + * Calls, interpolation, coercion, mutation, and general constant evaluation remain unresolved. + * @param node Candidate string expression. + * @param staticStringValues Binding-aware values for referenced constant identifiers. + * @returns Statically bounded string value when resolvable. + */ +export function staticStringValue( + node: unknown, + staticStringValues: StaticStringValues +): string | undefined { + if (!isRecord(node)) return undefined; + const literal = stringLiteralValue(node); + if (literal !== undefined) return literal; + const type = nodeType(node); + if (type === "Identifier") return staticStringValues.get(node); + if (transparentExpressionTypes.has(type ?? "")) { + return staticStringValue(node.expression, staticStringValues); + } + if (type === "BinaryExpression" && node.operator === "+") { + const left = staticStringValue(node.left, staticStringValues); + const right = staticStringValue(node.right, staticStringValues); + return left === undefined || right === undefined ? undefined : left + right; + } + if ( + type !== "TemplateLiteral" || + !Array.isArray(node.expressions) || + node.expressions.length > 0 || + !Array.isArray(node.quasis) || + node.quasis.length !== 1 || + !isRecord(node.quasis[0]) || + !isRecord(node.quasis[0].value) + ) { + return undefined; + } + const cooked = node.quasis[0].value.cooked; + if (typeof cooked === "string") return cooked; + const raw = node.quasis[0].value.raw; + return typeof raw === "string" ? raw : undefined; +} + +/** + * Returns a stable one-based source line for a node. + * @param node Babel AST record. + * @returns One-based source line, defaulting to one. + */ +export function sourceLine(node: AstRecord): number { + const location = node.loc; + if (!isRecord(location) || !isRecord(location.start)) return 1; + const line = location.start.line; + return typeof line === "number" && Number.isSafeInteger(line) && line > 0 ? line : 1; +} + +/** + * Returns call arguments without trusting an unknown AST shape. + * @param node Babel AST record. + * @returns Call arguments or an empty array. + */ +export function callArguments(node: AstRecord): readonly unknown[] { + return Array.isArray(node.arguments) ? node.arguments : []; +} + +/** + * Resolves a direct or bounded-computed member property name. + * @param node Member-expression AST record. + * @param staticStringValues Binding-aware values for referenced constant identifiers. + * @returns Resolved member property when bounded. + */ +export function memberPropertyName( + node: AstRecord, + staticStringValues: StaticStringValues +): string | undefined { + return node.computed === true + ? staticStringValue(node.property, staticStringValues) + : identifierName(node.property); +} diff --git a/scripts/sourceBoundaries/sourceBoundaryPaths.ts b/scripts/sourceBoundaries/sourceBoundaryPaths.ts new file mode 100644 index 000000000..e0d0662c1 --- /dev/null +++ b/scripts/sourceBoundaries/sourceBoundaryPaths.ts @@ -0,0 +1,41 @@ +import path from "node:path"; + +import type { SourceBoundaryViolation } from "./policyTypes.ts"; + +/** + * Normalizes a path for stable repository-relative diagnostics. + * @param filePath Candidate filesystem or repository path. + * @returns Forward-slash-normalized path. + */ +export function repositoryPath(filePath: string): string { + return filePath.replaceAll("\\", "/"); +} + +/** + * Returns whether a resolved candidate stays within a resolved container. + * @param container Resolved container path. + * @param candidate Resolved candidate path. + * @returns Whether the candidate remains contained. + */ +export function isContainedPath(container: string, candidate: string): boolean { + const relative = path.relative(container, candidate); + return ( + relative === "" || + (!path.isAbsolute(relative) && + !relative.startsWith(`..${path.sep}`) && + relative !== "..") + ); +} + +/** + * Creates a stable discovery/configuration violation at the owning path. + * @param importer Repository-relative owning path. + * @param message Actionable violation detail. + * @returns Stable source-boundary violation. + */ +export function boundaryPathViolation( + importer: string, + message: string +): SourceBoundaryViolation { + return { importer: repositoryPath(importer), line: 1, message }; +} diff --git a/scripts/sourceBoundaries/sourceDirectives.ts b/scripts/sourceBoundaries/sourceDirectives.ts new file mode 100644 index 000000000..97805fcfc --- /dev/null +++ b/scripts/sourceBoundaries/sourceDirectives.ts @@ -0,0 +1,70 @@ +import type { + SourceAmbientRuntimeDeclaration, + SourceReferenceDirective, + SourceTypeScriptSuppressionDirective, +} from "./importGraph.ts"; +import { isRecord, nodeType, sourceLine, type AstRecord } from "./sourceAst.ts"; + +/** + * Finds runtime-shaped ambient declarations that can restore forbidden globals. + * @param node Babel AST record. + * @returns Ambient runtime declaration finding when present. + */ +export function ambientRuntimeDeclarationFromNode( + node: AstRecord +): SourceAmbientRuntimeDeclaration | undefined { + const type = nodeType(node); + if ( + (type === "TSDeclareFunction" && node.declare === true) || + ((type === "VariableDeclaration" || + type === "ClassDeclaration" || + type === "TSEnumDeclaration" || + type === "TSModuleDeclaration") && + node.declare === true) || + (type === "TSModuleDeclaration" && node.global === true) + ) { + return { line: sourceLine(node) }; + } + return undefined; +} + +/** + * Finds TypeScript triple-slash references that alter per-file ambient authority. + * @param ast Parsed Babel file AST. + * @returns Triple-slash reference findings. + */ +export function referenceDirectives(ast: unknown): readonly SourceReferenceDirective[] { + if (!isRecord(ast) || !Array.isArray(ast.comments)) return []; + return ast.comments.flatMap((comment) => { + if ( + !isRecord(comment) || + nodeType(comment) !== "CommentLine" || + typeof comment.value !== "string" || + !/^[\t ]*\/[\t ]*]|$)/iu.test(comment.value) + ) { + return []; + } + return [{ line: sourceLine(comment) }]; + }); +} + +/** + * Finds TypeScript suppression comments that can hide erased runtime references. + * @param ast Parsed Babel file AST. + * @returns TypeScript suppression findings. + */ +export function typeScriptSuppressionDirectives( + ast: unknown +): readonly SourceTypeScriptSuppressionDirective[] { + if (!isRecord(ast) || !Array.isArray(ast.comments)) return []; + return ast.comments.flatMap((comment) => { + if ( + !isRecord(comment) || + typeof comment.value !== "string" || + !/@ts-(?:expect-error|ignore|nocheck)\b/u.test(comment.value) + ) { + return []; + } + return [{ line: sourceLine(comment) }]; + }); +} diff --git a/scripts/sourceBoundaries/sourceDiscovery.test.ts b/scripts/sourceBoundaries/sourceDiscovery.test.ts new file mode 100644 index 000000000..852e3cc47 --- /dev/null +++ b/scripts/sourceBoundaries/sourceDiscovery.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { checkSourceBoundaries } from "../checkSourceBoundaries.ts"; + +async function temporaryProject(): Promise { + const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-source-boundary-")); + await mkdir(path.join(projectRoot, "scripts")); + await mkdir(path.join(projectRoot, "src", "browser"), { recursive: true }); + await writeFile(path.join(projectRoot, "package.json"), "{}"); + return projectRoot; +} + +describe("source-boundary repository discovery", () => { + test("discovers and fails closed outside strict TS and TSX graphs", async () => { + const projectRoot = await temporaryProject(); + try { + const extensions = [ + "cjs", + "cts", + "js", + "jsx", + "mjs", + "mts", + "ts", + "tsx", + ] as const; + for (const extension of extensions) { + await writeFile( + path.join(projectRoot, "src", "browser", `forbidden.${extension}`), + 'const server = require("../server/private.ts"); void server;' + ); + } + + const violations = await checkSourceBoundaries(projectRoot); + + for (const extension of extensions) { + expect( + violations.some( + (violation) => + violation.importer === `src/browser/forbidden.${extension}` && + (extension === "ts" || extension === "tsx" + ? violation.message.includes( + "browser may not import server" + ) + : violation.message.includes("must use .ts or .tsx")) + ) + ).toBe(true); + } + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("discovers the exact executable Tailwind root configuration", async () => { + const projectRoot = await temporaryProject(); + try { + await writeFile( + path.join(projectRoot, "tailwind.config.ts"), + 'import "./src/browser/client.ts"; export default {};' + ); + + const violations = await checkSourceBoundaries(projectRoot); + + expect( + violations.some( + (violation) => + violation.importer === "tailwind.config.ts" && + violation.message.includes("scripts may not import browser") + ) + ).toBe(true); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("fails closed on unreviewed repository-root executable sources", async () => { + const projectRoot = await temporaryProject(); + try { + const unreviewedSources = [ + "unknown.cjs", + "unknown.cts", + "vite.config.js", + "unknown.jsx", + "unknown.mjs", + "unknown.mts", + "evil.spec.ts", + "evil.test.ts", + "foo.ts", + "unknown.tsx", + ] as const; + for (const source of unreviewedSources) { + await writeFile(path.join(projectRoot, source), "export default {};"); + } + await writeFile( + path.join(projectRoot, "drizzle.config.ts"), + "export default {};" + ); + await writeFile( + path.join(projectRoot, "tailwind.config.ts"), + "export default {};" + ); + + const violations = await checkSourceBoundaries(projectRoot); + + for (const importer of unreviewedSources) { + expect( + violations.some( + (violation) => + violation.importer === importer && + violation.message.includes("explicit reviewed process role") + ) + ).toBe(true); + } + for (const importer of ["drizzle.config.ts", "tailwind.config.ts"] as const) { + expect( + violations.some((violation) => violation.importer === importer) + ).toBe(false); + } + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects an unknown empty repository-root directory", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "tools")); + + const violations = await checkSourceBoundaries(projectRoot); + + expect( + violations.some( + (violation) => + violation.importer === "tools" && + violation.message.includes("exact reviewed project layout") + ) + ).toBe(true); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects executable source hidden in an unknown root directory", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "tools")); + await writeFile( + path.join(projectRoot, "tools", "evil.ts"), + "export const escaped = true;" + ); + + const violations = await checkSourceBoundaries(projectRoot); + + expect( + violations.some( + (violation) => + violation.importer === "tools" && + violation.message.includes("exact reviewed project layout") + ) + ).toBe(true); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects non-browser TSX that is outside the strict partitions", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "src", "shared")); + await writeFile( + path.join(projectRoot, "src", "shared", "outsideGraph.tsx"), + "export const outsideGraph =
;" + ); + await writeFile( + path.join(projectRoot, "scripts", "outsideGraph.tsx"), + "export const outsideGraph =
;" + ); + + const violations = await checkSourceBoundaries(projectRoot); + + for (const importer of [ + "scripts/outsideGraph.tsx", + "src/shared/outsideGraph.tsx", + ]) { + expect( + violations.some( + (violation) => + violation.importer === importer && + violation.message.includes("Only browser source may use .tsx") + ) + ).toBe(true); + } + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("allows only app composition tests assigned to the strict graph", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "src", "app", "__tests__"), { + recursive: true, + }); + const allowlistedFiles = [ + "dashboardServer.test.ts", + "trpcHttpHandler.test.ts", + "trpcRequestPolicy.test.ts", + ] as const; + const unassignedFiles = [ + "future.test.ts", + "future.spec.ts", + "__tests__/future.ts", + ] as const; + for (const file of [...allowlistedFiles, ...unassignedFiles]) { + await writeFile(path.join(projectRoot, "src", "app", file), "export {};"); + } + + const violations = await checkSourceBoundaries(projectRoot); + + for (const file of allowlistedFiles) { + expect( + violations.some( + (violation) => violation.importer === `src/app/${file}` + ) + ).toBe(false); + } + for (const file of unassignedFiles) { + expect( + violations.some( + (violation) => + violation.importer === `src/app/${file}` && + violation.message.includes("explicitly classified") + ) + ).toBe(true); + } + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects symbolic-link files and directories without following them", async () => { + const projectRoot = await temporaryProject(); + const externalRoot = await mkdtemp(path.join(tmpdir(), "mira-source-external-")); + try { + const externalFile = path.join(externalRoot, "external.ts"); + const externalDirectory = path.join(externalRoot, "directory"); + await writeFile(externalFile, "export const external = true;"); + await mkdir(externalDirectory); + await writeFile( + path.join(externalDirectory, "external.ts"), + "export const external = true;" + ); + await symlink( + externalFile, + path.join(projectRoot, "src", "browser", "linked.ts") + ); + await symlink( + externalDirectory, + path.join(projectRoot, "src", "browser", "linkedDirectory") + ); + + const violations = await checkSourceBoundaries(projectRoot); + + expect( + violations.some( + (violation) => + violation.importer === "src/browser/linked.ts" && + violation.message === + "Production source paths may not be symbolic links" + ) + ).toBe(true); + expect( + violations.some( + (violation) => + violation.importer === "src/browser/linkedDirectory" && + violation.message === + "Production source paths may not be symbolic links" + ) + ).toBe(true); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + await rm(externalRoot, { force: true, recursive: true }); + } + }); + + test("rejects nested package and project resolver metadata", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "src", "browser", "alias")); + await mkdir(path.join(projectRoot, "src", "server")); + await writeFile( + path.join(projectRoot, "src", "browser", "entry.ts"), + 'import "./alias";' + ); + await writeFile( + path.join(projectRoot, "src", "browser", "alias", "package.json"), + JSON.stringify({ + main: "../../server/private.ts", + module: "../../server/private.ts", + }) + ); + await writeFile( + path.join(projectRoot, "src", "browser", "tsconfig.paths.json"), + JSON.stringify({ + compilerOptions: { paths: { "safe/*": ["../server/*"] } }, + }) + ); + await writeFile( + path.join(projectRoot, "src", "browser", "bunfig.toml"), + "[install]\nproduction = true\n" + ); + await writeFile( + path.join(projectRoot, "src", "server", "private.ts"), + "export const privateValue = true;" + ); + + const violations = await checkSourceBoundaries(projectRoot); + for (const importer of [ + "src/browser/alias/package.json", + "src/browser/bunfig.toml", + "src/browser/tsconfig.paths.json", + ]) { + expect( + violations.some( + (violation) => + violation.importer === importer && + violation.message.includes("Nested source resolver metadata") + ) + ).toBe(true); + } + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); +}); diff --git a/scripts/sourceBoundaries/sourceDiscovery.ts b/scripts/sourceBoundaries/sourceDiscovery.ts new file mode 100644 index 000000000..d4ae69443 --- /dev/null +++ b/scripts/sourceBoundaries/sourceDiscovery.ts @@ -0,0 +1,229 @@ +import { lstat, readdir, realpath } from "node:fs/promises"; +import path from "node:path"; + +import type { SourceBoundaryViolation } from "./policyTypes.ts"; +import { + boundaryPathViolation, + isContainedPath, + repositoryPath, +} from "./sourceBoundaryPaths.ts"; + +const sourceExtensionPattern = /\.(?:cjs|cts|js|jsx|mjs|mts|ts|tsx)$/u; +const nestedResolverMetadataPattern = + /^(?:bunfig\.toml|(?:js|ts)config(?:\.[A-Za-z0-9_-]+)*\.json|package\.json)$/u; +const reviewedRootDirectories: ReadonlySet = new Set([ + ".git", + ".github", + "backend", + "contracts", + "coverage", + "dist", + "docs", + "frontend", + "migrations", + "node_modules", + "qualification", + "scripts", + "src", + "systemd", + "test", +]); + +/** Discovered executable sources plus fail-closed repository-layout findings. */ +export interface SourceDiscovery { + readonly files: readonly string[]; + readonly violations: readonly SourceBoundaryViolation[]; +} + +async function discoverDirectory( + lexicalProjectRoot: string, + realProjectRoot: string, + relativeDirectory: string, + files: Set, + violations: SourceBoundaryViolation[] +): Promise { + const absoluteDirectory = path.join(lexicalProjectRoot, relativeDirectory); + const directoryStatus = await lstat(absoluteDirectory); + if (directoryStatus.isSymbolicLink()) { + violations.push( + boundaryPathViolation( + relativeDirectory, + "Production source directories may not be symbolic links" + ) + ); + return; + } + const resolvedDirectory = await realpath(absoluteDirectory); + if (!isContainedPath(realProjectRoot, resolvedDirectory)) { + violations.push( + boundaryPathViolation( + relativeDirectory, + "Production source real path escapes the repository" + ) + ); + return; + } + + const directoryEntries = await readdir(absoluteDirectory, { withFileTypes: true }); + const entries = directoryEntries.toSorted((left, right) => + left.name.localeCompare(right.name) + ); + for (const entry of entries) { + const relativePath = repositoryPath(path.join(relativeDirectory, entry.name)); + const absolutePath = path.join(lexicalProjectRoot, relativePath); + const status = await lstat(absolutePath); + if (status.isSymbolicLink()) { + violations.push( + boundaryPathViolation( + relativePath, + "Production source paths may not be symbolic links" + ) + ); + continue; + } + if (nestedResolverMetadataPattern.test(entry.name)) { + violations.push( + boundaryPathViolation( + relativePath, + "Nested source resolver metadata is forbidden; only reviewed repository-root configuration may control resolution" + ) + ); + continue; + } + if (status.isDirectory()) { + await discoverDirectory( + lexicalProjectRoot, + realProjectRoot, + relativePath, + files, + violations + ); + continue; + } + if (!sourceExtensionPattern.test(entry.name)) continue; + if (!status.isFile()) { + violations.push( + boundaryPathViolation( + relativePath, + "Production source paths must be regular files" + ) + ); + continue; + } + const resolvedFile = await realpath(absolutePath); + if (!isContainedPath(realProjectRoot, resolvedFile)) { + violations.push( + boundaryPathViolation( + relativePath, + "Production source real path escapes the repository" + ) + ); + continue; + } + files.add(relativePath); + } +} + +async function discoverRootSources( + lexicalProjectRoot: string, + realProjectRoot: string, + files: Set, + violations: SourceBoundaryViolation[] +): Promise { + const rootEntries = await readdir(lexicalProjectRoot, { withFileTypes: true }); + const sourceEntries = rootEntries + .filter((entry) => sourceExtensionPattern.test(entry.name)) + .toSorted((left, right) => left.name.localeCompare(right.name)); + for (const entry of sourceEntries) { + const relativePath = entry.name; + const absolutePath = path.join(lexicalProjectRoot, relativePath); + const status = await lstat(absolutePath); + if (status.isSymbolicLink()) { + violations.push( + boundaryPathViolation( + relativePath, + "Production source paths may not be symbolic links" + ) + ); + continue; + } + if (!status.isFile()) { + violations.push( + boundaryPathViolation( + relativePath, + "Production source paths must be regular files" + ) + ); + continue; + } + const resolvedFile = await realpath(absolutePath); + if (!isContainedPath(realProjectRoot, resolvedFile)) { + violations.push( + boundaryPathViolation( + relativePath, + "Production source real path escapes the repository" + ) + ); + continue; + } + files.add(relativePath); + } +} + +async function validateRootDirectoryLayout( + lexicalProjectRoot: string, + violations: SourceBoundaryViolation[] +): Promise { + const rootEntries = await readdir(lexicalProjectRoot, { withFileTypes: true }); + const entries = rootEntries.toSorted((left, right) => + left.name.localeCompare(right.name) + ); + for (const entry of entries) { + if (entry.isSymbolicLink()) { + violations.push( + boundaryPathViolation( + entry.name, + "Repository-root symbolic links are forbidden until explicitly reviewed" + ) + ); + continue; + } + if (entry.isDirectory() && !reviewedRootDirectories.has(entry.name)) { + violations.push( + boundaryPathViolation( + entry.name, + "Repository-root directories must belong to the exact reviewed project layout" + ) + ); + } + } +} + +/** + * Discovers every reviewed production/script source without following symlinks. + * @param projectRoot Absolute repository root. + * @returns Sorted sources and fail-closed layout findings. + */ +export async function discoverSourceFiles(projectRoot: string): Promise { + const lexicalProjectRoot = path.resolve(projectRoot); + const realProjectRoot = await realpath(lexicalProjectRoot); + const files = new Set(); + const violations: SourceBoundaryViolation[] = []; + await validateRootDirectoryLayout(lexicalProjectRoot, violations); + for (const directory of ["scripts", "src"] as const) { + await discoverDirectory( + lexicalProjectRoot, + realProjectRoot, + directory, + files, + violations + ); + } + await discoverRootSources(lexicalProjectRoot, realProjectRoot, files, violations); + return { + files: [...files].toSorted(), + violations: violations.toSorted((left, right) => + left.importer.localeCompare(right.importer) + ), + }; +} diff --git a/scripts/sourceBoundaries/sourceTopologyPolicy.ts b/scripts/sourceBoundaries/sourceTopologyPolicy.ts new file mode 100644 index 000000000..812ff80c4 --- /dev/null +++ b/scripts/sourceBoundaries/sourceTopologyPolicy.ts @@ -0,0 +1,199 @@ +import path from "node:path"; + +/** Explicit process or architectural role assigned to a scanned source path. */ +export type SourceRole = + | "browser" + | "browser-app" + | "contracts" + | "environment-source" + | "legacy-backend" + | "legacy-frontend" + | "scripts" + | "server" + | "shared" + | "test" + | "unclassified-app" + | "unknown" + | "web-app" + | "worker" + | "worker-app"; + +const webApplicationFiles = new Set([ + "src/app/dashboardServer.ts", + "src/app/server.ts", + "src/app/trpcHttpHandler.ts", + "src/app/trpcRequestPolicy.ts", +]); + +const applicationCompositionTestFiles: ReadonlySet = new Set([ + "src/app/dashboardServer.test.ts", + "src/app/trpcHttpHandler.test.ts", + "src/app/trpcRequestPolicy.test.ts", +]); + +/** Composition-owned runtime environment source. */ +export const environmentSourceFile = "src/app/environmentSource.ts"; + +/** Exact composition roots permitted to import the runtime environment source. */ +export const environmentSourceConsumers: ReadonlySet = new Set([ + "src/app/dashboardServer.ts", + "src/app/worker.ts", +]); + +/** + * Creates a stable key for one script edge into a legacy implementation. + * @param importer Normalized script importer. + * @param target Normalized legacy target. + * @returns Stable allowlist key. + */ +export function legacyEdge(importer: string, target: string): string { + return `${importer}\0${target}`; +} + +/** Exact coexistence edges into the legacy implementation. New edges are rejected. */ +export const legacyScriptImportAllowlist: ReadonlySet = new Set([ + legacyEdge("scripts/buildBackend.ts", "backend/src/services/releases/runtime.ts"), + legacyEdge("scripts/developmentFrontend.ts", "frontend/index.html"), + legacyEdge( + "scripts/developmentFrontend.ts", + "frontend/src/lib/developmentProxyHeaders.ts" + ), + legacyEdge( + "scripts/developmentStack.ts", + "backend/src/development/developmentEnvironment.ts" + ), + legacyEdge( + "scripts/developmentStack.ts", + "backend/src/development/developmentRuntime.ts" + ), + legacyEdge( + "scripts/developmentStack.ts", + "backend/src/development/developmentStackConfig.ts" + ), + legacyEdge( + "scripts/developmentStack.ts", + "backend/src/development/developmentState.ts" + ), + legacyEdge("scripts/frontendBuild.ts", "backend/src/services/releases/runtime.ts"), + legacyEdge("scripts/productionBootstrap.ts", "backend/src/database/connection.ts"), + legacyEdge("scripts/productionBootstrap.ts", "backend/src/lib/dashboardPaths.ts"), + legacyEdge("scripts/productionBootstrap.ts", "backend/src/lib/processes.ts"), + legacyEdge("scripts/productionBootstrap.ts", "backend/src/lib/systemdProperties.ts"), + legacyEdge("scripts/productionBootstrap.ts", "backend/src/releaseLifecycle.ts"), + legacyEdge( + "scripts/productionBootstrap.ts", + "backend/src/services/releases/deployment.ts" + ), + legacyEdge( + "scripts/productionBootstrap.ts", + "backend/src/services/releases/releaseActivation.ts" + ), + legacyEdge( + "scripts/productionBootstrap.ts", + "backend/src/services/releases/systemdPolicy.ts" + ), + legacyEdge( + "scripts/qualification/legacyBackendRouteProbe.ts", + "backend/src/routes/registry.ts" + ), + legacyEdge( + "scripts/writeReleaseManifest.ts", + "backend/src/services/releases/manifestArtifacts.ts" + ), +]); + +/** Reviewed dependency-direction matrix for every source role. */ +export const allowedTargets: Readonly>> = { + browser: new Set(["browser", "contracts", "shared"]), + "browser-app": new Set(["browser", "browser-app", "contracts", "shared"]), + contracts: new Set(["contracts", "shared"]), + "environment-source": new Set(["shared"]), + "legacy-backend": new Set(), + "legacy-frontend": new Set(), + scripts: new Set(["contracts", "scripts", "shared"]), + server: new Set(["contracts", "server", "shared"]), + shared: new Set(["shared"]), + test: new Set([ + "browser", + "browser-app", + "contracts", + "scripts", + "server", + "shared", + "test", + "web-app", + "worker", + "worker-app", + ]), + "unclassified-app": new Set(), + unknown: new Set(), + "web-app": new Set(["contracts", "server", "shared", "web-app"]), + worker: new Set(["contracts", "shared", "worker"]), + "worker-app": new Set(["contracts", "shared", "worker", "worker-app"]), +}; + +/** + * Normalizes a repository-relative source path for policy evaluation. + * @param filePath Candidate repository-relative path. + * @returns Canonical forward-slash path without a leading dot segment. + */ +export function normalizeRepositoryPath(filePath: string): string { + return filePath.replaceAll("\\", "/").replace(/^\.\//u, ""); +} + +/** + * Resolves a relative import lexically within repository path semantics. + * @param importer Normalized repository-relative importer. + * @param specifier Relative module specifier. + * @returns Normalized repository-relative lexical target. + */ +export function relativeImportTarget(importer: string, specifier: string): string { + const importerDirectory = path.posix.dirname(importer); + const targetPath = path.posix.join(importerDirectory, specifier); + return normalizeRepositoryPath(path.posix.normalize(targetPath)); +} + +/** + * Identifies source and target paths reserved for tests or test support. + * @param filePath Normalized repository-relative path. + * @returns Whether the path belongs to test-only source. + */ +export function isTestPath(filePath: string): boolean { + return ( + /(?:^|\/)(?:__tests__|test(?:Support)?)\//u.test(filePath) || + /\.(?:spec|test)\.[cm]?[jt]sx?$/u.test(filePath) + ); +} + +/** + * Classifies one normalized repository path into its explicit source role. + * @param filePath Normalized repository-relative path. + * @returns Explicit process or architectural source role. + */ +export function sourceRole(filePath: string): SourceRole { + if (applicationCompositionTestFiles.has(filePath)) return "test"; + if (filePath.startsWith("src/app/") && isTestPath(filePath)) { + return "unclassified-app"; + } + if (isTestPath(filePath)) return "test"; + if (filePath === environmentSourceFile) return "environment-source"; + if (webApplicationFiles.has(filePath)) return "web-app"; + if (filePath === "src/app/browser.tsx") return "browser-app"; + if (filePath === "src/app/worker.ts") return "worker-app"; + if (filePath.startsWith("src/app/")) return "unclassified-app"; + if (filePath.startsWith("src/browser/")) return "browser"; + if (filePath.startsWith("src/contracts/")) return "contracts"; + if (filePath.startsWith("src/server/")) return "server"; + if (filePath.startsWith("src/shared/")) return "shared"; + if (filePath.startsWith("src/worker/")) return "worker"; + if ( + filePath.startsWith("scripts/") || + filePath === "tailwind.config.ts" || + /^drizzle\.config\.(?:cjs|cts|js|jsx|mjs|mts|ts|tsx)$/u.test(filePath) + ) { + return "scripts"; + } + if (filePath.startsWith("backend/")) return "legacy-backend"; + if (filePath.startsWith("frontend/")) return "legacy-frontend"; + return "unknown"; +} diff --git a/src/app/environmentSource.ts b/src/app/environmentSource.ts new file mode 100644 index 000000000..14c3d3252 --- /dev/null +++ b/src/app/environmentSource.ts @@ -0,0 +1,27 @@ +import { + type ApplicationConfigurationEnvironmentName, + type ApplicationProcessRole, + configurationEnvironmentNamesForRole, +} from "../shared/configuration/applicationConfigurationRegistry.ts"; + +export type ApplicationEnvironmentSource = Readonly< + Partial> +>; + +/** + * Reads only registered process-environment keys for a process composition root. + * The source-boundary gate permits imports only from the web and worker roots. + * @param role Web or worker composition role. + * @returns Frozen, null-prototype projection of that role's registered environment surface. + */ +export function environmentSource( + role: Extract +): ApplicationEnvironmentSource { + const environment = Object.create(null) as Partial< + Record + >; + for (const environmentName of configurationEnvironmentNamesForRole(role)) { + environment[environmentName] = process.env[environmentName]; + } + return Object.freeze(environment); +} diff --git a/src/app/server.ts b/src/app/server.ts index 71706b444..a7f725076 100644 --- a/src/app/server.ts +++ b/src/app/server.ts @@ -35,7 +35,7 @@ const serverGracefulShutdownTimeoutSchema = v.pipe( async function primaryErrorAfterCleanup( primaryError: unknown, - cleanup: () => Promise + cleanup: () => Promise | void ): Promise { try { await cleanup(); @@ -46,6 +46,61 @@ async function primaryErrorAfterCleanup( return primaryError; } +async function disposeRuntimeAndFlush( + applicationRuntime: ApplicationRuntime +): Promise { + try { + await applicationRuntime.dispose(); + } catch (error) { + throw await primaryErrorAfterCleanup(error, () => { + applicationRuntime.logger.flush(); + }); + } + applicationRuntime.logger.flush(); +} + +function responseWithRequestId(response: Response, requestId: string): Response { + const headers = new Headers(response.headers); + headers.set("x-request-id", requestId); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + +function requestDurationMs(startedAtMs: number): number { + const elapsedMs = performance.now() - startedAtMs; + if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return 0; + return Math.min(Number.MAX_SAFE_INTEGER, Math.round(elapsedMs)); +} + +function requestOutcome(status: number): "rejected" | "server-error" | "success" { + if (status >= 500) return "server-error"; + if (status >= 400) return "rejected"; + return "success"; +} + +function internalServerErrorResponse(requestId: string): Response { + return new Response("Internal Server Error", { + headers: { + "cache-control": "no-store", + "x-request-id": requestId, + }, + status: 500, + }); +} + +function cancelledRequestResponse(requestId: string): Response { + return new Response(null, { + headers: { + "cache-control": "no-store", + "x-request-id": requestId, + }, + status: 499, + }); +} + export { authenticationRequestBodyMaximumBytes, serverRequestBodyMaximumBytes, @@ -86,6 +141,7 @@ export interface ApplicationServer { */ export async function createServer(options: ServerOptions): Promise { try { + const logger = options.applicationRuntime.logger; readRuntimeIdentity(); const gracefulShutdownTimeoutMs = v.parse( serverGracefulShutdownTimeoutSchema, @@ -109,22 +165,86 @@ export async function createServer(options: ServerOptions): Promise - options.applicationRuntime.dispose() + disposeRuntimeAndFlush(options.applicationRuntime) ); } } diff --git a/src/app/trpcHttpHandler.test.ts b/src/app/trpcHttpHandler.test.ts index dd3ee36cf..7a39350e5 100644 --- a/src/app/trpcHttpHandler.test.ts +++ b/src/app/trpcHttpHandler.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { createStructuredLogger } from "../server/platform/observability/structuredLogger.ts"; import { createTestApplicationRuntime, createTestServerSecurityServices, @@ -27,6 +28,7 @@ interface EarlyRejectionExpectation { async function expectEarlyRejectionCancelsBody( input: EarlyRejectionExpectation ): Promise { + const logLines: string[] = []; const cancellationReasons: unknown[] = []; const body = new ReadableStream({ cancel(reason) { @@ -41,19 +43,40 @@ async function expectEarlyRejectionCancelsBody( ...(input.headers === undefined ? {} : { headers: input.headers }), method: "POST", }); + const logger = createStructuredLogger({ + identity: { + bun: "1.4.0-test", + pid: 123, + processRole: "web", + release: "handler-test", + service: "mira-dashboard", + }, + sink: { + write(line) { + logLines.push(line); + }, + }, + }); const handler = createTrpcHttpHandler({ ...createTestServerSecurityServices(), - applicationRuntime: createTestApplicationRuntime(), + applicationRuntime: createTestApplicationRuntime({ logger }), ...(input.browserOrigin === undefined ? {} : { browserOrigin: input.browserOrigin }), }); - const response = await handler(request, new URL(request.url), unreachableBunServer); + const response = await handler( + request, + new URL(request.url), + unreachableBunServer, + "01900000-0000-7000-8000-000000000001" + ); expect(response.status).toBe(input.expectedStatus); expect(await response.text()).toBe(input.expectedBody); expect(cancellationReasons).toEqual([input.expectedCancellationReason]); + expect(response.headers.get("x-request-id")).toBeNull(); + expect(logLines).toEqual([]); } describe("tRPC HTTP handler early rejection", () => { @@ -90,3 +113,56 @@ describe("tRPC HTTP handler early rejection", () => { path: "/trpc/auth.login?batch=1", })); }); + +test("redacts an unexpected context defect through the tRPC boundary", async () => { + const sentinel = "context-failure-secret"; + const logLines: string[] = []; + const request = new Request("https://dashboard.example/trpc/auth.status"); + const logger = createStructuredLogger({ + identity: { + bun: "1.4.0-test", + pid: 123, + processRole: "web", + release: "handler-test", + service: "mira-dashboard", + }, + sink: { + write(line) { + logLines.push(line); + }, + }, + }); + const handler = createTrpcHttpHandler({ + ...createTestServerSecurityServices(), + applicationRuntime: createTestApplicationRuntime({ logger }), + authenticateCredential() { + throw new Error(sentinel); + }, + }); + const bunServer = { + requestIP: () => ({ address: "127.0.0.1" }), + timeout(_request: Request, seconds: number) { + expect(seconds).toBeGreaterThan(0); + }, + }; + + const response = await handler( + request, + new URL(request.url), + bunServer, + "01900000-0000-7000-8000-000000000001" + ); + const records = logLines.map((line) => JSON.parse(line) as Record); + + expect(response.status).toBe(500); + expect(response.headers.get("x-request-id")).toBeNull(); + expect(records).toHaveLength(1); + expect(JSON.stringify(records)).not.toContain(sentinel); + expect(records[0]).toMatchObject({ + event: "trpc.request.defect", + outcome: "server-error", + }); + expect(records[0]).toMatchObject({ + requestId: "01900000-0000-7000-8000-000000000001", + }); +}); diff --git a/src/app/trpcHttpHandler.ts b/src/app/trpcHttpHandler.ts index 863e5c4b2..901a2b11b 100644 --- a/src/app/trpcHttpHandler.ts +++ b/src/app/trpcHttpHandler.ts @@ -140,10 +140,11 @@ export function createTrpcHttpHandler(options: TrpcHttpHandlerOptions) { trustedProxyAddresses: options.trustedProxyAddresses, }); - return async function handleTrpcHttpRequest( + async function dispatchTrpcHttpRequest( request: Request, requestUrl: URL, - bunServer: TrpcBunServer + bunServer: TrpcBunServer, + requestId: string ): Promise { if (!isAllowedRequestSource(request, options.browserOrigin)) { await cancelRequestBody(request, "tRPC request source is forbidden"); @@ -207,15 +208,46 @@ export function createTrpcHttpHandler(options: TrpcHttpHandlerOptions) { mfaLoginLifecycle: options.mfaLoginLifecycle, pendingLoginCredential: credentials.pendingLogin, request: req, + requestId, responseHeaders: resHeaders, }), endpoint: trpcEndpoint, maxBatchSize: trpcMaximumBatchSize, + onError: ({ error, path, type }) => { + if ( + error.code !== "INTERNAL_SERVER_ERROR" || + request.signal.aborted || + adapterRequest.signal.aborted + ) { + return; + } + options.applicationRuntime.logger.error({ + component: "trpc", + event: "trpc.request.defect", + failure: error.cause ?? error, + fields: { + kind: "trpc-defect", + path, + procedureType: type, + }, + outcome: "server-error", + requestId, + }); + }, req: adapterRequest, responseMeta: () => ({ headers: new Headers({ "cache-control": "no-store" }), }), router: appRouter, }); + } + + return async function handleTrpcHttpRequest( + request: Request, + requestUrl: URL, + bunServer: TrpcBunServer, + requestId: string + ): Promise { + return dispatchTrpcHttpRequest(request, requestUrl, bunServer, requestId); }; } diff --git a/src/contracts/contractRegistry.test.ts b/src/contracts/contractRegistry.test.ts new file mode 100644 index 000000000..a47e43a7c --- /dev/null +++ b/src/contracts/contractRegistry.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; + +import { procedureContracts } from "./contractRegistry.ts"; +import { + assertProcedureContractErrors, + contractErrorCodes, + type ProcedureContract, +} from "./registry.ts"; + +test("registers one sorted stable expected-error vocabulary", () => { + expect([...contractErrorCodes]).toEqual([...contractErrorCodes].toSorted()); + expect(new Set(contractErrorCodes).size).toBe(contractErrorCodes.length); + expect(new Set(contractErrorCodes).has("INTERNAL_SERVER_ERROR")).toBe(false); + expect(() => assertProcedureContractErrors(procedureContracts)).not.toThrow(); +}); + +test("rejects duplicate, unsorted, and unregistered procedure errors", () => { + const invalid = [ + { errors: ["UNAUTHORIZED", "FORBIDDEN"], name: "unsorted" }, + { errors: ["FORBIDDEN", "FORBIDDEN"], name: "duplicate" }, + { errors: ["INTERNAL_SERVER_ERROR"], name: "unregistered" }, + ]; + + for (const contract of invalid) { + expect(() => + assertProcedureContractErrors([ + contract as unknown as Pick, + ]) + ).toThrow(`Procedure contract errors are invalid for ${contract.name}`); + } +}); diff --git a/src/contracts/contractRegistry.ts b/src/contracts/contractRegistry.ts index 3b6233221..d649915fc 100644 --- a/src/contracts/contractRegistry.ts +++ b/src/contracts/contractRegistry.ts @@ -2,21 +2,24 @@ import { accountSecurityProcedureContracts } from "./accountSecurity.ts"; import { authProcedureContracts } from "./auth.ts"; import { automationSecurityProcedureContracts } from "./automationSecurity.ts"; import { eventsStreamContract } from "./events.ts"; -import type { - ProcedureContract, - RawHttpContract, - RealtimeEventContract, +import { + assertProcedureContractErrors, + type ProcedureContract, + type RawHttpContract, + type RealtimeEventContract, } from "./registry.ts"; import { systemProcedureContracts, systemRawHttpContracts } from "./system.ts"; /** Implemented tRPC procedure metadata used by runtime wiring and docs. */ -export const procedureContracts: readonly ProcedureContract[] = [ +const registeredProcedureContracts: readonly ProcedureContract[] = [ ...accountSecurityProcedureContracts, ...authProcedureContracts, ...automationSecurityProcedureContracts, eventsStreamContract, ...systemProcedureContracts, ]; +assertProcedureContractErrors(registeredProcedureContracts); +export const procedureContracts = Object.freeze(registeredProcedureContracts); /** Implemented raw HTTP metadata used by runtime wiring and docs. */ export const rawHttpContracts: readonly RawHttpContract[] = [...systemRawHttpContracts]; diff --git a/src/contracts/registry.ts b/src/contracts/registry.ts index b8fb0b434..a2ac9d3dd 100644 --- a/src/contracts/registry.ts +++ b/src/contracts/registry.ts @@ -3,6 +3,20 @@ import type * as v from "valibot"; /** Schema type accepted by the contract documentation generator. */ export type ContractSchema = v.GenericSchema; +/** Exhaustive expected tRPC codes that procedures may intentionally expose. */ +export const contractErrorCodes = [ + "BAD_REQUEST", + "CONFLICT", + "FORBIDDEN", + "NOT_FOUND", + "PRECONDITION_FAILED", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", +] as const; + +export type ContractErrorCode = (typeof contractErrorCodes)[number]; + /** Stable client-action reasons attached to authentication policy errors. */ export const contractAuthenticationErrorReasons = [ "mfa_enrollment_required", @@ -43,7 +57,7 @@ export interface ProcedureContract { access: ContractAccess; domain: string; errorReasons?: readonly ContractAuthenticationErrorReason[]; - errors: readonly string[]; + errors: readonly ContractErrorCode[]; input: ContractSchema; inputSchemaId: string; kind: "mutation" | "query" | "subscription"; @@ -54,6 +68,28 @@ export interface ProcedureContract { transport: ProcedureTransportContract; } +/** + * Fails closed when contract error metadata is unregistered, duplicated, or unstable. + * @param contracts Procedure names and their declared expected error codes. + */ +export function assertProcedureContractErrors( + contracts: readonly Pick[] +): void { + const registered = new Set(contractErrorCodes); + for (const contract of contracts) { + const errors = [...contract.errors]; + if ( + errors.some((error) => !registered.has(error)) || + new Set(errors).size !== errors.length || + errors.join("\n") !== errors.toSorted().join("\n") + ) { + throw new TypeError( + `Procedure contract errors are invalid for ${contract.name}` + ); + } + } +} + /** Response-body contract for one raw HTTP operation. */ export type RawHttpResponseContract = | { kind: "none" } diff --git a/src/server/domains/realtime/procedures.test.ts b/src/server/domains/realtime/procedures.test.ts index 487e95454..c8bfdbd1b 100644 --- a/src/server/domains/realtime/procedures.test.ts +++ b/src/server/domains/realtime/procedures.test.ts @@ -155,7 +155,7 @@ describe("events.stream procedure", () => { expect((failures[2] as TRPCError).code).toBe("FORBIDDEN"); }); - test("maps typed Effect stream failures and preserves unknown defects", async () => { + test("maps typed Effect stream failures and contains unknown defects", async () => { const typedFailure = new RealtimeEventStoreStreamError({ message: "internal store detail", }); @@ -163,7 +163,7 @@ describe("events.stream procedure", () => { for (const [failure, expected] of [ [typedFailure, "SERVICE_UNAVAILABLE"], - [defect, undefined], + [defect, "INTERNAL_SERVER_ERROR"], ] as const) { const runtime = createTestApplicationRuntime({ stream: () => @@ -186,12 +186,12 @@ describe("events.stream procedure", () => { ) ); - if (expected === undefined) { - expect(observed).toBe(defect); - } else { - expect(observed).toBeInstanceOf(TRPCError); - expect((observed as TRPCError).code).toBe(expected); + expect(observed).toBeInstanceOf(TRPCError); + expect((observed as TRPCError).code).toBe(expected); + if (expected === "SERVICE_UNAVAILABLE") { expect((observed as TRPCError).message).not.toContain("internal"); + } else { + expect((observed as TRPCError).cause).toBe(defect); } } }); diff --git a/src/server/domains/security/authenticationLifecycle.rateLimit.test.ts b/src/server/domains/security/authenticationLifecycle.rateLimit.test.ts index a11b70448..947fa8a29 100644 --- a/src/server/domains/security/authenticationLifecycle.rateLimit.test.ts +++ b/src/server/domains/security/authenticationLifecycle.rateLimit.test.ts @@ -4,6 +4,7 @@ import { Effect, Layer, Stream } from "effect"; import { RealtimeEventPumpService } from "../../platform/realtime/eventPumpService.ts"; import { createApplicationRuntime } from "../../platform/runtime/applicationRuntime.ts"; +import { createTestStructuredLogger } from "../../test/support/requestContext.ts"; import { createAuthenticationWorkBudget } from "./authenticationWorkBudget.ts"; import { bootstrapAuthenticationLifecycle, @@ -19,6 +20,8 @@ const inertRealtimeLayer = Layer.succeed( }) ); +const testStructuredLogger = createTestStructuredLogger(); + describe("authentication lifecycle rate limits", () => { test("commits Gateway cooldown before admitting the production Effect queue", async () => { const runtime = createApplicationRuntime({ @@ -26,6 +29,7 @@ describe("authentication lifecycle rate limits", () => { gatewayMaximumConcurrent: 1, gatewayMaximumQueued: 5, }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }); const firstStarted = Promise.withResolvers(); @@ -90,6 +94,7 @@ describe("authentication lifecycle rate limits", () => { gatewayMaximumConcurrent: 1, gatewayMaximumQueued: 5, }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }); const firstStarted = Promise.withResolvers(); diff --git a/src/server/domains/security/authenticationWorkGate.test.ts b/src/server/domains/security/authenticationWorkGate.test.ts index de0f89dda..ed5873273 100644 --- a/src/server/domains/security/authenticationWorkGate.test.ts +++ b/src/server/domains/security/authenticationWorkGate.test.ts @@ -5,6 +5,7 @@ import { Effect, Layer, Stream } from "effect"; import { RealtimeEventPumpService } from "../../platform/realtime/eventPumpService.ts"; import { createApplicationRuntime } from "../../platform/runtime/applicationRuntime.ts"; import { captureFailure } from "../../test/support/promise.ts"; +import { createTestStructuredLogger } from "../../test/support/requestContext.ts"; import { AuthenticationUpstreamUnavailableError, AuthenticationWorkTimeoutError, @@ -19,6 +20,8 @@ const inertRealtimeLayer = Layer.succeed( }) ); +const testStructuredLogger = createTestStructuredLogger(); + describe("process authentication work service", () => { test("serializes TOTP work and rejects overflow beyond the bounded queue", async () => { const runtime = createApplicationRuntime({ @@ -26,6 +29,7 @@ describe("process authentication work service", () => { totpMaximumConcurrent: 1, totpMaximumQueued: 1, }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }); const releaseFirst = Promise.withResolvers(); @@ -69,6 +73,7 @@ describe("process authentication work service", () => { passwordMaximumConcurrent: 1, passwordMaximumQueued: 1, }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }); const releaseFirst = Promise.withResolvers(); @@ -107,6 +112,7 @@ describe("process authentication work service", () => { test("preserves password-work defects for the existing domain contract", async () => { const runtime = createApplicationRuntime({ + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }); const expected = new Error("simulated password implementation defect"); @@ -131,6 +137,7 @@ describe("process authentication work service", () => { gatewayMaximumConcurrent: 1, gatewayMaximumQueued: 0, }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }); const pending = Promise.withResolvers(); @@ -189,12 +196,14 @@ describe("process authentication work service", () => { expect(() => createApplicationRuntime({ authenticationWork: { passwordMaximumConcurrent: 0 }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }) ).toThrow(RangeError); expect(() => createApplicationRuntime({ authenticationWork: { totpMaximumQueued: -1 }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }) ).toThrow(RangeError); diff --git a/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts b/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts index 5f17adbfe..4b235a253 100644 --- a/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts +++ b/src/server/domains/security/authenticationWorkGate.webAuthn.test.ts @@ -5,6 +5,7 @@ import { Effect, Layer, Stream } from "effect"; import { RealtimeEventPumpService } from "../../platform/realtime/eventPumpService.ts"; import { createApplicationRuntime } from "../../platform/runtime/applicationRuntime.ts"; import { captureFailure } from "../../test/support/promise.ts"; +import { createTestStructuredLogger } from "../../test/support/requestContext.ts"; import { type AuthenticationVerificationWorkOptions, AuthenticationUpstreamUnavailableError, @@ -20,6 +21,8 @@ const inertRealtimeLayer = Layer.succeed( }) ); +const testStructuredLogger = createTestStructuredLogger(); + async function yieldToWorkService(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -36,6 +39,7 @@ function webAuthnRunner(runtime: ReturnType) { describe("process WebAuthn verification work service", () => { test("uses an independent default two-active/four-queued gate", async () => { const runtime = createApplicationRuntime({ + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }); const releaseActive = Promise.withResolvers(); @@ -93,6 +97,7 @@ describe("process WebAuthn verification work service", () => { webAuthnMaximumConcurrent: 1, webAuthnMaximumQueued: 1, }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }); const releaseFirst = Promise.withResolvers(); @@ -155,6 +160,7 @@ describe("process WebAuthn verification work service", () => { webAuthnMaximumConcurrent: 1, webAuthnMaximumQueued: 0, }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }); const timedWork = Promise.withResolvers(); @@ -252,6 +258,7 @@ describe("process WebAuthn verification work service", () => { webAuthnMaximumConcurrent: 1, webAuthnMaximumQueued: 1, }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }); const releaseResult = Promise.withResolvers(); @@ -339,18 +346,21 @@ describe("process WebAuthn verification work service", () => { expect(() => createApplicationRuntime({ authenticationWork: { webAuthnMaximumConcurrent: 0 }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }) ).toThrow("WebAuthn verification concurrency limit is invalid"); expect(() => createApplicationRuntime({ authenticationWork: { webAuthnMaximumQueued: -1 }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }) ).toThrow("WebAuthn verification queue limit is invalid"); expect(() => createApplicationRuntime({ authenticationWork: { webAuthnMaximumQueued: 1.5 }, + logger: testStructuredLogger, realtimeEventPumpLayer: inertRealtimeLayer, }) ).toThrow(RangeError); diff --git a/src/server/domains/security/mfa/totpSecretCipher.ts b/src/server/domains/security/mfa/totpSecretCipher.ts index f276616ac..9e04e37c8 100644 --- a/src/server/domains/security/mfa/totpSecretCipher.ts +++ b/src/server/domains/security/mfa/totpSecretCipher.ts @@ -110,6 +110,15 @@ function parseKeyRing(serializedKeyRing: unknown) { } } +/** + * Validates the bounded TOTP encryption-keyring format without exposing key material. + * @param serializedKeyRing Untrusted serialized keyring configuration. + * @throws {TypeError} When the keyring is malformed, ambiguous, or outside its bounds. + */ +export function assertValidTotpEncryptionKeyRing(serializedKeyRing: unknown): void { + parseKeyRing(serializedKeyRing); +} + function isCanonicalSecurityRecordId(value: string): boolean { return v.safeParse(securityRecordIdSchema, value, { abortEarly: true }).success; } diff --git a/src/server/platform/configuration/applicationConfigurationError.ts b/src/server/platform/configuration/applicationConfigurationError.ts new file mode 100644 index 000000000..3cb0532aa --- /dev/null +++ b/src/server/platform/configuration/applicationConfigurationError.ts @@ -0,0 +1,49 @@ +import type { ApplicationConfigurationEnvironmentName } from "../../../shared/configuration/applicationConfigurationRegistry.ts"; + +export type ApplicationConfigurationFailureReason = + | "inconsistent" + | "invalid" + | "missing"; + +const inspectSymbol = Symbol.for("nodejs.util.inspect.custom"); + +/** Redacted composition failure that never retains the rejected input or parser cause. */ +export class ApplicationConfigurationError extends Error { + readonly _tag = "ApplicationConfigurationError"; + readonly field: ApplicationConfigurationEnvironmentName; + readonly reason: ApplicationConfigurationFailureReason; + + constructor( + field: ApplicationConfigurationEnvironmentName, + reason: ApplicationConfigurationFailureReason + ) { + super(`Application configuration ${field} is ${reason}`); + this.name = "ApplicationConfigurationError"; + this.field = field; + this.reason = reason; + } + + /** + * Produces a stable diagnostic payload containing no rejected value or cause. + * @returns Frozen redacted diagnostic fields. + */ + toJSON(): Readonly<{ + _tag: "ApplicationConfigurationError"; + field: ApplicationConfigurationEnvironmentName; + reason: ApplicationConfigurationFailureReason; + }> { + return Object.freeze({ + _tag: this._tag, + field: this.field, + reason: this.reason, + }); + } + + /** + * Keeps Node/Bun inspection on the same redacted surface as JSON serialization. + * @returns Frozen redacted diagnostic fields. + */ + [inspectSymbol](): ReturnType { + return this.toJSON(); + } +} diff --git a/src/server/platform/configuration/configurationRegistry.test.ts b/src/server/platform/configuration/configurationRegistry.test.ts new file mode 100644 index 000000000..f1e4afe72 --- /dev/null +++ b/src/server/platform/configuration/configurationRegistry.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; + +import { environmentSource } from "../../../app/environmentSource.ts"; +import { + applicationConfigurationEnvironmentNames, + applicationConfigurationRegistry, + configurationMetadata, + configurationEnvironmentNamesForRole, +} from "../../../shared/configuration/applicationConfigurationRegistry.ts"; + +describe("application configuration registry", () => { + test("accounts for every accepted environment name exactly once", () => { + expect(applicationConfigurationEnvironmentNames).toEqual([ + "NODE_ENV", + "MIRA_DASHBOARD_PROJECT_ROOT", + "PORT", + "MIRA_DASHBOARD_PUBLIC_ORIGIN", + "MIRA_DASHBOARD_TRUSTED_PROXY_IPS", + "OPENCLAW_GATEWAY_URL", + "MIRA_DASHBOARD_WEBAUTHN_RP_ID", + "MIRA_DASHBOARD_WEBAUTHN_ORIGINS", + "MIRA_DASHBOARD_WEBAUTHN_RP_NAME", + "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", + "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", + "MIRA_DASHBOARD_TOTP_KEYRING", + "MIRA_DASHBOARD_LOG_LEVEL", + ]); + expect(applicationConfigurationRegistry).toHaveLength(13); + expect( + new Set( + applicationConfigurationRegistry.map((entry) => entry.environmentName) + ).size + ).toBe(applicationConfigurationRegistry.length); + expect( + new Set(applicationConfigurationRegistry.map((entry) => entry.field)).size + ).toBe(applicationConfigurationRegistry.length); + }); + + test("publishes complete immutable operational metadata", () => { + expect(Object.isFrozen(applicationConfigurationRegistry)).toBe(true); + for (const entry of applicationConfigurationRegistry) { + expect(configurationMetadata(entry.environmentName)).toBe(entry); + expect(entry.description.length).toBeGreaterThan(0); + expect(entry.operationalEffect.length).toBeGreaterThan(0); + expect(entry.roles.length).toBeGreaterThan(0); + expect(entry.validationConstraints.length).toBeGreaterThan(0); + expect(entry.valueType.length).toBeGreaterThan(0); + expect(typeof entry.restartRequired).toBe("boolean"); + expect(typeof entry.secret).toBe("boolean"); + expect(typeof entry.overridePolicy.development).toBe("boolean"); + expect(typeof entry.overridePolicy.test).toBe("boolean"); + expect(Object.isFrozen(entry)).toBe(true); + expect(Object.isFrozen(entry.roles)).toBe(true); + expect(Object.isFrozen(entry.overridePolicy)).toBe(true); + if (entry.allowedValues !== null) { + expect(entry.allowedValues.length).toBeGreaterThan(0); + expect(Object.isFrozen(entry.allowedValues)).toBe(true); + } + if (entry.secret) expect(entry.browserExposure).not.toBe("value"); + } + expect( + applicationConfigurationRegistry + .filter((entry) => entry.secret) + .map((entry) => entry.environmentName) + ).toEqual(["MIRA_DASHBOARD_TOTP_KEYRING"]); + }); + + test("names parsed fields consistently with typed web configuration", () => { + expect( + Object.fromEntries( + applicationConfigurationRegistry.map((entry) => [ + entry.environmentName, + entry.field, + ]) + ) + ).toEqual({ + MIRA_DASHBOARD_LOG_LEVEL: "logLevel", + MIRA_DASHBOARD_PROJECT_ROOT: "projectRoot", + MIRA_DASHBOARD_PUBLIC_ORIGIN: "publicOrigin", + MIRA_DASHBOARD_RECENT_AUTH_MINUTES: "recentAuthenticationWindowMs", + MIRA_DASHBOARD_SESSION_IDLE_MINUTES: "sessionIdleDurationMs", + MIRA_DASHBOARD_TOTP_KEYRING: "totpKeyring", + MIRA_DASHBOARD_TRUSTED_PROXY_IPS: "trustedProxyAddresses", + MIRA_DASHBOARD_WEBAUTHN_ORIGINS: "webAuthnRelyingParty.allowedOrigins", + MIRA_DASHBOARD_WEBAUTHN_RP_ID: "webAuthnRelyingParty.rpId", + MIRA_DASHBOARD_WEBAUTHN_RP_NAME: "webAuthnRelyingParty.rpName", + NODE_ENV: "nodeEnvironment", + OPENCLAW_GATEWAY_URL: "gatewayUrl", + PORT: "port", + }); + }); + + test("projects only the registered keys for each composition role", () => { + const environment = environmentSource("web"); + const webEnvironmentNames = configurationEnvironmentNamesForRole("web"); + + expect(Object.keys(environment)).toEqual([...webEnvironmentNames]); + expect(Object.getPrototypeOf(environment)).toBeNull(); + expect(Object.isFrozen(environment)).toBe(true); + for (const environmentName of webEnvironmentNames) { + if (configurationMetadata(environmentName).secret) { + expect(Object.hasOwn(environment, environmentName)).toBe(true); + expect( + environment[environmentName] === undefined || + typeof environment[environmentName] === "string" + ).toBe(true); + continue; + } + expect(environment[environmentName]).toBe(process.env[environmentName]); + } + + const workerEnvironment = environmentSource("worker"); + expect(Object.keys(workerEnvironment)).toEqual([ + "NODE_ENV", + "MIRA_DASHBOARD_PROJECT_ROOT", + "MIRA_DASHBOARD_LOG_LEVEL", + ]); + expect(workerEnvironment).not.toHaveProperty("MIRA_DASHBOARD_TOTP_KEYRING"); + }); +}); diff --git a/src/server/platform/configuration/webConfiguration.test.ts b/src/server/platform/configuration/webConfiguration.test.ts new file mode 100644 index 000000000..a91e40f7c --- /dev/null +++ b/src/server/platform/configuration/webConfiguration.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, test } from "bun:test"; +import { inspect } from "node:util"; + +import { Redacted } from "effect"; + +import { ApplicationConfigurationError } from "./applicationConfigurationError.ts"; +import { + parseWebConfiguration, + webConfigurationEnvironmentNames, + webConfigurationEnvironmentSchema, +} from "./webConfiguration.ts"; + +function encodedKey(byte: number): string { + return Buffer.alloc(32, byte).toString("base64"); +} + +function serializedKeyring(overrides: Readonly> = {}): string { + return JSON.stringify({ + activeKeyId: "primary", + formatVersion: 1, + keys: [{ id: "primary", keyBase64: encodedKey(1) }], + ...overrides, + }); +} + +function validEnvironment(): Record { + return { + MIRA_DASHBOARD_LOG_LEVEL: "info", + MIRA_DASHBOARD_PROJECT_ROOT: "/srv/mira-dashboard", + MIRA_DASHBOARD_PUBLIC_ORIGIN: "https://dashboard.example.com", + MIRA_DASHBOARD_RECENT_AUTH_MINUTES: "10", + MIRA_DASHBOARD_SESSION_IDLE_MINUTES: "30", + MIRA_DASHBOARD_TOTP_KEYRING: serializedKeyring(), + MIRA_DASHBOARD_TRUSTED_PROXY_IPS: "127.0.0.1,::1", + MIRA_DASHBOARD_WEBAUTHN_ORIGINS: + "https://dashboard.example.com,https://admin.example.com", + MIRA_DASHBOARD_WEBAUTHN_RP_ID: "example.com", + MIRA_DASHBOARD_WEBAUTHN_RP_NAME: "Mira Dashboard", + NODE_ENV: "production", + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", + PORT: "3100", + }; +} + +function expectConfigurationError( + environment: Readonly>, + field: string, + reason: "inconsistent" | "invalid" | "missing" +): void { + try { + parseWebConfiguration(environment); + throw new Error("Expected application configuration parsing to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ApplicationConfigurationError); + expect(error).toMatchObject({ field, reason }); + } +} + +describe("web application configuration", () => { + test("parses defaults, domain policy, secrets, and deeply frozen output", () => { + const environment = validEnvironment(); + delete environment.MIRA_DASHBOARD_LOG_LEVEL; + delete environment.OPENCLAW_GATEWAY_URL; + delete environment.PORT; + const before = { ...environment }; + Object.freeze(environment); + + const configuration = parseWebConfiguration(environment); + + expect(configuration).toMatchObject({ + gatewayUrl: "ws://127.0.0.1:18789/", + logLevel: "info", + nodeEnvironment: "production", + port: 3100, + projectRoot: "/srv/mira-dashboard", + publicOrigin: "https://dashboard.example.com", + recentAuthenticationWindowMs: 600_000, + sessionIdleDurationMs: 1_800_000, + trustedProxyAddresses: ["127.0.0.1", "::1"], + webAuthnRelyingParty: { + allowedOrigins: [ + "https://admin.example.com", + "https://dashboard.example.com", + ], + rpId: "example.com", + rpName: "Mira Dashboard", + }, + }); + expect(Redacted.value(configuration.totpKeyring)).toBe( + environment.MIRA_DASHBOARD_TOTP_KEYRING as string + ); + expect(JSON.stringify(configuration.totpKeyring)).toBe( + '""' + ); + expect(environment).toEqual(before); + expect(Object.isFrozen(configuration)).toBe(true); + expect(Object.isFrozen(configuration.trustedProxyAddresses)).toBe(true); + expect(Object.isFrozen(configuration.webAuthnRelyingParty)).toBe(true); + expect(Object.isFrozen(configuration.webAuthnRelyingParty.allowedOrigins)).toBe( + true + ); + expect(Object.isFrozen(configuration.totpKeyring)).toBe(true); + }); + + test("observes only registered keys and ignores unrelated host variables", () => { + const environment = validEnvironment(); + const observed = new Set(); + const guarded = new Proxy(environment, { + getOwnPropertyDescriptor(target, property) { + observed.add(property); + if ( + typeof property === "string" && + !webConfigurationEnvironmentNames.includes( + property as (typeof webConfigurationEnvironmentNames)[number] + ) + ) { + throw new Error("Unregistered environment key was observed"); + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }); + + const baseline = parseWebConfiguration(environment); + const parsed = parseWebConfiguration(guarded); + + expect(JSON.stringify(parsed)).toBe(JSON.stringify(baseline)); + expect([...observed].map(String).toSorted()).toEqual( + [...webConfigurationEnvironmentNames].toSorted() + ); + expect( + parseWebConfiguration({ ...environment, HOST_SECRET: "must-not-be-read" }) + .publicOrigin + ).toBe(baseline.publicOrigin); + expect(Object.keys(webConfigurationEnvironmentSchema.entries).toSorted()).toEqual( + [...webConfigurationEnvironmentNames].toSorted() + ); + }); + + test("rejects accessors, hostile descriptors, and inherited values without leakage", () => { + const sentinel = "configuration-source-sentinel"; + let getterCalls = 0; + const accessorEnvironment = validEnvironment(); + Object.defineProperty(accessorEnvironment, "NODE_ENV", { + enumerable: true, + get() { + getterCalls += 1; + throw new Error(sentinel); + }, + }); + const hostileEnvironment = new Proxy(validEnvironment(), { + getOwnPropertyDescriptor(target, property) { + if (property === "PORT") throw new Error(sentinel); + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }); + const inheritedEnvironment = Object.assign( + Object.create({ MIRA_DASHBOARD_PUBLIC_ORIGIN: "https://inherited.invalid" }), + validEnvironment() + ) as Record; + delete inheritedEnvironment.MIRA_DASHBOARD_PUBLIC_ORIGIN; + + for (const [environment, field, reason] of [ + [accessorEnvironment, "NODE_ENV", "invalid"], + [hostileEnvironment, "PORT", "invalid"], + [inheritedEnvironment, "MIRA_DASHBOARD_PUBLIC_ORIGIN", "missing"], + ] as const) { + let caught: unknown; + try { + parseWebConfiguration(environment); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ApplicationConfigurationError); + expect(caught).toMatchObject({ field, reason }); + expect(String(caught)).not.toContain(sentinel); + expect((caught as Error).stack ?? "").not.toContain(sentinel); + expect(inspect(caught)).not.toContain(sentinel); + expect(JSON.stringify(caught)).not.toContain(sentinel); + expect("cause" in (caught as object)).toBe(false); + } + expect(getterCalls).toBe(0); + }); + + test("allows only the existing localhost WebAuthn policy outside production", () => { + for (const nodeEnvironment of ["development", "test"] as const) { + const configuration = parseWebConfiguration({ + ...validEnvironment(), + MIRA_DASHBOARD_PUBLIC_ORIGIN: "http://localhost:3100", + MIRA_DASHBOARD_WEBAUTHN_ORIGINS: "http://localhost:3100", + MIRA_DASHBOARD_WEBAUTHN_RP_ID: "localhost", + NODE_ENV: nodeEnvironment, + }); + expect(configuration.publicOrigin).toBe("http://localhost:3100"); + } + expectConfigurationError( + { + ...validEnvironment(), + MIRA_DASHBOARD_PUBLIC_ORIGIN: "http://localhost:3100", + MIRA_DASHBOARD_WEBAUTHN_ORIGINS: "http://localhost:3100", + MIRA_DASHBOARD_WEBAUTHN_RP_ID: "localhost", + }, + "MIRA_DASHBOARD_PUBLIC_ORIGIN", + "inconsistent" + ); + }); + + test("classifies missing required fields without retaining values", () => { + for (const field of [ + "MIRA_DASHBOARD_PROJECT_ROOT", + "MIRA_DASHBOARD_PUBLIC_ORIGIN", + "MIRA_DASHBOARD_WEBAUTHN_RP_ID", + "MIRA_DASHBOARD_WEBAUTHN_ORIGINS", + "MIRA_DASHBOARD_TOTP_KEYRING", + ] as const) { + const environment = validEnvironment(); + delete environment[field]; + expectConfigurationError(environment, field, "missing"); + } + }); + + test("rejects hostile scalar, URL, path, duration, and list values", () => { + const cases: readonly [string, unknown, string, "inconsistent" | "invalid"][] = [ + ["NODE_ENV", "staging", "NODE_ENV", "invalid"], + ["PORT", "0", "PORT", "invalid"], + ["PORT", "01", "PORT", "invalid"], + ["PORT", "65536", "PORT", "invalid"], + ["PORT", 3100, "PORT", "invalid"], + [ + "MIRA_DASHBOARD_PROJECT_ROOT", + "relative/path", + "MIRA_DASHBOARD_PROJECT_ROOT", + "invalid", + ], + [ + "MIRA_DASHBOARD_PROJECT_ROOT", + "/", + "MIRA_DASHBOARD_PROJECT_ROOT", + "invalid", + ], + [ + "MIRA_DASHBOARD_PROJECT_ROOT", + "/srv/../srv/mira-dashboard", + "MIRA_DASHBOARD_PROJECT_ROOT", + "invalid", + ], + [ + "MIRA_DASHBOARD_PUBLIC_ORIGIN", + "https://dashboard.example.com/path", + "MIRA_DASHBOARD_PUBLIC_ORIGIN", + "invalid", + ], + [ + "OPENCLAW_GATEWAY_URL", + "ws://127.0.0.1:18789/?", + "OPENCLAW_GATEWAY_URL", + "invalid", + ], + [ + "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", + "4", + "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", + "invalid", + ], + [ + "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", + "1441", + "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", + "invalid", + ], + [ + "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", + "0", + "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", + "invalid", + ], + [ + "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", + "61", + "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", + "invalid", + ], + [ + "MIRA_DASHBOARD_TRUSTED_PROXY_IPS", + "127.0.0.1, ::1", + "MIRA_DASHBOARD_TRUSTED_PROXY_IPS", + "invalid", + ], + [ + "MIRA_DASHBOARD_TRUSTED_PROXY_IPS", + "127.0.0.1,::ffff:127.0.0.1", + "MIRA_DASHBOARD_TRUSTED_PROXY_IPS", + "invalid", + ], + [ + "MIRA_DASHBOARD_WEBAUTHN_RP_ID", + "Example.com", + "MIRA_DASHBOARD_WEBAUTHN_RP_ID", + "invalid", + ], + [ + "MIRA_DASHBOARD_WEBAUTHN_ORIGINS", + "https://dashboard.example.com,https://dashboard.example.com", + "MIRA_DASHBOARD_WEBAUTHN_ORIGINS", + "invalid", + ], + [ + "MIRA_DASHBOARD_WEBAUTHN_ORIGINS", + "https://other.example.net", + "MIRA_DASHBOARD_WEBAUTHN_ORIGINS", + "inconsistent", + ], + [ + "MIRA_DASHBOARD_WEBAUTHN_RP_NAME", + "Mira\u0000Dashboard", + "MIRA_DASHBOARD_WEBAUTHN_RP_NAME", + "invalid", + ], + [ + "MIRA_DASHBOARD_LOG_LEVEL", + "verbose", + "MIRA_DASHBOARD_LOG_LEVEL", + "invalid", + ], + ]; + + for (const [key, value, field, reason] of cases) { + expectConfigurationError( + { ...validEnvironment(), [key]: value }, + field, + reason + ); + } + }); + + test("rejects bounded list and keyring integrity violations", () => { + expectConfigurationError( + { + ...validEnvironment(), + MIRA_DASHBOARD_TRUSTED_PROXY_IPS: Array.from( + { length: 33 }, + (_, index) => `10.0.0.${index + 1}` + ).join(","), + }, + "MIRA_DASHBOARD_TRUSTED_PROXY_IPS", + "invalid" + ); + + const keyringCases = [ + serializedKeyring({ extra: true }), + serializedKeyring({ activeKeyId: "missing" }), + serializedKeyring({ + keys: [ + { id: "primary", keyBase64: encodedKey(1) }, + { id: "primary", keyBase64: encodedKey(2) }, + ], + }), + serializedKeyring({ + keys: [ + { id: "primary", keyBase64: encodedKey(1) }, + { id: "secondary", keyBase64: encodedKey(1) }, + ], + }), + serializedKeyring({ + keys: Array.from({ length: 9 }, (_, index) => ({ + id: `key-${index}`, + keyBase64: encodedKey(index + 1), + })), + }), + ]; + for (const keyring of keyringCases) { + expectConfigurationError( + { ...validEnvironment(), MIRA_DASHBOARD_TOTP_KEYRING: keyring }, + "MIRA_DASHBOARD_TOTP_KEYRING", + "invalid" + ); + } + }); + + test("redacts rejected secret values from errors, inspection, and JSON", () => { + const sentinel = "never-render-this-secret"; + let caught: unknown; + try { + parseWebConfiguration({ + ...validEnvironment(), + MIRA_DASHBOARD_TOTP_KEYRING: `{"${sentinel}":true}`, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(ApplicationConfigurationError); + const error = caught as ApplicationConfigurationError; + expect(error).toMatchObject({ + _tag: "ApplicationConfigurationError", + field: "MIRA_DASHBOARD_TOTP_KEYRING", + reason: "invalid", + }); + for (const rendering of [ + String(error), + error.stack ?? "", + inspect(error), + JSON.stringify(error), + ]) { + expect(rendering).not.toContain(sentinel); + } + expect(JSON.stringify(error)).toBe( + '{"_tag":"ApplicationConfigurationError","field":"MIRA_DASHBOARD_TOTP_KEYRING","reason":"invalid"}' + ); + expect("cause" in error).toBe(false); + }); +}); diff --git a/src/server/platform/configuration/webConfiguration.ts b/src/server/platform/configuration/webConfiguration.ts new file mode 100644 index 000000000..2df42d1d1 --- /dev/null +++ b/src/server/platform/configuration/webConfiguration.ts @@ -0,0 +1,394 @@ +import { isIP } from "node:net"; +import path from "node:path"; + +import { minutesToMilliseconds } from "date-fns"; +import { Redacted } from "effect"; +import * as v from "valibot"; + +import { webAuthnRpIdSchema } from "../../../contracts/webauthn.ts"; +import { + applicationConfigurationLimits, + configurationMetadata, + configurationEnvironmentNamesForRole, + type ApplicationConfigurationEnvironmentName, +} from "../../../shared/configuration/applicationConfigurationRegistry.ts"; +import { parseBrowserSessionIdleDurationMs } from "../../domains/security/authenticationPolicy.ts"; +import { assertValidTotpEncryptionKeyRing } from "../../domains/security/mfa/totpSecretCipher.ts"; +import { + createWebAuthnRelyingPartyConfiguration, + type WebAuthnRelyingPartyConfiguration, +} from "../../domains/security/mfa/webauthn/relyingPartyConfiguration.ts"; +import { parseRecentAuthenticationWindowMs } from "../../domains/security/recentAuthentication.ts"; +import { parseBrowserOrigin } from "../../rawHttp/requestSecurity.ts"; +import { parseGatewayCredentialVerifierUrl } from "../gateway/gatewayCredentialVerifier.ts"; +import { + ApplicationConfigurationError, + type ApplicationConfigurationFailureReason, +} from "./applicationConfigurationError.ts"; + +export type ApplicationNodeEnvironment = "development" | "production" | "test"; +export type ApplicationLogLevel = "debug" | "error" | "info" | "warn"; + +/** Immutable, validated configuration consumed by the greenfield web process. */ +export interface WebConfiguration { + readonly gatewayUrl: string; + readonly logLevel: ApplicationLogLevel; + readonly nodeEnvironment: ApplicationNodeEnvironment; + readonly port: number; + readonly projectRoot: string; + readonly publicOrigin: string; + readonly recentAuthenticationWindowMs: number; + readonly sessionIdleDurationMs: number; + readonly totpKeyring: Redacted.Redacted; + readonly trustedProxyAddresses: readonly string[]; + readonly webAuthnRelyingParty: WebAuthnRelyingPartyConfiguration; +} + +const unsafeTextPattern = /[\p{Cc}\p{Cf}]/u; +const canonicalUnsignedIntegerPattern = /^(?:0|[1-9][0-9]*)$/u; +const optionalEnvironmentValueSchema = v.optional(v.unknown()); + +/** Valibot projection for the complete accepted web-process environment surface. */ +export const webConfigurationEnvironmentSchema = v.object({ + MIRA_DASHBOARD_LOG_LEVEL: optionalEnvironmentValueSchema, + MIRA_DASHBOARD_PROJECT_ROOT: optionalEnvironmentValueSchema, + MIRA_DASHBOARD_PUBLIC_ORIGIN: optionalEnvironmentValueSchema, + MIRA_DASHBOARD_RECENT_AUTH_MINUTES: optionalEnvironmentValueSchema, + MIRA_DASHBOARD_SESSION_IDLE_MINUTES: optionalEnvironmentValueSchema, + MIRA_DASHBOARD_TOTP_KEYRING: optionalEnvironmentValueSchema, + MIRA_DASHBOARD_TRUSTED_PROXY_IPS: optionalEnvironmentValueSchema, + MIRA_DASHBOARD_WEBAUTHN_ORIGINS: optionalEnvironmentValueSchema, + MIRA_DASHBOARD_WEBAUTHN_RP_ID: optionalEnvironmentValueSchema, + MIRA_DASHBOARD_WEBAUTHN_RP_NAME: optionalEnvironmentValueSchema, + NODE_ENV: optionalEnvironmentValueSchema, + OPENCLAW_GATEWAY_URL: optionalEnvironmentValueSchema, + PORT: optionalEnvironmentValueSchema, +}); + +/** Registered environment names consumed by the web-process parser. */ +export const webConfigurationEnvironmentNames = + configurationEnvironmentNamesForRole("web"); + +type PickedEnvironment = Readonly< + Partial> +>; + +function configurationError( + field: ApplicationConfigurationEnvironmentName, + reason: ApplicationConfigurationFailureReason +): never { + throw new ApplicationConfigurationError(field, reason); +} + +function pickEnvironment(source: Readonly>): PickedEnvironment { + const sourceProjection = Object.create(null) as Record; + for (const environmentName of webConfigurationEnvironmentNames) { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(source, environmentName); + } catch { + configurationError(environmentName, "invalid"); + } + if (descriptor === undefined) continue; + if (!("value" in descriptor)) { + configurationError(environmentName, "invalid"); + } + sourceProjection[environmentName] = descriptor.value; + } + const projected = v.parse(webConfigurationEnvironmentSchema, sourceProjection); + const picked = Object.create(null) as Record< + ApplicationConfigurationEnvironmentName, + unknown + >; + for (const environmentName of webConfigurationEnvironmentNames) { + const supplied = projected[environmentName]; + const fallback = configurationMetadata(environmentName).defaultValue; + picked[environmentName] = supplied === undefined ? fallback : supplied; + } + return picked; +} + +function requiredString( + input: PickedEnvironment, + field: ApplicationConfigurationEnvironmentName, + maximumLength: number, + allowEmpty = false +): string { + const value = input[field]; + if (value === null || value === undefined || value === "") { + if (allowEmpty && value === "") return value; + configurationError(field, "missing"); + } + if ( + typeof value !== "string" || + value.length > maximumLength || + value !== value.trim() || + unsafeTextPattern.test(value) + ) { + configurationError(field, "invalid"); + } + return value; +} + +function choice( + input: PickedEnvironment, + field: ApplicationConfigurationEnvironmentName, + choices: readonly T[] +): T { + const value = requiredString(input, field, 32); + if (!choices.includes(value as T)) configurationError(field, "invalid"); + return value as T; +} + +function canonicalInteger( + input: PickedEnvironment, + field: ApplicationConfigurationEnvironmentName, + minimum: number, + maximum: number +): number { + const value = requiredString(input, field, 16); + if (!canonicalUnsignedIntegerPattern.test(value)) { + configurationError(field, "invalid"); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + configurationError(field, "invalid"); + } + return parsed; +} + +function projectRoot(input: PickedEnvironment): string { + const field = "MIRA_DASHBOARD_PROJECT_ROOT" as const; + const value = requiredString( + input, + field, + applicationConfigurationLimits.projectRootMaximumLength + ); + if ( + !path.isAbsolute(value) || + value === path.parse(value).root || + path.resolve(value) !== value + ) { + configurationError(field, "invalid"); + } + return value; +} + +function parseIpAddress(value: string): string | undefined { + if (isIP(value) === 4) return value; + if (isIP(value) !== 6) return undefined; + const mappedIpv4 = /^::ffff:([0-9]{1,3}(?:\.[0-9]{1,3}){3})$/iu.exec(value)?.[1]; + if (mappedIpv4 !== undefined && isIP(mappedIpv4) === 4) return mappedIpv4; + try { + return new URL(`http://[${value}]/`).hostname.slice(1, -1).toLowerCase(); + } catch { + return undefined; + } +} + +function trustedProxyAddresses(input: PickedEnvironment): readonly string[] { + const field = "MIRA_DASHBOARD_TRUSTED_PROXY_IPS" as const; + const raw = requiredString( + input, + field, + applicationConfigurationLimits.trustedProxyAddresses.maximumLength, + true + ); + if (raw.length === 0) return Object.freeze([]); + const values = raw.split(","); + if ( + values.length > applicationConfigurationLimits.trustedProxyAddresses.maximumItems + ) { + configurationError(field, "invalid"); + } + const canonical = values.map((value) => { + if (value.length === 0 || value !== value.trim()) { + configurationError(field, "invalid"); + } + return parseIpAddress(value) ?? configurationError(field, "invalid"); + }); + if (new Set(canonical).size !== canonical.length) { + configurationError(field, "invalid"); + } + return Object.freeze(canonical.toSorted()); +} + +function publicOrigin(input: PickedEnvironment): string { + const field = "MIRA_DASHBOARD_PUBLIC_ORIGIN" as const; + const value = requiredString( + input, + field, + applicationConfigurationLimits.publicOriginMaximumLength + ); + try { + return parseBrowserOrigin(value); + } catch { + return configurationError(field, "invalid"); + } +} + +function gatewayUrl(input: PickedEnvironment): string { + const field = "OPENCLAW_GATEWAY_URL" as const; + const value = requiredString( + input, + field, + applicationConfigurationLimits.gatewayUrlMaximumLength + ); + try { + return parseGatewayCredentialVerifierUrl(value); + } catch { + return configurationError(field, "invalid"); + } +} + +function webAuthnOrigins(input: PickedEnvironment): readonly string[] { + const field = "MIRA_DASHBOARD_WEBAUTHN_ORIGINS" as const; + const raw = requiredString( + input, + field, + applicationConfigurationLimits.webAuthnOrigins.maximumLength + ); + const values = raw.split(","); + if ( + values.length < applicationConfigurationLimits.webAuthnOrigins.minimumItems || + values.length > applicationConfigurationLimits.webAuthnOrigins.maximumItems + ) { + configurationError(field, "invalid"); + } + for (const value of values) { + if (value.length === 0 || value !== value.trim()) { + configurationError(field, "invalid"); + } + } + if (new Set(values).size !== values.length) { + configurationError(field, "invalid"); + } + return values; +} + +function webAuthnConfiguration( + input: PickedEnvironment, + origin: string +): WebAuthnRelyingPartyConfiguration { + const rpIdField = "MIRA_DASHBOARD_WEBAUTHN_RP_ID" as const; + const originsField = "MIRA_DASHBOARD_WEBAUTHN_ORIGINS" as const; + const rpNameField = "MIRA_DASHBOARD_WEBAUTHN_RP_NAME" as const; + const rpId = requiredString( + input, + rpIdField, + applicationConfigurationLimits.webAuthnRpIdMaximumLength + ); + const rpName = requiredString( + input, + rpNameField, + applicationConfigurationLimits.webAuthnRpNameMaximumLength + ); + const origins = webAuthnOrigins(input); + + if (!v.safeParse(webAuthnRpIdSchema, rpId, { abortEarly: true }).success) { + configurationError(rpIdField, "invalid"); + } + if (rpName.normalize("NFC") !== rpName) { + configurationError(rpNameField, "invalid"); + } + let configuration: WebAuthnRelyingPartyConfiguration; + try { + configuration = createWebAuthnRelyingPartyConfiguration({ + allowedOrigins: origins, + rpId, + rpName, + }); + } catch { + return configurationError(originsField, "inconsistent"); + } + if (!configuration.allowedOrigins.includes(origin)) { + configurationError(originsField, "inconsistent"); + } + return configuration; +} + +function totpKeyring(input: PickedEnvironment): Redacted.Redacted { + const field = "MIRA_DASHBOARD_TOTP_KEYRING" as const; + const raw = requiredString( + input, + field, + applicationConfigurationLimits.totpKeyringMaximumLength + ); + try { + assertValidTotpEncryptionKeyRing(raw); + } catch { + return configurationError(field, "invalid"); + } + return Object.freeze(Redacted.make(raw, { label: "totp-keyring" })); +} + +function durationMs( + input: PickedEnvironment, + field: "MIRA_DASHBOARD_RECENT_AUTH_MINUTES" | "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", + parsePolicy: (value: number) => number +): number { + const limits = + field === "MIRA_DASHBOARD_RECENT_AUTH_MINUTES" + ? applicationConfigurationLimits.recentAuthenticationMinutes + : applicationConfigurationLimits.sessionIdleMinutes; + const minutes = canonicalInteger(input, field, limits.minimum, limits.maximum); + try { + return parsePolicy(minutesToMilliseconds(minutes)); + } catch { + return configurationError(field, "invalid"); + } +} + +/** + * Parses an injected untrusted environment record into immutable web configuration. + * Only registered names are observed; the source object is never modified. + * @param source Untrusted injected environment-like record. + * @returns Deeply frozen, domain-validated web configuration. + * @throws {ApplicationConfigurationError} With only a field and stable reason. + */ +export function parseWebConfiguration( + source: Readonly> +): WebConfiguration { + const input = pickEnvironment(source); + const nodeEnvironment = choice(input, "NODE_ENV", [ + "development", + "production", + "test", + ] as const); + const origin = publicOrigin(input); + if (nodeEnvironment === "production" && new URL(origin).protocol !== "https:") { + configurationError("MIRA_DASHBOARD_PUBLIC_ORIGIN", "inconsistent"); + } + const configuration = Object.freeze({ + gatewayUrl: gatewayUrl(input), + logLevel: choice(input, "MIRA_DASHBOARD_LOG_LEVEL", [ + "debug", + "error", + "info", + "warn", + ] as const), + nodeEnvironment, + port: canonicalInteger( + input, + "PORT", + applicationConfigurationLimits.port.minimum, + applicationConfigurationLimits.port.maximum + ), + projectRoot: projectRoot(input), + publicOrigin: origin, + recentAuthenticationWindowMs: durationMs( + input, + "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", + parseRecentAuthenticationWindowMs + ), + sessionIdleDurationMs: durationMs( + input, + "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", + parseBrowserSessionIdleDurationMs + ), + totpKeyring: totpKeyring(input), + trustedProxyAddresses: trustedProxyAddresses(input), + webAuthnRelyingParty: webAuthnConfiguration(input, origin), + } satisfies WebConfiguration); + return configuration; +} diff --git a/src/server/platform/errors/safeFailure.test.ts b/src/server/platform/errors/safeFailure.test.ts new file mode 100644 index 000000000..70d718c75 --- /dev/null +++ b/src/server/platform/errors/safeFailure.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test"; + +import { Cause, Data } from "effect"; + +import { describeSafeFailure } from "./safeFailure.ts"; + +class ExpectedFailure extends Data.TaggedError("ApplicationListenerStopError")<{ + readonly cause: unknown; + readonly operation: "read"; +}> {} + +test("describes errors without messages, stacks, causes, or arbitrary fields", () => { + const sentinel = "never-emit-this-secret"; + const failure = new ExpectedFailure({ + cause: new Error(sentinel), + operation: "read", + }); + Object.defineProperty(failure, "code", { + enumerable: true, + value: "SAFE_CODE", + }); + Object.defineProperty(failure, "payload", { + enumerable: true, + value: sentinel, + }); + + const descriptor = describeSafeFailure(failure); + expect(descriptor).toMatchObject({ + kind: "tagged", + name: "ApplicationListenerStopError", + tag: "ApplicationListenerStopError", + }); + expect(descriptor.fingerprint).toMatch(/^[0-9a-f]{24}$/u); + expect(JSON.stringify(descriptor)).not.toContain(sentinel); + expect(Object.isFrozen(descriptor)).toBe(true); +}); + +test("treats Effect causes and forged cause-like values as opaque", () => { + const sentinel = "cause-secret"; + const expected = Cause.fail(new Error(sentinel)); + const defect = Cause.die(sentinel); + let callbackCalls = 0; + const forgedCause = { + "~effect/Cause": "~effect/Cause", + reasons: { + some() { + callbackCalls += 1; + throw new Error(sentinel); + }, + }, + }; + + for (const failure of [Cause.combine(expected, defect), forgedCause]) { + const descriptor = describeSafeFailure(failure); + expect(descriptor).toMatchObject({ kind: "unknown" }); + expect(JSON.stringify(descriptor)).not.toContain(sentinel); + } + expect(callbackCalls).toBe(0); +}); + +test("does not invoke getters or expose unregistered tagged values", () => { + let getterCalls = 0; + const value = Object.defineProperty({ _tag: "SafeTag" }, "operation", { + enumerable: true, + get() { + getterCalls += 1; + return "unsafe"; + }, + }); + + expect(describeSafeFailure(value)).toMatchObject({ kind: "unknown" }); + expect(getterCalls).toBe(0); +}); + +test("does not invoke an Error name accessor", () => { + let getterCalls = 0; + const failure = Object.defineProperty(new Error("secret"), "name", { + get() { + getterCalls += 1; + throw new Error("name accessor secret"); + }, + }); + + expect(describeSafeFailure(failure)).toMatchObject({ + kind: "error", + name: "Error", + }); + expect(getterCalls).toBe(0); +}); + +test("fails closed for hostile proxy traps", () => { + let trapCalls = 0; + const hostile = new Proxy( + {}, + { + getOwnPropertyDescriptor() { + trapCalls += 1; + throw new Error("descriptor trap secret"); + }, + getPrototypeOf() { + trapCalls += 1; + throw new Error("prototype trap secret"); + }, + } + ); + + const descriptor = describeSafeFailure(hostile); + expect(descriptor).toMatchObject({ kind: "unknown" }); + expect(JSON.stringify(descriptor)).not.toContain("trap secret"); + expect(trapCalls).toBe(0); +}); diff --git a/src/server/platform/errors/safeFailure.ts b/src/server/platform/errors/safeFailure.ts new file mode 100644 index 000000000..8420dbd1b --- /dev/null +++ b/src/server/platform/errors/safeFailure.ts @@ -0,0 +1,97 @@ +import { sha256Hex } from "../../shared/crypto.ts"; + +const safeFailureIdentifierPattern = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u; +const knownFailureTags = new Set([ + "ApplicationConfigurationError", + "ApplicationListenerStopError", + "ApplicationListenerStopTimeoutError", + "AuthenticationUpstreamUnavailableError", + "AuthenticationWorkCapacityError", + "AuthenticationWorkTimeoutError", + "MonitoringRunConflictError", + "MonitoringSnapshotValidationError", + "RealtimeEventCursorStreamError", + "RealtimeEventSlowConsumerStreamError", + "RealtimeEventStoreBusyError", + "RealtimeEventStoreStreamError", + "RealtimeEventStoreUnavailableError", + "RealtimeEventSubscriptionStreamError", + "RenewableStreamLeaseInvalidError", + "RenewableStreamLeaseTimeoutError", + "WebAuthnRelyingPartyConfigurationError", +]); + +/** Redacted failure metadata safe to persist or emit outside the failing boundary. */ +export interface SafeFailureDescriptor { + readonly fingerprint: string; + readonly kind: "error" | "tagged" | "unknown"; + readonly name?: string; + readonly tag?: string; +} + +function safeIdentifier(value: unknown): string | undefined { + return typeof value === "string" && safeFailureIdentifierPattern.test(value) + ? value + : undefined; +} + +function knownFailureTag(value: unknown): string | undefined { + const identifier = safeIdentifier(value); + return identifier !== undefined && knownFailureTags.has(identifier) + ? identifier + : undefined; +} + +function ownDataProperty(value: object, property: string): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, property); + return descriptor !== undefined && "value" in descriptor + ? descriptor.value + : undefined; + } catch { + return undefined; + } +} + +function isError(value: unknown): value is Error { + try { + return Error.isError(value); + } catch { + return false; + } +} + +function descriptorWithoutFingerprint( + failure: unknown +): Omit { + if (isError(failure)) { + const tag = knownFailureTag(ownDataProperty(failure, "_tag")); + return { + kind: tag === undefined ? "error" : "tagged", + name: tag ?? "Error", + ...(tag === undefined ? {} : { tag }), + }; + } + + return { kind: "unknown" }; +} + +/** + * Converts an arbitrary failure into a stable descriptor without messages, stacks, causes, or values. + * @param failure Unknown failure crossing an observability boundary. + * @returns A frozen, bounded descriptor and classification-only fingerprint. + */ +export function describeSafeFailure(failure: unknown): SafeFailureDescriptor { + let descriptor: Omit; + try { + descriptor = descriptorWithoutFingerprint(failure); + } catch { + descriptor = { kind: "unknown" }; + } + return Object.freeze({ + ...descriptor, + fingerprint: sha256Hex( + `mira-dashboard:safe-failure:v1:${JSON.stringify(descriptor)}` + ).slice(0, 24), + }); +} diff --git a/src/server/platform/observability/effectLogger.test.ts b/src/server/platform/observability/effectLogger.test.ts new file mode 100644 index 000000000..ace943428 --- /dev/null +++ b/src/server/platform/observability/effectLogger.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; + +import { Effect, Logger } from "effect"; + +import { createEffectLoggerLayer } from "./effectLogger.ts"; +import { createStructuredLogger } from "./structuredLogger.ts"; + +test("preserves safe annotations and replaces the default Effect logger", async () => { + const lines: string[] = []; + const structuredLogger = createStructuredLogger({ + identity: { + bun: "1.4.0-test", + pid: 123, + processRole: "web", + release: "test-revision", + service: "mira-dashboard", + }, + sink: { + write(line) { + lines.push(line); + }, + }, + }); + const layer = createEffectLoggerLayer(structuredLogger, "Debug"); + const program = Effect.gen(function* () { + const activeLoggers = yield* Effect.service(Logger.CurrentLoggers); + yield* Effect.logInfo("runner failed", { password: "message-secret" }).pipe( + Effect.annotateLogs({ + component: "realtime-event-pump", + event: "realtime.runner.failed", + failureKind: "unexpected-runner-defect", + password: "annotation-secret", + requestId: "01900000-0000-7000-8000-000000000001", + }) + ); + return { + includesDefault: activeLoggers.has(Logger.defaultLogger), + loggerCount: activeLoggers.size, + }; + }).pipe(Effect.provide(layer)); + + expect(await Effect.runPromise(program)).toEqual({ + includesDefault: false, + loggerCount: 1, + }); + expect(lines).toHaveLength(1); + const record = JSON.parse(lines[0] ?? "null") as Record; + expect(record).toMatchObject({ + component: "realtime-event-pump", + event: "realtime.runner.failed", + fields: { + failureKind: "unexpected-runner-defect", + }, + level: "info", + requestId: "01900000-0000-7000-8000-000000000001", + }); + expect(lines[0]).not.toContain("message-secret"); + expect(lines[0]).not.toContain("annotation-secret"); + expect(lines[0]).not.toContain("runner failed"); +}); diff --git a/src/server/platform/observability/effectLogger.ts b/src/server/platform/observability/effectLogger.ts new file mode 100644 index 000000000..c19f288af --- /dev/null +++ b/src/server/platform/observability/effectLogger.ts @@ -0,0 +1,122 @@ +import { Layer, Logger, type LogLevel, References, type Cause } from "effect"; + +import type { + StructuredLogEvent, + StructuredLogFields, + StructuredLogLevel, + StructuredLogger, +} from "./structuredLogger.ts"; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function effectEventName( + value: unknown +): "effect.log" | "realtime.runner.failed" | "runtime.logger.connected" { + return value === "realtime.runner.failed" || value === "runtime.logger.connected" + ? value + : "effect.log"; +} + +function effectComponent( + event: ReturnType +): "application-runtime" | "effect" | "realtime-event-pump" { + if (event === "realtime.runner.failed") return "realtime-event-pump"; + if (event === "runtime.logger.connected") return "application-runtime"; + return "effect"; +} + +function effectLogLevel(level: LogLevel.LogLevel): StructuredLogLevel { + switch (level) { + case "Fatal": { + return "fatal"; + } + case "Error": { + return "error"; + } + case "Warn": { + return "warn"; + } + case "Info": { + return "info"; + } + case "All": + case "Debug": + case "None": + case "Trace": { + return "debug"; + } + } +} + +function effectFields( + event: string, + annotations: Readonly> +): StructuredLogFields | undefined { + if ( + event === "realtime.runner.failed" && + annotations.failureKind === "unexpected-runner-defect" + ) { + return { + failureKind: "unexpected-runner-defect", + kind: "realtime-runner-failure", + }; + } + return undefined; +} + +function eventFailure(cause: Cause.Cause): Cause.Cause | undefined { + return cause.reasons.length === 0 ? undefined : cause; +} + +/** + * Bridges Effect log events into the process structured logger without rendering Cause values. + * @param structuredLogger Process-scoped structured logger. + * @returns An Effect logger suitable for one ManagedRuntime layer. + */ +export function createEffectStructuredLogger( + structuredLogger: StructuredLogger +): Logger.Logger { + return Logger.make(({ cause, date, fiber, logLevel }) => { + const annotations = fiber.getRef(References.CurrentLogAnnotations); + const failure = eventFailure(cause); + const durationMs = annotations.durationMs; + const eventName = effectEventName(annotations.event); + const fields = effectFields(eventName, annotations); + const event: StructuredLogEvent = { + component: effectComponent(eventName), + ...(typeof durationMs === "number" ? { durationMs } : {}), + event: eventName, + ...(failure === undefined ? {} : { failure }), + ...(fields === undefined ? {} : { fields }), + ...(optionalString(annotations.jobId) === undefined + ? {} + : { jobId: optionalString(annotations.jobId) }), + ...(optionalString(annotations.outcome) === undefined + ? {} + : { outcome: optionalString(annotations.outcome) }), + ...(optionalString(annotations.requestId) === undefined + ? {} + : { requestId: optionalString(annotations.requestId) }), + timestamp: date, + }; + structuredLogger.log(effectLogLevel(logLevel), event); + }); +} + +/** + * Replaces Effect's default logger and installs a process-wide minimum level. + * @param structuredLogger Process-scoped structured logger. + * @param minimumLevel Minimum Effect severity emitted by the runtime. + * @returns A layer with exactly one active application logger. + */ +export function createEffectLoggerLayer( + structuredLogger: StructuredLogger, + minimumLevel: LogLevel.LogLevel = "Info" +): Layer.Layer { + return Layer.merge( + Logger.layer([createEffectStructuredLogger(structuredLogger)]), + Layer.succeed(References.MinimumLogLevel, minimumLevel) + ); +} diff --git a/src/server/platform/observability/structuredLogger.test.ts b/src/server/platform/observability/structuredLogger.test.ts new file mode 100644 index 000000000..9b426e324 --- /dev/null +++ b/src/server/platform/observability/structuredLogger.test.ts @@ -0,0 +1,330 @@ +import { expect, spyOn, test } from "bun:test"; + +import { createStructuredLogger, type StructuredLogSink } from "./structuredLogger.ts"; + +const identity = Object.freeze({ + bun: "1.4.0-test", + pid: 123, + processRole: "web" as const, + release: "0123456789abcdef", + service: "mira-dashboard", +}); + +test("writes bounded NDJSON with fixed envelope fields and selected details", () => { + const lines: string[] = []; + const logger = createStructuredLogger({ + identity, + now: () => new Date(1_700_000_000_000), + sink: { + write(line) { + lines.push(line); + }, + }, + }); + + logger.info({ + component: "http", + durationMs: 12, + event: "http.response.created", + fields: { + kind: "http-response", + method: "GET", + status: 200, + }, + outcome: "success", + requestId: "01900000-0000-7000-8000-000000000001", + }); + + expect(lines).toHaveLength(1); + expect(lines[0]?.endsWith("\n")).toBe(true); + const record = JSON.parse(lines[0] ?? "null") as Record; + expect(record).toMatchObject({ + component: "http", + durationMs: 12, + event: "http.response.created", + fields: { + method: "GET", + status: 200, + }, + level: "info", + outcome: "success", + requestId: "01900000-0000-7000-8000-000000000001", + timestamp: "2023-11-14T22:13:20.000Z", + ...identity, + }); + expect(lines[0]).not.toContain("never-log-this"); +}); + +test("normalizes unknown events and drops extra fields instead of relying on secret names", () => { + const lines: string[] = []; + const logger = createStructuredLogger({ + identity, + sink: { + write(line) { + lines.push(line); + }, + }, + }); + const sentinel = "https://gateway.invalid/?token=never-log-this"; + + logger.info({ + component: "http", + event: "http.response.created", + fields: { + details: sentinel, + gatewayCredential: sentinel, + kind: "http-response", + method: "GET", + status: 200, + } as never, + }); + logger.info({ + component: sentinel, + event: sentinel, + fields: { kind: "unknown", message: sentinel } as never, + }); + + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0] ?? "null")).toMatchObject({ + fields: { method: "GET", status: 200 }, + }); + expect(JSON.parse(lines[1] ?? "null")).toMatchObject({ + component: "effect", + event: "effect.log", + }); + expect(JSON.parse(lines[1] ?? "null")).not.toHaveProperty("fields"); + expect(lines.join("\n")).not.toContain("never-log-this"); +}); + +test("emits one constant fallback and never throws when the sink fails", () => { + const fallbacks: string[] = []; + const sink: StructuredLogSink = { + flush() { + throw new Error("flush secret"); + }, + write() { + throw new Error("write secret"); + }, + }; + const logger = createStructuredLogger({ + fallbackWrite: (line) => fallbacks.push(line), + identity, + sink, + }); + + expect(() => + logger.error({ component: "http", event: "http.request.failed" }) + ).not.toThrow(); + logger.warn({ component: "http", event: "http.request.failed" }); + logger.flush(); + logger.flush(); + + expect(fallbacks).toEqual([ + '{"event":"logger.sink_failed","level":"error","service":"mira-dashboard"}\n', + ]); +}); + +test("uses the constant stderr fallback when none is injected", () => { + const lines: string[] = []; + const writeSpy = spyOn(process.stderr, "write").mockImplementation((chunk) => { + lines.push(String(chunk)); + return true; + }); + try { + const logger = createStructuredLogger({ + identity, + sink: { + write() { + throw new Error("sink failure secret"); + }, + }, + }); + logger.error({ component: "http", event: "http.request.failed" }); + logger.error({ component: "http", event: "http.request.failed" }); + } finally { + writeSpy.mockRestore(); + } + + expect(lines).toEqual([ + '{"event":"logger.sink_failed","level":"error","service":"mira-dashboard"}\n', + ]); +}); + +test("fails closed when a sink method returns asynchronous work", async () => { + const fallbacks: string[] = []; + const writeLogger = createStructuredLogger({ + fallbackWrite: (line) => fallbacks.push(`write:${line}`), + identity, + sink: { + write: (async () => {}) as never, + }, + }); + const flushLogger = createStructuredLogger({ + fallbackWrite: (line) => fallbacks.push(`flush:${line}`), + identity, + sink: { + flush: (async () => {}) as never, + write() {}, + }, + }); + + writeLogger.info({ component: "http", event: "http.request.failed" }); + flushLogger.flush(); + await Promise.resolve(); + + expect(fallbacks).toEqual([ + 'write:{"event":"logger.sink_failed","level":"error","service":"mira-dashboard"}\n', + 'flush:{"event":"logger.sink_failed","level":"error","service":"mira-dashboard"}\n', + ]); +}); + +test("contains an asynchronous fallback double-fault", async () => { + const options = { + fallbackWrite() {}, + identity, + sink: { + write() { + throw new Error("sink failure secret"); + }, + }, + }; + Object.defineProperty(options, "fallbackWrite", { + value: () => Promise.reject(new Error("fallback failure secret")), + }); + const logger = createStructuredLogger(options); + + expect(() => + logger.error({ component: "http", event: "http.request.failed" }) + ).not.toThrow(); + await Bun.sleep(0); +}); + +test("normalizes invalid dynamic levels and rejects invalid process roles", () => { + const lines: string[] = []; + const levels: unknown[] = []; + const logger = createStructuredLogger({ + identity, + sink: { + write(line, level) { + lines.push(line); + levels.push(level); + }, + }, + }); + logger.log("level-secret" as never, { + component: "http", + event: "http.request.failed", + }); + + expect(JSON.parse(lines[0] ?? "null")).toMatchObject({ level: "error" }); + expect(levels).toEqual(["error"]); + expect(lines[0]).not.toContain("level-secret"); + expect(() => + createStructuredLogger({ + identity: { ...identity, processRole: "script" as never }, + sink: { write() {} }, + }) + ).toThrow("Structured logger identity is invalid"); +}); + +test("drops invalid optional identities and rejects invalid fixed envelopes safely", () => { + const lines: string[] = []; + const logger = createStructuredLogger({ + identity, + sink: { + write(line) { + lines.push(line); + }, + }, + }); + logger.info({ + component: "http", + event: "http.completed", + requestId: "contains a space", + }); + expect(JSON.parse(lines[0] ?? "null")).not.toHaveProperty("requestId"); + + expect(() => + createStructuredLogger({ + identity: { ...identity, service: "Invalid Service" }, + sink: { + write() { + throw new Error("unreachable invalid logger sink"); + }, + }, + }) + ).toThrow("Structured logger identity is invalid"); + for (const maximumSerializedBytes of [0, -1, 1.5, Number.POSITIVE_INFINITY]) { + expect(() => + createStructuredLogger({ + identity, + limits: { maximumSerializedBytes }, + sink: { write() {} }, + }) + ).toThrow("Structured logger limits are invalid"); + } +}); + +test("accepts the exact Bun canary version-with-revision identity", () => { + const lines: string[] = []; + const logger = createStructuredLogger({ + identity: { + ...identity, + bun: "1.4.0-canary.1+43783cedd", + }, + sink: { + write(line) { + lines.push(line); + }, + }, + }); + + logger.info({ component: "runtime", event: "runtime.started" }); + + expect(JSON.parse(lines[0] ?? "null")).toMatchObject({ + bun: "1.4.0-canary.1+43783cedd", + }); +}); + +test("snapshots limits and bound sink methods at construction", () => { + const fallbacks: string[] = []; + const limits = { maximumSerializedBytes: 1 }; + const boundedWrites: string[] = []; + const boundedLogger = createStructuredLogger({ + fallbackWrite: (line) => fallbacks.push(line), + identity, + limits, + sink: { + write(line) { + boundedWrites.push(line); + }, + }, + }); + limits.maximumSerializedBytes = Number.MAX_SAFE_INTEGER; + + const calls: string[] = []; + const originalFlush: NonNullable = () => { + calls.push("original-flush"); + }; + const originalWrite: StructuredLogSink["write"] = () => { + calls.push("original-write"); + }; + const sink = { flush: originalFlush, write: originalWrite }; + const fixedSinkLogger = createStructuredLogger({ identity, sink }); + sink.write = () => { + calls.push("replacement-write"); + }; + sink.flush = () => { + calls.push("replacement-flush"); + }; + + boundedLogger.info({ component: "runtime", event: "runtime.started" }); + fixedSinkLogger.info({ component: "runtime", event: "runtime.started" }); + fixedSinkLogger.flush(); + + expect(boundedWrites).toEqual([]); + expect(fallbacks).toEqual([ + '{"event":"logger.sink_failed","level":"error","service":"mira-dashboard"}\n', + ]); + expect(calls).toEqual(["original-write", "original-flush"]); +}); diff --git a/src/server/platform/observability/structuredLogger.ts b/src/server/platform/observability/structuredLogger.ts new file mode 100644 index 000000000..c6fca7ece --- /dev/null +++ b/src/server/platform/observability/structuredLogger.ts @@ -0,0 +1,389 @@ +import type { SafeFailureDescriptor } from "../errors/safeFailure.ts"; +import { describeSafeFailure } from "../errors/safeFailure.ts"; + +type StructuredLogValue = + | boolean + | null + | number + | string + | readonly StructuredLogValue[] + | { readonly [key: string]: StructuredLogValue }; + +/** Hard serialization limits applied after event-specific field selection. */ +export interface StructuredLogLimits { + readonly maximumSerializedBytes: number; +} + +const defaultStructuredLogLimits: StructuredLogLimits = Object.freeze({ + maximumSerializedBytes: 16 * 1024, +}); + +export type StructuredLogLevel = "debug" | "error" | "fatal" | "info" | "warn"; + +export interface StructuredLogSink { + flush?(): undefined; + write(line: string, level: StructuredLogLevel): undefined; +} + +export interface StructuredLoggerIdentity { + readonly bun: string; + readonly pid: number; + readonly processRole: "web" | "worker"; + readonly release: string; + readonly service: string; +} + +export interface StructuredLogEvent { + readonly component: string; + readonly durationMs?: number; + readonly event: string; + readonly failure?: unknown; + readonly fields?: StructuredLogFields; + readonly jobId?: string; + readonly outcome?: string; + readonly requestId?: string; + readonly timestamp?: Date; +} + +export type StructuredLogFields = + | { + readonly kind: "http-request"; + readonly method: string; + } + | { + readonly kind: "http-response"; + readonly method: string; + readonly status: number; + } + | { + readonly failureKind: "unexpected-runner-defect"; + readonly kind: "realtime-runner-failure"; + } + | { + readonly kind: "trpc-defect"; + readonly path?: string; + readonly procedureType: "mutation" | "query" | "subscription" | "unknown"; + }; + +export interface StructuredLogRecord extends StructuredLoggerIdentity { + readonly component: string; + readonly durationMs?: number; + readonly event: string; + readonly failure?: SafeFailureDescriptor; + readonly fields?: StructuredLogValue; + readonly jobId?: string; + readonly level: StructuredLogLevel; + readonly outcome?: string; + readonly requestId?: string; + readonly timestamp: string; +} + +/** Process-scoped structured logger shared by Effect and ordinary TypeScript boundaries. */ +export interface StructuredLogger { + debug(event: StructuredLogEvent): void; + error(event: StructuredLogEvent): void; + fatal(event: StructuredLogEvent): void; + flush(): undefined; + info(event: StructuredLogEvent): void; + log(level: StructuredLogLevel, event: StructuredLogEvent): void; + warn(event: StructuredLogEvent): void; +} + +export interface StructuredLoggerOptions { + readonly fallbackWrite?: (line: string) => void; + readonly identity: StructuredLoggerIdentity; + readonly limits?: StructuredLogLimits; + readonly now?: () => Date; + readonly sink: StructuredLogSink; +} + +const structuredNamePattern = /^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$/u; +const requestIdentityPattern = /^[A-Za-z0-9._:+-]{1,128}$/u; +const correlationIdentityPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const sinkFailureLine = + '{"event":"logger.sink_failed","level":"error","service":"mira-dashboard"}\n'; + +const structuredEventComponents = Object.freeze({ + "effect.log": "effect", + "http.request.cancelled": "http", + "http.request.failed": "http", + "http.response.created": "http", + "realtime.runner.failed": "realtime-event-pump", + "runtime.logger.connected": "application-runtime", + "runtime.started": "runtime", + "trpc.request.defect": "trpc", +} as const); + +type StructuredEventName = keyof typeof structuredEventComponents; + +const structuredOutcomes = new Set(["cancelled", "rejected", "server-error", "success"]); +const structuredLogLevels = new Set([ + "debug", + "error", + "fatal", + "info", + "warn", +]); + +function validStructuredName(value: string): boolean { + return value.length <= 128 && structuredNamePattern.test(value); +} + +function validIdentity(value: string): boolean { + return requestIdentityPattern.test(value); +} + +function validateLoggerIdentity(identity: StructuredLoggerIdentity): void { + if ( + !validStructuredName(identity.service) || + (identity.processRole !== "web" && identity.processRole !== "worker") || + !validIdentity(identity.release) || + !validIdentity(identity.bun) || + !Number.isSafeInteger(identity.pid) || + identity.pid <= 0 + ) { + throw new TypeError("Structured logger identity is invalid"); + } +} + +function validateLoggerLimits(limits: StructuredLogLimits): void { + if ( + !Number.isSafeInteger(limits.maximumSerializedBytes) || + limits.maximumSerializedBytes <= 0 + ) { + throw new TypeError("Structured logger limits are invalid"); + } +} + +function optionalIdentity(value: string | undefined): string | undefined { + return value !== undefined && correlationIdentityPattern.test(value) + ? value + : undefined; +} + +function optionalOutcome(value: string | undefined): string | undefined { + return value !== undefined && structuredOutcomes.has(value) ? value : undefined; +} + +function optionalDuration(value: number | undefined): number | undefined { + return value !== undefined && Number.isSafeInteger(value) && value >= 0 + ? value + : undefined; +} + +function normalizedLogLevel(value: unknown): StructuredLogLevel { + return structuredLogLevels.has(value as StructuredLogLevel) + ? (value as StructuredLogLevel) + : "error"; +} + +function assertSynchronousSinkResult(result: unknown): void { + if (result === undefined) return; + void Promise.resolve(result).catch(() => {}); + throw new TypeError("Structured log sink must be synchronous"); +} + +function safeHttpMethod(value: string): string | undefined { + return /^[A-Z]{1,16}$/u.test(value) ? value : undefined; +} + +function safeProcedurePath(value: string | undefined): string | undefined { + return value !== undefined && /^[A-Za-z0-9_.:-]{1,128}$/u.test(value) + ? value + : undefined; +} + +function safeProcedureType( + value: StructuredLogFields & { readonly kind: "trpc-defect" } +): "mutation" | "query" | "subscription" | "unknown" | undefined { + return value.procedureType === "mutation" || + value.procedureType === "query" || + value.procedureType === "subscription" || + value.procedureType === "unknown" + ? value.procedureType + : undefined; +} + +function safeEventFields( + eventName: StructuredEventName, + fields: StructuredLogFields | undefined +): StructuredLogValue | undefined { + if (fields === undefined) return undefined; + switch (fields.kind) { + case "http-request": { + if ( + eventName !== "http.request.cancelled" && + eventName !== "http.request.failed" + ) { + return undefined; + } + const method = safeHttpMethod(fields.method); + return method === undefined ? undefined : { method }; + } + case "http-response": { + if ( + eventName !== "http.response.created" || + !Number.isSafeInteger(fields.status) || + fields.status < 100 || + fields.status > 599 + ) { + return undefined; + } + const method = safeHttpMethod(fields.method); + return method === undefined ? undefined : { method, status: fields.status }; + } + case "realtime-runner-failure": { + return eventName === "realtime.runner.failed" && + fields.failureKind === "unexpected-runner-defect" + ? { failureKind: fields.failureKind } + : undefined; + } + case "trpc-defect": { + if (eventName !== "trpc.request.defect") return undefined; + const path = safeProcedurePath(fields.path); + const procedureType = safeProcedureType(fields); + if (procedureType === undefined) return undefined; + return { + ...(path === undefined ? {} : { path }), + procedureType, + }; + } + } +} + +function normalizedEvent(event: StructuredLogEvent): { + readonly component: string; + readonly event: StructuredEventName; +} { + const expectedComponent = Object.hasOwn(structuredEventComponents, event.event) + ? structuredEventComponents[event.event as StructuredEventName] + : undefined; + return expectedComponent !== undefined && event.component === expectedComponent + ? { component: expectedComponent, event: event.event as StructuredEventName } + : { component: "effect", event: "effect.log" }; +} + +function makeRecord( + identity: StructuredLoggerIdentity, + now: () => Date, + level: StructuredLogLevel, + event: StructuredLogEvent +): StructuredLogRecord { + const timestamp = event.timestamp ?? now(); + if (!Number.isFinite(Date.prototype.getTime.call(timestamp))) { + throw new TypeError("Structured log event is invalid"); + } + const normalized = normalizedEvent(event); + const safeLevel = normalizedLogLevel(level); + const durationMs = optionalDuration(event.durationMs); + const jobId = optionalIdentity(event.jobId); + const outcome = optionalOutcome(event.outcome); + const requestId = optionalIdentity(event.requestId); + const fields = safeEventFields(normalized.event, event.fields); + return { + ...identity, + component: normalized.component, + ...(durationMs === undefined ? {} : { durationMs }), + event: normalized.event, + ...(event.failure === undefined + ? {} + : { failure: describeSafeFailure(event.failure) }), + ...(fields === undefined ? {} : { fields }), + ...(jobId === undefined ? {} : { jobId }), + level: safeLevel, + ...(outcome === undefined ? {} : { outcome }), + ...(requestId === undefined ? {} : { requestId }), + timestamp: Date.prototype.toISOString.call(timestamp), + }; +} + +function serializeRecord( + record: StructuredLogRecord, + limits: StructuredLogLimits +): string { + const serialized = `${JSON.stringify(record)}\n`; + if ( + new TextEncoder().encode(serialized).byteLength <= limits.maximumSerializedBytes + ) { + return serialized; + } + const boundedRecord: StructuredLogRecord = { + ...record, + fields: { truncated: true }, + }; + const bounded = `${JSON.stringify(boundedRecord)}\n`; + if (new TextEncoder().encode(bounded).byteLength <= limits.maximumSerializedBytes) { + return bounded; + } + throw new RangeError("Structured log envelope exceeds its byte budget"); +} + +/** + * Creates a non-throwing structured logger. Sink failures emit one constant fallback. + * @param options Fixed identity, sink, clock, and redaction policy. + * @returns A frozen process logger with idempotent flushing. + */ +export function createStructuredLogger( + options: StructuredLoggerOptions +): StructuredLogger { + validateLoggerIdentity(options.identity); + const identity = Object.freeze({ ...options.identity }); + const limits = Object.freeze({ + maximumSerializedBytes: + options.limits?.maximumSerializedBytes ?? + defaultStructuredLogLimits.maximumSerializedBytes, + }); + validateLoggerLimits(limits); + const now = options.now ?? (() => new Date()); + const sinkWrite = options.sink.write.bind(options.sink); + const sinkFlush = options.sink.flush?.bind(options.sink); + let fallbackWritten = false; + let flushed = false; + + const fallbackWrite = + options.fallbackWrite ?? + ((line: string): void => void process.stderr.write(line)); + + const writeFallback = (): void => { + if (fallbackWritten) return; + fallbackWritten = true; + try { + const result: unknown = fallbackWrite(sinkFailureLine); + assertSynchronousSinkResult(result); + } catch { + // A logging double-fault must not recurse into logging or fail the process. + } + }; + const log = (level: StructuredLogLevel, event: StructuredLogEvent): void => { + try { + const safeLevel = normalizedLogLevel(level); + const result: unknown = sinkWrite( + serializeRecord(makeRecord(identity, now, safeLevel, event), limits), + safeLevel + ); + assertSynchronousSinkResult(result); + } catch { + writeFallback(); + } + }; + const logger: StructuredLogger = { + debug: (event) => log("debug", event), + error: (event) => log("error", event), + fatal: (event) => log("fatal", event), + flush() { + if (flushed) return; + flushed = true; + try { + const result: unknown = sinkFlush?.(); + assertSynchronousSinkResult(result); + } catch { + writeFallback(); + } + }, + info: (event) => log("info", event), + log, + warn: (event) => log("warn", event), + }; + return Object.freeze(logger); +} diff --git a/src/server/platform/realtime/eventPumpService.ts b/src/server/platform/realtime/eventPumpService.ts index 09b2eb15c..89937655f 100644 --- a/src/server/platform/realtime/eventPumpService.ts +++ b/src/server/platform/realtime/eventPumpService.ts @@ -343,6 +343,7 @@ export function realtimeEventPumpLayer( ).pipe( Effect.annotateLogs({ component: "realtime-event-pump", + event: "realtime.runner.failed", failureKind: "unexpected-runner-defect", }) ); diff --git a/src/server/platform/runtime/applicationRuntime.test.ts b/src/server/platform/runtime/applicationRuntime.test.ts index 682b5e4aa..12e9f5032 100644 --- a/src/server/platform/runtime/applicationRuntime.test.ts +++ b/src/server/platform/runtime/applicationRuntime.test.ts @@ -2,13 +2,15 @@ import { describe, expect, test } from "bun:test"; import { addMilliseconds, secondsToMilliseconds } from "date-fns"; import { maxTime } from "date-fns/constants"; -import { Effect, Layer, Stream } from "effect"; +import { Effect, Layer, Logger, Stream } from "effect"; import { captureFailure, rejectOnAbort, withTestTimeout, } from "../../test/support/promise.ts"; +import { createTestStructuredLogger } from "../../test/support/requestContext.ts"; +import { createStructuredLogger } from "../observability/structuredLogger.ts"; import type { RealtimeEventDelivery } from "../realtime/eventPump.ts"; import { isRealtimeEventStreamError, @@ -40,6 +42,8 @@ const stableLease: RenewableStreamLease = { renew: () => Promise.resolve(stableLease), }; +const testStructuredLogger = createTestStructuredLogger(); + function createInertApplicationRuntime() { const service = RealtimeEventPumpService.of({ metricsSnapshot: Effect.die("Realtime metrics are not used"), @@ -47,11 +51,79 @@ function createInertApplicationRuntime() { wake: Effect.void, }); return createApplicationRuntime({ + logger: testStructuredLogger, realtimeEventPumpLayer: Layer.succeed(RealtimeEventPumpService, service), }); } describe("application Effect runtime", () => { + test("installs the supplied structured logger without the default Effect logger", async () => { + const lines: string[] = []; + const logger = createStructuredLogger({ + identity: { + bun: "test-bun", + pid: 1, + processRole: "web", + release: "test-release", + service: "mira-dashboard", + }, + sink: { + write(line) { + lines.push(line); + }, + }, + }); + let activeLoggerCount = 0; + let includesDefaultLogger = true; + const service = RealtimeEventPumpService.of({ + metricsSnapshot: Effect.die("Realtime metrics are not used"), + stream: () => + Stream.fromEffect( + Effect.gen(function* () { + const activeLoggers = yield* Effect.service( + Logger.CurrentLoggers + ); + activeLoggerCount = activeLoggers.size; + includesDefaultLogger = activeLoggers.has(Logger.defaultLogger); + yield* Effect.logInfo("ignored runtime logger message").pipe( + Effect.annotateLogs({ + component: "application-runtime", + event: "runtime.logger.connected", + }) + ); + return delivery; + }) + ), + wake: Effect.void, + }); + const runtime = createApplicationRuntime({ + logger, + realtimeEventPumpLayer: Layer.succeed(RealtimeEventPumpService, service), + }); + + try { + await runtime.initialize(); + expect(runtime.logger).toBe(logger); + const deliveries = await runtime.services.realtimeEvents.stream( + { afterId: "0" }, + stableLease + ); + + expect(await Array.fromAsync(deliveries)).toEqual([delivery]); + expect(activeLoggerCount).toBe(1); + expect(includesDefaultLogger).toBe(false); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0] ?? "null")).toMatchObject({ + component: "application-runtime", + event: "runtime.logger.connected", + level: "info", + }); + expect(lines[0]).not.toContain("ignored runtime logger message"); + } finally { + await runtime.dispose(); + } + }); + test("coordinates graceful listener completion on the shared runtime", async () => { const runtime = createInertApplicationRuntime(); const stopCalls: boolean[] = []; @@ -263,7 +335,10 @@ describe("application Effect runtime", () => { }) ) ); - const runtime = createApplicationRuntime({ realtimeEventPumpLayer: layer }); + const runtime = createApplicationRuntime({ + logger: testStructuredLogger, + realtimeEventPumpLayer: layer, + }); const controller = new AbortController(); await runtime.initialize(); @@ -302,7 +377,10 @@ describe("application Effect runtime", () => { wake: Effect.void, }) ); - const runtime = createApplicationRuntime({ realtimeEventPumpLayer: layer }); + const runtime = createApplicationRuntime({ + logger: testStructuredLogger, + realtimeEventPumpLayer: layer, + }); let observed: unknown; try { @@ -337,7 +415,10 @@ describe("application Effect runtime", () => { wake: Effect.void, }) ); - const runtime = createApplicationRuntime({ realtimeEventPumpLayer: layer }); + const runtime = createApplicationRuntime({ + logger: testStructuredLogger, + realtimeEventPumpLayer: layer, + }); const controller = new AbortController(); controller.abort(); @@ -375,7 +456,10 @@ describe("application Effect runtime", () => { wake: Effect.void, }) ); - const runtime = createApplicationRuntime({ realtimeEventPumpLayer: layer }); + const runtime = createApplicationRuntime({ + logger: testStructuredLogger, + realtimeEventPumpLayer: layer, + }); const deliveries = await runtime.services.realtimeEvents.stream( { afterId: "0" }, stableLease @@ -419,7 +503,10 @@ describe("application Effect runtime", () => { wake: Effect.void, }) ); - const runtime = createApplicationRuntime({ realtimeEventPumpLayer: layer }); + const runtime = createApplicationRuntime({ + logger: testStructuredLogger, + realtimeEventPumpLayer: layer, + }); const controller = new AbortController(); let iterator: AsyncIterator | undefined; diff --git a/src/server/platform/runtime/applicationRuntime.ts b/src/server/platform/runtime/applicationRuntime.ts index a00802246..27ae69efa 100644 --- a/src/server/platform/runtime/applicationRuntime.ts +++ b/src/server/platform/runtime/applicationRuntime.ts @@ -8,6 +8,8 @@ import { authenticationWorkLayer, type AuthenticationVerificationWorkOptions, } from "../../domains/security/authenticationWorkGate.ts"; +import { createEffectLoggerLayer } from "../observability/effectLogger.ts"; +import type { StructuredLogger } from "../observability/structuredLogger.ts"; import type { RealtimeEventDelivery } from "../realtime/eventPump.ts"; import type { RealtimeEventStreamOptions } from "../realtime/eventPumpService.ts"; import { RealtimeEventPumpService } from "../realtime/eventPumpService.ts"; @@ -64,6 +66,8 @@ export interface ApplicationListenerShutdownOptions { /** Effect-backed lifecycle and request services owned by one long-lived Bun process. */ export interface ApplicationRuntime { + /** Exact process logger installed on this runtime's Effect layer. */ + readonly logger: StructuredLogger; readonly services: ApplicationRuntimeServices; dispose(): Promise; /** Eagerly builds and caches every process-owned layer before readiness. */ @@ -75,6 +79,7 @@ export interface ApplicationRuntime { /** Scoped layers owned by one composition root for the full process lifetime. */ export interface ApplicationRuntimeOptions { readonly authenticationWork?: AuthenticationWorkLayerOptions; + readonly logger: StructuredLogger; readonly realtimeEventPumpLayer: Layer.Layer; } @@ -205,9 +210,10 @@ export function createApplicationRuntime( options: ApplicationRuntimeOptions ): ApplicationRuntime { const runtime = ManagedRuntime.make( - Layer.merge( + Layer.mergeAll( options.realtimeEventPumpLayer, - authenticationWorkLayer(options.authenticationWork) + authenticationWorkLayer(options.authenticationWork), + createEffectLoggerLayer(options.logger) ) ); let disposePromise: Promise | undefined; @@ -337,6 +343,7 @@ export function createApplicationRuntime( async initialize() { await runtime.context(); }, + logger: options.logger, services, shutdownListener(options: ApplicationListenerShutdownOptions) { return runtime.runPromise(coordinatedListenerShutdown(options)); diff --git a/src/server/test/contracts/trpcErrors.test.ts b/src/server/test/contracts/trpcErrors.test.ts index da63e0929..9c71e21ea 100644 --- a/src/server/test/contracts/trpcErrors.test.ts +++ b/src/server/test/contracts/trpcErrors.test.ts @@ -16,60 +16,80 @@ type ErrorProcedure = | "tampered-policy-cause" | "unexpected"; +const errorProcedurePaths = { + expected: "events.stream", + "forged-policy-cause": "accountSecurity.summary", + mfa_enrollment_required: "accountSecurity.stepUpRecovery", + step_up_required: "auth.changePassword", + "tampered-policy-cause": "auth.revokeSession", + unexpected: "system.runtimeIdentity", +} as const satisfies Readonly>; + async function queryWireBody(procedure: ErrorProcedure): Promise<{ response: Response; text: string; }> { const errorRouter = router({ - expected: publicProcedure.query(() => { - throw new TRPCError({ code: "BAD_REQUEST", message: "Safe client error" }); - }), - "forged-policy-cause": publicProcedure.query(() => { - throw new TRPCError({ - cause: Object.assign(new Error(sentinel), { - reason: "step_up_required", - }), - code: "FORBIDDEN", - message: "Safe client error", - }); + accountSecurity: router({ + stepUpRecovery: publicProcedure.query(() => { + throw authenticationPolicyError( + "mfa_enrollment_required", + "Multi-factor authentication enrollment is required" + ); + }), + summary: publicProcedure.query(() => { + throw new TRPCError({ + cause: Object.assign(new Error(sentinel), { + reason: "step_up_required", + }), + code: "FORBIDDEN", + message: "Safe client error", + }); + }), }), - mfa_enrollment_required: publicProcedure.query(() => { - throw authenticationPolicyError( - "mfa_enrollment_required", - "Multi-factor authentication enrollment is required" - ); + auth: router({ + changePassword: publicProcedure.query(() => { + throw authenticationPolicyError( + "step_up_required", + "Recent authentication is required" + ); + }), + revokeSession: publicProcedure.query(() => { + const error = authenticationPolicyError( + "step_up_required", + "Recent authentication is required" + ); + const { cause } = error; + if (cause === undefined) { + throw new Error("Authentication policy cause is missing"); + } + Object.assign(cause, { + message: sentinel, + reason: "unknown_policy_reason", + }); + throw error; + }), }), - "tampered-policy-cause": publicProcedure.query(() => { - const error = authenticationPolicyError( - "step_up_required", - "Recent authentication is required" - ); - const { cause } = error; - if (cause === undefined) { - throw new Error("Authentication policy cause is missing"); - } - Object.assign(cause, { - message: sentinel, - reason: "unknown_policy_reason", - }); - throw error; + events: router({ + stream: publicProcedure.query(() => { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Safe client error", + }); + }), }), - step_up_required: publicProcedure.query(() => { - throw authenticationPolicyError( - "step_up_required", - "Recent authentication is required" - ); - }), - unexpected: publicProcedure.query(() => { - throw Object.assign(new Error(sentinel), { - reason: "step_up_required", - }); + system: router({ + runtimeIdentity: publicProcedure.query(() => { + throw Object.assign(new Error(sentinel), { + reason: "step_up_required", + }); + }), }), }); const response = await fetchRequestHandler({ createContext: () => createTestRequestContext(), endpoint: "/trpc", - req: new Request(`http://localhost/trpc/${procedure}`), + req: new Request(`http://localhost/trpc/${errorProcedurePaths[procedure]}`), router: errorRouter, }); return { response, text: await response.text() }; diff --git a/src/server/test/support/requestContext.ts b/src/server/test/support/requestContext.ts index ffbc9cc71..eff4901ad 100644 --- a/src/server/test/support/requestContext.ts +++ b/src/server/test/support/requestContext.ts @@ -13,6 +13,10 @@ import type { import type { AutomationSecurityLifecycleService } from "../../domains/security/automation/lifecycle.ts"; import type { MfaAccountLifecycleService } from "../../domains/security/mfa/accountLifecycle.ts"; import type { MfaLoginLifecycleService } from "../../domains/security/mfa/loginLifecycle.ts"; +import { + createStructuredLogger, + type StructuredLogger, +} from "../../platform/observability/structuredLogger.ts"; import type { ApplicationRuntime, RealtimeEventRuntimeService, @@ -30,6 +34,27 @@ export const testSecurityUserId = "019fc968-1a9b-7770-8f1b-d5b863b0e7b4"; export const testSessionSelector = "a".repeat(32); export const testAutomationCredentialId = "019fc968-1a9b-7771-9f1b-d5b863b0e7b4"; +const inertStructuredLogSink = Object.freeze({ + write(): undefined {}, +}); + +/** + * Creates an inert process logger for tests that compose runtime or server roots. + * @returns A complete structured logger that discards every validated record. + */ +export function createTestStructuredLogger(): StructuredLogger { + return createStructuredLogger({ + identity: { + bun: "test-bun", + pid: 1, + processRole: "web", + release: "test-release", + service: "mira-dashboard", + }, + sink: inertStructuredLogSink, + }); +} + /** * Creates one valid session identity with the requested test capabilities. * @param capabilities Capabilities granted to the test user. @@ -95,6 +120,7 @@ interface TestApplicationRuntimeOverrides { readonly authentication?: AuthenticationWorkRuntimeService; readonly dispose?: ApplicationRuntime["dispose"]; readonly initialize?: ApplicationRuntime["initialize"]; + readonly logger?: StructuredLogger; readonly shutdownListener?: ApplicationRuntime["shutdownListener"]; readonly stream?: RealtimeEventRuntimeService["stream"]; } @@ -306,6 +332,7 @@ export function createTestApplicationRuntime( return Object.freeze({ dispose: overrides.dispose ?? (() => Promise.resolve()), initialize: overrides.initialize ?? (() => Promise.resolve()), + logger: overrides.logger ?? createTestStructuredLogger(), services: Object.freeze({ authentication: overrides.authentication ?? inertAuthenticationRuntime, realtimeEvents: Object.freeze({ @@ -339,6 +366,7 @@ export function createTestRequestContext( readonly mfaAccountLifecycle?: MfaAccountLifecycleService; readonly mfaLoginLifecycle?: MfaLoginLifecycleService; readonly request?: Request; + readonly requestId?: string; readonly responseHeaders?: Headers; } = {} ): Promise { @@ -361,6 +389,7 @@ export function createTestRequestContext( options.mfaLoginLifecycle ?? createTestMfaLoginLifecycleService(), pendingLoginCredential: credentials.pendingLogin, request, + requestId: options.requestId ?? "test-request-id", responseHeaders: options.responseHeaders ?? new Headers(), }); } diff --git a/src/server/test/system/serverAutomationSecurity.test.ts b/src/server/test/system/serverAutomationSecurity.test.ts index 61011b622..222e4f54f 100644 --- a/src/server/test/system/serverAutomationSecurity.test.ts +++ b/src/server/test/system/serverAutomationSecurity.test.ts @@ -36,7 +36,10 @@ import { } from "../support/automationHttpSystem.ts"; import { CookieJar, postTrpcMutation, trpcData } from "../support/mfaHttpSystem.ts"; import { withTestTimeout } from "../support/promise.ts"; -import { createTestApplicationRuntime } from "../support/requestContext.ts"; +import { + createTestApplicationRuntime, + createTestStructuredLogger, +} from "../support/requestContext.ts"; const leaseInvalidationTimeoutMs = secondsToMilliseconds(5); async function createSystemPrincipal( @@ -316,6 +319,7 @@ describe("real HTTP automation credential lifecycle", () => { ); const unusedMetrics = Effect.die("Metrics are not used in this test"); const runtime = createApplicationRuntime({ + logger: createTestStructuredLogger(), realtimeEventPumpLayer: Layer.succeed( RealtimeEventPumpService, RealtimeEventPumpService.of({ diff --git a/src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts b/src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts index 51368772b..a5dae28a3 100644 --- a/src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts +++ b/src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts @@ -20,6 +20,7 @@ import { } from "../support/automationHttpSystem.ts"; import { postTrpcMutation, trpcData } from "../support/mfaHttpSystem.ts"; import { withTestTimeout } from "../support/promise.ts"; +import { createTestStructuredLogger } from "../support/requestContext.ts"; const leaseDurationMs = secondsToMilliseconds(1); const invalidationTimeoutMs = secondsToMilliseconds(5); @@ -39,6 +40,7 @@ function createQuietAutomationRuntime() { }); const realtimeEventPumpLayer = Layer.succeed(RealtimeEventPumpService, eventPump); return createApplicationRuntime({ + logger: createTestStructuredLogger(), realtimeEventPumpLayer, }); } diff --git a/src/server/test/system/serverFoundation.test.ts b/src/server/test/system/serverFoundation.test.ts index 229879366..029474c9b 100644 --- a/src/server/test/system/serverFoundation.test.ts +++ b/src/server/test/system/serverFoundation.test.ts @@ -4,17 +4,20 @@ import { createTRPCClient, httpBatchLink } from "@trpc/client"; import superjson from "superjson"; import { + authenticationRequestBodyMaximumBytes, type ApplicationServer, createServer, serverRequestBodyMaximumBytes, } from "../../../app/server.ts"; import { bunRuntimePolicy } from "../../../shared/bunRuntimePolicy.ts"; +import { createStructuredLogger } from "../../platform/observability/structuredLogger.ts"; import { createReadinessController, type ReadinessController, } from "../../platform/readiness/readinessState.ts"; import * as runtimeIdentityModule from "../../platform/runtime/readRuntimeIdentity.ts"; import type { AppRouter } from "../../trpc/appRouter.ts"; +import { rejectOnAbort, withTestTimeout } from "../support/promise.ts"; import { createTestApplicationRuntime, createTestAuthenticationLifecycleService, @@ -22,6 +25,12 @@ import { } from "../support/requestContext.ts"; const servers: ApplicationServer[] = []; +const requestIdPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +function compareUnknownStrings(left: unknown, right: unknown): number { + return String(left).localeCompare(String(right)); +} async function startServer(): Promise<{ readiness: ReadinessController; @@ -88,15 +97,23 @@ describe("system foundation", () => { const misleadingTrpcPrefix = await fetch(new URL("/trpc-unrelated", server.url)); expect(liveness.status).toBe(200); + expect(liveness.headers.get("x-request-id")).toMatch(requestIdPattern); expect(await liveness.json()).toEqual({ status: "live" }); expect(readiness.status).toBe(503); + expect(readiness.headers.get("x-request-id")).toMatch(requestIdPattern); expect(await readiness.json()).toEqual({ status: "not-ready" }); expect(headLiveness.status).toBe(200); + expect(headLiveness.headers.get("x-request-id")).toMatch(requestIdPattern); expect(await headLiveness.text()).toBe(""); expect(headReadiness.status).toBe(503); + expect(headReadiness.headers.get("x-request-id")).toMatch(requestIdPattern); expect(await headReadiness.text()).toBe(""); expect(missing.status).toBe(404); + expect(missing.headers.get("x-request-id")).toMatch(requestIdPattern); expect(misleadingTrpcPrefix.status).toBe(404); + expect(misleadingTrpcPrefix.headers.get("x-request-id")).toMatch( + requestIdPattern + ); readinessController.markReady(); const ready = await fetch(new URL("/api/health/ready", server.url)); @@ -109,7 +126,19 @@ describe("system foundation", () => { expect(await unavailable.json()).toEqual({ status: "not-ready" }); }); - test("rejects request bodies above the bounded application transport budget", async () => { + test("correlates request bodies rejected by the application transport budget", async () => { + const { server } = await startServer(); + const response = await fetch(new URL("/trpc/auth.status", server.url), { + body: "x".repeat(authenticationRequestBodyMaximumBytes + 1), + headers: { "content-type": "application/json" }, + method: "POST", + }); + + expect(response.status).toBe(413); + expect(response.headers.get("x-request-id")).toMatch(requestIdPattern); + }); + + test("keeps the Bun pre-dispatch request-body ceiling", async () => { const { server } = await startServer(); const response = await fetch( new URL("/trpc/system.runtimeIdentity", server.url), @@ -123,6 +152,252 @@ describe("system foundation", () => { expect(response.status).toBe(413); }); + test("emits one correlated response-created event for every response class", async () => { + const logLines: string[] = []; + const logger = createStructuredLogger({ + identity: { + bun: "1.4.0-test", + pid: 123, + processRole: "web", + release: "server-foundation-test", + service: "mira-dashboard", + }, + sink: { + write(line) { + logLines.push(line); + }, + }, + }); + const server = await createServer({ + ...createTestServerSecurityServices(), + applicationRuntime: createTestApplicationRuntime({ logger }), + hostname: "127.0.0.1", + port: 0, + readiness: createReadinessController(), + }); + servers.push(server); + + const responses = await Promise.all([ + fetch(new URL("/api/health/live", server.url)), + fetch(new URL("/api/unknown", server.url)), + fetch(new URL("/trpc/system.runtimeIdentity", server.url)), + ]); + await Promise.all(responses.map((response) => response.text())); + const records = logLines.map( + (line) => JSON.parse(line) as Record + ); + + expect(records).toHaveLength(3); + expect(records.map((record) => record.event)).toEqual([ + "http.response.created", + "http.response.created", + "http.response.created", + ]); + expect( + records.map((record) => record.requestId).toSorted(compareUnknownStrings) + ).toEqual( + responses + .map((response) => response.headers.get("x-request-id")) + .toSorted(compareUnknownStrings) + ); + expect( + records.every( + (record) => + Number.isSafeInteger(record.durationMs) && + Number(record.durationMs) >= 0 + ) + ).toBe(true); + }); + + test("classifies an aborted streaming upload without a server-error event", async () => { + const logLines: string[] = []; + const logger = createStructuredLogger({ + identity: { + bun: "1.4.0-test", + pid: 123, + processRole: "web", + release: "server-foundation-test", + service: "mira-dashboard", + }, + sink: { + write(line) { + logLines.push(line); + }, + }, + }); + const server = await createServer({ + ...createTestServerSecurityServices(), + applicationRuntime: createTestApplicationRuntime({ logger }), + hostname: "127.0.0.1", + port: 0, + readiness: createReadinessController(), + }); + servers.push(server); + const abortController = new AbortController(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{")); + }, + }); + const pendingRequest = fetch(new URL("/trpc/auth.status", server.url), { + body, + headers: { "content-type": "application/json" }, + method: "POST", + signal: abortController.signal, + }).catch((error: unknown) => error); + + await Bun.sleep(50); + abortController.abort(); + expect(await pendingRequest).toBeInstanceOf(Error); + for (let attempt = 0; attempt < 100 && logLines.length === 0; attempt += 1) { + await Bun.sleep(5); + } + + const records = logLines.map( + (line) => JSON.parse(line) as Record + ); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + component: "http", + event: "http.request.cancelled", + level: "info", + outcome: "cancelled", + }); + expect(records[0]).not.toHaveProperty("failure"); + expect(logLines.join("\n")).not.toContain("server-error"); + }); + + test("classifies resolver cancellation after dispatch as one cancellation event", async () => { + const logLines: string[] = []; + const logger = createStructuredLogger({ + identity: { + bun: "1.4.0-test", + pid: 123, + processRole: "web", + release: "server-foundation-test", + service: "mira-dashboard", + }, + sink: { + write(line) { + logLines.push(line); + }, + }, + }); + const resolverStarted = Promise.withResolvers(); + const server = await createServer({ + ...createTestServerSecurityServices(), + applicationRuntime: createTestApplicationRuntime({ logger }), + authenticationLifecycle: createTestAuthenticationLifecycleService({ + login(_input, metadata) { + resolverStarted.resolve(); + if (metadata.signal === undefined) { + return Promise.reject( + new Error("Login resolver did not receive cancellation") + ); + } + return rejectOnAbort(metadata.signal, "Login request was cancelled"); + }, + }), + authenticateCredential: () => ({ + authentication: { kind: "anonymous" }, + }), + hostname: "127.0.0.1", + port: 0, + readiness: createReadinessController(), + }); + servers.push(server); + const abortController = new AbortController(); + const pendingRequest = fetch(new URL("/trpc/auth.login", server.url), { + body: JSON.stringify({ + json: { + password: "correct-horse-battery", + username: "operator", + }, + }), + headers: { "content-type": "application/json" }, + method: "POST", + signal: abortController.signal, + }).catch((error: unknown) => error); + + await withTestTimeout( + resolverStarted.promise, + 1000, + "Login resolver did not start" + ); + abortController.abort(); + expect(await pendingRequest).toBeInstanceOf(Error); + for (let attempt = 0; attempt < 100 && logLines.length === 0; attempt += 1) { + await Bun.sleep(5); + } + + const records = logLines.map( + (line) => JSON.parse(line) as Record + ); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + component: "http", + event: "http.request.cancelled", + level: "info", + outcome: "cancelled", + }); + expect(records[0]).not.toHaveProperty("failure"); + expect(logLines.join("\n")).not.toContain("server-error"); + expect(logLines.join("\n")).not.toContain("trpc.request.defect"); + expect(logLines.join("\n")).not.toContain("http.response.created"); + expect(logLines.join("\n")).not.toContain("http.request.failed"); + }); + + test("returns a correlated sanitized 500 when a raw handler defects", async () => { + const sentinel = "readiness-defect-secret"; + const logLines: string[] = []; + const logger = createStructuredLogger({ + identity: { + bun: "1.4.0-test", + pid: 123, + processRole: "web", + release: "server-foundation-test", + service: "mira-dashboard", + }, + sink: { + write(line) { + logLines.push(line); + }, + }, + }); + const server = await createServer({ + ...createTestServerSecurityServices(), + applicationRuntime: createTestApplicationRuntime({ logger }), + hostname: "127.0.0.1", + port: 0, + readiness: { + isReady() { + throw new Error(sentinel); + }, + markReady() {}, + markUnavailable() {}, + }, + }); + servers.push(server); + + const response = await fetch(new URL("/api/health/ready", server.url)); + const body = await response.text(); + const requestId = response.headers.get("x-request-id"); + + expect(response.status).toBe(500); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(requestId).toMatch(requestIdPattern); + expect(body).toBe("Internal Server Error"); + expect(body).not.toContain(sentinel); + expect(logLines).toHaveLength(1); + expect(JSON.stringify(logLines)).not.toContain(sentinel); + expect(JSON.parse(logLines[0] ?? "null")).toMatchObject({ + component: "http", + event: "http.request.failed", + outcome: "server-error", + requestId, + }); + }); + test("rejects untrusted browser requests before authentication", async () => { let authenticationCalls = 0; const server = await createServer({ diff --git a/src/server/test/system/serverGatewayCredentialVerification.test.ts b/src/server/test/system/serverGatewayCredentialVerification.test.ts index 311cda9b3..799fd80f5 100644 --- a/src/server/test/system/serverGatewayCredentialVerification.test.ts +++ b/src/server/test/system/serverGatewayCredentialVerification.test.ts @@ -19,6 +19,7 @@ import { postTrpcMutation, } from "../support/mfaHttpSystem.ts"; import { captureFailure } from "../support/promise.ts"; +import { createTestStructuredLogger } from "../support/requestContext.ts"; const validGatewayCredential = "valid-gateway-token"; @@ -27,6 +28,7 @@ function createGatewayVerificationRuntime() { "Gateway verification system tests do not use realtime metrics" ); return createApplicationRuntime({ + logger: createTestStructuredLogger(), realtimeEventPumpLayer: Layer.succeed( RealtimeEventPumpService, RealtimeEventPumpService.of({ diff --git a/src/server/test/system/serverShutdown.test.ts b/src/server/test/system/serverShutdown.test.ts index 5b7577ec3..0557e8f17 100644 --- a/src/server/test/system/serverShutdown.test.ts +++ b/src/server/test/system/serverShutdown.test.ts @@ -4,6 +4,7 @@ import { secondsToMilliseconds } from "date-fns"; import { Effect, Layer, Stream } from "effect"; import { createServer } from "../../../app/server.ts"; +import { createStructuredLogger } from "../../platform/observability/structuredLogger.ts"; import { createReadinessController } from "../../platform/readiness/readinessState.ts"; import { RealtimeEventPumpService } from "../../platform/realtime/eventPumpService.ts"; import { @@ -15,6 +16,7 @@ import { createTestApplicationRuntime, createTestAuthenticationLifecycleService, createTestServerSecurityServices, + createTestStructuredLogger, } from "../support/requestContext.ts"; function createPendingBunServer(resolveWhenForced = true): { @@ -51,10 +53,55 @@ function createShutdownTestRuntime(onDispose: () => void) { Effect.sync(onDispose) ); const layer = Layer.effect(RealtimeEventPumpService, scopedService); - return createApplicationRuntime({ realtimeEventPumpLayer: layer }); + return createApplicationRuntime({ + logger: createTestStructuredLogger(), + realtimeEventPumpLayer: layer, + }); } describe("application server shutdown", () => { + test("flushes the process logger after runtime disposal", async () => { + const fake = createPendingBunServer(); + const serveSpy = spyOn(Bun, "serve").mockReturnValue(fake.server); + const order: string[] = []; + const logger = createStructuredLogger({ + identity: { + bun: "1.4.0-test", + pid: 123, + processRole: "web", + release: "server-shutdown-test", + service: "mira-dashboard", + }, + sink: { + flush() { + order.push("logger-flush"); + }, + write() {}, + }, + }); + + try { + const server = await createServer({ + ...createTestServerSecurityServices(), + applicationRuntime: createTestApplicationRuntime({ + dispose() { + order.push("runtime-dispose"); + return Promise.resolve(); + }, + logger, + }), + port: 3100, + readiness: createReadinessController(), + }); + + await server.stop(true); + + expect(order).toEqual(["runtime-dispose", "logger-flush"]); + } finally { + serveSpy.mockRestore(); + } + }); + test("forces immediately when the first stop request is forced", async () => { const fake = createPendingBunServer(); const serveSpy = spyOn(Bun, "serve").mockReturnValue(fake.server); diff --git a/src/server/trpc/appRouter.test.ts b/src/server/trpc/appRouter.test.ts index 91f99b399..c598d76d0 100644 --- a/src/server/trpc/appRouter.test.ts +++ b/src/server/trpc/appRouter.test.ts @@ -2,13 +2,18 @@ import { describe, expect, test } from "bun:test"; import { procedureContracts } from "../../contracts/contractRegistry.ts"; import { appRouterProcedureNames } from "./appRouter.ts"; +import { procedureExpectedErrorPolicy } from "./procedureErrorPolicy.ts"; describe("application router", () => { test("matches the registered procedure contract keys exactly", () => { const contractNames = procedureContracts.map(({ name }) => name).toSorted(); + const expectedErrorPolicyNames = Object.keys( + procedureExpectedErrorPolicy + ).toSorted(); const routerNames = appRouterProcedureNames.toSorted(); expect(routerNames).toEqual(contractNames); + expect(routerNames).toEqual(expectedErrorPolicyNames); }); test("exposes the exact automation-security namespace inventory", () => { diff --git a/src/server/trpc/context.test.ts b/src/server/trpc/context.test.ts index 34155b862..66ec7532c 100644 --- a/src/server/trpc/context.test.ts +++ b/src/server/trpc/context.test.ts @@ -57,6 +57,7 @@ describe("tRPC request context", () => { mfaLoginLifecycle: createTestMfaLoginLifecycleService(), pendingLoginCredential: credentials.pendingLogin, request, + requestId: "request-context-1", responseHeaders, }); @@ -86,6 +87,7 @@ describe("tRPC request context", () => { }, }); expect(context.responseHeaders).toBe(responseHeaders); + expect(context.requestId).toBe("request-context-1"); expect(context.userAgent).toBe("Context Test Browser"); expect("dispose" in context.services).toBe(false); if (context.authentication.kind === "authenticated") { @@ -110,6 +112,7 @@ describe("tRPC request context", () => { mfaLoginLifecycle: createTestMfaLoginLifecycleService(), pendingLoginCredential: credentials.pendingLogin, request, + requestId: "request-context-2", responseHeaders: new Headers(), }); @@ -145,6 +148,7 @@ describe("tRPC request context", () => { mfaLoginLifecycle: createTestMfaLoginLifecycleService(), pendingLoginCredential: credentials.pendingLogin, request, + requestId: "request-context-3", responseHeaders: new Headers(), }); } catch (error) { diff --git a/src/server/trpc/context.ts b/src/server/trpc/context.ts index 0393570c8..f079450de 100644 --- a/src/server/trpc/context.ts +++ b/src/server/trpc/context.ts @@ -31,6 +31,7 @@ export interface RequestContextOptions { readonly mfaLoginLifecycle: MfaLoginLifecycleService; readonly pendingLoginCredential: PendingLoginCredential; readonly request: Request; + readonly requestId: string; readonly responseHeaders: Headers; } @@ -71,7 +72,7 @@ export async function createRequestContext( mfaLoginLifecycle: options.mfaLoginLifecycle, ...(resolution.lease && { authenticationLease: resolution.lease }), pendingLoginCredential: options.pendingLoginCredential, - requestId: crypto.randomUUID(), + requestId: options.requestId, responseHeaders: options.responseHeaders, services: options.applicationRuntime.services, ...(userAgent !== null && { userAgent }), diff --git a/src/server/trpc/procedureErrorPolicy.test.ts b/src/server/trpc/procedureErrorPolicy.test.ts new file mode 100644 index 000000000..166c804e5 --- /dev/null +++ b/src/server/trpc/procedureErrorPolicy.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test"; + +import { TRPCError } from "@trpc/server"; +import * as v from "valibot"; + +import { procedureContracts } from "../../contracts/contractRegistry.ts"; +import type { ProcedureContract } from "../../contracts/registry.ts"; +import { captureFailure } from "../test/support/promise.ts"; +import { createTestRequestContext } from "../test/support/requestContext.ts"; +import { + assertProcedureExpectedErrorPolicy, + procedureExpectedErrorPolicy, + type ProcedureExpectedErrorPolicy, +} from "./procedureErrorPolicy.ts"; +import { publicProcedure, router } from "./trpc.ts"; + +const contractFixture = [ + { errors: ["FORBIDDEN"], name: "example.read" }, +] as const satisfies readonly Pick[]; +const invalidPolicyFixtures: { + name: string; + policy: ProcedureExpectedErrorPolicy; +}[] = [ + { + name: "missing route", + policy: {}, + }, + { + name: "extra route", + policy: { + "example.read": ["FORBIDDEN"], + "example.write": [], + }, + }, + { + name: "error-code drift", + policy: { "example.read": ["UNAUTHORIZED"] }, + }, +]; + +describe("procedure expected-error policy", () => { + test("matches every registered procedure contract exactly", () => { + expect(() => + assertProcedureExpectedErrorPolicy( + procedureContracts, + procedureExpectedErrorPolicy + ) + ).not.toThrow(); + }); + + test("deeply freezes the exported runtime allowlist", () => { + expect(Object.isFrozen(procedureExpectedErrorPolicy)).toBe(true); + for (const errors of Object.values(procedureExpectedErrorPolicy)) { + expect(Object.isFrozen(errors)).toBe(true); + } + + const logoutErrors = procedureExpectedErrorPolicy["auth.logout"]; + expect(() => + Reflect.apply(Array.prototype.push, logoutErrors, ["UNAUTHORIZED"]) + ).toThrow(); + expect(logoutErrors).toEqual([]); + }); + + test.each(invalidPolicyFixtures)("rejects $name", ({ policy }) => { + expect(() => + assertProcedureExpectedErrorPolicy(contractFixture, policy) + ).toThrow(); + }); + + test("passes declared errors and internalizes undeclared route errors", async () => { + const sentinel = "undeclared route detail"; + const testRouter = router({ + auth: router({ + bootstrap: publicProcedure.query(() => { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Gateway credential is invalid", + }); + }), + status: publicProcedure.query(() => { + throw new TRPCError({ + code: "FORBIDDEN", + message: sentinel, + }); + }), + }), + }); + const caller = testRouter.createCaller(await createTestRequestContext()); + + const declared = await captureFailure(() => caller.auth.bootstrap()); + expect(declared).toBeInstanceOf(TRPCError); + expect((declared as TRPCError).code).toBe("UNAUTHORIZED"); + + const undeclared = await captureFailure(() => caller.auth.status()); + expect(undeclared).toBeInstanceOf(TRPCError); + expect((undeclared as TRPCError).code).toBe("INTERNAL_SERVER_ERROR"); + expect((undeclared as TRPCError).message).not.toContain(sentinel); + }); + + test("internalizes expected-looking errors from unregistered procedure paths", async () => { + const testRouter = router({ + unregistered: publicProcedure.query(() => { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Unregistered route failure", + }); + }), + }); + const caller = testRouter.createCaller(await createTestRequestContext()); + const failure = await captureFailure(() => caller.unregistered()); + + expect(failure).toBeInstanceOf(TRPCError); + expect((failure as TRPCError).code).toBe("INTERNAL_SERVER_ERROR"); + }); + + test("keeps framework input validation implicit", async () => { + const statusInputSchema = v.strictObject({}); + const statusProcedure = publicProcedure + .input(statusInputSchema) + .query(() => ({ isOk: true as const })); + const testRouter = router({ + auth: router({ + status: statusProcedure, + }), + }); + const caller = testRouter.createCaller(await createTestRequestContext()); + const failure = await captureFailure(() => + caller.auth.status({ unexpected: true }) + ); + + expect(failure).toBeInstanceOf(TRPCError); + expect((failure as TRPCError).code).toBe("BAD_REQUEST"); + }); + + test("enforces the policy while a subscription is iterated", async () => { + const testRouter = router({ + system: router({ + runtimeIdentity: publicProcedure.subscription(async function* () { + await Promise.resolve(); + yield "started"; + throw new TRPCError({ + code: "FORBIDDEN", + message: "Deferred undeclared failure", + }); + }), + }), + }); + const caller = testRouter.createCaller(await createTestRequestContext()); + const stream = await caller.system.runtimeIdentity(); + const iterator = stream[Symbol.asyncIterator](); + + expect(await iterator.next()).toEqual({ done: false, value: "started" }); + const failure = await captureFailure(() => iterator.next()); + expect(failure).toBeInstanceOf(TRPCError); + expect((failure as TRPCError).code).toBe("INTERNAL_SERVER_ERROR"); + }); +}); diff --git a/src/server/trpc/procedureErrorPolicy.ts b/src/server/trpc/procedureErrorPolicy.ts new file mode 100644 index 000000000..2ea347fee --- /dev/null +++ b/src/server/trpc/procedureErrorPolicy.ts @@ -0,0 +1,286 @@ +import { getTRPCErrorFromUnknown, StandardSchemaV1Error, TRPCError } from "@trpc/server"; + +import { procedureContracts } from "../../contracts/contractRegistry.ts"; +import type { ContractErrorCode, ProcedureContract } from "../../contracts/registry.ts"; + +export type ProcedureExpectedErrorPolicy = Readonly< + Record +>; + +function freezeProcedureExpectedErrorPolicy< + const TPolicy extends ProcedureExpectedErrorPolicy, +>(policy: TPolicy): TPolicy { + for (const errors of Object.values(policy)) Object.freeze(errors); + return Object.freeze(policy); +} + +/** + * Server-owned allowlist for expected errors intentionally exposed by each route. + * The runtime boundary consumes this policy; contract metadata must match it exactly. + */ +export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ + "accountSecurity.beginTotpEnrollment": [ + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + "accountSecurity.beginWebAuthnEnrollment": [ + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + "accountSecurity.beginWebAuthnStepUp": [ + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + "accountSecurity.confirmTotpEnrollment": [ + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], + "accountSecurity.confirmWebAuthnEnrollment": [ + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], + "accountSecurity.disableMfa": [ + "CONFLICT", + "FORBIDDEN", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], + "accountSecurity.reauthenticatePassword": [ + "FORBIDDEN", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], + "accountSecurity.removeTotpFactor": [ + "CONFLICT", + "FORBIDDEN", + "NOT_FOUND", + "UNAUTHORIZED", + ], + "accountSecurity.removeWebAuthnCredential": [ + "CONFLICT", + "FORBIDDEN", + "NOT_FOUND", + "UNAUTHORIZED", + ], + "accountSecurity.rotateRecoveryCodes": [ + "CONFLICT", + "FORBIDDEN", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], + "accountSecurity.stepUpRecovery": [ + "CONFLICT", + "FORBIDDEN", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], + "accountSecurity.stepUpTotp": [ + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], + "accountSecurity.stepUpWebAuthn": [ + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], + "accountSecurity.summary": ["FORBIDDEN", "UNAUTHORIZED"], + "auth.beginWebAuthnLogin": ["CONFLICT", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + "auth.bootstrap": [ + "CONFLICT", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], + "auth.changePassword": ["CONFLICT", "FORBIDDEN", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], + "auth.login": [ + "CONFLICT", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], + "auth.loginRecovery": ["SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], + "auth.loginTotp": ["SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], + "auth.loginWebAuthn": ["SERVICE_UNAVAILABLE", "TOO_MANY_REQUESTS", "UNAUTHORIZED"], + "auth.logout": [], + "auth.revokeSession": ["FORBIDDEN", "UNAUTHORIZED"], + "auth.sessions": ["FORBIDDEN", "UNAUTHORIZED"], + "auth.status": [], + "auth.touch": ["FORBIDDEN", "UNAUTHORIZED"], + "automationSecurity.createCredential": [ + "CONFLICT", + "FORBIDDEN", + "NOT_FOUND", + "PRECONDITION_FAILED", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + "automationSecurity.createPrincipal": [ + "CONFLICT", + "FORBIDDEN", + "PRECONDITION_FAILED", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + "automationSecurity.disablePrincipal": [ + "CONFLICT", + "FORBIDDEN", + "NOT_FOUND", + "UNAUTHORIZED", + ], + "automationSecurity.listCredentials": ["FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED"], + "automationSecurity.listPrincipals": ["FORBIDDEN", "UNAUTHORIZED"], + "automationSecurity.replaceCapabilities": [ + "CONFLICT", + "FORBIDDEN", + "NOT_FOUND", + "UNAUTHORIZED", + ], + "automationSecurity.revokeCredential": [ + "CONFLICT", + "FORBIDDEN", + "NOT_FOUND", + "UNAUTHORIZED", + ], + "automationSecurity.rotateCredential": [ + "CONFLICT", + "FORBIDDEN", + "NOT_FOUND", + "PRECONDITION_FAILED", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + "events.stream": [ + "BAD_REQUEST", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "TOO_MANY_REQUESTS", + "UNAUTHORIZED", + ], + "system.runtimeIdentity": [], +} as const satisfies ProcedureExpectedErrorPolicy); + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +/** + * Fails closed when the implemented route inventory and public contract error metadata drift. + * @param contracts Public procedure contracts. + * @param policy Runtime route allowlist for expected client-visible errors. + */ +export function assertProcedureExpectedErrorPolicy( + contracts: readonly Pick[], + policy: ProcedureExpectedErrorPolicy +): void { + const contractNames = contracts.map(({ name }) => name); + const policyNames = Object.keys(policy); + if ( + new Set(contractNames).size !== contractNames.length || + !sameStrings(contractNames.toSorted(), policyNames.toSorted()) + ) { + throw new TypeError( + "Procedure expected-error policy does not match the contract inventory" + ); + } + + for (const contract of contracts) { + if (!sameStrings(contract.errors, policy[contract.name] ?? [])) { + throw new TypeError( + `Procedure expected-error policy does not match ${contract.name}` + ); + } + } +} + +assertProcedureExpectedErrorPolicy(procedureContracts, procedureExpectedErrorPolicy); +const runtimeProcedureExpectedErrorPolicy: ProcedureExpectedErrorPolicy = + procedureExpectedErrorPolicy; + +class UndeclaredProcedureErrorCause extends Error { + public constructor(path: string, code: string) { + super(`Procedure ${path} attempted to expose undeclared error ${code}`); + this.name = "UndeclaredProcedureErrorCause"; + } +} + +function isImplicitInputValidationError(error: TRPCError): boolean { + return error.code === "BAD_REQUEST" && error.cause instanceof StandardSchemaV1Error; +} + +/** + * Converts undeclared errors from registered production routes into internal defects. + * Framework-owned input-validation failures and existing internal defects remain implicit. + * @param path Fully qualified tRPC procedure path. + * @param error Error returned by the tRPC middleware chain. + * @returns Original declared/implicit error or a safe internal replacement. + */ +export function applyProcedureExpectedErrorPolicy( + path: string, + error: TRPCError +): TRPCError { + const expectedErrors = runtimeProcedureExpectedErrorPolicy[path]; + if ( + error.code === "INTERNAL_SERVER_ERROR" || + isImplicitInputValidationError(error) || + (expectedErrors !== undefined && + (expectedErrors as readonly string[]).includes(error.code)) + ) { + return error; + } + + return new TRPCError({ + cause: new UndeclaredProcedureErrorCause(path, error.code), + code: "INTERNAL_SERVER_ERROR", + message: "Internal server error", + }); +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + ((typeof value === "object" && value !== null) || typeof value === "function") && + typeof (value as AsyncIterable)[Symbol.asyncIterator] === "function" + ); +} + +async function* enforceAsyncIterableErrors( + path: string, + iterable: AsyncIterable +): AsyncGenerator { + try { + yield* iterable; + } catch (error) { + throw applyProcedureExpectedErrorPolicy(path, getTRPCErrorFromUnknown(error)); + } +} + +/** + * Extends expected-error enforcement through deferred subscription iteration. + * @param path Fully qualified tRPC procedure path. + * @param value Successful procedure result. + * @returns Original value or an equivalent policy-enforced async iterable. + */ +export function applyProcedureExpectedErrorPolicyToOutput(path: string, value: T): T { + return isAsyncIterable(value) + ? (enforceAsyncIterableErrors(path, value) as T) + : value; +} diff --git a/src/server/trpc/trpc.test.ts b/src/server/trpc/trpc.test.ts index a0ef85402..c2c659c70 100644 --- a/src/server/trpc/trpc.test.ts +++ b/src/server/trpc/trpc.test.ts @@ -11,9 +11,11 @@ import { import { capabilityProcedure, router } from "./trpc.ts"; const capabilityTestRouter = router({ - readNotifications: capabilityProcedure("notifications:read").query( - ({ ctx }) => ctx.principal.kind - ), + events: router({ + stream: capabilityProcedure("notifications:read").query( + ({ ctx }) => ctx.principal.kind + ), + }), }); describe("tRPC capability procedure", () => { @@ -30,7 +32,7 @@ describe("tRPC capability procedure", () => { const context = await createTestRequestContext(authentication); const resolvedPrincipalKind = await capabilityTestRouter .createCaller(context) - .readNotifications(); + .events.stream(); expect(resolvedPrincipalKind).toBe(principalKind); } @@ -45,7 +47,7 @@ describe("tRPC capability procedure", () => { : createTestSessionAuthentication([]); const context = await createTestRequestContext(authentication); const failure = await captureFailure(() => - capabilityTestRouter.createCaller(context).readNotifications() + capabilityTestRouter.createCaller(context).events.stream() ); expect(failure).toBeInstanceOf(TRPCError); @@ -56,7 +58,7 @@ describe("tRPC capability procedure", () => { test("rejects an unauthenticated caller before capability evaluation", async () => { const context = await createTestRequestContext(); const failure = await captureFailure(() => - capabilityTestRouter.createCaller(context).readNotifications() + capabilityTestRouter.createCaller(context).events.stream() ); expect(failure).toBeInstanceOf(TRPCError); diff --git a/src/server/trpc/trpc.ts b/src/server/trpc/trpc.ts index df48a4ce2..4435dcf62 100644 --- a/src/server/trpc/trpc.ts +++ b/src/server/trpc/trpc.ts @@ -8,6 +8,10 @@ import { } from "../../contracts/registry.ts"; import type { ApplicationCapability } from "../../contracts/security.ts"; import type { RequestContext } from "./context.ts"; +import { + applyProcedureExpectedErrorPolicy, + applyProcedureExpectedErrorPolicyToOutput, +} from "./procedureErrorPolicy.ts"; const internalErrorMessage = "Internal server error"; const contractAuthenticationErrorReasonSet: ReadonlySet = new Set( @@ -65,8 +69,18 @@ const trpc = initTRPC.context().create({ transformer: superjson, }); -/** Base procedure builder for explicitly public contracts. */ -export const publicProcedure = trpc.procedure; +/** Base procedure builder with fail-closed expected-error enforcement. */ +export const publicProcedure = trpc.procedure.use(async ({ next, path }) => { + const result = await next(); + if (!result.ok) { + const error = applyProcedureExpectedErrorPolicy(path, result.error); + return error === result.error ? result : { ...result, error }; + } + return { + ...result, + data: applyProcedureExpectedErrorPolicyToOutput(path, result.data), + }; +}); /** * Builds one client-actionable authentication-policy rejection. diff --git a/src/shared/configuration/applicationConfigurationRegistry.ts b/src/shared/configuration/applicationConfigurationRegistry.ts new file mode 100644 index 000000000..97e7e7e50 --- /dev/null +++ b/src/shared/configuration/applicationConfigurationRegistry.ts @@ -0,0 +1,336 @@ +/** Process roles that consume immutable application configuration. */ +export type ApplicationProcessRole = "build" | "script" | "web" | "worker"; + +/** Safe browser-facing representation of one configuration value. */ +export type ConfigurationBrowserExposure = "none" | "presence-only" | "value"; + +/** Shared parser/documentation limits for immutable application configuration. */ +export const applicationConfigurationLimits = Object.freeze({ + gatewayUrlMaximumLength: 2048, + port: Object.freeze({ maximum: 65_535, minimum: 1 }), + projectRootMaximumLength: 4096, + publicOriginMaximumLength: 2048, + recentAuthenticationMinutes: Object.freeze({ maximum: 60, minimum: 1 }), + sessionIdleMinutes: Object.freeze({ maximum: 1440, minimum: 5 }), + totpKeyringMaximumLength: 4096, + trustedProxyAddresses: Object.freeze({ maximumItems: 32, maximumLength: 2048 }), + webAuthnOrigins: Object.freeze({ + maximumItems: 8, + maximumLength: 16_384, + minimumItems: 1, + }), + webAuthnRpIdMaximumLength: 253, + webAuthnRpNameMaximumLength: 128, +}); + +/** Stable field names used by typed server configuration. */ +export type ApplicationConfigurationField = + | "gatewayUrl" + | "logLevel" + | "nodeEnvironment" + | "port" + | "projectRoot" + | "publicOrigin" + | "recentAuthenticationWindowMs" + | "sessionIdleDurationMs" + | "totpKeyring" + | "trustedProxyAddresses" + | "webAuthnRelyingParty.allowedOrigins" + | "webAuthnRelyingParty.rpId" + | "webAuthnRelyingParty.rpName"; + +/** Registered environment names accepted by the application configuration parser. */ +export const applicationConfigurationEnvironmentNames = [ + "NODE_ENV", + "MIRA_DASHBOARD_PROJECT_ROOT", + "PORT", + "MIRA_DASHBOARD_PUBLIC_ORIGIN", + "MIRA_DASHBOARD_TRUSTED_PROXY_IPS", + "OPENCLAW_GATEWAY_URL", + "MIRA_DASHBOARD_WEBAUTHN_RP_ID", + "MIRA_DASHBOARD_WEBAUTHN_ORIGINS", + "MIRA_DASHBOARD_WEBAUTHN_RP_NAME", + "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", + "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", + "MIRA_DASHBOARD_TOTP_KEYRING", + "MIRA_DASHBOARD_LOG_LEVEL", +] as const; + +export type ApplicationConfigurationEnvironmentName = + (typeof applicationConfigurationEnvironmentNames)[number]; + +/** Complete documentation and operational policy for one environment field. */ +export interface ApplicationConfigurationMetadata { + readonly allowedValues: readonly string[] | null; + readonly browserExposure: ConfigurationBrowserExposure; + readonly defaultValue: string | null; + readonly description: string; + readonly environmentName: ApplicationConfigurationEnvironmentName; + readonly field: ApplicationConfigurationField; + readonly operationalEffect: string; + readonly overridePolicy: { + readonly development: boolean; + readonly test: boolean; + }; + readonly restartRequired: boolean; + readonly roles: readonly ApplicationProcessRole[]; + readonly secret: boolean; + readonly validationConstraints: string; + readonly valueType: + | "absolute-path" + | "domain-name" + | "duration-minutes" + | "environment-mode" + | "http-origin" + | "http-origin-list" + | "ip-address-list" + | "json-secret" + | "log-level" + | "relying-party-name" + | "tcp-port" + | "websocket-url"; +} + +const allRoleOverrides = Object.freeze({ development: true, test: true }); + +function metadata( + value: Omit +): ApplicationConfigurationMetadata { + if (value.allowedValues !== null) Object.freeze(value.allowedValues); + Object.freeze(value.roles); + return Object.freeze({ ...value, overridePolicy: allRoleOverrides }); +} + +/** + * Authoritative immutable registry for the first greenfield web-process configuration. + * Generators consume this data rather than rediscovering environment reads from source. + */ +export const applicationConfigurationRegistry: readonly ApplicationConfigurationMetadata[] = + Object.freeze([ + metadata({ + allowedValues: Object.freeze(["development", "production", "test"]), + browserExposure: "value", + defaultValue: "production", + description: "Runtime mode used for fail-closed production trust policy.", + environmentName: "NODE_ENV", + field: "nodeEnvironment", + operationalEffect: + "Controls production-only security and diagnostic behavior.", + restartRequired: true, + roles: Object.freeze(["web", "worker", "build", "script"]), + secret: false, + validationConstraints: "Exactly one enumerated runtime mode.", + valueType: "environment-mode", + }), + metadata({ + allowedValues: null, + browserExposure: "none", + defaultValue: null, + description: + "Lexically normalized absolute Dashboard host-layout root; startup must resolve and validate its real directory before deriving managed paths.", + environmentName: "MIRA_DASHBOARD_PROJECT_ROOT", + field: "projectRoot", + operationalEffect: + "Selects the stable development, production-state, runtime, release, preview, and worktree hierarchy; it is not a checkout path.", + restartRequired: true, + roles: Object.freeze(["web", "worker", "build", "script"]), + secret: false, + validationConstraints: `Non-root normalized absolute path, at most ${applicationConfigurationLimits.projectRootMaximumLength} code units; realpath validation is staged for startup.`, + valueType: "absolute-path", + }), + metadata({ + allowedValues: null, + browserExposure: "none", + defaultValue: "3100", + description: "Loopback HTTP listener port for the greenfield web process.", + environmentName: "PORT", + field: "port", + operationalEffect: + "Changes the local listener endpoint used by the reverse proxy.", + restartRequired: true, + roles: Object.freeze(["web"]), + secret: false, + validationConstraints: `Canonical decimal integer from ${applicationConfigurationLimits.port.minimum} through ${applicationConfigurationLimits.port.maximum}.`, + valueType: "tcp-port", + }), + metadata({ + allowedValues: null, + browserExposure: "value", + defaultValue: null, + description: + "Canonical browser origin used for cookies and request-origin checks.", + environmentName: "MIRA_DASHBOARD_PUBLIC_ORIGIN", + field: "publicOrigin", + operationalEffect: + "Defines the browser trust boundary behind the reverse proxy.", + restartRequired: true, + roles: Object.freeze(["web"]), + secret: false, + validationConstraints: `Canonical HTTP(S) origin at most ${applicationConfigurationLimits.publicOriginMaximumLength} code units; HTTPS is required in production.`, + valueType: "http-origin", + }), + metadata({ + allowedValues: null, + browserExposure: "none", + defaultValue: "", + description: "Canonical comma-separated proxy peer IP allowlist.", + environmentName: "MIRA_DASHBOARD_TRUSTED_PROXY_IPS", + field: "trustedProxyAddresses", + operationalEffect: + "Allows overwritten forwarding headers only from exact peers.", + restartRequired: true, + roles: Object.freeze(["web"]), + secret: false, + validationConstraints: `Zero to ${applicationConfigurationLimits.trustedProxyAddresses.maximumItems} unique canonical IP addresses, comma-separated, at most ${applicationConfigurationLimits.trustedProxyAddresses.maximumLength} code units.`, + valueType: "ip-address-list", + }), + metadata({ + allowedValues: null, + browserExposure: "none", + defaultValue: "ws://127.0.0.1:18789", + description: + "Direct-loopback OpenClaw Gateway endpoint for bootstrap verification.", + environmentName: "OPENCLAW_GATEWAY_URL", + field: "gatewayUrl", + operationalEffect: + "Selects the one-shot native Gateway verification endpoint.", + restartRequired: true, + roles: Object.freeze(["web"]), + secret: false, + validationConstraints: `Canonical direct-loopback WebSocket URL at most ${applicationConfigurationLimits.gatewayUrlMaximumLength} code units.`, + valueType: "websocket-url", + }), + metadata({ + allowedValues: null, + browserExposure: "value", + defaultValue: null, + description: "Stable WebAuthn relying-party domain identifier.", + environmentName: "MIRA_DASHBOARD_WEBAUTHN_RP_ID", + field: "webAuthnRelyingParty.rpId", + operationalEffect: + "Binds every WebAuthn credential and ceremony to one RP ID.", + restartRequired: true, + roles: Object.freeze(["web"]), + secret: false, + validationConstraints: `Lowercase canonical domain name at most ${applicationConfigurationLimits.webAuthnRpIdMaximumLength} code units.`, + valueType: "domain-name", + }), + metadata({ + allowedValues: null, + browserExposure: "value", + defaultValue: null, + description: "Canonical comma-separated WebAuthn browser-origin allowlist.", + environmentName: "MIRA_DASHBOARD_WEBAUTHN_ORIGINS", + field: "webAuthnRelyingParty.allowedOrigins", + operationalEffect: "Restricts WebAuthn ceremonies to reviewed HTTPS origins.", + restartRequired: true, + roles: Object.freeze(["web"]), + secret: false, + validationConstraints: `${applicationConfigurationLimits.webAuthnOrigins.minimumItems} to ${applicationConfigurationLimits.webAuthnOrigins.maximumItems} unique canonical browser origins, comma-separated, at most ${applicationConfigurationLimits.webAuthnOrigins.maximumLength} code units.`, + valueType: "http-origin-list", + }), + metadata({ + allowedValues: null, + browserExposure: "value", + defaultValue: "Mira Dashboard", + description: "Human-readable relying-party name shown by authenticators.", + environmentName: "MIRA_DASHBOARD_WEBAUTHN_RP_NAME", + field: "webAuthnRelyingParty.rpName", + operationalEffect: + "Changes the relying-party label in registration ceremonies.", + restartRequired: true, + roles: Object.freeze(["web"]), + secret: false, + validationConstraints: `Trimmed NFC text without control characters, at most ${applicationConfigurationLimits.webAuthnRpNameMaximumLength} code units.`, + valueType: "relying-party-name", + }), + metadata({ + allowedValues: null, + browserExposure: "value", + defaultValue: "30", + description: "Browser-session idle lifetime in whole minutes.", + environmentName: "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", + field: "sessionIdleDurationMs", + operationalEffect: "Controls when inactive browser sessions expire.", + restartRequired: true, + roles: Object.freeze(["web"]), + secret: false, + validationConstraints: `Canonical whole minutes from ${applicationConfigurationLimits.sessionIdleMinutes.minimum} through ${applicationConfigurationLimits.sessionIdleMinutes.maximum}.`, + valueType: "duration-minutes", + }), + metadata({ + allowedValues: null, + browserExposure: "value", + defaultValue: "10", + description: "Recent password or MFA verification window in whole minutes.", + environmentName: "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", + field: "recentAuthenticationWindowMs", + operationalEffect: + "Controls step-up freshness for sensitive account operations.", + restartRequired: true, + roles: Object.freeze(["web"]), + secret: false, + validationConstraints: `Canonical whole minutes from ${applicationConfigurationLimits.recentAuthenticationMinutes.minimum} through ${applicationConfigurationLimits.recentAuthenticationMinutes.maximum}.`, + valueType: "duration-minutes", + }), + metadata({ + allowedValues: null, + browserExposure: "presence-only", + defaultValue: null, + description: "Versioned AES-256-GCM keyring for persisted TOTP secrets.", + environmentName: "MIRA_DASHBOARD_TOTP_KEYRING", + field: "totpKeyring", + operationalEffect: "Selects active and retained TOTP encryption keys.", + restartRequired: true, + roles: Object.freeze(["web"]), + secret: true, + validationConstraints: `Version 1 JSON with one to eight unique AES-256 keys and one active key, at most ${applicationConfigurationLimits.totpKeyringMaximumLength} code units.`, + valueType: "json-secret", + }), + metadata({ + allowedValues: Object.freeze(["debug", "error", "info", "warn"]), + browserExposure: "value", + defaultValue: "info", + description: "Minimum structured application log severity.", + environmentName: "MIRA_DASHBOARD_LOG_LEVEL", + field: "logLevel", + operationalEffect: "Changes structured diagnostic verbosity.", + restartRequired: true, + roles: Object.freeze(["web", "worker", "script"]), + secret: false, + validationConstraints: "Exactly one enumerated structured-log level.", + valueType: "log-level", + }), + ]); + +/** + * Returns registered environment names consumed by one process role. + * @param role Application process role. + * @returns Frozen registry-order projection for that role only. + */ +export function configurationEnvironmentNamesForRole( + role: ApplicationProcessRole +): readonly ApplicationConfigurationEnvironmentName[] { + return Object.freeze( + applicationConfigurationRegistry + .filter((entry) => entry.roles.includes(role)) + .map((entry) => entry.environmentName) + ); +} + +/** + * Returns the immutable registry entry for one accepted environment name. + * @param environmentName Registered process-environment name. + * @returns Immutable metadata for the field. + */ +export function configurationMetadata( + environmentName: ApplicationConfigurationEnvironmentName +): ApplicationConfigurationMetadata { + const entry = applicationConfigurationRegistry.find( + (candidate) => candidate.environmentName === environmentName + ); + if (entry === undefined) { + throw new Error("Application configuration registry is incomplete"); + } + return entry; +} diff --git a/src/shared/encoding.test.ts b/src/shared/encoding.test.ts index 54438b045..a4c62a20c 100644 --- a/src/shared/encoding.test.ts +++ b/src/shared/encoding.test.ts @@ -6,4 +6,6 @@ test("counts encoded UTF-8 bytes instead of UTF-16 code units", () => { expect(utf8ByteLength("plain")).toBe(5); expect(utf8ByteLength("blå")).toBe(4); expect(utf8ByteLength("👩‍💻")).toBe(11); + expect(utf8ByteLength("\uD800")).toBe(3); + expect(utf8ByteLength("\uDC00")).toBe(3); }); diff --git a/src/shared/encoding.ts b/src/shared/encoding.ts index d1577366b..3d1a2d96b 100644 --- a/src/shared/encoding.ts +++ b/src/shared/encoding.ts @@ -1,10 +1,23 @@ -const utf8Encoder = new TextEncoder(); - /** * Returns the encoded UTF-8 byte length of a string. * @param value String to encode. * @returns Encoded byte length. */ export function utf8ByteLength(value: string): number { - return utf8Encoder.encode(value).byteLength; + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const codePoint = value.codePointAt(index); + if (codePoint === undefined) continue; + if (codePoint <= 127) { + bytes += 1; + } else if (codePoint <= 2047) { + bytes += 2; + } else if (codePoint <= 65_535) { + bytes += 3; + } else { + bytes += 4; + index += 1; + } + } + return bytes; } diff --git a/tsconfig.browser.json b/tsconfig.browser.json new file mode 100644 index 000000000..a28277191 --- /dev/null +++ b/tsconfig.browser.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "useDefineForClassFields": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [], + "jsx": "react-jsx" + }, + "include": [ + "src/app/browser.tsx", + "src/browser/**/*.ts", + "src/browser/**/*.tsx", + "src/contracts/**/*.ts", + "src/shared/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx", + "src/**/__tests__/**/*.ts", + "src/**/__tests__/**/*.tsx", + "src/**/testSupport/**/*.ts", + "src/**/testSupport/**/*.tsx" + ] +} diff --git a/tsconfig.contracts.json b/tsconfig.contracts.json new file mode 100644 index 000000000..5634676b6 --- /dev/null +++ b/tsconfig.contracts.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ESNext"], + "types": [] + }, + "include": ["src/contracts/**/*.ts", "src/shared/**/*.ts"], + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/__tests__/**/*.ts", + "src/**/testSupport/**/*.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json index bf9305749..6d5a8db4a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,8 +23,12 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.browser.json" }, + { "path": "./tsconfig.contracts.json" }, { "path": "./tsconfig.node.json" }, { "path": "./tsconfig.qualification.json" }, - { "path": "./tsconfig.server.json" } + { "path": "./tsconfig.scripts.json" }, + { "path": "./tsconfig.server.json" }, + { "path": "./tsconfig.worker.json" } ] } diff --git a/tsconfig.node.json b/tsconfig.node.json index e26200b2f..ab6de7a7d 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -9,7 +9,6 @@ "backend/src/**/*.ts", "backend/test/**/*.ts", "contracts/**/*.ts", - "scripts/**/*.ts", "test/**/*.ts", "frontend/src/globals.d.ts" ] diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 000000000..a6c1978e4 --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ESNext"], + "types": ["bun-types", "node"] + }, + "include": [ + "drizzle.config.ts", + "frontend/src/globals.d.ts", + "scripts/**/*.ts", + "tailwind.config.ts" + ] +} diff --git a/tsconfig.server.json b/tsconfig.server.json index 64659570e..343284d05 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -5,10 +5,15 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo", "types": ["bun-types", "node"] }, - "include": [ - "src/app/**/*.ts", - "src/contracts/**/*.ts", - "src/server/**/*.ts", - "src/shared/**/*.ts" - ] + "files": [ + "src/app/dashboardServer.test.ts", + "src/app/dashboardServer.ts", + "src/app/environmentSource.ts", + "src/app/server.ts", + "src/app/trpcHttpHandler.test.ts", + "src/app/trpcHttpHandler.ts", + "src/app/trpcRequestPolicy.test.ts", + "src/app/trpcRequestPolicy.ts" + ], + "include": ["src/contracts/**/*.ts", "src/server/**/*.ts", "src/shared/**/*.ts"] } diff --git a/tsconfig.worker.json b/tsconfig.worker.json new file mode 100644 index 000000000..82f3f5574 --- /dev/null +++ b/tsconfig.worker.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ESNext"], + "types": ["bun-types", "node"] + }, + "include": [ + "src/app/worker.ts", + "src/contracts/**/*.ts", + "src/shared/**/*.ts", + "src/worker/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/__tests__/**/*.ts", + "src/**/testSupport/**/*.ts" + ] +} From c7790db5d4c11742bb36b59f0f9ca005210289d9 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 13:54:50 +0200 Subject: [PATCH 2/3] fix(platform): close boundary review findings --- .oxlintrc.json | 10 +- backend/src/requestPolicy/evaluator.ts | 3 + backend/test/utilityBehavior.test.ts | 2 +- bun.lock | 3 + .../application-architecture.md | 9 +- .../greenfield-rewrite/progress.md | 17 +- .../runtime-and-delivery.md | 18 +- docs/generated/packages-and-runtime.md | 1 + package.json | 1 + scripts/checkSourceBoundaries.ts | 211 ++++++++------ .../configurationMarkdown.test.ts | 11 + .../boundaryConfiguration.test.ts | 264 +++++++++++++++++- .../sourceBoundaries/boundaryConfiguration.ts | 233 +++++++++++++++- .../checkerIntegration.test.ts | 12 +- scripts/sourceBoundaries/importGraph.test.ts | 10 + .../importTargetValidation.test.ts | 21 +- .../importTargetValidation.ts | 153 +++++----- .../lintConfiguration.test.ts | 8 +- scripts/sourceBoundaries/policy.test.ts | 27 +- .../sourceBoundaries/runtimeOwnerAnalysis.ts | 10 +- .../sourceBoundaries/sourceDiscovery.test.ts | 34 ++- scripts/sourceBoundaries/sourceDiscovery.ts | 14 +- scripts/sourceBoundaries/testSupport.ts | 15 + src/app/trpcHttpHandler.test.ts | 34 +-- src/app/trpcHttpHandler.ts | 9 +- src/contracts/contractRegistry.test.ts | 25 +- src/contracts/contractRegistry.ts | 28 +- src/contracts/registry.ts | 7 +- .../domains/security/mfa/totpSecretCipher.ts | 5 +- .../configurationRegistry.test.ts | 5 + .../configuration/webConfiguration.test.ts | 14 + .../configuration/webConfiguration.ts | 25 +- .../observability/structuredLogger.ts | 8 +- src/server/test/support/requestContext.ts | 51 ++++ .../test/system/serverFoundation.test.ts | 77 +---- src/server/trpc/procedureErrorPolicy.test.ts | 31 ++ src/server/trpc/procedureErrorPolicy.ts | 7 +- .../applicationConfigurationRegistry.ts | 4 +- tsconfig.contracts.json | 2 + tsconfig.json | 27 +- tsconfig.scripts.json | 7 +- tsconfig.worker.json | 2 +- 42 files changed, 1086 insertions(+), 369 deletions(-) create mode 100644 scripts/sourceBoundaries/testSupport.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index a29ae892a..bc8ebc76d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -37,7 +37,7 @@ "denyWarnings": true, "reportUnusedDisableDirectives": "error", "typeAware": true, - "typeCheck": true + "typeCheck": false }, "plugins": [ "eslint", @@ -276,7 +276,13 @@ "src/browser/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" ], "rules": { - "no-restricted-globals": ["error", "Bun", "Buffer", "process"], + "no-restricted-globals": [ + "error", + { + "checkGlobalObject": true, + "globals": ["Bun", "Buffer", "Deno", "process"] + } + ], "no-restricted-imports": [ "error", { diff --git a/backend/src/requestPolicy/evaluator.ts b/backend/src/requestPolicy/evaluator.ts index 008e2d511..9c69a2f4d 100644 --- a/backend/src/requestPolicy/evaluator.ts +++ b/backend/src/requestPolicy/evaluator.ts @@ -99,6 +99,9 @@ async function callHandler( server: Server ): Promise { if (handler instanceof Response) { + // The scripts graph sees Node's Undici clone return alongside Bun's + // stricter Response headers, while both are the same runtime object. + // oxlint-disable-next-line typescript/no-unnecessary-type-assertion return handler.clone() as Response; } return handler(request, server); diff --git a/backend/test/utilityBehavior.test.ts b/backend/test/utilityBehavior.test.ts index 453baf476..9f9297f7f 100644 --- a/backend/test/utilityBehavior.test.ts +++ b/backend/test/utilityBehavior.test.ts @@ -1278,7 +1278,7 @@ describe("backend service utilities", () => { ); expect(response.status).toBe(503); - const payload = await response.json(); + const payload: unknown = await response.json(); expect(payload).toMatchObject({ checks: { database: { diff --git a/bun.lock b/bun.lock index 11f4abeeb..7ef75ad02 100644 --- a/bun.lock +++ b/bun.lock @@ -72,6 +72,7 @@ "drizzle-kit": "1.0.0-rc.4", "eventsource": "4.1.0", "happy-dom": "^20.11.1", + "jsonc-parser": "3.3.1", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "oxlint-config-presets": "^0.1.18", @@ -785,6 +786,8 @@ "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], diff --git a/docs/architecture/greenfield-rewrite/application-architecture.md b/docs/architecture/greenfield-rewrite/application-architecture.md index c51877b88..ab1abad32 100644 --- a/docs/architecture/greenfield-rewrite/application-architecture.md +++ b/docs/architecture/greenfield-rewrite/application-architecture.md @@ -353,9 +353,12 @@ the richer runtime type adds no value. The Bun `fetch` boundary creates one request ID before URL routing so application-handled health, readiness, not-found, tRPC, raw rejection, and sanitized defect responses share the same -correlation header. Every dispatch records exactly one outcome event: `http.response.created` for -a returned response, `http.request.failed` for a sanitized defect response, or -`http.request.cancelled` for client cancellation. For SSE the response-created event marks +correlation header. Every dispatch records exactly one terminal HTTP outcome event: +`http.response.created` for a returned response, `http.request.failed` for a sanitized raw-handler +defect response, or `http.request.cancelled` for client cancellation. A tRPC defect may additionally +emit one correlated `trpc.request.defect` diagnostic before the outer boundary returns and records +the sanitized `500` response; that diagnostic does not replace or duplicate the terminal HTTP +outcome. For SSE the response-created event marks successful dispatch, not stream termination; close/cancel/error observability remains part of the browser/realtime lifecycle slice. Client cancellation is informational and carries neither a failure fingerprint nor a server-error outcome. Bun's outer 64 KiB pre-dispatch body ceiling diff --git a/docs/architecture/greenfield-rewrite/progress.md b/docs/architecture/greenfield-rewrite/progress.md index e277f547c..41e6fc2a1 100644 --- a/docs/architecture/greenfield-rewrite/progress.md +++ b/docs/architecture/greenfield-rewrite/progress.md @@ -9,7 +9,7 @@ closes a phase; dated entries below provide the evidence, not a second status so | Phase | Status | Current evidence and remaining gate | | ----------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`, including build, transport, database/outbox, browser data, chat batching, shutdown, parity, OpenClaw source audit, and capped resource evidence. | +| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | | 1 — Foundation | In progress | Server composition, migrations, contracts, raw HTTP/realtime foundations, source-boundary enforcement, staged typed configuration, generated configuration reference, structured logging/request correlation, and procedure error policy exist; executable web/worker roots, database runtime, browser shell, complete generated references, and release/rollback closure remain. | | 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | | 3 — Core operator domains | Started | Monitoring transaction/schema foundations exist; task, agent, report, incident, notification, schedule/job, cache/metrics procedures and browser parity are not complete. | @@ -669,9 +669,12 @@ closes a phase; dated entries below provide the evidence, not a second status so environment reads. - The only script imports into the legacy backend/frontend are frozen as an exact 18-edge coexistence allowlist. New legacy edges fail CI. -- Strict TypeScript graphs now isolate contracts/shared, browser, server, worker, and scripts. - Supported Oxlint restricted-import/global rules provide a fast guard, while the AST checker is - authoritative. The server-foundation job runs both checker tests and every greenfield typecheck. +- Strict TypeScript graphs now isolate contracts/shared, browser, server, worker, and scripts and + are checked independently rather than exposed as incomplete composite project references. A + broad root compatibility graph supplies repository-wide type-aware Oxlint; supported Oxlint + restricted-import/global rules provide a fast guard, while the AST checker and the separate + TypeScript graphs are authoritative. The server-foundation job runs both checker tests and every + greenfield typecheck. ### 2026-08-06 — Typed configuration, errors, and observability boundary @@ -692,8 +695,10 @@ closes a phase; dated entries below provide the evidence, not a second status so one constant stderr fallback. Sink writes and flushes must settle synchronously, and runtime disposal precedes the idempotent flush. - The Bun request boundary creates correlation before application routing. Application responses - receive `x-request-id`, and each dispatch emits exactly one response-created, sanitized-defect, - or client-cancellation event. Cancellation carries no defect fingerprint. SSE termination + receive `x-request-id`, and each dispatch emits exactly one terminal HTTP response-created, + sanitized-defect, or client-cancellation event. A tRPC defect may additionally emit one + correlated diagnostic before its sanitized `500` response-created outcome; it is not a second + terminal HTTP outcome. Cancellation carries no defect fingerprint. SSE termination observability remains assigned to the later realtime/browser lifecycle rather than being overstated here. Bun's outer pre-dispatch body ceiling remains the documented exception. - The actual 36-procedure router, public contract metadata, and a server-owned diff --git a/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index 33daf8b47..285f89f16 100644 --- a/docs/architecture/greenfield-rewrite/runtime-and-delivery.md +++ b/docs/architecture/greenfield-rewrite/runtime-and-delivery.md @@ -167,14 +167,16 @@ field is only a lexically normalized absolute staging value: the future process resolve its real path and enforce the managed-filesystem containment policy before opening host paths. Startup wiring and that filesystem validation are not claimed by this slice. -The target repository uses a base TypeScript configuration plus strict browser, server/worker, and -script project references so browser libraries are unavailable to server code and Bun/filesystem -types are unavailable to browser code. The rewrite now has strict contracts/shared, browser, -server, worker, and script graphs plus an authoritative path-aware source-boundary check. Oxlint -also rejects supported import/global patterns as a fast feedback layer. Browser and worker -composition roots remain unimplemented, but adding an unclassified `src/app` root or a forbidden -edge fails the boundary gate. `bunfig.toml` contains only shared Bun test and selected serve-plugin -configuration; operational policy lives in typed source, not hidden shell environment. +The target repository uses a base TypeScript configuration plus separate strict browser, +contracts/shared, server, worker, and script configurations so browser libraries are unavailable +to server code and Bun/filesystem types are unavailable to browser code. These are independently +checked with `tsc -p`; they are deliberately not advertised as declaration-emitting composite +project references. The root configuration is a broad compatibility graph for repository-wide +type-aware Oxlint only, while the separate graphs and authoritative path-aware boundary check own +ambient authority and import policy. Browser and worker composition roots remain unimplemented, +but adding an unclassified `src/app` root or a forbidden edge fails the boundary gate. +`bunfig.toml` contains only shared Bun test and selected serve-plugin configuration; operational +policy lives in typed source, not hidden shell environment. ## Generated Documentation diff --git a/docs/generated/packages-and-runtime.md b/docs/generated/packages-and-runtime.md index 4567acd0e..6cabec584 100644 --- a/docs/generated/packages-and-runtime.md +++ b/docs/generated/packages-and-runtime.md @@ -79,6 +79,7 @@ | `drizzle-kit` | `1.0.0-rc.4` | `1.0.0-rc.4` | development | | `eventsource` | `4.1.0` | `4.1.0` | development | | `happy-dom` | `^20.11.1` | `20.11.1` | development | +| `jsonc-parser` | `3.3.1` | `3.3.1` | development | | `oxfmt` | `^0.62.0` | `0.62.0` | development | | `oxlint` | `^1.77.0` | `1.77.0` | development | | `oxlint-config-presets` | `^0.1.18` | `0.1.18` | development | diff --git a/package.json b/package.json index 16e30b69a..3ef271811 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "drizzle-kit": "1.0.0-rc.4", "eventsource": "4.1.0", "happy-dom": "^20.11.1", + "jsonc-parser": "3.3.1", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "oxlint-config-presets": "^0.1.18", diff --git a/scripts/checkSourceBoundaries.ts b/scripts/checkSourceBoundaries.ts index f34aaf3d9..9032547d0 100644 --- a/scripts/checkSourceBoundaries.ts +++ b/scripts/checkSourceBoundaries.ts @@ -1,3 +1,4 @@ +import { realpath } from "node:fs/promises"; import path from "node:path"; import { readBoundaryConfiguration } from "./sourceBoundaries/boundaryConfiguration.ts"; @@ -21,6 +22,123 @@ import { } from "./sourceBoundaries/policy.ts"; import { discoverSourceFiles } from "./sourceBoundaries/sourceDiscovery.ts"; +const sourceAnalysisConcurrency = 4; + +interface SourceAnalysisResult { + readonly observedLegacyScriptImports: readonly string[]; + readonly violations: readonly SourceBoundaryViolation[]; +} + +async function mapWithBoundedConcurrency( + values: readonly A[], + maximumConcurrency: number, + transform: (value: A) => Promise +): Promise { + const results = Array.from({ length: values.length }); + let nextIndex = 0; + const workers = Array.from( + { length: Math.min(maximumConcurrency, values.length) }, + async () => { + while (nextIndex < values.length) { + const index = nextIndex; + nextIndex += 1; + const value = values[index]; + if (value !== undefined) results[index] = await transform(value); + } + } + ); + await Promise.all(workers); + return results; +} + +async function analyzeSourceFile( + projectRoot: string, + realProjectRoot: string, + importer: string, + declaredPackageNames: ReadonlySet +): Promise { + const violations: SourceBoundaryViolation[] = []; + const observedLegacyScriptImports: string[] = []; + const fileViolation = validateSourceFile(importer); + if (fileViolation !== undefined) violations.push(fileViolation); + + const analysis = await parseSourceAnalysis( + await Bun.file(path.join(projectRoot, importer)).text(), + importer + ); + for (const declaration of analysis.ambientRuntimeDeclarations) { + const declarationViolation = validateSourceAmbientRuntimeDeclaration( + importer, + declaration.line + ); + if (declarationViolation !== undefined) violations.push(declarationViolation); + } + for (const referenceDirective of analysis.referenceDirectives) { + violations.push( + validateSourceReferenceDirective(importer, referenceDirective.line) + ); + } + for (const runtimeAuthorityEscape of analysis.runtimeAuthorityEscapes) { + const escapeViolation = validateSourceRuntimeAuthorityEscape( + importer, + runtimeAuthorityEscape.line + ); + if (escapeViolation !== undefined) violations.push(escapeViolation); + } + for (const suppression of analysis.typeScriptSuppressionDirectives) { + const suppressionViolation = validateSourceTypeScriptSuppressionDirective( + importer, + suppression.line + ); + if (suppressionViolation !== undefined) violations.push(suppressionViolation); + } + for (const environmentAccess of analysis.environmentAccesses) { + const environmentViolation = validateSourceEnvironmentAccess( + importer, + environmentAccess.line + ); + if (environmentViolation !== undefined) violations.push(environmentViolation); + } + for (const sourceImport of analysis.imports) { + const legacyImportKey = legacyScriptImportKey(importer, sourceImport); + if ( + legacyImportKey !== undefined && + legacyScriptImportAllowlist.has(legacyImportKey) + ) { + observedLegacyScriptImports.push(legacyImportKey); + const legacyTargetViolation = await validateLegacyAllowlistTarget( + projectRoot, + realProjectRoot, + legacyImportKey + ); + if (legacyTargetViolation !== undefined) { + violations.push(legacyTargetViolation); + } + } + const importViolation = validateSourceImport(importer, sourceImport); + if (importViolation === undefined) { + const exactTargetViolation = await validateExactRelativeImportTarget( + projectRoot, + realProjectRoot, + importer, + sourceImport + ); + if (exactTargetViolation !== undefined) { + violations.push(exactTargetViolation); + } + } else { + violations.push(importViolation); + } + const packageViolation = validateDeclaredPackageImport( + importer, + sourceImport, + declaredPackageNames + ); + if (packageViolation !== undefined) violations.push(packageViolation); + } + return { observedLegacyScriptImports, violations }; +} + /** * Scans all greenfield and script source against the explicit process-boundary policy. * @param projectRoot Absolute repository root. @@ -36,87 +154,22 @@ export async function checkSourceBoundaries( ...configuration.violations, ]; const observedLegacyScriptImports = new Set(); - for (const importer of discovery.files) { - const fileViolation = validateSourceFile(importer); - if (fileViolation !== undefined) violations.push(fileViolation); - - const analysis = await parseSourceAnalysis( - await Bun.file(path.join(projectRoot, importer)).text(), - importer - ); - for (const declaration of analysis.ambientRuntimeDeclarations) { - const declarationViolation = validateSourceAmbientRuntimeDeclaration( - importer, - declaration.line - ); - if (declarationViolation !== undefined) { - violations.push(declarationViolation); - } - } - for (const referenceDirective of analysis.referenceDirectives) { - violations.push( - validateSourceReferenceDirective(importer, referenceDirective.line) - ); - } - for (const runtimeAuthorityEscape of analysis.runtimeAuthorityEscapes) { - const escapeViolation = validateSourceRuntimeAuthorityEscape( + const realProjectRoot = await realpath(path.resolve(projectRoot)); + const sourceResults = await mapWithBoundedConcurrency( + discovery.files, + sourceAnalysisConcurrency, + (importer) => + analyzeSourceFile( + projectRoot, + realProjectRoot, importer, - runtimeAuthorityEscape.line - ); - if (escapeViolation !== undefined) violations.push(escapeViolation); - } - for (const suppression of analysis.typeScriptSuppressionDirectives) { - const suppressionViolation = validateSourceTypeScriptSuppressionDirective( - importer, - suppression.line - ); - if (suppressionViolation !== undefined) { - violations.push(suppressionViolation); - } - } - for (const environmentAccess of analysis.environmentAccesses) { - const environmentViolation = validateSourceEnvironmentAccess( - importer, - environmentAccess.line - ); - if (environmentViolation !== undefined) { - violations.push(environmentViolation); - } - } - for (const sourceImport of analysis.imports) { - const legacyImportKey = legacyScriptImportKey(importer, sourceImport); - if ( - legacyImportKey !== undefined && - legacyScriptImportAllowlist.has(legacyImportKey) - ) { - observedLegacyScriptImports.add(legacyImportKey); - const legacyTargetViolation = await validateLegacyAllowlistTarget( - projectRoot, - legacyImportKey - ); - if (legacyTargetViolation !== undefined) { - violations.push(legacyTargetViolation); - } - } - const importViolation = validateSourceImport(importer, sourceImport); - if (importViolation === undefined) { - const exactTargetViolation = await validateExactRelativeImportTarget( - projectRoot, - importer, - sourceImport - ); - if (exactTargetViolation !== undefined) { - violations.push(exactTargetViolation); - } - } else { - violations.push(importViolation); - } - const packageViolation = validateDeclaredPackageImport( - importer, - sourceImport, configuration.declaredPackageNames - ); - if (packageViolation !== undefined) violations.push(packageViolation); + ) + ); + for (const result of sourceResults) { + violations.push(...result.violations); + for (const observedImport of result.observedLegacyScriptImports) { + observedLegacyScriptImports.add(observedImport); } } for (const allowlistedImport of legacyScriptImportAllowlist) { diff --git a/scripts/documentation/configurationMarkdown.test.ts b/scripts/documentation/configurationMarkdown.test.ts index 516b626ee..941ab7c2d 100644 --- a/scripts/documentation/configurationMarkdown.test.ts +++ b/scripts/documentation/configurationMarkdown.test.ts @@ -59,6 +59,17 @@ describe("application configuration Markdown", () => { expect(documentation).not.toContain("sentinel-secret-choice"); }); + test("escapes Markdown table control characters in registry text", () => { + const documentation = renderConfiguration([ + { + ...completeEntry, + description: "Pipe | backslash \\ and\nnew line.", + }, + ]); + + expect(documentation).toContain(String.raw`Pipe \| backslash \\ and new line.`); + }); + test("fails closed when required metadata is missing", () => { const requiredFields = [ "allowedValues", diff --git a/scripts/sourceBoundaries/boundaryConfiguration.test.ts b/scripts/sourceBoundaries/boundaryConfiguration.test.ts index 844155804..e498369cb 100644 --- a/scripts/sourceBoundaries/boundaryConfiguration.test.ts +++ b/scripts/sourceBoundaries/boundaryConfiguration.test.ts @@ -1,19 +1,80 @@ import { describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { checkSourceBoundaries } from "../checkSourceBoundaries.ts"; - -async function temporaryProject(): Promise { - const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-source-boundary-")); - await mkdir(path.join(projectRoot, "scripts")); - await mkdir(path.join(projectRoot, "src", "browser"), { recursive: true }); - await writeFile(path.join(projectRoot, "package.json"), "{}"); - return projectRoot; -} +import { temporaryProject } from "./testSupport.ts"; describe("source-boundary root configuration", () => { + test("accepts TypeScript JSONC while keeping package.json strict", async () => { + const projectRoot = await temporaryProject(); + try { + await writeFile( + path.join(projectRoot, "tsconfig.json"), + `{ + // TypeScript configuration permits comments. + "compilerOptions": {}, + }` + ); + + const violations = await checkSourceBoundaries(projectRoot); + + expect( + violations.some( + (violation) => + violation.importer === "tsconfig.json" && + violation.message.includes("valid JSON") + ) + ).toBe(false); + + await writeFile(path.join(projectRoot, "package.json"), "{/* invalid */}"); + const strictViolations = await checkSourceBoundaries(projectRoot); + expect( + strictViolations.some( + (violation) => + violation.importer === "package.json" && + violation.message.includes("valid JSON") + ) + ).toBe(true); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects array-shaped root and dependency configuration", async () => { + const projectRoot = await temporaryProject(); + try { + await writeFile(path.join(projectRoot, "package.json"), "[]"); + let violations = await checkSourceBoundaries(projectRoot); + expect( + violations.some( + (violation) => + violation.importer === "package.json" && + violation.message.includes("must be an object") + ) + ).toBe(true); + + await writeFile( + path.join(projectRoot, "package.json"), + JSON.stringify({ dependencies: ["undeclared-array-package"] }) + ); + await writeFile( + path.join(projectRoot, "src", "browser", "arrayDependency.ts"), + 'import value from "undeclared-array-package"; void value;' + ); + violations = await checkSourceBoundaries(projectRoot); + expect( + violations.some( + (violation) => + violation.importer === "src/browser/arrayDependency.ts" && + violation.message.includes("declared by the root manifest") + ) + ).toBe(true); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + test("rejects root aliases and undeclared bare package imports", async () => { const projectRoot = await temporaryProject(); try { @@ -84,6 +145,189 @@ describe("source-boundary root configuration", () => { } }); + test("requires every reviewed TypeScript boundary configuration", async () => { + const projectRoot = await temporaryProject(); + try { + const violations = await checkSourceBoundaries(projectRoot); + + for (const importer of [ + "tsconfig.json", + "tsconfig.browser.json", + "tsconfig.contracts.json", + "tsconfig.qualification.json", + "tsconfig.scripts.json", + "tsconfig.server.json", + "tsconfig.worker.json", + ] as const) { + expect( + violations.some( + (violation) => + violation.importer === importer && + violation.message.includes("configuration is missing") + ) + ).toBe(true); + } + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects authority and membership drift in strict TypeScript partitions", async () => { + const projectRoot = await temporaryProject(); + try { + await writeFile(path.join(projectRoot, "tsconfig.json"), "{}"); + await mkdir(path.join(projectRoot, "src", "shared")); + await writeFile( + path.join(projectRoot, "src", "shared", "ambient.ts"), + "setImmediate(() => undefined);" + ); + const reviewedConfiguration = { + compilerOptions: { lib: ["ESNext"], types: [] }, + exclude: [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/__tests__/**/*.ts", + "src/**/testSupport/**/*.ts", + ], + extends: "./tsconfig.json", + include: ["src/contracts/**/*.ts", "src/shared/**/*.ts"], + }; + await writeFile( + path.join(projectRoot, "tsconfig.contracts.json"), + JSON.stringify(reviewedConfiguration) + ); + let violations = await checkSourceBoundaries(projectRoot); + expect( + violations.some( + (violation) => + violation.importer === "tsconfig.contracts.json" && + violation.message.includes("exact reviewed configuration") + ) + ).toBe(false); + expect( + violations.some( + (violation) => violation.importer === "src/shared/ambient.ts" + ) + ).toBe(false); + + const partitionDrift = [ + { + ...reviewedConfiguration, + compilerOptions: { lib: ["ESNext"], types: ["node"] }, + }, + { + ...reviewedConfiguration, + compilerOptions: { lib: ["ESNext", "DOM"], types: [] }, + }, + { + ...reviewedConfiguration, + compilerOptions: { + lib: ["ESNext"], + typeRoots: ["./types"], + types: [], + }, + }, + { + ...reviewedConfiguration, + compilerOptions: { + lib: ["ESNext"], + libReplacement: true, + types: [], + }, + }, + { + ...reviewedConfiguration, + typeAcquisition: { enable: true }, + }, + { + ...reviewedConfiguration, + include: ["src/contracts/**/*.ts"], + }, + { + ...reviewedConfiguration, + compilerOptions: { + lib: ["ESNext"], + rootDirs: ["src/shared", "src/server"], + types: [], + }, + }, + { + ...reviewedConfiguration, + compilerOptions: { + jsxImportSource: "unreviewed-runtime", + lib: ["ESNext"], + types: [], + }, + }, + { + ...reviewedConfiguration, + compilerOptions: { + lib: ["ESNext"], + strict: false, + types: [], + }, + }, + { + compilerOptions: reviewedConfiguration.compilerOptions, + exclude: reviewedConfiguration.exclude, + include: reviewedConfiguration.include, + }, + ] as const; + + for (const configuration of partitionDrift) { + await writeFile( + path.join(projectRoot, "tsconfig.contracts.json"), + JSON.stringify(configuration) + ); + violations = await checkSourceBoundaries(projectRoot); + expect( + violations.some( + (violation) => + violation.importer === "tsconfig.contracts.json" && + violation.message.includes("exact reviewed configuration") + ) + ).toBe(true); + } + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("rejects inherited root authority drift", async () => { + const projectRoot = await temporaryProject(); + try { + const reviewedRootSource = await Bun.file( + path.join(import.meta.dir, "..", "..", "tsconfig.json") + ).text(); + await writeFile(path.join(projectRoot, "tsconfig.json"), reviewedRootSource); + let violations = await checkSourceBoundaries(projectRoot); + expect( + violations.some( + (violation) => + violation.importer === "tsconfig.json" && + violation.message.includes("exact reviewed configuration") + ) + ).toBe(false); + + const driftedRootSource = reviewedRootSource.replace( + '"moduleResolution": "bundler",', + '"moduleResolution": "bundler",\n "rootDirs": ["src/shared", "src/server"],' + ); + expect(driftedRootSource).not.toBe(reviewedRootSource); + await writeFile(path.join(projectRoot, "tsconfig.json"), driftedRootSource); + violations = await checkSourceBoundaries(projectRoot); + expect( + violations.some( + (violation) => + violation.importer === "tsconfig.json" && + violation.message.includes("exact reviewed configuration") + ) + ).toBe(true); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + test("rejects root package browser mappings, exports, and workspace linkage", async () => { const projectRoot = await temporaryProject(); try { diff --git a/scripts/sourceBoundaries/boundaryConfiguration.ts b/scripts/sourceBoundaries/boundaryConfiguration.ts index e3d43d6d8..29e037cf4 100644 --- a/scripts/sourceBoundaries/boundaryConfiguration.ts +++ b/scripts/sourceBoundaries/boundaryConfiguration.ts @@ -1,6 +1,8 @@ import { lstat, readdir, realpath } from "node:fs/promises"; import path from "node:path"; +import { parse, type ParseError } from "jsonc-parser"; + import type { SourceBoundaryViolation } from "./policyTypes.ts"; import { boundaryPathViolation, isContainedPath } from "./sourceBoundaryPaths.ts"; @@ -10,8 +12,198 @@ export interface BoundaryConfiguration { readonly violations: readonly SourceBoundaryViolation[]; } +type ReviewedCompilerOption = boolean | string | readonly string[]; + +interface TypeScriptConfigurationPolicy { + readonly compilerOptions: Readonly>; + readonly exclude?: readonly string[]; + readonly extends?: "./tsconfig.json"; + readonly files?: readonly string[]; + readonly include: readonly string[]; +} + +const reviewedTypeScriptConfigurations: Readonly< + Record +> = Object.freeze({ + "tsconfig.json": { + compilerOptions: { + allowImportingTsExtensions: true, + erasableSyntaxOnly: true, + forceConsistentCasingInFileNames: true, + jsx: "react-jsx", + lib: ["ESNext", "DOM", "DOM.Iterable"], + module: "Preserve", + moduleDetection: "force", + moduleResolution: "bundler", + noEmit: true, + noFallthroughCasesInSwitch: true, + noImplicitAny: true, + noImplicitOverride: true, + noImplicitReturns: true, + noUncheckedIndexedAccess: true, + noUncheckedSideEffectImports: true, + noUnusedLocals: true, + noUnusedParameters: true, + skipLibCheck: true, + strict: true, + target: "ESNext", + types: ["bun-types", "node"], + verbatimModuleSyntax: true, + }, + include: [ + "backend/**/*.ts", + "contracts/**/*.ts", + "drizzle.config.ts", + "frontend/src/**/*", + "qualification/**/*.ts", + "scripts/**/*.ts", + "src/**/*.ts", + "src/**/*.tsx", + "tailwind.config.ts", + "test/**/*.ts", + ], + }, + "tsconfig.browser.json": { + compilerOptions: { + jsx: "react-jsx", + lib: ["ESNext", "DOM", "DOM.Iterable"], + types: [], + useDefineForClassFields: true, + }, + exclude: [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx", + "src/**/__tests__/**/*.ts", + "src/**/__tests__/**/*.tsx", + "src/**/testSupport/**/*.ts", + "src/**/testSupport/**/*.tsx", + ], + include: [ + "src/app/browser.tsx", + "src/browser/**/*.ts", + "src/browser/**/*.tsx", + "src/contracts/**/*.ts", + "src/shared/**/*.ts", + ], + extends: "./tsconfig.json", + }, + "tsconfig.contracts.json": { + compilerOptions: { lib: ["ESNext"], types: [] }, + exclude: [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/__tests__/**/*.ts", + "src/**/testSupport/**/*.ts", + ], + include: ["src/contracts/**/*.ts", "src/shared/**/*.ts"], + extends: "./tsconfig.json", + }, + "tsconfig.qualification.json": { + compilerOptions: { + lib: ["ESNext", "DOM", "DOM.Iterable"], + tsBuildInfoFile: "./node_modules/.tmp/tsconfig.qualification.tsbuildinfo", + types: ["bun-types", "node"], + }, + extends: "./tsconfig.json", + include: ["qualification/**/*.ts"], + }, + "tsconfig.scripts.json": { + compilerOptions: { lib: ["ESNext"], types: ["bun-types", "node"] }, + extends: "./tsconfig.json", + include: ["drizzle.config.ts", "scripts/**/*.ts", "tailwind.config.ts"], + }, + "tsconfig.server.json": { + compilerOptions: { + lib: ["ESNext"], + tsBuildInfoFile: "./node_modules/.tmp/tsconfig.server.tsbuildinfo", + types: ["bun-types", "node"], + }, + extends: "./tsconfig.json", + files: [ + "src/app/dashboardServer.test.ts", + "src/app/dashboardServer.ts", + "src/app/environmentSource.ts", + "src/app/server.ts", + "src/app/trpcHttpHandler.test.ts", + "src/app/trpcHttpHandler.ts", + "src/app/trpcRequestPolicy.test.ts", + "src/app/trpcRequestPolicy.ts", + ], + include: ["src/contracts/**/*.ts", "src/server/**/*.ts", "src/shared/**/*.ts"], + }, + "tsconfig.worker.json": { + compilerOptions: { lib: ["ESNext"], types: ["bun-types", "node"] }, + exclude: [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/__tests__/**/*.ts", + "src/**/testSupport/**/*.ts", + ], + include: [ + "src/app/worker*.ts", + "src/contracts/**/*.ts", + "src/shared/**/*.ts", + "src/worker/**/*.ts", + ], + extends: "./tsconfig.json", + }, +}); + function isRecord(value: unknown): value is Readonly> { - return typeof value === "object" && value !== null; + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function equalsStringArray(value: unknown, expected: readonly string[]): boolean { + return ( + Array.isArray(value) && + value.length === expected.length && + value.every((entry, index) => entry === expected[index]) + ); +} + +function equalsCompilerOptions( + value: unknown, + expected: Readonly> +): boolean { + if (!isRecord(value)) return false; + const actualNames = Object.keys(value).toSorted(); + const expectedNames = Object.keys(expected).toSorted(); + return ( + equalsStringArray(actualNames, expectedNames) && + expectedNames.every((name) => { + const expectedValue = expected[name]; + return typeof expectedValue === "object" + ? equalsStringArray(value[name], expectedValue) + : value[name] === expectedValue; + }) + ); +} + +function hasReviewedTypeScriptConfiguration( + tsconfig: Readonly>, + policy: TypeScriptConfigurationPolicy +): boolean { + const expectedTopLevelNames = ["compilerOptions", "include"]; + if (policy.exclude !== undefined) expectedTopLevelNames.push("exclude"); + if (policy.extends !== undefined) expectedTopLevelNames.push("extends"); + if (policy.files !== undefined) expectedTopLevelNames.push("files"); + return ( + equalsStringArray( + Object.keys(tsconfig).toSorted(), + expectedTopLevelNames.toSorted() + ) && + equalsCompilerOptions(tsconfig.compilerOptions, policy.compilerOptions) && + tsconfig.extends === policy.extends && + equalsStringArray(tsconfig.include, policy.include) && + (policy.files === undefined + ? tsconfig.files === undefined + : equalsStringArray(tsconfig.files, policy.files)) && + (policy.exclude === undefined + ? tsconfig.exclude === undefined + : equalsStringArray(tsconfig.exclude, policy.exclude)) + ); } async function readRootJson( @@ -42,7 +234,19 @@ async function readRootJson( } let parsed: unknown; try { - parsed = JSON.parse(await Bun.file(absolutePath).text()) as unknown; + const source = await Bun.file(absolutePath).text(); + if (relativePath === "package.json") { + parsed = JSON.parse(source) as unknown; + } else { + const parseErrors: ParseError[] = []; + parsed = parse(source, parseErrors, { + allowTrailingComma: true, + disallowComments: false, + }); + if (parseErrors.length > 0) { + throw new SyntaxError("TypeScript configuration is not valid JSONC"); + } + } } catch { violations.push( boundaryPathViolation( @@ -82,7 +286,7 @@ function dependencyNames( } /** - * Reads and validates the root package and TypeScript resolver configuration. + * Reads and validates the root package and TypeScript boundary configuration. * @param projectRoot Absolute repository root. * @returns Declared package names and configuration findings. */ @@ -164,6 +368,17 @@ export async function readBoundaryConfiguration( ) .map((entry) => entry.name) .toSorted(); + const tsconfigNameSet = new Set(tsconfigNames); + for (const reviewedName of Object.keys(reviewedTypeScriptConfigurations)) { + if (!tsconfigNameSet.has(reviewedName)) { + violations.push( + boundaryPathViolation( + reviewedName, + "Reviewed TypeScript boundary configuration is missing" + ) + ); + } + } for (const tsconfigName of tsconfigNames) { const tsconfig = await readRootJson(lexicalProjectRoot, tsconfigName, violations); if (tsconfig === undefined) continue; @@ -190,6 +405,18 @@ export async function readBoundaryConfiguration( ) ); } + const configurationPolicy = reviewedTypeScriptConfigurations[tsconfigName]; + if ( + configurationPolicy !== undefined && + !hasReviewedTypeScriptConfiguration(tsconfig, configurationPolicy) + ) { + violations.push( + boundaryPathViolation( + tsconfigName, + "TypeScript boundary authority and graph membership must match the exact reviewed configuration" + ) + ); + } } return { declaredPackageNames: diff --git a/scripts/sourceBoundaries/checkerIntegration.test.ts b/scripts/sourceBoundaries/checkerIntegration.test.ts index 4b2d5659e..b87ff7484 100644 --- a/scripts/sourceBoundaries/checkerIntegration.test.ts +++ b/scripts/sourceBoundaries/checkerIntegration.test.ts @@ -1,17 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { checkSourceBoundaries } from "../checkSourceBoundaries.ts"; - -async function temporaryProject(): Promise { - const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-source-boundary-")); - await mkdir(path.join(projectRoot, "scripts")); - await mkdir(path.join(projectRoot, "src", "browser"), { recursive: true }); - await writeFile(path.join(projectRoot, "package.json"), "{}"); - return projectRoot; -} +import { temporaryProject } from "./testSupport.ts"; describe("source-boundary checker integration", () => { test("rejects triple-slash lib, types, and path authority directives", async () => { diff --git a/scripts/sourceBoundaries/importGraph.test.ts b/scripts/sourceBoundaries/importGraph.test.ts index f3c43b254..53170850c 100644 --- a/scripts/sourceBoundaries/importGraph.test.ts +++ b/scripts/sourceBoundaries/importGraph.test.ts @@ -3,6 +3,16 @@ import { describe, expect, test } from "bun:test"; import { parseSourceAnalysis, parseSourceImports } from "./importGraph.ts"; describe("source-boundary import parsing", () => { + test("fails closed when production source cannot be parsed", async () => { + let caught: unknown; + try { + await parseSourceAnalysis("const broken: = ;", "src/server/broken.ts"); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + }); + test("finds value, type-only, side-effect, re-export, and dynamic edges", async () => { const imports = await parseSourceImports( ` diff --git a/scripts/sourceBoundaries/importTargetValidation.test.ts b/scripts/sourceBoundaries/importTargetValidation.test.ts index 1e33767ea..1d229e040 100644 --- a/scripts/sourceBoundaries/importTargetValidation.test.ts +++ b/scripts/sourceBoundaries/importTargetValidation.test.ts @@ -4,14 +4,8 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { checkSourceBoundaries } from "../checkSourceBoundaries.ts"; - -async function temporaryProject(): Promise { - const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-source-boundary-")); - await mkdir(path.join(projectRoot, "scripts")); - await mkdir(path.join(projectRoot, "src", "browser"), { recursive: true }); - await writeFile(path.join(projectRoot, "package.json"), "{}"); - return projectRoot; -} +import { legacyScriptImportAllowlist, legacyScriptImportKey } from "./policy.ts"; +import { temporaryProject } from "./testSupport.ts"; describe("source-boundary import target validation", () => { test("rejects encoded path input before the runtime resolver normalizes it", async () => { @@ -234,9 +228,18 @@ describe("source-boundary import target validation", () => { const projectRoot = await temporaryProject(); const externalRoot = await mkdtemp(path.join(tmpdir(), "mira-legacy-external-")); try { + const importer = "scripts/buildBackend.ts"; + const specifier = "../backend/src/services/releases/runtime.ts"; + const allowlistKey = legacyScriptImportKey(importer, { + kind: "import", + line: 1, + specifier, + }); + expect(allowlistKey).toBeDefined(); + expect(legacyScriptImportAllowlist.has(allowlistKey ?? "")).toBe(true); await writeFile( path.join(projectRoot, "scripts", "buildBackend.ts"), - 'import "../backend/src/services/releases/runtime.ts";' + `import "${specifier}";` ); await mkdir( path.join(projectRoot, "backend", "src", "services", "releases"), diff --git a/scripts/sourceBoundaries/importTargetValidation.ts b/scripts/sourceBoundaries/importTargetValidation.ts index 8e6a3b0ac..e79555649 100644 --- a/scripts/sourceBoundaries/importTargetValidation.ts +++ b/scripts/sourceBoundaries/importTargetValidation.ts @@ -6,20 +6,20 @@ import type { SourceBoundaryViolation } from "./policyTypes.ts"; import { isContainedPath, repositoryPath } from "./sourceBoundaryPaths.ts"; import { isTestPath } from "./sourceTopologyPolicy.ts"; -/** - * Validates that an exact legacy allowlist target remains a contained regular file. - * @param projectRoot Absolute repository root. - * @param allowlistKey Stable importer/target allowlist key. - * @returns Target violation when the reviewed target has drifted. - */ -export async function validateLegacyAllowlistTarget( - projectRoot: string, - allowlistKey: string +interface TargetValidationMessages { + readonly escaped: string; + readonly invalidType: string; + readonly missing: string; + readonly symbolicLink: string; +} + +async function validateContainedTarget( + lexicalProjectRoot: string, + realProjectRoot: string, + target: string, + messages: TargetValidationMessages, + violation: (message: string) => SourceBoundaryViolation ): Promise { - const separatorIndex = allowlistKey.indexOf("\0"); - const importer = allowlistKey.slice(0, separatorIndex); - const target = allowlistKey.slice(separatorIndex + 1); - const lexicalProjectRoot = path.resolve(projectRoot); let currentPath = lexicalProjectRoot; const components = target.split("/"); for (const [index, component] of components.entries()) { @@ -27,44 +27,59 @@ export async function validateLegacyAllowlistTarget( let status; try { status = await lstat(currentPath); - } catch { - return { - importer, - line: 1, - message: "Legacy allowlisted target is missing or unreadable", - specifier: target, - }; - } - if (status.isSymbolicLink()) { - return { - importer, - line: 1, - message: "Legacy allowlisted target paths may not contain symbolic links", - specifier: target, - }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return violation(messages.missing); } + if (status.isSymbolicLink()) return violation(messages.symbolicLink); const isTarget = index === components.length - 1; if ((isTarget && !status.isFile()) || (!isTarget && !status.isDirectory())) { - return { - importer, - line: 1, - message: "Legacy allowlisted target must be a regular repository file", - specifier: target, - }; + return violation(messages.invalidType); } } - const realProjectRoot = await realpath(lexicalProjectRoot); if (!isContainedPath(realProjectRoot, await realpath(currentPath))) { - return { - importer, - line: 1, - message: "Legacy allowlisted target real path escapes the repository", - specifier: target, - }; + return violation(messages.escaped); } return undefined; } +/** + * Validates that an exact legacy allowlist target remains a contained regular file. + * @param projectRoot Absolute repository root. + * @param realProjectRoot Canonical repository root used for containment. + * @param allowlistKey Stable importer/target allowlist key. + * @returns Target violation when the reviewed target has drifted. + */ +export async function validateLegacyAllowlistTarget( + projectRoot: string, + realProjectRoot: string, + allowlistKey: string +): Promise { + const separatorIndex = allowlistKey.indexOf("\0"); + const importer = allowlistKey.slice(0, separatorIndex); + const target = allowlistKey.slice(separatorIndex + 1); + const lexicalProjectRoot = path.resolve(projectRoot); + const violation = (message: string): SourceBoundaryViolation => ({ + importer, + line: 1, + message, + specifier: target, + }); + return validateContainedTarget( + lexicalProjectRoot, + realProjectRoot, + target, + { + escaped: "Legacy allowlisted target real path escapes the repository", + invalidType: "Legacy allowlisted target must be a regular repository file", + missing: "Legacy allowlisted target is missing or unreadable", + symbolicLink: + "Legacy allowlisted target paths may not contain symbolic links", + }, + violation + ); +} + function importTargetViolation( importer: string, sourceImport: SourceImport, @@ -83,12 +98,14 @@ function importTargetViolation( /** * Validates a production relative import without runtime resolver fallback. * @param projectRoot Absolute repository root. + * @param realProjectRoot Canonical repository root used for containment. * @param importer Repository-relative importing source. * @param sourceImport Parsed relative import edge. * @returns Exact-target violation when the lexical target is unsafe. */ export async function validateExactRelativeImportTarget( projectRoot: string, + realProjectRoot: string, importer: string, sourceImport: SourceImport ): Promise { @@ -107,44 +124,18 @@ export async function validateExactRelativeImportTarget( if (target === ".." || target.startsWith("../")) return undefined; const lexicalProjectRoot = path.resolve(projectRoot); - let currentPath = lexicalProjectRoot; - const components = target.split("/"); - for (const [index, component] of components.entries()) { - currentPath = path.join(currentPath, component); - let status; - try { - status = await lstat(currentPath); - } catch (error) { - if ((error as { code?: unknown }).code !== "ENOENT") throw error; - return importTargetViolation( - importer, - sourceImport, - "Relative production imports must resolve to an existing exact target; runtime extension fallback is forbidden" - ); - } - if (status.isSymbolicLink()) { - return importTargetViolation( - importer, - sourceImport, - "Relative production import target paths may not contain symbolic links" - ); - } - const isTarget = index === components.length - 1; - if ((isTarget && !status.isFile()) || (!isTarget && !status.isDirectory())) { - return importTargetViolation( - importer, - sourceImport, - "Relative production import targets must be exact regular files" - ); - } - } - const realProjectRoot = await realpath(lexicalProjectRoot); - if (!isContainedPath(realProjectRoot, await realpath(currentPath))) { - return importTargetViolation( - importer, - sourceImport, - "Relative production import target real path escapes the repository" - ); - } - return undefined; + return validateContainedTarget( + lexicalProjectRoot, + realProjectRoot, + target, + { + escaped: "Relative production import target real path escapes the repository", + invalidType: "Relative production import targets must be exact regular files", + missing: + "Relative production imports must resolve to an existing exact target; runtime extension fallback is forbidden", + symbolicLink: + "Relative production import target paths may not contain symbolic links", + }, + (message) => importTargetViolation(importer, sourceImport, message) + ); } diff --git a/scripts/sourceBoundaries/lintConfiguration.test.ts b/scripts/sourceBoundaries/lintConfiguration.test.ts index 060aeb2f0..8e276b9c6 100644 --- a/scripts/sourceBoundaries/lintConfiguration.test.ts +++ b/scripts/sourceBoundaries/lintConfiguration.test.ts @@ -116,7 +116,13 @@ describe("effective source-boundary lint configuration", () => { ] ); - expect(testResult).toEqual({ exitCode: 0, output: "\n" }); + expect(testResult.exitCode).toBe(0); + expect(testResult.output).not.toContain( + "'memo' import from 'react' is restricted" + ); + expect(testResult.output).not.toContain("no-implied-eval"); + expect(testResult.output).not.toContain("no-restricted-imports"); + expect(testResult.output).not.toContain("no-console"); } finally { await rm(fixtureRoot, { force: true, recursive: true }); } diff --git a/scripts/sourceBoundaries/policy.test.ts b/scripts/sourceBoundaries/policy.test.ts index a40260dbe..5162a3187 100644 --- a/scripts/sourceBoundaries/policy.test.ts +++ b/scripts/sourceBoundaries/policy.test.ts @@ -569,7 +569,32 @@ describe("source-boundary policy", () => { }); test("freezes the exact legacy script coexistence allowlist", () => { - expect(legacyScriptImportAllowlist.size).toBe(18); + expect( + [...legacyScriptImportAllowlist] + .map((entry) => entry.replace("\0", " -> ")) + .toSorted() + ).toMatchInlineSnapshot(` + [ + "scripts/buildBackend.ts -> backend/src/services/releases/runtime.ts", + "scripts/developmentFrontend.ts -> frontend/index.html", + "scripts/developmentFrontend.ts -> frontend/src/lib/developmentProxyHeaders.ts", + "scripts/developmentStack.ts -> backend/src/development/developmentEnvironment.ts", + "scripts/developmentStack.ts -> backend/src/development/developmentRuntime.ts", + "scripts/developmentStack.ts -> backend/src/development/developmentStackConfig.ts", + "scripts/developmentStack.ts -> backend/src/development/developmentState.ts", + "scripts/frontendBuild.ts -> backend/src/services/releases/runtime.ts", + "scripts/productionBootstrap.ts -> backend/src/database/connection.ts", + "scripts/productionBootstrap.ts -> backend/src/lib/dashboardPaths.ts", + "scripts/productionBootstrap.ts -> backend/src/lib/processes.ts", + "scripts/productionBootstrap.ts -> backend/src/lib/systemdProperties.ts", + "scripts/productionBootstrap.ts -> backend/src/releaseLifecycle.ts", + "scripts/productionBootstrap.ts -> backend/src/services/releases/deployment.ts", + "scripts/productionBootstrap.ts -> backend/src/services/releases/releaseActivation.ts", + "scripts/productionBootstrap.ts -> backend/src/services/releases/systemdPolicy.ts", + "scripts/qualification/legacyBackendRouteProbe.ts -> backend/src/routes/registry.ts", + "scripts/writeReleaseManifest.ts -> backend/src/services/releases/manifestArtifacts.ts", + ] + `); expect( validateSourceImport( "scripts/buildBackend.ts", diff --git a/scripts/sourceBoundaries/runtimeOwnerAnalysis.ts b/scripts/sourceBoundaries/runtimeOwnerAnalysis.ts index ef54cc867..7586d224d 100644 --- a/scripts/sourceBoundaries/runtimeOwnerAnalysis.ts +++ b/scripts/sourceBoundaries/runtimeOwnerAnalysis.ts @@ -30,6 +30,12 @@ const runtimeGlobalRootNames: ReadonlySet = new Set([ "window", ]); +const runtimeEnvironmentOwnerNames: ReadonlySet = new Set([ + "Bun", + "Deno", + "process", +]); + /** Names whose unbound, runtime references carry process or loader authority. */ export const runtimeAuthorityIdentifierNames: ReadonlySet = new Set([ ...runtimeGlobalRootNames, @@ -90,7 +96,7 @@ export function isRuntimeEnvironmentOwner( ): boolean { if (!isRecord(node)) return false; if ( - ["Bun", "Deno", "process"].includes(identifierName(node) ?? "") && + runtimeEnvironmentOwnerNames.has(identifierName(node) ?? "") && runtimeIdentifierReferences.has(node) ) { return true; @@ -104,7 +110,7 @@ export function isRuntimeEnvironmentOwner( return false; } if ( - !["Bun", "Deno", "process"].includes( + !runtimeEnvironmentOwnerNames.has( memberPropertyName(node, staticStringValues) ?? "" ) ) { diff --git a/scripts/sourceBoundaries/sourceDiscovery.test.ts b/scripts/sourceBoundaries/sourceDiscovery.test.ts index 852e3cc47..5c3474dc7 100644 --- a/scripts/sourceBoundaries/sourceDiscovery.test.ts +++ b/scripts/sourceBoundaries/sourceDiscovery.test.ts @@ -4,16 +4,31 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { checkSourceBoundaries } from "../checkSourceBoundaries.ts"; - -async function temporaryProject(): Promise { - const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-source-boundary-")); - await mkdir(path.join(projectRoot, "scripts")); - await mkdir(path.join(projectRoot, "src", "browser"), { recursive: true }); - await writeFile(path.join(projectRoot, "package.json"), "{}"); - return projectRoot; -} +import { temporaryProject } from "./testSupport.ts"; describe("source-boundary repository discovery", () => { + test("reports missing reviewed source roots as layout violations", async () => { + const projectRoot = await temporaryProject(); + try { + await rm(path.join(projectRoot, "scripts"), { recursive: true }); + await rm(path.join(projectRoot, "src"), { recursive: true }); + + const violations = await checkSourceBoundaries(projectRoot); + + for (const importer of ["scripts", "src"] as const) { + expect( + violations.some( + (violation) => + violation.importer === importer && + violation.message.includes("source directory is missing") + ) + ).toBe(true); + } + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + test("discovers and fails closed outside strict TS and TSX graphs", async () => { const projectRoot = await temporaryProject(); try { @@ -161,6 +176,9 @@ describe("source-boundary repository discovery", () => { violation.message.includes("exact reviewed project layout") ) ).toBe(true); + expect( + violations.some((violation) => violation.importer === "tools/evil.ts") + ).toBe(false); } finally { await rm(projectRoot, { force: true, recursive: true }); } diff --git a/scripts/sourceBoundaries/sourceDiscovery.ts b/scripts/sourceBoundaries/sourceDiscovery.ts index d4ae69443..74c9b9d28 100644 --- a/scripts/sourceBoundaries/sourceDiscovery.ts +++ b/scripts/sourceBoundaries/sourceDiscovery.ts @@ -43,7 +43,19 @@ async function discoverDirectory( violations: SourceBoundaryViolation[] ): Promise { const absoluteDirectory = path.join(lexicalProjectRoot, relativeDirectory); - const directoryStatus = await lstat(absoluteDirectory); + let directoryStatus; + try { + directoryStatus = await lstat(absoluteDirectory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + violations.push( + boundaryPathViolation( + relativeDirectory, + "Reviewed source directory is missing from the repository layout" + ) + ); + return; + } if (directoryStatus.isSymbolicLink()) { violations.push( boundaryPathViolation( diff --git a/scripts/sourceBoundaries/testSupport.ts b/scripts/sourceBoundaries/testSupport.ts new file mode 100644 index 000000000..1b89b3eff --- /dev/null +++ b/scripts/sourceBoundaries/testSupport.ts @@ -0,0 +1,15 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +/** + * Creates the smallest reviewed repository layout used by boundary tests. + * @returns Absolute path to the temporary repository fixture. + */ +export async function temporaryProject(): Promise { + const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-source-boundary-")); + await mkdir(path.join(projectRoot, "scripts")); + await mkdir(path.join(projectRoot, "src", "browser"), { recursive: true }); + await writeFile(path.join(projectRoot, "package.json"), "{}"); + return projectRoot; +} diff --git a/src/app/trpcHttpHandler.test.ts b/src/app/trpcHttpHandler.test.ts index 7a39350e5..241356d69 100644 --- a/src/app/trpcHttpHandler.test.ts +++ b/src/app/trpcHttpHandler.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { createStructuredLogger } from "../server/platform/observability/structuredLogger.ts"; import { + createCapturingTestStructuredLogger, createTestApplicationRuntime, createTestServerSecurityServices, } from "../server/test/support/requestContext.ts"; @@ -28,7 +28,7 @@ interface EarlyRejectionExpectation { async function expectEarlyRejectionCancelsBody( input: EarlyRejectionExpectation ): Promise { - const logLines: string[] = []; + const { logger, logLines } = createCapturingTestStructuredLogger(); const cancellationReasons: unknown[] = []; const body = new ReadableStream({ cancel(reason) { @@ -43,20 +43,6 @@ async function expectEarlyRejectionCancelsBody( ...(input.headers === undefined ? {} : { headers: input.headers }), method: "POST", }); - const logger = createStructuredLogger({ - identity: { - bun: "1.4.0-test", - pid: 123, - processRole: "web", - release: "handler-test", - service: "mira-dashboard", - }, - sink: { - write(line) { - logLines.push(line); - }, - }, - }); const handler = createTrpcHttpHandler({ ...createTestServerSecurityServices(), applicationRuntime: createTestApplicationRuntime({ logger }), @@ -116,22 +102,8 @@ describe("tRPC HTTP handler early rejection", () => { test("redacts an unexpected context defect through the tRPC boundary", async () => { const sentinel = "context-failure-secret"; - const logLines: string[] = []; + const { logger, logLines } = createCapturingTestStructuredLogger(); const request = new Request("https://dashboard.example/trpc/auth.status"); - const logger = createStructuredLogger({ - identity: { - bun: "1.4.0-test", - pid: 123, - processRole: "web", - release: "handler-test", - service: "mira-dashboard", - }, - sink: { - write(line) { - logLines.push(line); - }, - }, - }); const handler = createTrpcHttpHandler({ ...createTestServerSecurityServices(), applicationRuntime: createTestApplicationRuntime({ logger }), diff --git a/src/app/trpcHttpHandler.ts b/src/app/trpcHttpHandler.ts index 901a2b11b..1cebf5cf4 100644 --- a/src/app/trpcHttpHandler.ts +++ b/src/app/trpcHttpHandler.ts @@ -242,12 +242,5 @@ export function createTrpcHttpHandler(options: TrpcHttpHandlerOptions) { }); } - return async function handleTrpcHttpRequest( - request: Request, - requestUrl: URL, - bunServer: TrpcBunServer, - requestId: string - ): Promise { - return dispatchTrpcHttpRequest(request, requestUrl, bunServer, requestId); - }; + return dispatchTrpcHttpRequest; } diff --git a/src/contracts/contractRegistry.test.ts b/src/contracts/contractRegistry.test.ts index a47e43a7c..70f7d1dc9 100644 --- a/src/contracts/contractRegistry.test.ts +++ b/src/contracts/contractRegistry.test.ts @@ -14,7 +14,7 @@ test("registers one sorted stable expected-error vocabulary", () => { expect(() => assertProcedureContractErrors(procedureContracts)).not.toThrow(); }); -test("rejects duplicate, unsorted, and unregistered procedure errors", () => { +test("rejects duplicate procedure names and invalid error metadata", () => { const invalid = [ { errors: ["UNAUTHORIZED", "FORBIDDEN"], name: "unsorted" }, { errors: ["FORBIDDEN", "FORBIDDEN"], name: "duplicate" }, @@ -28,4 +28,27 @@ test("rejects duplicate, unsorted, and unregistered procedure errors", () => { ]) ).toThrow(`Procedure contract errors are invalid for ${contract.name}`); } + + expect(() => + assertProcedureContractErrors([ + { errors: [], name: "duplicate" }, + { errors: [], name: "duplicate" }, + ]) + ).toThrow("Procedure contract names must be unique"); +}); + +test("deeply freezes registered procedure policy metadata", () => { + expect(Object.isFrozen(procedureContracts)).toBe(true); + for (const contract of procedureContracts) { + expect(Object.isFrozen(contract)).toBe(true); + expect(Object.isFrozen(contract.access)).toBe(true); + expect(Object.isFrozen(contract.errors)).toBe(true); + expect(Object.isFrozen(contract.transport)).toBe(true); + if ("capabilities" in contract.access) { + expect(Object.isFrozen(contract.access.capabilities)).toBe(true); + } + if (contract.errorReasons !== undefined) { + expect(Object.isFrozen(contract.errorReasons)).toBe(true); + } + } }); diff --git a/src/contracts/contractRegistry.ts b/src/contracts/contractRegistry.ts index d649915fc..c4a5d6e6c 100644 --- a/src/contracts/contractRegistry.ts +++ b/src/contracts/contractRegistry.ts @@ -19,7 +19,33 @@ const registeredProcedureContracts: readonly ProcedureContract[] = [ ...systemProcedureContracts, ]; assertProcedureContractErrors(registeredProcedureContracts); -export const procedureContracts = Object.freeze(registeredProcedureContracts); +export const procedureContracts = Object.freeze( + registeredProcedureContracts.map((contract) => { + const access = + "capabilities" in contract.access + ? Object.freeze({ + ...contract.access, + capabilities: Object.freeze([...contract.access.capabilities]), + ...(contract.access.principalKinds === undefined + ? {} + : { + principalKinds: Object.freeze([ + ...contract.access.principalKinds, + ]), + }), + }) + : Object.freeze({ ...contract.access }); + return Object.freeze({ + ...contract, + access, + ...(contract.errorReasons === undefined + ? {} + : { errorReasons: Object.freeze([...contract.errorReasons]) }), + errors: Object.freeze([...contract.errors]), + transport: Object.freeze({ ...contract.transport }), + }); + }) +); /** Implemented raw HTTP metadata used by runtime wiring and docs. */ export const rawHttpContracts: readonly RawHttpContract[] = [...systemRawHttpContracts]; diff --git a/src/contracts/registry.ts b/src/contracts/registry.ts index a2ac9d3dd..41dcb3880 100644 --- a/src/contracts/registry.ts +++ b/src/contracts/registry.ts @@ -69,12 +69,17 @@ export interface ProcedureContract { } /** - * Fails closed when contract error metadata is unregistered, duplicated, or unstable. + * Fails closed for duplicate procedure names or error metadata that is + * unregistered, duplicated, or unstable. * @param contracts Procedure names and their declared expected error codes. */ export function assertProcedureContractErrors( contracts: readonly Pick[] ): void { + const names = contracts.map(({ name }) => name); + if (new Set(names).size !== names.length) { + throw new TypeError("Procedure contract names must be unique"); + } const registered = new Set(contractErrorCodes); for (const contract of contracts) { const errors = [...contract.errors]; diff --git a/src/server/domains/security/mfa/totpSecretCipher.ts b/src/server/domains/security/mfa/totpSecretCipher.ts index 9e04e37c8..78ff76a6b 100644 --- a/src/server/domains/security/mfa/totpSecretCipher.ts +++ b/src/server/domains/security/mfa/totpSecretCipher.ts @@ -1,6 +1,7 @@ import * as v from "valibot"; import { securityRecordIdSchema } from "../../../../contracts/security.ts"; +import { utf8ByteLength } from "../../../../shared/encoding.ts"; import { encryptedTotpSecretEnvelopeSchema, totpEncryptionKeyIdSchema, @@ -13,6 +14,7 @@ const encryptionKeyRingMaximumKeys = 8; const aesGcmNonceByteLength = 12; const aesGcmTagLengthBits = 128; const encodedEncryptionKeyPattern = /^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$/u; +const textEncoder = new TextEncoder(); const encodedEncryptionKeySchema = v.pipe( v.string("TOTP encryption key is invalid"), @@ -51,7 +53,6 @@ const encryptionKeyRingSchema = v.pipe( }, "TOTP encryption keyring is inconsistent") ); -const textEncoder = new TextEncoder(); const fatalTextDecoder = new TextDecoder("utf-8", { fatal: true }); function invalidKeyRingError(): TypeError { @@ -99,7 +100,7 @@ function parseKeyRing(serializedKeyRing: unknown) { if ( typeof serializedKeyRing !== "string" || serializedKeyRing.length > encryptionKeyRingMaximumBytes || - textEncoder.encode(serializedKeyRing).byteLength > encryptionKeyRingMaximumBytes + utf8ByteLength(serializedKeyRing) > encryptionKeyRingMaximumBytes ) { throw invalidKeyRingError(); } diff --git a/src/server/platform/configuration/configurationRegistry.test.ts b/src/server/platform/configuration/configurationRegistry.test.ts index f1e4afe72..94c707a5e 100644 --- a/src/server/platform/configuration/configurationRegistry.test.ts +++ b/src/server/platform/configuration/configurationRegistry.test.ts @@ -26,6 +26,11 @@ describe("application configuration registry", () => { "MIRA_DASHBOARD_LOG_LEVEL", ]); expect(applicationConfigurationRegistry).toHaveLength(13); + expect( + applicationConfigurationRegistry + .map((entry) => entry.environmentName) + .toSorted() + ).toEqual([...applicationConfigurationEnvironmentNames].toSorted()); expect( new Set( applicationConfigurationRegistry.map((entry) => entry.environmentName) diff --git a/src/server/platform/configuration/webConfiguration.test.ts b/src/server/platform/configuration/webConfiguration.test.ts index a91e40f7c..201b953fa 100644 --- a/src/server/platform/configuration/webConfiguration.test.ts +++ b/src/server/platform/configuration/webConfiguration.test.ts @@ -315,6 +315,12 @@ describe("web application configuration", () => { "MIRA_DASHBOARD_WEBAUTHN_RP_NAME", "invalid", ], + [ + "MIRA_DASHBOARD_WEBAUTHN_RP_NAME", + "Mira Cafe\u0301", + "MIRA_DASHBOARD_WEBAUTHN_RP_NAME", + "invalid", + ], [ "MIRA_DASHBOARD_LOG_LEVEL", "verbose", @@ -348,6 +354,14 @@ describe("web application configuration", () => { const keyringCases = [ serializedKeyring({ extra: true }), serializedKeyring({ activeKeyId: "missing" }), + serializedKeyring({ + keys: [ + { + id: "primary", + keyBase64: Buffer.alloc(31, 1).toString("base64"), + }, + ], + }), serializedKeyring({ keys: [ { id: "primary", keyBase64: encodedKey(1) }, diff --git a/src/server/platform/configuration/webConfiguration.ts b/src/server/platform/configuration/webConfiguration.ts index 2df42d1d1..f57669a9f 100644 --- a/src/server/platform/configuration/webConfiguration.ts +++ b/src/server/platform/configuration/webConfiguration.ts @@ -263,7 +263,18 @@ function webAuthnOrigins(input: PickedEnvironment): readonly string[] { if (new Set(values).size !== values.length) { configurationError(field, "invalid"); } - return values; + return Object.freeze(values); +} + +function webAuthnRelyingPartyName(input: PickedEnvironment): string { + const field = "MIRA_DASHBOARD_WEBAUTHN_RP_NAME" as const; + const value = requiredString( + input, + field, + applicationConfigurationLimits.webAuthnRpNameMaximumLength + ); + if (value.normalize("NFC") !== value) configurationError(field, "invalid"); + return value; } function webAuthnConfiguration( @@ -272,25 +283,17 @@ function webAuthnConfiguration( ): WebAuthnRelyingPartyConfiguration { const rpIdField = "MIRA_DASHBOARD_WEBAUTHN_RP_ID" as const; const originsField = "MIRA_DASHBOARD_WEBAUTHN_ORIGINS" as const; - const rpNameField = "MIRA_DASHBOARD_WEBAUTHN_RP_NAME" as const; const rpId = requiredString( input, rpIdField, applicationConfigurationLimits.webAuthnRpIdMaximumLength ); - const rpName = requiredString( - input, - rpNameField, - applicationConfigurationLimits.webAuthnRpNameMaximumLength - ); + const rpName = webAuthnRelyingPartyName(input); const origins = webAuthnOrigins(input); if (!v.safeParse(webAuthnRpIdSchema, rpId, { abortEarly: true }).success) { configurationError(rpIdField, "invalid"); } - if (rpName.normalize("NFC") !== rpName) { - configurationError(rpNameField, "invalid"); - } let configuration: WebAuthnRelyingPartyConfiguration; try { configuration = createWebAuthnRelyingPartyConfiguration({ @@ -299,6 +302,8 @@ function webAuthnConfiguration( rpName, }); } catch { + // RP ID and name have already passed the complete factory policy above; + // construction failures at this point belong to the origin allowlist. return configurationError(originsField, "inconsistent"); } if (!configuration.allowedOrigins.includes(origin)) { diff --git a/src/server/platform/observability/structuredLogger.ts b/src/server/platform/observability/structuredLogger.ts index c6fca7ece..4bca7fda8 100644 --- a/src/server/platform/observability/structuredLogger.ts +++ b/src/server/platform/observability/structuredLogger.ts @@ -17,6 +17,7 @@ export interface StructuredLogLimits { const defaultStructuredLogLimits: StructuredLogLimits = Object.freeze({ maximumSerializedBytes: 16 * 1024, }); +const structuredLogEncoder = new TextEncoder(); export type StructuredLogLevel = "debug" | "error" | "fatal" | "info" | "warn"; @@ -304,7 +305,8 @@ function serializeRecord( ): string { const serialized = `${JSON.stringify(record)}\n`; if ( - new TextEncoder().encode(serialized).byteLength <= limits.maximumSerializedBytes + structuredLogEncoder.encode(serialized).byteLength <= + limits.maximumSerializedBytes ) { return serialized; } @@ -313,7 +315,9 @@ function serializeRecord( fields: { truncated: true }, }; const bounded = `${JSON.stringify(boundedRecord)}\n`; - if (new TextEncoder().encode(bounded).byteLength <= limits.maximumSerializedBytes) { + if ( + structuredLogEncoder.encode(bounded).byteLength <= limits.maximumSerializedBytes + ) { return bounded; } throw new RangeError("Structured log envelope exceeds its byte budget"); diff --git a/src/server/test/support/requestContext.ts b/src/server/test/support/requestContext.ts index eff4901ad..3c51e0173 100644 --- a/src/server/test/support/requestContext.ts +++ b/src/server/test/support/requestContext.ts @@ -55,6 +55,57 @@ export function createTestStructuredLogger(): StructuredLogger { }); } +/** Capturing logger fixture for assertions at application logging boundaries. */ +export interface CapturingTestStructuredLogger { + readonly logger: StructuredLogger; + readonly logLines: string[]; +} + +/** + * Creates a test logger and its captured serialized records. + * @returns Stable logger fixture with an initially empty record buffer. + */ +export function createCapturingTestStructuredLogger(): CapturingTestStructuredLogger { + const logLines: string[] = []; + const logger = createStructuredLogger({ + identity: { + bun: "test-bun", + pid: 1, + processRole: "web", + release: "test-release", + service: "mira-dashboard", + }, + sink: { + write(line) { + logLines.push(line); + }, + }, + }); + return { logger, logLines }; +} + +/** + * Waits until the expected number of asynchronous log records remains stable. + * @param logLines Captured serialized log records. + * @param expectedCount Exact terminal record count. + */ +export async function waitForTestLogQuiescence( + logLines: readonly string[], + expectedCount: number +): Promise { + let stableObservations = 0; + for (let attempt = 0; attempt < 100; attempt += 1) { + if (logLines.length === expectedCount) { + stableObservations += 1; + if (stableObservations === 3) return; + } else { + stableObservations = 0; + } + await Bun.sleep(5); + } + throw new Error("Test log records did not reach a stable expected count"); +} + /** * Creates one valid session identity with the requested test capabilities. * @param capabilities Capabilities granted to the test user. diff --git a/src/server/test/system/serverFoundation.test.ts b/src/server/test/system/serverFoundation.test.ts index 029474c9b..089235d4f 100644 --- a/src/server/test/system/serverFoundation.test.ts +++ b/src/server/test/system/serverFoundation.test.ts @@ -10,7 +10,6 @@ import { serverRequestBodyMaximumBytes, } from "../../../app/server.ts"; import { bunRuntimePolicy } from "../../../shared/bunRuntimePolicy.ts"; -import { createStructuredLogger } from "../../platform/observability/structuredLogger.ts"; import { createReadinessController, type ReadinessController, @@ -19,9 +18,11 @@ import * as runtimeIdentityModule from "../../platform/runtime/readRuntimeIdenti import type { AppRouter } from "../../trpc/appRouter.ts"; import { rejectOnAbort, withTestTimeout } from "../support/promise.ts"; import { + createCapturingTestStructuredLogger, createTestApplicationRuntime, createTestAuthenticationLifecycleService, createTestServerSecurityServices, + waitForTestLogQuiescence, } from "../support/requestContext.ts"; const servers: ApplicationServer[] = []; @@ -153,21 +154,7 @@ describe("system foundation", () => { }); test("emits one correlated response-created event for every response class", async () => { - const logLines: string[] = []; - const logger = createStructuredLogger({ - identity: { - bun: "1.4.0-test", - pid: 123, - processRole: "web", - release: "server-foundation-test", - service: "mira-dashboard", - }, - sink: { - write(line) { - logLines.push(line); - }, - }, - }); + const { logger, logLines } = createCapturingTestStructuredLogger(); const server = await createServer({ ...createTestServerSecurityServices(), applicationRuntime: createTestApplicationRuntime({ logger }), @@ -183,6 +170,7 @@ describe("system foundation", () => { fetch(new URL("/trpc/system.runtimeIdentity", server.url)), ]); await Promise.all(responses.map((response) => response.text())); + await waitForTestLogQuiescence(logLines, 3); const records = logLines.map( (line) => JSON.parse(line) as Record ); @@ -210,21 +198,7 @@ describe("system foundation", () => { }); test("classifies an aborted streaming upload without a server-error event", async () => { - const logLines: string[] = []; - const logger = createStructuredLogger({ - identity: { - bun: "1.4.0-test", - pid: 123, - processRole: "web", - release: "server-foundation-test", - service: "mira-dashboard", - }, - sink: { - write(line) { - logLines.push(line); - }, - }, - }); + const { logger, logLines } = createCapturingTestStructuredLogger(); const server = await createServer({ ...createTestServerSecurityServices(), applicationRuntime: createTestApplicationRuntime({ logger }), @@ -249,9 +223,7 @@ describe("system foundation", () => { await Bun.sleep(50); abortController.abort(); expect(await pendingRequest).toBeInstanceOf(Error); - for (let attempt = 0; attempt < 100 && logLines.length === 0; attempt += 1) { - await Bun.sleep(5); - } + await waitForTestLogQuiescence(logLines, 1); const records = logLines.map( (line) => JSON.parse(line) as Record @@ -268,21 +240,7 @@ describe("system foundation", () => { }); test("classifies resolver cancellation after dispatch as one cancellation event", async () => { - const logLines: string[] = []; - const logger = createStructuredLogger({ - identity: { - bun: "1.4.0-test", - pid: 123, - processRole: "web", - release: "server-foundation-test", - service: "mira-dashboard", - }, - sink: { - write(line) { - logLines.push(line); - }, - }, - }); + const { logger, logLines } = createCapturingTestStructuredLogger(); const resolverStarted = Promise.withResolvers(); const server = await createServer({ ...createTestServerSecurityServices(), @@ -326,9 +284,7 @@ describe("system foundation", () => { ); abortController.abort(); expect(await pendingRequest).toBeInstanceOf(Error); - for (let attempt = 0; attempt < 100 && logLines.length === 0; attempt += 1) { - await Bun.sleep(5); - } + await waitForTestLogQuiescence(logLines, 1); const records = logLines.map( (line) => JSON.parse(line) as Record @@ -349,21 +305,7 @@ describe("system foundation", () => { test("returns a correlated sanitized 500 when a raw handler defects", async () => { const sentinel = "readiness-defect-secret"; - const logLines: string[] = []; - const logger = createStructuredLogger({ - identity: { - bun: "1.4.0-test", - pid: 123, - processRole: "web", - release: "server-foundation-test", - service: "mira-dashboard", - }, - sink: { - write(line) { - logLines.push(line); - }, - }, - }); + const { logger, logLines } = createCapturingTestStructuredLogger(); const server = await createServer({ ...createTestServerSecurityServices(), applicationRuntime: createTestApplicationRuntime({ logger }), @@ -382,6 +324,7 @@ describe("system foundation", () => { const response = await fetch(new URL("/api/health/ready", server.url)); const body = await response.text(); const requestId = response.headers.get("x-request-id"); + await waitForTestLogQuiescence(logLines, 1); expect(response.status).toBe(500); expect(response.headers.get("cache-control")).toBe("no-store"); diff --git a/src/server/trpc/procedureErrorPolicy.test.ts b/src/server/trpc/procedureErrorPolicy.test.ts index 166c804e5..11c01fe0c 100644 --- a/src/server/trpc/procedureErrorPolicy.test.ts +++ b/src/server/trpc/procedureErrorPolicy.test.ts @@ -8,6 +8,7 @@ import type { ProcedureContract } from "../../contracts/registry.ts"; import { captureFailure } from "../test/support/promise.ts"; import { createTestRequestContext } from "../test/support/requestContext.ts"; import { + applyProcedureExpectedErrorPolicy, assertProcedureExpectedErrorPolicy, procedureExpectedErrorPolicy, type ProcedureExpectedErrorPolicy, @@ -113,6 +114,16 @@ describe("procedure expected-error policy", () => { expect((failure as TRPCError).code).toBe("INTERNAL_SERVER_ERROR"); }); + test("treats inherited object keys as unregistered procedure paths", () => { + for (const path of ["constructor", "toString", "valueOf"] as const) { + const result = applyProcedureExpectedErrorPolicy( + path, + new TRPCError({ code: "FORBIDDEN" }) + ); + expect(result.code).toBe("INTERNAL_SERVER_ERROR"); + } + }); + test("keeps framework input validation implicit", async () => { const statusInputSchema = v.strictObject({}); const statusProcedure = publicProcedure @@ -154,4 +165,24 @@ describe("procedure expected-error policy", () => { expect(failure).toBeInstanceOf(TRPCError); expect((failure as TRPCError).code).toBe("INTERNAL_SERVER_ERROR"); }); + + test("preserves declared errors raised during subscription iteration", async () => { + const testRouter = router({ + events: router({ + stream: publicProcedure.subscription(async function* () { + await Promise.resolve(); + yield "started"; + throw new TRPCError({ code: "TOO_MANY_REQUESTS" }); + }), + }), + }); + const caller = testRouter.createCaller(await createTestRequestContext()); + const stream = await caller.events.stream(); + const iterator = stream[Symbol.asyncIterator](); + + expect(await iterator.next()).toEqual({ done: false, value: "started" }); + const failure = await captureFailure(() => iterator.next()); + expect(failure).toBeInstanceOf(TRPCError); + expect((failure as TRPCError).code).toBe("TOO_MANY_REQUESTS"); + }); }); diff --git a/src/server/trpc/procedureErrorPolicy.ts b/src/server/trpc/procedureErrorPolicy.ts index 2ea347fee..22665fb86 100644 --- a/src/server/trpc/procedureErrorPolicy.ts +++ b/src/server/trpc/procedureErrorPolicy.ts @@ -16,7 +16,8 @@ function freezeProcedureExpectedErrorPolicy< /** * Server-owned allowlist for expected errors intentionally exposed by each route. - * The runtime boundary consumes this policy; contract metadata must match it exactly. + * It intentionally duplicates contract metadata: the server policy is authored as an + * independent enforcement boundary, and the startup assertion detects drift between them. */ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "accountSecurity.beginTotpEnrollment": [ @@ -238,7 +239,9 @@ export function applyProcedureExpectedErrorPolicy( path: string, error: TRPCError ): TRPCError { - const expectedErrors = runtimeProcedureExpectedErrorPolicy[path]; + const expectedErrors = Object.hasOwn(runtimeProcedureExpectedErrorPolicy, path) + ? runtimeProcedureExpectedErrorPolicy[path] + : undefined; if ( error.code === "INTERNAL_SERVER_ERROR" || isImplicitInputValidationError(error) || diff --git a/src/shared/configuration/applicationConfigurationRegistry.ts b/src/shared/configuration/applicationConfigurationRegistry.ts index 97e7e7e50..5bcf935de 100644 --- a/src/shared/configuration/applicationConfigurationRegistry.ts +++ b/src/shared/configuration/applicationConfigurationRegistry.ts @@ -330,7 +330,9 @@ export function configurationMetadata( (candidate) => candidate.environmentName === environmentName ); if (entry === undefined) { - throw new Error("Application configuration registry is incomplete"); + throw new Error( + `Application configuration registry is missing ${environmentName}` + ); } return entry; } diff --git a/tsconfig.contracts.json b/tsconfig.contracts.json index 5634676b6..44f8b5f69 100644 --- a/tsconfig.contracts.json +++ b/tsconfig.contracts.json @@ -1,6 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { + // Contracts/shared stay environment-neutral: no DOM, Bun, or Node ambient types; + // byte budgets therefore use the shared manual UTF-8 counter. "lib": ["ESNext"], "types": [] }, diff --git a/tsconfig.json b/tsconfig.json index 6d5a8db4a..9808e5509 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,17 +18,22 @@ "noUncheckedSideEffectImports": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, - "erasableSyntaxOnly": true + "erasableSyntaxOnly": true, + "jsx": "react-jsx", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun-types", "node"] }, - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.browser.json" }, - { "path": "./tsconfig.contracts.json" }, - { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.qualification.json" }, - { "path": "./tsconfig.scripts.json" }, - { "path": "./tsconfig.server.json" }, - { "path": "./tsconfig.worker.json" } + // Repository-wide Oxlint compatibility graph; strict runtime partitions use their own configs. + "include": [ + "backend/**/*.ts", + "contracts/**/*.ts", + "drizzle.config.ts", + "frontend/src/**/*", + "qualification/**/*.ts", + "scripts/**/*.ts", + "src/**/*.ts", + "src/**/*.tsx", + "tailwind.config.ts", + "test/**/*.ts" ] } diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index a6c1978e4..9417abac4 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -4,10 +4,5 @@ "lib": ["ESNext"], "types": ["bun-types", "node"] }, - "include": [ - "drizzle.config.ts", - "frontend/src/globals.d.ts", - "scripts/**/*.ts", - "tailwind.config.ts" - ] + "include": ["drizzle.config.ts", "scripts/**/*.ts", "tailwind.config.ts"] } diff --git a/tsconfig.worker.json b/tsconfig.worker.json index 82f3f5574..10803b689 100644 --- a/tsconfig.worker.json +++ b/tsconfig.worker.json @@ -5,7 +5,7 @@ "types": ["bun-types", "node"] }, "include": [ - "src/app/worker.ts", + "src/app/worker*.ts", "src/contracts/**/*.ts", "src/shared/**/*.ts", "src/worker/**/*.ts" From ba76c1f42cfaa5f9cc4ec021aecb08f91bc6048c Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 14:09:58 +0200 Subject: [PATCH 3/3] test(platform): improve log quiescence diagnostics --- src/server/test/support/requestContext.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server/test/support/requestContext.ts b/src/server/test/support/requestContext.ts index 3c51e0173..74829160e 100644 --- a/src/server/test/support/requestContext.ts +++ b/src/server/test/support/requestContext.ts @@ -103,7 +103,11 @@ export async function waitForTestLogQuiescence( } await Bun.sleep(5); } - throw new Error("Test log records did not reach a stable expected count"); + throw new Error( + `Test log records did not reach a stable expected count: expected ${String( + expectedCount + )}, observed ${String(logLines.length)}: ${JSON.stringify(logLines)}` + ); } /**