chore: initial commit
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import * as readline from 'node:readline';
|
||||
import { EventEmitter } from 'node:events';
|
||||
type font_style = 'normal' | 'italic' | 'bold' | 'light' |'underline' | 'url';
|
||||
|
||||
interface log {
|
||||
message: any;
|
||||
font_color: string;
|
||||
font_style: font_style[];
|
||||
}
|
||||
|
||||
// 将 font_style 转为 ANSI 参数;normal 不产生参数
|
||||
function style_codes(font_style: font_style[]): number[] {
|
||||
const codes: number[] = [];
|
||||
for (const style of font_style ?? []) {
|
||||
switch (style) {
|
||||
case 'bold':
|
||||
codes.push(1);
|
||||
break;
|
||||
case 'light':
|
||||
codes.push(2);
|
||||
break;
|
||||
case 'italic':
|
||||
codes.push(3);
|
||||
break;
|
||||
case 'underline':
|
||||
case 'url':
|
||||
codes.push(4);
|
||||
break;
|
||||
case 'normal':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
// 解析 #rgb / #rrggbb,失败返回 null
|
||||
function parse_hex_color(color: string): [number, number, number] | null {
|
||||
if (typeof color !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const value = color.trim().replace(/^#/, '');
|
||||
if (/^[0-9a-fA-F]{3}$/.test(value)) {
|
||||
return [
|
||||
parseInt(value[0] + value[0], 16),
|
||||
parseInt(value[1] + value[1], 16),
|
||||
parseInt(value[2] + value[2], 16),
|
||||
];
|
||||
}
|
||||
if (/^[0-9a-fA-F]{6}$/.test(value)) {
|
||||
return [
|
||||
parseInt(value.slice(0, 2), 16),
|
||||
parseInt(value.slice(2, 4), 16),
|
||||
parseInt(value.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将一段 log 渲染为带颜色的字符串
|
||||
function to_ansi(segment: log): string {
|
||||
const message = segment.message === undefined
|
||||
? 'undefined'
|
||||
: typeof segment.message === 'string'
|
||||
? segment.message
|
||||
: String(segment.message);
|
||||
|
||||
const codes = style_codes(segment.font_style ?? []);
|
||||
const rgb = parse_hex_color(segment.font_color);
|
||||
if (rgb !== null) {
|
||||
codes.push(38, 2, rgb[0], rgb[1], rgb[2]);
|
||||
}
|
||||
if (codes.length === 0) {
|
||||
return message;
|
||||
}
|
||||
return `\x1b[${codes.join(';')}m${message}\x1b[0m`;
|
||||
}
|
||||
|
||||
export type console_segment = log | string | number | boolean | null | undefined;
|
||||
export type console_input = console_segment | console_segment[];
|
||||
|
||||
export interface _ConsolePrint {
|
||||
info(message: any): log;
|
||||
success(message: any): log;
|
||||
warning(message: any): log;
|
||||
error(message: any): log;
|
||||
tip(message: any): log;
|
||||
custom(message: any, font_color: string, font_style: font_style[]): log;
|
||||
log(input: console_input): void;
|
||||
on(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
once(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
enable_input(prompt?: string): void;
|
||||
disable_input(): void;
|
||||
set_prompt(prompt: string): void;
|
||||
}
|
||||
|
||||
export class ConsolePrint extends EventEmitter implements _ConsolePrint {
|
||||
private rl: readline.Interface | null = null;
|
||||
private prompt = '> ';
|
||||
private input_enabled = false;
|
||||
// 输入行提交后是否需要重新显示提示符(write_line 已恢复时置为 false)
|
||||
private need_prompt = false;
|
||||
|
||||
// 监听 input 时自动开启控制台输入
|
||||
public on(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
if (event === 'input') {
|
||||
this.enable_input();
|
||||
}
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
public once(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
if (event === 'input') {
|
||||
this.enable_input();
|
||||
}
|
||||
return super.once(event, listener);
|
||||
}
|
||||
|
||||
public info(message: any): log { // 白色
|
||||
return {
|
||||
message,
|
||||
font_color: '#ffffff',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public success(message: any): log { // 柔和绿
|
||||
return {
|
||||
message,
|
||||
font_color: '#8fb98f',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public warning(message: any): log { // 柔和沙黄
|
||||
return {
|
||||
message,
|
||||
font_color: '#d1b478',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public error(message: any): log { // 柔和玫红
|
||||
return {
|
||||
message,
|
||||
font_color: '#cf8e8e',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public tip(message: any): log { // 柔和钢蓝
|
||||
return {
|
||||
message,
|
||||
font_color: '#8fb0c9',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public custom(message: any, font_color: string, font_style: font_style[]): log {
|
||||
return {
|
||||
message,
|
||||
font_color,
|
||||
font_style
|
||||
};
|
||||
}
|
||||
|
||||
// 多段拼接后在同一行输出,每段可拥有独立的颜色与样式;支持单段或数组
|
||||
public log(input: console_input): void {
|
||||
const segments: console_segment[] = Array.isArray(input) ? input : [input];
|
||||
if (segments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const body = segments.map(segment => this.format(segment)).join('');
|
||||
this.write_line(this.format(this.timestamp()) + body);
|
||||
}
|
||||
|
||||
// 自动前缀:[month_day hh:mm:ss],统一使用 success 色
|
||||
private timestamp(): log {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hour = String(now.getHours()).padStart(2, '0');
|
||||
const minute = String(now.getMinutes()).padStart(2, '0');
|
||||
const second = String(now.getSeconds()).padStart(2, '0');
|
||||
return this.success(`[${month}-${day} ${hour}:${minute}:${second}] `);
|
||||
}
|
||||
|
||||
private format(segment: log | string | number | boolean | null | undefined): string {
|
||||
if (segment !== null && segment !== undefined && typeof segment === 'object' && 'message' in segment) {
|
||||
return to_ansi(segment as log);
|
||||
}
|
||||
return String(segment);
|
||||
}
|
||||
|
||||
// 有输入时先清掉当前输入行,打印日志后再把输入行恢复,避免输入被打断
|
||||
private write_line(text: string): void {
|
||||
if (this.rl !== null && this.input_enabled && process.stdout.isTTY === true) {
|
||||
readline.cursorTo(process.stdout, 0);
|
||||
readline.clearLine(process.stdout, 0);
|
||||
process.stdout.write(text + '\n');
|
||||
this.rl.prompt(true);
|
||||
this.need_prompt = false;
|
||||
return;
|
||||
}
|
||||
process.stdout.write(text + '\n');
|
||||
}
|
||||
|
||||
// 开启控制台输入,按回车后通过 'input' 事件抛出整行内容
|
||||
public enable_input(prompt?: string): void {
|
||||
if (prompt !== undefined) {
|
||||
this.prompt = prompt;
|
||||
}
|
||||
if (this.rl !== null) {
|
||||
this.rl.setPrompt(this.prompt);
|
||||
return;
|
||||
}
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: process.stdout.isTTY === true
|
||||
});
|
||||
this.rl.setPrompt(this.prompt);
|
||||
this.rl.on('line', (line: string) => {
|
||||
// 若输入处理期间没有输出日志,readline 已在换行后清空输入行,这里补回提示符;
|
||||
// 若期间输出过日志,write_line 已恢复提示符,避免重复
|
||||
this.need_prompt = true;
|
||||
this.emit('input', line);
|
||||
if (this.rl !== null && this.need_prompt) {
|
||||
this.rl.prompt();
|
||||
}
|
||||
this.need_prompt = false;
|
||||
});
|
||||
this.rl.on('close', () => {
|
||||
this.rl = null;
|
||||
this.input_enabled = false;
|
||||
});
|
||||
this.input_enabled = true;
|
||||
this.rl.prompt();
|
||||
}
|
||||
|
||||
public disable_input(): void {
|
||||
if (this.rl === null) {
|
||||
return;
|
||||
}
|
||||
const rl = this.rl;
|
||||
this.rl = null;
|
||||
this.input_enabled = false;
|
||||
rl.close();
|
||||
}
|
||||
|
||||
public set_prompt(prompt: string): void {
|
||||
this.prompt = prompt;
|
||||
this.rl?.setPrompt(prompt);
|
||||
if (this.rl !== null && this.input_enabled) {
|
||||
this.rl.prompt(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const console_print = new ConsolePrint();
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {ForwardServerOptions, RelayLimits} from '../types.ts';
|
||||
|
||||
export const DEFAULT_LIMITS: Readonly<RelayLimits> = Object.freeze({
|
||||
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: 5_000,
|
||||
max_lease_ms: 300_000,
|
||||
max_token_bytes: 8_192,
|
||||
max_payload_bytes: 8 * 1024 * 1024,
|
||||
max_chat_length: 256,
|
||||
max_connections: 2_000,
|
||||
max_sessions: 500,
|
||||
max_grants_per_session: 32,
|
||||
max_pending_per_web: 16,
|
||||
max_pending_total: 4_096,
|
||||
requests_per_second: 30,
|
||||
events_per_second: 200,
|
||||
max_dedupe_entries: 256,
|
||||
dedupe_ttl_ms: 30_000,
|
||||
max_buffered_packets: 128,
|
||||
});
|
||||
|
||||
export function resolve_options(options: ForwardServerOptions) {
|
||||
if (!options || !Number.isSafeInteger(options.forward_id) || options.forward_id < 0) {
|
||||
throw new Error('forward_id must be a nonnegative safe integer');
|
||||
}
|
||||
if (typeof options.key !== 'string' || options.key.trim().length < 16) {
|
||||
throw new Error('A relay secret of at least 16 characters is required');
|
||||
}
|
||||
if (!Array.isArray(options.allowed_origins) || options.allowed_origins.some(origin => {
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
return !['http:', 'https:'].includes(url.protocol) || url.origin !== origin;
|
||||
} catch { return true; }
|
||||
})) throw new Error('allowed_origins must contain explicit HTTP(S) origins');
|
||||
if (options.allow_no_origin !== undefined && typeof options.allow_no_origin !== 'boolean') {
|
||||
throw new Error('allow_no_origin must be a boolean');
|
||||
}
|
||||
const path = options.path ?? '/api/forward_server';
|
||||
const namespace = options.namespace ?? '/';
|
||||
if (!/^\/[A-Za-z0-9_/-]+$/.test(path) || path === '/' || path.endsWith('/')) {
|
||||
throw new Error('path must be an absolute Socket.IO path without a trailing slash');
|
||||
}
|
||||
if (!/^\/[A-Za-z0-9_/-]*$/.test(namespace) || (namespace !== '/' && namespace.endsWith('/'))) {
|
||||
throw new Error('namespace must be an absolute Socket.IO namespace');
|
||||
}
|
||||
const port = options.port ?? 38858;
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid relay port');
|
||||
if (options.http_server && (options.port !== undefined || options.host !== undefined)) {
|
||||
throw new Error('Borrowed HTTP server cannot specify a relay host or port');
|
||||
}
|
||||
const limits = {...DEFAULT_LIMITS, ...options.limits};
|
||||
for (const [name, value] of Object.entries(limits)) {
|
||||
if (!Object.hasOwn(DEFAULT_LIMITS, name) || !Number.isSafeInteger(value) || value < (name === 'clock_skew_ms' ? 0 : 1)
|
||||
|| value > 2_147_483_647) throw new Error(`Invalid relay limit: ${name}`);
|
||||
}
|
||||
if (limits.join_window_ms > limits.max_token_age_ms) throw new Error('Join window exceeds token lifetime');
|
||||
return {...options, allowed_origins: [...options.allowed_origins], path, namespace, port, limits,
|
||||
allow_no_origin: options.allow_no_origin ?? true};
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import {BOT_EVENTS, RELAY_METHODS, WORKFLOW_ACTIONS, WORKFLOW_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'): 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');
|
||||
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 : 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])
|
||||
|| (value.method !== 'bot.files' && RELAY_METHODS.includes(value.method as RelayMethod)), 'Invalid relay method');
|
||||
const payload = value.payload;
|
||||
switch (value.method) {
|
||||
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);
|
||||
}
|
||||
|
||||
export function validate_node_ack(method: RelayRequest['method'], value: unknown, bot_id: number, limits: RelayLimits): 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');
|
||||
require_valid((method === 'get_log_history' || method === 'tab_complete' || method === 'get_context_info'
|
||||
|| method === 'get_data_file_list' || method === 'get_file_data'
|
||||
|| 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 '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);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import type {RelayClaims, RelayLimits} from '../types.ts';
|
||||
import {check_permissions, check_size, fail, is_record, is_uuid, validate_scope} from './relay_validation.ts';
|
||||
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
function derive_key(secret: string): Buffer {
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
export function encode_token(secret: string, data: unknown): string {
|
||||
const key = derive_key(secret);
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const plain = Buffer.from(JSON.stringify(data), 'utf8');
|
||||
const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, ciphertext]).toString('base64url');
|
||||
}
|
||||
|
||||
export function decode_token<T = unknown>(secret: string, token: string): T {
|
||||
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]+$/.test(token) || token.length % 4 === 1) {
|
||||
throw new Error('invalid token');
|
||||
}
|
||||
const key = derive_key(secret);
|
||||
const raw = Buffer.from(token, 'base64url');
|
||||
if (raw.length <= IV_LEN + TAG_LEN || raw.toString('base64url') !== token) throw new Error('invalid token');
|
||||
const iv = raw.subarray(0, IV_LEN);
|
||||
const tag = raw.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const ciphertext = raw.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(plain)) as T;
|
||||
}
|
||||
|
||||
export function verify_token(secret: string, token: string, forward_id: number, limits: RelayLimits, now = Date.now()): RelayClaims {
|
||||
const safe_time = (value: unknown): value is number => typeof value === 'number'
|
||||
&& Number.isSafeInteger(value) && value >= 0;
|
||||
if (!safe_time(now) || !safe_time(forward_id)
|
||||
|| !safe_time(limits.max_token_bytes) || limits.max_token_bytes === 0
|
||||
|| !safe_time(limits.max_token_age_ms) || limits.max_token_age_ms === 0
|
||||
|| !safe_time(limits.join_window_ms) || limits.join_window_ms === 0
|
||||
|| !safe_time(limits.clock_skew_ms)) {
|
||||
fail('UNAUTHORIZED', 'Invalid token verification limits');
|
||||
}
|
||||
if (typeof token !== 'string' || token.length > limits.max_token_bytes
|
||||
|| !/^[A-Za-z0-9_-]+$/.test(token)) {
|
||||
fail('UNAUTHORIZED', 'Invalid relay token');
|
||||
}
|
||||
let claims: unknown;
|
||||
try {
|
||||
claims = decode_token(secret, token);
|
||||
check_size(claims, limits.max_token_bytes);
|
||||
if (!is_record(claims)) throw new Error('Invalid claims');
|
||||
const fields = claims;
|
||||
const required = ['v', 'forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id',
|
||||
'role', 'permissions', 'issued_at', 'join_before', 'expires_at', 'jti'];
|
||||
const allowed = [...required, 'user_id', 'grant_id'];
|
||||
if (!required.every(key => Object.hasOwn(fields, key))
|
||||
|| !Object.keys(fields).every(key => allowed.includes(key))) throw new Error('Invalid claims');
|
||||
validate_scope(claims);
|
||||
check_permissions(claims.permissions);
|
||||
if (claims.v !== 1 || claims.forward_id !== forward_id || !is_uuid(claims.jti)
|
||||
|| (claims.role !== 'node' && claims.role !== 'web')) throw new Error('Invalid claims');
|
||||
if (claims.role === 'web') {
|
||||
if (!safe_time(claims.user_id) || claims.user_id === 0 || !is_uuid(claims.grant_id)) {
|
||||
throw new Error('Invalid web claims');
|
||||
}
|
||||
} else if (Object.hasOwn(claims, 'user_id') || Object.hasOwn(claims, 'grant_id')) {
|
||||
throw new Error('Invalid node claims');
|
||||
}
|
||||
if (!safe_time(claims.issued_at) || !safe_time(claims.join_before) || !safe_time(claims.expires_at)
|
||||
|| claims.issued_at >= claims.join_before || claims.join_before > claims.expires_at
|
||||
|| claims.expires_at - claims.issued_at > limits.max_token_age_ms
|
||||
|| claims.join_before - claims.issued_at > limits.join_window_ms
|
||||
|| claims.issued_at - now > limits.clock_skew_ms) throw new Error('Invalid token times');
|
||||
} catch {
|
||||
fail('UNAUTHORIZED', 'Invalid relay token');
|
||||
}
|
||||
// Persisted session and grant authorization is deliberately checked by the store.
|
||||
const verified = claims as unknown as RelayClaims;
|
||||
if (now - verified.expires_at >= limits.clock_skew_ms
|
||||
|| now - verified.join_before >= limits.clock_skew_ms) {
|
||||
fail('TOKEN_EXPIRED', 'Relay token expired');
|
||||
}
|
||||
return verified;
|
||||
}
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
// 校验错误码映射;每个分组的 ok 均为 0,表示校验通过
|
||||
export const validate_code_map = {
|
||||
username: {
|
||||
ok: 0,
|
||||
over_length: 400,
|
||||
too_short: 401,
|
||||
invalid_char: 402,
|
||||
invalid_type: 403,
|
||||
empty: 404,
|
||||
},
|
||||
host: {
|
||||
ok: 0,
|
||||
invalid_type: 410,
|
||||
empty: 411,
|
||||
over_length: 412,
|
||||
invalid_ip: 413,
|
||||
invalid_format: 414,
|
||||
},
|
||||
port: {
|
||||
ok: 0,
|
||||
invalid_type: 420,
|
||||
not_integer: 421,
|
||||
not_number: 422,
|
||||
out_of_range: 423,
|
||||
},
|
||||
version: {
|
||||
ok: 0,
|
||||
invalid_type: 430,
|
||||
empty: 431,
|
||||
unsupported: 432,
|
||||
},
|
||||
proxy: {
|
||||
ok: 0,
|
||||
invalid_type: 440,
|
||||
missing_type: 441,
|
||||
invalid_type_value: 442,
|
||||
missing_host: 443,
|
||||
invalid_host: 444,
|
||||
missing_port: 445,
|
||||
invalid_port: 446,
|
||||
invalid_username: 447,
|
||||
invalid_password: 448,
|
||||
},
|
||||
bot_status: {
|
||||
online: 0,
|
||||
offline: 449,
|
||||
connecting: 450
|
||||
},
|
||||
bot_context: {
|
||||
ok: 0,
|
||||
instance_created: 460,
|
||||
instance_end_by_user: 461,
|
||||
instance_connected: 462,
|
||||
instance_connecting: 463,
|
||||
context_created: 464,
|
||||
processing: 465,
|
||||
error: 466,
|
||||
},
|
||||
forward: {
|
||||
ok: 0,
|
||||
invalid_token: 470,
|
||||
missing_key: 471,
|
||||
},
|
||||
web: {
|
||||
ok: 0,
|
||||
database_error: 480,
|
||||
method_not_allowed: 481,
|
||||
invalid_json: 482,
|
||||
missing_parameter: 483,
|
||||
invalid_parameter: 484,
|
||||
not_found: 485,
|
||||
internal_server_error: 486,
|
||||
spam: 487,
|
||||
timeout: 488,
|
||||
account: {
|
||||
ok: 0,
|
||||
invalid_username: 489,
|
||||
invalid_password: 490,
|
||||
invalid_register_code: 491,
|
||||
invalid_email: 492,
|
||||
invalid_code: 493,
|
||||
invalid_minecraft_username: 494,
|
||||
same_username: 495,
|
||||
same_email: 496,
|
||||
same_minecraft_username: 497,
|
||||
not_login: 498,
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
|
||||
type value_of<T> = T[keyof T];
|
||||
|
||||
export type validate_username_code = value_of<typeof validate_code_map.username>;
|
||||
export type validate_host_code = value_of<typeof validate_code_map.host>;
|
||||
export type validate_port_code = value_of<typeof validate_code_map.port>;
|
||||
export type validate_version_code = value_of<typeof validate_code_map.version>;
|
||||
export type validate_proxy_code = value_of<typeof validate_code_map.proxy>;
|
||||
export type bot_status_code = value_of<typeof validate_code_map.bot_status>;
|
||||
|
||||
// 所有校验错误码的联合类型
|
||||
export type validate_code =
|
||||
| validate_username_code
|
||||
| validate_host_code
|
||||
| validate_port_code
|
||||
| validate_version_code
|
||||
| validate_proxy_code
|
||||
| bot_status_code;
|
||||
Reference in New Issue
Block a user