feat: relay world snapshot and voxel payloads
Allow world.* methods through the relay, validate their payloads, and track per-session world revisions in the session store.
This commit is contained in:
@@ -278,36 +278,75 @@ test('tab_complete dispatch rejects ranges beyond request text and accepts exact
|
||||
} finally {await f.close();}
|
||||
});
|
||||
|
||||
test('bot_event is rejected while bot_log checks permission, scope and sequence', async () => {
|
||||
test('status lifecycle events fan out to snapshot readers with scope, permission and sequence checks', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const node = await f.node(); const web = await f.web();
|
||||
const limited = await f.web(randomUUID(), ['bot.subscribe']);
|
||||
await ack(web.socket, 'relay_request', request('bot.subscribe', {events: ['status_update', 'log']}));
|
||||
await ack(limited.socket, 'relay_request', request('bot.subscribe', {events: ['status_update']}));
|
||||
let limited_events = 0; limited.socket.on('bot_event', () => limited_events++);
|
||||
const node = await f.node();
|
||||
const web = await f.web(randomUUID(), ['bot.snapshot']);
|
||||
const log_reader = await f.web(randomUUID(), ['bot.logs']);
|
||||
let log_reader_events = 0;
|
||||
log_reader.socket.on('bot_event', () => log_reader_events++);
|
||||
const stream = randomUUID();
|
||||
const event = {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id, context_generation: null as string | null,
|
||||
stream_id: stream, seq: 1, emitted_at: Date.now(), name: 'status_update', payload: {status: 'offline', context_exists: false}};
|
||||
let web_events = 0; web.socket.on('bot_event', () => web_events++);
|
||||
code(await ack(node, 'bot_event', event), 'FORBIDDEN');
|
||||
code(await ack(web.socket, 'bot_event', {...event, seq: 2}), 'FORBIDDEN');
|
||||
code(await ack(web.socket, 'forward_open_session', {}), 'FORBIDDEN');
|
||||
node.emit('bot_event', {...event, seq: 3});
|
||||
node.on('relay_request', (_value, reply) => reply(snapshot(f.s.bot_id)));
|
||||
assert.equal((await ack(web.socket, 'relay_request', request('bot.snapshot'))).status, 'success');
|
||||
assert.equal(web_events, 0);
|
||||
const log = {...event, name: 'log', payload: {records: [] as never[]}};
|
||||
let limited_logs = 0; limited.socket.on('bot_log', () => limited_logs++);
|
||||
const delivered = received(web.socket, 'bot_log');
|
||||
const context_generation = randomUUID();
|
||||
const event = (seq: number, status: string, context_exists: boolean) => ({
|
||||
forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id,
|
||||
context_generation: context_exists ? context_generation : null,
|
||||
stream_id: stream, seq, emitted_at: Date.now(), name: 'status_update',
|
||||
payload: {status, context_exists},
|
||||
});
|
||||
for (const [seq, status, context_exists] of [[1, 'online', true], [2, 'disconnecting', true],
|
||||
[3, 'offline', true], [4, 'offline', false]] as const) {
|
||||
const expected = event(seq, status, context_exists);
|
||||
const delivered = received(web.socket, 'bot_event');
|
||||
assert.deepEqual(await ack(node, 'bot_event', expected), {status: 'success', data: {accepted: true}});
|
||||
assert.deepEqual((await delivered)[0], expected);
|
||||
}
|
||||
assert.equal(log_reader_events, 0);
|
||||
|
||||
code(await ack(web.socket, 'bot_event', event(5, 'offline', false)), 'FORBIDDEN');
|
||||
for (const [name, payload] of [['bot_error', {message: 'unexpected'}], ['message', {text: 'hello', position: 'chat'}],
|
||||
['log', {records: []}], ['arbitrary', {}], ['status_update', {status: 'invalid', context_exists: false}],
|
||||
['status_update', {status: 'offline', context_exists: false, extra: true}]] as const) {
|
||||
code(await ack(node, 'bot_event', {...event(5, 'offline', false), name, payload}), 'INVALID_REQUEST');
|
||||
}
|
||||
code(await ack(node, 'bot_event', event(5, 'online', false)), 'INVALID_REQUEST');
|
||||
code(await ack(node, 'bot_event', {...event(5, 'offline', true), context_generation: null}), 'INVALID_REQUEST');
|
||||
code(await ack(node, 'bot_event', {...event(5, 'offline', false), seq: -1}), 'INVALID_REQUEST');
|
||||
code(await ack(node, 'bot_event', {...event(5, 'offline', false), extra: true}), 'INVALID_REQUEST');
|
||||
code(await ack(node, 'bot_event', event(4, 'offline', false)), 'INVALID_REQUEST');
|
||||
code(await ack(node, 'bot_event', {...event(5, 'offline', false), bot_id: f.s.bot_id + 1}), 'FORBIDDEN');
|
||||
code(await ack(node, 'bot_event', {...event(5, 'offline', false), forward_session_id: randomUUID()}), 'FORBIDDEN');
|
||||
const next = event(5, 'offline', false);
|
||||
const delivered = received(web.socket, 'bot_event');
|
||||
node.emit('bot_event', next);
|
||||
assert.deepEqual((await delivered)[0], next);
|
||||
|
||||
const log = {...next, seq: 6, name: 'log', payload: {records: [] as never[]}};
|
||||
let limited_logs = 0; web.socket.on('bot_log', () => limited_logs++);
|
||||
const log_delivered = received(log_reader.socket, 'bot_log');
|
||||
assert.equal((await ack(node, 'bot_log', log)).status, 'success');
|
||||
assert.deepEqual((await delivered)[0], log);
|
||||
code(await ack(node, 'bot_log', log), 'INVALID_REQUEST');
|
||||
code(await ack(web.socket, 'bot_log', {...log, seq: 2}), 'FORBIDDEN');
|
||||
code(await ack(node, 'bot_log', {...log, seq: 2, bot_id: f.s.bot_id + 1}), 'FORBIDDEN');
|
||||
code(await ack(node, 'bot_log', {...event, seq: 2}), 'INVALID_REQUEST');
|
||||
assert.deepEqual((await log_delivered)[0], log);
|
||||
code(await ack(node, 'bot_log', {...log, seq: 5}), 'INVALID_REQUEST');
|
||||
code(await ack(web.socket, 'bot_log', {...log, seq: 6}), 'FORBIDDEN');
|
||||
code(await ack(node, 'bot_log', {...log, seq: 6, bot_id: f.s.bot_id + 1}), 'FORBIDDEN');
|
||||
code(await ack(node, 'bot_log', {...next, seq: 6}), 'INVALID_REQUEST');
|
||||
code(await ack(node, 'bot_event', event(6, 'offline', false)), 'INVALID_REQUEST');
|
||||
code(await ack(node, 'bot_event', event(5, 'offline', false)), 'INVALID_REQUEST');
|
||||
const update = {forward_session_id: next.forward_session_id, bot_id: next.bot_id,
|
||||
stream_id: stream, context_generation, emitted_at: next.emitted_at, seq: 7,
|
||||
data: {name: 'health', value: 20}};
|
||||
assert.equal((await ack(node, 'info_update', update)).status, 'success');
|
||||
code(await ack(node, 'bot_event', event(7, 'offline', false)), 'INVALID_REQUEST');
|
||||
const replacement = {...event(0, 'offline', false), stream_id: randomUUID()};
|
||||
assert.equal((await ack(node, 'bot_event', replacement)).status, 'success');
|
||||
code(await ack(node, 'bot_event', event(8, 'offline', false)), 'RESYNC_REQUIRED');
|
||||
assert.equal(limited_logs, 0);
|
||||
assert.equal(limited_events, 0);
|
||||
assert.equal(log_reader_events, 0);
|
||||
|
||||
const restricted = await f.node(['bot.logs']);
|
||||
code(await ack(restricted, 'bot_event', event(8, 'offline', false)), 'FORBIDDEN');
|
||||
const not_ready = await f.node(['bot.snapshot'], false);
|
||||
code(await ack(not_ready, 'bot_event', event(8, 'offline', false)), 'NODE_OFFLINE');
|
||||
} finally {await f.close();}
|
||||
});
|
||||
|
||||
@@ -434,6 +473,29 @@ test('info_update auto-pushes exact envelopes and shares log stream and sequence
|
||||
} finally {await f.close();}
|
||||
});
|
||||
|
||||
test('lifecycle packets use reliable bounded buffering and share the Node event rate', async () => {
|
||||
const f = await fixture({max_buffered_packets: 2, events_per_second: 3});
|
||||
try {
|
||||
const node = await f.node(['bot.snapshot', 'bot.logs']);
|
||||
const web = await f.web(randomUUID(), ['bot.snapshot']);
|
||||
const server_web = f.namespace.sockets.get(web.socket.id!)!;
|
||||
const transport = server_web.conn as unknown as {writeBuffer: unknown[]};
|
||||
server_web.conn.transport.writable = false;
|
||||
const event = {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id,
|
||||
context_generation: null as string | null, stream_id: randomUUID(), seq: 1, emitted_at: Date.now(),
|
||||
name: 'status_update', payload: {status: 'offline', context_exists: false}};
|
||||
for (let seq = 1; seq <= 3; seq++) {
|
||||
assert.equal((await ack(node, 'bot_event', {...event, seq})).status, 'success');
|
||||
if (seq === 1) assert.equal(transport.writeBuffer.length, 1);
|
||||
}
|
||||
assert.equal(server_web.connected, false);
|
||||
assert.ok(transport.writeBuffer.length <= 4);
|
||||
const closed = received(node, 'forward_session_closed');
|
||||
node.emit('bot_log', {...event, seq: 4, name: 'log', payload: {records: []}});
|
||||
assert.equal((await closed)[0].code, 'RATE_LIMITED');
|
||||
} finally {await f.close();}
|
||||
});
|
||||
|
||||
test('info_update enforces readiness and Node and Web token permissions independently of subscriptions', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
@@ -619,9 +681,9 @@ test('slow-client status buffer overflow and forbidden event floods require expl
|
||||
// Notification is emitted before disconnect; packet delivery depends on Engine.IO's buffer flush.
|
||||
assert.equal(server_web.connected, false);
|
||||
await Promise.race([closed, delay(30)]);
|
||||
code(await ack(replacement, 'bot_event', event), 'FORBIDDEN');
|
||||
code(await ack(replacement, 'bot_event', event), 'NODE_OFFLINE');
|
||||
const node_closed = received(replacement, 'forward_session_closed');
|
||||
for (let seq = 2; seq <= 10; seq++) replacement.emit('bot_event', {...event, seq});
|
||||
for (let seq = 2; seq <= 10; seq++) replacement.emit('unknown', {...event, seq});
|
||||
assert.equal((await node_closed)[0].code, 'RATE_LIMITED');
|
||||
} finally {await f.close();}
|
||||
});
|
||||
@@ -660,9 +722,11 @@ test('reauth extends only the same identity, reconnect restores subscriptions, r
|
||||
const replacement = await f.connect();
|
||||
await ack(replacement, 'join_room', {token: token(f.s, 'web', web.grant_id, expiry)});
|
||||
await ack(replacement, 'relay_request', subscribe);
|
||||
code(await ack(node, 'bot_event', {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id,
|
||||
const pushed = received(replacement, 'bot_event');
|
||||
assert.equal((await ack(node, 'bot_event', {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id,
|
||||
context_generation: null, stream_id: randomUUID(), seq: 1, emitted_at: Date.now(),
|
||||
name: 'status_update', payload: {status: 'offline', context_exists: false}}), 'FORBIDDEN');
|
||||
name: 'status_update', payload: {status: 'offline', context_exists: false}})).status, 'success');
|
||||
assert.equal((await pushed)[0].payload.status, 'offline');
|
||||
node.on('relay_request', (_value, reply) => reply(snapshot(f.s.bot_id)));
|
||||
assert.equal((await ack(replacement, 'relay_request', request('bot.snapshot'))).status, 'success');
|
||||
f.store.revoke_grant({forward_session_id: f.s.forward_session_id, grant_id: web.grant_id, reason: 'permission_removed'});
|
||||
|
||||
+182
-1
@@ -4,7 +4,7 @@ import {randomUUID} from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
import type {Socket} from 'socket.io';
|
||||
import {RELAY_METHODS, WORKFLOW_ACTIONS} from '../types.ts';
|
||||
import type {ForwardAck, JsonValue, NodeRelayRequest, RelayClaims, RelayLimits, RelayMethod, RelayRequest, WorkflowRunSnapshot} from '../types.ts';
|
||||
import type {ForwardAck, JsonValue, NodeRelayRequest, RelayClaims, RelayLimits, RelayMethod, RelayRequest, WorkflowRunSnapshot, WorldSnapshot} from '../types.ts';
|
||||
import {create_session_store} from '../runtime/session_store.ts';
|
||||
import {DEFAULT_LIMITS} from '../utils/relay_config.ts';
|
||||
import {encode_token} from '../utils/token_utils.ts';
|
||||
@@ -70,6 +70,187 @@ function code(value: ForwardAck<unknown>, expected: string) {
|
||||
const control = () => ({command_id: randomUUID(), context_generation: randomUUID(), action: 'run_once', workflow_id: 'a'});
|
||||
const command = (input: ReturnType<typeof control>) => ({command_id: input.command_id, action: input.action, workflow_id: input.workflow_id, state: 'accepted'});
|
||||
|
||||
const world = (): WorldSnapshot => ({world_generation: randomUUID(), context_generation: randomUUID(), stream_id: randomUUID(), watch_id: randomUUID(),
|
||||
version: '1.21.1', dimension: 'minecraft:overworld', min_y: -64, height: 384, center: {x: 0, z: 0},
|
||||
position: {x: 0, y: 64, z: 0, yaw: 0}, chunks: [], loaded: [] as Array<{x: number; z: number; revision: string}>, partial: true});
|
||||
|
||||
test('mock lifecycle status pushes require snapshot permission and do not require log subscriptions', async t => {
|
||||
for (const [web_permissions, node_permissions] of [
|
||||
[['bot.snapshot'], ['bot.snapshot']], [['bot.logs', 'bot.subscribe'], ['bot.snapshot']],
|
||||
[['bot.snapshot'], ['bot.logs']],
|
||||
] as RelayMethod[][][]) {
|
||||
const f = await fixture(t, {}, web_permissions, node_permissions);
|
||||
const received: unknown[] = [];
|
||||
f.web.on('bot_event', value => received.push(value));
|
||||
const base = {forward_session_id: f.scope.forward_session_id, bot_id: f.scope.bot_id,
|
||||
context_generation: randomUUID(), stream_id: randomUUID(), emitted_at: Date.now(), name: 'status_update'};
|
||||
const events = ['online', 'disconnecting', 'offline'].map((status, index) => ({...base, seq: index + 1,
|
||||
payload: {status, context_exists: true}}));
|
||||
events.push({...base, context_generation: null, seq: 4, payload: {status: 'offline', context_exists: false}});
|
||||
for (const event of events) {
|
||||
if (node_permissions.includes('bot.snapshot')) assert.deepEqual(f.store.event(f.node.socket, event), {accepted: true});
|
||||
else assert.throws(() => f.store.event(f.node.socket, event), {code: 'FORBIDDEN'});
|
||||
}
|
||||
assert.deepEqual(received, web_permissions.includes('bot.snapshot') && node_permissions.includes('bot.snapshot') ? events : []);
|
||||
assert.throws(() => f.store.event(f.web.socket, events[0]), {code: 'FORBIDDEN'});
|
||||
}
|
||||
});
|
||||
|
||||
test('mock lifecycle pushes reject invalid names, payloads, scopes and shared sequence replays', async t => {
|
||||
const f = await fixture(t, {}, ['bot.snapshot']);
|
||||
const event = {forward_session_id: f.scope.forward_session_id, bot_id: f.scope.bot_id,
|
||||
context_generation: randomUUID(), stream_id: randomUUID(), seq: 1, emitted_at: Date.now(),
|
||||
name: 'status_update', payload: {status: 'online', context_exists: true}};
|
||||
for (const invalid of [
|
||||
{...event, name: 'message', payload: {text: 'hi', position: 'chat'}},
|
||||
{...event, name: 'bot_error', payload: {message: 'error'}},
|
||||
{...event, name: 'log', payload: {records: [] as never[]}}, {...event, name: 'arbitrary'},
|
||||
{...event, payload: {status: 'invalid', context_exists: true}},
|
||||
{...event, payload: {status: 'offline', context_exists: 'true'}},
|
||||
{...event, payload: {status: 'online', context_exists: true, token: 'secret'}},
|
||||
{...event, context_generation: null}, {...event, seq: -1}, {...event, stream_id: 'invalid'},
|
||||
{...event, payload: {status: 'offline', context_exists: false}},
|
||||
]) assert.throws(() => f.store.event(f.node.socket, invalid), {code: 'INVALID_REQUEST'});
|
||||
for (const invalid of [{...event, bot_id: f.scope.bot_id + 1}, {...event, forward_session_id: randomUUID()}]) {
|
||||
assert.throws(() => f.store.event(f.node.socket, invalid), {code: 'FORBIDDEN'});
|
||||
}
|
||||
f.store.event(f.node.socket, event);
|
||||
f.store.log(f.node.socket, {...event, seq: 2, name: 'log', payload: {records: []}});
|
||||
for (const seq of [1, 2]) assert.throws(() => f.store.event(f.node.socket, {...event, seq}), {code: 'INVALID_REQUEST'});
|
||||
f.store.info(f.node.socket, {forward_session_id: event.forward_session_id, bot_id: event.bot_id,
|
||||
context_generation: event.context_generation, stream_id: event.stream_id, seq: 3,
|
||||
emitted_at: event.emitted_at, data: {name: 'health', value: 20}});
|
||||
assert.throws(() => f.store.event(f.node.socket, {...event, seq: 3}), {code: 'INVALID_REQUEST'});
|
||||
f.store.event(f.node.socket, {...event, seq: 4});
|
||||
assert.throws(() => f.store.log(f.node.socket, {...event, seq: 4, name: 'log', payload: {records: []}}), {code: 'INVALID_REQUEST'});
|
||||
f.store.event(f.node.socket, {...event, stream_id: randomUUID(), seq: 0});
|
||||
assert.throws(() => f.store.event(f.node.socket, {...event, seq: 5}), {code: 'RESYNC_REQUIRED'});
|
||||
});
|
||||
|
||||
test('mock lifecycle fanout checks current Web claims, grants, readiness and expiry', async t => {
|
||||
const f = await fixture(t, {}, ['bot.snapshot', 'bot.logs']);
|
||||
const received: unknown[] = [];
|
||||
const event = {forward_session_id: f.scope.forward_session_id, bot_id: f.scope.bot_id,
|
||||
context_generation: null as string | null, stream_id: randomUUID(), seq: 1, emitted_at: Date.now(),
|
||||
name: 'status_update', payload: {status: 'offline', context_exists: false}};
|
||||
const limited = new MockSocket();
|
||||
await f.join(limited, 'web', ['bot.logs']);
|
||||
limited.on('bot_event', value => received.push(value));
|
||||
f.store.event(f.node.socket, event);
|
||||
assert.deepEqual(received, []);
|
||||
const allowed = new MockSocket();
|
||||
await f.join(allowed, 'web', ['bot.snapshot']);
|
||||
allowed.on('bot_event', value => received.push(value));
|
||||
f.store.event(f.node.socket, {...event, seq: 2});
|
||||
assert.equal(received.length, 1);
|
||||
f.store.revoke_grant({forward_session_id: f.scope.forward_session_id, grant_id: f.grant_id, reason: 'revoked'});
|
||||
f.store.event(f.node.socket, {...event, seq: 3});
|
||||
assert.equal(received.length, 1);
|
||||
const replacement = new MockSocket();
|
||||
await f.join(replacement, 'node', ['bot.snapshot']);
|
||||
assert.throws(() => f.store.event(f.node.socket, {...event, seq: 4}), {code: 'UNAUTHORIZED'});
|
||||
assert.throws(() => f.store.event(replacement.socket, event), {code: 'NODE_OFFLINE'});
|
||||
f.store.node_ready(replacement.socket, {forward_session_id: f.scope.forward_session_id});
|
||||
t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: Date.now()});
|
||||
t.mock.timers.tick(50_001);
|
||||
assert.throws(() => f.store.event(replacement.socket, event), {code: 'UNAUTHORIZED'});
|
||||
t.mock.timers.reset();
|
||||
});
|
||||
|
||||
test('mock world RPCs use snapshot permission on web grants and Node tokens', async t => {
|
||||
for (const [web, node] of [
|
||||
[['bot.snapshot'], ['bot.snapshot']], [['bot.logs'], ['bot.snapshot']], [['bot.snapshot'], ['bot.logs']],
|
||||
] as RelayMethod[][][]) {
|
||||
const f = await fixture(t, {}, web, node);
|
||||
f.store.set_control_available(false);
|
||||
for (const [method, payload] of [['world.snapshot', {radius: 1, known: []}], ['world.unwatch', {watch_id: randomUUID()}]] as const) {
|
||||
const data = method === 'world.snapshot' ? world() : {released: true};
|
||||
f.node.respond = () => success(data);
|
||||
const result = await f.request(method, payload);
|
||||
if (web.includes('bot.snapshot') && node.includes('bot.snapshot')) {
|
||||
assert.deepEqual(result, success(data));
|
||||
const forwarded = f.node.requests.at(-1)!;
|
||||
assert.equal(forwarded.method, method);
|
||||
assert.deepEqual(forwarded.payload, payload);
|
||||
assert.equal(forwarded.forward_session_id, f.scope.forward_session_id);
|
||||
assert.notEqual(forwarded.correlation_id, forwarded.request_id);
|
||||
assert.ok(forwarded.deadline_at > Date.now());
|
||||
} else {
|
||||
code(result, 'FORBIDDEN');
|
||||
assert.equal(f.node.requests.length, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('mock world reads refresh watch results and validate ACKs against request radius', async t => {
|
||||
const f = await fixture(t);
|
||||
const reads = [['world.snapshot', {radius: 1, known: []}], ['world.unwatch', {watch_id: randomUUID()}]] as const;
|
||||
for (const [method, payload] of reads) {
|
||||
const before = f.node.requests.length;
|
||||
const id = randomUUID();
|
||||
let count = 0;
|
||||
f.node.respond = () => {count++; return success(method === 'world.snapshot' ? world() : {released: true});};
|
||||
const first = await f.request(method, payload, id);
|
||||
const second = await f.request(method, payload, id);
|
||||
assert.equal(first.status, 'success');
|
||||
assert.equal(second.status, 'success');
|
||||
assert.equal(count, 2);
|
||||
if (method === 'world.snapshot') assert.notDeepEqual(first, second);
|
||||
f.node.respond = () => error_ack({});
|
||||
code(await f.request(method, payload, id), 'INTERNAL_ERROR');
|
||||
assert.equal(f.node.requests.length - before, 3);
|
||||
const mismatched = method === 'world.snapshot' ? {...payload, radius: 2} : {watch_id: randomUUID()};
|
||||
code(await f.request(method, mismatched, id), 'INVALID_REQUEST');
|
||||
}
|
||||
f.node.respond = () => success({...world(), loaded: [{x: 2, z: 0, revision: randomUUID()}]});
|
||||
code(await f.request('world.snapshot', {radius: 1, known: []}), 'INVALID_REQUEST');
|
||||
assert.equal((await f.request('world.snapshot', {radius: 2, known: []})).status, 'success');
|
||||
const replacement = new MockSocket();
|
||||
const id = randomUUID(); const payload = {radius: 1, known: [] as unknown[]};
|
||||
f.node.respond = () => success(world());
|
||||
assert.equal((await f.request('world.snapshot', payload, id)).status, 'success');
|
||||
await f.join(replacement, 'node', ['bot.logs']);
|
||||
f.store.node_ready(replacement.socket, {forward_session_id: f.scope.forward_session_id});
|
||||
code(await f.request('world.snapshot', payload, id), 'FORBIDDEN');
|
||||
assert.equal(replacement.requests.length, 0);
|
||||
});
|
||||
|
||||
test('mock world pending duplicates do not dispatch twice and completed reads retry after errors', async t => {
|
||||
const f = await fixture(t);
|
||||
for (const [method, payload] of [['world.snapshot', {radius: 1, known: []}], ['world.unwatch', {watch_id: randomUUID()}]] as const) {
|
||||
const id = randomUUID();
|
||||
let release!: (value: unknown) => void;
|
||||
f.node.respond = () => new Promise(resolve => {release = resolve;});
|
||||
const pending = f.request(method, payload, id);
|
||||
await Promise.resolve();
|
||||
const before = f.node.requests.length;
|
||||
code(await f.request(method, payload, id), 'RATE_LIMITED');
|
||||
assert.equal(f.node.requests.length, before);
|
||||
release(success(method === 'world.snapshot' ? world() : {released: true}));
|
||||
assert.equal((await pending).status, 'success');
|
||||
f.node.respond = () => error_ack(new Error('private'));
|
||||
code(await f.request(method, payload, id), 'INTERNAL_ERROR');
|
||||
f.node.respond = () => success(method === 'world.snapshot' ? world() : {released: true});
|
||||
assert.equal((await f.request(method, payload, id)).status, 'success');
|
||||
assert.equal(f.node.requests.length - before, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('mock world_preview hints fan out on the existing scoped info channel', async t => {
|
||||
for (const permissions of [['bot.snapshot'], ['bot.logs']] as RelayMethod[][]) {
|
||||
const f = await fixture(t, {}, permissions);
|
||||
const received: unknown[] = [];
|
||||
f.web.on('info_update', value => received.push(value));
|
||||
const update = {forward_session_id: f.scope.forward_session_id, bot_id: f.scope.bot_id,
|
||||
context_generation: randomUUID(), stream_id: randomUUID(), seq: 1, emitted_at: Date.now(),
|
||||
data: {name: 'world_preview', value: {world_generation: randomUUID()}}};
|
||||
assert.deepEqual(f.store.info(f.node.socket, update), {accepted: true});
|
||||
assert.deepEqual(received, permissions.includes('bot.snapshot') ? [update] : []);
|
||||
assert.throws(() => f.store.info(f.node.socket, {...update, bot_id: f.scope.bot_id + 1}));
|
||||
}
|
||||
});
|
||||
|
||||
test('mock relay forwards all workflow RPCs with private correlated deadlines and safe ACKs', async t => {
|
||||
const f = await fixture(t);
|
||||
const input = control(); const run_id = randomUUID();
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
import type {WorldSnapshot} from '../types.ts';
|
||||
import {DEFAULT_LIMITS} from '../utils/relay_config.ts';
|
||||
import {RelayError, check_permissions, success, validate_info_update, validate_node_ack, validate_request} from '../utils/relay_validation.ts';
|
||||
|
||||
const uuid = randomUUID();
|
||||
const revision = {x: 0, z: 0, revision: uuid};
|
||||
const cells = Buffer.alloc(512).toString('base64');
|
||||
const encoded = (value: number, index = 255) => {
|
||||
const buffer = Buffer.alloc(512);
|
||||
buffer.writeUInt16LE(value, index * 2);
|
||||
return buffer.toString('base64');
|
||||
};
|
||||
const snapshot = (): WorldSnapshot => ({
|
||||
world_generation: uuid, context_generation: uuid, stream_id: uuid, watch_id: uuid,
|
||||
version: '1.21.1', dimension: 'minecraft:overworld', min_y: -64, height: 384,
|
||||
center: {x: 0, z: 0}, position: {x: 0.5, y: -63, z: -0.5, yaw: Math.PI},
|
||||
chunks: [{...revision, palette: [{name: 'minecraft:air', color: 0}], heights: cells, blocks: cells}],
|
||||
loaded: [revision], partial: false,
|
||||
});
|
||||
const fluid_chunk = (): WorldSnapshot['chunks'][number] => ({
|
||||
...snapshot().chunks[0]!,
|
||||
palette: [{name: 'minecraft:stone', color: 0x777777}, {name: 'minecraft:water', color: 0x3f76e4}],
|
||||
heights: encoded(384), blocks: encoded(1),
|
||||
terrain: {heights: encoded(383), blocks: cells},
|
||||
fluids: {heights: encoded(384), blocks: encoded(1), levels: encoded(15)},
|
||||
});
|
||||
const request = (method: string, payload: unknown) => validate_request({request_id: uuid, method, payload}, DEFAULT_LIMITS);
|
||||
const ack = (data: unknown, radius = 3) => validate_node_ack('world.snapshot', success(data), 1, DEFAULT_LIMITS, {radius, known: []});
|
||||
const rejected = (action: () => unknown) => assert.throws(action,
|
||||
(error: unknown) => error instanceof RelayError && error.code === 'INVALID_REQUEST');
|
||||
|
||||
test('surface features preserve bounded rotation, attachments and waterlogged states', () => {
|
||||
const chunk = {...snapshot().chunks[0]!, heights: encoded(10), blocks: cells,
|
||||
palette: [{name: 'oak_hanging_sign', color: 0}, {name: 'stone', color: 1}],
|
||||
features: {heights: encoded(8), blocks: encoded(1), entries: [{index: 255, height: 10, block: 0, properties: {rotation: 7, attached: true, waterlogged: true}}]}};
|
||||
const accept = (value: unknown) => ack({...snapshot(), chunks: [value]});
|
||||
accept(chunk);
|
||||
for (const patch of [{index: 256}, {height: 11}, {height: 8}, {block: 2}, {properties: {rotation: 16}},
|
||||
{properties: {attached: 'true'}}, {properties: {waterlogged: 1}}, {properties: {unknown: true}}, {properties: {facing: 'sideways'}}]) {
|
||||
rejected(() => accept({...chunk, features: {...chunk.features, entries: [{...chunk.features.entries[0], ...patch}]}}));
|
||||
}
|
||||
rejected(() => accept({...chunk, features: {...chunk.features, entries: [chunk.features.entries[0], chunk.features.entries[0]]}}));
|
||||
rejected(() => accept({...chunk, features: {...chunk.features, entries: []}}));
|
||||
rejected(() => accept({...chunk, features: {...chunk.features, heights: ''}}));
|
||||
});
|
||||
|
||||
test('spike feature payload validates exact states, bounds, duplicate cells and column budgets', () => {
|
||||
const chunk = {...snapshot().chunks[0]!, palette: [{name: 'pointed_dripstone', color: 0}, {name: 'stone', color: 1}],
|
||||
heights: encoded(10), blocks: cells, spikes: {heights: encoded(8), blocks: encoded(1),
|
||||
segments: [{index: 255, height: 10, direction: 'up', thickness: 'tip'}]}};
|
||||
const accept = (value: unknown) => ack({...snapshot(), chunks: [value]});
|
||||
accept(chunk);
|
||||
const segment = chunk.spikes.segments[0]!;
|
||||
for (const segments of [[], [segment, segment], [{...segment, direction: 'north'}], [{...segment, height: 11}],
|
||||
[{...segment, height: 8}], [{...segment, index: 256}], [{...segment, thickness: 'unknown'}],
|
||||
Array.from({length: 513}, () => segment)]) rejected(() => accept({...chunk, spikes: {...chunk.spikes, segments}}));
|
||||
rejected(() => accept({...chunk, spikes: {...chunk.spikes, extra: true}}));
|
||||
rejected(() => accept({...chunk, spikes: {...chunk.spikes, heights: cells.slice(0, -1)}}));
|
||||
rejected(() => accept({...chunk, palette: [{name: 'stone', color: 0}, {name: 'stone', color: 1}]}));
|
||||
});
|
||||
|
||||
test('maximum bounded spike segments survive the world-only complexity budget across 49 chunks', () => {
|
||||
const data = snapshot(), heights = Buffer.alloc(512);
|
||||
for (let offset = 0; offset < 512; offset += 2) heights.writeUInt16LE(16, offset);
|
||||
const segments = Array.from({length: 512}, (_, index) => ({index: Math.floor(index / 16), height: index % 16 + 1,
|
||||
direction: 'up' as const, thickness: 'middle' as const}));
|
||||
data.chunks = []; data.loaded = [];
|
||||
for (let z = -3; z <= 3; z++) for (let x = -3; x <= 3; x++) {
|
||||
const revision = {x, z, revision: randomUUID()};
|
||||
data.loaded.push(revision);
|
||||
data.chunks.push({...revision, heights: heights.toString('base64'), blocks: cells,
|
||||
palette: Array.from({length: 512}, (_, i) => ({name: i === 0 ? 'pointed_dripstone' : `block_${i}`, color: i})),
|
||||
spikes: {heights: cells, blocks: cells, segments}});
|
||||
}
|
||||
ack(data);
|
||||
});
|
||||
|
||||
test('world RPC whitelist uses existing capabilities and exact bounded request payloads', () => {
|
||||
check_permissions(['bot.snapshot']);
|
||||
rejected(() => check_permissions(['world.snapshot']));
|
||||
rejected(() => check_permissions(['world.unwatch']));
|
||||
for (const radius of [1, 2, 3]) {
|
||||
request('world.snapshot', {radius, known: []});
|
||||
request('world.snapshot', {radius, known: [revision], world_generation: uuid, watch_id: uuid});
|
||||
}
|
||||
request('world.snapshot', {radius: 3, known: Array.from({length: 49}, (_, x) => ({...revision, x: x - 24}))});
|
||||
request('world.snapshot', {radius: 1, known: [{...revision, x: -1_875_000, z: 1_875_000}]});
|
||||
request('world.unwatch', {watch_id: uuid});
|
||||
for (const payload of [null, {}, {radius: 1}, {known: []}, {radius: 0, known: []}, {radius: 4, known: []},
|
||||
{radius: 1.5, known: []}, {radius: '1', known: []}, {radius: 1, known: [revision, revision]},
|
||||
{radius: 1, known: [revision, {...revision, x: -0}]},
|
||||
{radius: 1, known: Array.from({length: 50}, (_, x) => ({...revision, x}))},
|
||||
...['world_generation', 'watch_id', 'bot_id', 'forward_session_id', 'context_generation'].map(key => ({radius: 1, known: [] as unknown[], [key]: 'bad'})),
|
||||
...[{x: 1.2}, {x: 1_875_001}, {z: -1_875_001}, {revision: 'bad'}, {extra: true}]
|
||||
.map(fields => ({radius: 1, known: [{...revision, ...fields}]})),
|
||||
]) rejected(() => request('world.snapshot', payload));
|
||||
for (const payload of [{}, {watch_id: 'bad'}, {watch_id: uuid, radius: 1}, {watch_id: uuid, bot_id: 1}]) {
|
||||
rejected(() => request('world.unwatch', payload));
|
||||
}
|
||||
rejected(() => request('world.subscribe', {}));
|
||||
const sparse = new Array(1);
|
||||
rejected(() => request('world.snapshot', {radius: 1, known: sparse}));
|
||||
});
|
||||
|
||||
test('world snapshot ACKs require exact metadata and JSON safe scoped fields', () => {
|
||||
ack(snapshot());
|
||||
ack({...snapshot(), min_y: -4096, height: 4096, partial: true});
|
||||
ack({...snapshot(), min_y: 4096, height: 1, chunks: [], loaded: []});
|
||||
validate_node_ack('world.unwatch', success({released: true}), 1, DEFAULT_LIMITS);
|
||||
for (const data of [null, {}, {released: false}, {released: true, watch_id: uuid}]) {
|
||||
rejected(() => validate_node_ack('world.unwatch', success(data), 1, DEFAULT_LIMITS));
|
||||
}
|
||||
for (const field of Object.keys(snapshot())) {
|
||||
const data = {...snapshot()} as Record<string, unknown>;
|
||||
delete data[field];
|
||||
rejected(() => ack(data));
|
||||
}
|
||||
const invalid: Record<string, unknown>[] = [
|
||||
{world_generation: 'bad'}, {context_generation: null}, {stream_id: 'bad'}, {watch_id: 'bad'},
|
||||
{version: ''}, {version: ' '.repeat(64)}, {version: 'v'.repeat(65)}, {dimension: ''}, {dimension: 'x'.repeat(257)},
|
||||
{min_y: -4097}, {min_y: 4097}, {min_y: 0.5}, {height: 0}, {height: 4097}, {height: 1.5}, {partial: 1},
|
||||
{bot_id: 99}, {forward_session_id: uuid}, {center: {x: 0, z: 0, y: 0}}, {center: {x: 0.1, z: 0}},
|
||||
{center: {x: 1_875_001, z: 0}}, {position: {x: 0, y: 0, z: 0}},
|
||||
{position: {x: 0, y: 0, z: 0, yaw: NaN}}, {position: {x: Infinity, y: 0, z: 0, yaw: 0}},
|
||||
{position: {x: 0, y: 0, z: 0, yaw: 0, pitch: 0}},
|
||||
];
|
||||
for (const fields of invalid) rejected(() => ack({...snapshot(), ...fields}));
|
||||
for (const binary of [Buffer.alloc(512), new Uint16Array(256), new Blob(['data'])]) {
|
||||
rejected(() => ack({...snapshot(), chunks: [{...snapshot().chunks[0], heights: binary}]}));
|
||||
}
|
||||
const accessor = {...snapshot()};
|
||||
Object.defineProperty(accessor, 'height', {enumerable: true, get() {throw new Error('must not execute');}});
|
||||
rejected(() => ack(accessor));
|
||||
const size = Buffer.byteLength(JSON.stringify(success(snapshot())));
|
||||
validate_node_ack('world.snapshot', success(snapshot()), 1, {...DEFAULT_LIMITS, max_payload_bytes: size});
|
||||
rejected(() => validate_node_ack('world.snapshot', success(snapshot()), 1, {...DEFAULT_LIMITS, max_payload_bytes: size - 1}));
|
||||
});
|
||||
|
||||
test('world encoded cells enforce canonical 684 character base64 and UInt16LE bounds', () => {
|
||||
const chunk = snapshot().chunks[0]!;
|
||||
ack({...snapshot(), chunks: [{...chunk, heights: encoded(384)}]});
|
||||
rejected(() => ack({...snapshot(), chunks: [{...chunk, heights: encoded(385)}]}));
|
||||
rejected(() => ack({...snapshot(), chunks: [{...chunk, blocks: encoded(1)}]}));
|
||||
const palette = Array.from({length: 256}, (_, color) => ({name: `block_${color}`, color}));
|
||||
ack({...snapshot(), chunks: [{...chunk, palette, blocks: encoded(255)}]});
|
||||
rejected(() => ack({...snapshot(), chunks: [{...chunk, palette, blocks: encoded(256)}]}));
|
||||
const expanded_palette = Array.from({length: 512}, (_, color) => ({name: `block_${color}`, color}));
|
||||
ack({...snapshot(), chunks: [{...chunk, palette: expanded_palette, blocks: encoded(511)}]});
|
||||
rejected(() => ack({...snapshot(), chunks: [{...chunk, palette: expanded_palette, blocks: encoded(512)}]}));
|
||||
const noncanonical = `${cells.slice(0, -2)}B=`;
|
||||
assert.equal(Buffer.from(noncanonical, 'base64').length, 512);
|
||||
assert.equal(Buffer.from(noncanonical, 'base64').toString('base64'), cells);
|
||||
for (const field of ['heights', 'blocks']) {
|
||||
for (const value of ['', cells.slice(0, -1), `${cells}\n`, 'A'.repeat(685), 'A'.repeat(684),
|
||||
Buffer.alloc(511).toString('base64'), Buffer.alloc(513).toString('base64'), noncanonical]) {
|
||||
rejected(() => ack({...snapshot(), chunks: [{...chunk, [field]: value}]}));
|
||||
}
|
||||
}
|
||||
for (const palette of [[], Array.from({length: 513}, () => ({name: 'a', color: 0})),
|
||||
[{name: 'x'.repeat(129), color: 0}], [{name: 'a', color: -1}], [{name: 'a', color: 0x1000000}],
|
||||
[{name: 'a', color: 0.5}], [{name: 'a', color: 0, extra: true}], [{color: 0}]]) {
|
||||
rejected(() => ack({...snapshot(), chunks: [{...chunk, palette}]}));
|
||||
}
|
||||
ack({...snapshot(), chunks: [{...chunk, palette: [{name: 'x'.repeat(128), color: 0xffffff}]}]});
|
||||
});
|
||||
|
||||
test('world terrain and fluids are paired exact optional objects with canonical bounded cells', () => {
|
||||
const chunk = fluid_chunk();
|
||||
const accept = (value: unknown) => ack({...snapshot(), chunks: [value]});
|
||||
accept(chunk);
|
||||
accept(snapshot().chunks[0]);
|
||||
for (const missing of ['terrain', 'fluids']) {
|
||||
const half = {...chunk};
|
||||
delete half[missing as 'terrain' | 'fluids'];
|
||||
rejected(() => accept(half));
|
||||
}
|
||||
for (const field of ['terrain', 'fluids'] as const) {
|
||||
for (const value of [null, undefined, [], {}, {...chunk[field], extra: true}]) {
|
||||
rejected(() => accept({...chunk, [field]: value}));
|
||||
}
|
||||
for (const key of Object.keys(chunk[field]!)) {
|
||||
const incomplete = {...chunk[field]} as Record<string, unknown>;
|
||||
delete incomplete[key];
|
||||
rejected(() => accept({...chunk, [field]: incomplete}));
|
||||
const noncanonical = `${cells.slice(0, -2)}B=`;
|
||||
for (const value of [null, 0, Buffer.alloc(512), '', cells.slice(0, -1), `${cells}\n`,
|
||||
'A'.repeat(684), Buffer.alloc(511).toString('base64'), Buffer.alloc(513).toString('base64'), noncanonical]) {
|
||||
rejected(() => accept({...chunk, [field]: {...chunk[field], [key]: value}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [field, key, value] of [
|
||||
['terrain', 'heights', 385], ['terrain', 'blocks', 2], ['fluids', 'heights', 385],
|
||||
['fluids', 'blocks', 2], ['fluids', 'levels', 16], ['fluids', 'levels', 65535],
|
||||
] as const) {
|
||||
for (const index of [0, 255]) {
|
||||
rejected(() => accept({...chunk, [field]: {...chunk[field], [key]: encoded(value, index)}}));
|
||||
}
|
||||
}
|
||||
accept({...chunk, terrain: {...chunk.terrain!, heights: encoded(384)}});
|
||||
accept({...chunk, fluids: {...chunk.fluids!, levels: cells}});
|
||||
// Absent fluid columns may refer to any in-bounds palette entry, but levels remain bounded.
|
||||
accept({...chunk, fluids: {heights: cells, blocks: cells, levels: encoded(15)}});
|
||||
rejected(() => accept({...chunk, fluids: {heights: cells, blocks: cells, levels: encoded(16)}}));
|
||||
});
|
||||
|
||||
test('world fluid columns require water or lava and consistent terrain and visible heights', () => {
|
||||
const chunk = fluid_chunk();
|
||||
const accept = (value: unknown) => ack({...snapshot(), chunks: [value]});
|
||||
for (const name of ['water', 'lava', 'minecraft:water', 'minecraft:lava']) {
|
||||
accept({...chunk, palette: [chunk.palette[0], {name, color: 0}]});
|
||||
}
|
||||
for (const name of ['stone', 'minecraft:stone', 'flowing_water', 'minecraft:flowing_lava',
|
||||
'other:water', 'Minecraft:water', 'water_extra', 'minecraft:water\n', '']) {
|
||||
rejected(() => accept({...chunk, palette: [chunk.palette[0], {name, color: 0}]}));
|
||||
}
|
||||
rejected(() => accept({...chunk, fluids: {...chunk.fluids!, blocks: cells}}));
|
||||
rejected(() => accept({...chunk, fluids: {...chunk.fluids!, heights: encoded(382)}}));
|
||||
rejected(() => accept({...chunk, heights: encoded(383)}));
|
||||
for (const index of [0, 255]) {
|
||||
const heights = encoded(384, index);
|
||||
accept({...chunk, heights, terrain: {heights: cells, blocks: cells},
|
||||
fluids: {heights, blocks: encoded(1, index), levels: encoded(8, index)}});
|
||||
}
|
||||
});
|
||||
|
||||
test('world snapshots support 49 mixed legacy and fluid chunks with expanded shared palettes', () => {
|
||||
const full = snapshot();
|
||||
full.loaded = Array.from({length: 49}, (_, index) => ({...revision, x: index % 7 - 3, z: Math.floor(index / 7) - 3}));
|
||||
full.chunks = full.loaded.map((entry, index) => {
|
||||
const chunk = index % 2 ? snapshot().chunks[0]! : fluid_chunk();
|
||||
const palette = Array.from({length: index % 2 ? 256 : 512}, (_, color) => ({name: `block_${color}`, color}));
|
||||
if (index % 2) return {...chunk, ...entry, palette, blocks: encoded(255)};
|
||||
palette[511] = {name: index % 4 ? 'lava' : 'minecraft:water', color: 0xffffff};
|
||||
return {...chunk, ...entry, palette, blocks: encoded(511),
|
||||
terrain: {heights: encoded(384), blocks: encoded(510)},
|
||||
fluids: {heights: encoded(384), blocks: encoded(511), levels: encoded(15)}};
|
||||
});
|
||||
ack(full);
|
||||
const size = Buffer.byteLength(JSON.stringify(success(full)));
|
||||
validate_node_ack('world.snapshot', success(full), 1, {...DEFAULT_LIMITS, max_payload_bytes: size});
|
||||
rejected(() => validate_node_ack('world.snapshot', success(full), 1, {...DEFAULT_LIMITS, max_payload_bytes: size - 1}));
|
||||
rejected(() => ack(full, 2));
|
||||
const largest_palette = full.chunks[0]!.palette;
|
||||
const maximum = {...full, chunks: full.loaded.map(entry => ({...full.chunks[0]!, ...entry, palette: largest_palette}))};
|
||||
ack(maximum);
|
||||
// The extra traversal allowance belongs only to world snapshots, not other ACKs.
|
||||
assert.throws(() => validate_node_ack('bot.snapshot', success(maximum), 1, DEFAULT_LIMITS),
|
||||
(error: unknown) => error instanceof RelayError && error.message === 'JSON complexity limit exceeded');
|
||||
});
|
||||
|
||||
test('world chunks are unique loaded members with matching revisions inside the requested radius', () => {
|
||||
const full = snapshot();
|
||||
full.loaded = Array.from({length: 49}, (_, index) => ({...revision, x: index % 7 - 3, z: Math.floor(index / 7) - 3}));
|
||||
full.chunks = full.loaded.map(entry => ({...full.chunks[0]!, ...entry}));
|
||||
ack(full);
|
||||
validate_node_ack('world.snapshot', success(full), 1, DEFAULT_LIMITS);
|
||||
rejected(() => ack(full, 2));
|
||||
for (const data of [
|
||||
{...snapshot(), chunks: [snapshot().chunks[0], snapshot().chunks[0]]},
|
||||
{...snapshot(), loaded: [revision, revision]}, {...snapshot(), loaded: []},
|
||||
{...snapshot(), loaded: [{...revision, revision: randomUUID()}]},
|
||||
{...snapshot(), chunks: [{...snapshot().chunks[0], x: 1}]},
|
||||
{...snapshot(), chunks: [{...snapshot().chunks[0], token: 'secret'}]},
|
||||
{...snapshot(), loaded: [{...revision, x: 4}]},
|
||||
{...snapshot(), loaded: [{...revision, x: 0.5}]},
|
||||
{...snapshot(), loaded: [{...revision, extra: true}]},
|
||||
{...full, loaded: [...full.loaded, {...revision, x: 4}]},
|
||||
{...full, chunks: [...full.chunks, {...full.chunks[0], x: 4}]},
|
||||
]) rejected(() => ack(data));
|
||||
const edge: WorldSnapshot = {...snapshot(), center: {x: 0, z: 0}, loaded: [{...revision, x: 2}], chunks: []};
|
||||
rejected(() => ack(edge, 1));
|
||||
ack(edge, 2);
|
||||
ack({...snapshot(), loaded: [{...revision, revision: uuid.toUpperCase()}]});
|
||||
});
|
||||
|
||||
test('world_preview hints retain existing bounded JSON info_update validation', () => {
|
||||
const hint = {forward_session_id: uuid, bot_id: 1, context_generation: uuid, stream_id: uuid,
|
||||
seq: 1, emitted_at: Date.now(), data: {name: 'world_preview', value: {world_generation: uuid}}};
|
||||
validate_info_update(hint, DEFAULT_LIMITS);
|
||||
rejected(() => validate_info_update({...hint, data: {...hint.data, value: {world_generation: Buffer.alloc(16)}}}, DEFAULT_LIMITS));
|
||||
rejected(() => validate_info_update({...hint, bot_id: 0}, DEFAULT_LIMITS));
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {once} from 'node:events';
|
||||
import {createServer} from 'node:http';
|
||||
import test from 'node:test';
|
||||
import {Server} from 'socket.io';
|
||||
import {io, type Socket as Client} from 'socket.io-client';
|
||||
import type {ForwardAck, NodeRelayRequest, RelayClaims, RelayMethod, WorldPayloads, WorldVoxelChunk} from '../types.ts';
|
||||
import {install_relay_routes} from '../runtime/relay_routes.ts';
|
||||
import {create_session_store} from '../runtime/session_store.ts';
|
||||
import {DEFAULT_LIMITS} from '../utils/relay_config.ts';
|
||||
import {RelayError, check_permissions, success, validate_node_ack, validate_request} from '../utils/relay_validation.ts';
|
||||
import {encode_token} from '../utils/token_utils.ts';
|
||||
|
||||
const input = (): WorldPayloads['world.chunk'] => ({x: -12, z: 7, world_generation: randomUUID(), watch_id: randomUUID()});
|
||||
const blocks = (index = 0, position = 4095) => {
|
||||
const data = Buffer.alloc(8192);
|
||||
data.writeUInt16LE(index, position * 2);
|
||||
return data.toString('base64');
|
||||
};
|
||||
const chunk = (payload = input()): WorldVoxelChunk => ({
|
||||
x: payload.x, z: payload.z, revision: randomUUID(), world_generation: payload.world_generation,
|
||||
min_y: -64, height: 384,
|
||||
palette: [{name: 'minecraft:air', color: 0, properties: {}, occluding: false},
|
||||
{name: 'minecraft:stone', color: 0x777777, properties: {}, occluding: true},
|
||||
{name: 'minecraft:oak_stairs', color: 0xb88754, properties: {facing: 'east', half: 'bottom', waterlogged: true}, occluding: false}],
|
||||
sections: [{y: 0, blocks: blocks(1, 17)}, {y: 1, blocks: blocks(2, 2048)}],
|
||||
});
|
||||
const request = (payload: unknown) => validate_request({request_id: randomUUID(), method: 'world.chunk', payload}, DEFAULT_LIMITS);
|
||||
const ack = (data: unknown, payload: unknown) => validate_node_ack('world.chunk', success(data), 3, DEFAULT_LIMITS, payload);
|
||||
const rejected = (action: () => unknown) => assert.throws(action,
|
||||
(error: unknown) => error instanceof RelayError && error.code === 'INVALID_REQUEST');
|
||||
|
||||
test('world.chunk requests require exact coordinates, generation and watch using existing permissions', () => {
|
||||
const payload = input();
|
||||
request(payload);
|
||||
request({...payload, x: -1_875_000, z: 1_875_000});
|
||||
request({...payload, x: 1_875_000, z: -1_875_000});
|
||||
check_permissions(['bot.snapshot']);
|
||||
rejected(() => check_permissions(['world.chunk']));
|
||||
for (const key of Object.keys(payload)) {
|
||||
const missing = {...payload} as Record<string, unknown>;
|
||||
delete missing[key];
|
||||
rejected(() => request(missing));
|
||||
}
|
||||
for (const patch of [{x: -1_875_001}, {z: 1_875_001}, {x: 0.5}, {z: '7'}, {x: NaN}, {z: Infinity},
|
||||
{world_generation: 'bad'}, {watch_id: null}, {watch_id: 'bad'}, {world_generation: undefined},
|
||||
{revision: randomUUID()}, {bot_id: 3}, {context_generation: randomUUID()}]) {
|
||||
rejected(() => request({...payload, ...patch}));
|
||||
}
|
||||
for (const value of [null, [] as unknown[], 'chunk']) rejected(() => request(value));
|
||||
});
|
||||
|
||||
test('world.chunk accepts empty sparse sections and preserves buried blocks and state in two real sections', () => {
|
||||
const payload = input(), data = chunk(payload);
|
||||
ack(data, payload);
|
||||
assert.equal(Buffer.from(data.sections[0]!.blocks, 'base64').readUInt16LE(17 * 2), 1);
|
||||
assert.equal(Buffer.from(data.sections[1]!.blocks, 'base64').readUInt16LE(2048 * 2), 2);
|
||||
assert.equal(Buffer.from(data.sections[0]!.blocks, 'base64').readUInt16LE(4095 * 2), 0);
|
||||
assert.equal(data.sections[0]!.blocks.length, 10_924);
|
||||
ack({...data, sections: []}, payload);
|
||||
ack({...data, min_y: -4096, height: 1, sections: [{y: 0, blocks: blocks()}]}, payload);
|
||||
ack({...data, min_y: 4096, height: 4096, sections: [{y: 255, blocks: blocks()}]}, payload);
|
||||
// The wire contract does not require zero padding beyond a partial section's height.
|
||||
ack({...data, height: 17, sections: [{y: 1, blocks: blocks(1)}]}, payload);
|
||||
ack({...data, world_generation: data.world_generation.toUpperCase()}, payload);
|
||||
});
|
||||
|
||||
test('world.chunk ACK metadata is exact and matches request coordinates and world generation', () => {
|
||||
const payload = input(), data = chunk(payload);
|
||||
for (const key of Object.keys(data)) {
|
||||
const missing = {...data} as Record<string, unknown>;
|
||||
delete missing[key];
|
||||
rejected(() => ack(missing, payload));
|
||||
}
|
||||
for (const patch of [{x: payload.x + 1}, {z: payload.z - 1}, {x: 1_875_001}, {z: 0.5},
|
||||
{world_generation: randomUUID()}, {world_generation: 'bad'}, {revision: 'bad'},
|
||||
{min_y: -4097}, {min_y: 4097}, {min_y: 0.5}, {height: 0}, {height: 4097}, {height: 1.5},
|
||||
{watch_id: payload.watch_id}, {stream_id: randomUUID()}, {context_generation: randomUUID()}, {extra: true}]) {
|
||||
rejected(() => ack({...data, ...patch}, payload));
|
||||
}
|
||||
for (const value of [null, [], {}, undefined]) rejected(() => ack(value, payload));
|
||||
for (const value of [undefined, {}, {...payload, x: payload.x + 1}, {...payload, world_generation: randomUUID()}]) {
|
||||
rejected(() => ack(data, value));
|
||||
}
|
||||
validate_node_ack('world.chunk', {status: 'error', data: {code: 'RESYNC_REQUIRED', message: 'Stale watch', retryable: true}}, 3, DEFAULT_LIMITS, payload);
|
||||
});
|
||||
|
||||
test('world.chunk palette entries require bounded colors, names, occlusion and exact fields', () => {
|
||||
const payload = input(), data = chunk(payload), block = data.palette[1]!;
|
||||
const accept = (palette: unknown) => ack({...data, palette, sections: []}, payload);
|
||||
accept([{...block, name: 'a', color: 0}]);
|
||||
accept([{...block, name: 'x'.repeat(128), color: 0xffffff}]);
|
||||
for (const palette of [[], null, {}, Array.from({length: 8193}, () => block)]) rejected(() => accept(palette));
|
||||
for (const key of Object.keys(block)) {
|
||||
const missing = {...block} as Record<string, unknown>;
|
||||
delete missing[key];
|
||||
rejected(() => accept([missing]));
|
||||
}
|
||||
for (const patch of [{name: ''}, {name: 'x'.repeat(129)}, {color: -1}, {color: 0x1000000}, {color: 0.5},
|
||||
{occluding: 1}, {occluding: 'true'}, {extra: true}, {shape: 'cube'}, {properties: [] as unknown[]}, {properties: null}]) {
|
||||
rejected(() => accept([{...block, ...patch}]));
|
||||
}
|
||||
});
|
||||
|
||||
test('world.chunk properties accept canonical state keys and bounded primitives without a semantic whitelist', () => {
|
||||
const payload = input(), data = chunk(payload);
|
||||
const accept = (properties: unknown) => ack({...data, palette: [{...data.palette[0], properties}], sections: []}, payload);
|
||||
accept({custom_block_state: 'unknown_but_valid', rotation: 65_536, min: -65_536, solid: true, empty: '', zero: 0});
|
||||
accept(Object.fromEntries(Array.from({length: 32}, (_, i) => [`property_${i}`, i % 2 === 0 ? 'x'.repeat(64) : false])));
|
||||
accept({['k'.repeat(32)]: true});
|
||||
for (const properties of [
|
||||
Object.fromEntries(Array.from({length: 33}, (_, i) => [`p${i}`, true])),
|
||||
{'': true}, {'Facing': 'east'}, {'minecraft:axis': 'x'}, {'a-b': true}, {'state\n': true}, {['k'.repeat(33)]: true},
|
||||
{key: 'x'.repeat(65)}, {key: 65_537}, {key: -65_537}, {key: 0.5}, {key: null}, {key: [] as unknown[]}, {key: {}},
|
||||
{key: undefined}, {key: NaN}, {key: Infinity},
|
||||
]) rejected(() => accept(properties));
|
||||
const getter = Object.defineProperty({}, 'state', {enumerable: true, get() {throw new Error('must not execute');}});
|
||||
rejected(() => accept(getter));
|
||||
rejected(() => accept(Object.defineProperty({}, 'state', {value: true})));
|
||||
rejected(() => accept({[Symbol('state')]: true}));
|
||||
});
|
||||
|
||||
test('world.chunk sections are sparse, unique, ascending and bounded by height', () => {
|
||||
const payload = input(), data = chunk(payload), section = data.sections[0]!;
|
||||
ack({...data, sections: [{...section, y: 3}, {...section, y: 23}]}, payload);
|
||||
for (const sections of [null, {}, [section, section], [{...section, y: 1}, section], [{...section, y: -1}],
|
||||
[{...section, y: 24}], [{...section, y: 0.5}], [{...section, y: '0'}], [{blocks: section.blocks}],
|
||||
[{y: 0}], [{...section, extra: true}], Array.from({length: 257}, (_, y) => ({...section, y}))]) {
|
||||
rejected(() => ack({...data, sections}, payload));
|
||||
}
|
||||
rejected(() => ack({...data, height: 16, sections: [{...section, y: 1}]}, payload));
|
||||
rejected(() => ack({...data, sections: new Array(1)}, payload));
|
||||
});
|
||||
|
||||
test('world.chunk section blocks enforce canonical base64, exact UInt16LE byte count and all palette indices', () => {
|
||||
const payload = input(), data = chunk(payload), encoded = blocks();
|
||||
const accept = (value: unknown) => ack({...data, sections: [{y: 0, blocks: value}]}, payload);
|
||||
const noncanonical = `${encoded.slice(0, -2)}B=`;
|
||||
assert.equal(Buffer.from(noncanonical, 'base64').toString('base64'), encoded);
|
||||
for (const value of ['', encoded.slice(0, -1), `${encoded}\n`, 'A'.repeat(10_924), Buffer.alloc(8191).toString('base64'),
|
||||
Buffer.alloc(8193).toString('base64'), noncanonical, `_${encoded.slice(1)}`, Buffer.alloc(8192), new Uint16Array(4096)]) {
|
||||
rejected(() => accept(value));
|
||||
}
|
||||
for (const position of [0, 17, 2048, 4095]) {
|
||||
accept(blocks(data.palette.length - 1, position));
|
||||
rejected(() => accept(blocks(data.palette.length, position)));
|
||||
rejected(() => accept(blocks(65_535, position)));
|
||||
}
|
||||
});
|
||||
|
||||
test('world.chunk maximum palette fits dedicated world complexity budget and preserves 8 MiB byte limits', () => {
|
||||
const payload = input(), data = chunk(payload);
|
||||
const properties = Object.fromEntries(Array.from({length: 32}, (_, index) => [`p${index}`, true]));
|
||||
const maximum: WorldVoxelChunk = {...data, height: 4096,
|
||||
palette: Array.from({length: 8192}, (_, index) => ({name: `block_${index}`, color: index, properties, occluding: true})),
|
||||
sections: Array.from({length: 256}, (_, y) => ({y, blocks: blocks(8191)}))};
|
||||
ack(maximum, payload);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(success(maximum))) < 8 * 1024 * 1024);
|
||||
const size = Buffer.byteLength(JSON.stringify(success(data)));
|
||||
validate_node_ack('world.chunk', success(data), 3, {...DEFAULT_LIMITS, max_payload_bytes: size}, payload);
|
||||
rejected(() => validate_node_ack('world.chunk', success(data), 3, {...DEFAULT_LIMITS, max_payload_bytes: size - 1}, payload));
|
||||
const oversized = {...maximum, palette: maximum.palette.map(block => ({...block,
|
||||
properties: Object.fromEntries(Object.keys(properties).map(key => [key, 'x'.repeat(64)]))}))};
|
||||
rejected(() => ack(oversized, payload));
|
||||
rejected(() => validate_node_ack('bot.snapshot', success(maximum), 3, DEFAULT_LIMITS));
|
||||
});
|
||||
|
||||
test('world.chunk uses private correlated ACKs, snapshot permission on both roles and fresh validated reads', async t => {
|
||||
const key = 'world-voxel-relay-test-secret';
|
||||
const scope = {forward_id: 1, forward_session_id: randomUUID(), node_session_id: randomUUID(), node_id: 2, bot_id: 3};
|
||||
const http = createServer(), server = new Server(http), namespace = server.of('/relay');
|
||||
const store = create_session_store({forward_id: 1, key, limits: DEFAULT_LIMITS, on_status() {}});
|
||||
const dispose = install_relay_routes(namespace, store), clients: Client[] = [];
|
||||
t.after(async () => {store.close(); dispose(); for (const client of clients) client.close(); await server.close();});
|
||||
http.listen(0, '127.0.0.1');
|
||||
await once(http, 'listening');
|
||||
store.open({...scope, lease_expires_at: Date.now() + 60_000});
|
||||
const send = (socket: Client, event: string, value: unknown): Promise<ForwardAck<unknown>> => socket.timeout(3000).emitWithAck(event, value);
|
||||
const connect = async (role: 'web' | 'node', permissions: RelayMethod[] = ['bot.snapshot']) => {
|
||||
const socket = io(`http://127.0.0.1:${(http.address() as {port: number}).port}/relay`,
|
||||
{transports: ['websocket'], forceNew: true, reconnection: false});
|
||||
clients.push(socket);
|
||||
await new Promise<void>(resolve => socket.once('connect', resolve));
|
||||
const now = Date.now(), grant_id = randomUUID(), expires_at = now + 50_000;
|
||||
if (role === 'web') store.authorize({forward_session_id: scope.forward_session_id, grant_id, user_id: 4, permissions, expires_at});
|
||||
const claims: RelayClaims = {...scope, v: 1, role, permissions, issued_at: now, join_before: now + 10_000,
|
||||
expires_at, jti: randomUUID(), ...(role === 'web' ? {grant_id, user_id: 4} : {})};
|
||||
assert.equal((await send(socket, 'join_room', {token: encode_token(key, claims)})).status, 'success');
|
||||
if (role === 'node') assert.equal((await send(socket, 'forward_node_ready', {forward_session_id: scope.forward_session_id})).status, 'success');
|
||||
return socket;
|
||||
};
|
||||
const node = await connect('node'), web = await connect('web'), observer = await connect('web');
|
||||
const limited = await connect('web', ['bot.logs']);
|
||||
const payload = input(), command = {request_id: randomUUID(), method: 'world.chunk', payload};
|
||||
const data = chunk(payload);
|
||||
let response: unknown = success(data), calls = 0;
|
||||
const leaked: string[] = [];
|
||||
observer.onAny(event => leaked.push(event));
|
||||
node.on('relay_request', (value: NodeRelayRequest, reply: (result: unknown) => void) => {
|
||||
calls++;
|
||||
assert.equal(value.method, 'world.chunk');
|
||||
assert.deepEqual(value.payload, payload);
|
||||
assert.equal(value.forward_session_id, scope.forward_session_id);
|
||||
assert.notEqual(value.correlation_id, value.request_id);
|
||||
assert.ok(value.deadline_at > Date.now());
|
||||
reply(response);
|
||||
});
|
||||
const error = (value: ForwardAck<unknown>, expected: string) => {
|
||||
assert.equal(value.status, 'error');
|
||||
if (value.status === 'error') assert.equal(value.data.code, expected);
|
||||
};
|
||||
store.set_control_available(false);
|
||||
assert.deepEqual(await send(web, 'relay_request', command), response);
|
||||
response = success({...data, revision: randomUUID(), sections: []});
|
||||
assert.deepEqual(await send(web, 'relay_request', command), response);
|
||||
assert.equal(calls, 2);
|
||||
for (const patch of [{x: data.x + 1}, {z: data.z + 1}, {world_generation: randomUUID()}, {sections: [{y: 0, blocks: blocks(3)}]}]) {
|
||||
response = success({...data, ...patch});
|
||||
error(await send(web, 'relay_request', command), 'INVALID_REQUEST');
|
||||
}
|
||||
response = {status: 'error', data: {code: 'RESYNC_REQUIRED', message: 'Stale watch', retryable: true}};
|
||||
assert.deepEqual(await send(web, 'relay_request', command), response);
|
||||
response = success(data);
|
||||
assert.deepEqual(await send(web, 'relay_request', command), response);
|
||||
const before = calls;
|
||||
error(await send(limited, 'relay_request', command), 'FORBIDDEN');
|
||||
error(await send(node, 'relay_request', command), 'FORBIDDEN');
|
||||
assert.equal(calls, before);
|
||||
assert.deepEqual(leaked, []);
|
||||
store.set_control_available(true);
|
||||
await connect('node', ['bot.logs']);
|
||||
error(await send(web, 'relay_request', command), 'FORBIDDEN');
|
||||
assert.equal(calls, before);
|
||||
});
|
||||
Reference in New Issue
Block a user