From 5d889d4445e0c2238fc3ba4ca3dd1ada43a2b8db Mon Sep 17 00:00:00 2001 From: Donald Date: Wed, 19 Aug 2026 15:53:47 +0000 Subject: [PATCH] feat: add local approval submission drafts --- AGENTS.md | 2 +- README.md | 4 +- distribution/agent-compat-install.template.md | 2 +- docs/architecture.md | 18 +++- docs/deployment.md | 6 +- docs/release-checklist.md | 1 + package-lock.json | 4 +- package.json | 2 +- scripts/verify-release.mjs | 8 +- skills/dingtalk-approval/SKILL.md | 6 +- .../references/create-approval.md | 2 + .../references/local-drafts.md | 83 +++++++++++++++++++ src/bridge.ts | 11 ++- src/client-installer.ts | 7 +- src/server.ts | 4 +- src/version.ts | 1 + test/bridge.test.ts | 3 + test/client-installer.test.ts | 2 + test/distribution.test.ts | 15 ++++ test/server-instructions.test.ts | 6 ++ test/version.test.ts | 14 ++++ 21 files changed, 176 insertions(+), 25 deletions(-) create mode 100644 skills/dingtalk-approval/references/local-drafts.md create mode 100644 src/version.ts create mode 100644 test/version.test.ts diff --git a/AGENTS.md b/AGENTS.md index 94a84bc..16ad08c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ - This repository implements one lightweight stdio bridge and one unified remote gateway for DingTalk OA and XFT approvals. - The supported customer architecture uses one gateway OAuth login for both approval sources. Do not reintroduce a local DWS dependency or a second user login without explicit product approval. - The gateway must derive identity from its verified OAuth token. Never accept caller-supplied `userId`, employee ID, owner ID, or equivalent identity selectors. -- Approval bodies, expense details, attachments, and upstream schemas must remain real-time and must not be persisted. +- The gateway must not persist approval bodies, expense details, attachments, or upstream schemas; they remain real-time. The only exception is a user-explicit local submission draft stored on the Agent machine under the current OS user's private data directory. Local drafts must never be uploaded to the gateway or repository, and every resumed draft must be revalidated against the live template schema before submission. ## Git workflow diff --git a/README.md b/README.md index 24261df..09c07fe 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确 安全准备后,确认引用绑定登录用户、模板版本、完整表单内容、重复风险决定和发票预留,10 分钟有效且只能使用一次;相同发票在另一个有效确认中时不会再签发确认,避免并发重复创建。正文和发票原号不落库。钉钉企业 OpenAPI 支持通过 `POST /v1.0/workflow/processes/forecast` 进行无副作用的流程预测;网关只在预测成功并补齐所有自选处理人后才生成提交确认。薪福通在正式提交时完成最终业务校验,并由网关写后读取 `billSts`:状态 `1(待审批)` 返回 `submitted` 和 `revokeId`,状态 `2(审批通过)` 返回 `completed` 且不提供撤回引用。 +用户可以让 Agent 把尚未填完的提单保存为本机草稿。它不是钉钉或薪福通的官方草稿:只写入运行 Agent 的当前 OS 用户私有数据目录,不上传网关,也不跨设备同步。草稿保存用户填写值、可选项决定以及附件的本地路径和 SHA-256,不保存 Token、身份 ID、短期模板/确认/撤回引用或完整上游 Schema;附件在正式准备前重新验哈希。恢复时重新实时列模板和读取 Schema,超过 90 天未更新的普通草稿在下次草稿操作时清理。提交结果不确定的草稿会冻结,核验清楚前禁止重试。宿主没有本地文件能力时不提供持久草稿,也不会改存服务器。当前 MCP 不提供附件上传工具;若实时 Schema 要求上游附件编号,本机路径不能代替,需保留草稿并在原生客户端完成。 + > 发布候选版限制:薪福通 `approve/recall` 撤回接口已经接入并具备本人归属校验与写后复核,但当前企业的真实待审批单仍返回 `XFTSYS9999`,尚无撤回成功样本。因此薪福通撤回属于实验能力;失败或结果不确定时绝不自动重试。查询、处理和发起审批不受此限制。 ## 部署 @@ -89,7 +91,7 @@ https://approval.justconn.top/downloads/dingmcp/install.md - `approvalId` 是绑定用户、来源和有效期的紧凑不透明加密引用;不包含展示字段或用户 ID 明文。 - 每批最多 20 条;引用预检失败时首个上游写调用不会发生。 - 提单的发起人、薪福通员工号和审批状态只能由网关填写。 -- 审批正文、费用明细、附件和薪福通 Schema 不落库。 +- 网关不保存审批正文、费用明细、附件和薪福通 Schema;用户明确创建的本机草稿是唯一例外,且只位于 Agent 机器的当前用户私有目录。 - 发票查重库只保存当前用户和发票标识的 HMAC、来源、上游业务单号、状态及时间,最多保留 35 天;不保存发票原号、商户原文或附件。 - 企业 AppSecret 与薪福通 AuthoritySecret 只保存在服务器。 - `failed`、`succeeded` 与 `outcome_uncertain` 有不同重试语义,后者禁止自动重试。 diff --git a/distribution/agent-compat-install.template.md b/distribution/agent-compat-install.template.md index 5422997..baf7133 100644 --- a/distribution/agent-compat-install.template.md +++ b/distribution/agent-compat-install.template.md @@ -167,7 +167,7 @@ Windows 的 `bridgePath` 和 `mcpConfig...command` 正常应以 `dingtalk-approv ## 步骤 4:安装 Skill 并注册 MCP -1. 按当前 Agent 的官方方式安装 `skillPath` 指向的整个目录,而不是只复制 `SKILL.md`。确认目录中同时存在 `SKILL.md` 和 `references/create-approval.md`。 +1. 按当前 Agent 的官方方式安装 `skillPath` 指向的整个目录,而不是只复制 `SKILL.md`。确认目录中同时存在 `SKILL.md`、`references/create-approval.md` 和 `references/local-drafts.md`。 2. 使用 `mcpConfig` 只新增或替换名称精确为 `dingtalk-approval` 的连接器。安装器本身不会猜测或修改 Agent 的私有配置。 3. 如果 Agent 接受完整 MCP JSON,原样使用整个 `mcpConfig`;如果界面只接受单个服务器配置,原样使用 `mcpConfig.mcpServers.dingtalk-approval`。 4. 该连接器必须是本地 `stdio`,`command` 必须是返回的本地 `bridgePath`,`args` 必须为空数组。Windows 中必须保留 JSON 已正确转义的反斜杠。 diff --git a/docs/architecture.md b/docs/architecture.md index bc16208..3c58072 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,9 +3,11 @@ ## 数据路径 ```text -用户 ─ Messenger ─ Agent ─ 远程 Streamable HTTP MCP/OAuth ─ 统一审批网关 - ├─ 钉钉 OA - └─ 薪福通 +用户 ─ Messenger ─ Agent + ├─ 可选本机私有提单草稿 + └─ 远程 Streamable HTTP MCP/OAuth ─ 统一审批网关 + ├─ 钉钉 OA + └─ 薪福通 ``` 支持远程 MCP 和标准 OAuth,且用户能完成宿主所注册回调的场景直接连接网关,不安装客户端运行时。若宿主使用 `127.0.0.1`/`localhost` 回调,授权页必须在 Agent 所在设备打开。宿主缺少远程传输/OAuth 能力,或用户通过 SSH、无头终端、Messenger 等方式跨设备交互而无法完成本机回调时,使用本地 Bridge 把 stdio 转换为远程 Streamable HTTP,并通过可跨设备的 device-code 登录。Bridge 只保存用户自己的短期 access/refresh token,不包含企业密钥、不读取审批上游,也不监听端口。 @@ -24,6 +26,14 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId 所有工具以普通 `content.text` 中的精简 JSON 作为跨宿主可见结果,同时保留相同的精简 `structuredContent` 供支持结构化结果的宿主使用;两处都不携带诊断字段或上游原始响应。`raw` 仍是受过滤、分页和硬限额保护的按需诊断视图,不是默认数据通道。 +## 本机提单草稿 + +草稿不是网关工具或上游单据状态,而是 Agent 的本地文件工作流。只有用户明确要求起草或保存时,Agent 才在当前 OS 用户的私有应用数据目录写入草稿;Windows 使用 `%LOCALAPPDATA%\DingTalkApproval\drafts`,macOS 使用 `~/Library/Application Support/DingTalkApproval/drafts`,Linux 使用 `${XDG_DATA_HOME:-~/.local/share}/dingtalk-approval/drafts`。POSIX 目录/文件权限分别为 `0700`/`0600`,保存采用同目录临时文件原子替换。宿主没有本地文件能力时明确不支持持久草稿,禁止退化为网关存储。 + +草稿仅保存用户填写值、来源和模板显示名、可选项决定,以及附件本地路径、大小、修改时间和 SHA-256。它不保存 Token、当前用户身份、短期 `templateId`/`confirmationId`/`revokeId`、审批动作引用或完整上游 Schema,也不复制或预上传附件。恢复时按来源和模板名重新实时列模板并获取 Schema;模板不唯一、字段变化、附件丢失或哈希变化都必须回到用户确认。当前 MCP 没有附件上传工具,本机路径或文件内容不能作为上游附件编号提交;实时 Schema 要求附件编号时只能保留草稿并转到原生客户端。普通草稿超过 90 天未更新会在下次草稿操作时清理。 + +正式提交仍完整经过网关的身份、模板、字段、发票查重、预览和一次性确认校验。本机文件被修改不能扩大权限。提交成功或即时完成后删除草稿;确定失败时保留并回到编辑;`outcome_uncertain` 时冻结草稿、丢弃短期引用并禁止自动重试,直到本人发起历史或原生客户端证明结果。 + 单张或部分操作使用 v4 紧凑无状态 `approvalId`。载荷只包含来源、上游标识、用途和 30 分钟有效期,不包含标题、申请人、金额、详情或用户 ID;网关以当前 OAuth 用户派生专属 AES-GCM 密钥,因此同一服务器密钥下其他用户也无法解开。常见薪福通引用约 60 字符,钉钉 OA 引用约 100 字符;长度头可以把宿主截断与普通篡改区分开。待办引用允许进入实时写前校验;`approval_list_history` 签发的 `handled/submitted` 引用只能读取详情,动作查询和批处理会在首次上游写入前拒绝。网关继续接受尚未过期的 v2/v3 待办引用,升级或进程重启不会让同一密钥签发的引用失效。 历史列表不与待办混合,也不签发 `selectionId`。未指定时间时默认查询最近 30 天;`startDate/endDate` 接受按 `Asia/Shanghai` 解释的 `YYYY-MM-DD` 或带时区的 ISO 8601 时间,结束日期包含全天,单次跨度最多 120 天。钉钉 `handled` 从本人已完成工作记录取得,`submitted` 在该时间窗口内按当前用户可见模板查询本人发起实例;薪福通 `handled` 从本人的已处理任务取得,`submitted` 使用服务器派生的本人员工号查询单据,再由网关统一按时间窗口过滤。读取历史详情时再次验证当前用户确实处理过该任务或确实是发起人;审批正文、费用明细和附件仍只实时读取、不落库。 @@ -82,7 +92,7 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId 撤回接口若返回 `XFTSYS*` 服务异常或网络级异常,网关会立即只读复核本人单据状态:已撤回则返回 `revoked`,仍为待审批则返回带 `postWriteVerified` 的 `failed`,无法明确归类才返回 `outcome_uncertain`。任何分支都不会自动重试写操作。 -截至 `1.1.0-rc.14`,薪福通撤回仍是实验能力:当前企业真实调用 `approve/recall` 会返回 `XFTSYS9999`,写后复核确认单据仍为待审批,尚无成功样本。该限制不影响薪福通查询、审批处理或发起审批;正式版不得把“接口已接通”表述为“撤回已验证成功”。 +截至 `1.1.0-rc.15`,薪福通撤回仍是实验能力:当前企业真实调用 `approve/recall` 会返回 `XFTSYS9999`,写后复核确认单据仍为待审批,尚无成功样本。该限制不影响薪福通查询、审批处理或发起审批;正式版不得把“接口已接通”表述为“撤回已验证成功”。 薪福通创建或撤回需要复核时,`approval_get_submission_status` 先用服务器派生的本人员工号和单据详情双重确认目标属于当前用户的已发起单据,再返回状态名称和精简流程历史。只有实时状态仍为 `1(待审批)` 才签发新的短期、用户绑定 `revokeId`;已通过、草稿、拒绝或已撤回单据都不会获得撤回引用。该工具不返回审批正文,也不触发写操作。 diff --git a/docs/deployment.md b/docs/deployment.md index 5c1af9f..23f9fb6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -37,7 +37,7 @@ https://approval.justconn.top/oauth/dingtalk/callback 创建接口必须保持权限校验开启。网关统一提交 `APPRV_PEND`,并在写后实时读取本人单据状态:进入待审批时提供撤回引用;按企业流程规则即时通过时返回 `completed`,不提供撤回引用且禁止重复提交。 -`1.1.0-rc.14` 中薪福通撤回仍标记为实验能力:权限和调用契约已接通,但当前企业真实请求返回 `XFTSYS9999`,尚未取得成功样本。网关会写后复核并禁止自动重试。面向试用用户时必须保留这一说明;若业务要求撤回必须稳定可用,应等待薪福通定位后再升级为正式能力。 +`1.1.0-rc.15` 中薪福通撤回仍标记为实验能力:权限和调用契约已接通,但当前企业真实请求返回 `XFTSYS9999`,尚未取得成功样本。网关会写后复核并禁止自动重试。面向试用用户时必须保留这一说明;若业务要求撤回必须稳定可用,应等待薪福通定位后再升级为正式能力。 ## 首次凭证配置 @@ -119,7 +119,7 @@ https://approval.justconn.top/downloads/dingmcp/install.md 公开 `install.md` 是按回调拓扑选择路径的短安装说明。同机浏览器能够完成宿主回调时,Agent 应按宿主官方方式注册名为 `dingtalk-approval` 的远程 Streamable HTTP MCP,URL 为 `https://approval.justconn.top/mcp`,并由宿主使用标准 OAuth 元数据、动态客户端注册和 PKCE 登录。该路径不下载 Node/npm、Bridge 或 Skill;网关 MCP `instructions` 已提供完整业务规则。登录后只读调用 `approval_list_todos(source=all, limit=1)`,钉钉 OA 与薪福通都为 `ok` 才算验收通过。 -宿主缺少远程 MCP/标准 OAuth,或 loopback 回调与用户打开授权页的设备不一致时,`install.md` 引导到 `compat-install.md`,无需先尝试一次必然失败的远程登录。兼容页保留签名客户端、平台引导、Skill、stdio MCP、可跨设备的 device-code 登录、更新、卸载和故障恢复步骤。Windows PowerShell 产物必须带 UTF-8 BOM,Node 探测使用外层双引号/内层单引号,避免 PowerShell 5.1 吞掉传给原生程序的双引号。 +宿主缺少远程 MCP/标准 OAuth,或 loopback 回调与用户打开授权页的设备不一致时,`install.md` 引导到 `compat-install.md`,无需先尝试一次必然失败的远程登录。兼容页保留签名客户端、平台引导、Skill、stdio MCP、可跨设备的 device-code 登录、更新、卸载和故障恢复步骤。完整 Skill 必须同时包含 `references/create-approval.md` 和 `references/local-drafts.md`。Windows PowerShell 产物必须带 UTF-8 BOM,Node 探测使用外层双引号/内层单引号,避免 PowerShell 5.1 吞掉传给原生程序的双引号。 兼容 Windows 下载优先使用 `curl.exe`,从系统代理变量继承代理,并设置 10 秒连接超时、20 秒低速判定和 90 秒单源总时限;没有 curl 才以 `Invoke-WebRequest -TimeoutSec 90` 兜底。Node 固定版本制品仍按阿里云、npmmirror、官方源顺序尝试并强制校验官方 SHA-256。项目 artifact 由 Ed25519 清单和 SHA-256 保护;来源失败可换源,签名或哈希不符必须立即停止。 @@ -135,7 +135,7 @@ curl --fail https://approval.justconn.top/downloads/dingmcp/manifest.json curl --fail https://approval.justconn.top/downloads/dingmcp/manifest.sig ``` -备份时必须把 `.runtime/auth.sqlite`、同路径派生的 `.runtime/auth.sqlite.invoice-dedup.sqlite` 与 `.secrets/oauth-session.key` 一并迁移。发票查重库只含不可逆 HMAC 和最小状态元数据;审批正文不落库,不需要备份。 +备份时必须把 `.runtime/auth.sqlite`、同路径派生的 `.runtime/auth.sqlite.invoice-dedup.sqlite` 与 `.secrets/oauth-session.key` 一并迁移。发票查重库只含不可逆 HMAC 和最小状态元数据;审批正文不落库,不需要备份。本机提单草稿不在服务器上,也不属于服务器备份;用户如需迁移,必须把 Agent 所在机器的当前用户私有草稿目录作为敏感业务数据单独处理。 每个候选版和正式版发布前执行 [发布检查清单](release-checklist.md)。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 73fb523..8a31d6a 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -17,6 +17,7 @@ - 兼容安装结果的 `loginCommand` 能在 MCP 未加载时刷新登录链接;MCP 已加载时 `approval_login` 完成同一操作。不得因链接过期而重装,首次成功响应只展示一次 onboarding 示例。 - 只读验证待办、本人已处理历史、本人发起历史、历史默认最近 30 天及显式时间窗口、搜索、详情、费用结构化字段、动作、模板 Schema、部门/城市选项和越权拒绝;历史引用必须被动作查询和批处理拒绝。 - 发票查重验证钉钉/XFT 跨来源精确匹配、弱匹配、用户隔离、状态更新、来源不可用、历史截断、详情失败、并发确认、35 天过期清理和网关重启恢复;数据库不得出现用户 ID、发票原号、审批正文或附件。重复风险和不完整覆盖未获用户确认时不得签发可提交 `confirmationId`。 +- 本机草稿验证 Windows/macOS/Linux 私有目录选择、POSIX `0700`/`0600`、原子保存、90 天普通草稿清理、模板和 Schema 实时恢复、附件 SHA-256 变化、提交成功删除、确定失败保留及 `outcome_uncertain` 冻结。确认网关数据库、日志、发行包和项目仓库都不出现草稿正文,草稿中不保存 Token、身份 ID、短期模板/确认/撤回引用或完整 Schema。 - 真实写操作只执行用户明确授权的样本;任何 `outcome_uncertain` 都停止,不自动重试。 - Mock 契约确认网关和 bridge 的批量 Schema 均固定为 20;第 6 个截断引用及任一无效退回目标都会返回 `preflight_failed`、`writesStarted=false`,上游写调用数为 0。 - 代表性 v4 引用长度满足钉钉 OA 不超过 128 字符、薪福通不超过 80 字符,并通过篡改、过期、跨用户、跨密钥、同密钥重启及 v2/v3 兼容测试。 diff --git a/package-lock.json b/package-lock.json index 0a2bbf6..0f04e60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dingtalk-approval-mcp", - "version": "1.1.0-rc.14", + "version": "1.1.0-rc.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dingtalk-approval-mcp", - "version": "1.1.0-rc.14", + "version": "1.1.0-rc.15", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "express": "^5.2.1", diff --git a/package.json b/package.json index 9f63b19..f9427ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dingtalk-approval-mcp", - "version": "1.1.0-rc.14", + "version": "1.1.0-rc.15", "description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.", "private": true, "type": "module", diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 14ea6dd..9f9cc28 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -70,8 +70,9 @@ try { if (!body.startsWith("#!/usr/bin/env node\n")) throw new Error(`客户端 CLI 缺少 Node shebang:${target}`); } if (!existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "SKILL.md")) - || !existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "references", "create-approval.md"))) { - throw new Error("客户端包缺少完整 Skill 与创建审批 reference"); + || !existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "references", "create-approval.md")) + || !existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "references", "local-drafts.md"))) { + throw new Error("客户端包缺少完整 Skill、创建审批或本机草稿 reference"); } const offlineRoot = join(root, "offline-install"); execFileSync("npm", [ @@ -80,7 +81,8 @@ try { ], { stdio: "pipe", env: { ...process.env, npm_config_update_notifier: "false" } }); if (!existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js")) || !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "approval-contract.js")) - || !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval", "references", "create-approval.md"))) { + || !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval", "references", "create-approval.md")) + || !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval", "references", "local-drafts.md"))) { throw new Error("签名客户端无法在空缓存中离线安装"); } const installer = readFileSync(join(releaseDir, artifactName(manifest.artifacts.installer, "installer")), "utf8"); diff --git a/skills/dingtalk-approval/SKILL.md b/skills/dingtalk-approval/SKILL.md index b882d91..e7154f8 100644 --- a/skills/dingtalk-approval/SKILL.md +++ b/skills/dingtalk-approval/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-approval -description: Use the protected DingTalk Approval MCP to list, inspect, summarize, approve, reject, return, or create the signed-in user's DingTalk OA and XFT approvals, including approvals the user handled or submitted. Trigger for approval todos or history, expense review, reimbursement details, explicit approval actions, or requests to start and submit a new approval. +description: Use the protected DingTalk Approval MCP to list, inspect, summarize, approve, reject, return, create, or locally draft the signed-in user's DingTalk OA and XFT approvals, including approvals the user handled or submitted. Trigger for approval todos or history, expense review, reimbursement details, explicit approval actions, requests to start or submit a new approval, or requests to save, continue, list, or delete a local approval draft. --- # DingTalk Approval @@ -30,4 +30,6 @@ Call `approval_list_history` with `scope=handled` for approvals the current user ## Create an approval -Before creating, submitting, revoking, or reconciling a newly initiated approval, read `references/create-approval.md` completely and follow it. Do not use that reference for ordinary todo review, history lookup, or processing someone else's pending approval. +When the user asks to draft, save, continue, list, or delete a submission draft, read `references/local-drafts.md` completely and follow it. A draft exists only on the Agent machine and never on the gateway or upstream service. Do not expose its UUID or local path. + +Before preparing, submitting, revoking, or reconciling a newly initiated approval—including one resumed from a local draft—read `references/create-approval.md` completely and follow it. Do not use that reference for ordinary todo review, history lookup, or processing someone else's pending approval. diff --git a/skills/dingtalk-approval/references/create-approval.md b/skills/dingtalk-approval/references/create-approval.md index 8dab1b2..ab557c1 100644 --- a/skills/dingtalk-approval/references/create-approval.md +++ b/skills/dingtalk-approval/references/create-approval.md @@ -19,3 +19,5 @@ If the user explicitly asks to undo a DingTalk or XFT approval just created thro For an XFT submission or recall result that needs reconciliation, call `approval_get_submission_status` with the `businessId` returned by `approval_submit`. It is read-only and revalidates that the bill belongs to the signed-in user's initiated bills. Report `statusName` and the compact flow history. `completed` means the workflow finished immediately under the enterprise's approval rules (for example, no other effective approver); do not retry it. A fresh short-lived `revokeId` is returned only while the live bill status is pending approval; it still requires the user's explicit authorization before one recall attempt. Never treat status lookup alone as permission to repeat a write, and never retry `completed`, `upstream_state_mismatch`, or `outcome_uncertain`. Never ask for or pass an applicant user ID, employee number, owner, or approval status. The gateway derives them from the authenticated user. For XFT custom fields, use the live `fieldNumber` and its declared `valueFormat`; people use roster-number arrays, departments use organization-code arrays, and tables use `sequence`/`columnValues`/`titleCode`. If a required field type is unsupported or XFT reports a missing employee profile or permission, explain the requirement and stop. + +Local file paths and file bytes are not valid attachment field values. When the live Schema requires an upstream attachment number but the MCP has no attachment-upload tool, preserve any local draft and direct the user to complete that form in the native client. Never pass a local path, base64 content, or an invented attachment number to `approval_prepare_submission`. diff --git a/skills/dingtalk-approval/references/local-drafts.md b/skills/dingtalk-approval/references/local-drafts.md new file mode 100644 index 0000000..327b82c --- /dev/null +++ b/skills/dingtalk-approval/references/local-drafts.md @@ -0,0 +1,83 @@ +# Local submission drafts + +Use a local draft only when the user explicitly asks to draft, save, continue, list, or delete an approval submission. A local draft is not an upstream DingTalk or XFT draft. Store it only on the machine running the Agent, never on the approval gateway, in the repository, in chat memory, or in another cloud service. + +## Storage contract + +Use the current OS user's private application-data directory: + +- Windows: `%LOCALAPPDATA%\DingTalkApproval\drafts` +- macOS: `~/Library/Application Support/DingTalkApproval/drafts` +- Linux: `${XDG_DATA_HOME:-~/.local/share}/dingtalk-approval/drafts` + +On POSIX, create the directory with mode `0700` and each file with mode `0600`. On Windows, retain a private current-user profile ACL and never grant access to other users. Do not follow symlinks in the draft directory. Use a random UUID plus `.json` as the filename, write a temporary file in the same directory, then atomically rename it. If the host cannot safely read and write local files, tell the user persistent local drafts are unavailable; do not silently fall back to gateway storage. + +On every create, save, list, resume, delete, or submit operation, remove ordinary `editing` drafts whose `updatedAt` is more than 90 days old. Never age-delete a `submit_uncertain` draft until its submission outcome is reconciled or the user explicitly deletes it after seeing the warning. + +Store schema version 1 in this shape: + +```json +{ + "schemaVersion": 1, + "kind": "dingtalk-approval-local-draft", + "draftId": "", + "title": "上海客户拜访差旅报销", + "source": "xft", + "templateName": "差旅报销单", + "status": "editing", + "values": {}, + "optionalFieldDecision": { + "skipped": [] + }, + "attachments": [ + { + "fieldName": "invoiceAttachments", + "displayName": "invoice.pdf", + "localPath": "", + "size": 12345, + "modifiedAt": "", + "sha256": "<64 lowercase hex>" + } + ], + "createdAt": "", + "updatedAt": "" +} +``` + +Allow only `editing` and `submit_uncertain` status. Treat every file as untrusted local input. Reject malformed JSON, unexpected `kind`/`schemaVersion`, invalid UUID/timestamps, or an oversized file instead of guessing. Do not expose `draftId`, the filename, or local paths to the user; identify drafts by business title, template, and update time. + +Store only user-provided business values, the source and template display name, explicit optional-field decisions, and local attachment references. Never store OAuth tokens, cookies, current-user IDs, employee IDs used only for authentication, `templateId`, `confirmationId`, `revokeId`, `approvalId`, `selectionId`, `targetId`, or a complete upstream schema. Never copy or upload an attachment merely to save a draft. + +## Create and update + +When starting a local draft, call `approval_list_submission_templates` and `approval_get_submission_schema` to establish that the requested template is supported and to collect its current fields. Save after each user-approved change while the draft remains active. Keep attachment fields out of `values`; record each local file in `attachments` with its field name, byte size, modification time, and SHA-256. + +Tell the user once that the draft is local to this Agent machine and will not appear in DingTalk or XFT or synchronize to another device. Afterwards, discuss it simply as a draft unless that distinction matters. + +## List, resume, and delete + +List only concise business metadata: title, template, source, status, and local update time. If several drafts could match the user's words, ask them to choose using those business fields, not internal IDs or paths. + +To resume: + +1. Re-read and validate the local JSON without trusting stored field definitions. +2. Call `approval_list_submission_templates` and locate the live template by exact source and display name. If none or more than one matches, ask the user; never reuse a stored short-lived ID. +3. Call `approval_get_submission_schema` again. Reconcile stored values against the live fields. Ask about newly required, changed, ambiguous, or removed fields and never silently drop a value. +4. For each attachment, verify the regular file still exists and recompute SHA-256. If it is missing or its hash changed, show the attachment display name and require the user to select or confirm the file again. A changed modification time with the same hash may update metadata without changing the business content. +5. Continue the required and optional field collection from `create-approval.md`. + +Delete only the selected local file. Confirm deletion in business terms. Deleting a local draft never calls an upstream revoke or approval API. + +## Prepare and submit + +Before preparing a draft, read `create-approval.md` completely and follow its live validation, optional-field, duplicate-invoice, preview, and final-confirmation workflow. Recompute attachment hashes immediately before `approval_prepare_submission`. The server remains authoritative: local file modification cannot bypass identity, template, field, invoice-duplicate, or confirmation checks. + +A local attachment path or file bytes are never a valid upstream attachment field value. If the live schema requires an upstream attachment number and the MCP exposes no attachment-upload tool, keep the draft and explain that this form must be completed in the native client; never send a local path, base64 file, or invented attachment number to the gateway. + +Never persist the returned `templateId`, `confirmationId`, or `revokeId`. After the user's final confirmation, call `approval_submit` once with the in-memory live values and confirmation: + +- On `submitted`, `succeeded`, or `completed`, delete the local draft after the tool result confirms the write. +- On a confirmed `failed` result, keep the draft as `editing`, discard all short-lived references, and explain the fixable business error. +- On `outcome_uncertain`, atomically mark the draft `submit_uncertain`, discard all short-lived write references, and freeze it. Never prepare or submit it again automatically. First query the signed-in user's live submitted history and, when a returned XFT business number permits it, use `approval_get_submission_status`. If the outcome cannot be proved, keep it frozen and ask the user to verify in the native client. + +When reconciliation proves that the approval was created, delete the draft. When it proves that no approval was created, return it to `editing` only after telling the user what was checked; a later submit still requires a fresh schema, preparation, preview, duplicate check, and explicit confirmation. diff --git a/src/bridge.ts b/src/bridge.ts index 94799a5..eab1e02 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -10,6 +10,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { z } from "zod"; import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js"; import { isDirectEntrypoint } from "./entrypoint.js"; +import { APPLICATION_VERSION } from "./version.js"; interface BridgeState { client?: { client_id: string; client_secret?: string }; @@ -117,7 +118,7 @@ export class RemoteBridge { await this.tryCompleteDeviceLogin(); if (!this.usableAccessToken()) await this.refreshAccessToken(); if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin()); - const client = new Client({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.13" }); + const client = new Client({ name: "dingtalk-approval-stdio-bridge", version: APPLICATION_VERSION }); const transport = new StreamableHTTPClientTransport(this.endpoint, { requestInit: { headers: { authorization: `Bearer ${this.state.tokens!.access_token}` } }, }); @@ -388,8 +389,8 @@ function parseEndpoint(argv: string[]): string { } export function createBridgeMcpServer(bridge: RemoteBridge): McpServer { - const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.13" }, { - instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。", + const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: APPLICATION_VERSION }, { + instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。", }); server.registerTool("approval_login", { title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {}, @@ -502,7 +503,9 @@ export function syncBundledSkillForLegacyUpdate( ): void { const resolvedState = resolve(statePath); if (basename(resolvedState) !== "session.json" || basename(dirname(resolvedState)) !== "state") return; - if (!existsSync(join(bundled, "SKILL.md")) || !existsSync(join(bundled, "references", "create-approval.md"))) return; + if (!existsSync(join(bundled, "SKILL.md")) + || !existsSync(join(bundled, "references", "create-approval.md")) + || !existsSync(join(bundled, "references", "local-drafts.md"))) return; const target = resolve(dirname(resolvedState), "../skill/dingtalk-approval"); const parent = dirname(target); mkdirSync(parent, { recursive: true, mode: 0o700 }); diff --git a/src/client-installer.ts b/src/client-installer.ts index 0fc1092..985daa3 100644 --- a/src/client-installer.ts +++ b/src/client-installer.ts @@ -808,8 +808,11 @@ function mcpConfig(paths: ClientPaths): Record { } function installBundledSkill(paths: ClientPaths, bundledSkillDir: string, skillBody: Buffer): void { - const reference = join(bundledSkillDir, "references", "create-approval.md"); - if (!existsSync(join(bundledSkillDir, "SKILL.md")) || !existsSync(reference)) { + const createReference = join(bundledSkillDir, "references", "create-approval.md"); + const draftReference = join(bundledSkillDir, "references", "local-drafts.md"); + if (!existsSync(join(bundledSkillDir, "SKILL.md")) + || !existsSync(createReference) + || !existsSync(draftReference)) { throw new Error("客户端包缺少完整 Skill 资源"); } const parent = dirname(paths.skillDir); diff --git a/src/server.ts b/src/server.ts index 5d2ea6b..c4ad872 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,6 +21,7 @@ import type { OfficialOaReturnClient } from "./unified-approval.js"; import { DINGTALK_OAUTH_AUTHORIZE_ORIGIN, DingTalkMcpOAuthProvider } from "./oauth.js"; import type { AuthenticatedDingTalkUser } from "./xft.js"; import { InvoiceDeduplicationService, InvoiceDedupStore, invoiceDedupDatabasePath } from "./invoice-dedup.js"; +import { APPLICATION_VERSION } from "./version.js"; type TransportEntry = | { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; createdAt: number; lastSeenAt: number } @@ -41,6 +42,7 @@ export const APPROVAL_MCP_INSTRUCTIONS = [ "钉钉开放接口无法预检部分模板的签字等业务规则;要求手写签名时返回客户端处理链接。处理结果中 failed 表示操作被拒绝且未生效;除非用户看过原因后再次明确要求,否则不要重试。succeeded 表示节点提交成功;completed 才表示无需当前用户继续审批。uncertain/outcome_uncertain 表示已经提交但未确认,禁止自动重试,应稍后重新列出待办。followUpRequired 大于 0 时应重新列出待办并告知用户。", "出现 authChallenge 时,工具不会通过其他消息通道自动发送内容。必须把 directUrl 原样写入当前面向用户的回复;有二维码图片时直接作为当前回复附件,禁止转成 ASCII,也不得只声称已生成或已推送。", "发起审批范围仅包括薪福通已支持模板,以及 6 个钉钉候选模板:北极大的出差申请、外出、业务招待申请,捷康的差旅费用报销、业务招待费用报销、业务招待申请。禁止绕过范围限制。固定使用 approval_list_submission_templates → approval_get_submission_schema → approval_prepare_submission。薪福通部门或城市编码必须用 approval_get_submission_options 实时查询,不得猜测。必须先检查 submissionCapability;client_only 时停止 API 构造并引导钉钉客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。必须列出尚未填写的非必填字段并询问用户是否补充,用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。", + "用户明确要求起草、保存、继续、列出或删除提单草稿时,草稿只能由 Agent 保存在运行机器的当前 OS 用户私有数据目录:Windows %LOCALAPPDATA%\\DingTalkApproval\\drafts、macOS ~/Library/Application Support/DingTalkApproval/drafts、Linux ${XDG_DATA_HOME:-~/.local/share}/dingtalk-approval/drafts;POSIX 目录/文件权限为 0700/0600并用同目录临时文件原子替换。不得上传网关、写入项目仓库或声称已在钉钉/薪福通创建草稿。宿主没有本地文件能力时明确说明无法持久保存。每个 UUID.json 使用 schemaVersion=1、kind=dingtalk-approval-local-draft、draftId、title、source、templateName、status(editing或submit_uncertain)、values、optionalFieldDecision、attachments、createdAt、updatedAt;只保存用户填写值和附件本地路径/大小/mtime/SHA-256,不得保存 Token、身份 ID、短期 templateId/confirmationId/revokeId 或完整上游 Schema。恢复时必须重新列模板并实时读取 Schema,附件必须在正式准备前重新校验;本地路径不能当作上游附件值。每次草稿操作自动清理超过 90 天未更新的普通草稿。正式提交成功或即时完成后删除;明确失败时保留;outcome_uncertain 时冻结且禁止自动重试,先实时核验本人发起记录。", "approval_prepare_submission 会自动检查当前用户近30天本人发起的钉钉与薪福通审批是否使用了相同发票。若返回 duplicate_confirmation_required 或 duplicate_check_unavailable,本次没有生成 confirmationId;必须展示匹配单号、状态和查重覆盖情况并询问用户。只有用户明确表示知晓重复或查重不完整仍要提交后,才可将 allowDuplicateInvoices=true 原样重新 prepare。不得自行代替用户确认。", "approval_submit 发起后会返回实时核验结果;仍在审批中的单据会带 revokeId,按企业规则即时完成的薪福通流程返回 completed 且不可撤回。只有用户明确要求撤销该本人审批时才调用 approval_revoke_submission;禁止用撤销代替拒绝别人的待办,禁止自动重试 completed、failed 或 outcome_uncertain。薪福通创建或撤回需要复核时使用只读 approval_get_submission_status,它会实时校验本人已发起单据归属,但不会再次执行写操作。", ].join("\n"); @@ -56,7 +58,7 @@ export function createApprovalMcpServer( invoiceDedup?: InvoiceDeduplicationService, ): McpServer { const server = new McpServer( - { name: "dingtalk-approval-mcp", version: "1.1.0-rc.13" }, + { name: "dingtalk-approval-mcp", version: APPLICATION_VERSION }, { instructions: APPROVAL_MCP_INSTRUCTIONS }, ); const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..26f0bbd --- /dev/null +++ b/src/version.ts @@ -0,0 +1 @@ +export const APPLICATION_VERSION = "1.1.0-rc.15"; diff --git a/test/bridge.test.ts b/test/bridge.test.ts index 6b4d84e..93fd74a 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -19,12 +19,14 @@ test("legacy updater post-install syncs the complete bundled Skill", () => { mkdirSync(target, { recursive: true }); writeFileSync(join(bundled, "SKILL.md"), "new skill"); writeFileSync(join(bundled, "references", "create-approval.md"), "create rules"); + writeFileSync(join(bundled, "references", "local-drafts.md"), "draft rules"); writeFileSync(join(target, "SKILL.md"), "old skill"); syncBundledSkillForLegacyUpdate(statePath, bundled); assert.equal(readFileSync(join(target, "SKILL.md"), "utf8"), "new skill"); assert.equal(readFileSync(join(target, "references", "create-approval.md"), "utf8"), "create rules"); + assert.equal(readFileSync(join(target, "references", "local-drafts.md"), "utf8"), "draft rules"); assert.deepEqual(readdirSync(join(root, "client", "skill")), ["dingtalk-approval"]); }); @@ -245,6 +247,7 @@ test("MCP approval_login returns both portable text and a standard resource link const instructions = client.getInstructions() ?? ""; assert.match(instructions, /登录链接过期.*approval_login.*绝不.*重新安装/); assert.match(instructions, /审批引用.*调用容量.*内部分批.*不得向用户解释/); + assert.match(instructions, /提单草稿仅可保存在 Agent 所在机器.*不得上传网关.*实时模板 Schema/s); assert.doesNotMatch(instructions, /最多.*20|selectionId|approvalId|targetId/); const history = tools.tools.find((tool) => tool.name === "approval_list_history")!; assert.equal((history.inputSchema as any).properties.startDate.default, ""); diff --git a/test/client-installer.test.ts b/test/client-installer.test.ts index 0820f38..ae90b95 100644 --- a/test/client-installer.test.ts +++ b/test/client-installer.test.ts @@ -389,6 +389,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr assert.equal(existsSync(paths.bridgeCommand), true); assert.equal(existsSync(paths.skillFile), true); assert.equal(existsSync(join(paths.skillDir, "references", "create-approval.md")), true); + assert.equal(existsSync(join(paths.skillDir, "references", "local-drafts.md")), true); const state = JSON.parse(readFileSync(paths.stateFile, "utf8")); assert.equal(state.version, "1.1.0-rc.5"); assert.deepEqual(state.manifestMirrors, [`${mirrorBase}manifest.json`]); @@ -654,6 +655,7 @@ const skill = join(prefix, "node_modules", "dingtalk-approval-client", "skill", mkdirSync(join(skill, "references"), { recursive: true }); writeFileSync(join(skill, "SKILL.md"), "---\\nname: dingtalk-approval\\ndescription: Bundled test\\n---\\n"); writeFileSync(join(skill, "references", "create-approval.md"), "# Create approval\\n"); +writeFileSync(join(skill, "references", "local-drafts.md"), "# Local drafts\\n"); `); if (process.platform === "win32") { writeFileSync(join(bin, "npm.cmd"), "@echo off\r\nexit /b 99\r\n"); diff --git a/test/distribution.test.ts b/test/distribution.test.ts index 917c20c..a03d0cf 100644 --- a/test/distribution.test.ts +++ b/test/distribution.test.ts @@ -36,6 +36,7 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati assert.match(compatibilityInstaller, /90 秒[\s\S]*stderr[\s\S]*stdout/); assert.match(compatibilityInstaller, /登录链接过期.*绝不能重新安装.*approval_login.*loginCommand/s); assert.match(compatibilityInstaller, /sourceStatus[\s\S]*dingtalk\.status[\s\S]*xft\.status[\s\S]*都必须是 `ok`/); + assert.match(compatibilityInstaller, /references\/local-drafts\.md/); assert.match(compatibilityInstaller, /更新[\s\S]*update --json[\s\S]*卸载[\s\S]*uninstall --yes --json/); assert.doesNotMatch(compatibilityInstaller, /TimeoutSec 300|--max-time 300|--timeout=300|至少 10 分钟超时/); assert.doesNotMatch(compatibilityInstaller, /--host|WorkBuddy|Codex|Claude/); @@ -80,7 +81,9 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati const skill = readFileSync("skills/dingtalk-approval/SKILL.md", "utf8"); const createApproval = readFileSync("skills/dingtalk-approval/references/create-approval.md", "utf8"); + const localDrafts = readFileSync("skills/dingtalk-approval/references/local-drafts.md", "utf8"); assert.match(skill, /read `references\/create-approval\.md` completely/); + assert.match(skill, /read `references\/local-drafts\.md` completely/); assert.doesNotMatch(skill, /XFT recall is experimental/); assert.match(createApproval, /XFT recall is experimental.*XFTSYS9999/s); assert.match(skill, /native remote-MCP OAuth flow when the user can complete its registered callback/); @@ -90,6 +93,18 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati assert.match(skill, /authChallenge\.directUrl.*current reply/s); assert.match(skill, /onboarding\.firstRun=true.*Do not execute an example automatically/s); assert.match(skill, /link expires.*approval_login.*Never reinstall/s); + assert.match(localDrafts, /%LOCALAPPDATA%\\DingTalkApproval\\drafts/); + assert.match(localDrafts, /Library\/Application Support\/DingTalkApproval\/drafts/); + assert.match(localDrafts, /\.local\/share.*dingtalk-approval\/drafts/s); + assert.match(localDrafts, /mode `0700`.*mode `0600`/s); + assert.match(localDrafts, /atomically rename/); + assert.match(localDrafts, /more than 90 days old/); + assert.match(localDrafts, /SHA-256/); + assert.match(localDrafts, /Never store[\s\S]*templateId[\s\S]*confirmationId[\s\S]*complete upstream schema/); + assert.match(localDrafts, /outcome_uncertain[\s\S]*freeze[\s\S]*Never prepare or submit it again automatically/); + assert.match(localDrafts, /never on the approval gateway/); + assert.match(localDrafts, /local attachment path.*never a valid upstream attachment field value.*native client/s); + assert.match(createApproval, /Local file paths.*not valid attachment field values.*native client/s); const builder = readFileSync("scripts/build-release.mjs", "utf8"); assert.match(builder, /schemaVersion: 3,\s+version,/s); diff --git a/test/server-instructions.test.ts b/test/server-instructions.test.ts index 64a069c..d927dde 100644 --- a/test/server-instructions.test.ts +++ b/test/server-instructions.test.ts @@ -46,6 +46,12 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a assert.match(client.getInstructions() ?? "", /单次条数限制.*内部调用细节.*绝不向用户.*一键全选.*如何分组/s); assert.match(client.getInstructions() ?? "", /用户只是在查看.*业务信息.*不主动讨论.*自动分批.*不得造成重复确认/s); assert.match(client.getInstructions() ?? "", /近30天.*allowDuplicateInvoices=true/s); + assert.match(client.getInstructions() ?? "", /草稿只能由 Agent 保存在.*当前 OS 用户私有数据目录/s); + assert.match(client.getInstructions() ?? "", /schemaVersion=1.*dingtalk-approval-local-draft.*submit_uncertain/s); + assert.match(client.getInstructions() ?? "", /不得上传网关.*不得.*完整上游 Schema/s); + assert.match(client.getInstructions() ?? "", /恢复时.*实时读取 Schema/s); + assert.match(client.getInstructions() ?? "", /自动清理超过 90 天/s); + assert.match(client.getInstructions() ?? "", /outcome_uncertain.*冻结.*禁止自动重试/s); const tools = await client.listTools(); assert.deepEqual(tools.tools.map((tool) => tool.name), [ diff --git a/test/version.test.ts b/test/version.test.ts new file mode 100644 index 0000000..2d29c7f --- /dev/null +++ b/test/version.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { APPLICATION_VERSION } from "../src/version.js"; + +test("package, gateway, and bridge share one application version", () => { + const packageMetadata = JSON.parse(readFileSync("package.json", "utf8")) as { version: string }; + assert.equal(APPLICATION_VERSION, packageMetadata.version); + for (const path of ["src/server.ts", "src/bridge.ts"]) { + const source = readFileSync(path, "utf8"); + assert.match(source, /version: APPLICATION_VERSION/); + assert.doesNotMatch(source, /version: "\d+\.\d+\.\d+(?:-rc\.\d+)?"/); + } +});