import assert from 'node:assert/strict'; import {createCipheriv, createHash} from 'node:crypto'; import {test} from 'node:test'; import {RELAY_METHODS, WORKFLOW_ACTIONS, WORKFLOW_METHODS} from '../types.ts'; import type {BotEvent, JsonValue, RelayClaims, RelayLimits, WorkflowRunSnapshot, WorkflowRuntimeState} from '../types.ts'; import {decode_token, encode_token, verify_token} from '../utils/token_utils.ts'; import {DEFAULT_LIMITS} from '../utils/relay_config.ts'; import { RelayError, check_permissions, check_size, error_ack, fail, is_record, is_uuid, success, validate_event, validate_grant, validate_info_update, validate_node_ack, validate_open, validate_request, validate_scope, } from '../utils/relay_validation.ts'; const secret = 'relay-test-key-not-a-production-secret'; const uuid = 'a89acd9a-0ab3-44ac-812e-e221a5b915e0'; const other_uuid = '45264c09-7529-478f-b3ef-6a84bec8c1a7'; const now = 1_800_000_000_000; const limits: RelayLimits = { auth_timeout_ms: 10_000, close_timeout_ms: 5_000, rpc_timeout_ms: 5_000, unused_timeout_ms: 60_000, idle_timeout_ms: 30_000, max_token_age_ms: 300_000, join_window_ms: 60_000, clock_skew_ms: 1_000, max_lease_ms: 300_000, max_token_bytes: 4096, max_payload_bytes: 256 * 1024, max_chat_length: 256, max_connections: 100, max_sessions: 100, max_grants_per_session: 100, max_pending_per_web: 8, max_pending_total: 100, requests_per_second: 20, events_per_second: 100, max_dedupe_entries: 100, dedupe_ttl_ms: 30_000, max_buffered_packets: 100, }; function claims(overrides: Record = {}): RelayClaims { return { v: 1, role: 'web', forward_id: 0, forward_session_id: uuid, node_session_id: 'mZAb09_-SocketIOmainID', node_id: 1, bot_id: 2, user_id: 3, grant_id: other_uuid, permissions: ['bot.snapshot', 'bot.logs'], issued_at: now, join_before: now + 60_000, expires_at: now + 300_000, jti: other_uuid, ...overrides, } as RelayClaims; } function rejected(action: () => unknown, code = 'INVALID_REQUEST'): void { assert.throws(action, (error: unknown) => error instanceof RelayError && error.code === code); } function verify(value: unknown, at = now, settings = limits): RelayClaims { return verify_token(secret, encode_token(secret, value), 0, settings, at); } function request(method: string, payload: unknown): unknown { return {request_id: uuid, method, payload}; } function record(): Record { return {id: uuid, timestamp: now, context_generation: null, seq: 1, segments: [{message: 'hello', font_color: '#ffffff', font_style: ['normal', 'bold']}]}; } function event(name: string, payload: unknown): unknown { return {forward_session_id: uuid, bot_id: 2, context_generation: null, stream_id: other_uuid, seq: 1, emitted_at: now, name, payload}; } test('AES-256-GCM wire format remains IV[12] + tag[16] + ciphertext', () => { const value = claims(); const iv = Buffer.from('000102030405060708090a0b', 'hex'); const cipher = createCipheriv('aes-256-gcm', createHash('sha256').update(secret).digest(), iv); const ciphertext = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final()]); const token = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64url'); assert.equal(token, 'AAECAwQFBgcICQoLKReCzFKlEY6g42nAQLV9_x0ByGStIbnU7jVnJBA372eKB2zodLxLE9mBUS1agWpMmTc7swZYqNfSWy53I7FUGk6-ik0it6dgU_Sr5B4xex5F89Dl89k2hg2wkenWZpeIksBqjx2kPX2vj1o05iSueFxOXLopUmDFt7SWUFLzsQUdE5Q1nyTH7cWi71k5S5Fmc-Fo9qetCUp3QDyMPaD5Bn4sTrmUoSztp1AHREowEAMFMNAhjQ5Y5-aVDg08biMmw9X2KbBMukqomk5t-cHk0-l6r2XudPdPxH6B4YFhfE8iqIPnekUXz-GyCwuOODvdPajUKQs48s13OCVyhW5WzvxsoimN1KrM0ac0S1ybB9aRvkXSbkDHuEfy9Q-NCcddJacIKjIpFqeIrRV0EJAdCaMirNPIer_5F4YvmJB9H5UKNGSg4mZ-DYkvmXsvYAleGpSJzIpwjWG_pXb8dbjltryrQQ5HbXSIvs_8GBVGZdROTmwXCOrI_ZS8azkFA29zrHRBHiz-H3Rs0bv0GUgfZPoz_eJWxyA'); assert.deepEqual(decode_token(secret, token), value); assert.deepEqual(verify_token(secret, token, 0, limits, now), value); assert.deepEqual(decode_token(secret, encode_token(secret, value)), value); assert.notEqual(encode_token(secret, value), encode_token(secret, value)); }); test('tokens enforce canonical base64url, byte limit, authentication and audience', () => { const token = encode_token(secret, claims()); for (const input of ['', token + '=', token + '\n', ' ' + token, token + '+', 'A', '_'.repeat(40)]) { rejected(() => verify_token(secret, input, 0, limits, now), 'UNAUTHORIZED'); } const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; // GCM token ends in unused base64 bits: accepting an alias would allow multiple wire IDs. let short_token = encode_token(secret, {a: 1}); if (short_token.length % 4 === 0) short_token = encode_token(secret, {a: 12}); const last_index = alphabet.indexOf(short_token.at(-1)!); const alias = short_token.slice(0, -1) + alphabet[last_index + 1]; assert.deepEqual(Buffer.from(alias, 'base64url'), Buffer.from(short_token, 'base64url')); assert.throws(() => decode_token(secret, alias)); const raw = Buffer.from(token, 'base64url'); for (const offset of [0, 12, 28, raw.length - 1]) { const modified = Buffer.from(raw); modified[offset] ^= 1; rejected(() => verify_token(secret, modified.toString('base64url'), 0, limits, now), 'UNAUTHORIZED'); } rejected(() => verify_token('wrong-secret', token, 0, limits, now), 'UNAUTHORIZED'); rejected(() => verify_token(secret, token, 1, limits, now), 'UNAUTHORIZED'); rejected(() => verify_token(secret, token, 0, {...limits, max_token_bytes: token.length - 1}, now), 'UNAUTHORIZED'); assert.deepEqual(verify_token(secret, token, 0, {...limits, max_token_bytes: token.length}, now), claims()); }); test('authenticated plaintext must be valid UTF-8 JSON of exactly typed claims', () => { for (const value of [null, [], 'claims', 1, {}, {...claims(), v: '1'}, {...claims(), password: 'sensitive'}, {...claims(), role: 'admin'}, {...claims(), permissions: ['*']}, {...claims(), permissions: ['bot.chat', 'bot.chat']}, {...claims(), permissions: null}]) { rejected(() => verify(value), 'UNAUTHORIZED'); } for (const key of Object.keys(claims())) { const missing = {...claims()} as Record; delete missing[key]; rejected(() => verify(missing), 'UNAUTHORIZED'); } const cipher = createCipheriv('aes-256-gcm', createHash('sha256').update(secret).digest(), Buffer.alloc(12)); const ciphertext = Buffer.concat([cipher.update(Buffer.from([0x22, 0xff, 0x22])), cipher.final()]); const token = Buffer.concat([Buffer.alloc(12), cipher.getAuthTag(), ciphertext]).toString('base64url'); rejected(() => verify_token(secret, token, 0, limits, now), 'UNAUTHORIZED'); }); test('IDs are safe integers, protocol IDs are UUIDs, and Main socket session IDs are bounded strings', () => { for (const field of ['forward_id', 'node_id', 'bot_id', 'user_id']) { for (const value of ['1', -1, 1.5, Number.MAX_SAFE_INTEGER + 1, null]) { rejected(() => verify(claims({[field]: value})), 'UNAUTHORIZED'); } } for (const field of ['forward_session_id', 'grant_id', 'jti']) { rejected(() => verify(claims({[field]: 'not-a-uuid'})), 'UNAUTHORIZED'); } for (const value of ['', ' ', 'x'.repeat(257), '\u754c'.repeat(86), null, 123]) { rejected(() => verify(claims({node_session_id: value})), 'UNAUTHORIZED'); } assert.equal(verify(claims({node_session_id: 'x'.repeat(256)})).node_session_id.length, 256); assert.equal(verify(claims({node_id: Number.MAX_SAFE_INTEGER})).node_id, Number.MAX_SAFE_INTEGER); }); test('node tokens exclude web identity; web tokens require both grant and positive user ID', () => { const node = {...claims(), role: 'node'} as Record; delete node.user_id; delete node.grant_id; assert.equal(verify(node).role, 'node'); for (const extras of [{user_id: 3}, {grant_id: uuid}, {user_id: null}, {grant_id: null}]) { rejected(() => verify({...node, ...extras}), 'UNAUTHORIZED'); } for (const extras of [{user_id: 0}, {user_id: null}, {grant_id: null}, {grant_id: ''}]) { rejected(() => verify(claims(extras)), 'UNAUTHORIZED'); } assert.deepEqual(verify(claims({permissions: []})).permissions, []); }); test('time ordering, TTL, join window and finite clock skew are enforced at boundaries', () => { for (const times of [ {issued_at: '1800000000000'}, {issued_at: -1}, {issued_at: now + 0.5}, {join_before: now}, {join_before: now - 1}, {join_before: now + 60_001}, {expires_at: now + 300_001}, {expires_at: now + 10}, {issued_at: now + 1001, join_before: now + 2000, expires_at: now + 3000}, ]) rejected(() => verify(claims(times)), 'UNAUTHORIZED'); assert.equal(verify(claims(), now + 60_999).role, 'web'); rejected(() => verify(claims(), now + 61_000), 'TOKEN_EXPIRED'); const short = claims({join_before: now + 1000, expires_at: now + 1000}); assert.equal(verify(short, now + 1999).role, 'web'); rejected(() => verify(short, now + 2000), 'TOKEN_EXPIRED'); rejected(() => verify(claims(), now + 60_000, {...limits, clock_skew_ms: 0}), 'TOKEN_EXPIRED'); assert.equal(verify(claims(), now - 1000).role, 'web'); rejected(() => verify(claims(), now - 1001), 'UNAUTHORIZED'); for (const clock of [NaN, Infinity, -1, 1.5]) { rejected(() => verify(claims(), clock), 'UNAUTHORIZED'); rejected(() => verify(claims(), now, {...limits, clock_skew_ms: clock}), 'UNAUTHORIZED'); } for (const option of ['max_token_bytes', 'max_token_age_ms', 'join_window_ms'] as const) { rejected(() => verify(claims(), now, {...limits, [option]: 0}), 'UNAUTHORIZED'); } }); test('errors are standard ACKs and unknown errors do not expose internal secrets', () => { assert.throws(() => fail('TIMEOUT', 'RPC timed out', true), RelayError); assert.deepEqual(error_ack(new RelayError('TIMEOUT', 'RPC timed out', true)), { status: 'error', data: {code: 'TIMEOUT', message: 'RPC timed out', retryable: true}, }); for (const error of [new Error('password=secret'), {code: 'TIMEOUT', message: 'secret'}, null]) { assert.deepEqual(error_ack(error), { status: 'error', data: {code: 'INTERNAL_ERROR', message: 'Internal relay error', retryable: false}, }); } assert.deepEqual(success({accepted: true}), {status: 'success', data: {accepted: true}}); assert.equal(is_record([]), false); assert.equal(is_record(new Date()), false); assert.equal(is_record(Object.create(null)), true); assert.equal(is_uuid(uuid), true); assert.equal(is_uuid('not-a-uuid'), false); }); test('payload size uses UTF-8 JSON bytes and limits traversal without invoking accessors', () => { const value = {text: '\u754c\n'}; const size = Buffer.byteLength(JSON.stringify(value)); check_size(value, size); rejected(() => check_size(value, size - 1)); const cyclic: Record = {}; cyclic.self = cyclic; let nested: unknown = 'leaf'; for (let i = 0; i < 17; i++) nested = {nested}; for (const invalid of [cyclic, nested, Array(4097).fill(0), {value: undefined}, {value: NaN}, {value: Infinity}, {value: 1n}, {value: () => 1}, Array(2), new Date()]) { rejected(() => check_size(invalid, limits.max_payload_bytes)); } let calls = 0; rejected(() => check_size({get secret() { calls++; return 'secret'; }}, 4096)); assert.equal(calls, 0); for (const bad_limit of [0, -1, Infinity, NaN]) rejected(() => check_size({}, bad_limit)); }); test('large segmented history fits default limits while bytes and complexity remain bounded', () => { const records = Array.from({length: 20}, (_, index) => ({...record(), id: `a89acd9a-0ab3-44ac-812e-${index.toString(16).padStart(12, '0')}`, seq: index + 1, segments: Array.from({length: 32}, () => ({message: '\u754c'.repeat(1024), font_color: '#ffffff', font_style: ['normal']}))})); const result = success({stream_id: uuid, seq: 20, records, truncated: false}); const size = Buffer.byteLength(JSON.stringify(result)); assert.ok(size > 1024 * 1024); assert.ok(size < DEFAULT_LIMITS.max_payload_bytes); validate_node_ack('get_log_history', result, 2, DEFAULT_LIMITS); validate_node_ack('bot.logs', result, 2, DEFAULT_LIMITS); rejected(() => validate_node_ack('get_log_history', result, 2, {...DEFAULT_LIMITS, max_payload_bytes: 262_144})); check_size('x'.repeat(DEFAULT_LIMITS.max_payload_bytes - 2), DEFAULT_LIMITS.max_payload_bytes); rejected(() => check_size('x'.repeat(DEFAULT_LIMITS.max_payload_bytes - 1), DEFAULT_LIMITS.max_payload_bytes)); const many_nodes = Array.from({length: 1024}, () => Array(64).fill(0)); rejected(() => check_size(many_nodes, DEFAULT_LIMITS.max_payload_bytes)); }); test('requests whitelist every method and reject identity or sensitive payload fields', () => { for (const [method, payload] of [ ['bot.snapshot', {}], ['bot.logs', {limit: 1}], ['bot.logs', {limit: 200, stream_id: uuid, after_seq: Number.MAX_SAFE_INTEGER}], ['bot.subscribe', {events: ['status_update', 'message']}], ['bot.unsubscribe', {events: []}], ['bot.chat', {message: 'hello'}], ] as const) validate_request(request(method, payload), limits); for (const [method, payload] of [ ['bot.delete', {}], ['bot.snapshot', null], ['bot.snapshot', {token: 'secret'}], ['bot.logs', {limit: 0}], ['bot.logs', {limit: 201}], ['bot.logs', {limit: '1'}], ['bot.logs', {limit: 1, after_seq: 0}], ['bot.logs', {limit: 1, stream_id: 'stream'}], ['bot.logs', {limit: 1, stream_id: uuid, after_seq: Number.MAX_SAFE_INTEGER + 1}], ['bot.subscribe', {events: ['log', 'log']}], ['bot.subscribe', {events: ['error']}], ['bot.chat', {message: ' '}], ['bot.chat', {message: 'x'.repeat(257)}], ['bot.chat', {message: 'hello', password: 'secret'}], ] as Array<[string, unknown]>) rejected(() => validate_request(request(method, payload), limits)); rejected(() => validate_request({...request('bot.snapshot', {}) as object, bot_id: 99}, limits)); rejected(() => validate_request({request_id: 'id', method: 'bot.snapshot', payload: {}}, limits)); rejected(() => validate_request(request('bot.chat', {message: '\u754c'.repeat(20)}), {...limits, max_payload_bytes: 100})); check_permissions(['bot.chat']); rejected(() => check_permissions(['bot.chat', 'bot.chat'])); rejected(() => check_permissions(['unknown'])); rejected(() => check_permissions(Array(1))); }); test('all six bot statuses and typed event DTOs are accepted, unexpected fields are rejected', () => { for (const status of ['offline', 'ready', 'connecting', 'online', 'disconnecting', 'reconnecting']) { validate_event({...event('status_update', {status, context_exists: true}) as BotEvent, context_generation: uuid}, limits); } validate_event(event('status_update', {status: 'offline', context_exists: false}), limits); rejected(() => validate_event(event('status_update', {status: 'online', context_exists: false}), limits)); rejected(() => validate_event(event('status_update', {status: 'offline', context_exists: true}), limits)); for (const [name, payload] of [ ['log', {records: [record()]}], ['message', {text: 'hello', position: 'chat', json: {extra: [{text: 'hello'}]}}], ['bot_error', {code: 'OFFLINE', message: 'not online'}], ['bot_error', {message: 'error'}], ] as Array<[string, unknown]>) validate_event(event(name, payload), limits); for (const [name, payload] of [ ['error', {message: 'error'}], ['status_update', {status: 'unknown', context_exists: true}], ['status_update', {status: 'online', context_exists: 'true'}], ['message', {text: 'hello', position: 'chat', token: 'secret'}], ['bot_error', {message: 'error', stack: 'sensitive stack'}], ['log', {records: [{...record(), password: 'secret'}]}], ['log', {records: [{...record(), segments: [{message: 'hi', font_color: '#fff', font_style: ['script']}]}]}], ] as Array<[string, unknown]>) rejected(() => validate_event(event(name, payload), limits)); for (const seq of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) { rejected(() => validate_event({...event('log', {records: []}) as BotEvent, seq}, limits)); } rejected(() => validate_event({...event('log', {records: []}) as BotEvent, key: 'secret'}, limits)); }); test('execute_function validates exact payloads, bounded JSON args, boolean ACKs and permissions', () => { const payload = {function: 'movement/navigation', name: 'go_to', args: [1, 'target', true, null, {position: [1, 2, 3]}]}; validate_request(request('execute_function', payload), limits); validate_request(request('execute_function', {function: '', name: '', args: []}), limits); for (const invalid of [null, [], {}, {...payload, category: 'movement'}, {...payload, function: []}, {...payload, name: 1}, {...payload, args: {}}, {name: 'go_to', args: []}, {function: 'movement', args: []}, {function: 'movement', name: 'go_to'}]) { rejected(() => validate_request(request('execute_function', invalid), limits)); } const cyclic: unknown[] = []; cyclic.push(cyclic); let deep: unknown = 0; for (let i = 0; i < 17; i++) deep = [deep]; for (const args of [[undefined], [NaN], [Infinity], [1n], [() => true], [new Date()], Array(1), cyclic, [deep], Array(4097).fill(0)]) { rejected(() => validate_request(request('execute_function', {...payload, args}), limits)); } const value = request('execute_function', payload); const bytes = Buffer.byteLength(JSON.stringify(value)); validate_request(value, {...limits, max_payload_bytes: bytes}); rejected(() => validate_request(value, {...limits, max_payload_bytes: bytes - 1})); for (const data of [true, false]) validate_node_ack('execute_function', success(data), 2, limits); for (const data of [null, 0, 1, 'true', [], {}, {accepted: true}]) { rejected(() => validate_node_ack('execute_function', success(data), 2, limits)); } validate_node_ack('execute_function', error_ack(new RelayError('NO_CONTEXT', 'No context')), 2, limits); rejected(() => validate_node_ack('execute_function', {status: 'success', data: true, extra: true}, 2, limits)); check_permissions(['execute_function']); rejected(() => check_permissions(['execute_function', 'execute_function'])); assert.deepEqual(verify(claims({permissions: ['execute_function']})).permissions, ['execute_function']); }); test('tab_complete validates exact bounded completion DTOs without adding token permissions', () => { for (const text of ['', ' ', '/help ', 'x'.repeat(256), '\u754c'.repeat(256)]) { validate_request(request('tab_complete', {text}), limits); } for (const payload of [null, {}, {text: 1}, {text: 'x'.repeat(257)}, {text: 'a\rb'}, {text: 'a\nb'}, {text: 'a\0b'}, {text: '', extra: true}, {message: '/help'}]) { rejected(() => validate_request(request('tab_complete', payload), limits)); } for (const matches of [[], [' '], ['x'.repeat(256), '\u754c'.repeat(256)], Array.from({length: 100}, (_, index) => String(index))]) { validate_node_ack('tab_complete', success({matches, start: 0, length: 0}), 2, limits); } for (const data of [null, {}, {matches: 'help'}, {matches: [1]}, {matches: ['']}, {matches: ['help', 'help']}, {matches: ['x'.repeat(257)]}, {matches: Array.from({length: 101}, (_, index) => String(index))}, {matches: [], extra: true}]) { rejected(() => validate_node_ack('tab_complete', success(data === null ? null : {start: 0, length: 0, ...data}), 2, limits)); } for (const range of [{start: 0, length: 256}, {start: 256, length: 0}, {start: 128, length: 128}]) { validate_node_ack('tab_complete', success({matches: [], ...range}), 2, limits); } for (const data of [{matches: []}, {matches: [], start: 0}, {matches: [], length: 0}] as unknown[]) { rejected(() => validate_node_ack('tab_complete', success(data), 2, limits)); } for (const value of [-1, 0.5, 257, Number.MAX_SAFE_INTEGER + 1, NaN, Infinity, '0', null]) { for (const field of ['start', 'length']) { rejected(() => validate_node_ack('tab_complete', success({matches: [], start: 0, length: 0, [field]: value}), 2, limits)); } } rejected(() => validate_node_ack('tab_complete', success({matches: [], start: 256, length: 1}), 2, limits)); validate_node_ack('tab_complete', error_ack(new RelayError('BOT_NOT_ONLINE', 'Offline')), 2, limits); check_permissions(['bot.chat']); rejected(() => check_permissions(['tab_complete'])); rejected(() => verify(claims({permissions: ['tab_complete']})), 'UNAUTHORIZED'); }); test('log metadata is accepted for events and history with exact bounded payloads', () => { const base = record(); const segment = {message: 'hello', font_color: '#ffffff', font_style: ['normal'], hoverEvent: {action: 'show_text', value: 'tooltip', segments: [{message: 'tooltip', font_color: '#55ffff', font_style: ['bold']}]}, clickEvent: {action: 'suggest_command', value: '/help'}, insertion: 'Alex'}; const records = [{...base, segments: [segment]}]; validate_event(event('log', {records}), limits); validate_node_ack('bot.logs', success({stream_id: uuid, seq: 1, records, truncated: false}), 2, limits); for (const metadata of [ {hoverEvent: {action: 'unknown', value: 'text'}}, {clickEvent: {action: 'run_command', value: '/help', secret: 'extra'}}, {clickEvent: {action: 'run_command', value: 42}}, {insertion: 'x'.repeat(4097)}, ]) rejected(() => validate_event(event('log', {records: [{...base, segments: [{...segment, ...metadata}]}]}), limits)); }); test('get_context_info validates empty requests and exact bounded info arrays without new permissions', () => { validate_request(request('get_context_info', {}), limits); for (const payload of [null, [], {name: 'health'}, {token: 'secret'}] as unknown[]) { rejected(() => validate_request(request('get_context_info', payload), limits)); } const info = {name: 'health_0-HP', i18n: {lang: 'en', name: 'Health', description: ''}, value: {hp: 20}}; for (const data of [[], [info], Array(256).fill(info), [{...info, name: 'a'.repeat(128), i18n: {lang: 'a'.repeat(128), name: 'a'.repeat(256), description: 'a'.repeat(4096)}}]]) { validate_node_ack('get_context_info', success(data), 2, limits); } for (const data of [null, {}, Array(257).fill(info), [{...info, extra: true}], [{...info, i18n: {...info.i18n, extra: true}}], [{name: 'health', value: 1}], [{...info, i18n: {...info.i18n, fields: {x: {lang: 'en', name: 'X'}}}}], [{...info, i18n: {...info.i18n, values: []}}], [{...info, i18n: {lang: 'en', name: 'Health'}}]]) { rejected(() => validate_node_ack('get_context_info', success(data), 2, limits)); } for (const name of ['', 'a.b', 'a b', '\u754c', 'a'.repeat(129), null, 1]) { rejected(() => validate_node_ack('get_context_info', success([{...info, name}]), 2, limits)); } for (const [field, length] of [['lang', 128], ['name', 256], ['description', 4096]] as const) { for (const value of ['a'.repeat(length + 1), '\u754c'.repeat(Math.floor(length / 3) + 1), null, 1]) { rejected(() => validate_node_ack('get_context_info', success([{...info, i18n: {...info.i18n, [field]: value}}]), 2, limits)); } } validate_node_ack('get_context_info', error_ack(new RelayError('BOT_NOT_ONLINE', 'Offline')), 2, limits); rejected(() => check_permissions(['get_context_info'])); rejected(() => verify(claims({permissions: ['get_context_info']})), 'UNAUTHORIZED'); }); test('info_update uses an exact envelope with non-null context and JSON-safe bounded values', () => { const update = {forward_session_id: uuid, bot_id: 2, context_generation: uuid, stream_id: other_uuid, seq: 1, emitted_at: now, data: {name: 'health', value: null as unknown}}; const info = {name: 'health', i18n: {lang: '', name: '', description: ''}, value: null as unknown}; for (const value of [null, true, 0, -1.5, '', [1, {nested: ['ok']}], {health: 20}, Object.create(null)]) { validate_info_update({...update, data: {...update.data, value}}, limits); validate_node_ack('get_context_info', success([{...info, value}]), 2, limits); } for (const invalid of [{...update, context_generation: null}, {...update, context_generation: ''}, {...update, name: 'info_update'}, {...update, payload: update.data}, {...update, seq: -1}, {...update, bot_id: 0}, {...update, emitted_at: 0.5}, {...update, stream_id: 'stream'}, {...update, forward_session_id: 'session'}, {...update, data: {name: 'health'}}, {...update, data: {...update.data, i18n: {}}}, {...update, data: {name: 'bad.name', value: 1}}, {...update, data: {name: 'a'.repeat(129), value: 1}}]) { rejected(() => validate_info_update(invalid, limits)); } const cyclic: Record = {}; cyclic.self = cyclic; let nested: unknown = 0; for (let index = 0; index < 69; index++) nested = {nested}; let accessor_calls = 0; for (const value of [undefined, NaN, Infinity, 1n, () => 1, new Date(), Array(1), cyclic, nested, {get secret() {accessor_calls++; return 1;}}, Array(65_537).fill(0)]) { rejected(() => validate_info_update({...update, data: {...update.data, value}}, limits)); rejected(() => validate_node_ack('get_context_info', success([{...info, value}]), 2, limits)); } assert.equal(accessor_calls, 0); const size = Buffer.byteLength(JSON.stringify(update)); validate_info_update(update, {...limits, max_payload_bytes: size}); rejected(() => validate_info_update(update, {...limits, max_payload_bytes: size - 1})); const result = success([info]); const ack_size = Buffer.byteLength(JSON.stringify(result)); validate_node_ack('get_context_info', result, 2, {...limits, max_payload_bytes: ack_size}); rejected(() => validate_node_ack('get_context_info', result, 2, {...limits, max_payload_bytes: ack_size - 1})); }); test('inventory info alone accepts deep NBT and large JSON within dedicated complexity limits', () => { const info = {name: 'inventory', i18n: {lang: 'en', name: 'Inventory', description: ''}, value: null as unknown}; const update = {forward_session_id: uuid, bot_id: 2, context_generation: uuid, stream_id: other_uuid, seq: 1, emitted_at: now, data: {name: 'inventory', value: null as unknown}}; const validate = (value: unknown) => { validate_info_update({...update, data: {...update.data, value}}, DEFAULT_LIMITS); validate_node_ack('get_context_info', success([{...info, value}]), 2, DEFAULT_LIMITS); }; let nbt: unknown = {text: 'deep'}; for (let index = 0; index < 40; index++) nbt = {type: 'compound', value: nbt}; const inventory = {slots: Array.from({length: 46}, (_, slot) => ({slot, nbt, components: '\u754c'.repeat(2048)}))}; assert.ok(Buffer.byteLength(JSON.stringify(inventory)) > 192 * 1024); validate(inventory); for (const value of [Array(65_536).fill(0), Object.fromEntries(Array.from({length: 65_536}, (_, index) => [String(index), 0])), Array.from({length: 4}, () => Array(60_000).fill(0))]) validate(value); for (const value of [Array.from({length: 4}, () => Array(65_000).fill(0)), Object.fromEntries(Array.from({length: 65_537}, (_, index) => [String(index), 0]))]) { rejected(() => validate(value)); } let nested: unknown = 0; for (let index = 0; index < 65; index++) nested = {nested}; validate(nested); // ACK value starts at depth 3, reaching the depth-68 boundary. rejected(() => validate_node_ack('get_context_info', success([{...info, value: {nested}}]), 2, DEFAULT_LIMITS)); validate_info_update({...update, data: {...update.data, value: {nested}}}, DEFAULT_LIMITS); rejected(() => validate_info_update({...update, data: {...update.data, value: {nested: {nested}}}}, DEFAULT_LIMITS)); rejected(() => check_size(inventory, DEFAULT_LIMITS.max_payload_bytes)); rejected(() => validate_event(event('message', {text: '', position: 'chat', json: nbt}), DEFAULT_LIMITS)); rejected(() => validate_node_ack('bot.chat', success({accepted: true, nested: nbt}), 2, DEFAULT_LIMITS)); rejected(() => validate_request(request('get_context_info', {nested: nbt}), DEFAULT_LIMITS)); }); test('Node success ACKs enforce method-specific exact DTOs and expected bot', () => { const snapshot = {bot_id: 2, bot_status: 'offline', context_exists: false, context_generation: null as string | null, stream_id: uuid, seq: 0, captured_at: now}; validate_node_ack('bot.snapshot', success(snapshot), 2, limits); validate_node_ack('bot.logs', success({stream_id: uuid, seq: 1, records: [record()], truncated: false}), 2, limits); validate_node_ack('bot.chat', success({accepted: true}), 2, limits); validate_node_ack('bot.subscribe', success({events: ['log']}), 2, limits); validate_node_ack('bot.chat', error_ack(new RelayError('NO_CONTEXT', 'No context')), 2, limits); rejected(() => validate_node_ack('bot.snapshot', success(snapshot), 3, limits)); for (const data of [true, {}, {accepted: false}, {accepted: true, token: 'secret'}, snapshot]) { rejected(() => validate_node_ack('bot.chat', success(data), 2, limits)); } rejected(() => validate_node_ack('bot.snapshot', success({...snapshot, config: {password: 'secret'}}), 2, limits)); rejected(() => validate_node_ack('bot.snapshot', success({...snapshot, seq: Number.MAX_SAFE_INTEGER + 1}), 2, limits)); rejected(() => validate_node_ack('bot.snapshot', success({...snapshot, bot_status: 'online'}), 2, limits)); rejected(() => validate_node_ack('bot.snapshot', success({...snapshot, context_generation: uuid}), 2, limits)); rejected(() => validate_node_ack('bot.chat', {status: 'error', data: {code: 'NO_CONTEXT', message: 'error', retryable: false, stack: 'secret'}}, 2, limits)); rejected(() => validate_node_ack('bot.chat', {status: 'error', data: {code: 'UNKNOWN', message: 'error', retryable: false}}, 2, limits)); }); test('logs result record count cannot exceed 200 even inside the byte budget', () => { const records = Array.from({length: 200}, (_, index) => ({...record(), id: `a89acd9a-0ab3-44ac-812e-${index.toString(16).padStart(12, '0')}`, seq: index + 1})); const result = {stream_id: uuid, seq: 200, records, truncated: false}; validate_node_ack('bot.logs', success(result), 2, limits); rejected(() => validate_node_ack('bot.logs', success({...result, records: [...records, record()]}), 2, limits)); rejected(() => validate_event(event('log', {records: [...records, record()]}), limits)); rejected(() => validate_node_ack('bot.logs', success({...result, seq: 199}), 2, limits)); rejected(() => validate_node_ack('bot.logs', success({...result, records: [records[1], records[0]]}), 2, limits)); rejected(() => validate_node_ack('bot.logs', success({...result, records: [records[0], records[0]]}), 2, limits)); }); test('control DTOs validate scope, permissions, audience and bounded leases', () => { const {forward_id, forward_session_id, node_session_id, node_id, bot_id} = claims(); const scope = {forward_id, forward_session_id, node_session_id, node_id, bot_id}; validate_scope(scope); const open = {...scope, lease_expires_at: Date.now() + 60_000}; validate_open(open, 0, limits); rejected(() => validate_open(open, 1, limits)); rejected(() => validate_open({...open, key: 'secret'}, 0, limits)); rejected(() => validate_open({...open, lease_expires_at: Date.now() + limits.max_lease_ms + 5000}, 0, limits)); rejected(() => validate_open({...open, lease_expires_at: Date.now() - 5000}, 0, limits)); const grant = {forward_session_id, grant_id: uuid, user_id: 3, permissions: ['bot.snapshot'], expires_at: Date.now() + 60_000}; validate_grant(grant, limits); rejected(() => validate_grant({...grant, user_id: 1.5}, limits)); rejected(() => validate_grant({...grant, permissions: ['bot.snapshot', 'bot.snapshot']}, limits)); rejected(() => validate_grant({...grant, key: 'secret'}, limits)); }); test('file requests have exact payloads and a dedicated bot.files permission', () => { check_permissions(['bot.files']); assert.deepEqual(verify(claims({permissions: ['bot.files']})).permissions, ['bot.files']); validate_request(request('get_data_file_list', {}), limits); for (const payload of [{id: uuid}, {id: uuid, offset: 0, length: 1}, {id: uuid, offset: Number.MAX_SAFE_INTEGER, length: 262_144}]) { validate_request(request('get_file_data', payload), limits); } for (const payload of [null, [], {id: uuid}] as unknown[]) rejected(() => validate_request(request('get_data_file_list', payload), limits)); for (const payload of [{}, {id: 'file'}, {id: uuid, path: 'secret'}, {id: uuid, offset: undefined}, ...[-1, 0.5, Number.MAX_SAFE_INTEGER + 1, '0', null].map(offset => ({id: uuid, offset})), ...[0, -1, 0.5, 262_145, '1', null].map(length => ({id: uuid, length}))]) { rejected(() => validate_request(request('get_file_data', payload), limits)); } rejected(() => validate_request(request('bot.files', {}), limits)); rejected(() => check_permissions(['get_file_data'])); rejected(() => check_permissions(['get_data_file_list'])); }); test('file listings bound exact recursive trees, JSON bytes, depth and total nodes', () => { const file = {type: 'file', name: 'data.bin', id: uuid, size: Number.MAX_SAFE_INTEGER, modified_at: 0}; const listing = {listing_id: uuid, expires_at: 1, partial: false, children: [file] as unknown[]}; const validate = (data: unknown) => validate_node_ack('get_data_file_list', success(data), 2, DEFAULT_LIMITS); validate(listing); validate({...listing, children: []}); validate({...listing, children: Array(2000).fill(file)}); let deep: unknown = file; for (let index = 0; index < 31; index++) deep = {type: 'directory', name: 'dir', children: [deep]}; validate({...listing, children: [deep]}); rejected(() => check_size(success({...listing, children: [deep]}), DEFAULT_LIMITS.max_payload_bytes)); rejected(() => validate({...listing, children: [{type: 'directory', name: 'dir', children: [deep]}]})); for (const data of [{...listing, children: Array(2001).fill(file)}, {...listing, listing_id: 'id'}, {...listing, expires_at: 0}, {...listing, partial: 1}, {...listing, extra: true}, {...listing, children: {}}, {...listing, children: [{...file, size: -1}]}, {...listing, children: [{...file, modified_at: Number.MAX_SAFE_INTEGER + 1}]}, {...listing, children: [{...file, path: 'secret'}]}, {...listing, children: [{type: 'directory', name: 'dir'}]}, {...listing, children: [{type: 'directory', name: 1, children: []}]}]) rejected(() => validate(data)); const small_tree = [{...file, name: ''}]; const overhead = Buffer.byteLength(JSON.stringify(small_tree)); const children = [{...file, name: 'x'.repeat(512 * 1024 - overhead)}]; validate({...listing, children}); rejected(() => validate({...listing, children: [{...children[0], name: children[0].name + 'x'}]})); const cyclic: unknown[] = []; cyclic.push({type: 'directory', name: 'dir', children: cyclic}); let calls = 0; for (const children of [cyclic, Array(1), [{...file, get name() {calls++; return 'secret';}}]]) { rejected(() => validate({...listing, children})); } assert.equal(calls, 0); const bytes = Buffer.byteLength(JSON.stringify(success(listing))); validate_node_ack('get_data_file_list', success(listing), 2, {...limits, max_payload_bytes: bytes}); rejected(() => validate_node_ack('get_data_file_list', success(listing), 2, {...limits, max_payload_bytes: bytes - 1})); }); test('file ACKs enforce canonical base64, safe offsets, byte counts and EOF equivalence', () => { const chunk = {id: uuid, name: 'data.bin', encoding: 'base64', offset: 0, next_offset: 1, total_bytes: 2, eof: false, data: 'YQ=='}; const validate = (data: unknown) => validate_node_ack('get_file_data', success(data), 2, DEFAULT_LIMITS); validate(chunk); validate({...chunk, total_bytes: 1, eof: true}); validate({...chunk, offset: 2, next_offset: 2, total_bytes: 2, eof: true, data: ''}); validate({...chunk, next_offset: 0, total_bytes: 0, eof: true, data: ''}); const data = Buffer.alloc(262_144).toString('base64'); validate({...chunk, data, next_offset: 262_144, total_bytes: 262_144, eof: true}); for (const invalid of [{...chunk, id: 'id'}, {...chunk, name: 1}, {...chunk, encoding: 'utf8'}, {...chunk, offset: -1}, {...chunk, next_offset: 0}, {...chunk, total_bytes: 0}, {...chunk, total_bytes: Number.MAX_SAFE_INTEGER + 1}, {...chunk, next_offset: 0.5}, {...chunk, eof: true}, {...chunk, total_bytes: 1}, {...chunk, extra: true}, ...['YQ', 'YR==', 'YQ==\n', 'YQ===', ' YQ==', '_w==', '!!!!'].map(data => ({...chunk, data})), {...chunk, data: Buffer.alloc(262_145).toString('base64'), next_offset: 262_145, total_bytes: 262_145, eof: true}]) { rejected(() => validate(invalid)); } }); test('workflow permissions are capabilities distinct from RPC names for tokens and grants', () => { check_permissions([...RELAY_METHODS]); assert.deepEqual(verify(claims({permissions: ['workflow.control', 'bot.snapshot']})).permissions, ['workflow.control', 'bot.snapshot']); const grant = {forward_session_id: uuid, grant_id: other_uuid, user_id: 3, permissions: ['workflow.control'], expires_at: Date.now() + 60_000}; validate_grant(grant, limits); rejected(() => check_permissions(['workflow.control', 'workflow.control'])); for (const method of WORKFLOW_METHODS.filter(method => method !== 'workflow.control')) { rejected(() => check_permissions([method])); rejected(() => verify(claims({permissions: [method]})), 'UNAUTHORIZED'); rejected(() => validate_grant({...grant, permissions: [method]}, limits)); } }); test('workflow requests enforce exact targets, UUID context, positive generation and safe 64 KiB inputs', () => { const control = {command_id: uuid, context_generation: other_uuid, workflow_id: 'workflow-1', action: 'run_once'}; for (const action of WORKFLOW_ACTIONS) { const payload = action === 'stop_run' ? {command_id: uuid, context_generation: other_uuid, action, run_id: uuid} : {...control, action}; validate_request(request('workflow.control', payload), limits); if (action !== 'stop_run') validate_request(request('workflow.control', {...payload, expected_generation: 1}), limits); if (action === 'run_once') { validate_request(request('workflow.control', {...payload, input: {value: [1, null, true]}}), limits); validate_request(request('workflow.control', {...payload, expected_generation: 1, input: {value: [1, null, true]}}), limits); } for (const [field, value] of Object.entries({workflow_id: 'workflow-1', run_id: uuid, expected_generation: 1, input: {value: true}})) { const allowed = field === 'workflow_id' || field === 'expected_generation' ? action !== 'stop_run' : field === 'run_id' ? action === 'stop_run' : action === 'run_once'; if (!allowed) rejected(() => validate_request(request('workflow.control', {...payload, [field]: value}), limits)); } } for (const [method, payload] of [['workflow.start', {workflow_id: 'a'}], ['workflow.start', {workflow_id: 'a', trigger: {}}], ['workflow.stop', {run_id: uuid}], ['workflow.snapshot', {}], ['workflow.snapshot', {run_id: uuid}], ['workflow.snapshot', {run_id: uuid, include_branches: false}], ['workflow.snapshot', {run_id: uuid, include_branches: true}], ['workflow.catalog', {}], ['workflow.runtime', {}], ['workflow.command', {command_id: uuid, context_generation: other_uuid}]]) { validate_request(request(method as string, payload), limits); rejected(() => validate_request(request(method as string, {...payload as object, extra: true}), limits)); } for (const payload of [{...control, command_id: 'bad'}, {...control, context_generation: null}, {command_id: uuid, workflow_id: 'a', action: 'run_once'}, {...control, action: 'unknown'}, {...control, workflow_id: undefined}, {...control, action: 'stop_run'}, {...control, run_id: 'bad'}, ...[0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, null, '1'].map(expected_generation => ({...control, expected_generation})), ...['', 'a/b', 'a'.repeat(101), '__proto__', 'constructor', 'prototype'].map(workflow_id => ({...control, workflow_id}))]) { rejected(() => validate_request(request('workflow.control', payload), limits)); } for (const method of ['workflow.stop', 'workflow.snapshot']) { rejected(() => validate_request(request(method, {run_id: 'bad'}), limits)); } for (const include_branches of [false, true]) { rejected(() => validate_request(request('workflow.snapshot', {include_branches}), limits)); rejected(() => validate_request(request('workflow.snapshot', {run_id: 'bad', include_branches}), limits)); rejected(() => validate_request(request('workflow.stop', {run_id: uuid, include_branches}), limits)); } for (const include_branches of [null, undefined, 0, 1, 'false', 'true', [], {}]) { rejected(() => validate_request(request('workflow.snapshot', {run_id: uuid, include_branches}), limits)); } rejected(() => validate_request(request('workflow.stop', {}), limits)); rejected(() => validate_request(request('workflow.command', {command_id: uuid}), limits)); const input = {value: 'x'.repeat(64 * 1024 - Buffer.byteLength(JSON.stringify({value: ''})))}; validate_request(request('workflow.control', {...control, input}), limits); validate_request(request('workflow.start', {workflow_id: 'a', trigger: input}), limits); rejected(() => validate_request(request('workflow.control', {...control, input: {value: input.value + 'x'}}), limits)); const cyclic: Record = {}; cyclic.self = cyclic; let calls = 0; for (const input of [null, [], {value: 'x'.repeat(64 * 1024)}, {value: undefined}, {value: NaN}, cyclic, {get value() {calls++; return 1;}}, JSON.parse('{"nested":{"__proto__":1}}'), JSON.parse('{"constructor":1}'), {value: new Date()}, {value: Array(1)}]) { rejected(() => validate_request(request('workflow.control', {...control, input}), limits)); rejected(() => validate_request(request('workflow.start', {workflow_id: 'a', trigger: input}), limits)); } assert.equal(calls, 0); for (const field of ['method', 'payload']) { const value = Object.defineProperty(request('workflow.control', control), field, {enumerable: true, get() {calls++; return control;}}); rejected(() => validate_request(value, limits)); } assert.equal(calls, 0); const value = request('workflow.control', control); const bytes = Buffer.byteLength(JSON.stringify(value)); validate_request(value, {...limits, max_payload_bytes: bytes}); rejected(() => validate_request(value, {...limits, max_payload_bytes: bytes - 1})); }); test('workflow ACKs validate start/stop, opaque bounded catalog, run output and runtime commands', () => { const command = {command_id: uuid, action: 'run_once', workflow_id: 'a', state: 'completed'}; const state: WorkflowRuntimeState = {workflow_id: 'a', generation: 1, enabled: true, start_type: 'timer', trigger_state: 'scheduled', accepted_count: 0, max_runs: null, next_fire_at: null, active_run_ids: [uuid], queued_count: 0, last_error: null}; const runtime = {source_config_revision: 0, workflows: [state], commands: [command]}; const run: WorkflowRunSnapshot = {run_id: uuid, workflow_id: 'a', status: 'running', steps: 0, active_nodes: ['n'], started_at: 0, ended_at: null, stop_reason: null, error: null}; const full = {...run, branches: [{trigger: {}, vars: {}, inputs: {}, retry_counts: {}, last_result: null as JsonValue, outputs: {n: {execution: 1, status: 'success', output: {value: [1, 'output']}}}}]}; const catalog = {catalog_version: 1, nodes: [{name: 'code', config: [] as JsonValue[], output: {type: 'json'}}], start_nodes: [] as JsonValue[], events: [] as JsonValue[], queries: [] as JsonValue[], common: {node_fields: [] as JsonValue[], policy_fields: [] as JsonValue[], result: {}, edge_input: {}, references: [] as JsonValue[]}}; for (const data of [{status: 'started', run_id: uuid}, {status: 'queued', request_id: uuid}, {status: 'skipped'}]) { validate_node_ack('workflow.start', success(data), 2, limits); validate_node_ack('workflow.control', success({...command, result: data}), 2, limits); } validate_node_ack('workflow.stop', success({stopped: true}), 2, limits); validate_node_ack('workflow.catalog', success(catalog), 2, limits); for (const data of [null, [], [run, full], run, full]) validate_node_ack('workflow.snapshot', success(data), 2, limits); const trigger: Record = {disabled: false, count: 0, empty: null, nested: {values: [false, 0, null, 'text']}}; const summary: WorkflowRunSnapshot = {...run, trigger_available: true}; const detail: WorkflowRunSnapshot = {...run, trigger, trigger_available: true, branches: []}; for (const data of [summary, [summary], detail, {...run, trigger}, {...full, trigger}, {...run, trigger: {}}, {...run, trigger: Object.create(null)}, {...run, trigger_available: false}]) { validate_node_ack('workflow.snapshot', success(data), 2, limits); } assert.equal(Object.hasOwn(summary, 'trigger'), false); assert.equal(Object.hasOwn(summary, 'branches'), false); assert.deepEqual(detail.trigger, trigger); const overhead = Buffer.byteLength(JSON.stringify({value: ''})); const bounded_trigger = {value: '\u754c'.repeat(Math.floor((65_536 - overhead) / 3)) + 'x'.repeat((65_536 - overhead) % 3)}; assert.equal(Buffer.byteLength(JSON.stringify(bounded_trigger), 'utf8'), 65_536); validate_node_ack('workflow.snapshot', success({...detail, trigger: bounded_trigger}), 2, limits); rejected(() => validate_node_ack('workflow.snapshot', success({...detail, trigger: {value: bounded_trigger.value + 'x'}}), 2, limits)); const cyclic: Record = {}; cyclic.self = cyclic; let calls = 0; for (const trigger of [null, [], 'trigger', false, 0, undefined, {value: NaN}, {value: Infinity}, {value: undefined}, {value: 1n}, {value: () => 1}, new Date(), Object.create({inherited: true}), cyclic, {nested: new Date()}, {nested: Object.create({inherited: true})}, {nested: Array(1)}, {nested: Object.setPrototypeOf([], null)}, JSON.parse('{"nested":{"__proto__":1}}'), JSON.parse('{"constructor":1}'), JSON.parse('{"nested":{"prototype":1}}'), {get value() {calls++; return 1;}}, Object.defineProperty({}, 'hidden', {value: true}), {[Symbol('key')]: true}]) { for (const data of [{...run, trigger}, [{...run, trigger}]]) { rejected(() => validate_node_ack('workflow.snapshot', success(data), 2, limits)); } } for (const trigger_available of [null, undefined, 0, 1, 'true', {}, []]) { rejected(() => validate_node_ack('workflow.snapshot', success({...run, trigger_available}), 2, limits)); } rejected(() => validate_node_ack('workflow.snapshot', success({...detail, trigger_available: false}), 2, limits)); for (const field of ['trigger', 'trigger_available']) { const data = Object.defineProperty({...run}, field, {enumerable: true, get() {calls++; return true;}}); rejected(() => validate_node_ack('workflow.snapshot', success(data), 2, limits)); } assert.equal(calls, 0); for (const data of [command, {...command, state: 'accepted'}, {...command, result: {stopped: true}}, {...command, result: {generation: 1}}, {...command, state: 'failed', error: {code: 'WORKFLOW_NOT_FOUND', message: 'Missing'}}]) { validate_node_ack('workflow.control', success(data), 2, limits); validate_node_ack('workflow.command', success(data), 2, limits); } validate_node_ack('workflow.command', success(null), 2, limits); validate_node_ack('workflow.runtime', success(runtime), 2, limits); const bytes = Buffer.byteLength(JSON.stringify(success(runtime))); validate_node_ack('workflow.runtime', success(runtime), 2, {...limits, max_payload_bytes: bytes}); rejected(() => validate_node_ack('workflow.runtime', success(runtime), 2, {...limits, max_payload_bytes: bytes - 1})); const catalog_bytes = Buffer.byteLength(JSON.stringify(catalog)); const bounded_catalog = {...catalog, nodes: [{...catalog.nodes[0], description: 'x'.repeat(100 * 1024 - catalog_bytes - 17)}]}; assert.equal(Buffer.byteLength(JSON.stringify(bounded_catalog)), 100 * 1024); validate_node_ack('workflow.catalog', success(bounded_catalog), 2, limits); rejected(() => validate_node_ack('workflow.catalog', success({...bounded_catalog, nodes: [{...bounded_catalog.nodes[0], description: bounded_catalog.nodes[0].description + 'x'}]}), 2, limits)); const full_bytes = Buffer.byteLength(JSON.stringify(success(full))); validate_node_ack('workflow.snapshot', success(full), 2, {...limits, max_payload_bytes: full_bytes}); rejected(() => validate_node_ack('workflow.snapshot', success(full), 2, {...limits, max_payload_bytes: full_bytes - 1})); for (const [method, data] of [ ['workflow.start', {status: 'started'}], ['workflow.start', {status: 'queued', request_id: 'bad'}], ['workflow.start', {status: 'other'}], ['workflow.stop', {stopped: false}], ['workflow.stop', {stopped: true, extra: true}], ['workflow.catalog', {...catalog, catalog_version: 2}], ['workflow.catalog', {...catalog, nodes: {}}], ['workflow.catalog', {...catalog, common: {}}], ['workflow.catalog', {...catalog, nodes: [{description: 'x'.repeat(100 * 1024)}]}], ['workflow.catalog', {...catalog, nodes: [JSON.parse('{"constructor":1}')]}], ['workflow.snapshot', {...run, run_id: 'bad'}], ['workflow.snapshot', {...run, status: 'unknown'}], ['workflow.snapshot', {...full, branches: [{}]}], ['workflow.snapshot', {...full, branches: [{...full.branches[0], vars: {bad: Infinity}}]}], ['workflow.snapshot', {...full, branches: [{...full.branches[0], outputs: {n: {status: 'invalid', output: null as JsonValue, execution: 1}}}]}], ['workflow.snapshot', {...full, branches: [{...full.branches[0], retry_counts: {n: -1}}]}], ['workflow.snapshot', {...full, branches: [{...full.branches[0], last_result: {status: 'success', output: true, node_id: 'n', extra: true}}]}], ['workflow.control', null], ['workflow.control', {...command, command_id: 'bad'}], ['workflow.control', {...command, action: 'invalid'}], ['workflow.control', {...command, result: {generation: 0}}], ['workflow.control', {...command, error: {code: 'bad', message: 'x'.repeat(1025)}}], ['workflow.runtime', {...runtime, source_config_revision: -1}], ['workflow.runtime', {...runtime, commands: Array(257).fill(command)}], ['workflow.runtime', {...runtime, workflows: [{...state, generation: 0}]}], ['workflow.runtime', {...runtime, workflows: [{...state, active_run_ids: ['bad']}]}], ] as const) rejected(() => validate_node_ack(method, success(data), 2, limits)); for (const method of WORKFLOW_METHODS) { validate_node_ack(method, error_ack(new RelayError('INVALID_REQUEST', 'Invalid workflow')), 2, limits); rejected(() => validate_node_ack(method, {status: 'error', data: {code: 'WORKFLOW_NOT_FOUND', message: 'bad', retryable: false}}, 2, limits)); } });