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:
+199
-6
@@ -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<object>();
|
||||
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<unknown> {
|
||||
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 === '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;
|
||||
|
||||
Reference in New Issue
Block a user