From 6ccb102d38a673babaaec398b03341fb00b65b10 Mon Sep 17 00:00:00 2001 From: wupengabc <2508932142@qq.com> Date: Thu, 22 Oct 2026 17:07:08 +0800 Subject: [PATCH] chore: initial commit --- .gitignore | 28 + MAIN_SERVER_IMPLEMENTATION_PLAN.md | 247 +++++++ index.ts | 179 +++++ package.json | 25 + pnpm-lock.yaml | 1058 ++++++++++++++++++++++++++++ runtime/http_mount.ts | 53 ++ runtime/init.ts | 50 ++ runtime/relay_routes.ts | 66 ++ runtime/session_store.ts | 690 ++++++++++++++++++ tests/lifecycle.test.ts | 284 ++++++++ tests/relay.integration.test.ts | 791 +++++++++++++++++++++ tests/session.test.ts | 295 ++++++++ tests/token.test.ts | 756 ++++++++++++++++++++ tsconfig.json | 16 + types.ts | 276 ++++++++ utils/console_print.ts | 260 +++++++ utils/relay_config.ts | 65 ++ utils/relay_validation.ts | 614 ++++++++++++++++ utils/token_utils.ts | 89 +++ utils/validate_code_map.ts | 107 +++ 20 files changed, 5949 insertions(+) create mode 100644 .gitignore create mode 100644 MAIN_SERVER_IMPLEMENTATION_PLAN.md create mode 100644 index.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 runtime/http_mount.ts create mode 100644 runtime/init.ts create mode 100644 runtime/relay_routes.ts create mode 100644 runtime/session_store.ts create mode 100644 tests/lifecycle.test.ts create mode 100644 tests/relay.integration.test.ts create mode 100644 tests/session.test.ts create mode 100644 tests/token.test.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 utils/console_print.ts create mode 100644 utils/relay_config.ts create mode 100644 utils/relay_validation.ts create mode 100644 utils/token_utils.ts create mode 100755 utils/validate_code_map.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3cf4a11 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/MAIN_SERVER_IMPLEMENTATION_PLAN.md b/MAIN_SERVER_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..8dbcb5d --- /dev/null +++ b/MAIN_SERVER_IMPLEMENTATION_PLAN.md @@ -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 = + | {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 监听链。 diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..16741f8 --- /dev/null +++ b/index.ts @@ -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; +} + +let current: ForwardServerHandle | null = null; +let initialization: Promise | null = null; +let startup_options: ReturnType | null = null; +let stopping: Promise | null = null; + +export async function start_forward_server(input: ForwardServerOptions): Promise { + 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): Promise { + 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(); + 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 | 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 | undefined; + try { + await Promise.race([ + io.close(), + new Promise((_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((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 { + if (initialization) { + try { await initialization; } catch { return; } + } + if (current) await current.stop(); + else if (stopping) await stopping; +} + +export type * from './types.ts'; diff --git a/package.json b/package.json new file mode 100644 index 0000000..6921fa1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..16f2f3f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1058 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^26.6.2 + version: 26.6.2 + express: + specifier: ^5.2.1 + version: 5.2.1 + socket.io: + specifier: 4.8.3 + version: 4.8.3 + devDependencies: + socket.io-client: + specifier: ^4.8.4 + version: 4.8.4 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + +packages: + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/node@26.6.2': + resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + engine.io-client@6.6.7: + resolution: {integrity: sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.10: + resolution: {integrity: sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q==} + engines: {node: '>=10.2.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + socket.io-adapter@2.5.8: + resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} + + socket.io-client@4.8.4: + resolution: {integrity: sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + +snapshots: + + '@socket.io/component-emitter@3.1.2': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 26.6.2 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 26.6.2 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 26.6.2 + + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 26.6.2 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/node@26.6.2': + dependencies: + undici-types: 8.9.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 26.6.2 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 26.6.2 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.6.2 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + base64id@2.0.0: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + engine.io-client@6.6.7: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + engine.io@6.6.10: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 26.6.2 + '@types/ws': 8.18.1 + accepts: 1.3.8 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@0.6.3: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + socket.io-adapter@2.5.8: + dependencies: + debug: 4.4.3 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.7 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.7: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3 + engine.io: 6.6.10 + socket.io-adapter: 2.5.8 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.9.0: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + wrappy@1.0.2: {} + + ws@8.21.3: {} + + xmlhttprequest-ssl@2.1.2: {} diff --git a/runtime/http_mount.ts b/runtime/http_mount.ts new file mode 100644 index 0000000..7aa3001 --- /dev/null +++ b/runtime/http_mount.ts @@ -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>(); + +// 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); +} diff --git a/runtime/init.ts b/runtime/init.ts new file mode 100644 index 0000000..706a574 --- /dev/null +++ b/runtime/init.ts @@ -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 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); +} diff --git a/runtime/relay_routes.ts b/runtime/relay_routes.ts new file mode 100644 index 0000000..2048006 --- /dev/null +++ b/runtime/relay_routes.ts @@ -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 void>(); + const connect = (socket: Socket) => { + if (!store.attach(socket)) return; + const handlers = new Map void>(); + const route = (name: string, operation: (value: unknown) => unknown | Promise, 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); + } + }; +} diff --git a/runtime/session_store.ts b/runtime/session_store.ts new file mode 100644 index 0000000..c03f8a4 --- /dev/null +++ b/runtime/session_store.ts @@ -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; +type Result = ForwardAck; +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; expires_at: number; + timer?: Timer; web?: Binding; dedupe: Map; +} +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; revoked_grants: Map; webs: Set; pending: Set; + stream?: string; seq?: number; retired?: Set; +} +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; pending: Set; +} +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(); + private readonly revoked_sessions = new Map(); + private readonly connections = new Map(); + private readonly pending = new Map(); + 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): 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 { + let cancelled = false; + let deadline: Timer | undefined; + const cancellation = new Promise((_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 { + 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 { + 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(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 { + 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 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);} diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts new file mode 100644 index 0000000..98d4fda --- /dev/null +++ b/tests/lifecycle.test.ts @@ -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) => { + 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((resolve, reject) => { + socket.once('connect', resolve); + socket.once('connect_error', reject); + socket.connect(); + }); +} +function control_request(control: EventEmitter, event: string, value: unknown): ForwardAck { + let result: ForwardAck | undefined; + control.emit(event, value, (ack: ForwardAck) => { 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(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(resolve => host.close(() => resolve())); + }); + const listener_counts = {request: host.listenerCount('request'), upgrade: host.listenerCount('upgrade')}; + await new Promise(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'); +}); diff --git a/tests/relay.integration.test.ts b/tests/relay.integration.test.ts new file mode 100644 index 0000000..7dffcef --- /dev/null +++ b/tests/relay.integration.test.ts @@ -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 {return new Promise(resolve => socket.once(event, (...args) => resolve(args)));} +async function ack(socket: Client, name: string, value: unknown): Promise> { + return socket.timeout(1500).emitWithAck(name, value); +} +function code(result: ForwardAck, expected: string): void { + assert.equal(result.status, 'error'); + if (result.status === 'error') assert.equal(result.data.code, expected); +} +async function fixture(overrides: Partial = {}) { + 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; + const entries = sessions.get(f.s.forward_session_id).grants.get(web.grant_id).dedupe as Map; + 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}; + 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(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(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(resolve => {entered = resolve;}); + socket.join = async room => {entered(); await new Promise(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(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}).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(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})?.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(resolve => {entered = resolve;}); + socket.join = async room => {entered(); await new Promise(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(resolve => {entered = resolve;}); + socket.join = async room => {entered(); await new Promise(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();} +}); diff --git a/tests/session.test.ts b/tests/session.test.ts new file mode 100644 index 0000000..8a00a61 --- /dev/null +++ b/tests/session.test.ts @@ -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 void>(); + rooms = new Set(); + 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((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 = {}, + 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> => { + 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, 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) => ({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)); + 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) : 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); +}); diff --git a/tests/token.test.ts b/tests/token.test.ts new file mode 100644 index 0000000..7231dd4 --- /dev/null +++ b/tests/token.test.ts @@ -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 = {}): 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 { + 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; + 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; + 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 = {}; + 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 = {}; 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 = {}; 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 = {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 = {}; 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)); + } +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..37b81cb --- /dev/null +++ b/tsconfig.json @@ -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 + } +} diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..0eb3fe7 --- /dev/null +++ b/types.ts @@ -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 = {status: 'success'; data: T} | { + status: 'error'; data: {code: ForwardErrorCode; message: string; retryable: boolean}; +}; +export type AckCallback = (result: ForwardAck) => 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>}; + 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}; + '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}; + '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; + trigger_available?: boolean; + branches?: Array<{ + trigger: Record; + vars: Record; + outputs: Record}>; + last_result: {status: 'success' | 'failure'; output: JsonValue; node_id: string; + error?: NonNullable} | null; + inputs: Record; + retry_counts: Record; + }>; +} +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; + values?: Record; +} +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; +} diff --git a/utils/console_print.ts b/utils/console_print.ts new file mode 100644 index 0000000..1b515ce --- /dev/null +++ b/utils/console_print.ts @@ -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(); diff --git a/utils/relay_config.ts b/utils/relay_config.ts new file mode 100644 index 0000000..68e37af --- /dev/null +++ b/utils/relay_config.ts @@ -0,0 +1,65 @@ +import type {ForwardServerOptions, RelayLimits} from '../types.ts'; + +export const DEFAULT_LIMITS: Readonly = 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}; +} diff --git a/utils/relay_validation.ts b/utils/relay_validation.ts new file mode 100644 index 0000000..0ab101a --- /dev/null +++ b/utils/relay_validation.ts @@ -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(data: T): ForwardAck { + return {status: 'success', data}; +} + +export function error_ack(error: unknown): ForwardAck { + 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 { + 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 { + 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(); + 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(); + 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)) workflow_node_result(output, 'execution'); + for (const count of Object.values(branch.retry_counts as Record)) 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 { + 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); +} diff --git a/utils/token_utils.ts b/utils/token_utils.ts new file mode 100644 index 0000000..67f6608 --- /dev/null +++ b/utils/token_utils.ts @@ -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(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; +} diff --git a/utils/validate_code_map.ts b/utils/validate_code_map.ts new file mode 100755 index 0000000..7c0b6dc --- /dev/null +++ b/utils/validate_code_map.ts @@ -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[keyof T]; + +export type validate_username_code = value_of; +export type validate_host_code = value_of; +export type validate_port_code = value_of; +export type validate_version_code = value_of; +export type validate_proxy_code = value_of; +export type bot_status_code = value_of; + +// 所有校验错误码的联合类型 +export type validate_code = + | validate_username_code + | validate_host_code + | validate_port_code + | validate_version_code + | validate_proxy_code + | bot_status_code;