Merge branch 'main' into feature/techlog-ui-migration

Integrates the frontend template sync (a0fbafb → 5434760) into the TechLog UI
migration. Merged in this direction so every conflict is resolved and proved in
the worktree; main is only fast-forwarded afterwards and never holds a state
that was not verified here.

15 conflicts. The rule throughout: keep the template's mechanism, keep the
product's content, and never invent a third state neither branch would accept.

The template's product manifest and its runtime feature kill switch are adopted.
The route registry is deliberately not composed from contract.routes: the
reference feature still declares screens this product deleted, and reducing over
them would register paths with no component behind them. ROUTE_FEATURE_OWNER is
narrowed to registered routes for the same reason. The first resolution did
compose from contract.routes and was rejected by product-features.test.ts.

Three files pinned counts and a digest describing the gate contract. Neither
side's numbers describe the merged config/ci/gates.json, so they were recomputed
from it rather than chosen: 27 gates, 82 commands, 94 command references, 107
evidence references, 128 artifacts, shape sha256 5063586d.

README.md and docs/accessibility/manual-checklist.md now enumerate this
product's 27 routes, which the template's own verify:documentation requires.

product-feature-switch.test.tsx was rewritten around the invariant that still
applies here — no registered route without a component — rather than deleted
with the screens it used to exercise.

