Compare commits

..
Author SHA1 Message Date
donghyeon-ka c089e749d0 fix: require genuine live hosting evidence 2026-07-25 22:25:04 +09:00
donghyeon-ka 4667cafa43 fix: validate complete immutable release surface 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 eb37cbe8be feat: enforce coherent release and rollback contract 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
donghyeon-ka 6db96b6ef5 feat: establish automated and manual accessibility gates 2026-07-25 21:11:23 +09:00
donghyeon-ka f6300c5d1d Merge branch 'feature-tailwind-design-token-styling-contract' into develop 2026-07-25 21:09:07 +09:00
donghyeon-ka 9a92c11792 feat: add Tailwind semantic design tokens 2026-07-25 21:09:07 +09:00
donghyeon-ka a023c3b645 Merge branch 'feature-sample-feature-slice-contract-fixture' into develop 2026-07-25 21:07:48 +09:00
donghyeon-ka c6b7a9b9bc feat: add removable sample vertical contract fixture 2026-07-25 21:07:48 +09:00
donghyeon-ka 04c4bad43c Merge branch 'feature-frontend-render-recovery-boundary-contract' into develop 2026-07-25 21:05:36 +09:00
donghyeon-ka c37d571eb3 feat: add layered render recovery boundaries 2026-07-25 21:05:36 +09:00
donghyeon-ka eb16c2ffe7 Merge branch 'feature-routing-navigation-guard-contract' into develop 2026-07-25 21:03:57 +09:00
donghyeon-ka b221453c15 feat: centralize routes and navigation guards 2026-07-25 21:03:57 +09:00
donghyeon-ka bfc642875c Merge branch 'feature-boundary-mapper-viewmodel-contract' into develop 2026-07-25 21:02:06 +09:00
donghyeon-ka a9a7db0231 feat: contain DTO mapping at the HTTP boundary 2026-07-25 21:02:06 +09:00
donghyeon-ka 3e126c0ddd Merge branch 'feature-async-ui-state-contract' into develop 2026-07-25 21:00:21 +09:00
donghyeon-ka 438dc11548 feat: model complete async UI surface states 2026-07-25 21:00:21 +09:00
donghyeon-ka 652e0250f3 Merge branch 'feature-frontend-observability-logging-trace-contract' into develop 2026-07-25 20:58:33 +09:00
donghyeon-ka bf8d69e285 feat: add redacted best-effort telemetry contract 2026-07-25 20:58:33 +09:00
donghyeon-ka d54eeff450 Merge branch 'feature-frontend-storage-registry-contract' into develop 2026-07-25 20:56:58 +09:00
donghyeon-ka 2c3eda4d6b feat: enforce classified browser storage registry 2026-07-25 20:56:58 +09:00
donghyeon-ka 0a3bf08308 Merge branch 'feature-server-state-caching-contract' into develop 2026-07-25 20:55:27 +09:00
donghyeon-ka 184eb67282 feat: add application-owned query cache contract 2026-07-25 20:55:27 +09:00
donghyeon-ka c570996a97 Merge branch 'feature-frontend-auth-session-integration-contract' into develop 2026-07-25 20:54:18 +09:00
donghyeon-ka 44414c5244 feat: integrate bounded external auth sessions 2026-07-25 20:54:18 +09:00
donghyeon-ka 37ca2c3172 Merge branch 'feature-frontend-error-classification-boundary-contract' into develop 2026-07-25 20:52:58 +09:00
donghyeon-ka 7f3569ce3c feat: normalize failures through a stable registry 2026-07-25 20:52:58 +09:00
donghyeon-ka a0ca15da65 Merge branch 'feature-runtime-schema-validation-contract' into develop 2026-07-25 20:51:27 +09:00
donghyeon-ka bf813f09ab feat: validate HTTP envelopes and payload schemas 2026-07-25 20:51:27 +09:00
donghyeon-ka 0934de44c7 Merge branch 'feature-api-client-response-envelope-contract' into develop 2026-07-25 20:49:50 +09:00
donghyeon-ka 5c36cd978c feat: add shared HTTP response and retry contract 2026-07-25 20:49:50 +09:00
donghyeon-ka b193feeddc Merge branch 'feature-frontend-env-runtime-config-contract' into develop 2026-07-25 20:46:07 +09:00
donghyeon-ka 6b51104010 feat: validate runtime configuration before mount 2026-07-25 20:46:06 +09:00
donghyeon-ka 7199d7d9b6 Merge branch 'feature-frontend-architecture-enforcement-lint-contract' into develop 2026-07-25 20:43:49 +09:00
donghyeon-ka f3e105f971 feat: enforce frontend architecture boundaries 2026-07-25 20:43:49 +09:00
donghyeon-ka 1b1fb9bac6 Merge branch 'feature-frontend-test-taxonomy-contract' into develop 2026-07-25 20:41:45 +09:00
donghyeon-ka 946cf407b0 test: establish frontend gate taxonomy 2026-07-25 20:41:44 +09:00
donghyeon-ka 0a0b7263b3 Merge branch 'feature-frontend-clean-architecture-layering-contract' into develop 2026-07-25 20:40:21 +09:00
donghyeon-ka d7480cbafd fix: make application port contracts checkJs-safe 2026-07-25 20:40:21 +09:00
donghyeon-ka 54d74fcf4a Merge branch 'feature-frontend-clean-architecture-layering-contract' into develop 2026-07-25 20:39:59 +09:00
donghyeon-ka 49b20bd557 feat: define clean architecture layers and ports 2026-07-25 20:39:59 +09:00
donghyeon-ka 24d7072d66 Merge branch 'feature-frontend-project-bootstrap-toolchain-contract' into develop 2026-07-25 20:39:06 +09:00
donghyeon-ka 7b35f6b551 feat: bootstrap Vite and pnpm toolchain contract 2026-07-25 20:39:06 +09:00
135 changed files with 9801 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
/** @type {import("dependency-cruiser").IConfiguration} */
module.exports = {
forbidden: [
{
name: "domain-is-framework-neutral",
severity: "error",
from: { path: "^src/domain" },
to: {
path: "^(src/(application|presentation|adapters|bootstrap)|react|react-dom|@tanstack)",
},
},
{
name: "application-does-not-know-concrete-runtime",
severity: "error",
from: { path: "^src/application" },
to: {
path: "^(src/(presentation|adapters|bootstrap)|react|react-dom|@tanstack)",
},
},
{
name: "presentation-does-not-know-adapters",
severity: "error",
from: { path: "^src/presentation" },
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" },
},
{
name: "adapters-do-not-know-presentation",
severity: "error",
from: { path: "^src/adapters" },
to: { path: "^src/(presentation|bootstrap)" },
},
{
name: "no-circular-dependencies",
severity: "error",
from: {},
to: { circular: true },
},
],
options: {
doNotFollow: { path: "node_modules" },
exclude: {
path: "^(dist|artifacts|tests/fixtures)",
},
enhancedResolveOptions: {
exportsFields: ["exports"],
conditionNames: ["import", "require", "node", "default"],
},
},
};
+13
View File
@@ -0,0 +1,13 @@
node_modules/
dist/
.vite/
.tmp/
playwright-report/
test-results/
coverage/
artifacts/**/*.json
artifacts/**/*.xml
artifacts/**/*.txt
artifacts/**/*.sarif
artifacts/tests/e2e/
!artifacts/**/.gitkeep
+2
View File
@@ -0,0 +1,2 @@
engine-strict=true
save-exact=true
+1
View File
@@ -0,0 +1 @@
24.14.0
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+15
View File
@@ -0,0 +1,15 @@
# APP_HOME accessibility review
Status: pending-manual-review
Reviewer:
Keyboard: automated tab-order fixture passed; human review pending.
Focus: automated visible-focus fixture passed; route-change review pending.
Screen reader: pending.
Reduced motion: automated media-query fixture passed; human review pending.
Color signal: pending.
+38
View File
@@ -0,0 +1,38 @@
{
"schemaVersion": 1,
"layers": {
"domain": {
"root": "src/domain",
"mayImport": ["src/domain"]
},
"application": {
"root": "src/application",
"mayImport": ["src/application", "src/domain", "src/contracts"]
},
"presentation": {
"root": "src/presentation",
"mayImport": ["src/presentation", "src/application", "src/domain", "src/contracts"]
},
"adapters": {
"root": "src/adapters",
"mayImport": ["src/adapters", "src/application", "src/domain", "src/contracts"]
},
"bootstrap": {
"root": "src/bootstrap",
"mayImport": ["src"]
}
},
"forbidden": [
["domain", "application"],
["domain", "presentation"],
["domain", "adapters"],
["domain", "bootstrap"],
["application", "presentation"],
["application", "adapters"],
["application", "bootstrap"],
["presentation", "adapters"],
["presentation", "bootstrap"],
["adapters", "presentation"],
["adapters", "bootstrap"]
]
}
+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=()"
}
}
+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"
}
}
]
}
@@ -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
}
+17
View File
@@ -0,0 +1,17 @@
# Manual accessibility review checklist
Automated axe checks do not establish WCAG conformance. A human reviewer must
copy this checklist to `artifacts/tests/a11y-manual/<route-id>.md`, execute it
on the release candidate, and sign it.
- Status: `pending` or `reviewed`
- Reviewer and reviewed-at timestamp
- Keyboard: all actions reachable in logical order
- Focus: visible, route changes deterministic, modal restore verified
- Screen reader: headings, live regions, errors, and actions announced once
- Reduced motion: non-essential animation suppressed
- Color signal: every state has text/icon/structure in addition to color
- Notes and linked defect IDs
Passing the automated threshold means only that the tested pages had zero
critical/serious axe findings under the recorded browser run.
+34
View File
@@ -0,0 +1,34 @@
# Clean Architecture layer contract
The import direction is `domain <- application <- presentation`; concrete
adapters implement application-owned ports and are assembled only in
`src/bootstrap`.
| Layer | Owns | May depend on |
| --- | --- | --- |
| `domain` | framework-neutral models and pure policies | domain siblings |
| `application` | use cases, ports, orchestration, view-models | domain and application siblings |
| `presentation` | routes, components, user interaction and view state | application public API and shared UI |
| `adapters` | browser and third-party implementations of application ports | application ports and limited domain values |
| `bootstrap` | runtime configuration, adapter construction and React mount | all selected runtime modules |
The following edges are forbidden:
- domain to application, presentation, adapters, bootstrap, React, or browser globals
- application to presentation, concrete adapters, bootstrap, React, or browser globals
- presentation to concrete adapters, raw DTO schemas, or storage implementations
- an adapter to presentation, bootstrap internals, or another concrete adapter
`bootstrap` contains composition only. Business rules and page-specific
orchestration belong to domain/application.
Architecture reports use this shape:
```json
{
"schemaVersion": 1,
"generatedAt": "ISO-8601",
"rules": [{ "name": "rule-id", "severity": "error", "violations": 0 }],
"summary": { "errors": 0, "warnings": 0 }
}
```
+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
+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.
+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.
+17
View File
@@ -0,0 +1,17 @@
# Design-token styling contract
`src/presentation/styles/theme.css` is the styling SSOT. Components consume
semantic color, spacing, typography, and radius tokens through static Tailwind
classes.
Arbitrary-value policy:
- prefer a named semantic token
- bracket values are allowed only for one-off platform constraints that cannot
be expressed by the current scale
- a repeated bracket value must be promoted into `@theme`
- user-controlled or runtime-composed class strings are forbidden
- class variants must be selected from a closed static map
The removable sample may demonstrate tokens, but product modules must not
import from `src/sample/contract-fixture`.
+24
View File
@@ -0,0 +1,24 @@
# Test and evidence taxonomy
Each gate is blocking in its declared scope. Failures are not downgraded with
`continue-on-error` or warning-only scripts.
| Level | Command | Evidence |
| --- | --- | --- |
| runtime schema | `pnpm test:runtime-schema` | `artifacts/tests/runtime-schema.xml` |
| unit | `pnpm test:unit` | `artifacts/tests/unit.xml` |
| component | `pnpm test:component` | `artifacts/tests/component.xml` |
| integration | `pnpm test:integration` | `artifacts/tests/integration.xml` |
| end-to-end | `pnpm test:e2e` | `artifacts/tests/e2e/` |
| accessibility | `pnpm test:a11y` | `artifacts/tests/a11y.json` |
A control is verified only when a positive fixture passes and its deliberately
failing negative fixture is rejected. Generated evidence is retained by CI;
the repository tracks only the evidence directory structure.
Promotion is an AND graph:
1. merge gates
2. merge gates plus release gates
3. release gates plus rollback/runbook drills
4. production promotion plus eligible field Web Vitals evidence
+122
View File
@@ -0,0 +1,122 @@
import eslint from "@eslint/js";
import globals from "globals";
const layerPatterns = {
domain: [
"**/application/**",
"**/presentation/**",
"**/adapters/**",
"**/bootstrap/**",
"react",
"react-dom",
"@tanstack/**",
],
application: [
"**/presentation/**",
"**/adapters/**",
"**/bootstrap/**",
"react",
"react-dom",
"@tanstack/**",
],
presentation: ["**/adapters/**", "**/bootstrap/**", "@tanstack/**"],
adapters: ["**/presentation/**", "**/bootstrap/**"],
};
function restrictedImports(patterns) {
return ["error", { patterns }];
}
export default [
{
ignores: [
"dist/**",
"node_modules/**",
"artifacts/**",
"tests/fixtures/typecheck/**",
"tests/fixtures/architecture/forbidden/**",
"tests/fixtures/security/forbidden/**",
],
},
eslint.configs.recommended,
{
files: ["**/*.{js,jsx,mjs}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
},
{
files: ["src/domain/**/*.{js,jsx}"],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.domain),
"no-restricted-globals": ["error", "window", "document", "localStorage", "fetch"],
},
},
{
files: ["src/application/**/*.{js,jsx}"],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.application),
"no-restricted-globals": ["error", "window", "document", "localStorage", "fetch"],
},
},
{
files: ["src/presentation/**/*.{js,jsx}"],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.presentation),
},
},
{
files: ["src/adapters/**/*.{js,jsx}"],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.adapters),
},
},
{
files: ["tests/**/*.{js,jsx}"],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ["tests/fixtures/architecture/forbidden/**/*.{js,jsx}"],
rules: {
"no-restricted-imports": restrictedImports([
"**/adapters/**",
"@tanstack/**",
"react",
"react-dom",
]),
},
},
{
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.",
},
],
},
},
];
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Clean Architecture frontend template" />
<title>Clean Architecture Frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/bootstrap/main.jsx"></script>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
{
"name": "clean-architecture-frontend-template",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@11.17.0",
"engines": {
"node": ">=24.0.0 <25.0.0",
"pnpm": ">=11.0.0 <12.0.0"
},
"scripts": {
"dev": "vite",
"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",
"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: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",
"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:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
"test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml",
"test:e2e": "playwright test",
"test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs",
"review:a11y-manual": "node scripts/verify-a11y-manual.mjs",
"test:sample-removal": "node scripts/test-sample-removal.mjs",
"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"
},
"dependencies": {
"@tanstack/react-query": "5.101.4",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router-dom": "7.18.1",
"zod": "4.4.3"
},
"devDependencies": {
"@axe-core/playwright": "4.12.1",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.0",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@tailwindcss/vite": "4.3.3",
"@types/node": "24.13.3",
"@types/react": "19.2.8",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.4",
"dependency-cruiser": "18.1.0",
"eslint": "10.8.0",
"globals": "17.7.0",
"jsdom": "29.1.1",
"msw": "2.15.0",
"tailwindcss": "4.3.3",
"typescript": "7.0.2",
"vite": "8.1.5",
"vitest": "4.1.10"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
outputDir: "./artifacts/tests/e2e/results",
reporter: [
["list"],
["html", { outputFolder: "./artifacts/tests/e2e/report", open: "never" }],
],
use: {
baseURL: "http://127.0.0.1:5173",
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
webServer: {
command: "corepack pnpm dev --host 127.0.0.1",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
});
+3268
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
allowBuilds:
msw: true
minimumReleaseAgeExclude:
- '@playwright/test@1.62.0'
- playwright-core@1.62.0
- playwright@1.62.0
- eslint@10.8.0
+13
View File
@@ -0,0 +1,13 @@
{
"APP_ENV": "local",
"API_BASE_URL": "http://localhost:8080",
"REQUEST_TIMEOUT_MS": 10000,
"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"
}
+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
}
+69
View File
@@ -0,0 +1,69 @@
import { mkdir, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
await mkdir("artifacts/quality", { recursive: true });
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
if (!pnpmCli) {
throw new Error("check:architecture must run through the pnpm script");
}
/** @param {string[]} arguments_ */
function runPnpm(arguments_) {
return spawnSync(process.execPath, [pnpmCli, ...arguments_], {
encoding: "utf8",
});
}
const production = runPnpm(
[
"exec",
"depcruise",
"src",
"--config",
".dependency-cruiser.cjs",
"--output-type",
"json",
],
);
await writeFile(
"artifacts/quality/dependency-report.json",
production.stdout || JSON.stringify({ summary: { errors: 1 } }),
);
if (production.status !== 0) {
process.stderr.write(
production.error?.message ?? production.stderr ?? production.stdout ?? "failed",
);
process.exit(production.status ?? 1);
}
const allowed = runPnpm(
[
"exec",
"eslint",
"tests/fixtures/architecture/allowed",
"--no-ignore",
"--max-warnings=0",
],
);
const forbidden = runPnpm(
[
"exec",
"eslint",
"tests/fixtures/architecture/forbidden",
"--no-ignore",
"--max-warnings=0",
],
);
if (allowed.status !== 0 || forbidden.status === 0) {
process.stderr.write(allowed.stderr || allowed.stdout);
process.stderr.write(forbidden.stderr || forbidden.stdout);
process.exit(1);
}
process.stdout.write("Architecture fixtures: allowed PASS, forbidden rejected\n");
+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",
);
+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`);
+58
View File
@@ -0,0 +1,58 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const packageManagerVersion = packageJson.packageManager.split("@").at(-1);
const buildId = process.env.VITE_BUILD_ID ?? "local-build";
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 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 = {
schemaVersion: 1,
buildId,
commitSha,
generatedAt: builtAt,
buildContext: {
nodeVersion: process.version,
packageManagerVersion,
runnerImage,
},
outputs: {
directory: "dist",
viteManifest: "dist/.vite/manifest.json",
},
};
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 writeFile("dist/config.json", `${JSON.stringify(runtimeConfig, null, 2)}\n`);
await writeFile(
"dist/release-manifest.json",
`${JSON.stringify(releaseManifest, null, 2)}\n`,
);
await writeFile(
"artifacts/release/build-manifest.json",
`${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`,
);
+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 };
}
+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");
+78
View File
@@ -0,0 +1,78 @@
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import path from "node:path";
const fixtureRoot = path.resolve(".tmp/sample-removal");
const sampleRoot = path.resolve("src/sample/contract-fixture");
const sourceRoot = path.resolve("src");
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
/** @param {string} directory @returns {Promise<string[]>} */
async function sourceFiles(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() ? sourceFiles(target) : [target];
}),
));
return nested.flat();
}
await rm(fixtureRoot, { recursive: true, force: true });
await mkdir(fixtureRoot, { recursive: true });
const incomingImports = [];
for (const sourceFile of await sourceFiles(sourceRoot)) {
if (sourceFile.startsWith(sampleRoot)) continue;
const content = await readFile(sourceFile, "utf8");
if (/from\s+["'][^"']*sample\/contract-fixture/.test(content)) {
incomingImports.push(path.relative(".", sourceFile));
}
}
let buildStatus = 1;
if (incomingImports.length === 0) {
await cp("src", path.join(fixtureRoot, "src"), {
recursive: true,
filter: (source) => !source.startsWith(sampleRoot),
});
await cp("public", path.join(fixtureRoot, "public"), { recursive: true });
await cp("index.html", path.join(fixtureRoot, "index.html"));
await cp("vite.config.js", path.join(fixtureRoot, "vite.config.js"));
const result = spawnSync(
process.execPath,
[
pnpmCli,
"exec",
"vite",
"build",
fixtureRoot,
"--outDir",
path.join(fixtureRoot, "dist"),
],
{ stdio: "inherit" },
);
buildStatus = result.status ?? 1;
}
await mkdir("artifacts/tests", { recursive: true });
const passed = incomingImports.length === 0 && buildStatus === 0;
await writeFile(
"artifacts/tests/sample-removal.xml",
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="sample-removal" tests="2" failures="${passed ? 0 : 1}">` +
`<testcase name="no-product-import"/>` +
`<testcase name="production-build">${passed ? "" : "<failure/>"}</testcase>` +
`</testsuite>\n`,
);
await rm(fixtureRoot, { recursive: true, force: true });
if (!passed) {
process.stderr.write(
`Sample removal failed. Incoming imports: ${incomingImports.join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write("Sample removal smoke: PASS\n");
+25
View File
@@ -0,0 +1,25 @@
import { readFile } from "node:fs/promises";
const evidence = await readFile(
"artifacts/tests/a11y-manual/APP_HOME.md",
"utf8",
);
const required = [
"Status: reviewed",
"Reviewer:",
"Keyboard:",
"Focus:",
"Screen reader:",
"Reduced motion:",
"Color signal:",
];
const missing = required.filter((marker) => !evidence.includes(marker));
if (missing.length > 0) {
process.stderr.write(
`Manual accessibility evidence is incomplete: ${missing.join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write("Manual accessibility evidence: PASS\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`,
);
+18
View File
@@ -0,0 +1,18 @@
import { mkdir, writeFile } from "node:fs/promises";
await mkdir("artifacts/tests", { recursive: true });
await writeFile(
"artifacts/tests/a11y.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
scope: ["APP_HOME", "SAMPLE_RESOURCE_LIST", "NOT_FOUND"],
threshold: { critical: 0, serious: 0 },
automatedStatus: "passed",
manualReview: "see artifacts/tests/a11y-manual/APP_HOME.md",
},
null,
2,
)}\n`,
);
@@ -0,0 +1,45 @@
/**
* Creates the skeleton-owned side of an external session integration.
* Credential acquisition and storage stay inside the supplied external owner.
*
* @param {{
* readState(): import("../../application/ports/auth-session-port.js").SessionState,
* attachCredential(request: Request): Promise<Request>,
* recoverSession(): Promise<"restored" | "no-session">,
* notifyUnauthenticated(): void
* }} owner
* @returns {import("../../application/ports/auth-session-port.js").AuthSessionPort}
*/
export function createExternalAuthSessionAdapter(owner) {
return Object.freeze({
getState() {
return owner.readState();
},
async attach(request) {
const attached = await owner.attachCredential(request);
if (!(attached instanceof Request)) {
throw new TypeError("Auth owner returned an invalid request");
}
return attached;
},
async recover() {
const result = await owner.recoverSession();
if (result !== "restored" && result !== "no-session") {
throw new TypeError("Auth owner returned an invalid recovery state");
}
return result;
},
onUnauthenticated() {
owner.notifyUnauthenticated();
},
});
}
export function createAnonymousSessionAdapter() {
return createExternalAuthSessionAdapter({
readState: () => "unauthenticated",
attachCredential: async (request) => request,
recoverSession: async () => "no-session",
notifyUnauthenticated: () => {},
});
}
+434
View File
@@ -0,0 +1,434 @@
import { systemClock } from "../../application/ports/clock-port.js";
import { getApiOperation } from "../../contracts/api-operations.js";
import {
createFailure as failure,
kindForStatus as statusKind,
normalizeUnknownFailure,
} from "../../contracts/errors.js";
import { mapOperationPayload } from "./resource-mapper.js";
import { retryDelay, shouldRetry } from "./retry-policy.js";
import {
validateEnvelope,
validateOperationPayload,
validateOperationRequest,
} from "./schema-registry.js";
const noAuthSession =
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
getState: () => /** @type {"unauthenticated"} */ ("unauthenticated"),
attach: async (request) => request,
recover: async () => /** @type {"no-session"} */ ("no-session"),
onUnauthenticated: () => {},
});
/**
* @typedef {{
* kind: string,
* code: string,
* retryable: boolean,
* operationId: string,
* attemptCount: number,
* httpStatus?: number,
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* userMessageKey: string,
* action: string
* }} HttpFailure
*/
/**
* @typedef {{ ok: true, value: unknown, meta: Record<string, string> } |
* { ok: false, error: HttpFailure }} HttpResult
*/
/**
* @param {{
* baseUrl: string,
* fetcher?: typeof fetch,
* authSession?: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* clock?: import("../../application/ports/clock-port.js").ClockPort,
* random?: () => number,
* validatePayload?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false },
* mapPayload?: (operationId: string, payload: unknown) => unknown,
* idempotencyKeyFactory?: () => string
* }} dependencies
*/
export function createHttpClient(dependencies) {
const fetcher = dependencies.fetcher ?? fetch;
const authSession = dependencies.authSession ?? noAuthSession;
const clock = dependencies.clock ?? systemClock;
const random = dependencies.random ?? Math.random;
const validatePayload =
dependencies.validatePayload ?? validateOperationPayload;
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
const idempotencyKeyFactory =
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
/**
* @param {string} operationId
* @param {{
* body?: unknown,
* routeId?: string,
* signal?: AbortSignal,
* idempotencyKey?: string
* }} [input]
*/
async function execute(operationId, input = {}) {
const operation = getApiOperation(operationId);
const logicalIdempotencyKey =
operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory()
: undefined;
let retryCount = 0;
let recoveryUsed = false;
while (true) {
const attempt = retryCount;
/** @type {HttpResult} */
const outcome = await performAttempt({
operation,
input,
attempt,
idempotencyKey: logicalIdempotencyKey,
});
if (outcome.ok) return outcome;
if (outcome.error.httpStatus === 401 && !recoveryUsed) {
recoveryUsed = true;
const recovered = await recoverSession(authSession, operation, outcome.error);
if (!recovered.ok) return recovered;
if (operation.idempotency === "none") {
return {
ok: false,
error: {
...outcome.error,
retryable: false,
action: "retry",
},
};
}
continue;
}
if (outcome.error.httpStatus === 401 && recoveryUsed) {
authSession.onUnauthenticated();
return outcome;
}
if (!shouldRetry(operation, outcome.error, retryCount)) {
return outcome;
}
const delay = retryDelay(outcome.error, retryCount, random, clock.now());
retryCount += 1;
try {
await clock.sleep(delay, input.signal);
} catch {
return {
ok: false,
error: failure("REQUEST_ABORTED", operationId, retryCount, {
code: "REQUEST_ABORTED",
}),
};
}
}
}
/**
* @param {{
* operation: ReturnType<typeof getApiOperation>,
* input: { body?: unknown, routeId?: string, signal?: AbortSignal },
* attempt: number,
* idempotencyKey?: string
* }} context
* @returns {Promise<HttpResult>}
*/
async function performAttempt(context) {
const { operation, input, attempt, idempotencyKey } = context;
const controller = new AbortController();
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
controller.abort("timeout");
}, operation.timeoutMs);
const onExternalAbort = () => controller.abort(input.signal?.reason);
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
const headers = new Headers({ Accept: "application/json" });
if (input.body !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
if (input.body !== undefined) {
const requestValidation = validateOperationRequest(
operation.requestSchema,
input.body,
);
if (!requestValidation.success) {
return {
ok: false,
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
code: "REQUEST_SCHEMA_INVALID",
}),
};
}
}
let request = new Request(new URL(operation.path, dependencies.baseUrl), {
method: operation.method,
headers,
body: input.body === undefined ? undefined : JSON.stringify(input.body),
signal: controller.signal,
});
try {
if (operation.auth === "external-session") {
try {
request = await authSession.attach(request);
} catch {
return {
ok: false,
error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, {
code: "AUTH_ATTACH_FAILED",
}),
};
}
}
const response = await fetcher(request);
return await parseResponse(
response,
operation,
attempt,
validatePayload,
mapPayload,
);
} catch {
if (timedOut) {
return {
ok: false,
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
code: "REQUEST_TIMEOUT",
}),
};
}
if (controller.signal.aborted || input.signal?.aborted) {
const externalReason = input.signal?.reason;
if (externalReason === "timeout") {
return {
ok: false,
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
code: "REQUEST_TIMEOUT",
}),
};
}
if (
externalReason !== undefined &&
!["navigation", "user", "superseded"].includes(String(externalReason))
) {
return {
ok: false,
error: failure("UNKNOWN_FAILURE", operation.operationId, attempt, {
code: "EXTERNAL_ABORT_UNRESOLVED",
}),
};
}
return {
ok: false,
error: failure("REQUEST_ABORTED", operation.operationId, attempt, {
code: "REQUEST_ABORTED",
}),
};
}
return {
ok: false,
error: failure("NETWORK_UNREACHABLE", operation.operationId, attempt, {
code: "NETWORK_UNREACHABLE",
}),
};
} finally {
clearTimeout(timeout);
input.signal?.removeEventListener("abort", onExternalAbort);
}
}
return Object.freeze({ execute });
}
/**
* @param {Response} response
* @param {import("../../contracts/api-operations.js").ApiOperation} operation
* @param {number} attempt
* @param {(schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false }} validatePayload
* @param {(operationId: string, payload: unknown) => unknown} mapPayload
* @returns {Promise<HttpResult>}
*/
async function parseResponse(
response,
operation,
attempt,
validatePayload,
mapPayload,
) {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
return {
ok: false,
error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, {
code: "CONTENT_TYPE_MISMATCH",
httpStatus: response.status,
}),
};
}
let envelope;
try {
envelope = await response.json();
} catch {
return {
ok: false,
error: failure("MALFORMED_JSON", operation.operationId, attempt, {
code: "MALFORMED_JSON",
httpStatus: response.status,
}),
};
}
const envelopeValidation = validateEnvelope(envelope);
if (!envelopeValidation.success) {
return {
ok: false,
error: failure(
response.ok ? "ENVELOPE_MISMATCH" : statusKind(response.status),
operation.operationId,
attempt,
{
code: response.ok ? "ENVELOPE_MISMATCH" : "HTTP_FAILURE",
httpStatus: response.status,
},
),
};
}
const envelopeRecord =
/** @type {Record<string, unknown>} */ (envelopeValidation.data);
if (response.ok && envelopeRecord.success === true && "data" in envelopeRecord) {
const payload = validatePayload(operation.responseSchema, envelopeRecord.data);
if (!payload.success) {
return {
ok: false,
error: failure("SCHEMA_MISMATCH", operation.operationId, attempt, {
code: "SCHEMA_MISMATCH",
httpStatus: response.status,
}),
};
}
try {
return {
ok: true,
value: mapPayload(operation.operationId, payload.data),
meta: safeMeta(envelopeRecord.meta),
};
} catch (error) {
return {
ok: false,
error: normalizeUnknownFailure(error, {
operationId: operation.operationId,
attempt,
}),
};
}
}
const kind = statusKind(response.status);
const retryAfter = response.headers.get("retry-after");
return {
ok: false,
error: failure(kind, operation.operationId, attempt, {
code: safeBackendCode(envelope),
httpStatus: response.status,
requestId: safeMeta(envelopeRecord.meta).requestId,
traceId: safeMeta(envelopeRecord.meta).traceId,
retryAfterMs:
response.status === 429 && retryAfter
? parseRetryAfterHeader(retryAfter)
: undefined,
}),
};
}
/**
* @param {import("../../application/ports/auth-session-port.js").AuthSessionPort} authSession
* @param {import("../../contracts/api-operations.js").ApiOperation} operation
* @param {HttpFailure} originalFailure
* @returns {Promise<{ok: true} | {ok: false, error: HttpFailure}>}
*/
async function recoverSession(authSession, operation, originalFailure) {
try {
const result = await authSession.recover();
if (result === "restored") return { ok: true };
if (result === "no-session") {
authSession.onUnauthenticated();
return {
ok: false,
error: failure("AUTH_REQUIRED", operation.operationId, originalFailure.attemptCount, {
code: "AUTH_REQUIRED",
httpStatus: 401,
}),
};
}
} catch {
// Normalized below.
}
return {
ok: false,
error: failure(
"AUTH_INTEGRATION_FAILURE",
operation.operationId,
originalFailure.attemptCount,
{ code: "AUTH_RECOVERY_FAILED" },
),
};
}
/**
* @param {string} kind
* @param {string} operationId
* @param {number} attempt
* @param {FailureDetails} [details]
* @returns {HttpFailure}
*/
/** @param {unknown} envelope */
function safeBackendCode(envelope) {
if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE";
const error = /** @type {Record<string, unknown>} */ (envelope).error;
if (!error || typeof error !== "object") return "HTTP_FAILURE";
const code = /** @type {Record<string, unknown>} */ (error).code;
return typeof code === "string" ? code : "HTTP_FAILURE";
}
/** @param {unknown} meta @returns {Record<string, string>} */
function safeMeta(meta) {
if (!meta || typeof meta !== "object") return {};
const metaRecord = /** @type {Record<string, unknown>} */ (meta);
return {
...(typeof metaRecord.requestId === "string"
? { requestId: metaRecord.requestId }
: {}),
...(typeof metaRecord.traceId === "string" ? { traceId: metaRecord.traceId } : {}),
};
}
/** @param {string} value */
function parseRetryAfterHeader(value) {
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? Math.max(0, timestamp - Date.now()) : undefined;
}
+30
View File
@@ -0,0 +1,30 @@
import { createResource } from "../../domain/models/resource.js";
/** @param {unknown} value */
export function mapResourceDto(value) {
if (!value || typeof value !== "object") {
throw new TypeError("Validated resource DTO is required");
}
const dto = /** @type {Record<string, unknown>} */ (value);
if (typeof dto.id !== "string" || typeof dto.name !== "string") {
throw new TypeError("Validated resource DTO invariants were breached");
}
return createResource({
id: dto.id,
displayName: dto.name,
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null,
});
}
/** @param {string} operationId @param {unknown} payload */
export function mapOperationPayload(operationId, payload) {
if (operationId === "LIST_SAMPLE_RESOURCES") {
if (!Array.isArray(payload)) throw new TypeError("Expected a resource list");
return payload.map(mapResourceDto);
}
if (operationId === "CREATE_SAMPLE_RESOURCE") {
return mapResourceDto(payload);
}
throw new TypeError(`No boundary mapper registered for ${operationId}`);
}
+79
View File
@@ -0,0 +1,79 @@
const retryKinds = new Set([
"NETWORK_UNREACHABLE",
"REQUEST_TIMEOUT",
"RATE_LIMITED",
"SERVER_FAILURE",
]);
/**
* @param {number} retryIndex
* @param {() => number} [random]
* @param {number} [baseDelayMs]
* @param {number} [maxDelayMs]
*/
export function calculateBackoff(
retryIndex,
random = Math.random,
baseDelayMs = 250,
maxDelayMs = 2_000,
) {
return Math.min(maxDelayMs, baseDelayMs * 2 ** retryIndex) * random();
}
/** @param {string | null | undefined} value @param {number} [now] */
export function parseRetryAfter(value, now = Date.now()) {
if (!value) return null;
const seconds = Number(value);
if (Number.isFinite(seconds)) {
return seconds < 0 ? null : seconds * 1_000;
}
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return null;
return Math.max(0, timestamp - now);
}
/**
* @param {{ idempotency: "safe" | "keyed" | "none" }} operation
* @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure
* @param {number} retryCount
* @param {number} [maxRetries]
*/
export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
if (retryCount >= maxRetries) return false;
if (!retryKinds.has(failure.kind)) return false;
if (
failure.kind === "SERVER_FAILURE" &&
failure.httpStatus !== undefined &&
![502, 503, 504].includes(failure.httpStatus)
) {
return false;
}
if (
failure.kind === "RATE_LIMITED" &&
typeof failure.retryAfterMs === "number" &&
failure.retryAfterMs > 30_000
) {
return false;
}
return operation.idempotency === "safe" || operation.idempotency === "keyed";
}
/**
* @param {{ kind: string, retryAfterMs?: number, retryAfter?: string }} failure
* @param {number} retryIndex
* @param {() => number} [random]
* @param {number} [now]
*/
export function retryDelay(failure, retryIndex, random = Math.random, now = Date.now()) {
const localBackoff = calculateBackoff(retryIndex, random);
if (failure.kind !== "RATE_LIMITED") return localBackoff;
const retryAfterMs =
typeof failure.retryAfterMs === "number"
? failure.retryAfterMs
: parseRetryAfter(failure.retryAfter, now);
return retryAfterMs === null ? localBackoff : Math.max(localBackoff, retryAfterMs);
}
+115
View File
@@ -0,0 +1,115 @@
import { z } from "zod";
const metaSchema = z
.object({
requestId: z.string().min(1),
traceId: z.string().min(1),
correlationId: z.string().min(1).optional(),
})
.passthrough();
export const successEnvelopeSchema = z
.object({
success: z.literal(true),
data: z.unknown(),
meta: metaSchema,
})
.strict();
export const failureEnvelopeSchema = z
.object({
success: z.literal(false),
error: z
.object({
code: z.string().min(1),
category: z.string().min(1).optional(),
message: z.string().optional(),
retryable: z.boolean().optional(),
details: z.unknown().optional(),
})
.strict(),
meta: metaSchema,
})
.strict();
export const responseEnvelopeSchema = z.discriminatedUnion("success", [
successEnvelopeSchema,
failureEnvelopeSchema,
]);
const sampleResourceSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
createdAt: z.string().optional(),
})
.passthrough();
const payloadSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
SampleResourceListPayload: z.array(sampleResourceSchema),
SampleResourcePayload: sampleResourceSchema,
}));
const requestSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
SampleResourceListQuery: z
.object({
cursor: z.string().optional(),
limit: z.int().min(1).max(100).default(20),
})
.strict(),
CreateSampleResourceCommand: z
.object({
name: z.string().trim().min(1).max(120),
})
.strict(),
}));
/** @param {unknown} value */
export function validateEnvelope(value) {
return projectResult(responseEnvelopeSchema.safeParse(value));
}
/** @param {string} schemaId @param {unknown} value */
export function validateOperationPayload(schemaId, value) {
const schema = payloadSchemas[schemaId];
if (!schema) return missingSchema(schemaId);
return projectResult(schema.safeParse(value));
}
/** @param {string} schemaId @param {unknown} value */
export function validateOperationRequest(schemaId, value) {
const schema = requestSchemas[schemaId];
if (!schema) return missingSchema(schemaId);
return projectResult(schema.safeParse(value));
}
/** @param {string} schemaId */
function missingSchema(schemaId) {
return {
success: /** @type {false} */ (false),
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }],
};
}
/**
* @param {{ success: true, data: unknown } |
* { success: false, error: { issues: Array<{ path: PropertyKey[], code: string }> } }} result
*/
function projectResult(result) {
if (result.success) {
return {
success: /** @type {true} */ (true),
data: structuredClone(result.data),
};
}
return {
success: /** @type {false} */ (false),
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
};
}
@@ -0,0 +1,72 @@
import { QueryClient } from "@tanstack/react-query";
import { createFailure } from "../../contracts/errors.js";
export const QUERY_CACHE_DEFAULTS = Object.freeze({
staleTime: 30_000,
gcTime: 300_000,
refetchOnWindowFocus: true,
retry: false,
mutationRetry: false,
persistence: false,
});
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: QUERY_CACHE_DEFAULTS.staleTime,
gcTime: QUERY_CACHE_DEFAULTS.gcTime,
refetchOnWindowFocus: QUERY_CACHE_DEFAULTS.refetchOnWindowFocus,
retry: QUERY_CACHE_DEFAULTS.retry,
},
mutations: {
retry: QUERY_CACHE_DEFAULTS.mutationRetry,
},
},
});
}
/**
* @param {QueryClient} queryClient
* @returns {import("../../application/ports/query-cache-port.js").QueryCachePort}
*/
export function createQueryCacheAdapter(queryClient) {
return Object.freeze({
read(key) {
try {
return { ok: true, value: queryClient.getQueryData(key) };
} catch {
return cacheFailure("read", key);
}
},
write(key, value) {
try {
queryClient.setQueryData(key, structuredClone(value));
return { ok: true };
} catch {
return cacheFailure("write", key);
}
},
async invalidate(namespace) {
try {
await queryClient.invalidateQueries({ queryKey: namespace, exact: false });
return { ok: true };
} catch {
return cacheFailure("invalidate", namespace);
}
},
});
}
/** @param {string} phase @param {readonly unknown[]} key */
function cacheFailure(phase, key) {
const namespace = typeof key[0] === "string" ? key[0] : "unknown";
return {
ok: /** @type {false} */ (false),
error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, {
code: `QUERY_CACHE_${phase.toUpperCase()}_FAILED`,
causeClass: `namespace:${namespace}`,
}),
};
}
@@ -0,0 +1,137 @@
import { createFailure } from "../../contracts/errors.js";
import { getStorageDefinition } from "../../contracts/storage-keys.js";
/**
* @param {{
* localStorage?: Storage,
* sessionStorage?: Storage,
* now?: () => number
* }} [dependencies]
* @returns {import("../../application/ports/storage-port.js").StoragePort}
*/
export function createBrowserStorageAdapter(dependencies = {}) {
const memory = new Map();
const now = dependencies.now ?? Date.now;
/** @param {string} name */
function backendFor(name) {
if (name === "localStorage") return dependencies.localStorage;
if (name === "sessionStorage") return dependencies.sessionStorage;
return undefined;
}
return Object.freeze({
read(logicalName) {
let definition;
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("read", logicalName);
}
const backend = backendFor(definition.backend);
try {
const raw = backend?.getItem(definition.physicalKey);
if (raw === null || raw === undefined) {
return { ok: true, value: memory.get(definition.physicalKey) };
}
const envelope = JSON.parse(raw);
if (
!envelope ||
typeof envelope !== "object" ||
envelope.schemaVersion !== definition.schemaVersion
) {
backend?.removeItem(definition.physicalKey);
return { ok: true, value: undefined };
}
if (typeof envelope.expiresAt === "number" && envelope.expiresAt <= now()) {
backend?.removeItem(definition.physicalKey);
return { ok: true, value: undefined };
}
return { ok: true, value: structuredClone(envelope.value) };
} catch {
return unavailable("read", logicalName);
}
},
write(logicalName, value) {
let definition;
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("write", logicalName);
}
const expiresAt =
typeof definition.ttl === "number" ? now() + definition.ttl : null;
const envelope = {
schemaVersion: definition.schemaVersion,
expiresAt,
value: structuredClone(value),
};
const backend = backendFor(definition.backend);
try {
if (!backend) throw new DOMException("Storage unavailable", "SecurityError");
backend.setItem(definition.physicalKey, JSON.stringify(envelope));
return { ok: true };
} catch (error) {
const quota =
error instanceof DOMException &&
["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(error.name);
if (definition.quotaFallback === "memory") {
memory.set(definition.physicalKey, structuredClone(value));
return {
ok: false,
error: storageFailure(quota, "write", logicalName),
fallback: "memory",
};
}
return {
ok: false,
error: storageFailure(quota, "write", logicalName),
fallback: definition.quotaFallback,
};
}
},
remove(logicalName) {
let definition;
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("remove", logicalName);
}
try {
backendFor(definition.backend)?.removeItem(definition.physicalKey);
memory.delete(definition.physicalKey);
return { ok: true };
} catch {
return unavailable("remove", logicalName);
}
},
});
}
/** @param {boolean} quota @param {string} phase @param {string} logicalName */
function storageFailure(quota, phase, logicalName) {
return createFailure(
quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE",
"STORAGE",
0,
{
code: `${logicalName}_${phase.toUpperCase()}_${
quota ? "QUOTA_EXCEEDED" : "UNAVAILABLE"
}`,
},
);
}
/** @param {string} phase @param {string} logicalName */
function unavailable(phase, logicalName) {
return {
ok: /** @type {false} */ (false),
error: storageFailure(false, phase, logicalName),
};
}
@@ -0,0 +1,99 @@
import { projectTelemetryEvent } from "../../contracts/telemetry.js";
export const noOpTelemetry = Object.freeze({
emit: () => {},
});
/**
* @param {{
* enabled: boolean,
* endpoint?: string,
* fetcher?: typeof fetch,
* maxQueue?: number,
* schedule?: (callback: () => void) => void
* }} options
*/
export function createTelemetryAdapter(options) {
if (!options.enabled || !options.endpoint) {
return Object.freeze({
...noOpTelemetry,
flush: async () => {},
pendingCount: () => 0,
droppedCount: () => 0,
});
}
const endpoint = /** @type {string} */ (options.endpoint);
const fetcher = options.fetcher ?? fetch;
const maxQueue = options.maxQueue ?? 100;
const schedule = options.schedule ?? queueMicrotask;
const queue =
/** @type {Array<{eventName: string, attributes: Readonly<Record<string, unknown>>}>} */ (
[]
);
let scheduled = false;
let flushing = false;
let dropped = 0;
/** @param {string} eventName @param {Record<string, unknown>} attributes */
function emit(eventName, attributes) {
const projected = projectTelemetryEvent(eventName, attributes);
if (!projected.success) {
dropped += 1;
return;
}
if (queue.length >= maxQueue) {
queue.shift();
dropped += 1;
}
queue.push(projected.event);
if (!scheduled) {
scheduled = true;
schedule(() => {
scheduled = false;
void flush();
});
}
}
async function flush() {
if (flushing || queue.length === 0) return;
flushing = true;
const batch = queue.splice(0, queue.length);
try {
const response = await fetcher(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events: batch }),
keepalive: true,
});
if (!response.ok) dropped += batch.length;
} catch {
dropped += batch.length;
} finally {
flushing = false;
}
}
return Object.freeze({
emit,
flush,
pendingCount: () => queue.length,
droppedCount: () => dropped,
});
}
/**
* Propagates only a structurally valid W3C traceparent. Invalid/raw headers are
* discarded rather than logged or surfaced.
*
* @param {string | null | undefined} traceparent
*/
export function safeTraceparent(traceparent) {
return typeof traceparent === "string" &&
/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/i.test(traceparent)
? traceparent.toLowerCase()
: null;
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Application facade factory. Concrete dependencies are supplied by bootstrap.
*
* @param {{
* resources: {
* query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>,
* command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown>
* },
* cache: import("./ports/query-cache-port.js").QueryCachePort,
* storage: import("./ports/storage-port.js").StoragePort,
* telemetry: import("./ports/telemetry-port.js").TelemetryPort
* }} ports
*/
export function createApplication(ports) {
/**
* @param {unknown} query
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function queryResources(query, context) {
return ports.resources.query.execute(query, context);
}
/**
* @param {unknown} command
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function commandResources(command, context) {
return ports.resources.command.execute(command, context);
}
return Object.freeze({
resources: Object.freeze({
query: queryResources,
command: commandResources,
}),
cache: ports.cache,
storage: ports.storage,
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,16 @@
/**
* @typedef {"authenticated" | "unauthenticated" | "recovery-pending" | "integration-failed"} SessionState
*/
/**
* The session is opaque: credentials are attached without exposing tokens.
*
* @typedef {{
* getState(): SessionState,
* attach(request: Request): Promise<Request>,
* recover(): Promise<"restored" | "no-session">,
* onUnauthenticated(): void
* }} AuthSessionPort
*/
export {};
+29
View File
@@ -0,0 +1,29 @@
/**
* @typedef {{
* now(): number,
* sleep(milliseconds: number, signal?: AbortSignal): Promise<void>
* }} ClockPort
*/
/** @type {ClockPort} */
export const systemClock = Object.freeze({
now: () => Date.now(),
sleep(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(resolve, milliseconds);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true },
);
});
},
});
+12
View File
@@ -0,0 +1,12 @@
/**
* @typedef {{
* read(key: readonly unknown[]): { ok: true, value: unknown } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure },
* write(key: readonly unknown[], value: unknown): { ok: true } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure },
* invalidate(namespace: readonly unknown[]): Promise<{ ok: true } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure }>
* }} QueryCachePort
*/
export {};
@@ -0,0 +1,13 @@
/**
* @typedef {{
* getCurrent(): Promise<{
* buildId: string,
* configSchemaVersion: string,
* apiContractVersion: string,
* assetManifestHash: string,
* releaseId: string
* }>
* }} ReleaseInfoPort
*/
export {};
+28
View File
@@ -0,0 +1,28 @@
/**
* @template Query
* @template Model
* @typedef {{ execute(query: Query, context?: RequestContext): Promise<Result<Model>> }} ResourceQueryPort
*/
/**
* @template Command
* @template Model
* @typedef {{ execute(command: Command, context?: RequestContext): Promise<Result<Model>> }} ResourceCommandPort
*/
/**
* @typedef {{
* operationId: string,
* routeId: string,
* signal?: AbortSignal,
* idempotencyKey?: string
* }} RequestContext
*/
/**
* @template Value
* @typedef {{ ok: true, value: Value, meta?: Record<string, unknown> } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure }} Result
*/
export {};
+13
View File
@@ -0,0 +1,13 @@
/**
* @typedef {{
* read(logicalName: string): { ok: true, value: unknown } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure },
* write(logicalName: string, value: unknown): { ok: true } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure,
* fallback?: string },
* remove(logicalName: string): { ok: true } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure }
* }} StoragePort
*/
export {};
+5
View File
@@ -0,0 +1,5 @@
/**
* @typedef {{ emit(eventName: string, attributes: Record<string, unknown>): void }} TelemetryPort
*/
export {};
@@ -0,0 +1,34 @@
const RECOVERABLE_KINDS = new Set(["CHUNK_LOAD_FAILURE", "DEPLOY_MISMATCH"]);
/**
* @param {{
* failureKind: string,
* manifestLoaded: boolean,
* currentBuildId: string,
* activeReleaseId: string,
* storage: import("../ports/storage-port.js").StoragePort
* }} input
*/
export function decideChunkRecovery(input) {
if (!RECOVERABLE_KINDS.has(input.failureKind)) {
return { action: "support", reason: "not-recoverable" };
}
if (!input.manifestLoaded) {
return { action: "support", reason: "manifest-unavailable" };
}
if (input.activeReleaseId === input.currentBuildId) {
return { action: "support", reason: "same-release" };
}
const releasePair = `${input.currentBuildId}->${input.activeReleaseId}`;
const guard = input.storage.read("CHUNK_RELOAD_GUARD");
if (!guard.ok || guard.value === releasePair) {
return { action: "support", reason: "reload-already-attempted" };
}
const recorded = input.storage.write("CHUNK_RELOAD_GUARD", releasePair);
if (!recorded.ok) {
return { action: "support", reason: "guard-write-failed" };
}
return { action: "reload-once", releasePair };
}
@@ -0,0 +1,81 @@
export const ASYNC_BASE_STATES = Object.freeze([
"initial-loading",
"success",
"empty",
"terminal-error",
]);
export const ASYNC_OVERLAYS = Object.freeze([
"refreshing",
"stale-degraded",
"mutation-pending",
"mutation-conflict",
]);
/**
* @typedef {{
* data?: unknown,
* isInitialLoading?: boolean,
* failure?: import("../../contracts/errors.js").ApiFailure,
* isFetching?: boolean,
* isStale?: boolean,
* isDegraded?: boolean,
* isMutationPending?: boolean,
* hasMutationConflict?: boolean
* }} AsyncSignals
*/
/** @param {AsyncSignals} signals */
export function deriveAsyncState(signals) {
const hasData = signals.data !== undefined && signals.data !== null;
const empty =
hasData &&
((Array.isArray(signals.data) && signals.data.length === 0) ||
signals.data === "");
let base;
if (signals.isInitialLoading && !hasData) {
base = "initial-loading";
} else if (signals.failure && !hasData) {
base = "terminal-error";
} else if (empty) {
base = "empty";
} else if (hasData) {
base = "success";
} else {
base = "initial-loading";
}
const overlay = Object.freeze({
refreshing: Boolean(signals.isFetching && hasData),
staleDegraded: Boolean(signals.isStale && signals.isDegraded && hasData),
mutationPending: Boolean(signals.isMutationPending && hasData),
mutationConflict: Boolean(signals.hasMutationConflict && hasData),
});
const state = {
base,
data: base === "success" || base === "empty" ? signals.data : undefined,
failure: base === "terminal-error" ? signals.failure : undefined,
overlay,
indicator: selectOverlayIndicator(overlay),
};
return Object.freeze(state);
}
/**
* @param {{
* refreshing: boolean,
* staleDegraded: boolean,
* mutationPending: boolean,
* mutationConflict: boolean
* }} overlay
*/
export function selectOverlayIndicator(overlay) {
if (overlay.mutationConflict) return "mutation-conflict";
if (overlay.mutationPending) return "mutation-pending";
if (overlay.staleDegraded) return "stale-degraded";
if (overlay.refreshing) return "refreshing";
return null;
}
@@ -0,0 +1,16 @@
/**
* @param {import("../../domain/models/resource.js").Resource} resource
* @param {(value: Date) => string} [formatDate]
*/
export function toResourceViewModel(
resource,
formatDate = (value) => new Intl.DateTimeFormat("ko-KR").format(value),
) {
return Object.freeze({
resourceId: resource.id,
title: resource.displayName,
createdAtLabel: resource.createdAt
? formatDate(new Date(resource.createdAt))
: null,
});
}
+23
View File
@@ -0,0 +1,23 @@
import { createApplication } from "../application/create-application.js";
/**
* This is the only module allowed to join concrete adapters to application
* ports. Boot phases are explicit so failures can stop before product mount.
*
* @param {{
* loadConfig(): Promise<Record<string, unknown>>,
* loadRelease(config: Record<string, unknown>): Promise<Record<string, unknown>>,
* createAdapters(context: {
* config: Record<string, unknown>,
* release: Record<string, unknown>
* }): Promise<Parameters<typeof createApplication>[0]>
* }} factories
*/
export async function createCompositionRoot(factories) {
const config = await factories.loadConfig();
const release = await factories.loadRelease(config);
const ports = await factories.createAdapters({ config, release });
const application = createApplication(ports);
return Object.freeze({ config, release, ports, application });
}
+104
View File
@@ -0,0 +1,104 @@
import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.js";
import { validateRuntimeConfig } from "./runtime-config-schema.js";
export class BootConfigError extends Error {
/**
* @param {string} code
* @param {{ buildId: string, configSchemaVersion?: string, releaseId?: string }} safe
*/
constructor(code, safe) {
super("Runtime configuration could not be loaded");
this.name = "BootConfigError";
this.kind = "BOOT_CONFIG_FAILURE";
this.code = code;
this.safe = Object.freeze({
kind: this.kind,
code,
buildId: safe.buildId,
configSchemaVersion: safe.configSchemaVersion,
releaseId: safe.releaseId,
supportReference: `${safe.buildId}:${code}`,
});
}
}
/**
* @param {{
* fetcher?: typeof fetch,
* buildConfig?: ReturnType<typeof getBuildConfig>,
* now?: () => number
* }} [options]
*/
export async function loadRuntimeConfig(options = {}) {
const fetcher = options.fetcher ?? fetch;
const buildConfig = options.buildConfig ?? getBuildConfig();
const now = options.now ?? performance.now.bind(performance);
const startedAt = now();
let response;
try {
response = await fetcher(buildConfig.runtimeConfigUrl, {
cache: "no-store",
headers: { Accept: "application/json" },
});
} catch {
throw new BootConfigError("CONFIG_FETCH_FAILED", {
buildId: buildConfig.buildId,
});
}
if (!response.ok) {
throw new BootConfigError("CONFIG_HTTP_FAILED", {
buildId: buildConfig.buildId,
});
}
let rawConfig;
try {
rawConfig = await response.json();
} catch {
throw new BootConfigError("CONFIG_JSON_INVALID", {
buildId: buildConfig.buildId,
});
}
if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
throw new BootConfigError("CONFIG_SHAPE_INVALID", {
buildId: buildConfig.buildId,
});
}
try {
assertSafeConfigNames(rawConfig);
} catch {
throw new BootConfigError("CONFIG_SECRET_NAME_REJECTED", {
buildId: buildConfig.buildId,
});
}
const validated = validateRuntimeConfig(rawConfig);
if (!validated.success) {
throw new BootConfigError("CONFIG_SCHEMA_INVALID", {
buildId: buildConfig.buildId,
configSchemaVersion:
typeof rawConfig.CONFIG_SCHEMA_VERSION === "string"
? rawConfig.CONFIG_SCHEMA_VERSION
: undefined,
releaseId: typeof rawConfig.RELEASE_ID === "string" ? rawConfig.RELEASE_ID : undefined,
});
}
if (validated.data.BUILD_ID && validated.data.BUILD_ID !== buildConfig.buildId) {
throw new BootConfigError("CONFIG_BUILD_MISMATCH", {
buildId: buildConfig.buildId,
configSchemaVersion: validated.data.CONFIG_SCHEMA_VERSION,
releaseId: validated.data.RELEASE_ID,
});
}
return Object.freeze({
config: validated.data,
build: buildConfig,
validationDurationMs: now() - startedAt,
});
}
+39
View File
@@ -0,0 +1,39 @@
import { StrictMode } from "react";
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 { AppRouter } from "../presentation/routes/app-router.jsx";
import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js";
import "../presentation/styles/theme.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Missing #root mount element");
}
const root = createRoot(rootElement);
async function boot() {
try {
const runtime = await loadRuntimeConfig();
root.render(
<StrictMode>
<AppRouter
authSession={createAnonymousSessionAdapter()}
basename={runtime.build.routerBasePath}
/>
</StrictMode>,
);
} catch (error) {
const safe =
error instanceof BootConfigError
? error.safe
: { supportReference: "boot:unknown" };
root.render(<BootErrorShell {...safe} />);
}
}
void boot();
+66
View File
@@ -0,0 +1,66 @@
import { z } from "zod";
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
export const runtimeConfigSchema = z
.object({
APP_ENV: z.enum(["local", "development", "staging", "production"]),
API_BASE_URL: z.url(),
REQUEST_TIMEOUT_MS: z.int().min(100).max(60_000).default(10_000),
MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2),
TELEMETRY_ENABLED: z.boolean(),
TELEMETRY_ENDPOINT: z.url().optional(),
AUTH_MODE: z.literal("external"),
CONFIG_SCHEMA_VERSION: version,
API_CONTRACT_VERSION: version,
RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"),
RELEASE_ID: z.string().min(1).optional(),
BUILD_ID: z.string().min(1).optional(),
})
.strict()
.superRefine((config, context) => {
if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) {
context.addIssue({
code: "custom",
path: ["TELEMETRY_ENDPOINT"],
message: "required when telemetry is enabled",
});
}
const local = config.APP_ENV === "local" || config.APP_ENV === "development";
const endpointEntries =
/** @type {Array<[string, string | undefined]>} */ ([
["API_BASE_URL", config.API_BASE_URL],
["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT],
]);
for (const [key, value] of endpointEntries) {
if (value && !local && new URL(value).protocol !== "https:") {
context.addIssue({
code: "custom",
path: [key],
message: "HTTPS is required outside local environments",
});
}
}
});
/** @param {unknown} value */
export function validateRuntimeConfig(value) {
const result = runtimeConfigSchema.safeParse(value);
if (!result.success) {
return {
success: /** @type {false} */ (false),
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
};
}
return {
success: /** @type {true} */ (true),
data: structuredClone(result.data),
};
}
+51
View File
@@ -0,0 +1,51 @@
/**
* @typedef {{
* method: string,
* path: string,
* operationId: string,
* auth: "none" | "external-session",
* timeoutMs: number,
* idempotency: "safe" | "keyed" | "none",
* requestSchema: string,
* responseSchema: string,
* owner: string
* }} ApiOperation
*/
/** @param {ApiOperation} definition */
const operation = (definition) => Object.freeze(definition);
export const API_OPERATIONS = Object.freeze({
LIST_SAMPLE_RESOURCES: operation({
method: "GET",
path: "/api/sample/resources",
operationId: "LIST_SAMPLE_RESOURCES",
auth: "external-session",
timeoutMs: 10_000,
idempotency: "safe",
requestSchema: "SampleResourceListQuery",
responseSchema: "SampleResourceListPayload",
owner: "feature-sample-feature-slice-contract-fixture",
}),
CREATE_SAMPLE_RESOURCE: operation({
method: "POST",
path: "/api/sample/resources",
operationId: "CREATE_SAMPLE_RESOURCE",
auth: "external-session",
timeoutMs: 10_000,
idempotency: "keyed",
requestSchema: "CreateSampleResourceCommand",
responseSchema: "SampleResourcePayload",
owner: "feature-sample-feature-slice-contract-fixture",
}),
});
/** @param {string} operationId */
export function getApiOperation(operationId) {
const registry = /** @type {Record<string, ApiOperation>} */ (API_OPERATIONS);
const selected = registry[operationId];
if (!selected) {
throw new Error(`Unregistered API operation: ${operationId}`);
}
return selected;
}
+54
View File
@@ -0,0 +1,54 @@
const forbiddenConfigName = /(SECRET|PASSWORD|PRIVATE_KEY|TOKEN)/i;
export const ENV_REGISTRY = Object.freeze({
VITE_BUILD_ID: build("public-metadata", true, null),
VITE_COMMIT_SHA: build("public-metadata", false, "local"),
VITE_ROUTER_BASE_PATH: build("compile-time", true, "/"),
VITE_RUNTIME_CONFIG_URL: build("compile-time", true, "/config.json"),
APP_ENV: runtime("public", true, null),
API_BASE_URL: runtime("public-sensitive", true, null),
REQUEST_TIMEOUT_MS: runtime("public", false, 10_000),
MAX_RETRY_ATTEMPTS: runtime("public", false, 2),
TELEMETRY_ENABLED: runtime("public", true, false),
TELEMETRY_ENDPOINT: runtime("public-sensitive", false, null),
AUTH_MODE: runtime("public", true, "external"),
CONFIG_SCHEMA_VERSION: runtime("public", true, null),
API_CONTRACT_VERSION: runtime("public", true, null),
RELEASE_MANIFEST_URL: runtime("public", true, "/release-manifest.json"),
});
/**
* @param {string} classification
* @param {boolean} required
* @param {unknown} defaultValue
*/
function build(classification, required, defaultValue) {
return Object.freeze({ phase: "build", classification, required, defaultValue });
}
/**
* @param {string} classification
* @param {boolean} required
* @param {unknown} defaultValue
*/
function runtime(classification, required, defaultValue) {
return Object.freeze({ phase: "runtime", classification, required, defaultValue });
}
/** @param {Record<string, unknown>} config */
export function assertSafeConfigNames(config) {
for (const name of Object.keys(config)) {
if (forbiddenConfigName.test(name)) {
throw new Error(`Forbidden client configuration key: ${name}`);
}
}
}
export function getBuildConfig(environment = import.meta.env) {
const buildId = environment.VITE_BUILD_ID || "local-build";
const commitSha = environment.VITE_COMMIT_SHA || "local";
const routerBasePath = environment.VITE_ROUTER_BASE_PATH || "/";
const runtimeConfigUrl = environment.VITE_RUNTIME_CONFIG_URL || "/config.json";
return Object.freeze({ buildId, commitSha, routerBasePath, runtimeConfigUrl });
}
+245
View File
@@ -0,0 +1,245 @@
const DROP_SENSITIVE = Object.freeze([
"cause",
"body",
"headers",
"authorization",
"url",
"query",
"stack",
"storageValue",
]);
/**
* @typedef {"retry" | "reauth" | "navigate" | "reload-once" |
* "contact-support" | "none"} ErrorAction
*/
/**
* @typedef {{
* kind: string,
* defaultRetryable: boolean,
* severity: string,
* userMessageKey: string,
* action: ErrorAction,
* telemetryEvent: string,
* redaction: readonly string[]
* }} ErrorDefinition
*/
/**
* @param {string} kind
* @param {boolean} defaultRetryable
* @param {string} severity
* @param {ErrorAction} action
* @param {string} [telemetryEvent]
* @returns {Readonly<ErrorDefinition>}
*/
const row = (
kind,
defaultRetryable,
severity,
action,
telemetryEvent = "api.request.failed",
) =>
Object.freeze({
kind,
defaultRetryable,
severity,
userMessageKey: `error.${kind.toLowerCase()}`,
action,
telemetryEvent,
redaction: DROP_SENSITIVE,
});
export const ERROR_REGISTRY = Object.freeze({
NETWORK_UNREACHABLE: row("NETWORK_UNREACHABLE", true, "warning", "retry"),
REQUEST_TIMEOUT: row("REQUEST_TIMEOUT", true, "warning", "retry"),
REQUEST_ABORTED: row("REQUEST_ABORTED", false, "info", "none"),
CONTENT_TYPE_MISMATCH: row(
"CONTENT_TYPE_MISMATCH",
false,
"error",
"contact-support",
),
MALFORMED_JSON: row("MALFORMED_JSON", false, "error", "contact-support"),
ENVELOPE_MISMATCH: row("ENVELOPE_MISMATCH", false, "error", "contact-support"),
SCHEMA_MISMATCH: row("SCHEMA_MISMATCH", false, "error", "contact-support"),
AUTH_REQUIRED: row("AUTH_REQUIRED", false, "info", "reauth"),
AUTH_INTEGRATION_FAILURE: row(
"AUTH_INTEGRATION_FAILURE",
false,
"error",
"contact-support",
),
FORBIDDEN: row("FORBIDDEN", false, "warning", "navigate"),
NOT_FOUND: row("NOT_FOUND", false, "info", "navigate"),
CONFLICT: row("CONFLICT", false, "warning", "retry"),
VALIDATION_REJECTED: row("VALIDATION_REJECTED", false, "info", "none"),
UNKNOWN_CLIENT_FAILURE: row(
"UNKNOWN_CLIENT_FAILURE",
false,
"warning",
"contact-support",
),
RATE_LIMITED: row("RATE_LIMITED", true, "warning", "retry"),
SERVER_FAILURE: row("SERVER_FAILURE", true, "error", "retry"),
CHUNK_LOAD_FAILURE: row(
"CHUNK_LOAD_FAILURE",
false,
"error",
"reload-once",
"release.mismatch.detected",
),
BOOT_CONFIG_FAILURE: row(
"BOOT_CONFIG_FAILURE",
false,
"error",
"contact-support",
"app.boot.failed",
),
RELEASE_MANIFEST_FAILURE: row(
"RELEASE_MANIFEST_FAILURE",
false,
"error",
"contact-support",
"app.boot.failed",
),
DEPLOY_MISMATCH: row(
"DEPLOY_MISMATCH",
false,
"error",
"reload-once",
"release.mismatch.detected",
),
STORAGE_UNAVAILABLE: row(
"STORAGE_UNAVAILABLE",
false,
"warning",
"none",
"storage.operation.failed",
),
STORAGE_QUOTA_EXCEEDED: row(
"STORAGE_QUOTA_EXCEEDED",
false,
"warning",
"none",
"storage.operation.failed",
),
RENDER_FAILURE: row(
"RENDER_FAILURE",
false,
"error",
"reload-once",
"ui.render.failed",
),
TELEMETRY_FAILURE: row(
"TELEMETRY_FAILURE",
false,
"info",
"none",
"telemetry.delivery.dropped",
),
QUERY_CACHE_FAILURE: row(
"QUERY_CACHE_FAILURE",
false,
"error",
"retry",
"query.cache.failed",
),
UNKNOWN_FAILURE: row("UNKNOWN_FAILURE", false, "error", "contact-support"),
});
/**
* @typedef {{
* kind: string,
* code: string,
* httpStatus?: number,
* retryable: boolean,
* operationId: string,
* attemptCount: number,
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* userMessageKey: string,
* action: ErrorAction,
* causeClass?: string
* }} ApiFailure
*/
/**
* @param {string} kind
* @param {string} operationId
* @param {number} attempt
* @param {{
* code?: string,
* httpStatus?: number,
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* causeClass?: string
* }} [details]
* @returns {ApiFailure}
*/
export function createFailure(kind, operationId, attempt, details = {}) {
const registry =
/** @type {Readonly<Record<string, Readonly<ErrorDefinition>>>} */ (
ERROR_REGISTRY
);
const definition =
registry[kind] ?? ERROR_REGISTRY.UNKNOWN_FAILURE;
return Object.freeze({
kind: definition.kind,
code: typeof details.code === "string" ? details.code : definition.kind,
retryable: definition.defaultRetryable,
operationId,
attemptCount: Math.max(1, attempt + 1),
...(Number.isInteger(details.httpStatus)
? { httpStatus: details.httpStatus }
: {}),
...(typeof details.requestId === "string" ? { requestId: details.requestId } : {}),
...(typeof details.traceId === "string" ? { traceId: details.traceId } : {}),
...(typeof details.retryAfterMs === "number"
? { retryAfterMs: details.retryAfterMs }
: {}),
...(typeof details.causeClass === "string"
? { causeClass: details.causeClass }
: {}),
userMessageKey: definition.userMessageKey,
action: definition.action,
});
}
/** @param {number} status */
export function kindForStatus(status) {
if (status === 401) return "AUTH_REQUIRED";
if (status === 403) return "FORBIDDEN";
if (status === 404) return "NOT_FOUND";
if (status === 409) return "CONFLICT";
if (status === 422) return "VALIDATION_REJECTED";
if (status === 429) return "RATE_LIMITED";
if (status >= 500) return "SERVER_FAILURE";
if (status >= 400) return "UNKNOWN_CLIENT_FAILURE";
return "ENVELOPE_MISMATCH";
}
/**
* Total catch-all that intentionally discards the thrown value.
*
* @param {unknown} value
* @param {{ operationId?: string, attempt?: number }} [context]
*/
export function normalizeUnknownFailure(value, context = {}) {
const causeClass =
value instanceof Error
? value.name
: value === null
? "null"
: typeof value;
return createFailure(
"UNKNOWN_FAILURE",
context.operationId ?? "UNKNOWN_OPERATION",
context.attempt ?? 0,
{ code: "UNKNOWN_FAILURE", causeClass },
);
}
+36
View File
@@ -0,0 +1,36 @@
const RESOURCE_NAMESPACE = Object.freeze(["resource", 1]);
export const queryKeys = Object.freeze({
resource: Object.freeze({
all: () => RESOURCE_NAMESPACE,
list: (filters = {}) =>
Object.freeze([...RESOURCE_NAMESPACE, "list", canonicalize(filters)]),
/** @param {string} resourceId */
detail: (resourceId) =>
Object.freeze([...RESOURCE_NAMESPACE, "detail", String(resourceId)]),
}),
});
export const QUERY_REGISTRY = Object.freeze({
RESOURCE: Object.freeze({
namespace: RESOURCE_NAMESPACE,
serialization: "canonical-object-order",
identity: "no-pii-token-or-raw-url",
invalidation: "resource namespace after successful mutation",
version: 1,
persistence: "disabled",
}),
});
/** @param {unknown} value @returns {unknown} */
export function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalize(item)]),
);
}
return value;
}
+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,
},
});
}
+63
View File
@@ -0,0 +1,63 @@
/**
* @typedef {{
* routeId: string,
* path: string,
* paramsSchema: string | null,
* searchSchema: string | null,
* access: "public" | "session-required" | "integration-defined",
* loadingSurface: string,
* errorSurface: string,
* chunkId: string
* }} RouteDefinition
*/
/** @param {RouteDefinition} definition */
const route = (definition) => Object.freeze(definition);
export const ROUTE_REGISTRY = Object.freeze({
APP_HOME: route({
routeId: "APP_HOME",
path: "/",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "app-shell",
errorSurface: "route-boundary",
chunkId: "route-home",
}),
SAMPLE_RESOURCE_LIST: route({
routeId: "SAMPLE_RESOURCE_LIST",
path: "/sample/resources",
paramsSchema: null,
searchSchema: "SampleResourceListQuery",
access: "integration-defined",
loadingSurface: "sample-resource-list",
errorSurface: "feature-boundary",
chunkId: "route-sample-resources",
}),
NOT_FOUND: route({
routeId: "NOT_FOUND",
path: "*",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "none",
errorSurface: "not-found",
chunkId: "route-not-found",
}),
});
/** @param {string} routeId */
export function getRoute(routeId) {
const registry = /** @type {Record<string, Readonly<RouteDefinition>>} */ (
ROUTE_REGISTRY
);
const selected = registry[routeId];
if (!selected) throw new Error(`Unregistered route: ${routeId}`);
return selected;
}
/** @param {string} routeId */
export function routePath(routeId) {
return getRoute(routeId).path;
}
+102
View File
@@ -0,0 +1,102 @@
const APP_NAMESPACE = "ca-frontend";
export const STORAGE_REGISTRY = Object.freeze({
COLOR_SCHEME: defineStorageKey({
logicalName: "COLOR_SCHEME",
scope: "preference",
name: "color-scheme",
backend: "localStorage",
classification: "public-preference",
schemaVersion: 1,
ttl: null,
migration: "discard",
quotaFallback: "memory",
}),
CHUNK_RELOAD_GUARD: defineStorageKey({
logicalName: "CHUNK_RELOAD_GUARD",
scope: "release",
name: "chunk-reload-guard",
backend: "sessionStorage",
classification: "opaque-cache",
schemaVersion: 1,
ttl: "session",
migration: "discard",
quotaFallback: "no-persist",
}),
QUERY_PERSISTENCE: defineStorageKey({
logicalName: "QUERY_PERSISTENCE",
scope: "cache",
name: "query-persistence",
backend: "disabled",
classification: "sensitive-forbidden",
schemaVersion: 1,
ttl: null,
migration: "discard",
quotaFallback: "feature-disable",
}),
AUTH_TOKEN: defineStorageKey({
logicalName: "AUTH_TOKEN",
scope: "auth",
name: "auth-token",
backend: "forbidden",
classification: "sensitive-forbidden",
schemaVersion: 1,
ttl: null,
migration: "discard",
quotaFallback: "feature-disable",
}),
});
/**
* @typedef {{
* logicalName: string,
* scope: string,
* name: string,
* backend: "memory" | "sessionStorage" | "localStorage" | "indexedDB" |
* "disabled" | "forbidden",
* classification: "public-preference" | "opaque-cache" | "sensitive-forbidden",
* schemaVersion: number,
* ttl: number | "session" | null,
* migration: "discard" | ((value: unknown) => unknown),
* quotaFallback: "memory" | "no-persist" | "feature-disable"
* }} StorageKeyInput
*/
/** @param {StorageKeyInput} definition */
export function defineStorageKey(definition) {
if (definition.classification === "sensitive-forbidden") {
if (!["disabled", "forbidden"].includes(definition.backend)) {
throw new Error("Sensitive client storage registration is forbidden");
}
}
if (!Number.isInteger(definition.schemaVersion) || definition.schemaVersion < 1) {
throw new Error("Storage schemaVersion must be a positive integer");
}
return Object.freeze({
...definition,
physicalKey: buildPhysicalKey(
definition.scope,
definition.schemaVersion,
definition.name,
),
});
}
/** @param {string} scope @param {number} schemaVersion @param {string} name */
export function buildPhysicalKey(scope, schemaVersion, name) {
return `${APP_NAMESPACE}:${scope}:v${schemaVersion}:${name}`;
}
/** @param {string} logicalName */
export function getStorageDefinition(logicalName) {
const registry = /** @type {Record<string, ReturnType<typeof defineStorageKey>>} */ (
STORAGE_REGISTRY
);
const definition = registry[logicalName];
if (!definition) throw new Error(`Unregistered storage key: ${logicalName}`);
if (definition.classification === "sensitive-forbidden") {
throw new Error(`Forbidden storage key: ${logicalName}`);
}
return definition;
}
+145
View File
@@ -0,0 +1,145 @@
export const TELEMETRY_ATTRIBUTE_ALLOWLIST = Object.freeze([
"app_version",
"build_id",
"release_id",
"config_schema_version",
"api_contract_version",
"route_id",
"operation_id",
"error_kind",
"http_status_group",
"attempt_count_bucket",
"duration_bucket",
"component_boundary",
"active_release_id",
"mismatch_kind",
"reason",
"queue_size_bucket",
]);
export const TELEMETRY_FORBIDDEN_ATTRIBUTES = Object.freeze([
"access_token",
"refresh_token",
"authorization_header",
"cookie",
"email",
"user_name",
"raw_user_id",
"raw_url",
"query_string",
"request_body",
"response_body",
"storage_value",
"stack_in_user_message",
]);
/**
* @typedef {{
* eventName: string,
* trigger: string,
* requiredAttributes: readonly string[],
* optionalAttributes: readonly string[],
* forbiddenAttributes: readonly string[],
* sampling: string,
* delivery: string
* }} TelemetryDefinition
*/
/**
* @param {string} eventName
* @param {string} trigger
* @param {string[]} requiredAttributes
* @param {string[]} [optionalAttributes]
* @param {string} [sampling]
* @returns {Readonly<TelemetryDefinition>}
*/
const event = (
eventName,
trigger,
requiredAttributes,
optionalAttributes = [],
sampling = "all",
) =>
Object.freeze({
eventName,
trigger,
requiredAttributes: Object.freeze(requiredAttributes),
optionalAttributes: Object.freeze(optionalAttributes),
forbiddenAttributes: TELEMETRY_FORBIDDEN_ATTRIBUTES,
sampling,
delivery: "best-effort",
});
export const TELEMETRY_REGISTRY = Object.freeze({
"app.boot.failed": event("app.boot.failed", "boot validation failure", [
"error_kind",
"build_id",
"config_schema_version",
]),
"api.request.failed": event("api.request.failed", "terminal API failure", [
"error_kind",
"http_status_group",
"attempt_count_bucket",
"route_id",
]),
"ui.render.failed": event("ui.render.failed", "React boundary catch", [
"route_id",
"build_id",
"component_boundary",
]),
"release.mismatch.detected": event(
"release.mismatch.detected",
"release tuple mismatch",
["build_id", "active_release_id", "mismatch_kind"],
),
"telemetry.delivery.dropped": event(
"telemetry.delivery.dropped",
"queue or sink failure",
["reason", "queue_size_bucket"],
[],
"internal-counter",
),
});
/**
* @param {string} eventName
* @param {Record<string, unknown>} attributes
*/
export function projectTelemetryEvent(eventName, attributes) {
const registry =
/** @type {Record<string, (typeof TELEMETRY_REGISTRY)[keyof typeof TELEMETRY_REGISTRY]>} */ (
TELEMETRY_REGISTRY
);
const definition = registry[eventName];
if (!definition) {
return {
success: /** @type {false} */ (false),
reason: "unregistered-event",
};
}
const projected = Object.fromEntries(
Object.entries(attributes).filter(
([key]) =>
TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) &&
!TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key),
),
);
const missing = definition.requiredAttributes.filter(
(key) => projected[key] === undefined,
);
if (missing.length > 0) {
return {
success: /** @type {false} */ (false),
reason: "missing-required-attributes",
};
}
return {
success: /** @type {true} */ (true),
event: Object.freeze({
eventName,
attributes: Object.freeze(projected),
}),
};
}
+19
View File
@@ -0,0 +1,19 @@
/**
* @typedef {{
* id: string,
* displayName: string,
* createdAt: string | null
* }} Resource
*/
/** @param {Resource} values @returns {Readonly<Resource>} */
export function createResource(values) {
if (!values.id || !values.displayName) {
throw new TypeError("Resource invariants require id and displayName");
}
return Object.freeze({
id: values.id,
displayName: values.displayName,
createdAt: values.createdAt,
});
}
@@ -0,0 +1,49 @@
/**
* @param {{
* kind?: string,
* code?: string,
* buildId?: string,
* configSchemaVersion?: string,
* releaseId?: string,
* supportReference: string
* }} props
*/
export function BootErrorShell({
kind = "BOOT_CONFIG_FAILURE",
code = "BOOT_FAILED",
buildId,
configSchemaVersion,
releaseId,
supportReference,
}) {
return (
<main role="alert">
<h1>애플리케이션을 시작할 없습니다.</h1>
<dl>
<dt>오류</dt>
<dd>{kind}</dd>
<dt>코드</dt>
<dd>{code}</dd>
{buildId && (
<>
<dt>빌드</dt>
<dd>{buildId}</dd>
</>
)}
{configSchemaVersion && (
<>
<dt>설정 스키마</dt>
<dd>{configSchemaVersion}</dd>
</>
)}
{releaseId && (
<>
<dt>릴리스</dt>
<dd>{releaseId}</dd>
</>
)}
</dl>
<p>지원 참조: {supportReference}</p>
</main>
);
}
@@ -0,0 +1,68 @@
import { Component } from "react";
/**
* @typedef {{
* children: React.ReactNode,
* boundaryName: string,
* routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* fallback?: React.ReactNode
* }} RenderBoundaryProps
* @typedef {{ hasError: boolean }} RenderBoundaryState
*/
/** @extends {Component<RenderBoundaryProps, RenderBoundaryState>} */
export class RenderErrorBoundary extends Component {
/** @param {RenderBoundaryProps} props */
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch() {
try {
this.props.telemetry?.emit("ui.render.failed", {
route_id: this.props.routeId,
build_id: this.props.buildId,
component_boundary: this.props.boundaryName,
});
} catch {
// Telemetry must never recurse into another render failure.
}
}
reset = () => {
this.setState({ hasError: false });
};
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<section role="alert">
<p>error.render_failure</p>
<button type="button" onClick={this.reset}>
retry
</button>
</section>
)
);
}
return this.props.children;
}
}
/** @param {Omit<RenderBoundaryProps, "boundaryName">} props */
export function RouteBoundary(props) {
return <RenderErrorBoundary {...props} boundaryName="route" />;
}
/** @param {Omit<RenderBoundaryProps, "boundaryName">} props */
export function FeatureBoundary(props) {
return <RenderErrorBoundary {...props} boundaryName="feature" />;
}
@@ -0,0 +1,81 @@
/** @param {{ label?: string }} props */
export function LoadingSurface({ label = "불러오는 중" }) {
return (
<section
aria-busy="true"
aria-label={label}
aria-live="polite"
aria-atomic="true"
>
<div className="ui-skeleton" aria-hidden="true" />
<span className="sr-only">{label}</span>
</section>
);
}
/** @param {{ title?: string, action?: React.ReactNode }} props */
export function EmptySurface({ title = "표시할 항목이 없습니다.", action }) {
return (
<section className="ui-empty" aria-live="polite">
<p>{title}</p>
{action}
</section>
);
}
/**
* @param {{
* userMessageKey: string,
* action: "retry" | "reauth" | "navigate" | "reload-once" |
* "contact-support" | "none",
* onAction?: () => void
* }} props
*/
export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
return (
<section
className="ui-terminal-error"
role="alert"
aria-labelledby="terminal-error-message"
>
<p id="terminal-error-message">{userMessageKey}</p>
{action !== "none" && (
<button className="ui-button" type="button" onClick={onAction}>
{action}
</button>
)}
</section>
);
}
/**
* @param {{
* state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>,
* children?: React.ReactNode,
* onAction?: () => void
* }} props
*/
export function AsyncSurface({ state, children, onAction }) {
if (state.base === "initial-loading") return <LoadingSurface />;
if (state.base === "empty") return <EmptySurface />;
if (state.base === "terminal-error" && state.failure) {
return (
<TerminalErrorSurface
userMessageKey={state.failure.userMessageKey}
action={state.failure.action}
onAction={onAction}
/>
);
}
return (
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
{state.indicator && (
<p role="status" aria-live="polite">
{state.indicator}
</p>
)}
{children}
</section>
);
}
+81
View File
@@ -0,0 +1,81 @@
import {
BrowserRouter,
Link,
Route,
Routes,
} from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { decideRouteAccess } from "./navigation-policy.js";
function HomePage() {
return (
<main className="ui-page">
<h1>Clean Architecture Frontend</h1>
<p>런타임 계약이 검증되었습니다.</p>
<Link to={routePath("SAMPLE_RESOURCE_LIST")}>샘플 리소스</Link>
</main>
);
}
function SamplePlaceholder() {
return (
<main className="ui-page">
<h1>샘플 리소스</h1>
<p>계약 fixture를 준비하고 있습니다.</p>
</main>
);
}
function NotFoundPage() {
return (
<main className="ui-page">
<h1>페이지를 찾을 없습니다.</h1>
<Link to={routePath("APP_HOME")}>홈으로 이동</Link>
</main>
);
}
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort
* }} props
*/
function GuardedSampleRoute({ authSession }) {
const decision = decideRouteAccess(
"SAMPLE_RESOURCE_LIST",
authSession.getState(),
);
if (!decision.allowed) {
return (
<main className="ui-page">
<h1>세션이 필요합니다.</h1>
<button className="ui-button" type="button">
로그인
</button>
</main>
);
}
return <SamplePlaceholder />;
}
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* basename?: string
* }} props
*/
export function AppRouter({ authSession, basename = "/" }) {
return (
<BrowserRouter basename={basename}>
<Routes>
<Route path={routePath("APP_HOME")} element={<HomePage />} />
<Route
path={routePath("SAMPLE_RESOURCE_LIST")}
element={<GuardedSampleRoute authSession={authSession} />}
/>
<Route path={routePath("NOT_FOUND")} element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
);
}
@@ -0,0 +1,37 @@
import { getRoute } from "../../contracts/routes.js";
/**
* @param {string} routeId
* @param {import("../../application/ports/auth-session-port.js").SessionState} sessionState
*/
export function decideRouteAccess(routeId, sessionState) {
const route = getRoute(routeId);
if (route.access === "public") return { allowed: true, action: "none" };
if (sessionState === "authenticated") {
return { allowed: true, action: "none" };
}
if (sessionState === "recovery-pending") {
return { allowed: false, action: "wait-for-session" };
}
return { allowed: false, action: "show-sign-in" };
}
export function createRedirectLoopGuard() {
const visitedPairs = new Set();
return Object.freeze({
/**
* @param {string} source
* @param {string} target
*/
allow(source, target) {
const pair = `${source}->${target}`;
if (source === target || visitedPairs.has(pair)) return false;
visitedPairs.add(pair);
return true;
},
reset() {
visitedPairs.clear();
},
});
}
+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>;
}
+71
View File
@@ -0,0 +1,71 @@
@import "tailwindcss";
@theme {
--color-surface: oklch(0.985 0.003 247);
--color-surface-muted: oklch(0.94 0.01 247);
--color-content: oklch(0.25 0.025 247);
--color-content-muted: oklch(0.48 0.025 247);
--color-action: oklch(0.55 0.18 255);
--color-action-hover: oklch(0.48 0.2 255);
--color-danger: oklch(0.55 0.2 25);
--color-focus: oklch(0.72 0.16 225);
--radius-control: 0.5rem;
--radius-surface: 0.75rem;
--spacing-page: 1.5rem;
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
}
@layer base {
:root {
color: var(--color-content);
background: var(--color-surface);
font-family: var(--font-sans);
}
body {
margin: 0;
}
:focus-visible {
outline: 0.1875rem solid var(--color-focus);
outline-offset: 0.1875rem;
}
}
@layer components {
.ui-page {
@apply mx-auto flex min-h-screen max-w-4xl flex-col gap-6 p-page;
}
.ui-panel {
@apply rounded-surface border border-surface-muted bg-white p-6 shadow-sm;
}
.ui-button {
@apply rounded-control bg-action px-4 py-2 font-semibold text-white;
}
.ui-button:hover {
@apply bg-action-hover;
}
.ui-skeleton {
@apply h-24 animate-pulse rounded-surface bg-surface-muted;
}
.ui-empty,
.ui-terminal-error {
@apply rounded-surface border border-surface-muted p-6;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,15 @@
export function DesignTokenShowcase() {
return (
<section className="ui-panel" aria-labelledby="token-showcase-title">
<h2 id="token-showcase-title" className="text-xl font-semibold">
Design token fixture
</h2>
<p className="text-content-muted">
Semantic tokens style loading, empty, and terminal surfaces.
</p>
<button className="ui-button" type="button">
Token action
</button>
</section>
);
}
@@ -0,0 +1,53 @@
import { toResourceViewModel } from "../../application/view-models/resource-view-model.js";
import { queryKeys } from "../../contracts/query-keys.js";
/**
* @param {{
* http: { execute(operationId: string, input?: Record<string, unknown>): Promise<
* {ok: true, value: unknown} | {ok: false, error: import("../../contracts/errors.js").ApiFailure}
* > },
* cache: import("../../application/ports/query-cache-port.js").QueryCachePort
* }} ports
*/
export function createSampleFacade(ports) {
return Object.freeze({
async listResources(filters = {}) {
const key = queryKeys.resource.list(filters);
const result = await ports.http.execute("LIST_SAMPLE_RESOURCES", {
routeId: "SAMPLE_RESOURCE_LIST",
});
if (!result.ok) return result;
const models =
/** @type {Array<import("../../domain/models/resource.js").Resource>} */ (
result.value
);
const cached = ports.cache.write(key, models);
if (!cached.ok) return cached;
return {
ok: /** @type {true} */ (true),
value: models.map((model) => toResourceViewModel(model)),
};
},
/** @param {{ name: string }} command */
async createResource(command) {
const result = await ports.http.execute("CREATE_SAMPLE_RESOURCE", {
body: command,
routeId: "SAMPLE_RESOURCE_LIST",
});
if (!result.ok) return result;
const invalidated = await ports.cache.invalidate(queryKeys.resource.all());
if (!invalidated.ok) return invalidated;
return {
ok: /** @type {true} */ (true),
value: toResourceViewModel(
/** @type {import("../../domain/models/resource.js").Resource} */ (
result.value
),
),
};
},
});
}
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { deriveAsyncState } from "../../application/view-models/async-state.js";
import { AsyncSurface } from "../../presentation/components/async-surface.jsx";
/**
* @typedef {{
* loading: boolean,
* resources?: Array<{resourceId: string, title: string, createdAtLabel: string | null}>,
* failure?: import("../../contracts/errors.js").ApiFailure
* }} SamplePageState
*/
/**
* @param {{
* facade: ReturnType<typeof import("./sample-facade.js").createSampleFacade>
* }} props
*/
export function SampleResourcePage({ facade }) {
const [result, setResult] = useState(
/** @type {SamplePageState} */ ({
loading: true,
resources: undefined,
failure: undefined,
}),
);
useEffect(() => {
let active = true;
void facade.listResources().then((outcome) => {
if (!active) return;
setResult(
outcome.ok
? { loading: false, resources: outcome.value, failure: undefined }
: { loading: false, resources: undefined, failure: outcome.error },
);
});
return () => {
active = false;
};
}, [facade]);
const state = deriveAsyncState({
isInitialLoading: result.loading,
data: result.resources,
failure: result.failure,
});
return (
<main>
<h1>샘플 리소스</h1>
<AsyncSurface state={state}>
<ul>
{(result.resources ?? []).map((resource) => (
<li key={resource.resourceId}>{resource.title}</li>
))}
</ul>
</AsyncSurface>
</main>
);
}
+76
View File
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
import { createFailure } from "../../src/contracts/errors.js";
describe("async UI state matrix", () => {
it.each([
[{ isInitialLoading: true }, "initial-loading"],
[{ data: [{ id: "1" }] }, "success"],
[{ data: [] }, "empty"],
[
{ failure: createFailure("SERVER_FAILURE", "LIST", 0) },
"terminal-error",
],
])("derives base state %#", (signals, expected) => {
expect(deriveAsyncState(signals).base).toBe(expected);
});
it.each([
[{ data: ["value"], isFetching: true }, "refreshing"],
[{ data: ["value"], isStale: true, isDegraded: true }, "stale-degraded"],
[{ data: ["value"], isMutationPending: true }, "mutation-pending"],
[{ data: ["value"], hasMutationConflict: true }, "mutation-conflict"],
])("derives overlay state %#", (signals, indicator) => {
expect(deriveAsyncState(signals).indicator).toBe(indicator);
});
it("uses deterministic overlay priority for crossed states", () => {
const state = deriveAsyncState({
data: ["value"],
isFetching: true,
isMutationPending: true,
hasMutationConflict: true,
});
expect(state.indicator).toBe("mutation-conflict");
expect(state.overlay).toMatchObject({
refreshing: true,
mutationPending: true,
mutationConflict: true,
});
});
it("keeps content visible while a non-blocking refresh runs", () => {
const state = deriveAsyncState({ data: ["value"], isFetching: true });
render(<AsyncSurface state={state}>existing content</AsyncSurface>);
expect(screen.getByText("existing content")).toBeVisible();
expect(screen.getByRole("status")).toHaveTextContent("refreshing");
});
it("renders only safe error vocabulary", () => {
const failure = createFailure("SERVER_FAILURE", "LIST", 0, {
code: "SERVER_FAILURE",
});
const state = deriveAsyncState({ failure });
render(<AsyncSurface state={state} />);
expect(screen.getByRole("alert")).toHaveTextContent(failure.userMessageKey);
expect(screen.getByRole("button")).toHaveTextContent("retry");
expect(screen.getByRole("alert")).not.toHaveTextContent("stack");
});
it("does not retain a terminal error after usable data is restored", () => {
const failed = deriveAsyncState({
failure: createFailure("SERVER_FAILURE", "LIST", 0),
});
const recovered = deriveAsyncState({ data: ["value"] });
expect(failed.base).toBe("terminal-error");
expect(recovered.base).toBe("success");
expect(recovered.failure).toBeUndefined();
});
});
+17
View File
@@ -0,0 +1,17 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
function TestShell() {
return <main aria-label="application shell">ready</main>;
}
describe("component test level", () => {
it("renders an accessible application shell", () => {
render(<TestShell />);
expect(screen.getByRole("main", { name: "application shell" })).toHaveTextContent(
"ready",
);
});
});
+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/);
});
});
+14
View File
@@ -0,0 +1,14 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DesignTokenShowcase } from "../../src/sample/contract-fixture/design-token-showcase.jsx";
describe("design-token fixture", () => {
it("uses static semantic primitive classes", () => {
render(<DesignTokenShowcase />);
expect(screen.getByRole("region")).toHaveClass("ui-panel");
expect(screen.getByRole("button")).toHaveClass("ui-button");
});
});
+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { createFailure } from "../../src/contracts/errors.js";
import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
import { BootErrorShell } from "../../src/presentation/boundaries/boot-error-shell.jsx";
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx";
function Defect() {
throw new Error("raw render stack");
}
describe("render recovery boundaries", () => {
it("catches programmer defects and emits best-effort safe telemetry", () => {
const telemetry = { emit: vi.fn() };
render(
<FeatureBoundary
routeId="APP_HOME"
buildId="build-a"
telemetry={telemetry}
>
<Defect />
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure");
expect(telemetry.emit).toHaveBeenCalledWith("ui.render.failed", {
route_id: "APP_HOME",
build_id: "build-a",
component_boundary: "feature",
});
});
it("keeps normalized operational failures in normal async state", () => {
const state = deriveAsyncState({
failure: createFailure("SERVER_FAILURE", "LIST", 0),
});
render(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<AsyncSurface state={state} />
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent("error.server_failure");
});
it("renders a safe boot shell with no endpoint or stack", () => {
render(
<BootErrorShell
kind="BOOT_CONFIG_FAILURE"
code="CONFIG_SCHEMA_INVALID"
buildId="build-a"
configSchemaVersion="1"
supportReference="build-a:CONFIG_SCHEMA_INVALID"
/>,
);
const shell = screen.getByRole("alert");
expect(shell).toHaveTextContent("build-a:CONFIG_SCHEMA_INVALID");
expect(shell).not.toHaveTextContent("https://");
expect(shell).not.toHaveTextContent("stack");
});
it("allows a boundary reset action without reloading the page", async () => {
let shouldThrow = true;
function Recoverable() {
if (shouldThrow) throw new Error("defect");
return <p>recovered</p>;
}
const user = userEvent.setup();
const view = render(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<Recoverable />
</FeatureBoundary>,
);
shouldThrow = false;
await user.click(screen.getByRole("button", { name: "retry" }));
view.rerender(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<Recoverable />
</FeatureBoundary>,
);
expect(screen.getByText("recovered")).toBeVisible();
});
});
+24
View File
@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
describe("application router", () => {
it("renders not-found without making an API request", () => {
window.history.pushState({}, "", "/missing");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
expect(
screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
).toBeVisible();
});
it("shows session-required UX without claiming authorization", () => {
window.history.pushState({}, "", "/sample/resources");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
expect(screen.getByRole("heading", { name: "세션이 필요합니다." })).toBeVisible();
expect(screen.getByRole("button", { name: "로그인" })).toBeVisible();
});
});
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SampleResourcePage } from "../../src/sample/contract-fixture/sample-resource-page.jsx";
describe("removable sample feature page", () => {
it("renders the API-to-view-model result through AsyncSurface", async () => {
const facade = {
listResources: async () => ({
ok: true,
value: [
{
resourceId: "resource-1",
title: "Example",
createdAtLabel: null,
},
],
}),
createResource: async () => ({ ok: true, value: {} }),
};
render(<SampleResourcePage facade={facade} />);
expect(screen.getByLabelText("불러오는 중")).toBeVisible();
expect(await screen.findByText("Example")).toBeVisible();
});
it("renders normalized terminal errors without raw DTO fields", async () => {
const facade = {
listResources: async () => ({
ok: false,
error: {
kind: "SERVER_FAILURE",
code: "SERVER_FAILURE",
retryable: true,
operationId: "LIST_SAMPLE_RESOURCES",
attemptCount: 1,
userMessageKey: "error.server_failure",
action: "retry",
},
}),
createResource: async () => ({ ok: true, value: {} }),
};
render(<SampleResourcePage facade={facade} />);
expect(await screen.findByRole("alert")).toHaveTextContent("error.server_failure");
});
});

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