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:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user