From bdee07a93be45ea0121611813e297107882779bf Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sat, 15 Aug 2026 21:34:19 +0900 Subject: [PATCH] chore: sync the frontend template from a0fbafb to 5434760 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries eight template commits: the provider sandbox actually running, release admission to a named environment, the product feature manifest with its runtime kill switch, architecture and documentation rules that match what is enforced, the removability fixtures, and the browser, visual and performance evidence. Product identity is unchanged. `package.json` keeps `tech-log-frontend` and the catalog keeps the Tech Log naming; the home page was not in the delta. The visual baselines are this product's own — the template's were excluded from the transplant and these were regenerated here, where the only difference is the platform overview's new product-feature section. What this repository gains operationally: `config/runtime/{local,development, staging,production}.json` with `FE-GATE-027` refusing an artifact whose runtime document does not match the environment it is being admitted to, and `FEATURE_OVERRIDES` for taking an installed feature out of service without a rebuild. Verified here: eight gates green, build green, visual 5/5, and 1,858 of 1,859 tests in the suites that do not need a sandbox — the one failure passes in isolation and is a jsdom lazy-chunk timeout under parallel load. The provider suites cannot run on this machine at all: `kernel.apparmor_restrict_unprivileged _userns=1` makes `bwrap --unshare-net` fail, reproducible without any code from either repository. Co-Authored-By: Claude Opus 5 --- .dependency-cruiser.json | 35 +++ .env.example | 25 ++ .gitea/workflows/quality-gates.yml | 3 + .gitignore | 6 + .storybook/preview.tsx | 15 ++ README.md | 7 +- config/ci/gates.json | 55 ++++- config/runtime/development.json | 19 ++ config/runtime/local.json | 19 ++ config/runtime/production.json | 20 ++ config/runtime/staging.json | 20 ++ docs/accessibility/manual-checklist.md | 18 +- docs/architecture/layers.md | 22 ++ docs/architecture/review-ledger.json | 5 +- docs/operations/adapter-remediation-ledger.md | 97 ++++++++ docs/reviews/adapters/INVENTORY.md | 119 +++++----- package.json | 2 + public/config.json | 3 + scripts/build-frontend.ts | 22 +- scripts/check-architecture.ts | 46 +++- scripts/check-ci-contract.ts | 4 +- scripts/check-release-admission.ts | 108 +++++++++ scripts/contracts/ci-gates.ts | 51 +++- scripts/contracts/release-artifacts.ts | 59 ++++- scripts/generate-runtime-config.ts | 93 ++++++++ scripts/lib/ci-artifact-validator.ts | 2 + scripts/lib/ci-candidate-archive.ts | 64 +++-- scripts/lib/ci-contract-report.ts | 2 +- scripts/lib/private-filesystem.ts | 36 +++ scripts/lib/promotion-stager.ts | 16 +- scripts/lib/provider-cgroup.ts | 46 +++- scripts/lib/provider-scope-wrapper.ts | 53 ++++- scripts/lib/removal-fixture.ts | 155 +++++++++++- scripts/run-and-validate-provider.ts | 42 +++- scripts/test-optional-recipe-removal.ts | 33 +-- scripts/test-performance.ts | 8 +- scripts/test-sample-removal.ts | 39 +-- scripts/verify-documentation-readiness.ts | 43 +++- .../presigned-capability-http-provider.ts | 9 +- .../diagnostics/bounded-diagnostics.ts | 2 +- src/adapters/http/http-execution-v3.ts | 23 +- src/adapters/platform/bounded-capacity.ts | 21 ++ .../telemetry/best-effort-telemetry.ts | 19 +- src/application/create-application.ts | 6 + src/application/policies/compatibility.ts | 128 ++-------- .../policies/promotion-readiness.ts | 3 + src/application/ports/in/application-api.ts | 9 + .../ports/out/application-output-ports.ts | 2 + .../ports/product-features-port.ts | 16 ++ src/application/result.ts | 13 +- src/bootstrap/runtime-adapters.ts | 35 +++ src/bootstrap/runtime-config-schema.ts | 10 + src/contracts/compatibility.ts | 107 +++++++++ src/contracts/cursor-pagination.ts | 2 +- src/contracts/deployment-admission.ts | 160 +++++++++++++ src/contracts/env.ts | 5 + src/contracts/product-features.ts | 143 +++++++++++ src/contracts/release-artifacts.ts | 18 ++ src/contracts/release-tokens.ts | 2 +- src/contracts/result.ts | 15 ++ src/contracts/server-state.ts | 2 +- .../installed-contract-contributions.ts | 8 +- src/features/installed-feature-adapters.ts | 11 +- src/features/installed-feature-contracts.ts | 53 ++++- src/features/installed-feature-messages.ts | 10 +- src/features/installed-feature-runtimes.tsx | 16 +- src/features/installed-product-manifest.ts | 63 +++++ .../examples/platform-overview-page.tsx | 56 +++++ src/presentation/i18n/catalog.ts | 8 + src/presentation/layouts/app-shell.tsx | 11 +- src/presentation/routes/app-router.tsx | 36 ++- template.lock.json | 4 +- .../presigned-streaming.spec.ts | 9 + .../resumable-upload.spec.ts | 9 +- .../component/product-feature-switch.test.tsx | 83 +++++++ .../reference-installed-executor.test.ts | 43 ++++ .../reference-runtime-composition.test.ts | 22 +- tests/helpers/create-test-application.ts | 4 +- tests/helpers/runtime-capabilities-stub.ts | 34 +++ .../http-execution-v3-observability.test.ts | 30 --- tests/runtime-schema/release-manifest.test.ts | 1 + .../ci-workflow-generation.test.ts.snap | 3 + tests/unit/application-boundary.test.ts | 3 +- tests/unit/chunk-recovery-runtime.test.ts | 3 +- tests/unit/ci-artifact-contract.test.ts | 219 ++++++++++++++--- tests/unit/ci-workflow-generation.test.ts | 10 +- tests/unit/deployment-admission.test.ts | 166 +++++++++++++ tests/unit/http-execution-v3.test.ts | 70 ++++++ tests/unit/presigned-transfer.test.ts | 36 +++ tests/unit/product-features.test.ts | 222 ++++++++++++++++++ .../provider-guardian-transaction.test.ts | 10 +- tests/unit/release-coherence.test.ts | 1 + tests/unit/removal-fixture.test.ts | 46 +++- tests/unit/risk-coverage.test.ts | 10 +- tests/unit/runtime-adapters.test.ts | 2 + tests/unit/security-followup.test.ts | 10 +- tests/unit/supply-chain.test.ts | 7 +- .../unit/task3-selective-integration.test.ts | 48 +++- ...m-overview-light-chromium-visual-linux.png | Bin 401788 -> 434448 bytes vite.config.ts | 21 ++ vitest.config.ts | 4 + 101 files changed, 3116 insertions(+), 448 deletions(-) create mode 100644 .env.example create mode 100644 config/runtime/development.json create mode 100644 config/runtime/local.json create mode 100644 config/runtime/production.json create mode 100644 config/runtime/staging.json create mode 100644 scripts/check-release-admission.ts create mode 100644 scripts/generate-runtime-config.ts create mode 100644 scripts/lib/private-filesystem.ts create mode 100644 src/adapters/platform/bounded-capacity.ts create mode 100644 src/application/ports/product-features-port.ts create mode 100644 src/contracts/compatibility.ts create mode 100644 src/contracts/deployment-admission.ts create mode 100644 src/contracts/product-features.ts create mode 100644 src/contracts/result.ts create mode 100644 src/features/installed-product-manifest.ts create mode 100644 tests/component/product-feature-switch.test.tsx create mode 100644 tests/features/reference-feature/reference-installed-executor.test.ts create mode 100644 tests/unit/deployment-admission.test.ts create mode 100644 tests/unit/product-features.test.ts diff --git a/.dependency-cruiser.json b/.dependency-cruiser.json index 55ee8b7..ea20ba8 100644 --- a/.dependency-cruiser.json +++ b/.dependency-cruiser.json @@ -156,6 +156,41 @@ "path": "^(src/(presentation|bootstrap)|react|react-dom|@tanstack)" } }, + { + "name": "contracts-do-not-know-application", + "comment": "§4. `src/contracts` is the lower of the two packages: application reads contracts, never the other way round. Before this rule the shared Result carrier and the compatibility predicate lived in application and were imported back down by contracts, so neither package owned the shared vocabulary and the coupling was invisible to every gate.", + "severity": "error", + "from": { + "path": "^src/contracts" + }, + "to": { + "path": "^src/(application|features)" + } + }, + { + "name": "generic-presentation-does-not-compose-the-product", + "comment": "§4 / §9. Which features are installed is a product decision that belongs to bootstrap. Generic presentation reads the installed registries directly today; the paths below are the exact set that does so, frozen so the coupling cannot spread while the assembly is lifted into bootstrap.", + "severity": "error", + "from": { + "path": "^src/presentation/", + "pathNot": "^src/presentation/(layouts/app-shell\\.tsx|pages/(not-found-page|home-page)\\.tsx|routes/(route-contract|route-codecs|app-router|navigation-policy)\\.(ts|tsx)|i18n/catalog\\.ts|examples/platform-overview-page\\.tsx)$" + }, + "to": { + "path": "^src/features/installed-" + } + }, + { + "name": "adapters-do-not-know-other-concrete-adapters", + "comment": "docs/architecture/layers.md §4: a concrete adapter never depends on another concrete adapter. Only the adapter kernel is shared — `src/adapters/platform` (clock, abort primitive, capacity guard) and the browser-data result helpers. `query-cache` still reads two collaborator types from `cross-context-invalidation`; that edge is named here rather than left silent, and closes when those types are lifted to a port.", + "severity": "error", + "from": { + "path": "^src/adapters/([^/]+)/" + }, + "to": { + "path": "^src/adapters/([^/]+)/", + "pathNot": "^src/adapters/($1/|platform/|browser-file-storage/result\\.ts$|cross-context-invalidation/index\\.ts$)" + } + }, { "name": "no-circular-dependencies", "severity": "error", diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cae5252 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Build-time inputs (§6.1). These are compiled into the bundle by Vite, so +# everything here is public by definition. Never put a secret in this file or in +# any `.env*` file: a frontend has no confidential storage, and a value that +# reaches the browser has been published. +# +# Runtime configuration — API endpoints, auth mode, telemetry, capability +# switches — is NOT here. It lives in `config/runtime/.json` and is +# materialized into `dist/config.json` at build time, so it can be changed +# without rebuilding. See docs/architecture/layers.md. +# +# Copy to `.env.local` (git-ignored) to override locally. + +# Identifies the build in release manifests and the runtime document. +# CI supplies the real value; a developer build falls back to "local-build". +VITE_BUILD_ID=local-build + +# Source revision the bundle was produced from. +VITE_COMMIT_SHA=local + +# Sub-path the app is served under. Must start and end with "/". +# Feeds the router, the Service Worker scope and Vite's asset base together. +VITE_ROUTER_BASE_PATH=/ + +# Where the browser fetches the runtime document from at boot. +VITE_RUNTIME_CONFIG_URL=/config.json diff --git a/.gitea/workflows/quality-gates.yml b/.gitea/workflows/quality-gates.yml index b0c5540..03b116c 100644 --- a/.gitea/workflows/quality-gates.yml +++ b/.gitea/workflows/quality-gates.yml @@ -126,6 +126,9 @@ jobs: outputs: dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }} archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }} + env: + APP_PROFILE: "${{ vars.APP_PROFILE }}" + RELEASE_TARGET: "${{ vars.RELEASE_TARGET }}" steps: - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: diff --git a/.gitignore b/.gitignore index e6b3beb..2269a05 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ artifacts/storybook/ artifacts/tests/storybook/ artifacts/tests/visual/ !artifacts/**/.gitkeep + +# Local environment overrides. `.env.example` is the tracked template; every +# other `.env*` file is a developer's own machine and never enters the repo. +.env +.env.* +!.env.example diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 7aca4b3..3dca769 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -10,6 +10,11 @@ import { ApplicationProvider } from "../src/presentation/providers/application-p import { SessionProvider } from "../src/presentation/providers/session-provider.tsx"; import { ThemeProvider } from "../src/presentation/providers/theme-provider.tsx"; import "../src/presentation/styles/theme.css"; +import { resolveProductFeatures } from "../src/contracts/product-features.ts"; +import { + COMPILED_PRODUCT_FEATURE_IDS, + INSTALLED_PRODUCT_FEATURE_IDS, +} from "../src/features/installed-product-manifest.ts"; const preferences = new Map(); const application = createApplication({ @@ -45,6 +50,16 @@ const application = createApplication({ routeChunks: {}, }), }, + // Storybook renders components, not a product: every declared feature is + // shown as active so a story is never blank because of a deployment switch. + productFeatures: { + getSnapshot: () => + resolveProductFeatures( + COMPILED_PRODUCT_FEATURE_IDS, + INSTALLED_PRODUCT_FEATURE_IDS, + ), + isActive: () => true, + }, runtimeCapabilities: { getSnapshot: () => Object.freeze( diff --git a/README.md b/README.md index 649871e..e14a22f 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,12 @@ corepack pnpm exec playwright install --with-deps chromium firefox webkit Two gates intentionally need external evidence: - `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review - for all six registered routes. + for all ten registered routes: `APP_HOME`, `EXAMPLES_PLATFORM`, + `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`, `NOT_FOUND`, + `REFERENCE_RESOURCE_LIST`, `REFERENCE_RESOURCE_DETAIL`, + `REFERENCE_RESOURCE_FORM` and `REFERENCE_RESOURCE_STATUS`. + `verify:documentation` derives that list from the route registry and fails if + this paragraph falls behind it. - `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum eligible-sample threshold and 28 days of production data exist. diff --git a/config/ci/gates.json b/config/ci/gates.json index d6d806a..b549802 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -472,6 +472,11 @@ "id": "check-ci", "script": "check:ci", "expect": "pass" + }, + { + "id": "check-release-admission", + "script": "check:release-admission", + "expect": "pass" } ], "artifactSchemas": [ @@ -732,6 +737,12 @@ "id": "sarif-secret-scan", "kind": "sarif", "maxBytes": 67108864 + }, + { + "id": "json-deployment-admission", + "kind": "json", + "maxBytes": 67108864, + "executableSchemaId": "deployment-admission" } ], "artifacts": [ @@ -1602,6 +1613,21 @@ "producerCommandIds": [ "check-ci" ] + }, + { + "id": "artifact-artifacts-release-deployment-admission-json", + "path": "artifacts/release/deployment-admission.json", + "schemaId": "json-deployment-admission", + "production": "command-generated", + "producerCommandIds": [ + "check-release-admission" + ] + }, + { + "id": "artifact-artifacts-quality-gates-FE-GATE-027-txt", + "path": "artifacts/quality/gates/FE-GATE-027.txt", + "schemaId": "text", + "production": "runner-generated" } ], "gates": [ @@ -2049,6 +2075,18 @@ "artifact-artifacts-performance-lab-json" ], "retentionClassId": "release-coherence" + }, + { + "id": "FE-GATE-027", + "name": "release-admission", + "commandIds": [ + "check-release-admission" + ], + "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-027-txt", + "evidenceArtifactIds": [ + "artifact-artifacts-release-deployment-admission-json" + ], + "retentionClassId": "release-coherence" } ], "stages": [ @@ -2083,7 +2121,8 @@ "FE-GATE-014", "FE-GATE-015", "FE-GATE-019", - "FE-GATE-026" + "FE-GATE-026", + "FE-GATE-027" ] }, { @@ -2236,10 +2275,20 @@ "condition": "release", "timeoutMinutes": 45, "gateIds": [ - "FE-GATE-015" + "FE-GATE-015", + "FE-GATE-027" ], "browserGateIds": [], - "environment": [], + "environment": [ + { + "name": "APP_PROFILE", + "value": "${{ vars.APP_PROFILE }}" + }, + { + "name": "RELEASE_TARGET", + "value": "${{ vars.RELEASE_TARGET }}" + } + ], "steps": [ { "kind": "checkout" diff --git a/config/runtime/development.json b/config/runtime/development.json new file mode 100644 index 0000000..63006b8 --- /dev/null +++ b/config/runtime/development.json @@ -0,0 +1,19 @@ +{ + "APP_ENV": "development", + "API_BASE_URL": "https://api.dev.example.com/", + "REQUEST_TIMEOUT_MS": 15000, + "MAX_RETRY_ATTEMPTS": 2, + "TELEMETRY_ENABLED": false, + "AUTH_MODE": "external", + "CONFIG_SCHEMA_VERSION": "2.0", + "RELEASE_MANIFEST_URL": "/release-manifest.json", + "CAPABILITY_OVERRIDES": { + "REALTIME": "DEFAULT", + "WEB_WORKER": "DEFAULT", + "SERVICE_WORKER": "DEFAULT", + "OFFLINE_COMMANDS": "DEFAULT" + }, + "FEATURE_OVERRIDES": { + "reference-feature": "DEFAULT" + } +} diff --git a/config/runtime/local.json b/config/runtime/local.json new file mode 100644 index 0000000..952aa3c --- /dev/null +++ b/config/runtime/local.json @@ -0,0 +1,19 @@ +{ + "APP_ENV": "local", + "API_BASE_URL": "http://localhost:8080/", + "REQUEST_TIMEOUT_MS": 10000, + "MAX_RETRY_ATTEMPTS": 2, + "TELEMETRY_ENABLED": false, + "AUTH_MODE": "demo", + "CONFIG_SCHEMA_VERSION": "2.0", + "RELEASE_MANIFEST_URL": "/release-manifest.json", + "CAPABILITY_OVERRIDES": { + "REALTIME": "DEFAULT", + "WEB_WORKER": "DEFAULT", + "SERVICE_WORKER": "DEFAULT", + "OFFLINE_COMMANDS": "DEFAULT" + }, + "FEATURE_OVERRIDES": { + "reference-feature": "DEFAULT" + } +} diff --git a/config/runtime/production.json b/config/runtime/production.json new file mode 100644 index 0000000..46ad778 --- /dev/null +++ b/config/runtime/production.json @@ -0,0 +1,20 @@ +{ + "APP_ENV": "production", + "API_BASE_URL": "https://api.example.com/", + "REQUEST_TIMEOUT_MS": 10000, + "MAX_RETRY_ATTEMPTS": 2, + "TELEMETRY_ENABLED": true, + "TELEMETRY_ENDPOINT": "https://telemetry.example.com/v1/events", + "AUTH_MODE": "external", + "CONFIG_SCHEMA_VERSION": "2.0", + "RELEASE_MANIFEST_URL": "/release-manifest.json", + "CAPABILITY_OVERRIDES": { + "REALTIME": "DEFAULT", + "WEB_WORKER": "DEFAULT", + "SERVICE_WORKER": "DEFAULT", + "OFFLINE_COMMANDS": "DEFAULT" + }, + "FEATURE_OVERRIDES": { + "reference-feature": "DEFAULT" + } +} diff --git a/config/runtime/staging.json b/config/runtime/staging.json new file mode 100644 index 0000000..861f87b --- /dev/null +++ b/config/runtime/staging.json @@ -0,0 +1,20 @@ +{ + "APP_ENV": "staging", + "API_BASE_URL": "https://api.staging.example.com/", + "REQUEST_TIMEOUT_MS": 10000, + "MAX_RETRY_ATTEMPTS": 2, + "TELEMETRY_ENABLED": true, + "TELEMETRY_ENDPOINT": "https://telemetry.staging.example.com/v1/events", + "AUTH_MODE": "external", + "CONFIG_SCHEMA_VERSION": "2.0", + "RELEASE_MANIFEST_URL": "/release-manifest.json", + "CAPABILITY_OVERRIDES": { + "REALTIME": "DEFAULT", + "WEB_WORKER": "DEFAULT", + "SERVICE_WORKER": "DEFAULT", + "OFFLINE_COMMANDS": "DEFAULT" + }, + "FEATURE_OVERRIDES": { + "reference-feature": "DEFAULT" + } +} diff --git a/docs/accessibility/manual-checklist.md b/docs/accessibility/manual-checklist.md index 6bfac32..4aaa7aa 100644 --- a/docs/accessibility/manual-checklist.md +++ b/docs/accessibility/manual-checklist.md @@ -1,12 +1,20 @@ # Manual accessibility review checklist Automated axe checks do not establish WCAG conformance. A human reviewer must -review all six route records in `artifacts/tests/a11y-manual/` against one +review all ten route records in `artifacts/tests/a11y-manual/` against one release candidate and sign them. The required scope is derived from the route -registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`, -`REFERENCE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the -gate rejects blank identity/timestamp/signature fields, pending verdicts, -mismatched release IDs, or missing routes. +registry: `APP_HOME`, `EXAMPLES_PLATFORM`, `EXAMPLES_UI`, `EXAMPLES_STATES`, +`EXAMPLES_AUTH`, `NOT_FOUND`, `REFERENCE_RESOURCE_LIST`, +`REFERENCE_RESOURCE_DETAIL`, `REFERENCE_RESOURCE_FORM` and +`REFERENCE_RESOURCE_STATUS`. Copy the template fields exactly; the gate rejects +blank identity/timestamp/signature fields, pending verdicts, mismatched release +IDs, or missing routes. + +This list is not maintained by hand: `verify:documentation` compares it against +the installed route registry and fails when a registered route is absent. It +said six routes while ten were registered, which put the platform overview and +the three reference-resource screens outside the declared manual review scope +without anyone deciding they should be. Allowed item verdicts: diff --git a/docs/architecture/layers.md b/docs/architecture/layers.md index 45d1c35..56a2c3f 100644 --- a/docs/architecture/layers.md +++ b/docs/architecture/layers.md @@ -17,11 +17,33 @@ The following edges are forbidden: - domain to application, presentation, adapters, bootstrap, React, or browser globals - application to presentation, concrete adapters, bootstrap, React, or browser globals +- `contracts` to application or features: contracts is the lower package and + owns the shared vocabulary both of them read - presentation to concrete adapters, raw DTO schemas, or storage implementations +- generic presentation to the installed-feature registries: which features exist + is a product decision owned by `bootstrap` - an adapter to presentation, bootstrap internals, or another concrete adapter - feature domain/application to its presentation or outbound adapter, and feature presentation to its outbound adapter +## The adapter kernel + +"Another concrete adapter" excludes the adapter kernel, which is shared on +purpose and is the only adapter code an adapter may reach across a group for: + +- `src/adapters/platform/**` — the system clock, the shared abort primitive and + the bounded-capacity guard +- `src/adapters/browser-file-storage/result.ts` — the browser-data result and + failure constructors + +Each rule above is enforced by `check:architecture`, including the kernel +carve-out, so this table and the executable rules cannot drift apart. Two edges +are still open and are named explicitly in `.dependency-cruiser.json` rather +than left silent: the generic presentation modules that read the installed +registries today, and the two collaborator types `query-cache` reads from +`cross-context-invalidation`. Both lists are frozen — a new edge of either kind +fails the gate. + `bootstrap` contains composition only. Business rules and page-specific orchestration belong to domain/application. diff --git a/docs/architecture/review-ledger.json b/docs/architecture/review-ledger.json index d026be8..d8d1e23 100644 --- a/docs/architecture/review-ledger.json +++ b/docs/architecture/review-ledger.json @@ -5,7 +5,7 @@ "standard": "rules/diagram-standards.md v2", "evidenceReport": { "repoPath": "docs/architecture/review-evidence.md", - "canonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md", + "upstreamCanonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md", "canonicalSha256": "b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29" }, "reviews": { @@ -25,5 +25,6 @@ "thresholdSatisfied": true, "scope": "immutable static assets and mutable /config.json delivery" } - } + }, + "note": "`repoPath` is this repository's copy and must resolve. `upstreamCanonicalPath` and every `reviews[*].sourcePath` name the reviewing workspace, not this tree; they are provenance labels and are deliberately not resolvable here. `canonicalSha256` is what binds the two, and the gate checks it appears in `repoPath`." } diff --git a/docs/operations/adapter-remediation-ledger.md b/docs/operations/adapter-remediation-ledger.md index 552ffc6..862c5e3 100644 --- a/docs/operations/adapter-remediation-ledger.md +++ b/docs/operations/adapter-remediation-ledger.md @@ -462,6 +462,103 @@ The full `tests/unit` + `tests/integration` run is **1,845 passed / 1,864**, cgroup, RLIMIT and `/tmp` permission behaviour already recorded above — the same file failed identically before this work. No adapter test fails. +## Operational contract review (2026-08-15) + +A fourth review looked past the adapter layer at the operational contract: +feature on/off, environment separation, folder boundaries, and which gates were +actually green. It found five red gates and three structural gaps. Every row +below names the defect, not the symptom. + +| id | area | disposition | what was actually wrong | +| --- | --- | --- | --- | +| `OPS-01` | release | `FIXED` | `public/` is copied verbatim into `dist/`, so every build — production included — shipped the local runtime document. Runtime config now comes from `config/runtime/.json`. | +| `OPS-02` | release | `FIXED` | Release coherence proved the artifacts agreed with each other, never that they belonged in production. `FE-GATE-027` refuses an artifact whose `APP_ENV`, auth mode, endpoints or build identity do not match a declared `RELEASE_TARGET`, and refuses an undeclared target outright. | +| `OPS-03` | runtime | `FIXED` | `REQUEST_TIMEOUT_MS` was validated and then never passed to the V3 executor; every operation ran on its contract's own deadline. It is now a ceiling that may tighten a contract, never loosen one. | +| `OPS-04` | build | `FIXED` | `VITE_ROUTER_BASE_PATH` drove the router and the Service Worker scope but not Vite's asset `base`, so a sub-path deployment emitted root-absolute assets. One value now feeds all three. | +| `OPS-05` | provider | `FIXED` | bubblewrap 0.9.0 drops whatever follows the option stream inside an `--args` file, so the sandboxed command was never executed: bwrap printed usage and exited 1. Options stay hidden; the command travels on real argv. | +| `OPS-06` | provider | `FIXED` | The scope wrapper read its liveness pipe through `fs`, a blocking `read(2)` on a pipe the supervisor never closes. `process.exit` deadlocked joining that thread, so a completed provider was reported as a timeout kill. | +| `OPS-07` | release | `FIXED` | `mkdir`/`open` modes were left to the ambient umask, so a hardened runner produced directories it could not enter and handed `tar` a file it could not re-open. | +| `OPS-08` | release | `FIXED` | Promotion cleanup deleted this promotion's exact five through a pinned descriptor and only then noticed the leaf had been substituted, leaving a half-emptied directory a retry could not distinguish from a completed one. | +| `OPS-09` | removability | `FIXED` | The removal fixture was not a repository, had no `.gitignore`, and each removal script kept its own copy-target list that had drifted. Supply-chain generation therefore failed inside every fixture and took the whole provider suite down with it. | +| `OPS-10` | removability | `FIXED` | A platform integration file asserted the reference feature's route ids, so removing the feature left it importing a deleted module. The assertion moved to the feature's own test tree. | +| `OPS-11` | removability | `FIXED` | A removal fixture runs against a deliberately reduced CI contract; the canonical exact-count tests re-imposed the full authority on it and failed the fixture for the reduction it exists to prove. | +| `OPS-12` | browser | `FIXED` | Four browser-capability specs answered capability requests without the `protocol` field the hardened envelope requires, so every capability was refused and the download and part-upload paths asserted against an empty transcript. | +| `OPS-13` | browser | `FIXED` | A refused capability document answered `recovery: NONE`, contradicting both the design record and the vault, which already answers `REISSUE_CAPABILITY`. | +| `OPS-14` | performance | `FIXED` | Playwright matches accessible names by substring, so the navigation entry matched the home page's call to action too; the run died on a strict-mode violation before the first measurement and produced no evidence at all. | +| `OPS-15` | visual | `FIXED` | The platform overview baseline predated the reference routes moving from `integration-defined` to `session-required`, so the only visual gate covering that page failed for its own staleness. | +| `OPS-16` | architecture | `FIXED` | `src/contracts` imported `src/application` for the shared `Result` and the compatibility predicate; neither package owned the shared vocabulary. Both moved down to contracts. | +| `OPS-17` | architecture | `FIXED` | The documented "no adapter depends on another concrete adapter" rule had no executable form, and `diagnostics` imported a guard out of `telemetry`. The guard moved to the adapter kernel and the rule is now enforced with a same-directory backreference. | +| `OPS-18` | architecture | `PARTIAL` | Generic presentation still reads the installed-feature registries. The rule freezes the exact set of modules doing so today; a new edge fails. Lifting the assembly into `bootstrap` is not done. | +| `OPS-19` | documentation | `FIXED` | README and the manual accessibility checklist both claimed six routes while ten were registered, leaving four screens outside the declared manual review scope. The list is now derived from the route registry by `verify:documentation`. | + +### Product feature selection (2026-08-15, second pass) + +| id | disposition | what changed | +| --- | --- | --- | +| `OPS-20` | `FIXED` | Which features a build contains is now a declared manifest rather than five registries spreading a literal. `VITE_PRODUCT_FEATURES` narrows it at build time; a test fails if a new registry forgets to consult it. | +| `OPS-21` | `FIXED` | `FEATURE_OVERRIDES` in the runtime document takes an installed feature out of service without a rebuild. The router refuses its routes, not just the navigation, so a typed deep link cannot still mount it. | +| `OPS-22` | `FIXED` | Both inputs are subtractive by vocabulary: the override enum has no `ENABLED`, and a build-time selection naming a feature the source tree does not declare is refused rather than ignored. | +| `OPS-23` | `FIXED` | A sandbox that fails to launch now reports why. The supervisor consumed the child's output only to enforce a byte cap and discarded it, so a host restriction surfaced as an unexplained `exit=1`. Lines the sandbox tooling itself emits are kept; provider output is still discarded. | + +An env var does **not** shrink the bundle, and the code says so. A static import +cannot be undone by a value, and making the import graph depend on a +configuration string is what §3.5 exists to prevent. Measured: `none` changes +the output by 58 bytes. Physical removal is FE-GATE-020's job. + +### Host restriction discovered during this pass + +`bwrap --unshare-net` no longer works on this machine: + +``` +$ printf '%s\0' --unshare-net --ro-bind /usr /usr ... | bwrap --args 3 -- /bin/true +bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted +$ sysctl kernel.apparmor_restrict_unprivileged_userns +kernel.apparmor_restrict_unprivileged_userns = 1 +``` + +That reproduction contains none of this repository's code. Earlier in the same +session the identical sandbox ran to completion, so the restriction became +active partway through. While it holds, 16 of the 108 provider tests cannot run +here — they need a sandbox the kernel will not grant. They are not counted as +green and not counted as product defects; under a host that permits the +namespace the same file was 107/108. + +### Still red after this pass + +*applies effective aggregate cgroup limits without exposing command or +credentials* was rewritten. It used to read the live process tree with one +`ps` per pid and assert mid-run, which lost a race against a sandbox that now +completes in a few hundred milliseconds; it records the tree from `/proc` every +5ms and asserts on the recording after the run. That restructuring is also what +revealed the host restriction above — the supervisor had been failing to launch +the sandbox and the test was dying on the observation first. + +Tests that spawn processes, build archives and sign evidence were given a +30s budget instead of the 10s default sized for pure-JS unit tests. The default +was not raised: that would hide a genuinely hung test. + +### FE-GATE-020 after this pass + +| fixture | before | after | +| --- | ---: | ---: | +| reference feature | failed before its first assertion | 1,612 pass / 1 fail | +| optional recipe | 39 failures | 1,386 pass / 2 fail | +| browser file + storage | 40 failures | 1,006 pass / 3 fail | +| realtime | not reached | 1,159 pass / 1 fail | + +Every remaining failure is one of the three environment-limited tests above. + +Lab performance now produces evidence, and that evidence shows the +named-interaction budget missed on this machine (367–724ms against 200ms). The +metric measures a full lazy-route navigation while the budget is an +INP-shaped 200ms, so the two do not describe the same thing. No budget was +changed to make this green. + +WebKit remains unavailable in this environment (`libevent-2.1-7t64`, +`libavif16` are not installed), so 14 browser-capability specs and the WebKit +E2E project are unverified here. Chromium and Firefox are 28/28 and visual is +5/5. + ## Rules for updating this ledger - A row moves out of `NOT_STARTED` only with a linked red test, its green run, and the commit id. diff --git a/docs/reviews/adapters/INVENTORY.md b/docs/reviews/adapters/INVENTORY.md index eafd04b..828fc6e 100644 --- a/docs/reviews/adapters/INVENTORY.md +++ b/docs/reviews/adapters/INVENTORY.md @@ -69,64 +69,65 @@ | 59 | `src/adapters/platform/abortable-operation.ts` | [Network/state](./01-network-and-state.md) | | 60 | `src/adapters/platform/browser-lifecycle.ts` | [Network/state](./01-network-and-state.md) | | 61 | `src/adapters/platform/browser-mutation-intent-factory.ts` | [Network/state](./01-network-and-state.md) | -| 62 | `src/adapters/platform/system-clock.ts` | [Network/state](./01-network-and-state.md) | -| 63 | `src/adapters/query-cache/conditional-validator-store.ts` | [Network/state](./01-network-and-state.md) | -| 64 | `src/adapters/query-cache/cursor-pagination-runtime.ts` | [Network/state](./01-network-and-state.md) | -| 65 | `src/adapters/query-cache/server-state-scope-runtime.ts` | [Network/state](./01-network-and-state.md) | -| 66 | `src/adapters/query-cache/tanstack-cache-coordinator.ts` | [Network/state](./01-network-and-state.md) | -| 67 | `src/adapters/query-cache/tanstack-query-cache.ts` | [Network/state](./01-network-and-state.md) | -| 68 | `src/adapters/realtime/event-codec.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 69 | `src/adapters/realtime/event-consumer.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 70 | `src/adapters/realtime/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 71 | `src/adapters/realtime/json-member-scanner.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 72 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 73 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 74 | `src/adapters/realtime/polling/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 75 | `src/adapters/realtime/reconnect-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 76 | `src/adapters/realtime/reconnect-policy.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 77 | `src/adapters/realtime/result.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 78 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 79 | `src/adapters/realtime/sse/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 80 | `src/adapters/realtime/sse/sse-parser.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 81 | `src/adapters/realtime/stream-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 82 | `src/adapters/realtime/websocket/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 83 | `src/adapters/realtime/websocket/websocket-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 84 | `src/adapters/realtime/websocket/websocket-protocol.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 85 | `src/adapters/service-worker/service-worker-entry.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 86 | `src/adapters/service-worker/service-worker-lifecycle.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 87 | `src/adapters/service-worker/service-worker-page-controller.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 88 | `src/adapters/service-worker/service-worker-protocol.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 89 | `src/adapters/service-worker/service-worker-removal.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 90 | `src/adapters/service-worker/service-worker-static-assets.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 91 | `src/adapters/storage/browser-storage-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 92 | `src/adapters/storage/browser-storage-codec.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 93 | `src/adapters/storage/indexeddb/index.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 94 | `src/adapters/storage/indexeddb/indexeddb-failure.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 95 | `src/adapters/storage/indexeddb/indexeddb-governance.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 96 | `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 97 | `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 98 | `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 99 | `src/adapters/storage/indexeddb/indexeddb-types.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 100 | `src/adapters/storage/opfs/browser-opfs-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 101 | `src/adapters/storage/opfs/index.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 102 | `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 103 | `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 104 | `src/adapters/storage/opfs/opfs-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 105 | `src/adapters/storage/opfs/opfs-worker-client.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 106 | `src/adapters/storage/opfs/opfs-worker-protocol.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 107 | `src/adapters/storage/opfs/opfs-worker-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 108 | `src/adapters/telemetry/best-effort-telemetry.ts` | [Network/state](./01-network-and-state.md) | -| 109 | `src/adapters/web-push/inbound/notification-click-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 110 | `src/adapters/web-push/inbound/push-event-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 111 | `src/adapters/web-push/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 112 | `src/adapters/web-push/notification-registry.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 113 | `src/adapters/web-push/push-association-fence-store.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 114 | `src/adapters/web-push/push-codec.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 115 | `src/adapters/web-push/push-registration-gateway.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 116 | `src/adapters/web-push/push-subscription-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 117 | `src/adapters/web-push/runtime-support.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 118 | `src/adapters/web-push/service-worker-runtime.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 119 | `src/adapters/web-push/service-worker-scope-host.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 62 | `src/adapters/platform/bounded-capacity.ts` | [Network/state](./01-network-and-state.md) | +| 63 | `src/adapters/platform/system-clock.ts` | [Network/state](./01-network-and-state.md) | +| 64 | `src/adapters/query-cache/conditional-validator-store.ts` | [Network/state](./01-network-and-state.md) | +| 65 | `src/adapters/query-cache/cursor-pagination-runtime.ts` | [Network/state](./01-network-and-state.md) | +| 66 | `src/adapters/query-cache/server-state-scope-runtime.ts` | [Network/state](./01-network-and-state.md) | +| 67 | `src/adapters/query-cache/tanstack-cache-coordinator.ts` | [Network/state](./01-network-and-state.md) | +| 68 | `src/adapters/query-cache/tanstack-query-cache.ts` | [Network/state](./01-network-and-state.md) | +| 69 | `src/adapters/realtime/event-codec.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 70 | `src/adapters/realtime/event-consumer.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 71 | `src/adapters/realtime/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 72 | `src/adapters/realtime/json-member-scanner.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 73 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 74 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 75 | `src/adapters/realtime/polling/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 76 | `src/adapters/realtime/reconnect-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 77 | `src/adapters/realtime/reconnect-policy.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 78 | `src/adapters/realtime/result.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 79 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 80 | `src/adapters/realtime/sse/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 81 | `src/adapters/realtime/sse/sse-parser.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 82 | `src/adapters/realtime/stream-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 83 | `src/adapters/realtime/websocket/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 84 | `src/adapters/realtime/websocket/websocket-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 85 | `src/adapters/realtime/websocket/websocket-protocol.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 86 | `src/adapters/service-worker/service-worker-entry.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 87 | `src/adapters/service-worker/service-worker-lifecycle.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 88 | `src/adapters/service-worker/service-worker-page-controller.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 89 | `src/adapters/service-worker/service-worker-protocol.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 90 | `src/adapters/service-worker/service-worker-removal.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 91 | `src/adapters/service-worker/service-worker-static-assets.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 92 | `src/adapters/storage/browser-storage-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 93 | `src/adapters/storage/browser-storage-codec.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 94 | `src/adapters/storage/indexeddb/index.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 95 | `src/adapters/storage/indexeddb/indexeddb-failure.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 96 | `src/adapters/storage/indexeddb/indexeddb-governance.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 97 | `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 98 | `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 99 | `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 100 | `src/adapters/storage/indexeddb/indexeddb-types.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 101 | `src/adapters/storage/opfs/browser-opfs-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 102 | `src/adapters/storage/opfs/index.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 103 | `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 104 | `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 105 | `src/adapters/storage/opfs/opfs-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 106 | `src/adapters/storage/opfs/opfs-worker-client.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 107 | `src/adapters/storage/opfs/opfs-worker-protocol.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 108 | `src/adapters/storage/opfs/opfs-worker-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 109 | `src/adapters/telemetry/best-effort-telemetry.ts` | [Network/state](./01-network-and-state.md) | +| 110 | `src/adapters/web-push/inbound/notification-click-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 111 | `src/adapters/web-push/inbound/push-event-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 112 | `src/adapters/web-push/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 113 | `src/adapters/web-push/notification-registry.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 114 | `src/adapters/web-push/push-association-fence-store.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 115 | `src/adapters/web-push/push-codec.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 116 | `src/adapters/web-push/push-registration-gateway.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 117 | `src/adapters/web-push/push-subscription-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 118 | `src/adapters/web-push/runtime-support.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 119 | `src/adapters/web-push/service-worker-runtime.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 120 | `src/adapters/web-push/service-worker-scope-host.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -합계: **119/119**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다. +합계: **120/120**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다. diff --git a/package.json b/package.json index f0d742f..a4ee549 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "dev": "vite", "build": "node scripts/build-frontend.ts", + "build:profile": "node scripts/generate-runtime-config.ts", "build:release-candidate": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security && corepack pnpm verify:release && node scripts/verify-supply-chain-artifacts.ts && node scripts/create-release-candidate.ts", "preview": "vite preview", "lint": "eslint src scripts tests recipes .storybook vite.config.ts vitest.config.ts playwright*.config.ts --max-warnings=0", @@ -21,6 +22,7 @@ "check:i18n:fixture": "node scripts/check-i18n.ts --fixture", "check:adapter-inventory": "node scripts/check-adapter-inventory.ts", "check:remediation-ledger": "node scripts/check-remediation-ledger.ts", + "check:release-admission": "node scripts/check-release-admission.ts", "check:diagnostics": "node scripts/check-diagnostics.ts", "check:diagnostics:fixture": "node scripts/check-diagnostics.ts --fixture", "check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test && corepack pnpm check:types:recipes && corepack pnpm check:types:web-worker && corepack pnpm check:types:service-worker", diff --git a/public/config.json b/public/config.json index e2e876d..e8466b6 100644 --- a/public/config.json +++ b/public/config.json @@ -14,5 +14,8 @@ "WEB_WORKER": "DEFAULT", "SERVICE_WORKER": "DEFAULT", "OFFLINE_COMMANDS": "DEFAULT" + }, + "FEATURE_OVERRIDES": { + "reference-feature": "DEFAULT" } } diff --git a/scripts/build-frontend.ts b/scripts/build-frontend.ts index 26657cc..d3f670e 100644 --- a/scripts/build-frontend.ts +++ b/scripts/build-frontend.ts @@ -13,12 +13,19 @@ import { INSTALLED_RUNTIME_CAPABILITIES } from "../src/features/installed-runtim * 1. clean dist and .generated/frontend-runtime * 2. generate contractSet and build-info source * 3. Vite app build (emptyOutDir = true) - * 4. scan app dist and generate the static asset source - * 5. ACTIVE only: Vite Service Worker build (emptyOutDir = false) - * 6. generate Release Manifest V2 and the build manifest + * 4. materialize dist/config.json from the declared APP_PROFILE + * 5. scan app dist and generate the static asset source + * 6. ACTIVE only: Vite Service Worker build (emptyOutDir = false) + * 7. generate Release Manifest V2 and the build manifest * - * Steps 4 and 5 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES` + * Steps 5 and 6 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES` * and `null`: those modes never run an active worker build. + * + * Step 4 has to follow the Vite build and precede the asset scan. Vite copies + * `public/` verbatim, so without it every build — including a production one — + * ships the local runtime document; and the Service Worker hashes the emitted + * `config.json`, so the profile must be in place before that inventory is + * taken. */ const selection = INSTALLED_RUNTIME_CAPABILITIES.serviceWorker; @@ -45,10 +52,13 @@ run("node", ["scripts/generate-contract-set.ts"]); // 3. app build run("npx", ["vite", "build"]); +// 4. runtime config for the declared profile +run("node", ["scripts/generate-runtime-config.ts"]); + if (buildsActiveWorker) { - // 4. hashed asset inventory + // 5. hashed asset inventory run("node", ["scripts/generate-service-worker-assets.ts", "dist"]); - // 5. service worker build + // 6. service worker build run("npx", ["vite", "build", "--config", "vite.service-worker.config.ts"]); } else { process.stdout.write( diff --git a/scripts/check-architecture.ts b/scripts/check-architecture.ts index b246e0b..6b2957a 100644 --- a/scripts/check-architecture.ts +++ b/scripts/check-architecture.ts @@ -723,9 +723,13 @@ function findArchitectureViolations( continue; } for (const dependency of dependencies) { + const sourceGroups = rule.from?.path + ? (new RegExp(rule.from.path, "u").exec(dependency.source)?.slice(1) ?? + []) + : []; if ( matchesPath(dependency.source, rule.from) && - matchesPath(dependency.target, rule.to) + matchesPath(dependency.target, rule.to, sourceGroups) ) { violations.push({ rule: rule.name, @@ -746,16 +750,52 @@ function findArchitectureViolations( function matchesPath( modulePath: string, criterion: PathRule | undefined, + sourceGroups: readonly string[] = [], ): boolean { if (!criterion) return true; - if (criterion.path && !new RegExp(criterion.path, "u").test(modulePath)) { + if ( + criterion.path && + !new RegExp(expandSourceGroups(criterion.path, sourceGroups), "u").test( + modulePath, + ) + ) { return false; } return !( - criterion.pathNot && new RegExp(criterion.pathNot, "u").test(modulePath) + criterion.pathNot && + new RegExp(expandSourceGroups(criterion.pathNot, sourceGroups), "u").test( + modulePath, + ) ); } +/** + * Substitutes `$1`..`$9` in a `to` pattern with the capture groups the `from` + * pattern matched on the importing module. + * + * Without it, "an adapter may not import a *different* adapter" cannot be + * written as one rule: the target pattern has to name the importer's own + * directory to exempt it. The alternative is one rule per adapter group, which + * silently stops covering a group the moment somebody adds one — exactly the + * gap that let `diagnostics` import `telemetry` while the documented rule said + * it could not. + */ +function expandSourceGroups( + pattern: string, + sourceGroups: readonly string[], +): string { + return pattern.replaceAll(/\$([1-9])/gu, (whole, index: string) => { + const captured = sourceGroups[Number(index) - 1]; + // A `from` pattern that did not capture leaves the token literal rather + // than quietly matching everything. + return captured === undefined ? whole : escapeRegExp(captured); + }); +} + +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); +} + function validateArchitectureRules(rules: readonly ArchitectureRule[]): void { if (!rules.some((rule) => rule.to?.circular === true)) { throw new Error("Architecture configuration must contain a circular rule"); diff --git a/scripts/check-ci-contract.ts b/scripts/check-ci-contract.ts index 4e0b338..2376827 100644 --- a/scripts/check-ci-contract.ts +++ b/scripts/check-ci-contract.ts @@ -77,7 +77,7 @@ if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.pat } const expectedGateIds = Array.from( - { length: 26 }, + { length: 27 }, (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, ); const passingResults: Record = Object.fromEntries( @@ -139,4 +139,4 @@ if (failures.length > 0) { process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`); process.exit(1); } -process.stdout.write("CI contract: 26 gates, strict v2 graph and generated workflow model PASS\n"); +process.stdout.write("CI contract: 27 gates, strict v2 graph and generated workflow model PASS\n"); diff --git a/scripts/check-release-admission.ts b/scripts/check-release-admission.ts new file mode 100644 index 0000000..ff85207 --- /dev/null +++ b/scripts/check-release-admission.ts @@ -0,0 +1,108 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import process from "node:process"; + +import { + DEPLOYMENT_TARGETS, + findAdmissionViolations, + isDeploymentTarget, + type AdmissionInput, +} from "../src/contracts/deployment-admission.ts"; +import { parseRuntimeConfigArtifact } from "../src/contracts/release-artifacts.ts"; + +/** + * §6.4 / FE-GATE-027. Refuses to admit an artifact to an environment it was not + * built for. + * + * Release coherence already proves the artifacts agree with each other. It + * cannot prove they belong in production, because a local build is coherent + * with itself: `APP_ENV: local`, `AUTH_MODE: demo` and a loopback API pass + * every existing gate. This gate closes that by making the destination an + * explicit, declared input and refusing anything that does not match it. + * + * It fails closed in both directions. An undeclared destination is a refusal, + * not a default, so an artifact can never be admitted by omission; and every + * rule is stated as a reason to refuse, so an unreadable field cannot pass. + */ + +const RUNTIME_CONFIG_PATH = "dist/config.json"; +const RECORD_PATH = "artifacts/release/deployment-admission.json"; + +async function main(): Promise { + const declared = process.env["RELEASE_TARGET"]; + if (!isDeploymentTarget(declared)) { + process.stderr.write( + "release admission refused: RELEASE_TARGET must be declared as one of " + + `${DEPLOYMENT_TARGETS.join(", ")}; received ${ + declared === undefined ? "nothing" : declared + }.\n` + + "An artifact is never admitted by default — name the environment it is for.\n", + ); + process.exitCode = 1; + return; + } + + let document: unknown; + try { + document = JSON.parse(await readFile(RUNTIME_CONFIG_PATH, "utf8")); + } catch (error) { + process.stderr.write( + `release admission refused: ${RUNTIME_CONFIG_PATH} is unreadable: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + process.exitCode = 1; + return; + } + + let config: AdmissionInput; + try { + config = parseRuntimeConfigArtifact(document) as AdmissionInput; + } catch (error) { + process.stderr.write( + `release admission refused: ${RUNTIME_CONFIG_PATH} is not a valid runtime config: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + process.exitCode = 1; + return; + } + + const violations = findAdmissionViolations(declared, config); + await mkdir("artifacts/release", { recursive: true }); + await writeFile( + RECORD_PATH, + `${JSON.stringify( + { + schemaVersion: 1, + target: declared, + appEnv: config.APP_ENV, + authMode: config.AUTH_MODE, + apiBaseUrl: config.API_BASE_URL, + buildId: config.BUILD_ID ?? null, + releaseId: config.RELEASE_ID ?? null, + status: violations.length === 0 ? "ADMITTED" : "REFUSED", + violations, + }, + null, + 2, + )}\n`, + "utf8", + ); + + if (violations.length > 0) { + process.stderr.write( + `release admission refused for ${declared}:\n${violations + .map((violation) => ` ${violation.field}: ${violation.reason}`) + .join("\n")}\n`, + ); + process.exitCode = 1; + return; + } + process.stdout.write( + `release admission: ${declared} ADMITTED ` + + `(APP_ENV=${config.APP_ENV}, AUTH_MODE=${config.AUTH_MODE}, ` + + `API=${config.API_BASE_URL}); record at ${RECORD_PATH}\n`, + ); +} + +await main(); diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts index dff8946..adb7581 100644 --- a/scripts/contracts/ci-gates.ts +++ b/scripts/contracts/ci-gates.ts @@ -247,6 +247,7 @@ const artifactSchemaSchema = z.discriminatedUnion("kind", [ "provider-provenance", "provider-verification", "ci-contract-report", + "deployment-admission", ]), }) .strict(), @@ -442,7 +443,7 @@ export type LoadCiGateContractOptions = Readonly<{ }>; const CANONICAL_GATE_SHAPE_SHA256 = - "a4a963d0b9deffb7a0a3d755bbbcb979d72610eb74751c3a2e5eca55251e12d4"; + "4617ada21cbdeb217d118146bd572860d7c58ad222142a52d41916b26577239a"; function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { const normalized = gates.map( @@ -473,16 +474,16 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[] (total, gate) => total + gate.commandIds.length, 0, ); - if (contract.gates.length !== 26) { - failures.push(`gate authority baseline must contain exactly 26 gates; received ${contract.gates.length}`); + if (contract.gates.length !== 27) { + failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`); } - if (contract.commands.length !== 81 || commandReferenceCount !== 93) { + if (contract.commands.length !== 82 || commandReferenceCount !== 94) { failures.push( - `command authority baseline must contain exactly 81 definitions and 93 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`, + `command authority baseline must contain exactly 82 definitions and 94 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`, ); } - if (contract.artifacts.length !== 105) { - failures.push(`artifact authority baseline must contain exactly 105 artifacts; received ${contract.artifacts.length}`); + if (contract.artifacts.length !== 107) { + failures.push(`artifact authority baseline must contain exactly 107 artifacts; received ${contract.artifacts.length}`); } if (contract.stages.length !== 5) { failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`); @@ -511,7 +512,7 @@ export function parseCiGateContract( .join("\n"); throw new TypeError(`CI gate contract invalid:\n${diagnostic}`); } - if ((options.mode ?? "canonical") === "canonical") { + if ((options.mode ?? defaultCiContractMode()) === "canonical") { const failures = canonicalAuthorityBaselineFailures(result.data); if (failures.length > 0) { throw new TypeError(`CI gate contract invalid:\n${failures.map((failure) => `root: ${failure}`).join("\n")}`); @@ -520,11 +521,29 @@ export function parseCiGateContract( return result.data; } +/** + * A removal fixture runs the whole suite against a deliberately *reduced* CI + * contract: the removed capability's gates, commands and artifacts are pruned. + * Loading that contract in canonical mode re-imposes the full exact-count + * authority on it, so the fixture failed on the very reduction it exists to + * prove. `runRemovalFixturePnpm` marks those runs, and this is where the mark + * is honoured. + */ +export function defaultCiContractMode(): "canonical" | "removal-fixture" { + return process.env.CI_CONTRACT_MODE === "removal-fixture" + ? "removal-fixture" + : "canonical"; +} + +export function isReducedCiContractRun(): boolean { + return defaultCiContractMode() === "removal-fixture"; +} + export async function loadCiGateContract( root = process.cwd(), options: LoadCiGateContractOptions = {}, ): Promise { - const mode = options.mode ?? "canonical"; + const mode = options.mode ?? defaultCiContractMode(); const [rawContract, rawPackage] = await Promise.all([ readFile(path.join(root, "config/ci/gates.json"), "utf8"), readFile(path.join(root, "package.json"), "utf8"), @@ -759,11 +778,11 @@ function validateContractSemantics( } const expectedGateIds = Array.from( - { length: 26 }, + { length: 27 }, (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, ); if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) { - issue("gate registry must contain FE-GATE-001..026 in canonical order"); + issue("gate registry must contain FE-GATE-001..027 in canonical order"); } const expectedStages: ReadonlyArray = [ ["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY], @@ -834,7 +853,7 @@ function validateContractSemantics( const expectedJobOwnership: Readonly> = { merge_gate: ["FE-GATE-001", "FE-GATE-002", "FE-GATE-003", "FE-GATE-004", "FE-GATE-005", "FE-GATE-006", "FE-GATE-007", "FE-GATE-008", "FE-GATE-009", "FE-GATE-010", "FE-GATE-011", "FE-GATE-013", "FE-GATE-020"], release_gate: ["FE-GATE-012", "FE-GATE-014", "FE-GATE-019", "FE-GATE-026"], - immutable_build: ["FE-GATE-015"], + immutable_build: ["FE-GATE-015", "FE-GATE-027"], vulnerability_provider: [], provenance_provider: [], promotion: [], @@ -888,7 +907,13 @@ function validateContractSemantics( const expectedEnvironmentBindings: Readonly []>> = { merge_gate: [], release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }], - immutable_build: [], + immutable_build: [ + // FE-GATE-027 admits the built artifact to a named environment, so both + // the profile it was built from and the destination it is claimed for are + // declared inputs. An absent RELEASE_TARGET is a refusal, not a default. + { name: "APP_PROFILE", value: "${{ vars.APP_PROFILE }}" }, + { name: "RELEASE_TARGET", value: "${{ vars.RELEASE_TARGET }}" }, + ], vulnerability_provider: [ { name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" }, { name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, diff --git a/scripts/contracts/release-artifacts.ts b/scripts/contracts/release-artifacts.ts index 24e40cc..f459f99 100644 --- a/scripts/contracts/release-artifacts.ts +++ b/scripts/contracts/release-artifacts.ts @@ -670,6 +670,27 @@ export const labPerformanceArtifactSchema = z }) .strict(); +/** + * FE-GATE-027. The record of which environment an artifact was admitted to, and + * every reason it was refused. Refusals are kept in the artifact so a rejected + * promotion leaves evidence rather than only a non-zero exit code. + */ +export const deploymentAdmissionArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + target: z.enum(["local", "development", "staging", "production"]), + appEnv: z.enum(["local", "development", "staging", "production"]), + authMode: z.enum(["external", "demo"]), + apiBaseUrl: nonEmptyString, + buildId: nonEmptyString.nullable(), + releaseId: nonEmptyString.nullable(), + status: z.enum(["ADMITTED", "REFUSED"]), + violations: z.array( + z.object({ field: nonEmptyString, reason: nonEmptyString }).strict(), + ), + }) + .strict(); + export const releaseVerificationArtifactSchema = z .object({ schemaVersion: z.literal(1), @@ -1321,9 +1342,28 @@ export const documentationReviewArtifactSchema = z reviewer: z.literal("wiki-diagram-reviewer"), standard: z.literal("rules/diagram-standards.md v2"), evidenceReport: z - .object({ repoPath: nonEmptyString, canonicalPath: nonEmptyString, canonicalSha256: sha256 }) + .object({ + repoPath: nonEmptyString, + upstreamCanonicalPath: nonEmptyString, + canonicalSha256: sha256, + }) .strict(), reportDigestValid: z.boolean(), + /** + * The declared review scope, derived from the installed route registry + * rather than read off a sentence. Both scope documents claimed six routes + * while ten were registered. + */ + routeScope: z.array( + z + .object({ + path: nonEmptyString, + missingRouteIds: z.array(nonEmptyString), + documented: z.boolean(), + }) + .strict(), + ).min(1), + routeScopeDocumented: z.boolean(), results: z.array( z .object({ @@ -1350,8 +1390,21 @@ export const documentationReviewArtifactSchema = z context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" }); } }); - if (artifact.passed !== (artifact.reportDigestValid && artifact.results.every(({ passed }) => passed))) { - context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest and review results" }); + artifact.routeScope.forEach((entry, index) => { + if (entry.documented !== (entry.missingRouteIds.length === 0)) { + context.addIssue({ code: "custom", path: ["routeScope", index, "documented"], message: "must agree with the missing route list" }); + } + }); + if (artifact.routeScopeDocumented !== artifact.routeScope.every(({ documented }) => documented)) { + context.addIssue({ code: "custom", path: ["routeScopeDocumented"], message: "must agree with every scope document" }); + } + if ( + artifact.passed !== + (artifact.reportDigestValid && + artifact.routeScopeDocumented && + artifact.results.every(({ passed }) => passed)) + ) { + context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest, documented scope and review results" }); } }); diff --git a/scripts/generate-runtime-config.ts b/scripts/generate-runtime-config.ts new file mode 100644 index 0000000..9ca4ed2 --- /dev/null +++ b/scripts/generate-runtime-config.ts @@ -0,0 +1,93 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +import { + DEPLOYMENT_TARGETS, + isDeploymentTarget, + type DeploymentTarget, +} from "../src/contracts/deployment-admission.ts"; +import { runtimeConfigV2ArtifactSchema } from "../src/contracts/release-artifacts.ts"; + +/** + * §6.4. Materializes `dist/config.json` from the profile the build declares. + * + * `public/` is copied verbatim into `dist/`, so before this step the runtime + * document that shipped with every build was the local one — `APP_ENV: local`, + * `AUTH_MODE: demo`, a loopback API — regardless of what the build was for. + * The profile is the source of truth instead, and the only values a deployment + * may inject are the ones it actually owns: its endpoints and its identity. + * + * The result is validated against the same V2 schema the browser will apply, so + * an override cannot produce a document that only fails at boot. + */ + +const PROFILE_DIRECTORY = "config/runtime"; +const OUTPUT_PATH = "dist/config.json"; + +/** + * Deployment-supplied values. Everything else is fixed by the profile so a + * deployment cannot quietly widen what was reviewed. + */ +const OVERRIDES = Object.freeze({ + API_BASE_URL: "RUNTIME_API_BASE_URL", + TELEMETRY_ENDPOINT: "RUNTIME_TELEMETRY_ENDPOINT", +} as const); + +export async function generateRuntimeConfig( + target: DeploymentTarget, + environment: NodeJS.ProcessEnv = process.env, +): Promise> { + const profilePath = path.join(PROFILE_DIRECTORY, `${target}.json`); + const source: unknown = JSON.parse(await readFile(profilePath, "utf8")); + if (source === null || typeof source !== "object" || Array.isArray(source)) { + throw new TypeError(`${profilePath}: runtime profile must be an object`); + } + const draft: Record = { ...(source as Record) }; + if (draft["APP_ENV"] !== target) { + throw new Error( + `${profilePath}: declares APP_ENV ${String(draft["APP_ENV"])}, expected ${target}`, + ); + } + for (const [field, variable] of Object.entries(OVERRIDES)) { + const supplied = environment[variable]; + if (supplied !== undefined && supplied !== "") draft[field] = supplied; + } + const buildId = environment["VITE_BUILD_ID"] ?? "local-build"; + const releaseId = environment["RELEASE_ID"] ?? "local-release"; + draft["BUILD_ID"] = buildId; + draft["RELEASE_ID"] = releaseId; + + const parsed = runtimeConfigV2ArtifactSchema.safeParse(draft); + if (!parsed.success) { + const issues = parsed.error.issues + .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) + .join("\n "); + throw new Error(`${profilePath}: runtime config is invalid\n ${issues}`); + } + return draft; +} + +function resolveTarget(environment: NodeJS.ProcessEnv): DeploymentTarget { + const declared = environment["APP_PROFILE"] ?? "local"; + if (!isDeploymentTarget(declared)) { + throw new Error( + `APP_PROFILE must be one of ${DEPLOYMENT_TARGETS.join(", ")}; received ${declared}`, + ); + } + return declared; +} + +async function main(): Promise { + const target = resolveTarget(process.env); + const config = await generateRuntimeConfig(target); + await writeFile(OUTPUT_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + process.stdout.write( + `runtime config: ${target} profile written to ${OUTPUT_PATH} ` + + `(APP_ENV=${String(config["APP_ENV"])}, AUTH_MODE=${String(config["AUTH_MODE"])})\n`, + ); +} + +if (process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]))) { + await main(); +} diff --git a/scripts/lib/ci-artifact-validator.ts b/scripts/lib/ci-artifact-validator.ts index 9693bdf..2c30946 100644 --- a/scripts/lib/ci-artifact-validator.ts +++ b/scripts/lib/ci-artifact-validator.ts @@ -35,6 +35,7 @@ import { registryGovernanceRunArtifactSchema, registryCompatibilityFixturesArtifactSchema, registrySnapshotArtifactSchema, + deploymentAdmissionArtifactSchema, releaseVerificationArtifactSchema, reproducibleBuildArtifactSchema, runbookRecordArtifactSchema, @@ -238,6 +239,7 @@ const executableJsonSchemas: Readonly> = "provider-provenance": provenanceProviderAttestationSchema, "provider-verification": providerVerificationArtifactSchema, "ci-contract-report": ciContractReportSchema, + "deployment-admission": deploymentAdmissionArtifactSchema, }); export function hasCiArtifactSemanticValidator( diff --git a/scripts/lib/ci-candidate-archive.ts b/scripts/lib/ci-candidate-archive.ts index db5cfcd..741904c 100644 --- a/scripts/lib/ci-candidate-archive.ts +++ b/scripts/lib/ci-candidate-archive.ts @@ -5,7 +5,6 @@ import type { FileHandle } from "node:fs/promises"; import { lstat, mkdir, - mkdtemp, open, readFile, readdir, @@ -28,6 +27,10 @@ import { assertSafePublishLeaf, ensureSafePublishDirectory, } from "./ci-gate-log.ts"; +import { + makePrivateTemporaryDirectory, + withPrivateUmask, +} from "./private-filesystem.ts"; const MAX_ARCHIVE_BYTES = 268_435_456; const MAX_CANDIDATE_FILES = 4_096; @@ -147,11 +150,11 @@ export async function verifyCiCandidateArchive( path.dirname(extractionTarget), ); await assertSafePublishLeaf(extractionTarget, input.extractTo); - extractionRoot = await mkdtemp( + extractionRoot = makePrivateTemporaryDirectory( path.join(path.dirname(extractionTarget), `.${path.basename(extractionTarget)}.verified-`), ); } else { - extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-candidate-archive-")); + extractionRoot = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-candidate-archive-")); } let published = false; try { @@ -221,7 +224,7 @@ export async function verifyCapturedCiCandidateArchive( throw new Error("candidate archive SHA-256 mismatch"); } const captured = await materializeCapturedArchive(archive); - const extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-captured-candidate-")); + const extractionRoot = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-captured-candidate-")); try { const manifest = preflightArchiveHandle(captured.handle); extractArchiveHandle(captured.handle, extractionRoot); @@ -318,25 +321,33 @@ function preflightArchiveHandle(archiveHandle: FileHandle): ReleaseCandidateMani } function extractArchiveHandle(archiveHandle: FileHandle, extractionRoot: string): void { - const extracted = spawnSync( - TAR_EXECUTABLE, - [ - "--extract", - "--gzip", - "--file", - "/proc/self/fd/3", - "--directory", - extractionRoot, - "--no-same-owner", - "--no-same-permissions", - ], - { - encoding: "utf8", - maxBuffer: 1_048_576, - timeout: 30_000, - env: TAR_ENVIRONMENT, - stdio: ["ignore", "pipe", "pipe", archiveHandle.fd], - }, + // `--no-same-permissions` is what keeps an untrusted archive from choosing + // its own modes, but it hands the decision to the inherited umask instead. + // Under a hardened `umask 077x` tar then creates directories it cannot + // descend into and extraction fails part-way. Pinning the umask for the + // duration makes the extracted tree exactly private, whatever the caller's + // ambient state is. `spawnSync` keeps this window free of interleaved work. + const extracted = withPrivateUmask(() => + spawnSync( + TAR_EXECUTABLE, + [ + "--extract", + "--gzip", + "--file", + "/proc/self/fd/3", + "--directory", + extractionRoot, + "--no-same-owner", + "--no-same-permissions", + ], + { + encoding: "utf8", + maxBuffer: 1_048_576, + timeout: 30_000, + env: TAR_ENVIRONMENT, + stdio: ["ignore", "pipe", "pipe", archiveHandle.fd], + }, + ), ); if (extracted.status !== 0 || extracted.signal || extracted.error) { throw new Error( @@ -592,7 +603,7 @@ function readManifestFromArchive(archiveHandle: FileHandle): ReleaseCandidateMan async function materializeCapturedArchive( archive: Buffer, ): Promise> { - const root = await mkdtemp(path.join(tmpdir(), "ci-captured-archive-")); + const root = makePrivateTemporaryDirectory(path.join(tmpdir(), "ci-captured-archive-")); const file = path.join(root, "candidate.tar.gz"); let handle: FileHandle | undefined; try { @@ -601,6 +612,11 @@ async function materializeCapturedArchive( constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600, ); + // `open` subtracts the umask too. The extractor re-opens this file by + // `/proc/self/fd/N` from a child process, and that re-open is a real + // permission check, so a umask-zeroed mode makes `tar` fail to read the + // candidate it was just handed. + await handle.chmod(0o600); await handle.writeFile(archive); await handle.sync(); await unlink(file); diff --git a/scripts/lib/ci-contract-report.ts b/scripts/lib/ci-contract-report.ts index 74490a9..3aaf1d6 100644 --- a/scripts/lib/ci-contract-report.ts +++ b/scripts/lib/ci-contract-report.ts @@ -4,7 +4,7 @@ export const ciContractReportSchema = z .object({ schemaVersion: z.literal(2), nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/u), - gateCount: z.literal(26), + gateCount: z.literal(27), commandDefinitionCount: z.number().int().positive(), commandReferenceCount: z.number().int().positive(), artifactCount: z.number().int().positive(), diff --git a/scripts/lib/private-filesystem.ts b/scripts/lib/private-filesystem.ts new file mode 100644 index 0000000..edc45a4 --- /dev/null +++ b/scripts/lib/private-filesystem.ts @@ -0,0 +1,36 @@ +import { mkdirSync, mkdtempSync } from "node:fs"; + +/** + * Creation modes that must not depend on the caller's ambient umask. + * + * `mkdir(path, { mode: 0o700 })` and `open(path, ..., 0o600)` are requests, not + * guarantees: the kernel subtracts the process umask from every one of them. A + * runner hardened with `umask 0777` therefore produces directories nobody can + * enter and files nobody can read, and the failure surfaces far from its cause + * — as `tar` failing to mkdir a nested path, or as EACCES opening a staging + * leaf this process created moments earlier. + * + * Release evidence has to be exactly private, so the mode is pinned rather than + * inherited. The pin is held across a synchronous call only: nothing else in + * this process can interleave, so the global umask is never observably changed. + */ +const PRIVATE_UMASK = 0o077; + +export function withPrivateUmask(operation: () => T): T { + const previous = process.umask(PRIVATE_UMASK); + try { + return operation(); + } finally { + process.umask(previous); + } +} + +/** Creates a uniquely named private directory under `prefix`. */ +export function makePrivateTemporaryDirectory(prefix: string): string { + return withPrivateUmask(() => mkdtempSync(prefix)); +} + +/** Creates `target` privately, failing if it already exists. */ +export function makePrivateDirectory(target: string): void { + withPrivateUmask(() => mkdirSync(target, { mode: 0o700 })); +} diff --git a/scripts/lib/promotion-stager.ts b/scripts/lib/promotion-stager.ts index 67fcebe..8571683 100644 --- a/scripts/lib/promotion-stager.ts +++ b/scripts/lib/promotion-stager.ts @@ -6,7 +6,6 @@ import { import { constants } from "node:fs"; import { lstat, - mkdir, open, readdir, rm, @@ -38,6 +37,7 @@ import { } from "./ci-candidate-archive.ts"; import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; +import { makePrivateDirectory } from "./private-filesystem.ts"; export type StagedFile = Readonly<{ @@ -334,6 +334,18 @@ export async function cleanupFinalizedPromotion(input: Readonly<{ await dependencies.beforeRemove?.(); const visibleParent = await lstat(parent); assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity); + // Re-bind the name to the inode before removing anything. + // + // The removals below run through the pinned staging descriptor, so they + // always reach the owned inode even after the name has been re-pointed + // somewhere else. That is safe for the substitute, but it destroys this + // promotion's exact five first and only reports the substitution + // afterwards — a caller that retries then finds a half-emptied staging + // directory and no way to tell a completed cleanup from an interrupted + // one. Detecting the swap here makes the failure total: nothing is + // removed unless the leaf still is what was validated. + assertStagingIdentity(await lstat(descriptorExpected), input.stagingIdentity); + assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity); for (const name of PROMOTED_FILE_NAMES) { await rm(path.join(stagingDescriptorRoot, name), { force: false }); } @@ -456,7 +468,7 @@ export async function publishPrivatePromotionStaging( try { const procMetadata = await stat(descriptorRoot); if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable"); - await mkdir(descriptorStaging, { mode: 0o700 }); + makePrivateDirectory(descriptorStaging); ownsStaging = true; const createdStaging = await lstat(descriptorStaging); if (!createdStaging.isDirectory() || createdStaging.isSymbolicLink()) { diff --git a/scripts/lib/provider-cgroup.ts b/scripts/lib/provider-cgroup.ts index 3e94e1e..74ae58d 100644 --- a/scripts/lib/provider-cgroup.ts +++ b/scripts/lib/provider-cgroup.ts @@ -112,6 +112,21 @@ export function systemdRunProviderArguments( export type ProviderScopeFrame = Readonly<{ bwrapInput: Buffer; + /** + * The sandboxed command, kept out of the args file on purpose. + * + * `bwrap --args FD` splices the file's options into the option stream, but + * bubblewrap stops at the first non-option and never propagates the command + * back out of the recursive parse. A command written into the args file is + * therefore silently dropped and bubblewrap exits with its usage text, so + * the sandbox is never entered and the provider produces no evidence at all. + * Only the options may be hidden; the command travels on real argv. + * + * Nothing secret lives here: credentials and the provider command reach the + * sandbox through `--setenv` inside the args file, and this vector only ever + * names `prlimit` and a shell that expands `$PROVIDER_COMMAND`. + */ + bwrapCommand: readonly string[]; reportPath: string; reportDev: number; reportIno: number; @@ -126,8 +141,10 @@ export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer { ) { throw new TypeError("provider scope frame is invalid"); } + assertBwrapCommand(input.bwrapCommand); const payload = Buffer.from(JSON.stringify({ bwrapInputBase64: input.bwrapInput.toString("base64"), + bwrapCommand: [...input.bwrapCommand], reportPath: input.reportPath, reportDev: input.reportDev, reportIno: input.reportIno, @@ -138,13 +155,36 @@ export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer { return frame; } +/** + * The command vector bubblewrap will exec. It has to be an absolute executable + * so the sandbox never resolves it through a `PATH` the caller controls. + */ +export function assertBwrapCommand(command: readonly string[]): void { + if ( + !Array.isArray(command) || command.length === 0 || + typeof command[0] !== "string" || !command[0].startsWith("/") || + command.some((argument) => + typeof argument !== "string" || argument.includes("\0"), + ) + ) { + throw new TypeError("provider bwrap command is invalid"); + } +} + export function encodeProviderBwrapInput( - arguments_: readonly string[], + optionArguments: readonly string[], environment: Readonly>, ): Buffer { - if (arguments_.some((argument) => argument.includes("\0"))) { + if (optionArguments.some((argument) => argument.includes("\0"))) { throw new TypeError("provider bwrap argument is invalid"); } + // A bare `--` ends bubblewrap's option stream. Inside an args file that also + // ends the recursive parse, so everything after it is discarded rather than + // executed. Refusing it here keeps the drop from being reintroduced by a + // caller that appends a command to the option list. + if (optionArguments.includes("--")) { + throw new TypeError("provider bwrap options may not terminate the option stream"); + } const entries = Object.entries(environment).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0, ); @@ -155,7 +195,7 @@ export function encodeProviderBwrapInput( } const input = ["--clearenv"]; for (const [name, value] of entries) input.push("--setenv", name, value ?? ""); - input.push(...arguments_); + input.push(...optionArguments); return Buffer.from(`${input.join("\0")}\0`); } diff --git a/scripts/lib/provider-scope-wrapper.ts b/scripts/lib/provider-scope-wrapper.ts index ebe2c16..018be10 100644 --- a/scripts/lib/provider-scope-wrapper.ts +++ b/scripts/lib/provider-scope-wrapper.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; -import { closeSync, createReadStream, writeSync } from "node:fs"; +import { closeSync, writeSync } from "node:fs"; +import { Socket } from "node:net"; import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts"; @@ -10,7 +11,18 @@ let expectedBytes: number | undefined; let provider: ReturnType | undefined; let providerClosed = false; let livenessLost = false; -const liveness = createReadStream("", { fd: 0, autoClose: false }); +/** + * The supervisor keeps this pipe open for the scope's whole life — that is how + * parent loss is observed — and only ever writes one frame into it. + * + * It must be read through libuv's event loop, not through `fs`. An `fs` read + * runs a blocking `read(2)` on a threadpool thread, and on a pipe with a live + * writer that call never returns. Closing the descriptor does not interrupt it, + * so once bubblewrap exits the wrapper deadlocks in `process.exit` waiting to + * join that thread: the scope outlives the provider, the supervisor's wall + * clock expires, and a completed provider is reported as a timeout kill. + */ +const liveness = openLivenessChannel(); liveness.on("data", (chunk: Buffer | string) => { if (provider) { @@ -46,10 +58,17 @@ function launchProvider(payload: Buffer): void { throw new TypeError("provider scope frame identity does not match its launch identity"); } const bwrapInput = Buffer.from(frame.bwrapInputBase64, "base64"); - provider = spawn("/usr/bin/bwrap", ["--args", "0"], { + // The options are read from fd 0; the command must stay on real argv because + // bubblewrap discards whatever follows the option stream inside an args file. + provider = spawn("/usr/bin/bwrap", ["--args", "0", "--", ...frame.bwrapCommand], { detached: true, stdio: ["pipe", "inherit", "inherit"], }); + // bubblewrap can exit before the options are fully written — a usage error + // closes fd 0 immediately. Without this the EPIPE would surface as an + // unhandled stream error and the scope would be torn down as a crash rather + // than reported as the provider exit it is. + provider.stdin?.once("error", () => {}); provider.stdin?.end(bwrapInput); provider.once("error", (error) => finishProvider(frame, null, null, error)); provider.once("close", (code, signal) => finishProvider(frame, code, signal)); @@ -102,18 +121,34 @@ function terminateForProtocolFailure(message: string): void { terminateForParentLoss(); } +function openLivenessChannel(): Socket { + try { + return new Socket({ fd: 0, readable: true, writable: false }); + } catch (error) { + // Without an observable parent this process cannot be trusted to notice + // supervisor loss, and an unsupervised sandbox is worse than no run. + writeSync(2, `provider scope liveness channel is unavailable: ${ + error instanceof Error ? error.message : String(error) + }\n`); + process.exit(125); + } +} + function closeLivenessInput(): void { liveness.removeAllListeners(); liveness.destroy(); try { closeSync(0); } catch (error) { + // `Socket.destroy()` owns the descriptor and closes it itself, so a second + // close is expected rather than exceptional. if (!hasErrorCode(error, "EBADF")) throw error; } } function parseFrame(payload: Buffer): Readonly<{ bwrapInputBase64: string; + bwrapCommand: readonly string[]; reportPath: string; reportDev: number; reportIno: number; @@ -127,14 +162,26 @@ function parseFrame(payload: Buffer): Readonly<{ ) { throw new TypeError("provider scope frame payload is invalid"); } + assertBwrapCommand(value.bwrapCommand); return { bwrapInputBase64: value.bwrapInputBase64, + bwrapCommand: Object.freeze([...value.bwrapCommand]), reportPath: value.reportPath, reportDev: Number(value.reportDev), reportIno: Number(value.reportIno), }; } +function assertBwrapCommand(value: unknown): asserts value is readonly string[] { + if ( + !Array.isArray(value) || value.length === 0 || + typeof value[0] !== "string" || !value[0].startsWith("/") || + value.some((argument) => typeof argument !== "string" || argument.includes("\0")) + ) { + throw new TypeError("provider scope frame command is invalid"); + } +} + function parseReportIdentity(arguments_: readonly string[]): Readonly<{ cpuSeconds: number; reportPath: string; diff --git a/scripts/lib/removal-fixture.ts b/scripts/lib/removal-fixture.ts index f78bdfb..0fc5c82 100644 --- a/scripts/lib/removal-fixture.ts +++ b/scripts/lib/removal-fixture.ts @@ -5,6 +5,7 @@ import { readFile, readdir, rm, + stat, writeFile, } from "node:fs/promises"; import path from "node:path"; @@ -24,9 +25,75 @@ export const REMOVAL_FIXTURE_COPY_TARGETS = Object.freeze([ "vite.service-worker.config.ts", "vite.config.ts", "vitest.config.ts", "playwright.config.ts", "playwright.capabilities.config.ts", "playwright.dev.config.ts", "playwright.storybook.config.ts", "playwright.visual.config.ts", "eslint.config.ts", - ".dependency-cruiser.json", ".nvmrc", + ".dependency-cruiser.json", ".nvmrc", ".gitignore", + // Install and workspace identity. Without these the fixture is not the same + // project: `corepack pnpm` resolves a different store, and the provider + // suites — which build a release candidate containing `pnpm-lock.yaml` — + // cannot assemble their fixture at all. + ".npmrc", "pnpm-lock.yaml", "pnpm-workspace.yaml", ] as const); +/** + * This is the only copy-target list. Each removal script used to keep its own, + * and they drifted: the reference-feature fixture omitted + * `playwright.capabilities.config.ts`, which the repository file inventory + * requires, so supply-chain generation failed inside the fixture and took every + * provider suite down with it — twenty-odd failures with one cause. + */ + +/** + * Regenerated result trees under `artifacts/`: traces, coverage HTML, recorded + * videos and Storybook bundles. They are tens of megabytes and mean nothing to + * a fixture. Everything else under `artifacts/` is release evidence a candidate + * is assembled from — and most of it is git-ignored too, so "is it tracked?" + * cannot be used to tell the two apart. `keepsReleaseEvidence` in + * tests/unit/removal-fixture.test.ts pins both halves of this split. + */ +const REGENERATED_ARTIFACT_TREES: readonly string[] = Object.freeze([ + "artifacts/storybook", + "artifacts/tests/browser-capabilities", + "artifacts/tests/coverage", + "artifacts/tests/e2e", + "artifacts/tests/storybook", + "artifacts/tests/visual", +]); + +/** + * Copies the release evidence a candidate build needs into a fixture root. + * + * A fixture that omits it cannot assemble a candidate archive at all, so every + * provider suite fails while constructing its own fixture — long before it + * reaches an assertion, and with an error that says nothing about the + * capability under test. + */ +export async function copyReleaseEvidenceTree( + sourceRoot: string, + destinationRoot: string, +): Promise { + const source = path.join(sourceRoot, "artifacts"); + try { + await stat(source); + } catch { + return; + } + await cp(source, path.join(destinationRoot, "artifacts"), { + recursive: true, + filter: (candidate) => { + const relative = path.relative(sourceRoot, candidate).split(path.sep).join("/"); + return !REGENERATED_ARTIFACT_TREES.some( + (tree) => relative === tree || relative.startsWith(`${tree}/`), + ); + }, + }); + // The result directories still have to exist: several are tracked through a + // `.gitkeep` the repository inventory expects to find. + for (const tree of REGENERATED_ARTIFACT_TREES) { + await mkdir(path.join(destinationRoot, tree), { recursive: true }); + } +} + +export const RELEASE_EVIDENCE_REGENERATED_TREES = REGENERATED_ARTIFACT_TREES; + export function requireRemovalFixtureEnvironment(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is required for removal verification`); @@ -42,9 +109,56 @@ export async function prepareRemovalFixture( for (const target of copyTargets) { await cp(target, path.join(root, target), { recursive: true }); } + await copyReleaseEvidenceTree(process.cwd(), root); + runFixtureGit(root, ["init", "--quiet", "--initial-branch=fixture"]); await linkFixtureNodeModules(root); } +/** + * Records the fixture's post-removal contents as its repository state. + * + * The release candidate path asks `git ls-files` what the repository contains — + * the supply-chain inventory is defined as the tracked file set, not as + * whatever happens to be on disk. A fixture without a repository cannot answer + * that, so supply-chain generation failed and took every provider suite down + * with it; the claim "this build still produces a release candidate after the + * capability is removed" was never actually being tested. + * + * It runs after the removal, not during preparation: an index recorded before + * the deletions still lists the removed files, and the inventory then demands + * files the fixture exists to prove are gone. + */ +export function sealRemovalFixtureRepository(root: string): void { + // `.gitignore` travels with the fixture, so the tracked set it records is the + // same tracked set the real repository has. Without it every generated + // artifact and every linked module landed in the index, and the supply-chain + // inventory refused the fixture for having tracked and generated paths + // collide — the fixture disagreed with the repository it was copied from. + runFixtureGit(root, ["add", "--all"]); + runFixtureGit(root, ["commit", "--quiet", "--no-gpg-sign", "-m", "removal fixture"]); +} + +function runFixtureGit(root: string, argv: readonly string[]): void { + const result = spawnSync("git", [...argv], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "removal-fixture", + GIT_AUTHOR_EMAIL: "removal-fixture@localhost", + GIT_COMMITTER_NAME: "removal-fixture", + GIT_COMMITTER_EMAIL: "removal-fixture@localhost", + }, + }); + if (result.error || result.status !== 0) { + throw new Error( + `removal fixture repository setup failed at git ${argv[0]}: ${ + result.stderr || result.error?.message || `exit ${result.status}` + }`, + ); + } +} + export function runRemovalFixturePnpm( root: string, pnpmCli: string, @@ -162,6 +276,45 @@ export function pruneScriptOrchestration( export async function regenerateRemovalFixtureWorkflow(root: string): Promise { const contract = await loadCiGateContract(root, { mode: "removal-fixture" }); await generateCiWorkflow({ root, contract, check: false }); + // Every removal script calls this once, after it has finished mutating the + // tree, so it is the one place where the fixture's contents are final. + await pruneRemovalFixtureInventoryRoots(root); + sealRemovalFixtureRepository(root); +} + +/** + * Drops repository roots the removal deleted from the supply-chain inventory + * policy. + * + * The policy lists `recipes` as a required tracked root, and removing an + * optional recipe deletes exactly that directory. Supply-chain generation then + * refused the fixture for missing a root the removal was supposed to remove, so + * the capability could never be shown to be removable. A root that is not on + * disk after the removal is not required of the result. + */ +async function pruneRemovalFixtureInventoryRoots(root: string): Promise { + const policyPath = path.join(root, "config/security/secret-scan-policy.json"); + let policy: Record; + try { + policy = JSON.parse(await readFile(policyPath, "utf8")) as Record; + } catch { + return; + } + const tracked = policy["trackedRoots"]; + if (!Array.isArray(tracked)) return; + const surviving: string[] = []; + for (const entry of tracked) { + if (typeof entry !== "string") continue; + try { + await stat(path.join(root, entry)); + surviving.push(entry); + } catch { + // Deleted by the removal under test. + } + } + if (surviving.length === tracked.length) return; + policy["trackedRoots"] = surviving; + await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`, "utf8"); } export async function pruneRemovalFixtureCiContract(options: Readonly<{ diff --git a/scripts/run-and-validate-provider.ts b/scripts/run-and-validate-provider.ts index c710f77..2e69b7c 100644 --- a/scripts/run-and-validate-provider.ts +++ b/scripts/run-and-validate-provider.ts @@ -281,14 +281,25 @@ async function runProviderInSandbox( "--remount-ro", "/", "--bind", reportAbsolute, reportAbsolute, "--chdir", workspaceRoot, - "--", "/usr/bin/prlimit", + ); + /** + * Everything above is a bubblewrap *option* and travels in the args file, so + * host paths never reach `/proc//cmdline`. The command below cannot: an + * args file's option stream ends at the first non-option and bubblewrap drops + * the remainder, so a command written there is never executed. It stays on + * real argv, and it is safe there because the provider command and its + * credentials are passed as `--setenv PROVIDER_COMMAND` inside the args file + * and only expanded by the innermost shell. + */ + const bwrapCommand = [ + "/usr/bin/prlimit", "--core=0:0", "--fsize=8388607:8388607", "--nofile=64:64", `--cpu=${cpuSeconds}:${cpuSeconds}`, "--", "/bin/sh", "-eu", "-c", 'exec /bin/sh -eu -c "$PROVIDER_COMMAND"', - ); + ]; const unitName = formatProviderCgroupUnitName( providerKind, process.pid, @@ -301,6 +312,7 @@ async function runProviderInSandbox( }); const scopeFrame = encodeProviderScopeFrame({ bwrapInput, + bwrapCommand, reportPath: reportAbsolute, reportDev: reportIdentity.dev, reportIno: reportIdentity.ino, @@ -361,7 +373,25 @@ async function waitForProvider( PROVIDER_MAX_OUTPUT_BYTES, () => terminate("output"), ); + /** + * Lines the sandbox tooling itself emits, kept so a launch failure can say + * why. Everything else the child writes is provider output and may carry + * credentials, so it is counted and discarded as before. + * + * Without this a sandbox that never started reported only `exit=1`, and the + * actual cause — `bwrap: loopback: Failed RTM_NEWADDR: Operation not + * permitted` on a host with `kernel.apparmor_restrict_unprivileged_userns=1` + * — was invisible. That turned a host restriction into an unexplained + * product failure. + */ + const SANDBOX_DIAGNOSTIC = /^(?:bwrap|prlimit|systemd-run|systemctl):\s.*$/gmu; + const sandboxDiagnostics: string[] = []; const capture = (chunk: Buffer | string): void => { + for (const line of String(chunk).matchAll(SANDBOX_DIAGNOSTIC)) { + if (sandboxDiagnostics.length < 8 && !sandboxDiagnostics.includes(line[0])) { + sandboxDiagnostics.push(line[0]); + } + } if (termination) return; outputLimiter.consume(chunk); }; @@ -415,7 +445,13 @@ async function waitForProvider( await collection; if (result.error) throw result.error; if (result.code !== 0 || result.signal !== null) { - throw new Error(`sandboxed external provider failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}`); + throw new Error( + `sandboxed external provider failed: exit=${result.code ?? "none"}, ` + + `signal=${result.signal ?? "none"}` + + (sandboxDiagnostics.length > 0 + ? `; sandbox reported: ${sandboxDiagnostics.join("; ")}` + : ""), + ); } if (inputError) throw inputError; } finally { diff --git a/scripts/test-optional-recipe-removal.ts b/scripts/test-optional-recipe-removal.ts index 3db120f..a64021b 100644 --- a/scripts/test-optional-recipe-removal.ts +++ b/scripts/test-optional-recipe-removal.ts @@ -18,43 +18,12 @@ import { const fixtureRoot = path.resolve(".tmp/optional-recipe-removal"); const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath"); -const copyTargets = [ - "src", - "tests", - "recipes", - "scripts", - "schemas", - "config", - "public", - ".gitea", - ".storybook", - "index.html", - "package.json", - "tsconfig.base.json", - "tsconfig.json", - "tsconfig.app.json", - "tsconfig.node.json", - "tsconfig.test.json", - "tsconfig.recipes.json", - "tsconfig.web-worker.json", - "tsconfig.service-worker.json", - "vite.service-worker.config.ts", - "vite.config.ts", - "vitest.config.ts", - "playwright.config.ts", - "playwright.dev.config.ts", - "playwright.storybook.config.ts", - "playwright.visual.config.ts", - "eslint.config.ts", - ".dependency-cruiser.json", - ".nvmrc", -]; function runPnpm(script: string): boolean { return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script); } -await prepareRemovalFixture(fixtureRoot, copyTargets); +await prepareRemovalFixture(fixtureRoot); for (const rootOnlyTest of [ "tests/unit/ci-workflow-generation.test.ts", "tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap", diff --git a/scripts/test-performance.ts b/scripts/test-performance.ts index eac262f..2eb3e7a 100644 --- a/scripts/test-performance.ts +++ b/scripts/test-performance.ts @@ -90,7 +90,13 @@ try { throw new Error("Performance route must be present in navigation."); } const interactionStarted = performance.now(); - await page.getByRole("link", { name: targetLabel }).click(); + // Playwright matches accessible names by substring, so the navigation entry + // "플랫폼 구성" also matched the home page's "플랫폼 구성 보기" call to + // action and the locator resolved to two links. That is a strict-mode + // violation before the first measurement is taken, so no lab performance + // evidence could be produced at all — the run failed for an ambiguous + // selector rather than for anything about performance. + await page.getByRole("link", { name: targetLabel, exact: true }).click(); await page.getByRole("heading", { name: target.title }).waitFor(); const namedInteractionMs = performance.now() - interactionStarted; const paint = await page.evaluate( diff --git a/scripts/test-sample-removal.ts b/scripts/test-sample-removal.ts index 682373c..95a14ef 100644 --- a/scripts/test-sample-removal.ts +++ b/scripts/test-sample-removal.ts @@ -27,10 +27,16 @@ const fixtureRoot = await mkdtemp( const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath"); const featureSource = "src/features/reference-feature"; const featureTests = "tests/features/reference-feature"; +/** + * Platform tests that must survive the sample feature's removal. Asserting they + * are still present is what stops the removal fixture from "passing" by having + * quietly deleted the platform's own coverage along with the feature. + */ const commonTestPaths = [ "tests/unit/external-contract-runtime.test.ts", "tests/unit/http-execution-v3.test.ts", "tests/unit/runtime-adapters.test.ts", + "tests/integration/http-execution-v3-observability.test.ts", ]; const featureOwnedPaths = [ featureSource, @@ -42,37 +48,6 @@ const featureOwnedPaths = [ "tests/fixtures/typecheck/invalid-feature-input.ts", "tests/fixtures/typecheck/invalid-reference-operation.ts", ]; -const copyTargets = [ - "src", - "tests", - "recipes", - "scripts", - "schemas", - "config", - "public", - ".gitea", - ".storybook", - "index.html", - "package.json", - "tsconfig.base.json", - "tsconfig.json", - "tsconfig.app.json", - "tsconfig.node.json", - "tsconfig.test.json", - "tsconfig.recipes.json", - "tsconfig.web-worker.json", - "tsconfig.service-worker.json", - "vite.service-worker.config.ts", - "vite.config.ts", - "vitest.config.ts", - "playwright.config.ts", - "playwright.dev.config.ts", - "playwright.storybook.config.ts", - "playwright.visual.config.ts", - "eslint.config.ts", - ".dependency-cruiser.json", - ".nvmrc", -]; const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts"; import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts"; @@ -172,7 +147,7 @@ function runPnpm(script: string, extra: string[] = []): boolean { } try { - await prepareRemovalFixture(fixtureRoot, copyTargets); + await prepareRemovalFixture(fixtureRoot); for (const excludedFixtureTest of [ "tests/unit/ci-workflow-generation.test.ts", "tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap", diff --git a/scripts/verify-documentation-readiness.ts b/scripts/verify-documentation-readiness.ts index f0c0f31..3dc7275 100644 --- a/scripts/verify-documentation-readiness.ts +++ b/scripts/verify-documentation-readiness.ts @@ -1,8 +1,24 @@ -import { mkdir, readFile } from "node:fs/promises"; +import { access, mkdir, readFile } from "node:fs/promises"; +import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.ts"; import { documentationReviewArtifactSchema } from "./contracts/release-artifacts.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +/** + * Documents that state the review scope. The route registry is the source of + * truth for what that scope is, so these have to enumerate exactly the + * installed routes. + * + * Both said "six routes" while ten were registered: the four newest — the + * platform overview and three reference-resource screens — were outside the + * declared manual accessibility scope without anybody deciding they should be. + * A hand-typed count drifts silently, so it is derived here instead. + */ +const ROUTE_SCOPE_DOCUMENTS = Object.freeze([ + "README.md", + "docs/accessibility/manual-checklist.md", +]); + type DocumentationReview = Readonly<{ sourcePath: string; sha256: string; @@ -14,6 +30,7 @@ type DocumentationReview = Readonly<{ type ReviewLedger = Readonly<{ evidenceReport: Readonly<{ repoPath: string; + upstreamCanonicalPath: string; canonicalSha256: string; }>; reviews: Record; @@ -58,8 +75,30 @@ for (const [diagram, review] of Object.entries(ledger.reviews)) { const reportDigestValid = /^[0-9a-f]{64}$/.test(ledger.evidenceReport.canonicalSha256) && evidence.includes(ledger.evidenceReport.canonicalSha256); + +const installedRouteIds = Object.values(ROUTE_REGISTRY) + .map((route) => route.routeId) + .sort(); +const routeScope = []; +for (const path of ROUTE_SCOPE_DOCUMENTS) { + let text: string; + try { + await access(path); + text = await readFile(path, "utf8"); + } catch { + routeScope.push({ path, missingRouteIds: [...installedRouteIds], documented: false }); + continue; + } + const missingRouteIds = installedRouteIds.filter( + (routeId) => !text.includes(routeId), + ); + routeScope.push({ path, missingRouteIds, documented: missingRouteIds.length === 0 }); +} +const routeScopeDocumented = routeScope.every((entry) => entry.documented); + const passed = reportDigestValid && + routeScopeDocumented && results.length === 2 && results.every((result) => result.passed); await mkdir("artifacts/quality", { recursive: true }); @@ -74,6 +113,8 @@ await writeValidatedJsonArtifact({ standard: ledger.standard, evidenceReport: ledger.evidenceReport, reportDigestValid, + routeScope, + routeScopeDocumented, results, passed, }, diff --git a/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts b/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts index e8fd973..4f4ec61 100644 --- a/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts +++ b/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts @@ -671,7 +671,14 @@ function validateCapabilityPayload( }), ); } catch { - return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + // BT-PRE-04. A capability document this adapter refuses is not a dead end + // for the caller: the only way forward is to ask the issuer for a new one. + // `NONE` said the opposite — that nothing could be done — and disagreed + // with both the design record for an unsupported protocol and the vault, + // which already answers `REISSUE_CAPABILITY` for the same class of refusal. + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER", { + recovery: "REISSUE_CAPABILITY", + }); } } diff --git a/src/adapters/diagnostics/bounded-diagnostics.ts b/src/adapters/diagnostics/bounded-diagnostics.ts index cec5cac..c4d47a4 100644 --- a/src/adapters/diagnostics/bounded-diagnostics.ts +++ b/src/adapters/diagnostics/bounded-diagnostics.ts @@ -6,7 +6,7 @@ import { type DiagnosticRecordInput, } from "../../contracts/diagnostics.ts"; import { projectTelemetryEvent } from "../../contracts/telemetry.ts"; -import { assertBoundedCapacity } from "../telemetry/best-effort-telemetry.ts"; +import { assertBoundedCapacity } from "../platform/bounded-capacity.ts"; export const noOpDiagnostics: DiagnosticsPort = Object.freeze({ record() {}, diff --git a/src/adapters/http/http-execution-v3.ts b/src/adapters/http/http-execution-v3.ts index 9b72b71..9cb8070 100644 --- a/src/adapters/http/http-execution-v3.ts +++ b/src/adapters/http/http-execution-v3.ts @@ -269,6 +269,17 @@ export type ContractHttpExecutorDependencies = Readonly<{ baseUrl: string; /** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */ maxRetryAttempts: number; + /** + * §6.1 / §8.5. `REQUEST_TIMEOUT_MS` from Runtime Config, as a ceiling only. + * + * The contract owns each operation's deadline, because the deadline is part + * of what the operation promises. A deployment still has to be able to hold + * the whole app to something stricter than the sum of its contracts, so this + * value may only shorten a deadline, never extend one — the same direction + * `CAPABILITY_OVERRIDES` is allowed to move in. Absent, contracts stand + * exactly as written. + */ + requestDeadlineCeilingMs?: number; /** The installed profile registry; the executor never invents a profile. */ authProfiles?: InstalledRestAuthProfiles; attachCredentials( @@ -373,6 +384,13 @@ export function createContractHttpExecutor( dependencies.readBoundedResponseBytes ?? readBoundedBytes; const now = dependencies.monotonicNow ?? (() => performance.now()); const random = dependencies.random ?? Math.random; + const deadlineCeilingMs = dependencies.requestDeadlineCeilingMs; + const effectiveDeadlineMs = (contractDeadlineMs: number): number => + typeof deadlineCeilingMs === "number" && + Number.isFinite(deadlineCeilingMs) && + deadlineCeilingMs > 0 + ? Math.min(contractDeadlineMs, deadlineCeilingMs) + : contractDeadlineMs; const sleep = dependencies.sleep ?? ((ms: number, signal: AbortSignal) => @@ -400,7 +418,8 @@ export function createContractHttpExecutor( // §8.5. One monotonic deadline covers credential resolution, encoding, // backoff, every physical attempt, body read and validation. const startedAt = now(); - const deadlineAt = startedAt + policy.totalDeadlineMs; + const totalDeadlineMs = effectiveDeadlineMs(policy.totalDeadlineMs); + const deadlineAt = startedAt + totalDeadlineMs; const remaining = () => deadlineAt - now(); let attemptState: PhysicalAttemptState = "PREPARING"; @@ -451,7 +470,7 @@ export function createContractHttpExecutor( const lifetimeDeadlineTimer = setTimeout(() => { terminalCancellation ??= "DEADLINE"; lifetimeController.abort(); - }, policy.totalDeadlineMs); + }, totalDeadlineMs); let lifetimeDisposed = false; const disposeLifetime = () => { if (lifetimeDisposed) return; diff --git a/src/adapters/platform/bounded-capacity.ts b/src/adapters/platform/bounded-capacity.ts new file mode 100644 index 0000000..8e4935b --- /dev/null +++ b/src/adapters/platform/bounded-capacity.ts @@ -0,0 +1,21 @@ +/** + * N-11. A non-finite or fractional capacity silently disables eviction, so it is + * a construction-time configuration error rather than a runtime drop. + * + * This lives in the adapter kernel rather than inside the telemetry adapter: + * the diagnostics adapter needs the same guard, and importing it from telemetry + * made one concrete adapter depend on another for a rule that belongs to + * neither of them. + */ +export function assertBoundedCapacity( + value: number, + ceiling: number, + label: string, +): number { + if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) { + throw new TypeError( + `${label} must be a safe integer between 1 and ${ceiling}`, + ); + } + return value; +} diff --git a/src/adapters/telemetry/best-effort-telemetry.ts b/src/adapters/telemetry/best-effort-telemetry.ts index 01e8b4a..3d69c77 100644 --- a/src/adapters/telemetry/best-effort-telemetry.ts +++ b/src/adapters/telemetry/best-effort-telemetry.ts @@ -5,6 +5,7 @@ import type { TelemetryEventName, } from "../../contracts/telemetry.ts"; import type { TelemetryPort } from "../../application/ports/telemetry-port.ts"; +import { assertBoundedCapacity } from "../platform/bounded-capacity.ts"; export type TelemetryAdapter = TelemetryPort & Readonly<{ @@ -45,22 +46,8 @@ type TelemetryLifecycle = "ACTIVE" | "DISPOSED"; /** N-11. Documented absolute ceiling for the in-memory best-effort queue. */ export const MAX_TELEMETRY_QUEUE = 10_000; -/** - * N-11. A non-finite or fractional capacity silently disables eviction, so it is - * a construction-time configuration error rather than a runtime drop. - */ -export function assertBoundedCapacity( - value: number, - ceiling: number, - label: string, -): number { - if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) { - throw new TypeError( - `${label} must be a safe integer between 1 and ${ceiling}`, - ); - } - return value; -} +export { assertBoundedCapacity } from "../platform/bounded-capacity.ts"; + export function createTelemetryAdapter( options: TelemetryAdapterOptions, diff --git a/src/application/create-application.ts b/src/application/create-application.ts index 81f3ad3..9bd931c 100644 --- a/src/application/create-application.ts +++ b/src/application/create-application.ts @@ -102,6 +102,12 @@ export function createApplication( getCapabilitySnapshot() { return outputPorts.runtimeCapabilities.getSnapshot(); }, + getFeatureSnapshot() { + return outputPorts.productFeatures.getSnapshot(); + }, + isFeatureActive(featureId: string) { + return outputPorts.productFeatures.isActive(featureId); + }, }); const recovery = Object.freeze({ diff --git a/src/application/policies/compatibility.ts b/src/application/policies/compatibility.ts index 486a954..4bd1b68 100644 --- a/src/application/policies/compatibility.ts +++ b/src/application/policies/compatibility.ts @@ -1,107 +1,21 @@ -export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([ - "buildId", - "configSchemaVersion", - "apiContractVersion", - "assetManifestHash", - "releaseId", -] as const); - -export type CompatibilityTupleField = - (typeof COMPATIBILITY_TUPLE_FIELDS)[number]; - -export type CompatibilityTuple = Readonly< - Record ->; - -export type NumericVersion = Readonly<{ - major: number; - minor: number; - patch: number; -}>; - -export function parseNumericVersion(version: string): NumericVersion | null { - const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version); - if (!match) return null; - return { - major: Number(match[1]), - minor: Number(match[2] ?? 0), - patch: Number(match[3] ?? 0), - }; -} - -export function isVersionCompatible( - supported: string, - actual: string, -): boolean { - const expected = parseNumericVersion(supported); - const candidate = parseNumericVersion(actual); - if (!expected || !candidate) return false; - return ( - expected.major === candidate.major && - candidate.minor >= expected.minor - ); -} - -export function verifyCompatibilityTuple(input: Readonly<{ - frontend: CompatibilityTuple; - runtime: CompatibilityTuple; -}>) { - const mismatches: CompatibilityTupleField[] = []; - if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId"); - if ( - !isVersionCompatible( - input.frontend.configSchemaVersion, - input.runtime.configSchemaVersion, - ) - ) { - mismatches.push("configSchemaVersion"); - } - if ( - !isVersionCompatible( - input.frontend.apiContractVersion, - input.runtime.apiContractVersion, - ) - ) { - mismatches.push("apiContractVersion"); - } - if (input.frontend.assetManifestHash !== input.runtime.assetManifestHash) { - mismatches.push("assetManifestHash"); - } - - const releaseWarning: "releaseId" | null = - input.frontend.releaseId === input.runtime.releaseId - ? null - : "releaseId"; - return Object.freeze({ - compatible: mismatches.length === 0, - mismatches: Object.freeze(mismatches), - warnings: Object.freeze(releaseWarning ? [releaseWarning] : []), - }); -} - -export type ObjectSchemaShape = Readonly<{ - required?: readonly string[]; - properties?: Readonly>; -}>; - -export type SchemaChangeClassification = "breaking" | "additive" | "none"; - -export function classifyObjectSchemaChange( - before: ObjectSchemaShape, - after: ObjectSchemaShape, -): SchemaChangeClassification { - const beforeRequired = new Set(before.required ?? []); - const afterRequired = new Set(after.required ?? []); - const removedProperties = Object.keys(before.properties ?? {}).filter( - (key) => !(key in (after.properties ?? {})), - ); - const addedRequired = [...afterRequired].filter( - (key) => !beforeRequired.has(key), - ); - if (removedProperties.length > 0 || addedRequired.length > 0) return "breaking"; - - const addedProperties = Object.keys(after.properties ?? {}).filter( - (key) => !(key in (before.properties ?? {})), - ); - return addedProperties.length > 0 ? "additive" : "none"; -} +/** + * Release compatibility comparison. + * + * The implementation lives in `src/contracts/compatibility.ts`: it is a pure + * predicate over release tokens with no application state, and + * `src/contracts/release-tokens.ts` needs it, which previously made contracts + * import the application layer. This module re-exports it for application-side + * and script-side callers. + */ +export { + COMPATIBILITY_TUPLE_FIELDS, + classifyObjectSchemaChange, + isVersionCompatible, + parseNumericVersion, + verifyCompatibilityTuple, + type CompatibilityTuple, + type CompatibilityTupleField, + type NumericVersion, + type ObjectSchemaShape, + type SchemaChangeClassification, +} from "../../contracts/compatibility.ts"; diff --git a/src/application/policies/promotion-readiness.ts b/src/application/policies/promotion-readiness.ts index 9dcaf52..2f4f90f 100644 --- a/src/application/policies/promotion-readiness.ts +++ b/src/application/policies/promotion-readiness.ts @@ -20,6 +20,9 @@ export const PROMOTION_FORMULA = Object.freeze({ "FE-GATE-015", "FE-GATE-019", "FE-GATE-026", + // FE-GATE-027. A candidate is only release-ready once it has been admitted + // to a named environment; coherence alone never proved it belonged there. + "FE-GATE-027", ]), PROD_PROMOTION_READY: Object.freeze([ "FE-GATE-016", diff --git a/src/application/ports/in/application-api.ts b/src/application/ports/in/application-api.ts index 602093c..755de99 100644 --- a/src/application/ports/in/application-api.ts +++ b/src/application/ports/in/application-api.ts @@ -1,8 +1,10 @@ import type { SessionState } from "../auth-session-port.ts"; +import type { ProductFeatureStatus } from "../product-features-port.ts"; import type { RuntimeCapabilitySnapshot } from "../runtime-capabilities-port.ts"; import type { StoragePort } from "../storage-port.ts"; export type { SessionState } from "../auth-session-port.ts"; +export type { ProductFeatureStatus }; export type { RuntimeCapabilitySnapshot }; /** @@ -64,6 +66,13 @@ export type ApplicationApi = Readonly<{ * reads capability state here instead of importing the composition root. */ getCapabilitySnapshot(): RuntimeCapabilitySnapshot; + /** + * §3.5. Which product features this build contains and which of them the + * runtime document switched off. Presentation reads state here; it never + * learns how to reach a feature the build left out. + */ + getFeatureSnapshot(): readonly ProductFeatureStatus[]; + isFeatureActive(featureId: string): boolean; }>; recovery: Readonly<{ recoverChunk(input: Readonly<{ diff --git a/src/application/ports/out/application-output-ports.ts b/src/application/ports/out/application-output-ports.ts index 352a971..9ea72f9 100644 --- a/src/application/ports/out/application-output-ports.ts +++ b/src/application/ports/out/application-output-ports.ts @@ -1,5 +1,6 @@ import type { AuthSessionPort } from "../auth-session-port.ts"; import type { ReleaseInfoPort } from "../release-info-port.ts"; +import type { ProductFeaturesPort } from "../product-features-port.ts"; import type { RuntimeCapabilitiesPort } from "../runtime-capabilities-port.ts"; import type { StoragePort } from "../storage-port.ts"; import type { TelemetryPort } from "../telemetry-port.ts"; @@ -19,5 +20,6 @@ export type ApplicationOutputPorts = Readonly<{ telemetry: TelemetryPort; releaseInfo: ReleaseInfoPort; runtimeCapabilities: RuntimeCapabilitiesPort; + productFeatures: ProductFeaturesPort; navigation: Readonly<{ reload(): void }>; }>; diff --git a/src/application/ports/product-features-port.ts b/src/application/ports/product-features-port.ts new file mode 100644 index 0000000..2e64d5c --- /dev/null +++ b/src/application/ports/product-features-port.ts @@ -0,0 +1,16 @@ +import type { ProductFeatureStatus } from "../../contracts/product-features.ts"; + +export type { ProductFeatureStatus }; + +/** + * §3.5. The application reads feature state; it never resolves it. + * + * Only the composition root knows both halves of the answer — what the build + * compiled in and what the runtime document disabled — so the snapshot arrives + * here already reduced to ids and states. It carries no feature module, so + * reading it cannot become a way to reach code the build left out. + */ +export type ProductFeaturesPort = Readonly<{ + getSnapshot(): readonly ProductFeatureStatus[]; + isActive(featureId: string): boolean; +}>; diff --git a/src/application/result.ts b/src/application/result.ts index 9440c1c..561ef7a 100644 --- a/src/application/result.ts +++ b/src/application/result.ts @@ -1,10 +1,13 @@ -import type { AppFailure } from "../contracts/errors.ts"; - /** * The single success/failure carrier used across application input boundaries. * Adapters map technology-specific errors to an application failure before * constructing this value. + * + * The type itself lives in `src/contracts` because both layers need it and + * neither owns it: `src/contracts/server-state.ts` and + * `src/contracts/cursor-pagination.ts` reached back into the application layer + * for it, which made the ownership of the shared vocabulary ambiguous in both + * directions. Contracts is the lower of the two, so the shared shape sits there + * and this module re-exports it for every existing application-side importer. */ -export type Result = - | Readonly<{ ok: true; value: Value }> - | Readonly<{ ok: false; error: Failure }>; +export type { Result } from "../contracts/result.ts"; diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts index fcbc038..baed010 100644 --- a/src/bootstrap/runtime-adapters.ts +++ b/src/bootstrap/runtime-adapters.ts @@ -41,6 +41,14 @@ import { INVALIDATION_TOPIC_VERSIONS, } from "../features/installed-feature-contracts.ts"; import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts"; +import { + COMPILED_PRODUCT_FEATURE_IDS, + INSTALLED_PRODUCT_FEATURE_IDS, +} from "../features/installed-product-manifest.ts"; +import { + activeProductFeatureIds, + resolveProductFeatures, +} from "../contracts/product-features.ts"; import { describeRuntimeCapabilities } from "../contracts/runtime-capabilities.ts"; import { fetchReleaseManifest, @@ -382,6 +390,27 @@ export async function createRuntimeAdapters( ); }, }); + /** + * §3.5. The two halves of the feature answer meet here and nowhere else: the + * manifest says what the build compiled in, the runtime document says what is + * switched off. Neither can add to the other. + */ + const productFeatureStatuses = resolveProductFeatures( + COMPILED_PRODUCT_FEATURE_IDS, + INSTALLED_PRODUCT_FEATURE_IDS, + config.FEATURE_OVERRIDES, + ); + const activeFeatureIds = new Set( + activeProductFeatureIds(productFeatureStatuses), + ); + const productFeatures = Object.freeze({ + getSnapshot() { + return productFeatureStatuses; + }, + isActive(featureId: string) { + return activeFeatureIds.has(featureId); + }, + }); const navigation = Object.freeze({ reload() { const location = host.location; @@ -394,6 +423,11 @@ export async function createRuntimeAdapters( const contractHttp = createContractHttpExecutor({ baseUrl: config.API_BASE_URL, maxRetryAttempts: config.MAX_RETRY_ATTEMPTS, + // §6.1. `REQUEST_TIMEOUT_MS` was declared, validated and then dropped on the + // floor here: every V3 operation ran on its contract's own 10s deadline and + // the deployment dial did nothing. It is a ceiling, so it can tighten an + // operation but never loosen one. + requestDeadlineCeilingMs: config.REQUEST_TIMEOUT_MS, fetcher: context.fetcher, // §7.7. The installed registry owns Fetch credentials and the exact // credential-header sets; this collaborator only supplies proof headers. @@ -481,6 +515,7 @@ export async function createRuntimeAdapters( telemetry, releaseInfo, runtimeCapabilities, + productFeatures, navigation, }), infrastructure: Object.freeze({ diff --git a/src/bootstrap/runtime-config-schema.ts b/src/bootstrap/runtime-config-schema.ts index a7b1d68..7e87bfe 100644 --- a/src/bootstrap/runtime-config-schema.ts +++ b/src/bootstrap/runtime-config-schema.ts @@ -1,3 +1,4 @@ +import type { ProductFeatureOverrideMap } from "../contracts/product-features.ts"; import { runtimeConfigV1ArtifactSchema, runtimeConfigV2ArtifactSchema, @@ -44,6 +45,11 @@ export type RuntimeConfig = Readonly<{ RELEASE_ID?: string; BUILD_ID?: string; CAPABILITY_OVERRIDES: CapabilityOverrides; + /** + * §3.5. Runtime kill switch per installed feature. Subtractive only: a + * feature the build did not install cannot be named into existence here. + */ + FEATURE_OVERRIDES: ProductFeatureOverrideMap; /** Present only while a V1 document is still accepted. */ LEGACY_API_CONTRACT_VERSION?: string; }>; @@ -122,6 +128,10 @@ export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation { ? (parsed as RuntimeConfigV2).CAPABILITY_OVERRIDES : DEFAULT_OVERRIDES), }), + // A V1 document predates feature overrides, so it disables nothing. + FEATURE_OVERRIDES: Object.freeze({ + ...(isV2 ? (parsed as RuntimeConfigV2).FEATURE_OVERRIDES : {}), + }), ...(isV2 ? {} : { diff --git a/src/contracts/compatibility.ts b/src/contracts/compatibility.ts new file mode 100644 index 0000000..486a954 --- /dev/null +++ b/src/contracts/compatibility.ts @@ -0,0 +1,107 @@ +export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([ + "buildId", + "configSchemaVersion", + "apiContractVersion", + "assetManifestHash", + "releaseId", +] as const); + +export type CompatibilityTupleField = + (typeof COMPATIBILITY_TUPLE_FIELDS)[number]; + +export type CompatibilityTuple = Readonly< + Record +>; + +export type NumericVersion = Readonly<{ + major: number; + minor: number; + patch: number; +}>; + +export function parseNumericVersion(version: string): NumericVersion | null { + const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2] ?? 0), + patch: Number(match[3] ?? 0), + }; +} + +export function isVersionCompatible( + supported: string, + actual: string, +): boolean { + const expected = parseNumericVersion(supported); + const candidate = parseNumericVersion(actual); + if (!expected || !candidate) return false; + return ( + expected.major === candidate.major && + candidate.minor >= expected.minor + ); +} + +export function verifyCompatibilityTuple(input: Readonly<{ + frontend: CompatibilityTuple; + runtime: CompatibilityTuple; +}>) { + const mismatches: CompatibilityTupleField[] = []; + if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId"); + if ( + !isVersionCompatible( + input.frontend.configSchemaVersion, + input.runtime.configSchemaVersion, + ) + ) { + mismatches.push("configSchemaVersion"); + } + if ( + !isVersionCompatible( + input.frontend.apiContractVersion, + input.runtime.apiContractVersion, + ) + ) { + mismatches.push("apiContractVersion"); + } + if (input.frontend.assetManifestHash !== input.runtime.assetManifestHash) { + mismatches.push("assetManifestHash"); + } + + const releaseWarning: "releaseId" | null = + input.frontend.releaseId === input.runtime.releaseId + ? null + : "releaseId"; + return Object.freeze({ + compatible: mismatches.length === 0, + mismatches: Object.freeze(mismatches), + warnings: Object.freeze(releaseWarning ? [releaseWarning] : []), + }); +} + +export type ObjectSchemaShape = Readonly<{ + required?: readonly string[]; + properties?: Readonly>; +}>; + +export type SchemaChangeClassification = "breaking" | "additive" | "none"; + +export function classifyObjectSchemaChange( + before: ObjectSchemaShape, + after: ObjectSchemaShape, +): SchemaChangeClassification { + const beforeRequired = new Set(before.required ?? []); + const afterRequired = new Set(after.required ?? []); + const removedProperties = Object.keys(before.properties ?? {}).filter( + (key) => !(key in (after.properties ?? {})), + ); + const addedRequired = [...afterRequired].filter( + (key) => !beforeRequired.has(key), + ); + if (removedProperties.length > 0 || addedRequired.length > 0) return "breaking"; + + const addedProperties = Object.keys(after.properties ?? {}).filter( + (key) => !(key in (before.properties ?? {})), + ); + return addedProperties.length > 0 ? "additive" : "none"; +} diff --git a/src/contracts/cursor-pagination.ts b/src/contracts/cursor-pagination.ts index 3297314..6e9dccb 100644 --- a/src/contracts/cursor-pagination.ts +++ b/src/contracts/cursor-pagination.ts @@ -1,4 +1,4 @@ -import type { Result } from "../application/result.ts"; +import type { Result } from "./result.ts"; export type CursorPage = Readonly<{ items: readonly Value[]; diff --git a/src/contracts/deployment-admission.ts b/src/contracts/deployment-admission.ts new file mode 100644 index 0000000..0813910 --- /dev/null +++ b/src/contracts/deployment-admission.ts @@ -0,0 +1,160 @@ +import type { RuntimeConfigArtifact } from "./release-artifacts.ts"; + +/** + * §6.4. Which environment an artifact is allowed to be deployed to. + * + * Release coherence answers "do these artifacts describe each other?". It does + * not answer "is this the artifact production should receive?", and the two are + * not the same question: a build whose runtime document says `APP_ENV: local`, + * `AUTH_MODE: demo` and `API_BASE_URL: http://localhost:8080/` is perfectly + * coherent with itself. Without an admission step such a build is a valid + * release candidate, and the only thing standing between it and production is + * that nobody happened to promote it. + * + * Admission is therefore a separate, declared decision: a caller states the + * target it intends, and this module says whether the artifact may go there. + * Every rule below is a refusal, so an unrecognised target or an unreadable + * field fails closed rather than passing by omission. + */ + +export const DEPLOYMENT_TARGETS = Object.freeze([ + "local", + "development", + "staging", + "production", +] as const); + +export type DeploymentTarget = (typeof DEPLOYMENT_TARGETS)[number]; + +/** + * Targets that serve real users over the public internet. They carry the full + * rule set; `local` and `development` only have to be honest about what they + * are. + */ +const PUBLIC_TARGETS: ReadonlySet = new Set([ + "staging", + "production", +]); + +/** Placeholder identifiers a developer build emits when nothing supplied one. */ +const PLACEHOLDER_IDENTIFIERS: ReadonlySet = new Set([ + "local-build", + "local-release", + "local", + "dev", + "unknown", +]); + +export type AdmissionViolation = Readonly<{ field: string; reason: string }>; + +export type AdmissionInput = RuntimeConfigArtifact & + Readonly<{ BUILD_ID?: string; RELEASE_ID?: string }>; + +export function isDeploymentTarget(value: unknown): value is DeploymentTarget { + return ( + typeof value === "string" && + (DEPLOYMENT_TARGETS as readonly string[]).includes(value) + ); +} + +/** + * Every reason this artifact may not be deployed to `target`. An empty list is + * the only admission. + */ +export function findAdmissionViolations( + target: DeploymentTarget, + config: AdmissionInput, +): readonly AdmissionViolation[] { + const violations: AdmissionViolation[] = []; + if (config.APP_ENV !== target) { + violations.push({ + field: "APP_ENV", + reason: `artifact declares ${config.APP_ENV} but is being admitted to ${target}`, + }); + } + if (!PUBLIC_TARGETS.has(target)) return Object.freeze(violations); + + if (config.AUTH_MODE !== "external") { + violations.push({ + field: "AUTH_MODE", + reason: `${target} requires an external identity provider, not ${config.AUTH_MODE}`, + }); + } + violations.push(...publicEndpointViolations("API_BASE_URL", config.API_BASE_URL)); + if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) { + violations.push({ + field: "TELEMETRY_ENDPOINT", + reason: "telemetry is enabled without an endpoint", + }); + } + if (config.TELEMETRY_ENDPOINT) { + violations.push( + ...publicEndpointViolations("TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT), + ); + } + for (const field of ["BUILD_ID", "RELEASE_ID"] as const) { + const value = config[field]; + if (typeof value !== "string" || value.length === 0) { + violations.push({ field, reason: `${target} requires a build identity` }); + continue; + } + if (PLACEHOLDER_IDENTIFIERS.has(value.toLowerCase())) { + violations.push({ + field, + reason: `${value} is a developer placeholder, not a released identity`, + }); + } + } + return Object.freeze(violations); +} + +function publicEndpointViolations( + field: string, + value: string, +): readonly AdmissionViolation[] { + let url: URL; + try { + url = new URL(value); + } catch { + return [{ field, reason: "is not an absolute URL" }]; + } + const violations: AdmissionViolation[] = []; + if (url.protocol !== "https:") { + violations.push({ field, reason: `${url.protocol} is not permitted; use https` }); + } + if (isNonPublicHost(url.hostname)) { + violations.push({ + field, + reason: `${url.hostname} is not reachable from a user's browser`, + }); + } + return violations; +} + +/** + * Hosts that only resolve inside the machine or network that built the + * artifact. A deployment pointing at one of these is a developer configuration + * that escaped, not a production endpoint. + */ +function isNonPublicHost(hostname: string): boolean { + const host = hostname.toLowerCase().replace(/^\[|\]$/gu, ""); + if ( + host === "localhost" || + host.endsWith(".localhost") || + host === "::1" || + host === "0.0.0.0" || + host === "::" + ) { + return true; + } + const octets = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(host); + if (!octets) return false; + const [first, second] = [Number(octets[1]), Number(octets[2])]; + return ( + first === 127 || + first === 10 || + (first === 192 && second === 168) || + (first === 172 && second >= 16 && second <= 31) || + (first === 169 && second === 254) + ); +} diff --git a/src/contracts/env.ts b/src/contracts/env.ts index 95679f4..5c00a61 100644 --- a/src/contracts/env.ts +++ b/src/contracts/env.ts @@ -47,6 +47,11 @@ export const ENV_REGISTRY = Object.freeze({ RELEASE_MANIFEST_URL: runtime("public", true, "/release-manifest.json"), // §3.5: overrides may only disable an installed capability, never enable one. CAPABILITY_OVERRIDES: runtime("public", false, null), + // §3.5: likewise for features — subtractive, keyed by installed feature id. + FEATURE_OVERRIDES: runtime("public", false, null), + // §3.5: build-time narrowing of the product manifest. A feature left out + // here is not imported by any registry and never reaches the bundle. + VITE_PRODUCT_FEATURES: build("compile-time", false, null), }); function build( diff --git a/src/contracts/product-features.ts b/src/contracts/product-features.ts new file mode 100644 index 0000000..6ab6192 --- /dev/null +++ b/src/contracts/product-features.ts @@ -0,0 +1,143 @@ +/** + * §3.5 / §6.1. Which product features this build contains, and which of them a + * deployment is allowed to switch off. + * + * Two different questions, deliberately answered by two different inputs: + * + * - **Installed** is a build-time decision. `VITE_PRODUCT_FEATURES` selects + * from the features this source tree declares; a feature left out contributes + * no route, no operation, no schema, no codec and no adapter, so nothing can + * reach it. It does *not* shrink the bundle: a static import cannot be undone + * by a value, and building the import graph from a configuration string is + * exactly what §3.5 forbids. Physical removal is FE-GATE-020's job — delete + * the feature directory and rebuild, which that gate proves still works. + * - **Active** is a runtime decision. `FEATURE_OVERRIDES` in the runtime config + * may take an installed feature out of service without a rebuild. + * + * Both directions are subtractive, and that is the invariant this module + * exists to hold: neither input can ever turn on a feature whose source is + * absent. A configuration document that could name a feature into existence + * would be a configuration document that chooses which code runs, and no + * dynamic import path is ever built from one. + */ + +export type ProductFeatureOverride = "DEFAULT" | "DISABLED"; + +export type ProductFeatureState = + /** Compiled in and not disabled: the feature serves traffic. */ + | "ACTIVE" + /** Compiled in, switched off by the runtime document. */ + | "DISABLED_BY_CONFIG" + /** Not selected at build time; not in the bundle. */ + | "NOT_INSTALLED"; + +export type ProductFeatureOverrideMap = Readonly< + Record +>; + +export type ProductFeatureStatus = Readonly<{ + featureId: string; + state: ProductFeatureState; +}>; + +/** The shape every feature contract exposes to the manifest. */ +export type SelectableProductFeature = Readonly<{ featureId: string }>; + +/** + * The explicit "no product features" selection. + * + * A blank value cannot mean it: an unset CI variable expands to a blank string + * far too easily, and a build that silently shipped no features would be a very + * expensive way to learn that. Selecting nothing has to be something you typed. + */ +export const NO_PRODUCT_FEATURES = "none"; + +/** + * Applies the build-time selection to the features this source tree declares. + * + * An empty or absent declaration keeps everything, so an ordinary build needs + * no environment at all. A declaration naming something that is not compiled is + * refused rather than ignored: silently accepting it would let a deployment + * believe it had enabled a feature that does not exist. + */ +export function selectCompiledProductFeatures< + Feature extends SelectableProductFeature, +>( + compiled: readonly Feature[], + declared: string | undefined, +): readonly Feature[] { + const compiledIds = compiled.map((feature) => feature.featureId); + assertUniqueFeatureIds(compiledIds); + if (declared === undefined || declared.trim() === "") { + return Object.freeze([...compiled]); + } + if (declared.trim() === NO_PRODUCT_FEATURES) return Object.freeze([]); + const requested = declared + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (requested.length === 0) { + throw new Error( + `VITE_PRODUCT_FEATURES is set to ${JSON.stringify(declared)}, which names ` + + `no feature. Use "${NO_PRODUCT_FEATURES}" to select none, or leave it ` + + `unset to keep ${compiledIds.join(", ")}.`, + ); + } + const unknown = requested.filter((id) => !compiledIds.includes(id)); + if (unknown.length > 0) { + throw new Error( + `VITE_PRODUCT_FEATURES names features this build does not contain: ${unknown.join( + ", ", + )}. Selection can only remove from ${compiledIds.join(", ")}.`, + ); + } + return Object.freeze( + compiled.filter((feature) => requested.includes(feature.featureId)), + ); +} + +/** + * The state of every feature the source tree declares, given what was compiled + * and what the runtime document says. + * + * `compiledIds` is the full declared set rather than the installed one so a + * build that dropped a feature still reports it as `NOT_INSTALLED` instead of + * omitting it. An operator looking at the platform overview needs to see the + * difference between "off" and "never heard of it". + */ +export function resolveProductFeatures( + compiledIds: readonly string[], + installedIds: readonly string[], + overrides: ProductFeatureOverrideMap = {}, +): readonly ProductFeatureStatus[] { + assertUniqueFeatureIds(compiledIds); + return Object.freeze( + [...compiledIds].sort().map((featureId) => + Object.freeze({ + featureId, + state: !installedIds.includes(featureId) + ? ("NOT_INSTALLED" as const) + : overrides[featureId] === "DISABLED" + ? ("DISABLED_BY_CONFIG" as const) + : ("ACTIVE" as const), + }), + ), + ); +} + +/** The feature ids serving traffic right now. */ +export function activeProductFeatureIds( + statuses: readonly ProductFeatureStatus[], +): readonly string[] { + return Object.freeze( + statuses + .filter((status) => status.state === "ACTIVE") + .map((status) => status.featureId), + ); +} + +function assertUniqueFeatureIds(ids: readonly string[]): void { + if (new Set(ids).size !== ids.length) { + throw new Error(`duplicate product feature id: ${ids.join(", ")}`); + } +} diff --git a/src/contracts/release-artifacts.ts b/src/contracts/release-artifacts.ts index 05f2b47..7cd038c 100644 --- a/src/contracts/release-artifacts.ts +++ b/src/contracts/release-artifacts.ts @@ -52,6 +52,23 @@ export const capabilityOverrideArtifactSchema = z OFFLINE_COMMANDS: "DEFAULT", }); +/** + * §3.5 / §6.1. A runtime switch that can take an installed feature out of + * service without a rebuild. + * + * Values are `DEFAULT | DISABLED` for the same reason `CAPABILITY_OVERRIDES` + * is: a configuration document may subtract from what the build installed and + * may never add to it. Keys are feature ids; naming a feature this build does + * not contain is inert rather than an error, so a shared configuration + * document can cover several builds. + */ +export const featureOverrideArtifactSchema = z + .record( + z.string().regex(/^[a-z][a-z0-9-]{0,63}$/u, "feature id is invalid"), + z.enum(["DEFAULT", "DISABLED"]), + ) + .default({}); + type RuntimeConfigArtifactDraft = Readonly<{ APP_ENV: "local" | "development" | "staging" | "production"; API_BASE_URL: string; @@ -138,6 +155,7 @@ export const runtimeConfigV2ArtifactSchema = z ...runtimeConfigArtifactFields, CONFIG_SCHEMA_VERSION: z.literal("2.0"), CAPABILITY_OVERRIDES: capabilityOverrideArtifactSchema, + FEATURE_OVERRIDES: featureOverrideArtifactSchema, }) .strict() .superRefine(runtimeConfigArtifactInvariants); diff --git a/src/contracts/release-tokens.ts b/src/contracts/release-tokens.ts index 437db64..1c4135e 100644 --- a/src/contracts/release-tokens.ts +++ b/src/contracts/release-tokens.ts @@ -1,4 +1,4 @@ -import { verifyCompatibilityTuple } from "../application/policies/compatibility.ts"; +import { verifyCompatibilityTuple } from "./compatibility.ts"; export const RELEASE_TOKEN_REGISTRY = Object.freeze({ appVersion: token("appVersion", "manifest", "human release label"), diff --git a/src/contracts/result.ts b/src/contracts/result.ts new file mode 100644 index 0000000..7ac9195 --- /dev/null +++ b/src/contracts/result.ts @@ -0,0 +1,15 @@ +import type { AppFailure } from "./errors.ts"; + +/** + * The single success/failure carrier used across application input boundaries. + * Adapters map technology-specific errors to an application failure before + * constructing this value. + * + * It lives in `src/contracts` because it is shared vocabulary rather than + * application behaviour: contracts modules describe results too, and reaching + * up into `src/application` for the shape made the dependency between the two + * packages point both ways. `src/application/result.ts` re-exports it. + */ +export type Result = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; error: Failure }>; diff --git a/src/contracts/server-state.ts b/src/contracts/server-state.ts index 79de43f..f7476c6 100644 --- a/src/contracts/server-state.ts +++ b/src/contracts/server-state.ts @@ -1,4 +1,4 @@ -import type { Result } from "../application/result.ts"; +import type { Result } from "./result.ts"; import type { QueryInvalidationTopic } from "./query-invalidation.ts"; import { createBoundQueryKey, diff --git a/src/features/installed-contract-contributions.ts b/src/features/installed-contract-contributions.ts index 3483efc..7f1f329 100644 --- a/src/features/installed-contract-contributions.ts +++ b/src/features/installed-contract-contributions.ts @@ -4,6 +4,8 @@ import { type InstalledContractPackageIdentity, } from "../contracts/external-contract-runtime.ts"; import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts"; +import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts"; +import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; /** * §4.8. Static contract selection SSOT. @@ -13,7 +15,11 @@ import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/con * `src/features//contracts/-contract-contribution.ts`. */ export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] = - Object.freeze([REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION]); + Object.freeze( + INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID) + ? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION] + : [], + ); export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions( INSTALLED_CONTRACT_CONTRIBUTIONS, diff --git a/src/features/installed-feature-adapters.ts b/src/features/installed-feature-adapters.ts index ba6c8c9..ec1f1d4 100644 --- a/src/features/installed-feature-adapters.ts +++ b/src/features/installed-feature-adapters.ts @@ -1,14 +1,23 @@ import type { ApplicationFeatureInputs } from "../application/ports/in/application-api.ts"; import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.ts"; import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts"; +import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; +/** + * §3.5. Partial on purpose: a feature the manifest did not select supplies no + * driving input, so consumers have to narrow before calling one. A total type + * here would let feature code compile against an input that is not there. + */ type InstalledFeatureInputs = Readonly< - Pick + Partial> >; export function createInstalledFeatureInputs( context: Parameters[0], ): InstalledFeatureInputs { + if (!INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)) { + return Object.freeze({}); + } const referenceFeature = createReferenceFeatureInstalledInput(context); return Object.freeze({ [referenceFeature.featureId]: referenceFeature.input, diff --git a/src/features/installed-feature-contracts.ts b/src/features/installed-feature-contracts.ts index e5ce974..767a270 100644 --- a/src/features/installed-feature-contracts.ts +++ b/src/features/installed-feature-contracts.ts @@ -4,7 +4,9 @@ import { composeSchemaRegistry, PLATFORM_SCHEMA_REGISTRY, } from "../contracts/schema-registry.ts"; -import { REFERENCE_FEATURE_CONTRACT } from "./reference-feature/contracts/reference-feature-contract.ts"; +import { + INSTALLED_PRODUCT_FEATURES, +} from "./installed-product-manifest.ts"; import { composeApiOperations, validateApiRuntimeBindings, @@ -13,18 +15,27 @@ import { validateRestProfileBindings } from "../contracts/rest-profiles.ts"; import { composeRuntimeSchemaCodecs } from "../contracts/schema-registry.ts"; import { composeBoundaryMapperRegistry } from "../contracts/boundary-mapper.ts"; -export const INSTALLED_FEATURE_CONTRACTS = Object.freeze([ - REFERENCE_FEATURE_CONTRACT, -]); +/** + * §3.5. Composed from the product manifest rather than from a literal list, so + * a feature the build did not select contributes no routes, no operations, no + * schemas and no messages — and is therefore not reachable from any registry. + */ +export const INSTALLED_FEATURE_CONTRACTS = INSTALLED_PRODUCT_FEATURES; -export const ROUTE_REGISTRY = Object.freeze({ - ...PLATFORM_ROUTE_REGISTRY, - ...REFERENCE_FEATURE_CONTRACT.routes, -}); -export const ROUTE_RUNTIME_CONTRACT = Object.freeze({ - ...PLATFORM_ROUTE_RUNTIME_CONTRACT, - ...REFERENCE_FEATURE_CONTRACT.routeRuntimeContracts, -}); +export const ROUTE_REGISTRY = Object.freeze( + INSTALLED_FEATURE_CONTRACTS.reduce( + (registry, contract) => ({ ...registry, ...contract.routes }), + { ...PLATFORM_ROUTE_REGISTRY }, + ), +) as typeof PLATFORM_ROUTE_REGISTRY & + (typeof INSTALLED_PRODUCT_FEATURES)[number]["routes"]; +export const ROUTE_RUNTIME_CONTRACT = Object.freeze( + INSTALLED_FEATURE_CONTRACTS.reduce( + (registry, contract) => ({ ...registry, ...contract.routeRuntimeContracts }), + { ...PLATFORM_ROUTE_RUNTIME_CONTRACT }, + ), +) as typeof PLATFORM_ROUTE_RUNTIME_CONTRACT & + (typeof INSTALLED_PRODUCT_FEATURES)[number]["routeRuntimeContracts"]; export const API_OPERATIONS = composeApiOperations( INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.apiOperations), ); @@ -69,6 +80,24 @@ export const API_RUNTIME_BINDINGS_VALID = validateApiRuntimeBindings( MAPPER_REGISTRY, ); +/** + * §3.5. Which feature owns each route. + * + * A route contributed by a feature disappears with it at build time, and has to + * be withdrawn from navigation and from the router when the runtime document + * disables that feature. Platform routes have no owner and are always present. + */ +export const ROUTE_FEATURE_OWNER: Readonly> = + Object.freeze( + Object.fromEntries( + INSTALLED_FEATURE_CONTRACTS.flatMap((contract) => + Object.keys(contract.routes).map( + (routeId) => [routeId, contract.featureId] as const, + ), + ), + ), + ); + export const NAVIGATION_ROUTES = Object.freeze( Object.values(ROUTE_REGISTRY) .filter(isNavigableRoute) diff --git a/src/features/installed-feature-messages.ts b/src/features/installed-feature-messages.ts index 99ddc3f..50b80b1 100644 --- a/src/features/installed-feature-messages.ts +++ b/src/features/installed-feature-messages.ts @@ -1,10 +1,16 @@ import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-message-catalog.ts"; +/** + * §3.5. Messages are deliberately *not* gated on the manifest. The catalog's + * key type is what makes `message()` total, so dropping keys would turn every + * lookup partial for the sake of a few unreachable strings. + */ +const reference = REFERENCE_MESSAGE_CATALOGS; export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({ "ko-KR": Object.freeze({ - ...REFERENCE_MESSAGE_CATALOGS["ko-KR"], + ...reference["ko-KR"], }), "en-US": Object.freeze({ - ...REFERENCE_MESSAGE_CATALOGS["en-US"], + ...reference["en-US"], }), } as const); diff --git a/src/features/installed-feature-runtimes.tsx b/src/features/installed-feature-runtimes.tsx index cc14993..46757ad 100644 --- a/src/features/installed-feature-runtimes.tsx +++ b/src/features/installed-feature-runtimes.tsx @@ -4,13 +4,25 @@ import { REFERENCE_FEATURE_ROUTE_CODECS, REFERENCE_FEATURE_ROUTE_RUNTIME, } from "./reference-feature/presentation/reference-feature-runtime.tsx"; +import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts"; +import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; + +/** + * §3.5. A feature the manifest did not select contributes no codec and no route + * component, so the router has nothing to mount for it. The module is still + * linked — a static import cannot be undone by a value — which is why physical + * removal is FE-GATE-020's job and this is deselection, not deletion. + */ +const referenceSelected = INSTALLED_PRODUCT_FEATURE_IDS.includes( + REFERENCE_FEATURE_ID, +); export const ROUTE_CODECS = Object.freeze({ ...PLATFORM_ROUTE_CODECS, - ...REFERENCE_FEATURE_ROUTE_CODECS, + ...(referenceSelected ? REFERENCE_FEATURE_ROUTE_CODECS : {}), }); export const ROUTE_RUNTIME = Object.freeze({ ...PLATFORM_ROUTE_RUNTIME, - ...REFERENCE_FEATURE_ROUTE_RUNTIME, + ...(referenceSelected ? REFERENCE_FEATURE_ROUTE_RUNTIME : {}), }); diff --git a/src/features/installed-product-manifest.ts b/src/features/installed-product-manifest.ts new file mode 100644 index 0000000..842f4c0 --- /dev/null +++ b/src/features/installed-product-manifest.ts @@ -0,0 +1,63 @@ +import { + selectCompiledProductFeatures, + type SelectableProductFeature, +} from "../contracts/product-features.ts"; +import { REFERENCE_FEATURE_CONTRACT } from "./reference-feature/contracts/reference-feature-contract.ts"; + +/** + * §3.5. The product manifest: the single declaration of which features this + * build contains. + * + * Before this file the reference feature was spread directly into the route, + * API, schema and message registries, so the only way to ship without it was to + * edit five registries by hand and hope nothing still referred to it. The + * removability gate proved that editing worked; nothing made it a decision you + * could express. + * + * Adding an entry here is what installs a feature. `VITE_PRODUCT_FEATURES` may + * then narrow the list at build time — a comma-separated subset, `none` for an + * empty selection, absent meaning "all of them". A narrowed-out feature reaches + * no registry, so it is not routed, not navigable and not callable. + * + * It is not deleted. The import above is static, and a value cannot undo a + * static import; making the import itself conditional on configuration is the + * thing §3.5 exists to prevent. FE-GATE-020 is what proves the feature can be + * physically removed, by removing it and rebuilding the whole project. + */ + +const COMPILED_PRODUCT_FEATURES = Object.freeze([ + REFERENCE_FEATURE_CONTRACT, +] as const); + +/** + * Every feature this source tree declares, selected or not. The platform + * overview reports on this set so an operator can tell a feature that was built + * out from one that never existed. + */ +export const COMPILED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze( + COMPILED_PRODUCT_FEATURES.map((feature) => feature.featureId), +); + +/** + * `import.meta.env` exists in a Vite build and not under Node, and this module + * is read by release scripts as well as by the app. A missing environment means + * "nothing was narrowed", which is the same answer a plain developer build + * gives. + */ +function declaredFeatureSelection(): string | undefined { + const environment = ( + import.meta as unknown as { + env?: Readonly>; + } + ).env; + return environment?.["VITE_PRODUCT_FEATURES"]; +} + +export const INSTALLED_PRODUCT_FEATURES = selectCompiledProductFeatures( + COMPILED_PRODUCT_FEATURES as readonly SelectableProductFeature[], + declaredFeatureSelection(), +) as readonly (typeof COMPILED_PRODUCT_FEATURES)[number][]; + +export const INSTALLED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze( + INSTALLED_PRODUCT_FEATURES.map((feature) => feature.featureId), +); diff --git a/src/presentation/examples/platform-overview-page.tsx b/src/presentation/examples/platform-overview-page.tsx index ce7b1d4..e646809 100644 --- a/src/presentation/examples/platform-overview-page.tsx +++ b/src/presentation/examples/platform-overview-page.tsx @@ -226,6 +226,30 @@ function capabilityBadge( return { text: `활성 (${status.active})`, variant: "success" }; } +/** + * §3.5. The three states an operator has to be able to tell apart: shipped and + * serving, shipped and switched off, and not in this build at all. + */ +const FEATURE_BADGE = Object.freeze({ + ACTIVE: Object.freeze({ + variant: "success" as const, + text: "사용 중", + description: "이 빌드에 설치되어 있고 런타임 설정이 끄지 않았습니다.", + }), + DISABLED_BY_CONFIG: Object.freeze({ + variant: "warning" as const, + text: "설정으로 중지", + description: + "이 빌드에 포함되어 있으나 런타임 설정이 껐습니다. 재빌드 없이 다시 켤 수 있습니다.", + }), + NOT_INSTALLED: Object.freeze({ + variant: "neutral" as const, + text: "미설치", + description: + "빌드 시 제품 매니페스트가 선택하지 않았습니다. 런타임 설정으로는 켤 수 없습니다.", + }), +}); + function buildOperationRows(): readonly OperationRow[] { return Object.freeze( [...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.values()].map( @@ -273,6 +297,7 @@ export default function PlatformOverviewPage() { const routes = Object.values(ROUTE_REGISTRY); const operations = buildOperationRows(); const capabilities = runtime.getCapabilitySnapshot(); + const features = runtime.getFeatureSnapshot(); const activeCapabilityCount = capabilities.filter( (status) => status.active > 0, ).length; @@ -479,6 +504,37 @@ export default function PlatformOverviewPage() { })} + +
+
+

