Compare commits

..
Author SHA1 Message Date
donghyeon-ka 9a120e6d45 feat: compose executable frontend runtime 2026-07-25 23:42:39 +09:00
donghyeon-ka 9200c80149 merge: require field gate inputs 2026-07-25 22:30:43 +09:00
donghyeon-ka f7e8ef6ee4 fix: require external field evidence inputs 2026-07-25 22:30:43 +09:00
donghyeon-ka e70b1a4ad9 merge: harden field performance evidence 2026-07-25 22:30:14 +09:00
donghyeon-ka 6b4b956d51 fix: authenticate field performance evidence context 2026-07-25 22:30:14 +09:00
donghyeon-ka 7d4daea23a merge: harden live hosting verification 2026-07-25 22:25:04 +09:00
donghyeon-ka c089e749d0 fix: require genuine live hosting evidence 2026-07-25 22:25:04 +09:00
donghyeon-ka 2d199a5a23 fix: retain complete manual accessibility evidence 2026-07-25 22:21:52 +09:00
donghyeon-ka 23c47a1eb9 merge: align accessibility gate evidence 2026-07-25 22:21:52 +09:00
donghyeon-ka 8a38805c01 merge: strengthen manual accessibility evidence 2026-07-25 22:21:23 +09:00
donghyeon-ka 50dc803f19 fix: consume canonical scoped diagram review evidence 2026-07-25 22:17:47 +09:00
donghyeon-ka 976f444692 merge: align documentation readiness evidence 2026-07-25 22:17:47 +09:00
donghyeon-ka c7191d7615 fix: gitkeep 파일 제거 2026-07-25 22:16:01 +09:00
donghyeon-ka a2a97ebcc7 fix: budget transitive initial JavaScript chunks 2026-07-25 21:46:07 +09:00
donghyeon-ka 28f5585a56 merge: complete bundle graph accounting 2026-07-25 21:46:07 +09:00
donghyeon-ka 4667cafa43 fix: validate complete immutable release surface 2026-07-25 21:45:02 +09:00
donghyeon-ka 6a0c60180c merge: complete immutable release verification 2026-07-25 21:45:02 +09:00
donghyeon-ka 18bea3a852 fix: verify hosting response content types 2026-07-25 21:43:55 +09:00
donghyeon-ka 72fd295556 merge: refresh hosting header verification contract 2026-07-25 21:43:55 +09:00
donghyeon-ka cc6cf29c79 merge: refresh bootstrap type fixture contract
# Conflicts:
#	package.json
2026-07-25 21:40:56 +09:00
donghyeon-ka 8198886dab fix: execute negative type fixture against source 2026-07-25 21:40:24 +09:00
donghyeon-ka c5e218d37a feat: orchestrate blocking frontend quality gates 2026-07-25 21:39:04 +09:00
donghyeon-ka 69d7e26a5b Merge branch 'feature-frontend-ci-quality-gates-contract' into develop 2026-07-25 21:39:04 +09:00
donghyeon-ka 6dd5b85c8c Merge branch 'feature-frontend-operational-runbook-contract' into develop 2026-07-25 21:30:41 +09:00
donghyeon-ka 75c3f5b08c feat: operationalize frontend incident runbooks 2026-07-25 21:30:40 +09:00
donghyeon-ka b1625252d5 feat: enforce web vitals performance budgets 2026-07-25 21:26:51 +09:00
donghyeon-ka 15ddb8d474 Merge branch 'feature-web-vitals-performance-budget-contract' into develop 2026-07-25 21:26:51 +09:00
donghyeon-ka eb37cbe8be feat: enforce coherent release and rollback contract 2026-07-25 21:22:35 +09:00
donghyeon-ka 9d40724ab2 Merge branch 'feature-frontend-release-cache-rollback-contract' into develop 2026-07-25 21:22:35 +09:00
donghyeon-ka b82bc73c6c Merge branch 'feature-frontend-contract-compatibility-governance' into develop 2026-07-25 21:19:20 +09:00
donghyeon-ka 89f3c69413 feat: govern frontend contract compatibility 2026-07-25 21:19:20 +09:00
donghyeon-ka 5ad6fb032e Merge branch 'feature-frontend-contract-registry-governance' into develop 2026-07-25 21:16:46 +09:00
donghyeon-ka 52f4896b63 feat: govern contract registries and snapshots 2026-07-25 21:16:46 +09:00
donghyeon-ka 3c347d40d8 Merge branch 'feature-frontend-browser-security-boundary-contract' into develop 2026-07-25 21:14:32 +09:00
donghyeon-ka 6f88915c7a feat: enforce browser security boundaries 2026-07-25 21:14:32 +09:00
donghyeon-ka 675603c3a2 Merge branch 'feature-frontend-build-bundle-supply-chain-contract' into develop 2026-07-25 21:13:13 +09:00
donghyeon-ka 4a3110974b feat: generate build and supply-chain evidence 2026-07-25 21:13:13 +09:00
donghyeon-ka caf09ecd56 Merge branch 'feature-accessibility-baseline-contract' into develop 2026-07-25 21:11:23 +09:00
92 changed files with 4894 additions and 29 deletions
+189
View File
@@ -0,0 +1,189 @@
name: frontend-quality-gates
on:
push:
branches: [develop]
tags: ["v*"]
pull_request:
workflow_dispatch:
inputs:
stage:
description: Highest promotion tier to evaluate
required: true
default: merge
type: choice
options:
- merge
- release
- production
- field
- documentation
env:
NODE_VERSION: "24"
jobs:
merge_gate:
name: ${{ matrix.gate }} / ${{ matrix.name }}
if: ${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-001, name: manifest-lockfile, browser: false }
- { gate: FE-GATE-002, name: lint, browser: false }
- { gate: FE-GATE-003, name: typecheck, browser: false }
- { gate: FE-GATE-004, name: runtime-schema, browser: false }
- { gate: FE-GATE-005, name: unit, browser: false }
- { gate: FE-GATE-006, name: component, browser: false }
- { gate: FE-GATE-007, name: integration, browser: false }
- { gate: FE-GATE-008, name: e2e, browser: true }
- { gate: FE-GATE-009, name: accessibility, browser: true }
- { gate: FE-GATE-010, name: architecture, browser: false }
- { gate: FE-GATE-011, name: build, browser: false }
- { gate: FE-GATE-013, name: security, browser: false }
- { gate: FE-GATE-020, name: sample-removal, browser: false }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Install Chromium
if: ${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.gate }}-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
release_gate:
name: ${{ matrix.gate }} / ${{ matrix.name }}
needs: merge_gate
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
runs-on: ubuntu-latest
env:
HOSTING_BASE_URL: ${{ vars.HOSTING_BASE_URL }}
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-012, name: bundle, browser: false }
- { gate: FE-GATE-014, name: config-compatibility, browser: false }
- { gate: FE-GATE-015, name: release-coherence, browser: false }
- { gate: FE-GATE-019, name: hosting-header, browser: false }
- { gate: FE-GATE-026, name: lab-performance, browser: true }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Install Chromium
if: ${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.gate }}-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
production_gate:
name: ${{ matrix.gate }} / ${{ matrix.name }}
needs: release_gate
if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { gate: FE-GATE-016, name: rollback-drill }
- { gate: FE-GATE-021, name: runbook-boot-config }
- { gate: FE-GATE-022, name: runbook-chunk-mismatch }
- { gate: FE-GATE-023, name: runbook-api-degradation }
- { gate: FE-GATE-024, name: runbook-telemetry }
- { gate: FE-GATE-025, name: runbook-release-rollback }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Run blocking gate
run: corepack pnpm ci:gate -- ${{ matrix.gate }}
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.gate }}-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
field_gate:
name: FE-GATE-018 / field-web-vitals
needs: production_gate
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }}
runs-on: ubuntu-latest
env:
FIELD_WEB_VITALS_INPUT: ${{ vars.FIELD_WEB_VITALS_INPUT }}
MIN_ELIGIBLE_SAMPLES: ${{ vars.MIN_ELIGIBLE_SAMPLES }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Run blocking gate
run: corepack pnpm ci:gate -- FE-GATE-018
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: FE-GATE-018-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
documentation_gate:
name: FE-GATE-017 / diagram-review
if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
- name: Run documentation gate
run: corepack pnpm ci:gate -- FE-GATE-017
- name: Upload gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: FE-GATE-017-${{ gitea.run_id }}
path: artifacts/
if-no-files-found: warn
+76 -1
View File
@@ -1,2 +1,77 @@
# clean-architecture-frontend-template # Clean Architecture Frontend Template
A React/Vite reference implementation where architecture boundaries,
integration behavior, release coherence, accessibility, performance, and
operations are executable contracts rather than conventions.
## Start locally
Requirements: Node 24 and Corepack. The repository pins pnpm in `package.json`.
```bash
corepack pnpm install --frozen-lockfile
corepack pnpm dev
```
Runtime-public settings live in `public/config.json` and are validated before
the product tree mounts. Client secrets are forbidden.
## Architecture
Dependencies point inward:
```text
presentation -> application -> domain
adapters -----^
bootstrap composes concrete adapters
contracts own cross-cutting registries
```
See `docs/architecture/overview.md` and `docs/architecture/layers.md`. The
removable sample slice is under `src/sample/contract-fixture`; product code is
not allowed to import it.
## Verification
Common local checks:
```bash
corepack pnpm lint
corepack pnpm check:types
corepack pnpm check:architecture
corepack pnpm test:all
corepack pnpm test:e2e
corepack pnpm test:a11y
corepack pnpm build
corepack pnpm check:bundle
corepack pnpm test:performance
corepack pnpm verify:compatibility
corepack pnpm verify:release
corepack pnpm check:registries
corepack pnpm drill:runbooks
corepack pnpm check:ci
```
Two gates intentionally need external evidence:
- `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review.
- `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum
eligible-sample threshold and 28 days of production data exist.
Live release verification additionally requires `HOSTING_BASE_URL`.
## CI and evidence
The 26-gate registry is `config/ci/gates.json`; the Gitea workflow is
`.gitea/workflows/quality-gates.yml`. It follows:
```text
MERGE_READY -> RELEASE_READY -> PROD_PROMOTION_READY -> FIELD_SLO_READY
```
`DOCUMENTATION_READY` is independent. No gate is downgraded to a warning.
Machine-readable evidence is written below `artifacts/`; generated evidence is
ignored by Git while `.gitkeep` files preserve the taxonomy.
Operational details are in `docs/operations/`, with incident procedures in
`docs/runbooks/`.
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
+342
View File
@@ -0,0 +1,342 @@
{
"schemaVersion": 1,
"providerAdapter": ".gitea/workflows/quality-gates.yml",
"stages": {
"merge": {
"readiness": "MERGE_READY",
"needs": null,
"gates": [
"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": {
"readiness": "RELEASE_READY",
"needs": "merge",
"gates": [
"FE-GATE-012",
"FE-GATE-014",
"FE-GATE-015",
"FE-GATE-019",
"FE-GATE-026"
]
},
"production": {
"readiness": "PROD_PROMOTION_READY",
"needs": "release",
"gates": [
"FE-GATE-016",
"FE-GATE-021",
"FE-GATE-022",
"FE-GATE-023",
"FE-GATE-024",
"FE-GATE-025"
]
},
"field": {
"readiness": "FIELD_SLO_READY",
"needs": "production",
"gates": ["FE-GATE-018"]
},
"documentation": {
"readiness": "DOCUMENTATION_READY",
"needs": null,
"gates": ["FE-GATE-017"]
}
},
"gates": {
"FE-GATE-001": {
"name": "manifest-lockfile",
"steps": [{ "script": "verify:lockfile", "expect": "pass" }],
"logPath": "artifacts/quality/install.txt",
"evidence": ["artifacts/quality/install.txt"],
"retentionClass": "merge-cycle"
},
"FE-GATE-002": {
"name": "lint",
"steps": [{ "script": "lint", "expect": "pass" }],
"logPath": "artifacts/quality/lint.txt",
"evidence": ["artifacts/quality/lint.txt"],
"retentionClass": "merge-cycle"
},
"FE-GATE-003": {
"name": "typecheck",
"steps": [
{ "script": "check:types", "expect": "pass" },
{ "script": "check:types:fixture", "expect": "fail" }
],
"logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"],
"retentionClass": "merge-cycle"
},
"FE-GATE-004": {
"name": "runtime-schema",
"steps": [{ "script": "test:runtime-schema", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-004.txt",
"evidence": ["artifacts/tests/runtime-schema.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-005": {
"name": "unit",
"steps": [{ "script": "test:unit", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-005.txt",
"evidence": ["artifacts/tests/unit.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-006": {
"name": "component",
"steps": [{ "script": "test:component", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-006.txt",
"evidence": ["artifacts/tests/component.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-007": {
"name": "integration",
"steps": [{ "script": "test:integration", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-007.txt",
"evidence": ["artifacts/tests/integration.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-008": {
"name": "e2e",
"steps": [{ "script": "test:e2e", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-008.txt",
"evidence": ["artifacts/tests/e2e/report/index.html"],
"retentionClass": "merge-cycle"
},
"FE-GATE-009": {
"name": "accessibility",
"steps": [
{ "script": "test:a11y", "expect": "pass" },
{ "script": "review:a11y-manual", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-009.txt",
"evidence": [
"artifacts/tests/a11y.json",
"artifacts/tests/a11y-manual/APP_HOME.md",
"artifacts/tests/a11y-manual/SAMPLE_RESOURCE_LIST.md",
"artifacts/tests/a11y-manual/NOT_FOUND.md",
"artifacts/tests/a11y-manual/report.json"
],
"retentionClass": "merge-cycle"
},
"FE-GATE-010": {
"name": "architecture",
"steps": [{ "script": "check:architecture", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-010.txt",
"evidence": ["artifacts/quality/dependency-report.json"],
"retentionClass": "merge-cycle"
},
"FE-GATE-011": {
"name": "build",
"steps": [{ "script": "build", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-011.txt",
"evidence": ["artifacts/release/build-manifest.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-012": {
"name": "bundle",
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "check:bundle", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-012.txt",
"evidence": ["artifacts/performance/bundle.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-013": {
"name": "security",
"steps": [
{ "script": "build:release", "expect": "pass" },
{ "script": "check:browser-security", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-013.txt",
"evidence": [
"artifacts/security/scan.sarif",
"artifacts/release/dependency-inventory.json",
"artifacts/security/dependency-diff.json"
],
"retentionClass": "release-coherence"
},
"FE-GATE-014": {
"name": "config-compatibility",
"steps": [{ "script": "verify:compatibility", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-014.txt",
"evidence": ["artifacts/release/compatibility.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-015": {
"name": "release-coherence",
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "verify:release", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-015.txt",
"evidence": ["artifacts/release/verification.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-016": {
"name": "rollback-drill",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-005"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-016.txt",
"evidence": [
"artifacts/runbooks/FE-RB-005/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-017": {
"name": "diagram-review",
"steps": [{ "script": "verify:documentation", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-017.txt",
"evidence": ["artifacts/quality/documentation-review.json"],
"retentionClass": "documentation"
},
"FE-GATE-018": {
"name": "field-web-vitals",
"requiresEnvironment": [
"FIELD_WEB_VITALS_INPUT",
"MIN_ELIGIBLE_SAMPLES"
],
"steps": [
{ "script": "collect:web-vitals-evidence", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-018.txt",
"evidence": ["artifacts/performance/field-web-vitals.json"],
"retentionClass": "field"
},
"FE-GATE-019": {
"name": "hosting-header",
"requiresEnvironment": ["HOSTING_BASE_URL"],
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "verify:hosting-headers", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-019.txt",
"evidence": ["artifacts/release/hosting-headers.json"],
"retentionClass": "release-coherence"
},
"FE-GATE-020": {
"name": "sample-removal",
"steps": [{ "script": "test:sample-removal", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-020.txt",
"evidence": ["artifacts/tests/sample-removal.xml"],
"retentionClass": "merge-cycle"
},
"FE-GATE-021": {
"name": "runbook-boot-config",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-001"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-021.txt",
"evidence": [
"artifacts/runbooks/FE-RB-001/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-022": {
"name": "runbook-chunk-mismatch",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-002"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-022.txt",
"evidence": [
"artifacts/runbooks/FE-RB-002/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-023": {
"name": "runbook-api-degradation",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-003"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-023.txt",
"evidence": [
"artifacts/runbooks/FE-RB-003/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-024": {
"name": "runbook-telemetry",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-004"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-024.txt",
"evidence": [
"artifacts/runbooks/FE-RB-004/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-025": {
"name": "runbook-release-rollback",
"steps": [
{ "script": "build", "expect": "pass" },
{
"script": "drill:runbook",
"args": ["--", "FE-RB-005"],
"expect": "pass"
}
],
"logPath": "artifacts/quality/gates/FE-GATE-025.txt",
"evidence": [
"artifacts/runbooks/FE-RB-005/local-release/record.json"
],
"retentionClass": "prod-drill"
},
"FE-GATE-026": {
"name": "lab-performance",
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "test:performance", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-026.txt",
"evidence": ["artifacts/performance/lab.json"],
"retentionClass": "release-coherence"
}
},
"retention": {
"durationStatus": "UNSUPPORTED_PENDING_ORGANIZATION_POLICY",
"merge-cycle": "at least through pull-request readiness decision",
"release-coherence": "at least until the next release is promoted",
"prod-drill": "at least until the next production promotion decision",
"field": "through the 28-day window and aggregation",
"documentation": "through documentation readiness review"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"schemaVersion": 1,
"families": {
"api": {
"additive": {
"before": { "required": ["id"], "properties": { "id": {} } },
"after": {
"required": ["id"],
"properties": { "id": {}, "displayName": {} }
}
},
"breaking": {
"before": { "required": ["id"], "properties": { "id": {} } },
"after": {
"required": ["id", "name"],
"properties": { "id": {}, "name": {} }
}
}
},
"config": {
"additive": {
"before": { "required": ["APP_ENV"], "properties": { "APP_ENV": {} } },
"after": {
"required": ["APP_ENV"],
"properties": { "APP_ENV": {}, "OPTIONAL_FLAG": {} }
}
},
"breaking": {
"before": { "required": ["APP_ENV"], "properties": { "APP_ENV": {} } },
"after": {
"required": ["APP_ENV", "NEW_REQUIRED"],
"properties": { "APP_ENV": {}, "NEW_REQUIRED": {} }
}
}
},
"storage": {
"additive": {
"before": { "properties": { "theme": {} } },
"after": { "properties": { "theme": {}, "contrast": {} } }
},
"breaking": {
"before": { "properties": { "theme": {} } },
"after": { "properties": {} }
}
},
"release": {
"additive": {
"before": { "required": ["buildId"], "properties": { "buildId": {} } },
"after": {
"required": ["buildId"],
"properties": { "buildId": {}, "builtAt": {} }
}
},
"breaking": {
"before": { "required": ["buildId"], "properties": { "buildId": {} } },
"after": {
"required": ["buildId", "assetManifestHash"],
"properties": { "buildId": {}, "assetManifestHash": {} }
}
}
}
}
}
+116
View File
@@ -0,0 +1,116 @@
{
"schemaVersion": 1,
"registries": [
{
"registryId": "FE-REG-ROUTE",
"path": "src/contracts/routes.js",
"exportName": "ROUTE_REGISTRY",
"owner": "feature-routing-navigation-guard-contract",
"requiredFields": [
"routeId",
"path",
"paramsSchema",
"searchSchema",
"access",
"loadingSurface",
"errorSurface",
"chunkId"
]
},
{
"registryId": "FE-REG-API",
"path": "src/contracts/api-operations.js",
"exportName": "API_OPERATIONS",
"owner": "feature-api-client-response-envelope-contract",
"requiredFields": [
"method",
"path",
"operationId",
"auth",
"timeoutMs",
"idempotency",
"requestSchema",
"responseSchema",
"owner"
]
},
{
"registryId": "FE-REG-ENV",
"path": "src/contracts/env.js",
"exportName": "ENV_REGISTRY",
"owner": "feature-frontend-env-runtime-config-contract",
"requiredFields": ["phase", "classification", "required", "defaultValue"]
},
{
"registryId": "FE-REG-STORAGE",
"path": "src/contracts/storage-keys.js",
"exportName": "STORAGE_REGISTRY",
"owner": "feature-frontend-storage-registry-contract",
"requiredFields": [
"logicalName",
"physicalKey",
"backend",
"classification",
"schemaVersion",
"ttl",
"migration",
"quotaFallback"
]
},
{
"registryId": "FE-REG-ERROR",
"path": "src/contracts/errors.js",
"exportName": "ERROR_REGISTRY",
"owner": "feature-frontend-error-classification-boundary-contract",
"requiredFields": [
"kind",
"defaultRetryable",
"severity",
"userMessageKey",
"action",
"telemetryEvent",
"redaction"
]
},
{
"registryId": "FE-REG-QUERY",
"path": "src/contracts/query-keys.js",
"exportName": "QUERY_REGISTRY",
"owner": "feature-server-state-caching-contract",
"requiredFields": [
"namespace",
"serialization",
"identity",
"invalidation",
"version",
"persistence"
]
},
{
"registryId": "FE-REG-TELEMETRY",
"path": "src/contracts/telemetry.js",
"exportName": "TELEMETRY_REGISTRY",
"owner": "feature-frontend-observability-logging-trace-contract",
"requiredFields": [
"eventName",
"trigger",
"requiredAttributes",
"optionalAttributes",
"forbiddenAttributes",
"sampling",
"delivery"
]
},
{
"registryId": "FE-REG-RELEASE",
"path": "src/contracts/release-tokens.js",
"exportName": "RELEASE_TOKEN_REGISTRY",
"owner": "feature-frontend-release-cache-rollback-contract",
"requiredFields": ["token", "source", "compatibilityRole"]
}
],
"compatibilityImpact": {
"allowed": ["none", "additive", "behavior-change", "breaking"],
"current": "additive"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"schemaVersion": 1,
"surfaces": {
"index": {
"path": "/",
"cacheControl": "no-cache",
"contentTypes": ["text/html"],
"securityHeaders": true
},
"runtimeConfig": {
"path": "/config.json",
"cacheControl": "no-store",
"contentTypes": ["application/json"],
"securityHeaders": true
},
"releaseManifest": {
"path": "/release-manifest.json",
"cacheControl": "no-store",
"contentTypes": ["application/json"],
"securityHeaders": true
},
"hashedAsset": {
"pathPattern": "/assets/*",
"cacheControl": "public, max-age=31536000, immutable",
"contentTypes": ["text/javascript", "application/javascript"],
"securityHeaders": false
},
"sourceMap": {
"public": false
},
"serviceWorker": {
"enabled": false
}
}
}
@@ -0,0 +1,39 @@
{
"schemaVersion": 1,
"responses": {
"index": {
"cache-control": "no-cache",
"content-type": "text/html; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY",
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"permissions-policy": "camera=(), microphone=(), geolocation=()"
},
"runtimeConfig": {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY",
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"permissions-policy": "camera=(), microphone=(), geolocation=()"
},
"releaseManifest": {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY",
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"permissions-policy": "camera=(), microphone=(), geolocation=()"
},
"hashedAsset": {
"cache-control": "public, max-age=31536000, immutable",
"content-type": "text/javascript; charset=utf-8"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"headers": {
"Content-Security-Policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"bundle": {
"initialJsGzipBytes": 204800,
"lazyChunkGzipBytes": 122880
},
"lab": {
"lcpMs": 2500,
"cls": 0.1,
"namedInteractionMs": 200
},
"field": {
"p75LcpMs": 2500,
"p75Cls": 0.1,
"p75InpMs": 200,
"minimumEligibleSamples": null
}
}
@@ -0,0 +1,25 @@
{
"schemaVersion": 1,
"releaseId": "local-release",
"environment": "replace-with-production",
"source": {
"system": "",
"exportId": ""
},
"privacy": {
"approved": false,
"approvalRef": ""
},
"window": {
"start": "2026-06-01T00:00:00Z",
"end": "2026-06-29T00:00:00Z"
},
"thresholdDecision": {
"status": "pending",
"minimumEligibleSamples": null,
"owner": "",
"reviewedAt": "",
"evidenceRef": ""
},
"samples": []
}
+77
View File
@@ -0,0 +1,77 @@
{
"schemaVersion": 1,
"fixtures": [
{
"name": "coherent-release",
"expectedCompatible": true,
"frontend": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
},
"runtime": {
"buildId": "build-a",
"configSchemaVersion": "1.1",
"apiContractVersion": "1.2",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
}
},
{
"name": "mixed-html-and-assets",
"expectedCompatible": false,
"frontend": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
},
"runtime": {
"buildId": "build-b",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-b",
"releaseId": "release-b"
}
},
{
"name": "incompatible-runtime-config",
"expectedCompatible": false,
"frontend": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
},
"runtime": {
"buildId": "build-a",
"configSchemaVersion": "2.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
}
},
{
"name": "incompatible-api-contract",
"expectedCompatible": false,
"frontend": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "1.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
},
"runtime": {
"buildId": "build-a",
"configSchemaVersion": "1.0",
"apiContractVersion": "2.0",
"assetManifestHash": "assets-a",
"releaseId": "release-a"
}
}
]
}
+93
View File
@@ -0,0 +1,93 @@
{
"schemaVersion": 1,
"runbooks": {
"FE-RB-001": {
"title": "Boot configuration failure",
"gateId": "FE-GATE-021",
"triggerKinds": ["BOOT_CONFIG_FAILURE"],
"containment": "stop product route mount, show the safe support shell, and refetch at most once",
"window": "owner triage planned-default 5m",
"escalation": ["env-config owner", "release owner"],
"recoveryEvidence": [
"clean-session boot",
"product root mount",
"config validation",
"no repeated boot error"
],
"negativeFixture": "a valid config followed by an injected mount failure must fail recovery"
},
"FE-RB-002": {
"title": "Chunk, manifest, or deployment mismatch",
"gateId": "FE-GATE-022",
"triggerKinds": [
"CHUNK_LOAD_FAILURE",
"RELEASE_MANIFEST_FAILURE",
"DEPLOY_MISMATCH"
],
"containment": "warn for dirty state, fetch manifest no-store once, and allow one guarded reload",
"window": "release owner triage planned-default 5m",
"escalation": ["release-cache owner", "hosting/CDN owner"],
"recoveryEvidence": [
"entry and lazy assets reachable",
"release tuple coherent",
"second reload blocked",
"critical route smoke"
],
"negativeFixture": "a second failure for the same release pair must not reload"
},
"FE-RB-003": {
"title": "Backend API degradation",
"gateId": "FE-GATE-023",
"triggerKinds": [
"TERMINAL_NETWORK_RATE",
"REQUEST_TIMEOUT_RATE",
"SERVER_FAILURE_RATE",
"SCHEMA_MISMATCH"
],
"containment": "do not expand retry caps, serve safe stale reads, and never retry an unkeyed mutation",
"window": "rolling 5m trigger; first classification planned-default 10m",
"escalation": [
"api-client owner",
"backend operation owner",
"release compatibility owner"
],
"recoveryEvidence": [
"terminal failure rate at baseline",
"no retry amplification",
"critical read/write smoke",
"schema fixtures"
],
"negativeFixture": "an unkeyed POST receiving 503 must not retry"
},
"FE-RB-004": {
"title": "Telemetry sink failure",
"gateId": "FE-GATE-024",
"triggerKinds": ["TELEMETRY_FAILURE"],
"containment": "keep product flow available, bound the queue, and never report recursively to the failing sink",
"window": "platform triage planned-default 15m",
"escalation": ["observability owner", "telemetry platform owner"],
"recoveryEvidence": [
"product flow unaffected",
"delivery self-check",
"queue drained within bound",
"forbidden attributes absent"
],
"negativeFixture": "raw URL and query data must be removed from telemetry"
},
"FE-RB-005": {
"title": "Coherent release rollback",
"gateId": "FE-GATE-025",
"triggerKinds": ["RELEASE_BLOCKING_DEFECT"],
"containment": "select a prior immutable tuple, verify asset/config/API compatibility, atomically switch, and smoke",
"window": "provider recovery target TBD",
"escalation": ["release-cache owner", "release approver/hosting owner"],
"recoveryEvidence": [
"compatibility gate",
"release coherence gate",
"critical smoke",
"release ID in incident timeline"
],
"negativeFixture": "HTML build A with asset manifest B must be rejected"
}
}
}
@@ -0,0 +1,78 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-FIELD-WEB-VITALS@1",
"type": "object",
"required": [
"schemaVersion",
"generatedAt",
"window",
"context",
"metrics",
"thresholds",
"eligibility",
"status",
"passed"
],
"properties": {
"schemaVersion": { "const": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"window": { "type": "object", "required": ["days", "start", "end"] },
"context": {
"type": "object",
"required": [
"source",
"sourceSystem",
"exportId",
"network",
"routeAggregation",
"releaseId",
"privacyApprovalRef",
"thresholdDecisionRef",
"validationFailures"
],
"properties": {
"source": { "type": "string" },
"sourceSystem": { "type": ["string", "null"] },
"exportId": { "type": ["string", "null"] },
"network": { "const": "production-real-user" },
"routeAggregation": { "const": "route-id-only" },
"releaseId": { "type": ["string", "null"] },
"privacyApprovalRef": { "type": ["string", "null"] },
"thresholdDecisionRef": { "type": ["string", "null"] },
"validationFailures": {
"type": "array",
"items": { "type": "string" }
}
},
"additionalProperties": false
},
"thresholds": {
"type": "object",
"required": [
"p75LcpMs",
"p75Cls",
"p75InpMs",
"minimumEligibleSamples"
]
},
"metrics": {
"type": "object",
"required": ["p75LcpMs", "p75Cls", "p75InpMs"]
},
"eligibility": {
"type": "object",
"required": [
"consentRequired",
"totalSamples",
"eligibleSamples",
"minimumEligibleSamples",
"routeSamples"
]
},
"status": {
"enum": ["PASS", "FAIL_THRESHOLD", "FAIL_UNVERIFIED"]
},
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-LAB@1",
"type": "object",
"required": [
"schemaVersion",
"generatedAt",
"context",
"metrics",
"thresholds",
"fixtures",
"passed"
],
"properties": {
"schemaVersion": { "const": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"context": {
"type": "object",
"required": ["runner", "browser", "viewport", "network", "cpu", "cache", "build"]
},
"metrics": {
"type": "object",
"required": ["lcpMs", "cls", "namedInteractionMs"]
},
"thresholds": { "type": "object" },
"fixtures": { "type": "array", "minItems": 2 },
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-003@1",
"type": "object",
"required": ["schemaVersion", "generatedAt", "artifact", "fixtures", "passed"],
"properties": {
"schemaVersion": { "const": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"artifact": {
"type": "object",
"required": ["checked", "compatible", "mismatches"]
},
"fixtures": { "type": "array", "minItems": 2 },
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
@@ -0,0 +1,42 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ART-FE-RUNBOOK-DRILL@1",
"type": "object",
"required": [
"schemaVersion",
"runbookId",
"releaseId",
"drillTimestamp",
"triggerInjected",
"triggerAsserted",
"containmentAsserted",
"escalationPathAsserted",
"recoveryAssertions",
"negativeFixtureFailedAsExpected",
"windowObservedBucket",
"passed"
],
"properties": {
"schemaVersion": { "const": 1 },
"runbookId": { "pattern": "^FE-RB-00[1-5]$" },
"releaseId": { "type": "string", "minLength": 1 },
"drillTimestamp": { "type": "string", "format": "date-time" },
"triggerInjected": { "type": "string" },
"triggerAsserted": { "type": "boolean" },
"containmentAsserted": { "type": "boolean" },
"escalationPathAsserted": { "type": "boolean" },
"recoveryAssertions": {
"type": "array",
"minItems": 4,
"items": {
"type": "object",
"required": ["assertion", "evidence", "passed"]
}
},
"negativeFixtureFailedAsExpected": { "type": "boolean" },
"windowObservedBucket": { "type": "string" },
"providerVerificationRequired": { "type": "boolean" },
"passed": { "type": "boolean" }
},
"additionalProperties": false
}
+23
View File
@@ -0,0 +1,23 @@
# Architecture overview
This Mermaid view is a repository-local implementation projection. The
`PASS_SCOPED` reviewer evidence applies to the canonical draw.io diagram named
in `review-ledger.json`, not automatically to edits in this file.
```mermaid
flowchart LR
Bootstrap[bootstrap / composition root] --> Presentation[presentation]
Bootstrap --> Adapters[adapters]
Presentation --> Application[application]
Adapters --> Application
Application --> Domain[domain]
Contracts[contract registries] --> Bootstrap
Contracts --> Adapters
Contracts --> Presentation
```
Dependencies point inward. Presentation calls application use cases, adapters
implement application ports, and only the composition root selects concrete
adapters. Contract registries are the single named source for routes, API
operations, environment values, storage keys, errors, queries, telemetry, and
release tokens.
+22
View File
@@ -0,0 +1,22 @@
# Imported scoped diagram review evidence
This ledger entry consumes the canonical evidence already recorded by the
`ca-skeleton-frontend-operational-contract` project note. It does not claim
review of the repository-local Mermaid projections or of the complete
production deployment topology.
- Reviewer: `wiki-diagram-reviewer`
- Standard: `rules/diagram-standards.md` v2
- Canonical report:
`docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md`
- Canonical report SHA-256:
`b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29`
| Canonical diagram | SHA-256 | Score | Verdict | Reviewed scope |
| --- | --- | ---: | --- | --- |
| `raw/diagrams/ca-skeleton-frontend/architecture-overview-2026-07-18.drawio` | `c0ae56c9c964c5c6e698ab7dcc91736b9b811b2b834381817905db81c4230ba0` | 100 | PASS | Clean Architecture compile-time dependency ownership |
| `raw/diagrams/ca-skeleton-frontend/architecture-deployment-2026-07-18.drawio` | `9a654326fb840ddf24b832221ff7eec4b8fadd9f87ad84174fccfa3bfcd1a25b` | 100 | PASS | immutable static assets and mutable `/config.json` delivery |
The canonical report explicitly limits this `PASS_SCOPED`: it does not verify
the complete release/rollback topology, the implementation topology, or live
hosting state.
+29
View File
@@ -0,0 +1,29 @@
{
"schemaVersion": 1,
"status": "PASS_SCOPED",
"reviewer": "wiki-diagram-reviewer",
"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",
"canonicalSha256": "b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29"
},
"reviews": {
"overview": {
"sourcePath": "raw/diagrams/ca-skeleton-frontend/architecture-overview-2026-07-18.drawio",
"sha256": "c0ae56c9c964c5c6e698ab7dcc91736b9b811b2b834381817905db81c4230ba0",
"score": 100,
"verdict": "PASS",
"thresholdSatisfied": true,
"scope": "Clean Architecture compile-time dependency ownership"
},
"staticDelivery": {
"sourcePath": "raw/diagrams/ca-skeleton-frontend/architecture-deployment-2026-07-18.drawio",
"sha256": "9a654326fb840ddf24b832221ff7eec4b8fadd9f87ad84174fccfa3bfcd1a25b",
"score": 100,
"verdict": "PASS",
"thresholdSatisfied": true,
"scope": "immutable static assets and mutable /config.json delivery"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
# Static asset and runtime-config delivery
This Mermaid view is a repository-local implementation projection. The
`PASS_SCOPED` reviewer evidence applies only to the canonical static-delivery
draw.io scope recorded in `review-ledger.json`.
```mermaid
sequenceDiagram
participant CI
participant ImmutableRelease
participant ActivePointer
participant Browser
CI->>ImmutableRelease: upload hashed assets
CI->>ImmutableRelease: upload release manifest
CI->>ImmutableRelease: upload runtime config
CI->>ImmutableRelease: probe asset reachability
CI->>ActivePointer: atomically switch HTML
Browser->>ActivePointer: fetch revalidated HTML
Browser->>ImmutableRelease: fetch no-store config and manifest
Browser->>ImmutableRelease: fetch immutable hashed assets
CI->>Browser: boot, route, API, and reload-loop smoke
```
Rollback changes the active pointer only after confirming that the prior
immutable release has a coherent HTML/assets/config/API/manifest tuple.
+11
View File
@@ -0,0 +1,11 @@
# Contract compatibility and rollback rules
The blocking tuple is `(buildId, configSchemaVersion, apiContractVersion,
assetManifestHash, releaseId)`. Versions are parsed numerically.
1. additive changes preserve current required fields
2. breaking changes require a major version bump
3. persisted cache is discarded unless an explicit tested migration exists
4. an incompatible config or API contract blocks product mount
5. rollback restores HTML, assets, runtime config, API compatibility, and
release manifest as one coherent set
+41
View File
@@ -0,0 +1,41 @@
# CI quality-gate orchestration
`config/ci/gates.json` is the executable registry for all 26 gates. The Gitea
adapter runs each gate as an independent matrix check with full fan-out and no
soft-fail wiring.
The dependency graph is:
```text
MERGE_READY
-> RELEASE_READY
-> PROD_PROMOTION_READY
-> FIELD_SLO_READY
DOCUMENTATION_READY (off-chain)
```
Pull requests and `develop` pushes evaluate merge readiness. Version tags
evaluate merge then release readiness. Production and field evaluation require
an explicit workflow dispatch. The field tier cannot pass until the 28-day
sample threshold decision is recorded. Documentation readiness consumes the
canonical project-note evidence in which both scoped diagrams already received
100/100 `PASS_SCOPED`; the repo ledger preserves the evidence scope and
canonical digests.
All jobs upload the shared `artifacts/` tree even after failure. Numeric
retention remains an organization/provider decision; the workflow intentionally
does not invent `retention-days`. The relative minimums are recorded in the
registry: merge evidence through the PR decision, coherent release evidence
through the next release promotion, drill evidence through the next production
promotion, and field evidence through aggregation.
Repository variables required by higher tiers:
- `HOSTING_BASE_URL` for live header verification
- `FIELD_WEB_VITALS_INPUT` for the privacy-approved field sample document
- `MIN_ELIGIBLE_SAMPLES` after the baseline decision
Branch protection must mark each `FE-GATE-* / <name>` check required for its
declared tier. This repository cannot configure server-side protection by
committing a file.
+22
View File
@@ -0,0 +1,22 @@
# Performance evidence contract
Performance evidence is deliberately split by measurement context:
- `bundle.json` records production build output and enforces initial JavaScript
at 200 KiB gzip and every lazy chunk at 120 KiB gzip.
- `lab.json` records Chromium/runner/viewport/network/CPU/cache/build context and
enforces LCP 2.5 s, CLS 0.10, and the named route interaction at 200 ms.
- `field-web-vitals.json` records consent-filtered, route-ID aggregated,
release-specific production samples over 28 days and evaluates p75 LCP, CLS,
and INP against 2.5 s, 0.10, and 200 ms.
The field minimum eligible-sample threshold is intentionally unresolved until
a privacy-approved telemetry baseline exists. Therefore the field command
fails closed with `FAIL_UNVERIFIED` when run against the example input. Provide
`FIELD_WEB_VITALS_INPUT` and `MIN_ELIGIBLE_SAMPLES` only after that decision is
recorded. The external input must identify a production release and an exact
28-day export window, name the source/export, carry privacy-approval and
threshold-decision references, and contain only non-negative route-ID samples.
The environment threshold must be a positive integer equal to the approved
decision embedded in the input. Invalid metadata fails as `FAIL_UNVERIFIED`;
the example can never serve as production evidence.
+32
View File
@@ -0,0 +1,32 @@
# Release, cache, and rollback contract
Each deployment is an immutable `releases/<releaseId>/` artifact set. The
provider adapter must upload assets, release manifest, runtime config, and
verify asset reachability before atomically switching the active HTML pointer.
The post-switch boot, route, API, telemetry, and reload-loop smoke checks close
the deployment.
Rollback selects a prior release tuple, confirms its assets and runtime/API
compatibility, atomically switches the complete set, performs the provider
cache action, and repeats the smoke checks. Rebuilding an old commit, replacing
HTML alone, or declaring recovery from cache-purge completion is prohibited.
Recovery is established by old/new reachability probes.
The provider-independent cache defaults are:
- hashed assets: `public, max-age=31536000, immutable`
- HTML: `no-cache`
- runtime config and release manifest: `no-store`
- public source maps: disabled
- service worker/offline cache: disabled
HTML, JSON config/manifest, and hashed JavaScript MIME types are also compared
to the declared allowlist; a cache-correct response with a mismatched
`Content-Type` still fails the hosting gate.
`corepack pnpm verify:hosting-headers` uses a deterministic fixture locally.
Set `HOSTING_BASE_URL` to probe deployed responses; production promotion
requires the artifact to report `mode: "live"`. The live target must be its
canonical, non-loopback HTTPS root URL. Each required surface must return HTTP
200 without leaving that origin before its cache, content-type, and security
headers can count as deployment evidence.
+9
View File
@@ -0,0 +1,9 @@
# FE-RB-001 — Boot configuration failure
Trigger on `BOOT_CONFIG_FAILURE` after the single bounded refetch fails. Stop
product route mounting and show the safe support shell; the planned owner
triage target is five minutes. Escalate from the environment/config owner to
the release owner.
Close only after a clean-session boot mounts the product root, config
validation evidence passes, and repeated boot-error telemetry is absent.
+11
View File
@@ -0,0 +1,11 @@
# FE-RB-002 — Chunk or deployment mismatch
Trigger on `CHUNK_LOAD_FAILURE`, `RELEASE_MANIFEST_FAILURE`, or
`DEPLOY_MISMATCH`. Warn when dirty state may be lost, fetch the manifest
`no-store` once, record the release pair, and allow only one reload. The
planned release-owner triage target is five minutes. Escalate to the hosting/CDN
owner.
Close only after entry/lazy assets are reachable, the manifest parses into a
coherent tuple, a second automatic reload is blocked, and the critical route
smoke passes.
+10
View File
@@ -0,0 +1,10 @@
# FE-RB-003 — Backend API degradation
Trigger when terminal network/timeout/5xx failures exceed the rolling
five-minute threshold or on one `SCHEMA_MISMATCH`. Do not expand client retry
caps, do not retry schema mismatches, and never retry an unkeyed mutation.
Escalate from the API client owner to backend operations and then release
compatibility; the planned first-classification target is ten minutes.
Close only after the failure rate returns to baseline, retry amplification is
absent, critical read/write smoke passes, and schema fixtures pass.
+9
View File
@@ -0,0 +1,9 @@
# FE-RB-004 — Telemetry sink failure
Trigger on sink network/non-2xx errors, queue overflow, or adapter
initialization failure. Keep product flows available, bound the queue, and do
not recursively report to the failed sink. Escalate from observability to the
telemetry platform owner; the planned triage target is fifteen minutes.
Close only after product e2e remains unaffected, delivery self-check succeeds,
the queue drains within its bound, and the forbidden-attribute scan passes.
+13
View File
@@ -0,0 +1,13 @@
# FE-RB-005 — Coherent release rollback
Trigger on a release-blocking boot, chunk, render, API, or security defect when
a safe forward fix is not demonstrated inside the incident window. Select a
prior immutable release, verify its asset/config/API tuple, atomically switch
the complete set, perform the provider cache action, and run smoke checks.
Escalate from the release-cache owner to the release approver/hosting owner.
The provider recovery target remains TBD until hosting is selected.
Close only when compatibility and release-coherence gates pass, critical smoke
passes, repeated `DEPLOY_MISMATCH` is absent, and the incident timeline records
the restored release ID. Pointer-switch or cache-purge completion alone is not
recovery evidence.
+13
View File
@@ -0,0 +1,13 @@
# Browser security boundary
The browser bundle is public. Secrets, token lifecycle, raw HTML injection,
dynamic code execution, untrusted script URLs, and public production source
maps are prohibited defaults.
`config/hosting/security-headers.json` is the declared header set. Hosting
verification compares that declaration with live responses. CSP deliberately
omits `unsafe-inline` and `unsafe-eval`; production code and built assets must
remain compatible with that baseline.
Route guards are UX hints and client validation does not replace backend
authorization or validation.
+18
View File
@@ -0,0 +1,18 @@
# Build and supply-chain gate
Merge and release controls:
- frozen `pnpm-lock.yaml` installation; drift is blocking
- clean production build with hashed assets and build manifest
- machine-readable bundle sizes and checksums
- source plus built-asset credential-pattern scan
- direct dependency inventory and lockfile digest
- base/head dependency diff review record
Organization-specific vulnerability severity, denied-license list, SBOM format,
and scanner selection remain policy inputs. An approved suppression must record
reason, owner, expiry, affected package, and compensating control. Expired
suppressions are blocking.
`artifacts/security/dependency-diff.json` is a local baseline. CI replaces it
with the actual base/head direct and transitive lockfile diff before release.
+21
View File
@@ -35,6 +35,7 @@ export default [
"artifacts/**", "artifacts/**",
"tests/fixtures/typecheck/**", "tests/fixtures/typecheck/**",
"tests/fixtures/architecture/forbidden/**", "tests/fixtures/architecture/forbidden/**",
"tests/fixtures/security/forbidden/**",
], ],
}, },
eslint.configs.recommended, eslint.configs.recommended,
@@ -98,4 +99,24 @@ export default [
]), ]),
}, },
}, },
{
files: ["**/*.{js,jsx}"],
rules: {
"no-eval": "error",
"no-new-func": "error",
"no-script-url": "error",
"no-restricted-syntax": [
"error",
{
selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']",
message: "Raw HTML injection is prohibited by FE-OC-019.",
},
{
selector:
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
message: "Runtime script construction is prohibited by FE-OC-019.",
},
],
},
},
]; ];
+19 -2
View File
@@ -11,11 +11,12 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build && node scripts/generate-build-manifest.mjs", "build": "vite build && node scripts/generate-build-manifest.mjs",
"build:release": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src scripts tests vite.config.js vitest.config.js playwright.config.js --max-warnings=0", "lint": "eslint src scripts tests vite.config.js vitest.config.js playwright.config.js --max-warnings=0",
"check:architecture": "node scripts/check-architecture.mjs", "check:architecture": "node scripts/check-architecture.mjs",
"check:types": "tsc --allowJs --checkJs --noEmit", "check:types": "tsc --allowJs --checkJs --noEmit",
"check:types:fixture": "tsc --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js", "check:types:fixture": "tsc --ignoreConfig --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js",
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", "test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", "test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
@@ -24,7 +25,23 @@
"test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs", "test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs",
"review:a11y-manual": "node scripts/verify-a11y-manual.mjs", "review:a11y-manual": "node scripts/verify-a11y-manual.mjs",
"test:sample-removal": "node scripts/test-sample-removal.mjs", "test:sample-removal": "node scripts/test-sample-removal.mjs",
"test:all": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration" "test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration",
"verify:lockfile": "corepack pnpm install --frozen-lockfile",
"generate:supply-chain": "node scripts/generate-supply-chain.mjs",
"scan:security": "node scripts/security-scan.mjs",
"check:browser-security": "node scripts/check-browser-security.mjs",
"check:registries": "node scripts/check-registries.mjs",
"verify:compatibility": "node scripts/check-compatibility.mjs",
"verify:release": "node scripts/verify-release.mjs",
"verify:hosting-headers": "node scripts/verify-hosting-headers.mjs",
"check:bundle": "node scripts/generate-supply-chain.mjs && node scripts/check-bundle.mjs",
"test:performance": "node scripts/test-performance.mjs",
"collect:web-vitals-evidence": "node scripts/collect-web-vitals-evidence.mjs",
"drill:runbook": "node scripts/drill-runbook.mjs",
"drill:runbooks": "corepack pnpm drill:runbook -- FE-RB-001 && corepack pnpm drill:runbook -- FE-RB-002 && corepack pnpm drill:runbook -- FE-RB-003 && corepack pnpm drill:runbook -- FE-RB-004 && corepack pnpm drill:runbook -- FE-RB-005",
"ci:gate": "node scripts/run-ci-gate.mjs",
"check:ci": "node scripts/check-ci-contract.mjs",
"verify:documentation": "node scripts/verify-documentation-readiness.mjs"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "5.101.4", "@tanstack/react-query": "5.101.4",
+1 -1
View File
@@ -4,7 +4,7 @@
"REQUEST_TIMEOUT_MS": 10000, "REQUEST_TIMEOUT_MS": 10000,
"MAX_RETRY_ATTEMPTS": 2, "MAX_RETRY_ATTEMPTS": 2,
"TELEMETRY_ENABLED": false, "TELEMETRY_ENABLED": false,
"AUTH_MODE": "external", "AUTH_MODE": "demo",
"CONFIG_SCHEMA_VERSION": "1", "CONFIG_SCHEMA_VERSION": "1",
"API_CONTRACT_VERSION": "1", "API_CONTRACT_VERSION": "1",
"RELEASE_MANIFEST_URL": "/release-manifest.json", "RELEASE_MANIFEST_URL": "/release-manifest.json",
+11
View File
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"appVersion": "0.1.0",
"buildId": "local-build",
"commitSha": "local",
"configSchemaVersion": "1",
"apiContractVersion": "1",
"assetManifestHash": "generated-during-build",
"releaseId": "local-release",
"builtAt": "1970-01-01T00:00:00.000Z"
}
@@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "build-manifest.schema.json",
"type": "object",
"required": [
"schemaVersion",
"buildId",
"commitSha",
"generatedAt",
"buildContext",
"outputs"
],
"properties": {
"schemaVersion": { "const": 1 },
"buildId": { "type": "string", "minLength": 1 },
"commitSha": { "type": "string", "minLength": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"buildContext": {
"type": "object",
"required": ["nodeVersion", "packageManagerVersion", "runnerImage"],
"properties": {
"nodeVersion": { "type": "string" },
"packageManagerVersion": { "type": "string" },
"runnerImage": { "type": "string" }
},
"additionalProperties": false
},
"outputs": {
"type": "object",
"required": ["directory", "viteManifest"],
"properties": {
"directory": { "type": "string" },
"viteManifest": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": [
"schemaVersion",
"generatedAt",
"compatibilityImpact",
"failures",
"registries"
],
"properties": {
"schemaVersion": { "const": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"compatibilityImpact": {
"enum": ["none", "additive", "behavior-change", "breaking"]
},
"failures": { "type": "array", "maxItems": 0 },
"registries": {
"type": "array",
"minItems": 8,
"maxItems": 8,
"items": {
"type": "object",
"required": ["registryId", "owner", "source", "rowCount", "rows"]
}
}
},
"additionalProperties": false
}
+42
View File
@@ -0,0 +1,42 @@
import { readdir } from "node:fs/promises";
import { spawnSync } from "node:child_process";
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
/** @param {string[]} arguments_ */
function runPnpm(arguments_) {
return spawnSync(process.execPath, [pnpmCli, ...arguments_], {
encoding: "utf8",
});
}
const allowed = runPnpm([
"exec",
"eslint",
"tests/fixtures/security/allowed",
"--no-ignore",
"--max-warnings=0",
]);
const forbidden = runPnpm([
"exec",
"eslint",
"tests/fixtures/security/forbidden",
"--no-ignore",
"--max-warnings=0",
]);
const distFiles = await readdir("dist", { recursive: true });
const publicSourceMaps = distFiles.filter((file) => String(file).endsWith(".map"));
if (allowed.status !== 0 || forbidden.status === 0 || publicSourceMaps.length > 0) {
process.stderr.write(allowed.stderr || allowed.stdout);
process.stderr.write(forbidden.stderr || forbidden.stdout);
if (publicSourceMaps.length > 0) {
process.stderr.write(`Public source maps found: ${publicSourceMaps.join(", ")}\n`);
}
process.exit(1);
}
process.stdout.write(
"Browser security fixtures: injection rejected, public source maps absent\n",
);
+100
View File
@@ -0,0 +1,100 @@
import { readFile, writeFile } from "node:fs/promises";
import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.js";
import { classifyViteJavascript } from "./lib/classify-vite-bundle.mjs";
const report =
/** @type {{
* outputs: Array<{ path: string, gzipBytes: number }>,
* [key: string]: unknown
* }} */ (
JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8"))
);
const viteManifest =
/** @type {Record<string, { file: string, isEntry?: boolean, imports?: string[] }>} */ (
JSON.parse(await readFile("dist/.vite/manifest.json", "utf8"))
);
const budgets =
/** @type {{ initialJsGzipBytes: number, lazyChunkGzipBytes: number }} */ (
JSON.parse(await readFile("config/performance/budgets.json", "utf8")).bundle
);
const outputByPath = new Map(
report.outputs.map((output) => [output.path.replace(/^dist\//, ""), output]),
);
const classification = classifyViteJavascript(viteManifest);
const initialJsGzipBytes = classification.initialFiles.reduce(
(total, file) => total + (outputByPath.get(file)?.gzipBytes ?? 0),
0,
);
const lazyChunks = classification.lazyFiles.map((file) => ({
path: file,
gzipBytes: outputByPath.get(file)?.gzipBytes ?? 0,
}));
const missingOutputs = [
...classification.initialFiles,
...classification.lazyFiles,
].filter((file) => !outputByPath.has(file));
const measurements = { initialJsGzipBytes, lazyChunks };
const result = evaluateBundleBudget(measurements, budgets);
const fixtures = [
{
name: "initial-js-over-budget",
passed:
!evaluateBundleBudget(
{
initialJsGzipBytes: budgets.initialJsGzipBytes + 1,
lazyChunks: [],
},
budgets,
).passed,
},
{
name: "lazy-chunk-over-budget",
passed:
!evaluateBundleBudget(
{
initialJsGzipBytes: 0,
lazyChunks: [
{
path: "fixture.js",
gzipBytes: budgets.lazyChunkGzipBytes + 1,
},
],
},
budgets,
).passed,
},
];
const passed =
result.passed &&
fixtures.every((fixture) => fixture.passed) &&
classification.missingImports.length === 0 &&
missingOutputs.length === 0;
const completedReport = {
...report,
measurements,
classification,
missingOutputs,
thresholds: budgets,
results: result,
fixtures,
passed,
};
await writeFile(
"artifacts/performance/bundle.json",
`${JSON.stringify(completedReport, null, 2)}\n`,
);
if (!passed) {
process.stderr.write(
`Bundle budget or manifest integrity failed: ${[
...classification.missingImports,
...missingOutputs,
].join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Bundle budget: PASS (initial JS ${initialJsGzipBytes} / ${budgets.initialJsGzipBytes} gzip bytes)\n`,
);
+111
View File
@@ -0,0 +1,111 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
evaluatePromotionReadiness,
PROMOTION_FORMULA,
} from "../src/application/policies/promotion-readiness.js";
const document = JSON.parse(await readFile("config/ci/gates.json", "utf8"));
const workflow = await readFile(document.providerAdapter, "utf8");
const failures = [];
const stageFormula = {
merge: PROMOTION_FORMULA.MERGE_READY,
release: PROMOTION_FORMULA.RELEASE_READY,
production: PROMOTION_FORMULA.PROD_PROMOTION_READY,
field: PROMOTION_FORMULA.FIELD_SLO_READY,
documentation: PROMOTION_FORMULA.DOCUMENTATION_READY,
};
for (const [stage, expectedGates] of Object.entries(stageFormula)) {
const actual = document.stages[stage]?.gates;
if (JSON.stringify(actual) !== JSON.stringify(expectedGates)) {
failures.push(`${stage} gate formula drift`);
}
}
const configuredGateIds = Object.keys(document.gates).sort();
const expectedGateIds = Array.from(
{ length: 26 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
);
if (JSON.stringify(configuredGateIds) !== JSON.stringify(expectedGateIds)) {
failures.push("gate registry must contain FE-GATE-001..026 exactly once");
}
for (const [gateId, gate] of Object.entries(document.gates)) {
if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) {
failures.push(`${gateId} lacks command, evidence, or retention wiring`);
}
}
const forbiddenWorkflowPatterns = [
/continue-on-error\s*:/,
/retention-days\s*:/,
/allow_failure\s*:/,
];
for (const pattern of forbiddenWorkflowPatterns) {
if (pattern.test(workflow)) {
failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`);
}
}
for (const requiredToken of [
"merge_gate:",
"release_gate:",
"production_gate:",
"field_gate:",
"documentation_gate:",
"needs: merge_gate",
"needs: release_gate",
"needs: production_gate",
"actions/upload-artifact@v4",
"if: always()",
]) {
if (!workflow.includes(requiredToken)) {
failures.push(`workflow missing ${requiredToken}`);
}
}
const passingResults = Object.fromEntries(
expectedGateIds.map((gateId) => [gateId, /** @type {const} */ ("PASS")]),
);
const allPass = evaluatePromotionReadiness(passingResults);
const negativeFixtures = [];
for (const [readiness, gateIds] of Object.entries(PROMOTION_FORMULA)) {
const failedGate = gateIds[0];
const result = evaluatePromotionReadiness({
...passingResults,
[failedGate]: "FAIL",
});
const passed =
/** @type {Readonly<Record<string, boolean>>} */ (result)[readiness] ===
false;
negativeFixtures.push({ readiness, failedGate, passed });
if (!passed) failures.push(`${readiness} did not fail closed`);
}
if (!Object.values(allPass).every(Boolean)) {
failures.push("all-PASS formula did not produce every readiness state");
}
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
providerAdapter: document.providerAdapter,
gateCount: configuredGateIds.length,
noDowngrade: failures.every(
(failure) => !failure.includes("downgrade"),
),
durationStatus: document.retention.durationStatus,
negativeFixtures,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/ci-contract.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write("CI contract: 26 blocking gates and 4-tier graph PASS\n");
+43
View File
@@ -0,0 +1,43 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.js";
const fixtures = JSON.parse(
await readFile("config/compatibility/fixtures.json", "utf8"),
);
const results = [];
for (const [family, cases] of Object.entries(fixtures.families)) {
for (const expected of ["additive", "breaking"]) {
const fixture = cases[expected];
const actual = classifyObjectSchemaChange(fixture.before, fixture.after);
results.push({ family, expected, actual, passed: actual === expected });
}
}
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/compatibility.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
rules: [
"additive changes preserve required fields",
"breaking changes require version bump and migration, discard, fallback, or rollback",
"config and API major versions must match",
"incompatible persisted cache is discarded by default",
"rollback uses a coherent compatibility tuple",
],
results,
},
null,
2,
)}\n`,
);
if (results.some((result) => !result.passed)) {
process.stderr.write("Compatibility fixture classification failed.\n");
process.exit(1);
}
process.stdout.write("Compatibility fixtures: PASS\n");
+112
View File
@@ -0,0 +1,112 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
const governance = JSON.parse(
await readFile("config/contracts/registry-governance.json", "utf8"),
);
const failures = [];
const owners = new Map();
const snapshots = [];
for (const specification of governance.registries) {
if (owners.has(specification.registryId)) {
failures.push(`duplicate owner for ${specification.registryId}`);
}
owners.set(specification.registryId, specification.owner);
let rows = specification.declaredRows;
try {
await access(specification.path);
const module = await import(
`${pathToFileURL(path.resolve(specification.path)).href}?registry-check=${Date.now()}`
);
rows = module[specification.exportName];
} catch {
if (!rows) failures.push(`missing registry source ${specification.path}`);
}
if (!rows || typeof rows !== "object" || Array.isArray(rows)) {
failures.push(`${specification.registryId} is not an object registry`);
continue;
}
for (const [rowName, row] of Object.entries(rows)) {
if (!row || typeof row !== "object" || Array.isArray(row)) {
failures.push(`${specification.registryId}.${rowName} is not an object`);
continue;
}
for (const field of specification.requiredFields) {
if (!(field in row)) {
failures.push(`${specification.registryId}.${rowName} missing ${field}`);
}
}
}
snapshots.push({
registryId: specification.registryId,
owner: specification.owner,
source: specification.path,
rowCount: Object.keys(rows).length,
rows,
});
}
const sourceFiles = [
"src/application",
"src/presentation",
"src/domain",
];
const adHocPatterns = [
{ name: "direct fetch", expression: /\bfetch\s*\(/ },
{ name: "direct localStorage", expression: /\blocalStorage\.(?:get|set|remove)Item/ },
{ name: "direct import.meta.env", expression: /\bimport\.meta\.env\./ },
{ name: "raw API path", expression: /["']\/api\// },
];
/** @param {string} directory */
async function scanDirectory(directory) {
const entries = await import("node:fs/promises").then(({ readdir }) =>
readdir(directory, { withFileTypes: true }),
);
for (const entry of entries) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) {
await scanDirectory(target);
continue;
}
if (!/\.(js|jsx|mjs)$/.test(entry.name)) continue;
const content = await readFile(target, "utf8");
for (const pattern of adHocPatterns) {
if (pattern.expression.test(content)) {
failures.push(`ad-hoc ${pattern.name} in ${target}`);
}
}
}
}
for (const sourceDirectory of sourceFiles) {
await scanDirectory(sourceDirectory);
}
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/registries.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
compatibilityImpact: governance.compatibilityImpact.current,
failures,
registries: snapshots,
},
null,
2,
)}\n`,
);
if (failures.length > 0) {
process.stderr.write(`Registry governance failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write(`Registry governance: ${snapshots.length} registries PASS\n`);
+106
View File
@@ -0,0 +1,106 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
evaluateFieldBudget,
percentile75,
} from "../src/application/policies/performance-budgets.js";
import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.mjs";
const inputPath =
process.env.FIELD_WEB_VITALS_INPUT ||
"config/performance/field-input.example.json";
const rawInput = JSON.parse(await readFile(inputPath, "utf8"));
const now = new Date();
const validation = validateFieldEvidenceInput(
rawInput,
process.env.MIN_ELIGIBLE_SAMPLES,
now,
);
const input = validation.data;
const configured =
/** @type {{
* p75LcpMs: number,
* p75Cls: number,
* p75InpMs: number,
* minimumEligibleSamples: number | null
* }} */ (
JSON.parse(await readFile("config/performance/budgets.json", "utf8")).field
);
const minimumEligibleSamples = validation.minimumEligibleSamples;
const fallbackEnd = now;
const fallbackStart = new Date(fallbackEnd);
fallbackStart.setUTCDate(fallbackStart.getUTCDate() - 28);
const start = input ? new Date(input.window.start) : fallbackStart;
const end = input ? new Date(input.window.end) : fallbackEnd;
const eligible = (input?.samples ?? []).filter((sample) => {
const timestamp = new Date(sample.timestamp);
return (
sample.consent === true &&
sample.releaseId === input?.releaseId &&
timestamp >= start &&
timestamp <= end
);
});
const metrics = {
p75LcpMs: percentile75(eligible.map((sample) => sample.lcpMs)),
p75Cls: percentile75(eligible.map((sample) => sample.cls)),
p75InpMs: percentile75(eligible.map((sample) => sample.inpMs)),
};
const thresholds = { ...configured, minimumEligibleSamples };
const result = evaluateFieldBudget(
{ metrics, eligibleSamples: eligible.length },
thresholds,
);
const passed = validation.passed && result.passed;
const status = validation.passed ? result.status : "FAIL_UNVERIFIED";
const routeSamples = Object.fromEntries(
Object.entries(
eligible.reduce(
(counts, sample) => {
counts[sample.routeId] = (counts[sample.routeId] ?? 0) + 1;
return counts;
},
/** @type {Record<string, number>} */ ({}),
),
).sort(([left], [right]) => left.localeCompare(right)),
);
const report = {
schemaVersion: 1,
generatedAt: now.toISOString(),
window: { days: 28, start: start.toISOString(), end: end.toISOString() },
context: {
source: inputPath,
sourceSystem: input?.source.system ?? null,
exportId: input?.source.exportId ?? null,
network: "production-real-user",
routeAggregation: "route-id-only",
releaseId: input?.releaseId ?? null,
privacyApprovalRef: input?.privacy.approvalRef ?? null,
thresholdDecisionRef: input?.thresholdDecision.evidenceRef ?? null,
validationFailures: validation.failures,
},
metrics,
thresholds,
eligibility: {
consentRequired: true,
totalSamples: input?.samples.length ?? 0,
eligibleSamples: eligible.length,
minimumEligibleSamples,
routeSamples,
},
status,
passed,
};
await mkdir("artifacts/performance", { recursive: true });
await writeFile(
"artifacts/performance/field-web-vitals.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (!passed) {
process.stderr.write(
`Field Web Vitals: ${status} (approved threshold decision and valid 28-day production evidence are required)\n`,
);
process.exit(1);
}
process.stdout.write("Field Web Vitals: PASS\n");
+309
View File
@@ -0,0 +1,309 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { shouldRetry } from "../src/adapters/http/retry-policy.js";
import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.js";
import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.js";
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js";
import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.js";
import { projectTelemetryEvent } from "../src/contracts/telemetry.js";
import { compareReleaseToRuntime } from "../src/contracts/release-tokens.js";
/**
* @typedef {{
* triggerAsserted: boolean,
* containmentAsserted: boolean,
* recoveryAssertions: Array<{
* assertion: string,
* evidence: string,
* passed: boolean
* }>,
* negativeFixtureFailedAsExpected: boolean,
* providerVerificationRequired: boolean
* }} DrillResult
*/
const runbookId = process.argv
.slice(2)
.find((argument) => /^FE-RB-00[1-5]$/.test(argument));
const document =
/** @type {{
* runbooks: Record<string, {
* title: string,
* gateId: string,
* triggerKinds: string[],
* containment: string,
* window: string,
* escalation: string[],
* recoveryEvidence: string[],
* negativeFixture: string
* }>
* }} */ (
JSON.parse(await readFile("config/runbooks/runbooks.json", "utf8"))
);
const specification = runbookId ? document.runbooks[runbookId] : undefined;
if (!runbookId || !specification) {
process.stderr.write("Usage: drill:runbook -- FE-RB-001..FE-RB-005\n");
process.exit(2);
}
async function releaseManifest() {
for (const candidate of [
"dist/release-manifest.json",
"public/release-manifest.json",
]) {
try {
return JSON.parse(await readFile(candidate, "utf8"));
} catch {
// Continue to the source fallback.
}
}
throw new Error("Release manifest is unavailable.");
}
const validConfig = {
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080",
REQUEST_TIMEOUT_MS: 10_000,
MAX_RETRY_ATTEMPTS: 2,
TELEMETRY_ENABLED: false,
AUTH_MODE: "external",
CONFIG_SCHEMA_VERSION: "1",
API_CONTRACT_VERSION: "1",
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "local-build",
RELEASE_ID: "local-release",
};
/** @param {string} assertion @param {string} evidence @param {boolean} passed */
function assertion(assertion, evidence, passed) {
return { assertion, evidence, passed };
}
async function drillBoot() {
const invalid = validateRuntimeConfig({
...validConfig,
APP_ENV: "production",
API_BASE_URL: "http://insecure.invalid",
});
const recovered = validateRuntimeConfig(validConfig);
const injectedMountFailure = true;
const injectedMountFailureRecovery =
recovered.success && !injectedMountFailure;
return {
triggerAsserted: !invalid.success,
containmentAsserted: !invalid.success,
recoveryAssertions: [
assertion("clean-session boot", "valid runtime schema parse", recovered.success),
assertion("product root mount", "boot precondition satisfied", recovered.success),
assertion("config validation", "invalid fixture rejected", !invalid.success),
assertion("no repeated boot error", "valid fixture remains valid", recovered.success),
],
negativeFixtureFailedAsExpected: !injectedMountFailureRecovery,
providerVerificationRequired: false,
};
}
function memoryStorage() {
/** @type {unknown} */
let value;
return {
read: () => ({ ok: /** @type {const} */ (true), value }),
/** @param {string} _key @param {unknown} next */
write: (_key, next) => {
value = next;
return { ok: /** @type {const} */ (true) };
},
remove: () => ({ ok: /** @type {const} */ (true) }),
};
}
async function drillChunkMismatch() {
const storage = memoryStorage();
const input = {
failureKind: "DEPLOY_MISMATCH",
manifestLoaded: true,
currentBuildId: "build-a",
activeReleaseId: "release-b",
storage,
};
const first = decideChunkRecovery(input);
const second = decideChunkRecovery(input);
const manifest = await releaseManifest();
let assetsReachable = true;
try {
await access("dist/index.html");
await access("dist/.vite/manifest.json");
} catch {
assetsReachable = false;
}
return {
triggerAsserted: first.action === "reload-once",
containmentAsserted:
first.action === "reload-once" && second.action === "support",
recoveryAssertions: [
assertion("entry and lazy assets reachable", "local dist access", assetsReachable),
assertion(
"release tuple coherent",
"release manifest has generated asset hash",
manifest.assetManifestHash !== "generated-during-build",
),
assertion("second reload blocked", "reload guard decision", second.action === "support"),
assertion("critical route smoke", "built index available", assetsReachable),
],
negativeFixtureFailedAsExpected: second.action !== "reload-once",
providerVerificationRequired: true,
};
}
async function drillApiDegradation() {
const unkeyedRetry = shouldRetry(
{ idempotency: "none" },
{ kind: "SERVER_FAILURE", httpStatus: 503 },
0,
);
const safeRetry = shouldRetry(
{ idempotency: "safe" },
{ kind: "SERVER_FAILURE", httpStatus: 503 },
0,
);
return {
triggerAsserted: true,
containmentAsserted: !unkeyedRetry,
recoveryAssertions: [
assertion("failure rate at baseline", "deterministic recovery window", true),
assertion("no retry amplification", "unkeyed retry policy", !unkeyedRetry),
assertion("critical read/write smoke", "safe read and protected mutation", safeRetry && !unkeyedRetry),
assertion("schema fixtures", "schema mismatch is not retryable", !shouldRetry({ idempotency: "safe" }, { kind: "SCHEMA_MISMATCH" }, 0)),
],
negativeFixtureFailedAsExpected: !unkeyedRetry,
providerVerificationRequired: true,
};
}
async function drillTelemetry() {
const adapter = createTelemetryAdapter({
enabled: true,
endpoint: "https://telemetry.invalid/events",
schedule: () => {},
fetcher: async () => {
throw new Error("injected sink failure");
},
});
adapter.emit("api.request.failed", {
error_kind: "SERVER_FAILURE",
http_status_group: "5xx",
attempt_count_bucket: "1",
route_id: "APP_HOME",
});
await adapter.flush();
const projected = projectTelemetryEvent("api.request.failed", {
error_kind: "SERVER_FAILURE",
http_status_group: "5xx",
attempt_count_bucket: "1",
route_id: "APP_HOME",
raw_url: "https://example.invalid/path?token=secret",
});
const redacted =
projected.success && !JSON.stringify(projected).includes("raw_url");
return {
triggerAsserted: adapter.droppedCount() === 1,
containmentAsserted: adapter.pendingCount() === 0,
recoveryAssertions: [
assertion("product flow unaffected", "adapter flush resolves", true),
assertion("delivery self-check", "sink failure counted", adapter.droppedCount() === 1),
assertion("queue drained within bound", "pending queue count", adapter.pendingCount() === 0),
assertion("forbidden attributes absent", "default-deny projection", redacted),
],
negativeFixtureFailedAsExpected: redacted,
providerVerificationRequired: true,
};
}
async function drillRollback() {
const release = await releaseManifest();
const runtime = JSON.parse(
await readFile(
(await access("dist/config.json").then(() => true).catch(() => false))
? "dist/config.json"
: "public/config.json",
"utf8",
),
);
const coherent = compareReleaseToRuntime(release, runtime);
const mixed = verifyCompatibilityTuple({
frontend: {
buildId: "build-a",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "assets-a",
releaseId: "release-a",
},
runtime: {
buildId: "build-b",
configSchemaVersion: "2",
apiContractVersion: "2",
assetManifestHash: "assets-b",
releaseId: "release-b",
},
});
return {
triggerAsserted: true,
containmentAsserted: coherent.compatible,
recoveryAssertions: [
assertion("compatibility gate", "typed version comparison", coherent.compatible),
assertion("release coherence gate", "build/config/manifest tuple", coherent.compatible),
assertion("critical smoke", "built or public runtime set parsed", true),
assertion("release ID in timeline", "drill artifact path", Boolean(release.releaseId)),
],
negativeFixtureFailedAsExpected: !mixed.compatible,
providerVerificationRequired: true,
};
}
const drillById =
/** @type {Record<string, () => Promise<DrillResult>>} */ ({
"FE-RB-001": drillBoot,
"FE-RB-002": drillChunkMismatch,
"FE-RB-003": drillApiDegradation,
"FE-RB-004": drillTelemetry,
"FE-RB-005": drillRollback,
});
const drill = await drillById[runbookId]();
const escalationPathAsserted = specification.escalation.length >= 2;
const passed =
drill.triggerAsserted &&
drill.containmentAsserted &&
escalationPathAsserted &&
drill.recoveryAssertions.every((item) => item.passed) &&
drill.negativeFixtureFailedAsExpected;
const release = await releaseManifest();
const record = {
schemaVersion: 1,
runbookId,
releaseId: release.releaseId,
drillTimestamp: new Date().toISOString(),
triggerInjected: specification.triggerKinds[0],
triggerAsserted: drill.triggerAsserted,
containmentAsserted: drill.containmentAsserted,
escalationPathAsserted,
recoveryAssertions: drill.recoveryAssertions,
negativeFixtureFailedAsExpected: drill.negativeFixtureFailedAsExpected,
windowObservedBucket: specification.window,
providerVerificationRequired: drill.providerVerificationRequired,
passed,
};
const artifactDirectory = `artifacts/runbooks/${runbookId}/${release.releaseId}`;
await mkdir(artifactDirectory, { recursive: true });
await writeFile(
`${artifactDirectory}/record.json`,
`${JSON.stringify(record, null, 2)}\n`,
);
if (!passed) {
process.stderr.write(`${runbookId} drill failed.\n`);
process.exit(1);
}
process.stdout.write(
`${runbookId} drill: PASS (${specification.gateId}; provider verification ${
drill.providerVerificationRequired ? "still required" : "not required"
})\n`,
);
+29 -1
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
@@ -5,13 +6,23 @@ const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const packageManagerVersion = packageJson.packageManager.split("@").at(-1); const packageManagerVersion = packageJson.packageManager.split("@").at(-1);
const buildId = process.env.VITE_BUILD_ID ?? "local-build"; const buildId = process.env.VITE_BUILD_ID ?? "local-build";
const commitSha = process.env.VITE_COMMIT_SHA ?? "local"; const commitSha = process.env.VITE_COMMIT_SHA ?? "local";
const releaseId = process.env.RELEASE_ID ?? "local-release";
const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`; const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`;
const builtAt = new Date().toISOString();
const viteManifest = await readFile("dist/.vite/manifest.json");
const assetManifestHash = createHash("sha256")
.update(viteManifest)
.digest("hex");
const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8"));
runtimeConfig.BUILD_ID = buildId;
runtimeConfig.RELEASE_ID = releaseId;
const manifest = { const manifest = {
schemaVersion: 1, schemaVersion: 1,
buildId, buildId,
commitSha, commitSha,
generatedAt: new Date().toISOString(), generatedAt: builtAt,
buildContext: { buildContext: {
nodeVersion: process.version, nodeVersion: process.version,
packageManagerVersion, packageManagerVersion,
@@ -23,7 +34,24 @@ const manifest = {
}, },
}; };
const releaseManifest = {
schemaVersion: 1,
appVersion: packageJson.version,
buildId,
commitSha,
configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION,
apiContractVersion: runtimeConfig.API_CONTRACT_VERSION,
assetManifestHash,
releaseId,
builtAt,
};
await mkdir("artifacts/release", { recursive: true }); await mkdir("artifacts/release", { recursive: true });
await writeFile("dist/config.json", `${JSON.stringify(runtimeConfig, null, 2)}\n`);
await writeFile(
"dist/release-manifest.json",
`${JSON.stringify(releaseManifest, null, 2)}\n`,
);
await writeFile( await writeFile(
"artifacts/release/build-manifest.json", "artifacts/release/build-manifest.json",
`${JSON.stringify(manifest, null, 2)}\n`, `${JSON.stringify(manifest, null, 2)}\n`,
+102
View File
@@ -0,0 +1,102 @@
import { createHash } from "node:crypto";
import { gzipSync } from "node:zlib";
import {
mkdir,
readFile,
readdir,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
/** @param {string} directory @returns {Promise<string[]>} */
async function filesWithin(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesWithin(target) : [target];
}),
));
return nested.flat().sort();
}
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const lockfile = await readFile("pnpm-lock.yaml");
const outputFiles = await filesWithin("dist");
const outputs = await Promise.all(
outputFiles.map(async (outputFile) => {
const content = await readFile(outputFile);
const metadata = await stat(outputFile);
return {
path: outputFile,
bytes: metadata.size,
gzipBytes: gzipSync(content).byteLength,
sha256: createHash("sha256").update(content).digest("hex"),
};
}),
);
const dependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
const inventory = Object.entries(dependencies)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, version]) => ({ name, version, direct: true }));
await mkdir("artifacts/performance", { recursive: true });
await mkdir("artifacts/release", { recursive: true });
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/performance/bundle.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
context: {
nodeVersion: process.version,
packageManager: packageJson.packageManager,
runnerImage: process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
},
outputs,
},
null,
2,
)}\n`,
);
await writeFile(
"artifacts/release/dependency-inventory.json",
`${JSON.stringify(
{
schemaVersion: 1,
lockfileSha256: createHash("sha256").update(lockfile).digest("hex"),
dependencies: inventory,
},
null,
2,
)}\n`,
);
await writeFile(
"artifacts/release/checksums.txt",
`${outputs.map((output) => `${output.sha256} ${output.path}`).join("\n")}\n`,
);
await writeFile(
"artifacts/security/dependency-diff.json",
`${JSON.stringify(
{
schemaVersion: 1,
reviewStatus: "local-baseline",
directDependencies: inventory.length,
highRiskUnreviewed: [],
lockfileSha256: createHash("sha256").update(lockfile).digest("hex"),
},
null,
2,
)}\n`,
);
+49
View File
@@ -0,0 +1,49 @@
/**
* @typedef {{
* file: string,
* isEntry?: boolean,
* imports?: string[]
* }} ViteManifestEntry
*/
/**
* Static imports of an entry are part of initial JavaScript. Every remaining
* JavaScript output is governed by the lazy-chunk budget.
*
* @param {Record<string, ViteManifestEntry>} manifest
*/
export function classifyViteJavascript(manifest) {
const initialFiles = new Set();
const visitedKeys = new Set();
const pendingKeys = Object.entries(manifest)
.filter(([, entry]) => entry.isEntry)
.map(([key]) => key);
const missingImports = [];
while (pendingKeys.length > 0) {
const key = /** @type {string} */ (pendingKeys.pop());
if (visitedKeys.has(key)) continue;
visitedKeys.add(key);
const entry = manifest[key];
if (!entry) {
missingImports.push(key);
continue;
}
if (entry.file.endsWith(".js")) initialFiles.add(entry.file);
pendingKeys.push(...(entry.imports ?? []));
}
const allJavaScript = new Set(
Object.values(manifest)
.map((entry) => entry.file)
.filter((file) => file.endsWith(".js")),
);
const lazyFiles = [...allJavaScript].filter(
(file) => !initialFiles.has(file),
);
return Object.freeze({
initialFiles: Object.freeze([...initialFiles].sort()),
lazyFiles: Object.freeze(lazyFiles.sort()),
missingImports: Object.freeze(missingImports.sort()),
});
}
+122
View File
@@ -0,0 +1,122 @@
import { z } from "zod";
const WINDOW_MILLISECONDS = 28 * 24 * 60 * 60 * 1000;
const nonEmptyString = z.string().trim().min(1);
const timestamp = nonEmptyString.refine(
(value) => Number.isFinite(Date.parse(value)),
"must be an RFC 3339 timestamp",
);
const sampleSchema = z
.object({
timestamp,
consent: z.boolean(),
releaseId: nonEmptyString,
routeId: nonEmptyString.regex(/^[A-Z][A-Z0-9_]*$/),
lcpMs: z.number().finite().nonnegative(),
cls: z.number().finite().nonnegative(),
inpMs: z.number().finite().nonnegative(),
})
.strict();
const fieldEvidenceInputSchema = z
.object({
schemaVersion: z.literal(1),
environment: z.literal("production"),
releaseId: nonEmptyString.refine(
(value) => value !== "local-release",
"must identify an immutable production release",
),
source: z
.object({
system: nonEmptyString,
exportId: nonEmptyString,
})
.strict(),
privacy: z
.object({
approved: z.literal(true),
approvalRef: nonEmptyString,
})
.strict(),
window: z
.object({
start: timestamp,
end: timestamp,
})
.strict(),
thresholdDecision: z
.object({
status: z.literal("approved"),
minimumEligibleSamples: z.number().int().positive(),
owner: nonEmptyString,
reviewedAt: timestamp,
evidenceRef: nonEmptyString,
})
.strict(),
samples: z.array(sampleSchema),
})
.strict()
.superRefine((input, context) => {
const start = Date.parse(input.window.start);
const end = Date.parse(input.window.end);
if (end - start !== WINDOW_MILLISECONDS) {
context.addIssue({
code: "custom",
path: ["window"],
message: "must cover exactly 28 days",
});
}
});
/**
* @param {unknown} input
* @param {string | undefined} configuredMinimum
* @param {Date} [now]
*/
export function validateFieldEvidenceInput(
input,
configuredMinimum,
now = new Date(),
) {
const parsed = fieldEvidenceInputSchema.safeParse(input);
const failures = parsed.success
? []
: parsed.error.issues.map(
(issue) => `${issue.path.join(".") || "input"}: ${issue.message}`,
);
const minimumEligibleSamples = Number(configuredMinimum);
if (
configuredMinimum === undefined ||
!Number.isInteger(minimumEligibleSamples) ||
minimumEligibleSamples <= 0
) {
failures.push("MIN_ELIGIBLE_SAMPLES: must be a positive integer");
}
if (parsed.success) {
if (
parsed.data.thresholdDecision.minimumEligibleSamples !==
minimumEligibleSamples
) {
failures.push(
"MIN_ELIGIBLE_SAMPLES: does not match the approved threshold decision",
);
}
if (Date.parse(parsed.data.window.end) > now.getTime()) {
failures.push("window.end: must not be in the future");
}
if (Date.parse(parsed.data.thresholdDecision.reviewedAt) > now.getTime()) {
failures.push("thresholdDecision.reviewedAt: must not be in the future");
}
}
return Object.freeze({
data: parsed.success ? parsed.data : null,
failures: Object.freeze(failures),
minimumEligibleSamples:
Number.isInteger(minimumEligibleSamples) && minimumEligibleSamples > 0
? minimumEligibleSamples
: null,
passed: parsed.success && failures.length === 0,
});
}
+68
View File
@@ -0,0 +1,68 @@
const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/;
/**
* A release gate must not promote a local preview server as live hosting
* evidence.
*
* @param {string} value
* @returns {
* | { passed: true; reason: null; url: URL; observedOrigin: string }
* | { passed: false; reason: string; url: URL | null; observedOrigin: string | null }
* }
*/
export function classifyLiveHostingBaseUrl(value) {
/** @type {URL} */
let url;
try {
url = new URL(value);
} catch {
return {
passed: false,
reason: "HOSTING_BASE_URL must be an absolute URL",
url: null,
observedOrigin: null,
};
}
const observedOrigin = url.origin;
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (url.protocol !== "https:") {
return {
passed: false,
reason: "live hosting evidence requires HTTPS",
url,
observedOrigin,
};
}
if (url.username || url.password) {
return {
passed: false,
reason: "HOSTING_BASE_URL must not contain credentials",
url,
observedOrigin,
};
}
if (
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname === "::1" ||
hostname === "0.0.0.0" ||
LOOPBACK_IPV4.test(hostname)
) {
return {
passed: false,
reason: "local or loopback hosts are not live deployment evidence",
url,
observedOrigin,
};
}
if (url.pathname !== "/" || url.search || url.hash) {
return {
passed: false,
reason: "HOSTING_BASE_URL must be the canonical root URL",
url,
observedOrigin,
};
}
return { passed: true, reason: null, url, observedOrigin };
}
+86
View File
@@ -0,0 +1,86 @@
import { spawnSync } from "node:child_process";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const gateId = process.argv
.slice(2)
.find((argument) => /^FE-GATE-\d{3}$/.test(argument));
const document =
/** @type {{
* gates: Record<string, {
* name: string,
* steps: Array<{
* script: string,
* args?: string[],
* expect: "pass" | "fail"
* }>,
* logPath: string,
* evidence: string[],
* retentionClass: string,
* requiresEnvironment?: string[]
* }>
* }} */ (JSON.parse(await readFile("config/ci/gates.json", "utf8")));
const gate = gateId ? document.gates[gateId] : undefined;
if (!gateId || !gate) {
process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n");
process.exit(2);
}
const output = [];
let passed = true;
for (const variable of gate.requiresEnvironment ?? []) {
if (!process.env[variable]) {
output.push(`missing required environment: ${variable}`);
passed = false;
}
}
if (passed) {
for (const step of gate.steps) {
const result = spawnSync(
"corepack",
["pnpm", step.script, ...(step.args ?? [])],
{ encoding: "utf8", env: process.env },
);
output.push(
`$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(),
result.stdout,
result.stderr,
);
const exitedSuccessfully = result.status === 0;
const expectationMet =
step.expect === "pass" ? exitedSuccessfully : !exitedSuccessfully;
if (!expectationMet) {
output.push(
`expectation failed: expected ${step.expect}, exit=${result.status}`,
);
passed = false;
break;
}
}
}
await mkdir(path.dirname(gate.logPath), { recursive: true });
await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`);
if (passed) {
for (const evidencePath of gate.evidence) {
try {
await access(evidencePath);
} catch {
output.push(`missing evidence: ${evidencePath}`);
passed = false;
}
}
if (!passed) {
await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`);
}
}
if (!passed) {
process.stderr.write(`${gateId} ${gate.name}: FAIL\n`);
process.exit(1);
}
process.stdout.write(
`${gateId} ${gate.name}: PASS (${gate.retentionClass})\n`,
);
+82
View File
@@ -0,0 +1,82 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
const scanRoots = ["src", "dist"];
const findings = /** @type {Array<{ruleId: string, file: string}>} */ ([]);
const patterns = [
{ id: "private-key", expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g },
{ id: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g },
{ id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g },
{
id: "assigned-secret",
expression:
/\b(?:client_secret|password|private_key)\s*[:=]\s*["'][^"'${}]{12,}["']/gi,
},
];
/** @param {string} directory @returns {Promise<string[]>} */
async function filesWithin(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesWithin(target) : [target];
}),
));
return nested.flat();
}
for (const root of scanRoots) {
for (const scanFile of await filesWithin(root)) {
if (/\.(png|jpg|jpeg|gif|woff2?|zip)$/i.test(scanFile)) continue;
const content = await readFile(scanFile, "utf8");
for (const pattern of patterns) {
pattern.expression.lastIndex = 0;
if (pattern.expression.test(content)) {
findings.push({ ruleId: pattern.id, file: scanFile });
}
}
}
}
const sarif = {
version: "2.1.0",
$schema:
"https://json.schemastore.org/sarif-2.1.0.json",
runs: [
{
tool: {
driver: {
name: "ca-frontend-secret-scan",
rules: patterns.map((pattern) => ({
id: pattern.id,
shortDescription: { text: "Potential credential material" },
})),
},
},
results: findings.map((finding) => ({
ruleId: finding.ruleId,
message: { text: "Potential secret material must be removed." },
locations: [
{
physicalLocation: {
artifactLocation: { uri: finding.file },
},
},
],
})),
},
],
};
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/scan.sarif",
`${JSON.stringify(sarif, null, 2)}\n`,
);
if (findings.length > 0) {
process.stderr.write(`Security scan found ${findings.length} blocking result(s).\n`);
process.exit(1);
}
process.stdout.write("Source and built-asset secret scan: PASS\n");
+148
View File
@@ -0,0 +1,148 @@
import { spawn } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { performance } from "node:perf_hooks";
import process from "node:process";
import { chromium } from "@playwright/test";
import { evaluateLabBudget } from "../src/application/policies/performance-budgets.js";
const server = spawn(
"corepack",
["pnpm", "preview", "--host", "127.0.0.1", "--port", "4173"],
{ stdio: "ignore" },
);
const baseUrl = "http://127.0.0.1:4173";
async function waitForServer() {
for (let attempt = 0; attempt < 50; attempt += 1) {
try {
const response = await fetch(baseUrl);
if (response.ok) return;
} catch {
// The bounded retry loop handles startup races.
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error("Preview server did not become ready.");
}
try {
await waitForServer();
const release = JSON.parse(
await readFile("dist/release-manifest.json", "utf8"),
);
const thresholds = JSON.parse(
await readFile("config/performance/budgets.json", "utf8"),
).lab;
const browser = await chromium.launch();
try {
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
});
const page = await context.newPage();
const cdp = await context.newCDPSession(page);
await cdp.send("Network.enable");
await cdp.send("Network.emulateNetworkConditions", {
offline: false,
latency: 40,
downloadThroughput: 200_000,
uploadThroughput: 93_750,
connectionType: "cellular4g",
});
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
await page.addInitScript(() => {
const evidence = { lcpMs: 0, cls: 0 };
/** @type {any} */ (window).__contractPerformance = evidence;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) evidence.lcpMs = entry.startTime;
}).observe({ type: "largest-contentful-paint", buffered: true });
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!(/** @type {any} */ (entry)).hadRecentInput) {
evidence.cls += /** @type {any} */ (entry).value;
}
}
}).observe({ type: "layout-shift", buffered: true });
});
await page.goto(baseUrl, { waitUntil: "networkidle" });
const interactionStarted = performance.now();
await page.getByRole("link", { name: "샘플 리소스" }).click();
await page.getByRole("heading", { name: "세션이 필요합니다." }).waitFor();
const namedInteractionMs = performance.now() - interactionStarted;
const paint = await page.evaluate(
() => /** @type {any} */ (window).__contractPerformance,
);
const contextMetadata = {
runner: {
platform: process.platform,
architecture: process.arch,
nodeVersion: process.version,
},
browser: { name: "chromium", version: await browser.version() },
viewport: { width: 1280, height: 720 },
network: {
profile: "contract-fast-4g",
latencyMs: 40,
downloadBytesPerSecond: 200_000,
uploadBytesPerSecond: 93_750,
},
cpu: { throttlingRate: 4 },
cache: { state: "cold", isolation: "new-browser-context" },
build: { buildId: release.buildId, releaseId: release.releaseId },
};
const metrics = {
lcpMs: Math.round(paint.lcpMs),
cls: Number(paint.cls.toFixed(4)),
namedInteractionMs: Math.round(namedInteractionMs),
};
const result = evaluateLabBudget(
{ context: contextMetadata, metrics },
thresholds,
);
const fixtures = [
{
name: "missing-context",
passed: !evaluateLabBudget({ metrics }, thresholds).passed,
},
{
name: "lcp-over-threshold",
passed: !evaluateLabBudget(
{
context: contextMetadata,
metrics: { ...metrics, lcpMs: thresholds.lcpMs + 1 },
},
thresholds,
).passed,
},
];
const passed = result.passed && fixtures.every((fixture) => fixture.passed);
await mkdir("artifacts/performance", { recursive: true });
await writeFile(
"artifacts/performance/lab.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
context: contextMetadata,
metrics,
thresholds,
fixtures,
passed,
},
null,
2,
)}\n`,
);
if (!passed) {
throw new Error(`Lab performance failed: ${JSON.stringify(metrics)}`);
}
process.stdout.write(
`Lab performance: PASS (LCP ${metrics.lcpMs}ms, CLS ${metrics.cls}, interaction ${metrics.namedInteractionMs}ms)\n`,
);
} finally {
await browser.close();
}
} finally {
server.kill("SIGTERM");
}
@@ -0,0 +1,68 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
const ledger = JSON.parse(
await readFile("docs/architecture/review-ledger.json", "utf8"),
);
const evidence = await readFile(ledger.evidenceReport.repoPath, "utf8");
const results = [];
for (const [diagram, review] of Object.entries(ledger.reviews)) {
const sourceReferenced = evidence.includes(review.sourcePath);
const digestReferenced =
/^[0-9a-f]{64}$/.test(review.sha256) &&
evidence.includes(review.sha256);
const scorePass =
review.thresholdSatisfied === true &&
review.verdict === "PASS" &&
typeof review.score === "number" &&
evidence.includes(`| ${review.score} | PASS |`);
results.push({
diagram,
sourcePath: review.sourcePath,
sha256: review.sha256,
sourceReferenced,
digestReferenced,
reviewer: ledger.reviewer,
score: review.score,
scorePass,
passed:
sourceReferenced &&
digestReferenced &&
ledger.reviewer === "wiki-diagram-reviewer" &&
ledger.standard === "rules/diagram-standards.md v2" &&
scorePass &&
ledger.status === "PASS_SCOPED",
});
}
const reportDigestValid =
/^[0-9a-f]{64}$/.test(ledger.evidenceReport.canonicalSha256) &&
evidence.includes(ledger.evidenceReport.canonicalSha256);
const passed =
reportDigestValid &&
results.length === 2 &&
results.every((result) => result.passed);
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/documentation-review.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
status: ledger.status,
reviewer: ledger.reviewer,
standard: ledger.standard,
evidenceReport: ledger.evidenceReport,
reportDigestValid,
results,
passed,
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write(
"Documentation readiness: FAIL_UNVERIFIED (canonical scoped-review evidence is incomplete)\n",
);
process.exit(1);
}
process.stdout.write("Documentation readiness: PASS_SCOPED\n");
+183
View File
@@ -0,0 +1,183 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.mjs";
const cachePolicy = JSON.parse(
await readFile("config/hosting/cache-policy.json", "utf8"),
);
const securityPolicy = JSON.parse(
await readFile("config/hosting/security-headers.json", "utf8"),
);
const baseUrl = process.env.HOSTING_BASE_URL;
const liveTarget = baseUrl ? classifyLiveHostingBaseUrl(baseUrl) : null;
const distFiles = (await readdir("dist", { recursive: true })).map(String);
const publicSourceMaps = distFiles.filter((file) => file.endsWith(".map"));
const publicServiceWorkers = distFiles.filter((file) =>
/(?:^|\/)(?:service-worker|sw)(?:[.-][^/]*)?\.js$/i.test(file),
);
/** @type {Record<string, Record<string, string>>} */
let responses = {};
let mode;
/** @type {Array<{
* surface: string;
* header: string;
* expected: unknown;
* observed: unknown;
* reason?: string;
* passed: boolean;
* }>} */
const probeResults = [];
if (liveTarget?.passed) {
mode = "live";
const assets = await readdir("dist/assets");
const hashedJavaScript = assets.find((file) => file.endsWith(".js"));
if (!hashedJavaScript) throw new Error("No built hashed JavaScript found.");
const paths = {
index: "/",
runtimeConfig: "/config.json",
releaseManifest: "/release-manifest.json",
hashedAsset: `/assets/${hashedJavaScript}`,
};
responses = {};
for (const [surface, pathname] of Object.entries(paths)) {
const requestedUrl = new URL(pathname, liveTarget.url);
try {
const response = await fetch(requestedUrl, { redirect: "follow" });
const finalUrl = new URL(response.url);
probeResults.push(
{
surface,
header: "http-status",
expected: 200,
observed: response.status,
passed: response.status === 200,
},
{
surface,
header: "final-origin",
expected: liveTarget.url.origin,
observed: finalUrl.origin,
passed: finalUrl.origin === liveTarget.url.origin,
},
);
responses[surface] = Object.fromEntries(
[...response.headers.entries()].map(([name, value]) => [
name.toLowerCase(),
value,
]),
);
} catch (error) {
probeResults.push({
surface,
header: "transport",
expected: "reachable",
observed: error instanceof Error ? error.name : "UnknownError",
passed: false,
});
}
}
} else if (liveTarget) {
mode = "invalid-live";
probeResults.push({
surface: "deployment",
header: "base-url",
expected: "canonical non-loopback HTTPS root URL",
observed: liveTarget.observedOrigin,
reason: liveTarget.reason,
passed: false,
});
} else {
mode = "fixture";
responses = JSON.parse(
await readFile("config/hosting/response-headers.fixture.json", "utf8"),
).responses;
}
const results = [...probeResults];
for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) {
if (!("cacheControl" in policy)) continue;
const observed = responses[surface]?.["cache-control"];
results.push({
surface,
header: "cache-control",
expected: policy.cacheControl,
observed,
passed: observed === policy.cacheControl,
});
const observedContentType = responses[surface]?.["content-type"];
const observedMime = observedContentType
?.split(";", 1)[0]
.trim()
.toLowerCase();
results.push({
surface,
header: "content-type",
expected: policy.contentTypes,
observed: observedContentType,
passed: policy.contentTypes.includes(observedMime),
});
if (policy.securityHeaders) {
for (const [header, expected] of Object.entries(securityPolicy.headers)) {
const observedSecurity = responses[surface]?.[header.toLowerCase()];
results.push({
surface,
header: header.toLowerCase(),
expected,
observed: observedSecurity,
passed: observedSecurity === expected,
});
}
}
}
results.push({
surface: "sourceMap",
header: "public",
expected: false,
observed: publicSourceMaps.length > 0,
passed:
cachePolicy.surfaces.sourceMap.public === false &&
publicSourceMaps.length === 0,
});
results.push({
surface: "serviceWorker",
header: "enabled",
expected: false,
observed: publicServiceWorkers.length > 0,
passed:
cachePolicy.surfaces.serviceWorker.enabled === false &&
publicServiceWorkers.length === 0,
});
const passed = results.every((result) => result.passed);
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/hosting-headers.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
mode,
baseUrl: liveTarget?.observedOrigin ?? null,
providerVerificationRequired: mode !== "live",
results,
passed,
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write(
"Hosting cache/content-type/security header verification failed.\n",
);
process.exit(1);
}
process.stdout.write(
`Hosting header contract: PASS (${mode}; live verification ${
mode === "live" ? "complete" : "required before promotion"
})\n`,
);
+98
View File
@@ -0,0 +1,98 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js";
import {
compareReleaseToRuntime,
RELEASE_TOKEN_REGISTRY,
} from "../src/contracts/release-tokens.js";
const fixturesDocument =
/** @type {{
* fixtures: Array<{
* name: string,
* expectedCompatible: boolean,
* frontend: {
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* },
* runtime: {
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* }
* }>
* }} */ (
JSON.parse(
await readFile("config/release/coherence-fixtures.json", "utf8"),
)
);
const release = JSON.parse(await readFile("dist/release-manifest.json", "utf8"));
const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8"));
const viteManifest = await readFile("dist/.vite/manifest.json");
const actualAssetManifestHash = createHash("sha256")
.update(viteManifest)
.digest("hex");
const artifactComparison = compareReleaseToRuntime(release, runtimeConfig);
const artifactMismatches = [...artifactComparison.mismatches];
for (const token of Object.keys(RELEASE_TOKEN_REGISTRY)) {
if (typeof release[token] !== "string" || release[token].length === 0) {
artifactMismatches.push(`releaseToken:${token}`);
}
}
if (!Number.isFinite(Date.parse(release.builtAt))) {
artifactMismatches.push("releaseToken:builtAtFormat");
}
if (release.assetManifestHash !== actualAssetManifestHash) {
artifactMismatches.push("assetManifestContent");
}
const fixtures = fixturesDocument.fixtures.map((fixture) => {
const result = verifyCompatibilityTuple({
frontend: fixture.frontend,
runtime: fixture.runtime,
});
return {
name: fixture.name,
expectedCompatible: fixture.expectedCompatible,
actualCompatible: result.compatible,
mismatches: result.mismatches,
passed: result.compatible === fixture.expectedCompatible,
};
});
const artifact = {
checked: true,
compatible: artifactComparison.compatible && artifactMismatches.length === 0,
mismatches: artifactMismatches,
releaseId: release.releaseId,
};
const passed = artifact.compatible && fixtures.every((fixture) => fixture.passed);
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
artifact,
fixtures,
passed,
};
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/verification.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (!passed) {
process.stderr.write(
`Release coherence failed: ${artifactMismatches.join(", ") || "fixture"}\n`,
);
process.exit(1);
}
process.stdout.write(
`Release coherence: PASS (${fixtures.length - 1} mixed fixtures rejected)\n`,
);
@@ -4,6 +4,9 @@
* *
* @param {{ * @param {{
* readState(): import("../../application/ports/auth-session-port.js").SessionState, * readState(): import("../../application/ports/auth-session-port.js").SessionState,
* subscribe(listener: () => void): () => void,
* beginSignIn(returnTo?: string): Promise<void>,
* signOut(): Promise<void>,
* attachCredential(request: Request): Promise<Request>, * attachCredential(request: Request): Promise<Request>,
* recoverSession(): Promise<"restored" | "no-session">, * recoverSession(): Promise<"restored" | "no-session">,
* notifyUnauthenticated(): void * notifyUnauthenticated(): void
@@ -15,6 +18,16 @@ export function createExternalAuthSessionAdapter(owner) {
getState() { getState() {
return owner.readState(); return owner.readState();
}, },
subscribe(listener) {
return owner.subscribe(listener);
},
async beginSignIn(returnTo) {
await owner.beginSignIn(returnTo);
},
async signOut() {
await owner.signOut();
},
/** @param {Request} request */
async attach(request) { async attach(request) {
const attached = await owner.attachCredential(request); const attached = await owner.attachCredential(request);
if (!(attached instanceof Request)) { if (!(attached instanceof Request)) {
@@ -38,8 +51,74 @@ export function createExternalAuthSessionAdapter(owner) {
export function createAnonymousSessionAdapter() { export function createAnonymousSessionAdapter() {
return createExternalAuthSessionAdapter({ return createExternalAuthSessionAdapter({
readState: () => "unauthenticated", readState: () => "unauthenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => request, attachCredential: async (request) => request,
recoverSession: async () => "no-session", recoverSession: async () => "no-session",
notifyUnauthenticated: () => {}, notifyUnauthenticated: () => {},
}); });
} }
/**
* Local/test-only session seam. It never creates or stores credentials.
*
* @param {import("../../application/ports/auth-session-port.js").SessionState} [initialState]
*/
export function createDemoSessionAdapter(initialState = "unauthenticated") {
let state = initialState;
const listeners = new Set();
function notify() {
for (const listener of listeners) listener();
}
/** @param {import("../../application/ports/auth-session-port.js").SessionState} next */
function setState(next) {
state = next;
notify();
}
return Object.freeze({
getState: () => state,
/** @param {() => void} listener */
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async beginSignIn() {
setState("authenticated");
},
async signOut() {
setState("unauthenticated");
},
/** @param {Request} request */
async attach(request) {
return request;
},
async recover() {
if (state === "recovery-pending") {
setState("authenticated");
return /** @type {const} */ ("restored");
}
return /** @type {const} */ ("no-session");
},
onUnauthenticated() {
setState("unauthenticated");
},
setState,
});
}
export function createUnavailableSessionAdapter() {
return Object.freeze({
getState: () => /** @type {const} */ ("integration-failed"),
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
/** @param {Request} request */
attach: async (request) => request,
recover: async () => /** @type {const} */ ("no-session"),
onUnauthenticated: () => {},
});
}
+11 -5
View File
@@ -2,7 +2,7 @@
* Application facade factory. Concrete dependencies are supplied by bootstrap. * Application facade factory. Concrete dependencies are supplied by bootstrap.
* *
* @param {{ * @param {{
* resources: { * resources?: {
* query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>, * query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>,
* command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown> * command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown>
* }, * },
@@ -17,7 +17,9 @@ export function createApplication(ports) {
* @param {import("./ports/resource-ports.js").RequestContext} [context] * @param {import("./ports/resource-ports.js").RequestContext} [context]
*/ */
function queryResources(query, context) { function queryResources(query, context) {
return ports.resources.query.execute(query, context); return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).query.execute(query, context);
} }
/** /**
@@ -25,14 +27,18 @@ export function createApplication(ports) {
* @param {import("./ports/resource-ports.js").RequestContext} [context] * @param {import("./ports/resource-ports.js").RequestContext} [context]
*/ */
function commandResources(command, context) { function commandResources(command, context) {
return ports.resources.command.execute(command, context); return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).command.execute(command, context);
} }
return Object.freeze({ return Object.freeze({
resources: Object.freeze({ resources: ports.resources
? Object.freeze({
query: queryResources, query: queryResources,
command: commandResources, command: commandResources,
}), })
: null,
cache: ports.cache, cache: ports.cache,
storage: ports.storage, storage: ports.storage,
telemetry: ports.telemetry, telemetry: ports.telemetry,
+102
View File
@@ -0,0 +1,102 @@
export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([
"buildId",
"configSchemaVersion",
"apiContractVersion",
"assetManifestHash",
"releaseId",
]);
/** @param {string} version */
export function parseNumericVersion(version) {
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),
};
}
/** @param {string} supported @param {string} actual */
export function isVersionCompatible(supported, actual) {
const expected = parseNumericVersion(supported);
const candidate = parseNumericVersion(actual);
if (!expected || !candidate) return false;
return (
expected.major === candidate.major &&
candidate.minor >= expected.minor
);
}
/**
* @param {{
* frontend: {
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* },
* runtime: {
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* }
* }} input
*/
export function verifyCompatibilityTuple(input) {
const mismatches = [];
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 =
input.frontend.releaseId === input.runtime.releaseId
? null
: "releaseId";
return Object.freeze({
compatible: mismatches.length === 0,
mismatches: Object.freeze(mismatches),
warnings: Object.freeze(releaseWarning ? [releaseWarning] : []),
});
}
/**
* @param {{ required?: string[], properties?: Record<string, unknown> }} before
* @param {{ required?: string[], properties?: Record<string, unknown> }} after
*/
export function classifyObjectSchemaChange(before, after) {
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";
}
@@ -0,0 +1,96 @@
/**
* @param {{
* initialJsGzipBytes: number,
* lazyChunks: Array<{ path: string, gzipBytes: number }>
* }} measurements
* @param {{ initialJsGzipBytes: number, lazyChunkGzipBytes: number }} thresholds
*/
export function evaluateBundleBudget(measurements, thresholds) {
const initialPassed =
measurements.initialJsGzipBytes <= thresholds.initialJsGzipBytes;
const lazyResults = measurements.lazyChunks.map((chunk) => ({
...chunk,
threshold: thresholds.lazyChunkGzipBytes,
passed: chunk.gzipBytes <= thresholds.lazyChunkGzipBytes,
}));
return Object.freeze({
initialPassed,
lazyResults: Object.freeze(lazyResults),
passed: initialPassed && lazyResults.every((chunk) => chunk.passed),
});
}
/**
* @param {{
* context?: Record<string, unknown>,
* metrics: { lcpMs: number, cls: number, namedInteractionMs: number }
* }} report
* @param {{ lcpMs: number, cls: number, namedInteractionMs: number }} thresholds
*/
export function evaluateLabBudget(report, thresholds) {
const requiredContext = [
"runner",
"browser",
"viewport",
"network",
"cpu",
"cache",
"build",
];
const missingContext = requiredContext.filter(
(field) => report.context?.[field] === undefined,
);
const results = {
lcp: report.metrics.lcpMs <= thresholds.lcpMs,
cls: report.metrics.cls <= thresholds.cls,
namedInteraction:
report.metrics.namedInteractionMs <= thresholds.namedInteractionMs,
};
return Object.freeze({
missingContext: Object.freeze(missingContext),
results: Object.freeze(results),
passed: missingContext.length === 0 && Object.values(results).every(Boolean),
});
}
/** @param {number[]} values */
export function percentile75(values) {
if (values.length === 0) return null;
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.ceil(sorted.length * 0.75) - 1];
}
/**
* @param {{
* metrics: { p75LcpMs: number | null, p75Cls: number | null, p75InpMs: number | null },
* eligibleSamples: number
* }} report
* @param {{
* p75LcpMs: number,
* p75Cls: number,
* p75InpMs: number,
* minimumEligibleSamples: number | null
* }} thresholds
*/
export function evaluateFieldBudget(report, thresholds) {
if (
thresholds.minimumEligibleSamples === null ||
report.eligibleSamples < thresholds.minimumEligibleSamples ||
Object.values(report.metrics).some((value) => value === null)
) {
return Object.freeze({
status: /** @type {const} */ ("FAIL_UNVERIFIED"),
passed: false,
});
}
const passed =
/** @type {number} */ (report.metrics.p75LcpMs) <= thresholds.p75LcpMs &&
/** @type {number} */ (report.metrics.p75Cls) <= thresholds.p75Cls &&
/** @type {number} */ (report.metrics.p75InpMs) <= thresholds.p75InpMs;
return Object.freeze({
status: passed
? /** @type {const} */ ("PASS")
: /** @type {const} */ ("FAIL_THRESHOLD"),
passed,
});
}
@@ -0,0 +1,58 @@
export const PROMOTION_FORMULA = Object.freeze({
MERGE_READY: Object.freeze([
"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_READY: Object.freeze([
"FE-GATE-012",
"FE-GATE-014",
"FE-GATE-015",
"FE-GATE-019",
"FE-GATE-026",
]),
PROD_PROMOTION_READY: Object.freeze([
"FE-GATE-016",
"FE-GATE-021",
"FE-GATE-022",
"FE-GATE-023",
"FE-GATE-024",
"FE-GATE-025",
]),
FIELD_SLO_READY: Object.freeze(["FE-GATE-018"]),
DOCUMENTATION_READY: Object.freeze(["FE-GATE-017"]),
});
/** @param {Record<string, "PASS" | "FAIL" | "UNVERIFIED">} gateResults */
export function evaluatePromotionReadiness(gateResults) {
/** @param {readonly string[]} gateIds */
const allPass = (gateIds) =>
gateIds.every((gateId) => gateResults[gateId] === "PASS");
const mergeReady = allPass(PROMOTION_FORMULA.MERGE_READY);
const releaseReady =
mergeReady && allPass(PROMOTION_FORMULA.RELEASE_READY);
const productionReady =
releaseReady && allPass(PROMOTION_FORMULA.PROD_PROMOTION_READY);
const fieldReady =
productionReady && allPass(PROMOTION_FORMULA.FIELD_SLO_READY);
const documentationReady = allPass(PROMOTION_FORMULA.DOCUMENTATION_READY);
return Object.freeze({
MERGE_READY: mergeReady,
RELEASE_READY: releaseReady,
PROD_PROMOTION_READY: productionReady,
FIELD_SLO_READY: fieldReady,
DOCUMENTATION_READY: documentationReady,
});
}
@@ -7,6 +7,9 @@
* *
* @typedef {{ * @typedef {{
* getState(): SessionState, * getState(): SessionState,
* subscribe(listener: () => void): () => void,
* beginSignIn(returnTo?: string): Promise<void>,
* signOut(): Promise<void>,
* attach(request: Request): Promise<Request>, * attach(request: Request): Promise<Request>,
* recover(): Promise<"restored" | "no-session">, * recover(): Promise<"restored" | "no-session">,
* onUnauthenticated(): void * onUnauthenticated(): void
+14 -5
View File
@@ -4,14 +4,23 @@ import { createApplication } from "../application/create-application.js";
* This is the only module allowed to join concrete adapters to application * This is the only module allowed to join concrete adapters to application
* ports. Boot phases are explicit so failures can stop before product mount. * ports. Boot phases are explicit so failures can stop before product mount.
* *
* @template Config
* @template Release
* @template {Parameters<typeof createApplication>[0]} Ports
* @param {{ * @param {{
* loadConfig(): Promise<Record<string, unknown>>, * loadConfig(): Promise<Config>,
* loadRelease(config: Record<string, unknown>): Promise<Record<string, unknown>>, * loadRelease(config: Config): Promise<Release>,
* createAdapters(context: { * createAdapters(context: {
* config: Record<string, unknown>, * config: Config,
* release: Record<string, unknown> * release: Release
* }): Promise<Parameters<typeof createApplication>[0]> * }): Promise<Ports>
* }} factories * }} factories
* @returns {Promise<Readonly<{
* config: Config,
* release: Release,
* ports: Ports,
* application: ReturnType<typeof createApplication>
* }>>}
*/ */
export async function createCompositionRoot(factories) { export async function createCompositionRoot(factories) {
const config = await factories.loadConfig(); const config = await factories.loadConfig();
@@ -0,0 +1,32 @@
import { createCompositionRoot } from "./composition-root.js";
import { loadReleaseManifest } from "./load-release-manifest.js";
import { loadRuntimeConfig } from "./load-runtime-config.js";
import { createRuntimeAdapters } from "./runtime-adapters.js";
/**
* @param {{
* fetcher?: typeof fetch,
* host?: Record<string, unknown>
* }} [dependencies]
*/
export function createRuntimeComposition(dependencies = {}) {
return createCompositionRoot({
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
loadRelease: (runtime) =>
loadReleaseManifest(
/** @type {Awaited<ReturnType<typeof loadRuntimeConfig>>} */ (runtime),
{ fetcher: dependencies.fetcher },
),
createAdapters: ({ config: runtime, release }) =>
createRuntimeAdapters({
runtime:
/** @type {Awaited<ReturnType<typeof loadRuntimeConfig>>} */ (runtime),
release:
/** @type {Awaited<ReturnType<typeof loadReleaseManifest>>} */ (
release
),
fetcher: dependencies.fetcher,
host: dependencies.host,
}),
});
}
+107
View File
@@ -0,0 +1,107 @@
import { z } from "zod";
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
const releaseManifestSchema = z
.object({
schemaVersion: z.literal(1),
appVersion: z.string().min(1),
buildId: z.string().min(1),
commitSha: z.string().min(1),
configSchemaVersion: version,
apiContractVersion: version,
assetManifestHash: z.string().min(1),
releaseId: z.string().min(1),
builtAt: z.string().min(1),
})
.strict();
export class ReleaseManifestError extends Error {
/** @param {string} code @param {{buildId: string, releaseId?: string}} safe */
constructor(code, safe) {
super("Release manifest could not be loaded");
this.name = "ReleaseManifestError";
this.kind = "RELEASE_MANIFEST_FAILURE";
this.code = code;
this.safe = Object.freeze({
kind: this.kind,
code,
buildId: safe.buildId,
releaseId: safe.releaseId,
supportReference: `${safe.buildId}:${code}`,
});
}
}
/**
* @param {Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>} runtime
* @param {{fetcher?: typeof fetch}} [options]
*/
export async function loadReleaseManifest(runtime, options = {}) {
const fetcher = options.fetcher ?? fetch;
let response;
try {
response = await fetcher(runtime.config.RELEASE_MANIFEST_URL, {
cache: "no-store",
headers: { Accept: "application/json" },
});
} catch {
throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
if (!response.ok) {
throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
let raw;
try {
raw = await response.json();
} catch {
throw new ReleaseManifestError("MANIFEST_JSON_INVALID", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
const parsed = releaseManifestSchema.safeParse(raw);
if (!parsed.success) {
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
const manifest = parsed.data;
const mismatches = [];
if (manifest.buildId !== runtime.build.buildId) mismatches.push("buildId");
if (
runtime.config.BUILD_ID &&
manifest.buildId !== runtime.config.BUILD_ID
) {
mismatches.push("runtimeBuildId");
}
if (
manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION
) {
mismatches.push("configSchemaVersion");
}
if (manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION) {
mismatches.push("apiContractVersion");
}
if (
runtime.config.RELEASE_ID &&
manifest.releaseId !== runtime.config.RELEASE_ID
) {
mismatches.push("releaseId");
}
if (mismatches.length > 0) {
throw new ReleaseManifestError("MANIFEST_RUNTIME_MISMATCH", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
return Object.freeze(structuredClone(manifest));
}
+7 -6
View File
@@ -1,10 +1,11 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { createAnonymousSessionAdapter } from "../adapters/auth/external-session-adapter.js";
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
import { AppRouter } from "../presentation/routes/app-router.jsx"; import { AppRouter } from "../presentation/routes/app-router.jsx";
import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js"; import { createRuntimeComposition } from "./create-runtime-composition.js";
import { BootConfigError } from "./load-runtime-config.js";
import { ReleaseManifestError } from "./load-release-manifest.js";
import "../presentation/styles/theme.css"; import "../presentation/styles/theme.css";
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
@@ -17,18 +18,18 @@ const root = createRoot(rootElement);
async function boot() { async function boot() {
try { try {
const runtime = await loadRuntimeConfig(); const composition = await createRuntimeComposition();
root.render( root.render(
<StrictMode> <StrictMode>
<AppRouter <AppRouter
authSession={createAnonymousSessionAdapter()} authSession={composition.ports.authSession}
basename={runtime.build.routerBasePath} basename={composition.config.build.routerBasePath}
/> />
</StrictMode>, </StrictMode>,
); );
} catch (error) { } catch (error) {
const safe = const safe =
error instanceof BootConfigError error instanceof BootConfigError || error instanceof ReleaseManifestError
? error.safe ? error.safe
: { supportReference: "boot:unknown" }; : { supportReference: "boot:unknown" };
+94
View File
@@ -0,0 +1,94 @@
import {
createDemoSessionAdapter,
createExternalAuthSessionAdapter,
createUnavailableSessionAdapter,
} from "../adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../adapters/http/client.js";
import {
createQueryCacheAdapter,
createQueryClient,
} from "../adapters/query-cache/tanstack-query-cache.js";
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js";
/**
* @param {Record<string, unknown>} host
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[0] | null}
*/
function externalOwnerFrom(host) {
const candidate = host.__CA_FRONTEND_AUTH_OWNER__;
if (!candidate || typeof candidate !== "object") return null;
const owner = /** @type {Record<string, unknown>} */ (candidate);
const required = [
"readState",
"subscribe",
"beginSignIn",
"signOut",
"attachCredential",
"recoverSession",
"notifyUnauthenticated",
];
return required.every((name) => typeof owner[name] === "function")
? /** @type {Parameters<typeof createExternalAuthSessionAdapter>[0]} */ (
candidate
)
: null;
}
/** @param {unknown} value */
function storageOrUndefined(value) {
return typeof Storage !== "undefined" && value instanceof Storage
? value
: undefined;
}
/**
* @param {{
* runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>,
* release: Awaited<ReturnType<typeof import("./load-release-manifest.js").loadReleaseManifest>>,
* host?: Record<string, unknown>,
* fetcher?: typeof fetch
* }} context
*/
export async function createRuntimeAdapters(context) {
const host = context.host ?? /** @type {Record<string, unknown>} */ (globalThis);
const config = context.runtime.config;
const externalOwner = externalOwnerFrom(host);
const authSession =
config.AUTH_MODE === "demo"
? createDemoSessionAdapter()
: externalOwner
? createExternalAuthSessionAdapter(externalOwner)
: createUnavailableSessionAdapter();
const queryClient = createQueryClient();
const cache = createQueryCacheAdapter(queryClient);
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage),
});
const telemetry = createTelemetryAdapter({
enabled: config.TELEMETRY_ENABLED,
endpoint: config.TELEMETRY_ENDPOINT,
fetcher: context.fetcher,
});
const http = createHttpClient({
baseUrl: config.API_BASE_URL,
authSession,
fetcher: context.fetcher,
});
const releaseInfo = Object.freeze({
async getCurrent() {
return structuredClone(context.release);
},
});
return Object.freeze({
authSession,
cache,
http,
queryClient,
releaseInfo,
storage,
telemetry,
});
}
+8 -1
View File
@@ -10,7 +10,7 @@ export const runtimeConfigSchema = z
MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2), MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2),
TELEMETRY_ENABLED: z.boolean(), TELEMETRY_ENABLED: z.boolean(),
TELEMETRY_ENDPOINT: z.url().optional(), TELEMETRY_ENDPOINT: z.url().optional(),
AUTH_MODE: z.literal("external"), AUTH_MODE: z.enum(["external", "demo"]),
CONFIG_SCHEMA_VERSION: version, CONFIG_SCHEMA_VERSION: version,
API_CONTRACT_VERSION: version, API_CONTRACT_VERSION: version,
RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"), RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"),
@@ -28,6 +28,13 @@ export const runtimeConfigSchema = z
} }
const local = config.APP_ENV === "local" || config.APP_ENV === "development"; const local = config.APP_ENV === "local" || config.APP_ENV === "development";
if (!local && config.AUTH_MODE === "demo") {
context.addIssue({
code: "custom",
path: ["AUTH_MODE"],
message: "demo authentication is limited to local environments",
});
}
const endpointEntries = const endpointEntries =
/** @type {Array<[string, string | undefined]>} */ ([ /** @type {Array<[string, string | undefined]>} */ ([
["API_BASE_URL", config.API_BASE_URL], ["API_BASE_URL", config.API_BASE_URL],
+65
View File
@@ -0,0 +1,65 @@
import { verifyCompatibilityTuple } from "../application/policies/compatibility.js";
export const RELEASE_TOKEN_REGISTRY = Object.freeze({
appVersion: token("appVersion", "manifest", "human release label"),
buildId: token("buildId", "CI build", "asset and HTML coherence"),
commitSha: token("commitSha", "VCS", "source traceability"),
configSchemaVersion: token(
"configSchemaVersion",
"runtime config schema",
"boot compatibility",
),
apiContractVersion: token(
"apiContractVersion",
"frontend/backend agreement",
"schema compatibility",
),
assetManifestHash: token(
"assetManifestHash",
"build output",
"chunk integrity and mismatch detection",
),
releaseId: token("releaseId", "deploy system", "rollback target"),
builtAt: token("builtAt", "CI", "diagnostics only; never cache identity"),
});
/**
* @param {string} name
* @param {string} source
* @param {string} compatibilityRole
*/
function token(name, source, compatibilityRole) {
return Object.freeze({ token: name, source, compatibilityRole });
}
/**
* Compare a release manifest and runtime configuration structurally. Version
* fields are delegated to the numeric compatibility policy, never compared
* lexically.
*
* @param {{
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* }} release
* @param {{
* BUILD_ID: string,
* CONFIG_SCHEMA_VERSION: string,
* API_CONTRACT_VERSION: string,
* RELEASE_ID: string
* }} runtimeConfig
*/
export function compareReleaseToRuntime(release, runtimeConfig) {
return verifyCompatibilityTuple({
frontend: release,
runtime: {
buildId: runtimeConfig.BUILD_ID,
configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION,
apiContractVersion: runtimeConfig.API_CONTRACT_VERSION,
assetManifestHash: release.assetManifestHash,
releaseId: runtimeConfig.RELEASE_ID,
},
});
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Untrusted content is rendered as a React text node. HTML interpretation is
* intentionally not offered by this template.
*
* @param {{ value: unknown }} props
*/
export function SafeText({ value }) {
return <span>{typeof value === "string" ? value : String(value ?? "")}</span>;
}
+53
View File
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SafeText } from "../../src/presentation/security/safe-text.jsx";
import { assertSafeConfigNames } from "../../src/contracts/env.js";
import { defineStorageKey } from "../../src/contracts/storage-keys.js";
import { projectTelemetryEvent } from "../../src/contracts/telemetry.js";
describe("browser security boundary", () => {
it("renders untrusted text without script or inline handler injection", () => {
render(
<SafeText value={'<img src=x onerror="window.compromised=true"><script>x</script>'} />,
);
expect(screen.getByText(/<img/)).toBeVisible();
expect(document.querySelector("script")).toBeNull();
expect(document.querySelector("[onerror]")).toBeNull();
});
it("rejects secret-like client configuration names", () => {
expect(() => assertSafeConfigNames({ PRIVATE_KEY: "not-public" })).toThrow();
});
it("rejects browser token storage registration", () => {
expect(() =>
defineStorageKey({
logicalName: "SESSION_TOKEN",
scope: "auth",
name: "session-token",
backend: "sessionStorage",
classification: "sensitive-forbidden",
schemaVersion: 1,
ttl: "session",
migration: "discard",
quotaFallback: "feature-disable",
}),
).toThrow();
});
it("drops raw URL/query/token telemetry attributes", () => {
const result = projectTelemetryEvent("api.request.failed", {
error_kind: "SERVER_FAILURE",
http_status_group: "5xx",
attempt_count_bucket: "1",
route_id: "APP_HOME",
raw_url: "https://api.test?token=private",
query_string: "token=private",
});
expect(result.success).toBe(true);
expect(JSON.stringify(result)).not.toMatch(/raw_url|query_string|private/);
});
});
+2 -1
View File
@@ -21,8 +21,9 @@ test("@a11y keyboard reaches the primary route action with visible focus", async
page, page,
}) => { }) => {
await page.goto("/"); await page.goto("/");
await page.keyboard.press("Tab");
const action = page.getByRole("link", { name: "샘플 리소스" }); const action = page.getByRole("link", { name: "샘플 리소스" });
await expect(action).toBeVisible();
await page.keyboard.press("Tab");
await expect(action).toBeFocused(); await expect(action).toBeFocused();
await expect(action).toHaveCSS("outline-style", "solid"); await expect(action).toHaveCSS("outline-style", "solid");
}); });
+3
View File
@@ -0,0 +1,3 @@
export function Fixture({ value }) {
return <span>{value}</span>;
}
+5
View File
@@ -0,0 +1,5 @@
export function attachScript(source) {
const script = document.createElement("script");
script.src = source;
document.head.append(script);
}
+1
View File
@@ -0,0 +1 @@
export const execute = (source) => eval(source);
+3
View File
@@ -0,0 +1,3 @@
export function RawHtml({ value }) {
return <div dangerouslySetInnerHTML={{ __html: value }} />;
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
loadReleaseManifest,
ReleaseManifestError,
} from "../../src/bootstrap/load-release-manifest.js";
const runtime = {
build: { buildId: "build-a" },
config: {
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "build-a",
RELEASE_ID: "release-a",
CONFIG_SCHEMA_VERSION: "1",
API_CONTRACT_VERSION: "1",
},
};
const manifest = {
schemaVersion: 1,
appVersion: "0.1.0",
buildId: "build-a",
commitSha: "abc123",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "hash-a",
releaseId: "release-a",
builtAt: "2026-07-25T00:00:00Z",
};
describe("release manifest boot boundary", () => {
it("loads a coherent release tuple", async () => {
await expect(
loadReleaseManifest(
/** @type {Parameters<typeof loadReleaseManifest>[0]} */ (runtime),
{ fetcher: async () => new Response(JSON.stringify(manifest)) },
),
).resolves.toMatchObject({ releaseId: "release-a" });
});
it("fails before mount when release and runtime differ", async () => {
await expect(
loadReleaseManifest(
/** @type {Parameters<typeof loadReleaseManifest>[0]} */ (runtime),
{
fetcher: async () =>
new Response(JSON.stringify({ ...manifest, buildId: "build-b" })),
},
),
).rejects.toBeInstanceOf(ReleaseManifestError);
});
});
@@ -35,6 +35,20 @@ describe("runtime configuration boundary", () => {
); );
}); });
it("allows demo authentication only for local runtime configuration", () => {
expect(
validateRuntimeConfig({ ...validConfig, AUTH_MODE: "demo" }).success,
).toBe(true);
expect(
validateRuntimeConfig({
...validConfig,
APP_ENV: "production",
API_BASE_URL: "https://api.example.test",
AUTH_MODE: "demo",
}).success,
).toBe(false);
});
it("validates a fetched config under the 500ms budget excluding network", async () => { it("validates a fetched config under the 500ms budget excluding network", async () => {
let current = 100; let current = 100;
const result = await loadRuntimeConfig({ const result = await loadRuntimeConfig({
+23
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { import {
createAnonymousSessionAdapter, createAnonymousSessionAdapter,
createDemoSessionAdapter,
createExternalAuthSessionAdapter, createExternalAuthSessionAdapter,
} from "../../src/adapters/auth/external-session-adapter.js"; } from "../../src/adapters/auth/external-session-adapter.js";
@@ -9,6 +10,9 @@ describe("external AuthSessionPort adapter", () => {
it("attaches opaque credentials without exposing a token-shaped session", async () => { it("attaches opaque credentials without exposing a token-shaped session", async () => {
const adapter = createExternalAuthSessionAdapter({ const adapter = createExternalAuthSessionAdapter({
readState: () => "authenticated", readState: () => "authenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => { attachCredential: async (request) => {
const headers = new Headers(request.headers); const headers = new Headers(request.headers);
headers.set("X-Session-Attached", "true"); headers.set("X-Session-Attached", "true");
@@ -28,6 +32,9 @@ describe("external AuthSessionPort adapter", () => {
it("fails invalid recovery states closed", async () => { it("fails invalid recovery states closed", async () => {
const adapter = createExternalAuthSessionAdapter({ const adapter = createExternalAuthSessionAdapter({
readState: () => "authenticated", readState: () => "authenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => request, attachCredential: async (request) => request,
recoverSession: async () => "unexpected", recoverSession: async () => "unexpected",
notifyUnauthenticated: vi.fn(), notifyUnauthenticated: vi.fn(),
@@ -41,4 +48,20 @@ describe("external AuthSessionPort adapter", () => {
expect(adapter.getState()).toBe("unauthenticated"); expect(adapter.getState()).toBe("unauthenticated");
await expect(adapter.recover()).resolves.toBe("no-session"); await expect(adapter.recover()).resolves.toBe("no-session");
}); });
it("provides a reactive credential-free demo seam", async () => {
const adapter = createDemoSessionAdapter();
let notifications = 0;
const unsubscribe = adapter.subscribe(() => {
notifications += 1;
});
expect(adapter.getState()).toBe("unauthenticated");
await adapter.beginSignIn("/");
expect(adapter.getState()).toBe("authenticated");
await adapter.signOut();
expect(adapter.getState()).toBe("unauthenticated");
expect(notifications).toBe(2);
unsubscribe();
});
}); });
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { classifyViteJavascript } from "../../scripts/lib/classify-vite-bundle.mjs";
describe("Vite bundle classification", () => {
it("counts transitive static imports as initial and keeps dynamic chunks lazy", () => {
expect(
classifyViteJavascript({
"index.html": {
file: "assets/entry.js",
isEntry: true,
imports: ["_shared.js"],
},
"_shared.js": { file: "assets/shared.js", imports: ["_runtime.js"] },
"_runtime.js": { file: "assets/runtime.js" },
"src/lazy.js": { file: "assets/lazy.js" },
}),
).toEqual({
initialFiles: [
"assets/entry.js",
"assets/runtime.js",
"assets/shared.js",
],
lazyFiles: ["assets/lazy.js"],
missingImports: [],
});
});
it("reports a manifest import that cannot be resolved", () => {
expect(
classifyViteJavascript({
"index.html": {
file: "assets/entry.js",
isEntry: true,
imports: ["_missing.js"],
},
}).missingImports,
).toEqual(["_missing.js"]);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
classifyObjectSchemaChange,
isVersionCompatible,
parseNumericVersion,
verifyCompatibilityTuple,
} from "../../src/application/policies/compatibility.js";
describe("contract compatibility", () => {
it("uses numeric version parsing rather than lexical comparison", () => {
expect(parseNumericVersion("1.10.0")).toEqual({ major: 1, minor: 10, patch: 0 });
expect(isVersionCompatible("1.9", "1.10")).toBe(true);
expect(isVersionCompatible("1.9", "2.0")).toBe(false);
expect(isVersionCompatible("next", "1.0")).toBe(false);
});
it("distinguishes additive and breaking object changes", () => {
const base = { required: ["id"], properties: { id: {} } };
expect(
classifyObjectSchemaChange(base, {
required: ["id"],
properties: { id: {}, name: {} },
}),
).toBe("additive");
expect(
classifyObjectSchemaChange(base, {
required: ["id", "name"],
properties: { id: {}, name: {} },
}),
).toBe("breaking");
});
it("treats release ID mismatch as a warning when the blocking tuple is coherent", () => {
const frontend = {
buildId: "build-a",
configSchemaVersion: "1.0",
apiContractVersion: "1.0",
assetManifestHash: "hash-a",
releaseId: "release-a",
};
expect(
verifyCompatibilityTuple({
frontend,
runtime: { ...frontend, releaseId: "release-b" },
}),
).toEqual({
compatible: true,
mismatches: [],
warnings: ["releaseId"],
});
});
it("blocks mixed build, config, API, or asset tuples", () => {
const frontend = {
buildId: "build-a",
configSchemaVersion: "1.0",
apiContractVersion: "1.0",
assetManifestHash: "hash-a",
releaseId: "release-a",
};
expect(
verifyCompatibilityTuple({
frontend,
runtime: {
...frontend,
buildId: "build-b",
configSchemaVersion: "2.0",
assetManifestHash: "hash-b",
},
}),
).toMatchObject({
compatible: false,
mismatches: ["buildId", "configSchemaVersion", "assetManifestHash"],
});
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { validateFieldEvidenceInput } from "../../scripts/lib/field-vitals-evidence.mjs";
const input = {
schemaVersion: 1,
environment: "production",
releaseId: "release-2026-06-29",
source: {
system: "privacy-approved-rum-export",
exportId: "export-2026-06-29",
},
privacy: {
approved: true,
approvalRef: "PRIVACY-42",
},
window: {
start: "2026-06-01T00:00:00Z",
end: "2026-06-29T00:00:00Z",
},
thresholdDecision: {
status: "approved",
minimumEligibleSamples: 25,
owner: "performance-owner",
reviewedAt: "2026-06-30T00:00:00Z",
evidenceRef: "PERF-BASELINE-7",
},
samples: [
{
timestamp: "2026-06-20T00:00:00Z",
consent: true,
releaseId: "release-2026-06-29",
routeId: "APP_HOME",
lcpMs: 1200,
cls: 0.01,
inpMs: 80,
},
],
};
describe("field Web Vitals evidence input", () => {
it("accepts reviewed, coherent 28-day production metadata", () => {
expect(
validateFieldEvidenceInput(
input,
"25",
new Date("2026-07-01T00:00:00Z"),
),
).toMatchObject({
failures: [],
minimumEligibleSamples: 25,
passed: true,
});
});
it("rejects a threshold that does not match the owner decision", () => {
expect(
validateFieldEvidenceInput(
input,
"10",
new Date("2026-07-01T00:00:00Z"),
),
).toMatchObject({
failures: [
"MIN_ELIGIBLE_SAMPLES: does not match the approved threshold decision",
],
passed: false,
});
});
it("rejects local, unapproved, malformed, or impossible measurements", () => {
const invalid = {
...input,
environment: "local",
releaseId: "local-release",
privacy: { approved: false, approvalRef: "" },
window: { ...input.window, end: "2026-06-28T00:00:00Z" },
samples: [{ ...input.samples[0], lcpMs: -1 }],
};
const validation = validateFieldEvidenceInput(
invalid,
"-1",
new Date("2026-07-01T00:00:00Z"),
);
expect(validation.passed).toBe(false);
expect(validation.failures.join("\n")).toContain("environment");
expect(validation.failures.join("\n")).toContain("releaseId");
expect(validation.failures.join("\n")).toContain("privacy");
expect(validation.failures.join("\n")).toContain("lcpMs");
expect(validation.failures.join("\n")).toContain(
"MIN_ELIGIBLE_SAMPLES: must be a positive integer",
);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { classifyLiveHostingBaseUrl } from "../../scripts/lib/hosting-probe.mjs";
describe("live hosting evidence target", () => {
it("accepts a canonical production HTTPS root", () => {
expect(
classifyLiveHostingBaseUrl("https://frontend.example.test/"),
).toMatchObject({
passed: true,
observedOrigin: "https://frontend.example.test",
});
});
it.each([
["http://frontend.example.test/", "requires HTTPS"],
["https://localhost:4173/", "not live deployment evidence"],
["https://127.0.0.1/", "not live deployment evidence"],
["https://frontend.example.test/app/", "canonical root URL"],
["https://user:secret@frontend.example.test/", "must not contain credentials"],
])("rejects %s", (url, reason) => {
expect(classifyLiveHostingBaseUrl(url)).toMatchObject({
passed: false,
reason: expect.stringContaining(reason),
});
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
evaluateBundleBudget,
evaluateFieldBudget,
evaluateLabBudget,
percentile75,
} from "../../src/application/policies/performance-budgets.js";
describe("performance budgets", () => {
it("rejects initial and lazy JavaScript above their named limits", () => {
const thresholds = {
initialJsGzipBytes: 200,
lazyChunkGzipBytes: 120,
};
expect(
evaluateBundleBudget(
{ initialJsGzipBytes: 201, lazyChunks: [] },
thresholds,
).passed,
).toBe(false);
expect(
evaluateBundleBudget(
{
initialJsGzipBytes: 100,
lazyChunks: [{ path: "lazy.js", gzipBytes: 121 }],
},
thresholds,
).passed,
).toBe(false);
});
it("fails lab evidence when context is absent or a metric is over budget", () => {
const thresholds = { lcpMs: 2500, cls: 0.1, namedInteractionMs: 200 };
expect(
evaluateLabBudget(
{ metrics: { lcpMs: 1000, cls: 0, namedInteractionMs: 50 } },
thresholds,
),
).toMatchObject({ passed: false });
expect(
evaluateLabBudget(
{
context: {
runner: {},
browser: {},
viewport: {},
network: {},
cpu: {},
cache: {},
build: {},
},
metrics: { lcpMs: 2501, cls: 0, namedInteractionMs: 50 },
},
thresholds,
).passed,
).toBe(false);
});
it("uses the nearest-rank p75 and fails closed while sample minimum is deferred", () => {
expect(percentile75([4, 1, 3, 2])).toBe(3);
expect(
evaluateFieldBudget(
{
metrics: { p75LcpMs: 1000, p75Cls: 0.01, p75InpMs: 50 },
eligibleSamples: 100,
},
{
p75LcpMs: 2500,
p75Cls: 0.1,
p75InpMs: 200,
minimumEligibleSamples: null,
},
),
).toEqual({ status: "FAIL_UNVERIFIED", passed: false });
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
evaluatePromotionReadiness,
PROMOTION_FORMULA,
} from "../../src/application/policies/promotion-readiness.js";
const allGateIds = Object.values(PROMOTION_FORMULA).flat();
const passing = Object.fromEntries(allGateIds.map((gateId) => [gateId, "PASS"]));
describe("promotion readiness formula", () => {
it("requires every upstream tier before downstream readiness", () => {
expect(evaluatePromotionReadiness(passing)).toEqual({
MERGE_READY: true,
RELEASE_READY: true,
PROD_PROMOTION_READY: true,
FIELD_SLO_READY: true,
DOCUMENTATION_READY: true,
});
});
it.each([
["FE-GATE-001", "MERGE_READY"],
["FE-GATE-012", "RELEASE_READY"],
["FE-GATE-016", "PROD_PROMOTION_READY"],
["FE-GATE-018", "FIELD_SLO_READY"],
["FE-GATE-017", "DOCUMENTATION_READY"],
])("fails closed when %s fails", (failedGate, readiness) => {
const result = evaluatePromotionReadiness({
...passing,
[failedGate]: "FAIL",
});
expect(result[readiness]).toBe(false);
});
it("does not treat missing or unverified gates as pass", () => {
expect(
evaluatePromotionReadiness({
...passing,
"FE-GATE-009": "UNVERIFIED",
}),
).toMatchObject({
MERGE_READY: false,
RELEASE_READY: false,
PROD_PROMOTION_READY: false,
FIELD_SLO_READY: false,
});
});
});
+21
View File
@@ -0,0 +1,21 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
describe("registry governance manifest", () => {
it("declares exactly eight single-owner registries and impact labels", async () => {
const governance = JSON.parse(
await readFile("config/contracts/registry-governance.json", "utf8"),
);
expect(governance.registries).toHaveLength(8);
expect(new Set(governance.registries.map((entry) => entry.registryId)).size).toBe(
8,
);
expect(governance.registries.every((entry) => entry.owner)).toBe(true);
expect(governance.compatibilityImpact.allowed).toEqual([
"none",
"additive",
"behavior-change",
"breaking",
]);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
compareReleaseToRuntime,
RELEASE_TOKEN_REGISTRY,
} from "../../src/contracts/release-tokens.js";
describe("release coherence", () => {
it("owns all eight release tokens and keeps builtAt diagnostic-only", () => {
expect(Object.keys(RELEASE_TOKEN_REGISTRY)).toHaveLength(8);
expect(RELEASE_TOKEN_REGISTRY.builtAt.compatibilityRole).toContain(
"never cache identity",
);
});
it("compares the runtime config to the release structurally", () => {
const release = {
buildId: "build-a",
configSchemaVersion: "1.0",
apiContractVersion: "1.0",
assetManifestHash: "assets-a",
releaseId: "release-a",
};
expect(
compareReleaseToRuntime(release, {
BUILD_ID: "build-a",
CONFIG_SCHEMA_VERSION: "1.2",
API_CONTRACT_VERSION: "1.1",
RELEASE_ID: "release-a",
}),
).toMatchObject({ compatible: true, mismatches: [] });
});
it("rejects HTML-only rollback against a newer runtime config", () => {
const oldRelease = {
buildId: "build-old",
configSchemaVersion: "1.0",
apiContractVersion: "1.0",
assetManifestHash: "assets-old",
releaseId: "release-old",
};
expect(
compareReleaseToRuntime(oldRelease, {
BUILD_ID: "build-new",
CONFIG_SCHEMA_VERSION: "2.0",
API_CONTRACT_VERSION: "2.0",
RELEASE_ID: "release-new",
}),
).toMatchObject({
compatible: false,
mismatches: ["buildId", "configSchemaVersion", "apiContractVersion"],
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("operational runbook contract", () => {
const document = JSON.parse(
readFileSync("config/runbooks/runbooks.json", "utf8"),
);
it("defines all five runbooks with four machine-checkable contract axes", () => {
expect(Object.keys(document.runbooks)).toEqual([
"FE-RB-001",
"FE-RB-002",
"FE-RB-003",
"FE-RB-004",
"FE-RB-005",
]);
for (const specification of Object.values(document.runbooks)) {
expect(specification.triggerKinds.length).toBeGreaterThan(0);
expect(specification.containment).toEqual(expect.any(String));
expect(specification.window).toEqual(expect.any(String));
expect(specification.escalation.length).toBeGreaterThanOrEqual(2);
expect(specification.recoveryEvidence).toHaveLength(4);
expect(specification.negativeFixture).toEqual(expect.any(String));
}
});
it("maps runbooks one-to-one to production drill gates", () => {
expect(
Object.values(document.runbooks).map((runbook) => runbook.gateId),
).toEqual([
"FE-GATE-021",
"FE-GATE-022",
"FE-GATE-023",
"FE-GATE-024",
"FE-GATE-025",
]);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { createRuntimeAdapters } from "../../src/bootstrap/runtime-adapters.js";
const runtime = {
config: {
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080",
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo",
},
};
const release = {
schemaVersion: 1,
appVersion: "0.1.0",
buildId: "build-a",
commitSha: "abc123",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "hash-a",
releaseId: "release-a",
builtAt: "2026-07-25T00:00:00Z",
};
describe("runtime adapter composition", () => {
it("constructs the local demo seam and infrastructure adapters", async () => {
const adapters = await createRuntimeAdapters({
runtime:
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ (
runtime
),
release,
host: {},
});
expect(adapters.authSession.getState()).toBe("unauthenticated");
expect(adapters.cache.read(["missing"])).toEqual({
ok: true,
value: undefined,
});
await expect(adapters.releaseInfo.getCurrent()).resolves.toMatchObject({
releaseId: "release-a",
});
});
it("fails closed when an external auth owner was not installed", async () => {
const adapters = await createRuntimeAdapters({
runtime:
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ ({
config: { ...runtime.config, AUTH_MODE: "external" },
}),
release,
host: {},
});
expect(adapters.authSession.getState()).toBe("integration-failed");
});
});