fix: align browser validation with Rust wire shape

This commit is contained in:
2026-09-29 05:15:59 +09:00
parent df3251b2a0
commit f33e737d5c
7 changed files with 277 additions and 18 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ The generator fails closed on unsupported or lossy OpenAPI constructs, including
## Workspace subscription / Worker protocol
`protocol.ts` and `protocol-validator.ts` are generated together from the Serde/`schemars` closure of `protocol::subscription::SubscriptionFrame`. Rust DTOs and validation constants in `crates/protocol` remain the sole wire authority. The validator closes every generated object shape, rejects unknown tagged variants and unsafe integers, applies Rust-owned aggregate bounds, and mirrors the Browser-relevant `SubscriptionFrame::validate()` selector/payload checks. The Workspace multiplexer additionally checks live event payloads against its locally retained selector.
`protocol.ts` and `protocol-validator.ts` are generated together from the Serde/`schemars` closure of `protocol::subscription::SubscriptionFrame`. Rust DTOs and validation constants in `crates/protocol` remain the sole wire authority. The generator normalizes Serde defaults to the actual serialized required/nullable shape, tightens every supported Rust integer format to its JavaScript-safe wire range, and fails on an unknown integer format. The validator closes every generated object shape, rejects unknown tagged variants and unsafe integers, applies Rust-owned aggregate bounds to object keys as well as values, and mirrors the Browser-relevant `SubscriptionFrame::validate()` selector/payload checks. Rust-serialized compatibility fixtures are embedded for Browser regression tests. The Workspace multiplexer additionally checks live event payloads against its locally retained selector.
Inbound Browser frames only are validated: Browser-to-Server methods remain compile-time typed and are decoded plus semantically validated by the Rust server. Invalid inbound frames close the shared socket with a fixed, non-reflective diagnostic; the existing reconnect path clears routing state and requests fresh snapshots. A valid `subscription_closed` frame continues to isolate and resubscribe only that subscription.
File diff suppressed because one or more lines are too long
@@ -1,6 +1,8 @@
import type { EventSubscriptionSelector, SubscriptionFrame } from '$lib/generated/protocol';
import {
decodeSubscriptionFrame,
MAX_SUBSCRIPTION_STRING_BYTES,
RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES,
subscriptionFrameMatchesSelector,
} from '$lib/generated/protocol-validator';
@@ -40,6 +42,13 @@ Deno.test('generated subscription validator accepts a current exact frame', () =
assert(result.value.frame === 'response', 'expected response frame');
});
Deno.test('Rust-serialized compatibility fixtures satisfy the generated Browser contract', () => {
for (const fixture of RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES) {
const result = decode(fixture);
assert(result.ok, `Rust fixture was rejected: ${JSON.stringify(result)}`);
}
});
Deno.test('generated subscription validator rejects malformed and newer shapes', () => {
const cases: Array<[string, unknown]> = [
['malformed json', '{'],
@@ -52,6 +61,19 @@ Deno.test('generated subscription validator rejects malformed and newer shapes',
],
['wrong field type', subscribedFrame({ snapshot_revision: '1' })],
['unsafe integer', subscribedFrame({ snapshot_revision: Number.MAX_SAFE_INTEGER + 1 })],
[
'missing serialized Worker defaults',
subscribedFrame({
snapshot: {
topic: 'workers',
data: {
workers: [
{ worker_id: 'worker-1', subject_revision: 1, state: 'idle' },
],
},
},
}),
],
['unsupported version', { ...subscribedFrame(), protocol_version: 2 }],
[
'newer Worker event',
@@ -71,6 +93,37 @@ Deno.test('generated subscription validator rejects malformed and newer shapes',
},
},
],
[
'out-of-range Rust u32',
{
protocol_version: 1,
frame: 'event',
message: {
event: 'event',
data: {
subscription_id: 'subscription-1',
subject_revision: 2,
payload: {
event: 'worker_protocol',
data: {
worker_id: 'worker-1',
event: {
event: 'llm_retry',
data: {
llm_call: 1,
failed_attempt: 4_294_967_296,
max_attempts: 2,
wait_ms: 1,
elapsed_ms: 1,
error: 'retry',
},
},
},
},
},
},
},
],
];
for (const [name, value] of cases) {
@@ -161,6 +214,32 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
}
});
Deno.test('aggregate limits include keys in unconstrained Worker JSON values', () => {
const oversizedKey = 'k'.repeat(MAX_SUBSCRIPTION_STRING_BYTES + 1);
const result = decode({
protocol_version: 1,
frame: 'event',
message: {
event: 'event',
data: {
subscription_id: 'subscription-1',
subject_revision: 2,
payload: {
event: 'worker_protocol',
data: {
worker_id: 'worker-1',
event: {
event: 'system_item',
data: { item: { [oversizedKey]: true } },
},
},
},
},
},
});
assert(!result.ok && result.reason === 'aggregate_limit', 'oversized object key passed');
});
Deno.test('stateful selector validation fences a routed event before projection', () => {
const frame: SubscriptionFrame = {
protocol_version: 1,