From c82f15e8b601d588f0bb373b1f6c839188651161 Mon Sep 17 00:00:00 2001 From: wupengabc <2508932142@qq.com> Date: Fri, 23 Oct 2026 10:44:42 +0800 Subject: [PATCH] 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. --- runtime/relay_routes.ts | 1 + runtime/session_store.ts | 25 ++- tests/relay.integration.test.ts | 122 ++++++++++---- tests/session.test.ts | 183 +++++++++++++++++++- tests/world.test.ts | 286 ++++++++++++++++++++++++++++++++ tests/world_voxels.test.ts | 235 ++++++++++++++++++++++++++ types.ts | 53 +++++- utils/relay_validation.ts | 205 ++++++++++++++++++++++- 8 files changed, 1064 insertions(+), 46 deletions(-) create mode 100644 tests/world.test.ts create mode 100644 tests/world_voxels.test.ts diff --git a/runtime/relay_routes.ts b/runtime/relay_routes.ts index 2048006..2024c14 100644 --- a/runtime/relay_routes.ts +++ b/runtime/relay_routes.ts @@ -32,6 +32,7 @@ export function install_relay_routes(namespace: Namespace, store: RelaySessionSt route('forward_node_ready', value => store.node_ready(socket, value)); route('relay_request', value => store.request(socket, value), false); route('bot_log', value => store.log(socket, value), true, false); + route('bot_event', value => store.event(socket, value), true, false); route('info_update', value => store.info(socket, value), true, false); const unknown = (name: string, ...args: unknown[]) => { if (handlers.has(name)) return; diff --git a/runtime/session_store.ts b/runtime/session_store.ts index c03f8a4..983d245 100644 --- a/runtime/session_store.ts +++ b/runtime/session_store.ts @@ -1,7 +1,7 @@ import {createHash, randomUUID} from 'node:crypto'; import type {Socket} from 'socket.io'; -import type {BotEvent, BotEventName, ForwardAck, ForwardErrorCode, ForwardSessionStatus, InfoUpdate, JoinRoomReady, - OpenSessionCommand, RelayClaims, RelayLimits, RelayMethod, RelayRequest, SessionScope} from '../types.ts'; +import type {BotEventName, ForwardAck, ForwardErrorCode, ForwardSessionStatus, JoinRoomReady, + OpenSessionCommand, RelayClaims, RelayEventPayloads, RelayLimits, RelayMethod, RelayRequest, SessionScope} from '../types.ts'; import {RelayError, fail, is_record, is_uuid, success, error_ack, validate_open, validate_grant, validate_request, validate_event, validate_info_update, validate_node_ack} from '../utils/relay_validation.ts'; import {verify_token} from '../utils/token_utils.ts'; @@ -476,7 +476,7 @@ export class RelaySessionStore { const request = command; const permission = request.method === 'get_log_history' ? 'bot.logs' : request.method === 'tab_complete' ? 'bot.chat' - : request.method === 'get_context_info' ? 'bot.snapshot' + : request.method === 'get_context_info' || request.method === 'world.snapshot' || request.method === 'world.chunk' || request.method === 'world.unwatch' ? 'bot.snapshot' : request.method === 'get_data_file_list' || request.method === 'get_file_data' ? 'bot.files' : request.method === 'workflow.start' || request.method === 'workflow.stop' || request.method === 'workflow.control' ? 'workflow.control' : request.method === 'workflow.snapshot' || request.method === 'workflow.catalog' @@ -493,7 +493,7 @@ export class RelaySessionStore { const now = Date.now(); const dedupe = binding.grant!.dedupe; const file_request = request.method === 'get_data_file_list' || request.method === 'get_file_data'; - const fresh_read = file_request || request.method.startsWith('workflow.') && !non_replayable; + const fresh_read = file_request || request.method.startsWith('world.') || request.method.startsWith('workflow.') && !non_replayable; for (const [id, entry] of dedupe) if (entry.expires_at <= now && (entry.completed || entry.result)) dedupe.delete(id); const fingerprint = createHash('sha256').update(canonical({method: request.method, payload: request.payload})).digest('hex'); const existing = dedupe.get(request.request_id); @@ -577,7 +577,7 @@ export class RelaySessionStore { if (session.node !== node || session.generation !== pending.generation || !this.valid(node)) fail('NODE_OFFLINE', 'Node connection changed', true); if (!this.valid(binding)) fail('CANCELLED', 'Web authorization ended'); - validate_node_ack(request.method, response, session.scope.bot_id, this.limits); + validate_node_ack(request.method, response, session.scope.bot_id, this.limits, request.payload); if ((request.method === 'workflow.control' || request.method === 'workflow.command') && response.status === 'success' && response.data !== null) { const input = request.payload as {command_id: string; action?: string; workflow_id?: string}; @@ -641,17 +641,24 @@ export class RelaySessionStore { if (command.name !== 'log') fail('INVALID_REQUEST', 'Expected a log event'); return this.fanout(binding, command, 'bot.logs', 'bot_log'); } + event(socket: Socket, command: unknown): {accepted: true} { + const binding = this.event_binding(socket); + validate_event(command, this.limits); + if (command.name !== 'status_update') fail('INVALID_REQUEST', 'Expected a status update event'); + if (!binding.claims.permissions.includes('bot.snapshot')) fail('FORBIDDEN', 'Node token does not permit status updates'); + return this.fanout(binding, command, 'bot.snapshot', 'bot_event'); + } info(socket: Socket, command: unknown): {accepted: true} { const binding = this.event_binding(socket); validate_info_update(command, this.limits); if (!binding.claims.permissions.includes('bot.snapshot')) fail('FORBIDDEN', 'Node token does not permit info updates'); return this.fanout(binding, command, 'bot.snapshot', 'info_update'); } - private fanout(binding: Binding, command: BotEvent | InfoUpdate, permission: RelayMethod, - event: 'bot_log' | 'info_update'): {accepted: true} { + private fanout(binding: Binding, command: RelayEventPayloads[K], permission: RelayMethod, + event: K): {accepted: true} { const {session, connection} = binding; - const label = event === 'bot_log' ? 'Log' : 'Info'; - const kind = event === 'bot_log' ? 'log' : 'info'; + const label = event === 'bot_log' ? 'Log' : event === 'info_update' ? 'Info' : 'Lifecycle'; + const kind = event === 'bot_log' ? 'log' : event === 'info_update' ? 'info' : 'lifecycle'; if (command.forward_session_id !== session.scope.forward_session_id || command.bot_id !== session.scope.bot_id) fail('FORBIDDEN', `${label} scope does not match the Node connection`); if (session.stream !== command.stream_id) { diff --git a/tests/relay.integration.test.ts b/tests/relay.integration.test.ts index 7dffcef..b63a5b5 100644 --- a/tests/relay.integration.test.ts +++ b/tests/relay.integration.test.ts @@ -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'}); diff --git a/tests/session.test.ts b/tests/session.test.ts index 8a00a61..3404940 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -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, expected: string) { const control = () => ({command_id: randomUUID(), context_generation: randomUUID(), action: 'run_once', workflow_id: 'a'}); const command = (input: ReturnType) => ({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(); diff --git a/tests/world.test.ts b/tests/world.test.ts new file mode 100644 index 0000000..40cdf46 --- /dev/null +++ b/tests/world.test.ts @@ -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; + delete data[field]; + rejected(() => ack(data)); + } + const invalid: Record[] = [ + {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; + 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)); +}); diff --git a/tests/world_voxels.test.ts b/tests/world_voxels.test.ts new file mode 100644 index 0000000..93de004 --- /dev/null +++ b/tests/world_voxels.test.ts @@ -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; + 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; + 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; + 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> => 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(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, 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); +}); diff --git a/types.ts b/types.ts index 0eb3fe7..75d02eb 100644 --- a/types.ts +++ b/types.ts @@ -5,6 +5,7 @@ export const RELAY_METHODS = ['bot.snapshot', 'bot.logs', 'bot.subscribe', 'bot. export const WORKFLOW_ACTIONS = ['run_once', 'trigger_start', 'trigger_stop', 'stop', 'stop_run', 'reload'] as const; export const WORKFLOW_METHODS = ['workflow.start', 'workflow.stop', 'workflow.snapshot', 'workflow.catalog', 'workflow.control', 'workflow.command', 'workflow.runtime'] as const; +export const WORLD_METHODS = ['world.snapshot', 'world.chunk', 'world.unwatch'] as const; export const BOT_EVENTS = ['status_update', 'log', 'message', 'bot_error'] as const; export type RelayMethod = typeof RELAY_METHODS[number]; export type BotEventName = typeof BOT_EVENTS[number]; @@ -49,7 +50,7 @@ export interface SessionClosed { } export interface RelayRequest { request_id: string; - method: RelayMethod | keyof WorkflowPayloads | 'get_log_history' | 'tab_complete' | 'get_context_info' | 'get_data_file_list' | 'get_file_data'; + method: RelayMethod | keyof WorkflowPayloads | keyof WorldPayloads | 'get_log_history' | 'tab_complete' | 'get_context_info' | 'get_data_file_list' | 'get_file_data'; payload: unknown; } export interface NodeRelayRequest extends RelayRequest { @@ -57,6 +58,51 @@ export interface NodeRelayRequest extends RelayRequest { forward_session_id: string; deadline_at: number; } +export interface WorldChunkRevision {x: number; z: number; revision: string} +export interface WorldChunk extends WorldChunkRevision { + palette: Array<{name: string; color: number}>; + // Canonical Base64 UInt16LE[256]; heights/blocks retain the top visible surface. + heights: string; + blocks: string; + // Emitted together for chunks with fluids; omitted by legacy snapshots. + terrain?: {heights: string; blocks: string}; + fluids?: {heights: string; blocks: string; levels: string}; + spikes?: {heights: string; blocks: string; segments: Array<{ + index: number; height: number; direction: 'up' | 'down'; + thickness: 'tip' | 'tip_merge' | 'frustum' | 'middle' | 'base'; + }>}; + features?: {heights: string; blocks: string; entries: Array<{ + index: number; height: number; block: number; properties: Record; + }>}; +} +export interface WorldPayloads { + 'world.snapshot': {radius: number; known: WorldChunkRevision[]; world_generation?: string; watch_id?: string}; + 'world.chunk': {x: number; z: number; world_generation: string; watch_id: string}; + 'world.unwatch': {watch_id: string}; +} +export interface WorldVoxelChunk extends WorldChunkRevision { + world_generation: string; + min_y: number; + height: number; + palette: Array<{name: string; color: number; properties: Record; occluding: boolean}>; + // Sparse sections ordered by y relative to min_y; blocks are canonical Base64 UInt16LE[4096]. + sections: Array<{y: number; blocks: string}>; +} +export interface WorldSnapshot { + world_generation: string; + context_generation: string; + stream_id: string; + watch_id: string; + version: string; + dimension: string; + min_y: number; + height: number; + center: {x: number; z: number}; + position: {x: number; y: number; z: number; yaw: number}; + chunks: WorldChunk[]; + loaded: WorldChunkRevision[]; + partial: boolean; +} export interface BotSnapshot { bot_id: number; bot_status: BotStatus; @@ -208,6 +254,11 @@ export type BotEvent = {[K in BotEventName]: { name: K; payload: BotEventPayloads[K]; }}[BotEventName]; +export interface RelayEventPayloads { + bot_event: Extract; + bot_log: Extract; + info_update: InfoUpdate; +} export interface OpenSessionCommand extends SessionScope {lease_expires_at: number} export interface AuthorizeGrantCommand { forward_session_id: string; diff --git a/utils/relay_validation.ts b/utils/relay_validation.ts index 0ab101a..e0bd4f1 100644 --- a/utils/relay_validation.ts +++ b/utils/relay_validation.ts @@ -1,4 +1,4 @@ -import {BOT_EVENTS, RELAY_METHODS, WORKFLOW_ACTIONS, WORKFLOW_METHODS} from '../types.ts'; +import {BOT_EVENTS, RELAY_METHODS, WORKFLOW_ACTIONS, WORKFLOW_METHODS, WORLD_METHODS} from '../types.ts'; import type { AuthorizeGrantCommand, BotEvent, ForwardAck, ForwardErrorCode, InfoUpdate, OpenSessionCommand, RelayLimits, RelayMethod, RelayRequest, SessionScope, @@ -86,13 +86,14 @@ export function check_size(value: unknown, max: number): void { check_json_size(value, max, false); } -function check_json_size(value: unknown, max: number, info: boolean | 'files' | 'workflow'): void { +function check_json_size(value: unknown, max: number, info: boolean | 'files' | 'workflow' | 'world'): void { require_valid(integer(max, 1), 'Invalid byte limit'); let entries = 0; let bytes = 0; const ancestors = new Set(); function visit(item: unknown, depth: number): void { - require_valid(depth <= (info ? 68 : 16) && ++entries <= (info === true ? 260_000 : 65_536), 'JSON complexity limit exceeded'); + require_valid(depth <= (info === 'world' ? 16 : info ? 68 : 16) + && ++entries <= (info === true ? 260_000 : info === 'world' ? 850_000 : 65_536), 'JSON complexity limit exceeded'); if (item === null || typeof item === 'boolean') { bytes += item === null ? 4 : item ? 4 : 5; } else if (typeof item === 'string') { @@ -109,7 +110,7 @@ function check_json_size(value: unknown, max: number, info: boolean | 'files' | const keys = Reflect.ownKeys(item); if (Array.isArray(item)) { if (info === 'workflow') require_valid(Object.getPrototypeOf(item) === Array.prototype, 'Expected a plain JSON array'); - require_valid(item.length <= (info === true ? 65_536 : 4096) && keys.length === item.length + 1, 'Expected a dense JSON array'); + require_valid(item.length <= (info === true ? 65_536 : info === 'world' ? 8192 : 4096) && keys.length === item.length + 1, 'Expected a dense JSON array'); for (let index = 0; index < item.length; index++) { const descriptor = Object.getOwnPropertyDescriptor(item, String(index)); require_valid(descriptor && 'value' in descriptor && descriptor.enumerable, 'Expected JSON array data'); @@ -173,9 +174,27 @@ export function validate_request(value: unknown, limits: RelayLimits): asserts v require_valid(value.method === 'get_log_history' || value.method === 'tab_complete' || value.method === 'get_context_info' || value.method === 'get_data_file_list' || value.method === 'get_file_data' || WORKFLOW_METHODS.includes(value.method as typeof WORKFLOW_METHODS[number]) + || WORLD_METHODS.includes(value.method as typeof WORLD_METHODS[number]) || (value.method !== 'bot.files' && RELAY_METHODS.includes(value.method as RelayMethod)), 'Invalid relay method'); const payload = value.payload; switch (value.method) { + case 'world.snapshot': + exact(payload, ['radius', 'known'], ['world_generation', 'watch_id']); + require_valid(integer(payload.radius, 1) && payload.radius <= 3, 'Invalid world radius'); + world_revisions(payload.known); + for (const field of ['world_generation', 'watch_id']) { + if (Object.hasOwn(payload, field)) require_valid(is_uuid(payload[field]), 'Invalid world watch scope'); + } + break; + case 'world.chunk': + exact(payload, ['x', 'z', 'world_generation', 'watch_id']); + require_valid(world_coordinate(payload.x) && world_coordinate(payload.z) + && is_uuid(payload.world_generation) && is_uuid(payload.watch_id), 'Invalid world chunk request'); + break; + case 'world.unwatch': + exact(payload, ['watch_id']); + require_valid(is_uuid(payload.watch_id), 'Invalid world watch ID'); + break; case 'bot.snapshot': case 'get_log_history': case 'get_context_info': @@ -456,11 +475,175 @@ function workflow_node_result(value: unknown, field: 'execution' | 'node_id'): v if (Object.hasOwn(value, 'error')) workflow_error(value.error, true); } -export function validate_node_ack(method: RelayRequest['method'], value: unknown, bot_id: number, limits: RelayLimits): asserts value is ForwardAck { +function world_coordinate(value: unknown): value is number { + return integer(value, -1_875_000) && value <= 1_875_000; +} + +function world_revisions(value: unknown): asserts value is Array<{x: number; z: number; revision: string}> { + require_valid(Array.isArray(value) && value.length <= 49, 'Invalid world revisions'); + const seen = new Set(); + for (const entry of value) { + exact(entry, ['x', 'z', 'revision']); + require_valid(world_coordinate(entry.x) && world_coordinate(entry.z) && is_uuid(entry.revision), 'Invalid world chunk revision'); + const key = `${entry.x},${entry.z}`; + require_valid(!seen.has(key), 'Duplicate world coordinates'); + seen.add(key); + } +} + +function world_cells(value: unknown, max: number): Buffer { + require_valid(typeof value === 'string' && value.length === 684, 'Invalid world encoded length'); + const decoded = Buffer.from(value, 'base64'); + require_valid(decoded.length === 512 && decoded.toString('base64') === value, 'Invalid world encoded cells'); + for (let offset = 0; offset < 512; offset += 2) { + require_valid(decoded.readUInt16LE(offset) <= max, 'Invalid world cell value'); + } + return decoded; +} + +function world_snapshot(value: unknown, payload: unknown): void { + exact(value, ['world_generation', 'context_generation', 'stream_id', 'watch_id', 'version', 'dimension', + 'min_y', 'height', 'center', 'position', 'chunks', 'loaded', 'partial']); + require_valid(['world_generation', 'context_generation', 'stream_id', 'watch_id'].every(key => is_uuid(value[key])) + && text(value.version, 64, true) && text(value.dimension, 256, true) + && integer(value.min_y, -4096) && value.min_y <= 4096 && integer(value.height, 1) && value.height <= 4096 + && typeof value.partial === 'boolean', 'Invalid world snapshot metadata'); + exact(value.center, ['x', 'z']); + require_valid(world_coordinate(value.center.x) && world_coordinate(value.center.z), 'Invalid world center'); + exact(value.position, ['x', 'y', 'z', 'yaw']); + require_valid(Object.values(value.position).every(number => typeof number === 'number' && Number.isFinite(number)), 'Invalid world position'); + const radius = is_record(payload) ? payload.radius : 3; + require_valid(integer(radius, 1) && radius <= 3, 'Invalid world request radius'); + const center = value.center; + const in_radius = (x: number, z: number) => Math.abs(x - (center.x as number)) <= radius && Math.abs(z - (center.z as number)) <= radius; + world_revisions(value.loaded); + const loaded = new Map(value.loaded.map(entry => [`${entry.x},${entry.z}`, entry.revision.toLowerCase()])); + require_valid(value.loaded.every(entry => in_radius(entry.x, entry.z)), 'Loaded world chunk outside radius'); + require_valid(Array.isArray(value.chunks) && value.chunks.length <= 49, 'Invalid world chunks'); + const seen = new Set(); + for (const chunk of value.chunks) { + exact(chunk, ['x', 'z', 'revision', 'palette', 'heights', 'blocks'], ['terrain', 'fluids', 'spikes', 'features']); + require_valid(world_coordinate(chunk.x) && world_coordinate(chunk.z) && is_uuid(chunk.revision) + && in_radius(chunk.x, chunk.z), 'Invalid world chunk scope'); + const key = `${chunk.x},${chunk.z}`; + require_valid(!seen.has(key) && loaded.get(key) === chunk.revision.toLowerCase(), 'Invalid world chunk membership'); + seen.add(key); + require_valid(Array.isArray(chunk.palette) && chunk.palette.length >= 1 && chunk.palette.length <= 512, 'Invalid world palette'); + for (const block of chunk.palette) { + exact(block, ['name', 'color']); + require_valid(text(block.name, 128) && integer(block.color) && block.color <= 0xffffff, 'Invalid world palette entry'); + } + // Heights encode topY - min_y + 1; zero represents an empty column. + const heights = world_cells(chunk.heights, value.height as number); + const blocks = world_cells(chunk.blocks, chunk.palette.length - 1); + require_valid(Object.hasOwn(chunk, 'terrain') === Object.hasOwn(chunk, 'fluids'), 'World terrain and fluids must appear together'); + if (Object.hasOwn(chunk, 'fluids')) { + exact(chunk.terrain, ['heights', 'blocks']); + exact(chunk.fluids, ['heights', 'blocks', 'levels']); + const terrain_heights = world_cells(chunk.terrain.heights, value.height as number); + world_cells(chunk.terrain.blocks, chunk.palette.length - 1); + const fluid_heights = world_cells(chunk.fluids.heights, value.height as number); + const fluid_blocks = world_cells(chunk.fluids.blocks, chunk.palette.length - 1); + world_cells(chunk.fluids.levels, 15); + for (let offset = 0; offset < 512; offset += 2) { + const fluid_height = fluid_heights.readUInt16LE(offset); + if (fluid_height === 0) continue; + const name = chunk.palette[fluid_blocks.readUInt16LE(offset)].name; + require_valid(['water', 'lava', 'minecraft:water', 'minecraft:lava'].includes(name), 'Invalid world fluid block'); + require_valid(terrain_heights.readUInt16LE(offset) <= fluid_height, 'World terrain exceeds fluid height'); + require_valid(heights.readUInt16LE(offset) === fluid_height, 'World fluid differs from visible height'); + } + } + if (Object.hasOwn(chunk, 'features')) { + exact(chunk.features, ['heights', 'blocks', 'entries']); + const bases = world_cells(chunk.features.heights, value.height as number); + world_cells(chunk.features.blocks, chunk.palette.length - 1); + require_valid(Array.isArray(chunk.features.entries) && chunk.features.entries.length > 0 && chunk.features.entries.length <= 512, 'Invalid world features'); + const pairs = new Set(), counts = new Map(); + const enums: Record = {facing: ['north', 'south', 'east', 'west', 'up', 'down'], half: ['top', 'bottom'], type: ['top', 'bottom', 'double', 'single', 'left', 'right'], + shape: ['straight', 'inner_left', 'inner_right', 'outer_left', 'outer_right'], axis: ['x', 'y', 'z'], vertical_direction: ['up', 'down'], thickness: ['tip', 'tip_merge', 'frustum', 'middle', 'base']}; + for (const entry of chunk.features.entries) { + exact(entry, ['index', 'height', 'block', 'properties']); + require_valid(integer(entry.index) && entry.index <= 255 && integer(entry.height, 1) && entry.height <= (value.height as number) + && integer(entry.block) && entry.block < chunk.palette.length, 'Invalid feature position'); + exact(entry.properties, [], ['facing', 'rotation', 'half', 'type', 'shape', 'open', 'attached', 'lit', 'waterlogged', 'east', 'west', 'north', 'south', 'axis', 'up', 'vertical_direction', 'thickness']); + for (const [key, item] of Object.entries(entry.properties)) require_valid(key === 'rotation' ? integer(item) && item <= 15 + : enums[key] ? typeof item === 'string' && enums[key]!.includes(item) + : ['north', 'south', 'east', 'west', 'up'].includes(key) ? typeof item === 'boolean' || ['none', 'low', 'tall'].includes(item as string) + : typeof item === 'boolean', 'Invalid feature property'); + const index = entry.index as number, height = entry.height as number, pair = `${index}:${height}`; + const name = chunk.palette[entry.block as number].name.replace(/^minecraft:/, ''); + require_valid(entry.properties.waterlogged === true || /(?:^|_)(?:wall_)?torch$|_(?:sign|slab|stairs|fence|pane|trapdoor|wall)$/.test(name), 'Invalid feature block'); + require_valid(!pairs.has(pair) && height <= heights.readUInt16LE(index * 2) && height > bases.readUInt16LE(index * 2), 'Invalid feature column'); + pairs.add(pair); counts.set(index, (counts.get(index) ?? 0) + 1); + require_valid(counts.get(index)! <= 16, 'Feature column exceeds limit'); + } + } + if (Object.hasOwn(chunk, 'spikes')) { + exact(chunk.spikes, ['heights', 'blocks', 'segments']); + const bases = world_cells(chunk.spikes.heights, value.height as number); + world_cells(chunk.spikes.blocks, chunk.palette.length - 1); + require_valid(Array.isArray(chunk.spikes.segments) && chunk.spikes.segments.length >= 1 + && chunk.spikes.segments.length <= 512, 'Invalid world spike segments'); + const visible = Object.hasOwn(chunk, 'terrain') ? world_cells((chunk.terrain as Record).blocks, chunk.palette.length - 1) : blocks; + const pairs = new Set(), counts = new Map(); + for (const segment of chunk.spikes.segments) { + exact(segment, ['index', 'height', 'direction', 'thickness']); + require_valid(integer(segment.index) && segment.index <= 255 && integer(segment.height, 1) + && segment.height <= (value.height as number) && ['up', 'down'].includes(segment.direction as string) + && ['tip', 'tip_merge', 'frustum', 'middle', 'base'].includes(segment.thickness as string), 'Invalid world spike state'); + const index = segment.index as number, height = segment.height as number, pair = `${index}:${height}`; + require_valid(!pairs.has(pair) && height <= heights.readUInt16LE(index * 2) && height > bases.readUInt16LE(index * 2) + && /^(?:minecraft:)?pointed_dripstone$/.test(chunk.palette[visible.readUInt16LE(index * 2)].name), 'Invalid world spike column'); + pairs.add(pair); counts.set(index, (counts.get(index) ?? 0) + 1); + require_valid(counts.get(index)! <= 16, 'World spike column exceeds segment limit'); + } + } + } +} + +function world_voxel_chunk(value: unknown, payload: unknown): void { + exact(value, ['x', 'z', 'revision', 'world_generation', 'min_y', 'height', 'palette', 'sections']); + require_valid(world_coordinate(value.x) && world_coordinate(value.z) && is_uuid(value.revision) + && is_uuid(value.world_generation) && integer(value.min_y, -4096) && value.min_y <= 4096 + && integer(value.height, 1) && value.height <= 4096, 'Invalid world voxel metadata'); + require_valid(is_record(payload) && value.x === payload.x && value.z === payload.z + && is_uuid(payload.world_generation) && value.world_generation.toLowerCase() === payload.world_generation.toLowerCase(), + 'World chunk does not match the request'); + require_valid(Array.isArray(value.palette) && value.palette.length >= 1 && value.palette.length <= 8192, 'Invalid world voxel palette'); + for (const block of value.palette) { + exact(block, ['name', 'color', 'properties', 'occluding']); + require_valid(text(block.name, 128, true) && integer(block.color) && block.color <= 0xffffff + && typeof block.occluding === 'boolean' && is_record(block.properties) + && Object.keys(block.properties).length <= 32, 'Invalid world voxel palette entry'); + for (const [key, item] of Object.entries(block.properties)) { + require_valid(key.length >= 1 && key.length <= 32 && !/[^a-z0-9_]/.test(key) && (typeof item === 'boolean' || text(item, 64) + || integer(item, -65_536) && item <= 65_536), 'Invalid world voxel property'); + } + } + require_valid(Array.isArray(value.sections) && value.sections.length <= 256, 'Invalid world voxel sections'); + let previous_y = -1; + for (const section of value.sections) { + exact(section, ['y', 'blocks']); + require_valid(integer(section.y) && section.y < Math.ceil(value.height / 16) && section.y > previous_y, + 'Invalid world voxel section order'); + previous_y = section.y; + require_valid(typeof section.blocks === 'string' && section.blocks.length === 10_924, 'Invalid world voxel encoded length'); + const decoded = Buffer.from(section.blocks, 'base64'); + require_valid(decoded.length === 8192 && decoded.toString('base64') === section.blocks, 'Invalid world voxel encoded blocks'); + for (let offset = 0; offset < decoded.length; offset += 2) { + require_valid(decoded.readUInt16LE(offset) < value.palette.length, 'Invalid world voxel palette index'); + } + } +} + +export function validate_node_ack(method: RelayRequest['method'], value: unknown, bot_id: number, limits: RelayLimits, payload?: unknown): asserts value is ForwardAck { const workflow = WORKFLOW_METHODS.includes(method as typeof WORKFLOW_METHODS[number]); - check_json_size(value, limits.max_payload_bytes, workflow ? 'workflow' : method === 'get_data_file_list' ? 'files' : method === 'get_context_info'); + check_json_size(value, limits.max_payload_bytes, workflow ? 'workflow' : method === 'world.snapshot' || method === 'world.chunk' ? 'world' + : method === 'get_data_file_list' ? 'files' : method === 'get_context_info'); require_valid((method === 'get_log_history' || method === 'tab_complete' || method === 'get_context_info' || method === 'get_data_file_list' || method === 'get_file_data' + || WORLD_METHODS.includes(method as typeof WORLD_METHODS[number]) || workflow || (method !== 'bot.files' && RELAY_METHODS.includes(method as RelayMethod))) && integer(bot_id, 1), 'Invalid ACK context'); exact(value, ['status', 'data']); const data = value.data; @@ -472,6 +655,16 @@ export function validate_node_ack(method: RelayRequest['method'], value: unknown } require_valid(value.status === 'success', 'Invalid ACK status'); switch (method) { + case 'world.snapshot': + world_snapshot(data, payload); + break; + case 'world.chunk': + world_voxel_chunk(data, payload); + break; + case 'world.unwatch': + exact(data, ['released']); + require_valid(data.released === true, 'Invalid world unwatch result'); + break; case 'workflow.start': workflow_start_result(data); break;