chore: sync the frontend template from a0fbafb to 5434760
Carries eight template commits: the provider sandbox actually running, release
admission to a named environment, the product feature manifest with its runtime
kill switch, architecture and documentation rules that match what is enforced,
the removability fixtures, and the browser, visual and performance evidence.
Product identity is unchanged. `package.json` keeps `tech-log-frontend` and the
catalog keeps the Tech Log naming; the home page was not in the delta. The
visual baselines are this product's own — the template's were excluded from the
transplant and these were regenerated here, where the only difference is the
platform overview's new product-feature section.
What this repository gains operationally: `config/runtime/{local,development,
staging,production}.json` with `FE-GATE-027` refusing an artifact whose runtime
document does not match the environment it is being admitted to, and
`FEATURE_OVERRIDES` for taking an installed feature out of service without a
rebuild.
Verified here: eight gates green, build green, visual 5/5, and 1,858 of 1,859
tests in the suites that do not need a sandbox — the one failure passes in
isolation and is a jsdom lazy-chunk timeout under parallel load. The provider
suites cannot run on this machine at all: `kernel.apparmor_restrict_unprivileged
_userns=1` makes `bwrap --unshare-net` fail, reproducible without any code from
either repository.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
325a2a0843
commit
bdee07a93b
@@ -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,
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import type { ApplicationFeatureInputs } from "../application/ports/in/application-api.ts";
|
||||
import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.ts";
|
||||
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.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>
|
||||
Partial<Pick<ApplicationFeatureInputs, typeof REFERENCE_FEATURE_ID>>
|
||||
>;
|
||||
|
||||
export function createInstalledFeatureInputs(
|
||||
context: Parameters<typeof createReferenceFeatureInstalledInput>[0],
|
||||
): InstalledFeatureInputs {
|
||||
if (!INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)) {
|
||||
return Object.freeze({});
|
||||
}
|
||||
const referenceFeature = createReferenceFeatureInstalledInput(context);
|
||||
return Object.freeze({
|
||||
[referenceFeature.featureId]: referenceFeature.input,
|
||||
|
||||
@@ -4,7 +4,9 @@ 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,
|
||||
@@ -13,18 +15,27 @@ import { validateRestProfileBindings } from "../contracts/rest-profiles.ts";
|
||||
import { composeRuntimeSchemaCodecs } from "../contracts/schema-registry.ts";
|
||||
import { composeBoundaryMapperRegistry } from "../contracts/boundary-mapper.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;
|
||||
|
||||
export const ROUTE_REGISTRY = Object.freeze({
|
||||
...PLATFORM_ROUTE_REGISTRY,
|
||||
...REFERENCE_FEATURE_CONTRACT.routes,
|
||||
});
|
||||
export const ROUTE_RUNTIME_CONTRACT = Object.freeze({
|
||||
...PLATFORM_ROUTE_RUNTIME_CONTRACT,
|
||||
...REFERENCE_FEATURE_CONTRACT.routeRuntimeContracts,
|
||||
});
|
||||
export const ROUTE_REGISTRY = Object.freeze(
|
||||
INSTALLED_FEATURE_CONTRACTS.reduce(
|
||||
(registry, contract) => ({ ...registry, ...contract.routes }),
|
||||
{ ...PLATFORM_ROUTE_REGISTRY },
|
||||
),
|
||||
) as typeof PLATFORM_ROUTE_REGISTRY &
|
||||
(typeof INSTALLED_PRODUCT_FEATURES)[number]["routes"];
|
||||
export const ROUTE_RUNTIME_CONTRACT = Object.freeze(
|
||||
INSTALLED_FEATURE_CONTRACTS.reduce(
|
||||
(registry, contract) => ({ ...registry, ...contract.routeRuntimeContracts }),
|
||||
{ ...PLATFORM_ROUTE_RUNTIME_CONTRACT },
|
||||
),
|
||||
) as typeof PLATFORM_ROUTE_RUNTIME_CONTRACT &
|
||||
(typeof INSTALLED_PRODUCT_FEATURES)[number]["routeRuntimeContracts"];
|
||||
export const API_OPERATIONS = composeApiOperations(
|
||||
INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.apiOperations),
|
||||
);
|
||||
@@ -69,6 +80,24 @@ 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).map(
|
||||
(routeId) => [routeId, contract.featureId] as const,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
export const NAVIGATION_ROUTES = Object.freeze(
|
||||
Object.values(ROUTE_REGISTRY)
|
||||
.filter(isNavigableRoute)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-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.
|
||||
*/
|
||||
const reference = REFERENCE_MESSAGE_CATALOGS;
|
||||
|
||||
export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
|
||||
"ko-KR": Object.freeze({
|
||||
...REFERENCE_MESSAGE_CATALOGS["ko-KR"],
|
||||
...reference["ko-KR"],
|
||||
}),
|
||||
"en-US": Object.freeze({
|
||||
...REFERENCE_MESSAGE_CATALOGS["en-US"],
|
||||
...reference["en-US"],
|
||||
}),
|
||||
} as const);
|
||||
|
||||
@@ -4,13 +4,25 @@ import {
|
||||
REFERENCE_FEATURE_ROUTE_CODECS,
|
||||
REFERENCE_FEATURE_ROUTE_RUNTIME,
|
||||
} from "./reference-feature/presentation/reference-feature-runtime.tsx";
|
||||
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
|
||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||
|
||||
/**
|
||||
* §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. The module is still
|
||||
* linked — a static import cannot be undone by a value — which is why physical
|
||||
* removal is FE-GATE-020's job and this is deselection, not deletion.
|
||||
*/
|
||||
const referenceSelected = INSTALLED_PRODUCT_FEATURE_IDS.includes(
|
||||
REFERENCE_FEATURE_ID,
|
||||
);
|
||||
|
||||
export const ROUTE_CODECS = Object.freeze({
|
||||
...PLATFORM_ROUTE_CODECS,
|
||||
...REFERENCE_FEATURE_ROUTE_CODECS,
|
||||
...(referenceSelected ? REFERENCE_FEATURE_ROUTE_CODECS : {}),
|
||||
});
|
||||
|
||||
export const ROUTE_RUNTIME = Object.freeze({
|
||||
...PLATFORM_ROUTE_RUNTIME,
|
||||
...REFERENCE_FEATURE_ROUTE_RUNTIME,
|
||||
...(referenceSelected ? REFERENCE_FEATURE_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),
|
||||
);
|
||||
@@ -226,6 +226,30 @@ function capabilityBadge(
|
||||
return { text: `활성 (${status.active})`, variant: "success" };
|
||||
}
|
||||
|
||||
/**
|
||||
* §3.5. The three states an operator has to be able to tell apart: shipped and
|
||||
* serving, shipped and switched off, and not in this build at all.
|
||||
*/
|
||||
const FEATURE_BADGE = Object.freeze({
|
||||
ACTIVE: Object.freeze({
|
||||
variant: "success" as const,
|
||||
text: "사용 중",
|
||||
description: "이 빌드에 설치되어 있고 런타임 설정이 끄지 않았습니다.",
|
||||
}),
|
||||
DISABLED_BY_CONFIG: Object.freeze({
|
||||
variant: "warning" as const,
|
||||
text: "설정으로 중지",
|
||||
description:
|
||||
"이 빌드에 포함되어 있으나 런타임 설정이 껐습니다. 재빌드 없이 다시 켤 수 있습니다.",
|
||||
}),
|
||||
NOT_INSTALLED: Object.freeze({
|
||||
variant: "neutral" as const,
|
||||
text: "미설치",
|
||||
description:
|
||||
"빌드 시 제품 매니페스트가 선택하지 않았습니다. 런타임 설정으로는 켤 수 없습니다.",
|
||||
}),
|
||||
});
|
||||
|
||||
function buildOperationRows(): readonly OperationRow[] {
|
||||
return Object.freeze(
|
||||
[...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.values()].map(
|
||||
@@ -273,6 +297,7 @@ export default function PlatformOverviewPage() {
|
||||
const routes = Object.values(ROUTE_REGISTRY);
|
||||
const operations = buildOperationRows();
|
||||
const capabilities = runtime.getCapabilitySnapshot();
|
||||
const features = runtime.getFeatureSnapshot();
|
||||
const activeCapabilityCount = capabilities.filter(
|
||||
(status) => status.active > 0,
|
||||
).length;
|
||||
@@ -479,6 +504,37 @@ export default function PlatformOverviewPage() {
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-features-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-features-title">제품 기능</h2>
|
||||
<p>
|
||||
제품 매니페스트가 이 빌드에 어떤 기능이 설치되었는지 정하고, 런타임
|
||||
설정은 설치된 기능을 끌 수만 있습니다. 두 입력 모두 감산만 하므로
|
||||
설정 문서가 없는 기능을 켜 낼 수는 없습니다. 그래서 「빌드에서 빠진
|
||||
기능」과 「운영자가 끈 기능」이 여기서 구분됩니다.
|
||||
</p>
|
||||
</header>
|
||||
<div className="component-grid component-grid--two">
|
||||
{features.map((status) => {
|
||||
const badge = FEATURE_BADGE[status.state];
|
||||
return (
|
||||
<Card
|
||||
key={status.featureId}
|
||||
title={status.featureId}
|
||||
footer={<Badge variant={badge.variant}>{badge.text}</Badge>}
|
||||
>
|
||||
<p data-product-feature={status.featureId}>
|
||||
{badge.description}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
|
||||
import {
|
||||
getRoute,
|
||||
ROUTE_FEATURE_OWNER,
|
||||
ROUTE_REGISTRY,
|
||||
} from "../../features/installed-feature-contracts.ts";
|
||||
import type { RouteDefinition } from "../../contracts/routes.ts";
|
||||
@@ -96,6 +97,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,
|
||||
@@ -245,11 +266,22 @@ function RegisteredRoute({
|
||||
buildId: string;
|
||||
}) {
|
||||
const definition = getRoute(routeId);
|
||||
const runtime = ROUTE_RUNTIME[routeId];
|
||||
const params = useParams();
|
||||
const [search] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const { diagnostics, recovery } = useApplication();
|
||||
const { diagnostics, runtime: platformRuntime, recovery } = useApplication();
|
||||
// §3.5. 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.
|
||||
const owner = ROUTE_FEATURE_OWNER[routeId];
|
||||
if (owner !== undefined && !platformRuntime.isFeatureActive(owner)) {
|
||||
return <DisabledFeatureSurface featureId={owner} />;
|
||||
}
|
||||
// The registry and the runtime table are composed from the same manifest, so
|
||||
// a route without a component means the two disagree — refuse rather than
|
||||
// crash the shell.
|
||||
const runtime = ROUTE_RUNTIME[routeId];
|
||||
if (!runtime) return <DisabledFeatureSurface featureId={owner ?? routeId} />;
|
||||
const parsed = parseRouteInput(routeId, params, search);
|
||||
if (!parsed.success) return <InvalidRouteSurface code={parsed.code} />;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user