Allow world.* methods through the relay, validate their payloads, and track per-session world revisions in the session store.
808 lines
49 KiB
TypeScript
808 lines
49 KiB
TypeScript
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,
|
|
} from '../types.ts';
|
|
|
|
const ERROR_CODES: readonly ForwardErrorCode[] = [
|
|
'INVALID_REQUEST', 'UNAUTHORIZED', 'FORBIDDEN', 'NODE_OFFLINE', 'RELAY_UNAVAILABLE',
|
|
'SESSION_STALE', 'TOKEN_EXPIRED', 'TIMEOUT', 'CANCELLED', 'NO_CONTEXT',
|
|
'BOT_NOT_ONLINE', 'RATE_LIMITED', 'RESYNC_REQUIRED', 'INTERNAL_ERROR',
|
|
];
|
|
const STATUSES = ['offline', 'ready', 'connecting', 'online', 'disconnecting', 'reconnecting'];
|
|
const STYLES = ['normal', 'italic', 'bold', 'light', 'underline', 'url'];
|
|
|
|
export class RelayError extends Error {
|
|
code: ForwardErrorCode;
|
|
retryable: boolean;
|
|
|
|
constructor(code: ForwardErrorCode, message: string, retryable = false) {
|
|
super(message);
|
|
this.name = 'RelayError';
|
|
this.code = code;
|
|
this.retryable = retryable;
|
|
}
|
|
}
|
|
|
|
export function fail(code: ForwardErrorCode, message: string, retryable = false): never {
|
|
throw new RelayError(code, message, retryable);
|
|
}
|
|
|
|
export function success<T>(data: T): ForwardAck<T> {
|
|
return {status: 'success', data};
|
|
}
|
|
|
|
export function error_ack(error: unknown): ForwardAck<never> {
|
|
if (error instanceof RelayError && ERROR_CODES.includes(error.code)
|
|
&& typeof error.message === 'string' && Buffer.byteLength(error.message) <= 1024
|
|
&& typeof error.retryable === 'boolean') {
|
|
return {status: 'error', data: {
|
|
code: error.code, message: error.message, retryable: error.retryable,
|
|
}};
|
|
}
|
|
return {status: 'error', data: {
|
|
code: 'INTERNAL_ERROR', message: 'Internal relay error', retryable: false,
|
|
}};
|
|
}
|
|
|
|
export function is_record(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 === Object.prototype || prototype === null;
|
|
}
|
|
|
|
export function is_uuid(value: unknown): value is string {
|
|
return 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);
|
|
}
|
|
|
|
function require_valid(condition: unknown, message: string): asserts condition {
|
|
if (!condition) fail('INVALID_REQUEST', message);
|
|
}
|
|
|
|
function exact(value: unknown, required: string[], optional: string[] = []): asserts value is Record<string, unknown> {
|
|
require_valid(is_record(value), 'Expected an object');
|
|
const keys = Reflect.ownKeys(value);
|
|
require_valid(keys.every(key => typeof key === 'string' && (required.includes(key) || optional.includes(key))),
|
|
'Unknown DTO field');
|
|
require_valid(required.every(key => Object.hasOwn(value, key)), 'Missing DTO field');
|
|
for (const key of keys) {
|
|
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
|
require_valid('value' in descriptor && descriptor.enumerable, 'Expected JSON data fields');
|
|
}
|
|
}
|
|
|
|
function integer(value: unknown, min = 0): value is number {
|
|
return typeof value === 'number' && Number.isSafeInteger(value) && value >= min;
|
|
}
|
|
|
|
function text(value: unknown, max = 1024, nonempty = false): value is string {
|
|
return typeof value === 'string' && (!nonempty || value.trim().length > 0)
|
|
&& Buffer.byteLength(value, 'utf8') <= max;
|
|
}
|
|
|
|
// Inspect JSON before stringify: bound traversal and reject values serialization would omit or coerce.
|
|
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' | 'world'): void {
|
|
require_valid(integer(max, 1), 'Invalid byte limit');
|
|
let entries = 0;
|
|
let bytes = 0;
|
|
const ancestors = new Set<object>();
|
|
function visit(item: unknown, depth: number): void {
|
|
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') {
|
|
require_valid(Buffer.byteLength(item, 'utf8') <= max, 'Payload byte limit exceeded');
|
|
bytes += Buffer.byteLength(JSON.stringify(item), 'utf8');
|
|
} else if (typeof item === 'number') {
|
|
require_valid(Number.isFinite(item), 'Expected a finite JSON number');
|
|
bytes += Buffer.byteLength(JSON.stringify(item));
|
|
} else {
|
|
require_valid(Array.isArray(item) || is_record(item), 'Expected JSON data');
|
|
require_valid(!ancestors.has(item), 'Cyclic JSON data');
|
|
ancestors.add(item);
|
|
bytes += 2;
|
|
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 : 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');
|
|
if (index) bytes++;
|
|
visit(descriptor.value, depth + 1);
|
|
}
|
|
} else {
|
|
require_valid(keys.length <= (info === true ? 65_536 : 4096), 'JSON key limit exceeded');
|
|
for (let index = 0; index < keys.length; index++) {
|
|
const key = keys[index];
|
|
require_valid(typeof key === 'string', 'Expected JSON string keys');
|
|
if (info === 'workflow') require_valid(!['__proto__', 'prototype', 'constructor'].includes(key), 'Unsafe workflow JSON key');
|
|
const descriptor = Object.getOwnPropertyDescriptor(item, key)!;
|
|
require_valid('value' in descriptor && descriptor.enumerable, 'Expected JSON data fields');
|
|
require_valid(Buffer.byteLength(key) <= max, 'Payload byte limit exceeded');
|
|
bytes += Buffer.byteLength(JSON.stringify(key)) + 1 + (index ? 1 : 0);
|
|
visit(descriptor.value, depth + 1);
|
|
}
|
|
}
|
|
ancestors.delete(item);
|
|
}
|
|
require_valid(bytes <= max, 'Payload byte limit exceeded');
|
|
}
|
|
visit(value, 0);
|
|
}
|
|
|
|
export function check_permissions(value: unknown): asserts value is RelayMethod[] {
|
|
check_size(value, 1024);
|
|
require_valid(Array.isArray(value) && value.length <= RELAY_METHODS.length
|
|
&& value.every(item => RELAY_METHODS.includes(item)) && new Set(value).size === value.length,
|
|
'Invalid relay permissions');
|
|
}
|
|
|
|
export function validate_scope(value: unknown): asserts value is SessionScope {
|
|
require_valid(is_record(value), 'Invalid session scope');
|
|
require_valid(integer(value.forward_id) && integer(value.node_id, 1) && integer(value.bot_id, 1), 'Invalid scope IDs');
|
|
require_valid(is_uuid(value.forward_session_id), 'Invalid forward session ID');
|
|
require_valid(text(value.node_session_id, 256, true), 'Invalid node session ID');
|
|
}
|
|
|
|
function events(value: unknown): void {
|
|
require_valid(Array.isArray(value) && value.length <= BOT_EVENTS.length
|
|
&& value.every(item => BOT_EVENTS.includes(item)) && new Set(value).size === value.length,
|
|
'Invalid bot events');
|
|
}
|
|
|
|
function workflow_id(value: unknown): value is string {
|
|
return typeof value === 'string' && /^[a-zA-Z0-9_-]{1,100}$/.test(value)
|
|
&& !['__proto__', 'prototype', 'constructor'].includes(value);
|
|
}
|
|
|
|
function workflow_input(value: unknown): void {
|
|
check_json_size(value, 64 * 1024, 'workflow');
|
|
require_valid(is_record(value), 'Invalid workflow input');
|
|
}
|
|
|
|
export function validate_request(value: unknown, limits: RelayLimits): asserts value is RelayRequest {
|
|
exact(value, ['request_id', 'method', 'payload']);
|
|
check_json_size(value, limits.max_payload_bytes, WORKFLOW_METHODS.includes(value.method as typeof WORKFLOW_METHODS[number]) ? 'workflow' : false);
|
|
require_valid(is_uuid(value.request_id), 'Invalid request ID');
|
|
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':
|
|
case 'get_data_file_list':
|
|
case 'workflow.catalog':
|
|
case 'workflow.runtime':
|
|
exact(payload, []);
|
|
break;
|
|
case 'workflow.start':
|
|
exact(payload, ['workflow_id'], ['trigger']);
|
|
require_valid(workflow_id(payload.workflow_id), 'Invalid workflow ID');
|
|
if (Object.hasOwn(payload, 'trigger')) workflow_input(payload.trigger);
|
|
break;
|
|
case 'workflow.stop':
|
|
case 'workflow.snapshot':
|
|
exact(payload, value.method === 'workflow.stop' ? ['run_id'] : [],
|
|
value.method === 'workflow.snapshot' ? ['run_id', 'include_branches'] : []);
|
|
if (Object.hasOwn(payload, 'run_id')) require_valid(is_uuid(payload.run_id), 'Invalid workflow run ID');
|
|
if (Object.hasOwn(payload, 'include_branches')) {
|
|
require_valid(Object.hasOwn(payload, 'run_id') && typeof payload.include_branches === 'boolean',
|
|
'Workflow branch selection requires a run ID and boolean flag');
|
|
}
|
|
break;
|
|
case 'workflow.command':
|
|
case 'workflow.control':
|
|
exact(payload, ['command_id', 'context_generation', ...(value.method === 'workflow.control' ? ['action'] : [])],
|
|
value.method === 'workflow.control' ? ['workflow_id', 'run_id', 'expected_generation', 'input'] : []);
|
|
require_valid(is_uuid(payload.command_id) && is_uuid(payload.context_generation), 'Invalid workflow command context');
|
|
if (value.method === 'workflow.command') break;
|
|
require_valid(WORKFLOW_ACTIONS.includes(payload.action as typeof WORKFLOW_ACTIONS[number]), 'Invalid workflow action');
|
|
exact(payload, ['command_id', 'context_generation', 'action', payload.action === 'stop_run' ? 'run_id' : 'workflow_id'],
|
|
[...(payload.action !== 'stop_run' ? ['expected_generation'] : []), ...(payload.action === 'run_once' ? ['input'] : [])]);
|
|
if (Object.hasOwn(payload, 'workflow_id')) require_valid(workflow_id(payload.workflow_id), 'Invalid workflow ID');
|
|
if (Object.hasOwn(payload, 'run_id')) require_valid(is_uuid(payload.run_id), 'Invalid workflow run ID');
|
|
require_valid(payload.action === 'stop_run' ? is_uuid(payload.run_id) : workflow_id(payload.workflow_id), 'Missing workflow target');
|
|
if (Object.hasOwn(payload, 'expected_generation')) require_valid(integer(payload.expected_generation, 1), 'Invalid workflow generation');
|
|
if (Object.hasOwn(payload, 'input')) workflow_input(payload.input);
|
|
break;
|
|
case 'get_file_data':
|
|
exact(payload, ['id'], ['offset', 'length']);
|
|
require_valid(is_uuid(payload.id), 'Invalid file ID');
|
|
if (Object.hasOwn(payload, 'offset')) require_valid(integer(payload.offset), 'Invalid file offset');
|
|
if (Object.hasOwn(payload, 'length')) require_valid(integer(payload.length, 1) && payload.length <= 262_144, 'Invalid file length');
|
|
break;
|
|
case 'bot.logs':
|
|
exact(payload, ['limit'], ['stream_id', 'after_seq']);
|
|
require_valid(integer(payload.limit, 1) && payload.limit <= 200, 'Invalid logs limit');
|
|
if (Object.hasOwn(payload, 'stream_id')) require_valid(is_uuid(payload.stream_id), 'Invalid stream ID');
|
|
if (Object.hasOwn(payload, 'after_seq')) {
|
|
require_valid(integer(payload.after_seq) && is_uuid(payload.stream_id), 'Invalid log cursor');
|
|
}
|
|
break;
|
|
case 'bot.subscribe':
|
|
case 'bot.unsubscribe':
|
|
exact(payload, ['events']);
|
|
events(payload.events);
|
|
break;
|
|
case 'tab_complete':
|
|
exact(payload, ['text']);
|
|
require_valid(typeof payload.text === 'string' && payload.text.length <= 256
|
|
&& !/[\r\n\0]/.test(payload.text), 'Invalid completion text');
|
|
break;
|
|
case 'bot.chat':
|
|
exact(payload, ['message']);
|
|
require_valid(integer(limits.max_chat_length, 1) && typeof payload.message === 'string'
|
|
&& payload.message.trim().length > 0 && payload.message.length <= limits.max_chat_length,
|
|
'Invalid chat message');
|
|
break;
|
|
case 'execute_function':
|
|
exact(payload, ['function', 'name', 'args']);
|
|
require_valid(typeof payload.function === 'string' && typeof payload.name === 'string'
|
|
&& Array.isArray(payload.args), 'Invalid function execution payload');
|
|
break;
|
|
}
|
|
}
|
|
|
|
function generation(value: unknown): boolean {
|
|
return value === null || is_uuid(value);
|
|
}
|
|
|
|
function context_state(status: unknown, exists: boolean, context_generation: unknown): void {
|
|
require_valid(exists ? is_uuid(context_generation) : context_generation === null && status === 'offline',
|
|
'Bot context and status are inconsistent');
|
|
}
|
|
|
|
function log_records(value: unknown): void {
|
|
require_valid(Array.isArray(value) && value.length <= 200, 'Invalid log records');
|
|
let previous_seq = -1;
|
|
const ids = new Set<string>();
|
|
for (const record of value) {
|
|
exact(record, ['id', 'timestamp', 'context_generation', 'seq', 'segments']);
|
|
require_valid(is_uuid(record.id) && integer(record.timestamp) && generation(record.context_generation)
|
|
&& integer(record.seq), 'Invalid log record');
|
|
require_valid(record.seq > previous_seq && !ids.has(record.id), 'Log records must be ordered and unique');
|
|
previous_seq = record.seq;
|
|
ids.add(record.id);
|
|
require_valid(Array.isArray(record.segments), 'Invalid log segments');
|
|
for (const segment of record.segments) {
|
|
exact(segment, ['message', 'font_color', 'font_style'], ['hoverEvent', 'clickEvent', 'insertion']);
|
|
require_valid(typeof segment.message === 'string' && text(segment.font_color, 128, true)
|
|
&& Array.isArray(segment.font_style) && segment.font_style.length <= STYLES.length
|
|
&& segment.font_style.every(style => STYLES.includes(style)), 'Invalid log segment');
|
|
for (const key of ['hoverEvent', 'clickEvent']) {
|
|
if (!Object.hasOwn(segment, key)) continue;
|
|
const event = segment[key];
|
|
exact(event, ['action', 'value'], key === 'hoverEvent' ? ['segments'] : []);
|
|
const actions = key === 'hoverEvent' ? ['show_text', 'show_item', 'show_entity']
|
|
: ['open_url', 'open_file', 'run_command', 'suggest_command', 'change_page',
|
|
'copy_to_clipboard', 'show_dialog', 'custom'];
|
|
require_valid(actions.includes(event.action as string) && text(event.value, 4096, true), 'Invalid log event');
|
|
if (Object.hasOwn(event, 'segments')) {
|
|
require_valid(event.action === 'show_text' && Array.isArray(event.segments) && event.segments.length <= 64, 'Invalid hover segments');
|
|
let length = 0;
|
|
for (const part of event.segments) {
|
|
exact(part, ['message', 'font_color', 'font_style']);
|
|
require_valid(text(part.message, 4096, true) && text(part.font_color, 128, true)
|
|
&& Array.isArray(part.font_style) && part.font_style.length <= STYLES.length
|
|
&& part.font_style.every(style => STYLES.includes(style)), 'Invalid hover segment');
|
|
length += (part.message as string).length;
|
|
}
|
|
require_valid(length <= 4096, 'Hover text too long');
|
|
}
|
|
}
|
|
if (Object.hasOwn(segment, 'insertion')) require_valid(text(segment.insertion, 4096, true), 'Invalid log insertion');
|
|
}
|
|
}
|
|
}
|
|
|
|
export function validate_event(value: unknown, limits: RelayLimits): asserts value is BotEvent {
|
|
check_size(value, limits.max_payload_bytes);
|
|
exact(value, ['forward_session_id', 'bot_id', 'context_generation', 'stream_id', 'seq', 'emitted_at', 'name', 'payload']);
|
|
require_valid(is_uuid(value.forward_session_id) && integer(value.bot_id, 1)
|
|
&& generation(value.context_generation) && is_uuid(value.stream_id)
|
|
&& integer(value.seq) && integer(value.emitted_at), 'Invalid bot event envelope');
|
|
const payload = value.payload;
|
|
switch (value.name) {
|
|
case 'status_update':
|
|
exact(payload, ['status', 'context_exists']);
|
|
require_valid(STATUSES.includes(payload.status as string) && typeof payload.context_exists === 'boolean',
|
|
'Invalid bot status');
|
|
context_state(payload.status, payload.context_exists, value.context_generation);
|
|
break;
|
|
case 'log':
|
|
exact(payload, ['records']);
|
|
log_records(payload.records);
|
|
require_valid((payload.records as {seq: number}[]).every(record => record.seq <= (value.seq as number)),
|
|
'Log record exceeds event watermark');
|
|
break;
|
|
case 'message':
|
|
exact(payload, ['text', 'position'], ['json']);
|
|
require_valid(typeof payload.text === 'string' && text(payload.position, 128, true), 'Invalid bot message');
|
|
break;
|
|
case 'bot_error':
|
|
exact(payload, ['message'], ['code']);
|
|
require_valid(text(payload.message, 1024), 'Invalid bot error');
|
|
if (Object.hasOwn(payload, 'code')) require_valid(text(payload.code, 128, true), 'Invalid bot error code');
|
|
break;
|
|
default:
|
|
fail('INVALID_REQUEST', 'Invalid bot event');
|
|
}
|
|
}
|
|
|
|
function info_name(value: unknown): boolean {
|
|
return text(value, 128) && /^[a-zA-Z0-9_-]+$/.test(value);
|
|
}
|
|
|
|
export function validate_info_update(value: unknown, limits: RelayLimits): asserts value is InfoUpdate {
|
|
check_json_size(value, limits.max_payload_bytes, true);
|
|
exact(value, ['forward_session_id', 'bot_id', 'context_generation', 'stream_id', 'seq', 'emitted_at', 'data']);
|
|
require_valid(is_uuid(value.forward_session_id) && integer(value.bot_id, 1)
|
|
&& is_uuid(value.context_generation) && is_uuid(value.stream_id)
|
|
&& integer(value.seq) && integer(value.emitted_at), 'Invalid info update envelope');
|
|
exact(value.data, ['name', 'value']);
|
|
require_valid(info_name(value.data.name), 'Invalid info name');
|
|
}
|
|
|
|
function validate_info_i18n(value: unknown): void {
|
|
exact(value, ['lang', 'name', 'description'], ['fields', 'values']);
|
|
require_valid(text(value.lang, 128) && text(value.name, 256) && text(value.description, 4096), 'Invalid info translation');
|
|
for (const key of ['fields', 'values']) {
|
|
if (!Object.hasOwn(value, key)) continue;
|
|
const children = value[key];
|
|
require_valid(is_record(children), 'Invalid info translation map');
|
|
for (const [name, metadata] of Object.entries(children)) {
|
|
require_valid(text(name, 128), 'Invalid info translation key');
|
|
validate_info_i18n(metadata);
|
|
}
|
|
}
|
|
}
|
|
|
|
function workflow_start_result(value: unknown): void {
|
|
exact(value, ['status'], ['run_id', 'request_id']);
|
|
require_valid(['started', 'queued', 'skipped'].includes(value.status as string), 'Invalid workflow start result');
|
|
if (Object.hasOwn(value, 'run_id')) require_valid(is_uuid(value.run_id), 'Invalid workflow run ID');
|
|
if (Object.hasOwn(value, 'request_id')) require_valid(is_uuid(value.request_id), 'Invalid workflow request ID');
|
|
require_valid(value.status !== 'started' || is_uuid(value.run_id), 'Missing started run ID');
|
|
require_valid(value.status !== 'queued' || is_uuid(value.request_id), 'Missing queued request ID');
|
|
}
|
|
|
|
function workflow_error(value: unknown, node = false): void {
|
|
exact(value, ['code', 'message', ...(node ? ['node_id'] : [])]);
|
|
require_valid(text(value.code, 128, true) && text(value.message, 1024), 'Invalid workflow error');
|
|
if (node) require_valid(text(value.node_id, 100), 'Invalid workflow error node');
|
|
}
|
|
|
|
function workflow_command(value: unknown): void {
|
|
exact(value, ['command_id', 'action', 'workflow_id', 'state'], ['result', 'error']);
|
|
require_valid(is_uuid(value.command_id) && WORKFLOW_ACTIONS.includes(value.action as typeof WORKFLOW_ACTIONS[number])
|
|
&& (value.workflow_id === null || workflow_id(value.workflow_id))
|
|
&& ['accepted', 'completed', 'failed'].includes(value.state as string), 'Invalid workflow command');
|
|
if (Object.hasOwn(value, 'error')) workflow_error(value.error);
|
|
if (Object.hasOwn(value, 'result')) {
|
|
require_valid(is_record(value.result), 'Invalid workflow command result');
|
|
if (Object.hasOwn(value.result, 'status')) workflow_start_result(value.result);
|
|
else if (Object.hasOwn(value.result, 'stopped')) {
|
|
exact(value.result, ['stopped']);
|
|
require_valid(value.result.stopped === true, 'Invalid workflow stop result');
|
|
} else {
|
|
exact(value.result, ['generation']);
|
|
require_valid(integer(value.result.generation, 1), 'Invalid workflow result generation');
|
|
}
|
|
}
|
|
}
|
|
|
|
function workflow_runtime(value: unknown): void {
|
|
exact(value, ['source_config_revision', 'workflows', 'commands']);
|
|
require_valid(integer(value.source_config_revision) && Array.isArray(value.workflows)
|
|
&& value.workflows.length <= 4096 && Array.isArray(value.commands) && value.commands.length <= 256,
|
|
'Invalid workflow runtime snapshot');
|
|
for (const state of value.workflows) {
|
|
exact(state, ['workflow_id', 'generation', 'enabled', 'start_type', 'trigger_state', 'accepted_count',
|
|
'max_runs', 'next_fire_at', 'active_run_ids', 'queued_count', 'last_error']);
|
|
require_valid(workflow_id(state.workflow_id) && integer(state.generation, 1) && typeof state.enabled === 'boolean'
|
|
&& ['common', 'event', 'timer'].includes(state.start_type as string)
|
|
&& ['stopped', 'listening', 'scheduled', 'exhausted', 'reloading', 'error'].includes(state.trigger_state as string)
|
|
&& integer(state.accepted_count) && (state.max_runs === null || integer(state.max_runs, 1))
|
|
&& (state.next_fire_at === null || integer(state.next_fire_at)) && integer(state.queued_count)
|
|
&& Array.isArray(state.active_run_ids) && state.active_run_ids.every(is_uuid), 'Invalid workflow runtime state');
|
|
if (state.last_error !== null) workflow_error(state.last_error);
|
|
}
|
|
for (const command of value.commands) workflow_command(command);
|
|
}
|
|
|
|
function workflow_run(value: unknown): void {
|
|
exact(value, ['run_id', 'workflow_id', 'status', 'steps', 'active_nodes', 'started_at', 'ended_at', 'stop_reason', 'error'],
|
|
['branches', 'trigger', 'trigger_available']);
|
|
require_valid(is_uuid(value.run_id) && workflow_id(value.workflow_id)
|
|
&& ['running', 'stopping', 'completed', 'failed', 'cancelled', 'timed_out'].includes(value.status as string)
|
|
&& integer(value.steps) && Array.isArray(value.active_nodes) && value.active_nodes.every(id => text(id, 100, true))
|
|
&& integer(value.started_at) && (value.ended_at === null || integer(value.ended_at))
|
|
&& (value.stop_reason === null || ['manual', 'disconnect', 'timeout', 'disposed', 'updated', 'reloaded'].includes(value.stop_reason as string)),
|
|
'Invalid workflow run');
|
|
if (value.error !== null) workflow_error(value.error, true);
|
|
if (Object.hasOwn(value, 'trigger_available')) {
|
|
require_valid(typeof value.trigger_available === 'boolean', 'Invalid workflow trigger availability');
|
|
}
|
|
if (Object.hasOwn(value, 'trigger')) {
|
|
workflow_input(value.trigger);
|
|
require_valid(value.trigger_available !== false, 'Workflow trigger availability is inconsistent');
|
|
}
|
|
if (Object.hasOwn(value, 'branches')) {
|
|
require_valid(Array.isArray(value.branches), 'Invalid workflow branches');
|
|
for (const branch of value.branches) {
|
|
exact(branch, ['trigger', 'vars', 'outputs', 'last_result', 'inputs', 'retry_counts']);
|
|
require_valid(['trigger', 'vars', 'outputs', 'inputs', 'retry_counts'].every(key => is_record(branch[key]))
|
|
&& (branch.last_result === null || is_record(branch.last_result)), 'Invalid workflow branch');
|
|
for (const output of Object.values(branch.outputs as Record<string, unknown>)) workflow_node_result(output, 'execution');
|
|
for (const count of Object.values(branch.retry_counts as Record<string, unknown>)) require_valid(integer(count), 'Invalid workflow retry count');
|
|
if (branch.last_result !== null) workflow_node_result(branch.last_result, 'node_id');
|
|
}
|
|
}
|
|
}
|
|
|
|
function workflow_node_result(value: unknown, field: 'execution' | 'node_id'): void {
|
|
exact(value, ['status', 'output', field], ['error']);
|
|
require_valid(value.status === 'success' || value.status === 'failure', 'Invalid workflow node result');
|
|
require_valid(field === 'execution' ? integer(value.execution, 1) : text(value.node_id, 100, true), 'Invalid workflow node result identity');
|
|
if (Object.hasOwn(value, 'error')) workflow_error(value.error, true);
|
|
}
|
|
|
|
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<string>();
|
|
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<string>();
|
|
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<string>(), counts = new Map<number, number>();
|
|
const enums: Record<string, readonly string[]> = {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<string, unknown>).blocks, chunk.palette.length - 1) : blocks;
|
|
const pairs = new Set<string>(), counts = new Map<number, number>();
|
|
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<unknown> {
|
|
const workflow = WORKFLOW_METHODS.includes(method as typeof WORKFLOW_METHODS[number]);
|
|
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;
|
|
if (value.status === 'error') {
|
|
exact(data, ['code', 'message', 'retryable']);
|
|
require_valid(ERROR_CODES.includes(data.code as ForwardErrorCode) && text(data.message, 1024)
|
|
&& typeof data.retryable === 'boolean', 'Invalid error ACK');
|
|
return;
|
|
}
|
|
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;
|
|
case 'workflow.stop':
|
|
exact(data, ['stopped']);
|
|
require_valid(data.stopped === true, 'Invalid workflow stop result');
|
|
break;
|
|
case 'workflow.catalog':
|
|
check_json_size(data, 100 * 1024, 'workflow');
|
|
exact(data, ['catalog_version', 'nodes', 'start_nodes', 'events', 'queries', 'common']);
|
|
require_valid(data.catalog_version === 1
|
|
&& ['nodes', 'start_nodes', 'events', 'queries'].every(key => Array.isArray(data[key]) && data[key].every(is_record)),
|
|
'Invalid workflow catalog');
|
|
require_valid(is_record(data.common) && Array.isArray(data.common.node_fields)
|
|
&& Array.isArray(data.common.policy_fields) && is_record(data.common.result)
|
|
&& is_record(data.common.edge_input) && Array.isArray(data.common.references), 'Invalid workflow catalog common metadata');
|
|
break;
|
|
case 'workflow.snapshot':
|
|
if (data !== null) {
|
|
if (Array.isArray(data)) for (const run of data) workflow_run(run);
|
|
else workflow_run(data);
|
|
}
|
|
break;
|
|
case 'workflow.control':
|
|
workflow_command(data);
|
|
break;
|
|
case 'workflow.command':
|
|
if (data !== null) workflow_command(data);
|
|
break;
|
|
case 'workflow.runtime':
|
|
workflow_runtime(data);
|
|
break;
|
|
case 'get_data_file_list': {
|
|
exact(data, ['listing_id', 'expires_at', 'partial', 'children']);
|
|
require_valid(is_uuid(data.listing_id) && integer(data.expires_at, 1)
|
|
&& typeof data.partial === 'boolean', 'Invalid file listing');
|
|
check_json_size(data.children, 512 * 1024, 'files');
|
|
let nodes = 0;
|
|
function visit(children: unknown, depth: number): void {
|
|
require_valid(Array.isArray(children), 'Invalid file children');
|
|
for (const child of children) {
|
|
require_valid(depth <= 32 && ++nodes <= 2000, 'File tree complexity limit exceeded');
|
|
require_valid(is_record(child), 'Invalid file node');
|
|
if (child.type === 'directory') {
|
|
exact(child, ['type', 'name', 'children']);
|
|
require_valid(typeof child.name === 'string', 'Invalid directory name');
|
|
visit(child.children, depth + 1);
|
|
} else {
|
|
exact(child, ['type', 'name', 'id', 'size', 'modified_at']);
|
|
require_valid(child.type === 'file' && typeof child.name === 'string' && is_uuid(child.id)
|
|
&& integer(child.size) && integer(child.modified_at), 'Invalid file node');
|
|
}
|
|
}
|
|
}
|
|
visit(data.children, 1);
|
|
break;
|
|
}
|
|
case 'get_file_data': {
|
|
exact(data, ['id', 'name', 'encoding', 'offset', 'next_offset', 'total_bytes', 'eof', 'data']);
|
|
require_valid(is_uuid(data.id) && typeof data.name === 'string' && data.encoding === 'base64'
|
|
&& integer(data.offset) && integer(data.next_offset) && integer(data.total_bytes)
|
|
&& data.offset <= data.next_offset && data.next_offset <= data.total_bytes
|
|
&& typeof data.eof === 'boolean' && data.eof === (data.next_offset === data.total_bytes)
|
|
&& typeof data.data === 'string' && data.data.length <= 4 * Math.ceil(262_144 / 3), 'Invalid file data');
|
|
const decoded = Buffer.from(data.data, 'base64');
|
|
require_valid(decoded.length <= 262_144 && decoded.toString('base64') === data.data
|
|
&& decoded.length === data.next_offset - data.offset, 'Invalid file bytes');
|
|
break;
|
|
}
|
|
case 'get_context_info':
|
|
require_valid(Array.isArray(data) && data.length <= 256, 'Invalid context info result');
|
|
for (const info of data) {
|
|
exact(info, ['name', 'i18n', 'value']);
|
|
require_valid(info_name(info.name), 'Invalid info name');
|
|
validate_info_i18n(info.i18n);
|
|
}
|
|
break;
|
|
case 'bot.snapshot':
|
|
exact(data, ['bot_id', 'bot_status', 'context_exists', 'context_generation', 'stream_id', 'seq', 'captured_at']);
|
|
require_valid(data.bot_id === bot_id && STATUSES.includes(data.bot_status as string)
|
|
&& typeof data.context_exists === 'boolean' && generation(data.context_generation)
|
|
&& is_uuid(data.stream_id) && integer(data.seq) && integer(data.captured_at), 'Invalid bot snapshot');
|
|
context_state(data.bot_status, data.context_exists, data.context_generation);
|
|
break;
|
|
case 'bot.logs':
|
|
case 'get_log_history':
|
|
exact(data, ['stream_id', 'seq', 'records', 'truncated']);
|
|
require_valid(is_uuid(data.stream_id) && integer(data.seq) && typeof data.truncated === 'boolean',
|
|
'Invalid logs result');
|
|
log_records(data.records);
|
|
if (method === 'get_log_history') require_valid((data.records as unknown[]).length <= 20, 'History exceeds 20 records');
|
|
require_valid((data.records as {seq: number}[]).every(record => record.seq <= (data.seq as number)),
|
|
'Log record exceeds response watermark');
|
|
break;
|
|
case 'tab_complete':
|
|
exact(data, ['matches', 'start', 'length']);
|
|
require_valid(integer(data.start) && data.start <= 256 && integer(data.length)
|
|
&& data.length <= 256 && data.start + data.length <= 256, 'Invalid completion range');
|
|
require_valid(Array.isArray(data.matches) && data.matches.length <= 100
|
|
&& data.matches.every(match => typeof match === 'string' && match.length > 0 && match.length <= 256)
|
|
&& new Set(data.matches).size === data.matches.length, 'Invalid completion matches');
|
|
break;
|
|
case 'bot.chat':
|
|
exact(data, ['accepted']);
|
|
require_valid(data.accepted === true, 'Invalid chat result');
|
|
break;
|
|
case 'execute_function':
|
|
require_valid(typeof data === 'boolean', 'Invalid function execution result');
|
|
break;
|
|
case 'bot.subscribe':
|
|
case 'bot.unsubscribe':
|
|
exact(data, ['events']);
|
|
events(data.events);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function expiry(value: unknown, limits: RelayLimits): void {
|
|
require_valid(integer(limits.max_lease_ms, 1) && integer(limits.clock_skew_ms), 'Invalid lease limits');
|
|
const now = Date.now();
|
|
require_valid(integer(value, 1) && value > now - limits.clock_skew_ms
|
|
&& value - now <= limits.max_lease_ms + limits.clock_skew_ms, 'Invalid lease expiry');
|
|
}
|
|
|
|
export function validate_open(value: unknown, forward_id: number, limits: RelayLimits): asserts value is OpenSessionCommand {
|
|
check_size(value, limits.max_payload_bytes);
|
|
exact(value, ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id', 'lease_expires_at']);
|
|
validate_scope(value);
|
|
require_valid(value.forward_id === forward_id, 'Invalid forward audience');
|
|
expiry(value.lease_expires_at, limits);
|
|
}
|
|
|
|
export function validate_grant(value: unknown, limits: RelayLimits): asserts value is AuthorizeGrantCommand {
|
|
check_size(value, limits.max_payload_bytes);
|
|
exact(value, ['forward_session_id', 'grant_id', 'user_id', 'permissions', 'expires_at']);
|
|
require_valid(is_uuid(value.forward_session_id) && is_uuid(value.grant_id) && integer(value.user_id, 1),
|
|
'Invalid grant identity');
|
|
check_permissions(value.permissions);
|
|
expiry(value.expires_at, limits);
|
|
}
|