Compare commits

...
55 changed files with 2584 additions and 188 deletions
+14 -11
View File
@@ -1,15 +1,18 @@
# APP_HOME accessibility review # APP_HOME accessibility review
Status: pending-manual-review Status: pending-manual-review
Route ID: APP_HOME
Release ID:
Reviewer: Reviewer:
Reviewed at:
Keyboard: automated tab-order fixture passed; human review pending. Signature:
Attestation: pending
Focus: automated visible-focus fixture passed; route-change review pending. M1 Keyboard: pending
M2 Visible focus: pending
Screen reader: pending. M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
Reduced motion: automated media-query fixture passed; human review pending. M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
Color signal: pending. M7 Reduced motion: pending
Screen reader: pending
Notes: Automated axe, keyboard-focus, and reduced-motion evidence is available; human review pending.
+18
View File
@@ -0,0 +1,18 @@
# NOT_FOUND accessibility review
Status: pending-manual-review
Route ID: NOT_FOUND
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -0,0 +1,18 @@
# SAMPLE_RESOURCE_LIST accessibility review
Status: pending-manual-review
Route ID: SAMPLE_RESOURCE_LIST
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
+8 -1
View File
@@ -124,7 +124,10 @@
"logPath": "artifacts/quality/gates/FE-GATE-009.txt", "logPath": "artifacts/quality/gates/FE-GATE-009.txt",
"evidence": [ "evidence": [
"artifacts/tests/a11y.json", "artifacts/tests/a11y.json",
"artifacts/tests/a11y-manual/APP_HOME.md" "artifacts/tests/a11y-manual/APP_HOME.md",
"artifacts/tests/a11y-manual/SAMPLE_RESOURCE_LIST.md",
"artifacts/tests/a11y-manual/NOT_FOUND.md",
"artifacts/tests/a11y-manual/report.json"
], ],
"retentionClass": "merge-cycle" "retentionClass": "merge-cycle"
}, },
@@ -208,6 +211,10 @@
}, },
"FE-GATE-018": { "FE-GATE-018": {
"name": "field-web-vitals", "name": "field-web-vitals",
"requiresEnvironment": [
"FIELD_WEB_VITALS_INPUT",
"MIN_ELIGIBLE_SAMPLES"
],
"steps": [ "steps": [
{ "script": "collect:web-vitals-evidence", "expect": "pass" } { "script": "collect:web-vitals-evidence", "expect": "pass" }
], ],
@@ -1,5 +1,25 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"releaseId": "local-release", "releaseId": "local-release",
"environment": "replace-with-production",
"source": {
"system": "",
"exportId": ""
},
"privacy": {
"approved": false,
"approvalRef": ""
},
"window": {
"start": "2026-06-01T00:00:00Z",
"end": "2026-06-29T00:00:00Z"
},
"thresholdDecision": {
"status": "pending",
"minimumEligibleSamples": null,
"owner": "",
"reviewedAt": "",
"evidenceRef": ""
},
"samples": [] "samples": []
} }
+47 -3
View File
@@ -8,6 +8,7 @@
"window", "window",
"context", "context",
"metrics", "metrics",
"thresholds",
"eligibility", "eligibility",
"status", "status",
"passed" "passed"
@@ -18,12 +19,55 @@
"window": { "type": "object", "required": ["days", "start", "end"] }, "window": { "type": "object", "required": ["days", "start", "end"] },
"context": { "context": {
"type": "object", "type": "object",
"required": ["source", "network", "routeAggregation", "releaseId"] "required": [
"source",
"sourceSystem",
"exportId",
"network",
"routeAggregation",
"releaseId",
"privacyApprovalRef",
"thresholdDecisionRef",
"validationFailures"
],
"properties": {
"source": { "type": "string" },
"sourceSystem": { "type": ["string", "null"] },
"exportId": { "type": ["string", "null"] },
"network": { "const": "production-real-user" },
"routeAggregation": { "const": "route-id-only" },
"releaseId": { "type": ["string", "null"] },
"privacyApprovalRef": { "type": ["string", "null"] },
"thresholdDecisionRef": { "type": ["string", "null"] },
"validationFailures": {
"type": "array",
"items": { "type": "string" }
}
},
"additionalProperties": false
},
"thresholds": {
"type": "object",
"required": [
"p75LcpMs",
"p75Cls",
"p75InpMs",
"minimumEligibleSamples"
]
},
"metrics": {
"type": "object",
"required": ["p75LcpMs", "p75Cls", "p75InpMs"]
}, },
"metrics": { "type": "object" },
"eligibility": { "eligibility": {
"type": "object", "type": "object",
"required": ["consentRequired", "eligibleSamples", "minimumEligibleSamples"] "required": [
"consentRequired",
"totalSamples",
"eligibleSamples",
"minimumEligibleSamples",
"routeSamples"
]
}, },
"status": { "status": {
"enum": ["PASS", "FAIL_THRESHOLD", "FAIL_UNVERIFIED"] "enum": ["PASS", "FAIL_THRESHOLD", "FAIL_UNVERIFIED"]
+42 -12
View File
@@ -1,17 +1,47 @@
# Manual accessibility review checklist # Manual accessibility review checklist
Automated axe checks do not establish WCAG conformance. A human reviewer must 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 review all three route records in `artifacts/tests/a11y-manual/` against one
on the release candidate, and sign it. release candidate and sign them. Copy the template fields exactly; the gate
rejects blank identity/timestamp/signature fields, pending verdicts, mismatched
release IDs, or missing routes.
- Status: `pending` or `reviewed` Allowed item verdicts:
- 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 - `pass`
critical/serious axe findings under the recorded browser run. - `not-applicable (<specific reason>)`
Required record:
```text
Status: reviewed
Route ID: APP_HOME
Release ID: <immutable release ID>
Reviewer: <human reviewer identity>
Reviewed at: <RFC 3339 timestamp>
Signature: <reviewer identity or approved signature reference>
Attestation: accepted
M1 Keyboard: pass
M2 Visible focus: pass
M3 Route focus: pass
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pass
M7 Reduced motion: pass
Screen reader: pass
Notes: <observations and linked defect IDs>
```
The reviewer must verify:
- M1: every action works without a pointing device
- M2: every focused element has a visible indicator
- M3: route transitions move focus to a deterministic target
- M4: modal focus is trapped and restored, when a modal exists
- M5: errors are programmatically associated with their controls, when present
- M6: state never relies on color alone
- M7: non-essential motion is suppressed with reduced-motion preference
- Screen reader: headings, live regions, errors, and actions are announced once
Passing automated evidence means only that tested pages had no critical or
serious axe findings under the recorded browser run.
+4
View File
@@ -1,5 +1,9 @@
# Architecture overview # Architecture overview
This Mermaid view is a repository-local implementation projection. The
`PASS_SCOPED` reviewer evidence applies to the canonical draw.io diagram named
in `review-ledger.json`, not automatically to edits in this file.
```mermaid ```mermaid
flowchart LR flowchart LR
Bootstrap[bootstrap / composition root] --> Presentation[presentation] Bootstrap[bootstrap / composition root] --> Presentation[presentation]
+22
View File
@@ -0,0 +1,22 @@
# Imported scoped diagram review evidence
This ledger entry consumes the canonical evidence already recorded by the
`ca-skeleton-frontend-operational-contract` project note. It does not claim
review of the repository-local Mermaid projections or of the complete
production deployment topology.
- Reviewer: `wiki-diagram-reviewer`
- Standard: `rules/diagram-standards.md` v2
- Canonical report:
`docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md`
- Canonical report SHA-256:
`b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29`
| Canonical diagram | SHA-256 | Score | Verdict | Reviewed scope |
| --- | --- | ---: | --- | --- |
| `raw/diagrams/ca-skeleton-frontend/architecture-overview-2026-07-18.drawio` | `c0ae56c9c964c5c6e698ab7dcc91736b9b811b2b834381817905db81c4230ba0` | 100 | PASS | Clean Architecture compile-time dependency ownership |
| `raw/diagrams/ca-skeleton-frontend/architecture-deployment-2026-07-18.drawio` | `9a654326fb840ddf24b832221ff7eec4b8fadd9f87ad84174fccfa3bfcd1a25b` | 100 | PASS | immutable static assets and mutable `/config.json` delivery |
The canonical report explicitly limits this `PASS_SCOPED`: it does not verify
the complete release/rollback topology, the implementation topology, or live
hosting state.
+20 -8
View File
@@ -1,17 +1,29 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"status": "pending-review", "status": "PASS_SCOPED",
"reviewerThreshold": null, "reviewer": "wiki-diagram-reviewer",
"standard": "rules/diagram-standards.md v2",
"evidenceReport": {
"repoPath": "docs/architecture/review-evidence.md",
"canonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md",
"canonicalSha256": "b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29"
},
"reviews": { "reviews": {
"overview": { "overview": {
"path": "docs/architecture/overview.md", "sourcePath": "raw/diagrams/ca-skeleton-frontend/architecture-overview-2026-07-18.drawio",
"reviewer": null, "sha256": "c0ae56c9c964c5c6e698ab7dcc91736b9b811b2b834381817905db81c4230ba0",
"score": null "score": 100,
"verdict": "PASS",
"thresholdSatisfied": true,
"scope": "Clean Architecture compile-time dependency ownership"
}, },
"staticDelivery": { "staticDelivery": {
"path": "docs/architecture/static-delivery.md", "sourcePath": "raw/diagrams/ca-skeleton-frontend/architecture-deployment-2026-07-18.drawio",
"reviewer": null, "sha256": "9a654326fb840ddf24b832221ff7eec4b8fadd9f87ad84174fccfa3bfcd1a25b",
"score": null "score": 100,
"verdict": "PASS",
"thresholdSatisfied": true,
"scope": "immutable static assets and mutable /config.json delivery"
} }
} }
} }
+4
View File
@@ -1,5 +1,9 @@
# Static asset and runtime-config delivery # Static asset and runtime-config delivery
This Mermaid view is a repository-local implementation projection. The
`PASS_SCOPED` reviewer evidence applies only to the canonical static-delivery
draw.io scope recorded in `review-ledger.json`.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant CI participant CI
+4 -3
View File
@@ -18,9 +18,10 @@ DOCUMENTATION_READY (off-chain)
Pull requests and `develop` pushes evaluate merge readiness. Version tags Pull requests and `develop` pushes evaluate merge readiness. Version tags
evaluate merge then release readiness. Production and field evaluation require evaluate merge then release readiness. Production and field evaluation require
an explicit workflow dispatch. The field tier cannot pass until the 28-day an explicit workflow dispatch. The field tier cannot pass until the 28-day
sample threshold decision is recorded. Documentation readiness cannot pass sample threshold decision is recorded. Documentation readiness consumes the
until both scoped diagrams have a recorded reviewer threshold and signed canonical project-note evidence in which both scoped diagrams already received
scores. 100/100 `PASS_SCOPED`; the repo ledger preserves the evidence scope and
canonical digests.
All jobs upload the shared `artifacts/` tree even after failure. Numeric All jobs upload the shared `artifacts/` tree even after failure. Numeric
retention remains an organization/provider decision; the workflow intentionally retention remains an organization/provider decision; the workflow intentionally
+7 -2
View File
@@ -13,5 +13,10 @@ Performance evidence is deliberately split by measurement context:
The field minimum eligible-sample threshold is intentionally unresolved until The field minimum eligible-sample threshold is intentionally unresolved until
a privacy-approved telemetry baseline exists. Therefore the field command a privacy-approved telemetry baseline exists. Therefore the field command
fails closed with `FAIL_UNVERIFIED` when run against the example input. Provide fails closed with `FAIL_UNVERIFIED` when run against the example input. Provide
`FIELD_WEB_VITALS_INPUT` and a reviewed `MIN_ELIGIBLE_SAMPLES` only after that `FIELD_WEB_VITALS_INPUT` and `MIN_ELIGIBLE_SAMPLES` only after that decision is
decision is recorded. recorded. The external input must identify a production release and an exact
28-day export window, name the source/export, carry privacy-approval and
threshold-decision references, and contain only non-negative route-ID samples.
The environment threshold must be a positive integer equal to the approved
decision embedded in the input. Invalid metadata fails as `FAIL_UNVERIFIED`;
the example can never serve as production evidence.
+4 -1
View File
@@ -26,4 +26,7 @@ to the declared allowlist; a cache-correct response with a mismatched
`corepack pnpm verify:hosting-headers` uses a deterministic fixture locally. `corepack pnpm verify:hosting-headers` uses a deterministic fixture locally.
Set `HOSTING_BASE_URL` to probe deployed responses; production promotion Set `HOSTING_BASE_URL` to probe deployed responses; production promotion
requires the artifact to report `mode: "live"`. 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.
+1 -1
View File
@@ -4,7 +4,7 @@
"REQUEST_TIMEOUT_MS": 10000, "REQUEST_TIMEOUT_MS": 10000,
"MAX_RETRY_ATTEMPTS": 2, "MAX_RETRY_ATTEMPTS": 2,
"TELEMETRY_ENABLED": false, "TELEMETRY_ENABLED": false,
"AUTH_MODE": "external", "AUTH_MODE": "demo",
"CONFIG_SCHEMA_VERSION": "1", "CONFIG_SCHEMA_VERSION": "1",
"API_CONTRACT_VERSION": "1", "API_CONTRACT_VERSION": "1",
"RELEASE_MANIFEST_URL": "/release-manifest.json", "RELEASE_MANIFEST_URL": "/release-manifest.json",
+31 -27
View File
@@ -4,23 +4,19 @@ import {
evaluateFieldBudget, evaluateFieldBudget,
percentile75, percentile75,
} from "../src/application/policies/performance-budgets.js"; } from "../src/application/policies/performance-budgets.js";
import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.mjs";
const inputPath = const inputPath =
process.env.FIELD_WEB_VITALS_INPUT || process.env.FIELD_WEB_VITALS_INPUT ||
"config/performance/field-input.example.json"; "config/performance/field-input.example.json";
const input = const rawInput = JSON.parse(await readFile(inputPath, "utf8"));
/** @type {{ const now = new Date();
* releaseId: string, const validation = validateFieldEvidenceInput(
* samples: Array<{ rawInput,
* timestamp: string, process.env.MIN_ELIGIBLE_SAMPLES,
* consent: boolean, now,
* releaseId: string, );
* routeId: string, const input = validation.data;
* lcpMs: number,
* cls: number,
* inpMs: number
* }>
* }} */ (JSON.parse(await readFile(inputPath, "utf8")));
const configured = const configured =
/** @type {{ /** @type {{
* p75LcpMs: number, * p75LcpMs: number,
@@ -30,17 +26,17 @@ const configured =
* }} */ ( * }} */ (
JSON.parse(await readFile("config/performance/budgets.json", "utf8")).field JSON.parse(await readFile("config/performance/budgets.json", "utf8")).field
); );
const minimumEligibleSamples = process.env.MIN_ELIGIBLE_SAMPLES const minimumEligibleSamples = validation.minimumEligibleSamples;
? Number(process.env.MIN_ELIGIBLE_SAMPLES) const fallbackEnd = now;
: configured.minimumEligibleSamples; const fallbackStart = new Date(fallbackEnd);
const end = new Date(); fallbackStart.setUTCDate(fallbackStart.getUTCDate() - 28);
const start = new Date(end); const start = input ? new Date(input.window.start) : fallbackStart;
start.setUTCDate(start.getUTCDate() - 28); const end = input ? new Date(input.window.end) : fallbackEnd;
const eligible = input.samples.filter((sample) => { const eligible = (input?.samples ?? []).filter((sample) => {
const timestamp = new Date(sample.timestamp); const timestamp = new Date(sample.timestamp);
return ( return (
sample.consent === true && sample.consent === true &&
sample.releaseId === input.releaseId && sample.releaseId === input?.releaseId &&
timestamp >= start && timestamp >= start &&
timestamp <= end timestamp <= end
); );
@@ -55,6 +51,8 @@ const result = evaluateFieldBudget(
{ metrics, eligibleSamples: eligible.length }, { metrics, eligibleSamples: eligible.length },
thresholds, thresholds,
); );
const passed = validation.passed && result.passed;
const status = validation.passed ? result.status : "FAIL_UNVERIFIED";
const routeSamples = Object.fromEntries( const routeSamples = Object.fromEntries(
Object.entries( Object.entries(
eligible.reduce( eligible.reduce(
@@ -68,24 +66,30 @@ const routeSamples = Object.fromEntries(
); );
const report = { const report = {
schemaVersion: 1, schemaVersion: 1,
generatedAt: end.toISOString(), generatedAt: now.toISOString(),
window: { days: 28, start: start.toISOString(), end: end.toISOString() }, window: { days: 28, start: start.toISOString(), end: end.toISOString() },
context: { context: {
source: inputPath, source: inputPath,
sourceSystem: input?.source.system ?? null,
exportId: input?.source.exportId ?? null,
network: "production-real-user", network: "production-real-user",
routeAggregation: "route-id-only", routeAggregation: "route-id-only",
releaseId: input.releaseId, releaseId: input?.releaseId ?? null,
privacyApprovalRef: input?.privacy.approvalRef ?? null,
thresholdDecisionRef: input?.thresholdDecision.evidenceRef ?? null,
validationFailures: validation.failures,
}, },
metrics, metrics,
thresholds, thresholds,
eligibility: { eligibility: {
consentRequired: true, consentRequired: true,
totalSamples: input?.samples.length ?? 0,
eligibleSamples: eligible.length, eligibleSamples: eligible.length,
minimumEligibleSamples, minimumEligibleSamples,
routeSamples, routeSamples,
}, },
status: result.status, status,
passed: result.passed, passed,
}; };
await mkdir("artifacts/performance", { recursive: true }); await mkdir("artifacts/performance", { recursive: true });
@@ -93,9 +97,9 @@ await writeFile(
"artifacts/performance/field-web-vitals.json", "artifacts/performance/field-web-vitals.json",
`${JSON.stringify(report, null, 2)}\n`, `${JSON.stringify(report, null, 2)}\n`,
); );
if (!result.passed) { if (!passed) {
process.stderr.write( process.stderr.write(
`Field Web Vitals: ${result.status} (minimum eligible sample threshold and 28-day production data are required)\n`, `Field Web Vitals: ${status} (approved threshold decision and valid 28-day production evidence are required)\n`,
); );
process.exit(1); process.exit(1);
} }
+122
View File
@@ -0,0 +1,122 @@
import { z } from "zod";
const WINDOW_MILLISECONDS = 28 * 24 * 60 * 60 * 1000;
const nonEmptyString = z.string().trim().min(1);
const timestamp = nonEmptyString.refine(
(value) => Number.isFinite(Date.parse(value)),
"must be an RFC 3339 timestamp",
);
const sampleSchema = z
.object({
timestamp,
consent: z.boolean(),
releaseId: nonEmptyString,
routeId: nonEmptyString.regex(/^[A-Z][A-Z0-9_]*$/),
lcpMs: z.number().finite().nonnegative(),
cls: z.number().finite().nonnegative(),
inpMs: z.number().finite().nonnegative(),
})
.strict();
const fieldEvidenceInputSchema = z
.object({
schemaVersion: z.literal(1),
environment: z.literal("production"),
releaseId: nonEmptyString.refine(
(value) => value !== "local-release",
"must identify an immutable production release",
),
source: z
.object({
system: nonEmptyString,
exportId: nonEmptyString,
})
.strict(),
privacy: z
.object({
approved: z.literal(true),
approvalRef: nonEmptyString,
})
.strict(),
window: z
.object({
start: timestamp,
end: timestamp,
})
.strict(),
thresholdDecision: z
.object({
status: z.literal("approved"),
minimumEligibleSamples: z.number().int().positive(),
owner: nonEmptyString,
reviewedAt: timestamp,
evidenceRef: nonEmptyString,
})
.strict(),
samples: z.array(sampleSchema),
})
.strict()
.superRefine((input, context) => {
const start = Date.parse(input.window.start);
const end = Date.parse(input.window.end);
if (end - start !== WINDOW_MILLISECONDS) {
context.addIssue({
code: "custom",
path: ["window"],
message: "must cover exactly 28 days",
});
}
});
/**
* @param {unknown} input
* @param {string | undefined} configuredMinimum
* @param {Date} [now]
*/
export function validateFieldEvidenceInput(
input,
configuredMinimum,
now = new Date(),
) {
const parsed = fieldEvidenceInputSchema.safeParse(input);
const failures = parsed.success
? []
: parsed.error.issues.map(
(issue) => `${issue.path.join(".") || "input"}: ${issue.message}`,
);
const minimumEligibleSamples = Number(configuredMinimum);
if (
configuredMinimum === undefined ||
!Number.isInteger(minimumEligibleSamples) ||
minimumEligibleSamples <= 0
) {
failures.push("MIN_ELIGIBLE_SAMPLES: must be a positive integer");
}
if (parsed.success) {
if (
parsed.data.thresholdDecision.minimumEligibleSamples !==
minimumEligibleSamples
) {
failures.push(
"MIN_ELIGIBLE_SAMPLES: does not match the approved threshold decision",
);
}
if (Date.parse(parsed.data.window.end) > now.getTime()) {
failures.push("window.end: must not be in the future");
}
if (Date.parse(parsed.data.thresholdDecision.reviewedAt) > now.getTime()) {
failures.push("thresholdDecision.reviewedAt: must not be in the future");
}
}
return Object.freeze({
data: parsed.success ? parsed.data : null,
failures: Object.freeze(failures),
minimumEligibleSamples:
Number.isInteger(minimumEligibleSamples) && minimumEligibleSamples > 0
? minimumEligibleSamples
: null,
passed: parsed.success && failures.length === 0,
});
}
+68
View File
@@ -0,0 +1,68 @@
const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/;
/**
* A release gate must not promote a local preview server as live hosting
* evidence.
*
* @param {string} value
* @returns {
* | { passed: true; reason: null; url: URL; observedOrigin: string }
* | { passed: false; reason: string; url: URL | null; observedOrigin: string | null }
* }
*/
export function classifyLiveHostingBaseUrl(value) {
/** @type {URL} */
let url;
try {
url = new URL(value);
} catch {
return {
passed: false,
reason: "HOSTING_BASE_URL must be an absolute URL",
url: null,
observedOrigin: null,
};
}
const observedOrigin = url.origin;
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (url.protocol !== "https:") {
return {
passed: false,
reason: "live hosting evidence requires HTTPS",
url,
observedOrigin,
};
}
if (url.username || url.password) {
return {
passed: false,
reason: "HOSTING_BASE_URL must not contain credentials",
url,
observedOrigin,
};
}
if (
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname === "::1" ||
hostname === "0.0.0.0" ||
LOOPBACK_IPV4.test(hostname)
) {
return {
passed: false,
reason: "local or loopback hosts are not live deployment evidence",
url,
observedOrigin,
};
}
if (url.pathname !== "/" || url.search || url.hash) {
return {
passed: false,
reason: "HOSTING_BASE_URL must be the canonical root URL",
url,
observedOrigin,
};
}
return { passed: true, reason: null, url, observedOrigin };
}
+58
View File
@@ -0,0 +1,58 @@
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze([
"APP_HOME",
"SAMPLE_RESOURCE_LIST",
"NOT_FOUND",
]);
const REVIEW_FIELDS = Object.freeze([
"M1 Keyboard",
"M2 Visible focus",
"M3 Route focus",
"M4 Modal focus",
"M5 Error association",
"M6 Color signal",
"M7 Reduced motion",
"Screen reader",
]);
/** @param {string} content */
export function validateManualA11yEvidence(content) {
const fields = Object.fromEntries(
content
.split(/\r?\n/)
.map((line) => /^([^:]+):\s*(.*)$/.exec(line))
.filter(Boolean)
.map((match) => [
/** @type {RegExpExecArray} */ (match)[1].trim(),
/** @type {RegExpExecArray} */ (match)[2].trim(),
]),
);
const failures = [];
if (fields.Status !== "reviewed") failures.push("Status");
if (!fields["Route ID"]) failures.push("Route ID");
if (!fields["Release ID"]) failures.push("Release ID");
if (!fields.Reviewer) failures.push("Reviewer");
if (!fields.Signature) failures.push("Signature");
if (fields.Attestation !== "accepted") failures.push("Attestation");
if (
!fields["Reviewed at"] ||
!Number.isFinite(Date.parse(fields["Reviewed at"]))
) {
failures.push("Reviewed at");
}
for (const field of REVIEW_FIELDS) {
const result = fields[field];
if (
result !== "pass" &&
!/^not-applicable \(.+\)$/.test(result ?? "")
) {
failures.push(field);
}
}
return Object.freeze({
fields: Object.freeze(fields),
failures: Object.freeze(failures),
passed: failures.length === 0,
});
}
+64 -18
View File
@@ -1,25 +1,71 @@
import { readFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
const evidence = await readFile( import {
"artifacts/tests/a11y-manual/APP_HOME.md", MANUAL_A11Y_ROUTE_IDS,
"utf8", validateManualA11yEvidence,
} from "./lib/manual-a11y-evidence.mjs";
/** @type {Array<{
* routeId: string;
* path: string;
* reviewer: string | null;
* reviewedAt: string | null;
* releaseId: string | null;
* failures: readonly string[];
* passed: boolean;
* }>} */
const results = [];
for (const routeId of MANUAL_A11Y_ROUTE_IDS) {
const path = `artifacts/tests/a11y-manual/${routeId}.md`;
const evidence = await readFile(path, "utf8");
const validation = validateManualA11yEvidence(evidence);
const failures =
validation.fields["Route ID"] === routeId
? validation.failures
: Object.freeze([...validation.failures, "Route ID mismatch"]);
results.push({
routeId,
path,
reviewer: validation.fields.Reviewer ?? null,
reviewedAt: validation.fields["Reviewed at"] ?? null,
releaseId: validation.fields["Release ID"] ?? null,
failures,
passed: validation.passed && failures.length === 0,
});
}
const releaseIds = new Set(results.map((result) => result.releaseId));
const passed =
results.every((result) => result.passed) &&
releaseIds.size === 1 &&
results.every((result) => Boolean(result.releaseId));
await mkdir("artifacts/tests/a11y-manual", { recursive: true });
await writeFile(
"artifacts/tests/a11y-manual/report.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
scope: MANUAL_A11Y_ROUTE_IDS,
results,
coherentRelease: releaseIds.size === 1,
passed,
},
null,
2,
)}\n`,
); );
const required = [ if (!passed) {
"Status: reviewed", const failures = results
"Reviewer:", .filter((result) => !result.passed)
"Keyboard:", .map((result) => `${result.routeId}: ${result.failures.join(", ")}`);
"Focus:", if (releaseIds.size !== 1) failures.push("release IDs do not match");
"Screen reader:",
"Reduced motion:",
"Color signal:",
];
const missing = required.filter((marker) => !evidence.includes(marker));
if (missing.length > 0) {
process.stderr.write( process.stderr.write(
`Manual accessibility evidence is incomplete: ${missing.join(", ")}\n`, `Manual accessibility evidence is incomplete:\n${failures.join("\n")}\n`,
); );
process.exit(1); process.exit(1);
} }
process.stdout.write("Manual accessibility evidence: PASS\n"); process.stdout.write(
`Manual accessibility evidence: PASS (${results.length} routes)\n`,
);
+29 -12
View File
@@ -3,29 +3,43 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
const ledger = JSON.parse( const ledger = JSON.parse(
await readFile("docs/architecture/review-ledger.json", "utf8"), await readFile("docs/architecture/review-ledger.json", "utf8"),
); );
const evidence = await readFile(ledger.evidenceReport.repoPath, "utf8");
const results = []; const results = [];
for (const [diagram, review] of Object.entries(ledger.reviews)) { for (const [diagram, review] of Object.entries(ledger.reviews)) {
const content = await readFile(review.path, "utf8"); const sourceReferenced = evidence.includes(review.sourcePath);
const hasDiagram = /```mermaid[\s\S]+```/.test(content); const digestReferenced =
/^[0-9a-f]{64}$/.test(review.sha256) &&
evidence.includes(review.sha256);
const scorePass = const scorePass =
typeof ledger.reviewerThreshold === "number" && review.thresholdSatisfied === true &&
review.verdict === "PASS" &&
typeof review.score === "number" && typeof review.score === "number" &&
review.score >= ledger.reviewerThreshold; evidence.includes(`| ${review.score} | PASS |`);
results.push({ results.push({
diagram, diagram,
path: review.path, sourcePath: review.sourcePath,
hasDiagram, sha256: review.sha256,
reviewer: review.reviewer, sourceReferenced,
digestReferenced,
reviewer: ledger.reviewer,
score: review.score, score: review.score,
scorePass, scorePass,
passed: passed:
hasDiagram && sourceReferenced &&
Boolean(review.reviewer) && digestReferenced &&
ledger.reviewer === "wiki-diagram-reviewer" &&
ledger.standard === "rules/diagram-standards.md v2" &&
scorePass && scorePass &&
ledger.status === "PASS_SCOPED", ledger.status === "PASS_SCOPED",
}); });
} }
const passed = results.every((result) => result.passed); const reportDigestValid =
/^[0-9a-f]{64}$/.test(ledger.evidenceReport.canonicalSha256) &&
evidence.includes(ledger.evidenceReport.canonicalSha256);
const passed =
reportDigestValid &&
results.length === 2 &&
results.every((result) => result.passed);
await mkdir("artifacts/quality", { recursive: true }); await mkdir("artifacts/quality", { recursive: true });
await writeFile( await writeFile(
"artifacts/quality/documentation-review.json", "artifacts/quality/documentation-review.json",
@@ -34,7 +48,10 @@ await writeFile(
schemaVersion: 1, schemaVersion: 1,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
status: ledger.status, status: ledger.status,
reviewerThreshold: ledger.reviewerThreshold, reviewer: ledger.reviewer,
standard: ledger.standard,
evidenceReport: ledger.evidenceReport,
reportDigestValid,
results, results,
passed, passed,
}, },
@@ -44,7 +61,7 @@ await writeFile(
); );
if (!passed) { if (!passed) {
process.stderr.write( process.stderr.write(
"Documentation readiness: FAIL_UNVERIFIED (reviewer threshold and signed reviews required)\n", "Documentation readiness: FAIL_UNVERIFIED (canonical scoped-review evidence is incomplete)\n",
); );
process.exit(1); process.exit(1);
} }
+55 -5
View File
@@ -1,5 +1,7 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.mjs";
const cachePolicy = JSON.parse( const cachePolicy = JSON.parse(
await readFile("config/hosting/cache-policy.json", "utf8"), await readFile("config/hosting/cache-policy.json", "utf8"),
); );
@@ -7,6 +9,7 @@ const securityPolicy = JSON.parse(
await readFile("config/hosting/security-headers.json", "utf8"), await readFile("config/hosting/security-headers.json", "utf8"),
); );
const baseUrl = process.env.HOSTING_BASE_URL; const baseUrl = process.env.HOSTING_BASE_URL;
const liveTarget = baseUrl ? classifyLiveHostingBaseUrl(baseUrl) : null;
const distFiles = (await readdir("dist", { recursive: true })).map(String); const distFiles = (await readdir("dist", { recursive: true })).map(String);
const publicSourceMaps = distFiles.filter((file) => file.endsWith(".map")); const publicSourceMaps = distFiles.filter((file) => file.endsWith(".map"));
const publicServiceWorkers = distFiles.filter((file) => const publicServiceWorkers = distFiles.filter((file) =>
@@ -14,10 +17,19 @@ const publicServiceWorkers = distFiles.filter((file) =>
); );
/** @type {Record<string, Record<string, string>>} */ /** @type {Record<string, Record<string, string>>} */
let responses; let responses = {};
let mode; let mode;
/** @type {Array<{
* surface: string;
* header: string;
* expected: unknown;
* observed: unknown;
* reason?: string;
* passed: boolean;
* }>} */
const probeResults = [];
if (baseUrl) { if (liveTarget?.passed) {
mode = "live"; mode = "live";
const assets = await readdir("dist/assets"); const assets = await readdir("dist/assets");
const hashedJavaScript = assets.find((file) => file.endsWith(".js")); const hashedJavaScript = assets.find((file) => file.endsWith(".js"));
@@ -30,14 +42,52 @@ if (baseUrl) {
}; };
responses = {}; responses = {};
for (const [surface, pathname] of Object.entries(paths)) { for (const [surface, pathname] of Object.entries(paths)) {
const response = await fetch(new URL(pathname, baseUrl)); 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( responses[surface] = Object.fromEntries(
[...response.headers.entries()].map(([name, value]) => [ [...response.headers.entries()].map(([name, value]) => [
name.toLowerCase(), name.toLowerCase(),
value, 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 { } else {
mode = "fixture"; mode = "fixture";
responses = JSON.parse( responses = JSON.parse(
@@ -45,7 +95,7 @@ if (baseUrl) {
).responses; ).responses;
} }
const results = []; const results = [...probeResults];
for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) { for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) {
if (!("cacheControl" in policy)) continue; if (!("cacheControl" in policy)) continue;
const observed = responses[surface]?.["cache-control"]; const observed = responses[surface]?.["cache-control"];
@@ -110,7 +160,7 @@ await writeFile(
schemaVersion: 1, schemaVersion: 1,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
mode, mode,
baseUrl: baseUrl ?? null, baseUrl: liveTarget?.observedOrigin ?? null,
providerVerificationRequired: mode !== "live", providerVerificationRequired: mode !== "live",
results, results,
passed, passed,
+1 -1
View File
@@ -10,7 +10,7 @@ await writeFile(
scope: ["APP_HOME", "SAMPLE_RESOURCE_LIST", "NOT_FOUND"], scope: ["APP_HOME", "SAMPLE_RESOURCE_LIST", "NOT_FOUND"],
threshold: { critical: 0, serious: 0 }, threshold: { critical: 0, serious: 0 },
automatedStatus: "passed", automatedStatus: "passed",
manualReview: "see artifacts/tests/a11y-manual/APP_HOME.md", manualReview: "see artifacts/tests/a11y-manual/report.json",
}, },
null, null,
2, 2,
@@ -4,6 +4,9 @@
* *
* @param {{ * @param {{
* readState(): import("../../application/ports/auth-session-port.js").SessionState, * readState(): import("../../application/ports/auth-session-port.js").SessionState,
* subscribe(listener: () => void): () => void,
* beginSignIn(returnTo?: string): Promise<void>,
* signOut(): Promise<void>,
* attachCredential(request: Request): Promise<Request>, * attachCredential(request: Request): Promise<Request>,
* recoverSession(): Promise<"restored" | "no-session">, * recoverSession(): Promise<"restored" | "no-session">,
* notifyUnauthenticated(): void * notifyUnauthenticated(): void
@@ -15,6 +18,16 @@ export function createExternalAuthSessionAdapter(owner) {
getState() { getState() {
return owner.readState(); return owner.readState();
}, },
subscribe(listener) {
return owner.subscribe(listener);
},
async beginSignIn(returnTo) {
await owner.beginSignIn(returnTo);
},
async signOut() {
await owner.signOut();
},
/** @param {Request} request */
async attach(request) { async attach(request) {
const attached = await owner.attachCredential(request); const attached = await owner.attachCredential(request);
if (!(attached instanceof Request)) { if (!(attached instanceof Request)) {
@@ -38,8 +51,74 @@ export function createExternalAuthSessionAdapter(owner) {
export function createAnonymousSessionAdapter() { export function createAnonymousSessionAdapter() {
return createExternalAuthSessionAdapter({ return createExternalAuthSessionAdapter({
readState: () => "unauthenticated", readState: () => "unauthenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => request, attachCredential: async (request) => request,
recoverSession: async () => "no-session", recoverSession: async () => "no-session",
notifyUnauthenticated: () => {}, notifyUnauthenticated: () => {},
}); });
} }
/**
* Local/test-only session seam. It never creates or stores credentials.
*
* @param {import("../../application/ports/auth-session-port.js").SessionState} [initialState]
*/
export function createDemoSessionAdapter(initialState = "unauthenticated") {
let state = initialState;
const listeners = new Set();
function notify() {
for (const listener of listeners) listener();
}
/** @param {import("../../application/ports/auth-session-port.js").SessionState} next */
function setState(next) {
state = next;
notify();
}
return Object.freeze({
getState: () => state,
/** @param {() => void} listener */
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async beginSignIn() {
setState("authenticated");
},
async signOut() {
setState("unauthenticated");
},
/** @param {Request} request */
async attach(request) {
return request;
},
async recover() {
if (state === "recovery-pending") {
setState("authenticated");
return /** @type {const} */ ("restored");
}
return /** @type {const} */ ("no-session");
},
onUnauthenticated() {
setState("unauthenticated");
},
setState,
});
}
export function createUnavailableSessionAdapter() {
return Object.freeze({
getState: () => /** @type {const} */ ("integration-failed"),
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
/** @param {Request} request */
attach: async (request) => request,
recover: async () => /** @type {const} */ ("no-session"),
onUnauthenticated: () => {},
});
}
+11 -5
View File
@@ -2,7 +2,7 @@
* Application facade factory. Concrete dependencies are supplied by bootstrap. * Application facade factory. Concrete dependencies are supplied by bootstrap.
* *
* @param {{ * @param {{
* resources: { * resources?: {
* query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>, * query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>,
* command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown> * command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown>
* }, * },
@@ -17,7 +17,9 @@ export function createApplication(ports) {
* @param {import("./ports/resource-ports.js").RequestContext} [context] * @param {import("./ports/resource-ports.js").RequestContext} [context]
*/ */
function queryResources(query, context) { function queryResources(query, context) {
return ports.resources.query.execute(query, context); return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).query.execute(query, context);
} }
/** /**
@@ -25,14 +27,18 @@ export function createApplication(ports) {
* @param {import("./ports/resource-ports.js").RequestContext} [context] * @param {import("./ports/resource-ports.js").RequestContext} [context]
*/ */
function commandResources(command, context) { function commandResources(command, context) {
return ports.resources.command.execute(command, context); return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).command.execute(command, context);
} }
return Object.freeze({ return Object.freeze({
resources: Object.freeze({ resources: ports.resources
? Object.freeze({
query: queryResources, query: queryResources,
command: commandResources, command: commandResources,
}), })
: null,
cache: ports.cache, cache: ports.cache,
storage: ports.storage, storage: ports.storage,
telemetry: ports.telemetry, telemetry: ports.telemetry,
@@ -7,6 +7,9 @@
* *
* @typedef {{ * @typedef {{
* getState(): SessionState, * getState(): SessionState,
* subscribe(listener: () => void): () => void,
* beginSignIn(returnTo?: string): Promise<void>,
* signOut(): Promise<void>,
* attach(request: Request): Promise<Request>, * attach(request: Request): Promise<Request>,
* recover(): Promise<"restored" | "no-session">, * recover(): Promise<"restored" | "no-session">,
* onUnauthenticated(): void * onUnauthenticated(): void
+14 -5
View File
@@ -4,14 +4,23 @@ import { createApplication } from "../application/create-application.js";
* This is the only module allowed to join concrete adapters to application * This is the only module allowed to join concrete adapters to application
* ports. Boot phases are explicit so failures can stop before product mount. * ports. Boot phases are explicit so failures can stop before product mount.
* *
* @template Config
* @template Release
* @template {Parameters<typeof createApplication>[0]} Ports
* @param {{ * @param {{
* loadConfig(): Promise<Record<string, unknown>>, * loadConfig(): Promise<Config>,
* loadRelease(config: Record<string, unknown>): Promise<Record<string, unknown>>, * loadRelease(config: Config): Promise<Release>,
* createAdapters(context: { * createAdapters(context: {
* config: Record<string, unknown>, * config: Config,
* release: Record<string, unknown> * release: Release
* }): Promise<Parameters<typeof createApplication>[0]> * }): Promise<Ports>
* }} factories * }} factories
* @returns {Promise<Readonly<{
* config: Config,
* release: Release,
* ports: Ports,
* application: ReturnType<typeof createApplication>
* }>>}
*/ */
export async function createCompositionRoot(factories) { export async function createCompositionRoot(factories) {
const config = await factories.loadConfig(); const config = await factories.loadConfig();
@@ -0,0 +1,32 @@
import { createCompositionRoot } from "./composition-root.js";
import { loadReleaseManifest } from "./load-release-manifest.js";
import { loadRuntimeConfig } from "./load-runtime-config.js";
import { createRuntimeAdapters } from "./runtime-adapters.js";
/**
* @param {{
* fetcher?: typeof fetch,
* host?: Record<string, unknown>
* }} [dependencies]
*/
export function createRuntimeComposition(dependencies = {}) {
return createCompositionRoot({
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
loadRelease: (runtime) =>
loadReleaseManifest(
/** @type {Awaited<ReturnType<typeof loadRuntimeConfig>>} */ (runtime),
{ fetcher: dependencies.fetcher },
),
createAdapters: ({ config: runtime, release }) =>
createRuntimeAdapters({
runtime:
/** @type {Awaited<ReturnType<typeof loadRuntimeConfig>>} */ (runtime),
release:
/** @type {Awaited<ReturnType<typeof loadReleaseManifest>>} */ (
release
),
fetcher: dependencies.fetcher,
host: dependencies.host,
}),
});
}
+107
View File
@@ -0,0 +1,107 @@
import { z } from "zod";
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
const releaseManifestSchema = z
.object({
schemaVersion: z.literal(1),
appVersion: z.string().min(1),
buildId: z.string().min(1),
commitSha: z.string().min(1),
configSchemaVersion: version,
apiContractVersion: version,
assetManifestHash: z.string().min(1),
releaseId: z.string().min(1),
builtAt: z.string().min(1),
})
.strict();
export class ReleaseManifestError extends Error {
/** @param {string} code @param {{buildId: string, releaseId?: string}} safe */
constructor(code, safe) {
super("Release manifest could not be loaded");
this.name = "ReleaseManifestError";
this.kind = "RELEASE_MANIFEST_FAILURE";
this.code = code;
this.safe = Object.freeze({
kind: this.kind,
code,
buildId: safe.buildId,
releaseId: safe.releaseId,
supportReference: `${safe.buildId}:${code}`,
});
}
}
/**
* @param {Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>} runtime
* @param {{fetcher?: typeof fetch}} [options]
*/
export async function loadReleaseManifest(runtime, options = {}) {
const fetcher = options.fetcher ?? fetch;
let response;
try {
response = await fetcher(runtime.config.RELEASE_MANIFEST_URL, {
cache: "no-store",
headers: { Accept: "application/json" },
});
} catch {
throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
if (!response.ok) {
throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
let raw;
try {
raw = await response.json();
} catch {
throw new ReleaseManifestError("MANIFEST_JSON_INVALID", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
const parsed = releaseManifestSchema.safeParse(raw);
if (!parsed.success) {
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
const manifest = parsed.data;
const mismatches = [];
if (manifest.buildId !== runtime.build.buildId) mismatches.push("buildId");
if (
runtime.config.BUILD_ID &&
manifest.buildId !== runtime.config.BUILD_ID
) {
mismatches.push("runtimeBuildId");
}
if (
manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION
) {
mismatches.push("configSchemaVersion");
}
if (manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION) {
mismatches.push("apiContractVersion");
}
if (
runtime.config.RELEASE_ID &&
manifest.releaseId !== runtime.config.RELEASE_ID
) {
mismatches.push("releaseId");
}
if (mismatches.length > 0) {
throw new ReleaseManifestError("MANIFEST_RUNTIME_MISMATCH", {
buildId: runtime.build.buildId,
releaseId: runtime.config.RELEASE_ID,
});
}
return Object.freeze(structuredClone(manifest));
}
+12 -6
View File
@@ -1,10 +1,12 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { createAnonymousSessionAdapter } from "../adapters/auth/external-session-adapter.js";
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
import { AppRouter } from "../presentation/routes/app-router.jsx"; import { AppRouter } from "../presentation/routes/app-router.jsx";
import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js"; import { createRuntimeComposition } from "./create-runtime-composition.js";
import { BootConfigError } from "./load-runtime-config.js";
import { ReleaseManifestError } from "./load-release-manifest.js";
import "../presentation/styles/theme.css"; import "../presentation/styles/theme.css";
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
@@ -17,18 +19,22 @@ const root = createRoot(rootElement);
async function boot() { async function boot() {
try { try {
const runtime = await loadRuntimeConfig(); const composition = await createRuntimeComposition();
root.render( root.render(
<StrictMode> <StrictMode>
<QueryClientProvider client={composition.ports.queryClient}>
<AppRouter <AppRouter
authSession={createAnonymousSessionAdapter()} authSession={composition.ports.authSession}
basename={runtime.build.routerBasePath} basename={composition.config.build.routerBasePath}
buildId={composition.release.buildId}
telemetry={composition.ports.telemetry}
/> />
</QueryClientProvider>
</StrictMode>, </StrictMode>,
); );
} catch (error) { } catch (error) {
const safe = const safe =
error instanceof BootConfigError error instanceof BootConfigError || error instanceof ReleaseManifestError
? error.safe ? error.safe
: { supportReference: "boot:unknown" }; : { supportReference: "boot:unknown" };
+94
View File
@@ -0,0 +1,94 @@
import {
createDemoSessionAdapter,
createExternalAuthSessionAdapter,
createUnavailableSessionAdapter,
} from "../adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../adapters/http/client.js";
import {
createQueryCacheAdapter,
createQueryClient,
} from "../adapters/query-cache/tanstack-query-cache.js";
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js";
/**
* @param {Record<string, unknown>} host
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[0] | null}
*/
function externalOwnerFrom(host) {
const candidate = host.__CA_FRONTEND_AUTH_OWNER__;
if (!candidate || typeof candidate !== "object") return null;
const owner = /** @type {Record<string, unknown>} */ (candidate);
const required = [
"readState",
"subscribe",
"beginSignIn",
"signOut",
"attachCredential",
"recoverSession",
"notifyUnauthenticated",
];
return required.every((name) => typeof owner[name] === "function")
? /** @type {Parameters<typeof createExternalAuthSessionAdapter>[0]} */ (
candidate
)
: null;
}
/** @param {unknown} value */
function storageOrUndefined(value) {
return typeof Storage !== "undefined" && value instanceof Storage
? value
: undefined;
}
/**
* @param {{
* runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>,
* release: Awaited<ReturnType<typeof import("./load-release-manifest.js").loadReleaseManifest>>,
* host?: Record<string, unknown>,
* fetcher?: typeof fetch
* }} context
*/
export async function createRuntimeAdapters(context) {
const host = context.host ?? /** @type {Record<string, unknown>} */ (globalThis);
const config = context.runtime.config;
const externalOwner = externalOwnerFrom(host);
const authSession =
config.AUTH_MODE === "demo"
? createDemoSessionAdapter()
: externalOwner
? createExternalAuthSessionAdapter(externalOwner)
: createUnavailableSessionAdapter();
const queryClient = createQueryClient();
const cache = createQueryCacheAdapter(queryClient);
const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage),
});
const telemetry = createTelemetryAdapter({
enabled: config.TELEMETRY_ENABLED,
endpoint: config.TELEMETRY_ENDPOINT,
fetcher: context.fetcher,
});
const http = createHttpClient({
baseUrl: config.API_BASE_URL,
authSession,
fetcher: context.fetcher,
});
const releaseInfo = Object.freeze({
async getCurrent() {
return structuredClone(context.release);
},
});
return Object.freeze({
authSession,
cache,
http,
queryClient,
releaseInfo,
storage,
telemetry,
});
}
+8 -1
View File
@@ -10,7 +10,7 @@ export const runtimeConfigSchema = z
MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2), MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2),
TELEMETRY_ENABLED: z.boolean(), TELEMETRY_ENABLED: z.boolean(),
TELEMETRY_ENDPOINT: z.url().optional(), TELEMETRY_ENDPOINT: z.url().optional(),
AUTH_MODE: z.literal("external"), AUTH_MODE: z.enum(["external", "demo"]),
CONFIG_SCHEMA_VERSION: version, CONFIG_SCHEMA_VERSION: version,
API_CONTRACT_VERSION: version, API_CONTRACT_VERSION: version,
RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"), RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"),
@@ -28,6 +28,13 @@ export const runtimeConfigSchema = z
} }
const local = config.APP_ENV === "local" || config.APP_ENV === "development"; const local = config.APP_ENV === "local" || config.APP_ENV === "development";
if (!local && config.AUTH_MODE === "demo") {
context.addIssue({
code: "custom",
path: ["AUTH_MODE"],
message: "demo authentication is limited to local environments",
});
}
const endpointEntries = const endpointEntries =
/** @type {Array<[string, string | undefined]>} */ ([ /** @type {Array<[string, string | undefined]>} */ ([
["API_BASE_URL", config.API_BASE_URL], ["API_BASE_URL", config.API_BASE_URL],
+62 -1
View File
@@ -7,7 +7,10 @@
* access: "public" | "session-required" | "integration-defined", * access: "public" | "session-required" | "integration-defined",
* loadingSurface: string, * loadingSurface: string,
* errorSurface: string, * errorSurface: string,
* chunkId: string * chunkId: string,
* title: string,
* navigationLabel: string | null,
* navigationOrder: number | null
* }} RouteDefinition * }} RouteDefinition
*/ */
@@ -24,6 +27,48 @@ export const ROUTE_REGISTRY = Object.freeze({
loadingSurface: "app-shell", loadingSurface: "app-shell",
errorSurface: "route-boundary", errorSurface: "route-boundary",
chunkId: "route-home", chunkId: "route-home",
title: "시작",
navigationLabel: "시작",
navigationOrder: 10,
}),
EXAMPLES_UI: route({
routeId: "EXAMPLES_UI",
path: "/examples/ui",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "example-page",
errorSurface: "route-boundary",
chunkId: "route-examples-ui",
title: "UI 구성요소",
navigationLabel: "UI 구성요소",
navigationOrder: 20,
}),
EXAMPLES_STATES: route({
routeId: "EXAMPLES_STATES",
path: "/examples/states",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "example-page",
errorSurface: "route-boundary",
chunkId: "route-examples-states",
title: "화면 상태",
navigationLabel: "화면 상태",
navigationOrder: 30,
}),
EXAMPLES_AUTH: route({
routeId: "EXAMPLES_AUTH",
path: "/examples/auth",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "example-page",
errorSurface: "route-boundary",
chunkId: "route-examples-auth",
title: "인증 연동",
navigationLabel: "인증 연동",
navigationOrder: 40,
}), }),
SAMPLE_RESOURCE_LIST: route({ SAMPLE_RESOURCE_LIST: route({
routeId: "SAMPLE_RESOURCE_LIST", routeId: "SAMPLE_RESOURCE_LIST",
@@ -34,6 +79,9 @@ export const ROUTE_REGISTRY = Object.freeze({
loadingSurface: "sample-resource-list", loadingSurface: "sample-resource-list",
errorSurface: "feature-boundary", errorSurface: "feature-boundary",
chunkId: "route-sample-resources", chunkId: "route-sample-resources",
title: "보호된 연동 지점",
navigationLabel: "보호된 연동 지점",
navigationOrder: 50,
}), }),
NOT_FOUND: route({ NOT_FOUND: route({
routeId: "NOT_FOUND", routeId: "NOT_FOUND",
@@ -44,9 +92,22 @@ export const ROUTE_REGISTRY = Object.freeze({
loadingSurface: "none", loadingSurface: "none",
errorSurface: "not-found", errorSurface: "not-found",
chunkId: "route-not-found", chunkId: "route-not-found",
title: "페이지를 찾을 수 없음",
navigationLabel: null,
navigationOrder: null,
}), }),
}); });
export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null)
.sort(
(left, right) =>
/** @type {number} */ (left.navigationOrder) -
/** @type {number} */ (right.navigationOrder),
),
);
/** @param {string} routeId */ /** @param {string} routeId */
export function getRoute(routeId) { export function getRoute(routeId) {
const registry = /** @type {Record<string, Readonly<RouteDefinition>>} */ ( const registry = /** @type {Record<string, Readonly<RouteDefinition>>} */ (
@@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
/**
* @param {{
* title: string,
* description?: string,
* eyebrow?: string
* }} props
*/
export function PageHeader({ title, description, eyebrow }) {
const headingRef = useRef(/** @type {HTMLHeadingElement | null} */ (null));
useEffect(() => {
document.title = `${title} · Frontend Skeleton`;
headingRef.current?.focus();
}, [title]);
return (
<header className="page-header">
{eyebrow ? <p className="page-header__eyebrow">{eyebrow}</p> : null}
<h1 ref={headingRef} tabIndex={-1} data-route-heading>
{title}
</h1>
{description ? <p className="page-header__description">{description}</p> : null}
</header>
);
}
@@ -0,0 +1,80 @@
import { useState } from "react";
import { useLocation } from "react-router-dom";
import { PageHeader } from "../components/page-header.jsx";
import { useSession } from "../providers/session-provider.jsx";
export default function AuthExamplePage() {
const location = useLocation();
const { sessionState, beginSignIn, signOut, recover } = useSession();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
/** @param {() => Promise<unknown>} action */
async function execute(action) {
setPending(true);
setFailed(false);
try {
await action();
} catch {
setFailed(true);
} finally {
setPending(false);
}
}
return (
<section className="ui-page">
<PageHeader
eyebrow="예제"
title="인증 연동"
description="스켈레톤은 자격 증명을 소유하지 않고 외부 인증 구현이 연결될 포트와 화면 상태만 제공합니다."
/>
<section className="ui-panel auth-example" aria-labelledby="auth-state-title">
<div>
<h2 id="auth-state-title">현재 세션 상태</h2>
<output className="session-status" data-state={sessionState}>
{sessionState}
</output>
</div>
<div className="button-row">
<button
className="ui-button"
type="button"
disabled={pending || sessionState === "integration-failed"}
onClick={() =>
void execute(() =>
beginSignIn(`${location.pathname}${location.search}`),
)
}
>
로그인 시작
</button>
<button
className="ui-button ui-button--secondary"
type="button"
disabled={pending || sessionState !== "authenticated"}
onClick={() => void execute(signOut)}
>
로그아웃
</button>
<button
className="ui-button ui-button--secondary"
type="button"
disabled={pending || sessionState !== "recovery-pending"}
onClick={() => void execute(recover)}
>
세션 복구
</button>
</div>
{sessionState === "integration-failed" ? (
<p role="status">
외부 인증 소유자가 연결되지 않았습니다. 런타임 호스트의 인증
계약을 연결하세요.
</p>
) : null}
{failed ? <p role="alert">인증 작업을 완료하지 못했습니다.</p> : null}
</section>
</section>
);
}
@@ -0,0 +1,24 @@
import { PageHeader } from "../components/page-header.jsx";
export default function StateGalleryPage() {
return (
<section className="ui-page">
<PageHeader
eyebrow="예제"
title="화면 상태"
description="로딩, 빈 화면, 오류, 인증 필요와 권한 없음 상태의 기본 표현을 확인합니다."
/>
<div className="readiness-grid">
<section className="ui-skeleton" aria-label="로딩 상태 예제" />
<section className="ui-empty">
<h2>표시할 항목이 없습니다.</h2>
<p>다음 행동이 있다면 위치에 명확한 안내를 제공합니다.</p>
</section>
<section className="ui-terminal-error" role="alert">
<h2>요청을 완료하지 못했습니다.</h2>
<p>안전한 재시도 또는 지원 참조 정보를 제공합니다.</p>
</section>
</div>
</section>
);
}
@@ -0,0 +1,20 @@
import { PageHeader } from "../components/page-header.jsx";
export default function UiGalleryPage() {
return (
<section className="ui-page">
<PageHeader
eyebrow="예제"
title="UI 구성요소"
description="제품 도메인과 독립적인 공통 컨트롤과 표면을 확인하는 공간입니다."
/>
<section className="ui-panel" aria-labelledby="ui-gallery-status">
<h2 id="ui-gallery-status">구성요소 계약</h2>
<p>
버튼, 입력창, 카드, 알림, 모달의 상호작용과 디자인 토큰을
라우트에 조립합니다.
</p>
</section>
</section>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { NAVIGATION_ROUTES, routePath } from "../../contracts/routes.js";
import { useSession } from "../providers/session-provider.jsx";
const SESSION_LABELS = Object.freeze({
authenticated: "인증됨",
unauthenticated: "로그인 전",
"recovery-pending": "복구 대기",
"integration-failed": "연동 필요",
});
export function AppShell() {
const location = useLocation();
const { sessionState, beginSignIn, signOut, recover } = useSession();
const [navigationOpen, setNavigationOpen] = useState(false);
const [sessionActionPending, setSessionActionPending] = useState(false);
const [sessionActionFailed, setSessionActionFailed] = useState(false);
useEffect(() => {
setNavigationOpen(false);
}, [location.pathname]);
useEffect(() => {
if (!navigationOpen) return undefined;
/** @param {KeyboardEvent} event */
const closeOnEscape = (event) => {
if (event.key === "Escape") setNavigationOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [navigationOpen]);
async function runSessionAction() {
setSessionActionPending(true);
setSessionActionFailed(false);
try {
if (sessionState === "authenticated") {
await signOut();
} else if (sessionState === "recovery-pending") {
await recover();
} else {
await beginSignIn(
`${location.pathname}${location.search}${location.hash}`,
);
}
} catch {
setSessionActionFailed(true);
} finally {
setSessionActionPending(false);
}
}
const sessionActionLabel =
sessionState === "authenticated"
? "로그아웃"
: sessionState === "recovery-pending"
? "세션 복구"
: "로그인";
const integrationAvailable = sessionState !== "integration-failed";
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">
본문으로 건너뛰기
</a>
<header className="app-shell__header">
<button
className="app-shell__menu-button"
type="button"
aria-controls="primary-navigation"
aria-expanded={navigationOpen}
onClick={() => setNavigationOpen((open) => !open)}
>
<span aria-hidden="true"></span>
<span>메뉴</span>
</button>
<NavLink className="app-shell__brand" to={routePath("APP_HOME")}>
Frontend Skeleton
</NavLink>
<div className="app-shell__session">
<span className="session-status" data-state={sessionState}>
{SESSION_LABELS[sessionState]}
</span>
{integrationAvailable ? (
<button
className="ui-button ui-button--compact"
type="button"
disabled={sessionActionPending}
onClick={() => void runSessionAction()}
>
{sessionActionPending ? "처리 중…" : sessionActionLabel}
</button>
) : null}
</div>
{sessionActionFailed ? (
<p className="app-shell__session-error" role="alert">
세션 작업을 완료하지 못했습니다.
</p>
) : null}
</header>
<aside
className="app-shell__sidebar"
data-open={navigationOpen}
aria-label="사이드바"
>
<nav id="primary-navigation" aria-label="주요 탐색">
<ul className="app-navigation">
{NAVIGATION_ROUTES.map((definition) => (
<li key={definition.routeId}>
<NavLink
className={({ isActive }) =>
`app-navigation__link${isActive ? " is-active" : ""}`
}
end={definition.path === "/"}
to={definition.path}
>
{definition.navigationLabel}
</NavLink>
</li>
))}
</ul>
</nav>
</aside>
{navigationOpen ? (
<button
className="app-shell__scrim"
type="button"
aria-label="메뉴 닫기"
onClick={() => setNavigationOpen(false)}
/>
) : null}
<main className="app-shell__content" id="main-content" tabIndex={-1}>
<Outlet />
</main>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { PageHeader } from "../components/page-header.jsx";
const READINESS_ITEMS = Object.freeze([
{
title: "실행 계약",
description: "런타임 설정, 릴리스 정합성, 오류 경계가 마운트 전에 검증됩니다.",
},
{
title: "교체 가능한 연동",
description: "인증, HTTP, 캐시, 저장소, 텔레메트리가 포트 뒤에 분리되어 있습니다.",
},
{
title: "접근 가능한 화면",
description: "키보드 탐색, 포커스 이동, 반응형 앱 셸의 기본 동작이 준비되어 있습니다.",
},
]);
export default function HomePage() {
return (
<section className="ui-page">
<PageHeader
eyebrow="프로젝트 시작점"
title="Clean Architecture Frontend"
description="도메인을 추가하기 전에 실행 구조와 범용 사용자 경험을 확인할 수 있는 중립적인 스켈레톤입니다."
/>
<div className="readiness-grid" aria-label="구현 준비 상태">
{READINESS_ITEMS.map((item) => (
<article className="ui-panel" key={item.title}>
<h2>{item.title}</h2>
<p>{item.description}</p>
</article>
))}
</div>
<section className="ui-panel starter-actions" aria-labelledby="starter-title">
<div>
<h2 id="starter-title">준비된 화면 살펴보기</h2>
<p>공통 구성요소와 비동기 화면 상태를 예제 라우트에서 확인하세요.</p>
</div>
<div className="button-row">
<Link className="ui-button" to={routePath("EXAMPLES_UI")}>
UI 구성요소 보기
</Link>
<Link
className="ui-button ui-button--secondary"
to={routePath("EXAMPLES_STATES")}
>
화면 상태 보기
</Link>
</div>
</section>
</section>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { PageHeader } from "../components/page-header.jsx";
export default function NotFoundPage() {
return (
<section className="ui-page">
<PageHeader
title="페이지를 찾을 수 없습니다."
description="주소를 확인하거나 준비된 시작 화면으로 돌아가세요."
/>
<div>
<Link className="ui-button" to={routePath("APP_HOME")}>
홈으로 이동
</Link>
</div>
</section>
);
}
@@ -0,0 +1,23 @@
import { PageHeader } from "../components/page-header.jsx";
import { useSession } from "../providers/session-provider.jsx";
export default function SampleContractPage() {
const { sessionState } = useSession();
return (
<section className="ui-page">
<PageHeader
eyebrow="보호 라우트"
title="보호된 연동 지점"
description="실제 도메인 기능이 인증된 세션과 연결되는 위치를 보여주는 중립적인 계약 화면입니다."
/>
<section className="ui-panel" aria-labelledby="protected-state-title">
<h2 id="protected-state-title">라우트 접근 허용</h2>
<p>
현재 세션 상태는 <strong>{sessionState}</strong>입니다. 서버의
권한 검증은 클라이언트 라우트 정책과 별도로 유지해야 합니다.
</p>
</section>
</section>
);
}
@@ -0,0 +1,50 @@
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
/**
* @typedef {{
* sessionState: import("../../application/ports/auth-session-port.js").SessionState,
* beginSignIn: import("../../application/ports/auth-session-port.js").AuthSessionPort["beginSignIn"],
* signOut: import("../../application/ports/auth-session-port.js").AuthSessionPort["signOut"],
* recover: import("../../application/ports/auth-session-port.js").AuthSessionPort["recover"]
* }} SessionContextValue
*/
const SessionContext = createContext(
/** @type {SessionContextValue | null} */ (null),
);
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* children: React.ReactNode
* }} props
*/
export function SessionProvider({ authSession, children }) {
const sessionState = useSyncExternalStore(
authSession.subscribe,
authSession.getState,
authSession.getState,
);
const value = useMemo(
() =>
Object.freeze({
sessionState,
beginSignIn: authSession.beginSignIn,
signOut: authSession.signOut,
recover: authSession.recover,
}),
[authSession, sessionState],
);
return (
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
);
}
export function useSession() {
const session = useContext(SessionContext);
if (!session) {
throw new Error("SessionProvider is required");
}
return session;
}
+227 -41
View File
@@ -1,81 +1,267 @@
import { lazy, Suspense, useState } from "react";
import { import {
BrowserRouter, BrowserRouter,
Link,
Route, Route,
Routes, Routes,
useLocation,
} from "react-router-dom"; } from "react-router-dom";
import { routePath } from "../../contracts/routes.js"; import { getRoute, routePath } from "../../contracts/routes.js";
import { RouteBoundary } from "../boundaries/render-error-boundary.jsx";
import { AppShell } from "../layouts/app-shell.jsx";
import { PageHeader } from "../components/page-header.jsx";
import { SessionProvider, useSession } from "../providers/session-provider.jsx";
import { decideRouteAccess } from "./navigation-policy.js"; import { decideRouteAccess } from "./navigation-policy.js";
function HomePage() { const HomePage = lazy(() => import("../pages/home-page.jsx"));
const UiGalleryPage = lazy(() => import("../examples/ui-gallery-page.jsx"));
const StateGalleryPage = lazy(
() => import("../examples/state-gallery-page.jsx"),
);
const AuthExamplePage = lazy(
() => import("../examples/auth-example-page.jsx"),
);
const SampleContractPage = lazy(
() => import("../pages/sample-contract-page.jsx"),
);
const NotFoundPage = lazy(() => import("../pages/not-found-page.jsx"));
/** @param {{ routeId: string }} props */
function RouteLoadingSurface({ routeId }) {
const definition = getRoute(routeId);
return ( return (
<main className="ui-page"> <section className="ui-page route-loading" aria-live="polite" aria-busy="true">
<h1>Clean Architecture Frontend</h1> <div className="ui-skeleton" aria-hidden="true" />
<p>런타임 계약이 검증되었습니다.</p> <p>화면을 준비하고 습니다.</p>
<Link to={routePath("SAMPLE_RESOURCE_LIST")}>샘플 리소스</Link> <span className="visually-hidden">{definition.title} 로딩 </span>
</main> </section>
); );
} }
function SamplePlaceholder() { function RouteFailureSurface() {
return ( return (
<main className="ui-page"> <section className="ui-page">
<h1>샘플 리소스</h1> <PageHeader
<p>계약 fixture를 준비하고 있습니다.</p> title="화면을 표시하지 못했습니다."
</main> description="잠시 후 페이지를 새로고침해 주세요. 문제가 계속되면 운영 지원 참조 정보를 확인하세요."
); />
} </section>
function NotFoundPage() {
return (
<main className="ui-page">
<h1>페이지를 찾을 없습니다.</h1>
<Link to={routePath("APP_HOME")}>홈으로 이동</Link>
</main>
); );
} }
/** /**
* @param {{ * @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort * routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* children: React.ReactNode
* }} props * }} props
*/ */
function GuardedSampleRoute({ authSession }) { function RouteSurface({ routeId, buildId, telemetry, children }) {
const decision = decideRouteAccess(
"SAMPLE_RESOURCE_LIST",
authSession.getState(),
);
if (!decision.allowed) {
return ( return (
<main className="ui-page"> <RouteBoundary
<h1>세션이 필요합니다.</h1> routeId={routeId}
<button className="ui-button" type="button"> buildId={buildId}
로그인 telemetry={telemetry}
</button> fallback={<RouteFailureSurface />}
</main> >
<Suspense fallback={<RouteLoadingSurface routeId={routeId} />}>
{children}
</Suspense>
</RouteBoundary>
);
}
/**
* @param {{
* routeId: string,
* children: React.ReactNode
* }} props
*/
function ProtectedRoute({ routeId, children }) {
const location = useLocation();
const { sessionState, beginSignIn, recover } = useSession();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const decision = decideRouteAccess(routeId, sessionState);
async function continueSession() {
setPending(true);
setFailed(false);
try {
if (decision.action === "wait-for-session") {
await recover();
} else {
await beginSignIn(
`${location.pathname}${location.search}${location.hash}`,
); );
} }
return <SamplePlaceholder />; } catch {
setFailed(true);
} finally {
setPending(false);
}
}
if (decision.allowed) return children;
if (sessionState === "integration-failed") {
return (
<section className="ui-page">
<PageHeader
title="로그인 연동이 필요합니다."
description="외부 인증 소유자가 런타임에 연결되면 이 보호 라우트를 사용할 수 있습니다."
/>
</section>
);
}
const recovering = decision.action === "wait-for-session";
return (
<section className="ui-page">
<PageHeader
title={recovering ? "세션을 복구하고 있습니다." : "세션이 필요합니다."}
description={
recovering
? "기존 세션 확인을 계속하려면 복구를 실행하세요."
: "이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다."
}
/>
<div>
<button
className="ui-button"
type="button"
disabled={pending}
onClick={() => void continueSession()}
>
{pending
? "처리 중…"
: recovering
? "세션 복구"
: "로그인 시작"}
</button>
</div>
{failed ? (
<p className="ui-terminal-error" role="alert">
세션 작업을 완료하지 못했습니다.
</p>
) : null}
</section>
);
}
/**
* @param {{
* routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* children: React.ReactNode
* }} props
*/
function PublicRoute({ routeId, buildId, telemetry, children }) {
return (
<RouteSurface routeId={routeId} buildId={buildId} telemetry={telemetry}>
{children}
</RouteSurface>
);
} }
/** /**
* @param {{ * @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort, * authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* basename?: string * basename?: string,
* buildId?: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort
* }} props * }} props
*/ */
export function AppRouter({ authSession, basename = "/" }) { export function AppRouter({
authSession,
basename = "/",
buildId = "local-build",
telemetry,
}) {
return ( return (
<BrowserRouter basename={basename}> <BrowserRouter basename={basename}>
<SessionProvider authSession={authSession}>
<Routes> <Routes>
<Route path={routePath("APP_HOME")} element={<HomePage />} /> <Route element={<AppShell />}>
<Route
index
element={
<PublicRoute
routeId="APP_HOME"
buildId={buildId}
telemetry={telemetry}
>
<HomePage />
</PublicRoute>
}
/>
<Route
path={routePath("EXAMPLES_UI")}
element={
<PublicRoute
routeId="EXAMPLES_UI"
buildId={buildId}
telemetry={telemetry}
>
<UiGalleryPage />
</PublicRoute>
}
/>
<Route
path={routePath("EXAMPLES_STATES")}
element={
<PublicRoute
routeId="EXAMPLES_STATES"
buildId={buildId}
telemetry={telemetry}
>
<StateGalleryPage />
</PublicRoute>
}
/>
<Route
path={routePath("EXAMPLES_AUTH")}
element={
<PublicRoute
routeId="EXAMPLES_AUTH"
buildId={buildId}
telemetry={telemetry}
>
<AuthExamplePage />
</PublicRoute>
}
/>
<Route <Route
path={routePath("SAMPLE_RESOURCE_LIST")} path={routePath("SAMPLE_RESOURCE_LIST")}
element={<GuardedSampleRoute authSession={authSession} />} element={
<RouteSurface
routeId="SAMPLE_RESOURCE_LIST"
buildId={buildId}
telemetry={telemetry}
>
<ProtectedRoute routeId="SAMPLE_RESOURCE_LIST">
<SampleContractPage />
</ProtectedRoute>
</RouteSurface>
}
/> />
<Route path={routePath("NOT_FOUND")} element={<NotFoundPage />} /> <Route
path={routePath("NOT_FOUND")}
element={
<PublicRoute
routeId="NOT_FOUND"
buildId={buildId}
telemetry={telemetry}
>
<NotFoundPage />
</PublicRoute>
}
/>
</Route>
</Routes> </Routes>
</SessionProvider>
</BrowserRouter> </BrowserRouter>
); );
} }
+325 -1
View File
@@ -24,6 +24,35 @@
body { body {
margin: 0; margin: 0;
min-width: 20rem;
min-height: 100vh;
}
button,
input,
textarea,
select {
font: inherit;
}
a {
color: inherit;
}
h1,
h2,
p {
margin-block-start: 0;
}
h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1.05;
letter-spacing: -0.04em;
}
h2 {
line-height: 1.25;
} }
:focus-visible { :focus-visible {
@@ -33,8 +62,165 @@
} }
@layer components { @layer components {
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: fixed;
z-index: 100;
top: 0.75rem;
left: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-control);
color: white;
background: var(--color-content);
transform: translateY(-200%);
}
.skip-link:focus {
transform: translateY(0);
}
.app-shell {
display: grid;
min-height: 100vh;
grid-template-rows: 4.5rem 1fr;
grid-template-columns: 15rem minmax(0, 1fr);
grid-template-areas:
"header header"
"sidebar content";
}
.app-shell__header {
position: sticky;
z-index: 30;
top: 0;
grid-area: header;
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--color-surface-muted);
background: color-mix(in oklch, white 92%, var(--color-surface));
box-shadow: 0 1px 8px color-mix(in oklch, var(--color-content) 8%, transparent);
}
.app-shell__brand {
overflow: hidden;
font-size: 1.05rem;
font-weight: 800;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-shell__menu-button {
display: none;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--color-surface-muted);
border-radius: var(--radius-control);
padding: 0.55rem 0.75rem;
color: var(--color-content);
background: white;
cursor: pointer;
}
.app-shell__session {
display: flex;
align-items: center;
gap: 0.75rem;
margin-inline-start: auto;
}
.app-shell__session-error {
position: absolute;
top: calc(100% + 0.25rem);
right: 1rem;
padding: 0.5rem;
color: var(--color-danger);
background: white;
}
.session-status {
display: inline-flex;
width: fit-content;
align-items: center;
border-radius: 999px;
padding: 0.3rem 0.65rem;
color: var(--color-content-muted);
background: var(--color-surface-muted);
font-size: 0.8rem;
font-weight: 700;
}
.session-status[data-state="authenticated"] {
color: oklch(0.35 0.12 155);
background: oklch(0.93 0.05 155);
}
.session-status[data-state="integration-failed"] {
color: var(--color-danger);
}
.app-shell__sidebar {
position: sticky;
top: 4.5rem;
grid-area: sidebar;
height: calc(100vh - 4.5rem);
padding: 1.25rem 0.75rem;
border-right: 1px solid var(--color-surface-muted);
background: white;
}
.app-navigation {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.app-navigation__link {
display: block;
border-radius: var(--radius-control);
padding: 0.75rem 0.9rem;
color: var(--color-content-muted);
font-weight: 650;
text-decoration: none;
}
.app-navigation__link:hover {
color: var(--color-content);
background: var(--color-surface);
}
.app-navigation__link.is-active {
color: var(--color-action-hover);
background: color-mix(in oklch, var(--color-action) 10%, white);
font-weight: 800;
}
.app-shell__content {
grid-area: content;
min-width: 0;
}
.ui-page { .ui-page {
@apply mx-auto flex min-h-screen max-w-4xl flex-col gap-6 p-page; @apply mx-auto flex max-w-6xl flex-col gap-6 p-page;
width: 100%;
box-sizing: border-box;
} }
.ui-panel { .ui-panel {
@@ -43,12 +229,41 @@
.ui-button { .ui-button {
@apply rounded-control bg-action px-4 py-2 font-semibold text-white; @apply rounded-control bg-action px-4 py-2 font-semibold text-white;
display: inline-flex;
min-height: 2.75rem;
align-items: center;
justify-content: center;
border: 0;
text-decoration: none;
cursor: pointer;
} }
.ui-button:hover { .ui-button:hover {
@apply bg-action-hover; @apply bg-action-hover;
} }
.ui-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.ui-button--secondary {
border: 1px solid var(--color-surface-muted);
color: var(--color-content);
background: white;
}
.ui-button--secondary:hover {
color: var(--color-action);
background: var(--color-surface);
}
.ui-button--compact {
min-height: 2.25rem;
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
}
.ui-skeleton { .ui-skeleton {
@apply h-24 animate-pulse rounded-surface bg-surface-muted; @apply h-24 animate-pulse rounded-surface bg-surface-muted;
} }
@@ -57,6 +272,115 @@
.ui-terminal-error { .ui-terminal-error {
@apply rounded-surface border border-surface-muted p-6; @apply rounded-surface border border-surface-muted p-6;
} }
.page-header {
max-width: 50rem;
padding-block: clamp(1rem, 5vw, 3.5rem) 0.5rem;
}
.page-header__eyebrow {
margin-block-end: 0.75rem;
color: var(--color-action);
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-header__description {
max-width: 45rem;
color: var(--color-content-muted);
font-size: 1.05rem;
line-height: 1.7;
}
.readiness-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.readiness-grid p,
.starter-actions p {
margin-block-end: 0;
color: var(--color-content-muted);
line-height: 1.65;
}
.starter-actions,
.auth-example {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.route-loading {
padding-block-start: 4rem;
}
.app-shell__scrim {
display: none;
}
}
@media (max-width: 47.999rem) {
.app-shell {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"header"
"content";
}
.app-shell__header {
padding-inline: 0.75rem;
}
.app-shell__menu-button {
display: inline-flex;
}
.app-shell__sidebar {
position: fixed;
z-index: 50;
top: 4.5rem;
bottom: 0;
left: 0;
display: none;
width: min(18rem, 85vw);
height: auto;
box-sizing: border-box;
box-shadow: 10px 0 30px color-mix(in oklch, var(--color-content) 15%, transparent);
}
.app-shell__sidebar[data-open="true"] {
display: block;
}
.app-shell__scrim {
position: fixed;
z-index: 40;
inset: 4.5rem 0 0;
display: block;
border: 0;
background: color-mix(in oklch, var(--color-content) 35%, transparent);
cursor: pointer;
}
.app-shell__session .session-status {
display: none;
}
.readiness-grid {
grid-template-columns: minmax(0, 1fr);
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
+54 -6
View File
@@ -1,24 +1,72 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js"; import {
createAnonymousSessionAdapter,
createDemoSessionAdapter,
} from "../../src/adapters/auth/external-session-adapter.js";
import { AppRouter } from "../../src/presentation/routes/app-router.jsx"; import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
describe("application router", () => { describe("application router", () => {
it("renders not-found without making an API request", () => { it("renders the app shell and not-found route without an API request", async () => {
window.history.pushState({}, "", "/missing"); window.history.pushState({}, "", "/missing");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />); render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
expect( expect(
screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }), await screen.findByRole("heading", {
name: "페이지를 찾을 수 없습니다.",
}),
).toBeVisible(); ).toBeVisible();
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
expect(screen.getByRole("main")).toBeVisible();
}); });
it("shows session-required UX without claiming authorization", () => { it("navigates between registry-backed example routes", async () => {
const user = userEvent.setup();
window.history.pushState({}, "", "/");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
await user.click(
await screen.findByRole("link", { name: "UI 구성요소", exact: true }),
);
expect(
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
).toBeVisible();
expect(window.location.pathname).toBe("/examples/ui");
});
it("reacts to demo sign-in and opens the protected integration route", async () => {
const user = userEvent.setup();
const authSession = createDemoSessionAdapter();
window.history.pushState({}, "", "/sample/resources");
render(<AppRouter authSession={authSession} />);
expect(
await screen.findByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
await user.click(screen.getByRole("button", { name: "로그인 시작" }));
expect(
await screen.findByRole("heading", { name: "보호된 연동 지점" }),
).toBeVisible();
expect(screen.getByText("인증됨")).toBeVisible();
});
it("fails closed when the auth integration does not change state", async () => {
const user = userEvent.setup();
window.history.pushState({}, "", "/sample/resources"); window.history.pushState({}, "", "/sample/resources");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />); render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
expect(screen.getByRole("heading", { name: "세션이 필요합니다." })).toBeVisible();
expect(screen.getByRole("button", { name: "로그인" })).toBeVisible(); await user.click(
await screen.findByRole("button", { name: "로그인 시작" }),
);
expect(
screen.getByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
}); });
}); });
+10 -2
View File
@@ -1,7 +1,14 @@
import AxeBuilder from "@axe-core/playwright"; import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
for (const route of ["/", "/sample/resources", "/not-found"]) { for (const route of [
"/",
"/examples/ui",
"/examples/states",
"/examples/auth",
"/sample/resources",
"/not-found",
]) {
test(`@a11y ${route} has no critical or serious axe violations`, async ({ test(`@a11y ${route} has no critical or serious axe violations`, async ({
page, page,
}) => { }) => {
@@ -21,8 +28,9 @@ test("@a11y keyboard reaches the primary route action with visible focus", async
page, page,
}) => { }) => {
await page.goto("/"); await page.goto("/");
const action = page.getByRole("link", { name: "UI 구성요소 보기" });
await expect(action).toBeVisible();
await page.keyboard.press("Tab"); await page.keyboard.press("Tab");
const action = page.getByRole("link", { name: "샘플 리소스" });
await expect(action).toBeFocused(); await expect(action).toBeFocused();
await expect(action).toHaveCSS("outline-style", "solid"); await expect(action).toHaveCSS("outline-style", "solid");
}); });
+47
View File
@@ -5,4 +5,51 @@ test("boots the public app shell", async ({ page }) => {
await expect(page.getByRole("heading", { level: 1 })).toHaveText( await expect(page.getByRole("heading", { level: 1 })).toHaveText(
"Clean Architecture Frontend", "Clean Architecture Frontend",
); );
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
await expect(page.getByRole("main")).toBeVisible();
});
test("navigates to a registry-backed example without a page reload", async ({
page,
}) => {
await page.goto("/");
await page
.getByRole("navigation", { name: "주요 탐색" })
.getByRole("link", { name: "화면 상태" })
.click();
await expect(page).toHaveURL(/\/examples\/states$/);
await expect(
page.getByRole("heading", { level: 1, name: "화면 상태" }),
).toBeFocused();
});
test("opens the protected integration route through the local demo seam", async ({
page,
}) => {
await page.goto("/sample/resources");
await expect(
page.getByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
await page.getByRole("button", { name: "로그인 시작" }).click();
await expect(
page.getByRole("heading", { name: "보호된 연동 지점" }),
).toBeVisible();
await expect(page.getByText("인증됨")).toBeVisible();
});
test("provides an escape-dismissible mobile navigation", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
const menu = page.getByRole("button", { name: "메뉴", exact: true });
await menu.click();
await expect(menu).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(menu).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeHidden();
}); });
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
loadReleaseManifest,
ReleaseManifestError,
} from "../../src/bootstrap/load-release-manifest.js";
const runtime = {
build: { buildId: "build-a" },
config: {
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "build-a",
RELEASE_ID: "release-a",
CONFIG_SCHEMA_VERSION: "1",
API_CONTRACT_VERSION: "1",
},
};
const manifest = {
schemaVersion: 1,
appVersion: "0.1.0",
buildId: "build-a",
commitSha: "abc123",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "hash-a",
releaseId: "release-a",
builtAt: "2026-07-25T00:00:00Z",
};
describe("release manifest boot boundary", () => {
it("loads a coherent release tuple", async () => {
await expect(
loadReleaseManifest(
/** @type {Parameters<typeof loadReleaseManifest>[0]} */ (runtime),
{ fetcher: async () => new Response(JSON.stringify(manifest)) },
),
).resolves.toMatchObject({ releaseId: "release-a" });
});
it("fails before mount when release and runtime differ", async () => {
await expect(
loadReleaseManifest(
/** @type {Parameters<typeof loadReleaseManifest>[0]} */ (runtime),
{
fetcher: async () =>
new Response(JSON.stringify({ ...manifest, buildId: "build-b" })),
},
),
).rejects.toBeInstanceOf(ReleaseManifestError);
});
});
@@ -35,6 +35,20 @@ describe("runtime configuration boundary", () => {
); );
}); });
it("allows demo authentication only for local runtime configuration", () => {
expect(
validateRuntimeConfig({ ...validConfig, AUTH_MODE: "demo" }).success,
).toBe(true);
expect(
validateRuntimeConfig({
...validConfig,
APP_ENV: "production",
API_BASE_URL: "https://api.example.test",
AUTH_MODE: "demo",
}).success,
).toBe(false);
});
it("validates a fetched config under the 500ms budget excluding network", async () => { it("validates a fetched config under the 500ms budget excluding network", async () => {
let current = 100; let current = 100;
const result = await loadRuntimeConfig({ const result = await loadRuntimeConfig({
+23
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { import {
createAnonymousSessionAdapter, createAnonymousSessionAdapter,
createDemoSessionAdapter,
createExternalAuthSessionAdapter, createExternalAuthSessionAdapter,
} from "../../src/adapters/auth/external-session-adapter.js"; } from "../../src/adapters/auth/external-session-adapter.js";
@@ -9,6 +10,9 @@ describe("external AuthSessionPort adapter", () => {
it("attaches opaque credentials without exposing a token-shaped session", async () => { it("attaches opaque credentials without exposing a token-shaped session", async () => {
const adapter = createExternalAuthSessionAdapter({ const adapter = createExternalAuthSessionAdapter({
readState: () => "authenticated", readState: () => "authenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => { attachCredential: async (request) => {
const headers = new Headers(request.headers); const headers = new Headers(request.headers);
headers.set("X-Session-Attached", "true"); headers.set("X-Session-Attached", "true");
@@ -28,6 +32,9 @@ describe("external AuthSessionPort adapter", () => {
it("fails invalid recovery states closed", async () => { it("fails invalid recovery states closed", async () => {
const adapter = createExternalAuthSessionAdapter({ const adapter = createExternalAuthSessionAdapter({
readState: () => "authenticated", readState: () => "authenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => request, attachCredential: async (request) => request,
recoverSession: async () => "unexpected", recoverSession: async () => "unexpected",
notifyUnauthenticated: vi.fn(), notifyUnauthenticated: vi.fn(),
@@ -41,4 +48,20 @@ describe("external AuthSessionPort adapter", () => {
expect(adapter.getState()).toBe("unauthenticated"); expect(adapter.getState()).toBe("unauthenticated");
await expect(adapter.recover()).resolves.toBe("no-session"); await expect(adapter.recover()).resolves.toBe("no-session");
}); });
it("provides a reactive credential-free demo seam", async () => {
const adapter = createDemoSessionAdapter();
let notifications = 0;
const unsubscribe = adapter.subscribe(() => {
notifications += 1;
});
expect(adapter.getState()).toBe("unauthenticated");
await adapter.beginSignIn("/");
expect(adapter.getState()).toBe("authenticated");
await adapter.signOut();
expect(adapter.getState()).toBe("unauthenticated");
expect(notifications).toBe(2);
unsubscribe();
});
}); });
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { validateFieldEvidenceInput } from "../../scripts/lib/field-vitals-evidence.mjs";
const input = {
schemaVersion: 1,
environment: "production",
releaseId: "release-2026-06-29",
source: {
system: "privacy-approved-rum-export",
exportId: "export-2026-06-29",
},
privacy: {
approved: true,
approvalRef: "PRIVACY-42",
},
window: {
start: "2026-06-01T00:00:00Z",
end: "2026-06-29T00:00:00Z",
},
thresholdDecision: {
status: "approved",
minimumEligibleSamples: 25,
owner: "performance-owner",
reviewedAt: "2026-06-30T00:00:00Z",
evidenceRef: "PERF-BASELINE-7",
},
samples: [
{
timestamp: "2026-06-20T00:00:00Z",
consent: true,
releaseId: "release-2026-06-29",
routeId: "APP_HOME",
lcpMs: 1200,
cls: 0.01,
inpMs: 80,
},
],
};
describe("field Web Vitals evidence input", () => {
it("accepts reviewed, coherent 28-day production metadata", () => {
expect(
validateFieldEvidenceInput(
input,
"25",
new Date("2026-07-01T00:00:00Z"),
),
).toMatchObject({
failures: [],
minimumEligibleSamples: 25,
passed: true,
});
});
it("rejects a threshold that does not match the owner decision", () => {
expect(
validateFieldEvidenceInput(
input,
"10",
new Date("2026-07-01T00:00:00Z"),
),
).toMatchObject({
failures: [
"MIN_ELIGIBLE_SAMPLES: does not match the approved threshold decision",
],
passed: false,
});
});
it("rejects local, unapproved, malformed, or impossible measurements", () => {
const invalid = {
...input,
environment: "local",
releaseId: "local-release",
privacy: { approved: false, approvalRef: "" },
window: { ...input.window, end: "2026-06-28T00:00:00Z" },
samples: [{ ...input.samples[0], lcpMs: -1 }],
};
const validation = validateFieldEvidenceInput(
invalid,
"-1",
new Date("2026-07-01T00:00:00Z"),
);
expect(validation.passed).toBe(false);
expect(validation.failures.join("\n")).toContain("environment");
expect(validation.failures.join("\n")).toContain("releaseId");
expect(validation.failures.join("\n")).toContain("privacy");
expect(validation.failures.join("\n")).toContain("lcpMs");
expect(validation.failures.join("\n")).toContain(
"MIN_ELIGIBLE_SAMPLES: must be a positive integer",
);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { classifyLiveHostingBaseUrl } from "../../scripts/lib/hosting-probe.mjs";
describe("live hosting evidence target", () => {
it("accepts a canonical production HTTPS root", () => {
expect(
classifyLiveHostingBaseUrl("https://frontend.example.test/"),
).toMatchObject({
passed: true,
observedOrigin: "https://frontend.example.test",
});
});
it.each([
["http://frontend.example.test/", "requires HTTPS"],
["https://localhost:4173/", "not live deployment evidence"],
["https://127.0.0.1/", "not live deployment evidence"],
["https://frontend.example.test/app/", "canonical root URL"],
["https://user:secret@frontend.example.test/", "must not contain credentials"],
])("rejects %s", (url, reason) => {
expect(classifyLiveHostingBaseUrl(url)).toMatchObject({
passed: false,
reason: expect.stringContaining(reason),
});
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { validateManualA11yEvidence } from "../../scripts/lib/manual-a11y-evidence.mjs";
const reviewed = `Status: reviewed
Route ID: APP_HOME
Release ID: release-1
Reviewer: reviewer@example.test
Reviewed at: 2026-07-25T12:00:00Z
Signature: review-record-1
Attestation: accepted
M1 Keyboard: pass
M2 Visible focus: pass
M3 Route focus: pass
M4 Modal focus: not-applicable (no modal)
M5 Error association: not-applicable (no form error)
M6 Color signal: pass
M7 Reduced motion: pass
Screen reader: pass
Notes: no defects`;
describe("manual accessibility evidence", () => {
it("accepts a complete signed human review record", () => {
expect(validateManualA11yEvidence(reviewed)).toMatchObject({
failures: [],
passed: true,
});
});
it("rejects pending, unsigned, or incomplete evidence", () => {
expect(
validateManualA11yEvidence(
reviewed
.replace("Status: reviewed", "Status: pending-manual-review")
.replace("Signature: review-record-1", "Signature:")
.replace("Screen reader: pass", "Screen reader: pending"),
),
).toMatchObject({
failures: ["Status", "Signature", "Screen reader"],
passed: false,
});
});
it("does not treat an unexplained not-applicable verdict as evidence", () => {
expect(
validateManualA11yEvidence(
reviewed.replace(
"M4 Modal focus: not-applicable (no modal)",
"M4 Modal focus: not-applicable",
),
),
).toMatchObject({
failures: ["M4 Modal focus"],
passed: false,
});
});
});
+62 -1
View File
@@ -4,7 +4,10 @@ import {
createRedirectLoopGuard, createRedirectLoopGuard,
decideRouteAccess, decideRouteAccess,
} from "../../src/presentation/routes/navigation-policy.js"; } from "../../src/presentation/routes/navigation-policy.js";
import { ROUTE_REGISTRY } from "../../src/contracts/routes.js"; import {
NAVIGATION_ROUTES,
ROUTE_REGISTRY,
} from "../../src/contracts/routes.js";
describe("route registry", () => { describe("route registry", () => {
it("matches the stable registry snapshot", () => { it("matches the stable registry snapshot", () => {
@@ -15,35 +18,93 @@ describe("route registry", () => {
"chunkId": "route-home", "chunkId": "route-home",
"errorSurface": "route-boundary", "errorSurface": "route-boundary",
"loadingSurface": "app-shell", "loadingSurface": "app-shell",
"navigationLabel": "시작",
"navigationOrder": 10,
"paramsSchema": null, "paramsSchema": null,
"path": "/", "path": "/",
"routeId": "APP_HOME", "routeId": "APP_HOME",
"searchSchema": null, "searchSchema": null,
"title": "시작",
},
"EXAMPLES_AUTH": {
"access": "public",
"chunkId": "route-examples-auth",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "인증 연동",
"navigationOrder": 40,
"paramsSchema": null,
"path": "/examples/auth",
"routeId": "EXAMPLES_AUTH",
"searchSchema": null,
"title": "인증 연동",
},
"EXAMPLES_STATES": {
"access": "public",
"chunkId": "route-examples-states",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "화면 상태",
"navigationOrder": 30,
"paramsSchema": null,
"path": "/examples/states",
"routeId": "EXAMPLES_STATES",
"searchSchema": null,
"title": "화면 상태",
},
"EXAMPLES_UI": {
"access": "public",
"chunkId": "route-examples-ui",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "UI 구성요소",
"navigationOrder": 20,
"paramsSchema": null,
"path": "/examples/ui",
"routeId": "EXAMPLES_UI",
"searchSchema": null,
"title": "UI 구성요소",
}, },
"NOT_FOUND": { "NOT_FOUND": {
"access": "public", "access": "public",
"chunkId": "route-not-found", "chunkId": "route-not-found",
"errorSurface": "not-found", "errorSurface": "not-found",
"loadingSurface": "none", "loadingSurface": "none",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": null, "paramsSchema": null,
"path": "*", "path": "*",
"routeId": "NOT_FOUND", "routeId": "NOT_FOUND",
"searchSchema": null, "searchSchema": null,
"title": "페이지를 찾을 수 없음",
}, },
"SAMPLE_RESOURCE_LIST": { "SAMPLE_RESOURCE_LIST": {
"access": "integration-defined", "access": "integration-defined",
"chunkId": "route-sample-resources", "chunkId": "route-sample-resources",
"errorSurface": "feature-boundary", "errorSurface": "feature-boundary",
"loadingSurface": "sample-resource-list", "loadingSurface": "sample-resource-list",
"navigationLabel": "보호된 연동 지점",
"navigationOrder": 50,
"paramsSchema": null, "paramsSchema": null,
"path": "/sample/resources", "path": "/sample/resources",
"routeId": "SAMPLE_RESOURCE_LIST", "routeId": "SAMPLE_RESOURCE_LIST",
"searchSchema": "SampleResourceListQuery", "searchSchema": "SampleResourceListQuery",
"title": "보호된 연동 지점",
}, },
} }
`); `);
}); });
it("derives visible navigation in explicit order", () => {
expect(NAVIGATION_ROUTES.map(({ routeId }) => routeId)).toEqual([
"APP_HOME",
"EXAMPLES_UI",
"EXAMPLES_STATES",
"EXAMPLES_AUTH",
"SAMPLE_RESOURCE_LIST",
]);
});
it("treats client access as a UX hint, not authorization", () => { it("treats client access as a UX hint, not authorization", () => {
expect(decideRouteAccess("APP_HOME", "unauthenticated")).toEqual({ expect(decideRouteAccess("APP_HOME", "unauthenticated")).toEqual({
allowed: true, allowed: true,
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { createRuntimeAdapters } from "../../src/bootstrap/runtime-adapters.js";
const runtime = {
config: {
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080",
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo",
},
};
const release = {
schemaVersion: 1,
appVersion: "0.1.0",
buildId: "build-a",
commitSha: "abc123",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "hash-a",
releaseId: "release-a",
builtAt: "2026-07-25T00:00:00Z",
};
describe("runtime adapter composition", () => {
it("constructs the local demo seam and infrastructure adapters", async () => {
const adapters = await createRuntimeAdapters({
runtime:
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ (
runtime
),
release,
host: {},
});
expect(adapters.authSession.getState()).toBe("unauthenticated");
expect(adapters.cache.read(["missing"])).toEqual({
ok: true,
value: undefined,
});
await expect(adapters.releaseInfo.getCurrent()).resolves.toMatchObject({
releaseId: "release-a",
});
});
it("fails closed when an external auth owner was not installed", async () => {
const adapters = await createRuntimeAdapters({
runtime:
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ ({
config: { ...runtime.config, AUTH_MODE: "external" },
}),
release,
host: {},
});
expect(adapters.authSession.getState()).toBe("integration-failed");
});
});