fix: install bounded Browser RPC stream leases
R-04: install the RPC contract bindings as exact immutable snapshots. Registry and row data are copied from own data descriptors into frozen null-prototype maps before validation, so a getter is never invoked, extra and symbol keys and malformed descriptors are composition-time TypeErrors, and the runtime reads only the snapshot. A post-validation mutation can no longer change replay policy, deadlines, byte ceilings or transport selection. R-01: bound transport stream cleanup. The generation is fenced and listeners released immediately, and iterator.return() is awaited only within a cleanup bound, so a non-cooperative iterator cannot keep the application generator, its listeners or the total deadline alive. Unresolved cleanup stays observed. R-05: reject oversized WebSocket text frames before allocating an encoded copy and count UTF-8 bytes incrementally with an early exit, matching TextEncoder for surrogate pairs and lone surrogates. R-06: canonicalise clock and generation-fence failures into the closed Result taxonomy instead of letting them escape as native rejections, with listener and timer cleanup on every exit path. Browser RPC remains AVAILABLE_NOT_COMPOSED; R-07 transport evidence is still required before composition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2f29ccbf1a
commit
8f67974f68
@@ -327,6 +327,188 @@ export function composeBrowserRpcRequestEncoderRegistry(
|
||||
);
|
||||
}
|
||||
|
||||
export type InstalledBrowserRpcContractBindings = Readonly<{
|
||||
operations: ReadonlyMap<string, BrowserRpcOperationV3>;
|
||||
profiles: ReadonlyMap<string, BrowserRpcProviderProfile>;
|
||||
schemaCodecs: ReadonlyMap<string, RuntimeSchemaCodec>;
|
||||
mappers: ReadonlyMap<string, InstalledBoundaryMapper>;
|
||||
requestEncoders: ReadonlyMap<string, BrowserRpcRequestEncoder>;
|
||||
runtimeBindings: ReadonlyMap<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[],
|
||||
): ReadonlyMap<string, Value> {
|
||||
let ownKeys: string[];
|
||||
let symbols: readonly symbol[];
|
||||
try {
|
||||
ownKeys = Object.keys(source);
|
||||
symbols = Object.getOwnPropertySymbols(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.`);
|
||||
}
|
||||
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 Object.freeze(installed) as ReadonlyMap<string, Value>;
|
||||
}
|
||||
|
||||
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[];
|
||||
try {
|
||||
ownKeys = Object.keys(row);
|
||||
symbols = Object.getOwnPropertySymbols(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.`);
|
||||
}
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user