diff --git a/config/runtime/development.json b/config/runtime/development.json index 02901fe..63006b8 100644 --- a/config/runtime/development.json +++ b/config/runtime/development.json @@ -12,5 +12,8 @@ "WEB_WORKER": "DEFAULT", "SERVICE_WORKER": "DEFAULT", "OFFLINE_COMMANDS": "DEFAULT" + }, + "FEATURE_OVERRIDES": { + "reference-feature": "DEFAULT" } } diff --git a/config/runtime/local.json b/config/runtime/local.json index 38c616b..952aa3c 100644 --- a/config/runtime/local.json +++ b/config/runtime/local.json @@ -12,5 +12,8 @@ "WEB_WORKER": "DEFAULT", "SERVICE_WORKER": "DEFAULT", "OFFLINE_COMMANDS": "DEFAULT" + }, + "FEATURE_OVERRIDES": { + "reference-feature": "DEFAULT" } } diff --git a/config/runtime/production.json b/config/runtime/production.json index 87943ce..46ad778 100644 --- a/config/runtime/production.json +++ b/config/runtime/production.json @@ -13,5 +13,8 @@ "WEB_WORKER": "DEFAULT", "SERVICE_WORKER": "DEFAULT", "OFFLINE_COMMANDS": "DEFAULT" + }, + "FEATURE_OVERRIDES": { + "reference-feature": "DEFAULT" } } diff --git a/config/runtime/staging.json b/config/runtime/staging.json index b7d7887..861f87b 100644 --- a/config/runtime/staging.json +++ b/config/runtime/staging.json @@ -13,5 +13,8 @@ "WEB_WORKER": "DEFAULT", "SERVICE_WORKER": "DEFAULT", "OFFLINE_COMMANDS": "DEFAULT" + }, + "FEATURE_OVERRIDES": { + "reference-feature": "DEFAULT" } } diff --git a/public/config.json b/public/config.json index e2e876d..e8466b6 100644 --- a/public/config.json +++ b/public/config.json @@ -14,5 +14,8 @@ "WEB_WORKER": "DEFAULT", "SERVICE_WORKER": "DEFAULT", "OFFLINE_COMMANDS": "DEFAULT" + }, + "FEATURE_OVERRIDES": { + "reference-feature": "DEFAULT" } } diff --git a/src/application/create-application.ts b/src/application/create-application.ts index 81f3ad3..9bd931c 100644 --- a/src/application/create-application.ts +++ b/src/application/create-application.ts @@ -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({ diff --git a/src/application/ports/in/application-api.ts b/src/application/ports/in/application-api.ts index 602093c..755de99 100644 --- a/src/application/ports/in/application-api.ts +++ b/src/application/ports/in/application-api.ts @@ -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<{ diff --git a/src/application/ports/out/application-output-ports.ts b/src/application/ports/out/application-output-ports.ts index 352a971..9ea72f9 100644 --- a/src/application/ports/out/application-output-ports.ts +++ b/src/application/ports/out/application-output-ports.ts @@ -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 }>; }>; diff --git a/src/application/ports/product-features-port.ts b/src/application/ports/product-features-port.ts new file mode 100644 index 0000000..2e64d5c --- /dev/null +++ b/src/application/ports/product-features-port.ts @@ -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; +}>; diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts index 6a653a2..baed010 100644 --- a/src/bootstrap/runtime-adapters.ts +++ b/src/bootstrap/runtime-adapters.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; @@ -486,6 +515,7 @@ export async function createRuntimeAdapters( telemetry, releaseInfo, runtimeCapabilities, + productFeatures, navigation, }), infrastructure: Object.freeze({ diff --git a/src/bootstrap/runtime-config-schema.ts b/src/bootstrap/runtime-config-schema.ts index a7b1d68..7e87bfe 100644 --- a/src/bootstrap/runtime-config-schema.ts +++ b/src/bootstrap/runtime-config-schema.ts @@ -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 ? {} : { diff --git a/src/contracts/env.ts b/src/contracts/env.ts index 95679f4..5c00a61 100644 --- a/src/contracts/env.ts +++ b/src/contracts/env.ts @@ -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( diff --git a/src/contracts/product-features.ts b/src/contracts/product-features.ts new file mode 100644 index 0000000..6ab6192 --- /dev/null +++ b/src/contracts/product-features.ts @@ -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 +>; + +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(", ")}`); + } +} diff --git a/src/contracts/release-artifacts.ts b/src/contracts/release-artifacts.ts index 05f2b47..7cd038c 100644 --- a/src/contracts/release-artifacts.ts +++ b/src/contracts/release-artifacts.ts @@ -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); diff --git a/src/features/installed-contract-contributions.ts b/src/features/installed-contract-contributions.ts index 3483efc..7f1f329 100644 --- a/src/features/installed-contract-contributions.ts +++ b/src/features/installed-contract-contributions.ts @@ -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//contracts/-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, diff --git a/src/features/installed-feature-adapters.ts b/src/features/installed-feature-adapters.ts index ba6c8c9..ec1f1d4 100644 --- a/src/features/installed-feature-adapters.ts +++ b/src/features/installed-feature-adapters.ts @@ -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 + Partial> >; export function createInstalledFeatureInputs( context: Parameters[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, diff --git a/src/features/installed-feature-contracts.ts b/src/features/installed-feature-contracts.ts index e5ce974..767a270 100644 --- a/src/features/installed-feature-contracts.ts +++ b/src/features/installed-feature-contracts.ts @@ -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> = + 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) diff --git a/src/features/installed-feature-messages.ts b/src/features/installed-feature-messages.ts index 99ddc3f..50b80b1 100644 --- a/src/features/installed-feature-messages.ts +++ b/src/features/installed-feature-messages.ts @@ -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); diff --git a/src/features/installed-feature-runtimes.tsx b/src/features/installed-feature-runtimes.tsx index cc14993..46757ad 100644 --- a/src/features/installed-feature-runtimes.tsx +++ b/src/features/installed-feature-runtimes.tsx @@ -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 : {}), }); diff --git a/src/features/installed-product-manifest.ts b/src/features/installed-product-manifest.ts new file mode 100644 index 0000000..842f4c0 --- /dev/null +++ b/src/features/installed-product-manifest.ts @@ -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>; + } + ).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), +); diff --git a/src/presentation/examples/platform-overview-page.tsx b/src/presentation/examples/platform-overview-page.tsx index ce7b1d4..e646809 100644 --- a/src/presentation/examples/platform-overview-page.tsx +++ b/src/presentation/examples/platform-overview-page.tsx @@ -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() { })} + +
+
+

제품 기능

+

+ 제품 매니페스트가 이 빌드에 어떤 기능이 설치되었는지 정하고, 런타임 + 설정은 설치된 기능을 끌 수만 있습니다. 두 입력 모두 감산만 하므로 + 설정 문서가 없는 기능을 켜 낼 수는 없습니다. 그래서 「빌드에서 빠진 + 기능」과 「운영자가 끈 기능」이 여기서 구분됩니다. +

+
+
+ {features.map((status) => { + const badge = FEATURE_BADGE[status.state]; + return ( + {badge.text}} + > +

+ {badge.description} +

+
+ ); + })} +
+
); } diff --git a/src/presentation/i18n/catalog.ts b/src/presentation/i18n/catalog.ts index 71033c4..9bf90c7 100644 --- a/src/presentation/i18n/catalog.ts +++ b/src/presentation/i18n/catalog.ts @@ -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.", diff --git a/src/presentation/layouts/app-shell.tsx b/src/presentation/layouts/app-shell.tsx index ff7be35..366ba56 100644 --- a/src/presentation/layouts/app-shell.tsx +++ b/src/presentation/layouts/app-shell.tsx @@ -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 (