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:
co-authored by
Claude Opus 5
parent
002ba3624e
commit
4bff9ca151
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user