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
+28
View File
@@ -0,0 +1,28 @@
# Dependencies and generated output
node_modules/
dist/
coverage/
*.tsbuildinfo
# Legacy compiled copies of TypeScript sources
/index.js
/types.js
/utils/token_utils.js
/utils/validate_code_map.js
# Local configuration and logs
.env
.env.*
!.env.example
!.env.sample
*.local
logs/
*.log
# Local tooling and editor files
.idea/
.codex/
.design-review/
.DS_Store
*.swp
*.swo
+247
View File
@@ -0,0 +1,247 @@
# Main 转发通道接入方案
本文对应当前 `forward_server` 实现的接入契约。本文只描述 Main 的后续修改,不能视为 Main、Node 或 Web 已完成接入。各项目在自己的目录维护协议类型和校验,不跨目录导入生产协议源码。
## 1. 已实现的转发服务接口
启动接口已升级为配置对象,不再支持旧的位置参数:
```ts
const relay = await start_forward_server({
forward_id: 0,
key: config.forward_secret_key,
http_server,
path: '/api/forward_server',
namespace: '/',
allowed_origins: config.forward_allowed_origins,
allow_no_origin: true,
control: authenticated_control,
logger: console_print,
});
```
`forward_id` 为非负安全整数,内置服务为 `0`。`node_id/bot_id/user_id` 为正安全整数。密钥至少 16 字符,不允许已知默认值兜底。允许的 Origin 必须是准确的 HTTP(S) Origin,不能填写 `*` 或包含路径。Node 可以不发送 Origin,但仍必须完成 token 和会话鉴权。
借用 `http_server` 时不能再指定 `host/port`;自建服务使用 `host/port`。返回的 handle 包含 `io/http_server/store/forward_id/path/namespace/owns_http_server`、`bind_control(transport)` 和幂等的 `stop()`。Main 保留 handle,在进程退出时显式停止;转发服务的关闭不会关闭借用的主站 HTTP server。
关闭默认最多等待 5 秒,可用 `limits.close_timeout_ms` 调整;超时强制释放自有 TCP 连接并返回失败,仍复位实例状态。借用宿主的 TCP 连接不归 Forward 强制销毁。
重复相同配置启动返回同一实例;配置变化必须先停止再启动。控制重连通过 `bind_control()` 重新安装处理器,不能通过重复启动来隐式换控制连接。旧的 disposer 不影响新控制绑定。
控制 transport 只要求 `on/off/emit`,支持 Main 内置 EventEmitter、Main 服务端 Socket 及 Node 客户端 Socket。数据 Socket 无法调用控制命令。
## 2. 控制通道 wire 契约
所有时间均为 Unix 毫秒,UUID 使用 `crypto.randomUUID()`。`node_session_id` 是当前已认证 Node 连接标识,允许非空、最大 256 UTF-8 字节字符串,不要求 UUID。Main 当前内部保存的 `socket.id` 可以作为该值,但必须在 Node 注册成功 ACK 中显式下发。
```ts
type ForwardAck<T> =
| {status: 'success'; data: T}
| {status: 'error'; data: {
code: ForwardErrorCode;
message: string;
retryable: boolean;
}};
interface SessionScope {
forward_id: number;
forward_session_id: string;
node_session_id: string;
node_id: number;
bot_id: number;
}
```
输入 DTO 为精确字段白名单,不能附加数据库实体、内部配置或其他字段。
| 事件 | 请求 | 成功 data |
| --- | --- | --- |
| `forward_open_session` | `SessionScope & {lease_expires_at:number}` | 实际 scope 与 `lease_expires_at` |
| `forward_authorize_grant` | `{forward_session_id,grant_id,user_id,permissions,expires_at}` | 同结构的实际授权信息 |
| `forward_revoke_session` | `{forward_session_id,reason}` | `{forward_session_id,revoked:true}` |
| `forward_revoke_grant` | `{forward_session_id,grant_id,reason}` | `{forward_session_id,grant_id,revoked:true}` |
同一 session 的 scope 不可改变,同一 Node/Bot 不能同时登记不同 session;先撤销旧会话,再建立新会话。同一 grant 的用户和权限不可变,权限改变时撤销后分配新 UUID。续期使用原身份和权限,延长时间。撤销消息幂等,reason 为非空、最大 256 UTF-8 字节字符串。
Forward 在本进程中保留有界撤销记录,期限为最大 token 寿命加时钟偏差,防止迟到的 open/authorize 复活已撤销身份。未过期撤销记录计入 session/grant 容量,达到限制返回 RATE_LIMITED,不提前逐出撤销记录。Main 收到容量限制时应退避,不能紧循环分配新 UUID。
默认 session 租约上限为当前时间后 5 分钟;grant 到期不得超过 session。Main 应统一计算截止时间,给网络传输保留余量,不能在远端处理时已经过期。撤销的 UUID 不得重新使用。
`forward_session_status` 为 Forward 向 Main 的单向通知,字段为:
```ts
interface ForwardSessionStatus extends SessionScope {
node_ready: boolean;
reason: string;
state: 'open' | 'ready' | 'unavailable' | 'closed';
reported_at: number;
}
```
Main 只接受当前转发控制连接的报告,再核对本地 session 的完整 scope。`closed` 立即使本地会话不可复用,`unavailable` 表示转发 Node 未就绪,不覆盖 Bot 游戏状态。通知没有 ACK;申请成功仍须依靠有截止时间的命令 ACK,不能依赖通知到达时序。
Socket.IO 控制请求使用带超时的单 Socket ACK。EventEmitter 适配器使用自己的定时器和一次性回调,不能调用 `.timeout()` 或 `.disconnect()`。禁止将旧的数字 ACK 解析器用于本协议。
## 3. Token 签发契约
Main 在本项目的 token 工具中实现签发,格式保持 AES-256-GCM:`base64url(IV[12] + tag[16] + ciphertext)`,密钥为共享 secret 的 SHA-256。使用随机 IV,JSON 使用 UTF-8,不输出密钥或 token 到日志。
```ts
interface RelayClaims extends SessionScope {
v: 1;
role: 'node' | 'web';
user_id?: number;
grant_id?: string;
permissions: RelayMethod[];
issued_at: number;
join_before: number;
expires_at: number;
jti: string;
}
```
Node token 不带 `user_id/grant_id`;Web token 必须同时带这两个字段。`jti/forward_session_id/grant_id` 为 UUID。权限不重复,只能取 `bot.snapshot/bot.logs/bot.subscribe/bot.unsubscribe/bot.chat`。Web 权限不得超出 Main 已登记的 grant,Node 权限应覆盖它需要执行的 RPC。
事件读取权限为:`log` 需要 `bot.subscribe` 与 `bot.logs`;`status_update/message/bot_error` 需要 `bot.subscribe` 与 `bot.snapshot`。取消订阅需要 `bot.unsubscribe`。首版完整交互可授予上述五个方法,纯只读页面不能包含 `bot.chat`。订阅默认空,重连后 Web 必须重新设置。
默认 `issued_at < join_before <= expires_at`,token 寿命不超过 5 分钟,入房或 reauth 窗口不超过 60 秒,时钟偏差 5 秒。凭证文本最大 8192 字节。建议到期前 60 秒续期,重连超过入房窗口时需获取新 token,不能因为旧 token 尚未到 expires_at 就直接重入。
Main 与 Forward 使用相同合法/非法测试样例和无生产密钥的固定测试向量。不能只用各自加解密往返测试判断跨服务兼容。
固定向量位于 `tests/token.test.ts` 的首个测试,密钥 `relay-test-key-not-a-production-secret`、IV 十六进制 `000102030405060708090a0b`、验证时间 `1800000000000`,包含完整预期 token 文本和 claims。该固定 IV 仅用于测试;生产签发必须使用随机 IV。
## 4. Main 文件修改安排
| 文件或模块 | 后续工作 |
| --- | --- |
| `index.ts` | 使用新 options,内置 ID 为 0,保留 handle,显式 stop;删除默认密钥兜底 |
| `runtime/forward_server/socket.ts` | 健康注册表、当前控制连接 generation、disposer、控制请求适配器 |
| `runtime/forward_server/event_handler/` | 接收并校验 `forward_session_status`,只接受当前连接 |
| `router/node/main_socket.ts` | Node 注册成功 ACK 下发 `node_session_id` |
| `runtime/node/socket.ts` | 提供当前 Node 会话查询、替换/移除撤销 hook |
| `service/forward/` 或现有等效目录 | session/grant 存储、申请合并、授权、续期、撤销、定时回收 |
| `router/node/action.ts` | 修正原缺少 `/` 的路由,接入申请/续期/释放服务 |
| `types/`、`utils/` | 本地协议类型、严格 ACK 校验、token 签发、HTTP 错误映射 |
| `database/forward/init.ts` 与配置 | endpoint 字段和安全回填、内置服务解析规则 |
| 已有删除/迁移/退出/权限入口 | 调用 grant/session 撤销,不复制 Bot 生命周期 |
`main_server/forward_server`、`node_server/forward_server` 均为 `../forward_server` 符号链接。Main 不再修改另一份 relay 副本,Node 入口调用点也必须同步升级新启动参数;本次没有替 Main/Node 修改这些入口。
## 5. Main 内存模型
Session 以 `(forward_id,node_id,bot_id,node_session_id)` 查找,分配独立 `forward_session_id`,记录:当前 relay 注册 generation、Node Socket 身份、Bot config_revision/资源分配、租约、建立 Promise、等待者及 ready/closed 状态。
Grant 以随机 `grant_id` 查找,记录:session、user_id、HTTP `sessionID`、权限、期限和撤销状态。不同标签页独立 grant;同一 grant 在 Forward 只保留一个当前 Web Socket。
多个 HTTP 申请合并 session 建立任务,各自保持截止时间和取消状态。一个 HTTP 超时不能关闭其他等待者或有效 grant 仍使用的通道。没有成功返回凭证的申请不保留永久 grant,迟到 Node ACK 不产生 HTTP 成功。
转发服务重启会丢失授权。Main 必须先重新登记 session/grant,再下发新 token 让 Node/Web 恢复;Node 会话或资源分配变化则建立新 UUID,不能恢复旧 scope。Main 自身重启不假装恢复旧内存授权。
## 6. 初次申请流程
接口:`POST /api/user/node/forward_bot`,body 精确为 `{bot_id}`。
1. 验证登录会话、活跃账号、正安全整数 Bot ID。
2. 使用 `query_bot_info(user_id,bot_id)` 确认归属,保存 Node/team/config_revision。
3. 复用资源授权逻辑确认团队、Node 与 Forward 权限。现有 `authorize_bot_resources(user,bot.node_id,0,bot.team_id)` 可作为提取入口,`proxy_id=0` 跳过与转发无关的代理检查。核对团队 allocation 和成员限额。
4. 获取当前可命令 Node Socket 及 node_session_id,不能仅查看缓存 ONLINE。
5. 获取当前健康 Forward 控制连接及部署 endpoint,捕获注册 generation。
6. 查找或合并 session 建立,控制请求 `forward_open_session` 成功后签发 Node token。
7. 通过当前 Node Socket 发送原计划的 `forward_bot` 命令,等待完整 ready ACK。
8. 再次核对当前 Node、Forward 控制连接、Bot 归属、资源分配及配置版本;变化则拒绝并清理本申请。
9. 创建独立 grant,经 `forward_authorize_grant` ACK 成功后签发 Web token。
10. 显式投影公开 DTO 返回,不返回实体、密钥、Node token 或 node_url。
不检查 Bot 游戏状态、Minecraft 版本、启动队列或代理可用性,不调用 `prepare_bot_resources/start_bot_context`,不把申请写入离线停止队列。
公开 DTO 保持原计划的 `{bot_id,node_id,forward_id,forward_session_id,grant_id,url,path,namespace,token,expires_at}`。url 使用 public_url;token 只能是 Web 角色。
## 7. Node 接入前置条件
Main 的 `forward_bot` 成功 ACK 必须表示 Node 已完成:连接 Forward、`join_room` ACK、安装 RPC 与上下文观察、`forward_node_ready` ACK。新增数据事件:
```ts
// Node -> Forward
forward_node_ready({forward_session_id}, callback);
// callback: ForwardAck<{node_ready:true}>
```
`join_room/reauth` 只收 `{token}`,成功 data 为完整 SessionScope 加 `role/node_ready/expires_at`。Node 入房后 initially node_ready=false,需要显式确认。
Forward 向 Node 定向发送的 `relay_request` 在原请求上增加 `correlation_id/forward_session_id/deadline_at`。Node 通过同一个 Socket.IO ACK 返回 `ForwardAck`;chat 成功 data 为 `{accepted:true}`,snapshot/logs 按 `forward_server/types.ts` 的 DTO 本地同步。subscribe/unsubscribe 由 Forward 本地处理。
无 BotContext 的 snapshot/status 必须为 `offline/context_exists:false/context_generation:null`;存在上下文时 generation 为 UUID。日志按 seq 递增、ID 唯一且不得超过读取/事件水位,响应数量不得超过请求 limit。所有 DTO 使用准确字段,serializer 不附带原始 Bot、配置或堆栈。
Node/Web 接收 `forward_session_closed {forward_session_id?,reason,code}` 后按原因停止旧授权重试。未入房连接被关闭时可能没有 session ID。`relay_status.reason === 'resync_required'` 要触发快照/日志重新同步。
## 8. 续期与释放
`POST /api/user/node/forward_bot/renew`,body `{bot_id,forward_session_id,grant_id}`。验证账号、HTTP 会话、Bot 归属、grant 所有权和当前分配;先续 session,再对 Node ensure/reauth 成功,登记延长的 grant,最后签发 Web 新 token。建议返回相同 ForwardAccess 结构,前端调用 reauth。Node 会话/转发分配变化返回 SESSION_STALE,重新初次申请。
`POST /api/user/node/forward_bot/release`,body 相同,严格验证所有权,撤销本 grant 并调用 `forward_revoke_grant`,幂等返回成功。其他标签页 grant 不受影响。通过本地有界终态记录支持重复 release,不能让未知 grant 成为可操作他人授权的入口。
远端撤销失败时本地立即标记不可续期,记录有界重试直到当前健康控制通道恢复或租约到期。不能把“本地已撤销”描述成“远端已断开”。Forward 的 session/grant/token 到期和空闲回收提供兜底。
## 9. 真实撤销入口
| 入口 | 接入方式 |
| --- | --- |
| `service/bot/delete.ts` 事务提交后 | 撤销该 Bot 的全部 session |
| `service/bot/update.ts` 提交后 | Node/team/资源分配变化时撤销旧 session;普通不影响授权的配置不无条件踢线 |
| `router/user/user_info.ts` logout | 按当前 HTTP sessionID 撤销对应 grants,不能只按 user_id 踢掉全部设备 |
| `runtime/node/socket.ts` 替换或移除当前连接 | 按 expected node_session_id 撤销旧 session,旧 disconnect 不影响新会话 |
| `service/user/team.ts` 成员移除/团队删除 | 提交后重新验证并撤销失效 grant/session |
| 授权巡检 | 独立巡检 relay grants 的登录会话、活跃账号和资源权限,覆盖没有 Main Web Socket 的用户 |
现有 `service/user/socket_session.ts` 的巡检只遍历 Main 用户 Socket,不能直接认为它覆盖 relay-only 浏览器。当前未确认存在账号停用和 Node/Forward 重新分配的 HTTP 入口;后续真实添加时接同一个撤销服务,不创建从未调用的 hook 充当完成。
## 10. HTTP 错误映射
沿用现有 `res.json({code:number,data})`,不整体改变 HTTP envelope。建议增加未占用的转发错误码并在 Main/Web 同步冻结:
| 情况 | 建议 code |
| --- | --- |
| 成功 | 0 |
| 未登录 | 498 |
| 参数非法 | 484 |
| Bot 不存在或不属于用户 | 485 |
| 数据库错误 | 480 |
| 权限不足 | 499 |
| Node 离线 | 501 |
| 超时 | 488 |
| 内部错误 | 486 |
| Relay 不可用 | 510(新增建议) |
| SESSION_STALE | 511(新增建议) |
| TOKEN_EXPIRED | 512(新增建议) |
| CANCELLED | 513(新增建议) |
| RATE_LIMITED | 487,结合原有 spam 语义确认 |
错误 data 建议投影为 `{error_code,message,retryable}`,前端不能解析任意服务内部错误。上述新增数字是 Main 的后续方案,尚未修改其码表。NO_CONTEXT、BOT_NOT_ONLINE、RESYNC_REQUIRED 是业务 RPC 语义,不能因 Bot 无上下文而阻止申请。
总预算沿用 Node 建立 8 秒、Main 等待 10 秒、HTTP 12 秒,所有阶段共享绝对 deadline,不能逐阶段叠加无限等待。Node 副作用操作 ACK 超时不可自动重发。
## 11. 注册表与部署配置
注册表存储 origin、transport、generation、node_session_id、健康状态和 disposer。当前连接重复注册幂等,初始化完成才入健康表并 ACK。旧断开只清理自己拥有的记录。origin=1 借用 Node 控制 Socket,删除 Forward 登记不得断开整个 Node;origin=0 的 EventEmitter 不调用 disconnect。
Forward endpoint 模型增加 `node_url/public_url/path/namespace/allowed_origins/config_revision`,监听 host/port 与公布地址分开。当前数据库启用 synchronize 且未发现迁移目录,新增字段应先可空、回填、校验,再收紧,不能直接添加无默认的非空字段影响现有行。
内置 ID 0 当前只有 runtime 登记并不足够,资源授权仍会查 Forward 实体;需显式 seed/验证内置配置或统一 builtin resolver,并保留权限检查。HTTPS 页面使用 HTTPS 公开入口,代理同时支持 polling 与 WebSocket。
## 12. 实施与验收顺序
1. 冻结本地 DTO、错误映射及 endpoint 配置;实现 token 测试向量。
2. 修改 Main 启动和健康注册表,Node 注册 ACK 暴露当前会话;接控制 ACK 适配器。
3. 实现 session/grant service,先用模拟 Node 验证并发、deadline、撤销和旧 ACK。
4. 接申请、续期、释放路由以及真实撤销入口;接 Node 新协议后执行真实 relay 联调。
5. 接 Web 并验证多标签页、无上下文、stop/start、凭证续期、服务重启及故障恢复。
验收至少覆盖:全部 Bot 状态和无上下文可申请;foreign Bot/无权限/离线 Node 拒绝;等待期间迁移或替换会话不返回旧凭证;并发标签页共享上行而 grant 独立;release/logout 只影响对应授权;公开响应无密钥/Node token/内部地址;转发重启先登记授权再恢复;Main/Node/relay 分别断线不互相覆盖状态。
当前 relay 的本地测试通过不能代替以上 Main/Node/Web 端到端验收。上线需同批升级协议,不允许新旧客户端混用。
## 13. 当前交付验证
在 Node.js 22.23.2 下执行 `pnpm run typecheck` 通过,`pnpm test` 共 38 项全部通过。测试包含真实 Socket.IO polling/WebSocket、模拟 Node RPC、异步 adapter、凭证与撤销、限流背压、借用宿主关闭/重启及自有 HTTP 关闭超时;未连接生产数据库、Minecraft 或实际 Main/Node/Web 全流程。
首版会话与授权保存在单进程内存。Socket.IO 固定为 `4.8.3`,RPC 取消及缓冲检测使用该版本的内部 `acks/writeBuffer` 结构;升级依赖时必须重新验证取消与背压测试。借用宿主模式在同一 host/path 保留一个无活动会话的转接监听器,停止时返回 503,重启复用,避免破坏其他 Socket.IO 服务的 HTTP 监听链。
+179
View File
@@ -0,0 +1,179 @@
import {createServer} from 'node:http';
import type {Server as HttpServer} from 'node:http';
import type {Socket as NetSocket} from 'node:net';
import {Server} from 'socket.io';
import type {ControlTransport, ForwardServerOptions} from './types.ts';
import {resolve_options} from './utils/relay_config.ts';
import {create_session_store} from './runtime/session_store.ts';
import type {RelaySessionStore} from './runtime/session_store.ts';
import {install_relay_routes} from './runtime/relay_routes.ts';
import {install_control_routes, report_status} from './runtime/init.ts';
import {mount_http} from './runtime/http_mount.ts';
export interface ForwardServerHandle {
readonly io: Server;
readonly http_server: HttpServer;
readonly store: RelaySessionStore;
readonly forward_id: number;
readonly path: string;
readonly namespace: string;
readonly owns_http_server: boolean;
bind_control(transport: ControlTransport): () => void;
stop(): Promise<void>;
}
let current: ForwardServerHandle | null = null;
let initialization: Promise<ForwardServerHandle> | null = null;
let startup_options: ReturnType<typeof resolve_options> | null = null;
let stopping: Promise<void> | null = null;
export async function start_forward_server(input: ForwardServerOptions): Promise<ForwardServerHandle> {
const options = resolve_options(input);
if (stopping) await stopping;
if (startup_options) {
const same = startup_options.forward_id === options.forward_id && startup_options.key === options.key
&& startup_options.http_server === options.http_server && startup_options.path === options.path
&& startup_options.namespace === options.namespace && startup_options.port === options.port
&& startup_options.host === options.host
&& JSON.stringify(startup_options.allowed_origins) === JSON.stringify(options.allowed_origins)
&& startup_options.allow_no_origin === options.allow_no_origin
&& JSON.stringify(startup_options.limits) === JSON.stringify(options.limits);
if (!same) throw new Error('Relay already initialized with different options; stop it before reconfiguration');
}
if (current) return current;
if (initialization) return initialization;
startup_options = options;
initialization = create_relay(options);
try {
current = await initialization;
return current;
} catch (error) {
startup_options = null;
throw error;
} finally { initialization = null; }
}
async function create_relay(options: ReturnType<typeof resolve_options>): Promise<ForwardServerHandle> {
const owns_http_server = !options.http_server;
const host = options.http_server ?? createServer((_request, response) => {
response.statusCode = 404;
response.end();
});
// Socket.IO owns only this private server in borrowed mode. Its close()
// therefore cannot close the Main HTTP server or unrelated Socket.IO services.
const transport_server = owns_http_server ? host : createServer();
const owned_connections = new Set<NetSocket>();
const track_connection = (socket: NetSocket) => {
owned_connections.add(socket);
socket.once('close', () => owned_connections.delete(socket));
};
if (owns_http_server) host.on('connection', track_connection);
let control: ControlTransport | undefined;
let dispose_control = () => {};
let unmount = () => {};
const io = new Server(transport_server, {
path: options.path,
serveClient: false,
maxHttpBufferSize: options.limits.max_payload_bytes,
connectTimeout: options.limits.auth_timeout_ms,
cors: {origin: options.allowed_origins, credentials: false},
allowRequest: (request, callback) => {
const origin = request.headers.origin;
const permitted = origin === undefined ? options.allow_no_origin : options.allowed_origins.includes(origin);
callback(null, permitted && io.engine.clientsCount < options.limits.max_connections);
},
});
const store = create_session_store({
forward_id: options.forward_id,
key: options.key,
limits: options.limits,
on_status: status => {
try { report_status(control, status); }
catch { options.logger?.log('Unable to report relay session status'); }
},
});
store.set_control_available(false);
const namespace = io.of(options.namespace);
if (options.namespace !== '/') io.of('/').use((_socket, next) => next(new Error('UNAUTHORIZED')));
const dispose_routes = install_relay_routes(namespace, store);
let stop_promise: Promise<void> | null = null;
const handle: ForwardServerHandle = {
io, http_server: host, store, forward_id: options.forward_id,
path: options.path, namespace: options.namespace, owns_http_server,
bind_control(transport) {
if (stop_promise) throw new Error('Relay is stopped');
dispose_control();
control = transport;
const dispose = install_control_routes(transport, store, options.logger);
dispose_control = dispose;
return () => {
if (dispose_control !== dispose) return;
dispose();
dispose_control = () => {};
control = undefined;
};
},
stop() {
if (stop_promise) return stop_promise;
stop_promise = (async () => {
host.off('close', host_closed);
store.close();
dispose_routes();
dispose_control();
control = undefined;
unmount();
let deadline: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
io.close(),
new Promise<never>((_resolve, reject) => {
deadline = setTimeout(() => {
for (const socket of owned_connections) socket.destroy();
reject(new Error('Relay shutdown timed out'));
}, options.limits.close_timeout_ms);
}),
]);
} finally {
clearTimeout(deadline);
host.off('connection', track_connection);
for (const socket of owned_connections) socket.destroy();
owned_connections.clear();
}
})().finally(() => {
if (current === handle) current = null;
startup_options = null;
stopping = null;
});
stopping = stop_promise;
return stop_promise;
},
};
const host_closed = () => { void handle.stop().catch(() => options.logger?.log('Relay shutdown failed')); };
try {
if (options.control) handle.bind_control(options.control);
if (owns_http_server) {
await new Promise<void>((resolve, reject) => {
const on_error = (error: Error) => { host.off('listening', on_listening); reject(error); };
const on_listening = () => { host.off('error', on_error); resolve(); };
host.once('error', on_error);
host.once('listening', on_listening);
host.listen(options.port, options.host);
});
} else unmount = mount_http(host, options.path, transport_server);
host.once('close', host_closed);
return handle;
} catch (error) {
await handle.stop();
throw error;
}
}
export async function stop_forward_server(): Promise<void> {
if (initialization) {
try { await initialization; } catch { return; }
}
if (current) await current.stop();
else if (stopping) await stopping;
}
export type * from './types.ts';
+25
View File
@@ -0,0 +1,25 @@
{
"name": "forward_server",
"version": "1.0.0",
"description": "",
"main": "index.ts",
"scripts": {
"test": "node --experimental-strip-types --test tests/*.test.ts",
"test:unit": "node --experimental-strip-types --test tests/token.test.ts tests/session.test.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@types/express": "^5.0.6",
"@types/node": "^26.6.2",
"express": "^5.2.1",
"socket.io": "4.8.3"
},
"devDependencies": {
"socket.io-client": "^4.8.4",
"typescript": "^7.0.2"
}
}
+1058
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
import type {Server, IncomingMessage, ServerResponse} from 'node:http';
import type {Duplex} from 'node:stream';
interface Mount {
target: Server | null;
acquire(target: Server): () => void;
}
const mounts = new WeakMap<Server, Map<string, Mount>>();
// Keep one dormant dispatcher per host/path: later Socket.IO services may wrap
// this listener, so removing it on stop would corrupt their request chain.
export function mount_http(host: Server, path: string, target: Server): () => void {
let entries = mounts.get(host);
if (!entries) {
entries = new Map();
mounts.set(host, entries);
}
const existing = entries.get(path);
if (existing) return existing.acquire(target);
const original = host.listeners('request');
const prefix = `${path}/`;
const matches = (request: IncomingMessage) => (request.url ?? '').split('?')[0].startsWith(prefix);
const mount: Mount = {
target: null,
acquire(next) {
if (this.target) throw new Error(`Relay path already mounted: ${path}`);
this.target = next;
return () => { if (this.target === next) this.target = null; };
},
};
const request_listener = (request: IncomingMessage, response: ServerResponse) => {
if (matches(request)) {
if (mount.target) mount.target.emit('request', request, response);
else { response.statusCode = 503; response.end('Relay unavailable'); }
} else {
for (const listener of original) listener.call(host, request, response);
}
};
const upgrade_listener = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
if (!matches(request)) return;
if (mount.target) mount.target.emit('upgrade', request, socket, head);
else socket.destroy();
};
for (const listener of original) host.removeListener('request', listener);
host.on('request', request_listener);
host.on('upgrade', upgrade_listener);
entries.set(path, mount);
host.on('close', () => {
mount.target = null;
});
return mount.acquire(target);
}
+50
View File
@@ -0,0 +1,50 @@
import {CONTROL_EVENTS} from '../types.ts';
import type {ControlTransport, ForwardSessionStatus, RelayLogger} from '../types.ts';
import type {RelaySessionStore} from './session_store.ts';
import {error_ack, fail, success} from '../utils/relay_validation.ts';
export function install_control_routes(
transport: ControlTransport,
store: RelaySessionStore,
logger?: RelayLogger,
): () => void {
let active = true;
const handlers = {
forward_open_session: (value: unknown) => store.open(value),
forward_authorize_grant: (value: unknown) => store.authorize(value),
forward_revoke_session: (value: unknown) => store.revoke_session(value),
forward_revoke_grant: (value: unknown) => store.revoke_grant(value),
};
const listeners = new Map<string, (...args: unknown[]) => void>();
for (const event of CONTROL_EVENTS) {
const listener = (value: unknown, callback: unknown) => {
if (!active || typeof callback !== 'function') return;
let ack;
try {
if (!store.control_available) fail('RELAY_UNAVAILABLE', 'Control channel is disconnected', true);
ack = success(handlers[event](value));
} catch (error) { ack = error_ack(error); }
try { callback(ack); }
catch { logger?.log('Relay control ACK delivery failed'); }
};
transport.on(event, listener);
listeners.set(event, listener);
}
const disconnected = () => {
store.set_control_available(false);
logger?.log('Relay control channel disconnected');
};
transport.on('disconnect', disconnected);
store.set_control_available(true);
return () => {
if (!active) return;
active = false;
for (const [event, listener] of listeners) transport.off(event, listener);
transport.off('disconnect', disconnected);
store.set_control_available(false);
};
}
export function report_status(transport: ControlTransport | undefined, status: ForwardSessionStatus): void {
transport?.emit('forward_session_status', status);
}
+66
View File
@@ -0,0 +1,66 @@
import type {Namespace, Socket} from 'socket.io';
import type {RelaySessionStore} from './session_store.ts';
import {RelayError, error_ack, success} from '../utils/relay_validation.ts';
export function install_relay_routes(namespace: Namespace, store: RelaySessionStore): () => void {
const sockets = new Map<Socket, () => void>();
const connect = (socket: Socket) => {
if (!store.attach(socket)) return;
const handlers = new Map<string, (...args: unknown[]) => void>();
const route = (name: string, operation: (value: unknown) => unknown | Promise<unknown>, wrapped = true, ack_required = true) => {
const handler = (...args: unknown[]) => {
const callback = args[args.length - 1];
if (ack_required && typeof callback !== 'function') return;
let replied = false;
const ack = (value: unknown) => {
if (replied || typeof callback !== 'function' || !store.can_reply(socket)) return;
replied = true;
try {callback(value);} catch { /* A peer callback cannot disrupt route cleanup. */ }
};
if (args.length !== (typeof callback === 'function' ? 2 : 1)) {
ack(error_ack(new RelayError('INVALID_REQUEST', 'Expected one request payload')));
return;
}
void Promise.resolve().then(() => operation(args[0])).then(
value => ack(wrapped ? success(value) : value), error => ack(error_ack(error)));
};
handlers.set(name, handler);
socket.on(name, handler);
};
route('join_room', value => store.join(socket, value));
route('reauth', value => store.join(socket, value, true));
route('forward_node_ready', value => store.node_ready(socket, value));
route('relay_request', value => store.request(socket, value), false);
route('bot_log', value => store.log(socket, value), true, false);
route('info_update', value => store.info(socket, value), true, false);
const unknown = (name: string, ...args: unknown[]) => {
if (handlers.has(name)) return;
const callback = args[args.length - 1];
try {
store.unknown_event(socket);
if (typeof callback === 'function' && store.can_reply(socket))
callback(error_ack(new RelayError('FORBIDDEN', 'Event is not allowed on the relay data channel')));
} catch { /* The store disconnects unknown-event floods. */ }
};
const cleanup = () => {
socket.offAny(unknown);
socket.off('disconnect', cleanup);
for (const [name, handler] of handlers) socket.off(name, handler);
sockets.delete(socket);
};
socket.onAny(unknown);
socket.on('disconnect', cleanup);
sockets.set(socket, cleanup);
};
namespace.on('connection', connect);
let disposed = false;
return () => {
if (disposed) return;
disposed = true;
namespace.off('connection', connect);
for (const [socket, cleanup] of [...sockets]) {
cleanup();
store.disconnect(socket);
}
};
}
+690
View File
@@ -0,0 +1,690 @@
import {createHash, randomUUID} from 'node:crypto';
import type {Socket} from 'socket.io';
import type {BotEvent, BotEventName, ForwardAck, ForwardErrorCode, ForwardSessionStatus, InfoUpdate, JoinRoomReady,
OpenSessionCommand, RelayClaims, RelayLimits, RelayMethod, RelayRequest, SessionScope} from '../types.ts';
import {RelayError, fail, is_record, is_uuid, success, error_ack, validate_open, validate_grant,
validate_request, validate_event, validate_info_update, validate_node_ack} from '../utils/relay_validation.ts';
import {verify_token} from '../utils/token_utils.ts';
type Timer = ReturnType<typeof setTimeout>;
type Result = ForwardAck<unknown>;
interface DedupeEntry {fingerprint: string; expires_at: number; result?: Result; completed?: boolean; non_replayable: boolean; local?: boolean}
interface Grant {
id: string; user_id: number; permissions: Set<RelayMethod>; expires_at: number;
timer?: Timer; web?: Binding; dedupe: Map<string, DedupeEntry>;
}
interface Session {
scope: SessionScope; lease_expires_at: number; timer?: Timer; reclaim?: Timer;
node?: Binding; generation: number; ready: boolean; had_web: boolean; first_ready: boolean;
grants: Map<string, Grant>; revoked_grants: Map<string, number>; webs: Set<Binding>; pending: Set<Pending>;
stream?: string; seq?: number; retired?: Set<string>;
}
interface Connection {
socket: Socket; binding?: Binding; busy: boolean; auth_timer?: Timer;
cancel_join?: (error: RelayError) => void; joining_session?: Session; joining_grant?: Grant;
requests: number; request_window: number;
logs?: number; log_window?: number;
disconnect: () => void;
}
interface Binding {
connection: Connection; session: Session; claims: RelayClaims; grant?: Grant;
expires_at: number; timer?: Timer; generation: number;
subscriptions: Set<BotEventName>; pending: Set<Pending>;
}
interface Pending {
id: string; binding: Binding; node: Binding; generation: number;
session: Session; timer?: Timer; entry: DedupeEntry; cancel_transport?: () => void;
finish: (result: Result) => void;
}
export interface SessionStoreOptions {
forward_id: number; key: string; limits: RelayLimits;
on_status: (status: ForwardSessionStatus) => void;
on_error?: (error: unknown) => void;
}
function timer(callback: () => void, delay: number): Timer {
const handle = setTimeout(callback, Math.max(1, Math.min(delay, 2_147_483_647)));
handle.unref();
return handle;
}
function same_scope(a: SessionScope, b: SessionScope): boolean {
return a.forward_id === b.forward_id && a.forward_session_id === b.forward_session_id
&& a.node_session_id === b.node_session_id && a.node_id === b.node_id && a.bot_id === b.bot_id;
}
function same_identity(a: RelayClaims, b: RelayClaims): boolean {
return same_scope(a, b) && a.role === b.role && a.grant_id === b.grant_id && a.user_id === b.user_id;
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
if (is_record(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
return JSON.stringify(value);
}
export class RelaySessionStore {
private readonly sessions = new Map<string, Session>();
private readonly revoked_sessions = new Map<string, number>();
private readonly connections = new Map<Socket, Connection>();
private readonly pending = new Map<string, Pending>();
private closed = false;
private control = true;
readonly limits: RelayLimits;
private readonly options: SessionStoreOptions;
constructor(options: SessionStoreOptions) {this.options = options; this.limits = options.limits;}
get control_available(): boolean {return this.control;}
get size(): number {return this.sessions.size;}
set_control_available(available: boolean): void {if (!this.closed) this.control = available;}
private report(session: Session, state: ForwardSessionStatus['state'], reason: string): void {
try {
this.options.on_status({...session.scope, node_ready: session.ready, state, reason, reported_at: Date.now()});
} catch (error) {this.report_error(error);}
}
private report_error(error: unknown): void {
try {this.options.on_error?.(error);} catch { /* Observer failures cannot retain relay resources. */ }
}
private active(session: Session): void {
if (this.closed || this.sessions.get(session.scope.forward_session_id) !== session)
fail('SESSION_STALE', 'Session is no longer active');
if (session.lease_expires_at <= Date.now()) {
this.close_session(session, 'lease_expired', 'TOKEN_EXPIRED');
fail('TOKEN_EXPIRED', 'Session lease expired');
}
}
private session(id: string): Session {
const session = this.sessions.get(id);
if (!session) fail('SESSION_STALE', 'Session is not registered');
this.active(session);
return session;
}
private control_required(): void {
if (this.closed || !this.control) fail('RELAY_UNAVAILABLE', 'Control channel is unavailable', true);
}
private prune_revoked(entries: Map<string, number>): void {
for (const [id, expires_at] of entries) if (expires_at <= Date.now()) entries.delete(id);
}
private tombstone_expiry(): number {return Date.now() + this.limits.max_token_age_ms + this.limits.clock_skew_ms;}
open(command: unknown): OpenSessionCommand {
this.control_required();
validate_open(command, this.options.forward_id, this.limits);
const value = command;
this.prune_revoked(this.revoked_sessions);
if (this.revoked_sessions.has(value.forward_session_id)) fail('SESSION_STALE', 'Session ID was revoked');
const now = Date.now();
if (value.lease_expires_at <= now || value.lease_expires_at > now + this.limits.max_lease_ms)
fail('INVALID_REQUEST', 'Session lease is outside the allowed range');
let session = this.sessions.get(value.forward_session_id);
if (session && session.lease_expires_at <= now) {
this.close_session(session, 'lease_expired', 'TOKEN_EXPIRED');
fail('SESSION_STALE', 'Expired session ID cannot be reused');
}
if (session) {
if (!same_scope(session.scope, value)) fail('SESSION_STALE', 'Session scope is immutable');
session.lease_expires_at = Math.max(session.lease_expires_at, value.lease_expires_at);
} else {
for (const other of this.sessions.values()) {
if (other.lease_expires_at <= now) this.close_session(other, 'lease_expired', 'TOKEN_EXPIRED');
else if (other.scope.node_id === value.node_id && other.scope.bot_id === value.bot_id)
fail('SESSION_STALE', 'Revoke the existing Node/Bot session before opening another');
}
if (this.sessions.size + this.revoked_sessions.size >= this.limits.max_sessions) fail('RATE_LIMITED', 'Session capacity reached', true);
const {forward_id, forward_session_id, node_session_id, node_id, bot_id} = value;
session = {scope: {forward_id, forward_session_id, node_session_id, node_id, bot_id},
lease_expires_at: value.lease_expires_at, generation: 0, ready: false, had_web: false,
first_ready: false, grants: new Map(), revoked_grants: new Map(), webs: new Set(), pending: new Set()};
this.sessions.set(value.forward_session_id, session);
this.report(session, 'open', 'session_opened');
}
clearTimeout(session.timer);
const owned = session;
session.timer = timer(() => {
if (this.sessions.get(value.forward_session_id) === owned)
this.close_session(owned, 'lease_expired', 'TOKEN_EXPIRED');
}, session.lease_expires_at - Date.now());
return {...session.scope, lease_expires_at: session.lease_expires_at};
}
authorize(command: unknown) {
this.control_required();
validate_grant(command, this.limits);
const value = command;
const session = this.session(value.forward_session_id);
this.prune_revoked(session.revoked_grants);
if (session.revoked_grants.has(value.grant_id)) fail('FORBIDDEN', 'Grant ID was revoked');
const now = Date.now();
if (value.expires_at <= now || value.expires_at > now + this.limits.max_lease_ms
|| value.expires_at > session.lease_expires_at) fail('INVALID_REQUEST', 'Grant lease is outside the session lease');
let grant = session.grants.get(value.grant_id);
if (grant && grant.expires_at <= now) {
this.close_grant(session, grant, 'grant_expired', 'TOKEN_EXPIRED');
fail('FORBIDDEN', 'Expired grant ID cannot be reused');
}
if (grant) {
if (grant.user_id !== value.user_id || grant.permissions.size !== value.permissions.length
|| value.permissions.some(permission => !grant!.permissions.has(permission)))
fail('FORBIDDEN', 'Grant identity and permissions are immutable');
grant.expires_at = Math.max(grant.expires_at, value.expires_at);
} else {
if (session.grants.size + session.revoked_grants.size >= this.limits.max_grants_per_session) fail('RATE_LIMITED', 'Grant capacity reached', true);
grant = {id: value.grant_id, user_id: value.user_id, permissions: new Set(value.permissions),
expires_at: value.expires_at, dedupe: new Map()};
session.grants.set(grant.id, grant);
}
clearTimeout(grant.timer);
const owned = grant;
grant.timer = timer(() => {
if (session.grants.get(owned.id) === owned) this.close_grant(session, owned, 'grant_expired', 'TOKEN_EXPIRED');
}, grant.expires_at - Date.now());
return {...value, expires_at: grant.expires_at};
}
private revoke_command(command: unknown, grant: boolean): {forward_session_id: string; reason: string; grant_id?: string} {
if (!is_record(command) || !is_uuid(command.forward_session_id) || typeof command.reason !== 'string'
|| command.reason.trim().length < 1 || Buffer.byteLength(command.reason) > 256
|| (grant && !is_uuid(command.grant_id))
|| Object.keys(command).some(key => !['forward_session_id', 'reason', ...(grant ? ['grant_id'] : [])].includes(key)))
fail('INVALID_REQUEST', 'Invalid revoke command');
return command as {forward_session_id: string; reason: string; grant_id?: string};
}
revoke_session(command: unknown) {
const value = this.revoke_command(command, false);
const session = this.sessions.get(value.forward_session_id);
if (session) this.close_session(session, value.reason, 'SESSION_STALE');
else {
this.prune_revoked(this.revoked_sessions);
if (!this.revoked_sessions.has(value.forward_session_id) && this.sessions.size + this.revoked_sessions.size >= this.limits.max_sessions)
fail('RATE_LIMITED', 'Revocation capacity reached', true);
this.revoked_sessions.set(value.forward_session_id, this.tombstone_expiry());
}
return {forward_session_id: value.forward_session_id, revoked: true};
}
revoke_grant(command: unknown) {
const value = this.revoke_command(command, true);
const session = this.sessions.get(value.forward_session_id);
const grant = session?.grants.get(value.grant_id!);
if (grant) this.close_grant(session!, grant, value.reason, 'FORBIDDEN');
else if (session) {
this.prune_revoked(session.revoked_grants);
if (!session.revoked_grants.has(value.grant_id!) && session.grants.size + session.revoked_grants.size >= this.limits.max_grants_per_session)
fail('RATE_LIMITED', 'Grant revocation capacity reached', true);
session.revoked_grants.set(value.grant_id!, this.tombstone_expiry());
}
return {forward_session_id: value.forward_session_id, grant_id: value.grant_id!, revoked: true};
}
attach(socket: Socket): boolean {
if (this.closed || this.connections.size >= this.limits.max_connections) {
socket.emit('forward_session_closed', {reason: 'connection_capacity', code: 'RATE_LIMITED'});
socket.disconnect(true);
return false;
}
const connection: Connection = {socket, busy: false, requests: 0, request_window: Date.now(),
disconnect: () => this.detach(socket)};
this.connections.set(socket, connection);
connection.auth_timer = timer(() => this.drop(connection, 'authentication_timeout', 'UNAUTHORIZED'), this.limits.auth_timeout_ms);
socket.on('disconnect', connection.disconnect);
return true;
}
detach(socket: Socket): void {
const connection = this.connections.get(socket);
if (!connection) return;
this.connections.delete(socket);
connection.cancel_join?.(new RelayError('CANCELLED', 'Connection closed while joining'));
clearTimeout(connection.auth_timer);
socket.off('disconnect', connection.disconnect);
const binding = connection.binding;
if (!binding) return;
connection.binding = undefined;
clearTimeout(binding.timer);
for (const pending of [...binding.pending]) pending.finish(error_ack(new RelayError('CANCELLED', 'Web connection closed')));
const session = binding.session;
if (binding.claims.role === 'node') {
if (session.node !== binding) return;
session.node = undefined;
session.ready = false;
for (const pending of [...session.pending]) pending.finish(error_ack(new RelayError('NODE_OFFLINE', 'Node disconnected', true)));
this.status(session, 'node_disconnected');
} else {
if (binding.grant?.web === binding) binding.grant.web = undefined;
session.webs.delete(binding);
if (this.sessions.get(session.scope.forward_session_id) === session && session.webs.size === 0)
this.reclaim(session, this.limits.idle_timeout_ms, 'last_web_left');
}
}
private drop(connection: Connection, reason: string, code: ForwardErrorCode): void {
connection.cancel_join?.(new RelayError(code, reason));
const binding = connection.binding;
const scope = binding?.session.scope ?? connection.joining_session?.scope;
if (connection.socket.connected) connection.socket.emit('forward_session_closed', {
...(scope ? {forward_session_id: scope.forward_session_id} : {}), reason, code});
this.detach(connection.socket);
connection.socket.disconnect(true);
}
disconnect(socket: Socket, reason = 'relay_routes_closed'): void {
const connection = this.connections.get(socket);
if (connection) this.drop(connection, reason, 'CANCELLED');
}
can_reply(socket: Socket): boolean {
const connection = this.connections.get(socket);
return !!connection && this.writable(connection);
}
unknown_event(socket: Socket): void {
const connection = this.connections.get(socket);
if (!connection) fail('UNAUTHORIZED', 'Socket is disconnected');
try {this.rate(connection);}
catch (error) {this.drop(connection, 'unknown_event_rate_exceeded', 'RATE_LIMITED'); throw error;}
}
private writable(connection: Connection): boolean {
const transport = connection.socket.conn as unknown as {writeBuffer: unknown[]};
if (transport.writeBuffer.length >= this.limits.max_buffered_packets) {
this.drop(connection, 'resync_required', 'RESYNC_REQUIRED');
return false;
}
return connection.socket.connected;
}
private close_grant(session: Session, grant: Grant, reason: string, code: ForwardErrorCode): void {
if (session.grants.get(grant.id) !== grant) return;
session.grants.delete(grant.id);
session.revoked_grants.set(grant.id, this.tombstone_expiry());
clearTimeout(grant.timer);
for (const connection of [...this.connections.values()])
if (connection.joining_grant === grant) this.drop(connection, reason, code);
if (grant.web) this.drop(grant.web.connection, reason, code);
grant.dedupe.clear();
}
private close_session(session: Session, reason: string, code: ForwardErrorCode): void {
if (this.sessions.get(session.scope.forward_session_id) !== session) return;
this.sessions.delete(session.scope.forward_session_id);
this.revoked_sessions.set(session.scope.forward_session_id, this.tombstone_expiry());
clearTimeout(session.timer);
clearTimeout(session.reclaim);
for (const connection of [...this.connections.values()])
if (connection.joining_session === session) this.drop(connection, reason, code);
for (const pending of [...session.pending]) pending.finish(error_ack(new RelayError(code, reason)));
for (const grant of session.grants.values()) clearTimeout(grant.timer);
const sockets = [...session.webs, ...(session.node ? [session.node] : [])];
session.ready = false;
for (const binding of sockets) this.drop(binding.connection, reason, code);
session.grants.clear();
this.report(session, 'closed', reason);
}
private reclaim(session: Session, delay: number, reason: string): void {
clearTimeout(session.reclaim);
const owned = timer(() => {
if (session.reclaim === owned && this.sessions.get(session.scope.forward_session_id) === session && !session.webs.size)
this.close_session(session, reason, 'SESSION_STALE');
}, delay);
session.reclaim = owned;
}
private status(session: Session, reason: string): void {
if (this.sessions.get(session.scope.forward_session_id) !== session) return;
const data = {forward_session_id: session.scope.forward_session_id, node_ready: session.ready, reason};
for (const web of [...session.webs]) if (this.valid(web)) this.send(web, 'relay_status', data);
this.report(session, session.ready ? 'ready' : 'unavailable', reason);
}
private grant(session: Session, claims: RelayClaims): Grant | undefined {
if (claims.role !== 'web') return undefined;
const grant = session.grants.get(claims.grant_id!);
if (!grant || grant.user_id !== claims.user_id) fail('FORBIDDEN', 'Web grant is not authorized');
if (grant.expires_at <= Date.now()) {
this.close_grant(session, grant, 'grant_expired', 'TOKEN_EXPIRED');
fail('TOKEN_EXPIRED', 'Web grant expired');
}
if (claims.permissions.some(permission => !grant.permissions.has(permission))) fail('FORBIDDEN', 'Token exceeds grant permissions');
return grant;
}
private valid(binding: Binding): boolean {
const {connection, session, grant} = binding;
if (this.connections.get(connection.socket) !== connection || connection.binding !== binding || !connection.socket.connected)
return false;
if (this.sessions.get(session.scope.forward_session_id) !== session) return false;
if (binding.expires_at <= Date.now() || session.lease_expires_at <= Date.now()
|| (grant && (session.grants.get(grant.id) !== grant || grant.expires_at <= Date.now()))) {
this.drop(connection, 'authorization_expired', 'TOKEN_EXPIRED');
return false;
}
return binding.claims.role === 'node' ? session.node === binding : grant?.web === binding;
}
private binding(socket: Socket, role?: RelayClaims['role']): Binding {
const connection = this.connections.get(socket);
if (!connection || connection.busy || !connection.binding || !this.valid(connection.binding))
fail('UNAUTHORIZED', 'Socket is not bound to an active session');
if (role && connection.binding.claims.role !== role) fail('FORBIDDEN', 'Socket role cannot perform this operation');
return connection.binding;
}
private ready_data(binding: Binding): JoinRoomReady {
return {...binding.session.scope, role: binding.claims.role, node_ready: binding.session.ready, expires_at: binding.expires_at};
}
private async join_room(connection: Connection, room: string): Promise<void> {
let cancelled = false;
let deadline: Timer | undefined;
const cancellation = new Promise<never>((_resolve, reject) => {
connection.cancel_join = error => {cancelled = true; reject(error);};
deadline = timer(() => connection.cancel_join?.(new RelayError('TIMEOUT', 'Room join timed out', true)), this.limits.auth_timeout_ms);
});
const joining = Promise.resolve().then(() => connection.socket.join(room)).then(() => {
// An async adapter can complete after disconnect/cancellation and add the
// socket back to its room. Clean that late membership without retaining it.
if (cancelled) void Promise.resolve(connection.socket.leave(room)).catch(error => this.report_error(error));
});
try {await Promise.race([joining, cancellation]);}
finally {clearTimeout(deadline); connection.cancel_join = undefined;}
}
async join(socket: Socket, command: unknown, reauth = false): Promise<JoinRoomReady> {
this.control_required();
const connection = this.connections.get(socket);
if (!connection || !socket.connected) fail('UNAUTHORIZED', 'Socket is disconnected');
this.rate(connection);
if (connection.busy) fail('INVALID_REQUEST', 'Another authentication operation is pending');
if (!is_record(command) || Object.keys(command).length !== 1 || typeof command.token !== 'string')
fail('INVALID_REQUEST', 'Expected a token');
connection.busy = true;
let joined = false;
let room: string | undefined;
try {
const claims = verify_token(this.options.key, command.token, this.options.forward_id, this.limits);
const session = this.session(claims.forward_session_id);
if (!same_scope(session.scope, claims)) fail('SESSION_STALE', 'Token scope differs from the registered session');
const grant = this.grant(session, claims);
connection.joining_session = session;
connection.joining_grant = grant;
const previous = connection.binding;
if (previous && (!same_identity(previous.claims, claims) || !this.valid(previous)))
fail('FORBIDDEN', 'Socket identity cannot change');
if (previous && reauth && (previous.claims.permissions.length !== claims.permissions.length
|| claims.permissions.some(permission => !previous.claims.permissions.includes(permission))))
fail('FORBIDDEN', 'Reauthorization cannot change permissions');
if (reauth && !previous) fail('UNAUTHORIZED', 'Join before renewing authorization');
if (previous && !reauth) return this.ready_data(previous);
const expires_at = Math.min(claims.expires_at, session.lease_expires_at, grant?.expires_at ?? Infinity);
if (expires_at <= Date.now()) fail('TOKEN_EXPIRED', 'Authorization expired');
if (previous && expires_at < previous.expires_at) fail('INVALID_REQUEST', 'Reauthorization can only extend the lease');
room = `relay:${session.scope.forward_id}:${session.scope.node_id}:${session.scope.bot_id}:${session.scope.node_session_id}:${session.scope.forward_session_id}:${claims.role}`;
if (!previous) {await this.join_room(connection, room); joined = true;}
this.control_required();
this.active(session);
if (!socket.connected || this.connections.get(socket) !== connection) fail('CANCELLED', 'Connection closed while joining');
if (this.grant(session, claims) !== grant) fail('FORBIDDEN', 'Grant was replaced while joining');
if (expires_at <= Date.now()) fail('TOKEN_EXPIRED', 'Authorization expired while joining');
if (previous) {
previous.claims = claims;
previous.expires_at = expires_at;
this.expiry(previous);
return this.ready_data(previous);
}
const binding: Binding = {connection, session, claims, grant, expires_at, generation: session.generation,
subscriptions: new Set(), pending: new Set()};
connection.binding = binding;
clearTimeout(connection.auth_timer);
if (claims.role === 'node') {
const old = session.node;
binding.generation = ++session.generation;
session.node = binding;
session.ready = false;
session.stream = undefined;
session.seq = undefined;
session.retired = undefined;
for (const pending of [...session.pending]) pending.finish(error_ack(new RelayError('NODE_OFFLINE', 'Node connection replaced', true)));
if (old) this.drop(old.connection, 'node_replaced', 'SESSION_STALE');
this.status(session, 'node_joined');
} else {
const old = grant!.web;
for (const [id, entry] of grant!.dedupe) if (entry.local) grant!.dedupe.delete(id);
grant!.web = binding;
session.webs.add(binding);
session.had_web = true;
clearTimeout(session.reclaim);
session.reclaim = undefined;
if (old) this.drop(old.connection, 'grant_connection_replaced', 'SESSION_STALE');
}
this.expiry(binding);
return this.ready_data(binding);
} catch (error) {
if (joined && room && !connection.binding)
void Promise.resolve(socket.leave(room)).catch(error => this.report_error(error));
throw error;
} finally {connection.busy = false; connection.joining_session = undefined; connection.joining_grant = undefined;}
}
private expiry(binding: Binding): void {
clearTimeout(binding.timer);
binding.timer = timer(() => {
if (binding.connection.binding === binding) this.drop(binding.connection, 'authorization_expired', 'TOKEN_EXPIRED');
}, binding.expires_at - Date.now());
}
node_ready(socket: Socket, command: unknown): {node_ready: true} {
const binding = this.binding(socket, 'node');
const session = binding.session;
if (!is_record(command) || Object.keys(command).length !== 1 || command.forward_session_id !== session.scope.forward_session_id)
fail('INVALID_REQUEST', 'Invalid Node readiness message');
if (!session.ready) {
session.ready = true;
this.status(session, 'node_ready');
if (!session.first_ready) {
session.first_ready = true;
if (!session.had_web && !session.webs.size) this.reclaim(session, this.limits.unused_timeout_ms, 'unused_session');
}
}
return {node_ready: true};
}
private rate(connection: Connection): void {
const now = Date.now();
if (now - connection.request_window >= 1000) {connection.request_window = now; connection.requests = 0;}
if (++connection.requests > this.limits.requests_per_second) fail('RATE_LIMITED', 'Request rate exceeded', true);
}
async request(socket: Socket, command: unknown): Promise<Result> {
const binding = this.binding(socket, 'web');
this.rate(binding.connection);
validate_request(command, this.limits);
const request = command;
const permission = request.method === 'get_log_history' ? 'bot.logs'
: request.method === 'tab_complete' ? 'bot.chat'
: request.method === 'get_context_info' ? 'bot.snapshot'
: request.method === 'get_data_file_list' || request.method === 'get_file_data' ? 'bot.files'
: request.method === 'workflow.start' || request.method === 'workflow.stop' || request.method === 'workflow.control' ? 'workflow.control'
: request.method === 'workflow.snapshot' || request.method === 'workflow.catalog'
|| request.method === 'workflow.command' || request.method === 'workflow.runtime' ? 'bot.snapshot' : request.method;
if (!binding.claims.permissions.includes(permission) || !binding.grant!.permissions.has(permission))
fail('FORBIDDEN', 'Method is not authorized');
const workflow_function = request.method === 'execute_function'
&& (request.payload as {function: string}).function.split('/')[0] === 'workflow';
if (workflow_function && (!binding.claims.permissions.includes('workflow.control') || !binding.grant!.permissions.has('workflow.control')))
fail('FORBIDDEN', 'Workflow functions require workflow control permission');
const non_replayable = request.method === 'bot.chat' || request.method === 'execute_function'
|| request.method === 'workflow.start' || request.method === 'workflow.stop' || request.method === 'workflow.control';
if (non_replayable || request.method === 'tab_complete') this.control_required();
const now = Date.now();
const dedupe = binding.grant!.dedupe;
const file_request = request.method === 'get_data_file_list' || request.method === 'get_file_data';
const fresh_read = file_request || request.method.startsWith('workflow.') && !non_replayable;
for (const [id, entry] of dedupe) if (entry.expires_at <= now && (entry.completed || entry.result)) dedupe.delete(id);
const fingerprint = createHash('sha256').update(canonical({method: request.method, payload: request.payload})).digest('hex');
const existing = dedupe.get(request.request_id);
if (existing) {
if (existing.fingerprint !== fingerprint) fail('INVALID_REQUEST', 'Request ID was already used with a different payload');
if (!existing.completed && !existing.result) fail('RATE_LIMITED', 'Request is already pending', true);
if (existing.non_replayable) fail('CANCELLED', 'Mutating requests cannot be replayed');
// Runtime reads must reflect the current Node and its permissions.
if (request.method !== 'get_context_info' && !fresh_read) return existing.result!;
}
if (!existing && dedupe.size >= this.limits.max_dedupe_entries) {
const oldest = [...dedupe].find(([, entry]) => entry.completed || entry.result);
if (oldest) dedupe.delete(oldest[0]);
else fail('RATE_LIMITED', 'Request deduplication capacity reached', true);
}
const entry: DedupeEntry = {fingerprint, expires_at: now + this.limits.dedupe_ttl_ms, non_replayable};
if (request.method === 'bot.subscribe' || request.method === 'bot.unsubscribe') {
entry.local = true;
const events = (request.payload as {events: BotEventName[]}).events;
if (request.method === 'bot.subscribe') binding.subscriptions = new Set(events);
else for (const event of events) binding.subscriptions.delete(event);
entry.result = success({events: [...binding.subscriptions]});
dedupe.set(request.request_id, entry);
return entry.result;
}
const session = binding.session;
const node = session.node;
if (!node || !session.ready || !this.valid(node)) fail('NODE_OFFLINE', 'Node relay is not ready', true);
if (!node.claims.permissions.includes(permission)) fail('FORBIDDEN', 'Node token does not permit this method');
if (workflow_function && !node.claims.permissions.includes('workflow.control')) fail('FORBIDDEN', 'Node token does not permit workflow functions');
if (binding.pending.size >= this.limits.max_pending_per_web || this.pending.size >= this.limits.max_pending_total)
fail('RATE_LIMITED', 'Pending request capacity reached', true);
dedupe.set(request.request_id, entry);
return new Promise<Result>(resolve => {
const id = randomUUID();
let done = false;
const pending: Pending = {id, binding, node, session, generation: node.generation, entry, finish: result => {
if (done) return;
done = true;
clearTimeout(pending.timer);
this.pending.delete(id);
session.pending.delete(pending);
binding.pending.delete(pending);
pending.cancel_transport?.();
entry.completed = true;
if (!fresh_read) entry.result = result;
entry.expires_at = Date.now() + this.limits.dedupe_ttl_ms;
resolve(result);
}};
this.pending.set(id, pending);
session.pending.add(pending);
binding.pending.add(pending);
pending.timer = timer(() => pending.finish(error_ack(new RelayError('TIMEOUT', 'Node request timed out', true))), this.limits.rpc_timeout_ms);
void this.dispatch(pending, request);
});
}
private async dispatch(pending: Pending, request: RelayRequest): Promise<void> {
const {session, node, binding} = pending;
try {
if ((node.connection.socket.conn as unknown as {writeBuffer: unknown[]}).writeBuffer.length >= this.limits.max_buffered_packets) {
pending.finish(error_ack(new RelayError('RESYNC_REQUIRED', 'Node transport buffer exceeded its limit')));
this.drop(node.connection, 'resync_required', 'RESYNC_REQUIRED');
return;
}
// Socket.IO has no public ACK cancellation API. Release its ACK wrapper as
// well as our own timer when a grant, Web, or Node is revoked/replaced.
const acks = (node.connection.socket as unknown as {acks: Map<number, (...args: unknown[]) => void>}).acks;
const before = new Set(acks.keys());
const response_promise = node.connection.socket.timeout(this.limits.rpc_timeout_ms).emitWithAck('relay_request', {
...request, correlation_id: pending.id, forward_session_id: session.scope.forward_session_id,
deadline_at: Date.now() + this.limits.rpc_timeout_ms});
const ack_id = [...acks.keys()].find(id => !before.has(id));
pending.cancel_transport = () => {
if (ack_id === undefined) return;
const callback = acks.get(ack_id);
acks.delete(ack_id);
callback?.(error_ack(new RelayError('CANCELLED', 'Relay request was cancelled')));
};
const response = await response_promise;
if (!this.pending.has(pending.id)) return;
if (session.node !== node || session.generation !== pending.generation || !this.valid(node))
fail('NODE_OFFLINE', 'Node connection changed', true);
if (!this.valid(binding)) fail('CANCELLED', 'Web authorization ended');
validate_node_ack(request.method, response, session.scope.bot_id, this.limits);
if ((request.method === 'workflow.control' || request.method === 'workflow.command')
&& response.status === 'success' && response.data !== null) {
const input = request.payload as {command_id: string; action?: string; workflow_id?: string};
const data = response.data as {command_id: string; action: string; workflow_id: string | null};
if (data.command_id !== input.command_id || (request.method === 'workflow.control'
&& (data.action !== input.action || (input.workflow_id !== undefined && data.workflow_id !== input.workflow_id))))
fail('INVALID_REQUEST', 'Node workflow command does not match the request');
}
if (request.method === 'workflow.snapshot' && response.status === 'success') {
const {run_id} = request.payload as {run_id?: string};
if (run_id === undefined ? !Array.isArray(response.data)
: response.data !== null && (Array.isArray(response.data) || (response.data as {run_id: string}).run_id !== run_id))
fail('INVALID_REQUEST', 'Node workflow snapshot does not match the request');
}
if (request.method === 'get_file_data' && response.status === 'success') {
const input = request.payload as {id: string; offset?: number; length?: number};
const data = response.data as {id: string; offset: number; next_offset: number};
if (data.id !== input.id || data.offset !== (input.offset ?? 0)
|| data.next_offset - data.offset > (input.length ?? 262_144))
fail('INVALID_REQUEST', 'Node file response does not match the request');
}
if (request.method === 'tab_complete' && response.status === 'success') {
const {start, length} = response.data as {start: number; length: number};
if (start + length > (request.payload as {text: string}).text.length)
fail('INVALID_REQUEST', 'Node completion range exceeds the requested text');
}
if (request.method === 'bot.logs' && response.status === 'success'
&& (response.data as {records: unknown[]}).records.length > (request.payload as {limit: number}).limit)
fail('INVALID_REQUEST', 'Node logs response exceeds the requested limit');
pending.finish(response);
} catch (error) {
if (!this.pending.has(pending.id)) return;
pending.finish(error_ack(error instanceof RelayError ? error : new RelayError('TIMEOUT', 'Node request failed or timed out', true)));
}
}
private send(binding: Binding, event: string, value: unknown): void {
const socket = binding.connection.socket;
if (!this.writable(binding.connection)) return;
socket.emit(event, value);
}
private event_binding(socket: Socket): Binding {
const binding = this.binding(socket, 'node');
const connection = binding.connection;
const now = Date.now();
if (!connection.log_window || now - connection.log_window >= 1000) {
connection.log_window = now;
connection.logs = 0;
}
connection.logs = (connection.logs ?? 0) + 1;
if (connection.logs > this.limits.events_per_second) {
this.drop(connection, 'log_rate_exceeded', 'RATE_LIMITED');
fail('RATE_LIMITED', 'Node log rate exceeded', true);
}
const session = binding.session;
if (!session.ready) fail('NODE_OFFLINE', 'Node has not declared readiness', true);
return binding;
}
log(socket: Socket, command: unknown): {accepted: true} {
const binding = this.event_binding(socket);
validate_event(command, this.limits);
if (command.name !== 'log') fail('INVALID_REQUEST', 'Expected a log event');
return this.fanout(binding, command, 'bot.logs', 'bot_log');
}
info(socket: Socket, command: unknown): {accepted: true} {
const binding = this.event_binding(socket);
validate_info_update(command, this.limits);
if (!binding.claims.permissions.includes('bot.snapshot')) fail('FORBIDDEN', 'Node token does not permit info updates');
return this.fanout(binding, command, 'bot.snapshot', 'info_update');
}
private fanout(binding: Binding, command: BotEvent | InfoUpdate, permission: RelayMethod,
event: 'bot_log' | 'info_update'): {accepted: true} {
const {session, connection} = binding;
const label = event === 'bot_log' ? 'Log' : 'Info';
const kind = event === 'bot_log' ? 'log' : 'info';
if (command.forward_session_id !== session.scope.forward_session_id || command.bot_id !== session.scope.bot_id)
fail('FORBIDDEN', `${label} scope does not match the Node connection`);
if (session.stream !== command.stream_id) {
session.retired ??= new Set();
if (session.retired.has(command.stream_id)) fail('RESYNC_REQUIRED', `Retired ${kind} stream cannot resume`);
if (session.stream) {
if (session.retired.size >= 32) {
this.drop(connection, 'resync_required', 'RESYNC_REQUIRED');
fail('RESYNC_REQUIRED', `Too many ${kind} stream replacements`);
}
session.retired.add(session.stream);
this.status(session, 'resync_required');
}
session.stream = command.stream_id;
session.seq = -1;
}
if (command.seq <= (session.seq ?? -1)) fail('INVALID_REQUEST', `${label} sequence must increase`);
session.seq = command.seq;
for (const web of [...session.webs]) {
if (this.valid(web) && web.claims.permissions.includes(permission) && web.grant!.permissions.has(permission))
this.send(web, event, command);
}
return {accepted: true};
}
close(): void {
if (this.closed) return;
this.closed = true;
this.control = false;
for (const session of [...this.sessions.values()]) this.close_session(session, 'relay_closed', 'RELAY_UNAVAILABLE');
for (const connection of [...this.connections.values()]) this.drop(connection, 'relay_closed', 'RELAY_UNAVAILABLE');
this.revoked_sessions.clear();
}
}
export function create_session_store(options: SessionStoreOptions): RelaySessionStore {return new RelaySessionStore(options);}
+284
View File
@@ -0,0 +1,284 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createServer} from 'node:http';
import {EventEmitter, once} from 'node:events';
import {randomUUID} from 'node:crypto';
import {connect as connect_tcp} from 'node:net';
import {Server} from 'socket.io';
import {io as client} from 'socket.io-client';
import type {Socket as ClientSocket} from 'socket.io-client';
import {start_forward_server, stop_forward_server} from '../index.ts';
import {DEFAULT_LIMITS, resolve_options} from '../utils/relay_config.ts';
import {encode_token} from '../utils/token_utils.ts';
import type {BotLogRecord, ForwardAck, RelayClaims, SessionScope} from '../types.ts';
const key = 'lifecycle-test-secret-no-production-value';
const options = () => ({forward_id: 0, key, port: 0, allowed_origins: ['https://console.example']});
const address = (server: ReturnType<typeof createServer>) => {
const value = server.address();
assert.ok(value && typeof value === 'object');
return `http://127.0.0.1:${value.port}`;
};
async function connect(socket: ClientSocket) {
await new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('connect_error', reject);
socket.connect();
});
}
function control_request(control: EventEmitter, event: string, value: unknown): ForwardAck<unknown> {
let result: ForwardAck<unknown> | undefined;
control.emit(event, value, (ack: ForwardAck<unknown>) => { result = ack; });
assert.ok(result, `Missing control ACK for ${event}`);
return result;
}
test('configuration validates explicit origins, IDs, limits and required secret', () => {
assert.throws(() => resolve_options({...options(), key: ''}));
assert.throws(() => resolve_options({...options(), forward_id: -1}));
assert.throws(() => resolve_options({...options(), allowed_origins: ['*']}));
assert.throws(() => resolve_options({...options(), limits: {rpc_timeout_ms: 0}}));
assert.equal(DEFAULT_LIMITS.rpc_timeout_ms, 5_000);
});
test('concurrent startup shares one listening service, stop is idempotent and restart works', async t => {
t.after(stop_forward_server);
const input = options();
const [a, b] = await Promise.all([start_forward_server(input), start_forward_server(input)]);
assert.equal(a, b);
assert.ok(a.http_server.listening);
await assert.rejects(start_forward_server({...input, forward_id: 2}), /different options/);
await Promise.all([a.stop(), a.stop()]);
assert.equal(a.http_server.listening, false);
const restarted = await start_forward_server(options());
assert.notEqual(restarted, a);
assert.ok(restarted.http_server.listening);
});
test('port collision rejects startup and resets initialization for retry', async t => {
const occupied = createServer();
occupied.listen(0, '127.0.0.1');
await once(occupied, 'listening');
t.after(async () => {
await stop_forward_server();
await new Promise<void>(resolve => occupied.close(() => resolve()));
});
const bound = occupied.address();
assert.ok(bound && typeof bound === 'object');
await assert.rejects(start_forward_server({...options(), port: bound.port, host: '127.0.0.1'}), /EADDRINUSE/);
const relay = await start_forward_server(options());
assert.ok(relay.http_server.listening);
});
test('shutdown deadline terminates owned stalled HTTP connections and allows restart', async t => {
t.after(stop_forward_server);
const relay = await start_forward_server({...options(), limits: {close_timeout_ms: 30}});
const bound = relay.http_server.address();
assert.ok(bound && typeof bound === 'object');
const stalled = connect_tcp({host: '127.0.0.1', port: bound.port});
t.after(() => stalled.destroy());
await once(stalled, 'connect');
stalled.write('GET / HTTP/1.1\r\nHost: localhost\r\n');
const closed = once(stalled, 'close');
await assert.rejects(relay.stop(), /shutdown timed out/);
await closed;
const restarted = await start_forward_server(options());
assert.ok(restarted.http_server.listening);
});
test('borrowed host and unrelated Socket.IO stay alive through relay stop and restart', async t => {
const host = createServer((_request, response) => response.end('main-alive'));
const control = new EventEmitter();
const input = {forward_id: 0, key, http_server: host, allowed_origins: [] as string[], control};
let relay = await start_forward_server(input);
// Main installs its own Socket.IO service after the embedded relay.
const main_io = new Server(host, {path: '/api/node', serveClient: false});
host.listen(0, '127.0.0.1');
await once(host, 'listening');
const main_client = client(address(host), {path: '/api/node', autoConnect: false, reconnection: false});
t.after(async () => {
main_client.disconnect();
await stop_forward_server();
await main_io.close();
});
await connect(main_client);
for (let count = 0; count < 3; count++) {
await relay.stop();
assert.equal(host.listening, true);
assert.equal(main_client.connected, true);
assert.equal(await (await fetch(`${address(host)}/health`)).text(), 'main-alive');
assert.equal((await fetch(`${address(host)}/api/forward_server/?EIO=4&transport=polling`)).status, 503);
relay = await start_forward_server(input);
const response = await fetch(`${address(host)}/api/forward_server/?EIO=4&transport=polling`);
assert.equal(response.status, 200);
assert.match(await response.text(), /^0\{/);
assert.equal(control.listenerCount('forward_open_session'), 1);
}
assert.ok(host.listenerCount('request') <= 3);
});
test('polling and websocket enforce origins and use authenticated control scope', async t => {
const control = new EventEmitter();
const relay = await start_forward_server({...options(), control});
const sockets: ClientSocket[] = [];
t.after(async () => {
for (const socket of sockets) socket.disconnect();
await stop_forward_server();
});
const scope: SessionScope = {
forward_id: 0, forward_session_id: randomUUID(), node_session_id: 'authenticated-main-node-session',
node_id: 10, bot_id: 20,
};
const now = Date.now();
assert.equal(control_request(control, 'forward_open_session', {...scope, lease_expires_at: now + 60_000}).status, 'success');
const grant_id = randomUUID();
assert.equal(control_request(control, 'forward_authorize_grant', {
forward_session_id: scope.forward_session_id, grant_id, user_id: 30,
permissions: ['bot.snapshot'], expires_at: now + 60_000,
}).status, 'success');
const claims: RelayClaims = {
...scope, v: 1, role: 'web', user_id: 30, grant_id, permissions: ['bot.snapshot'],
issued_at: now, join_before: now + 30_000, expires_at: now + 60_000, jti: randomUUID(),
};
for (const transport of ['polling', 'websocket'] as const) {
const socket = client(address(relay.http_server), {
path: relay.path, transports: [transport], autoConnect: false, reconnection: false,
extraHeaders: {Origin: 'https://console.example'},
});
sockets.push(socket);
await connect(socket);
const ack = await socket.timeout(2_000).emitWithAck('join_room', {token: encode_token(key, claims)});
assert.equal(ack.status, 'success');
assert.equal(ack.data.node_ready, false);
socket.disconnect();
const forbidden = client(address(relay.http_server), {
path: relay.path, transports: [transport], autoConnect: false, reconnection: false,
extraHeaders: {Origin: 'https://attacker.example'},
});
sockets.push(forbidden);
await assert.rejects(connect(forbidden));
}
});
test('default transport and validators relay large log history over polling and websocket', async t => {
const control = new EventEmitter();
const relay = await start_forward_server({...options(), control});
const sockets: ClientSocket[] = [];
t.after(async () => {
for (const socket of sockets) socket.disconnect();
await stop_forward_server();
});
const scope: SessionScope = {forward_id: 0, forward_session_id: randomUUID(),
node_session_id: 'large-history-node-session', node_id: 10, bot_id: 20};
const now = Date.now();
assert.equal(control_request(control, 'forward_open_session', {...scope,
lease_expires_at: now + 60_000}).status, 'success');
const records: BotLogRecord[] = Array.from({length: 20}, (_, index): BotLogRecord => ({id: randomUUID(),
timestamp: now, context_generation: null, seq: index + 1,
segments: Array.from({length: 32}, () => ({message: '\u754c'.repeat(1024),
font_color: '#ffffff', font_style: ['normal']}))}));
const result = {status: 'success', data: {stream_id: randomUUID(), seq: 20, records, truncated: false}};
assert.ok(Buffer.byteLength(JSON.stringify(result)) > 1024 * 1024);
for (const transport of ['polling', 'websocket'] as const) {
const grant_id = randomUUID();
assert.equal(control_request(control, 'forward_authorize_grant', {
forward_session_id: scope.forward_session_id, grant_id, user_id: 30,
permissions: ['bot.logs'], expires_at: now + 60_000,
}).status, 'success');
const base: RelayClaims = {...scope, v: 1, role: 'node', permissions: ['bot.logs'],
issued_at: now, join_before: now + 30_000, expires_at: now + 60_000, jti: randomUUID()};
const node = client(address(relay.http_server), {path: relay.path, transports: [transport],
autoConnect: false, reconnection: false});
sockets.push(node);
node.on('relay_request', (_request, reply) => reply(result));
await connect(node);
assert.equal((await node.timeout(2_000).emitWithAck('join_room', {token: encode_token(key, base)})).status, 'success');
assert.equal((await node.timeout(2_000).emitWithAck('forward_node_ready', {
forward_session_id: scope.forward_session_id})).status, 'success');
const web = client(address(relay.http_server), {path: relay.path, transports: [transport],
autoConnect: false, reconnection: false});
sockets.push(web);
await connect(web);
assert.equal((await web.timeout(2_000).emitWithAck('join_room', {token: encode_token(key, {
...base, role: 'web', grant_id, user_id: 30, jti: randomUUID(),
})})).status, 'success');
const history = await web.timeout(5_000).emitWithAck('relay_request', {
request_id: randomUUID(), method: 'get_log_history', payload: {},
});
assert.deepEqual(history, result);
assert.equal(node.connected, true);
assert.equal(web.connected, true);
web.disconnect();
node.disconnect();
}
});
test('control rebinding removes only owned listeners and stale disconnect cannot disable new control', async t => {
const first = new EventEmitter();
const second = new EventEmitter();
let unrelated = 0;
first.on('forward_open_session', () => { unrelated++; });
const relay = await start_forward_server({...options(), control: first});
t.after(stop_forward_server);
const dispose = relay.bind_control(second);
assert.equal(first.listenerCount('forward_open_session'), 1);
first.emit('disconnect');
assert.equal(relay.store.control_available, true);
first.emit('forward_open_session');
assert.equal(unrelated, 1);
second.emit('disconnect');
assert.equal(relay.store.control_available, false);
dispose();
assert.equal(second.listenerCount('forward_open_session'), 0);
});
test('borrowed host dispatches custom namespace WebSocket without closing an existing Main connection', async t => {
const host = createServer((_request, response) => response.end('main'));
const main_io = new Server(host, {path: '/api/node', serveClient: false});
host.listen(0, '127.0.0.1');
await once(host, 'listening');
const main_client = client(address(host), {path: '/api/node', autoConnect: false, reconnection: false});
await connect(main_client);
const relay = await start_forward_server({forward_id: 0, key, http_server: host,
allowed_origins: [], namespace: '/relay'});
const web = client(`${address(host)}/relay`, {path: relay.path,
transports: ['websocket'], autoConnect: false, reconnection: false});
t.after(async () => {
web.disconnect();
main_client.disconnect();
await stop_forward_server();
await main_io.close();
});
await connect(web);
const result = await web.timeout(1_000).emitWithAck('forward_open_session', {});
assert.equal(result.status, 'error');
assert.equal(result.data.code, 'FORBIDDEN');
await relay.stop();
assert.equal(main_client.connected, true);
assert.equal(host.listening, true);
});
test('closing and relistening the borrowed host reuses its relay dispatcher', async t => {
const host = createServer((_request, response) => response.end('main'));
const input = {forward_id: 0, key, http_server: host, allowed_origins: [] as string[]};
let relay = await start_forward_server(input);
host.listen(0, '127.0.0.1');
await once(host, 'listening');
t.after(async () => {
await stop_forward_server();
if (host.listening) await new Promise<void>(resolve => host.close(() => resolve()));
});
const listener_counts = {request: host.listenerCount('request'), upgrade: host.listenerCount('upgrade')};
await new Promise<void>(resolve => host.close(() => resolve()));
await relay.stop();
relay = await start_forward_server(input);
host.listen(0, '127.0.0.1');
await once(host, 'listening');
const web = client(address(host), {path: relay.path, transports: ['websocket'],
autoConnect: false, reconnection: false});
t.after(() => web.disconnect());
await connect(web);
assert.equal(host.listenerCount('request'), listener_counts.request);
assert.equal(host.listenerCount('upgrade'), listener_counts.upgrade);
assert.equal(await (await fetch(`${address(host)}/`)).text(), 'main');
});
+791
View File
@@ -0,0 +1,791 @@
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {createServer} from 'node:http';
import {once} from 'node:events';
import {setTimeout as delay} from 'node:timers/promises';
import test from 'node:test';
import {Server} from 'socket.io';
import {io, type Socket as Client} from 'socket.io-client';
import {create_session_store} from '../runtime/session_store.ts';
import {install_relay_routes} from '../runtime/relay_routes.ts';
import {DEFAULT_LIMITS} from '../utils/relay_config.ts';
import {encode_token} from '../utils/token_utils.ts';
import type {BotLogRecord, ForwardAck, RelayClaims, RelayLimits, RelayMethod, RelayRequest, SessionScope} from '../types.ts';
const key = 'relay-integration-test-secret-only';
const permissions: RelayMethod[] = ['bot.snapshot', 'bot.logs', 'bot.subscribe', 'bot.unsubscribe', 'bot.chat', 'execute_function', 'bot.files'];
const scope = (): SessionScope => ({forward_id: 1, forward_session_id: randomUUID(), node_session_id: randomUUID(), node_id: 2, bot_id: 3});
function token(s: SessionScope, role: 'node' | 'web', grant_id?: string, expires_at = Date.now() + 10_000, user_id = 4,
granted = permissions): string {
const now = Date.now();
const claims: RelayClaims = {...s, role, v: 1, permissions: granted, issued_at: now, join_before: Math.min(now + 1000, expires_at),
expires_at, jti: randomUUID(), ...(role === 'web' ? {grant_id, user_id} : {})};
return encode_token(key, claims);
}
const request = (method: RelayRequest['method'], payload: unknown = {}, request_id = randomUUID()) => ({method, payload, request_id});
function received(socket: Client, event: string): Promise<any[]> {return new Promise(resolve => socket.once(event, (...args) => resolve(args)));}
async function ack(socket: Client, name: string, value: unknown): Promise<ForwardAck<any>> {
return socket.timeout(1500).emitWithAck(name, value);
}
function code(result: ForwardAck<unknown>, expected: string): void {
assert.equal(result.status, 'error');
if (result.status === 'error') assert.equal(result.data.code, expected);
}
async function fixture(overrides: Partial<RelayLimits> = {}) {
const http = createServer();
const server = new Server(http);
const namespace = server.of('/relay');
const statuses: unknown[] = [];
const store = create_session_store({forward_id: 1, key, limits: {...DEFAULT_LIMITS, rpc_timeout_ms: 120, ...overrides},
on_status: value => statuses.push(value)});
const dispose = install_relay_routes(namespace, store);
http.listen(0, '127.0.0.1');
await once(http, 'listening');
const port = (http.address() as {port: number}).port;
const clients: Client[] = [];
const s = scope();
store.open({...s, lease_expires_at: Date.now() + 20_000});
async function connect() {
const socket = io(`http://127.0.0.1:${port}/relay`, {transports: ['websocket'], reconnection: false, forceNew: true});
clients.push(socket);
await received(socket, 'connect');
return socket;
}
async function node(granted = permissions, ready = true) {
const socket = await connect();
assert.equal((await ack(socket, 'join_room', {token: token(s, 'node', undefined, Date.now() + 10_000, 4, granted)})).status, 'success');
if (ready) assert.equal((await ack(socket, 'forward_node_ready', {forward_session_id: s.forward_session_id})).status, 'success');
return socket;
}
async function web(grant_id = randomUUID(), granted = permissions, expires_at = Date.now() + 10_000) {
store.authorize({forward_session_id: s.forward_session_id, grant_id, user_id: 4, permissions: granted, expires_at});
const socket = await connect();
assert.equal((await ack(socket, 'join_room', {token: token(s, 'web', grant_id, expires_at, 4, granted)})).status, 'success');
return {socket, grant_id};
}
async function close() {
store.close(); dispose();
for (const client of clients) client.close();
await server.close();
}
return {store, namespace, connect, node, web, s, close, statuses, dispose, http};
}
function snapshot(bot_id: number, seq = 0) {
return {status: 'success', data: {bot_id, bot_status: 'offline', context_exists: false,
context_generation: null as string | null, stream_id: randomUUID(), seq, captured_at: Date.now()}};
}
test('file RPCs require bot.files, match requested ranges and redispatch without caching ACK bodies', async () => {
const f = await fixture({requests_per_second: 100});
try {
const node = await f.node(); const web = await f.web(randomUUID(), ['bot.files']);
const limited = await f.web(randomUUID(), ['bot.snapshot']);
const file_id = randomUUID();
const listing = request('get_data_file_list');
const read = request('get_file_data', {id: file_id});
let calls = 0;
let response: unknown = {status: 'success', data: {listing_id: randomUUID(), expires_at: Date.now() + 1000,
partial: false, children: []}};
let held: ((value: unknown) => void) | undefined;
let hold = false;
node.on('relay_request', (_value, reply) => {calls++; if (hold) held = reply; else reply(response);});
for (const command of [listing, read]) code(await ack(limited.socket, 'relay_request', command), 'FORBIDDEN');
code(await ack(node, 'relay_request', listing), 'FORBIDDEN');
code(await ack(web.socket, 'relay_request', request('bot.files')), 'INVALID_REQUEST');
assert.deepEqual(await ack(web.socket, 'relay_request', listing), response);
assert.deepEqual(await ack(web.socket, 'relay_request', listing), response);
assert.equal(calls, 2);
const sessions = (f.store as any).sessions as Map<string, any>;
const entries = sessions.get(f.s.forward_session_id).grants.get(web.grant_id).dedupe as Map<string, any>;
assert.equal(entries.get(listing.request_id).result, undefined);
assert.equal(entries.get(listing.request_id).completed, true);
response = {status: 'success', data: {id: file_id, name: 'data.bin', encoding: 'base64',
offset: 0, next_offset: 1, total_bytes: 1, eof: true, data: 'YQ=='}};
assert.deepEqual(await ack(web.socket, 'relay_request', read), response);
assert.deepEqual(await ack(web.socket, 'relay_request', read), response);
assert.equal(entries.get(read.request_id).result, undefined);
code(await ack(web.socket, 'relay_request', {...read, payload: {id: randomUUID()}}), 'INVALID_REQUEST');
code(await ack(web.socket, 'relay_request', {...listing, method: 'get_file_data', payload: {id: file_id}}), 'INVALID_REQUEST');
for (const changes of [{id: randomUUID()}, {offset: 1, next_offset: 2, total_bytes: 2},
{data: 'YWI=', next_offset: 2, total_bytes: 2}]) {
const original = response as {status: string; data: Record<string, unknown>};
response = {...original, data: {...original.data, ...changes}};
const command = request('get_file_data', {id: file_id, length: 1});
code(await ack(web.socket, 'relay_request', command), 'INVALID_REQUEST');
assert.equal(entries.get(command.request_id).result, undefined);
response = original;
}
response = {status: 'success', data: {id: file_id, name: 'data.bin', encoding: 'base64',
offset: 7, next_offset: 7, total_bytes: 7, eof: true, data: ''}};
assert.deepEqual(await ack(web.socket, 'relay_request', request('get_file_data', {id: file_id, offset: 7, length: 1})), response);
hold = true;
const pending = ack(web.socket, 'relay_request', read);
while (!held) await delay(1);
code(await ack(web.socket, 'relay_request', read), 'RATE_LIMITED');
held({status: 'error', data: {code: 'NO_CONTEXT', message: 'No context', retryable: false}});
code(await pending, 'NO_CONTEXT');
assert.equal(entries.get(read.request_id).result, undefined);
hold = false;
response = {status: 'error', data: {code: 'BOT_NOT_ONLINE', message: 'Offline', retryable: false}};
const before = calls;
code(await ack(web.socket, 'relay_request', read), 'BOT_NOT_ONLINE');
assert.equal(calls, before + 1);
f.store.set_control_available(false);
code(await ack(web.socket, 'relay_request', listing), 'BOT_NOT_ONLINE');
assert.equal(calls, before + 2);
} finally {await f.close();}
});
test('file RPC redispatch rechecks Node permission and readiness', async () => {
const f = await fixture();
try {
const node = await f.node(['bot.snapshot']);
const web = await f.web(randomUUID(), ['bot.files']);
const command = request('get_data_file_list');
code(await ack(web.socket, 'relay_request', command), 'FORBIDDEN');
const replacement = await f.node(['bot.files'], false);
code(await ack(web.socket, 'relay_request', command), 'NODE_OFFLINE');
await ack(replacement, 'forward_node_ready', {forward_session_id: f.s.forward_session_id});
replacement.on('relay_request', (_value, reply) => reply({status: 'success', data: {
listing_id: randomUUID(), expires_at: 1, partial: true, children: []}}));
assert.equal((await ack(web.socket, 'relay_request', command)).status, 'success');
await f.node(['bot.snapshot']);
code(await ack(web.socket, 'relay_request', command), 'FORBIDDEN');
node.close();
} finally {await f.close();}
});
test('RPC responses stay private, request IDs are grant-scoped, dedupe conflicts and chat replay are rejected', async () => {
const f = await fixture();
try {
const node = await f.node();
let calls = 0;
node.on('relay_request', (value, reply) => {calls++; reply(value.method === 'bot.chat' ? {status: 'success', data: {accepted: true}} : snapshot(f.s.bot_id, calls));});
const a = await f.web(); const b = await f.web();
let leaked = 0;
b.socket.on('relay_request', () => leaked++);
const id = randomUUID();
const [ra, rb] = await Promise.all([ack(a.socket, 'relay_request', request('bot.snapshot', {}, id)), ack(b.socket, 'relay_request', request('bot.snapshot', {}, id))]);
assert.equal(ra.status, 'success'); assert.equal(rb.status, 'success');
assert.equal(calls, 2); assert.equal(leaked, 0);
assert.deepEqual(await ack(a.socket, 'relay_request', request('bot.snapshot', {}, id)), ra);
assert.equal(calls, 2);
code(await ack(a.socket, 'relay_request', request('bot.logs', {limit: 1}, id)), 'INVALID_REQUEST');
const chat = request('bot.chat', {message: 'hello'});
assert.equal((await ack(a.socket, 'relay_request', chat)).status, 'success');
code(await ack(a.socket, 'relay_request', chat), 'CANCELLED');
assert.equal(calls, 3);
} finally {await f.close();}
});
test('execute_function forwards exact payloads, validates ACKs and rejects pending and completed replay', async () => {
const f = await fixture();
try {
const node = await f.node(); const web = await f.web(randomUUID(), ['execute_function']);
const payload = {function: 'movement/navigation', name: 'go_to', args: [{x: 1, y: 2}, true, null]};
const command = request('execute_function', payload);
let calls = 0;
let reply!: (value: unknown) => void;
const dispatched = new Promise<void>(resolve => node.on('relay_request', (value, callback) => {
calls++;
assert.equal(value.method, command.method);
assert.deepEqual(value.payload, payload);
assert.equal(value.forward_session_id, f.s.forward_session_id);
reply = callback; resolve();
}));
const pending = ack(web.socket, 'relay_request', command);
await dispatched;
code(await ack(web.socket, 'relay_request', command), 'RATE_LIMITED');
reply({status: 'success', data: true});
assert.deepEqual(await pending, {status: 'success', data: true});
code(await ack(web.socket, 'relay_request', command), 'CANCELLED');
code(await ack(web.socket, 'relay_request', {...command, payload: {...payload, args: []}}), 'INVALID_REQUEST');
assert.equal(calls, 1);
node.removeAllListeners('relay_request');
let response: unknown = {status: 'success', data: false};
node.on('relay_request', (_value, callback) => {calls++; callback(response);});
assert.deepEqual(await ack(web.socket, 'relay_request', request('execute_function', payload)), response);
response = {status: 'success', data: {accepted: true}};
const invalid_ack = request('execute_function', payload);
code(await ack(web.socket, 'relay_request', invalid_ack), 'INVALID_REQUEST');
code(await ack(web.socket, 'relay_request', invalid_ack), 'CANCELLED');
f.store.set_control_available(false);
code(await ack(web.socket, 'relay_request', request('execute_function', payload)), 'RELAY_UNAVAILABLE');
assert.equal(calls, 3);
} finally {await f.close();}
});
test('execute_function requires permission in Web token, grant and Node token', async () => {
const f = await fixture();
try {
const node = await f.node(['bot.chat']);
let calls = 0; node.on('relay_request', () => calls++);
const grant_id = randomUUID();
f.store.authorize({forward_session_id: f.s.forward_session_id, grant_id, user_id: 4,
permissions, expires_at: Date.now() + 10_000});
const limited = await f.connect();
assert.equal((await ack(limited, 'join_room', {token: token(f.s, 'web', grant_id,
Date.now() + 10_000, 4, ['bot.chat'])})).status, 'success');
const command = request('execute_function', {function: 'movement', name: 'stop', args: []});
code(await ack(limited, 'relay_request', command), 'FORBIDDEN');
const web = await f.web(randomUUID(), ['execute_function']);
code(await ack(web.socket, 'relay_request', command), 'FORBIDDEN');
const restricted_grant = randomUUID();
f.store.authorize({forward_session_id: f.s.forward_session_id, grant_id: restricted_grant, user_id: 4,
permissions: ['bot.chat'], expires_at: Date.now() + 10_000});
const excessive = await f.connect();
code(await ack(excessive, 'join_room', {token: token(f.s, 'web', restricted_grant,
Date.now() + 10_000, 4, ['execute_function'])}), 'FORBIDDEN');
assert.equal(calls, 0);
} finally {await f.close();}
});
test('Node replacement cancels pending, ignores late ACK, resets readiness, and old disconnect cannot clear new Node', async () => {
const f = await fixture();
try {
const old = await f.node(); const web = await f.web();
let late: (value: unknown) => void;
const received = new Promise<void>(resolve => old.on('relay_request', (_value, reply) => {late = reply; resolve();}));
const response = ack(web.socket, 'relay_request', request('bot.snapshot'));
await received;
const replacement = await f.connect();
assert.equal((await ack(replacement, 'join_room', {token: token(f.s, 'node')})).status, 'success');
code(await response, 'NODE_OFFLINE');
late!(snapshot(f.s.bot_id, 999));
code(await ack(web.socket, 'relay_request', request('bot.snapshot')), 'NODE_OFFLINE');
replacement.on('relay_request', (_value, reply) => reply(snapshot(f.s.bot_id, 2)));
await ack(replacement, 'forward_node_ready', {forward_session_id: f.s.forward_session_id});
old.disconnect();
assert.equal((await ack(web.socket, 'relay_request', request('bot.snapshot'))).data.seq, 2);
} finally {await f.close();}
});
test('tab_complete dispatch rejects ranges beyond request text and accepts exact boundaries', async () => {
const f = await fixture();
try {
const node = await f.node(); const web = await f.web();
let range = {start: 0, length: 0};
node.on('relay_request', (_value, reply) => reply({status: 'success', data: {matches: ['/help'], ...range}}));
for (const [text, start, length] of [['', 0, 0], ['/h', 0, 2], ['/h', 2, 0], ['界'.repeat(256), 128, 128]] as const) {
range = {start, length};
assert.deepEqual(await ack(web.socket, 'relay_request', {method: 'tab_complete', payload: {text}, request_id: randomUUID()}),
{status: 'success', data: {matches: ['/help'], start, length}});
}
for (const [start, length] of [[2, 0], [1, 1], [0, 2]]) {
range = {start, length};
code(await ack(web.socket, 'relay_request', {method: 'tab_complete', payload: {text: '/'}, request_id: randomUUID()}), 'INVALID_REQUEST');
}
} finally {await f.close();}
});
test('bot_event is rejected while bot_log checks permission, scope and sequence', async () => {
const f = await fixture();
try {
const node = await f.node(); const web = await f.web();
const limited = await f.web(randomUUID(), ['bot.subscribe']);
await ack(web.socket, 'relay_request', request('bot.subscribe', {events: ['status_update', 'log']}));
await ack(limited.socket, 'relay_request', request('bot.subscribe', {events: ['status_update']}));
let limited_events = 0; limited.socket.on('bot_event', () => limited_events++);
const stream = randomUUID();
const event = {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id, context_generation: null as string | null,
stream_id: stream, seq: 1, emitted_at: Date.now(), name: 'status_update', payload: {status: 'offline', context_exists: false}};
let web_events = 0; web.socket.on('bot_event', () => web_events++);
code(await ack(node, 'bot_event', event), 'FORBIDDEN');
code(await ack(web.socket, 'bot_event', {...event, seq: 2}), 'FORBIDDEN');
code(await ack(web.socket, 'forward_open_session', {}), 'FORBIDDEN');
node.emit('bot_event', {...event, seq: 3});
node.on('relay_request', (_value, reply) => reply(snapshot(f.s.bot_id)));
assert.equal((await ack(web.socket, 'relay_request', request('bot.snapshot'))).status, 'success');
assert.equal(web_events, 0);
const log = {...event, name: 'log', payload: {records: [] as never[]}};
let limited_logs = 0; limited.socket.on('bot_log', () => limited_logs++);
const delivered = received(web.socket, 'bot_log');
assert.equal((await ack(node, 'bot_log', log)).status, 'success');
assert.deepEqual((await delivered)[0], log);
code(await ack(node, 'bot_log', log), 'INVALID_REQUEST');
code(await ack(web.socket, 'bot_log', {...log, seq: 2}), 'FORBIDDEN');
code(await ack(node, 'bot_log', {...log, seq: 2, bot_id: f.s.bot_id + 1}), 'FORBIDDEN');
code(await ack(node, 'bot_log', {...event, seq: 2}), 'INVALID_REQUEST');
assert.equal(limited_logs, 0);
assert.equal(limited_events, 0);
} finally {await f.close();}
});
test('get_context_info maps to snapshot permissions and validates Node ACKs', async () => {
const f = await fixture();
try {
const node = await f.node(); const web = await f.web(randomUUID(), ['bot.snapshot']);
const limited = await f.web(randomUUID(), ['bot.logs']);
let calls = 0;
let response: unknown = {status: 'success', data: [{name: 'health',
i18n: {lang: 'en', name: 'Health', description: 'Current HP'}, value: {current: 20}}]};
node.on('relay_request', (value, reply) => {
calls++;
assert.equal(value.method, 'get_context_info');
assert.deepEqual(value.payload, {});
assert.equal(value.forward_session_id, f.s.forward_session_id);
reply(response);
});
assert.deepEqual(await ack(web.socket, 'relay_request', request('get_context_info')), response);
code(await ack(limited.socket, 'relay_request', request('get_context_info')), 'FORBIDDEN');
code(await ack(node, 'relay_request', request('get_context_info')), 'FORBIDDEN');
code(await ack(web.socket, 'relay_request', request('get_context_info', {name: 'health'})), 'INVALID_REQUEST');
assert.equal(calls, 1);
response = {status: 'success', data: [{name: 'health', value: 20}]};
code(await ack(web.socket, 'relay_request', request('get_context_info')), 'INVALID_REQUEST');
await f.node(['bot.logs']);
code(await ack(web.socket, 'relay_request', request('get_context_info')), 'FORBIDDEN');
} finally {await f.close();}
});
test('completed get_context_info IDs recheck Node online state, readiness and permissions', async () => {
const f = await fixture();
try {
const node = await f.node(); const web = await f.web();
let calls = 0;
node.on('relay_request', (_value, reply) => {
calls++;
reply(calls === 1 ? {status: 'success', data: []}
: {status: 'error', data: {code: 'BOT_NOT_ONLINE', message: 'Offline', retryable: false}});
});
const read = request('get_context_info');
assert.deepEqual(await ack(web.socket, 'relay_request', read), {status: 'success', data: []});
code(await ack(web.socket, 'relay_request', read), 'BOT_NOT_ONLINE');
assert.equal(calls, 2);
const replacement = await f.node(permissions, false);
code(await ack(web.socket, 'relay_request', read), 'NODE_OFFLINE');
replacement.on('relay_request', (_value, reply) => reply({status: 'success', data: [{name: 'health',
i18n: {lang: '', name: '', description: ''}, value: 10}]}));
await ack(replacement, 'forward_node_ready', {forward_session_id: f.s.forward_session_id});
assert.equal((await ack(web.socket, 'relay_request', read)).data[0].value, 10);
code(await ack(web.socket, 'relay_request', request('bot.snapshot', {}, read.request_id)), 'INVALID_REQUEST');
const restricted = await f.node(['bot.logs']);
code(await ack(web.socket, 'relay_request', read), 'FORBIDDEN');
const server_node = f.namespace.sockets.get(restricted.id!)!;
const disconnected = once(server_node, 'disconnect'); restricted.disconnect(); await disconnected;
code(await ack(web.socket, 'relay_request', read), 'NODE_OFFLINE');
} finally {await f.close();}
});
test('get_context_info duplicate pending and reused payload checks survive fresh dispatch', async () => {
const f = await fixture({max_dedupe_entries: 1});
try {
const node = await f.node(); const web = await f.web();
let calls = 0;
let reply!: (value: unknown) => void;
const incoming = received(node, 'relay_request');
node.on('relay_request', (_value, callback) => {calls++; reply = callback;});
const read = request('get_context_info');
const first = ack(web.socket, 'relay_request', read);
await incoming;
code(await ack(web.socket, 'relay_request', read), 'RATE_LIMITED');
code(await ack(web.socket, 'relay_request', request('bot.snapshot', {}, read.request_id)), 'INVALID_REQUEST');
reply({status: 'success', data: []}); await first;
const repeated = received(node, 'relay_request');
const second = ack(web.socket, 'relay_request', read); await repeated;
code(await ack(web.socket, 'relay_request', read), 'RATE_LIMITED');
reply({status: 'success', data: []}); await second;
assert.equal(calls, 2);
const cached = request('bot.snapshot');
node.removeAllListeners('relay_request');
node.on('relay_request', (_value, callback) => callback(snapshot(f.s.bot_id)));
await ack(web.socket, 'relay_request', cached);
code(await ack(web.socket, 'relay_request', request('get_context_info', {}, cached.request_id)), 'INVALID_REQUEST');
} finally {await f.close();}
});
test('info_update auto-pushes exact envelopes and shares log stream and sequence state', async () => {
const f = await fixture();
try {
const node = await f.node(); const web = await f.web(randomUUID(), ['bot.snapshot']);
const logs = await f.web(randomUUID(), ['bot.logs']);
let info_leaks = 0; let log_leaks = 0;
logs.socket.on('info_update', () => info_leaks++);
web.socket.on('bot_log', () => log_leaks++);
const update = {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id,
context_generation: randomUUID(), stream_id: randomUUID(), seq: 1, emitted_at: Date.now(),
data: {name: 'health', value: {current: 20}}};
const log = {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id, context_generation: null as string | null,
stream_id: update.stream_id, seq: 2, emitted_at: Date.now(), name: 'log', payload: {records: [] as BotLogRecord[]}};
const first = received(web.socket, 'info_update');
assert.equal((await ack(node, 'info_update', update)).status, 'success');
assert.deepEqual((await first)[0], update);
const next = received(logs.socket, 'bot_log');
assert.equal((await ack(node, 'bot_log', log)).status, 'success');
assert.deepEqual((await next)[0], log);
code(await ack(node, 'info_update', {...update, seq: 2}), 'INVALID_REQUEST');
const third = received(web.socket, 'info_update');
node.emit('info_update', {...update, seq: 3});
assert.equal((await third)[0].seq, 3);
code(await ack(node, 'bot_log', {...log, seq: 3}), 'INVALID_REQUEST');
code(await ack(web.socket, 'info_update', {...update, seq: 4}), 'FORBIDDEN');
for (const invalid of [{...update, seq: 4, context_generation: null as string | null}, {...update, seq: 4, name: 'info_update'},
{...update, seq: 4, payload: update.data}]) {
code(await ack(node, 'info_update', invalid), 'INVALID_REQUEST');
}
for (const invalid of [{...update, seq: 4, bot_id: f.s.bot_id + 1},
{...update, seq: 4, forward_session_id: randomUUID()}]) {
code(await ack(node, 'info_update', invalid), 'FORBIDDEN');
}
const replacement = {...update, stream_id: randomUUID(), seq: 0};
assert.equal((await ack(node, 'info_update', replacement)).status, 'success');
code(await ack(node, 'bot_log', {...log, seq: 4}), 'RESYNC_REQUIRED');
assert.equal(info_leaks, 0); assert.equal(log_leaks, 0);
} finally {await f.close();}
});
test('info_update enforces readiness and Node and Web token permissions independently of subscriptions', async () => {
const f = await fixture();
try {
const node = await f.node(permissions, false);
const web = await f.web();
const update = {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id,
context_generation: randomUUID(), stream_id: randomUUID(), seq: 1, emitted_at: Date.now(),
data: {name: 'health', value: 20}};
code(await ack(node, 'info_update', update), 'NODE_OFFLINE');
const restricted = await f.node(['bot.logs']);
code(await ack(restricted, 'info_update', update), 'FORBIDDEN');
const full = await f.node();
const narrow = await f.connect();
assert.equal((await ack(narrow, 'join_room', {token: token(f.s, 'web', web.grant_id,
Date.now() + 10_000, 4, ['bot.logs'])})).status, 'success');
let leaked = 0; narrow.on('info_update', () => leaked++);
code(await ack(narrow, 'relay_request', request('get_context_info')), 'FORBIDDEN');
assert.equal((await ack(full, 'info_update', update)).status, 'success');
const allowed = await f.web(randomUUID(), ['bot.snapshot']);
const pushed = received(allowed.socket, 'info_update');
await ack(full, 'info_update', {...update, seq: 2}); await pushed;
assert.equal(leaked, 0);
} finally {await f.close();}
});
test('logs and info updates share the Node event rate and slow-Web buffer protection', async () => {
for (const first_info of [false, true]) {
const f = await fixture({events_per_second: 2, max_buffered_packets: 2});
try {
const node = await f.node(); const web = await f.web();
const info = {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id,
context_generation: randomUUID(), stream_id: randomUUID(), seq: 1, emitted_at: Date.now(),
data: {name: 'health', value: 20}};
const log = {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id,
context_generation: null as string | null, stream_id: info.stream_id, seq: 2, emitted_at: Date.now(),
name: 'log', payload: {records: [] as BotLogRecord[]}};
const server_web = f.namespace.sockets.get(web.socket.id!)!;
server_web.conn.transport.writable = false;
assert.equal((await ack(node, first_info ? 'info_update' : 'bot_log', first_info ? info : log)).status, 'success');
assert.equal((await ack(node, first_info ? 'bot_log' : 'info_update',
first_info ? log : {...info, seq: 3})).status, 'success');
const closed = received(node, 'forward_session_closed');
node.emit('info_update', {...info, seq: 4});
assert.equal((await closed)[0].code, 'RATE_LIMITED');
} finally {await f.close();}
}
const f = await fixture({max_buffered_packets: 2});
try {
const node = await f.node(); const web = await f.web(randomUUID(), ['bot.snapshot']);
const server_web = f.namespace.sockets.get(web.socket.id!)!;
server_web.conn.transport.writable = false;
const info = {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id,
context_generation: randomUUID(), stream_id: randomUUID(), seq: 1, emitted_at: Date.now(),
data: {name: 'health', value: 20}};
for (let seq = 1; seq <= 3; seq++) assert.equal((await ack(node, 'info_update', {...info, seq})).status, 'success');
assert.equal(server_web.connected, false);
assert.ok((server_web.conn as unknown as {writeBuffer: unknown[]}).writeBuffer.length <= 4);
} finally {await f.close();}
});
test('async join awaits the adapter and rechecks revoked grant before binding', async () => {
const f = await fixture();
try {
const grant_id = randomUUID();
f.store.authorize({forward_session_id: f.s.forward_session_id, grant_id, user_id: 4, permissions, expires_at: Date.now() + 10_000});
const client = await f.connect();
const socket = f.namespace.sockets.get(client.id!)!;
const original = socket.join.bind(socket);
let release!: () => void;
let entered!: () => void;
const joining = new Promise<void>(resolve => {entered = resolve;});
socket.join = async room => {entered(); await new Promise<void>(resolve => {release = resolve;}); await original(room);};
let completed = false;
const joined = ack(client, 'join_room', {token: token(f.s, 'web', grant_id)}).then(value => {completed = true; return value;});
await joining;
assert.equal(completed, false);
code(await ack(client, 'reauth', {token: token(f.s, 'web', grant_id)}), 'INVALID_REQUEST');
f.store.revoke_grant({forward_session_id: f.s.forward_session_id, grant_id, reason: 'revoked_during_join'});
release();
await joined.catch((): void => {});
await delay(0);
assert.equal(socket.rooms.size, 0);
} finally {await f.close();}
});
test('grant connection replacement and immutable identity, control disconnect allows reads but blocks joins, reauth, chat', async () => {
const f = await fixture();
try {
const node = await f.node(); node.on('relay_request', (_value, reply) => reply(snapshot(f.s.bot_id)));
const web = await f.web();
const old_closed = received(web.socket, 'forward_session_closed');
const replacement = await f.connect();
const credential = token(f.s, 'web', web.grant_id);
const joined = await ack(replacement, 'join_room', {token: credential});
assert.equal(joined.status, 'success'); assert.equal((await old_closed)[0].reason, 'grant_connection_replaced');
assert.deepEqual(await ack(replacement, 'join_room', {token: credential}), joined);
code(await ack(replacement, 'reauth', {token: token(f.s, 'web', web.grant_id, Date.now() + 10_000, 5)}), 'FORBIDDEN');
f.store.set_control_available(false);
assert.equal((await ack(replacement, 'relay_request', request('bot.snapshot'))).status, 'success');
code(await ack(replacement, 'relay_request', request('bot.chat', {message: 'no'})), 'RELAY_UNAVAILABLE');
code(await ack(replacement, 'reauth', {token: credential}), 'RELAY_UNAVAILABLE');
const another = await f.connect(); code(await ack(another, 'join_room', {token: credential}), 'RELAY_UNAVAILABLE');
} finally {await f.close();}
});
test('timeouts, pending caps and invalid Node responses return one strict ACK', async () => {
const f = await fixture({max_pending_per_web: 1});
try {
const node = await f.node(); const web = await f.web();
let started!: () => void;
const received = new Promise<void>(resolve => {started = resolve;});
node.on('relay_request', () => started());
const pending = ack(web.socket, 'relay_request', request('bot.snapshot'));
await received;
code(await ack(web.socket, 'relay_request', request('bot.snapshot')), 'RATE_LIMITED');
code(await pending, 'TIMEOUT');
node.removeAllListeners('relay_request');
node.on('relay_request', (_value, reply) => reply({status: 'success', data: {bot_id: 999}}));
code(await ack(web.socket, 'relay_request', request('bot.snapshot')), 'INVALID_REQUEST');
node.removeAllListeners('relay_request');
node.on('relay_request', (_value, reply) => reply({status: 'success', data: {stream_id: randomUUID(), seq: 2,
truncated: false, records: [1, 2].map((seq): BotLogRecord => ({id: randomUUID(), timestamp: Date.now(),
context_generation: null, seq, segments: []}))}}));
code(await ack(web.socket, 'relay_request', request('bot.logs', {limit: 1})), 'INVALID_REQUEST');
assert.equal((f.namespace.sockets.get(node.id!)! as unknown as {acks: Map<number, unknown>}).acks.size, 0);
} finally {await f.close();}
});
test('grant renewal does not silently extend socket lease and revoke releases pending transport timers', async () => {
const f = await fixture();
try {
const node = await f.node(); const expires_at = Date.now() + 180;
const web = await f.web(randomUUID(), permissions, expires_at);
f.store.authorize({forward_session_id: f.s.forward_session_id, grant_id: web.grant_id, user_id: 4, permissions, expires_at: Date.now() + 10_000});
const closed = received(web.socket, 'forward_session_closed');
assert.equal((await closed)[0].code, 'TOKEN_EXPIRED');
const renewed = await f.web();
let started!: () => void; const request_received = new Promise<void>(resolve => {started = resolve;});
node.on('relay_request', () => started());
const response = ack(renewed.socket, 'relay_request', request('bot.snapshot'));
await request_received;
f.store.revoke_session({forward_session_id: f.s.forward_session_id, reason: 'ownership_changed'});
// The close notification may terminate the client before its pending ACK arrives.
await response.catch((): void => {});
assert.equal(f.store.size, 0);
assert.equal((f.namespace.sockets.get(node.id!) as unknown as {acks?: Map<number, unknown>})?.acks?.size ?? 0, 0);
} finally {await f.close();}
});
test('initial-ready unused and final-Web idle reclamation release sessions with explicit reason', async () => {
const unused = await fixture({unused_timeout_ms: 45});
try {
const node = await unused.node();
const closed = await received(node, 'forward_session_closed');
assert.equal(closed[0].reason, 'unused_session'); assert.equal(unused.store.size, 0);
} finally {await unused.close();}
const idle = await fixture({idle_timeout_ms: 45});
try {
const node = await idle.node(); const web = await idle.web();
const closed = received(node, 'forward_session_closed'); web.socket.disconnect();
assert.equal((await closed)[0].reason, 'last_web_left'); assert.equal(idle.store.size, 0);
} finally {await idle.close();}
});
test('slow-client status buffer overflow and forbidden event floods require explicit recovery', async () => {
const f = await fixture({requests_per_second: 6, max_buffered_packets: 2});
try {
const node = await f.node(); const web = await f.web();
await ack(web.socket, 'relay_request', request('bot.subscribe', {events: ['status_update']}));
const server_web = f.namespace.sockets.get(web.socket.id!)!;
const transport = server_web.conn as unknown as {writeBuffer: unknown[]};
const actual = transport.writeBuffer;
let checks = 0;
Object.defineProperty(transport, 'writeBuffer', {configurable: true, get: () => ++checks === 1 ? [null, null] : actual, set: () => {}});
const closed = received(web.socket, 'forward_session_closed');
const event = {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id, context_generation: null as string | null,
stream_id: randomUUID(), seq: 1, emitted_at: Date.now(), name: 'status_update', payload: {status: 'offline', context_exists: false}};
const replacement = await f.connect();
const first = ack(replacement, 'join_room', {token: token(f.s, 'node')});
await delay(10);
Object.defineProperty(transport, 'writeBuffer', {configurable: true, writable: true, value: actual});
await first;
// Notification is emitted before disconnect; packet delivery depends on Engine.IO's buffer flush.
assert.equal(server_web.connected, false);
await Promise.race([closed, delay(30)]);
code(await ack(replacement, 'bot_event', event), 'FORBIDDEN');
const node_closed = received(replacement, 'forward_session_closed');
for (let seq = 2; seq <= 10; seq++) replacement.emit('bot_event', {...event, seq});
assert.equal((await node_closed)[0].code, 'RATE_LIMITED');
} finally {await f.close();}
});
test('scope conflicts and route disposer preserve HTTP ownership', async () => {
const f = await fixture();
try {
assert.throws(() => f.store.open({...f.s, forward_session_id: randomUUID(), node_session_id: randomUUID(), lease_expires_at: Date.now() + 10_000}), {code: 'SESSION_STALE'});
assert.throws(() => f.store.revoke_session({forward_session_id: f.s.forward_session_id, reason: 'x'.repeat(257)}), {code: 'INVALID_REQUEST'});
const node = await f.node();
const extra = install_relay_routes(f.namespace, f.store);
assert.equal(f.namespace.listenerCount('connection'), 2);
extra(); assert.equal(f.namespace.listenerCount('connection'), 1);
assert.equal(node.connected, true);
const closed = received(node, 'forward_session_closed');
f.dispose();
assert.equal((await closed)[0].reason, 'relay_routes_closed');
assert.equal(f.namespace.listenerCount('connection'), 0);
assert.equal(f.http.listening, true);
} finally {await f.close();}
});
test('reauth extends only the same identity, reconnect restores subscriptions, revoked IDs cannot revive', async () => {
const f = await fixture();
try {
const node = await f.node();
const web = await f.web(randomUUID(), permissions, Date.now() + 180);
const subscribe = request('bot.subscribe', {events: ['status_update']});
await ack(web.socket, 'relay_request', subscribe);
const expiry = Date.now() + 3000;
f.store.authorize({forward_session_id: f.s.forward_session_id, grant_id: web.grant_id, user_id: 4, permissions, expires_at: expiry});
const renewed = await ack(web.socket, 'reauth', {token: token(f.s, 'web', web.grant_id, expiry)});
assert.equal(renewed.data.expires_at, expiry);
await delay(200);
assert.equal(web.socket.connected, true);
const replacement = await f.connect();
await ack(replacement, 'join_room', {token: token(f.s, 'web', web.grant_id, expiry)});
await ack(replacement, 'relay_request', subscribe);
code(await ack(node, 'bot_event', {forward_session_id: f.s.forward_session_id, bot_id: f.s.bot_id,
context_generation: null, stream_id: randomUUID(), seq: 1, emitted_at: Date.now(),
name: 'status_update', payload: {status: 'offline', context_exists: false}}), 'FORBIDDEN');
node.on('relay_request', (_value, reply) => reply(snapshot(f.s.bot_id)));
assert.equal((await ack(replacement, 'relay_request', request('bot.snapshot'))).status, 'success');
f.store.revoke_grant({forward_session_id: f.s.forward_session_id, grant_id: web.grant_id, reason: 'permission_removed'});
assert.throws(() => f.store.authorize({forward_session_id: f.s.forward_session_id, grant_id: web.grant_id,
user_id: 4, permissions, expires_at: expiry}), {code: 'FORBIDDEN'});
f.store.revoke_session({forward_session_id: f.s.forward_session_id, reason: 'closed'});
assert.throws(() => f.store.open({...f.s, lease_expires_at: expiry}), {code: 'SESSION_STALE'});
} finally {await f.close();}
});
test('async join cannot commit after session revocation or disconnect, successful join ACK follows room membership', async () => {
for (const action of ['revoke', 'disconnect', 'success']) {
const f = await fixture();
try {
const client = await f.connect();
const socket = f.namespace.sockets.get(client.id!)!;
const join = socket.join.bind(socket);
let release!: () => void;
let entered!: () => void;
const started = new Promise<void>(resolve => {entered = resolve;});
socket.join = async room => {entered(); await new Promise<void>(resolve => {release = resolve;}); await join(room);};
const joining = f.store.join(socket, {token: token(f.s, 'node')});
await started;
if (action === 'revoke') f.store.revoke_session({forward_session_id: f.s.forward_session_id, reason: 'revoked_while_joining'});
if (action === 'disconnect') {client.disconnect(); await once(socket, 'disconnect');}
release();
if (action === 'success') {
await joining;
assert.equal(socket.rooms.size, 2);
} else {
await assert.rejects(joining, {code: action === 'revoke' ? 'SESSION_STALE' : 'CANCELLED'});
await delay(0);
assert.equal(socket.rooms.size, 0);
}
} finally {await f.close();}
}
});
test('stalled async adapters are cancelled without waiting for join and late membership is removed', async () => {
for (const action of ['disconnect', 'timeout']) {
const f = await fixture({auth_timeout_ms: 45});
try {
const client = await f.connect();
const socket = f.namespace.sockets.get(client.id!)!;
const original = socket.join.bind(socket);
let release!: () => void;
let entered!: () => void;
const started = new Promise<void>(resolve => {entered = resolve;});
socket.join = async room => {entered(); await new Promise<void>(resolve => {release = resolve;}); await original(room);};
const joining = f.store.join(socket, {token: token(f.s, 'node')});
const rejected = assert.rejects(joining, {code: action === 'disconnect' ? 'CANCELLED' : 'UNAUTHORIZED'});
await started;
if (action === 'disconnect') client.disconnect();
await rejected;
release();
await delay(0);
assert.equal(socket.rooms.size, 0);
} finally {await f.close();}
}
});
test('capacity bounds reject new sessions, grants and sockets, including retained revocation records', async () => {
const f = await fixture({max_sessions: 1, max_grants_per_session: 1, max_connections: 1});
try {
assert.throws(() => f.store.open({...scope(), bot_id: 9, lease_expires_at: Date.now() + 10_000}), {code: 'RATE_LIMITED'});
const web = await f.web();
assert.throws(() => f.store.authorize({forward_session_id: f.s.forward_session_id, grant_id: randomUUID(),
user_id: 4, permissions, expires_at: Date.now() + 10_000}), {code: 'RATE_LIMITED'});
const closed = received(web.socket, 'forward_session_closed');
f.store.revoke_grant({forward_session_id: f.s.forward_session_id, grant_id: web.grant_id, reason: 'revoked'});
await closed;
assert.throws(() => f.store.authorize({forward_session_id: f.s.forward_session_id, grant_id: randomUUID(),
user_id: 4, permissions, expires_at: Date.now() + 10_000}), {code: 'RATE_LIMITED'});
const first = await f.connect();
const rejected = await f.connect();
assert.equal((await Promise.race([received(rejected, 'disconnect').then(() => true), delay(30).then(() => !rejected.connected)])), true);
assert.equal(first.connected, true);
} finally {await f.close();}
});
test('unauthenticated sockets and session leases expire, authentication is rate limited', async () => {
const f = await fixture({auth_timeout_ms: 40, requests_per_second: 2});
try {
const idle = await f.connect();
assert.equal((await received(idle, 'forward_session_closed'))[0].reason, 'authentication_timeout');
const node = await f.connect();
const credential = token(f.s, 'node');
await ack(node, 'join_room', {token: credential});
await ack(node, 'join_room', {token: credential});
code(await ack(node, 'join_room', {token: credential}), 'RATE_LIMITED');
assert.equal(f.namespace.sockets.get(node.id!)!.rooms.size, 2);
} finally {await f.close();}
const lease = await fixture();
try {
const short = {...scope(), node_id: 10};
lease.store.open({...short, lease_expires_at: Date.now() + 70});
const node = await lease.connect();
const joined = await ack(node, 'join_room', {token: token(short, 'node')});
assert.equal(joined.status, 'success');
assert.ok(joined.data.expires_at <= Date.now() + 70);
assert.equal((await received(node, 'forward_session_closed'))[0].code, 'TOKEN_EXPIRED');
await delay(10);
assert.equal(lease.store.size, 1);
} finally {await lease.close();}
});
test('slow Node RPC queues and unauthenticated unknown-event ACK queues stay bounded', async () => {
const f = await fixture({max_buffered_packets: 2, rpc_timeout_ms: 20});
try {
const node = await f.node(); const web = await f.web();
const server_node = f.namespace.sockets.get(node.id!)!;
server_node.conn.transport.writable = false;
code(await ack(web.socket, 'relay_request', request('bot.snapshot')), 'TIMEOUT');
code(await ack(web.socket, 'relay_request', request('bot.snapshot')), 'TIMEOUT');
code(await ack(web.socket, 'relay_request', request('bot.snapshot')), 'RESYNC_REQUIRED');
assert.equal(server_node.connected, false);
const raw = await f.connect();
const server_raw = f.namespace.sockets.get(raw.id!)!;
server_raw.conn.transport.writable = false;
for (let index = 0; index < 20; index++) raw.emit('unknown', {}, () => {});
await delay(20);
assert.equal(server_raw.connected, false);
// Two bounded replies plus the explicit close notification and disconnect packet.
assert.ok((server_raw.conn as unknown as {writeBuffer: unknown[]}).writeBuffer.length <= 4);
} finally {await f.close();}
});
+295
View File
@@ -0,0 +1,295 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {randomUUID} from 'node:crypto';
import test from 'node:test';
import type {Socket} from 'socket.io';
import {RELAY_METHODS, WORKFLOW_ACTIONS} from '../types.ts';
import type {ForwardAck, JsonValue, NodeRelayRequest, RelayClaims, RelayLimits, RelayMethod, RelayRequest, WorkflowRunSnapshot} from '../types.ts';
import {create_session_store} from '../runtime/session_store.ts';
import {DEFAULT_LIMITS} from '../utils/relay_config.ts';
import {encode_token} from '../utils/token_utils.ts';
import {error_ack, success} from '../utils/relay_validation.ts';
class MockSocket extends EventEmitter {
connected = true;
conn = {writeBuffer: [] as unknown[]};
acks = new Map<number, (value: unknown) => void>();
rooms = new Set<string>();
requests: NodeRelayRequest[] = [];
respond: (request: NodeRelayRequest) => unknown = () => success(null);
private next_ack = 0;
join(room: string) {this.rooms.add(room);}
leave(room: string) {this.rooms.delete(room);}
disconnect() {this.connected = false; this.emit('disconnect');}
timeout(_ms: number) {return this;}
emitWithAck(event: string, request: NodeRelayRequest) {
assert.equal(event, 'relay_request');
this.requests.push(request);
return new Promise<unknown>((resolve, reject) => {
const id = this.next_ack++;
this.acks.set(id, resolve);
Promise.resolve().then(() => this.respond(request)).then(value => {
if (this.acks.delete(id)) resolve(value);
}, reject);
});
}
get socket(): Socket {return this as unknown as Socket;}
}
async function fixture(t: {after: (fn: () => void) => unknown}, overrides: Partial<RelayLimits> = {},
web_permissions: RelayMethod[] = [...RELAY_METHODS], node_permissions: RelayMethod[] = [...RELAY_METHODS]) {
const key = 'mock-session-test-secret-only';
const scope = {forward_id: 1, forward_session_id: randomUUID(), node_session_id: 'main-session', node_id: 2, bot_id: 3};
const store = create_session_store({forward_id: 1, key, limits: {...DEFAULT_LIMITS, ...overrides}, on_status() {}});
t.after(() => store.close());
store.open({...scope, lease_expires_at: Date.now() + 60_000});
const grant_id = randomUUID();
const expires_at = Date.now() + 50_000;
store.authorize({forward_session_id: scope.forward_session_id, grant_id, user_id: 4, permissions: web_permissions, expires_at});
const join = async (socket: MockSocket, role: 'node' | 'web', permissions: RelayMethod[]) => {
const now = Date.now();
const claims: RelayClaims = {...scope, v: 1, role, permissions, issued_at: now, join_before: now + 10_000,
expires_at, jti: randomUUID(), ...(role === 'web' ? {grant_id, user_id: 4} : {})};
store.attach(socket.socket);
await store.join(socket.socket, {token: encode_token(key, claims)});
};
const node = new MockSocket(); const web = new MockSocket();
await join(node, 'node', node_permissions);
store.node_ready(node.socket, {forward_session_id: scope.forward_session_id});
await join(web, 'web', web_permissions);
const request = async (method: RelayRequest['method'], payload: unknown = {}, request_id = randomUUID()): Promise<ForwardAck<unknown>> => {
try {return await store.request(web.socket, {method, payload, request_id});}
catch (error) {return error_ack(error);}
};
return {store, node, web, scope, grant_id, join, request};
}
function code(value: ForwardAck<unknown>, expected: string) {
assert.equal(value.status, 'error');
if (value.status === 'error') assert.equal(value.data.code, expected);
}
const control = () => ({command_id: randomUUID(), context_generation: randomUUID(), action: 'run_once', workflow_id: 'a'});
const command = (input: ReturnType<typeof control>) => ({command_id: input.command_id, action: input.action, workflow_id: input.workflow_id, state: 'accepted'});
test('mock relay forwards all workflow RPCs with private correlated deadlines and safe ACKs', async t => {
const f = await fixture(t);
const input = control(); const run_id = randomUUID();
const summary: WorkflowRunSnapshot = {run_id, workflow_id: 'a', status: 'completed', steps: 1, active_nodes: [],
started_at: 0, ended_at: 1, stop_reason: null, error: null, trigger_available: true};
const detail: WorkflowRunSnapshot = {...summary, trigger: {disabled: false, count: 0, empty: null, nested: {values: [0, false, null]}},
branches: [{trigger: {}, vars: {}, outputs: {}, inputs: {}, retry_counts: {}, last_result: null}]};
const trigger_detail = {...detail};
delete trigger_detail.branches;
const legacy = {...summary};
delete legacy.trigger_available;
const runtime = {source_config_revision: 0, workflows: [] as JsonValue[], commands: [command(input)]};
const catalog = {catalog_version: 1, nodes: [] as JsonValue[], start_nodes: [] as JsonValue[], events: [] as JsonValue[], queries: [] as JsonValue[],
common: {node_fields: [] as JsonValue[], policy_fields: [] as JsonValue[], result: {}, edge_input: {}, references: [] as JsonValue[]}};
const requests: Array<[RelayRequest['method'], unknown, unknown]> = [
['workflow.start', {workflow_id: 'a', trigger: {x: 1}}, {status: 'started', run_id}],
['workflow.stop', {run_id}, {stopped: true}], ['workflow.snapshot', {}, []],
['workflow.snapshot', {}, [summary]], ['workflow.snapshot', {run_id}, detail],
['workflow.snapshot', {run_id, include_branches: false}, trigger_detail],
['workflow.snapshot', {run_id, include_branches: true}, detail],
['workflow.snapshot', {run_id}, legacy],
['workflow.snapshot', {run_id}, null], ['workflow.catalog', {}, catalog],
['workflow.control', input, command(input)],
['workflow.command', {command_id: input.command_id, context_generation: input.context_generation}, command(input)],
['workflow.runtime', {}, runtime],
];
let received = 0;
f.web.on('relay_request', () => received++);
for (const [method, payload, data] of requests) {
f.node.respond = () => success(data);
const before = Date.now();
assert.deepEqual(await f.request(method, payload), success(data));
const forwarded = f.node.requests.at(-1)!;
assert.equal(forwarded.method, method);
assert.deepEqual(forwarded.payload, payload);
assert.equal(forwarded.forward_session_id, f.scope.forward_session_id);
assert.notEqual(forwarded.correlation_id, forwarded.request_id);
assert.ok(forwarded.deadline_at >= before && forwarded.deadline_at <= Date.now() + DEFAULT_LIMITS.rpc_timeout_ms);
}
for (const action of WORKFLOW_ACTIONS) {
const payload = action === 'stop_run' ? {command_id: randomUUID(), context_generation: randomUUID(), action, run_id}
: {...control(), action, expected_generation: 1, ...(action === 'run_once' ? {input: {value: true}} : {})};
const data = {command_id: payload.command_id, action, workflow_id: action === 'stop_run' ? null : 'a', state: 'accepted'};
f.node.respond = () => success(data);
assert.deepEqual(await f.request('workflow.control', payload), success(data));
assert.deepEqual(f.node.requests.at(-1)!.payload, payload);
}
assert.equal(received, 0);
assert.equal(f.node.acks.size, 0);
});
test('mock workflow read/write permissions apply to web grants and Node tokens', async t => {
for (const [web, node] of [
[['bot.snapshot'], [...RELAY_METHODS]], [['workflow.control'], [...RELAY_METHODS]],
[[...RELAY_METHODS], ['bot.snapshot']], [[...RELAY_METHODS], ['workflow.control']],
] as RelayMethod[][][]) {
const f = await fixture(t, {}, web, node);
for (const [method, payload] of [['workflow.start', {workflow_id: 'a'}], ['workflow.stop', {run_id: randomUUID()}],
['workflow.control', control()]] as const) {
f.node.respond = request => success(method === 'workflow.start' ? {status: 'skipped'} : method === 'workflow.stop' ? {stopped: true}
: command(request.payload as ReturnType<typeof control>));
const result = await f.request(method, payload);
if (web.includes('workflow.control') && node.includes('workflow.control')) assert.equal(result.status, 'success');
else code(result, 'FORBIDDEN');
}
for (const method of ['workflow.catalog', 'workflow.snapshot', 'workflow.command', 'workflow.runtime'] as const) {
const before = f.node.requests.length;
const payload = method === 'workflow.command' ? {command_id: randomUUID(), context_generation: randomUUID()} : {};
await f.request(method, payload);
assert.equal(f.node.requests.length - before, web.includes('bot.snapshot') && node.includes('bot.snapshot') ? 1 : 0);
}
}
});
test('mock execute_function workflow category requires both capabilities including on Node', async t => {
for (const [web, node] of [
[['execute_function'], [...RELAY_METHODS]], [['workflow.control'], [...RELAY_METHODS]],
[[...RELAY_METHODS], ['execute_function']], [[...RELAY_METHODS], ['workflow.control']],
[['execute_function', 'workflow.control'], ['execute_function', 'workflow.control']],
] as RelayMethod[][][]) {
const f = await fixture(t, {}, web, node);
f.node.respond = () => success(true);
for (const category of ['workflow', 'workflow/nested']) {
const result = await f.request('execute_function', {function: category, name: 'run_once', args: []});
if ([web, node].every(permissions => permissions.includes('execute_function') && permissions.includes('workflow.control')))
assert.deepEqual(result, success(true));
else code(result, 'FORBIDDEN');
}
const result = await f.request('execute_function', {function: 'movement', name: 'go', args: []});
if (web.includes('execute_function') && node.includes('execute_function')) assert.deepEqual(result, success(true));
else code(result, 'FORBIDDEN');
}
});
test('mock workflow mutations cannot replay and reads refresh with current Node permission', async t => {
const f = await fixture(t);
for (const [method, payload, data] of [
['workflow.start', {workflow_id: 'a'}, {status: 'skipped'}], ['workflow.stop', {run_id: randomUUID()}, {stopped: true}],
['workflow.control', control(), null],
] as const) {
f.node.respond = request => success(method === 'workflow.control' ? command(request.payload as ReturnType<typeof control>) : data);
const id = randomUUID();
assert.equal((await f.request(method, payload, id)).status, 'success');
const before = f.node.requests.length;
code(await f.request(method, payload, id), 'CANCELLED');
assert.equal(f.node.requests.length, before);
}
const id = randomUUID(); let revision = 0;
f.node.respond = () => success({source_config_revision: revision++, workflows: [], commands: []});
assert.deepEqual(await f.request('workflow.runtime', {}, id), success({source_config_revision: 0, workflows: [], commands: []}));
assert.deepEqual(await f.request('workflow.runtime', {}, id), success({source_config_revision: 1, workflows: [], commands: []}));
const replacement = new MockSocket();
await f.join(replacement, 'node', ['workflow.control']);
f.store.node_ready(replacement.socket, {forward_session_id: f.scope.forward_session_id});
code(await f.request('workflow.runtime', {}, id), 'FORBIDDEN');
assert.equal(replacement.requests.length, 0);
});
test('mock control availability, method rate limits and malformed context prevent dispatch', async t => {
const f = await fixture(t);
f.store.set_control_available(false);
for (const [method, payload] of [['workflow.start', {workflow_id: 'a'}], ['workflow.stop', {run_id: randomUUID()}],
['workflow.control', control()]] as const) code(await f.request(method, payload), 'RELAY_UNAVAILABLE');
f.node.respond = () => success({source_config_revision: 0, workflows: [], commands: []});
assert.equal((await f.request('workflow.runtime')).status, 'success');
f.store.set_control_available(true);
const before = f.node.requests.length;
code(await f.request('workflow.control', {...control(), context_generation: 'bad'}), 'INVALID_REQUEST');
code(await f.request('workflow.command', {command_id: randomUUID()}), 'INVALID_REQUEST');
for (const include_branches of [false, true]) {
code(await f.request('workflow.snapshot', {include_branches}), 'INVALID_REQUEST');
}
for (const include_branches of [null, 0, 1, 'false', 'true', [], {}]) {
code(await f.request('workflow.snapshot', {run_id: randomUUID(), include_branches}), 'INVALID_REQUEST');
}
for (const action of WORKFLOW_ACTIONS) {
const payload = action === 'stop_run' ? {command_id: randomUUID(), context_generation: randomUUID(), action, run_id: randomUUID()}
: {...control(), action};
for (const [field, value] of Object.entries({workflow_id: 'a', run_id: randomUUID(), expected_generation: 1, input: {value: true}})) {
const allowed = field === 'workflow_id' || field === 'expected_generation' ? action !== 'stop_run'
: field === 'run_id' ? action === 'stop_run' : action === 'run_once';
if (!allowed) code(await f.request('workflow.control', {...payload, [field]: value}), 'INVALID_REQUEST');
}
}
assert.equal(f.node.requests.length, before);
const limited = await fixture(t, {requests_per_second: 1});
limited.node.respond = f.node.respond;
// Authentication consumes this connection's first request in the same rate window.
code(await limited.request('workflow.runtime'), 'RATE_LIMITED');
assert.equal(limited.node.requests.length, 0);
});
test('mock relay rejects malformed and mismatched workflow ACKs without admitting domain envelope errors', async t => {
const f = await fixture(t); const input = control();
for (const data of [{...command(input), command_id: randomUUID()}, {...command(input), action: 'stop'},
{...command(input), workflow_id: 'other'}, {...command(input), state: 'invalid'}]) {
f.node.respond = () => success(data);
code(await f.request('workflow.control', input), 'INVALID_REQUEST');
}
f.node.respond = () => success({...command(input), command_id: randomUUID()});
code(await f.request('workflow.command', {command_id: input.command_id, context_generation: input.context_generation}), 'INVALID_REQUEST');
f.node.respond = () => success(null);
code(await f.request('workflow.snapshot'), 'INVALID_REQUEST');
f.node.respond = () => success([]);
code(await f.request('workflow.snapshot', {run_id: randomUUID()}), 'INVALID_REQUEST');
const run: WorkflowRunSnapshot = {run_id: randomUUID(), workflow_id: 'a', status: 'running', steps: 0, active_nodes: [],
started_at: 0, ended_at: null, stop_reason: null, error: null, trigger_available: true, trigger: {value: false}, branches: []};
f.node.respond = () => success(run);
code(await f.request('workflow.snapshot', {run_id: randomUUID()}), 'INVALID_REQUEST');
code(await f.request('workflow.snapshot'), 'INVALID_REQUEST');
f.node.respond = () => success([run]);
code(await f.request('workflow.snapshot', {run_id: run.run_id}), 'INVALID_REQUEST');
for (const data of [{...run, trigger: {value: 'x'.repeat(65_536)}}, {...run, trigger: []},
{...run, trigger: {value: NaN}}, {...run, trigger: JSON.parse('{"nested":{"constructor":1}}')},
{...run, trigger_available: false}]) {
f.node.respond = () => success(data);
code(await f.request('workflow.snapshot', {run_id: run.run_id}), 'INVALID_REQUEST');
}
f.node.respond = () => ({status: 'error', data: {code: 'WORKFLOW_NOT_FOUND', message: 'missing', retryable: false}});
code(await f.request('workflow.runtime'), 'INVALID_REQUEST');
f.node.respond = () => ({status: 'error', data: {code: 'INVALID_REQUEST', message: 'missing', retryable: false}});
code(await f.request('workflow.runtime'), 'INVALID_REQUEST');
});
test('mock deadlines, grant revocation and Node replacement release pending workflow ACKs', async t => {
for (const mode of ['timeout', 'revoke', 'replace', 'close'] as const) {
const f = await fixture(t);
t.mock.timers.enable({apis: ['setTimeout']});
let release!: (value: unknown) => void;
f.node.respond = () => new Promise(resolve => {release = resolve;});
const input = control(); const id = randomUUID();
const result = f.request('workflow.control', input, id);
await Promise.resolve();
if (mode === 'timeout') t.mock.timers.tick(DEFAULT_LIMITS.rpc_timeout_ms);
else if (mode === 'revoke') f.store.revoke_grant({forward_session_id: f.scope.forward_session_id, grant_id: f.grant_id, reason: 'revoked'});
else if (mode === 'replace') await f.join(new MockSocket(), 'node', [...RELAY_METHODS]);
else f.store.close();
code(await result, mode === 'timeout' ? 'TIMEOUT' : mode === 'revoke' ? 'CANCELLED' : mode === 'replace' ? 'NODE_OFFLINE' : 'RELAY_UNAVAILABLE');
assert.equal(f.node.acks.size, 0);
release(success(command(input)));
await Promise.resolve();
if (mode === 'timeout') code(await f.request('workflow.control', input, id), 'CANCELLED');
t.mock.timers.reset();
}
});
test('mock workflow_states info updates fan out only to snapshot readers', async t => {
const f = await fixture(t, {}, ['bot.snapshot']);
const received: unknown[] = [];
f.web.on('info_update', value => received.push(value));
const summary: WorkflowRunSnapshot = {run_id: randomUUID(), workflow_id: 'a', status: 'completed', steps: 1, active_nodes: [],
started_at: 0, ended_at: 1, stop_reason: null, error: null, trigger_available: true};
const update = {forward_session_id: f.scope.forward_session_id, bot_id: f.scope.bot_id, context_generation: randomUUID(),
stream_id: randomUUID(), seq: 1, emitted_at: Date.now(), data: {name: 'workflow_states', value: {available: true,
runs: [summary]}}};
assert.deepEqual(f.store.info(f.node.socket, update), {accepted: true});
assert.deepEqual(received, [update]);
const writer = await fixture(t, {}, ['workflow.control']);
let leaked = false;
writer.web.on('info_update', () => leaked = true);
writer.store.info(writer.node.socket, {...update, forward_session_id: writer.scope.forward_session_id});
assert.equal(leaked, false);
});
+756
View File
@@ -0,0 +1,756 @@
import assert from 'node:assert/strict';
import {createCipheriv, createHash} from 'node:crypto';
import {test} from 'node:test';
import {RELAY_METHODS, WORKFLOW_ACTIONS, WORKFLOW_METHODS} from '../types.ts';
import type {BotEvent, JsonValue, RelayClaims, RelayLimits, WorkflowRunSnapshot, WorkflowRuntimeState} from '../types.ts';
import {decode_token, encode_token, verify_token} from '../utils/token_utils.ts';
import {DEFAULT_LIMITS} from '../utils/relay_config.ts';
import {
RelayError, check_permissions, check_size, error_ack, fail, is_record, is_uuid,
success, validate_event, validate_grant, validate_info_update, validate_node_ack, validate_open, validate_request, validate_scope,
} from '../utils/relay_validation.ts';
const secret = 'relay-test-key-not-a-production-secret';
const uuid = 'a89acd9a-0ab3-44ac-812e-e221a5b915e0';
const other_uuid = '45264c09-7529-478f-b3ef-6a84bec8c1a7';
const now = 1_800_000_000_000;
const limits: RelayLimits = {
auth_timeout_ms: 10_000, close_timeout_ms: 5_000, rpc_timeout_ms: 5_000, unused_timeout_ms: 60_000,
idle_timeout_ms: 30_000, max_token_age_ms: 300_000, join_window_ms: 60_000,
clock_skew_ms: 1_000, max_lease_ms: 300_000, max_token_bytes: 4096,
max_payload_bytes: 256 * 1024, max_chat_length: 256, max_connections: 100,
max_sessions: 100, max_grants_per_session: 100, max_pending_per_web: 8,
max_pending_total: 100, requests_per_second: 20, events_per_second: 100,
max_dedupe_entries: 100, dedupe_ttl_ms: 30_000, max_buffered_packets: 100,
};
function claims(overrides: Record<string, unknown> = {}): RelayClaims {
return {
v: 1, role: 'web', forward_id: 0, forward_session_id: uuid,
node_session_id: 'mZAb09_-SocketIOmainID', node_id: 1, bot_id: 2,
user_id: 3, grant_id: other_uuid, permissions: ['bot.snapshot', 'bot.logs'],
issued_at: now, join_before: now + 60_000, expires_at: now + 300_000,
jti: other_uuid, ...overrides,
} as RelayClaims;
}
function rejected(action: () => unknown, code = 'INVALID_REQUEST'): void {
assert.throws(action, (error: unknown) => error instanceof RelayError && error.code === code);
}
function verify(value: unknown, at = now, settings = limits): RelayClaims {
return verify_token(secret, encode_token(secret, value), 0, settings, at);
}
function request(method: string, payload: unknown): unknown {
return {request_id: uuid, method, payload};
}
function record(): Record<string, unknown> {
return {id: uuid, timestamp: now, context_generation: null, seq: 1,
segments: [{message: 'hello', font_color: '#ffffff', font_style: ['normal', 'bold']}]};
}
function event(name: string, payload: unknown): unknown {
return {forward_session_id: uuid, bot_id: 2, context_generation: null,
stream_id: other_uuid, seq: 1, emitted_at: now, name, payload};
}
test('AES-256-GCM wire format remains IV[12] + tag[16] + ciphertext', () => {
const value = claims();
const iv = Buffer.from('000102030405060708090a0b', 'hex');
const cipher = createCipheriv('aes-256-gcm', createHash('sha256').update(secret).digest(), iv);
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final()]);
const token = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64url');
assert.equal(token, 'AAECAwQFBgcICQoLKReCzFKlEY6g42nAQLV9_x0ByGStIbnU7jVnJBA372eKB2zodLxLE9mBUS1agWpMmTc7swZYqNfSWy53I7FUGk6-ik0it6dgU_Sr5B4xex5F89Dl89k2hg2wkenWZpeIksBqjx2kPX2vj1o05iSueFxOXLopUmDFt7SWUFLzsQUdE5Q1nyTH7cWi71k5S5Fmc-Fo9qetCUp3QDyMPaD5Bn4sTrmUoSztp1AHREowEAMFMNAhjQ5Y5-aVDg08biMmw9X2KbBMukqomk5t-cHk0-l6r2XudPdPxH6B4YFhfE8iqIPnekUXz-GyCwuOODvdPajUKQs48s13OCVyhW5WzvxsoimN1KrM0ac0S1ybB9aRvkXSbkDHuEfy9Q-NCcddJacIKjIpFqeIrRV0EJAdCaMirNPIer_5F4YvmJB9H5UKNGSg4mZ-DYkvmXsvYAleGpSJzIpwjWG_pXb8dbjltryrQQ5HbXSIvs_8GBVGZdROTmwXCOrI_ZS8azkFA29zrHRBHiz-H3Rs0bv0GUgfZPoz_eJWxyA');
assert.deepEqual(decode_token(secret, token), value);
assert.deepEqual(verify_token(secret, token, 0, limits, now), value);
assert.deepEqual(decode_token(secret, encode_token(secret, value)), value);
assert.notEqual(encode_token(secret, value), encode_token(secret, value));
});
test('tokens enforce canonical base64url, byte limit, authentication and audience', () => {
const token = encode_token(secret, claims());
for (const input of ['', token + '=', token + '\n', ' ' + token, token + '+', 'A', '_'.repeat(40)]) {
rejected(() => verify_token(secret, input, 0, limits, now), 'UNAUTHORIZED');
}
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
// GCM token ends in unused base64 bits: accepting an alias would allow multiple wire IDs.
let short_token = encode_token(secret, {a: 1});
if (short_token.length % 4 === 0) short_token = encode_token(secret, {a: 12});
const last_index = alphabet.indexOf(short_token.at(-1)!);
const alias = short_token.slice(0, -1) + alphabet[last_index + 1];
assert.deepEqual(Buffer.from(alias, 'base64url'), Buffer.from(short_token, 'base64url'));
assert.throws(() => decode_token(secret, alias));
const raw = Buffer.from(token, 'base64url');
for (const offset of [0, 12, 28, raw.length - 1]) {
const modified = Buffer.from(raw);
modified[offset] ^= 1;
rejected(() => verify_token(secret, modified.toString('base64url'), 0, limits, now), 'UNAUTHORIZED');
}
rejected(() => verify_token('wrong-secret', token, 0, limits, now), 'UNAUTHORIZED');
rejected(() => verify_token(secret, token, 1, limits, now), 'UNAUTHORIZED');
rejected(() => verify_token(secret, token, 0, {...limits, max_token_bytes: token.length - 1}, now), 'UNAUTHORIZED');
assert.deepEqual(verify_token(secret, token, 0, {...limits, max_token_bytes: token.length}, now), claims());
});
test('authenticated plaintext must be valid UTF-8 JSON of exactly typed claims', () => {
for (const value of [null, [], 'claims', 1, {}, {...claims(), v: '1'}, {...claims(), password: 'sensitive'},
{...claims(), role: 'admin'}, {...claims(), permissions: ['*']},
{...claims(), permissions: ['bot.chat', 'bot.chat']}, {...claims(), permissions: null}]) {
rejected(() => verify(value), 'UNAUTHORIZED');
}
for (const key of Object.keys(claims())) {
const missing = {...claims()} as Record<string, unknown>;
delete missing[key];
rejected(() => verify(missing), 'UNAUTHORIZED');
}
const cipher = createCipheriv('aes-256-gcm', createHash('sha256').update(secret).digest(), Buffer.alloc(12));
const ciphertext = Buffer.concat([cipher.update(Buffer.from([0x22, 0xff, 0x22])), cipher.final()]);
const token = Buffer.concat([Buffer.alloc(12), cipher.getAuthTag(), ciphertext]).toString('base64url');
rejected(() => verify_token(secret, token, 0, limits, now), 'UNAUTHORIZED');
});
test('IDs are safe integers, protocol IDs are UUIDs, and Main socket session IDs are bounded strings', () => {
for (const field of ['forward_id', 'node_id', 'bot_id', 'user_id']) {
for (const value of ['1', -1, 1.5, Number.MAX_SAFE_INTEGER + 1, null]) {
rejected(() => verify(claims({[field]: value})), 'UNAUTHORIZED');
}
}
for (const field of ['forward_session_id', 'grant_id', 'jti']) {
rejected(() => verify(claims({[field]: 'not-a-uuid'})), 'UNAUTHORIZED');
}
for (const value of ['', ' ', 'x'.repeat(257), '\u754c'.repeat(86), null, 123]) {
rejected(() => verify(claims({node_session_id: value})), 'UNAUTHORIZED');
}
assert.equal(verify(claims({node_session_id: 'x'.repeat(256)})).node_session_id.length, 256);
assert.equal(verify(claims({node_id: Number.MAX_SAFE_INTEGER})).node_id, Number.MAX_SAFE_INTEGER);
});
test('node tokens exclude web identity; web tokens require both grant and positive user ID', () => {
const node = {...claims(), role: 'node'} as Record<string, unknown>;
delete node.user_id;
delete node.grant_id;
assert.equal(verify(node).role, 'node');
for (const extras of [{user_id: 3}, {grant_id: uuid}, {user_id: null}, {grant_id: null}]) {
rejected(() => verify({...node, ...extras}), 'UNAUTHORIZED');
}
for (const extras of [{user_id: 0}, {user_id: null}, {grant_id: null}, {grant_id: ''}]) {
rejected(() => verify(claims(extras)), 'UNAUTHORIZED');
}
assert.deepEqual(verify(claims({permissions: []})).permissions, []);
});
test('time ordering, TTL, join window and finite clock skew are enforced at boundaries', () => {
for (const times of [
{issued_at: '1800000000000'}, {issued_at: -1}, {issued_at: now + 0.5},
{join_before: now}, {join_before: now - 1}, {join_before: now + 60_001},
{expires_at: now + 300_001}, {expires_at: now + 10},
{issued_at: now + 1001, join_before: now + 2000, expires_at: now + 3000},
]) rejected(() => verify(claims(times)), 'UNAUTHORIZED');
assert.equal(verify(claims(), now + 60_999).role, 'web');
rejected(() => verify(claims(), now + 61_000), 'TOKEN_EXPIRED');
const short = claims({join_before: now + 1000, expires_at: now + 1000});
assert.equal(verify(short, now + 1999).role, 'web');
rejected(() => verify(short, now + 2000), 'TOKEN_EXPIRED');
rejected(() => verify(claims(), now + 60_000, {...limits, clock_skew_ms: 0}), 'TOKEN_EXPIRED');
assert.equal(verify(claims(), now - 1000).role, 'web');
rejected(() => verify(claims(), now - 1001), 'UNAUTHORIZED');
for (const clock of [NaN, Infinity, -1, 1.5]) {
rejected(() => verify(claims(), clock), 'UNAUTHORIZED');
rejected(() => verify(claims(), now, {...limits, clock_skew_ms: clock}), 'UNAUTHORIZED');
}
for (const option of ['max_token_bytes', 'max_token_age_ms', 'join_window_ms'] as const) {
rejected(() => verify(claims(), now, {...limits, [option]: 0}), 'UNAUTHORIZED');
}
});
test('errors are standard ACKs and unknown errors do not expose internal secrets', () => {
assert.throws(() => fail('TIMEOUT', 'RPC timed out', true), RelayError);
assert.deepEqual(error_ack(new RelayError('TIMEOUT', 'RPC timed out', true)), {
status: 'error', data: {code: 'TIMEOUT', message: 'RPC timed out', retryable: true},
});
for (const error of [new Error('password=secret'), {code: 'TIMEOUT', message: 'secret'}, null]) {
assert.deepEqual(error_ack(error), {
status: 'error', data: {code: 'INTERNAL_ERROR', message: 'Internal relay error', retryable: false},
});
}
assert.deepEqual(success({accepted: true}), {status: 'success', data: {accepted: true}});
assert.equal(is_record([]), false);
assert.equal(is_record(new Date()), false);
assert.equal(is_record(Object.create(null)), true);
assert.equal(is_uuid(uuid), true);
assert.equal(is_uuid('not-a-uuid'), false);
});
test('payload size uses UTF-8 JSON bytes and limits traversal without invoking accessors', () => {
const value = {text: '\u754c\n'};
const size = Buffer.byteLength(JSON.stringify(value));
check_size(value, size);
rejected(() => check_size(value, size - 1));
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
let nested: unknown = 'leaf';
for (let i = 0; i < 17; i++) nested = {nested};
for (const invalid of [cyclic, nested, Array(4097).fill(0), {value: undefined}, {value: NaN},
{value: Infinity}, {value: 1n}, {value: () => 1}, Array(2), new Date()]) {
rejected(() => check_size(invalid, limits.max_payload_bytes));
}
let calls = 0;
rejected(() => check_size({get secret() { calls++; return 'secret'; }}, 4096));
assert.equal(calls, 0);
for (const bad_limit of [0, -1, Infinity, NaN]) rejected(() => check_size({}, bad_limit));
});
test('large segmented history fits default limits while bytes and complexity remain bounded', () => {
const records = Array.from({length: 20}, (_, index) => ({...record(),
id: `a89acd9a-0ab3-44ac-812e-${index.toString(16).padStart(12, '0')}`, seq: index + 1,
segments: Array.from({length: 32}, () => ({message: '\u754c'.repeat(1024),
font_color: '#ffffff', font_style: ['normal']}))}));
const result = success({stream_id: uuid, seq: 20, records, truncated: false});
const size = Buffer.byteLength(JSON.stringify(result));
assert.ok(size > 1024 * 1024);
assert.ok(size < DEFAULT_LIMITS.max_payload_bytes);
validate_node_ack('get_log_history', result, 2, DEFAULT_LIMITS);
validate_node_ack('bot.logs', result, 2, DEFAULT_LIMITS);
rejected(() => validate_node_ack('get_log_history', result, 2,
{...DEFAULT_LIMITS, max_payload_bytes: 262_144}));
check_size('x'.repeat(DEFAULT_LIMITS.max_payload_bytes - 2), DEFAULT_LIMITS.max_payload_bytes);
rejected(() => check_size('x'.repeat(DEFAULT_LIMITS.max_payload_bytes - 1), DEFAULT_LIMITS.max_payload_bytes));
const many_nodes = Array.from({length: 1024}, () => Array(64).fill(0));
rejected(() => check_size(many_nodes, DEFAULT_LIMITS.max_payload_bytes));
});
test('requests whitelist every method and reject identity or sensitive payload fields', () => {
for (const [method, payload] of [
['bot.snapshot', {}], ['bot.logs', {limit: 1}],
['bot.logs', {limit: 200, stream_id: uuid, after_seq: Number.MAX_SAFE_INTEGER}],
['bot.subscribe', {events: ['status_update', 'message']}], ['bot.unsubscribe', {events: []}],
['bot.chat', {message: 'hello'}],
] as const) validate_request(request(method, payload), limits);
for (const [method, payload] of [
['bot.delete', {}], ['bot.snapshot', null], ['bot.snapshot', {token: 'secret'}],
['bot.logs', {limit: 0}], ['bot.logs', {limit: 201}], ['bot.logs', {limit: '1'}],
['bot.logs', {limit: 1, after_seq: 0}], ['bot.logs', {limit: 1, stream_id: 'stream'}],
['bot.logs', {limit: 1, stream_id: uuid, after_seq: Number.MAX_SAFE_INTEGER + 1}],
['bot.subscribe', {events: ['log', 'log']}], ['bot.subscribe', {events: ['error']}],
['bot.chat', {message: ' '}], ['bot.chat', {message: 'x'.repeat(257)}],
['bot.chat', {message: 'hello', password: 'secret'}],
] as Array<[string, unknown]>) rejected(() => validate_request(request(method, payload), limits));
rejected(() => validate_request({...request('bot.snapshot', {}) as object, bot_id: 99}, limits));
rejected(() => validate_request({request_id: 'id', method: 'bot.snapshot', payload: {}}, limits));
rejected(() => validate_request(request('bot.chat', {message: '\u754c'.repeat(20)}), {...limits, max_payload_bytes: 100}));
check_permissions(['bot.chat']);
rejected(() => check_permissions(['bot.chat', 'bot.chat']));
rejected(() => check_permissions(['unknown']));
rejected(() => check_permissions(Array(1)));
});
test('all six bot statuses and typed event DTOs are accepted, unexpected fields are rejected', () => {
for (const status of ['offline', 'ready', 'connecting', 'online', 'disconnecting', 'reconnecting']) {
validate_event({...event('status_update', {status, context_exists: true}) as BotEvent,
context_generation: uuid}, limits);
}
validate_event(event('status_update', {status: 'offline', context_exists: false}), limits);
rejected(() => validate_event(event('status_update', {status: 'online', context_exists: false}), limits));
rejected(() => validate_event(event('status_update', {status: 'offline', context_exists: true}), limits));
for (const [name, payload] of [
['log', {records: [record()]}], ['message', {text: 'hello', position: 'chat', json: {extra: [{text: 'hello'}]}}],
['bot_error', {code: 'OFFLINE', message: 'not online'}], ['bot_error', {message: 'error'}],
] as Array<[string, unknown]>) validate_event(event(name, payload), limits);
for (const [name, payload] of [
['error', {message: 'error'}], ['status_update', {status: 'unknown', context_exists: true}],
['status_update', {status: 'online', context_exists: 'true'}],
['message', {text: 'hello', position: 'chat', token: 'secret'}],
['bot_error', {message: 'error', stack: 'sensitive stack'}],
['log', {records: [{...record(), password: 'secret'}]}],
['log', {records: [{...record(), segments: [{message: 'hi', font_color: '#fff', font_style: ['script']}]}]}],
] as Array<[string, unknown]>) rejected(() => validate_event(event(name, payload), limits));
for (const seq of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) {
rejected(() => validate_event({...event('log', {records: []}) as BotEvent, seq}, limits));
}
rejected(() => validate_event({...event('log', {records: []}) as BotEvent, key: 'secret'}, limits));
});
test('execute_function validates exact payloads, bounded JSON args, boolean ACKs and permissions', () => {
const payload = {function: 'movement/navigation', name: 'go_to', args: [1, 'target', true, null, {position: [1, 2, 3]}]};
validate_request(request('execute_function', payload), limits);
validate_request(request('execute_function', {function: '', name: '', args: []}), limits);
for (const invalid of [null, [], {}, {...payload, category: 'movement'}, {...payload, function: []},
{...payload, name: 1}, {...payload, args: {}}, {name: 'go_to', args: []},
{function: 'movement', args: []}, {function: 'movement', name: 'go_to'}]) {
rejected(() => validate_request(request('execute_function', invalid), limits));
}
const cyclic: unknown[] = []; cyclic.push(cyclic);
let deep: unknown = 0;
for (let i = 0; i < 17; i++) deep = [deep];
for (const args of [[undefined], [NaN], [Infinity], [1n], [() => true], [new Date()],
Array(1), cyclic, [deep], Array(4097).fill(0)]) {
rejected(() => validate_request(request('execute_function', {...payload, args}), limits));
}
const value = request('execute_function', payload);
const bytes = Buffer.byteLength(JSON.stringify(value));
validate_request(value, {...limits, max_payload_bytes: bytes});
rejected(() => validate_request(value, {...limits, max_payload_bytes: bytes - 1}));
for (const data of [true, false]) validate_node_ack('execute_function', success(data), 2, limits);
for (const data of [null, 0, 1, 'true', [], {}, {accepted: true}]) {
rejected(() => validate_node_ack('execute_function', success(data), 2, limits));
}
validate_node_ack('execute_function', error_ack(new RelayError('NO_CONTEXT', 'No context')), 2, limits);
rejected(() => validate_node_ack('execute_function', {status: 'success', data: true, extra: true}, 2, limits));
check_permissions(['execute_function']);
rejected(() => check_permissions(['execute_function', 'execute_function']));
assert.deepEqual(verify(claims({permissions: ['execute_function']})).permissions, ['execute_function']);
});
test('tab_complete validates exact bounded completion DTOs without adding token permissions', () => {
for (const text of ['', ' ', '/help ', 'x'.repeat(256), '\u754c'.repeat(256)]) {
validate_request(request('tab_complete', {text}), limits);
}
for (const payload of [null, {}, {text: 1}, {text: 'x'.repeat(257)}, {text: 'a\rb'},
{text: 'a\nb'}, {text: 'a\0b'}, {text: '', extra: true}, {message: '/help'}]) {
rejected(() => validate_request(request('tab_complete', payload), limits));
}
for (const matches of [[], [' '], ['x'.repeat(256), '\u754c'.repeat(256)],
Array.from({length: 100}, (_, index) => String(index))]) {
validate_node_ack('tab_complete', success({matches, start: 0, length: 0}), 2, limits);
}
for (const data of [null, {}, {matches: 'help'}, {matches: [1]}, {matches: ['']},
{matches: ['help', 'help']}, {matches: ['x'.repeat(257)]},
{matches: Array.from({length: 101}, (_, index) => String(index))}, {matches: [], extra: true}]) {
rejected(() => validate_node_ack('tab_complete', success(data === null ? null : {start: 0, length: 0, ...data}), 2, limits));
}
for (const range of [{start: 0, length: 256}, {start: 256, length: 0}, {start: 128, length: 128}]) {
validate_node_ack('tab_complete', success({matches: [], ...range}), 2, limits);
}
for (const data of [{matches: []}, {matches: [], start: 0}, {matches: [], length: 0}] as unknown[]) {
rejected(() => validate_node_ack('tab_complete', success(data), 2, limits));
}
for (const value of [-1, 0.5, 257, Number.MAX_SAFE_INTEGER + 1, NaN, Infinity, '0', null]) {
for (const field of ['start', 'length']) {
rejected(() => validate_node_ack('tab_complete', success({matches: [], start: 0, length: 0, [field]: value}), 2, limits));
}
}
rejected(() => validate_node_ack('tab_complete', success({matches: [], start: 256, length: 1}), 2, limits));
validate_node_ack('tab_complete', error_ack(new RelayError('BOT_NOT_ONLINE', 'Offline')), 2, limits);
check_permissions(['bot.chat']);
rejected(() => check_permissions(['tab_complete']));
rejected(() => verify(claims({permissions: ['tab_complete']})), 'UNAUTHORIZED');
});
test('log metadata is accepted for events and history with exact bounded payloads', () => {
const base = record();
const segment = {message: 'hello', font_color: '#ffffff', font_style: ['normal'], hoverEvent: {action: 'show_text', value: 'tooltip', segments: [{message: 'tooltip', font_color: '#55ffff', font_style: ['bold']}]},
clickEvent: {action: 'suggest_command', value: '/help'}, insertion: 'Alex'};
const records = [{...base, segments: [segment]}];
validate_event(event('log', {records}), limits);
validate_node_ack('bot.logs', success({stream_id: uuid, seq: 1, records, truncated: false}), 2, limits);
for (const metadata of [
{hoverEvent: {action: 'unknown', value: 'text'}},
{clickEvent: {action: 'run_command', value: '/help', secret: 'extra'}},
{clickEvent: {action: 'run_command', value: 42}},
{insertion: 'x'.repeat(4097)},
]) rejected(() => validate_event(event('log', {records: [{...base, segments: [{...segment, ...metadata}]}]}), limits));
});
test('get_context_info validates empty requests and exact bounded info arrays without new permissions', () => {
validate_request(request('get_context_info', {}), limits);
for (const payload of [null, [], {name: 'health'}, {token: 'secret'}] as unknown[]) {
rejected(() => validate_request(request('get_context_info', payload), limits));
}
const info = {name: 'health_0-HP', i18n: {lang: 'en', name: 'Health', description: ''}, value: {hp: 20}};
for (const data of [[], [info], Array(256).fill(info), [{...info, name: 'a'.repeat(128),
i18n: {lang: 'a'.repeat(128), name: 'a'.repeat(256), description: 'a'.repeat(4096)}}]]) {
validate_node_ack('get_context_info', success(data), 2, limits);
}
for (const data of [null, {}, Array(257).fill(info), [{...info, extra: true}],
[{...info, i18n: {...info.i18n, extra: true}}], [{name: 'health', value: 1}],
[{...info, i18n: {...info.i18n, fields: {x: {lang: 'en', name: 'X'}}}}],
[{...info, i18n: {...info.i18n, values: []}}],
[{...info, i18n: {lang: 'en', name: 'Health'}}]]) {
rejected(() => validate_node_ack('get_context_info', success(data), 2, limits));
}
for (const name of ['', 'a.b', 'a b', '\u754c', 'a'.repeat(129), null, 1]) {
rejected(() => validate_node_ack('get_context_info', success([{...info, name}]), 2, limits));
}
for (const [field, length] of [['lang', 128], ['name', 256], ['description', 4096]] as const) {
for (const value of ['a'.repeat(length + 1), '\u754c'.repeat(Math.floor(length / 3) + 1), null, 1]) {
rejected(() => validate_node_ack('get_context_info', success([{...info, i18n: {...info.i18n, [field]: value}}]), 2, limits));
}
}
validate_node_ack('get_context_info', error_ack(new RelayError('BOT_NOT_ONLINE', 'Offline')), 2, limits);
rejected(() => check_permissions(['get_context_info']));
rejected(() => verify(claims({permissions: ['get_context_info']})), 'UNAUTHORIZED');
});
test('info_update uses an exact envelope with non-null context and JSON-safe bounded values', () => {
const update = {forward_session_id: uuid, bot_id: 2, context_generation: uuid, stream_id: other_uuid,
seq: 1, emitted_at: now, data: {name: 'health', value: null as unknown}};
const info = {name: 'health', i18n: {lang: '', name: '', description: ''}, value: null as unknown};
for (const value of [null, true, 0, -1.5, '', [1, {nested: ['ok']}], {health: 20}, Object.create(null)]) {
validate_info_update({...update, data: {...update.data, value}}, limits);
validate_node_ack('get_context_info', success([{...info, value}]), 2, limits);
}
for (const invalid of [{...update, context_generation: null}, {...update, context_generation: ''},
{...update, name: 'info_update'}, {...update, payload: update.data}, {...update, seq: -1},
{...update, bot_id: 0}, {...update, emitted_at: 0.5}, {...update, stream_id: 'stream'},
{...update, forward_session_id: 'session'}, {...update, data: {name: 'health'}},
{...update, data: {...update.data, i18n: {}}}, {...update, data: {name: 'bad.name', value: 1}},
{...update, data: {name: 'a'.repeat(129), value: 1}}]) {
rejected(() => validate_info_update(invalid, limits));
}
const cyclic: Record<string, unknown> = {}; cyclic.self = cyclic;
let nested: unknown = 0;
for (let index = 0; index < 69; index++) nested = {nested};
let accessor_calls = 0;
for (const value of [undefined, NaN, Infinity, 1n, () => 1, new Date(), Array(1), cyclic, nested,
{get secret() {accessor_calls++; return 1;}}, Array(65_537).fill(0)]) {
rejected(() => validate_info_update({...update, data: {...update.data, value}}, limits));
rejected(() => validate_node_ack('get_context_info', success([{...info, value}]), 2, limits));
}
assert.equal(accessor_calls, 0);
const size = Buffer.byteLength(JSON.stringify(update));
validate_info_update(update, {...limits, max_payload_bytes: size});
rejected(() => validate_info_update(update, {...limits, max_payload_bytes: size - 1}));
const result = success([info]);
const ack_size = Buffer.byteLength(JSON.stringify(result));
validate_node_ack('get_context_info', result, 2, {...limits, max_payload_bytes: ack_size});
rejected(() => validate_node_ack('get_context_info', result, 2, {...limits, max_payload_bytes: ack_size - 1}));
});
test('inventory info alone accepts deep NBT and large JSON within dedicated complexity limits', () => {
const info = {name: 'inventory', i18n: {lang: 'en', name: 'Inventory', description: ''}, value: null as unknown};
const update = {forward_session_id: uuid, bot_id: 2, context_generation: uuid, stream_id: other_uuid,
seq: 1, emitted_at: now, data: {name: 'inventory', value: null as unknown}};
const validate = (value: unknown) => {
validate_info_update({...update, data: {...update.data, value}}, DEFAULT_LIMITS);
validate_node_ack('get_context_info', success([{...info, value}]), 2, DEFAULT_LIMITS);
};
let nbt: unknown = {text: 'deep'};
for (let index = 0; index < 40; index++) nbt = {type: 'compound', value: nbt};
const inventory = {slots: Array.from({length: 46}, (_, slot) => ({slot, nbt, components: '\u754c'.repeat(2048)}))};
assert.ok(Buffer.byteLength(JSON.stringify(inventory)) > 192 * 1024);
validate(inventory);
for (const value of [Array(65_536).fill(0), Object.fromEntries(Array.from({length: 65_536}, (_, index) => [String(index), 0])),
Array.from({length: 4}, () => Array(60_000).fill(0))]) validate(value);
for (const value of [Array.from({length: 4}, () => Array(65_000).fill(0)),
Object.fromEntries(Array.from({length: 65_537}, (_, index) => [String(index), 0]))]) {
rejected(() => validate(value));
}
let nested: unknown = 0;
for (let index = 0; index < 65; index++) nested = {nested};
validate(nested); // ACK value starts at depth 3, reaching the depth-68 boundary.
rejected(() => validate_node_ack('get_context_info', success([{...info, value: {nested}}]), 2, DEFAULT_LIMITS));
validate_info_update({...update, data: {...update.data, value: {nested}}}, DEFAULT_LIMITS);
rejected(() => validate_info_update({...update, data: {...update.data, value: {nested: {nested}}}}, DEFAULT_LIMITS));
rejected(() => check_size(inventory, DEFAULT_LIMITS.max_payload_bytes));
rejected(() => validate_event(event('message', {text: '', position: 'chat', json: nbt}), DEFAULT_LIMITS));
rejected(() => validate_node_ack('bot.chat', success({accepted: true, nested: nbt}), 2, DEFAULT_LIMITS));
rejected(() => validate_request(request('get_context_info', {nested: nbt}), DEFAULT_LIMITS));
});
test('Node success ACKs enforce method-specific exact DTOs and expected bot', () => {
const snapshot = {bot_id: 2, bot_status: 'offline', context_exists: false, context_generation: null as string | null,
stream_id: uuid, seq: 0, captured_at: now};
validate_node_ack('bot.snapshot', success(snapshot), 2, limits);
validate_node_ack('bot.logs', success({stream_id: uuid, seq: 1, records: [record()], truncated: false}), 2, limits);
validate_node_ack('bot.chat', success({accepted: true}), 2, limits);
validate_node_ack('bot.subscribe', success({events: ['log']}), 2, limits);
validate_node_ack('bot.chat', error_ack(new RelayError('NO_CONTEXT', 'No context')), 2, limits);
rejected(() => validate_node_ack('bot.snapshot', success(snapshot), 3, limits));
for (const data of [true, {}, {accepted: false}, {accepted: true, token: 'secret'}, snapshot]) {
rejected(() => validate_node_ack('bot.chat', success(data), 2, limits));
}
rejected(() => validate_node_ack('bot.snapshot', success({...snapshot, config: {password: 'secret'}}), 2, limits));
rejected(() => validate_node_ack('bot.snapshot', success({...snapshot, seq: Number.MAX_SAFE_INTEGER + 1}), 2, limits));
rejected(() => validate_node_ack('bot.snapshot', success({...snapshot, bot_status: 'online'}), 2, limits));
rejected(() => validate_node_ack('bot.snapshot', success({...snapshot, context_generation: uuid}), 2, limits));
rejected(() => validate_node_ack('bot.chat', {status: 'error', data: {code: 'NO_CONTEXT', message: 'error', retryable: false, stack: 'secret'}}, 2, limits));
rejected(() => validate_node_ack('bot.chat', {status: 'error', data: {code: 'UNKNOWN', message: 'error', retryable: false}}, 2, limits));
});
test('logs result record count cannot exceed 200 even inside the byte budget', () => {
const records = Array.from({length: 200}, (_, index) => ({...record(),
id: `a89acd9a-0ab3-44ac-812e-${index.toString(16).padStart(12, '0')}`, seq: index + 1}));
const result = {stream_id: uuid, seq: 200, records, truncated: false};
validate_node_ack('bot.logs', success(result), 2, limits);
rejected(() => validate_node_ack('bot.logs', success({...result, records: [...records, record()]}), 2, limits));
rejected(() => validate_event(event('log', {records: [...records, record()]}), limits));
rejected(() => validate_node_ack('bot.logs', success({...result, seq: 199}), 2, limits));
rejected(() => validate_node_ack('bot.logs', success({...result, records: [records[1], records[0]]}), 2, limits));
rejected(() => validate_node_ack('bot.logs', success({...result, records: [records[0], records[0]]}), 2, limits));
});
test('control DTOs validate scope, permissions, audience and bounded leases', () => {
const {forward_id, forward_session_id, node_session_id, node_id, bot_id} = claims();
const scope = {forward_id, forward_session_id, node_session_id, node_id, bot_id};
validate_scope(scope);
const open = {...scope, lease_expires_at: Date.now() + 60_000};
validate_open(open, 0, limits);
rejected(() => validate_open(open, 1, limits));
rejected(() => validate_open({...open, key: 'secret'}, 0, limits));
rejected(() => validate_open({...open, lease_expires_at: Date.now() + limits.max_lease_ms + 5000}, 0, limits));
rejected(() => validate_open({...open, lease_expires_at: Date.now() - 5000}, 0, limits));
const grant = {forward_session_id, grant_id: uuid, user_id: 3, permissions: ['bot.snapshot'], expires_at: Date.now() + 60_000};
validate_grant(grant, limits);
rejected(() => validate_grant({...grant, user_id: 1.5}, limits));
rejected(() => validate_grant({...grant, permissions: ['bot.snapshot', 'bot.snapshot']}, limits));
rejected(() => validate_grant({...grant, key: 'secret'}, limits));
});
test('file requests have exact payloads and a dedicated bot.files permission', () => {
check_permissions(['bot.files']);
assert.deepEqual(verify(claims({permissions: ['bot.files']})).permissions, ['bot.files']);
validate_request(request('get_data_file_list', {}), limits);
for (const payload of [{id: uuid}, {id: uuid, offset: 0, length: 1},
{id: uuid, offset: Number.MAX_SAFE_INTEGER, length: 262_144}]) {
validate_request(request('get_file_data', payload), limits);
}
for (const payload of [null, [], {id: uuid}] as unknown[]) rejected(() => validate_request(request('get_data_file_list', payload), limits));
for (const payload of [{}, {id: 'file'}, {id: uuid, path: 'secret'}, {id: uuid, offset: undefined},
...[-1, 0.5, Number.MAX_SAFE_INTEGER + 1, '0', null].map(offset => ({id: uuid, offset})),
...[0, -1, 0.5, 262_145, '1', null].map(length => ({id: uuid, length}))]) {
rejected(() => validate_request(request('get_file_data', payload), limits));
}
rejected(() => validate_request(request('bot.files', {}), limits));
rejected(() => check_permissions(['get_file_data']));
rejected(() => check_permissions(['get_data_file_list']));
});
test('file listings bound exact recursive trees, JSON bytes, depth and total nodes', () => {
const file = {type: 'file', name: 'data.bin', id: uuid, size: Number.MAX_SAFE_INTEGER, modified_at: 0};
const listing = {listing_id: uuid, expires_at: 1, partial: false, children: [file] as unknown[]};
const validate = (data: unknown) => validate_node_ack('get_data_file_list', success(data), 2, DEFAULT_LIMITS);
validate(listing);
validate({...listing, children: []});
validate({...listing, children: Array(2000).fill(file)});
let deep: unknown = file;
for (let index = 0; index < 31; index++) deep = {type: 'directory', name: 'dir', children: [deep]};
validate({...listing, children: [deep]});
rejected(() => check_size(success({...listing, children: [deep]}), DEFAULT_LIMITS.max_payload_bytes));
rejected(() => validate({...listing, children: [{type: 'directory', name: 'dir', children: [deep]}]}));
for (const data of [{...listing, children: Array(2001).fill(file)}, {...listing, listing_id: 'id'},
{...listing, expires_at: 0}, {...listing, partial: 1}, {...listing, extra: true},
{...listing, children: {}}, {...listing, children: [{...file, size: -1}]},
{...listing, children: [{...file, modified_at: Number.MAX_SAFE_INTEGER + 1}]},
{...listing, children: [{...file, path: 'secret'}]},
{...listing, children: [{type: 'directory', name: 'dir'}]},
{...listing, children: [{type: 'directory', name: 1, children: []}]}]) rejected(() => validate(data));
const small_tree = [{...file, name: ''}];
const overhead = Buffer.byteLength(JSON.stringify(small_tree));
const children = [{...file, name: 'x'.repeat(512 * 1024 - overhead)}];
validate({...listing, children});
rejected(() => validate({...listing, children: [{...children[0], name: children[0].name + 'x'}]}));
const cyclic: unknown[] = [];
cyclic.push({type: 'directory', name: 'dir', children: cyclic});
let calls = 0;
for (const children of [cyclic, Array(1), [{...file, get name() {calls++; return 'secret';}}]]) {
rejected(() => validate({...listing, children}));
}
assert.equal(calls, 0);
const bytes = Buffer.byteLength(JSON.stringify(success(listing)));
validate_node_ack('get_data_file_list', success(listing), 2, {...limits, max_payload_bytes: bytes});
rejected(() => validate_node_ack('get_data_file_list', success(listing), 2, {...limits, max_payload_bytes: bytes - 1}));
});
test('file ACKs enforce canonical base64, safe offsets, byte counts and EOF equivalence', () => {
const chunk = {id: uuid, name: 'data.bin', encoding: 'base64', offset: 0, next_offset: 1,
total_bytes: 2, eof: false, data: 'YQ=='};
const validate = (data: unknown) => validate_node_ack('get_file_data', success(data), 2, DEFAULT_LIMITS);
validate(chunk);
validate({...chunk, total_bytes: 1, eof: true});
validate({...chunk, offset: 2, next_offset: 2, total_bytes: 2, eof: true, data: ''});
validate({...chunk, next_offset: 0, total_bytes: 0, eof: true, data: ''});
const data = Buffer.alloc(262_144).toString('base64');
validate({...chunk, data, next_offset: 262_144, total_bytes: 262_144, eof: true});
for (const invalid of [{...chunk, id: 'id'}, {...chunk, name: 1}, {...chunk, encoding: 'utf8'},
{...chunk, offset: -1}, {...chunk, next_offset: 0}, {...chunk, total_bytes: 0},
{...chunk, total_bytes: Number.MAX_SAFE_INTEGER + 1}, {...chunk, next_offset: 0.5},
{...chunk, eof: true}, {...chunk, total_bytes: 1}, {...chunk, extra: true},
...['YQ', 'YR==', 'YQ==\n', 'YQ===', ' YQ==', '_w==', '!!!!'].map(data => ({...chunk, data})),
{...chunk, data: Buffer.alloc(262_145).toString('base64'), next_offset: 262_145, total_bytes: 262_145, eof: true}]) {
rejected(() => validate(invalid));
}
});
test('workflow permissions are capabilities distinct from RPC names for tokens and grants', () => {
check_permissions([...RELAY_METHODS]);
assert.deepEqual(verify(claims({permissions: ['workflow.control', 'bot.snapshot']})).permissions, ['workflow.control', 'bot.snapshot']);
const grant = {forward_session_id: uuid, grant_id: other_uuid, user_id: 3,
permissions: ['workflow.control'], expires_at: Date.now() + 60_000};
validate_grant(grant, limits);
rejected(() => check_permissions(['workflow.control', 'workflow.control']));
for (const method of WORKFLOW_METHODS.filter(method => method !== 'workflow.control')) {
rejected(() => check_permissions([method]));
rejected(() => verify(claims({permissions: [method]})), 'UNAUTHORIZED');
rejected(() => validate_grant({...grant, permissions: [method]}, limits));
}
});
test('workflow requests enforce exact targets, UUID context, positive generation and safe 64 KiB inputs', () => {
const control = {command_id: uuid, context_generation: other_uuid, workflow_id: 'workflow-1', action: 'run_once'};
for (const action of WORKFLOW_ACTIONS) {
const payload = action === 'stop_run' ? {command_id: uuid, context_generation: other_uuid, action, run_id: uuid}
: {...control, action};
validate_request(request('workflow.control', payload), limits);
if (action !== 'stop_run') validate_request(request('workflow.control', {...payload, expected_generation: 1}), limits);
if (action === 'run_once') {
validate_request(request('workflow.control', {...payload, input: {value: [1, null, true]}}), limits);
validate_request(request('workflow.control', {...payload, expected_generation: 1, input: {value: [1, null, true]}}), limits);
}
for (const [field, value] of Object.entries({workflow_id: 'workflow-1', run_id: uuid, expected_generation: 1, input: {value: true}})) {
const allowed = field === 'workflow_id' || field === 'expected_generation' ? action !== 'stop_run'
: field === 'run_id' ? action === 'stop_run' : action === 'run_once';
if (!allowed) rejected(() => validate_request(request('workflow.control', {...payload, [field]: value}), limits));
}
}
for (const [method, payload] of [['workflow.start', {workflow_id: 'a'}], ['workflow.start', {workflow_id: 'a', trigger: {}}],
['workflow.stop', {run_id: uuid}], ['workflow.snapshot', {}], ['workflow.snapshot', {run_id: uuid}],
['workflow.snapshot', {run_id: uuid, include_branches: false}], ['workflow.snapshot', {run_id: uuid, include_branches: true}],
['workflow.catalog', {}], ['workflow.runtime', {}], ['workflow.command', {command_id: uuid, context_generation: other_uuid}]]) {
validate_request(request(method as string, payload), limits);
rejected(() => validate_request(request(method as string, {...payload as object, extra: true}), limits));
}
for (const payload of [{...control, command_id: 'bad'}, {...control, context_generation: null},
{command_id: uuid, workflow_id: 'a', action: 'run_once'}, {...control, action: 'unknown'},
{...control, workflow_id: undefined}, {...control, action: 'stop_run'}, {...control, run_id: 'bad'},
...[0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, null, '1'].map(expected_generation => ({...control, expected_generation})),
...['', 'a/b', 'a'.repeat(101), '__proto__', 'constructor', 'prototype'].map(workflow_id => ({...control, workflow_id}))]) {
rejected(() => validate_request(request('workflow.control', payload), limits));
}
for (const method of ['workflow.stop', 'workflow.snapshot']) {
rejected(() => validate_request(request(method, {run_id: 'bad'}), limits));
}
for (const include_branches of [false, true]) {
rejected(() => validate_request(request('workflow.snapshot', {include_branches}), limits));
rejected(() => validate_request(request('workflow.snapshot', {run_id: 'bad', include_branches}), limits));
rejected(() => validate_request(request('workflow.stop', {run_id: uuid, include_branches}), limits));
}
for (const include_branches of [null, undefined, 0, 1, 'false', 'true', [], {}]) {
rejected(() => validate_request(request('workflow.snapshot', {run_id: uuid, include_branches}), limits));
}
rejected(() => validate_request(request('workflow.stop', {}), limits));
rejected(() => validate_request(request('workflow.command', {command_id: uuid}), limits));
const input = {value: 'x'.repeat(64 * 1024 - Buffer.byteLength(JSON.stringify({value: ''})))};
validate_request(request('workflow.control', {...control, input}), limits);
validate_request(request('workflow.start', {workflow_id: 'a', trigger: input}), limits);
rejected(() => validate_request(request('workflow.control', {...control, input: {value: input.value + 'x'}}), limits));
const cyclic: Record<string, unknown> = {}; cyclic.self = cyclic;
let calls = 0;
for (const input of [null, [], {value: 'x'.repeat(64 * 1024)}, {value: undefined}, {value: NaN},
cyclic, {get value() {calls++; return 1;}}, JSON.parse('{"nested":{"__proto__":1}}'),
JSON.parse('{"constructor":1}'), {value: new Date()}, {value: Array(1)}]) {
rejected(() => validate_request(request('workflow.control', {...control, input}), limits));
rejected(() => validate_request(request('workflow.start', {workflow_id: 'a', trigger: input}), limits));
}
assert.equal(calls, 0);
for (const field of ['method', 'payload']) {
const value = Object.defineProperty(request('workflow.control', control), field, {enumerable: true, get() {calls++; return control;}});
rejected(() => validate_request(value, limits));
}
assert.equal(calls, 0);
const value = request('workflow.control', control);
const bytes = Buffer.byteLength(JSON.stringify(value));
validate_request(value, {...limits, max_payload_bytes: bytes});
rejected(() => validate_request(value, {...limits, max_payload_bytes: bytes - 1}));
});
test('workflow ACKs validate start/stop, opaque bounded catalog, run output and runtime commands', () => {
const command = {command_id: uuid, action: 'run_once', workflow_id: 'a', state: 'completed'};
const state: WorkflowRuntimeState = {workflow_id: 'a', generation: 1, enabled: true, start_type: 'timer', trigger_state: 'scheduled',
accepted_count: 0, max_runs: null, next_fire_at: null, active_run_ids: [uuid], queued_count: 0, last_error: null};
const runtime = {source_config_revision: 0, workflows: [state], commands: [command]};
const run: WorkflowRunSnapshot = {run_id: uuid, workflow_id: 'a', status: 'running', steps: 0, active_nodes: ['n'], started_at: 0,
ended_at: null, stop_reason: null, error: null};
const full = {...run, branches: [{trigger: {}, vars: {}, inputs: {}, retry_counts: {}, last_result: null as JsonValue,
outputs: {n: {execution: 1, status: 'success', output: {value: [1, 'output']}}}}]};
const catalog = {catalog_version: 1, nodes: [{name: 'code', config: [] as JsonValue[], output: {type: 'json'}}], start_nodes: [] as JsonValue[],
events: [] as JsonValue[], queries: [] as JsonValue[], common: {node_fields: [] as JsonValue[], policy_fields: [] as JsonValue[],
result: {}, edge_input: {}, references: [] as JsonValue[]}};
for (const data of [{status: 'started', run_id: uuid}, {status: 'queued', request_id: uuid}, {status: 'skipped'}]) {
validate_node_ack('workflow.start', success(data), 2, limits);
validate_node_ack('workflow.control', success({...command, result: data}), 2, limits);
}
validate_node_ack('workflow.stop', success({stopped: true}), 2, limits);
validate_node_ack('workflow.catalog', success(catalog), 2, limits);
for (const data of [null, [], [run, full], run, full]) validate_node_ack('workflow.snapshot', success(data), 2, limits);
const trigger: Record<string, JsonValue> = {disabled: false, count: 0, empty: null, nested: {values: [false, 0, null, 'text']}};
const summary: WorkflowRunSnapshot = {...run, trigger_available: true};
const detail: WorkflowRunSnapshot = {...run, trigger, trigger_available: true, branches: []};
for (const data of [summary, [summary], detail, {...run, trigger}, {...full, trigger}, {...run, trigger: {}},
{...run, trigger: Object.create(null)}, {...run, trigger_available: false}]) {
validate_node_ack('workflow.snapshot', success(data), 2, limits);
}
assert.equal(Object.hasOwn(summary, 'trigger'), false);
assert.equal(Object.hasOwn(summary, 'branches'), false);
assert.deepEqual(detail.trigger, trigger);
const overhead = Buffer.byteLength(JSON.stringify({value: ''}));
const bounded_trigger = {value: '\u754c'.repeat(Math.floor((65_536 - overhead) / 3))
+ 'x'.repeat((65_536 - overhead) % 3)};
assert.equal(Buffer.byteLength(JSON.stringify(bounded_trigger), 'utf8'), 65_536);
validate_node_ack('workflow.snapshot', success({...detail, trigger: bounded_trigger}), 2, limits);
rejected(() => validate_node_ack('workflow.snapshot', success({...detail,
trigger: {value: bounded_trigger.value + 'x'}}), 2, limits));
const cyclic: Record<string, unknown> = {}; cyclic.self = cyclic;
let calls = 0;
for (const trigger of [null, [], 'trigger', false, 0, undefined, {value: NaN}, {value: Infinity}, {value: undefined},
{value: 1n}, {value: () => 1}, new Date(), Object.create({inherited: true}), cyclic,
{nested: new Date()}, {nested: Object.create({inherited: true})}, {nested: Array(1)},
{nested: Object.setPrototypeOf([], null)}, JSON.parse('{"nested":{"__proto__":1}}'),
JSON.parse('{"constructor":1}'), JSON.parse('{"nested":{"prototype":1}}'),
{get value() {calls++; return 1;}}, Object.defineProperty({}, 'hidden', {value: true}), {[Symbol('key')]: true}]) {
for (const data of [{...run, trigger}, [{...run, trigger}]]) {
rejected(() => validate_node_ack('workflow.snapshot', success(data), 2, limits));
}
}
for (const trigger_available of [null, undefined, 0, 1, 'true', {}, []]) {
rejected(() => validate_node_ack('workflow.snapshot', success({...run, trigger_available}), 2, limits));
}
rejected(() => validate_node_ack('workflow.snapshot', success({...detail, trigger_available: false}), 2, limits));
for (const field of ['trigger', 'trigger_available']) {
const data = Object.defineProperty({...run}, field, {enumerable: true, get() {calls++; return true;}});
rejected(() => validate_node_ack('workflow.snapshot', success(data), 2, limits));
}
assert.equal(calls, 0);
for (const data of [command, {...command, state: 'accepted'}, {...command, result: {stopped: true}},
{...command, result: {generation: 1}}, {...command, state: 'failed', error: {code: 'WORKFLOW_NOT_FOUND', message: 'Missing'}}]) {
validate_node_ack('workflow.control', success(data), 2, limits);
validate_node_ack('workflow.command', success(data), 2, limits);
}
validate_node_ack('workflow.command', success(null), 2, limits);
validate_node_ack('workflow.runtime', success(runtime), 2, limits);
const bytes = Buffer.byteLength(JSON.stringify(success(runtime)));
validate_node_ack('workflow.runtime', success(runtime), 2, {...limits, max_payload_bytes: bytes});
rejected(() => validate_node_ack('workflow.runtime', success(runtime), 2, {...limits, max_payload_bytes: bytes - 1}));
const catalog_bytes = Buffer.byteLength(JSON.stringify(catalog));
const bounded_catalog = {...catalog, nodes: [{...catalog.nodes[0], description: 'x'.repeat(100 * 1024 - catalog_bytes - 17)}]};
assert.equal(Buffer.byteLength(JSON.stringify(bounded_catalog)), 100 * 1024);
validate_node_ack('workflow.catalog', success(bounded_catalog), 2, limits);
rejected(() => validate_node_ack('workflow.catalog', success({...bounded_catalog,
nodes: [{...bounded_catalog.nodes[0], description: bounded_catalog.nodes[0].description + 'x'}]}), 2, limits));
const full_bytes = Buffer.byteLength(JSON.stringify(success(full)));
validate_node_ack('workflow.snapshot', success(full), 2, {...limits, max_payload_bytes: full_bytes});
rejected(() => validate_node_ack('workflow.snapshot', success(full), 2, {...limits, max_payload_bytes: full_bytes - 1}));
for (const [method, data] of [
['workflow.start', {status: 'started'}], ['workflow.start', {status: 'queued', request_id: 'bad'}],
['workflow.start', {status: 'other'}], ['workflow.stop', {stopped: false}], ['workflow.stop', {stopped: true, extra: true}],
['workflow.catalog', {...catalog, catalog_version: 2}], ['workflow.catalog', {...catalog, nodes: {}}],
['workflow.catalog', {...catalog, common: {}}], ['workflow.catalog', {...catalog, nodes: [{description: 'x'.repeat(100 * 1024)}]}],
['workflow.catalog', {...catalog, nodes: [JSON.parse('{"constructor":1}')]}],
['workflow.snapshot', {...run, run_id: 'bad'}], ['workflow.snapshot', {...run, status: 'unknown'}],
['workflow.snapshot', {...full, branches: [{}]}], ['workflow.snapshot', {...full, branches: [{...full.branches[0], vars: {bad: Infinity}}]}],
['workflow.snapshot', {...full, branches: [{...full.branches[0], outputs: {n: {status: 'invalid', output: null as JsonValue, execution: 1}}}]}],
['workflow.snapshot', {...full, branches: [{...full.branches[0], retry_counts: {n: -1}}]}],
['workflow.snapshot', {...full, branches: [{...full.branches[0], last_result: {status: 'success', output: true, node_id: 'n', extra: true}}]}],
['workflow.control', null], ['workflow.control', {...command, command_id: 'bad'}],
['workflow.control', {...command, action: 'invalid'}], ['workflow.control', {...command, result: {generation: 0}}],
['workflow.control', {...command, error: {code: 'bad', message: 'x'.repeat(1025)}}],
['workflow.runtime', {...runtime, source_config_revision: -1}], ['workflow.runtime', {...runtime, commands: Array(257).fill(command)}],
['workflow.runtime', {...runtime, workflows: [{...state, generation: 0}]}],
['workflow.runtime', {...runtime, workflows: [{...state, active_run_ids: ['bad']}]}],
] as const) rejected(() => validate_node_ack(method, success(data), 2, limits));
for (const method of WORKFLOW_METHODS) {
validate_node_ack(method, error_ack(new RelayError('INVALID_REQUEST', 'Invalid workflow')), 2, limits);
rejected(() => validate_node_ack(method, {status: 'error', data: {code: 'WORKFLOW_NOT_FOUND', message: 'bad', retryable: false}}, 2, limits));
}
});
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"strictNullChecks": false,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"],
"allowImportingTsExtensions": true,
"allowJs": true
}
}
+276
View File
@@ -0,0 +1,276 @@
import type {Server as HttpServer} from 'node:http';
// Token permissions are capabilities, not the complete RPC method list.
export const RELAY_METHODS = ['bot.snapshot', 'bot.logs', 'bot.subscribe', 'bot.unsubscribe', 'bot.chat', 'execute_function', 'bot.files', 'workflow.control'] as const;
export const WORKFLOW_ACTIONS = ['run_once', 'trigger_start', 'trigger_stop', 'stop', 'stop_run', 'reload'] as const;
export const WORKFLOW_METHODS = ['workflow.start', 'workflow.stop', 'workflow.snapshot', 'workflow.catalog',
'workflow.control', 'workflow.command', 'workflow.runtime'] as const;
export const BOT_EVENTS = ['status_update', 'log', 'message', 'bot_error'] as const;
export type RelayMethod = typeof RELAY_METHODS[number];
export type BotEventName = typeof BOT_EVENTS[number];
export type BotStatus = 'offline' | 'ready' | 'connecting' | 'online' | 'disconnecting' | 'reconnecting';
export type 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';
export type ForwardAck<T> = {status: 'success'; data: T} | {
status: 'error'; data: {code: ForwardErrorCode; message: string; retryable: boolean};
};
export type AckCallback<T = unknown> = (result: ForwardAck<T>) => void;
export interface SessionScope {
forward_id: number;
forward_session_id: string;
node_session_id: string;
node_id: number;
bot_id: number;
}
export interface RelayClaims extends SessionScope {
v: 1;
role: 'node' | 'web';
user_id?: number;
grant_id?: string;
permissions: RelayMethod[];
issued_at: number;
join_before: number;
expires_at: number;
jti: string;
}
export interface JoinRoomReady extends SessionScope {
role: 'node' | 'web';
node_ready: boolean;
expires_at: number;
}
export interface NodeReadyRequest {forward_session_id: string}
export interface NodeReadyResult {node_ready: true}
export interface SessionClosed {
forward_session_id?: string;
reason: string;
code: ForwardErrorCode;
}
export interface RelayRequest {
request_id: string;
method: RelayMethod | keyof WorkflowPayloads | 'get_log_history' | 'tab_complete' | 'get_context_info' | 'get_data_file_list' | 'get_file_data';
payload: unknown;
}
export interface NodeRelayRequest extends RelayRequest {
correlation_id: string;
forward_session_id: string;
deadline_at: number;
}
export interface BotSnapshot {
bot_id: number;
bot_status: BotStatus;
context_exists: boolean;
context_generation: string | null;
stream_id: string;
seq: number;
captured_at: number;
}
export interface LogSegment {
message: string;
font_color: string;
font_style: Array<'normal' | 'italic' | 'bold' | 'light' | 'underline' | 'url'>;
hoverEvent?: {action: 'show_text' | 'show_item' | 'show_entity'; value: string; segments?: Array<Pick<LogSegment, 'message' | 'font_color' | 'font_style'>>};
clickEvent?: {action: string; value: string};
insertion?: string;
}
export interface BotLogRecord {
id: string;
timestamp: number;
context_generation: string | null;
seq: number;
segments: LogSegment[];
}
export interface BotLogsResult {
stream_id: string;
seq: number;
records: BotLogRecord[];
truncated: boolean;
}
export interface TabCompleteResult {
matches: string[];
start: number;
length: number;
}
export type DataFileNode = {type: 'directory'; name: string; children: DataFileNode[]}
| {type: 'file'; name: string; id: string; size: number; modified_at: number};
export interface DataFileListResult {
listing_id: string;
expires_at: number;
partial: boolean;
children: DataFileNode[];
}
export interface FileDataResult {
id: string;
name: string;
encoding: 'base64';
offset: number;
next_offset: number;
total_bytes: number;
eof: boolean;
data: string;
}
export type JsonValue = null | boolean | number | string | JsonValue[] | {[key: string]: JsonValue};
export type WorkflowAction = typeof WORKFLOW_ACTIONS[number];
export interface WorkflowPayloads {
'workflow.start': {workflow_id: string; trigger?: Record<string, JsonValue>};
'workflow.stop': {run_id: string};
'workflow.snapshot': {run_id?: string; include_branches?: boolean};
'workflow.catalog': {};
'workflow.control': {command_id: string; context_generation: string; action: WorkflowAction;
workflow_id?: string; run_id?: string; expected_generation?: number; input?: Record<string, JsonValue>};
'workflow.command': {command_id: string; context_generation: string};
'workflow.runtime': {};
}
export interface WorkflowStartResult {status: 'started' | 'queued' | 'skipped'; run_id?: string; request_id?: string}
export interface WorkflowRunSnapshot {
run_id: string;
workflow_id: string;
status: 'running' | 'stopping' | 'completed' | 'failed' | 'cancelled' | 'timed_out';
steps: number;
active_nodes: string[];
started_at: number;
ended_at: number | null;
stop_reason: 'manual' | 'disconnect' | 'timeout' | 'disposed' | 'updated' | 'reloaded' | null;
error: {code: string; message: string; node_id: string} | null;
// Summary responses omit trigger and branches; legacy responses may omit the marker.
trigger?: Record<string, JsonValue>;
trigger_available?: boolean;
branches?: Array<{
trigger: Record<string, JsonValue>;
vars: Record<string, JsonValue>;
outputs: Record<string, {status: 'success' | 'failure'; output: JsonValue; execution: number;
error?: NonNullable<WorkflowRunSnapshot['error']>}>;
last_result: {status: 'success' | 'failure'; output: JsonValue; node_id: string;
error?: NonNullable<WorkflowRunSnapshot['error']>} | null;
inputs: Record<string, JsonValue>;
retry_counts: Record<string, number>;
}>;
}
export interface WorkflowCommand {
command_id: string;
action: WorkflowAction;
workflow_id: string | null;
state: 'accepted' | 'completed' | 'failed';
result?: WorkflowStartResult | {stopped: true} | {generation: number};
error?: {code: string; message: string};
}
export interface WorkflowRuntimeState {
workflow_id: string;
generation: number;
enabled: boolean;
start_type: 'common' | 'event' | 'timer';
trigger_state: 'stopped' | 'listening' | 'scheduled' | 'exhausted' | 'reloading' | 'error';
accepted_count: number;
max_runs: number | null;
next_fire_at: number | null;
active_run_ids: string[];
queued_count: number;
last_error: {code: string; message: string} | null;
}
export interface WorkflowRuntimeSnapshot {
source_config_revision: number;
workflows: WorkflowRuntimeState[];
commands: WorkflowCommand[];
}
export interface ContextInfoI18n {
lang: string; name: string; description: string;
fields?: Record<string, ContextInfoI18n>;
values?: Record<string, ContextInfoI18n>;
}
export interface ContextInfo {
name: string;
i18n: ContextInfoI18n;
value: JsonValue;
}
export interface InfoUpdate {
forward_session_id: string;
bot_id: number;
context_generation: string;
stream_id: string;
seq: number;
emitted_at: number;
data: {name: string; value: JsonValue};
}
export interface BotEventPayloads {
status_update: {status: BotStatus; context_exists: boolean};
log: {records: BotLogRecord[]};
message: {text: string; position: string; json?: unknown};
bot_error: {code?: string; message: string};
}
export type BotEvent = {[K in BotEventName]: {
forward_session_id: string;
bot_id: number;
context_generation: string | null;
stream_id: string;
seq: number;
emitted_at: number;
name: K;
payload: BotEventPayloads[K];
}}[BotEventName];
export interface OpenSessionCommand extends SessionScope {lease_expires_at: number}
export interface AuthorizeGrantCommand {
forward_session_id: string;
grant_id: string;
user_id: number;
permissions: RelayMethod[];
expires_at: number;
}
export interface RevokeSessionCommand {forward_session_id: string; reason: string}
export interface RevokeGrantCommand extends RevokeSessionCommand {grant_id: string}
export interface RelayStatus {
forward_session_id: string;
node_ready: boolean;
reason: string;
}
export interface ForwardSessionStatus extends RelayStatus, SessionScope {
state: 'open' | 'ready' | 'unavailable' | 'closed';
reported_at: number;
}
export const CONTROL_EVENTS = ['forward_open_session', 'forward_authorize_grant',
'forward_revoke_session', 'forward_revoke_grant'] as const;
export type ControlEvent = typeof CONTROL_EVENTS[number];
export interface RelayLimits {
auth_timeout_ms: number;
close_timeout_ms: number;
rpc_timeout_ms: number;
unused_timeout_ms: number;
idle_timeout_ms: number;
max_token_age_ms: number;
join_window_ms: number;
clock_skew_ms: number;
max_lease_ms: number;
max_token_bytes: number;
max_payload_bytes: number;
max_chat_length: number;
max_connections: number;
max_sessions: number;
max_grants_per_session: number;
max_pending_per_web: number;
max_pending_total: number;
requests_per_second: number;
events_per_second: number;
max_dedupe_entries: number;
dedupe_ttl_ms: number;
max_buffered_packets: number;
}
export interface RelayLogger {log(input: unknown): void; error(message: unknown): unknown}
export interface ControlTransport {
on(event: string, listener: (...args: any[]) => void): unknown;
off(event: string, listener: (...args: any[]) => void): unknown;
emit(event: string, ...args: any[]): unknown;
}
export interface ForwardServerOptions {
forward_id: number;
key: string;
http_server?: HttpServer;
port?: number;
host?: string;
path?: string;
namespace?: string;
allowed_origins: string[];
allow_no_origin?: boolean;
control?: ControlTransport;
logger?: RelayLogger;
limits?: Partial<RelayLimits>;
}
+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;