fix: keep Browser RPC collaborator input and output inside the contract

RPC-RR-02. The server-stream path captured the generation fence outside its
protected boundary and raceWithin invoked clock.sleep outside a promise
boundary, so a synchronous throw from either escaped the Result contract and
skipped the listener and timer release. Both now run inside the boundary, and
release moved to finally.

RPC-RR-03. The runtime snapshotted its transports only after validating the
caller's raw objects, which ran their accessors first. It now decodes the
registry from own data descriptors before anything reads it — refusing an
accessor without invoking it and rejecting extra, inherited and symbol-keyed
fields — and validates that snapshot. Every installed binding registry is a
read facade over a private store instead of a frozen Map whose set, delete and
clear still worked.

RPC-RR-04. Transport results and stream frames are decoded per union variant
from own data descriptors into new frozen values. A throwing getter, an
inherited or extra field, a symbol key, an unknown failure code and an
out-of-range retryAfterMs all close as protocol failures instead of escaping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 14:06:00 +09:00
co-authored by Claude Opus 5
parent 6a8281a941
commit bd90e0c983
3 changed files with 575 additions and 59 deletions
+243 -51
View File
@@ -1,3 +1,7 @@
import {
createReadOnlyRegistry,
type ReadOnlyRegistry,
} from "../../contracts/read-only-registry.ts";
import type { import type {
BrowserRpcGenerationFence, BrowserRpcGenerationFence,
BrowserRpcServerStreamPort, BrowserRpcServerStreamPort,
@@ -107,7 +111,11 @@ export function createBrowserRpcRuntime(
const clock = dependencies.clock ?? systemClock; const clock = dependencies.clock ?? systemClock;
const generationFence = const generationFence =
dependencies.generationFence ?? stableGenerationFence; dependencies.generationFence ?? stableGenerationFence;
validateRuntimeDependencies(dependencies); // RPC-RR-03. Snapshot before validating. Reading the caller's transport
// objects first would run their accessors, letting a hostile getter observe
// validation and then return something else to the runtime.
const installedTransports = snapshotTransports(dependencies.transports);
validateRuntimeDependencies(dependencies, installedTransports);
// R-04. Install exact immutable snapshots once. Every later `bind()` reads // R-04. Install exact immutable snapshots once. Every later `bind()` reads
// the snapshot, never the caller's registry objects, so a post-composition // the snapshot, never the caller's registry objects, so a post-composition
// mutation cannot change replay policy, deadlines, byte ceilings or // mutation cannot change replay policy, deadlines, byte ceilings or
@@ -119,7 +127,6 @@ export function createBrowserRpcRuntime(
mappers: dependencies.mappers, mappers: dependencies.mappers,
requestEncoders: dependencies.requestEncoders, requestEncoders: dependencies.requestEncoders,
}); });
const installedTransports = snapshotTransports(dependencies.transports);
function bind( function bind(
operationId: string, operationId: string,
@@ -457,7 +464,6 @@ async function* executeServerStream<Event>(
clock: ClockPort, clock: ClockPort,
): AsyncIterable<Result<Event, AppFailure>> { ): AsyncIterable<Result<Event, AppFailure>> {
const { operation, profile, encoder, transport } = bound; const { operation, profile, encoder, transport } = bound;
const generation = generationFence.capture();
const deadlineAt = safeNowOrDeadline(clock, 0) + operation.totalDeadlineMs; const deadlineAt = safeNowOrDeadline(clock, 0) + operation.totalDeadlineMs;
const linked = linkedAbortController(context.signal); const linked = linkedAbortController(context.signal);
let iterator: AsyncIterator<BrowserRpcStreamFrame> | null = null; let iterator: AsyncIterator<BrowserRpcStreamFrame> | null = null;
@@ -470,6 +476,23 @@ async function* executeServerStream<Event>(
observe(dependencies, operation, outcome, 1, messageCount); observe(dependencies, operation, outcome, 1, messageCount);
}; };
// RPC-RR-02. The fence is a caller collaborator, so a synchronous throw from
// it is an ordinary failure of this call. Capturing outside the protected
// boundary let that throw escape the stream's Result contract entirely.
const generation = safeCapture(generationFence);
if (generation === FENCE_FAILURE) {
finish("FAILED");
yield failureResult(
callFailure(
operation,
0,
"SCOPE_GENERATION_CHANGED",
"RPC_SCOPE_GENERATION_UNAVAILABLE",
),
);
return;
}
try { try {
const contextFailure = validateCallContext(operation, context, 0); const contextFailure = validateCallContext(operation, context, 0);
if (contextFailure) { if (contextFailure) {
@@ -711,9 +734,23 @@ async function* executeServerStream<Event>(
* R-04. Transports are collaborators, not data rows, so only their identity is * R-04. Transports are collaborators, not data rows, so only their identity is
* snapshotted; the callable itself is captured once and rebound. * snapshotted; the callable itself is captured once and rebound.
*/ */
const TRANSPORT_KEYS: ReadonlySet<string> = new Set([
"runtimeProfileId",
"providerId",
"protocol",
"rpcKind",
"invokeUnary",
"openServerStream",
]);
/**
* RPC-RR-03. Decodes the transport registry from own data descriptors only, so
* no accessor on a caller's object is ever invoked, and returns a read facade
* whose backing store cannot be reached by `set`, `delete` or `clear`.
*/
function snapshotTransports( function snapshotTransports(
source: Readonly<Record<string, BrowserRpcTransport>>, source: Readonly<Record<string, BrowserRpcTransport>>,
): ReadonlyMap<string, BrowserRpcTransport> { ): ReadOnlyRegistry<string, BrowserRpcTransport> {
const installed = new Map<string, BrowserRpcTransport>(); const installed = new Map<string, BrowserRpcTransport>();
if (Object.getOwnPropertySymbols(source).length > 0) { if (Object.getOwnPropertySymbols(source).length > 0) {
throw new TypeError("Browser RPC transport registry has symbol keys."); throw new TypeError("Browser RPC transport registry has symbol keys.");
@@ -725,28 +762,59 @@ function snapshotTransports(
`Browser RPC transport registry entry is not a data property: ${key}`, `Browser RPC transport registry entry is not a data property: ${key}`,
); );
} }
const transport = descriptor.value as BrowserRpcTransport; const transport = descriptor.value as unknown;
if (!transport || typeof transport !== "object") {
throw new TypeError(
`Browser RPC transport registry entry is not an object: ${key}`,
);
}
if (Object.getOwnPropertySymbols(transport).length > 0) {
throw new TypeError(
`Browser RPC transport row has symbol keys: ${key}`,
);
}
const row: Record<string, unknown> = Object.create(null);
for (const field of Object.getOwnPropertyNames(transport)) {
if (!TRANSPORT_KEYS.has(field)) {
throw new TypeError(
`Browser RPC transport row has an unexpected key: ${key}.${field}`,
);
}
const fieldDescriptor = Object.getOwnPropertyDescriptor(
transport,
field,
);
if (!fieldDescriptor || !("value" in fieldDescriptor)) {
throw new TypeError(
`Browser RPC transport row key is not a data property: ${key}.${field}`,
);
}
row[field] = fieldDescriptor.value;
}
const invokeUnary = row.invokeUnary;
const openServerStream = row.openServerStream;
installed.set( installed.set(
key, key,
Object.freeze({ Object.freeze({
runtimeProfileId: transport.runtimeProfileId, runtimeProfileId: row.runtimeProfileId,
providerId: transport.providerId, providerId: row.providerId,
protocol: transport.protocol, protocol: row.protocol,
rpcKind: transport.rpcKind, rpcKind: row.rpcKind,
...(transport.invokeUnary ...(typeof invokeUnary === "function"
? { invokeUnary: transport.invokeUnary.bind(transport) } ? { invokeUnary: invokeUnary.bind(transport) }
: {}), : {}),
...(transport.openServerStream ...(typeof openServerStream === "function"
? { openServerStream: transport.openServerStream.bind(transport) } ? { openServerStream: openServerStream.bind(transport) }
: {}), : {}),
}) as BrowserRpcTransport, }) as BrowserRpcTransport,
); );
} }
return Object.freeze(installed) as ReadonlyMap<string, BrowserRpcTransport>; return createReadOnlyRegistry(installed);
} }
function validateRuntimeDependencies( function validateRuntimeDependencies(
dependencies: BrowserRpcRuntimeDependencies, dependencies: BrowserRpcRuntimeDependencies,
transports: ReadOnlyRegistry<string, BrowserRpcTransport>,
): void { ): void {
const runtimeBindings: Record< const runtimeBindings: Record<
string, string,
@@ -755,9 +823,7 @@ function validateRuntimeDependencies(
"runtimeProfileId" | "providerId" | "protocol" | "rpcKind" "runtimeProfileId" | "providerId" | "protocol" | "rpcKind"
> >
> = Object.create(null); > = Object.create(null);
for (const [profileId, transport] of Object.entries( for (const [profileId, transport] of transports.entries()) {
dependencies.transports,
)) {
if ( if (
profileId !== transport.runtimeProfileId || profileId !== transport.runtimeProfileId ||
Object.hasOwn(runtimeBindings, profileId) Object.hasOwn(runtimeBindings, profileId)
@@ -782,7 +848,7 @@ function validateRuntimeDependencies(
runtimeBindings: Object.freeze(runtimeBindings), runtimeBindings: Object.freeze(runtimeBindings),
}); });
for (const operation of Object.values(dependencies.operations)) { for (const operation of Object.values(dependencies.operations)) {
if (!dependencies.transports[operation.runtimeProfileId]) { if (!transports.has(operation.runtimeProfileId)) {
throw new TypeError( throw new TypeError(
`Browser RPC transport is missing: ${operation.operationId}`, `Browser RPC transport is missing: ${operation.operationId}`,
); );
@@ -1072,29 +1138,145 @@ function safelyMatches<Output>(
} }
} }
/**
* RPC-RR-04. A transport value is decoded, never adopted.
*
* `in` and a direct property read run accessors, so a throwing getter escapes
* the Result contract and an inherited or extra field passes unseen. Each
* variant is decoded from own data descriptors into a new frozen value, so a
* transport that mutates its own object after returning it cannot change what
* the runtime already admitted.
*/
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;
}
}
function exactOwnKeys(
source: unknown,
allowed: ReadonlySet<string>,
): boolean {
if (source === null || typeof source !== "object") return false;
try {
if (Object.getOwnPropertySymbols(source).length > 0) return false;
for (const key of Object.getOwnPropertyNames(source)) {
if (!allowed.has(key)) return false;
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!descriptor || !("value" in descriptor)) return false;
}
return true;
} catch {
return false;
}
}
const UNARY_OK_KEYS: ReadonlySet<string> = new Set([
"ok",
"message",
"encodedBytes",
]);
const UNARY_FAILED_KEYS: ReadonlySet<string> = new Set(["ok", "failure"]);
const FRAME_MESSAGE_KEYS: ReadonlySet<string> = new Set([
"kind",
"message",
"encodedBytes",
]);
const FRAME_TERMINAL_OK_KEYS: ReadonlySet<string> = new Set(["kind", "ok"]);
const FRAME_TERMINAL_FAILED_KEYS: ReadonlySet<string> = new Set([
"kind",
"ok",
"failure",
]);
const TRANSPORT_FAILURE_KEYS: ReadonlySet<string> = new Set([
"code",
"retryAfterMs",
]);
function validateUnaryTransportResult( function validateUnaryTransportResult(
value: unknown, value: unknown,
): BrowserRpcUnaryTransportResult | null { ): BrowserRpcUnaryTransportResult | null {
if (!value || typeof value !== "object" || !("ok" in value)) return null; const ok = ownDataValue(value, "ok");
const candidate = value as BrowserRpcUnaryTransportResult; if (typeof ok !== "boolean") return null;
if (candidate.ok) { if (ok) {
return validEncodedByteCount(candidate.encodedBytes, Number.MAX_SAFE_INTEGER) if (!exactOwnKeys(value, UNARY_OK_KEYS)) return null;
? candidate const encodedBytes = ownDataValue(value, "encodedBytes");
: null; if (!validEncodedByteCount(encodedBytes, Number.MAX_SAFE_INTEGER)) {
return null;
}
return Object.freeze({
ok: true as const,
message: ownDataValue(value, "message"),
encodedBytes,
});
} }
return validTransportFailure(candidate.failure) ? candidate : null; if (!exactOwnKeys(value, UNARY_FAILED_KEYS)) return null;
const failure = decodeTransportFailure(ownDataValue(value, "failure"));
return failure === null
? null
: Object.freeze({ ok: false as const, failure });
} }
function validateStreamFrame(value: unknown): BrowserRpcStreamFrame | null { function validateStreamFrame(value: unknown): BrowserRpcStreamFrame | null {
if (!value || typeof value !== "object" || !("kind" in value)) return null; const kind = ownDataValue(value, "kind");
const frame = value as BrowserRpcStreamFrame; if (kind === "MESSAGE") {
if (frame.kind === "MESSAGE") { if (!exactOwnKeys(value, FRAME_MESSAGE_KEYS)) return null;
return validEncodedByteCount(frame.encodedBytes, Number.MAX_SAFE_INTEGER) const encodedBytes = ownDataValue(value, "encodedBytes");
? frame if (!validEncodedByteCount(encodedBytes, Number.MAX_SAFE_INTEGER)) {
: null; return null;
}
return Object.freeze({
kind: "MESSAGE" as const,
message: ownDataValue(value, "message"),
encodedBytes,
});
} }
if (frame.kind !== "TERMINAL" || typeof frame.ok !== "boolean") return null; if (kind !== "TERMINAL") return null;
return frame.ok || validTransportFailure(frame.failure) ? frame : null; const ok = ownDataValue(value, "ok");
if (typeof ok !== "boolean") return null;
if (ok) {
if (!exactOwnKeys(value, FRAME_TERMINAL_OK_KEYS)) return null;
return Object.freeze({ kind: "TERMINAL" as const, ok: true as const });
}
if (!exactOwnKeys(value, FRAME_TERMINAL_FAILED_KEYS)) return null;
const failure = decodeTransportFailure(ownDataValue(value, "failure"));
return failure === null
? null
: Object.freeze({
kind: "TERMINAL" as const,
ok: false as const,
failure,
});
}
function decodeTransportFailure(
value: unknown,
): BrowserRpcTransportFailure | null {
if (!exactOwnKeys(value, TRANSPORT_FAILURE_KEYS)) return null;
const code = ownDataValue(value, "code");
const retryAfterMs = ownDataValue(value, "retryAfterMs");
if (!TRANSPORT_FAILURE_CODES.has(code as BrowserRpcTransportFailureCode)) {
return null;
}
if (retryAfterMs === undefined) {
return Object.freeze({ code: code as BrowserRpcTransportFailureCode });
}
if (
!Number.isSafeInteger(retryAfterMs) ||
(retryAfterMs as number) < 0 ||
(retryAfterMs as number) > BROWSER_RPC_HARD_LIMITS.maxRetryAfterMs
) {
return null;
}
return Object.freeze({
code: code as BrowserRpcTransportFailureCode,
retryAfterMs: retryAfterMs as number,
});
} }
function validTransportFailure( function validTransportFailure(
@@ -1240,8 +1422,12 @@ function failureResult(
return Object.freeze({ ok: false, error }); return Object.freeze({ ok: false, error });
} }
function validEncodedByteCount(value: number, maximum: number): boolean { function validEncodedByteCount(
value: unknown,
maximum: number,
): value is number {
return ( return (
typeof value === "number" &&
Number.isSafeInteger(value) && Number.isSafeInteger(value) &&
value >= 0 && value >= 0 &&
value <= maximum value <= maximum
@@ -1274,23 +1460,29 @@ async function raceWithin<Value>(
const timerController = new AbortController(); const timerController = new AbortController();
const onAbort = () => timerController.abort(signal.reason); const onAbort = () => timerController.abort(signal.reason);
signal.addEventListener("abort", onAbort, { once: true }); signal.addEventListener("abort", onAbort, { once: true });
const workResult = work.then<TimedResult<Value>, TimedResult<Value>>( try {
(value) => Object.freeze({ kind: "VALUE", value }), const workResult = work.then<TimedResult<Value>, TimedResult<Value>>(
() => Object.freeze({ kind: "THREW" }), (value) => Object.freeze({ kind: "VALUE", value }),
); () => Object.freeze({ kind: "THREW" }),
const timerResult = clock.sleep(milliseconds, timerController.signal).then< );
TimedResult<Value>, // RPC-RR-02. A clock is an external collaborator: calling `sleep` inside a
TimedResult<Value> // promise boundary turns its synchronous throw into a normalized rejection
>( // instead of an exception that escapes the Result contract and skips the
() => Object.freeze({ kind: "TIMEOUT" }), // listener and timer release below.
() => Object.freeze({ kind: "ABORTED" }), const timerResult = Promise.resolve()
); .then(() => clock.sleep(milliseconds, timerController.signal))
const selected = await Promise.race([workResult, timerResult]); .then<TimedResult<Value>, TimedResult<Value>>(
timerController.abort("race-complete"); () => Object.freeze({ kind: "TIMEOUT" }),
signal.removeEventListener("abort", onAbort); () => Object.freeze({ kind: "ABORTED" }),
return signal.aborted && selected.kind === "VALUE" );
? Object.freeze({ kind: "ABORTED" }) const selected = await Promise.race([workResult, timerResult]);
: selected; return signal.aborted && selected.kind === "VALUE"
? Object.freeze({ kind: "ABORTED" })
: selected;
} finally {
timerController.abort("race-complete");
signal.removeEventListener("abort", onAbort);
}
} }
function observe( function observe(
+17 -8
View File
@@ -1,3 +1,7 @@
import {
createReadOnlyRegistry,
type ReadOnlyRegistry,
} from "./read-only-registry.ts";
import type { InstalledBoundaryMapper } from "./boundary-mapper.ts"; import type { InstalledBoundaryMapper } from "./boundary-mapper.ts";
import type { RuntimeSchemaCodec } from "./schema-registry.ts"; import type { RuntimeSchemaCodec } from "./schema-registry.ts";
@@ -327,13 +331,18 @@ 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<{ export type InstalledBrowserRpcContractBindings = Readonly<{
operations: ReadonlyMap<string, BrowserRpcOperationV3>; operations: ReadOnlyRegistry<string, BrowserRpcOperationV3>;
profiles: ReadonlyMap<string, BrowserRpcProviderProfile>; profiles: ReadOnlyRegistry<string, BrowserRpcProviderProfile>;
schemaCodecs: ReadonlyMap<string, RuntimeSchemaCodec>; schemaCodecs: ReadOnlyRegistry<string, RuntimeSchemaCodec>;
mappers: ReadonlyMap<string, InstalledBoundaryMapper>; mappers: ReadOnlyRegistry<string, InstalledBoundaryMapper>;
requestEncoders: ReadonlyMap<string, BrowserRpcRequestEncoder>; requestEncoders: ReadOnlyRegistry<string, BrowserRpcRequestEncoder>;
runtimeBindings: ReadonlyMap<string, BrowserRpcRuntimeBindingIdentity>; runtimeBindings: ReadOnlyRegistry<string, BrowserRpcRuntimeBindingIdentity>;
}>; }>;
/** /**
@@ -351,7 +360,7 @@ function installRegistrySnapshot<Value extends object>(
source: Readonly<Record<string, Value>>, source: Readonly<Record<string, Value>>,
label: string, label: string,
allowedKeys: readonly string[], allowedKeys: readonly string[],
): ReadonlyMap<string, Value> { ): ReadOnlyRegistry<string, Value> {
let ownKeys: string[]; let ownKeys: string[];
let symbols: readonly symbol[]; let symbols: readonly symbol[];
try { try {
@@ -376,7 +385,7 @@ function installRegistrySnapshot<Value extends object>(
installRowSnapshot(descriptor.value as Value, `${label}.${key}`, allowedKeys), installRowSnapshot(descriptor.value as Value, `${label}.${key}`, allowedKeys),
); );
} }
return Object.freeze(installed) as ReadonlyMap<string, Value>; return createReadOnlyRegistry(installed);
} }
function installRowSnapshot<Value extends object>( function installRowSnapshot<Value extends object>(
@@ -0,0 +1,315 @@
import { describe, expect, it } from "vitest";
import {
createBrowserRpcRuntime,
defineBrowserRpcTransport,
type BrowserRpcStreamFrame,
type BrowserRpcTransport,
} from "../../../src/adapters/browser-rpc/index.ts";
import { installBrowserRpcContractBindings } from "../../../src/contracts/browser-rpc.ts";
import {
MAPPERS,
SCHEMA_CODECS,
STREAM_ENCODER,
UNARY_ENCODER,
isResourceView,
streamOperation,
streamProfile,
unaryOperation,
unaryProfile,
} from "./fixture.ts";
function unaryRuntime(
transport: BrowserRpcTransport,
extra: Record<string, unknown> = {},
) {
return createBrowserRpcRuntime({
operations: { GET_RPC_RESOURCE: unaryOperation() },
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
schemaCodecs: SCHEMA_CODECS,
mappers: MAPPERS,
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
transports: { CONNECT_REFERENCE_UNARY: transport },
...extra,
});
}
function streamingRuntime(
transport: BrowserRpcTransport,
extra: Record<string, unknown> = {},
) {
return createBrowserRpcRuntime({
operations: { WATCH_RPC_RESOURCES: streamOperation() },
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
schemaCodecs: SCHEMA_CODECS,
mappers: MAPPERS,
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
transports: { CONNECT_REFERENCE_STREAM: transport },
...extra,
});
}
function unaryTransport(
invokeUnary: BrowserRpcTransport["invokeUnary"],
): BrowserRpcTransport {
return defineBrowserRpcTransport({
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
providerId: "REFERENCE_RPC",
protocol: "CONNECT_HTTP",
rpcKind: "UNARY",
invokeUnary,
});
}
function streamTransport(
frames: readonly BrowserRpcStreamFrame[],
): BrowserRpcTransport {
return defineBrowserRpcTransport({
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
providerId: "REFERENCE_RPC",
protocol: "CONNECT_HTTP",
rpcKind: "SERVER_STREAM",
openServerStream: () => ({
async *[Symbol.asyncIterator]() {
for (const frame of frames) yield frame;
},
}),
});
}
async function collect(
source: AsyncIterable<unknown>,
): Promise<readonly unknown[]> {
const seen: unknown[] = [];
for await (const value of source) seen.push(value);
return seen;
}
/**
* RPC-RR-02. A collaborator that throws synchronously must not escape the
* Result contract. A generation fence captured outside the protected boundary
* and a `clock.sleep` invoked outside a promise boundary both did exactly that,
* and the second also skipped the listener and timer release.
*/
describe("RPC-RR-02 synchronous collaborator throws stay inside Result", () => {
const throwingFence = {
capture: () => {
throw new TypeError("fence exploded");
},
isCurrent: () => true,
};
it("closes a unary call whose fence throws", async () => {
const runtime = unaryRuntime(
unaryTransport(async () => ({
ok: true,
message: { id: "a", name: "A" },
encodedBytes: 8,
})),
{ generationFence: throwingFence },
);
const result = await runtime
.bindUnary("GET_RPC_RESOURCE", isResourceView)
.execute({ resourceId: "resource-1" });
expect(result.ok).toBe(false);
});
it("closes a stream whose fence throws", async () => {
const runtime = streamingRuntime(
streamTransport([{ kind: "TERMINAL", ok: true }]),
{ generationFence: throwingFence },
);
const results = await collect(
runtime
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
.open({ topic: "resources" }),
);
expect(results.length).toBeGreaterThan(0);
expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe(
true,
);
});
it("closes a unary call whose clock throws synchronously", async () => {
const runtime = unaryRuntime(
unaryTransport(() => new Promise(() => {})),
{
clock: {
now: () => 0,
sleep: () => {
throw new TypeError("clock exploded");
},
},
},
);
const result = await runtime
.bindUnary("GET_RPC_RESOURCE", isResourceView)
.execute({ resourceId: "resource-1" });
expect(result.ok).toBe(false);
});
});
/**
* RPC-RR-03. The runtime reads a validated snapshot, never the caller's
* objects: an accessor is refused without being invoked, and no installed
* registry exposes a mutator.
*/
describe("RPC-RR-03 transport and binding registries are snapshots", () => {
it("never invokes a transport accessor", () => {
let getterCalls = 0;
const hostile = {} as Record<string, unknown>;
Object.defineProperties(hostile, {
runtimeProfileId: {
enumerable: true,
get: () => {
getterCalls += 1;
return "CONNECT_REFERENCE_UNARY";
},
},
providerId: { enumerable: true, value: "REFERENCE_RPC" },
protocol: { enumerable: true, value: "CONNECT_HTTP" },
rpcKind: { enumerable: true, value: "UNARY" },
invokeUnary: {
enumerable: true,
value: async () => ({ ok: true, message: {}, encodedBytes: 1 }),
},
});
expect(() =>
unaryRuntime(hostile as unknown as BrowserRpcTransport),
).toThrow(TypeError);
expect(getterCalls).toBe(0);
});
it("rejects an unexpected own field on a transport row", () => {
const transport = unaryTransport(async () => ({
ok: true,
message: { id: "a", name: "A" },
encodedBytes: 8,
}));
const widened = { ...transport, injected: true };
expect(() =>
unaryRuntime(widened as unknown as BrowserRpcTransport),
).toThrow(TypeError);
});
it("exposes no mutation API on any installed binding registry", () => {
const installed = installBrowserRpcContractBindings({
operations: { GET_RPC_RESOURCE: unaryOperation() },
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
schemaCodecs: SCHEMA_CODECS,
mappers: MAPPERS,
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
});
for (const registry of Object.values(installed)) {
const record = registry as unknown as Record<string, unknown>;
for (const mutator of ["set", "delete", "clear"]) {
expect(record[mutator]).toBeUndefined();
}
expect(() =>
Map.prototype.clear.call(registry as never),
).toThrow();
}
expect(installed.operations.get("GET_RPC_RESOURCE")).toBeDefined();
});
});
/**
* RPC-RR-04. Transport values are decoded, not adopted. `in` and a direct
* property read run accessors and admit inherited or extra fields, and keeping
* the caller's object lets it change after validation.
*/
describe("RPC-RR-04 transport results and frames are exactly decoded", () => {
const hostileResults: readonly (readonly [string, () => unknown])[] = [
["extra own field", () => ({ ok: true, message: {}, encodedBytes: 1, injected: 1 })],
[
"inherited fields",
() =>
Object.create({ ok: true, message: {}, encodedBytes: 1 }) as object,
],
[
"throwing getter",
() => {
const value: Record<string, unknown> = { message: {}, encodedBytes: 1 };
Object.defineProperty(value, "ok", {
enumerable: true,
get: () => {
throw new TypeError("hostile getter");
},
});
return value;
},
],
[
"symbol key",
() => ({
ok: true,
message: {},
encodedBytes: 1,
[Symbol("injected")]: 1,
}),
],
];
for (const [label, build] of hostileResults) {
it(`refuses a unary result with ${label}`, async () => {
const runtime = unaryRuntime(
unaryTransport(async () => build() as never),
);
const result = await runtime
.bindUnary("GET_RPC_RESOURCE", isResourceView)
.execute({ resourceId: "resource-1" });
expect(result.ok).toBe(false);
});
}
it("refuses an unknown transport failure code", async () => {
const runtime = unaryRuntime(
unaryTransport(async () => ({
ok: false,
failure: { code: "MADE_UP_CODE" },
}) as never),
);
const result = await runtime
.bindUnary("GET_RPC_RESOURCE", isResourceView)
.execute({ resourceId: "resource-1" });
expect(result.ok).toBe(false);
});
it("refuses a retryAfterMs outside the hard ceiling", async () => {
const runtime = unaryRuntime(
unaryTransport(async () => ({
ok: false,
failure: { code: "NETWORK_UNREACHABLE", retryAfterMs: -1 },
}) as never),
);
const result = await runtime
.bindUnary("GET_RPC_RESOURCE", isResourceView)
.execute({ resourceId: "resource-1" });
expect(result.ok).toBe(false);
});
it("refuses a stream frame with an extra own field", async () => {
const runtime = streamingRuntime(
streamTransport([
{
kind: "MESSAGE",
message: { id: "a", name: "A" },
encodedBytes: 4,
injected: 1,
} as never,
]),
);
const results = await collect(
runtime
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
.open({ topic: "resources" }),
);
expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe(
true,
);
});
});