Collect chunk voxel data from the bot world and stream snapshot, chunk, and unwatch messages through the forward protocol.
253 lines
14 KiB
TypeScript
253 lines
14 KiB
TypeScript
import type {EnsureForward, ForwardAck, ForwardErrorCode, JoinReady, RelayRequest, WorkflowPayloads, WorldPayloads} from '../types/forward.ts';
|
|
import {assert_json} from '../bot/workflow/runtime/value.ts';
|
|
|
|
const errors: Record<ForwardErrorCode, [string, boolean]> = {
|
|
INVALID_REQUEST: ['Invalid request', false],
|
|
UNAUTHORIZED: ['Authorization required', false],
|
|
FORBIDDEN: ['Operation forbidden', false],
|
|
NODE_OFFLINE: ['Node unavailable', true],
|
|
RELAY_UNAVAILABLE: ['Relay unavailable', true],
|
|
SESSION_STALE: ['Session is no longer current', false],
|
|
TOKEN_EXPIRED: ['Authorization expired', false],
|
|
TIMEOUT: ['Request deadline exceeded', true],
|
|
CANCELLED: ['Request cancelled', false],
|
|
NO_CONTEXT: ['Bot context unavailable', false],
|
|
BOT_NOT_ONLINE: ['Bot is not online', false],
|
|
RATE_LIMITED: ['Request limit exceeded', true],
|
|
RESYNC_REQUIRED: ['Synchronization required', true],
|
|
INTERNAL_ERROR: ['Operation failed', false],
|
|
};
|
|
|
|
export class ForwardFailure extends Error {
|
|
constructor(public readonly code: ForwardErrorCode) {
|
|
super(errors[code][0]);
|
|
}
|
|
}
|
|
|
|
export function error_ack(code: ForwardErrorCode): ForwardAck<never> {
|
|
const [message, retryable] = errors[code];
|
|
return {status: 'error', data: {code, message, retryable}};
|
|
}
|
|
|
|
export function failure_code(error: unknown): ForwardErrorCode {
|
|
if (error !== null && typeof error === 'object' && 'code' in error
|
|
&& typeof error.code === 'string' && Object.hasOwn(errors, error.code)) {
|
|
return error.code as ForwardErrorCode;
|
|
}
|
|
return 'INTERNAL_ERROR';
|
|
}
|
|
|
|
export function object(value: unknown): value is Record<string, unknown> {
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
const prototype = Object.getPrototypeOf(value);
|
|
return prototype === null || Object.getPrototypeOf(prototype) === null;
|
|
}
|
|
|
|
export function exact(value: unknown, required: readonly string[], optional: readonly string[] = []): value is Record<string, unknown> {
|
|
return object(value) && required.every(key => Object.hasOwn(value, key))
|
|
&& Reflect.ownKeys(value).every(key => {
|
|
if (typeof key !== 'string' || (!required.includes(key) && !optional.includes(key))) return false;
|
|
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
|
return descriptor.enumerable && 'value' in descriptor;
|
|
});
|
|
}
|
|
|
|
export const uuid = (value: unknown): value is string => typeof value === 'string'
|
|
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
export const integer = (value: unknown, min = 0): value is number => Number.isSafeInteger(value) && (value as number) >= min;
|
|
const text = (value: unknown, max: number): value is string => typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
|
|
export function validate_command(value: unknown): EnsureForward {
|
|
if (!exact(value, ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id',
|
|
'url', 'path', 'namespace', 'token', 'expires_at', 'deadline_at'])
|
|
|| !integer(value.forward_id) || !integer(value.node_id, 1) || !integer(value.bot_id, 1)
|
|
|| !uuid(value.forward_session_id) || !text(value.node_session_id, 256) || !value.node_session_id.trim()
|
|
|| Buffer.byteLength(value.node_session_id) > 256
|
|
|| !text(value.token, 8192) || Buffer.byteLength(value.token) > 8192
|
|
|| !integer(value.expires_at, 1) || !integer(value.deadline_at, 1)
|
|
|| !text(value.url, 2048) || !text(value.path, 256) || !text(value.namespace, 256)
|
|
|| !/^\/[a-zA-Z0-9_./-]*$/.test(value.path)
|
|
|| !/^\/[a-zA-Z0-9_/-]*$/.test(value.namespace)) {
|
|
throw new ForwardFailure('INVALID_REQUEST');
|
|
}
|
|
try {
|
|
const url = new URL(value.url);
|
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash || url.search
|
|
|| (url.pathname !== '' && url.pathname !== '/')) throw new Error();
|
|
} catch {
|
|
throw new ForwardFailure('INVALID_REQUEST');
|
|
}
|
|
return value as unknown as EnsureForward;
|
|
}
|
|
|
|
export function success_data(value: unknown): unknown {
|
|
if (!exact(value, ['status', 'data'])) throw new ForwardFailure('RELAY_UNAVAILABLE');
|
|
if (value.status === 'error') {
|
|
if (exact(value.data, ['code', 'message', 'retryable']) && typeof value.data.code === 'string'
|
|
&& Object.hasOwn(errors, value.data.code) && typeof value.data.message === 'string'
|
|
&& typeof value.data.retryable === 'boolean') {
|
|
throw new ForwardFailure(value.data.code as ForwardErrorCode);
|
|
}
|
|
throw new ForwardFailure('RELAY_UNAVAILABLE');
|
|
}
|
|
if (value.status !== 'success') throw new ForwardFailure('RELAY_UNAVAILABLE');
|
|
return value.data;
|
|
}
|
|
|
|
export function validate_join(value: unknown, command: EnsureForward, joining: boolean): JoinReady {
|
|
const data = success_data(value);
|
|
if (!exact(data, ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id', 'role', 'node_ready', 'expires_at'])
|
|
|| data.forward_id !== command.forward_id || data.forward_session_id !== command.forward_session_id
|
|
|| data.node_session_id !== command.node_session_id
|
|
|| data.node_id !== command.node_id || data.bot_id !== command.bot_id || data.role !== 'node'
|
|
|| typeof data.node_ready !== 'boolean' || (joining && data.node_ready !== false)
|
|
|| data.expires_at !== command.expires_at || (data.expires_at as number) <= Date.now()) {
|
|
throw new ForwardFailure('SESSION_STALE');
|
|
}
|
|
return data as unknown as JoinReady;
|
|
}
|
|
|
|
export function validate_rpc(value: unknown): RelayRequest {
|
|
if (!exact(value, ['request_id', 'method', 'payload', 'correlation_id', 'forward_session_id', 'deadline_at'])
|
|
|| !uuid(value.request_id) || !uuid(value.correlation_id) || !uuid(value.forward_session_id)
|
|
|| !integer(value.deadline_at, 1)) throw new ForwardFailure('INVALID_REQUEST');
|
|
switch (value.method) {
|
|
case 'world.snapshot':
|
|
case 'world.chunk':
|
|
case 'world.unwatch':
|
|
validate_world_payload(value.method, value.payload);
|
|
break;
|
|
case 'workflow.start':
|
|
case 'workflow.stop':
|
|
case 'workflow.snapshot':
|
|
case 'workflow.catalog':
|
|
case 'workflow.runtime':
|
|
case 'workflow.control':
|
|
case 'workflow.command':
|
|
validate_workflow_payload(value.method, value.payload);
|
|
break;
|
|
case 'execute_function':
|
|
if (!exact(value.payload, ['function', 'name', 'args'])
|
|
|| typeof value.payload.function !== 'string'
|
|
|| !/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(value.payload.function)
|
|
|| typeof value.payload.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(value.payload.name)
|
|
|| !Array.isArray(value.payload.args)) throw new ForwardFailure('INVALID_REQUEST');
|
|
break;
|
|
case 'bot.snapshot':
|
|
case 'get_log_history':
|
|
case 'get_context_info':
|
|
case 'get_data_file_list':
|
|
if (!exact(value.payload, [])) throw new ForwardFailure('INVALID_REQUEST');
|
|
break;
|
|
case 'get_file_data': {
|
|
const payload = value.payload;
|
|
if (!exact(payload, ['id'], ['offset', 'length']) || !uuid(payload.id)
|
|
|| (Object.hasOwn(payload, 'offset') && !integer(payload.offset))
|
|
|| (Object.hasOwn(payload, 'length') && (!integer(payload.length, 1) || payload.length > 262_144))) {
|
|
throw new ForwardFailure('INVALID_REQUEST');
|
|
}
|
|
break;
|
|
}
|
|
case 'bot.logs': {
|
|
const payload = value.payload;
|
|
if (!exact(payload, ['limit'], ['stream_id', 'after_seq']) || !integer(payload.limit, 1) || payload.limit > 200
|
|
|| (Object.hasOwn(payload, 'stream_id') && !uuid(payload.stream_id))
|
|
|| (Object.hasOwn(payload, 'after_seq') && (!uuid(payload.stream_id) || !integer(payload.after_seq)))) {
|
|
throw new ForwardFailure('INVALID_REQUEST');
|
|
}
|
|
break;
|
|
}
|
|
case 'bot.chat':
|
|
if (!exact(value.payload, ['message']) || !text(value.payload.message, 256)
|
|
|| !value.payload.message.trim() || /[\r\n\u0000]/.test(value.payload.message)) throw new ForwardFailure('INVALID_REQUEST');
|
|
break;
|
|
case 'tab_complete':
|
|
if (!exact(value.payload, ['text']) || typeof value.payload.text !== 'string'
|
|
|| value.payload.text.length > 256 || /[\r\n\u0000]/.test(value.payload.text)) throw new ForwardFailure('INVALID_REQUEST');
|
|
break;
|
|
default:
|
|
throw new ForwardFailure('INVALID_REQUEST');
|
|
}
|
|
return value as unknown as RelayRequest;
|
|
}
|
|
|
|
export function validate_workflow_payload<M extends keyof WorkflowPayloads>(method: M, payload: unknown): WorkflowPayloads[M] {
|
|
if (method === 'workflow.start') {
|
|
if (!exact(payload, ['workflow_id'], ['trigger']) || typeof payload.workflow_id !== 'string'
|
|
|| !/^[a-zA-Z0-9_-]{1,100}$/.test(payload.workflow_id)
|
|
|| ['__proto__', 'prototype', 'constructor'].includes(payload.workflow_id)) {
|
|
throw new ForwardFailure('INVALID_REQUEST');
|
|
}
|
|
if (Object.hasOwn(payload, 'trigger')) {
|
|
try {
|
|
if (!object(payload.trigger)) throw new Error();
|
|
assert_json(payload.trigger);
|
|
} catch { throw new ForwardFailure('INVALID_REQUEST'); }
|
|
}
|
|
} else if (method === 'workflow.catalog' || method === 'workflow.runtime') {
|
|
if (!exact(payload, [])) throw new ForwardFailure('INVALID_REQUEST');
|
|
} else if (method === 'workflow.command') {
|
|
if (!exact(payload, ['command_id', 'context_generation']) || !uuid(payload.command_id)
|
|
|| !uuid(payload.context_generation)) throw new ForwardFailure('INVALID_REQUEST');
|
|
} else if (method === 'workflow.control') {
|
|
try {assert_json(payload);} catch {throw new ForwardFailure('INVALID_REQUEST');}
|
|
if (!object(payload) || !uuid(payload.command_id) || !uuid(payload.context_generation)
|
|
|| !['run_once', 'trigger_start', 'trigger_stop', 'stop', 'stop_run', 'reload'].includes(payload.action as string)) {
|
|
throw new ForwardFailure('INVALID_REQUEST');
|
|
}
|
|
const run = payload.action === 'stop_run';
|
|
if (!exact(payload, ['command_id', 'context_generation', 'action', run ? 'run_id' : 'workflow_id'],
|
|
[...(!run ? ['expected_generation'] : []), ...(payload.action === 'run_once' ? ['input'] : [])])
|
|
|| run && !uuid(payload.run_id)
|
|
|| !run && (typeof payload.workflow_id !== 'string' || !/^[a-zA-Z0-9_-]{1,100}$/.test(payload.workflow_id)
|
|
|| ['__proto__', 'prototype', 'constructor'].includes(payload.workflow_id))
|
|
|| Object.hasOwn(payload, 'expected_generation') && !integer(payload.expected_generation, 1)) {
|
|
throw new ForwardFailure('INVALID_REQUEST');
|
|
}
|
|
if (Object.hasOwn(payload, 'input')) {
|
|
try {
|
|
if (!object(payload.input)) throw new Error();
|
|
assert_json(payload.input);
|
|
if (Buffer.byteLength(JSON.stringify(payload.input)) > 65_536) throw new Error();
|
|
} catch {throw new ForwardFailure('INVALID_REQUEST');}
|
|
}
|
|
} else if (method === 'workflow.stop' || method === 'workflow.snapshot') {
|
|
if (!exact(payload, method === 'workflow.stop' ? ['run_id'] : [], method === 'workflow.snapshot' ? ['run_id', 'include_branches'] : [])
|
|
|| Object.hasOwn(payload, 'run_id') && !uuid(payload.run_id)
|
|
|| Object.hasOwn(payload, 'include_branches') && (!Object.hasOwn(payload, 'run_id') || typeof payload.include_branches !== 'boolean')) {
|
|
throw new ForwardFailure('INVALID_REQUEST');
|
|
}
|
|
} else {
|
|
throw new ForwardFailure('INVALID_REQUEST');
|
|
}
|
|
return payload as WorkflowPayloads[M];
|
|
}
|
|
|
|
export function validate_world_payload<M extends keyof WorldPayloads>(method: M, payload: unknown): WorldPayloads[M] {
|
|
if (method === 'world.unwatch') {
|
|
if (!exact(payload, ['watch_id']) || !uuid(payload.watch_id)) throw new ForwardFailure('INVALID_REQUEST');
|
|
} else if (method === 'world.chunk') {
|
|
if (!exact(payload, ['x', 'z', 'world_generation', 'watch_id'])
|
|
|| !Number.isSafeInteger(payload.x) || !Number.isSafeInteger(payload.z)
|
|
|| !uuid(payload.world_generation) || !uuid(payload.watch_id)) throw new ForwardFailure('INVALID_REQUEST');
|
|
} else if (method === 'world.snapshot') {
|
|
if (!exact(payload, ['radius', 'known'], ['world_generation', 'watch_id'])
|
|
|| !integer(payload.radius, 1) || payload.radius > 3
|
|
|| Object.hasOwn(payload, 'world_generation') && !uuid(payload.world_generation)
|
|
|| Object.hasOwn(payload, 'watch_id') && !uuid(payload.watch_id)
|
|
|| !Array.isArray(payload.known) || payload.known.length > 49
|
|
|| Reflect.ownKeys(payload.known).length !== payload.known.length + 1) throw new ForwardFailure('INVALID_REQUEST');
|
|
const seen = new Set<string>();
|
|
for (let index = 0; index < payload.known.length; index++) {
|
|
const descriptor = Object.getOwnPropertyDescriptor(payload.known, String(index));
|
|
const item = descriptor && 'value' in descriptor && descriptor.enumerable ? descriptor.value : undefined;
|
|
if (!exact(item, ['x', 'z', 'revision']) || !Number.isSafeInteger(item.x)
|
|
|| !Number.isSafeInteger(item.z) || !uuid(item.revision)) throw new ForwardFailure('INVALID_REQUEST');
|
|
const key = `${item.x},${item.z}`;
|
|
if (seen.has(key)) throw new ForwardFailure('INVALID_REQUEST');
|
|
seen.add(key);
|
|
}
|
|
} else throw new ForwardFailure('INVALID_REQUEST');
|
|
return payload as WorldPayloads[M];
|
|
}
|