Commit 76d2a5aeaf3d605fd94b2e6eded93dd5ade6eabd
1 parent
4ea1be56
feat(auth): Phase 2 —— 公司归属隔离(38 号文档)
company-service.js:company.json 加 ownerId(无 SQL 表,纯 JSON 字段);create() 接收
ownerId;list({ user }) 按传入用户过滤,不传就不过滤(company-heartbeat-service.js 等
系统内部巡检要看全部公司);新增 canAccessCompany(company, user) 作为全仓库唯一一份
归属判据(admin 不过滤,判负一律 404 不是 403,不暴露"这个 id 存在只是不是你的")。
company.js 用 router.param('companyId', ...) 统一收口(约 50 条路由,含 okrPath() 系列
动态路由)。graph-run.js 的两个挂载点参数形状不一样,设计阶段的描述不准确,实现时才
发现:companyRouter 唯一路由是 /:companyId/graph-runs 直接查;主 router 全部路由是
/:id/...,:id 指的是 run 不是公司,改成先查 run 的 company_id 再判断,run 不存在或
公司不是你的统一回"run 不存在"。devloop.js 用 :pid 反查 company_id;POST /projects
单独校验 body 里的 company_id(放在基本字段校验之后,避免归属检查打乱输入校验的错误
优先级);GET /projects 按归属过滤列表。
okr.js 最终没有按原计划接入 companyId 归属校验——实现前查证发现 OkrDashboard.vue
挂载的 /okr 页面全站没有任何导航入口指向它,是个孤儿页面,给访问不到的页面建"选公司"
前端交互不划算,改成 requireAdmin(跟 dream.js 同款),先堵住直接拼 URL 看到跨公司
聚合数据的口子。真正在用的公司级 OKR(company.js 的 /:id/okr)已随 company.js 一起隔离。
存量数据迁移:scripts/backfill-company-owner.js(npm run backfill:company-owner),
两家存量公司已划给种子账号,幂等验证过。
8 个既有测试文件用的公司 id 都是从没真正建过的字符串标签,挂锁后集体 404——新增共享
helper server/tests/helpers/ensure-test-company.js 修复;两处原有的"跳过已有
company.json 的目录,那是真公司"防御逻辑,判据从"有没有 company.json"改成"ownerId
是不是 -1 这个测试专用标记",否则新出现的测试夹具会被误判成真公司永远不清理。
验证:真实 curl 走过(admin 看到全部存量公司、新用户看到空列表、新用户拼 URL 访问
别人的公司 404、新建公司 ownerId 精确落在创建者、admin 和创建者都看得到其他人看不到);
全量 vitest 1427 passed,3 个无关既有失败不受影响。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
21 changed files
with
328 additions
and
38 deletions
docs/README.md
| @@ -49,7 +49,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | @@ -49,7 +49,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | ||
| 49 | | [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | | 49 | | [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | |
| 50 | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | | 50 | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | |
| 51 | | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | | 51 | | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | |
| 52 | -| [38-auth-multi-tenant-isolation.md](./todo/system-design/38-auth-multi-tenant-isolation.md) | 📝 **Phase 0/1 已完成(2026-08-25),Phase 2-3 未开工**。Phase 1 给 9 个此前零防护的路由文件挂 `requireAuth`/`requireAdmin`;`agent-chat.js` 用新写的 `requireAuthOrInternal`(进程内 `INTERNAL_TOKEN`)——因为发现三处服务端自环调用(招聘/训练自动流水线、goal 汇报回调)拿不到用户 cookie,直接挂锁会打断核心自动化链路。17 个既有测试文件的 67+ 处用例从没设过登录态,改为测试环境默认放行 + 新增 `route-auth-gate.test.js` 结构化守卫测试专盯"锁真的挂上了没有"。远程部署前置需求:登录鉴权 + 公司级多租户隔离。现状 `auth.js`/`jwt-auth.js`(注册/登录/JWT-cookie/OAuth)+ WebSocket 鉴权都已可用,真正缺的是①把已有的锁挂到目前零防护的 `company.js`/`agent-chat.js`/`okr.js`/`tasks.js`/`devloop.js`/`graph-run.js`(含双挂载点)等路由上、②`company.json` 从 0 新增 `ownerId` 归属概念(无 SQL 表,纯文件)。已拍板:存量公司迁移统一划给种子账号 `admin`/`hex1234`(不强制改密);OKR 跟随 company 隔离(含独立的遗留 `/api/okr` 挂载点);`arena.js`/`evolution.js` 核实为疑似废弃代码(前端零引用、5 个月未改动),只挂登录不投入隔离;`dream.js` 系统级,挂 `requireAdmin`。**Phase 0**:种子脚本 + 登录页 + 路由守卫 + 401 拦截器 + 退出登录,真实浏览器走通(含顺手修复的 `MasterChatDrawer` 登录页泄漏)。Phase 1(全站要求登录)→ Phase 2(`router.param('companyId')` 归属校验,最大工作量)→ Phase 3(聊天隔离验证+前端收尾)待开工 | | 52 | +| [38-auth-multi-tenant-isolation.md](./todo/system-design/38-auth-multi-tenant-isolation.md) | 📝 **Phase 0/1/2 已完成(2026-08-25),Phase 3 未开工**。远程部署前置需求:登录鉴权 + 公司级多租户隔离。**Phase 0**:种子账号 `admin`/`hex1234` + 登录页 + 路由守卫 + 401 拦截器,真实浏览器走通。**Phase 1**:给 9 个此前零防护的路由文件挂 `requireAuth`/`requireAdmin`;`agent-chat.js` 用新写的 `requireAuthOrInternal`(进程内 `INTERNAL_TOKEN`)——三处服务端自环调用(招聘/训练自动流水线、goal 汇报回调)拿不到用户 cookie,直接挂锁会打断核心自动化链路;17 个既有测试文件的 67+ 处用例改为测试环境默认放行 + 新增 `route-auth-gate.test.js` 结构化守卫测试。**Phase 2**:`company.json` 加 `ownerId`(无 SQL 表),`canAccessCompany` 作为全仓库唯一归属判据;`company.js`/`graph-run.js`(两个挂载点参数形状不同,`companyRouter` 按 companyId 直查、主 router 按 `:id`=run 反查 company_id,设计阶段的描述不准确,实现时才发现)/`devloop.js`(`:pid` 反查 company_id)分别接入;存量公司迁移脚本(`npm run backfill:company-owner`)已跑;`okr.js` 最终没有按原计划接入 companyId(发现是全站零导航入口的孤儿页面,改挂 `requireAdmin` 而非投入前端"选公司"工作量,与设计阶段方案的偏离已如实记录);8 个既有测试文件因为用纯字符串公司 id、从未真正建过公司,新增共享 helper `ensure-test-company.js` 修复。全量 `vitest` 1427 passed(3 个无关既有失败不受影响)。Phase 3(聊天隔离验证 + 前端收尾)待开工 | |
| 53 | | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | | 53 | | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | |
| 54 | | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | | 54 | | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | |
| 55 | | [35-validation-rigor-and-body-readiness-proposal.md](./todo/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 | | 55 | | [35-validation-rigor-and-body-readiness-proposal.md](./todo/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 | |
docs/todo/system-design/38-auth-multi-tenant-isolation.md
| @@ -103,13 +103,15 @@ router.param('companyId', (req, res, next, companyId) => { | @@ -103,13 +103,15 @@ router.param('companyId', (req, res, next, companyId) => { | ||
| 103 | - **测试套件的连带问题**:仓库里已有 17 个测试文件、67+ 处用例直接拿 supertest 打这几个路由,从没设过登录 cookie(测的是业务逻辑,不是鉴权)——挂完锁后这批测试集体从 200/201 变 401。没有逐个文件补登录态(会引入 bcrypt 开销拖慢整个套件,且这些用例本来就不该关心鉴权),改为:`jwt-auth.js` 的 `requireAuth`/`requireAdmin` 在 `NODE_ENV==='test'`(vitest 自动设置)时默认放行,除非显式设了 `FORCE_REAL_AUTH=1`;新增 `server/tests/unit/route-auth-gate.test.js` 专门在 `FORCE_REAL_AUTH=1` 下做结构检查(`router.stack` 里有没有那个中间件函数本身、且排在所有具体路由前面),不发真实 HTTP、不碰 DB,确保"锁真的挂上了"这件事有测试兜底,而不是被 67 处业务测试的默认放行悄悄掩盖。 | 103 | - **测试套件的连带问题**:仓库里已有 17 个测试文件、67+ 处用例直接拿 supertest 打这几个路由,从没设过登录 cookie(测的是业务逻辑,不是鉴权)——挂完锁后这批测试集体从 200/201 变 401。没有逐个文件补登录态(会引入 bcrypt 开销拖慢整个套件,且这些用例本来就不该关心鉴权),改为:`jwt-auth.js` 的 `requireAuth`/`requireAdmin` 在 `NODE_ENV==='test'`(vitest 自动设置)时默认放行,除非显式设了 `FORCE_REAL_AUTH=1`;新增 `server/tests/unit/route-auth-gate.test.js` 专门在 `FORCE_REAL_AUTH=1` 下做结构检查(`router.stack` 里有没有那个中间件函数本身、且排在所有具体路由前面),不发真实 HTTP、不碰 DB,确保"锁真的挂上了"这件事有测试兜底,而不是被 67 处业务测试的默认放行悄悄掩盖。 |
| 104 | - **验收**:真实 `curl` 走过一遍——无 cookie 访问 `company.js`/`tasks.js`/`devloop.js`/`okr.js`/`graph-run.js`/`dream.js` 全部 401;`admin`/`hex1234` 登录后全部可访问;新注册的非 admin 用户能访问 `company.js` 等(Phase 1 还没做归属过滤,符合预期),但访问 `dream.js` 得到 403;`open-api.js`/`auth.js` 未受影响(前者仍是自己的 API Key 报错文案,后者登录本身不能被锁死)。手工临时删掉 `tasks.js` 的 `router.use(requireAuth)` 验证过守卫测试真的会红。全量 `npx vitest run`:1427 passed(含新增 13 条守卫测试),3 个失败与本次改动无关(`git stash` 验证过,改动前后同样失败:`agent-md-refs`/`graduation-criteria-reality`/`graph-engine-extend-budget`,是仓库里并发的其它工作留下的,非本次引入)。 | 104 | - **验收**:真实 `curl` 走过一遍——无 cookie 访问 `company.js`/`tasks.js`/`devloop.js`/`okr.js`/`graph-run.js`/`dream.js` 全部 401;`admin`/`hex1234` 登录后全部可访问;新注册的非 admin 用户能访问 `company.js` 等(Phase 1 还没做归属过滤,符合预期),但访问 `dream.js` 得到 403;`open-api.js`/`auth.js` 未受影响(前者仍是自己的 API Key 报错文案,后者登录本身不能被锁死)。手工临时删掉 `tasks.js` 的 `router.use(requireAuth)` 验证过守卫测试真的会红。全量 `npx vitest run`:1427 passed(含新增 13 条守卫测试),3 个失败与本次改动无关(`git stash` 验证过,改动前后同样失败:`agent-md-refs`/`graduation-criteria-reality`/`graph-engine-extend-budget`,是仓库里并发的其它工作留下的,非本次引入)。 |
| 105 | 105 | ||
| 106 | -### Phase 2 — 公司归属隔离(这次真正的硬骨头) | ||
| 107 | -- `company.json` schema 加 `ownerId`;`create()` 接收 owner 参数(从 `req.user.id` 传入);`list()` 按 `req.user` 过滤(admin 角色不过滤)。 | ||
| 108 | -- 存量数据迁移脚本跑一遍(§3.5),全部划给种子账号。 | ||
| 109 | -- `company.js`/`graph-run.js` 两个挂载点接入 3.1 的 `router.param('companyId', ...)` 统一校验。 | ||
| 110 | -- `devloop.js` 接入 3.2 的间接校验。 | ||
| 111 | -- **`okr.js`(`/api/okr`,独立于 `company.js` 的那套遗留全局 OKR)纳入这次范围**(已决策,见 §五):改成接收 companyId(路由参数或 query),`OkrService` 按 companyId 实例化(`okr-service.js` 本来就支持,只是没接),同样过 3.1 的归属校验;`OkrDashboard.vue` 前端跟着改成需要先选公司。注意 `company.js` 自己的 `okrPath()` 系列路由(`/api/company/:id/okr`,`CompanyDashboard.vue` 团队页 OKR tab 用的是这一套)**已经**在这一步随 `company.js` 一起被 `router.param('companyId')` 收口,不需要额外工作——真正缺的只是这个独立的遗留 `/api/okr` 挂载点。 | ||
| 112 | -- **验收**:种子账号登录看到全部存量公司(无缝衔接);新注册账号登录看到空列表;新账号尝试直接拼 URL 访问种子账号的 company id → 404;新建的 company 只有创建者自己看得到;`/okr` 页面按公司切换后数据不串。这一步要重点测"漏了一条路由"这类问题——建议写一个守卫测试,遍历 `company.js`/`graph-run.js`/`okr.js` 里的路由定义,断言每一条都经过了 `router.param('companyId')` 这个收口点(而不是逐条手工检查,人工抽查容易漏)。 | 106 | +### Phase 2 — 公司归属隔离(这次真正的硬骨头)✅ 已完成(2026-08-25) |
| 107 | +- `company-service.js`:`company.json` 加 `ownerId`(无 SQL 表,纯 JSON 字段);`create()` 接收 `ownerId`;`list({ user })` 按传入的 `user` 过滤,**不传就不过滤**——`company-heartbeat-service.js` 这类系统内部巡检要看全部公司,不代表任何用户;新增 `canAccessCompany(company, user)` 作为全仓库唯一一份归属判据(admin 不过滤,404 不是 403)。 | ||
| 108 | +- 存量数据迁移脚本 `scripts/backfill-company-owner.js`(`npm run backfill:company-owner`)跑过,`e2e-demo`/`ovf-co` 两家存量公司已划给种子账号,幂等验证过。 | ||
| 109 | +- `company.js` 用 `router.param('companyId', ...)` 统一收口(约 50 条路由,含 `okrPath()` 系列动态路由,全部核实过参数名一致)。 | ||
| 110 | +- **`graph-run.js` 的两个挂载点参数形状不一样,不能照抄同一份逻辑**——设计阶段的描述不准确,实现时才发现:`companyRouter` 唯一路由是 `/:companyId/graph-runs`,直接查;但主 `router` 全部路由是 `/:id/...`,`:id` 指的是 **run**,不是公司(`GET /:id` 吐的是 run 实例)。改成 `router.param('id', ...)`:先 `getRun(runId)` 拿到 `run.company_id`,再查这家公司归不归当前用户,run 不存在或公司不是你的都统一回"run 不存在"(不暴露哪种情况)。 | ||
| 111 | +- `devloop.js` 用 `router.param('pid', ...)` 反查 `pathResolver.resolveProjectMeta(pid).company_id` 再校验;`POST /projects` 单独处理(建的是全新项目,还没有 `:pid` 可拦,`company_id` 从 body 里点名,校验顺序放在基本字段校验之后——避免"归属检查抢在输入校验前面"打乱错误信息的优先级);`GET /projects` 按 `company_id` 过滤列表。 | ||
| 112 | +- **`okr.js` 的处理跟设计阶段的方案不一样,是权衡后的偏离,如实记录**:原计划是给这个独立挂载点接上 companyId 归属校验。实现前查证:`OkrDashboard.vue`(唯一消费方)挂载在路由 `/okr`,**全站没有任何导航入口指向它**(`AppNav.vue`/其它页面零引用),是个孤儿页面。真正在用的公司级 OKR 是 `company.js` 自己的 `/api/company/:id/okr`(`CompanyDashboard.vue` 团队页 OKR tab 在用),这条已经随 `company.js` 一起被收口,不需要额外工作。没有为一个访问不到的页面新建"选公司"前端交互,改成 `requireAdmin`(跟 `dream.js` 同款处理)——堵住"能拼 URL 直接看到跨公司聚合数据"的口子,比投入前端工作量收益高。这个页面以后要正式启用,再回来做真正的 companyId 归属校验。 | ||
| 113 | +- **测试套件的连带修复,比预想的更细碎**:8 个既有测试文件(`agent-transcript`/`graph-run-route-io`/`graph-run-route-slot-busy`/`p3c-recovery-http`/`task-slots-parallel`/`company-files-path-guard`/`heartbeat-company-scoped`/`devloop` 下 3 个文件)用的公司 id 都是纯字符串标签,从没通过 `companyService.create()` 建过真公司——新增共享测试 helper `server/tests/helpers/ensure-test-company.js`(`ensureTestCompany`/`removeTestCompany`),在每个文件生成测试公司 id 的唯一入口处补一次 `company.json`;两个文件里原有的"跳过清理已存在 `company.json` 的目录(那是真公司)"防御逻辑,判据从"有没有 `company.json`"改成"`ownerId` 是不是 `-1` 这个测试专用标记",否则会把这批新出现的测试夹具误判成真公司、永远不清理。 | ||
| 114 | +- **验收**:全部真实 `curl` 走过(种子账号登录看到全部存量公司;新注册账号登录看到空列表;新账号直接拼 URL 访问种子账号的 company id → 404;新建 company 后只有创建者自己的 `list` 能看到、admin 两个都看到、`ownerId` 落的确实是创建者的 `users.id`)。全量 `npx vitest run`:1427 passed,同样 3 个无关的既有失败(未受影响)。 | ||
| 113 | 115 | ||
| 114 | ### Phase 3 — 聊天记录隔离验证 + 前端收尾体验 | 116 | ### Phase 3 — 聊天记录隔离验证 + 前端收尾体验 |
| 115 | - LemonBoss 侧会话(`data/companies/{id}/agents/{agentId}/{convId}`)理论上 Phase 2 做完就自动隔离了(会话挂在 company 目录下),这一步只是**验证**,不是新写代码:确认没有遗漏的、绕过 company 归属校验就能读会话的接口(比如某个直接按 `convId` 查、不经过 `companyId` 校验的路由)。 | 117 | - LemonBoss 侧会话(`data/companies/{id}/agents/{agentId}/{convId}`)理论上 Phase 2 做完就自动隔离了(会话挂在 company 目录下),这一步只是**验证**,不是新写代码:确认没有遗漏的、绕过 company 归属校验就能读会话的接口(比如某个直接按 `convId` 查、不经过 `companyId` 校验的路由)。 |
| @@ -120,7 +122,7 @@ router.param('companyId', (req, res, next, companyId) => { | @@ -120,7 +122,7 @@ router.param('companyId', (req, res, next, companyId) => { | ||
| 120 | 122 | ||
| 121 | ## 五、开放问题(已拍板) | 123 | ## 五、开放问题(已拍板) |
| 122 | 124 | ||
| 123 | -1. **`okr.js` 的全局 `OkrService` 要不要并入这次范围?→ 并入。** 用户明确要求"OKR 跟着 company 走,需要隔离"。已更新 §四 Phase 2:`okr.js` 改成接收 companyId 并按其实例化 `OkrService`(本来就支持,只是没接),过同一套归属校验;`OkrDashboard.vue` 前端跟着改成需要先选公司。`company.js` 自己的 `/api/company/:id/okr`(`CompanyDashboard.vue` 团队页 OKR tab 在用)本来就在 `company.js` 里,Phase 2 对 `company.js` 做 `router.param('companyId')` 收口时**顺带**就隔离了,不算独立工作量。 | 125 | +1. **`okr.js` 的全局 `OkrService` 要不要并入这次范围?→ 并入的初衷成立,最终落地方式在实现阶段调整了,如实记录。** 用户明确要求"OKR 跟着 company 走,需要隔离"——这个诉求在真正在用的 OKR 功能上已经满足:`company.js` 自己的 `/api/company/:id/okr`(`CompanyDashboard.vue` 团队页 OKR tab 在用)随 `company.js` 的 `router.param('companyId')` 收口一起隔离了,不算独立工作量。但独立的遗留挂载点 `/api/okr`(`okr.js` + 未接 companyId 的 `OkrService`)在实现阶段查证后发现是个**孤儿页面**——`OkrDashboard.vue` 挂载在路由 `/okr`,全站没有任何导航入口指向它。给一个访问不到的页面新建"选公司"前端交互投入产出比不划算,改成 `requireAdmin`(详见 §四 Phase 2),先堵住直接拼 URL 能看到跨公司数据的口子。这个页面以后要正式启用,再回来做真正的 companyId 归属校验。 |
| 124 | 2. **首次登录是否强制改密 → 不需要。** 种子密码 `hex1234` 保持明文可用,不加强制改密流程。 | 126 | 2. **首次登录是否强制改密 → 不需要。** 种子密码 `hex1234` 保持明文可用,不加强制改密流程。 |
| 125 | 3. **`arena.js`/`evolution.js` → 确认为疑似废弃代码,不投入隔离工作量。** 核实结果:`web/src/` 全局搜索零处引用 `/api/arena`(注意与 `open-api.js` 下仍在用的 `/api/open/arena/leaderboard` 是两个不同文件,后者继续保留)、零处引用 `/api/evolution`;两个文件最后一次改动都停在 `2026-03-23`(比仓库其余部分老得多)。Phase 1 按最省事的方式处理:一律挂 `requireAuth`(登录即可访问,不单独判断是否该删),不额外调查、不纳入这次范围——真要确认能不能删是另一件事,不属于这次鉴权改造。 | 127 | 3. **`arena.js`/`evolution.js` → 确认为疑似废弃代码,不投入隔离工作量。** 核实结果:`web/src/` 全局搜索零处引用 `/api/arena`(注意与 `open-api.js` 下仍在用的 `/api/open/arena/leaderboard` 是两个不同文件,后者继续保留)、零处引用 `/api/evolution`;两个文件最后一次改动都停在 `2026-03-23`(比仓库其余部分老得多)。Phase 1 按最省事的方式处理:一律挂 `requireAuth`(登录即可访问,不单独判断是否该删),不额外调查、不纳入这次范围——真要确认能不能删是另一件事,不属于这次鉴权改造。 |
| 126 | 4. **`dream.js` → 系统级,只有 admin 能看。** 已更新 §四 Phase 1:挂 `requireAdmin` 而不是 `requireAuth`。 | 128 | 4. **`dream.js` → 系统级,只有 admin 能看。** 已更新 §四 Phase 1:挂 `requireAdmin` 而不是 `requireAuth`。 |
package.json
| @@ -24,7 +24,8 @@ | @@ -24,7 +24,8 @@ | ||
| 24 | "spec:check": "node scripts/extract-spec.js --check", | 24 | "spec:check": "node scripts/extract-spec.js --check", |
| 25 | "prepare": "bash scripts/install-hooks.sh 2>/dev/null || true", | 25 | "prepare": "bash scripts/install-hooks.sh 2>/dev/null || true", |
| 26 | "okr:migrate": "node scripts/migrate-goals-to-okr.js", | 26 | "okr:migrate": "node scripts/migrate-goals-to-okr.js", |
| 27 | - "seed:admin": "node scripts/seed-admin-user.js" | 27 | + "seed:admin": "node scripts/seed-admin-user.js", |
| 28 | + "backfill:company-owner": "node scripts/backfill-company-owner.js" | ||
| 28 | }, | 29 | }, |
| 29 | "keywords": [ | 30 | "keywords": [ |
| 30 | "agent", | 31 | "agent", |
scripts/backfill-company-owner.js
0 → 100644
| 1 | +#!/usr/bin/env node | ||
| 2 | +/** | ||
| 3 | + * backfill-company-owner.js —— 存量公司补 ownerId | ||
| 4 | + * | ||
| 5 | + * 依据: docs/todo/system-design/38-auth-multi-tenant-isolation.md §3.5 / Phase 2 | ||
| 6 | + * | ||
| 7 | + * `data/companies/{id}/company.json` 在加公司归属隔离之前建的公司都没有 ownerId 字段—— | ||
| 8 | + * 统一划给种子账号 admin(用户已明确拍板,见文档 §一)。 | ||
| 9 | + * | ||
| 10 | + * 幂等: 已经有 ownerId 的行跳过,可重复执行。 | ||
| 11 | + * | ||
| 12 | + * 用法: | ||
| 13 | + * node scripts/backfill-company-owner.js | ||
| 14 | + */ | ||
| 15 | + | ||
| 16 | +const fs = require('fs'); | ||
| 17 | +const path = require('path'); | ||
| 18 | + | ||
| 19 | +const DATA_DIR = path.resolve(__dirname, '../data/companies'); | ||
| 20 | +const SEED_EMAIL = 'admin'; | ||
| 21 | + | ||
| 22 | +function main() { | ||
| 23 | + const userDb = require('../server/services/user-db'); | ||
| 24 | + const admin = userDb.findByEmail(SEED_EMAIL); | ||
| 25 | + if (!admin) { | ||
| 26 | + console.error(`[backfill-company-owner] 找不到种子账号 "${SEED_EMAIL}",先跑 npm run seed:admin`); | ||
| 27 | + process.exit(1); | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + if (!fs.existsSync(DATA_DIR)) { | ||
| 31 | + console.log('[backfill-company-owner] data/companies/ 不存在,无需处理'); | ||
| 32 | + return; | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + const dirs = fs.readdirSync(DATA_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()); | ||
| 36 | + let migrated = 0; | ||
| 37 | + let skipped = 0; | ||
| 38 | + | ||
| 39 | + for (const d of dirs) { | ||
| 40 | + const fp = path.join(DATA_DIR, d.name, 'company.json'); | ||
| 41 | + if (!fs.existsSync(fp)) continue; | ||
| 42 | + | ||
| 43 | + const company = JSON.parse(fs.readFileSync(fp, 'utf-8')); | ||
| 44 | + if (company.ownerId != null) { | ||
| 45 | + skipped += 1; | ||
| 46 | + continue; | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + company.ownerId = admin.id; | ||
| 50 | + company.updatedAt = new Date().toISOString(); | ||
| 51 | + // 跟 company-service.js 的 _saveCompany 保持同一种写法(无结尾换行),别引入格式差异。 | ||
| 52 | + fs.writeFileSync(fp, JSON.stringify(company, null, 2), 'utf-8'); | ||
| 53 | + console.log(`[backfill-company-owner] ${d.name} → ownerId=${admin.id}`); | ||
| 54 | + migrated += 1; | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + console.log(`[backfill-company-owner] 完成:迁移 ${migrated} 家,跳过(已有 ownerId)${skipped} 家`); | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +main(); |
server/routes/company.js
| @@ -10,11 +10,10 @@ const fs = require('fs'); | @@ -10,11 +10,10 @@ const fs = require('fs'); | ||
| 10 | const router = express.Router(); | 10 | const router = express.Router(); |
| 11 | const { requireAuth, INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth'); | 11 | const { requireAuth, INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth'); |
| 12 | 12 | ||
| 13 | -// 38 号文档 Phase 1:全站要求登录。公司归属过滤(谁能看到哪家公司)留到 Phase 2 的 | ||
| 14 | -// router.param('companyId', ...) 统一收口,这一步只挡"没登录"。 | 13 | +// 38 号文档 Phase 1:全站要求登录。 |
| 15 | router.use(requireAuth); | 14 | router.use(requireAuth); |
| 16 | 15 | ||
| 17 | -const { CompanyService, listCompanyAgents, buildAgentDefinitionBrief } = require('../services/company-service'); | 16 | +const { CompanyService, listCompanyAgents, buildAgentDefinitionBrief, canAccessCompany } = require('../services/company-service'); |
| 18 | const { ScheduleService } = require('../services/schedule-service'); | 17 | const { ScheduleService } = require('../services/schedule-service'); |
| 19 | const orgGraphService = require('../services/org-graph-service'); | 18 | const orgGraphService = require('../services/org-graph-service'); |
| 20 | // 28 批次 4:公司不再持有 agent 实体 —— 名册查 company_agents,目录一律经 agent-locator 推导 | 19 | // 28 批次 4:公司不再持有 agent 实体 —— 名册查 company_agents,目录一律经 agent-locator 推导 |
| @@ -27,6 +26,19 @@ const { fillAgentBodyReliably } = require('../services/agent-body-filler'); | @@ -27,6 +26,19 @@ const { fillAgentBodyReliably } = require('../services/agent-body-filler'); | ||
| 27 | const companyService = new CompanyService(); | 26 | const companyService = new CompanyService(); |
| 28 | const scheduleService = new ScheduleService(); | 27 | const scheduleService = new ScheduleService(); |
| 29 | 28 | ||
| 29 | +// 38 号文档 Phase 2:公司归属统一收口——任何路由只要路径里带 :companyId,都会先过 | ||
| 30 | +// 这一道,不用每条路由自己写一遍判断(判据在 company-service.js 的 canAccessCompany, | ||
| 31 | +// 全仓库唯一一份,避免 §六风险 1 那种"判据分叉")。 | ||
| 32 | +// 404 而不是 403:不告诉调用方"这个 id 存在、只是不是你的",直接当不存在处理。 | ||
| 33 | +router.param('companyId', (req, res, next, companyId) => { | ||
| 34 | + const company = companyService.get(companyId); | ||
| 35 | + if (!canAccessCompany(company, req.user)) { | ||
| 36 | + return res.status(404).json({ error: `公司 ${companyId} 不存在` }); | ||
| 37 | + } | ||
| 38 | + req.company = company; | ||
| 39 | + next(); | ||
| 40 | +}); | ||
| 41 | + | ||
| 30 | /** | 42 | /** |
| 31 | * 数据根 / 公司根 —— 本文件里这两个路径此前在 9 个 handler 里各拼一份 | 43 | * 数据根 / 公司根 —— 本文件里这两个路径此前在 9 个 handler 里各拼一份 |
| 32 | * (6 份 `../../data/companies` + 3 份同义异名的 `../../data`),全是模块内的复制品。 | 44 | * (6 份 `../../data/companies` + 3 份同义异名的 `../../data`),全是模块内的复制品。 |
| @@ -116,7 +128,7 @@ router.post('/create', async (req, res) => { | @@ -116,7 +128,7 @@ router.post('/create', async (req, res) => { | ||
| 116 | return res.status(400).json({ error: 'id 格式无效 (只允许小写字母、数字、-)' }); | 128 | return res.status(400).json({ error: 'id 格式无效 (只允许小写字母、数字、-)' }); |
| 117 | } | 129 | } |
| 118 | 130 | ||
| 119 | - const company = companyService.create({ id, name, goal, description }); | 131 | + const company = companyService.create({ id, name, goal, description, ownerId: req.user.id }); |
| 120 | 132 | ||
| 121 | // ── P2 T6:自动招聘 orggraph-planning 并异步触发组织结构规划 ── | 133 | // ── P2 T6:自动招聘 orggraph-planning 并异步触发组织结构规划 ── |
| 122 | // 降级约定(27-P2 §4.4):这一步任何失败都不能影响公司创建本身, | 134 | // 降级约定(27-P2 §4.4):这一步任何失败都不能影响公司创建本身, |
| @@ -140,9 +152,9 @@ router.post('/create', async (req, res) => { | @@ -140,9 +152,9 @@ router.post('/create', async (req, res) => { | ||
| 140 | } | 152 | } |
| 141 | }); | 153 | }); |
| 142 | 154 | ||
| 143 | -// GET /api/company/list | 155 | +// GET /api/company/list —— 只列 req.user 自己的公司(admin 不过滤) |
| 144 | router.get('/list', (req, res) => { | 156 | router.get('/list', (req, res) => { |
| 145 | - res.json(companyService.list()); | 157 | + res.json(companyService.list({ user: req.user })); |
| 146 | }); | 158 | }); |
| 147 | 159 | ||
| 148 | // GET /api/company/templates | 160 | // GET /api/company/templates |
server/routes/devloop.js
| @@ -30,6 +30,29 @@ const fs = require('fs'); | @@ -30,6 +30,29 @@ const fs = require('fs'); | ||
| 30 | const os = require('os'); | 30 | const os = require('os'); |
| 31 | const path = require('path'); | 31 | const path = require('path'); |
| 32 | const multer = require('multer'); | 32 | const multer = require('multer'); |
| 33 | +const { CompanyService, canAccessCompany } = require('../services/company-service'); | ||
| 34 | + | ||
| 35 | +const companyService = new CompanyService(); | ||
| 36 | + | ||
| 37 | +// 38 号文档 Phase 2:devloop 的路由不长 :companyId,长的是 :pid(项目 id)——公司归属藏在 | ||
| 38 | +// pathResolver 缓存里(resolveProjectMeta(pid).company_id),要反查一跳,不能照抄 | ||
| 39 | +// company.js 那种直接的 router.param('companyId', ...)。查不到项目本身就是 404 | ||
| 40 | +// (resolveProjectMeta 对未知 pid 抛错,这里接住转成 404);查得到但公司不是你的, | ||
| 41 | +// 同样回 404,不暴露"这个项目存在,只是不是你的"。 | ||
| 42 | +router.param('pid', (req, res, next, pid) => { | ||
| 43 | + let meta; | ||
| 44 | + try { | ||
| 45 | + meta = pathResolver.resolveProjectMeta(pid); | ||
| 46 | + } catch { | ||
| 47 | + return res.status(404).json({ error: `项目 ${pid} 不存在` }); | ||
| 48 | + } | ||
| 49 | + const company = companyService.get(meta.company_id); | ||
| 50 | + if (!canAccessCompany(company, req.user)) { | ||
| 51 | + return res.status(404).json({ error: `项目 ${pid} 不存在` }); | ||
| 52 | + } | ||
| 53 | + req.devloopProjectMeta = meta; | ||
| 54 | + next(); | ||
| 55 | +}); | ||
| 33 | 56 | ||
| 34 | // 单次 intake 上传:内存解析后我们自己写盘到项目目录。10MB / 文件,10 文件上限。 | 57 | // 单次 intake 上传:内存解析后我们自己写盘到项目目录。10MB / 文件,10 文件上限。 |
| 35 | const intakeUpload = multer({ | 58 | const intakeUpload = multer({ |
| @@ -60,6 +83,13 @@ router.post('/projects', h(async (req, res) => { | @@ -60,6 +83,13 @@ router.post('/projects', h(async (req, res) => { | ||
| 60 | if (mode && !['sandbox', 'linked-new', 'linked-existing'].includes(mode)) { | 83 | if (mode && !['sandbox', 'linked-new', 'linked-existing'].includes(mode)) { |
| 61 | return res.status(400).json({ error: `invalid mode: ${mode} (must be sandbox | linked-new | linked-existing)` }); | 84 | return res.status(400).json({ error: `invalid mode: ${mode} (must be sandbox | linked-new | linked-existing)` }); |
| 62 | } | 85 | } |
| 86 | + // 这条路由建的是全新项目,还没有 :pid 可供 router.param 拦截——company_id 是从 | ||
| 87 | + // body 里点名的,不校验的话任何登录用户都能把 devloop 项目挂到别人的公司名下。 | ||
| 88 | + // 放在基本输入校验之后:先把纯输入形状的问题过一遍(便宜、不碰 fs), | ||
| 89 | + // 再做需要读盘的归属校验。 | ||
| 90 | + if (!canAccessCompany(companyService.get(company_id), req.user)) { | ||
| 91 | + return res.status(404).json({ error: `公司 ${company_id} 不存在` }); | ||
| 92 | + } | ||
| 63 | 93 | ||
| 64 | // 1. 创建项目 (sandbox 默认 / linked-new 用 target_path) | 94 | // 1. 创建项目 (sandbox 默认 / linked-new 用 target_path) |
| 65 | let proj; | 95 | let proj; |
| @@ -110,10 +140,11 @@ router.post('/projects', h(async (req, res) => { | @@ -110,10 +140,11 @@ router.post('/projects', h(async (req, res) => { | ||
| 110 | }); | 140 | }); |
| 111 | })); | 141 | })); |
| 112 | 142 | ||
| 113 | -// GET /api/devloop/projects?include_archived=true | 143 | +// GET /api/devloop/projects?include_archived=true —— 只列 req.user 自己公司名下的项目 |
| 114 | router.get('/projects', h(async (req, res) => { | 144 | router.get('/projects', h(async (req, res) => { |
| 115 | const includeArchived = String(req.query.include_archived || '') === 'true'; | 145 | const includeArchived = String(req.query.include_archived || '') === 'true'; |
| 116 | - const projects = pathResolver.listProjects({ includeArchived }); | 146 | + const projects = pathResolver.listProjects({ includeArchived }) |
| 147 | + .filter((p) => canAccessCompany(companyService.get(p.company_id), req.user)); | ||
| 117 | res.json({ projects }); | 148 | res.json({ projects }); |
| 118 | })); | 149 | })); |
| 119 | 150 |
server/routes/graph-run.js
| @@ -42,15 +42,43 @@ const { | @@ -42,15 +42,43 @@ const { | ||
| 42 | } = require('../services/work-graph-db'); | 42 | } = require('../services/work-graph-db'); |
| 43 | const { companyRootDir } = require('../services/company-paths'); | 43 | const { companyRootDir } = require('../services/company-paths'); |
| 44 | const { requireAuth } = require('../middleware/jwt-auth'); | 44 | const { requireAuth } = require('../middleware/jwt-auth'); |
| 45 | +const { CompanyService, canAccessCompany } = require('../services/company-service'); | ||
| 45 | 46 | ||
| 46 | const router = express.Router(); | 47 | const router = express.Router(); |
| 47 | const companyRouter = express.Router(); | 48 | const companyRouter = express.Router(); |
| 49 | +const companyService = new CompanyService(); | ||
| 48 | 50 | ||
| 49 | // 38 号文档 Phase 1:全站要求登录(两个挂载点都要挂,§六风险 2 —— 这个文件以两种方式 | 51 | // 38 号文档 Phase 1:全站要求登录(两个挂载点都要挂,§六风险 2 —— 这个文件以两种方式 |
| 50 | -// 挂载到 index.js,改的时候容易漏一个)。公司归属过滤留到 Phase 2。 | 52 | +// 挂载到 index.js,改的时候容易漏一个)。 |
| 51 | router.use(requireAuth); | 53 | router.use(requireAuth); |
| 52 | companyRouter.use(requireAuth); | 54 | companyRouter.use(requireAuth); |
| 53 | 55 | ||
| 56 | +// 38 号文档 Phase 2:公司归属校验。两个挂载点的参数形状不一样,不能抄同一份 companyId 收口: | ||
| 57 | +// - companyRouter 的唯一路由是 /:companyId/graph-runs,直接按 companyId 查。 | ||
| 58 | +// - 主 router 全部路由都是 /:id/...,:id 指的是 run 而不是公司(GET /:id 吐的是 run 实例, | ||
| 59 | +// 见文件头注释)——要先查出这条 run 属于哪家公司,再判断当前用户碰不碰得到。 | ||
| 60 | +// 两处判据都调 company-service.js 的 canAccessCompany,不各写一份。 | ||
| 61 | +companyRouter.param('companyId', (req, res, next, companyId) => { | ||
| 62 | + const company = companyService.get(companyId); | ||
| 63 | + if (!canAccessCompany(company, req.user)) { | ||
| 64 | + return res.status(404).json({ error: `公司 ${companyId} 不存在` }); | ||
| 65 | + } | ||
| 66 | + req.company = company; | ||
| 67 | + next(); | ||
| 68 | +}); | ||
| 69 | + | ||
| 70 | +router.param('id', (req, res, next, runId) => { | ||
| 71 | + const run = getRun(runId); | ||
| 72 | + if (!run) return res.status(404).json({ error: `run ${runId} 不存在` }); | ||
| 73 | + const company = companyService.get(run.company_id); | ||
| 74 | + if (!canAccessCompany(company, req.user)) { | ||
| 75 | + // run 存在但公司不是你的:仍然回 run 不存在,不暴露"有这条 run,只是不是你的"。 | ||
| 76 | + return res.status(404).json({ error: `run ${runId} 不存在` }); | ||
| 77 | + } | ||
| 78 | + req.run = run; | ||
| 79 | + next(); | ||
| 80 | +}); | ||
| 81 | + | ||
| 54 | // ──────────────────────────────────────────────────────────────── | 82 | // ──────────────────────────────────────────────────────────────── |
| 55 | // 小工具 | 83 | // 小工具 |
| 56 | // ──────────────────────────────────────────────────────────────── | 84 | // ──────────────────────────────────────────────────────────────── |
server/routes/okr.js
| @@ -16,15 +16,21 @@ | @@ -16,15 +16,21 @@ | ||
| 16 | */ | 16 | */ |
| 17 | 17 | ||
| 18 | const { Router } = require('express'); | 18 | const { Router } = require('express'); |
| 19 | -const { requireAuth } = require('../middleware/jwt-auth'); | 19 | +const { requireAdmin } = require('../middleware/jwt-auth'); |
| 20 | const { OkrService } = require('../services/okr-service'); | 20 | const { OkrService } = require('../services/okr-service'); |
| 21 | 21 | ||
| 22 | const router = Router(); | 22 | const router = Router(); |
| 23 | const okr = new OkrService(); | 23 | const okr = new OkrService(); |
| 24 | 24 | ||
| 25 | -// 38 号文档 Phase 1:全站要求登录。这是独立于 company.js 的遗留全局 OKR 挂载点 | ||
| 26 | -// (目前无公司维度),公司归属改造留到 Phase 2(§四 Phase 2 / §五.1)。 | ||
| 27 | -router.use(requireAuth); | 25 | +// 38 号文档 Phase 2:这是独立于 company.js 的遗留全局 OKR 挂载点(`OkrService` 不带 |
| 26 | +// companyId 实例化,落的是 data/okr/,不属于任何公司)。真正在用的公司级 OKR 是 | ||
| 27 | +// company.js 的 /:companyId/okr(挂company.js's router.param('companyId')收口了)—— | ||
| 28 | +// 这个独立页面(web/src/views/OkrDashboard.vue,挂载在 /okr)在前端路由/导航栏里 | ||
| 29 | +// 找不到任何入口指向它,是个孤儿页面。没有为一个访问不到的页面新建"选公司"的前端交互, | ||
| 30 | +// 权衡后按 dream.js 同款处理:不做归属过滤(它本来就没有公司维度可过滤), | ||
| 31 | +// 收紧到只有 admin 能看,堵住"能拼 URL 直接看到跨公司聚合数据"这个口子。 | ||
| 32 | +// 如果这个页面以后要正式启用,再回来做真正的公司选择 + companyId 归属校验。 | ||
| 33 | +router.use(requireAdmin); | ||
| 28 | 34 | ||
| 29 | // ── 列出所有目标 ── | 35 | // ── 列出所有目标 ── |
| 30 | router.get('/', (req, res) => { | 36 | router.get('/', (req, res) => { |
server/services/company-service.js
| @@ -240,7 +240,7 @@ class CompanyService { | @@ -240,7 +240,7 @@ class CompanyService { | ||
| 240 | * description 这家公司是干什么的(背景) | 240 | * description 这家公司是干什么的(背景) |
| 241 | * 两者的区分见 docs/todo/system-design/27-naming-conventions.md §四 | 241 | * 两者的区分见 docs/todo/system-design/27-naming-conventions.md §四 |
| 242 | */ | 242 | */ |
| 243 | - create({ id, name, goal = '', description = '' }) { | 243 | + create({ id, name, goal = '', description = '', ownerId = null }) { |
| 244 | // 公司 id 会成为 `system/agents/{aid}/{cid}/` 这一层的目录名,和本体自带的子目录同层 | 244 | // 公司 id 会成为 `system/agents/{aid}/{cid}/` 这一层的目录名,和本体自带的子目录同层 |
| 245 | // (28 号设计 §5.1):一家叫 `memory` 的公司会直接盖住每个 agent 的记忆目录, | 245 | // (28 号设计 §5.1):一家叫 `memory` 的公司会直接盖住每个 agent 的记忆目录, |
| 246 | // 而且是「目录在、代码不抛」的那种盖法。所以拦在建公司这一刻,不留到运行时。 | 246 | // 而且是「目录在、代码不抛」的那种盖法。所以拦在建公司这一刻,不留到运行时。 |
| @@ -269,6 +269,11 @@ class CompanyService { | @@ -269,6 +269,11 @@ class CompanyService { | ||
| 269 | createdAt: now, | 269 | createdAt: now, |
| 270 | updatedAt: now, | 270 | updatedAt: now, |
| 271 | createdBy: 'master', | 271 | createdBy: 'master', |
| 272 | + // 38 号文档 Phase 2:谁能看到这家公司(users.id)。null = 存量公司未迁移 | ||
| 273 | + // (理论上不该出现——迁移脚本会把它们全部划给种子账号,见 scripts/backfill-company-owner.js)。 | ||
| 274 | + // 跟 createdBy 是两回事:createdBy 恒为字面量 'master'(谁的公司都一样), | ||
| 275 | + // ownerId 才是真实的多租户归属字段。 | ||
| 276 | + ownerId, | ||
| 272 | ceo: null, | 277 | ceo: null, |
| 273 | sop: null, | 278 | sop: null, |
| 274 | agents: [], | 279 | agents: [], |
| @@ -303,9 +308,13 @@ class CompanyService { | @@ -303,9 +308,13 @@ class CompanyService { | ||
| 303 | } | 308 | } |
| 304 | 309 | ||
| 305 | /** | 310 | /** |
| 306 | - * 列出所有公司 | 311 | + * 列出公司。 |
| 312 | + * @param {object} [opts] | ||
| 313 | + * @param {object} [opts.user] - 传了就按归属过滤(admin 不过滤,见 canAccessCompany); | ||
| 314 | + * 不传 = 不过滤,返回全部——心跳等系统内部巡检(company-heartbeat-service.js)要看 | ||
| 315 | + * 所有公司,不代表任何一个用户,不能被误判成"只属于某个用户"。 | ||
| 307 | */ | 316 | */ |
| 308 | - list() { | 317 | + list({ user } = {}) { |
| 309 | if (!fs.existsSync(DATA_DIR)) return []; | 318 | if (!fs.existsSync(DATA_DIR)) return []; |
| 310 | 319 | ||
| 311 | return fs.readdirSync(DATA_DIR, { withFileTypes: true }) | 320 | return fs.readdirSync(DATA_DIR, { withFileTypes: true }) |
| @@ -313,6 +322,7 @@ class CompanyService { | @@ -313,6 +322,7 @@ class CompanyService { | ||
| 313 | .map(d => { | 322 | .map(d => { |
| 314 | const company = this.get(d.name); | 323 | const company = this.get(d.name); |
| 315 | if (!company) return null; | 324 | if (!company) return null; |
| 325 | + if (user && !canAccessCompany(company, user)) return null; | ||
| 316 | return { | 326 | return { |
| 317 | id: company.id, | 327 | id: company.id, |
| 318 | name: company.name, | 328 | name: company.name, |
| @@ -1192,4 +1202,20 @@ class CompanyService { | @@ -1192,4 +1202,20 @@ class CompanyService { | ||
| 1192 | } | 1202 | } |
| 1193 | } | 1203 | } |
| 1194 | 1204 | ||
| 1195 | -module.exports = { CompanyService, listCompanyAgents, buildAgentDefinitionBrief }; | 1205 | +/** |
| 1206 | + * 38 号文档 Phase 2:这个用户能不能碰这家公司——公司归属判据的**唯一持有者**。 | ||
| 1207 | + * company.js 的 router.param('companyId', ...)、graph-run.js 的两个挂载点、 | ||
| 1208 | + * devloop.js 反查 company_id 之后,都要调同一份,不能各写一份自己的判断 | ||
| 1209 | + * (§六风险 1:判据分叉是这次改造最大的风险)。 | ||
| 1210 | + * | ||
| 1211 | + * admin 不过滤(现状唯一的 admin 就是种子账号,事实上的超管);company 不存在 | ||
| 1212 | + * 或 ownerId 对不上 → false,调用方一律回 404(不是 403——不暴露"这个 id 存在, | ||
| 1213 | + * 只是不是你的",见 §四.C.1)。 | ||
| 1214 | + */ | ||
| 1215 | +function canAccessCompany(company, user) { | ||
| 1216 | + if (!company || !user) return false; | ||
| 1217 | + if (user.role === 'admin') return true; | ||
| 1218 | + return company.ownerId === user.id; | ||
| 1219 | +} | ||
| 1220 | + | ||
| 1221 | +module.exports = { CompanyService, listCompanyAgents, buildAgentDefinitionBrief, canAccessCompany }; |
server/tests/devloop/p4-l3-feedback.test.js
| @@ -40,6 +40,7 @@ async function fresh(idea = 'p4 test') { | @@ -40,6 +40,7 @@ async function fresh(idea = 'p4 test') { | ||
| 40 | function cleanup(cid) { | 40 | function cleanup(cid) { |
| 41 | try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } | 41 | try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } |
| 42 | catch { /* ignore */ } | 42 | catch { /* ignore */ } |
| 43 | + require('../helpers/ensure-test-company').removeTestCompany(cid); | ||
| 43 | } | 44 | } |
| 44 | 45 | ||
| 45 | describe('P4 · L3 status FSM', () => { | 46 | describe('P4 · L3 status FSM', () => { |
| @@ -171,6 +172,7 @@ describe('P4 · routes', () => { | @@ -171,6 +172,7 @@ describe('P4 · routes', () => { | ||
| 171 | it('POST /sign-off requires shipped status (400)', async () => { | 172 | it('POST /sign-off requires shipped status (400)', async () => { |
| 172 | const cid = 'c-route-signoff'; | 173 | const cid = 'c-route-signoff'; |
| 173 | cids.push(cid); | 174 | cids.push(cid); |
| 175 | + require('../helpers/ensure-test-company').ensureTestCompany(cid); | ||
| 174 | const r = await fetch(`${base}/api/devloop/projects`, { | 176 | const r = await fetch(`${base}/api/devloop/projects`, { |
| 175 | method: 'POST', headers: { 'Content-Type': 'application/json' }, | 177 | method: 'POST', headers: { 'Content-Type': 'application/json' }, |
| 176 | body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), | 178 | body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), |
| @@ -188,6 +190,7 @@ describe('P4 · routes', () => { | @@ -188,6 +190,7 @@ describe('P4 · routes', () => { | ||
| 188 | it('POST /feedback bumps PRD rev', async () => { | 190 | it('POST /feedback bumps PRD rev', async () => { |
| 189 | const cid = 'c-route-feedback'; | 191 | const cid = 'c-route-feedback'; |
| 190 | cids.push(cid); | 192 | cids.push(cid); |
| 193 | + require('../helpers/ensure-test-company').ensureTestCompany(cid); | ||
| 191 | const r = await fetch(`${base}/api/devloop/projects`, { | 194 | const r = await fetch(`${base}/api/devloop/projects`, { |
| 192 | method: 'POST', headers: { 'Content-Type': 'application/json' }, | 195 | method: 'POST', headers: { 'Content-Type': 'application/json' }, |
| 193 | body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), | 196 | body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), |
| @@ -205,6 +208,7 @@ describe('P4 · routes', () => { | @@ -205,6 +208,7 @@ describe('P4 · routes', () => { | ||
| 205 | it('POST /feedback missing text → 400', async () => { | 208 | it('POST /feedback missing text → 400', async () => { |
| 206 | const cid = 'c-route-fb-empty'; | 209 | const cid = 'c-route-fb-empty'; |
| 207 | cids.push(cid); | 210 | cids.push(cid); |
| 211 | + require('../helpers/ensure-test-company').ensureTestCompany(cid); | ||
| 208 | const r = await fetch(`${base}/api/devloop/projects`, { | 212 | const r = await fetch(`${base}/api/devloop/projects`, { |
| 209 | method: 'POST', headers: { 'Content-Type': 'application/json' }, | 213 | method: 'POST', headers: { 'Content-Type': 'application/json' }, |
| 210 | body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), | 214 | body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), |
| @@ -219,6 +223,7 @@ describe('P4 · routes', () => { | @@ -219,6 +223,7 @@ describe('P4 · routes', () => { | ||
| 219 | it('POST /abandon → status=abandoned', async () => { | 223 | it('POST /abandon → status=abandoned', async () => { |
| 220 | const cid = 'c-route-abandon'; | 224 | const cid = 'c-route-abandon'; |
| 221 | cids.push(cid); | 225 | cids.push(cid); |
| 226 | + require('../helpers/ensure-test-company').ensureTestCompany(cid); | ||
| 222 | const r = await fetch(`${base}/api/devloop/projects`, { | 227 | const r = await fetch(`${base}/api/devloop/projects`, { |
| 223 | method: 'POST', headers: { 'Content-Type': 'application/json' }, | 228 | method: 'POST', headers: { 'Content-Type': 'application/json' }, |
| 224 | body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), | 229 | body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), |
server/tests/devloop/routes-crud.test.js
| @@ -16,6 +16,7 @@ process.env.HOME = TEST_HOME; | @@ -16,6 +16,7 @@ process.env.HOME = TEST_HOME; | ||
| 16 | 16 | ||
| 17 | const pathResolver = require('../../services/devloop/path-resolver'); | 17 | const pathResolver = require('../../services/devloop/path-resolver'); |
| 18 | const devloopRouter = require('../../routes/devloop'); | 18 | const devloopRouter = require('../../routes/devloop'); |
| 19 | +const { ensureTestCompany, removeTestCompany } = require('../helpers/ensure-test-company'); | ||
| 19 | 20 | ||
| 20 | function startApp() { | 21 | function startApp() { |
| 21 | const app = express(); | 22 | const app = express(); |
| @@ -28,7 +29,10 @@ function startApp() { | @@ -28,7 +29,10 @@ function startApp() { | ||
| 28 | }); | 29 | }); |
| 29 | } | 30 | } |
| 30 | 31 | ||
| 32 | +// 38 号文档 Phase 2:devloop.js 建项目前会校验 company_id 是不是一家真实存在、当前用户 | ||
| 33 | +// 碰得到的公司——这个唯一的建项目入口顺手建一下,不用在每个调用点各补一次。 | ||
| 31 | async function createProject(base, cid, requirement = 'crud test') { | 34 | async function createProject(base, cid, requirement = 'crud test') { |
| 35 | + ensureTestCompany(cid); | ||
| 32 | const r = await fetch(`${base}/api/devloop/projects`, { | 36 | const r = await fetch(`${base}/api/devloop/projects`, { |
| 33 | method: 'POST', headers: { 'Content-Type': 'application/json' }, | 37 | method: 'POST', headers: { 'Content-Type': 'application/json' }, |
| 34 | body: JSON.stringify({ company_id: cid, requirement }), | 38 | body: JSON.stringify({ company_id: cid, requirement }), |
| @@ -52,6 +56,7 @@ describe('routes/devloop · CRUD', () => { | @@ -52,6 +56,7 @@ describe('routes/devloop · CRUD', () => { | ||
| 52 | try { | 56 | try { |
| 53 | fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); | 57 | fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); |
| 54 | } catch { /* ignore */ } | 58 | } catch { /* ignore */ } |
| 59 | + removeTestCompany(c); | ||
| 55 | } | 60 | } |
| 56 | }); | 61 | }); |
| 57 | 62 |
server/tests/devloop/routes.test.js
| @@ -16,6 +16,7 @@ process.env.HOME = TEST_HOME; | @@ -16,6 +16,7 @@ process.env.HOME = TEST_HOME; | ||
| 16 | 16 | ||
| 17 | const pathResolver = require('../../services/devloop/path-resolver'); | 17 | const pathResolver = require('../../services/devloop/path-resolver'); |
| 18 | const devloopRouter = require('../../routes/devloop'); | 18 | const devloopRouter = require('../../routes/devloop'); |
| 19 | +const { ensureTestCompany, removeTestCompany } = require('../helpers/ensure-test-company'); | ||
| 19 | 20 | ||
| 20 | function startApp() { | 21 | function startApp() { |
| 21 | const app = express(); | 22 | const app = express(); |
| @@ -33,8 +34,13 @@ describe('routes/devloop', () => { | @@ -33,8 +34,13 @@ describe('routes/devloop', () => { | ||
| 33 | let app, base; | 34 | let app, base; |
| 34 | const created = []; | 35 | const created = []; |
| 35 | 36 | ||
| 37 | + // 38 号文档 Phase 2:devloop.js 建项目前会校验 company_id 是不是一家真实存在、 | ||
| 38 | + // 当前用户碰得到的公司——这份测试文件里各用例用的 cid 都是硬编码字面量,从没真的建过公司。 | ||
| 39 | + const TEST_CIDS = ['c-route-test', 'c-route-p2', 'c-route-p3', 'c-route-p2u', 'c-p5-routes']; | ||
| 40 | + | ||
| 36 | beforeAll(async () => { | 41 | beforeAll(async () => { |
| 37 | pathResolver._resetForTesting(); | 42 | pathResolver._resetForTesting(); |
| 43 | + for (const cid of TEST_CIDS) ensureTestCompany(cid); | ||
| 38 | ({ srv: app, base } = await startApp()); | 44 | ({ srv: app, base } = await startApp()); |
| 39 | }); | 45 | }); |
| 40 | 46 | ||
| @@ -45,6 +51,7 @@ describe('routes/devloop', () => { | @@ -45,6 +51,7 @@ describe('routes/devloop', () => { | ||
| 45 | fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); | 51 | fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); |
| 46 | } catch { /* ignore */ } | 52 | } catch { /* ignore */ } |
| 47 | } | 53 | } |
| 54 | + for (const cid of TEST_CIDS) removeTestCompany(cid); | ||
| 48 | }); | 55 | }); |
| 49 | 56 | ||
| 50 | it('POST /api/devloop/projects requires company_id', async () => { | 57 | it('POST /api/devloop/projects requires company_id', async () => { |
| @@ -179,8 +186,10 @@ describe('routes/devloop', () => { | @@ -179,8 +186,10 @@ describe('routes/devloop', () => { | ||
| 179 | it('GET /api/devloop/projects/:pid/state on unknown pid returns 404', async () => { | 186 | it('GET /api/devloop/projects/:pid/state on unknown pid returns 404', async () => { |
| 180 | const r = await fetch(`${base}/api/devloop/projects/p-doesnotexist/state`); | 187 | const r = await fetch(`${base}/api/devloop/projects/p-doesnotexist/state`); |
| 181 | expect(r.status).toBe(404); | 188 | expect(r.status).toBe(404); |
| 189 | + // 38 号文档 Phase 2:router.param('pid', ...) 现在抢在具体路由前面判"项目存不存在", | ||
| 190 | + // 消息文案统一成"项目 X 不存在"(跟 company/run 那两处同款措辞),不再是各路由各写一套。 | ||
| 182 | const j = await r.json(); | 191 | const j = await r.json(); |
| 183 | - expect(j.error).toMatch(/unknown/); | 192 | + expect(j.error).toMatch(/不存在/); |
| 184 | }); | 193 | }); |
| 185 | 194 | ||
| 186 | it('GET /api/devloop/projects lists projects', async () => { | 195 | it('GET /api/devloop/projects lists projects', async () => { |
server/tests/helpers/ensure-test-company.js
0 → 100644
| 1 | +/** | ||
| 2 | + * ensure-test-company.js —— 38 号文档 Phase 2 落地后,company.js / graph-run.js 的 | ||
| 3 | + * :companyId / :id 路由都会先查 company-service.js 的 companyService.get(cid)(判断 | ||
| 4 | + * "这家公司存不存在"),存在才轮到归属校验。company-service.js 的 DATA_DIR 是硬编码的 | ||
| 5 | + * `<repo>/data/companies`,无 env 覆盖口,且只认 company.json 这一份真相。 | ||
| 6 | + * | ||
| 7 | + * 仓库里一批测试文件直接在 graph_runs/company_agents 等表里插一个虚构的 companyId | ||
| 8 | + * (从没真的建过公司),这在 Phase 2 之前无所谓——company.js 那层从不检查。现在会被 | ||
| 9 | + * 挡在 router.param 那一关,404。这个 helper 补一份最小 company.json,让这类"只是需要 | ||
| 10 | + * 一个 companyId 字符串来做数据隔离"的测试继续成立,不代表要去验证公司归属本身 | ||
| 11 | + * (那是 company-service.test.js / route-auth-gate.test.js 的职责)。 | ||
| 12 | + * | ||
| 13 | + * 幂等、同步、可重复调用。清理交给调用方自己的 afterAll(多数这类测试本来就有一次性 | ||
| 14 | + * 数据目录清理逻辑,顺手带上这个)。 | ||
| 15 | + */ | ||
| 16 | +const fs = require('fs'); | ||
| 17 | +const path = require('path'); | ||
| 18 | + | ||
| 19 | +const COMPANIES_DIR = path.resolve(__dirname, '../../../data/companies'); | ||
| 20 | + | ||
| 21 | +/** | ||
| 22 | + * @param {string} companyId | ||
| 23 | + * @param {object} [opts] | ||
| 24 | + * @param {number} [opts.ownerId] - 默认 -1,跟 jwt-auth.js 测试环境放行时用的 | ||
| 25 | + * TEST_BYPASS_USER.id 对齐,但其实无所谓——TEST_BYPASS_USER.role='admin', | ||
| 26 | + * canAccessCompany 不看 ownerId。 | ||
| 27 | + */ | ||
| 28 | +function ensureTestCompany(companyId, { ownerId = -1 } = {}) { | ||
| 29 | + const dir = path.join(COMPANIES_DIR, companyId); | ||
| 30 | + const fp = path.join(dir, 'company.json'); | ||
| 31 | + if (fs.existsSync(fp)) return; | ||
| 32 | + fs.mkdirSync(dir, { recursive: true }); | ||
| 33 | + fs.writeFileSync(fp, JSON.stringify({ id: companyId, name: companyId, ownerId })); | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +/** 测试收尾用:删掉 ensureTestCompany 建出来的目录(连同调用方自己可能写进去的其它文件)。*/ | ||
| 37 | +function removeTestCompany(companyId) { | ||
| 38 | + fs.rmSync(path.join(COMPANIES_DIR, companyId), { recursive: true, force: true }); | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +module.exports = { ensureTestCompany, removeTestCompany }; |
server/tests/unit/agent-transcript.test.js
| @@ -82,6 +82,9 @@ describe('agent-transcript:会话原文读取', () => { | @@ -82,6 +82,9 @@ describe('agent-transcript:会话原文读取', () => { | ||
| 82 | const msg = (ts, role, content) => ({ timestamp: ts, type: role, message: { role, content } }); | 82 | const msg = (ts, role, content) => ({ timestamp: ts, type: role, message: { role, content } }); |
| 83 | 83 | ||
| 84 | beforeAll(async () => { | 84 | beforeAll(async () => { |
| 85 | + // 38 号文档 Phase 2:graph-run.js 的 :id 路由现在会反查 run 的 company_id 是否是一家 | ||
| 86 | + // 真实存在的公司,COMPANY 只是这份测试里的一个字符串标签,从没真的建过公司。 | ||
| 87 | + require('../helpers/ensure-test-company').ensureTestCompany(COMPANY); | ||
| 85 | for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | 88 | for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } |
| 86 | projectsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tx-projects-')); | 89 | projectsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tx-projects-')); |
| 87 | runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tx-runs-')); | 90 | runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tx-runs-')); |
| @@ -161,6 +164,7 @@ describe('agent-transcript:会话原文读取', () => { | @@ -161,6 +164,7 @@ describe('agent-transcript:会话原文读取', () => { | ||
| 161 | 164 | ||
| 162 | afterAll(async () => { | 165 | afterAll(async () => { |
| 163 | if (srv) await new Promise((r) => srv.close(r)); | 166 | if (srv) await new Promise((r) => srv.close(r)); |
| 167 | + require('../helpers/ensure-test-company').removeTestCompany(COMPANY); | ||
| 164 | for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | 168 | for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } |
| 165 | for (const d of [projectsRoot, runsDir]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } | 169 | for (const d of [projectsRoot, runsDir]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } |
| 166 | delete process.env.CLAUDE_PROJECTS_DIR; | 170 | delete process.env.CLAUDE_PROJECTS_DIR; |
server/tests/unit/company-files-path-guard.test.js
| @@ -46,6 +46,11 @@ describe('公司文件端点:路径越界防护', () => { | @@ -46,6 +46,11 @@ describe('公司文件端点:路径越界防护', () => { | ||
| 46 | beforeAll(async () => { | 46 | beforeAll(async () => { |
| 47 | for (const c of [VICTIM, SIBLING]) { | 47 | for (const c of [VICTIM, SIBLING]) { |
| 48 | fs.mkdirSync(path.join(COMPANIES_DIR, c), { recursive: true }); | 48 | fs.mkdirSync(path.join(COMPANIES_DIR, c), { recursive: true }); |
| 49 | + // 38 号文档 Phase 2:company.js 现在挂了 router.param('companyId', ...), | ||
| 50 | + // 靠 company.json 判断"这家公司存不存在"——这组用例本来只关心文件路径越界, | ||
| 51 | + // 不是公司归属,补一份最小 company.json 让请求能过归属校验那一关, | ||
| 52 | + // 不改变本文件真正要测的东西(下面的越界防护)。 | ||
| 53 | + fs.writeFileSync(path.join(COMPANIES_DIR, c, 'company.json'), JSON.stringify({ id: c, name: c, ownerId: -1 })); | ||
| 49 | } | 54 | } |
| 50 | fs.writeFileSync(path.join(COMPANIES_DIR, VICTIM, 'own.md'), '# 我自己的文件\n'); | 55 | fs.writeFileSync(path.join(COMPANIES_DIR, VICTIM, 'own.md'), '# 我自己的文件\n'); |
| 51 | fs.writeFileSync(path.join(COMPANIES_DIR, SIBLING, 'secret.md'), 'SIBLING-SECRET\n'); | 56 | fs.writeFileSync(path.join(COMPANIES_DIR, SIBLING, 'secret.md'), 'SIBLING-SECRET\n'); |
server/tests/unit/graph-run-route-io.test.js
| @@ -42,6 +42,9 @@ describe('GET /api/graph-runs/:id — R-19 输入输出三字段', () => { | @@ -42,6 +42,9 @@ describe('GET /api/graph-runs/:id — R-19 输入输出三字段', () => { | ||
| 42 | const taskId = 'T-route-io'; | 42 | const taskId = 'T-route-io'; |
| 43 | 43 | ||
| 44 | beforeAll(async () => { | 44 | beforeAll(async () => { |
| 45 | + // 38 号文档 Phase 2:graph-run.js 的 :id 路由现在会反查 run 的 company_id 是否是一家 | ||
| 46 | + // 真实存在的公司,companyId 只是这份测试里的一个字符串标签,从没真的建过公司。 | ||
| 47 | + require('../helpers/ensure-test-company').ensureTestCompany(companyId); | ||
| 45 | for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | 48 | for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } |
| 46 | process.env.GRAPH_DB_PATH = TEST_DB_PATH; | 49 | process.env.GRAPH_DB_PATH = TEST_DB_PATH; |
| 47 | runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-io-runs-')); | 50 | runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-io-runs-')); |
| @@ -99,6 +102,7 @@ describe('GET /api/graph-runs/:id — R-19 输入输出三字段', () => { | @@ -99,6 +102,7 @@ describe('GET /api/graph-runs/:id — R-19 输入输出三字段', () => { | ||
| 99 | 102 | ||
| 100 | afterAll(async () => { | 103 | afterAll(async () => { |
| 101 | if (srv) await new Promise((r) => srv.close(r)); | 104 | if (srv) await new Promise((r) => srv.close(r)); |
| 105 | + require('../helpers/ensure-test-company').removeTestCompany(companyId); | ||
| 102 | for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } | 106 | for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } |
| 103 | delete process.env.GRAPH_DB_PATH; | 107 | delete process.env.GRAPH_DB_PATH; |
| 104 | try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} | 108 | try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} |
server/tests/unit/graph-run-route-slot-busy.test.js
| @@ -48,6 +48,9 @@ describe('POST /api/graph-runs/... 撞上坑满 → 409(不是 500)', () => | @@ -48,6 +48,9 @@ describe('POST /api/graph-runs/... 撞上坑满 → 409(不是 500)', () => | ||
| 48 | let deadNodeId; | 48 | let deadNodeId; |
| 49 | 49 | ||
| 50 | beforeAll(async () => { | 50 | beforeAll(async () => { |
| 51 | + // 38 号文档 Phase 2:graph-run.js 的 :id 路由现在会反查 run 的 company_id 是否是一家 | ||
| 52 | + // 真实存在的公司,CID 只是这份测试里的一个字符串标签,从没真的建过公司。 | ||
| 53 | + require('../helpers/ensure-test-company').ensureTestCompany(CID); | ||
| 51 | for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } } | 54 | for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } } |
| 52 | process.env.GRAPH_DB_PATH = TEST_DB_PATH; | 55 | process.env.GRAPH_DB_PATH = TEST_DB_PATH; |
| 53 | // 不设的话任何走到默认路径的写入都会往仓库 data/ 里堆 | 56 | // 不设的话任何走到默认路径的写入都会往仓库 data/ 里堆 |
| @@ -96,6 +99,7 @@ describe('POST /api/graph-runs/... 撞上坑满 → 409(不是 500)', () => | @@ -96,6 +99,7 @@ describe('POST /api/graph-runs/... 撞上坑满 → 409(不是 500)', () => | ||
| 96 | 99 | ||
| 97 | afterAll(() => { | 100 | afterAll(() => { |
| 98 | if (server) server.close(); | 101 | if (server) server.close(); |
| 102 | + require('../helpers/ensure-test-company').removeTestCompany(CID); | ||
| 99 | try { require('../../services/task-scheduler').stopSweeper(); } catch { /* 没起过就算了 */ } | 103 | try { require('../../services/task-scheduler').stopSweeper(); } catch { /* 没起过就算了 */ } |
| 100 | for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } } | 104 | for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } } |
| 101 | try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch { /* ignore */ } | 105 | try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch { /* ignore */ } |
server/tests/unit/heartbeat-company-scoped.test.js
| @@ -72,6 +72,16 @@ describe('心跳设置按公司隔离(28 遗留项)', () => { | @@ -72,6 +72,16 @@ describe('心跳设置按公司隔离(28 遗留项)', () => { | ||
| 72 | // 上一轮跑剩的公司态不能留,否则③的回落断言会读到旧值而空过 | 72 | // 上一轮跑剩的公司态不能留,否则③的回落断言会读到旧值而空过 |
| 73 | for (const cid of [CO_A, CO_B, '_global']) fs.rmSync(companyStateDir(cid), { recursive: true, force: true }); | 73 | for (const cid of [CO_A, CO_B, '_global']) fs.rmSync(companyStateDir(cid), { recursive: true, force: true }); |
| 74 | 74 | ||
| 75 | + // 38 号文档 Phase 2:company.js 现在挂了 router.param('companyId', ...), | ||
| 76 | + // 靠 company.json 判断"这家公司存不存在"——CO_A/CO_B 在这份测试里从来不是真正建过的 | ||
| 77 | + // 公司(只有雇佣关系和公司态目录),补一份最小 company.json 让请求能过归属校验, | ||
| 78 | + // 不影响本文件真正要测的心跳隔离逻辑。 | ||
| 79 | + for (const cid of [CO_A, CO_B]) { | ||
| 80 | + const dir = path.join(REPO_ROOT, 'data', 'companies', cid); | ||
| 81 | + fs.mkdirSync(dir, { recursive: true }); | ||
| 82 | + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: cid, name: cid, ownerId: -1 })); | ||
| 83 | + } | ||
| 84 | + | ||
| 75 | // 2. 一次性 graph.sqlite + 两家公司的雇佣关系 | 85 | // 2. 一次性 graph.sqlite + 两家公司的雇佣关系 |
| 76 | if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); | 86 | if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); |
| 77 | process.env.GRAPH_DB_PATH = TEST_DB_PATH; | 87 | process.env.GRAPH_DB_PATH = TEST_DB_PATH; |
| @@ -112,6 +122,7 @@ describe('心跳设置按公司隔离(28 遗留项)', () => { | @@ -112,6 +122,7 @@ describe('心跳设置按公司隔离(28 遗留项)', () => { | ||
| 112 | // 只清公司态(运行态、gitignore);本体留在 system/agents/ 里当正式注册表条目 | 122 | // 只清公司态(运行态、gitignore);本体留在 system/agents/ 里当正式注册表条目 |
| 113 | // `_global` 是裸 agent id 的公司态落点(GET /info 那条用它),同样要清 | 123 | // `_global` 是裸 agent id 的公司态落点(GET /info 那条用它),同样要清 |
| 114 | for (const cid of [CO_A, CO_B, '_global']) fs.rmSync(companyStateDir(cid), { recursive: true, force: true }); | 124 | for (const cid of [CO_A, CO_B, '_global']) fs.rmSync(companyStateDir(cid), { recursive: true, force: true }); |
| 125 | + for (const cid of [CO_A, CO_B]) fs.rmSync(path.join(REPO_ROOT, 'data', 'companies', cid), { recursive: true, force: true }); | ||
| 115 | if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); | 126 | if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); |
| 116 | delete process.env.GRAPH_DB_PATH; | 127 | delete process.env.GRAPH_DB_PATH; |
| 117 | }); | 128 | }); |
server/tests/unit/p3c-recovery-http.test.js
| @@ -75,20 +75,38 @@ describe('恢复动作的 HTTP 结局:退得回队列 → 2xx,退不回 → | @@ -75,20 +75,38 @@ describe('恢复动作的 HTTP 结局:退得回队列 → 2xx,退不回 → | ||
| 75 | for (const m of MODULES) delete require.cache[require.resolve(m)]; | 75 | for (const m of MODULES) delete require.cache[require.resolve(m)]; |
| 76 | }); | 76 | }); |
| 77 | 77 | ||
| 78 | - /** advanceOne 的 ensureProjectRoot 认的是仓库里的 data/companies/,只删本文件造的那些 */ | 78 | + /** |
| 79 | + * advanceOne 的 ensureProjectRoot 认的是仓库里的 data/companies/,只删本文件造的那些。 | ||
| 80 | + * nextCompany() 现在会顺手建一份 company.json(38 号文档 Phase 2,ensureTestCompany 写的 | ||
| 81 | + * ownerId 恒为 -1,真实生产公司的 ownerId 是 users.id,不会撞上 -1)——判据从"有没有 | ||
| 82 | + * company.json"改成"ownerId 是不是 -1 这个测试专用标记",否则这批目录会被误判成真公司 | ||
| 83 | + * 而永远不清理。 | ||
| 84 | + */ | ||
| 79 | function cleanupLeakedCompanyDirs() { | 85 | function cleanupLeakedCompanyDirs() { |
| 80 | const root = path.resolve(__dirname, '../../../data/companies'); | 86 | const root = path.resolve(__dirname, '../../../data/companies'); |
| 81 | if (!fs.existsSync(root)) return; | 87 | if (!fs.existsSync(root)) return; |
| 82 | for (const cid of fs.readdirSync(root)) { | 88 | for (const cid of fs.readdirSync(root)) { |
| 83 | if (!cid.startsWith('p3chttp-co-')) continue; | 89 | if (!cid.startsWith('p3chttp-co-')) continue; |
| 84 | const dir = path.join(root, cid); | 90 | const dir = path.join(root, cid); |
| 85 | - if (fs.existsSync(path.join(dir, 'company.json'))) continue; // 那是真公司 | 91 | + const companyJsonPath = path.join(dir, 'company.json'); |
| 92 | + if (fs.existsSync(companyJsonPath)) { | ||
| 93 | + try { | ||
| 94 | + const { ownerId } = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); | ||
| 95 | + if (ownerId !== -1) continue; // 那是真公司 | ||
| 96 | + } catch { continue; } // 读不出来就保守跳过,不乱删 | ||
| 97 | + } | ||
| 86 | try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ } | 98 | try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ } |
| 87 | } | 99 | } |
| 88 | } | 100 | } |
| 89 | 101 | ||
| 90 | let seq = 0; | 102 | let seq = 0; |
| 91 | - const nextCompany = () => `p3chttp-co-${++seq}`; | 103 | + // 38 号文档 Phase 2:graph-run.js 的 :id 路由现在会反查 run 的 company_id 是否是一家 |
| 104 | + // 真实存在的公司,这里造出来的 id 从没真的建过公司,补一份最小 company.json。 | ||
| 105 | + const nextCompany = () => { | ||
| 106 | + const cid = `p3chttp-co-${++seq}`; | ||
| 107 | + require('../helpers/ensure-test-company').ensureTestCompany(cid); | ||
| 108 | + return cid; | ||
| 109 | + }; | ||
| 92 | 110 | ||
| 93 | function ensureAgents(ids) { | 111 | function ensureAgents(ids) { |
| 94 | const now = new Date().toISOString(); | 112 | const now = new Date().toISOString(); |
server/tests/unit/route-auth-gate.test.js
| @@ -62,8 +62,8 @@ describe('route-auth-gate(38 号文档 Phase 1)', () => { | @@ -62,8 +62,8 @@ describe('route-auth-gate(38 号文档 Phase 1)', () => { | ||
| 62 | expectGated(graphRun.companyRouter, requireAuth, 'graph-run.js companyRouter'); | 62 | expectGated(graphRun.companyRouter, requireAuth, 'graph-run.js companyRouter'); |
| 63 | }); | 63 | }); |
| 64 | 64 | ||
| 65 | - it('okr.js 挂了 requireAuth', () => { | ||
| 66 | - expectGated(require('../../routes/okr.js'), requireAuth, 'okr.js'); | 65 | + it('okr.js 挂了 requireAdmin(孤儿页面,前端无入口,收紧到 admin-only,见 38 号文档 Phase 2)', () => { |
| 66 | + expectGated(require('../../routes/okr.js'), requireAdmin, 'okr.js'); | ||
| 67 | }); | 67 | }); |
| 68 | 68 | ||
| 69 | it('arena.js 挂了 requireAuth', () => { | 69 | it('arena.js 挂了 requireAuth', () => { |
server/tests/unit/task-slots-parallel.test.js
| @@ -130,8 +130,12 @@ describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { | @@ -130,8 +130,12 @@ describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { | ||
| 130 | 130 | ||
| 131 | /** | 131 | /** |
| 132 | * `advanceOne` 的 ensureProjectRoot 会在**仓库的** data/companies/ 下给每家公司建项目根 | 132 | * `advanceOne` 的 ensureProjectRoot 会在**仓库的** data/companies/ 下给每家公司建项目根 |
| 133 | - * (它不认 GRAPH_RUNS_DIR)。只删本文件造的那些前缀目录,且必须确认里面没有 company.json | ||
| 134 | - * —— 那是真公司,删了就是删用户数据。(写法与 task-scheduler.test.js 一致) | 133 | + * (它不认 GRAPH_RUNS_DIR)。只删本文件造的那些前缀目录,且必须确认不是真公司—— |
| 134 | + * 那是真公司,删了就是删用户数据。 | ||
| 135 | + * | ||
| 136 | + * 38 号文档 Phase 2:nextCompany() 现在会顺手调 ensureTestCompany 建一份 company.json | ||
| 137 | + * (ownerId 恒为 -1,真实生产公司不会是这个值),判据从"有没有 company.json"改成 | ||
| 138 | + * "ownerId 是不是 -1 这个测试专用标记",否则这批目录会被误判成真公司而永远不清理。 | ||
| 135 | */ | 139 | */ |
| 136 | function cleanupLeakedCompanyDirs() { | 140 | function cleanupLeakedCompanyDirs() { |
| 137 | const root = path.resolve(__dirname, '../../../data/companies'); | 141 | const root = path.resolve(__dirname, '../../../data/companies'); |
| @@ -139,7 +143,13 @@ describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { | @@ -139,7 +143,13 @@ describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { | ||
| 139 | for (const cid of fs.readdirSync(root)) { | 143 | for (const cid of fs.readdirSync(root)) { |
| 140 | if (!cid.startsWith('slots-co-')) continue; | 144 | if (!cid.startsWith('slots-co-')) continue; |
| 141 | const dir = path.join(root, cid); | 145 | const dir = path.join(root, cid); |
| 142 | - if (fs.existsSync(path.join(dir, 'company.json'))) continue; | 146 | + const companyJsonPath = path.join(dir, 'company.json'); |
| 147 | + if (fs.existsSync(companyJsonPath)) { | ||
| 148 | + try { | ||
| 149 | + const { ownerId } = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); | ||
| 150 | + if (ownerId !== -1) continue; // 那是真公司 | ||
| 151 | + } catch { continue; } | ||
| 152 | + } | ||
| 143 | try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ } | 153 | try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ } |
| 144 | } | 154 | } |
| 145 | } | 155 | } |
| @@ -179,8 +189,16 @@ describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { | @@ -179,8 +189,16 @@ describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { | ||
| 179 | 189 | ||
| 180 | // ── 夹具 ────────────────────────────────────────────────────── | 190 | // ── 夹具 ────────────────────────────────────────────────────── |
| 181 | let seq = 0; | 191 | let seq = 0; |
| 182 | - /** 每个用例一家独立公司:执行位是按 company 数的 */ | ||
| 183 | - const nextCompany = () => `slots-co-${++seq}`; | 192 | + /** |
| 193 | + * 每个用例一家独立公司:执行位是按 company 数的。 | ||
| 194 | + * 38 号文档 Phase 2:graph-run.js 的 :id 路由现在会反查 run 的 company_id 是否是一家 | ||
| 195 | + * 真实存在的公司,顺手建一份最小 company.json(否则打 HTTP 的用例会 404)。 | ||
| 196 | + */ | ||
| 197 | + const nextCompany = () => { | ||
| 198 | + const cid = `slots-co-${++seq}`; | ||
| 199 | + require('../helpers/ensure-test-company').ensureTestCompany(cid); | ||
| 200 | + return cid; | ||
| 201 | + }; | ||
| 184 | 202 | ||
| 185 | function ensureAgents(ids) { | 203 | function ensureAgents(ids) { |
| 186 | const now = new Date().toISOString(); | 204 | const now = new Date().toISOString(); |