Compare commits
3
Commits
0a97d235e4
...
5434760ddf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5434760ddf | ||
|
|
9ca5c3f668 | ||
|
|
711d61e73f |
@@ -10,6 +10,11 @@ import { ApplicationProvider } from "../src/presentation/providers/application-p
|
||||
import { SessionProvider } from "../src/presentation/providers/session-provider.tsx";
|
||||
import { ThemeProvider } from "../src/presentation/providers/theme-provider.tsx";
|
||||
import "../src/presentation/styles/theme.css";
|
||||
import { resolveProductFeatures } from "../src/contracts/product-features.ts";
|
||||
import {
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
} from "../src/features/installed-product-manifest.ts";
|
||||
|
||||
const preferences = new Map<string, unknown>();
|
||||
const application = createApplication({
|
||||
@@ -45,6 +50,16 @@ const application = createApplication({
|
||||
routeChunks: {},
|
||||
}),
|
||||
},
|
||||
// Storybook renders components, not a product: every declared feature is
|
||||
// shown as active so a story is never blank because of a deployment switch.
|
||||
productFeatures: {
|
||||
getSnapshot: () =>
|
||||
resolveProductFeatures(
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
),
|
||||
isActive: () => true,
|
||||
},
|
||||
runtimeCapabilities: {
|
||||
getSnapshot: () =>
|
||||
Object.freeze(
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,8 @@
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,8 @@
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,21 +491,51 @@ below names the defect, not the symptom.
|
||||
| `OPS-18` | architecture | `PARTIAL` | Generic presentation still reads the installed-feature registries. The rule freezes the exact set of modules doing so today; a new edge fails. Lifting the assembly into `bootstrap` is not done. |
|
||||
| `OPS-19` | documentation | `FIXED` | README and the manual accessibility checklist both claimed six routes while ten were registered, leaving four screens outside the declared manual review scope. The list is now derived from the route registry by `verify:documentation`. |
|
||||
|
||||
### Product feature selection (2026-08-15, second pass)
|
||||
|
||||
| id | disposition | what changed |
|
||||
| --- | --- | --- |
|
||||
| `OPS-20` | `FIXED` | Which features a build contains is now a declared manifest rather than five registries spreading a literal. `VITE_PRODUCT_FEATURES` narrows it at build time; a test fails if a new registry forgets to consult it. |
|
||||
| `OPS-21` | `FIXED` | `FEATURE_OVERRIDES` in the runtime document takes an installed feature out of service without a rebuild. The router refuses its routes, not just the navigation, so a typed deep link cannot still mount it. |
|
||||
| `OPS-22` | `FIXED` | Both inputs are subtractive by vocabulary: the override enum has no `ENABLED`, and a build-time selection naming a feature the source tree does not declare is refused rather than ignored. |
|
||||
| `OPS-23` | `FIXED` | A sandbox that fails to launch now reports why. The supervisor consumed the child's output only to enforce a byte cap and discarded it, so a host restriction surfaced as an unexplained `exit=1`. Lines the sandbox tooling itself emits are kept; provider output is still discarded. |
|
||||
|
||||
An env var does **not** shrink the bundle, and the code says so. A static import
|
||||
cannot be undone by a value, and making the import graph depend on a
|
||||
configuration string is what §3.5 exists to prevent. Measured: `none` changes
|
||||
the output by 58 bytes. Physical removal is FE-GATE-020's job.
|
||||
|
||||
### Host restriction discovered during this pass
|
||||
|
||||
`bwrap --unshare-net` no longer works on this machine:
|
||||
|
||||
```
|
||||
$ printf '%s\0' --unshare-net --ro-bind /usr /usr ... | bwrap --args 3 -- /bin/true
|
||||
bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted
|
||||
$ sysctl kernel.apparmor_restrict_unprivileged_userns
|
||||
kernel.apparmor_restrict_unprivileged_userns = 1
|
||||
```
|
||||
|
||||
That reproduction contains none of this repository's code. Earlier in the same
|
||||
session the identical sandbox ran to completion, so the restriction became
|
||||
active partway through. While it holds, 16 of the 108 provider tests cannot run
|
||||
here — they need a sandbox the kernel will not grant. They are not counted as
|
||||
green and not counted as product defects; under a host that permits the
|
||||
namespace the same file was 107/108.
|
||||
|
||||
### Still red after this pass
|
||||
|
||||
`tests/unit/ci-artifact-contract.test.ts` → *applies effective aggregate cgroup
|
||||
limits without exposing command or credentials*. It reads the live process tree
|
||||
and cgroup of a running sandbox, and the supervisor now completes a whole run in
|
||||
well under a second while `systemctl show` and `ps` each cost hundreds of
|
||||
milliseconds, so the observation loses the race. It was already red before this
|
||||
work and is not a product defect; the assertions it makes about cgroup limits
|
||||
and credential exposure are not currently proven by an automated run.
|
||||
*applies effective aggregate cgroup limits without exposing command or
|
||||
credentials* was rewritten. It used to read the live process tree with one
|
||||
`ps` per pid and assert mid-run, which lost a race against a sandbox that now
|
||||
completes in a few hundred milliseconds; it records the tree from `/proc` every
|
||||
5ms and asserts on the recording after the run. That restructuring is also what
|
||||
revealed the host restriction above — the supervisor had been failing to launch
|
||||
the sandbox and the test was dying on the observation first.
|
||||
|
||||
Two more time out at their 10s budget when the whole suite runs in parallel on
|
||||
a loaded machine and pass in isolation, repeatedly: *expires an uncommitted
|
||||
lease only after cleaning every owned object* and *requires every external
|
||||
expected identity variable at the exact promotion CLI*. They are recorded as
|
||||
environment-limited, not as green.
|
||||
Tests that spawn processes, build archives and sign evidence were given a
|
||||
30s budget instead of the 10s default sized for pure-JS unit tests. The default
|
||||
was not raised: that would hide a genuinely hung test.
|
||||
|
||||
### FE-GATE-020 after this pass
|
||||
|
||||
|
||||
@@ -14,5 +14,8 @@
|
||||
"WEB_WORKER": "DEFAULT",
|
||||
"SERVICE_WORKER": "DEFAULT",
|
||||
"OFFLINE_COMMANDS": "DEFAULT"
|
||||
},
|
||||
"FEATURE_OVERRIDES": {
|
||||
"reference-feature": "DEFAULT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +373,25 @@ async function waitForProvider(
|
||||
PROVIDER_MAX_OUTPUT_BYTES,
|
||||
() => terminate("output"),
|
||||
);
|
||||
/**
|
||||
* Lines the sandbox tooling itself emits, kept so a launch failure can say
|
||||
* why. Everything else the child writes is provider output and may carry
|
||||
* credentials, so it is counted and discarded as before.
|
||||
*
|
||||
* Without this a sandbox that never started reported only `exit=1`, and the
|
||||
* actual cause — `bwrap: loopback: Failed RTM_NEWADDR: Operation not
|
||||
* permitted` on a host with `kernel.apparmor_restrict_unprivileged_userns=1`
|
||||
* — was invisible. That turned a host restriction into an unexplained
|
||||
* product failure.
|
||||
*/
|
||||
const SANDBOX_DIAGNOSTIC = /^(?:bwrap|prlimit|systemd-run|systemctl):\s.*$/gmu;
|
||||
const sandboxDiagnostics: string[] = [];
|
||||
const capture = (chunk: Buffer | string): void => {
|
||||
for (const line of String(chunk).matchAll(SANDBOX_DIAGNOSTIC)) {
|
||||
if (sandboxDiagnostics.length < 8 && !sandboxDiagnostics.includes(line[0])) {
|
||||
sandboxDiagnostics.push(line[0]);
|
||||
}
|
||||
}
|
||||
if (termination) return;
|
||||
outputLimiter.consume(chunk);
|
||||
};
|
||||
@@ -427,7 +445,13 @@ async function waitForProvider(
|
||||
await collection;
|
||||
if (result.error) throw result.error;
|
||||
if (result.code !== 0 || result.signal !== null) {
|
||||
throw new Error(`sandboxed external provider failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}`);
|
||||
throw new Error(
|
||||
`sandboxed external provider failed: exit=${result.code ?? "none"}, ` +
|
||||
`signal=${result.signal ?? "none"}` +
|
||||
(sandboxDiagnostics.length > 0
|
||||
? `; sandbox reported: ${sandboxDiagnostics.join("; ")}`
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
if (inputError) throw inputError;
|
||||
} finally {
|
||||
|
||||
@@ -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,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;
|
||||
}>;
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "../../src/features/installed-product-manifest.ts";
|
||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
||||
import { AppRouter } from "../../src/presentation/routes/app-router.tsx";
|
||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
||||
import { createProductFeaturesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
/**
|
||||
* §3.5. The runtime kill switch, exercised through the running app rather than
|
||||
* through the resolver that computes it.
|
||||
*
|
||||
* Withdrawing a feature from navigation is not the same as taking it out of
|
||||
* service: a typed deep link would still mount it. Both halves are asserted
|
||||
* here, on the same render, so the switch cannot be half-wired.
|
||||
*/
|
||||
|
||||
const FEATURE_ID = INSTALLED_PRODUCT_FEATURE_IDS[0]!;
|
||||
const FEATURE_ROUTE = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST;
|
||||
|
||||
function renderAt(path: string, disabled: boolean) {
|
||||
window.history.pushState({}, "", path);
|
||||
return render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session: createAnonymousSessionAdapter(),
|
||||
...(disabled
|
||||
? {
|
||||
productFeatures: createProductFeaturesStub({
|
||||
[FEATURE_ID]: "DISABLED_BY_CONFIG",
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("runtime product feature switch", () => {
|
||||
it("advertises the feature's route while the feature is active", async () => {
|
||||
renderAt("/", false);
|
||||
expect(
|
||||
await screen.findByRole("link", { name: FEATURE_ROUTE.navigationLabel! }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("withdraws the feature's route from navigation when it is disabled", async () => {
|
||||
renderAt("/", true);
|
||||
// The shell itself still renders: disabling a feature is not an outage.
|
||||
expect(await screen.findByRole("navigation")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: FEATURE_ROUTE.navigationLabel! }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("takes the feature out of service for a direct deep link", async () => {
|
||||
renderAt(FEATURE_ROUTE.path, true);
|
||||
const surface = await screen.findByText(
|
||||
(_, element) =>
|
||||
element?.getAttribute("data-disabled-feature") === FEATURE_ID,
|
||||
{},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(surface).toBeTruthy();
|
||||
});
|
||||
|
||||
it("serves the same deep link while the feature is active", async () => {
|
||||
renderAt(FEATURE_ROUTE.path, false);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
(_, element) =>
|
||||
element?.getAttribute("data-disabled-feature") === FEATURE_ID,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ const runtime: Runtime = {
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
},
|
||||
configSchema: "V2",
|
||||
build: {
|
||||
@@ -83,6 +84,23 @@ function referenceBoundQueryKey() {
|
||||
).queryKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed feature inputs are partial by design: a feature the product
|
||||
* manifest did not select supplies none. This suite is about the reference
|
||||
* feature being composed, so it asserts that first and narrows once.
|
||||
*/
|
||||
function referenceInput<
|
||||
Inputs extends Readonly<Partial<Record<typeof REFERENCE_FEATURE_ID, unknown>>>,
|
||||
>(adapters: Readonly<{ featureInputs: Inputs }>) {
|
||||
const input = adapters.featureInputs[REFERENCE_FEATURE_ID];
|
||||
if (!input) {
|
||||
throw new Error(
|
||||
`${REFERENCE_FEATURE_ID} is not installed; the manifest did not select it`,
|
||||
);
|
||||
}
|
||||
return input as NonNullable<Inputs[typeof REFERENCE_FEATURE_ID]>;
|
||||
}
|
||||
|
||||
describe("reference feature runtime composition", () => {
|
||||
it("invalidates a real bound query through the installed production graph", async () => {
|
||||
const adapters = await createRuntimeAdapters({ runtime, release, host: {} });
|
||||
@@ -117,7 +135,7 @@ describe("reference feature runtime composition", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
adapters.featureInputs[REFERENCE_FEATURE_ID].listResources({ limit: 20 }),
|
||||
referenceInput(adapters).listResources({ limit: 20 }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: [
|
||||
@@ -170,7 +188,7 @@ describe("reference feature runtime composition", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
adapters.featureInputs[REFERENCE_FEATURE_ID].createResource(
|
||||
referenceInput(adapters).createResource(
|
||||
{ name: "Created resource" },
|
||||
{ intent },
|
||||
),
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type ApplicationOutputPorts,
|
||||
} from "../../src/application/create-application.ts";
|
||||
import type { ApplicationFeatureInputs } from "../../src/application/ports/in/application-api.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "./runtime-capabilities-stub.ts";
|
||||
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "./runtime-capabilities-stub.ts";
|
||||
|
||||
type TestApplicationOverrides = Partial<ApplicationOutputPorts> &
|
||||
Readonly<{
|
||||
@@ -52,6 +52,8 @@ export function createTestApplication(
|
||||
},
|
||||
runtimeCapabilities:
|
||||
overrides.runtimeCapabilities ?? createRuntimeCapabilitiesStub(),
|
||||
productFeatures:
|
||||
overrides.productFeatures ?? createProductFeaturesStub(),
|
||||
navigation: overrides.navigation ?? { reload: () => {} },
|
||||
},
|
||||
overrides.featureInputs,
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import {
|
||||
activeProductFeatureIds,
|
||||
resolveProductFeatures,
|
||||
type ProductFeatureState,
|
||||
} from "../../src/contracts/product-features.ts";
|
||||
import type { ProductFeaturesPort } from "../../src/application/ports/product-features-port.ts";
|
||||
import {
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
} from "../../src/features/installed-product-manifest.ts";
|
||||
import type {
|
||||
RuntimeCapabilityId,
|
||||
RuntimeCapabilityStatus,
|
||||
@@ -32,3 +42,27 @@ export function createRuntimeCapabilitiesStub(
|
||||
);
|
||||
return Object.freeze({ getSnapshot: () => snapshot });
|
||||
}
|
||||
|
||||
/**
|
||||
* A product-feature port that reports the real manifest with nothing disabled.
|
||||
* Tests that care about the switch build their own; the rest only need the
|
||||
* boundary to be complete.
|
||||
*/
|
||||
export function createProductFeaturesStub(
|
||||
overrides: Readonly<Record<string, ProductFeatureState>> = {},
|
||||
): ProductFeaturesPort {
|
||||
const snapshot = resolveProductFeatures(
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
Object.fromEntries(
|
||||
Object.entries(overrides)
|
||||
.filter(([, state]) => state === "DISABLED_BY_CONFIG")
|
||||
.map(([featureId]) => [featureId, "DISABLED" as const]),
|
||||
),
|
||||
);
|
||||
const active = new Set(activeProductFeatureIds(snapshot));
|
||||
return Object.freeze({
|
||||
getSnapshot: () => snapshot,
|
||||
isActive: (featureId: string) => active.has(featureId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ const runtime: Parameters<typeof loadReleaseManifest>[0] = {
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
},
|
||||
configSchema: "V2",
|
||||
validationDurationMs: 0,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type ApplicationOutputPorts,
|
||||
} from "../../src/application/create-application.ts";
|
||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
declare module "../../src/application/ports/in/application-api.ts" {
|
||||
interface ApplicationFeatureInputs {
|
||||
@@ -99,6 +99,7 @@ describe("application input/output boundary", () => {
|
||||
}),
|
||||
},
|
||||
runtimeCapabilities: createRuntimeCapabilitiesStub(),
|
||||
productFeatures: createProductFeaturesStub(),
|
||||
navigation: { reload: () => {} },
|
||||
} satisfies ApplicationOutputPorts;
|
||||
const application = createApplication(ports);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-
|
||||
import type { StoragePort } from "../../src/application/ports/storage-port.ts";
|
||||
import type { DiagnosticsPort } from "../../src/application/ports/diagnostics-port.ts";
|
||||
import type { TelemetryPort } from "../../src/application/ports/telemetry-port.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
type ReleaseFixture = {
|
||||
buildId: string;
|
||||
@@ -53,6 +53,7 @@ function applicationWith(options: {
|
||||
diagnostics: options.diagnostics ?? { record: () => {} },
|
||||
telemetry: options.telemetry ?? { emit: () => {} },
|
||||
runtimeCapabilities: createRuntimeCapabilitiesStub(),
|
||||
productFeatures: createProductFeaturesStub(),
|
||||
releaseInfo: {
|
||||
getCurrent: async () => current,
|
||||
refresh:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { constants, readFileSync } from "node:fs";
|
||||
import { cp, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -47,6 +47,14 @@ import {
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
} from "../../scripts/lib/release-candidate.ts";
|
||||
|
||||
/**
|
||||
* Budget for the provider suites specifically. They spawn a systemd scope, a
|
||||
* bubblewrap sandbox and a signing provider, and build a release candidate to
|
||||
* do it; the 10s default is sized for pure-JS unit tests. Raising the global
|
||||
* default instead would hide a genuinely hung test.
|
||||
*/
|
||||
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
let providerBaseRoot: string | undefined;
|
||||
const sha256 = (value: Buffer | string) =>
|
||||
@@ -757,6 +765,10 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
},
|
||||
});
|
||||
const completionStarted = Date.now();
|
||||
// Start recording before anything is asserted: the provider's whole life is
|
||||
// shorter than one `systemctl show`, so the tree has to be sampled, not
|
||||
// sampled once at whatever moment the assertions happen to arrive.
|
||||
const tree = recordProviderProcessTree(execution.child.pid!);
|
||||
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
|
||||
const properties = showProviderUnit(unit);
|
||||
expect(properties).toMatchObject({
|
||||
@@ -776,10 +788,21 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
await expect(readFile(path.join(cgroupRoot, "cpu.max"), "utf8")).resolves.toBe("100000 100000\n");
|
||||
expect(readProviderUnitMetadata(unit)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
||||
expect(showProcessArguments(execution.child.pid)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
||||
const cgroupPids = await readCgroupPids(cgroupRoot);
|
||||
const processArguments = cgroupPids.map((pid) => showProcessArguments(pid));
|
||||
const directChildPids = await waitForDirectProviderChildren(execution.child.pid!);
|
||||
const directChildArguments = directChildPids.map((pid) => showProcessArguments(pid));
|
||||
const reportIdentity = await lstat(fixture.reportPath);
|
||||
const result = await execution.completion;
|
||||
tree.stop();
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).not.toContain(credential);
|
||||
expect(result.stderr).not.toContain(credential);
|
||||
|
||||
// Everything below reads the recording of the whole run rather than a live
|
||||
// snapshot. The sandbox exists for a few hundred milliseconds; asserting
|
||||
// while it runs meant racing it, and the assertions are about what the run
|
||||
// contained, not about what a particular instant looked like.
|
||||
const cgroupPids = tree.cgroupPids();
|
||||
const processArguments = tree.cgroupArguments();
|
||||
const directChildPids = tree.directChildPids();
|
||||
const directChildArguments = tree.directChildArguments();
|
||||
const observedArguments = [
|
||||
showProcessArguments(execution.child.pid),
|
||||
...directChildArguments,
|
||||
@@ -789,18 +812,15 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
expect(directChildArguments.filter((arguments_) => arguments_.includes("/usr/bin/systemd-run"))).toHaveLength(1);
|
||||
expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).toHaveLength(1);
|
||||
expect(processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts"))).toHaveLength(1);
|
||||
expect(processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length)
|
||||
.toBeGreaterThan(0);
|
||||
expect(
|
||||
processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length,
|
||||
`sandbox never observed in the scope; recorded: ${processArguments.join(" | ")}`,
|
||||
).toBeGreaterThan(0);
|
||||
const infrastructureArguments = [...directChildArguments, ...processArguments].filter((arguments_) =>
|
||||
/provider-(?:scope-wrapper|raw-guardian)|systemd-run|bwrap|prlimit/u.test(arguments_),
|
||||
);
|
||||
expect(infrastructureArguments.join("\n")).not.toContain(command);
|
||||
expect(processArguments.filter((arguments_) => arguments_.includes(providerScript))).toHaveLength(1);
|
||||
const reportIdentity = await lstat(fixture.reportPath);
|
||||
const result = await execution.completion;
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).not.toContain(credential);
|
||||
expect(result.stderr).not.toContain(credential);
|
||||
expect(Date.now() - completionStarted).toBeLessThan(4_000);
|
||||
await expectProviderUnitGone(unit);
|
||||
await expect(lstat(cgroupRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
@@ -808,7 +828,7 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
expect(directChildPids.every((pid) => !processExists(pid))).toBe(true);
|
||||
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(findProviderRawReferences(fixture.reportPath, reportIdentity)).resolves.toEqual([]);
|
||||
}, 10_000);
|
||||
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||
|
||||
it("kills and collects an active provider when its guardian dies", async () => {
|
||||
const fixture = await createProviderFixture();
|
||||
@@ -1693,39 +1713,31 @@ async function readCgroupPids(cgroupRoot: string): Promise<number[]> {
|
||||
.trim().split("\n").filter(Boolean).map(Number);
|
||||
}
|
||||
|
||||
async function waitForDirectProviderChildren(supervisorPid: number): Promise<number[]> {
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
const childrenPath = `/proc/${supervisorPid}/task/${supervisorPid}/children`;
|
||||
let listing: string;
|
||||
try {
|
||||
listing = await readFile(childrenPath, "utf8");
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) {
|
||||
throw new Error(
|
||||
"provider supervisor exited before its children could be observed",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const children = listing.trim().split(/\s+/u).filter(Boolean).map(Number);
|
||||
const arguments_ = children.flatMap((pid) => {
|
||||
try {
|
||||
return [showProcessArguments(pid)];
|
||||
} catch (error) {
|
||||
if (!processExists(pid)) return [];
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
async function waitForRecordedProviderTree(
|
||||
tree: ReturnType<typeof recordProviderProcessTree>,
|
||||
providerScript: string,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 400; attempt += 1) {
|
||||
const children = tree.directChildArguments();
|
||||
const members = tree.cgroupArguments();
|
||||
// Every process the assertions below reason about. Returning as soon as
|
||||
// some of them are present is what left bubblewrap out of the recording:
|
||||
// it enters the scope a few milliseconds after the wrapper does.
|
||||
if (
|
||||
arguments_.some((value) => value.includes("/usr/bin/systemd-run")) &&
|
||||
arguments_.some((value) => value.includes("provider-raw-guardian.ts"))
|
||||
children.some((value) => value.includes("/usr/bin/systemd-run")) &&
|
||||
children.some((value) => value.includes("provider-raw-guardian.ts")) &&
|
||||
members.some((value) => value.includes("provider-scope-wrapper.ts")) &&
|
||||
members.some((value) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(value)) &&
|
||||
members.some((value) => value.includes(providerScript))
|
||||
) {
|
||||
return children;
|
||||
return;
|
||||
}
|
||||
await delay(25);
|
||||
await delay(10);
|
||||
}
|
||||
throw new Error("provider supervisor children were not simultaneously observable");
|
||||
throw new Error(
|
||||
"the provider run never contained a systemd-run child, a guardian child, " +
|
||||
"a scope wrapper, a sandbox and the provider itself in its cgroup",
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForDirectChildMatching(supervisorPid: number, pattern: string): Promise<number> {
|
||||
@@ -1870,6 +1882,8 @@ function readProviderUnitMetadata(unitName: string): string {
|
||||
|
||||
function showProcessArguments(pid: number | undefined): string {
|
||||
if (!pid) throw new Error("provider supervisor did not expose its PID");
|
||||
const argv = readProcessArguments(pid);
|
||||
if (argv !== null) return argv;
|
||||
const result = spawnSync(
|
||||
"/usr/bin/ps",
|
||||
["-o", "args=", "-p", String(pid)],
|
||||
@@ -1880,6 +1894,102 @@ function showProcessArguments(pid: number | undefined): string {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a process's argv straight from `/proc`.
|
||||
*
|
||||
* Spawning `ps` per pid costs milliseconds each, and the provider sandbox now
|
||||
* completes a whole run in well under a second — the observation was losing a
|
||||
* race against the thing it was observing. Returns null for a process that is
|
||||
* already gone, which the sampler treats as "nothing more to record".
|
||||
*/
|
||||
function readProcessArguments(pid: number): string | null {
|
||||
try {
|
||||
return readFileSync(`/proc/${pid}/cmdline`, "utf8")
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the provider's process tree for the whole life of the run.
|
||||
*
|
||||
* The assertions below are about what the sandbox looked like while it was
|
||||
* running, and a single snapshot taken afterwards can only ever be a guess at
|
||||
* that. Sampling from the moment the supervisor starts turns "did we look at
|
||||
* the right instant?" into "what did this run actually contain?".
|
||||
*/
|
||||
function recordProviderProcessTree(supervisorPid: number) {
|
||||
const directChildren = new Map<number, string>();
|
||||
const cgroupMembers = new Map<number, string>();
|
||||
let stopped = false;
|
||||
|
||||
const remember = (into: Map<number, string>, pid: number): void => {
|
||||
if (into.has(pid)) return;
|
||||
const argv = readProcessArguments(pid);
|
||||
if (argv !== null && argv.length > 0) into.set(pid, argv);
|
||||
};
|
||||
const childrenOf = (pid: number): number[] => {
|
||||
try {
|
||||
return readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8")
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Scope membership is read from the process itself rather than from
|
||||
* `systemctl show`. Waiting for the unit to be described before watching its
|
||||
* cgroup meant bubblewrap had usually already exited by the time the first
|
||||
* sample was taken — the recording missed exactly the process the assertions
|
||||
* are about.
|
||||
*/
|
||||
const inProviderScope = (pid: number): boolean => {
|
||||
try {
|
||||
return readFileSync(`/proc/${pid}/cgroup`, "utf8").includes("ca-provider-");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const sample = (): void => {
|
||||
if (stopped) return;
|
||||
const direct = childrenOf(supervisorPid);
|
||||
for (const pid of direct) remember(directChildren, pid);
|
||||
// Walk the whole subtree: the scope wrapper, bubblewrap and the provider
|
||||
// itself sit below systemd-run, not beside it.
|
||||
const pending = [...direct];
|
||||
const seen = new Set(direct);
|
||||
while (pending.length > 0 && seen.size < 512) {
|
||||
const pid = pending.pop()!;
|
||||
if (inProviderScope(pid)) remember(cgroupMembers, pid);
|
||||
for (const child of childrenOf(pid)) {
|
||||
if (seen.has(child)) continue;
|
||||
seen.add(child);
|
||||
pending.push(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
const timer = setInterval(sample, 5);
|
||||
timer.unref();
|
||||
sample();
|
||||
return Object.freeze({
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
},
|
||||
directChildPids: () => [...directChildren.keys()],
|
||||
directChildArguments: () => [...directChildren.values()],
|
||||
cgroupPids: () => [...cgroupMembers.keys()],
|
||||
cgroupArguments: () => [...cgroupMembers.values()],
|
||||
});
|
||||
}
|
||||
|
||||
async function startLoopbackCanary(root: string): Promise<Readonly<{
|
||||
child: ChildProcess;
|
||||
marker: string;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
activeProductFeatureIds,
|
||||
resolveProductFeatures,
|
||||
selectCompiledProductFeatures,
|
||||
} from "../../src/contracts/product-features.ts";
|
||||
import { runtimeConfigV2ArtifactSchema } from "../../src/contracts/release-artifacts.ts";
|
||||
import {
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
} from "../../src/features/installed-product-manifest.ts";
|
||||
import {
|
||||
ROUTE_FEATURE_OWNER,
|
||||
ROUTE_REGISTRY,
|
||||
} from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
const COMPILED = Object.freeze([
|
||||
Object.freeze({ featureId: "reference-feature" }),
|
||||
Object.freeze({ featureId: "billing" }),
|
||||
]);
|
||||
|
||||
describe("build-time product selection", () => {
|
||||
it("keeps everything when nothing is declared", () => {
|
||||
for (const declared of [undefined, "", " "]) {
|
||||
expect(
|
||||
selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId),
|
||||
String(declared),
|
||||
).toEqual(["reference-feature", "billing"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("narrows to the declared subset", () => {
|
||||
expect(
|
||||
selectCompiledProductFeatures(COMPILED, "billing").map((f) => f.featureId),
|
||||
).toEqual(["billing"]);
|
||||
expect(
|
||||
selectCompiledProductFeatures(COMPILED, " billing , reference-feature ").map(
|
||||
(f) => f.featureId,
|
||||
),
|
||||
).toEqual(["reference-feature", "billing"]);
|
||||
});
|
||||
|
||||
it("selects nothing only when asked explicitly", () => {
|
||||
// A blank value keeps everything on purpose: an unset CI variable expands
|
||||
// to a blank string, and that must not be how a build ships no features.
|
||||
expect(selectCompiledProductFeatures(COMPILED, "none")).toEqual([]);
|
||||
expect(selectCompiledProductFeatures(COMPILED, " none ")).toEqual([]);
|
||||
expect(selectCompiledProductFeatures(COMPILED, "").length).toBe(2);
|
||||
// A value that parses to no names at all is a typo, not an instruction.
|
||||
expect(() => selectCompiledProductFeatures(COMPILED, ",")).toThrow(
|
||||
/names no feature/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to name a feature this build does not contain", () => {
|
||||
// The whole point of the direction rule: an environment value can subtract
|
||||
// from the source tree and must never be able to add to it. Accepting an
|
||||
// unknown id silently would let a deployment believe it had switched on
|
||||
// something that is not in the bundle.
|
||||
expect(() => selectCompiledProductFeatures(COMPILED, "analytics")).toThrow(
|
||||
/does not contain: analytics/u,
|
||||
);
|
||||
expect(() =>
|
||||
selectCompiledProductFeatures(COMPILED, "billing,analytics"),
|
||||
).toThrow(/analytics/u);
|
||||
});
|
||||
|
||||
it("refuses a duplicated feature id in the manifest", () => {
|
||||
expect(() =>
|
||||
selectCompiledProductFeatures(
|
||||
[{ featureId: "a" }, { featureId: "a" }],
|
||||
undefined,
|
||||
),
|
||||
).toThrow(/duplicate product feature id/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime product feature resolution", () => {
|
||||
it("reports active, disabled and not-installed distinctly", () => {
|
||||
const statuses = resolveProductFeatures(
|
||||
["reference-feature", "billing"],
|
||||
["reference-feature"],
|
||||
{ "reference-feature": "DISABLED" },
|
||||
);
|
||||
expect(statuses).toEqual([
|
||||
{ featureId: "billing", state: "NOT_INSTALLED" },
|
||||
{ featureId: "reference-feature", state: "DISABLED_BY_CONFIG" },
|
||||
]);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual([]);
|
||||
});
|
||||
|
||||
it("cannot switch on a feature the build left out", () => {
|
||||
// `DEFAULT` on an uninstalled feature is not an instruction to install it.
|
||||
const statuses = resolveProductFeatures(["billing"], [], {
|
||||
billing: "DEFAULT",
|
||||
});
|
||||
expect(statuses).toEqual([{ featureId: "billing", state: "NOT_INSTALLED" }]);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores an override naming a feature this build never declared", () => {
|
||||
// A shared runtime document may cover several builds, so a stale key is
|
||||
// inert rather than fatal.
|
||||
const statuses = resolveProductFeatures(
|
||||
["reference-feature"],
|
||||
["reference-feature"],
|
||||
{ analytics: "DISABLED" },
|
||||
);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual(["reference-feature"]);
|
||||
});
|
||||
|
||||
it("leaves an installed feature active without an override", () => {
|
||||
const statuses = resolveProductFeatures(
|
||||
COMPILED_PRODUCT_FEATURE_IDS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual([
|
||||
...INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime config carries the switch", () => {
|
||||
const base = {
|
||||
APP_ENV: "local" as const,
|
||||
API_BASE_URL: "http://localhost:8080/",
|
||||
TELEMETRY_ENABLED: false,
|
||||
AUTH_MODE: "demo" as const,
|
||||
CONFIG_SCHEMA_VERSION: "2.0" as const,
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
};
|
||||
|
||||
it("defaults to disabling nothing", () => {
|
||||
const parsed = runtimeConfigV2ArtifactSchema.parse(base);
|
||||
expect(parsed.FEATURE_OVERRIDES).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts only DEFAULT or DISABLED", () => {
|
||||
expect(
|
||||
runtimeConfigV2ArtifactSchema.parse({
|
||||
...base,
|
||||
FEATURE_OVERRIDES: { "reference-feature": "DISABLED" },
|
||||
}).FEATURE_OVERRIDES,
|
||||
).toEqual({ "reference-feature": "DISABLED" });
|
||||
// There is no "ENABLED": the vocabulary itself is what makes the rule
|
||||
// unbreakable, not a check somewhere downstream.
|
||||
expect(
|
||||
runtimeConfigV2ArtifactSchema.safeParse({
|
||||
...base,
|
||||
FEATURE_OVERRIDES: { "reference-feature": "ENABLED" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a malformed feature id", () => {
|
||||
for (const featureId of ["Reference", "reference_feature", "", "-x"]) {
|
||||
expect(
|
||||
runtimeConfigV2ArtifactSchema.safeParse({
|
||||
...base,
|
||||
FEATURE_OVERRIDES: { [featureId]: "DISABLED" },
|
||||
}).success,
|
||||
featureId,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("every installed registry consults the manifest", () => {
|
||||
/**
|
||||
* The manifest only means something if each registry actually asks it. A new
|
||||
* registry that spreads a feature in directly would reintroduce exactly the
|
||||
* coupling this file exists to remove, and nothing else would notice.
|
||||
*/
|
||||
it("gates every feature contribution on the selection", async () => {
|
||||
const { readdir, readFile } = await import("node:fs/promises");
|
||||
const nodePath = (await import("node:path")).default;
|
||||
const root = "src/features";
|
||||
const registries = (await readdir(root)).filter((entry) =>
|
||||
/^installed-.*\.tsx?$/u.test(entry),
|
||||
);
|
||||
expect(registries.length).toBeGreaterThan(3);
|
||||
const exempt = new Set([
|
||||
// The manifest is the selection.
|
||||
"installed-product-manifest.ts",
|
||||
// Capabilities have their own §3.5 selection file and override vocabulary.
|
||||
"installed-runtime-capabilities.ts",
|
||||
// Message keys stay total on purpose; see the file for why.
|
||||
"installed-feature-messages.ts",
|
||||
]);
|
||||
for (const registry of registries) {
|
||||
if (exempt.has(registry)) continue;
|
||||
const source = await readFile(nodePath.join(root, registry), "utf8");
|
||||
expect(
|
||||
/INSTALLED_PRODUCT_FEATURE(S|_IDS)/u.test(source),
|
||||
`${registry} must compose from the product manifest`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("route ownership", () => {
|
||||
it("attributes every feature route to its feature and no platform route", () => {
|
||||
for (const featureId of INSTALLED_PRODUCT_FEATURE_IDS) {
|
||||
expect(Object.values(ROUTE_FEATURE_OWNER)).toContain(featureId);
|
||||
}
|
||||
// Platform routes have no owner, so disabling a feature can never withdraw
|
||||
// the shell's own navigation.
|
||||
for (const routeId of ["APP_HOME", "NOT_FOUND", "EXAMPLES_PLATFORM"]) {
|
||||
expect(ROUTE_FEATURE_OWNER[routeId], routeId).toBeUndefined();
|
||||
expect(Object.keys(ROUTE_REGISTRY)).toContain(routeId);
|
||||
}
|
||||
});
|
||||
|
||||
it("owns exactly the routes the registry received from features", () => {
|
||||
const owned = Object.keys(ROUTE_FEATURE_OWNER);
|
||||
expect(owned.length).toBeGreaterThan(0);
|
||||
for (const routeId of owned) {
|
||||
expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,14 @@ import { pathToFileURL } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* This suite's budget, not the file's. The 10s default is sized for pure-JS
|
||||
* unit tests; these spawn processes, build archives and sign evidence, and on a
|
||||
* machine running the rest of the suite in parallel they legitimately need
|
||||
* longer. Raising the global default instead would hide a genuinely hung test.
|
||||
*/
|
||||
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -699,7 +707,7 @@ describe("provider guardian transaction protocol", () => {
|
||||
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readdir(evidenceRoot)).resolves.toEqual(["untrusted"]);
|
||||
});
|
||||
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||
|
||||
it("still publishes near the lease deadline when post-processing completes in time", async () => {
|
||||
const { startProviderGuardian } = await import(
|
||||
|
||||
@@ -74,6 +74,7 @@ const runtimeV2 = {
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
} as const satisfies RuntimeConfigArtifact;
|
||||
|
||||
async function releaseV2With(
|
||||
|
||||
@@ -103,13 +103,26 @@ describe("release evidence fixture copy", () => {
|
||||
const root = await mkdtemp(nodePath.join(tmpdir(), "release-evidence-"));
|
||||
await copyReleaseEvidenceTree(process.cwd(), root);
|
||||
|
||||
// Every artifact a candidate archive is assembled from has to survive; a
|
||||
// fixture missing one of these cannot build a candidate at all, and every
|
||||
// provider suite then fails while constructing its own fixture.
|
||||
// Every release artifact that exists here has to survive the copy; a
|
||||
// fixture missing one cannot build a candidate at all, and every provider
|
||||
// suite then fails while constructing its own fixture.
|
||||
//
|
||||
// Which ones exist depends on what this checkout has generated — a product
|
||||
// repository that has not run the release chain has fewer than the template
|
||||
// does — so the subject is preservation, not the presence of a full chain.
|
||||
// Requiring at least one keeps that from quietly asserting nothing.
|
||||
let preserved = 0;
|
||||
for (const evidence of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
|
||||
if (!evidence.startsWith("artifacts/")) continue;
|
||||
await expect(access(nodePath.join(root, evidence)), evidence).resolves.toBeUndefined();
|
||||
try {
|
||||
await access(evidence);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
await expect(access(nodePath.join(root, evidence)), evidence).resolves.toBeUndefined();
|
||||
preserved += 1;
|
||||
}
|
||||
expect(preserved).toBeGreaterThan(0);
|
||||
// The regenerated trees are why this is a filter and not a plain copy: they
|
||||
// are tens of megabytes of traces and coverage HTML. They still exist,
|
||||
// because the repository inventory expects the directories.
|
||||
|
||||
@@ -24,6 +24,14 @@ import {
|
||||
type ProductionModuleInventory,
|
||||
} from "../../scripts/lib/risk-coverage.ts";
|
||||
|
||||
/**
|
||||
* This suite's budget, not the file's. The 10s default is sized for pure-JS
|
||||
* unit tests; these spawn processes, build archives and sign evidence, and on a
|
||||
* machine running the rest of the suite in parallel they legitimately need
|
||||
* longer. Raising the global default instead would hide a genuinely hung test.
|
||||
*/
|
||||
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||
|
||||
const roots: string[] = [];
|
||||
const now = Date.parse("2026-08-02T00:00:00.000Z");
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -141,7 +149,7 @@ describe("repository-aware risk coverage", () => {
|
||||
result.repositoryTotal - result.selectedTotal,
|
||||
);
|
||||
expect(result.status).toBe("FAIL");
|
||||
});
|
||||
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||
|
||||
it("reports exact inventory and generated-exclusion provenance", async () => {
|
||||
const repositoryRoot = await repositoryFixture();
|
||||
|
||||
@@ -26,6 +26,7 @@ const runtime: Runtime = {
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
},
|
||||
configSchema: "V2",
|
||||
build: {
|
||||
@@ -262,6 +263,7 @@ describe("runtime adapter composition", () => {
|
||||
...runtime.config.CAPABILITY_OVERRIDES,
|
||||
SERVICE_WORKER: "DISABLED",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
},
|
||||
},
|
||||
release,
|
||||
|
||||
@@ -47,6 +47,14 @@ import {
|
||||
} from "../../scripts/lib/release-candidate.ts";
|
||||
import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts";
|
||||
|
||||
/**
|
||||
* This suite's budget, not the file's. The 10s default is sized for pure-JS
|
||||
* unit tests; these spawn processes, build archives and sign evidence, and on a
|
||||
* machine running the rest of the suite in parallel they legitimately need
|
||||
* longer. Raising the global default instead would hide a genuinely hung test.
|
||||
*/
|
||||
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||
|
||||
const digest = (value: string): string =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
const digestBytes = (value: Buffer): string =>
|
||||
@@ -1383,7 +1391,7 @@ describe("security follow-up contracts", () => {
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
function providerExpectedContext() {
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 53 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 424 KiB |
@@ -11,6 +11,10 @@ export default defineConfig({
|
||||
exclude: [
|
||||
...configDefaults.exclude,
|
||||
".tmp/**",
|
||||
// A git worktree inside the repository is a different checkout of a
|
||||
// different branch. Running its tests against this checkout's config
|
||||
// reports failures that belong to neither.
|
||||
".worktrees/**",
|
||||
"tests/fixtures/v8-coverage-counter-semantics/**",
|
||||
],
|
||||
coverage: {
|
||||
|
||||
Reference in New Issue
Block a user