docs/operations/template-merge-2026-08-17.md records every decision, the gate
results, and the three follow-ups this merge deliberately did not decide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-17 18:38:08 +09:00
co-authored by Claude Opus 5
100 changed files with 3310 additions and 443 deletions
@@ -671,7 +671,14 @@ function validateCapabilityPayload(
}),
);
} catch {
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
// BT-PRE-04. A capability document this adapter refuses is not a dead end
// for the caller: the only way forward is to ask the issuer for a new one.
// `NONE` said the opposite — that nothing could be done — and disagreed
// with both the design record for an unsupported protocol and the vault,
// which already answers `REISSUE_CAPABILITY` for the same class of refusal.
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER", {
recovery: "REISSUE_CAPABILITY",
});
}
}
@@ -6,7 +6,7 @@ import {
type DiagnosticRecordInput,
} from "../../contracts/diagnostics.ts";
import { projectTelemetryEvent } from "../../contracts/telemetry.ts";
import { assertBoundedCapacity } from "../telemetry/best-effort-telemetry.ts";
import { assertBoundedCapacity } from "../platform/bounded-capacity.ts";
export const noOpDiagnostics: DiagnosticsPort = Object.freeze({
record() {},
+21 -2
View File
@@ -269,6 +269,17 @@ export type ContractHttpExecutorDependencies = Readonly<{
baseUrl: string;
/** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */
maxRetryAttempts: number;
/**
* §6.1 / §8.5. `REQUEST_TIMEOUT_MS` from Runtime Config, as a ceiling only.
*
* The contract owns each operation's deadline, because the deadline is part
* of what the operation promises. A deployment still has to be able to hold
* the whole app to something stricter than the sum of its contracts, so this
* value may only shorten a deadline, never extend one — the same direction
* `CAPABILITY_OVERRIDES` is allowed to move in. Absent, contracts stand
* exactly as written.
*/
requestDeadlineCeilingMs?: number;
/** The installed profile registry; the executor never invents a profile. */
authProfiles?: InstalledRestAuthProfiles;
attachCredentials(
@@ -373,6 +384,13 @@ export function createContractHttpExecutor(
dependencies.readBoundedResponseBytes ?? readBoundedBytes;
const now = dependencies.monotonicNow ?? (() => performance.now());
const random = dependencies.random ?? Math.random;
const deadlineCeilingMs = dependencies.requestDeadlineCeilingMs;
const effectiveDeadlineMs = (contractDeadlineMs: number): number =>
typeof deadlineCeilingMs === "number" &&
Number.isFinite(deadlineCeilingMs) &&
deadlineCeilingMs > 0
? Math.min(contractDeadlineMs, deadlineCeilingMs)
: contractDeadlineMs;
const sleep =
dependencies.sleep ??
((ms: number, signal: AbortSignal) =>
@@ -400,7 +418,8 @@ export function createContractHttpExecutor(
// §8.5. One monotonic deadline covers credential resolution, encoding,
// backoff, every physical attempt, body read and validation.
const startedAt = now();
const deadlineAt = startedAt + policy.totalDeadlineMs;
const totalDeadlineMs = effectiveDeadlineMs(policy.totalDeadlineMs);
const deadlineAt = startedAt + totalDeadlineMs;
const remaining = () => deadlineAt - now();
let attemptState: PhysicalAttemptState = "PREPARING";
@@ -451,7 +470,7 @@ export function createContractHttpExecutor(
const lifetimeDeadlineTimer = setTimeout(() => {
terminalCancellation ??= "DEADLINE";
lifetimeController.abort();
}, policy.totalDeadlineMs);
}, totalDeadlineMs);
let lifetimeDisposed = false;
const disposeLifetime = () => {
if (lifetimeDisposed) return;
+21
View File
@@ -0,0 +1,21 @@
/**
* N-11. A non-finite or fractional capacity silently disables eviction, so it is
* a construction-time configuration error rather than a runtime drop.
*
* This lives in the adapter kernel rather than inside the telemetry adapter:
* the diagnostics adapter needs the same guard, and importing it from telemetry
* made one concrete adapter depend on another for a rule that belongs to
* neither of them.
*/
export function assertBoundedCapacity(
value: number,
ceiling: number,
label: string,
): number {
if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) {
throw new TypeError(
`${label} must be a safe integer between 1 and ${ceiling}`,
);
}
return value;
}
@@ -5,6 +5,7 @@ import type {
TelemetryEventName,
} from "../../contracts/telemetry.ts";
import type { TelemetryPort } from "../../application/ports/telemetry-port.ts";
import { assertBoundedCapacity } from "../platform/bounded-capacity.ts";
export type TelemetryAdapter = TelemetryPort &
Readonly<{
@@ -45,22 +46,8 @@ type TelemetryLifecycle = "ACTIVE" | "DISPOSED";
/** N-11. Documented absolute ceiling for the in-memory best-effort queue. */
export const MAX_TELEMETRY_QUEUE = 10_000;
/**
* N-11. A non-finite or fractional capacity silently disables eviction, so it is
* a construction-time configuration error rather than a runtime drop.
*/
export function assertBoundedCapacity(
value: number,
ceiling: number,
label: string,
): number {
if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) {
throw new TypeError(
`${label} must be a safe integer between 1 and ${ceiling}`,
);
}
return value;
}
export { assertBoundedCapacity } from "../platform/bounded-capacity.ts";
export function createTelemetryAdapter(
options: TelemetryAdapterOptions,
+6
View File
@@ -102,6 +102,12 @@ export function createApplication(
getCapabilitySnapshot() {
return outputPorts.runtimeCapabilities.getSnapshot();
},
getFeatureSnapshot() {
return outputPorts.productFeatures.getSnapshot();
},
isFeatureActive(featureId: string) {
return outputPorts.productFeatures.isActive(featureId);
},
});
const recovery = Object.freeze({
+21 -107
View File
@@ -1,107 +1,21 @@
export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([
"buildId",
"configSchemaVersion",
"apiContractVersion",
"assetManifestHash",
"releaseId",
] as const);
export type CompatibilityTupleField =
(typeof COMPATIBILITY_TUPLE_FIELDS)[number];
export type CompatibilityTuple = Readonly<
Record<CompatibilityTupleField, string>
>;
export type NumericVersion = Readonly<{
major: number;
minor: number;
patch: number;
}>;
export function parseNumericVersion(version: string): NumericVersion | null {
const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2] ?? 0),
patch: Number(match[3] ?? 0),
};
}
export function isVersionCompatible(
supported: string,
actual: string,
): boolean {
const expected = parseNumericVersion(supported);
const candidate = parseNumericVersion(actual);
if (!expected || !candidate) return false;
return (
expected.major === candidate.major &&
candidate.minor >= expected.minor
);
}
export function verifyCompatibilityTuple(input: Readonly<{
frontend: CompatibilityTuple;
runtime: CompatibilityTuple;
}>) {
const mismatches: CompatibilityTupleField[] = [];
if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId");
if (
!isVersionCompatible(
input.frontend.configSchemaVersion,
input.runtime.configSchemaVersion,
)
) {
mismatches.push("configSchemaVersion");
}
if (
!isVersionCompatible(
input.frontend.apiContractVersion,
input.runtime.apiContractVersion,
)
) {
mismatches.push("apiContractVersion");
}
if (input.frontend.assetManifestHash !== input.runtime.assetManifestHash) {
mismatches.push("assetManifestHash");
}
const releaseWarning: "releaseId" | null =
input.frontend.releaseId === input.runtime.releaseId
? null
: "releaseId";
return Object.freeze({
compatible: mismatches.length === 0,
mismatches: Object.freeze(mismatches),
warnings: Object.freeze(releaseWarning ? [releaseWarning] : []),
});
}
export type ObjectSchemaShape = Readonly<{
required?: readonly string[];
properties?: Readonly<Record<string, unknown>>;
}>;
export type SchemaChangeClassification = "breaking" | "additive" | "none";
export function classifyObjectSchemaChange(
before: ObjectSchemaShape,
after: ObjectSchemaShape,
): SchemaChangeClassification {
const beforeRequired = new Set(before.required ?? []);
const afterRequired = new Set(after.required ?? []);
const removedProperties = Object.keys(before.properties ?? {}).filter(
(key) => !(key in (after.properties ?? {})),
);
const addedRequired = [...afterRequired].filter(
(key) => !beforeRequired.has(key),
);
if (removedProperties.length > 0 || addedRequired.length > 0) return "breaking";
const addedProperties = Object.keys(after.properties ?? {}).filter(
(key) => !(key in (before.properties ?? {})),
);
return addedProperties.length > 0 ? "additive" : "none";
}
/**
* Release compatibility comparison.
*
* The implementation lives in `src/contracts/compatibility.ts`: it is a pure
* predicate over release tokens with no application state, and
* `src/contracts/release-tokens.ts` needs it, which previously made contracts
* import the application layer. This module re-exports it for application-side
* and script-side callers.
*/
export {
COMPATIBILITY_TUPLE_FIELDS,
classifyObjectSchemaChange,
isVersionCompatible,
parseNumericVersion,
verifyCompatibilityTuple,
type CompatibilityTuple,
type CompatibilityTupleField,
type NumericVersion,
type ObjectSchemaShape,
type SchemaChangeClassification,
} from "../../contracts/compatibility.ts";
@@ -20,6 +20,9 @@ export const PROMOTION_FORMULA = Object.freeze({
"FE-GATE-015",
"FE-GATE-019",
"FE-GATE-026",
// FE-GATE-027. A candidate is only release-ready once it has been admitted
// to a named environment; coherence alone never proved it belonged there.
"FE-GATE-027",
]),
PROD_PROMOTION_READY: Object.freeze([
"FE-GATE-016",
@@ -1,8 +1,10 @@
import type { SessionState } from "../auth-session-port.ts";
import type { ProductFeatureStatus } from "../product-features-port.ts";
import type { RuntimeCapabilitySnapshot } from "../runtime-capabilities-port.ts";
import type { StoragePort } from "../storage-port.ts";
export type { SessionState } from "../auth-session-port.ts";
export type { ProductFeatureStatus };
export type { RuntimeCapabilitySnapshot };
/**
@@ -64,6 +66,13 @@ export type ApplicationApi = Readonly<{
* reads capability state here instead of importing the composition root.
*/
getCapabilitySnapshot(): RuntimeCapabilitySnapshot;
/**
* §3.5. Which product features this build contains and which of them the
* runtime document switched off. Presentation reads state here; it never
* learns how to reach a feature the build left out.
*/
getFeatureSnapshot(): readonly ProductFeatureStatus[];
isFeatureActive(featureId: string): boolean;
}>;
recovery: Readonly<{
recoverChunk(input: Readonly<{
@@ -1,5 +1,6 @@
import type { AuthSessionPort } from "../auth-session-port.ts";
import type { ReleaseInfoPort } from "../release-info-port.ts";
import type { ProductFeaturesPort } from "../product-features-port.ts";
import type { RuntimeCapabilitiesPort } from "../runtime-capabilities-port.ts";
import type { StoragePort } from "../storage-port.ts";
import type { TelemetryPort } from "../telemetry-port.ts";
@@ -19,5 +20,6 @@ export type ApplicationOutputPorts = Readonly<{
telemetry: TelemetryPort;
releaseInfo: ReleaseInfoPort;
runtimeCapabilities: RuntimeCapabilitiesPort;
productFeatures: ProductFeaturesPort;
navigation: Readonly<{ reload(): void }>;
}>;
@@ -0,0 +1,16 @@
import type { ProductFeatureStatus } from "../../contracts/product-features.ts";
export type { ProductFeatureStatus };
/**
* §3.5. The application reads feature state; it never resolves it.
*
* Only the composition root knows both halves of the answer — what the build
* compiled in and what the runtime document disabled — so the snapshot arrives
* here already reduced to ids and states. It carries no feature module, so
* reading it cannot become a way to reach code the build left out.
*/
export type ProductFeaturesPort = Readonly<{
getSnapshot(): readonly ProductFeatureStatus[];
isActive(featureId: string): boolean;
}>;
+8 -5
View File
@@ -1,10 +1,13 @@
import type { AppFailure } from "../contracts/errors.ts";
/**
* The single success/failure carrier used across application input boundaries.
* Adapters map technology-specific errors to an application failure before
* constructing this value.
*
* The type itself lives in `src/contracts` because both layers need it and
* neither owns it: `src/contracts/server-state.ts` and
* `src/contracts/cursor-pagination.ts` reached back into the application layer
* for it, which made the ownership of the shared vocabulary ambiguous in both
* directions. Contracts is the lower of the two, so the shared shape sits there
* and this module re-exports it for every existing application-side importer.
*/
export type Result<Value, Failure = AppFailure> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: Failure }>;
export type { Result } from "../contracts/result.ts";
+35
View File
@@ -41,6 +41,14 @@ import {
INVALIDATION_TOPIC_VERSIONS,
} from "../features/installed-feature-contracts.ts";
import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts";
import {
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
} from "../features/installed-product-manifest.ts";
import {
activeProductFeatureIds,
resolveProductFeatures,
} from "../contracts/product-features.ts";
import { describeRuntimeCapabilities } from "../contracts/runtime-capabilities.ts";
import {
fetchReleaseManifest,
@@ -382,6 +390,27 @@ export async function createRuntimeAdapters(
);
},
});
/**
* §3.5. The two halves of the feature answer meet here and nowhere else: the
* manifest says what the build compiled in, the runtime document says what is
* switched off. Neither can add to the other.
*/
const productFeatureStatuses = resolveProductFeatures(
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
config.FEATURE_OVERRIDES,
);
const activeFeatureIds = new Set(
activeProductFeatureIds(productFeatureStatuses),
);
const productFeatures = Object.freeze({
getSnapshot() {
return productFeatureStatuses;
},
isActive(featureId: string) {
return activeFeatureIds.has(featureId);
},
});
const navigation = Object.freeze({
reload() {
const location = host.location;
@@ -394,6 +423,11 @@ export async function createRuntimeAdapters(
const contractHttp = createContractHttpExecutor({
baseUrl: config.API_BASE_URL,
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
// §6.1. `REQUEST_TIMEOUT_MS` was declared, validated and then dropped on the
// floor here: every V3 operation ran on its contract's own 10s deadline and
// the deployment dial did nothing. It is a ceiling, so it can tighten an
// operation but never loosen one.
requestDeadlineCeilingMs: config.REQUEST_TIMEOUT_MS,
fetcher: context.fetcher,
// §7.7. The installed registry owns Fetch credentials and the exact
// credential-header sets; this collaborator only supplies proof headers.
@@ -481,6 +515,7 @@ export async function createRuntimeAdapters(
telemetry,
releaseInfo,
runtimeCapabilities,
productFeatures,
navigation,
}),
infrastructure: Object.freeze({
+10
View File
@@ -1,3 +1,4 @@
import type { ProductFeatureOverrideMap } from "../contracts/product-features.ts";
import {
runtimeConfigV1ArtifactSchema,
runtimeConfigV2ArtifactSchema,
@@ -44,6 +45,11 @@ export type RuntimeConfig = Readonly<{
RELEASE_ID?: string;
BUILD_ID?: string;
CAPABILITY_OVERRIDES: CapabilityOverrides;
/**
* §3.5. Runtime kill switch per installed feature. Subtractive only: a
* feature the build did not install cannot be named into existence here.
*/
FEATURE_OVERRIDES: ProductFeatureOverrideMap;
/** Present only while a V1 document is still accepted. */
LEGACY_API_CONTRACT_VERSION?: string;
}>;
@@ -122,6 +128,10 @@ export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation {
? (parsed as RuntimeConfigV2).CAPABILITY_OVERRIDES
: DEFAULT_OVERRIDES),
}),
// A V1 document predates feature overrides, so it disables nothing.
FEATURE_OVERRIDES: Object.freeze({
...(isV2 ? (parsed as RuntimeConfigV2).FEATURE_OVERRIDES : {}),
}),
...(isV2
? {}
: {
+107
View File
@@ -0,0 +1,107 @@
export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([
"buildId",
"configSchemaVersion",
"apiContractVersion",
"assetManifestHash",
"releaseId",
] as const);
export type CompatibilityTupleField =
(typeof COMPATIBILITY_TUPLE_FIELDS)[number];
export type CompatibilityTuple = Readonly<
Record<CompatibilityTupleField, string>
>;
export type NumericVersion = Readonly<{
major: number;
minor: number;
patch: number;
}>;
export function parseNumericVersion(version: string): NumericVersion | null {
const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2] ?? 0),
patch: Number(match[3] ?? 0),
};
}
export function isVersionCompatible(
supported: string,
actual: string,
): boolean {
const expected = parseNumericVersion(supported);
const candidate = parseNumericVersion(actual);
if (!expected || !candidate) return false;
return (
expected.major === candidate.major &&
candidate.minor >= expected.minor
);
}
export function verifyCompatibilityTuple(input: Readonly<{
frontend: CompatibilityTuple;
runtime: CompatibilityTuple;
}>) {
const mismatches: CompatibilityTupleField[] = [];
if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId");
if (
!isVersionCompatible(
input.frontend.configSchemaVersion,
input.runtime.configSchemaVersion,
)
) {
mismatches.push("configSchemaVersion");
}
if (
!isVersionCompatible(
input.frontend.apiContractVersion,
input.runtime.apiContractVersion,
)
) {
mismatches.push("apiContractVersion");
}
if (input.frontend.assetManifestHash !== input.runtime.assetManifestHash) {
mismatches.push("assetManifestHash");
}
const releaseWarning: "releaseId" | null =
input.frontend.releaseId === input.runtime.releaseId
? null
: "releaseId";
return Object.freeze({
compatible: mismatches.length === 0,
mismatches: Object.freeze(mismatches),
warnings: Object.freeze(releaseWarning ? [releaseWarning] : []),
});
}
export type ObjectSchemaShape = Readonly<{
required?: readonly string[];
properties?: Readonly<Record<string, unknown>>;
}>;
export type SchemaChangeClassification = "breaking" | "additive" | "none";
export function classifyObjectSchemaChange(
before: ObjectSchemaShape,
after: ObjectSchemaShape,
): SchemaChangeClassification {
const beforeRequired = new Set(before.required ?? []);
const afterRequired = new Set(after.required ?? []);
const removedProperties = Object.keys(before.properties ?? {}).filter(
(key) => !(key in (after.properties ?? {})),
);
const addedRequired = [...afterRequired].filter(
(key) => !beforeRequired.has(key),
);
if (removedProperties.length > 0 || addedRequired.length > 0) return "breaking";
const addedProperties = Object.keys(after.properties ?? {}).filter(
(key) => !(key in (before.properties ?? {})),
);
return addedProperties.length > 0 ? "additive" : "none";
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Result } from "../application/result.ts";
import type { Result } from "./result.ts";
export type CursorPage<Value> = Readonly<{
items: readonly Value[];
+160
View File
@@ -0,0 +1,160 @@
import type { RuntimeConfigArtifact } from "./release-artifacts.ts";
/**
* §6.4. Which environment an artifact is allowed to be deployed to.
*
* Release coherence answers "do these artifacts describe each other?". It does
* not answer "is this the artifact production should receive?", and the two are
* not the same question: a build whose runtime document says `APP_ENV: local`,
* `AUTH_MODE: demo` and `API_BASE_URL: http://localhost:8080/` is perfectly
* coherent with itself. Without an admission step such a build is a valid
* release candidate, and the only thing standing between it and production is
* that nobody happened to promote it.
*
* Admission is therefore a separate, declared decision: a caller states the
* target it intends, and this module says whether the artifact may go there.
* Every rule below is a refusal, so an unrecognised target or an unreadable
* field fails closed rather than passing by omission.
*/
export const DEPLOYMENT_TARGETS = Object.freeze([
"local",
"development",
"staging",
"production",
] as const);
export type DeploymentTarget = (typeof DEPLOYMENT_TARGETS)[number];
/**
* Targets that serve real users over the public internet. They carry the full
* rule set; `local` and `development` only have to be honest about what they
* are.
*/
const PUBLIC_TARGETS: ReadonlySet<DeploymentTarget> = new Set([
"staging",
"production",
]);
/** Placeholder identifiers a developer build emits when nothing supplied one. */
const PLACEHOLDER_IDENTIFIERS: ReadonlySet<string> = new Set([
"local-build",
"local-release",
"local",
"dev",
"unknown",
]);
export type AdmissionViolation = Readonly<{ field: string; reason: string }>;
export type AdmissionInput = RuntimeConfigArtifact &
Readonly<{ BUILD_ID?: string; RELEASE_ID?: string }>;
export function isDeploymentTarget(value: unknown): value is DeploymentTarget {
return (
typeof value === "string" &&
(DEPLOYMENT_TARGETS as readonly string[]).includes(value)
);
}
/**
* Every reason this artifact may not be deployed to `target`. An empty list is
* the only admission.
*/
export function findAdmissionViolations(
target: DeploymentTarget,
config: AdmissionInput,
): readonly AdmissionViolation[] {
const violations: AdmissionViolation[] = [];
if (config.APP_ENV !== target) {
violations.push({
field: "APP_ENV",
reason: `artifact declares ${config.APP_ENV} but is being admitted to ${target}`,
});
}
if (!PUBLIC_TARGETS.has(target)) return Object.freeze(violations);
if (config.AUTH_MODE !== "external") {
violations.push({
field: "AUTH_MODE",
reason: `${target} requires an external identity provider, not ${config.AUTH_MODE}`,
});
}
violations.push(...publicEndpointViolations("API_BASE_URL", config.API_BASE_URL));
if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) {
violations.push({
field: "TELEMETRY_ENDPOINT",
reason: "telemetry is enabled without an endpoint",
});
}
if (config.TELEMETRY_ENDPOINT) {
violations.push(
...publicEndpointViolations("TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT),
);
}
for (const field of ["BUILD_ID", "RELEASE_ID"] as const) {
const value = config[field];
if (typeof value !== "string" || value.length === 0) {
violations.push({ field, reason: `${target} requires a build identity` });
continue;
}
if (PLACEHOLDER_IDENTIFIERS.has(value.toLowerCase())) {
violations.push({
field,
reason: `${value} is a developer placeholder, not a released identity`,
});
}
}
return Object.freeze(violations);
}
function publicEndpointViolations(
field: string,
value: string,
): readonly AdmissionViolation[] {
let url: URL;
try {
url = new URL(value);
} catch {
return [{ field, reason: "is not an absolute URL" }];
}
const violations: AdmissionViolation[] = [];
if (url.protocol !== "https:") {
violations.push({ field, reason: `${url.protocol} is not permitted; use https` });
}
if (isNonPublicHost(url.hostname)) {
violations.push({
field,
reason: `${url.hostname} is not reachable from a user's browser`,
});
}
return violations;
}
/**
* Hosts that only resolve inside the machine or network that built the
* artifact. A deployment pointing at one of these is a developer configuration
* that escaped, not a production endpoint.
*/
function isNonPublicHost(hostname: string): boolean {
const host = hostname.toLowerCase().replace(/^\[|\]$/gu, "");
if (
host === "localhost" ||
host.endsWith(".localhost") ||
host === "::1" ||
host === "0.0.0.0" ||
host === "::"
) {
return true;
}
const octets = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(host);
if (!octets) return false;
const [first, second] = [Number(octets[1]), Number(octets[2])];
return (
first === 127 ||
first === 10 ||
(first === 192 && second === 168) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 169 && second === 254)
);
}
+5
View File
@@ -47,6 +47,11 @@ export const ENV_REGISTRY = Object.freeze({
RELEASE_MANIFEST_URL: runtime("public", true, "/release-manifest.json"),
// §3.5: overrides may only disable an installed capability, never enable one.
CAPABILITY_OVERRIDES: runtime("public", false, null),
// §3.5: likewise for features — subtractive, keyed by installed feature id.
FEATURE_OVERRIDES: runtime("public", false, null),
// §3.5: build-time narrowing of the product manifest. A feature left out
// here is not imported by any registry and never reaches the bundle.
VITE_PRODUCT_FEATURES: build("compile-time", false, null),
});
function build(
+143
View File
@@ -0,0 +1,143 @@
/**
* §3.5 / §6.1. Which product features this build contains, and which of them a
* deployment is allowed to switch off.
*
* Two different questions, deliberately answered by two different inputs:
*
* - **Installed** is a build-time decision. `VITE_PRODUCT_FEATURES` selects
* from the features this source tree declares; a feature left out contributes
* no route, no operation, no schema, no codec and no adapter, so nothing can
* reach it. It does *not* shrink the bundle: a static import cannot be undone
* by a value, and building the import graph from a configuration string is
* exactly what §3.5 forbids. Physical removal is FE-GATE-020's job — delete
* the feature directory and rebuild, which that gate proves still works.
* - **Active** is a runtime decision. `FEATURE_OVERRIDES` in the runtime config
* may take an installed feature out of service without a rebuild.
*
* Both directions are subtractive, and that is the invariant this module
* exists to hold: neither input can ever turn on a feature whose source is
* absent. A configuration document that could name a feature into existence
* would be a configuration document that chooses which code runs, and no
* dynamic import path is ever built from one.
*/
export type ProductFeatureOverride = "DEFAULT" | "DISABLED";
export type ProductFeatureState =
/** Compiled in and not disabled: the feature serves traffic. */
| "ACTIVE"
/** Compiled in, switched off by the runtime document. */
| "DISABLED_BY_CONFIG"
/** Not selected at build time; not in the bundle. */
| "NOT_INSTALLED";
export type ProductFeatureOverrideMap = Readonly<
Record<string, ProductFeatureOverride>
>;
export type ProductFeatureStatus = Readonly<{
featureId: string;
state: ProductFeatureState;
}>;
/** The shape every feature contract exposes to the manifest. */
export type SelectableProductFeature = Readonly<{ featureId: string }>;
/**
* The explicit "no product features" selection.
*
* A blank value cannot mean it: an unset CI variable expands to a blank string
* far too easily, and a build that silently shipped no features would be a very
* expensive way to learn that. Selecting nothing has to be something you typed.
*/
export const NO_PRODUCT_FEATURES = "none";
/**
* Applies the build-time selection to the features this source tree declares.
*
* An empty or absent declaration keeps everything, so an ordinary build needs
* no environment at all. A declaration naming something that is not compiled is
* refused rather than ignored: silently accepting it would let a deployment
* believe it had enabled a feature that does not exist.
*/
export function selectCompiledProductFeatures<
Feature extends SelectableProductFeature,
>(
compiled: readonly Feature[],
declared: string | undefined,
): readonly Feature[] {
const compiledIds = compiled.map((feature) => feature.featureId);
assertUniqueFeatureIds(compiledIds);
if (declared === undefined || declared.trim() === "") {
return Object.freeze([...compiled]);
}
if (declared.trim() === NO_PRODUCT_FEATURES) return Object.freeze([]);
const requested = declared
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (requested.length === 0) {
throw new Error(
`VITE_PRODUCT_FEATURES is set to ${JSON.stringify(declared)}, which names ` +
`no feature. Use "${NO_PRODUCT_FEATURES}" to select none, or leave it ` +
`unset to keep ${compiledIds.join(", ")}.`,
);
}
const unknown = requested.filter((id) => !compiledIds.includes(id));
if (unknown.length > 0) {
throw new Error(
`VITE_PRODUCT_FEATURES names features this build does not contain: ${unknown.join(
", ",
)}. Selection can only remove from ${compiledIds.join(", ")}.`,
);
}
return Object.freeze(
compiled.filter((feature) => requested.includes(feature.featureId)),
);
}
/**
* The state of every feature the source tree declares, given what was compiled
* and what the runtime document says.
*
* `compiledIds` is the full declared set rather than the installed one so a
* build that dropped a feature still reports it as `NOT_INSTALLED` instead of
* omitting it. An operator looking at the platform overview needs to see the
* difference between "off" and "never heard of it".
*/
export function resolveProductFeatures(
compiledIds: readonly string[],
installedIds: readonly string[],
overrides: ProductFeatureOverrideMap = {},
): readonly ProductFeatureStatus[] {
assertUniqueFeatureIds(compiledIds);
return Object.freeze(
[...compiledIds].sort().map((featureId) =>
Object.freeze({
featureId,
state: !installedIds.includes(featureId)
? ("NOT_INSTALLED" as const)
: overrides[featureId] === "DISABLED"
? ("DISABLED_BY_CONFIG" as const)
: ("ACTIVE" as const),
}),
),
);
}
/** The feature ids serving traffic right now. */
export function activeProductFeatureIds(
statuses: readonly ProductFeatureStatus[],
): readonly string[] {
return Object.freeze(
statuses
.filter((status) => status.state === "ACTIVE")
.map((status) => status.featureId),
);
}
function assertUniqueFeatureIds(ids: readonly string[]): void {
if (new Set(ids).size !== ids.length) {
throw new Error(`duplicate product feature id: ${ids.join(", ")}`);
}
}
+18
View File
@@ -52,6 +52,23 @@ export const capabilityOverrideArtifactSchema = z
OFFLINE_COMMANDS: "DEFAULT",
});
/**
* §3.5 / §6.1. A runtime switch that can take an installed feature out of
* service without a rebuild.
*
* Values are `DEFAULT | DISABLED` for the same reason `CAPABILITY_OVERRIDES`
* is: a configuration document may subtract from what the build installed and
* may never add to it. Keys are feature ids; naming a feature this build does
* not contain is inert rather than an error, so a shared configuration
* document can cover several builds.
*/
export const featureOverrideArtifactSchema = z
.record(
z.string().regex(/^[a-z][a-z0-9-]{0,63}$/u, "feature id is invalid"),
z.enum(["DEFAULT", "DISABLED"]),
)
.default({});
type RuntimeConfigArtifactDraft = Readonly<{
APP_ENV: "local" | "development" | "staging" | "production";
API_BASE_URL: string;
@@ -138,6 +155,7 @@ export const runtimeConfigV2ArtifactSchema = z
...runtimeConfigArtifactFields,
CONFIG_SCHEMA_VERSION: z.literal("2.0"),
CAPABILITY_OVERRIDES: capabilityOverrideArtifactSchema,
FEATURE_OVERRIDES: featureOverrideArtifactSchema,
})
.strict()
.superRefine(runtimeConfigArtifactInvariants);
+1 -1
View File
@@ -1,4 +1,4 @@
import { verifyCompatibilityTuple } from "../application/policies/compatibility.ts";
import { verifyCompatibilityTuple } from "./compatibility.ts";
export const RELEASE_TOKEN_REGISTRY = Object.freeze({
appVersion: token("appVersion", "manifest", "human release label"),
+15
View File
@@ -0,0 +1,15 @@
import type { AppFailure } from "./errors.ts";
/**
* The single success/failure carrier used across application input boundaries.
* Adapters map technology-specific errors to an application failure before
* constructing this value.
*
* It lives in `src/contracts` because it is shared vocabulary rather than
* application behaviour: contracts modules describe results too, and reaching
* up into `src/application` for the shape made the dependency between the two
* packages point both ways. `src/application/result.ts` re-exports it.
*/
export type Result<Value, Failure = AppFailure> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: Failure }>;
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Result } from "../application/result.ts";
import type { Result } from "./result.ts";
import type { QueryInvalidationTopic } from "./query-invalidation.ts";
import {
createBoundQueryKey,
@@ -4,6 +4,8 @@ import {
type InstalledContractPackageIdentity,
} from "../contracts/external-contract-runtime.ts";
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
/**
* §4.8. Static contract selection SSOT.
@@ -13,7 +15,11 @@ import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/con
* `src/features/<feature>/contracts/<service>-contract-contribution.ts`.
*/
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
Object.freeze([REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION]);
Object.freeze(
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION]
: [],
);
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
INSTALLED_CONTRACT_CONTRIBUTIONS,
+19 -6
View File
@@ -3,21 +3,34 @@ import { createReferenceFeatureInstalledInput } from "./reference-feature/adapte
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
import { createTechLogFeatureInstalledInput } from "./tech-log/adapters/create-tech-log-feature-input.ts";
import { TECH_LOG_FEATURE_ID } from "./tech-log/application/tech-log-feature-input.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
/**
* §3.5. Partial on purpose: a feature the manifest did not select supplies no
* driving input, so consumers have to narrow before calling one. A total type
* here would let feature code compile against an input that is not there.
*/
type InstalledFeatureInputs = Readonly<
Pick<
ApplicationFeatureInputs,
typeof REFERENCE_FEATURE_ID | typeof TECH_LOG_FEATURE_ID
>
// Template merge. The reference feature is optional — the manifest may
// narrow it out — so its input stays `Partial`. TechLog is this product's
// own UI and is always installed, so its input is total.
Partial<Pick<ApplicationFeatureInputs, typeof REFERENCE_FEATURE_ID>> &
Pick<ApplicationFeatureInputs, typeof TECH_LOG_FEATURE_ID>
>;
export function createInstalledFeatureInputs(
context: Parameters<typeof createReferenceFeatureInstalledInput>[0],
): InstalledFeatureInputs {
const referenceFeature = createReferenceFeatureInstalledInput(context);
const techLogFeature = createTechLogFeatureInstalledInput();
if (!INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)) {
// A build that narrowed the reference feature out still ships TechLog.
return Object.freeze({
[techLogFeature.featureId]: techLogFeature.input,
}) as InstalledFeatureInputs;
}
const referenceFeature = createReferenceFeatureInstalledInput(context);
return Object.freeze({
[referenceFeature.featureId]: referenceFeature.input,
[techLogFeature.featureId]: techLogFeature.input,
});
}) as InstalledFeatureInputs;
}
+58 -6
View File
@@ -1,10 +1,18 @@
import type { RouteRuntimeDefinition } from "../contracts/route-runtime-contract.ts";
import type { RouteDefinition } from "../contracts/routes.ts";
import {
PLATFORM_ROUTE_RUNTIME_CONTRACT,
type RouteRuntimeDefinition,
} from "../contracts/route-runtime-contract.ts";
import {
PLATFORM_ROUTE_REGISTRY,
type RouteDefinition,
} from "../contracts/routes.ts";
import {
composeSchemaRegistry,
PLATFORM_SCHEMA_REGISTRY,
} from "../contracts/schema-registry.ts";
import { REFERENCE_FEATURE_CONTRACT } from "./reference-feature/contracts/reference-feature-contract.ts";
import {
INSTALLED_PRODUCT_FEATURES,
} from "./installed-product-manifest.ts";
import {
composeApiOperations,
validateApiRuntimeBindings,
@@ -18,14 +26,36 @@ import {
TECH_LOG_ROUTE_SCHEMA_REGISTRY,
} from "./tech-log/contracts/tech-log-route-contract.ts";
export const INSTALLED_FEATURE_CONTRACTS = Object.freeze([
REFERENCE_FEATURE_CONTRACT,
]);
/**
* §3.5. Composed from the product manifest rather than from a literal list, so
* a feature the build did not select contributes no routes, no operations, no
* schemas and no messages — and is therefore not reachable from any registry.
*/
export const INSTALLED_FEATURE_CONTRACTS = INSTALLED_PRODUCT_FEATURES;
/**
* Template merge. Two intents meet here and both are kept.
*
* The template's manifest is retained above, so narrowing the selection still
* withdraws a feature's operations, schemas, mappers and invalidation edges.
*
* The route registry is *not* composed from `contract.routes`, though. The
* reference feature still declares `REFERENCE_RESOURCE_*` routes, but this
* product deleted their screens during the UI migration, so reducing over those
* routes would register paths with no component behind them — a deep link that
* resolves to nothing. The registry therefore lists the platform routes (empty
* on this branch) and TechLog's, which are the routes that actually exist.
*
* Re-adding the reference screens, or dropping the reference feature's route
* declarations, is a product decision; a merge only has to avoid inventing a
* third state where a route is registered and unmountable.
*/
export const ROUTE_REGISTRY = Object.freeze({
...PLATFORM_ROUTE_REGISTRY,
...TECH_LOG_ROUTE_REGISTRY,
}) satisfies Readonly<Record<string, RouteDefinition>>;
export const ROUTE_RUNTIME_CONTRACT = Object.freeze({
...PLATFORM_ROUTE_RUNTIME_CONTRACT,
...TECH_LOG_ROUTE_RUNTIME_CONTRACT,
}) satisfies Readonly<Record<string, RouteRuntimeDefinition>>;
export const API_OPERATIONS = composeApiOperations(
@@ -73,6 +103,28 @@ export const API_RUNTIME_BINDINGS_VALID = validateApiRuntimeBindings(
MAPPER_REGISTRY,
);
/**
* §3.5. Which feature owns each route.
*
* A route contributed by a feature disappears with it at build time, and has to
* be withdrawn from navigation and from the router when the runtime document
* disables that feature. Platform routes have no owner and are always present.
*/
export const ROUTE_FEATURE_OWNER: Readonly<Record<string, string>> =
Object.freeze(
Object.fromEntries(
INSTALLED_FEATURE_CONTRACTS.flatMap((contract) =>
Object.keys(contract.routes)
// Template merge. Ownership describes routes this product actually
// registered. The reference feature still declares screens this
// product removed; attributing an unregistered route to a feature
// would claim the kill switch governs something no router can mount.
.filter((routeId) => routeId in ROUTE_REGISTRY)
.map((routeId) => [routeId, contract.featureId] as const),
),
),
);
export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY)
.filter(isNavigableRoute)
+12 -2
View File
@@ -1,13 +1,23 @@
import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-message-catalog.ts";
import { TECH_LOG_MESSAGE_CATALOGS } from "./tech-log/contracts/tech-log-message-catalog.ts";
/**
* §3.5. Messages are deliberately *not* gated on the manifest. The catalog's
* key type is what makes `message()` total, so dropping keys would turn every
* lookup partial for the sake of a few unreachable strings.
*
* Template merge. TechLog's catalog is merged in on the same terms: it is this
* product's own UI and is never narrowed out.
*/
const reference = REFERENCE_MESSAGE_CATALOGS;
export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({
...REFERENCE_MESSAGE_CATALOGS["ko-KR"],
...reference["ko-KR"],
...TECH_LOG_MESSAGE_CATALOGS["ko-KR"],
}),
"en-US": Object.freeze({
...REFERENCE_MESSAGE_CATALOGS["en-US"],
...reference["en-US"],
...TECH_LOG_MESSAGE_CATALOGS["en-US"],
}),
} as const);
@@ -1,12 +1,26 @@
import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
import { TECH_LOG_ROUTE_CODECS } from "./tech-log/presentation/tech-log-route-codecs.ts";
import { TECH_LOG_ROUTE_RUNTIME } from "./tech-log/presentation/tech-log-route-runtime.tsx";
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
/**
* §3.5. A feature the manifest did not select contributes no codec and no route
* component, so the router has nothing to mount for it.
*
* Template merge. The template gated the reference feature's codecs and route
* components on the manifest. This product deleted that feature's presentation
* layer during the UI migration, so there is nothing left to gate — the import
* would not resolve. The gating belongs back here the day those screens return.
* TechLog is spread unconditionally: it is this product's own UI, not a
* selectable feature. The platform entries are empty on this branch and are
* kept in the spread so a later platform route lands here without another edit.
*/
export const ROUTE_CODECS = Object.freeze({
...PLATFORM_ROUTE_CODECS,
...TECH_LOG_ROUTE_CODECS,
});
export const ROUTE_RUNTIME = Object.freeze({
...PLATFORM_ROUTE_RUNTIME,
...TECH_LOG_ROUTE_RUNTIME,
});
@@ -0,0 +1,63 @@
import {
selectCompiledProductFeatures,
type SelectableProductFeature,
} from "../contracts/product-features.ts";
import { REFERENCE_FEATURE_CONTRACT } from "./reference-feature/contracts/reference-feature-contract.ts";
/**
* §3.5. The product manifest: the single declaration of which features this
* build contains.
*
* Before this file the reference feature was spread directly into the route,
* API, schema and message registries, so the only way to ship without it was to
* edit five registries by hand and hope nothing still referred to it. The
* removability gate proved that editing worked; nothing made it a decision you
* could express.
*
* Adding an entry here is what installs a feature. `VITE_PRODUCT_FEATURES` may
* then narrow the list at build time — a comma-separated subset, `none` for an
* empty selection, absent meaning "all of them". A narrowed-out feature reaches
* no registry, so it is not routed, not navigable and not callable.
*
* It is not deleted. The import above is static, and a value cannot undo a
* static import; making the import itself conditional on configuration is the
* thing §3.5 exists to prevent. FE-GATE-020 is what proves the feature can be
* physically removed, by removing it and rebuilding the whole project.
*/
const COMPILED_PRODUCT_FEATURES = Object.freeze([
REFERENCE_FEATURE_CONTRACT,
] as const);
/**
* Every feature this source tree declares, selected or not. The platform
* overview reports on this set so an operator can tell a feature that was built
* out from one that never existed.
*/
export const COMPILED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze(
COMPILED_PRODUCT_FEATURES.map((feature) => feature.featureId),
);
/**
* `import.meta.env` exists in a Vite build and not under Node, and this module
* is read by release scripts as well as by the app. A missing environment means
* "nothing was narrowed", which is the same answer a plain developer build
* gives.
*/
function declaredFeatureSelection(): string | undefined {
const environment = (
import.meta as unknown as {
env?: Readonly<Record<string, string | undefined>>;
}
).env;
return environment?.["VITE_PRODUCT_FEATURES"];
}
export const INSTALLED_PRODUCT_FEATURES = selectCompiledProductFeatures(
COMPILED_PRODUCT_FEATURES as readonly SelectableProductFeature[],
declaredFeatureSelection(),
) as readonly (typeof COMPILED_PRODUCT_FEATURES)[number][];
export const INSTALLED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze(
INSTALLED_PRODUCT_FEATURES.map((feature) => feature.featureId),
);
+8
View File
@@ -79,6 +79,10 @@ const PLATFORM_KO_MESSAGES = {
"route.invalid.title": "올바르지 않은 주소입니다.",
"route.invalid.description": "주소의 경로 또는 검색 조건을 확인해 주세요.",
"route.invalid.action": "안전한 탐색 링크를 사용해 주세요.",
"route.disabledFeature.title": "현재 사용할 수 없는 기능입니다.",
"route.disabledFeature.description":
"이 기능은 배포 설정에서 중지되어 있습니다. 코드에는 포함되어 있으며 운영자가 다시 켤 수 있습니다.",
"route.disabledFeature.action": "다른 탐색 링크를 사용해 주세요.",
"route.auth.integration.title": "로그인 연동이 필요합니다.",
"route.auth.integration.description":
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
@@ -233,6 +237,10 @@ const PLATFORM_EN_MESSAGES = {
"route.invalid.title": "This address is invalid.",
"route.invalid.description": "Check the path and search parameters.",
"route.invalid.action": "Use a safe navigation link.",
"route.disabledFeature.title": "This feature is not available right now.",
"route.disabledFeature.description":
"The deployment configuration has switched it off. It is still part of this build and an operator can switch it back on.",
"route.disabledFeature.action": "Use another navigation link.",
"route.auth.integration.title": "Sign-in integration is required.",
"route.auth.integration.description":
"This protected route is available after an external authentication owner is connected.",
+10 -1
View File
@@ -5,6 +5,7 @@ import type { SessionState } from "../../application/ports/in/application-api.ts
import { normalizeColorSchemePreference } from "../../application/policies/color-scheme.ts";
import {
NAVIGATION_ROUTES,
ROUTE_FEATURE_OWNER,
routePath,
} from "../../features/installed-feature-contracts.ts";
import {
@@ -19,6 +20,7 @@ import {
useLocale,
type MessageKey,
} from "../i18n/index.ts";
import { useApplication } from "../providers/application-provider.tsx";
import { useSession } from "../providers/session-provider.tsx";
import { useTheme } from "../providers/theme-provider.tsx";
@@ -166,10 +168,17 @@ export function AppShell() {
function PrimaryNavigation({ id }: Readonly<{ id: string }>) {
const { resolve, message } = useLocale();
const { runtime } = useApplication();
// §3.5. A feature the runtime document disabled does not advertise itself.
// The router refuses its routes too, so this is presentation, not the switch.
const routes = NAVIGATION_ROUTES.filter((definition) => {
const owner = ROUTE_FEATURE_OWNER[definition.routeId];
return owner === undefined || runtime.isFeatureActive(owner);
});
return (
<nav id={id} aria-label={message("shell.primaryNavigation")}>
<ul className="app-navigation">
{NAVIGATION_ROUTES.map((definition) => (
{routes.map((definition) => (
<li key={definition.routeId}>
<NavLink
className={({ isActive }) =>
+35 -2
View File
@@ -17,7 +17,10 @@ import {
useSearchParams,
} from "react-router-dom";
import { ROUTE_REGISTRY } from "../../features/installed-feature-contracts.ts";
import {
ROUTE_FEATURE_OWNER,
ROUTE_REGISTRY,
} from "../../features/installed-feature-contracts.ts";
import type {
RouteDefinition,
RouteLayoutGroup,
@@ -104,6 +107,26 @@ function InvalidRouteSurface({ code }: { code: string }) {
);
}
/**
* §3.5. A route whose feature the runtime document switched off. It answers as
* "not available" rather than rendering the feature or crashing, so disabling a
* feature is a deployment action and not an outage.
*/
function DisabledFeatureSurface({ featureId }: { featureId: string }) {
const { message } = useLocale();
return (
<section className="ui-page" data-surface="disabled-feature">
<PageHeader
title={message("route.disabledFeature.title")}
description={message("route.disabledFeature.description")}
/>
<p data-disabled-feature={featureId}>
{message("route.disabledFeature.action")}
</p>
</section>
);
}
function RouteLifecycle({
definition,
buildId,
@@ -269,7 +292,17 @@ function RegisteredRoute<RouteIdValue extends string>({
const params = useParams();
const [search] = useSearchParams();
const location = useLocation();
const { diagnostics, recovery } = useApplication();
const { diagnostics, runtime: platformRuntime, recovery } = useApplication();
// §3.5 (template merge). A feature the runtime document disabled is out of
// service, not merely hidden: withdrawing it from navigation alone would
// leave a typed deep link that still mounts it. The template looked the
// definition and the runtime component up by id here; on this branch both
// arrive as props from the grouped route contract, so only the kill switch
// is adopted.
const owner = ROUTE_FEATURE_OWNER[routeId];
if (owner !== undefined && !platformRuntime.isFeatureActive(owner)) {
return <DisabledFeatureSurface featureId={owner} />;
}
const parsed = parseRouteInputFromContract(
routeId,
definition,