chore: sync the frontend template from a0fbafb to 5434760

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 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 21:34:19 +09:00
co-authored by Claude Opus 5
parent 325a2a0843
commit bdee07a93b
101 changed files with 3116 additions and 448 deletions
+35
View File
@@ -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",
+25
View File
@@ -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/<profile>.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
+3
View File
@@ -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:
+6
View File
@@ -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
+15
View File
@@ -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<string, unknown>();
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(
+6 -1
View File
@@ -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.
+52 -3
View File
@@ -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"
+19
View File
@@ -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"
}
}
+19
View File
@@ -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"
}
}
+20
View File
@@ -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"
}
}
+20
View File
@@ -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"
}
}
+13 -5
View File
@@ -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:
+22
View File
@@ -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.
+3 -2
View File
@@ -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`."
}
@@ -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/<profile>.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 (367724ms 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.
+60 -59
View File
@@ -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를 같은 변경에서 갱신한다.
+2
View File
@@ -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",
+3
View File
@@ -14,5 +14,8 @@
"WEB_WORKER": "DEFAULT",
"SERVICE_WORKER": "DEFAULT",
"OFFLINE_COMMANDS": "DEFAULT"
},
"FEATURE_OVERRIDES": {
"reference-feature": "DEFAULT"
}
}
+16 -6
View File
@@ -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(
+43 -3
View File
@@ -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");
+2 -2
View File
@@ -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<string, GateResult> = 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");
+108
View File
@@ -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<void> {
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();
+38 -13
View File
@@ -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<CiGateContract> {
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<readonly [string, string, readonly string[], readonly string[]]> = [
["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY],
@@ -834,7 +853,7 @@ function validateContractSemantics(
const expectedJobOwnership: Readonly<Record<string, readonly string[]>> = {
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<Record<string, readonly Readonly<{ name: string; value: string }> []>> = {
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" },
+56 -3
View File
@@ -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" });
}
});
+93
View File
@@ -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<Record<string, unknown>> {
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<string, unknown> = { ...(source as Record<string, unknown>) };
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<void> {
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();
}
+2
View File
@@ -35,6 +35,7 @@ import {
registryGovernanceRunArtifactSchema,
registryCompatibilityFixturesArtifactSchema,
registrySnapshotArtifactSchema,
deploymentAdmissionArtifactSchema,
releaseVerificationArtifactSchema,
reproducibleBuildArtifactSchema,
runbookRecordArtifactSchema,
@@ -238,6 +239,7 @@ const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> =
"provider-provenance": provenanceProviderAttestationSchema,
"provider-verification": providerVerificationArtifactSchema,
"ci-contract-report": ciContractReportSchema,
"deployment-admission": deploymentAdmissionArtifactSchema,
});
export function hasCiArtifactSemanticValidator(
+40 -24
View File
@@ -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<Readonly<{ root: string; handle: FileHandle }>> {
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);
+1 -1
View File
@@ -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(),
+36
View File
@@ -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<T>(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 }));
}
+14 -2
View File
@@ -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()) {
+43 -3
View File
@@ -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<Record<string, string | undefined>>,
): 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`);
}
+50 -3
View File
@@ -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<typeof spawn> | 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;
+154 -1
View File
@@ -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<void> {
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<void> {
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<void> {
const policyPath = path.join(root, "config/security/secret-scan-policy.json");
let policy: Record<string, unknown>;
try {
policy = JSON.parse(await readFile(policyPath, "utf8")) as Record<string, unknown>;
} 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<{
+39 -3
View File
@@ -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/<pid>/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 {
+1 -32
View File
@@ -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",
+7 -1
View File
@@ -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(
+7 -32
View File
@@ -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",
+42 -1
View File
@@ -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<string, DocumentationReview>;
@@ -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,
},
@@ -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",
});
}
}
@@ -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() {},
+21 -2
View File
@@ -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;
+21
View File
@@ -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;
}
@@ -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,
+6
View File
@@ -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({
+21 -107
View File
@@ -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<CompatibilityTupleField, string>
>;
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<Record<string, unknown>>;
}>;
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";
@@ -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",
@@ -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<{
@@ -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 }>;
}>;
@@ -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;
}>;
+8 -5
View File
@@ -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<Value, Failure = AppFailure> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: Failure }>;
export type { Result } from "../contracts/result.ts";
+35
View File
@@ -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({
+10
View File
@@ -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
? {}
: {
+107
View File
@@ -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<CompatibilityTupleField, string>
>;
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<Record<string, unknown>>;
}>;
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";
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Result } from "../application/result.ts";
import type { Result } from "./result.ts";
export type CursorPage<Value> = Readonly<{
items: readonly Value[];
+160
View File
@@ -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<DeploymentTarget> = new Set([
"staging",
"production",
]);
/** Placeholder identifiers a developer build emits when nothing supplied one. */
const PLACEHOLDER_IDENTIFIERS: ReadonlySet<string> = 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)
);
}
+5
View File
@@ -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(
+143
View File
@@ -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<string, ProductFeatureOverride>
>;
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(", ")}`);
}
}
+18
View File
@@ -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);
+1 -1
View File
@@ -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"),
+15
View File
@@ -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<Value, Failure = AppFailure> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: Failure }>;
+1 -1
View File
@@ -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,
@@ -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/<feature>/contracts/<service>-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,
+10 -1
View File
@@ -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<ApplicationFeatureInputs, typeof REFERENCE_FEATURE_ID>
Partial<Pick<ApplicationFeatureInputs, typeof REFERENCE_FEATURE_ID>>
>;
export function createInstalledFeatureInputs(
context: Parameters<typeof createReferenceFeatureInstalledInput>[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,
+41 -12
View File
@@ -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<Record<string, string>> =
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)
+8 -2
View File
@@ -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);
+14 -2
View File
@@ -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 : {}),
});
@@ -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<Record<string, string | undefined>>;
}
).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),
);
@@ -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() {
})}
</div>
</section>
<section
className="gallery-section"
aria-labelledby="platform-features-title"
>
<header className="gallery-section__header">
<h2 id="platform-features-title"> </h2>
<p>
,
.
.
.
</p>
</header>
<div className="component-grid component-grid--two">
{features.map((status) => {
const badge = FEATURE_BADGE[status.state];
return (
<Card
key={status.featureId}
title={status.featureId}
footer={<Badge variant={badge.variant}>{badge.text}</Badge>}
>
<p data-product-feature={status.featureId}>
{badge.description}
</p>
</Card>
);
})}
</div>
</section>
</section>
);
}
+8
View File
@@ -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.",
+10 -1
View File
@@ -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 (
<nav id={id} aria-label={message("shell.primaryNavigation")}>
<ul className="app-navigation">
{NAVIGATION_ROUTES.map((definition) => (
{routes.map((definition) => (
<li key={definition.routeId}>
<NavLink
className={({ isActive }) =>
+34 -2
View File
@@ -18,6 +18,7 @@ import {
import {
getRoute,
ROUTE_FEATURE_OWNER,
ROUTE_REGISTRY,
} from "../../features/installed-feature-contracts.ts";
import type { RouteDefinition } from "../../contracts/routes.ts";
@@ -96,6 +97,26 @@ function InvalidRouteSurface({ code }: { code: string }) {
);
}
/**
* §3.5. A route whose feature the runtime document switched off. It answers as
* "not available" rather than rendering the feature or crashing, so disabling a
* feature is a deployment action and not an outage.
*/
function DisabledFeatureSurface({ featureId }: { featureId: string }) {
const { message } = useLocale();
return (
<section className="ui-page" data-surface="disabled-feature">
<PageHeader
title={message("route.disabledFeature.title")}
description={message("route.disabledFeature.description")}
/>
<p data-disabled-feature={featureId}>
{message("route.disabledFeature.action")}
</p>
</section>
);
}
function RouteLifecycle({
definition,
buildId,
@@ -245,11 +266,22 @@ function RegisteredRoute({
buildId: string;
}) {
const definition = getRoute(routeId);
const runtime = ROUTE_RUNTIME[routeId];
const params = useParams();
const [search] = useSearchParams();
const location = useLocation();
const { diagnostics, recovery } = useApplication();
const { diagnostics, runtime: platformRuntime, recovery } = useApplication();
// §3.5. A feature the runtime document disabled is out of service, not
// merely hidden: withdrawing it from navigation alone would leave a typed
// deep link that still mounts it.
const owner = ROUTE_FEATURE_OWNER[routeId];
if (owner !== undefined && !platformRuntime.isFeatureActive(owner)) {
return <DisabledFeatureSurface featureId={owner} />;
}
// The registry and the runtime table are composed from the same manifest, so
// a route without a component means the two disagree — refuse rather than
// crash the shell.
const runtime = ROUTE_RUNTIME[routeId];
if (!runtime) return <DisabledFeatureSurface featureId={owner ?? routeId} />;
const parsed = parseRouteInput(routeId, params, search);
if (!parsed.success) return <InvalidRouteSurface code={parsed.code} />;
+2 -2
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"template": "clean-architecture-frontend-template",
"sourceRepository": "https://git.learn.hyeonworks.com/donghyeon.kang/clean-architecture-frontend-template",
"sourceRevision": "a0fbafb77b814498fef1be63967921c707f315ac",
"sourceTree": "b955b062f28ec66bf84f6787ba40971f4e7b528f",
"sourceRevision": "5434760ddf88106d78e335c092367512bb34eacd",
"sourceTree": "f90673eab3a0ed949ed7483e99fba7ac0a0e9eaf",
"materialization": "tracked-snapshot"
}
@@ -32,6 +32,10 @@ test("issues an opaque capability and streams a verified object into the writabl
status: 200,
contentType: "application/json",
body: JSON.stringify({
// The wire envelope names the protocol it speaks. Without it the
// capability is refused before any object request is made, so the whole
// download path below was asserting on an empty transcript.
protocol: "PRESIGNED_TRANSFER_V1",
capabilityReceipt: "browser-download-capability-1",
method: "GET",
binding: {
@@ -307,6 +311,11 @@ test("issues an opaque capability and streams a verified object into the writabl
expect(bffRequests).toEqual([
{
// The capability request names the protocol it is asking for. Leaving it
// out of this expectation meant the fixture stopped describing the
// request the adapter actually sends when PRESIGNED_TRANSFER_V1 was
// hardened, and the object GET path stopped being exercised at all.
protocol: "PRESIGNED_TRANSFER_V1",
method: "GET",
binding: {
kind: "DOWNLOAD",
@@ -129,6 +129,10 @@ test("uploads three presigned parts through native IndexedDB, Web Locks and fetc
const objectPath =
`/uploads/${SESSION_ID}/parts/${String(partNumber)}`;
await fulfillJson(route, 200, {
// The part capability travels in a presigned envelope, so it names the
// transfer protocol even though its binding names the upload one.
// Without it every part was refused before any object PUT was made.
protocol: "PRESIGNED_TRANSFER_V1",
capabilityReceipt: `browser-part-capability-${String(partNumber)}`,
method: "PUT",
binding,
@@ -483,9 +487,12 @@ test("uploads three presigned parts through native IndexedDB, Web Locks and fetc
},
});
expect(result.checkpoint).toEqual({ ok: true, value: null });
// The deletion reports the physical effect it observed, not only the state it
// reached, so a caller can tell a delete that happened from one that found
// nothing to do. The fixture asserts it rather than ignoring it.
expect(result.deletion).toEqual({
ok: true,
value: { state: "DELETED" },
value: { state: "DELETED", effect: "APPLIED" },
});
expect(
uploadedBodies
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "../../src/features/installed-product-manifest.ts";
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
import { AppRouter } from "../../src/presentation/routes/app-router.tsx";
import { createTestApplication } from "../helpers/create-test-application.ts";
import { createProductFeaturesStub } from "../helpers/runtime-capabilities-stub.ts";
/**
* §3.5. The runtime kill switch, exercised through the running app rather than
* through the resolver that computes it.
*
* Withdrawing a feature from navigation is not the same as taking it out of
* service: a typed deep link would still mount it. Both halves are asserted
* here, on the same render, so the switch cannot be half-wired.
*/
const FEATURE_ID = INSTALLED_PRODUCT_FEATURE_IDS[0]!;
const FEATURE_ROUTE = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST;
function renderAt(path: string, disabled: boolean) {
window.history.pushState({}, "", path);
return render(
<ApplicationProvider
application={createTestApplication({
session: createAnonymousSessionAdapter(),
...(disabled
? {
productFeatures: createProductFeaturesStub({
[FEATURE_ID]: "DISABLED_BY_CONFIG",
}),
}
: {}),
})}
>
<AppRouter />
</ApplicationProvider>,
);
}
describe("runtime product feature switch", () => {
it("advertises the feature's route while the feature is active", async () => {
renderAt("/", false);
expect(
await screen.findByRole("link", { name: FEATURE_ROUTE.navigationLabel! }),
).toBeTruthy();
});
it("withdraws the feature's route from navigation when it is disabled", async () => {
renderAt("/", true);
// The shell itself still renders: disabling a feature is not an outage.
expect(await screen.findByRole("navigation")).toBeTruthy();
expect(
screen.queryByRole("link", { name: FEATURE_ROUTE.navigationLabel! }),
).toBeNull();
});
it("takes the feature out of service for a direct deep link", async () => {
renderAt(FEATURE_ROUTE.path, true);
const surface = await screen.findByText(
(_, element) =>
element?.getAttribute("data-disabled-feature") === FEATURE_ID,
{},
{ timeout: 5000 },
);
expect(surface).toBeTruthy();
});
it("serves the same deep link while the feature is active", async () => {
renderAt(FEATURE_ROUTE.path, false);
expect(
screen.queryByText(
(_, element) =>
element?.getAttribute("data-disabled-feature") === FEATURE_ID,
),
).toBeNull();
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
/**
* This assertion is about the reference feature, not about HTTP observability:
* it names the feature's own route ids. It used to live in
* `tests/integration/http-execution-v3-observability.test.ts`, which meant a
* platform integration file imported the removable feature's installed input.
* Removing the feature then left that file importing a deleted module, and the
* removability fixture failed on typecheck, the unit/integration run, coverage
* and residue at once four symptoms of one misplaced test.
*/
describe("reference feature installed operation executor", () => {
it("preserves the feature route id through the installed operation executor", async () => {
const seen: Array<Readonly<Record<string, unknown>>> = [];
const installed = createReferenceFeatureInstalledInput({
contractOperations: Object.freeze({
async execute(
_operationId: string,
_input: unknown,
context?: Readonly<Record<string, unknown>>,
) {
seen.push(Object.freeze({ ...(context ?? {}) }));
return Object.freeze({
kind: "SUCCESS" as const,
value: [],
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
},
}),
});
await installed.input.listResources({ limit: 20 });
await installed.input.getResource("resource-1");
expect(seen.map((context) => context.routeId)).toEqual([
"REFERENCE_RESOURCE_LIST",
"REFERENCE_RESOURCE_DETAIL",
]);
});
});
@@ -28,6 +28,7 @@ const runtime: Runtime = {
SERVICE_WORKER: "DEFAULT",
OFFLINE_COMMANDS: "DEFAULT",
},
FEATURE_OVERRIDES: {},
},
configSchema: "V2",
build: {
@@ -83,6 +84,23 @@ function referenceBoundQueryKey() {
).queryKey;
}
/**
* The installed feature inputs are partial by design: a feature the product
* manifest did not select supplies none. This suite is about the reference
* feature being composed, so it asserts that first and narrows once.
*/
function referenceInput<
Inputs extends Readonly<Partial<Record<typeof REFERENCE_FEATURE_ID, unknown>>>,
>(adapters: Readonly<{ featureInputs: Inputs }>) {
const input = adapters.featureInputs[REFERENCE_FEATURE_ID];
if (!input) {
throw new Error(
`${REFERENCE_FEATURE_ID} is not installed; the manifest did not select it`,
);
}
return input as NonNullable<Inputs[typeof REFERENCE_FEATURE_ID]>;
}
describe("reference feature runtime composition", () => {
it("invalidates a real bound query through the installed production graph", async () => {
const adapters = await createRuntimeAdapters({ runtime, release, host: {} });
@@ -117,7 +135,7 @@ describe("reference feature runtime composition", () => {
);
await expect(
adapters.featureInputs[REFERENCE_FEATURE_ID].listResources({ limit: 20 }),
referenceInput(adapters).listResources({ limit: 20 }),
).resolves.toEqual({
ok: true,
value: [
@@ -170,7 +188,7 @@ describe("reference feature runtime composition", () => {
});
await expect(
adapters.featureInputs[REFERENCE_FEATURE_ID].createResource(
referenceInput(adapters).createResource(
{ name: "Created resource" },
{ intent },
),
+3 -1
View File
@@ -4,7 +4,7 @@ import {
type ApplicationOutputPorts,
} from "../../src/application/create-application.ts";
import type { ApplicationFeatureInputs } from "../../src/application/ports/in/application-api.ts";
import { createRuntimeCapabilitiesStub } from "./runtime-capabilities-stub.ts";
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "./runtime-capabilities-stub.ts";
type TestApplicationOverrides = Partial<ApplicationOutputPorts> &
Readonly<{
@@ -52,6 +52,8 @@ export function createTestApplication(
},
runtimeCapabilities:
overrides.runtimeCapabilities ?? createRuntimeCapabilitiesStub(),
productFeatures:
overrides.productFeatures ?? createProductFeaturesStub(),
navigation: overrides.navigation ?? { reload: () => {} },
},
overrides.featureInputs,
@@ -1,3 +1,13 @@
import {
activeProductFeatureIds,
resolveProductFeatures,
type ProductFeatureState,
} from "../../src/contracts/product-features.ts";
import type { ProductFeaturesPort } from "../../src/application/ports/product-features-port.ts";
import {
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
} from "../../src/features/installed-product-manifest.ts";
import type {
RuntimeCapabilityId,
RuntimeCapabilityStatus,
@@ -32,3 +42,27 @@ export function createRuntimeCapabilitiesStub(
);
return Object.freeze({ getSnapshot: () => snapshot });
}
/**
* A product-feature port that reports the real manifest with nothing disabled.
* Tests that care about the switch build their own; the rest only need the
* boundary to be complete.
*/
export function createProductFeaturesStub(
overrides: Readonly<Record<string, ProductFeatureState>> = {},
): ProductFeaturesPort {
const snapshot = resolveProductFeatures(
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
Object.fromEntries(
Object.entries(overrides)
.filter(([, state]) => state === "DISABLED_BY_CONFIG")
.map(([featureId]) => [featureId, "DISABLED" as const]),
),
);
const active = new Set(activeProductFeatureIds(snapshot));
return Object.freeze({
getSnapshot: () => snapshot,
isActive: (featureId: string) => active.has(featureId),
});
}
@@ -5,7 +5,6 @@ import type {
HttpExecutionObservation,
} from "../../src/adapters/http/http-execution-v3.ts";
import { createHttpObservationProjector } from "../../src/bootstrap/runtime-adapters.ts";
import { createReferenceFeatureInstalledInput } from "../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
import {
DIAGNOSTIC_CONTEXT_ALLOWLIST,
projectDiagnosticRecord,
@@ -233,35 +232,6 @@ describe("V3 HTTP observability projection", () => {
expect(fenced.telemetry).toHaveLength(0);
});
it("preserves the feature route id through the installed operation executor", async () => {
const seen: Array<Readonly<Record<string, unknown>>> = [];
const installed = createReferenceFeatureInstalledInput({
contractOperations: Object.freeze({
async execute(
_operationId: string,
_input: unknown,
context?: Readonly<Record<string, unknown>>,
) {
seen.push(Object.freeze({ ...(context ?? {}) }));
return Object.freeze({
kind: "SUCCESS" as const,
value: [],
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
},
}),
});
await installed.input.listResources({ limit: 20 });
await installed.input.getResource("resource-1");
expect(seen.map((context) => context.routeId)).toEqual([
"REFERENCE_RESOURCE_LIST",
"REFERENCE_RESOURCE_DETAIL",
]);
});
it("cannot change the HTTP result when diagnostics or telemetry throws", async () => {
const projector = createHttpObservationProjector({
diagnostics: {
@@ -32,6 +32,7 @@ const runtime: Parameters<typeof loadReleaseManifest>[0] = {
SERVICE_WORKER: "DEFAULT",
OFFLINE_COMMANDS: "DEFAULT",
},
FEATURE_OVERRIDES: {},
},
configSchema: "V2",
validationDurationMs: 0,
@@ -129,6 +129,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:
+2 -1
View File
@@ -5,7 +5,7 @@ import {
type ApplicationOutputPorts,
} from "../../src/application/create-application.ts";
import { createTestApplication } from "../helpers/create-test-application.ts";
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
declare module "../../src/application/ports/in/application-api.ts" {
interface ApplicationFeatureInputs {
@@ -99,6 +99,7 @@ describe("application input/output boundary", () => {
}),
},
runtimeCapabilities: createRuntimeCapabilitiesStub(),
productFeatures: createProductFeaturesStub(),
navigation: { reload: () => {} },
} satisfies ApplicationOutputPorts;
const application = createApplication(ports);
+2 -1
View File
@@ -5,7 +5,7 @@ import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-
import type { StoragePort } from "../../src/application/ports/storage-port.ts";
import type { DiagnosticsPort } from "../../src/application/ports/diagnostics-port.ts";
import type { TelemetryPort } from "../../src/application/ports/telemetry-port.ts";
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
type ReleaseFixture = {
buildId: string;
@@ -53,6 +53,7 @@ function applicationWith(options: {
diagnostics: options.diagnostics ?? { record: () => {} },
telemetry: options.telemetry ?? { emit: () => {} },
runtimeCapabilities: createRuntimeCapabilitiesStub(),
productFeatures: createProductFeaturesStub(),
releaseInfo: {
getCurrent: async () => current,
refresh:
+181 -38
View File
@@ -1,6 +1,6 @@
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { constants } from "node:fs";
import { constants, readFileSync } from "node:fs";
import { cp, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -20,6 +20,7 @@ import {
} from "../../scripts/lib/ci-artifact-validator.ts";
import { writeCiGateLogAtomic } from "../../scripts/lib/ci-gate-log.ts";
import { linkFixtureNodeModules } from "../../scripts/lib/fixture-node-modules.ts";
import { copyReleaseEvidenceTree } from "../../scripts/lib/removal-fixture.ts";
import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../scripts/lib/ci-candidate-archive.ts";
import {
CANDIDATE_ARCHIVE_USAGE,
@@ -46,6 +47,14 @@ import {
RELEASE_CANDIDATE_MANIFEST_PATH,
} from "../../scripts/lib/release-candidate.ts";
/**
* Budget for the provider suites specifically. They spawn a systemd scope, a
* bubblewrap sandbox and a signing provider, and build a release candidate to
* do it; the 10s default is sized for pure-JS unit tests. Raising the global
* default instead would hide a genuinely hung test.
*/
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
const temporaryRoots: string[] = [];
let providerBaseRoot: string | undefined;
const sha256 = (value: Buffer | string) =>
@@ -756,6 +765,10 @@ describe("candidate archive and provider upload boundaries", () => {
},
});
const completionStarted = Date.now();
// Start recording before anything is asserted: the provider's whole life is
// shorter than one `systemctl show`, so the tree has to be sampled, not
// sampled once at whatever moment the assertions happen to arrive.
const tree = recordProviderProcessTree(execution.child.pid!);
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
const properties = showProviderUnit(unit);
expect(properties).toMatchObject({
@@ -775,10 +788,21 @@ describe("candidate archive and provider upload boundaries", () => {
await expect(readFile(path.join(cgroupRoot, "cpu.max"), "utf8")).resolves.toBe("100000 100000\n");
expect(readProviderUnitMetadata(unit)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
expect(showProcessArguments(execution.child.pid)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
const cgroupPids = await readCgroupPids(cgroupRoot);
const processArguments = cgroupPids.map((pid) => showProcessArguments(pid));
const directChildPids = await waitForDirectProviderChildren(execution.child.pid!);
const directChildArguments = directChildPids.map((pid) => showProcessArguments(pid));
const reportIdentity = await lstat(fixture.reportPath);
const result = await execution.completion;
tree.stop();
expect(result.code, result.stderr).toBe(0);
expect(result.stdout).not.toContain(credential);
expect(result.stderr).not.toContain(credential);
// Everything below reads the recording of the whole run rather than a live
// snapshot. The sandbox exists for a few hundred milliseconds; asserting
// while it runs meant racing it, and the assertions are about what the run
// contained, not about what a particular instant looked like.
const cgroupPids = tree.cgroupPids();
const processArguments = tree.cgroupArguments();
const directChildPids = tree.directChildPids();
const directChildArguments = tree.directChildArguments();
const observedArguments = [
showProcessArguments(execution.child.pid),
...directChildArguments,
@@ -788,18 +812,15 @@ describe("candidate archive and provider upload boundaries", () => {
expect(directChildArguments.filter((arguments_) => arguments_.includes("/usr/bin/systemd-run"))).toHaveLength(1);
expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).toHaveLength(1);
expect(processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts"))).toHaveLength(1);
expect(processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length)
.toBeGreaterThan(0);
expect(
processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length,
`sandbox never observed in the scope; recorded: ${processArguments.join(" | ")}`,
).toBeGreaterThan(0);
const infrastructureArguments = [...directChildArguments, ...processArguments].filter((arguments_) =>
/provider-(?:scope-wrapper|raw-guardian)|systemd-run|bwrap|prlimit/u.test(arguments_),
);
expect(infrastructureArguments.join("\n")).not.toContain(command);
expect(processArguments.filter((arguments_) => arguments_.includes(providerScript))).toHaveLength(1);
const reportIdentity = await lstat(fixture.reportPath);
const result = await execution.completion;
expect(result.code, result.stderr).toBe(0);
expect(result.stdout).not.toContain(credential);
expect(result.stderr).not.toContain(credential);
expect(Date.now() - completionStarted).toBeLessThan(4_000);
await expectProviderUnitGone(unit);
await expect(lstat(cgroupRoot)).rejects.toMatchObject({ code: "ENOENT" });
@@ -807,7 +828,7 @@ describe("candidate archive and provider upload boundaries", () => {
expect(directChildPids.every((pid) => !processExists(pid))).toBe(true);
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(findProviderRawReferences(fixture.reportPath, reportIdentity)).resolves.toEqual([]);
}, 10_000);
}, PROCESS_HEAVY_TIMEOUT_MS);
it("kills and collects an active provider when its guardian dies", async () => {
const fixture = await createProviderFixture();
@@ -842,12 +863,15 @@ describe("candidate archive and provider upload boundaries", () => {
expect((await readdir(path.dirname(sealedPath))).filter((leaf) => leaf.includes(".guardian-")))
.toEqual([]);
// `providerWriter` is only a path; the retry has to materialise the script
// it names or the clean-retry claim is proven by a module-not-found error.
await writeFile(fixture.providerWriter, providerV2WriterSource());
const retried = runProviderSupervisor(fixture, {
command: `node ${JSON.stringify(fixture.providerWriter)}`,
sealedPath,
});
expect(retried.status, retried.stderr).toBe(0);
}, 15_000);
}, 20_000);
it("collects the whole provider scope when its supervisor dies", async () => {
const fixture = await createProviderFixture();
@@ -1272,9 +1296,17 @@ describe("verified promotion finalizer", () => {
"provider-verification.json": valid["promotion-verification.json"],
"promotion-verification.json": valid["provider-verification.json"],
};
// `{}` never reaches the digest comparison: it fails the report schema
// first, so this case asserted a decode error while claiming to cover the
// digest branch. The substitution has to be a structurally valid report
// that simply is not the one the verification records committed to.
const divergentReport = JSON.parse(
valid["vulnerability-report.json"].toString("utf8"),
) as Record<string, any>;
divergentReport.provider = "divergent-provider";
const reportMismatch = {
...valid,
"vulnerability-report.json": Buffer.from("{}\n"),
"vulnerability-report.json": jsonBytes(divergentReport),
};
const absent = { ...valid } as Partial<typeof valid>;
delete absent["provider-verification.json"];
@@ -1513,7 +1545,10 @@ describe("verified promotion finalizer", () => {
}),
).rejects.toThrow(/leaf.*identity|staging leaf/u);
await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n");
await expect(readdir(saved)).resolves.toEqual([]);
// A substituted leaf aborts the cleanup before anything is removed, so the
// promotion this call owned is still intact and a retry sees a coherent
// directory rather than a half-emptied one.
expect((await readdir(saved)).sort()).toEqual([...PROMOTED_FILE_NAMES].sort());
}, 30_000);
it("never deletes an unrelated leaf substituted after cleanup validation", async () => {
@@ -1678,28 +1713,31 @@ async function readCgroupPids(cgroupRoot: string): Promise<number[]> {
.trim().split("\n").filter(Boolean).map(Number);
}
async function waitForDirectProviderChildren(supervisorPid: number): Promise<number[]> {
for (let attempt = 0; attempt < 120; attempt += 1) {
const childrenPath = `/proc/${supervisorPid}/task/${supervisorPid}/children`;
const children = (await readFile(childrenPath, "utf8"))
.trim().split(/\s+/u).filter(Boolean).map(Number);
const arguments_ = children.flatMap((pid) => {
try {
return [showProcessArguments(pid)];
} catch (error) {
if (!processExists(pid)) return [];
throw error;
}
});
async function waitForRecordedProviderTree(
tree: ReturnType<typeof recordProviderProcessTree>,
providerScript: string,
): Promise<void> {
for (let attempt = 0; attempt < 400; attempt += 1) {
const children = tree.directChildArguments();
const members = tree.cgroupArguments();
// Every process the assertions below reason about. Returning as soon as
// some of them are present is what left bubblewrap out of the recording:
// it enters the scope a few milliseconds after the wrapper does.
if (
arguments_.some((value) => value.includes("/usr/bin/systemd-run")) &&
arguments_.some((value) => value.includes("provider-raw-guardian.ts"))
children.some((value) => value.includes("/usr/bin/systemd-run")) &&
children.some((value) => value.includes("provider-raw-guardian.ts")) &&
members.some((value) => value.includes("provider-scope-wrapper.ts")) &&
members.some((value) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(value)) &&
members.some((value) => value.includes(providerScript))
) {
return children;
return;
}
await delay(25);
await delay(10);
}
throw new Error("provider supervisor children were not simultaneously observable");
throw new Error(
"the provider run never contained a systemd-run child, a guardian child, " +
"a scope wrapper, a sandbox and the provider itself in its cgroup",
);
}
async function waitForDirectChildMatching(supervisorPid: number, pattern: string): Promise<number> {
@@ -1844,6 +1882,8 @@ function readProviderUnitMetadata(unitName: string): string {
function showProcessArguments(pid: number | undefined): string {
if (!pid) throw new Error("provider supervisor did not expose its PID");
const argv = readProcessArguments(pid);
if (argv !== null) return argv;
const result = spawnSync(
"/usr/bin/ps",
["-o", "args=", "-p", String(pid)],
@@ -1854,6 +1894,102 @@ function showProcessArguments(pid: number | undefined): string {
return result.stdout.trim();
}
/**
* Reads a process's argv straight from `/proc`.
*
* Spawning `ps` per pid costs milliseconds each, and the provider sandbox now
* completes a whole run in well under a second the observation was losing a
* race against the thing it was observing. Returns null for a process that is
* already gone, which the sampler treats as "nothing more to record".
*/
function readProcessArguments(pid: number): string | null {
try {
return readFileSync(`/proc/${pid}/cmdline`, "utf8")
.split("\0")
.filter(Boolean)
.join(" ")
.trim();
} catch {
return null;
}
}
/**
* Records the provider's process tree for the whole life of the run.
*
* The assertions below are about what the sandbox looked like while it was
* running, and a single snapshot taken afterwards can only ever be a guess at
* that. Sampling from the moment the supervisor starts turns "did we look at
* the right instant?" into "what did this run actually contain?".
*/
function recordProviderProcessTree(supervisorPid: number) {
const directChildren = new Map<number, string>();
const cgroupMembers = new Map<number, string>();
let stopped = false;
const remember = (into: Map<number, string>, pid: number): void => {
if (into.has(pid)) return;
const argv = readProcessArguments(pid);
if (argv !== null && argv.length > 0) into.set(pid, argv);
};
const childrenOf = (pid: number): number[] => {
try {
return readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8")
.trim()
.split(/\s+/u)
.filter(Boolean)
.map(Number);
} catch {
return [];
}
};
/**
* Scope membership is read from the process itself rather than from
* `systemctl show`. Waiting for the unit to be described before watching its
* cgroup meant bubblewrap had usually already exited by the time the first
* sample was taken the recording missed exactly the process the assertions
* are about.
*/
const inProviderScope = (pid: number): boolean => {
try {
return readFileSync(`/proc/${pid}/cgroup`, "utf8").includes("ca-provider-");
} catch {
return false;
}
};
const sample = (): void => {
if (stopped) return;
const direct = childrenOf(supervisorPid);
for (const pid of direct) remember(directChildren, pid);
// Walk the whole subtree: the scope wrapper, bubblewrap and the provider
// itself sit below systemd-run, not beside it.
const pending = [...direct];
const seen = new Set(direct);
while (pending.length > 0 && seen.size < 512) {
const pid = pending.pop()!;
if (inProviderScope(pid)) remember(cgroupMembers, pid);
for (const child of childrenOf(pid)) {
if (seen.has(child)) continue;
seen.add(child);
pending.push(child);
}
}
};
const timer = setInterval(sample, 5);
timer.unref();
sample();
return Object.freeze({
stop() {
stopped = true;
clearInterval(timer);
},
directChildPids: () => [...directChildren.keys()],
directChildArguments: () => [...directChildren.values()],
cgroupPids: () => [...cgroupMembers.keys()],
cgroupArguments: () => [...cgroupMembers.values()],
});
}
async function startLoopbackCanary(root: string): Promise<Readonly<{
child: ChildProcess;
marker: string;
@@ -2128,10 +2264,17 @@ async function ensureProviderBaseFixture(): Promise<string> {
return ![".release", "artifacts", "dist", "node_modules"].includes(first ?? "");
},
});
await cp(path.join(sourceRoot, "artifacts"), path.join(root, "artifacts"), {
recursive: true,
});
await rm(path.join(root, "artifacts/release"), { recursive: true, force: true });
// The release evidence, minus the trace and Storybook trees. Copying the
// whole `artifacts/` directory pulled ~28MB of test output into every
// provider fixture; sharing the copier with the removal fixture is also what
// makes both fixtures contain the same evidence.
// `artifacts/release` travels with the fixture rather than being deleted and
// rebuilt. Supply-chain generation runs before the candidate is created and
// validates the release evidence paths, so deleting them made that step
// depend on a previous local run having left them behind — which is why this
// fixture only worked in a workspace that had already built a candidate. The
// build chain overwrites them anyway.
await copyReleaseEvidenceTree(sourceRoot, root);
await linkFixtureNodeModules(root, sourceRoot);
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
cwd: sourceRoot,
+5 -5
View File
@@ -184,16 +184,16 @@ describe("CI gate contract", () => {
const index = indexCiGateContract(contract);
expect(contract.schemaVersion).toBe(2);
expect(contract.gates.map(({ id }) => id)).toEqual(
Array.from({ length: 26 }, (_, index) =>
Array.from({ length: 27 }, (_, index) =>
`FE-GATE-${String(index + 1).padStart(3, "0")}`,
),
);
expect(contract.jobs).toHaveLength(9);
expect(contract.commands).toHaveLength(81);
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(93);
expect(contract.commands).toHaveLength(82);
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(94);
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(85);
expect(contract.artifacts).toHaveLength(105);
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(86);
expect(contract.artifacts).toHaveLength(107);
expect(contract.stages).toHaveLength(5);
expect(contract.retention.classes).toHaveLength(5);
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
+166
View File
@@ -0,0 +1,166 @@
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
DEPLOYMENT_TARGETS,
findAdmissionViolations,
isDeploymentTarget,
type AdmissionInput,
} from "../../src/contracts/deployment-admission.ts";
import { runtimeConfigV2ArtifactSchema } from "../../src/contracts/release-artifacts.ts";
import { generateRuntimeConfig } from "../../scripts/generate-runtime-config.ts";
const PRODUCTION_ARTIFACT: AdmissionInput = Object.freeze({
APP_ENV: "production",
API_BASE_URL: "https://api.example.com/",
REQUEST_TIMEOUT_MS: 10_000,
MAX_RETRY_ATTEMPTS: 2,
TELEMETRY_ENABLED: false,
AUTH_MODE: "external",
CONFIG_SCHEMA_VERSION: "2.0",
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "20260815.42",
RELEASE_ID: "r-2026.08.15-1",
CAPABILITY_OVERRIDES: Object.freeze({
REALTIME: "DEFAULT",
WEB_WORKER: "DEFAULT",
SERVICE_WORKER: "DEFAULT",
OFFLINE_COMMANDS: "DEFAULT",
}),
}) as AdmissionInput;
const LOCAL_ARTIFACT: AdmissionInput = Object.freeze({
...PRODUCTION_ARTIFACT,
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080/",
AUTH_MODE: "demo",
BUILD_ID: "local-build",
RELEASE_ID: "local-release",
}) as AdmissionInput;
describe("deployment admission", () => {
it("admits an artifact only to the environment it declares", () => {
expect(findAdmissionViolations("production", PRODUCTION_ARTIFACT)).toEqual([]);
expect(findAdmissionViolations("local", LOCAL_ARTIFACT)).toEqual([]);
});
it("refuses the exact local build that release coherence used to approve", () => {
// The review's strongest reproduction: FE-GATE-015 passed on a build whose
// runtime document was APP_ENV=local / AUTH_MODE=demo / loopback API. Each
// of those is now an independent refusal, so fixing one does not admit it.
const violations = findAdmissionViolations("production", LOCAL_ARTIFACT);
const fields = violations.map((violation) => violation.field);
expect(fields).toContain("APP_ENV");
expect(fields).toContain("AUTH_MODE");
expect(fields).toContain("API_BASE_URL");
expect(fields).toContain("BUILD_ID");
expect(fields).toContain("RELEASE_ID");
});
it("refuses endpoints a browser on the public internet cannot reach", () => {
for (const host of [
"http://api.example.com/",
"https://localhost/",
"https://127.0.0.1/",
"https://10.0.0.5/",
"https://192.168.1.10/",
"https://172.16.4.4/",
"https://169.254.169.254/",
"https://[::1]/",
]) {
const violations = findAdmissionViolations("production", {
...PRODUCTION_ARTIFACT,
API_BASE_URL: host,
} as AdmissionInput);
expect(violations.map((violation) => violation.field), host).toContain(
"API_BASE_URL",
);
}
});
it("permits a routable public host", () => {
expect(
findAdmissionViolations("production", {
...PRODUCTION_ARTIFACT,
API_BASE_URL: "https://api.172.16.example.com/",
} as AdmissionInput),
).toEqual([]);
});
it("refuses a placeholder identity on a public target", () => {
for (const buildId of ["local-build", "local", "dev", "unknown", ""]) {
const violations = findAdmissionViolations("production", {
...PRODUCTION_ARTIFACT,
BUILD_ID: buildId,
} as AdmissionInput);
expect(violations.map((violation) => violation.field), buildId).toContain(
"BUILD_ID",
);
}
});
it("treats an unknown target as not a target at all", () => {
for (const value of ["prod", "PRODUCTION", "", undefined, null, 1]) {
expect(isDeploymentTarget(value), String(value)).toBe(false);
}
for (const target of DEPLOYMENT_TARGETS) {
expect(isDeploymentTarget(target)).toBe(true);
}
});
});
describe("runtime config profiles", () => {
it("ships one valid profile per deployment target", async () => {
const files = (await readdir("config/runtime")).sort();
expect(files).toEqual(
[...DEPLOYMENT_TARGETS].map((target) => `${target}.json`).sort(),
);
for (const target of DEPLOYMENT_TARGETS) {
const source: unknown = JSON.parse(
await readFile(path.join("config/runtime", `${target}.json`), "utf8"),
);
const parsed = runtimeConfigV2ArtifactSchema.safeParse({
...(source as Record<string, unknown>),
BUILD_ID: "20260815.42",
RELEASE_ID: "r-1",
});
expect(parsed.success, `${target}: ${JSON.stringify(parsed.error?.issues)}`).toBe(
true,
);
expect((source as Record<string, unknown>)["APP_ENV"]).toBe(target);
}
});
it("produces an admissible document for every public target", async () => {
for (const target of ["staging", "production"] as const) {
const config = await generateRuntimeConfig(target, {
VITE_BUILD_ID: "20260815.42",
RELEASE_ID: "r-2026.08.15-1",
});
expect(
findAdmissionViolations(target, config as unknown as AdmissionInput),
).toEqual([]);
}
});
it("refuses a deployment override that would make the document unservable", async () => {
await expect(
generateRuntimeConfig("production", {
VITE_BUILD_ID: "20260815.42",
RELEASE_ID: "r-1",
RUNTIME_API_BASE_URL: "http://api.example.com/",
}),
).rejects.toThrow(/runtime config is invalid/u);
});
it("keeps a developer build from carrying a released identity by default", async () => {
const config = await generateRuntimeConfig("local", {});
expect(config["BUILD_ID"]).toBe("local-build");
expect(
findAdmissionViolations("production", config as unknown as AdmissionInput)
.length,
).toBeGreaterThan(0);
});
});
+70
View File
@@ -66,6 +66,76 @@ async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
}
describe("runtime request deadline ceiling", () => {
/**
* `REQUEST_TIMEOUT_MS` was validated by the runtime config schema and then
* never handed to the V3 executor, so the deployment dial did nothing and
* every operation ran on its contract's own deadline. It is a ceiling: it may
* tighten an operation, never loosen one.
*/
async function settlesWithin(
contractDeadlineMs: number,
ceilingMs: number | undefined,
advanceMs: number,
): Promise<boolean> {
vi.useFakeTimers();
try {
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
...(ceilingMs === undefined ? {} : { requestDeadlineCeilingMs: ceilingMs }),
attachCredentials: () => ({ kind: "READY", headers: {} }),
// A request that only ever ends by being cut off, so what settles it is
// exactly the deadline under test.
fetcher: (_input, init) =>
new Promise((_resolve, reject) => {
const signal = (init as RequestInit | undefined)?.signal;
signal?.addEventListener(
"abort",
() => reject(new DOMException("aborted", "AbortError")),
{ once: true },
);
}),
});
let settled = false;
const pending = executor
.execute(operation({ deadlineMs: contractDeadlineMs }), {}, {
routeId: ROUTE_ID,
scope,
})
.then(
() => { settled = true; },
() => { settled = true; },
);
await vi.advanceTimersByTimeAsync(advanceMs);
await flushMicrotasks();
const observed = settled;
if (!observed) await vi.advanceTimersByTimeAsync(contractDeadlineMs + 1_000);
await pending;
return observed;
} finally {
vi.useRealTimers();
}
}
it("applies the tighter of the contract and deployment bounds", async () => {
await expect(settlesWithin(5_000, 500, 800)).resolves.toBe(true);
await expect(settlesWithin(5_000, undefined, 800)).resolves.toBe(false);
});
it("never extends a contract deadline", async () => {
await expect(settlesWithin(500, 60_000, 800)).resolves.toBe(true);
});
it("ignores a ceiling that is not a usable duration", async () => {
for (const ceiling of [0, -1, Number.NaN]) {
await expect(settlesWithin(5_000, ceiling, 800), String(ceiling)).resolves.toBe(
false,
);
}
});
});
describe("descriptor-driven HTTP execution lifetime", () => {
it("normalizes a read-side 429 to the non-applicable effect vocabulary", async () => {
const executor = createContractHttpExecutor({
+36
View File
@@ -242,6 +242,42 @@ describe("presigned transfer", () => {
);
});
it("answers a refused capability envelope with a re-issuable recovery", async () => {
// BT-PRE-04. A capability document the adapter will not accept is closed as
// `POLICY_REJECTED`, and the caller's only way forward is a new capability.
// `NONE` said there was nothing to be done, which contradicted both the
// design record for an unsupported protocol and the vault, which already
// answers `REISSUE_CAPABILITY` for the same class of refusal.
for (const [label, overrides] of [
["unknown protocol", { protocol: "PRESIGNED_TRANSFER_V2" }],
["missing protocol", { protocol: undefined }],
] as const) {
const bytes = new Uint8Array([1, 2, 3]);
const payload: Record<string, unknown> = {
...downloadCapabilityPayload(bytes),
...overrides,
};
if (overrides.protocol === undefined) delete payload["protocol"];
const fetcher = vi.fn(async () => jsonResponse(payload)) as unknown as typeof fetch;
const { provider } = createHarness({ fetcher });
expect(
await provider.issueDownload({
resourceId: "resource-1",
signal: new AbortController().signal,
}),
label,
).toMatchObject({
ok: false,
error: {
code: "POLICY_REJECTED",
retryable: false,
recovery: "REISSUE_CAPABILITY",
},
});
}
});
it("keeps URL and headers adapter-private and streams bounded verified chunks", async () => {
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
const payload = downloadCapabilityPayload(bytes);
+222
View File
@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import {
activeProductFeatureIds,
resolveProductFeatures,
selectCompiledProductFeatures,
} from "../../src/contracts/product-features.ts";
import { runtimeConfigV2ArtifactSchema } from "../../src/contracts/release-artifacts.ts";
import {
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
} from "../../src/features/installed-product-manifest.ts";
import {
ROUTE_FEATURE_OWNER,
ROUTE_REGISTRY,
} from "../../src/features/installed-feature-contracts.ts";
const COMPILED = Object.freeze([
Object.freeze({ featureId: "reference-feature" }),
Object.freeze({ featureId: "billing" }),
]);
describe("build-time product selection", () => {
it("keeps everything when nothing is declared", () => {
for (const declared of [undefined, "", " "]) {
expect(
selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId),
String(declared),
).toEqual(["reference-feature", "billing"]);
}
});
it("narrows to the declared subset", () => {
expect(
selectCompiledProductFeatures(COMPILED, "billing").map((f) => f.featureId),
).toEqual(["billing"]);
expect(
selectCompiledProductFeatures(COMPILED, " billing , reference-feature ").map(
(f) => f.featureId,
),
).toEqual(["reference-feature", "billing"]);
});
it("selects nothing only when asked explicitly", () => {
// A blank value keeps everything on purpose: an unset CI variable expands
// to a blank string, and that must not be how a build ships no features.
expect(selectCompiledProductFeatures(COMPILED, "none")).toEqual([]);
expect(selectCompiledProductFeatures(COMPILED, " none ")).toEqual([]);
expect(selectCompiledProductFeatures(COMPILED, "").length).toBe(2);
// A value that parses to no names at all is a typo, not an instruction.
expect(() => selectCompiledProductFeatures(COMPILED, ",")).toThrow(
/names no feature/u,
);
});
it("refuses to name a feature this build does not contain", () => {
// The whole point of the direction rule: an environment value can subtract
// from the source tree and must never be able to add to it. Accepting an
// unknown id silently would let a deployment believe it had switched on
// something that is not in the bundle.
expect(() => selectCompiledProductFeatures(COMPILED, "analytics")).toThrow(
/does not contain: analytics/u,
);
expect(() =>
selectCompiledProductFeatures(COMPILED, "billing,analytics"),
).toThrow(/analytics/u);
});
it("refuses a duplicated feature id in the manifest", () => {
expect(() =>
selectCompiledProductFeatures(
[{ featureId: "a" }, { featureId: "a" }],
undefined,
),
).toThrow(/duplicate product feature id/u);
});
});
describe("runtime product feature resolution", () => {
it("reports active, disabled and not-installed distinctly", () => {
const statuses = resolveProductFeatures(
["reference-feature", "billing"],
["reference-feature"],
{ "reference-feature": "DISABLED" },
);
expect(statuses).toEqual([
{ featureId: "billing", state: "NOT_INSTALLED" },
{ featureId: "reference-feature", state: "DISABLED_BY_CONFIG" },
]);
expect(activeProductFeatureIds(statuses)).toEqual([]);
});
it("cannot switch on a feature the build left out", () => {
// `DEFAULT` on an uninstalled feature is not an instruction to install it.
const statuses = resolveProductFeatures(["billing"], [], {
billing: "DEFAULT",
});
expect(statuses).toEqual([{ featureId: "billing", state: "NOT_INSTALLED" }]);
expect(activeProductFeatureIds(statuses)).toEqual([]);
});
it("ignores an override naming a feature this build never declared", () => {
// A shared runtime document may cover several builds, so a stale key is
// inert rather than fatal.
const statuses = resolveProductFeatures(
["reference-feature"],
["reference-feature"],
{ analytics: "DISABLED" },
);
expect(activeProductFeatureIds(statuses)).toEqual(["reference-feature"]);
});
it("leaves an installed feature active without an override", () => {
const statuses = resolveProductFeatures(
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
);
expect(activeProductFeatureIds(statuses)).toEqual([
...INSTALLED_PRODUCT_FEATURE_IDS,
]);
});
});
describe("runtime config carries the switch", () => {
const base = {
APP_ENV: "local" as const,
API_BASE_URL: "http://localhost:8080/",
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo" as const,
CONFIG_SCHEMA_VERSION: "2.0" as const,
RELEASE_MANIFEST_URL: "/release-manifest.json",
};
it("defaults to disabling nothing", () => {
const parsed = runtimeConfigV2ArtifactSchema.parse(base);
expect(parsed.FEATURE_OVERRIDES).toEqual({});
});
it("accepts only DEFAULT or DISABLED", () => {
expect(
runtimeConfigV2ArtifactSchema.parse({
...base,
FEATURE_OVERRIDES: { "reference-feature": "DISABLED" },
}).FEATURE_OVERRIDES,
).toEqual({ "reference-feature": "DISABLED" });
// There is no "ENABLED": the vocabulary itself is what makes the rule
// unbreakable, not a check somewhere downstream.
expect(
runtimeConfigV2ArtifactSchema.safeParse({
...base,
FEATURE_OVERRIDES: { "reference-feature": "ENABLED" },
}).success,
).toBe(false);
});
it("refuses a malformed feature id", () => {
for (const featureId of ["Reference", "reference_feature", "", "-x"]) {
expect(
runtimeConfigV2ArtifactSchema.safeParse({
...base,
FEATURE_OVERRIDES: { [featureId]: "DISABLED" },
}).success,
featureId,
).toBe(false);
}
});
});
describe("every installed registry consults the manifest", () => {
/**
* The manifest only means something if each registry actually asks it. A new
* registry that spreads a feature in directly would reintroduce exactly the
* coupling this file exists to remove, and nothing else would notice.
*/
it("gates every feature contribution on the selection", async () => {
const { readdir, readFile } = await import("node:fs/promises");
const nodePath = (await import("node:path")).default;
const root = "src/features";
const registries = (await readdir(root)).filter((entry) =>
/^installed-.*\.tsx?$/u.test(entry),
);
expect(registries.length).toBeGreaterThan(3);
const exempt = new Set([
// The manifest is the selection.
"installed-product-manifest.ts",
// Capabilities have their own §3.5 selection file and override vocabulary.
"installed-runtime-capabilities.ts",
// Message keys stay total on purpose; see the file for why.
"installed-feature-messages.ts",
]);
for (const registry of registries) {
if (exempt.has(registry)) continue;
const source = await readFile(nodePath.join(root, registry), "utf8");
expect(
/INSTALLED_PRODUCT_FEATURE(S|_IDS)/u.test(source),
`${registry} must compose from the product manifest`,
).toBe(true);
}
});
});
describe("route ownership", () => {
it("attributes every feature route to its feature and no platform route", () => {
for (const featureId of INSTALLED_PRODUCT_FEATURE_IDS) {
expect(Object.values(ROUTE_FEATURE_OWNER)).toContain(featureId);
}
// Platform routes have no owner, so disabling a feature can never withdraw
// the shell's own navigation.
for (const routeId of ["APP_HOME", "NOT_FOUND", "EXAMPLES_PLATFORM"]) {
expect(ROUTE_FEATURE_OWNER[routeId], routeId).toBeUndefined();
expect(Object.keys(ROUTE_REGISTRY)).toContain(routeId);
}
});
it("owns exactly the routes the registry received from features", () => {
const owned = Object.keys(ROUTE_FEATURE_OWNER);
expect(owned.length).toBeGreaterThan(0);
for (const routeId of owned) {
expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId);
}
});
});
@@ -18,6 +18,14 @@ import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
/**
* This suite's budget, not the file's. The 10s default is sized for pure-JS
* unit tests; these spawn processes, build archives and sign evidence, and on a
* machine running the rest of the suite in parallel they legitimately need
* longer. Raising the global default instead would hide a genuinely hung test.
*/
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
const roots: string[] = [];
afterEach(async () => {
@@ -699,7 +707,7 @@ describe("provider guardian transaction protocol", () => {
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(readdir(evidenceRoot)).resolves.toEqual(["untrusted"]);
});
}, PROCESS_HEAVY_TIMEOUT_MS);
it("still publishes near the lease deadline when post-processing completes in time", async () => {
const { startProviderGuardian } = await import(
+1
View File
@@ -74,6 +74,7 @@ const runtimeV2 = {
SERVICE_WORKER: "DEFAULT",
OFFLINE_COMMANDS: "DEFAULT",
},
FEATURE_OVERRIDES: {},
} as const satisfies RuntimeConfigArtifact;
async function releaseV2With(
+45 -1
View File
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { loadCiGateContract } from "../../scripts/contracts/ci-gates.ts";
import {
@@ -88,3 +88,47 @@ it("rejects pruning that leaves a reduced gate without commands", async () => {
removedEvidencePathFragments: ["runtime-schema.xml"],
})).rejects.toThrow(/commandIds|too small|at least 1/i);
});
describe("release evidence fixture copy", () => {
it("keeps the release evidence and leaves the regenerated trees behind", async () => {
const { copyReleaseEvidenceTree, RELEASE_EVIDENCE_REGENERATED_TREES } =
await import("../../scripts/lib/removal-fixture.ts");
const { RELEASE_CANDIDATE_EVIDENCE_PATHS } = await import(
"../../scripts/lib/release-candidate.ts"
);
const { mkdtemp, access, readdir } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const nodePath = (await import("node:path")).default;
const root = await mkdtemp(nodePath.join(tmpdir(), "release-evidence-"));
await copyReleaseEvidenceTree(process.cwd(), root);
// Every release artifact that exists here has to survive the copy; a
// fixture missing one cannot build a candidate at all, and every provider
// suite then fails while constructing its own fixture.
//
// Which ones exist depends on what this checkout has generated — a product
// repository that has not run the release chain has fewer than the template
// does — so the subject is preservation, not the presence of a full chain.
// Requiring at least one keeps that from quietly asserting nothing.
let preserved = 0;
for (const evidence of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
if (!evidence.startsWith("artifacts/")) continue;
try {
await access(evidence);
} catch {
continue;
}
await expect(access(nodePath.join(root, evidence)), evidence).resolves.toBeUndefined();
preserved += 1;
}
expect(preserved).toBeGreaterThan(0);
// The regenerated trees are why this is a filter and not a plain copy: they
// are tens of megabytes of traces and coverage HTML. They still exist,
// because the repository inventory expects the directories.
for (const tree of RELEASE_EVIDENCE_REGENERATED_TREES) {
await expect(access(nodePath.join(root, tree)), tree).resolves.toBeUndefined();
await expect(readdir(nodePath.join(root, tree)), tree).resolves.toEqual([]);
}
});
});
+9 -1
View File
@@ -24,6 +24,14 @@ import {
type ProductionModuleInventory,
} from "../../scripts/lib/risk-coverage.ts";
/**
* This suite's budget, not the file's. The 10s default is sized for pure-JS
* unit tests; these spawn processes, build archives and sign evidence, and on a
* machine running the rest of the suite in parallel they legitimately need
* longer. Raising the global default instead would hide a genuinely hung test.
*/
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
const roots: string[] = [];
const now = Date.parse("2026-08-02T00:00:00.000Z");
const execFileAsync = promisify(execFile);
@@ -141,7 +149,7 @@ describe("repository-aware risk coverage", () => {
result.repositoryTotal - result.selectedTotal,
);
expect(result.status).toBe("FAIL");
});
}, PROCESS_HEAVY_TIMEOUT_MS);
it("reports exact inventory and generated-exclusion provenance", async () => {
const repositoryRoot = await repositoryFixture();
+2
View File
@@ -26,6 +26,7 @@ const runtime: Runtime = {
SERVICE_WORKER: "DEFAULT",
OFFLINE_COMMANDS: "DEFAULT",
},
FEATURE_OVERRIDES: {},
},
configSchema: "V2",
build: {
@@ -262,6 +263,7 @@ describe("runtime adapter composition", () => {
...runtime.config.CAPABILITY_OVERRIDES,
SERVICE_WORKER: "DISABLED",
},
FEATURE_OVERRIDES: {},
},
},
release,
+9 -1
View File
@@ -47,6 +47,14 @@ import {
} from "../../scripts/lib/release-candidate.ts";
import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts";
/**
* This suite's budget, not the file's. The 10s default is sized for pure-JS
* unit tests; these spawn processes, build archives and sign evidence, and on a
* machine running the rest of the suite in parallel they legitimately need
* longer. Raising the global default instead would hide a genuinely hung test.
*/
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
const digest = (value: string): string =>
createHash("sha256").update(value).digest("hex");
const digestBytes = (value: Buffer): string =>
@@ -1383,7 +1391,7 @@ describe("security follow-up contracts", () => {
} finally {
await rm(root, { recursive: true, force: true });
}
});
}, PROCESS_HEAVY_TIMEOUT_MS);
});
function providerExpectedContext() {
+6 -1
View File
@@ -14,6 +14,7 @@ import {
validateLicensePolicy,
} from "../../scripts/lib/supply-chain.ts";
import { digestReleaseInputFiles } from "../../scripts/lib/release-input-evidence.ts";
import { isReducedCiContractRun } from "../../scripts/contracts/ci-gates.ts";
import { findSecretMatches } from "../../scripts/lib/secret-scan.ts";
import {
parseSecretScanIncludedPaths,
@@ -535,7 +536,11 @@ describe("supply-chain policy", () => {
]);
});
it("covers every mandatory release input in the secret scan policy", async () => {
// A removal fixture deletes some of these inputs on purpose — removing the
// browser file/storage capability takes the whole browser-capability harness
// with it — and prunes them from its own policy. This is a claim about the
// full repository, so it does not describe a deliberately reduced one.
it.skipIf(isReducedCiContractRun())("covers every mandatory release input in the secret scan policy", async () => {
const policy = JSON.parse(
await readFile("config/security/secret-scan-policy.json", "utf8"),
) as { trackedRoots: string[] };
+42 -6
View File
@@ -6,6 +6,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
isReducedCiContractRun,
loadCiGateContract,
parseCiGateContract,
} from "../../scripts/contracts/ci-gates.ts";
@@ -70,8 +71,19 @@ describe("selective Task 3 contract closure", () => {
"--signal=SIGKILL",
unit,
]);
// `bwrap --args FD` stops parsing at the first non-option and never hands
// the remainder back, so a command placed in the args file is dropped and
// bubblewrap exits with its usage text. Refusing `--` in the option stream
// is what keeps that silent no-sandbox launch from returning.
expect(() =>
encodeProviderBwrapInput(
["--unshare-net", "--", "/usr/bin/prlimit"],
{ PROVIDER_COMMAND: command },
),
).toThrow(/terminate the option stream/u);
const frame = encodeProviderScopeFrame({
bwrapInput: Buffer.from("private-bwrap-vector\0"),
bwrapCommand: ["/usr/bin/prlimit", "--nofile=64:64", "--", "/bin/sh", "-eu", "-c", 'exec /bin/sh -eu -c "$PROVIDER_COMMAND"'],
reportPath: "/exact/report.json",
reportDev: 12,
reportIno: 34,
@@ -81,6 +93,27 @@ describe("selective Task 3 contract closure", () => {
Buffer.from("private-bwrap-vector\0").toString("base64"),
);
expect(launch.join("\0")).not.toContain("private-bwrap-vector");
// The command vector rides on real argv, so it must never be able to carry
// the secret that the args file exists to hide.
expect(frame.subarray(4).toString("utf8")).not.toContain(credential);
expect(() =>
encodeProviderScopeFrame({
bwrapInput: Buffer.from("x\0"),
bwrapCommand: [],
reportPath: "/exact/report.json",
reportDev: 12,
reportIno: 34,
}),
).toThrow(/bwrap command is invalid/u);
expect(() =>
encodeProviderScopeFrame({
bwrapInput: Buffer.from("x\0"),
bwrapCommand: ["prlimit"],
reportPath: "/exact/report.json",
reportDev: 12,
reportIno: 34,
}),
).toThrow(/bwrap command is invalid/u);
});
it("removes only the pinned raw inode during parent-loss cleanup", async () => {
@@ -143,12 +176,15 @@ describe("selective Task 3 contract closure", () => {
.toContain("package script missing: root -> missing");
});
it("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
// A removal fixture runs against a pruned contract on purpose, so the
// canonical counts do not describe it. Asserting them there failed the
// fixture for the reduction it exists to demonstrate.
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
const canonical = await loadCiGateContract(process.cwd());
expect(canonical.gates).toHaveLength(26);
expect(canonical.commands).toHaveLength(81);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(93);
expect(canonical.artifacts).toHaveLength(105);
expect(canonical.gates).toHaveLength(27);
expect(canonical.commands).toHaveLength(82);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94);
expect(canonical.artifacts).toHaveLength(107);
expect(canonical.stages).toHaveLength(5);
expect(canonical.retention.classes).toHaveLength(5);
@@ -157,7 +193,7 @@ describe("selective Task 3 contract closure", () => {
expect(() => parseCiGateContract(orphan)).toThrow(/five canonical retention|orphan retention/u);
});
it("rejects the retired validate-candidate-archive grammar", async () => {
it.skipIf(isReducedCiContractRun())("rejects the retired validate-candidate-archive grammar", async () => {
const canonical = JSON.parse(
JSON.stringify(await loadCiGateContract(process.cwd())),
) as Record<string, any>;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 424 KiB

+21
View File
@@ -4,7 +4,28 @@ import tailwindcss from "@tailwindcss/vite";
import { viteModuleInventoryPlugin } from "./scripts/lib/vite-module-inventory.ts";
/**
* §6.1. One sub-path, declared once.
*
* `VITE_ROUTER_BASE_PATH` already drives the router and the Service Worker
* scope. Vite's asset `base` was left at its default, so a build served from
* `/app/` emitted root-absolute asset URLs and loaded nothing: the three
* consumers of the same setting disagreed. They are read from one value here so
* a sub-path deployment is coherent or fails at build time.
*/
function routerBasePath(environment: NodeJS.ProcessEnv): string {
const declared = environment["VITE_ROUTER_BASE_PATH"];
if (declared === undefined || declared === "") return "/";
if (!declared.startsWith("/") || !declared.endsWith("/")) {
throw new Error(
`VITE_ROUTER_BASE_PATH must start and end with "/"; received ${declared}`,
);
}
return declared;
}
export default defineConfig({
base: routerBasePath(process.env),
plugins: [react(), tailwindcss(), viteModuleInventoryPlugin()],
build: {
manifest: true,

Some files were not shown because too many files have changed in this diff Show More