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:
@@ -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() {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import type { Result } from "../application/result.ts";
|
||||
import type { Result } from "./result.ts";
|
||||
|
||||
export type CursorPage<Value> = Readonly<{
|
||||
items: readonly Value[];
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(", ")}`);
|
||||
}
|
||||
}
|
||||
@@ -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,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"),
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user