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:
2026-10-23 10:44:42 +08:00
parent 6ccb102d38
commit c82f15e8b6
8 changed files with 1064 additions and 46 deletions
+16 -9
View File
@@ -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<K extends keyof RelayEventPayloads>(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) {