Commit 76d2a5aeaf3d605fd94b2e6eded93dd5ade6eabd

Authored by yibo
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>
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(&#39;companyId&#39;, (req, res, next, companyId) =&gt; { @@ -103,13 +103,15 @@ router.param(&#39;companyId&#39;, (req, res, next, companyId) =&gt; {
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(&#39;companyId&#39;, (req, res, next, companyId) =&gt; { @@ -120,7 +122,7 @@ router.param(&#39;companyId&#39;, (req, res, next, companyId) =&gt; {
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(&#39;fs&#39;); @@ -10,11 +10,10 @@ const fs = require(&#39;fs&#39;);
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(&#39;../services/agent-body-filler&#39;); @@ -27,6 +26,19 @@ const { fillAgentBodyReliably } = require(&#39;../services/agent-body-filler&#39;);
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(&#39;/create&#39;, async (req, res) =&gt; { @@ -116,7 +128,7 @@ router.post(&#39;/create&#39;, async (req, res) =&gt; {
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(&#39;/create&#39;, async (req, res) =&gt; { @@ -140,9 +152,9 @@ router.post(&#39;/create&#39;, async (req, res) =&gt; {
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(&#39;fs&#39;); @@ -30,6 +30,29 @@ const fs = require(&#39;fs&#39;);
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(&#39;/projects&#39;, h(async (req, res) =&gt; { @@ -60,6 +83,13 @@ router.post(&#39;/projects&#39;, h(async (req, res) =&gt; {
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(&#39;/projects&#39;, h(async (req, res) =&gt; { @@ -110,10 +140,11 @@ router.post(&#39;/projects&#39;, h(async (req, res) =&gt; {
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 = &#39;p4 test&#39;) { @@ -40,6 +40,7 @@ async function fresh(idea = &#39;p4 test&#39;) {
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(&#39;P4 · routes&#39;, () =&gt; { @@ -171,6 +172,7 @@ describe(&#39;P4 · routes&#39;, () =&gt; {
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(&#39;P4 · routes&#39;, () =&gt; { @@ -188,6 +190,7 @@ describe(&#39;P4 · routes&#39;, () =&gt; {
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(&#39;P4 · routes&#39;, () =&gt; { @@ -205,6 +208,7 @@ describe(&#39;P4 · routes&#39;, () =&gt; {
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(&#39;P4 · routes&#39;, () =&gt; { @@ -219,6 +223,7 @@ describe(&#39;P4 · routes&#39;, () =&gt; {
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(&#39;routes/devloop · CRUD&#39;, () =&gt; { @@ -52,6 +56,7 @@ describe(&#39;routes/devloop · CRUD&#39;, () =&gt; {
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(&#39;routes/devloop&#39;, () =&gt; { @@ -33,8 +34,13 @@ describe(&#39;routes/devloop&#39;, () =&gt; {
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(&#39;routes/devloop&#39;, () =&gt; { @@ -45,6 +51,7 @@ describe(&#39;routes/devloop&#39;, () =&gt; {
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(&#39;routes/devloop&#39;, () =&gt; { @@ -179,8 +186,10 @@ describe(&#39;routes/devloop&#39;, () =&gt; {
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(&#39;agent-transcript:会话原文读取&#39;, () =&gt; { @@ -82,6 +82,9 @@ describe(&#39;agent-transcript:会话原文读取&#39;, () =&gt; {
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(&#39;agent-transcript:会话原文读取&#39;, () =&gt; { @@ -161,6 +164,7 @@ describe(&#39;agent-transcript:会话原文读取&#39;, () =&gt; {
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(&#39;公司文件端点:路径越界防护&#39;, () =&gt; { @@ -46,6 +46,11 @@ describe(&#39;公司文件端点:路径越界防护&#39;, () =&gt; {
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(&#39;GET /api/graph-runs/:id — R-19 输入输出三字段&#39;, () =&gt; { @@ -42,6 +42,9 @@ describe(&#39;GET /api/graph-runs/:id — R-19 输入输出三字段&#39;, () =&gt; {
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(&#39;GET /api/graph-runs/:id — R-19 输入输出三字段&#39;, () =&gt; { @@ -99,6 +102,7 @@ describe(&#39;GET /api/graph-runs/:id — R-19 输入输出三字段&#39;, () =&gt; {
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(&#39;POST /api/graph-runs/... 撞上坑满 → 409(不是 500)&#39;, () =&gt; @@ -48,6 +48,9 @@ describe(&#39;POST /api/graph-runs/... 撞上坑满 → 409(不是 500)&#39;, () =&gt;
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(&#39;POST /api/graph-runs/... 撞上坑满 → 409(不是 500)&#39;, () =&gt; @@ -96,6 +99,7 @@ describe(&#39;POST /api/graph-runs/... 撞上坑满 → 409(不是 500)&#39;, () =&gt;
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(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; { @@ -72,6 +72,16 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; {
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(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; { @@ -112,6 +122,7 @@ describe(&#39;心跳设置按公司隔离(28 遗留项)&#39;, () =&gt; {
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(&#39;恢复动作的 HTTP 结局:退得回队列 → 2xx,退不回 → @@ -75,20 +75,38 @@ describe(&#39;恢复动作的 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(&#39;route-auth-gate(38 号文档 Phase 1)&#39;, () =&gt; { @@ -62,8 +62,8 @@ describe(&#39;route-auth-gate(38 号文档 Phase 1)&#39;, () =&gt; {
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(&#39;批 4 · 公司级执行位(max_parallel_tasks)&#39;, () =&gt; { @@ -130,8 +130,12 @@ describe(&#39;批 4 · 公司级执行位(max_parallel_tasks)&#39;, () =&gt; {
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(&#39;批 4 · 公司级执行位(max_parallel_tasks)&#39;, () =&gt; { @@ -139,7 +143,13 @@ describe(&#39;批 4 · 公司级执行位(max_parallel_tasks)&#39;, () =&gt; {
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(&#39;批 4 · 公司级执行位(max_parallel_tasks)&#39;, () =&gt; { @@ -179,8 +189,16 @@ describe(&#39;批 4 · 公司级执行位(max_parallel_tasks)&#39;, () =&gt; {
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();