chore: sync the frontend template from 4dc033c to 8157ad4

The product was materialized from the template at `4dc033c` and has stayed
on it through 43 template commits, so it was missing all three rounds of
adapter remediation — including files it never had, such as the shared
`abortable-operation` primitive and the `exact-snapshot` decoder that
later fixes are written against. Taking only the newest round was not
possible for that reason: the delta is coherent only as a whole.

The product had not touched `src/adapters` at all since materialization,
so the 140-file delta applied with a three-way merge and no conflicts.
`package.json` was the single overlap and merged cleanly: the product owns
`name`, the template contributed `check:adapter-inventory`,
`check:remediation-ledger` and the image-resolve-signal type fixture.
All 24 product-owned files — README, index.html, CI workflow, i18n
catalog, home page, generated schemas, evidence scripts, component and
visual snapshots — are byte-identical to `main`.

`template.lock.json` now pins the synced revision and tree.

Verified in this repository, not inherited from the template: six type
projects, lint, nine gates (adapter inventory, remediation ledger,
registries, diagnostics, realtime boundaries, architecture, browser
file/storage boundaries, optional recipes, documentation), the production
build, and 2,054 of 2,073 tests. The 19 failures are all in
`tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing
sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template
records; four suites that failed once under parallel load pass in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 12:04:58 +09:00
co-authored by Claude Opus 5
parent 002ba3624e
commit 4bff9ca151
142 changed files with 23010 additions and 1544 deletions
+207
View File
@@ -1,3 +1,7 @@
import {
createReadOnlyRegistry,
type ReadOnlyRegistry,
} from "./read-only-registry.ts";
import type { InstalledBoundaryMapper } from "./boundary-mapper.ts";
import type { RuntimeSchemaCodec } from "./schema-registry.ts";
@@ -327,6 +331,209 @@ export function composeBrowserRpcRequestEncoderRegistry(
);
}
/**
* RPC-RR-03. Read facades, never `Map`s. `Object.freeze(new Map(...))` leaves
* `set`, `delete` and `clear` working, so an installed registry could still be
* emptied or re-pointed after the snapshot was validated.
*/
export type InstalledBrowserRpcContractBindings = Readonly<{
operations: ReadOnlyRegistry<string, BrowserRpcOperationV3>;
profiles: ReadOnlyRegistry<string, BrowserRpcProviderProfile>;
schemaCodecs: ReadOnlyRegistry<string, RuntimeSchemaCodec>;
mappers: ReadOnlyRegistry<string, InstalledBoundaryMapper>;
requestEncoders: ReadOnlyRegistry<string, BrowserRpcRequestEncoder>;
runtimeBindings: ReadOnlyRegistry<string, BrowserRpcRuntimeBindingIdentity>;
}>;
/**
* R-04. Parse → validate → install.
*
* `Readonly` is a TypeScript annotation, not a runtime guarantee, and a source
* registry can be mutated after validation so replay policy, deadlines, byte
* ceilings or transport selection differ from what was checked. Every row is
* therefore copied once into a frozen null-prototype snapshot built from exact
* own data properties. A getter, an extra or symbol key, a malformed descriptor
* or a revoked proxy is a composition-time `TypeError`, and the runtime reads
* only the snapshot afterwards.
*/
function installRegistrySnapshot<Value extends object>(
source: Readonly<Record<string, Value>>,
label: string,
allowedKeys: readonly string[],
): ReadOnlyRegistry<string, Value> {
let ownKeys: string[];
let symbols: readonly symbol[];
let prototype: object | null;
try {
// RPC-03. Own *names*, not just enumerable keys: a non-enumerable own entry
// is as much a smuggled row as an inherited one, and `Object.keys` never
// saw either.
ownKeys = Object.getOwnPropertyNames(source);
symbols = Object.getOwnPropertySymbols(source);
prototype = Reflect.getPrototypeOf(source);
} catch {
throw new TypeError(`Browser RPC ${label} registry is unreadable.`);
}
if (symbols.length > 0) {
throw new TypeError(`Browser RPC ${label} registry has symbol keys.`);
}
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError(`Browser RPC ${label} registry has a custom prototype.`);
}
const installed = new Map<string, Value>();
for (const key of ownKeys) {
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!descriptor || !("value" in descriptor)) {
throw new TypeError(
`Browser RPC ${label} registry entry is not a data property: ${key}`,
);
}
installed.set(
key,
installRowSnapshot(descriptor.value as Value, `${label}.${key}`, allowedKeys),
);
}
return createReadOnlyRegistry(installed);
}
function installRowSnapshot<Value extends object>(
row: Value,
label: string,
allowedKeys: readonly string[],
): Value {
if (!row || typeof row !== "object") {
throw new TypeError(`Browser RPC ${label} row is not an object.`);
}
let ownKeys: string[];
let symbols: readonly symbol[];
let prototype: object | null;
try {
ownKeys = Object.getOwnPropertyNames(row);
symbols = Object.getOwnPropertySymbols(row);
prototype = Reflect.getPrototypeOf(row);
} catch {
throw new TypeError(`Browser RPC ${label} row is unreadable.`);
}
if (symbols.length > 0) {
throw new TypeError(`Browser RPC ${label} row has symbol keys.`);
}
// RPC-03. A custom prototype carries fields the name sweep never sees and
// stays live after installation, so the installed row would not be the row
// that was checked.
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError(`Browser RPC ${label} row has a custom prototype.`);
}
const snapshot = Object.create(null) as Record<string, unknown>;
for (const key of ownKeys) {
if (!allowedKeys.includes(key)) {
throw new TypeError(
`Browser RPC ${label} row has an unexpected key: ${key}`,
);
}
const descriptor = Object.getOwnPropertyDescriptor(row, key);
// Reading an accessor would invoke a getter; refuse without calling it.
if (!descriptor || !("value" in descriptor)) {
throw new TypeError(
`Browser RPC ${label} row key is not a data property: ${key}`,
);
}
const value = descriptor.value as unknown;
snapshot[key] = Array.isArray(value)
? Object.freeze([...value])
: value;
}
return Object.freeze(snapshot) as Value;
}
const OPERATION_KEYS = Object.freeze([
"contractVersion", "operationId", "owner", "protocol", "semantics",
"replayPolicy", "idempotencyKeyPolicy", "idempotencyLevel",
"dataClassification", "runtimeProfileId", "providerId",
"fullyQualifiedService", "method", "rpcKind", "requestMessageId",
"responseMessageId", "descriptorArtifactId", "descriptorDigest",
"requestSchemaId", "responseSchemaId", "requestEncoderId", "mapperId",
"authProfileId", "csrfProfileId", "errorProfileId", "deadlineProfileId",
"retryProfileId", "serverStateProfileId", "maxRequestMessageBytes",
"maxResponseMessageBytes", "maxResponseMessages", "maxTotalResponseBytes",
"maxBufferedBytes", "idleDeadlineMs", "totalDeadlineMs",
] as const);
const PROFILE_KEYS = Object.freeze([
"runtimeProfileId", "providerId", "fixedBaseUrl", "runtimeId",
"runtimeVersion", "runtimeDigest", "protocol", "runtimeKind",
"clientApiKind", "rpcKind", "messageEncoding", "framing", "requestMethod",
"descriptorArtifactId", "descriptorDigest", "allowedProcedures",
"authProfileId", "csrfProfileId", "corsProfileId", "errorProfileId",
"deadlineProfileId", "retryProfileId", "retryOwner", "maxAttempts",
"backoffMs", "retryableFailures", "maxRetryAfterMs", "deadlineDialect",
"cancelDialect", "rawByteCeilingOwner", "streamMessageCompression",
] as const);
const SCHEMA_KEYS = Object.freeze(["schemaId", "parse"] as const);
const MAPPER_KEYS = Object.freeze([
"mapperId", "mapperVersion", "inputSchemaId", "outputContractId", "owner",
"maxOutputItems", "map",
] as const);
const ENCODER_KEYS = Object.freeze([
"encoderId", "operationId", "encode",
] as const);
const RUNTIME_BINDING_KEYS = Object.freeze([
"runtimeProfileId", "providerId", "protocol", "rpcKind",
] as const);
export function installBrowserRpcContractBindings(
bindings: BrowserRpcContractBindings,
): InstalledBrowserRpcContractBindings {
// Parse first. Snapshotting from own data descriptors rejects accessors
// without ever invoking them, so a hostile getter cannot observe validation
// or return a different value to it than to the runtime.
const operations = installRegistrySnapshot(
bindings.operations,
"operation",
OPERATION_KEYS,
);
const profiles = installRegistrySnapshot(
bindings.profiles,
"profile",
PROFILE_KEYS,
);
const schemaCodecs = installRegistrySnapshot(
bindings.schemaCodecs,
"schema",
SCHEMA_KEYS,
);
const mappers = installRegistrySnapshot(
bindings.mappers,
"mapper",
MAPPER_KEYS,
);
const requestEncoders = installRegistrySnapshot(
bindings.requestEncoders,
"encoder",
ENCODER_KEYS,
);
const runtimeBindings = installRegistrySnapshot(
bindings.runtimeBindings ?? {},
"runtime",
RUNTIME_BINDING_KEYS,
);
// Then validate the snapshot, so what was checked is exactly what installs.
validateBrowserRpcContractBindings({
operations: Object.fromEntries(operations),
profiles: Object.fromEntries(profiles),
schemaCodecs: Object.fromEntries(schemaCodecs),
mappers: Object.fromEntries(mappers),
requestEncoders: Object.fromEntries(requestEncoders),
runtimeBindings: Object.fromEntries(runtimeBindings),
});
return Object.freeze({
operations,
profiles,
schemaCodecs,
mappers,
requestEncoders,
runtimeBindings,
});
}
export function validateBrowserRpcContractBindings(
bindings: BrowserRpcContractBindings,
): true {
+174
View File
@@ -0,0 +1,174 @@
/**
* Descriptor-based exact decoding for values that cross a trust boundary.
*
* Several adapters independently wrote "check the shape, then read it again to
* copy it". That order is the bug: between the check and the copy an accessor
* or a Proxy can answer differently, so the value that was validated and the
* value that was installed are two different things. Every helper here reads a
* property exactly once, through its own data descriptor, and hands back an
* owned plain object. Validation then runs on the snapshot, never on the source.
*
* The helpers are total: a hostile `getPrototypeOf`, `ownKeys` or
* `getOwnPropertyDescriptor` trap yields `null`, never a thrown exception, so a
* caller can keep its own typed failure vocabulary.
*/
const DEFAULT_PROTOTYPES: readonly (object | null)[] = Object.freeze([
Object.prototype,
null,
]);
export type ExactObjectPolicy = Readonly<{
/** Every own key the value may carry. Anything else rejects the snapshot. */
allowed: readonly string[];
/** Keys that must be present as own data properties. */
required?: readonly string[];
/**
* Prototypes the value may have. Defaults to a plain object or a null
* prototype, which is what a decoded wire payload or a literal produces.
*/
prototypes?: readonly (object | null)[];
}>;
/**
* Reads `source[key]` exactly once through its own data descriptor. An accessor,
* an inherited property or a missing key all answer `undefined`, and a trap that
* throws answers `undefined` rather than escaping.
*/
export function ownDataValue(source: unknown, key: string): unknown {
if (source === null || typeof source !== "object") return undefined;
try {
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!descriptor || !("value" in descriptor)) return undefined;
return descriptor.value;
} catch {
return undefined;
}
}
/** True when `key` is present as an own data property. */
export function hasOwnDataKey(source: unknown, key: string): boolean {
if (source === null || typeof source !== "object") return false;
try {
const descriptor = Object.getOwnPropertyDescriptor(source, key);
return Boolean(descriptor) && "value" in (descriptor as PropertyDescriptor);
} catch {
return false;
}
}
/**
* Copies `source` into a frozen plain object, reading every property exactly
* once. Returns `null` when the value is not an object, carries a symbol or an
* unexpected own key, exposes an accessor, has an unapproved prototype, misses a
* required key, or makes any reflection operation throw.
*/
export function snapshotExactObject(
source: unknown,
policy: ExactObjectPolicy,
): Readonly<Record<string, unknown>> | null {
if (source === null || typeof source !== "object") return null;
try {
const prototypes = policy.prototypes ?? DEFAULT_PROTOTYPES;
if (!prototypes.includes(Reflect.getPrototypeOf(source))) return null;
if (Object.getOwnPropertySymbols(source).length > 0) return null;
const allowed = new Set(policy.allowed);
const names = Object.getOwnPropertyNames(source);
const snapshot: Record<string, unknown> = {};
for (const name of names) {
if (!allowed.has(name)) return null;
const descriptor = Object.getOwnPropertyDescriptor(source, name);
// A non-enumerable own property is as much a smuggled field as an
// inherited one, and an accessor is a second read waiting to happen.
if (
!descriptor ||
!("value" in descriptor) ||
descriptor.enumerable !== true
) {
return null;
}
Object.defineProperty(snapshot, name, {
value: descriptor.value,
enumerable: true,
writable: false,
configurable: false,
});
}
for (const name of policy.required ?? []) {
if (!Object.hasOwn(snapshot, name)) return null;
}
return Object.freeze(snapshot);
} catch {
return null;
}
}
/**
* Copies an open-keyed record — a header bag, a query map — into a frozen owned
* object, reading every property exactly once. The key set is not constrained
* here; admission against an allow-list stays with the policy that owns it, so
* the more specific rejection can still be reported. Returns `null` for a
* non-object, a symbol key, an accessor, a non-enumerable own key, an
* unapproved prototype, more than `maximumKeys` entries, or a throwing trap.
*/
export function snapshotOwnDataRecord(
source: unknown,
maximumKeys = 64,
): Readonly<Record<string, unknown>> | null {
if (source === null || typeof source !== "object") return null;
try {
if (!DEFAULT_PROTOTYPES.includes(Reflect.getPrototypeOf(source))) {
return null;
}
if (Object.getOwnPropertySymbols(source).length > 0) return null;
const names = Object.getOwnPropertyNames(source);
if (names.length > maximumKeys) return null;
const snapshot: Record<string, unknown> = {};
for (const name of names) {
const descriptor = Object.getOwnPropertyDescriptor(source, name);
if (
!descriptor ||
!("value" in descriptor) ||
descriptor.enumerable !== true
) {
return null;
}
Object.defineProperty(snapshot, name, {
value: descriptor.value,
enumerable: true,
writable: false,
configurable: false,
});
}
return Object.freeze(snapshot);
} catch {
return null;
}
}
/**
* Copies a genuine array into a frozen owned array, reading each element exactly
* once. Returns `null` for a non-array, a hostile length or a trap that throws.
*/
export function snapshotExactArray(
source: unknown,
maximumLength = 4_096,
): readonly unknown[] | null {
try {
if (!Array.isArray(source)) return null;
const length = source.length;
if (!Number.isSafeInteger(length) || length < 0 || length > maximumLength) {
return null;
}
const items: unknown[] = [];
for (let index = 0; index < length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(source, String(index));
if (!descriptor || !("value" in descriptor)) return null;
items.push(descriptor.value);
}
return Object.freeze(items);
} catch {
return null;
}
}
+210 -27
View File
@@ -7,6 +7,13 @@
* applies before a contribution may be composed.
*/
import { INSTALLED_REST_AUTH_PROFILES } from "./rest-profiles.ts";
import {
createReadOnlyRegistry,
exactOwnDataSnapshot,
type ReadOnlyRegistry,
} from "./read-only-registry.ts";
/** §7.3 hard ceilings. A contribution may lower these, never raise them. */
export const HTTP_EXECUTION_CEILINGS = Object.freeze({
defaultRequestBytes: 262_144,
@@ -313,6 +320,11 @@ function assertExecutionPolicy(
) {
fail(`${label}: frontend execution policy identity`);
}
// §7.7 / VD-23. A declared profile that the installed registry does not own
// is a composition failure; the executor must never resolve it at runtime.
if (!INSTALLED_REST_AUTH_PROFILES.has(policy.authProfileId)) {
fail(`${label}: unknown authProfileId ${policy.authProfileId}`);
}
if (
!Number.isSafeInteger(policy.requestByteLimit) ||
policy.requestByteLimit < 0 ||
@@ -473,14 +485,113 @@ function assertEventContract(
export type ComposedContractContributions = Readonly<{
contributions: readonly InstalledContractContribution[];
httpByOperationId: ReadonlyMap<
/**
* LIVE-03. Read facades over private stores. The executor resolves an
* operation on every request, so an exported `Map` would let any holder of
* the composed singleton delete or replace a validated row after boot.
*/
httpByOperationId: ReadOnlyRegistry<
string,
InstalledHttpContract<unknown, unknown, unknown>
>;
eventByType: ReadonlyMap<string, InstalledEventContract<unknown, unknown>>;
eventByType: ReadOnlyRegistry<
string,
InstalledEventContract<unknown, unknown>
>;
externalPackages: readonly InstalledContractPackageIdentity[];
}>;
const EXECUTION_POLICY_KEYS = [
"policyId",
"requestByteLimit",
"responseByteLimit",
"totalDeadlineMs",
"retryBudget",
"authProfileId",
"diagnosticsOperation",
] as const;
const HTTP_CONTRACT_KEYS = [
"operationId",
"method",
"pathTemplate",
"inputValidator",
"outputValidator",
"problemValidator",
"acceptedStatuses",
"emptyBodyStatuses",
"retrySemantics",
"requestBody",
"responseBody",
"commandRecovery",
"commandEffect",
"projectRequest",
] as const;
const COMMAND_RECOVERY_KEYS = [
"mode",
"operationIdentityField",
"inspectOperationId",
] as const;
/**
* LIVE-03. Composition is the last point at which a contribution row is
* trusted, so the registry keeps an exact own-data copy rather than the
* caller's object. A later mutation of the source — including one that swaps a
* deadline or a credential policy — cannot reach what the executor reads.
*
* Validators and the descriptor-owned `projectRequest` stay by reference: they
* are behaviour the contribution owns, not data this repository re-derives.
*/
function snapshotHttpContract(
installed: InstalledHttpContract<unknown, unknown, unknown>,
label: string,
): InstalledHttpContract<unknown, unknown, unknown> {
const reject = (detail: string): never => fail(`${label}: ${detail}`);
// NS-02. The outer row is snapshotted first, so every nested read below comes
// from an owned object rather than from the caller's, which could answer
// differently on a second read.
const row = exactOwnDataSnapshot<
InstalledHttpContract<unknown, unknown, unknown>
>(installed, ["contract", "frontend"], ["contract", "frontend"], reject);
const frontend = exactOwnDataSnapshot<HttpExecutionPolicy>(
row.frontend,
EXECUTION_POLICY_KEYS,
EXECUTION_POLICY_KEYS,
reject,
);
const source = exactOwnDataSnapshot<
InstalledHttpContract<unknown, unknown, unknown>["contract"]
>(row.contract, HTTP_CONTRACT_KEYS, HTTP_CONTRACT_KEYS, reject);
const contract = Object.freeze({
...source,
acceptedStatuses: Object.freeze([...source.acceptedStatuses]),
emptyBodyStatuses: Object.freeze([...source.emptyBodyStatuses]),
commandRecovery:
source.commandRecovery === null
? null
: exactOwnDataSnapshot<CommandRecoveryDescriptor>(
source.commandRecovery,
COMMAND_RECOVERY_KEYS,
["mode", "operationIdentityField"],
reject,
),
});
return Object.freeze({ contract, frontend });
}
function snapshotEventContract(
event: InstalledEventContract<unknown, unknown>,
label: string,
): InstalledEventContract<unknown, unknown> {
return exactOwnDataSnapshot<InstalledEventContract<unknown, unknown>>(
event,
["eventType", "envelopeValidator", "payloadValidator"],
["eventType", "envelopeValidator", "payloadValidator"],
(detail) => fail(`${label}: ${detail}`),
);
}
/**
* §4.8–§4.9. The only place installed contributions become a runtime registry.
* Every bound is checked before composition; a violation stops the boot rather
@@ -500,11 +611,22 @@ export function composeContractContributions(
>();
const packagesById = new Map<string, InstalledContractPackageIdentity>();
const contributionIds = new Set<string>();
const installedContributions: InstalledContractContribution[] = [];
for (const contribution of contributions) {
if (!contribution || typeof contribution !== "object") {
for (const raw of contributions) {
if (!raw || typeof raw !== "object") {
fail("contribution: object required");
}
// NS-02. Snapshot first, then validate the snapshot, then install exactly
// what was validated. Validating the caller's object and reading it again
// to copy it let a stateful answer pass the ceiling check and still install
// a different deadline, retry budget or auth profile.
const contribution = exactOwnDataSnapshot<InstalledContractContribution>(
raw,
["contributionId", "featureId", "source", "http", "events"],
["contributionId", "featureId", "source", "http", "events"],
(detail) => fail(`contribution: ${detail}`),
);
const contributionId = contribution.contributionId;
if (
typeof contributionId !== "string" ||
@@ -520,53 +642,114 @@ export function composeContractContributions(
if (typeof featureId !== "string" || !FEATURE_ID.test(featureId)) {
fail(`featureId: ${String(featureId)}`);
}
const source = contribution.source;
if (!source || typeof source !== "object" || !("kind" in source)) {
const rawSource = contribution.source;
if (!rawSource || typeof rawSource !== "object" || !("kind" in rawSource)) {
fail(`${featureId}: source`);
}
if (!Array.isArray(contribution.http) || !Array.isArray(contribution.events)) {
fail(`${featureId}: contribution arrays`);
}
if (source.kind === "EXTERNAL_PACKAGE") {
assertPackageIdentity(source.package, featureId);
const existing = packagesById.get(source.package.packageId);
const rejectSource = (detail: string): never =>
fail(`${featureId}: source ${detail}`);
let source: ContractContributionSource;
if (rawSource.kind === "EXTERNAL_PACKAGE") {
const outer = exactOwnDataSnapshot<
Readonly<{ kind: "EXTERNAL_PACKAGE"; package: unknown }>
>(rawSource, ["kind", "package"], ["kind", "package"], rejectSource);
const identity = exactOwnDataSnapshot<InstalledContractPackageIdentity>(
outer.package,
[
"packageId",
"version",
"digest",
"runtimeProtocolVersion",
"sourceRevision",
],
[
"packageId",
"version",
"digest",
"runtimeProtocolVersion",
"sourceRevision",
],
rejectSource,
);
assertPackageIdentity(identity, featureId);
source = Object.freeze({
kind: "EXTERNAL_PACKAGE" as const,
package: identity,
});
const existing = packagesById.get(identity.packageId);
if (
existing &&
(existing.version !== source.package.version ||
existing.digest !== source.package.digest ||
existing.sourceRevision !== source.package.sourceRevision)
(existing.version !== identity.version ||
existing.digest !== identity.digest ||
existing.sourceRevision !== identity.sourceRevision)
) {
fail(
`${featureId}: package ${source.package.packageId} has conflicting identities`,
`${featureId}: package ${identity.packageId} has conflicting identities`,
);
}
packagesById.set(source.package.packageId, source.package);
} else if (source.kind === "TEMPLATE_FIXTURE") {
if (source.fixtureId !== "REFERENCE_FEATURE_V1" || source.revision !== 1) {
packagesById.set(identity.packageId, identity);
} else if (rawSource.kind === "TEMPLATE_FIXTURE") {
const fixture = exactOwnDataSnapshot<
Readonly<{
kind: "TEMPLATE_FIXTURE";
fixtureId: "REFERENCE_FEATURE_V1";
revision: 1;
}>
>(
rawSource,
["kind", "fixtureId", "revision"],
["kind", "fixtureId", "revision"],
rejectSource,
);
if (
fixture.fixtureId !== "REFERENCE_FEATURE_V1" ||
fixture.revision !== 1
) {
fail(`${featureId}: template fixture identity`);
}
if (contribution.events.length !== 0) {
fail(`${featureId}: template fixture must not contribute events`);
}
source = fixture;
} else {
fail(`${featureId}: unknown contribution source kind`);
}
const installedHttp: InstalledHttpContract<unknown, unknown, unknown>[] = [];
for (const installed of contribution.http) {
assertHttpContract(installed, featureId);
const operationId = installed.contract.operationId;
const snapshot = snapshotHttpContract(installed, featureId);
assertHttpContract(snapshot, featureId);
const operationId = snapshot.contract.operationId;
const previous = httpByOperationId.get(operationId);
if (previous) fail(`duplicate operation: ${operationId}`);
httpByOperationId.set(operationId, installed);
httpByOperationId.set(operationId, snapshot);
installedHttp.push(snapshot);
}
const installedEvents: InstalledEventContract<unknown, unknown>[] = [];
for (const event of contribution.events) {
assertEventContract(event, featureId);
if (eventByType.has(event.eventType)) {
fail(`duplicate event type: ${event.eventType}`);
const snapshot = snapshotEventContract(event, featureId);
assertEventContract(snapshot, featureId);
if (eventByType.has(snapshot.eventType)) {
fail(`duplicate event type: ${snapshot.eventType}`);
}
eventByType.set(event.eventType, event);
eventByType.set(snapshot.eventType, snapshot);
installedEvents.push(snapshot);
}
// Everything published downstream is the validated snapshot, so no consumer
// can be handed the caller's still-live object.
installedContributions.push(
Object.freeze({
contributionId,
featureId,
source,
http: Object.freeze(installedHttp),
events: Object.freeze(installedEvents),
}),
);
}
const externalPackages = [...packagesById.values()].map((identity) =>
@@ -574,9 +757,9 @@ export function composeContractContributions(
);
return Object.freeze({
contributions: Object.freeze([...contributions]),
httpByOperationId,
eventByType,
contributions: Object.freeze(installedContributions),
httpByOperationId: createReadOnlyRegistry(httpByOperationId),
eventByType: createReadOnlyRegistry(eventByType),
externalPackages: Object.freeze(externalPackages),
});
}
+37 -4
View File
@@ -23,6 +23,39 @@ function validBoundedString(value: unknown, maxBytes: number): value is string {
);
}
/**
* N-06. The single idempotency-key authority shared by the V2 compatibility
* client and the V3 executor.
*
* A caller-supplied value is never trimmed, regenerated or silently dropped:
* an invalid key is a contract violation, because replaying a keyed command
* without its key is exactly the unsafe behaviour the key exists to prevent.
*/
export function isValidIdempotencyKey(value: unknown): value is string {
if (
!validBoundedString(
value,
MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes,
)
) {
return false;
}
for (const character of value) {
const codePoint = character.codePointAt(0) ?? 0;
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
return false;
}
}
return true;
}
export function defineIdempotencyKey(value: unknown): string {
if (!isValidIdempotencyKey(value)) {
throw new TypeError("Idempotency key is invalid.");
}
return value;
}
export function defineMutationIntent(intent: MutationIntent): MutationIntent {
if (
!validBoundedString(
@@ -37,11 +70,11 @@ export function defineMutationIntent(intent: MutationIntent): MutationIntent {
intent.canonicalInputIdentity,
MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes,
) ||
// OPT-NET-02. Intent definition and executor admission share one key
// authority; a second, looser rule here is how a control character reaches
// an `Idempotency-Key` header.
(intent.idempotencyKey !== undefined &&
!validBoundedString(
intent.idempotencyKey,
MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes,
)) ||
!isValidIdempotencyKey(intent.idempotencyKey)) ||
!Number.isFinite(intent.createdAtMonotonicMs) ||
intent.createdAtMonotonicMs < 0
) {
+89
View File
@@ -0,0 +1,89 @@
/**
* LIVE-02 / LIVE-03. A composed registry is authority, not data.
*
* `Object.freeze(new Map(...))` only freezes the wrapper object: `set`,
* `delete` and `clear` still reach the backing store, so anything holding the
* exported singleton can empty a validated registry after composition and
* silently change what every later request resolves. The fix is structural —
* the store stays private in a closure and only read operations are exported.
*
* The facade is deliberately *not* a `Map` instance, so borrowing a mutator
* (`Map.prototype.clear.call(facade)`) fails on the missing internal slot
* rather than succeeding.
*/
export type ReadOnlyRegistry<Key, Value> = Readonly<{
get(key: Key): Value | undefined;
has(key: Key): boolean;
keys(): IterableIterator<Key>;
values(): IterableIterator<Value>;
entries(): IterableIterator<readonly [Key, Value]>;
forEach(visit: (value: Value, key: Key) => void): void;
readonly size: number;
[Symbol.iterator](): IterableIterator<readonly [Key, Value]>;
}>;
export function createReadOnlyRegistry<Key, Value>(
entries: Iterable<readonly [Key, Value]>,
): ReadOnlyRegistry<Key, Value> {
const store = new Map<Key, Value>(entries as Iterable<[Key, Value]>);
const facade = {
get: (key: Key) => store.get(key),
has: (key: Key) => store.has(key),
keys: () => store.keys(),
values: () => store.values(),
entries: () => store.entries(),
forEach: (visit: (value: Value, key: Key) => void) => {
for (const [key, value] of store) visit(value, key);
},
get size() {
return store.size;
},
[Symbol.iterator]: () => store.entries(),
};
return Object.freeze(facade) as ReadOnlyRegistry<Key, Value>;
}
/**
* Rejects anything that is not an exact own-data record over `allowedKeys`.
*
* A validated row must survive the validation: an accessor re-runs on every
* later read, an inherited field can be replaced through the prototype, and a
* symbol-keyed field escapes a name-based sweep entirely. Only own data
* descriptors are copied, and the result is frozen.
*/
export function exactOwnDataSnapshot<Shape extends object>(
source: unknown,
allowedKeys: readonly (keyof Shape & string)[],
requiredKeys: readonly (keyof Shape & string)[],
onViolation: (detail: string) => never,
): Readonly<Shape> {
if (source === null || typeof source !== "object") {
onViolation("object required");
}
if (Object.getOwnPropertySymbols(source).length > 0) {
onViolation("symbol-keyed field");
}
// NS-02. A custom prototype carries fields a name sweep never sees, and it
// stays live: replacing one after composition changes what the row answers.
const prototype = Reflect.getPrototypeOf(source);
if (prototype !== Object.prototype && prototype !== null) {
onViolation("unexpected prototype");
}
const allowed = new Set<string>(allowedKeys);
const snapshot: Record<string, unknown> = {};
for (const key of Object.getOwnPropertyNames(source)) {
if (!allowed.has(key)) onViolation(`unexpected field ${key}`);
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!descriptor || !("value" in descriptor)) {
onViolation(`accessor field ${key}`);
}
if (descriptor.enumerable !== true) {
onViolation(`non-enumerable field ${key}`);
}
snapshot[key] = descriptor.value;
}
for (const key of requiredKeys) {
if (!Object.hasOwn(snapshot, key)) onViolation(`missing field ${key}`);
}
return Object.freeze(snapshot) as Readonly<Shape>;
}
+151 -1
View File
@@ -1,3 +1,8 @@
import {
createReadOnlyRegistry,
type ReadOnlyRegistry,
} from "./read-only-registry.ts";
export type FetchCredentialsMode = "omit" | "same-origin" | "include";
export type RestProviderProfile = Readonly<{
@@ -8,13 +13,42 @@ export type RestProviderProfile = Readonly<{
referrerPolicy: "no-referrer";
}>;
/**
* VD-23. The complete closed set of headers a credential owner may contribute.
* Transport-owned headers (`accept`, `content-type`, `idempotency-key`) and
* every forbidden request header are deliberately absent.
*/
export const CREDENTIAL_HEADER_NAMES = Object.freeze([
"authorization",
"x-csrf-token",
"x-tenant-context",
] as const);
export type CredentialHeaderName = (typeof CREDENTIAL_HEADER_NAMES)[number];
export type RestAuthProfile = Readonly<{
authProfileId: string;
transport: "ANONYMOUS" | "BEARER_HEADER" | "SAME_ORIGIN_COOKIE";
credentials: FetchCredentialsMode;
allowedCredentialHeaders: readonly ("authorization" | "x-csrf-token")[];
allowedCredentialHeaders: readonly CredentialHeaderName[];
/**
* Proof headers the transport must observe before dispatch. A missing entry
* fails closed with zero `fetch()` calls rather than sending an anonymous
* request under an authenticated profile.
*/
requiredCredentialHeaders: readonly CredentialHeaderName[];
}>;
/**
* LIVE-02. A read facade over a private store, never a `Map`. The executor
* resolves a profile on every request, so a post-installation `clear()` would
* otherwise turn every authenticated call into `UNKNOWN_AUTH_PROFILE`.
*/
export type InstalledRestAuthProfiles = ReadOnlyRegistry<
string,
RestAuthProfile
>;
export type RestCsrfProfile = Readonly<{
csrfProfileId: string;
mode: "NONE" | "HEADER";
@@ -27,15 +61,131 @@ export const REST_AUTH_PROFILES = Object.freeze({
transport: "BEARER_HEADER",
credentials: "omit",
allowedCredentialHeaders: Object.freeze(["authorization"] as const),
requiredCredentialHeaders: Object.freeze(["authorization"] as const),
}),
ANONYMOUS: Object.freeze({
authProfileId: "ANONYMOUS",
transport: "ANONYMOUS",
credentials: "omit",
allowedCredentialHeaders: Object.freeze([]),
requiredCredentialHeaders: Object.freeze([]),
}),
} satisfies Readonly<Record<string, RestAuthProfile>>);
function isCredentialHeaderName(value: unknown): value is CredentialHeaderName {
return (
typeof value === "string" &&
(CREDENTIAL_HEADER_NAMES as readonly string[]).includes(value)
);
}
function exactHeaderSet(
names: unknown,
label: string,
): readonly CredentialHeaderName[] {
if (!Array.isArray(names)) {
throw new TypeError(`REST auth profile ${label} must be an array.`);
}
const seen = new Set<string>();
for (const name of names) {
if (!isCredentialHeaderName(name) || seen.has(name)) {
throw new TypeError(`REST auth profile ${label} is not an exact set.`);
}
seen.add(name);
}
return Object.freeze([...(names as readonly CredentialHeaderName[])]);
}
/**
* §7.7 / VD-23. Installs the composition-owned auth profile registry once.
*
* The registry — not a credential collaborator — owns Fetch `credentials` and
* the exact allowed/required credential-header sets. An incoherent profile is a
* composition failure, never a runtime downgrade.
*/
export function installRestAuthProfileRegistry(
profiles: Readonly<Record<string, RestAuthProfile>> = REST_AUTH_PROFILES,
): InstalledRestAuthProfiles {
const installed = new Map<string, RestAuthProfile>();
for (const [key, candidate] of Object.entries(profiles)) {
if (!candidate || typeof candidate !== "object") {
throw new TypeError(`REST auth profile ${key} is not an object.`);
}
const authProfileId = candidate.authProfileId;
if (
typeof authProfileId !== "string" ||
authProfileId.length === 0 ||
authProfileId !== key
) {
throw new TypeError(`REST auth profile ${key} has a mismatched identity.`);
}
const allowed = exactHeaderSet(
candidate.allowedCredentialHeaders,
"allowedCredentialHeaders",
);
const required = exactHeaderSet(
candidate.requiredCredentialHeaders,
"requiredCredentialHeaders",
);
if (!required.every((name) => allowed.includes(name))) {
throw new TypeError(
`REST auth profile ${key} requires a header it does not allow.`,
);
}
const credentials = candidate.credentials;
if (!["omit", "same-origin", "include"].includes(credentials)) {
throw new TypeError(`REST auth profile ${key} has invalid credentials.`);
}
switch (candidate.transport) {
case "ANONYMOUS":
if (
credentials !== "omit" ||
allowed.length > 0 ||
required.length > 0
) {
throw new TypeError(
`Anonymous REST auth profile ${key} cannot carry credentials.`,
);
}
break;
case "BEARER_HEADER":
if (credentials !== "omit" || !required.includes("authorization")) {
throw new TypeError(
`Bearer REST auth profile ${key} must require authorization with omitted credentials.`,
);
}
break;
case "SAME_ORIGIN_COOKIE":
if (credentials === "omit" || allowed.includes("authorization")) {
throw new TypeError(
`Cookie REST auth profile ${key} must send ambient credentials without a bearer header.`,
);
}
break;
default:
throw new TypeError(`REST auth profile ${key} has unknown transport.`);
}
installed.set(
authProfileId,
Object.freeze({
authProfileId,
transport: candidate.transport,
credentials,
allowedCredentialHeaders: allowed,
requiredCredentialHeaders: required,
}),
);
}
if (installed.size === 0) {
throw new TypeError("REST auth profile registry cannot be empty.");
}
return createReadOnlyRegistry(installed);
}
/** The single installed registry every composition root shares. */
export const INSTALLED_REST_AUTH_PROFILES: InstalledRestAuthProfiles =
installRestAuthProfileRegistry();
export const REST_CSRF_PROFILES = Object.freeze({
NO_CSRF_BEARER: Object.freeze({
csrfProfileId: "NO_CSRF_BEARER",
@@ -0,0 +1,243 @@
import { SERVICE_WORKER_BOUNDS } from "./service-worker.ts";
/**
* SW-05. Runtime-neutral static manifest codec.
*
* The generator, the Node build gate and the Service Worker all need the same
* answer to "is this manifest exactly the one that was generated?". This module
* owns the exact row keys, the content-type and extension allowlist, the
* root-relative URL rule and the length-prefixed canonical byte serialization.
*
* It deliberately contains no digest implementation: the generator and build
* gate hash these bytes with Node SHA-256 while the worker hashes the very same
* bytes with injected WebCrypto, so `node:crypto` never reaches worker code and
* the algorithm is never written twice.
*/
export type StaticAssetRow = Readonly<{
url: string;
sha256: string;
bytes: number;
contentType: string;
}>;
export type StaticAssetManifest = Readonly<{
schemaVersion: 1;
buildId: string;
releaseId: string;
setDigest: string;
assets: readonly StaticAssetRow[];
}>;
export const STATIC_ASSET_SET_DOMAIN = "CA_STATIC_ASSET_SET_V1";
/**
* SW-RR-03. The single authoritative extension → content type table.
*
* The build generator and this decoder must agree exactly: an extension the
* generator emits but the decoder refuses turns a correct build into a runtime
* contract failure, and the reverse admits an asset kind no build produces.
* `.json` is deliberately absent — every JSON file in a build output is a
* control document (runtime config, release manifest, schema), not a cacheable
* static asset, and the generator excludes them by name.
*/
export const CACHEABLE_ASSET_CONTENT_TYPES: Readonly<
Record<string, string>
> = Object.freeze({
".css": "text/css",
".js": "text/javascript",
".mjs": "text/javascript",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".woff2": "font/woff2",
});
const MANIFEST_KEYS = Object.freeze([
"assets",
"buildId",
"releaseId",
"schemaVersion",
"setDigest",
] as const);
const ASSET_ROW_KEYS = Object.freeze([
"bytes",
"contentType",
"sha256",
"url",
] as const);
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
/** Root-relative, hashed, no dot segments, no query and no fragment. */
const ASSET_URL = /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/u;
/**
* SW-02. The one canonical asset-path predicate, shared by the build generator
* and this decoder. Sharing only the extension table left the two with
* different path grammars: the generator emitted a URL for a directory
* containing a space, an `@` or a percent-escape, and the decoder then refused
* the manifest it had just produced, failing the release build.
*/
export function isCanonicalStaticAssetUrl(url: string): boolean {
return (
typeof url === "string" &&
ASSET_URL.test(url) &&
!url.includes("/../") &&
!url.includes("/./")
);
}
export type StaticManifestDecodeFailure = Readonly<{
reason: string;
}>;
export type StaticManifestDecodeResult =
| Readonly<{ ok: true; manifest: StaticAssetManifest }>
| Readonly<{ ok: false; error: StaticManifestDecodeFailure }>;
function exactKeys(
value: unknown,
allowed: readonly string[],
): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (Object.getOwnPropertySymbols(record).length > 0) return null;
const keys = Object.keys(record).sort();
return keys.length === allowed.length &&
keys.every((key, index) => key === allowed[index])
? record
: null;
}
function extensionOf(url: string): string {
const lastSlash = url.lastIndexOf("/");
const base = url.slice(lastSlash + 1);
const dot = base.lastIndexOf(".");
return dot < 0 ? "" : base.slice(dot).toLowerCase();
}
/**
* Decodes a generated manifest with every row rule applied. It does not verify
* `setDigest`; callers pair it with their own digest implementation over
* `canonicalStaticManifestBytes`.
*/
export function decodeStaticAssetManifest(
value: unknown,
): StaticManifestDecodeResult {
const record = exactKeys(value, MANIFEST_KEYS);
if (!record) return failure("manifest keys are not exact");
if (record.schemaVersion !== 1) return failure("schemaVersion must be 1");
if (
typeof record.buildId !== "string" ||
!IDENTITY.test(record.buildId) ||
typeof record.releaseId !== "string" ||
!IDENTITY.test(record.releaseId)
) {
return failure("buildId or releaseId is invalid");
}
if (typeof record.setDigest !== "string" || !DIGEST.test(record.setDigest)) {
return failure("setDigest is not a lower-hex sha256");
}
if (!Array.isArray(record.assets)) return failure("assets must be an array");
if (record.assets.length > SERVICE_WORKER_BOUNDS.assets) {
return failure("asset count exceeds its bound");
}
const rows: StaticAssetRow[] = [];
const seen = new Set<string>();
let totalBytes = 0;
let previousUrl: string | null = null;
for (const candidate of record.assets) {
const row = exactKeys(candidate, ASSET_ROW_KEYS);
if (!row) return failure("asset row keys are not exact");
const { url, sha256, bytes, contentType } = row;
if (typeof url !== "string" || !isCanonicalStaticAssetUrl(url)) {
return failure("asset url must be root-relative without dot segments");
}
if (seen.has(url)) return failure("asset urls must be unique");
// A sorted set makes the canonical bytes independent of directory order.
if (previousUrl !== null && url <= previousUrl) {
return failure("asset urls must be sorted");
}
if (typeof sha256 !== "string" || !DIGEST.test(sha256)) {
return failure("asset sha256 is not a lower-hex sha256");
}
if (
typeof bytes !== "number" ||
!Number.isSafeInteger(bytes) ||
bytes < 0 ||
bytes > SERVICE_WORKER_BOUNDS.singleAssetBytes
) {
return failure("asset byte length is invalid");
}
if (typeof contentType !== "string") {
return failure("asset content type is invalid");
}
const expectedContentType =
CACHEABLE_ASSET_CONTENT_TYPES[extensionOf(url)];
if (!expectedContentType || expectedContentType !== contentType) {
return failure("asset extension and content type do not match");
}
totalBytes += bytes;
if (totalBytes > SERVICE_WORKER_BOUNDS.assetSetBytes) {
return failure("asset set exceeds its byte bound");
}
seen.add(url);
previousUrl = url;
rows.push(Object.freeze({ url, sha256, bytes, contentType }));
}
return Object.freeze({
ok: true as const,
manifest: Object.freeze({
schemaVersion: 1 as const,
buildId: record.buildId,
releaseId: record.releaseId,
setDigest: record.setDigest,
assets: Object.freeze(rows),
}),
});
}
/**
* The exact bytes both the Node generator and the worker hash. A reordered
* directory listing, a renamed field or a changed byte length all change these
* bytes; nothing else does.
*/
export function canonicalStaticManifestBytes(
assets: readonly StaticAssetRow[],
): Uint8Array {
const encoder = new TextEncoder();
const parts: Uint8Array[] = [encoder.encode(`${STATIC_ASSET_SET_DOMAIN}\0`)];
for (const asset of assets) {
parts.push(lengthPrefixed(encoder, asset.url));
parts.push(lengthPrefixed(encoder, asset.sha256));
parts.push(lengthPrefixed(encoder, String(asset.bytes)));
parts.push(lengthPrefixed(encoder, asset.contentType));
}
let total = 0;
for (const part of parts) total += part.byteLength;
const bytes = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.byteLength;
}
return bytes;
}
function lengthPrefixed(encoder: TextEncoder, value: string): Uint8Array {
const encoded = encoder.encode(value);
const prefix = encoder.encode(`${encoded.byteLength}:`);
const combined = new Uint8Array(prefix.byteLength + encoded.byteLength);
combined.set(prefix, 0);
combined.set(encoded, prefix.byteLength);
return combined;
}
function failure(reason: string): StaticManifestDecodeResult {
return Object.freeze({
ok: false as const,
error: Object.freeze({ reason }),
});
}
+12
View File
@@ -69,6 +69,18 @@ export const STORAGE_REGISTRY = Object.freeze({
migration: "discard",
quotaFallback: "feature-disable",
}),
CACHE_INVALIDATION_PULSE: defineStorageKey({
logicalName: "CACHE_INVALIDATION_PULSE",
scope: "cache-invalidation",
name: "pulse",
backend: "localStorage",
classification: "opaque-cache",
schemaVersion: 1,
valueCodec: "opaque-string-v1",
ttl: null,
migration: "discard",
quotaFallback: "no-persist",
}),
AUTH_TOKEN: defineStorageKey({
logicalName: "AUTH_TOKEN",
scope: "auth",
+31
View File
@@ -161,10 +161,41 @@ export type WebPushObservationEvent =
| "web_push_click_dispatched"
| "web_push_association_revoked";
/**
* WP-06. Bounded fan-out is a deliberate policy, but reporting a truncated pass
* as plain success hid the fact that only part of the set was handled.
*/
export type WebPushCountBucket =
| "0"
| "1_8"
| "9_32"
| "33_64"
| "GT_64";
export function webPushCountBucket(count: number): WebPushCountBucket {
if (!Number.isFinite(count) || count <= 0) return "0";
if (count <= 8) return "1_8";
if (count <= 32) return "9_32";
if (count <= 64) return "33_64";
return "GT_64";
}
/**
* WP-07. Certainty of a user-visible native effect. It is evidence only and
* never authorizes a retry.
*/
export type WebPushNativeEffectCertainty =
| "CONFIRMED"
| "NOT_APPLIED"
| "MAYBE_APPLIED";
export type WebPushObservation = Readonly<{
event: WebPushObservationEvent;
outcome: "SUCCEEDED" | "FAILED" | "DEGRADED";
reason?: WebPushFailureCode | WebPushUnavailableReason;
countBucket?: WebPushCountBucket;
truncated?: boolean;
nativeEffect?: WebPushNativeEffectCertainty;
}>;
export interface WebPushObserver {