chore: initial commit
This commit is contained in:
+28
@@ -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
|
||||
@@ -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 监听链。
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1058
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import * as readline from 'node:readline';
|
||||
import { EventEmitter } from 'node:events';
|
||||
type font_style = 'normal' | 'italic' | 'bold' | 'light' |'underline' | 'url';
|
||||
|
||||
interface log {
|
||||
message: any;
|
||||
font_color: string;
|
||||
font_style: font_style[];
|
||||
}
|
||||
|
||||
// 将 font_style 转为 ANSI 参数;normal 不产生参数
|
||||
function style_codes(font_style: font_style[]): number[] {
|
||||
const codes: number[] = [];
|
||||
for (const style of font_style ?? []) {
|
||||
switch (style) {
|
||||
case 'bold':
|
||||
codes.push(1);
|
||||
break;
|
||||
case 'light':
|
||||
codes.push(2);
|
||||
break;
|
||||
case 'italic':
|
||||
codes.push(3);
|
||||
break;
|
||||
case 'underline':
|
||||
case 'url':
|
||||
codes.push(4);
|
||||
break;
|
||||
case 'normal':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
// 解析 #rgb / #rrggbb,失败返回 null
|
||||
function parse_hex_color(color: string): [number, number, number] | null {
|
||||
if (typeof color !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const value = color.trim().replace(/^#/, '');
|
||||
if (/^[0-9a-fA-F]{3}$/.test(value)) {
|
||||
return [
|
||||
parseInt(value[0] + value[0], 16),
|
||||
parseInt(value[1] + value[1], 16),
|
||||
parseInt(value[2] + value[2], 16),
|
||||
];
|
||||
}
|
||||
if (/^[0-9a-fA-F]{6}$/.test(value)) {
|
||||
return [
|
||||
parseInt(value.slice(0, 2), 16),
|
||||
parseInt(value.slice(2, 4), 16),
|
||||
parseInt(value.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将一段 log 渲染为带颜色的字符串
|
||||
function to_ansi(segment: log): string {
|
||||
const message = segment.message === undefined
|
||||
? 'undefined'
|
||||
: typeof segment.message === 'string'
|
||||
? segment.message
|
||||
: String(segment.message);
|
||||
|
||||
const codes = style_codes(segment.font_style ?? []);
|
||||
const rgb = parse_hex_color(segment.font_color);
|
||||
if (rgb !== null) {
|
||||
codes.push(38, 2, rgb[0], rgb[1], rgb[2]);
|
||||
}
|
||||
if (codes.length === 0) {
|
||||
return message;
|
||||
}
|
||||
return `\x1b[${codes.join(';')}m${message}\x1b[0m`;
|
||||
}
|
||||
|
||||
export type console_segment = log | string | number | boolean | null | undefined;
|
||||
export type console_input = console_segment | console_segment[];
|
||||
|
||||
export interface _ConsolePrint {
|
||||
info(message: any): log;
|
||||
success(message: any): log;
|
||||
warning(message: any): log;
|
||||
error(message: any): log;
|
||||
tip(message: any): log;
|
||||
custom(message: any, font_color: string, font_style: font_style[]): log;
|
||||
log(input: console_input): void;
|
||||
on(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
once(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
enable_input(prompt?: string): void;
|
||||
disable_input(): void;
|
||||
set_prompt(prompt: string): void;
|
||||
}
|
||||
|
||||
export class ConsolePrint extends EventEmitter implements _ConsolePrint {
|
||||
private rl: readline.Interface | null = null;
|
||||
private prompt = '> ';
|
||||
private input_enabled = false;
|
||||
// 输入行提交后是否需要重新显示提示符(write_line 已恢复时置为 false)
|
||||
private need_prompt = false;
|
||||
|
||||
// 监听 input 时自动开启控制台输入
|
||||
public on(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
if (event === 'input') {
|
||||
this.enable_input();
|
||||
}
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
public once(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
if (event === 'input') {
|
||||
this.enable_input();
|
||||
}
|
||||
return super.once(event, listener);
|
||||
}
|
||||
|
||||
public info(message: any): log { // 白色
|
||||
return {
|
||||
message,
|
||||
font_color: '#ffffff',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public success(message: any): log { // 柔和绿
|
||||
return {
|
||||
message,
|
||||
font_color: '#8fb98f',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public warning(message: any): log { // 柔和沙黄
|
||||
return {
|
||||
message,
|
||||
font_color: '#d1b478',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public error(message: any): log { // 柔和玫红
|
||||
return {
|
||||
message,
|
||||
font_color: '#cf8e8e',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public tip(message: any): log { // 柔和钢蓝
|
||||
return {
|
||||
message,
|
||||
font_color: '#8fb0c9',
|
||||
font_style: ['normal']
|
||||
};
|
||||
}
|
||||
|
||||
public custom(message: any, font_color: string, font_style: font_style[]): log {
|
||||
return {
|
||||
message,
|
||||
font_color,
|
||||
font_style
|
||||
};
|
||||
}
|
||||
|
||||
// 多段拼接后在同一行输出,每段可拥有独立的颜色与样式;支持单段或数组
|
||||
public log(input: console_input): void {
|
||||
const segments: console_segment[] = Array.isArray(input) ? input : [input];
|
||||
if (segments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const body = segments.map(segment => this.format(segment)).join('');
|
||||
this.write_line(this.format(this.timestamp()) + body);
|
||||
}
|
||||
|
||||
// 自动前缀:[month_day hh:mm:ss],统一使用 success 色
|
||||
private timestamp(): log {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hour = String(now.getHours()).padStart(2, '0');
|
||||
const minute = String(now.getMinutes()).padStart(2, '0');
|
||||
const second = String(now.getSeconds()).padStart(2, '0');
|
||||
return this.success(`[${month}-${day} ${hour}:${minute}:${second}] `);
|
||||
}
|
||||
|
||||
private format(segment: log | string | number | boolean | null | undefined): string {
|
||||
if (segment !== null && segment !== undefined && typeof segment === 'object' && 'message' in segment) {
|
||||
return to_ansi(segment as log);
|
||||
}
|
||||
return String(segment);
|
||||
}
|
||||
|
||||
// 有输入时先清掉当前输入行,打印日志后再把输入行恢复,避免输入被打断
|
||||
private write_line(text: string): void {
|
||||
if (this.rl !== null && this.input_enabled && process.stdout.isTTY === true) {
|
||||
readline.cursorTo(process.stdout, 0);
|
||||
readline.clearLine(process.stdout, 0);
|
||||
process.stdout.write(text + '\n');
|
||||
this.rl.prompt(true);
|
||||
this.need_prompt = false;
|
||||
return;
|
||||
}
|
||||
process.stdout.write(text + '\n');
|
||||
}
|
||||
|
||||
// 开启控制台输入,按回车后通过 'input' 事件抛出整行内容
|
||||
public enable_input(prompt?: string): void {
|
||||
if (prompt !== undefined) {
|
||||
this.prompt = prompt;
|
||||
}
|
||||
if (this.rl !== null) {
|
||||
this.rl.setPrompt(this.prompt);
|
||||
return;
|
||||
}
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: process.stdout.isTTY === true
|
||||
});
|
||||
this.rl.setPrompt(this.prompt);
|
||||
this.rl.on('line', (line: string) => {
|
||||
// 若输入处理期间没有输出日志,readline 已在换行后清空输入行,这里补回提示符;
|
||||
// 若期间输出过日志,write_line 已恢复提示符,避免重复
|
||||
this.need_prompt = true;
|
||||
this.emit('input', line);
|
||||
if (this.rl !== null && this.need_prompt) {
|
||||
this.rl.prompt();
|
||||
}
|
||||
this.need_prompt = false;
|
||||
});
|
||||
this.rl.on('close', () => {
|
||||
this.rl = null;
|
||||
this.input_enabled = false;
|
||||
});
|
||||
this.input_enabled = true;
|
||||
this.rl.prompt();
|
||||
}
|
||||
|
||||
public disable_input(): void {
|
||||
if (this.rl === null) {
|
||||
return;
|
||||
}
|
||||
const rl = this.rl;
|
||||
this.rl = null;
|
||||
this.input_enabled = false;
|
||||
rl.close();
|
||||
}
|
||||
|
||||
public set_prompt(prompt: string): void {
|
||||
this.prompt = prompt;
|
||||
this.rl?.setPrompt(prompt);
|
||||
if (this.rl !== null && this.input_enabled) {
|
||||
this.rl.prompt(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const console_print = new ConsolePrint();
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {ForwardServerOptions, RelayLimits} from '../types.ts';
|
||||
|
||||
export const DEFAULT_LIMITS: Readonly<RelayLimits> = Object.freeze({
|
||||
auth_timeout_ms: 10_000,
|
||||
close_timeout_ms: 5_000,
|
||||
rpc_timeout_ms: 5_000,
|
||||
unused_timeout_ms: 60_000,
|
||||
idle_timeout_ms: 30_000,
|
||||
max_token_age_ms: 300_000,
|
||||
join_window_ms: 60_000,
|
||||
clock_skew_ms: 5_000,
|
||||
max_lease_ms: 300_000,
|
||||
max_token_bytes: 8_192,
|
||||
max_payload_bytes: 8 * 1024 * 1024,
|
||||
max_chat_length: 256,
|
||||
max_connections: 2_000,
|
||||
max_sessions: 500,
|
||||
max_grants_per_session: 32,
|
||||
max_pending_per_web: 16,
|
||||
max_pending_total: 4_096,
|
||||
requests_per_second: 30,
|
||||
events_per_second: 200,
|
||||
max_dedupe_entries: 256,
|
||||
dedupe_ttl_ms: 30_000,
|
||||
max_buffered_packets: 128,
|
||||
});
|
||||
|
||||
export function resolve_options(options: ForwardServerOptions) {
|
||||
if (!options || !Number.isSafeInteger(options.forward_id) || options.forward_id < 0) {
|
||||
throw new Error('forward_id must be a nonnegative safe integer');
|
||||
}
|
||||
if (typeof options.key !== 'string' || options.key.trim().length < 16) {
|
||||
throw new Error('A relay secret of at least 16 characters is required');
|
||||
}
|
||||
if (!Array.isArray(options.allowed_origins) || options.allowed_origins.some(origin => {
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
return !['http:', 'https:'].includes(url.protocol) || url.origin !== origin;
|
||||
} catch { return true; }
|
||||
})) throw new Error('allowed_origins must contain explicit HTTP(S) origins');
|
||||
if (options.allow_no_origin !== undefined && typeof options.allow_no_origin !== 'boolean') {
|
||||
throw new Error('allow_no_origin must be a boolean');
|
||||
}
|
||||
const path = options.path ?? '/api/forward_server';
|
||||
const namespace = options.namespace ?? '/';
|
||||
if (!/^\/[A-Za-z0-9_/-]+$/.test(path) || path === '/' || path.endsWith('/')) {
|
||||
throw new Error('path must be an absolute Socket.IO path without a trailing slash');
|
||||
}
|
||||
if (!/^\/[A-Za-z0-9_/-]*$/.test(namespace) || (namespace !== '/' && namespace.endsWith('/'))) {
|
||||
throw new Error('namespace must be an absolute Socket.IO namespace');
|
||||
}
|
||||
const port = options.port ?? 38858;
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid relay port');
|
||||
if (options.http_server && (options.port !== undefined || options.host !== undefined)) {
|
||||
throw new Error('Borrowed HTTP server cannot specify a relay host or port');
|
||||
}
|
||||
const limits = {...DEFAULT_LIMITS, ...options.limits};
|
||||
for (const [name, value] of Object.entries(limits)) {
|
||||
if (!Object.hasOwn(DEFAULT_LIMITS, name) || !Number.isSafeInteger(value) || value < (name === 'clock_skew_ms' ? 0 : 1)
|
||||
|| value > 2_147_483_647) throw new Error(`Invalid relay limit: ${name}`);
|
||||
}
|
||||
if (limits.join_window_ms > limits.max_token_age_ms) throw new Error('Join window exceeds token lifetime');
|
||||
return {...options, allowed_origins: [...options.allowed_origins], path, namespace, port, limits,
|
||||
allow_no_origin: options.allow_no_origin ?? true};
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import {BOT_EVENTS, RELAY_METHODS, WORKFLOW_ACTIONS, WORKFLOW_METHODS} from '../types.ts';
|
||||
import type {
|
||||
AuthorizeGrantCommand, BotEvent, ForwardAck, ForwardErrorCode, InfoUpdate, OpenSessionCommand,
|
||||
RelayLimits, RelayMethod, RelayRequest, SessionScope,
|
||||
} from '../types.ts';
|
||||
|
||||
const ERROR_CODES: readonly ForwardErrorCode[] = [
|
||||
'INVALID_REQUEST', 'UNAUTHORIZED', 'FORBIDDEN', 'NODE_OFFLINE', 'RELAY_UNAVAILABLE',
|
||||
'SESSION_STALE', 'TOKEN_EXPIRED', 'TIMEOUT', 'CANCELLED', 'NO_CONTEXT',
|
||||
'BOT_NOT_ONLINE', 'RATE_LIMITED', 'RESYNC_REQUIRED', 'INTERNAL_ERROR',
|
||||
];
|
||||
const STATUSES = ['offline', 'ready', 'connecting', 'online', 'disconnecting', 'reconnecting'];
|
||||
const STYLES = ['normal', 'italic', 'bold', 'light', 'underline', 'url'];
|
||||
|
||||
export class RelayError extends Error {
|
||||
code: ForwardErrorCode;
|
||||
retryable: boolean;
|
||||
|
||||
constructor(code: ForwardErrorCode, message: string, retryable = false) {
|
||||
super(message);
|
||||
this.name = 'RelayError';
|
||||
this.code = code;
|
||||
this.retryable = retryable;
|
||||
}
|
||||
}
|
||||
|
||||
export function fail(code: ForwardErrorCode, message: string, retryable = false): never {
|
||||
throw new RelayError(code, message, retryable);
|
||||
}
|
||||
|
||||
export function success<T>(data: T): ForwardAck<T> {
|
||||
return {status: 'success', data};
|
||||
}
|
||||
|
||||
export function error_ack(error: unknown): ForwardAck<never> {
|
||||
if (error instanceof RelayError && ERROR_CODES.includes(error.code)
|
||||
&& typeof error.message === 'string' && Buffer.byteLength(error.message) <= 1024
|
||||
&& typeof error.retryable === 'boolean') {
|
||||
return {status: 'error', data: {
|
||||
code: error.code, message: error.message, retryable: error.retryable,
|
||||
}};
|
||||
}
|
||||
return {status: 'error', data: {
|
||||
code: 'INTERNAL_ERROR', message: 'Internal relay error', retryable: false,
|
||||
}};
|
||||
}
|
||||
|
||||
export function is_record(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
export function is_uuid(value: unknown): value is string {
|
||||
return typeof value === 'string'
|
||||
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
function require_valid(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) fail('INVALID_REQUEST', message);
|
||||
}
|
||||
|
||||
function exact(value: unknown, required: string[], optional: string[] = []): asserts value is Record<string, unknown> {
|
||||
require_valid(is_record(value), 'Expected an object');
|
||||
const keys = Reflect.ownKeys(value);
|
||||
require_valid(keys.every(key => typeof key === 'string' && (required.includes(key) || optional.includes(key))),
|
||||
'Unknown DTO field');
|
||||
require_valid(required.every(key => Object.hasOwn(value, key)), 'Missing DTO field');
|
||||
for (const key of keys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
||||
require_valid('value' in descriptor && descriptor.enumerable, 'Expected JSON data fields');
|
||||
}
|
||||
}
|
||||
|
||||
function integer(value: unknown, min = 0): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= min;
|
||||
}
|
||||
|
||||
function text(value: unknown, max = 1024, nonempty = false): value is string {
|
||||
return typeof value === 'string' && (!nonempty || value.trim().length > 0)
|
||||
&& Buffer.byteLength(value, 'utf8') <= max;
|
||||
}
|
||||
|
||||
// Inspect JSON before stringify: bound traversal and reject values serialization would omit or coerce.
|
||||
export function check_size(value: unknown, max: number): void {
|
||||
check_json_size(value, max, false);
|
||||
}
|
||||
|
||||
function check_json_size(value: unknown, max: number, info: boolean | 'files' | 'workflow'): void {
|
||||
require_valid(integer(max, 1), 'Invalid byte limit');
|
||||
let entries = 0;
|
||||
let bytes = 0;
|
||||
const ancestors = new Set<object>();
|
||||
function visit(item: unknown, depth: number): void {
|
||||
require_valid(depth <= (info ? 68 : 16) && ++entries <= (info === true ? 260_000 : 65_536), 'JSON complexity limit exceeded');
|
||||
if (item === null || typeof item === 'boolean') {
|
||||
bytes += item === null ? 4 : item ? 4 : 5;
|
||||
} else if (typeof item === 'string') {
|
||||
require_valid(Buffer.byteLength(item, 'utf8') <= max, 'Payload byte limit exceeded');
|
||||
bytes += Buffer.byteLength(JSON.stringify(item), 'utf8');
|
||||
} else if (typeof item === 'number') {
|
||||
require_valid(Number.isFinite(item), 'Expected a finite JSON number');
|
||||
bytes += Buffer.byteLength(JSON.stringify(item));
|
||||
} else {
|
||||
require_valid(Array.isArray(item) || is_record(item), 'Expected JSON data');
|
||||
require_valid(!ancestors.has(item), 'Cyclic JSON data');
|
||||
ancestors.add(item);
|
||||
bytes += 2;
|
||||
const keys = Reflect.ownKeys(item);
|
||||
if (Array.isArray(item)) {
|
||||
if (info === 'workflow') require_valid(Object.getPrototypeOf(item) === Array.prototype, 'Expected a plain JSON array');
|
||||
require_valid(item.length <= (info === true ? 65_536 : 4096) && keys.length === item.length + 1, 'Expected a dense JSON array');
|
||||
for (let index = 0; index < item.length; index++) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(item, String(index));
|
||||
require_valid(descriptor && 'value' in descriptor && descriptor.enumerable, 'Expected JSON array data');
|
||||
if (index) bytes++;
|
||||
visit(descriptor.value, depth + 1);
|
||||
}
|
||||
} else {
|
||||
require_valid(keys.length <= (info === true ? 65_536 : 4096), 'JSON key limit exceeded');
|
||||
for (let index = 0; index < keys.length; index++) {
|
||||
const key = keys[index];
|
||||
require_valid(typeof key === 'string', 'Expected JSON string keys');
|
||||
if (info === 'workflow') require_valid(!['__proto__', 'prototype', 'constructor'].includes(key), 'Unsafe workflow JSON key');
|
||||
const descriptor = Object.getOwnPropertyDescriptor(item, key)!;
|
||||
require_valid('value' in descriptor && descriptor.enumerable, 'Expected JSON data fields');
|
||||
require_valid(Buffer.byteLength(key) <= max, 'Payload byte limit exceeded');
|
||||
bytes += Buffer.byteLength(JSON.stringify(key)) + 1 + (index ? 1 : 0);
|
||||
visit(descriptor.value, depth + 1);
|
||||
}
|
||||
}
|
||||
ancestors.delete(item);
|
||||
}
|
||||
require_valid(bytes <= max, 'Payload byte limit exceeded');
|
||||
}
|
||||
visit(value, 0);
|
||||
}
|
||||
|
||||
export function check_permissions(value: unknown): asserts value is RelayMethod[] {
|
||||
check_size(value, 1024);
|
||||
require_valid(Array.isArray(value) && value.length <= RELAY_METHODS.length
|
||||
&& value.every(item => RELAY_METHODS.includes(item)) && new Set(value).size === value.length,
|
||||
'Invalid relay permissions');
|
||||
}
|
||||
|
||||
export function validate_scope(value: unknown): asserts value is SessionScope {
|
||||
require_valid(is_record(value), 'Invalid session scope');
|
||||
require_valid(integer(value.forward_id) && integer(value.node_id, 1) && integer(value.bot_id, 1), 'Invalid scope IDs');
|
||||
require_valid(is_uuid(value.forward_session_id), 'Invalid forward session ID');
|
||||
require_valid(text(value.node_session_id, 256, true), 'Invalid node session ID');
|
||||
}
|
||||
|
||||
function events(value: unknown): void {
|
||||
require_valid(Array.isArray(value) && value.length <= BOT_EVENTS.length
|
||||
&& value.every(item => BOT_EVENTS.includes(item)) && new Set(value).size === value.length,
|
||||
'Invalid bot events');
|
||||
}
|
||||
|
||||
function workflow_id(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[a-zA-Z0-9_-]{1,100}$/.test(value)
|
||||
&& !['__proto__', 'prototype', 'constructor'].includes(value);
|
||||
}
|
||||
|
||||
function workflow_input(value: unknown): void {
|
||||
check_json_size(value, 64 * 1024, 'workflow');
|
||||
require_valid(is_record(value), 'Invalid workflow input');
|
||||
}
|
||||
|
||||
export function validate_request(value: unknown, limits: RelayLimits): asserts value is RelayRequest {
|
||||
exact(value, ['request_id', 'method', 'payload']);
|
||||
check_json_size(value, limits.max_payload_bytes, WORKFLOW_METHODS.includes(value.method as typeof WORKFLOW_METHODS[number]) ? 'workflow' : false);
|
||||
require_valid(is_uuid(value.request_id), 'Invalid request ID');
|
||||
require_valid(value.method === 'get_log_history' || value.method === 'tab_complete' || value.method === 'get_context_info'
|
||||
|| value.method === 'get_data_file_list' || value.method === 'get_file_data'
|
||||
|| WORKFLOW_METHODS.includes(value.method as typeof WORKFLOW_METHODS[number])
|
||||
|| (value.method !== 'bot.files' && RELAY_METHODS.includes(value.method as RelayMethod)), 'Invalid relay method');
|
||||
const payload = value.payload;
|
||||
switch (value.method) {
|
||||
case 'bot.snapshot':
|
||||
case 'get_log_history':
|
||||
case 'get_context_info':
|
||||
case 'get_data_file_list':
|
||||
case 'workflow.catalog':
|
||||
case 'workflow.runtime':
|
||||
exact(payload, []);
|
||||
break;
|
||||
case 'workflow.start':
|
||||
exact(payload, ['workflow_id'], ['trigger']);
|
||||
require_valid(workflow_id(payload.workflow_id), 'Invalid workflow ID');
|
||||
if (Object.hasOwn(payload, 'trigger')) workflow_input(payload.trigger);
|
||||
break;
|
||||
case 'workflow.stop':
|
||||
case 'workflow.snapshot':
|
||||
exact(payload, value.method === 'workflow.stop' ? ['run_id'] : [],
|
||||
value.method === 'workflow.snapshot' ? ['run_id', 'include_branches'] : []);
|
||||
if (Object.hasOwn(payload, 'run_id')) require_valid(is_uuid(payload.run_id), 'Invalid workflow run ID');
|
||||
if (Object.hasOwn(payload, 'include_branches')) {
|
||||
require_valid(Object.hasOwn(payload, 'run_id') && typeof payload.include_branches === 'boolean',
|
||||
'Workflow branch selection requires a run ID and boolean flag');
|
||||
}
|
||||
break;
|
||||
case 'workflow.command':
|
||||
case 'workflow.control':
|
||||
exact(payload, ['command_id', 'context_generation', ...(value.method === 'workflow.control' ? ['action'] : [])],
|
||||
value.method === 'workflow.control' ? ['workflow_id', 'run_id', 'expected_generation', 'input'] : []);
|
||||
require_valid(is_uuid(payload.command_id) && is_uuid(payload.context_generation), 'Invalid workflow command context');
|
||||
if (value.method === 'workflow.command') break;
|
||||
require_valid(WORKFLOW_ACTIONS.includes(payload.action as typeof WORKFLOW_ACTIONS[number]), 'Invalid workflow action');
|
||||
exact(payload, ['command_id', 'context_generation', 'action', payload.action === 'stop_run' ? 'run_id' : 'workflow_id'],
|
||||
[...(payload.action !== 'stop_run' ? ['expected_generation'] : []), ...(payload.action === 'run_once' ? ['input'] : [])]);
|
||||
if (Object.hasOwn(payload, 'workflow_id')) require_valid(workflow_id(payload.workflow_id), 'Invalid workflow ID');
|
||||
if (Object.hasOwn(payload, 'run_id')) require_valid(is_uuid(payload.run_id), 'Invalid workflow run ID');
|
||||
require_valid(payload.action === 'stop_run' ? is_uuid(payload.run_id) : workflow_id(payload.workflow_id), 'Missing workflow target');
|
||||
if (Object.hasOwn(payload, 'expected_generation')) require_valid(integer(payload.expected_generation, 1), 'Invalid workflow generation');
|
||||
if (Object.hasOwn(payload, 'input')) workflow_input(payload.input);
|
||||
break;
|
||||
case 'get_file_data':
|
||||
exact(payload, ['id'], ['offset', 'length']);
|
||||
require_valid(is_uuid(payload.id), 'Invalid file ID');
|
||||
if (Object.hasOwn(payload, 'offset')) require_valid(integer(payload.offset), 'Invalid file offset');
|
||||
if (Object.hasOwn(payload, 'length')) require_valid(integer(payload.length, 1) && payload.length <= 262_144, 'Invalid file length');
|
||||
break;
|
||||
case 'bot.logs':
|
||||
exact(payload, ['limit'], ['stream_id', 'after_seq']);
|
||||
require_valid(integer(payload.limit, 1) && payload.limit <= 200, 'Invalid logs limit');
|
||||
if (Object.hasOwn(payload, 'stream_id')) require_valid(is_uuid(payload.stream_id), 'Invalid stream ID');
|
||||
if (Object.hasOwn(payload, 'after_seq')) {
|
||||
require_valid(integer(payload.after_seq) && is_uuid(payload.stream_id), 'Invalid log cursor');
|
||||
}
|
||||
break;
|
||||
case 'bot.subscribe':
|
||||
case 'bot.unsubscribe':
|
||||
exact(payload, ['events']);
|
||||
events(payload.events);
|
||||
break;
|
||||
case 'tab_complete':
|
||||
exact(payload, ['text']);
|
||||
require_valid(typeof payload.text === 'string' && payload.text.length <= 256
|
||||
&& !/[\r\n\0]/.test(payload.text), 'Invalid completion text');
|
||||
break;
|
||||
case 'bot.chat':
|
||||
exact(payload, ['message']);
|
||||
require_valid(integer(limits.max_chat_length, 1) && typeof payload.message === 'string'
|
||||
&& payload.message.trim().length > 0 && payload.message.length <= limits.max_chat_length,
|
||||
'Invalid chat message');
|
||||
break;
|
||||
case 'execute_function':
|
||||
exact(payload, ['function', 'name', 'args']);
|
||||
require_valid(typeof payload.function === 'string' && typeof payload.name === 'string'
|
||||
&& Array.isArray(payload.args), 'Invalid function execution payload');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function generation(value: unknown): boolean {
|
||||
return value === null || is_uuid(value);
|
||||
}
|
||||
|
||||
function context_state(status: unknown, exists: boolean, context_generation: unknown): void {
|
||||
require_valid(exists ? is_uuid(context_generation) : context_generation === null && status === 'offline',
|
||||
'Bot context and status are inconsistent');
|
||||
}
|
||||
|
||||
function log_records(value: unknown): void {
|
||||
require_valid(Array.isArray(value) && value.length <= 200, 'Invalid log records');
|
||||
let previous_seq = -1;
|
||||
const ids = new Set<string>();
|
||||
for (const record of value) {
|
||||
exact(record, ['id', 'timestamp', 'context_generation', 'seq', 'segments']);
|
||||
require_valid(is_uuid(record.id) && integer(record.timestamp) && generation(record.context_generation)
|
||||
&& integer(record.seq), 'Invalid log record');
|
||||
require_valid(record.seq > previous_seq && !ids.has(record.id), 'Log records must be ordered and unique');
|
||||
previous_seq = record.seq;
|
||||
ids.add(record.id);
|
||||
require_valid(Array.isArray(record.segments), 'Invalid log segments');
|
||||
for (const segment of record.segments) {
|
||||
exact(segment, ['message', 'font_color', 'font_style'], ['hoverEvent', 'clickEvent', 'insertion']);
|
||||
require_valid(typeof segment.message === 'string' && text(segment.font_color, 128, true)
|
||||
&& Array.isArray(segment.font_style) && segment.font_style.length <= STYLES.length
|
||||
&& segment.font_style.every(style => STYLES.includes(style)), 'Invalid log segment');
|
||||
for (const key of ['hoverEvent', 'clickEvent']) {
|
||||
if (!Object.hasOwn(segment, key)) continue;
|
||||
const event = segment[key];
|
||||
exact(event, ['action', 'value'], key === 'hoverEvent' ? ['segments'] : []);
|
||||
const actions = key === 'hoverEvent' ? ['show_text', 'show_item', 'show_entity']
|
||||
: ['open_url', 'open_file', 'run_command', 'suggest_command', 'change_page',
|
||||
'copy_to_clipboard', 'show_dialog', 'custom'];
|
||||
require_valid(actions.includes(event.action as string) && text(event.value, 4096, true), 'Invalid log event');
|
||||
if (Object.hasOwn(event, 'segments')) {
|
||||
require_valid(event.action === 'show_text' && Array.isArray(event.segments) && event.segments.length <= 64, 'Invalid hover segments');
|
||||
let length = 0;
|
||||
for (const part of event.segments) {
|
||||
exact(part, ['message', 'font_color', 'font_style']);
|
||||
require_valid(text(part.message, 4096, true) && text(part.font_color, 128, true)
|
||||
&& Array.isArray(part.font_style) && part.font_style.length <= STYLES.length
|
||||
&& part.font_style.every(style => STYLES.includes(style)), 'Invalid hover segment');
|
||||
length += (part.message as string).length;
|
||||
}
|
||||
require_valid(length <= 4096, 'Hover text too long');
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(segment, 'insertion')) require_valid(text(segment.insertion, 4096, true), 'Invalid log insertion');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validate_event(value: unknown, limits: RelayLimits): asserts value is BotEvent {
|
||||
check_size(value, limits.max_payload_bytes);
|
||||
exact(value, ['forward_session_id', 'bot_id', 'context_generation', 'stream_id', 'seq', 'emitted_at', 'name', 'payload']);
|
||||
require_valid(is_uuid(value.forward_session_id) && integer(value.bot_id, 1)
|
||||
&& generation(value.context_generation) && is_uuid(value.stream_id)
|
||||
&& integer(value.seq) && integer(value.emitted_at), 'Invalid bot event envelope');
|
||||
const payload = value.payload;
|
||||
switch (value.name) {
|
||||
case 'status_update':
|
||||
exact(payload, ['status', 'context_exists']);
|
||||
require_valid(STATUSES.includes(payload.status as string) && typeof payload.context_exists === 'boolean',
|
||||
'Invalid bot status');
|
||||
context_state(payload.status, payload.context_exists, value.context_generation);
|
||||
break;
|
||||
case 'log':
|
||||
exact(payload, ['records']);
|
||||
log_records(payload.records);
|
||||
require_valid((payload.records as {seq: number}[]).every(record => record.seq <= (value.seq as number)),
|
||||
'Log record exceeds event watermark');
|
||||
break;
|
||||
case 'message':
|
||||
exact(payload, ['text', 'position'], ['json']);
|
||||
require_valid(typeof payload.text === 'string' && text(payload.position, 128, true), 'Invalid bot message');
|
||||
break;
|
||||
case 'bot_error':
|
||||
exact(payload, ['message'], ['code']);
|
||||
require_valid(text(payload.message, 1024), 'Invalid bot error');
|
||||
if (Object.hasOwn(payload, 'code')) require_valid(text(payload.code, 128, true), 'Invalid bot error code');
|
||||
break;
|
||||
default:
|
||||
fail('INVALID_REQUEST', 'Invalid bot event');
|
||||
}
|
||||
}
|
||||
|
||||
function info_name(value: unknown): boolean {
|
||||
return text(value, 128) && /^[a-zA-Z0-9_-]+$/.test(value);
|
||||
}
|
||||
|
||||
export function validate_info_update(value: unknown, limits: RelayLimits): asserts value is InfoUpdate {
|
||||
check_json_size(value, limits.max_payload_bytes, true);
|
||||
exact(value, ['forward_session_id', 'bot_id', 'context_generation', 'stream_id', 'seq', 'emitted_at', 'data']);
|
||||
require_valid(is_uuid(value.forward_session_id) && integer(value.bot_id, 1)
|
||||
&& is_uuid(value.context_generation) && is_uuid(value.stream_id)
|
||||
&& integer(value.seq) && integer(value.emitted_at), 'Invalid info update envelope');
|
||||
exact(value.data, ['name', 'value']);
|
||||
require_valid(info_name(value.data.name), 'Invalid info name');
|
||||
}
|
||||
|
||||
function validate_info_i18n(value: unknown): void {
|
||||
exact(value, ['lang', 'name', 'description'], ['fields', 'values']);
|
||||
require_valid(text(value.lang, 128) && text(value.name, 256) && text(value.description, 4096), 'Invalid info translation');
|
||||
for (const key of ['fields', 'values']) {
|
||||
if (!Object.hasOwn(value, key)) continue;
|
||||
const children = value[key];
|
||||
require_valid(is_record(children), 'Invalid info translation map');
|
||||
for (const [name, metadata] of Object.entries(children)) {
|
||||
require_valid(text(name, 128), 'Invalid info translation key');
|
||||
validate_info_i18n(metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function workflow_start_result(value: unknown): void {
|
||||
exact(value, ['status'], ['run_id', 'request_id']);
|
||||
require_valid(['started', 'queued', 'skipped'].includes(value.status as string), 'Invalid workflow start result');
|
||||
if (Object.hasOwn(value, 'run_id')) require_valid(is_uuid(value.run_id), 'Invalid workflow run ID');
|
||||
if (Object.hasOwn(value, 'request_id')) require_valid(is_uuid(value.request_id), 'Invalid workflow request ID');
|
||||
require_valid(value.status !== 'started' || is_uuid(value.run_id), 'Missing started run ID');
|
||||
require_valid(value.status !== 'queued' || is_uuid(value.request_id), 'Missing queued request ID');
|
||||
}
|
||||
|
||||
function workflow_error(value: unknown, node = false): void {
|
||||
exact(value, ['code', 'message', ...(node ? ['node_id'] : [])]);
|
||||
require_valid(text(value.code, 128, true) && text(value.message, 1024), 'Invalid workflow error');
|
||||
if (node) require_valid(text(value.node_id, 100), 'Invalid workflow error node');
|
||||
}
|
||||
|
||||
function workflow_command(value: unknown): void {
|
||||
exact(value, ['command_id', 'action', 'workflow_id', 'state'], ['result', 'error']);
|
||||
require_valid(is_uuid(value.command_id) && WORKFLOW_ACTIONS.includes(value.action as typeof WORKFLOW_ACTIONS[number])
|
||||
&& (value.workflow_id === null || workflow_id(value.workflow_id))
|
||||
&& ['accepted', 'completed', 'failed'].includes(value.state as string), 'Invalid workflow command');
|
||||
if (Object.hasOwn(value, 'error')) workflow_error(value.error);
|
||||
if (Object.hasOwn(value, 'result')) {
|
||||
require_valid(is_record(value.result), 'Invalid workflow command result');
|
||||
if (Object.hasOwn(value.result, 'status')) workflow_start_result(value.result);
|
||||
else if (Object.hasOwn(value.result, 'stopped')) {
|
||||
exact(value.result, ['stopped']);
|
||||
require_valid(value.result.stopped === true, 'Invalid workflow stop result');
|
||||
} else {
|
||||
exact(value.result, ['generation']);
|
||||
require_valid(integer(value.result.generation, 1), 'Invalid workflow result generation');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function workflow_runtime(value: unknown): void {
|
||||
exact(value, ['source_config_revision', 'workflows', 'commands']);
|
||||
require_valid(integer(value.source_config_revision) && Array.isArray(value.workflows)
|
||||
&& value.workflows.length <= 4096 && Array.isArray(value.commands) && value.commands.length <= 256,
|
||||
'Invalid workflow runtime snapshot');
|
||||
for (const state of value.workflows) {
|
||||
exact(state, ['workflow_id', 'generation', 'enabled', 'start_type', 'trigger_state', 'accepted_count',
|
||||
'max_runs', 'next_fire_at', 'active_run_ids', 'queued_count', 'last_error']);
|
||||
require_valid(workflow_id(state.workflow_id) && integer(state.generation, 1) && typeof state.enabled === 'boolean'
|
||||
&& ['common', 'event', 'timer'].includes(state.start_type as string)
|
||||
&& ['stopped', 'listening', 'scheduled', 'exhausted', 'reloading', 'error'].includes(state.trigger_state as string)
|
||||
&& integer(state.accepted_count) && (state.max_runs === null || integer(state.max_runs, 1))
|
||||
&& (state.next_fire_at === null || integer(state.next_fire_at)) && integer(state.queued_count)
|
||||
&& Array.isArray(state.active_run_ids) && state.active_run_ids.every(is_uuid), 'Invalid workflow runtime state');
|
||||
if (state.last_error !== null) workflow_error(state.last_error);
|
||||
}
|
||||
for (const command of value.commands) workflow_command(command);
|
||||
}
|
||||
|
||||
function workflow_run(value: unknown): void {
|
||||
exact(value, ['run_id', 'workflow_id', 'status', 'steps', 'active_nodes', 'started_at', 'ended_at', 'stop_reason', 'error'],
|
||||
['branches', 'trigger', 'trigger_available']);
|
||||
require_valid(is_uuid(value.run_id) && workflow_id(value.workflow_id)
|
||||
&& ['running', 'stopping', 'completed', 'failed', 'cancelled', 'timed_out'].includes(value.status as string)
|
||||
&& integer(value.steps) && Array.isArray(value.active_nodes) && value.active_nodes.every(id => text(id, 100, true))
|
||||
&& integer(value.started_at) && (value.ended_at === null || integer(value.ended_at))
|
||||
&& (value.stop_reason === null || ['manual', 'disconnect', 'timeout', 'disposed', 'updated', 'reloaded'].includes(value.stop_reason as string)),
|
||||
'Invalid workflow run');
|
||||
if (value.error !== null) workflow_error(value.error, true);
|
||||
if (Object.hasOwn(value, 'trigger_available')) {
|
||||
require_valid(typeof value.trigger_available === 'boolean', 'Invalid workflow trigger availability');
|
||||
}
|
||||
if (Object.hasOwn(value, 'trigger')) {
|
||||
workflow_input(value.trigger);
|
||||
require_valid(value.trigger_available !== false, 'Workflow trigger availability is inconsistent');
|
||||
}
|
||||
if (Object.hasOwn(value, 'branches')) {
|
||||
require_valid(Array.isArray(value.branches), 'Invalid workflow branches');
|
||||
for (const branch of value.branches) {
|
||||
exact(branch, ['trigger', 'vars', 'outputs', 'last_result', 'inputs', 'retry_counts']);
|
||||
require_valid(['trigger', 'vars', 'outputs', 'inputs', 'retry_counts'].every(key => is_record(branch[key]))
|
||||
&& (branch.last_result === null || is_record(branch.last_result)), 'Invalid workflow branch');
|
||||
for (const output of Object.values(branch.outputs as Record<string, unknown>)) workflow_node_result(output, 'execution');
|
||||
for (const count of Object.values(branch.retry_counts as Record<string, unknown>)) require_valid(integer(count), 'Invalid workflow retry count');
|
||||
if (branch.last_result !== null) workflow_node_result(branch.last_result, 'node_id');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function workflow_node_result(value: unknown, field: 'execution' | 'node_id'): void {
|
||||
exact(value, ['status', 'output', field], ['error']);
|
||||
require_valid(value.status === 'success' || value.status === 'failure', 'Invalid workflow node result');
|
||||
require_valid(field === 'execution' ? integer(value.execution, 1) : text(value.node_id, 100, true), 'Invalid workflow node result identity');
|
||||
if (Object.hasOwn(value, 'error')) workflow_error(value.error, true);
|
||||
}
|
||||
|
||||
export function validate_node_ack(method: RelayRequest['method'], value: unknown, bot_id: number, limits: RelayLimits): asserts value is ForwardAck<unknown> {
|
||||
const workflow = WORKFLOW_METHODS.includes(method as typeof WORKFLOW_METHODS[number]);
|
||||
check_json_size(value, limits.max_payload_bytes, workflow ? 'workflow' : method === 'get_data_file_list' ? 'files' : method === 'get_context_info');
|
||||
require_valid((method === 'get_log_history' || method === 'tab_complete' || method === 'get_context_info'
|
||||
|| method === 'get_data_file_list' || method === 'get_file_data'
|
||||
|| workflow || (method !== 'bot.files' && RELAY_METHODS.includes(method as RelayMethod))) && integer(bot_id, 1), 'Invalid ACK context');
|
||||
exact(value, ['status', 'data']);
|
||||
const data = value.data;
|
||||
if (value.status === 'error') {
|
||||
exact(data, ['code', 'message', 'retryable']);
|
||||
require_valid(ERROR_CODES.includes(data.code as ForwardErrorCode) && text(data.message, 1024)
|
||||
&& typeof data.retryable === 'boolean', 'Invalid error ACK');
|
||||
return;
|
||||
}
|
||||
require_valid(value.status === 'success', 'Invalid ACK status');
|
||||
switch (method) {
|
||||
case 'workflow.start':
|
||||
workflow_start_result(data);
|
||||
break;
|
||||
case 'workflow.stop':
|
||||
exact(data, ['stopped']);
|
||||
require_valid(data.stopped === true, 'Invalid workflow stop result');
|
||||
break;
|
||||
case 'workflow.catalog':
|
||||
check_json_size(data, 100 * 1024, 'workflow');
|
||||
exact(data, ['catalog_version', 'nodes', 'start_nodes', 'events', 'queries', 'common']);
|
||||
require_valid(data.catalog_version === 1
|
||||
&& ['nodes', 'start_nodes', 'events', 'queries'].every(key => Array.isArray(data[key]) && data[key].every(is_record)),
|
||||
'Invalid workflow catalog');
|
||||
require_valid(is_record(data.common) && Array.isArray(data.common.node_fields)
|
||||
&& Array.isArray(data.common.policy_fields) && is_record(data.common.result)
|
||||
&& is_record(data.common.edge_input) && Array.isArray(data.common.references), 'Invalid workflow catalog common metadata');
|
||||
break;
|
||||
case 'workflow.snapshot':
|
||||
if (data !== null) {
|
||||
if (Array.isArray(data)) for (const run of data) workflow_run(run);
|
||||
else workflow_run(data);
|
||||
}
|
||||
break;
|
||||
case 'workflow.control':
|
||||
workflow_command(data);
|
||||
break;
|
||||
case 'workflow.command':
|
||||
if (data !== null) workflow_command(data);
|
||||
break;
|
||||
case 'workflow.runtime':
|
||||
workflow_runtime(data);
|
||||
break;
|
||||
case 'get_data_file_list': {
|
||||
exact(data, ['listing_id', 'expires_at', 'partial', 'children']);
|
||||
require_valid(is_uuid(data.listing_id) && integer(data.expires_at, 1)
|
||||
&& typeof data.partial === 'boolean', 'Invalid file listing');
|
||||
check_json_size(data.children, 512 * 1024, 'files');
|
||||
let nodes = 0;
|
||||
function visit(children: unknown, depth: number): void {
|
||||
require_valid(Array.isArray(children), 'Invalid file children');
|
||||
for (const child of children) {
|
||||
require_valid(depth <= 32 && ++nodes <= 2000, 'File tree complexity limit exceeded');
|
||||
require_valid(is_record(child), 'Invalid file node');
|
||||
if (child.type === 'directory') {
|
||||
exact(child, ['type', 'name', 'children']);
|
||||
require_valid(typeof child.name === 'string', 'Invalid directory name');
|
||||
visit(child.children, depth + 1);
|
||||
} else {
|
||||
exact(child, ['type', 'name', 'id', 'size', 'modified_at']);
|
||||
require_valid(child.type === 'file' && typeof child.name === 'string' && is_uuid(child.id)
|
||||
&& integer(child.size) && integer(child.modified_at), 'Invalid file node');
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(data.children, 1);
|
||||
break;
|
||||
}
|
||||
case 'get_file_data': {
|
||||
exact(data, ['id', 'name', 'encoding', 'offset', 'next_offset', 'total_bytes', 'eof', 'data']);
|
||||
require_valid(is_uuid(data.id) && typeof data.name === 'string' && data.encoding === 'base64'
|
||||
&& integer(data.offset) && integer(data.next_offset) && integer(data.total_bytes)
|
||||
&& data.offset <= data.next_offset && data.next_offset <= data.total_bytes
|
||||
&& typeof data.eof === 'boolean' && data.eof === (data.next_offset === data.total_bytes)
|
||||
&& typeof data.data === 'string' && data.data.length <= 4 * Math.ceil(262_144 / 3), 'Invalid file data');
|
||||
const decoded = Buffer.from(data.data, 'base64');
|
||||
require_valid(decoded.length <= 262_144 && decoded.toString('base64') === data.data
|
||||
&& decoded.length === data.next_offset - data.offset, 'Invalid file bytes');
|
||||
break;
|
||||
}
|
||||
case 'get_context_info':
|
||||
require_valid(Array.isArray(data) && data.length <= 256, 'Invalid context info result');
|
||||
for (const info of data) {
|
||||
exact(info, ['name', 'i18n', 'value']);
|
||||
require_valid(info_name(info.name), 'Invalid info name');
|
||||
validate_info_i18n(info.i18n);
|
||||
}
|
||||
break;
|
||||
case 'bot.snapshot':
|
||||
exact(data, ['bot_id', 'bot_status', 'context_exists', 'context_generation', 'stream_id', 'seq', 'captured_at']);
|
||||
require_valid(data.bot_id === bot_id && STATUSES.includes(data.bot_status as string)
|
||||
&& typeof data.context_exists === 'boolean' && generation(data.context_generation)
|
||||
&& is_uuid(data.stream_id) && integer(data.seq) && integer(data.captured_at), 'Invalid bot snapshot');
|
||||
context_state(data.bot_status, data.context_exists, data.context_generation);
|
||||
break;
|
||||
case 'bot.logs':
|
||||
case 'get_log_history':
|
||||
exact(data, ['stream_id', 'seq', 'records', 'truncated']);
|
||||
require_valid(is_uuid(data.stream_id) && integer(data.seq) && typeof data.truncated === 'boolean',
|
||||
'Invalid logs result');
|
||||
log_records(data.records);
|
||||
if (method === 'get_log_history') require_valid((data.records as unknown[]).length <= 20, 'History exceeds 20 records');
|
||||
require_valid((data.records as {seq: number}[]).every(record => record.seq <= (data.seq as number)),
|
||||
'Log record exceeds response watermark');
|
||||
break;
|
||||
case 'tab_complete':
|
||||
exact(data, ['matches', 'start', 'length']);
|
||||
require_valid(integer(data.start) && data.start <= 256 && integer(data.length)
|
||||
&& data.length <= 256 && data.start + data.length <= 256, 'Invalid completion range');
|
||||
require_valid(Array.isArray(data.matches) && data.matches.length <= 100
|
||||
&& data.matches.every(match => typeof match === 'string' && match.length > 0 && match.length <= 256)
|
||||
&& new Set(data.matches).size === data.matches.length, 'Invalid completion matches');
|
||||
break;
|
||||
case 'bot.chat':
|
||||
exact(data, ['accepted']);
|
||||
require_valid(data.accepted === true, 'Invalid chat result');
|
||||
break;
|
||||
case 'execute_function':
|
||||
require_valid(typeof data === 'boolean', 'Invalid function execution result');
|
||||
break;
|
||||
case 'bot.subscribe':
|
||||
case 'bot.unsubscribe':
|
||||
exact(data, ['events']);
|
||||
events(data.events);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function expiry(value: unknown, limits: RelayLimits): void {
|
||||
require_valid(integer(limits.max_lease_ms, 1) && integer(limits.clock_skew_ms), 'Invalid lease limits');
|
||||
const now = Date.now();
|
||||
require_valid(integer(value, 1) && value > now - limits.clock_skew_ms
|
||||
&& value - now <= limits.max_lease_ms + limits.clock_skew_ms, 'Invalid lease expiry');
|
||||
}
|
||||
|
||||
export function validate_open(value: unknown, forward_id: number, limits: RelayLimits): asserts value is OpenSessionCommand {
|
||||
check_size(value, limits.max_payload_bytes);
|
||||
exact(value, ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id', 'lease_expires_at']);
|
||||
validate_scope(value);
|
||||
require_valid(value.forward_id === forward_id, 'Invalid forward audience');
|
||||
expiry(value.lease_expires_at, limits);
|
||||
}
|
||||
|
||||
export function validate_grant(value: unknown, limits: RelayLimits): asserts value is AuthorizeGrantCommand {
|
||||
check_size(value, limits.max_payload_bytes);
|
||||
exact(value, ['forward_session_id', 'grant_id', 'user_id', 'permissions', 'expires_at']);
|
||||
require_valid(is_uuid(value.forward_session_id) && is_uuid(value.grant_id) && integer(value.user_id, 1),
|
||||
'Invalid grant identity');
|
||||
check_permissions(value.permissions);
|
||||
expiry(value.expires_at, limits);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import type {RelayClaims, RelayLimits} from '../types.ts';
|
||||
import {check_permissions, check_size, fail, is_record, is_uuid, validate_scope} from './relay_validation.ts';
|
||||
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
function derive_key(secret: string): Buffer {
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
export function encode_token(secret: string, data: unknown): string {
|
||||
const key = derive_key(secret);
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const plain = Buffer.from(JSON.stringify(data), 'utf8');
|
||||
const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, ciphertext]).toString('base64url');
|
||||
}
|
||||
|
||||
export function decode_token<T = unknown>(secret: string, token: string): T {
|
||||
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]+$/.test(token) || token.length % 4 === 1) {
|
||||
throw new Error('invalid token');
|
||||
}
|
||||
const key = derive_key(secret);
|
||||
const raw = Buffer.from(token, 'base64url');
|
||||
if (raw.length <= IV_LEN + TAG_LEN || raw.toString('base64url') !== token) throw new Error('invalid token');
|
||||
const iv = raw.subarray(0, IV_LEN);
|
||||
const tag = raw.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const ciphertext = raw.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(plain)) as T;
|
||||
}
|
||||
|
||||
export function verify_token(secret: string, token: string, forward_id: number, limits: RelayLimits, now = Date.now()): RelayClaims {
|
||||
const safe_time = (value: unknown): value is number => typeof value === 'number'
|
||||
&& Number.isSafeInteger(value) && value >= 0;
|
||||
if (!safe_time(now) || !safe_time(forward_id)
|
||||
|| !safe_time(limits.max_token_bytes) || limits.max_token_bytes === 0
|
||||
|| !safe_time(limits.max_token_age_ms) || limits.max_token_age_ms === 0
|
||||
|| !safe_time(limits.join_window_ms) || limits.join_window_ms === 0
|
||||
|| !safe_time(limits.clock_skew_ms)) {
|
||||
fail('UNAUTHORIZED', 'Invalid token verification limits');
|
||||
}
|
||||
if (typeof token !== 'string' || token.length > limits.max_token_bytes
|
||||
|| !/^[A-Za-z0-9_-]+$/.test(token)) {
|
||||
fail('UNAUTHORIZED', 'Invalid relay token');
|
||||
}
|
||||
let claims: unknown;
|
||||
try {
|
||||
claims = decode_token(secret, token);
|
||||
check_size(claims, limits.max_token_bytes);
|
||||
if (!is_record(claims)) throw new Error('Invalid claims');
|
||||
const fields = claims;
|
||||
const required = ['v', 'forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id',
|
||||
'role', 'permissions', 'issued_at', 'join_before', 'expires_at', 'jti'];
|
||||
const allowed = [...required, 'user_id', 'grant_id'];
|
||||
if (!required.every(key => Object.hasOwn(fields, key))
|
||||
|| !Object.keys(fields).every(key => allowed.includes(key))) throw new Error('Invalid claims');
|
||||
validate_scope(claims);
|
||||
check_permissions(claims.permissions);
|
||||
if (claims.v !== 1 || claims.forward_id !== forward_id || !is_uuid(claims.jti)
|
||||
|| (claims.role !== 'node' && claims.role !== 'web')) throw new Error('Invalid claims');
|
||||
if (claims.role === 'web') {
|
||||
if (!safe_time(claims.user_id) || claims.user_id === 0 || !is_uuid(claims.grant_id)) {
|
||||
throw new Error('Invalid web claims');
|
||||
}
|
||||
} else if (Object.hasOwn(claims, 'user_id') || Object.hasOwn(claims, 'grant_id')) {
|
||||
throw new Error('Invalid node claims');
|
||||
}
|
||||
if (!safe_time(claims.issued_at) || !safe_time(claims.join_before) || !safe_time(claims.expires_at)
|
||||
|| claims.issued_at >= claims.join_before || claims.join_before > claims.expires_at
|
||||
|| claims.expires_at - claims.issued_at > limits.max_token_age_ms
|
||||
|| claims.join_before - claims.issued_at > limits.join_window_ms
|
||||
|| claims.issued_at - now > limits.clock_skew_ms) throw new Error('Invalid token times');
|
||||
} catch {
|
||||
fail('UNAUTHORIZED', 'Invalid relay token');
|
||||
}
|
||||
// Persisted session and grant authorization is deliberately checked by the store.
|
||||
const verified = claims as unknown as RelayClaims;
|
||||
if (now - verified.expires_at >= limits.clock_skew_ms
|
||||
|| now - verified.join_before >= limits.clock_skew_ms) {
|
||||
fail('TOKEN_EXPIRED', 'Relay token expired');
|
||||
}
|
||||
return verified;
|
||||
}
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
// 校验错误码映射;每个分组的 ok 均为 0,表示校验通过
|
||||
export const validate_code_map = {
|
||||
username: {
|
||||
ok: 0,
|
||||
over_length: 400,
|
||||
too_short: 401,
|
||||
invalid_char: 402,
|
||||
invalid_type: 403,
|
||||
empty: 404,
|
||||
},
|
||||
host: {
|
||||
ok: 0,
|
||||
invalid_type: 410,
|
||||
empty: 411,
|
||||
over_length: 412,
|
||||
invalid_ip: 413,
|
||||
invalid_format: 414,
|
||||
},
|
||||
port: {
|
||||
ok: 0,
|
||||
invalid_type: 420,
|
||||
not_integer: 421,
|
||||
not_number: 422,
|
||||
out_of_range: 423,
|
||||
},
|
||||
version: {
|
||||
ok: 0,
|
||||
invalid_type: 430,
|
||||
empty: 431,
|
||||
unsupported: 432,
|
||||
},
|
||||
proxy: {
|
||||
ok: 0,
|
||||
invalid_type: 440,
|
||||
missing_type: 441,
|
||||
invalid_type_value: 442,
|
||||
missing_host: 443,
|
||||
invalid_host: 444,
|
||||
missing_port: 445,
|
||||
invalid_port: 446,
|
||||
invalid_username: 447,
|
||||
invalid_password: 448,
|
||||
},
|
||||
bot_status: {
|
||||
online: 0,
|
||||
offline: 449,
|
||||
connecting: 450
|
||||
},
|
||||
bot_context: {
|
||||
ok: 0,
|
||||
instance_created: 460,
|
||||
instance_end_by_user: 461,
|
||||
instance_connected: 462,
|
||||
instance_connecting: 463,
|
||||
context_created: 464,
|
||||
processing: 465,
|
||||
error: 466,
|
||||
},
|
||||
forward: {
|
||||
ok: 0,
|
||||
invalid_token: 470,
|
||||
missing_key: 471,
|
||||
},
|
||||
web: {
|
||||
ok: 0,
|
||||
database_error: 480,
|
||||
method_not_allowed: 481,
|
||||
invalid_json: 482,
|
||||
missing_parameter: 483,
|
||||
invalid_parameter: 484,
|
||||
not_found: 485,
|
||||
internal_server_error: 486,
|
||||
spam: 487,
|
||||
timeout: 488,
|
||||
account: {
|
||||
ok: 0,
|
||||
invalid_username: 489,
|
||||
invalid_password: 490,
|
||||
invalid_register_code: 491,
|
||||
invalid_email: 492,
|
||||
invalid_code: 493,
|
||||
invalid_minecraft_username: 494,
|
||||
same_username: 495,
|
||||
same_email: 496,
|
||||
same_minecraft_username: 497,
|
||||
not_login: 498,
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
|
||||
type value_of<T> = T[keyof T];
|
||||
|
||||
export type validate_username_code = value_of<typeof validate_code_map.username>;
|
||||
export type validate_host_code = value_of<typeof validate_code_map.host>;
|
||||
export type validate_port_code = value_of<typeof validate_code_map.port>;
|
||||
export type validate_version_code = value_of<typeof validate_code_map.version>;
|
||||
export type validate_proxy_code = value_of<typeof validate_code_map.proxy>;
|
||||
export type bot_status_code = value_of<typeof validate_code_map.bot_status>;
|
||||
|
||||
// 所有校验错误码的联合类型
|
||||
export type validate_code =
|
||||
| validate_username_code
|
||||
| validate_host_code
|
||||
| validate_port_code
|
||||
| validate_version_code
|
||||
| validate_proxy_code
|
||||
| bot_status_code;
|
||||
Reference in New Issue
Block a user