fix: align browser validation with Rust wire shape
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user