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