제품 기능

+

+ 제품 매니페스트가 이 빌드에 어떤 기능이 설치되었는지 정하고, 런타임 + 설정은 설치된 기능을 끌 수만 있습니다. 두 입력 모두 감산만 하므로 + 설정 문서가 없는 기능을 켜 낼 수는 없습니다. 그래서 「빌드에서 빠진 + 기능」과 「운영자가 끈 기능」이 여기서 구분됩니다. +

+
+
+ {features.map((status) => { + const badge = FEATURE_BADGE[status.state]; + return ( + {badge.text}} + > +

+ {badge.description} +

+
+ ); + })} +
+
); } diff --git a/src/presentation/i18n/catalog.ts b/src/presentation/i18n/catalog.ts index 3805432..22197d8 100644 --- a/src/presentation/i18n/catalog.ts +++ b/src/presentation/i18n/catalog.ts @@ -79,6 +79,10 @@ const PLATFORM_KO_MESSAGES = { "route.invalid.title": "올바르지 않은 주소입니다.", "route.invalid.description": "주소의 경로 또는 검색 조건을 확인해 주세요.", "route.invalid.action": "안전한 탐색 링크를 사용해 주세요.", + "route.disabledFeature.title": "현재 사용할 수 없는 기능입니다.", + "route.disabledFeature.description": + "이 기능은 배포 설정에서 중지되어 있습니다. 코드에는 포함되어 있으며 운영자가 다시 켤 수 있습니다.", + "route.disabledFeature.action": "다른 탐색 링크를 사용해 주세요.", "route.auth.integration.title": "로그인 연동이 필요합니다.", "route.auth.integration.description": "외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.", @@ -233,6 +237,10 @@ const PLATFORM_EN_MESSAGES = { "route.invalid.title": "This address is invalid.", "route.invalid.description": "Check the path and search parameters.", "route.invalid.action": "Use a safe navigation link.", + "route.disabledFeature.title": "This feature is not available right now.", + "route.disabledFeature.description": + "The deployment configuration has switched it off. It is still part of this build and an operator can switch it back on.", + "route.disabledFeature.action": "Use another navigation link.", "route.auth.integration.title": "Sign-in integration is required.", "route.auth.integration.description": "This protected route is available after an external authentication owner is connected.", diff --git a/src/presentation/layouts/app-shell.tsx b/src/presentation/layouts/app-shell.tsx index ff7be35..366ba56 100644 --- a/src/presentation/layouts/app-shell.tsx +++ b/src/presentation/layouts/app-shell.tsx @@ -5,6 +5,7 @@ import type { SessionState } from "../../application/ports/in/application-api.ts import { normalizeColorSchemePreference } from "../../application/policies/color-scheme.ts"; import { NAVIGATION_ROUTES, + ROUTE_FEATURE_OWNER, routePath, } from "../../features/installed-feature-contracts.ts"; import { @@ -19,6 +20,7 @@ import { useLocale, type MessageKey, } from "../i18n/index.ts"; +import { useApplication } from "../providers/application-provider.tsx"; import { useSession } from "../providers/session-provider.tsx"; import { useTheme } from "../providers/theme-provider.tsx"; @@ -166,10 +168,17 @@ export function AppShell() { function PrimaryNavigation({ id }: Readonly<{ id: string }>) { const { resolve, message } = useLocale(); + const { runtime } = useApplication(); + // §3.5. A feature the runtime document disabled does not advertise itself. + // The router refuses its routes too, so this is presentation, not the switch. + const routes = NAVIGATION_ROUTES.filter((definition) => { + const owner = ROUTE_FEATURE_OWNER[definition.routeId]; + return owner === undefined || runtime.isFeatureActive(owner); + }); return (