chore: initial commit

This commit is contained in:
2026-10-22 17:07:08 +08:00
commit 6ccb102d38
20 changed files with 5949 additions and 0 deletions
+260
View File
@@ -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();
+65
View File
@@ -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};
}
+614
View File
@@ -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);
}
+89
View File
@@ -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;
}
+107
View File
@@ -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;