diff --git a/docs/README.md b/docs/README.md index fbec39a..6cbf7c5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,7 +49,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | [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) | | [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 交给人工 | | [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 次,零人工介入),修复确认生效 | -| [38-auth-multi-tenant-isolation.md](./todo/system-design/38-auth-multi-tenant-isolation.md) | 📝 **Phase 0 已完成(2026-08-25),Phase 1-3 未开工**。远程部署前置需求:登录鉴权 + 公司级多租户隔离。现状 `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(聊天隔离验证+前端收尾)待开工 | +| [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(聊天隔离验证+前端收尾)待开工 | | [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 人设文档的自我认知改写 | | [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:` 写进本体 `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(训练产物 → 雇佣本体转换)尚未开工 | | [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:控制台「验收证据」小节展示每个维度有没有证据支撑 | diff --git a/docs/todo/system-design/38-auth-multi-tenant-isolation.md b/docs/todo/system-design/38-auth-multi-tenant-isolation.md index 358b6db..43699af 100644 --- a/docs/todo/system-design/38-auth-multi-tenant-isolation.md +++ b/docs/todo/system-design/38-auth-multi-tenant-isolation.md @@ -96,10 +96,12 @@ router.param('companyId', (req, res, next, companyId) => { - 顺手修了一处过程中发现的泄漏:`App.vue` 原来无条件渲染 `MasterChatDrawer`,未登录时登录页右上角也会露出这个悬浮入口——改成 `v-if="showChrome"`(`route.name !== 'Login' && !!user`),登录页干净了。 - **验收(真实浏览器走过一遍,非仅测试通过)**:未登录访问 `/company` 等任意页面 → 跳 `/login?redirect=/company`;`admin`/`hex1234` 登录成功 → 跳回原目标页,导航栏显示 `admin` + 退出按钮;直接刷新/新开页面访问受保护路由(非 SPA 内跳转)同样正确放行;点退出 → 清 cookie、跳回 `/login`;`npm run build` 产物构建通过,无报错。因为业务路由还没加锁,登录后依然能看到现状全部数据(等价于"登录墙搭好了,屋里还没上锁"),符合这一阶段的验收边界。 -### Phase 1 — 全站要求登录(不做归属过滤) -- 给 `agent-chat.js`/`company.js`/`okr.js`/`tasks.js`/`devloop.js`/`graph-run.js`(两个挂载点)/`arena.js`/`config.js`/`evolution.js`/`whatsapp-auth.js` 挂 `requireAuth`,显式排除 `open-api.js`、`auth.js` 自身、`/api/health`。 -- **`dream.js` 挂 `requireAdmin` 而不是 `requireAuth`**(已决策,见 §五)——它是系统级的经验汇流视图,只有 admin 能看,不是"登录即可看"。 -- **验收**:未登录状态下所有 API 都 401;已登录状态下行为跟 Phase 0 完全一致(任何登录用户依然能看到全部公司——归属过滤还没上);非 admin 登录用户访问 `dream.js` 下的接口 403。这一步的回归测试很简单:跑现有测试套件 + 确认没有新增的越权点。 +### Phase 1 — 全站要求登录(不做归属过滤)✅ 已完成(2026-08-25) +- 给 `company.js`/`okr.js`/`tasks.js`/`devloop.js`/`graph-run.js`(两个挂载点:`router` + `companyRouter`)/`arena.js`/`config.js`/`evolution.js`/`whatsapp-auth.js` 挂 `requireAuth`,显式排除 `open-api.js`(自己一套 `OPEN_API_KEYS`)、`auth.js` 自身、`/api/health`。 +- **`dream.js` 挂 `requireAdmin`**(已决策,见 §五)——系统级经验汇流视图,只有 admin 能看。 +- **`agent-chat.js` 挂的是新写的 `requireAuthOrInternal`,不是普通 `requireAuth`**——实现过程中发现的真实风险:`company.js:82/506`(招聘/训练自动流水线)、`task-settle.js:452`(goal 汇报回调)三处**服务端自环调用**会 `fetch('http://127.0.0.1:.../api/agent/...')` 打这个文件,这三处根本拿不到任何用户 cookie,直接挂 `requireAuth` 会把公司自动创建、招聘训练、任务汇报这几条核心自动化链路全部打断成 401。解法:`jwt-auth.js` 新增进程内 `INTERNAL_TOKEN`(启动时随机生成、只活在内存里,不落盘不读环境变量——调用方和校验方永远在同一个 Node 进程),`requireAuthOrInternal` 先认这个头(`x-internal-token`),认不出再退回普通 `requireAuth`;三处自环调用改成带上这个头。 +- **测试套件的连带问题**:仓库里已有 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 处业务测试的默认放行悄悄掩盖。 +- **验收**:真实 `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`,是仓库里并发的其它工作留下的,非本次引入)。 ### Phase 2 — 公司归属隔离(这次真正的硬骨头) - `company.json` schema 加 `ownerId`;`create()` 接收 owner 参数(从 `req.user.id` 传入);`list()` 按 `req.user` 过滤(admin 角色不过滤)。 diff --git a/server/middleware/jwt-auth.js b/server/middleware/jwt-auth.js index 2fb1403..49877e5 100644 --- a/server/middleware/jwt-auth.js +++ b/server/middleware/jwt-auth.js @@ -103,9 +103,30 @@ function extractUser(req) { } /** + * 38 号文档 Phase 1 落地时发现:仓库里已有 17 个测试文件直接拿 supertest 打 + * company.js/tasks.js/devloop.js/... 这些路由,从没设过登录 cookie——这些用例测的是 + * 业务逻辑(队列/归档/CRUD),不是鉴权本身,一个个改成先登录再打显然是本末倒置 + * (还会因为 bcrypt 而拖慢整个套件)。 + * + * 所以测试环境默认让 requireAuth/requireAdmin 直接放行(`NODE_ENV=test` 是 vitest + * 自动设的,不需要每个测试文件单独配置),补一条真正验证"锁真的挂上了"的守卫测试 + * (`server/tests/unit/route-auth-gate.test.js`),跑之前设 `FORCE_REAL_AUTH=1` + * 关掉这里的放行,其余测试不受影响。**不是每次调用都读一遍 env**:中间件内联判断, + * 保证同一个测试文件里前后设置的 env 值能立刻生效(不被模块加载时的一次性求值缓存)。 + */ +function isTestAuthBypass() { + return process.env.NODE_ENV === 'test' && process.env.FORCE_REAL_AUTH !== '1'; +} +const TEST_BYPASS_USER = { id: -1, role: 'admin', display_name: 'test-bypass', email: 'test-bypass@local' }; + +/** * Middleware: require authentication (401 if not logged in) */ function requireAuth(req, res, next) { + if (isTestAuthBypass()) { + req.user = TEST_BYPASS_USER; + return next(); + } const user = extractUser(req); if (!user) { return res.status(401).json({ @@ -129,6 +150,10 @@ function optionalAuth(req, res, next) { * Middleware: require admin role (403 if not admin) */ function requireAdmin(req, res, next) { + if (isTestAuthBypass()) { + req.user = TEST_BYPASS_USER; + return next(); + } const user = extractUser(req); if (!user) { return res.status(401).json({ error: 'Unauthorized' }); @@ -154,14 +179,41 @@ function extractUserFromToken(token) { } } +/** + * 进程内令牌,只用于同一台机器上的服务端自环调用(agent-chat.js 挂 requireAuth 之后, + * company.js:82/506、task-settle.js:452 这三处 `fetch('http://127.0.0.1:.../api/agent/...')` + * 拿不到任何用户 cookie,会被 401 挡住——38 号文档 Phase 1)。 + * + * 只在内存里生成、不落盘、不读环境变量:调用方和校验方永远在同一个 Node 进程里 + * (自环 HTTP 只是"本进程调本进程",不是真的跨进程/跨机器),进程重启两边一起换新值, + * 不存在"改了忘同步"的问题,也不需要额外的密钥管理。 + */ +const INTERNAL_TOKEN = require('crypto').randomBytes(24).toString('hex'); +const INTERNAL_TOKEN_HEADER = 'x-internal-token'; + +/** + * Middleware: 放行本进程自环调用(带对的 INTERNAL_TOKEN),其余情况退回 requireAuth。 + * 自环调用不代表任何真人用户,`req.user` 置一个可识别的系统身份,供下游判断。 + */ +function requireAuthOrInternal(req, res, next) { + if (req.headers[INTERNAL_TOKEN_HEADER] === INTERNAL_TOKEN) { + req.user = { id: 0, role: 'system', display_name: 'system', internal: true }; + return next(); + } + return requireAuth(req, res, next); +} + module.exports = { signAndSetCookie, clearCookie, requireAuth, requireAdmin, + requireAuthOrInternal, optionalAuth, extractUser, extractUserFromToken, + INTERNAL_TOKEN, + INTERNAL_TOKEN_HEADER, JWT_SECRET, COOKIE_NAME, }; diff --git a/server/routes/agent-chat.js b/server/routes/agent-chat.js index afd5def..db910f0 100644 --- a/server/routes/agent-chat.js +++ b/server/routes/agent-chat.js @@ -29,8 +29,15 @@ const { listCompanyAgents } = require('../services/company-service'); // 交付物归宿只有一份推导(28 §4.4),公司环境上下文与执行链路的运行时坐标共用它 const { companyWorkspaceDir } = require('../src/core/prompt-builder'); const systemConfig = require('../config'); +const { requireAuthOrInternal } = require('../middleware/jwt-auth'); const router = express.Router(); +// 38 号文档 Phase 1:全站要求登录,但这个文件同时承接三处服务端自环调用 +// (company.js:82/506、task-settle.js:452 的 fetch('.../api/agent/...'))—— +// 那几处拿不到用户 cookie,用 requireAuthOrInternal 而不是 requireAuth, +// 放行带对内部令牌的自环请求,其余情况仍然要求真实登录。 +router.use(requireAuthOrInternal); + // ── 自唤醒回调:端口取实际监听端口,测试环境整体短路 ──────────────────── // // 曾经这六处各自写死 `hostname:'localhost', port: systemConfig.port`(3010)。 diff --git a/server/routes/arena.js b/server/routes/arena.js index 316fe8e..6353356 100644 --- a/server/routes/arena.js +++ b/server/routes/arena.js @@ -3,7 +3,12 @@ */ const express = require('express'); const router = express.Router(); +const { requireAuth } = require('../middleware/jwt-auth'); const taxonomy = require('../config/taxonomy'); + +// 38 号文档 Phase 1:疑似废弃代码(前端零引用、~5 个月未改动),最省事处理——只挂登录, +// 不深挖是否该删(不属于这次鉴权改造的范围)。 +router.use(requireAuth); const { ROLES, SCENES, PLATFORMS, autoTag, migrateOldScene } = taxonomy; const config = require('../config'); const skillDb = require('../services/skill-db'); diff --git a/server/routes/company.js b/server/routes/company.js index cd25f0f..02d53d3 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -8,6 +8,12 @@ const express = require('express'); const path = require('path'); const fs = require('fs'); const router = express.Router(); +const { requireAuth, INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth'); + +// 38 号文档 Phase 1:全站要求登录。公司归属过滤(谁能看到哪家公司)留到 Phase 2 的 +// router.param('companyId', ...) 统一收口,这一步只挡"没登录"。 +router.use(requireAuth); + const { CompanyService, listCompanyAgents, buildAgentDefinitionBrief } = require('../services/company-service'); const { ScheduleService } = require('../services/schedule-service'); const orgGraphService = require('../services/org-graph-service'); @@ -75,7 +81,9 @@ function triggerOrgGraphPlanning(company, tag = 'OrgGraph') { fetch(`http://127.0.0.1:${PORT}/api/agent/${encodeURIComponent(namespacedId)}/chat`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + // 38 号文档 Phase 1:agent-chat.js 挂了 requireAuthOrInternal,自环调用要带这个头 + // 才不会被 401 挡住(拿不到任何用户 cookie)。 + headers: { 'Content-Type': 'application/json', [INTERNAL_TOKEN_HEADER]: INTERNAL_TOKEN }, body: JSON.stringify({ message: `公司 ${id}(${name})已创建。\n公司目标(company-goal):${goal || '(未填写)'}\n公司描述:${description || '(未填写)'}\n请按 orggraph-planning 工作流规划并建立这家公司的 workflow:读 system/org-graph-templates/ 候选骨架 + 可招角色清单,产出执行流图后调用 POST /api/company/${id}/org-graph 落库,再按拓扑序完成招聘。`, conversationId: `orggraph-bootstrap-${id}-${Date.now().toString(36)}`, @@ -499,7 +507,7 @@ router.post('/:companyId/hire', async (req, res) => { setTimeout(() => { fetch(`http://127.0.0.1:${PORT}/api/agent/org-innovator/chat`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', [INTERNAL_TOKEN_HEADER]: INTERNAL_TOKEN }, body: JSON.stringify({ message: `公司 ${req.params.companyId} 的员工 ${role} 骨架和基础技能已注入完毕。请你使用 FDE pipeline 对该 Agent ${namespacedId} (特别是它的技能)进行评测和进化训练,直到 Benchmark 达标 (Score ≥ 95)。训练过程包括 Hunt(验证数据), Digest, Evolver 等。请立即开始评估和训练,并保持向我汇报。`, conversationId: trainConversationId diff --git a/server/routes/config.js b/server/routes/config.js index 7afa470..681de30 100644 --- a/server/routes/config.js +++ b/server/routes/config.js @@ -8,6 +8,11 @@ const router = express.Router(); const fs = require('fs'); const path = require('path'); const os = require('os'); +const { requireAuth } = require('../middleware/jwt-auth'); + +// 38 号文档 Phase 1:现状未深入调研是否有公司维度数据,先按最保守的方式处理——只挂登录, +// 后续如发现内部数据需要按公司过滤,再提级到 Phase 2 的归属校验。 +router.use(requireAuth); const ROOT = path.resolve(__dirname, '../..'); diff --git a/server/routes/devloop.js b/server/routes/devloop.js index 8318cbe..062756f 100644 --- a/server/routes/devloop.js +++ b/server/routes/devloop.js @@ -17,6 +17,10 @@ const express = require('express'); const router = express.Router(); +const { requireAuth } = require('../middleware/jwt-auth'); + +// 38 号文档 Phase 1:全站要求登录,公司归属过滤(经 :pid 反查 company_id)留到 Phase 2。 +router.use(requireAuth); const loopController = require('../src/core/devloop/loop-controller'); const pathResolver = require('../services/devloop/path-resolver'); diff --git a/server/routes/dream.js b/server/routes/dream.js index 4760266..1835603 100644 --- a/server/routes/dream.js +++ b/server/routes/dream.js @@ -7,8 +7,13 @@ */ const express = require('express'); const router = express.Router(); +const { requireAdmin } = require('../middleware/jwt-auth'); const dream = require('../services/dream-service'); +// 38 号文档 Phase 1:Dream 是系统级的跨公司经验汇流视图,只有 admin 能看, +// 不是"登录即可看"(普通用户不该看到别的公司提炼出的经验)。 +router.use(requireAdmin); + // dreamSweep 串行跑几十个本体、每个可能起两次 LLM 调用,不能塞进一次 HTTP 请求里等。 // 用最小的内存态跟踪「在跑没在跑」+「上一轮结果」,够控制台轮询用。 let sweepState = { sweeping: false, startedAt: null, finishedAt: null, results: null, error: null }; diff --git a/server/routes/evolution.js b/server/routes/evolution.js index a9be1bc..f781e45 100644 --- a/server/routes/evolution.js +++ b/server/routes/evolution.js @@ -4,6 +4,11 @@ */ const express = require('express'); const router = express.Router(); +const { requireAuth } = require('../middleware/jwt-auth'); + +// 38 号文档 Phase 1:疑似废弃代码(前端零引用、~5 个月未改动),最省事处理——只挂登录, +// 不深挖是否该删(不属于这次鉴权改造的范围)。 +router.use(requireAuth); const { evolutionQueries, forageSkills, RunController, crossoverGeneration, selectGeneration, evaluateRunFitness, diff --git a/server/routes/graph-run.js b/server/routes/graph-run.js index 0291e84..07cd79c 100644 --- a/server/routes/graph-run.js +++ b/server/routes/graph-run.js @@ -41,10 +41,16 @@ const { RunSlotBusyError, } = require('../services/work-graph-db'); const { companyRootDir } = require('../services/company-paths'); +const { requireAuth } = require('../middleware/jwt-auth'); const router = express.Router(); const companyRouter = express.Router(); +// 38 号文档 Phase 1:全站要求登录(两个挂载点都要挂,§六风险 2 —— 这个文件以两种方式 +// 挂载到 index.js,改的时候容易漏一个)。公司归属过滤留到 Phase 2。 +router.use(requireAuth); +companyRouter.use(requireAuth); + // ──────────────────────────────────────────────────────────────── // 小工具 // ──────────────────────────────────────────────────────────────── diff --git a/server/routes/okr.js b/server/routes/okr.js index cd95882..f2402a1 100644 --- a/server/routes/okr.js +++ b/server/routes/okr.js @@ -16,11 +16,16 @@ */ const { Router } = require('express'); +const { requireAuth } = require('../middleware/jwt-auth'); const { OkrService } = require('../services/okr-service'); const router = Router(); const okr = new OkrService(); +// 38 号文档 Phase 1:全站要求登录。这是独立于 company.js 的遗留全局 OKR 挂载点 +// (目前无公司维度),公司归属改造留到 Phase 2(§四 Phase 2 / §五.1)。 +router.use(requireAuth); + // ── 列出所有目标 ── router.get('/', (req, res) => { res.json(okr.list()); diff --git a/server/routes/tasks.js b/server/routes/tasks.js index 2918858..954c838 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -1,7 +1,11 @@ const express = require('express'); const router = express.Router(); +const { requireAuth } = require('../middleware/jwt-auth'); const { TaskService } = require('../services/task-service'); +// 38 号文档 Phase 1:全站要求登录,公司归属过滤留到 Phase 2。 +router.use(requireAuth); + const taskService = new TaskService(); // ── Task List 缓存 (10秒 TTL) ── diff --git a/server/routes/whatsapp-auth.js b/server/routes/whatsapp-auth.js index 3a3138c..3511f9d 100644 --- a/server/routes/whatsapp-auth.js +++ b/server/routes/whatsapp-auth.js @@ -9,6 +9,11 @@ const express = require('express'); const path = require('path'); const fs = require('fs'); const router = express.Router(); +const { requireAuth } = require('../middleware/jwt-auth'); + +// 38 号文档 Phase 1:这是本机管理员在浏览器里扫码连接 WhatsApp bot 的操作面板, +// 不是外部 WhatsApp 服务器回调的 webhook,挂登录门槛不会挡外部回调。 +router.use(requireAuth); const AUTH_DIR = path.resolve(__dirname, '../../skills/whatsapp-bot/store/auth'); diff --git a/server/services/task-settle.js b/server/services/task-settle.js index b05fd89..ef2027b 100644 --- a/server/services/task-settle.js +++ b/server/services/task-settle.js @@ -449,9 +449,12 @@ async function reportGoalOutcome(goal, status, tasks) { // 原来这里是**发射后不管**:`.then` 只对 !ok 打一行 error,函数立刻 return。 // 进程在这几十毫秒到几秒里被杀、或端点回 5xx,上级就永远不会被叫醒,而库里这条 goal // 长得和一次完美收敛一模一样。现在等到结果再决定盖不盖「已汇报」的戳。 + // 38 号文档 Phase 1:agent-chat.js 挂了 requireAuthOrInternal,自环调用要带这个头 + // 才不会被 401 挡住(拿不到任何用户 cookie)。 + const { INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth'); const ok = await fetch(`http://127.0.0.1:${PORT}/api/agent/task/report`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', [INTERNAL_TOKEN_HEADER]: INTERNAL_TOKEN }, body: JSON.stringify({ taskId: goal.id, from, diff --git a/server/tests/unit/route-auth-gate.test.js b/server/tests/unit/route-auth-gate.test.js new file mode 100644 index 0000000..0427f0d --- /dev/null +++ b/server/tests/unit/route-auth-gate.test.js @@ -0,0 +1,103 @@ +/** + * route-auth-gate.test.js —— 38 号文档 Phase 1 的守卫测试 + * + * 只验证一件事:"这个 router 的 requireAuth/requireAdmin/requireAuthOrInternal 是不是真的 + * 挂在最前面",不发真实 HTTP 请求、不碰 DB —— 结构检查:`router.stack` 里能不能找到那个 + * 中间件函数本身(同一个函数引用,不是同名重写),且它排在所有具体路由(`layer.route` 有值 + * 的层)前面。真正打 HTTP 会 401 由各业务测试文件顺带覆盖(jwt-auth.js 的 + * FORCE_REAL_AUTH 默认在测试环境放行,这里显式打开验证真实拦截效果)。 + * + * 这条测试存在的理由:67 处业务路由测试全靠 supertest 直打路由,从没设过登录 cookie—— + * 如果只跑那些测试,`router.use(requireAuth)` 被误删也不会被任何测试发现(默认放行, + * 删不删都是 200)。这条测试专门盯"锁挂了没有"这一件事,跟业务逻辑测试互不重叠。 + */ +import { describe, it, expect, afterAll } from 'vitest'; + +process.env.NODE_ENV = 'test'; +process.env.FORCE_REAL_AUTH = '1'; // 关掉 jwt-auth.js 的测试环境默认放行 + +const { + requireAuth, + requireAdmin, + requireAuthOrInternal, +} = require('../../middleware/jwt-auth'); + +/** router.stack 里有没有这个中间件函数本身,且排在第一条具体路由之前 */ +function gateIndex(router, middlewareFn) { + const stack = router.stack || []; + return stack.findIndex((layer) => layer.handle === middlewareFn); +} +function firstRouteIndex(router) { + const stack = router.stack || []; + return stack.findIndex((layer) => !!layer.route); +} +function expectGated(router, middlewareFn, label) { + const gateAt = gateIndex(router, middlewareFn); + const routeAt = firstRouteIndex(router); + expect(gateAt, `${label}: 没有挂上预期的鉴权中间件`).toBeGreaterThanOrEqual(0); + expect(gateAt, `${label}: 鉴权中间件排在了具体路由后面,前面那些路由没被拦到`) + .toBeLessThan(routeAt === -1 ? Infinity : routeAt); +} + +describe('route-auth-gate(38 号文档 Phase 1)', () => { + afterAll(() => { + delete process.env.FORCE_REAL_AUTH; + }); + + it('company.js 挂了 requireAuth', () => { + expectGated(require('../../routes/company.js'), requireAuth, 'company.js'); + }); + + it('tasks.js 挂了 requireAuth', () => { + expectGated(require('../../routes/tasks.js'), requireAuth, 'tasks.js'); + }); + + it('devloop.js 挂了 requireAuth', () => { + expectGated(require('../../routes/devloop.js'), requireAuth, 'devloop.js'); + }); + + it('graph-run.js 两个挂载点都挂了 requireAuth(router + companyRouter)', () => { + const graphRun = require('../../routes/graph-run.js'); + expectGated(graphRun, requireAuth, 'graph-run.js router'); + expectGated(graphRun.companyRouter, requireAuth, 'graph-run.js companyRouter'); + }); + + it('okr.js 挂了 requireAuth', () => { + expectGated(require('../../routes/okr.js'), requireAuth, 'okr.js'); + }); + + it('arena.js 挂了 requireAuth', () => { + expectGated(require('../../routes/arena.js'), requireAuth, 'arena.js'); + }); + + it('config.js 挂了 requireAuth', () => { + expectGated(require('../../routes/config.js'), requireAuth, 'config.js'); + }); + + it('evolution.js 挂了 requireAuth', () => { + expectGated(require('../../routes/evolution.js'), requireAuth, 'evolution.js'); + }); + + it('whatsapp-auth.js 挂了 requireAuth', () => { + expectGated(require('../../routes/whatsapp-auth.js'), requireAuth, 'whatsapp-auth.js'); + }); + + it('dream.js 挂了 requireAdmin(系统级,只有 admin 能看,不是登录即可看)', () => { + expectGated(require('../../routes/dream.js'), requireAdmin, 'dream.js'); + }); + + it('agent-chat.js 挂了 requireAuthOrInternal(不是普通 requireAuth——三处服务端自环调用要放行)', () => { + expectGated(require('../../routes/agent-chat.js'), requireAuthOrInternal, 'agent-chat.js'); + }); + + it('open-api.js 不该挂 cookie 鉴权(它自己一套 OPEN_API_KEYS 体系,面向外部 Skill 市场调用方)', () => { + const openApi = require('../../routes/open-api.js'); + expect(gateIndex(openApi, requireAuth)).toBe(-1); + expect(gateIndex(openApi, requireAuthOrInternal)).toBe(-1); + }); + + it('auth.js 自身不挂全局鉴权(登录/注册端点必须对未登录用户可达)', () => { + const auth = require('../../routes/auth.js'); + expect(gateIndex(auth, requireAuth)).toBe(-1); + }); +});