Commit 6f48c19483dc5b6d2dca09ebdd7a1a6a80a31558

Authored by yibo
1 parent 86a45035

feat(auth): Phase 0 —— 种子账号 + 登录页 + 路由守卫跑通(38 号文档)

远程部署前置需求:登录鉴权 + 公司级多租户隔离。后端 auth.js/jwt-auth.js(注册/登录/
JWT-cookie/OAuth)本来就已经可用,这次只是把它接到前端。

- scripts/seed-admin-user.js(npm run seed:admin):幂等种子脚本,建出 admin/hex1234,
  role=admin。
- web/src/composables/useAuth.js + views/Login.vue:登录态 + 登录页。
- router/index.js 全局 beforeEach:未登录跳 /login?redirect=...,登录后带着原目标跳回。
- main.js 全局 401 拦截器:cookie 中途过期同样兜住,不止页面切换那一刻。
- AppNav.vue:显示用户名 + 退出登录。
- 顺手修:App.vue 原来无条件渲染 MasterChatDrawer,登录页也会露出这个悬浮入口,
  改成登录且非登录页才显示。

业务路由还没加锁(Phase 1 的事),登录后依然能看到现状全部数据,符合这一阶段的验收边界。
真实浏览器走过一遍全部流程(跳转/登录/跳回/直接刷新受保护路由/退出),npm run build 通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/README.md
... ... @@ -49,6 +49,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
49 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 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 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 已完成(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(聊天隔离验证+前端收尾)待开工 |
52 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 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 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 0 → 100644
  1 +# 38 · 登录鉴权 + 公司级多租户隔离 — 实现计划
  2 +
  3 +> **日期**:2026-08-25
  4 +> **触发**:代码要部署到远程服务器,现状零鉴权——任何人打开域名直接进系统,`:companyId` 全部路由零权限校验,谁都能看/改任何一家公司。用户要求:登录注册(种子一个默认账号)+ 全站 cookie 鉴权 + 每个用户只看得到自己的 company(system agent 全局共享不受影响)+ 每个用户的聊天记录互相隔离。
  5 +> **前置结论(已在同一会话里做过一次调研,详见对话记录,不重复展开)**:这不是从零建鉴权系统。`server/routes/auth.js` + `server/middleware/jwt-auth.js` 已经是一套完整可用的注册/登录/JWT-cookie/GitHub+Google OAuth 实现,`/ws/claude` 的 WebSocket 鉴权也已经做完了。真正缺的是两件事:①把这套已有的锁**挂到**目前完全不设防的路由上;②公司归属这个概念**从 0 到 1** 新建(`company.json` 目前连 `owner` 字段都没有)。
  6 +> **已拍板的产品决策**:存量公司(`data/companies/` 下现有的全部)迁移时统一划给种子默认账号;种子账号 `admin` / `hex1234`(首次部署后建议改密,见 §七)。
  7 +> **状态**:实现计划,未开工。
  8 +
  9 +---
  10 +
  11 +## 一、现状盘点(本次调研的精确定位,含文件:行号)
  12 +
  13 +### 1.1 已经有、不用动的部分
  14 +- **认证核心**:`server/routes/auth.js`(279 行)—— `POST /register`(`bcrypt.hash`,`SALT_ROUNDS`)、`POST /login`(`bcrypt.compare`)、`POST /logout`、`GET /me`、GitHub/Google OAuth 全套,真实可用。
  15 +- **JWT + Cookie 中间件**:`server/middleware/jwt-auth.js` —— `signAndSetCookie`(httpOnly、`sameSite:'lax'`、生产环境 `secure:true`、7 天过期、cookie 名 `lemon_token`)、`extractUser`(先读 cookie 再 fallback `Authorization: Bearer`)、`requireAuth`/`optionalAuth`/`requireAdmin` 三个中间件。生产环境 `JWT_SECRET` 未设直接拒绝启动(第 24-33 行),开发环境有兜底密钥但每次启动打警告。
  16 +- **用户表**:`server/services/user-db.js:37-51`,`users` 表已有 `id/email/password_hash/display_name/avatar_url/provider/provider_id/github_login/role/api_key/created_at/last_login`,字段齐全,不用改 schema。
  17 +- **WebSocket 鉴权**:`server/routes/claude-chat-ws.js:396-411` 握手阶段解析 `Cookie` header、验证 JWT,未授权直接 close(4001)。同源部署(Vite dev 代理 `/api`+`/ws` 到 :3010,生产环境同一个 Express 进程托管前后端)下浏览器会自动带上 cookie,前端不需要为 WS 连接单独处理鉴权。
  18 +- **已经挂了 `requireAuth`/`optionalAuth` 的路由**:`claude-chat.js`、`innovator.js`、`master-chat.js`、`users.js`(仅 admin)——这几个文件里 `req.user.id` 的用法(尤其 `claude-chat.js` 用 `req.user.id` 隔离上传目录 + 每个 session 校验 `session.userId !== String(req.user.id)`)是一份**现成的、已经做对的参考实现**,公司归属校验直接照这个模式抄,不用重新设计。
  19 +
  20 +### 1.2 完全零防护的部分
  21 +`server/index.js:82-99` 挂载的路由里,**除上面 4 个之外全部零 `requireAuth`**:`agent-chat.js`、`company.js`、`okr.js`、`tasks.js`、`devloop.js`、`graph-run.js`(含它在 `/api/company` 下的第二个挂载点 `.companyRouter`,`server/routes/graph-run.js:571`)、`arena.js`、`config.js`、`evolution.js`、`dream.js`、`whatsapp-auth.js`。`open-api.js` 是个例外——它自己有一套独立的 `OPEN_API_KEYS` 鉴权(面向外部 Skill 市场调用方),**必须显式排除**在 cookie 鉴权闸门之外,不能被顺手扫进去。
  22 +
  23 +### 1.3 公司归属:真正的 0→1
  24 +`server/services/company-service.js` 全程没有 `owner`/`user_id` 概念:
  25 +- `get(companyId)`(约 300 行)直接读 `data/companies/{id}/company.json`,`list()`(308 行起)无条件遍历 `DATA_DIR` 下所有目录返回全部公司。
  26 +- `create({id, name, goal, description})`(243 行)不接收 owner 参数。
  27 +- **没有 `companies` SQL 表**——公司身份完全是文件系统 + `company.json`,不是 DB 行。这点利好这次改造:加 `ownerId` 字段是往一个 JSON 文件里加一个键,不需要写 SQL migration。
  28 +
  29 +`:companyId` 作为路由参数被信任的范围比"只有 `/list`"大得多:`company.js` 一个文件就有 50+ 条 `/:companyId/...` 路由(goals/tasks/hire/fire/org-graph/pipelines/files/...)。
  30 +
  31 +### 1.4 两个容易漏掉的边角
  32 +- **`server/routes/devloop.js`**:路由用的是 `/projects/:pid/...`,**不是** `:companyId`——URL 上看不出公司归属。但项目创建时强制要求 `company_id`(`devloop.js:46-48`),`path-resolver.js` 也按 `pid → {root_path, mode, company_id}` 缓存(`path-resolver.js:74,87,218-219,317`)——归属信息在,只是要多一跳:校验 `:pid` 时得先查出它的 `company_id`,再判断这个 company 是不是当前用户的,不能直接复用 `company.js` 那种"路由参数本身就是 companyId"的中间件写法。
  33 +- **`server/routes/okr.js`**(挂载于 `/api/okr`,前端消费方是 `OkrDashboard.vue`)是一套**独立的、目前完全没有公司维度的全局 OKR 视图**——`OkrService` 类本身支持按 `companyId` 分目录存储(`okr-service.js:28-33`),但 `okr.js:20` 实例化时是 `new OkrService()`,**没传 companyId**,落在"向后兼容"的全局模式,读写的是 `data/okr/`,跟任何公司都不挂钩。这是一个此前就存在、这次调研才浮出水面的独立缺口,不是这次改造引入的——要不要把它并入这次的公司隔离范围,见 §六。
  34 +
  35 +---
  36 +
  37 +## 二、目标与非目标
  38 +
  39 +**目标**:
  40 +1. 未登录访问任何页面 → 跳转登录页;登录后正常使用。
  41 +2. 每个普通用户只能看到/操作自己名下的 company(含其 goals/tasks/pipelines/devloop 项目/graph-runs/聊天记录)。
  42 +3. `system/agents/` 全局共享,不受影响(本来就是文件系统级共享,天然满足,见 §一验证过)。
  43 +4. 种子一个默认账号 `admin`,密码 `hex1234`,存量公司全部归它。
  44 +
  45 +**非目标**(这次不做,未来需要再单独立项):
  46 +- 不做团队/组织级共享(一个 company 只属于一个 user,不支持"邀请协作者")。
  47 +- 不重新设计 GitHub/Google OAuth(已经能用,保持现状,登录页并列展示"账号密码登录"和"第三方登录"两个入口即可)。
  48 +- 不处理 `/api/open/*` 的 Skill 市场访问控制(它自己的 `OPEN_API_KEYS` 体系是独立需求)。
  49 +
  50 +---
  51 +
  52 +## 三、实现方案
  53 +
  54 +### 3.1 归属校验的统一收口模式
  55 +每个持有 `:companyId` 的 router 用 `router.param('companyId', ...)` 收口一次,而不是逐条路由里手写检查——这样"新增一条路由忘了查权限"这类问题在写路由的时候就不可能发生(少了一步就报错,不是少了一步就悄悄放行):
  56 +
  57 +```js
  58 +router.param('companyId', (req, res, next, companyId) => {
  59 + const company = companyService.get(companyId);
  60 + if (!company) return res.status(404).json({ error: `公司 ${companyId} 不存在` });
  61 + if (req.user.role !== 'admin' && company.ownerId !== req.user.id) {
  62 + return res.status(404).json({ error: `公司 ${companyId} 不存在` }); // 404 不是 403:不暴露"这个 id 存在但不是你的"
  63 + }
  64 + req.company = company;
  65 + next();
  66 +});
  67 +```
  68 +返回 404 而不是 403 是刻意的:403 会告诉攻击者"这个 company id 是有效的,只是你无权访问",等于把 id 空间泄漏出去;未登录/无权都统一表现为"不存在"。
  69 +
  70 +`company.js`、`graph-run.js`(两个挂载点都要加)都走这个模式。`okr.js`/`tasks.js` 目前不接 `:companyId` 路由参数(见 §一.4),需要看 §六 的决定再定具体接入方式。
  71 +
  72 +### 3.2 `devloop.js` 的间接校验
  73 +`router.param('pid', ...)` 里先用 `path-resolver` 查出 `company_id`,再复用 3.1 同一份归属判断逻辑(抽成共享函数,不要复制一份判断代码到两个地方)。
  74 +
  75 +### 3.3 前端
  76 +- 新增登录页(账号密码表单 + 第三方登录按钮,直接调用已有的 `/api/auth/login`、`/api/auth/github`)。
  77 +- `web/src/router/index.js` 加全局 `beforeEach`:调 `GET /api/auth/me`(`optionalAuth`,不 401)判断登录态,未登录且目标路由非登录页 → 重定向登录页。
  78 +- 一个全局的 fetch/axios 拦截器:收到 401 → 清本地状态、跳登录页(避免每个页面各自处理一次 401)。
  79 +- 退出登录入口(调 `POST /api/auth/logout` 清 cookie)。
  80 +
  81 +### 3.4 种子账号
  82 +不新增脚本,直接复用已有的 `POST /api/auth/register`(或等价的一次性 seed 脚本包一层 `userDb.createUser` + `bcrypt.hash('hex1234', SALT_ROUNDS)`),部署时跑一次。
  83 +
  84 +### 3.5 存量数据迁移
  85 +一次性脚本:遍历 `data/companies/*/company.json`,无 `ownerId` 字段的统一写入种子账号的 `user.id`。脚本本身是幂等的(`ownerId` 已存在就跳过),可以放心重复跑。
  86 +
  87 +---
  88 +
  89 +## 四、分 Phase 实施计划
  90 +
  91 +每个 Phase 独立可验收,不依赖后一个 Phase 就能上线(参考 `27-p2-org-graph.md` 的分阶段惯例)。
  92 +
  93 +### Phase 0 — 种子账号 + 登录页跑通(不改任何业务路由)✅ 已完成(2026-08-25)
  94 +- `scripts/seed-admin-user.js`(`npm run seed:admin`)——幂等,建出 `admin`/`hex1234`、`role=admin`。已在本机跑过,账号 `id=396`。
  95 +- 前端:`web/src/views/Login.vue`(登录页)+ `web/src/composables/useAuth.js`(登录态 + `checkAuth`/`login`/`logout`/`clearAuth`)+ `router/index.js` 的 `beforeEach` 守卫(未登录 → 跳 `/login?redirect=...`,登录后带着原目标跳回)+ `main.js` 的全局 401 拦截器(中途 cookie 过期同样兜住,不止页面切换那一刻)+ `AppNav.vue` 加了用户名/退出按钮。
  96 +- 顺手修了一处过程中发现的泄漏:`App.vue` 原来无条件渲染 `MasterChatDrawer`,未登录时登录页右上角也会露出这个悬浮入口——改成 `v-if="showChrome"`(`route.name !== 'Login' && !!user`),登录页干净了。
  97 +- **验收(真实浏览器走过一遍,非仅测试通过)**:未登录访问 `/company` 等任意页面 → 跳 `/login?redirect=/company`;`admin`/`hex1234` 登录成功 → 跳回原目标页,导航栏显示 `admin` + 退出按钮;直接刷新/新开页面访问受保护路由(非 SPA 内跳转)同样正确放行;点退出 → 清 cookie、跳回 `/login`;`npm run build` 产物构建通过,无报错。因为业务路由还没加锁,登录后依然能看到现状全部数据(等价于"登录墙搭好了,屋里还没上锁"),符合这一阶段的验收边界。
  98 +
  99 +### Phase 1 — 全站要求登录(不做归属过滤)
  100 +- 给 `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`。
  101 +- **`dream.js` 挂 `requireAdmin` 而不是 `requireAuth`**(已决策,见 §五)——它是系统级的经验汇流视图,只有 admin 能看,不是"登录即可看"。
  102 +- **验收**:未登录状态下所有 API 都 401;已登录状态下行为跟 Phase 0 完全一致(任何登录用户依然能看到全部公司——归属过滤还没上);非 admin 登录用户访问 `dream.js` 下的接口 403。这一步的回归测试很简单:跑现有测试套件 + 确认没有新增的越权点。
  103 +
  104 +### Phase 2 — 公司归属隔离(这次真正的硬骨头)
  105 +- `company.json` schema 加 `ownerId`;`create()` 接收 owner 参数(从 `req.user.id` 传入);`list()` 按 `req.user` 过滤(admin 角色不过滤)。
  106 +- 存量数据迁移脚本跑一遍(§3.5),全部划给种子账号。
  107 +- `company.js`/`graph-run.js` 两个挂载点接入 3.1 的 `router.param('companyId', ...)` 统一校验。
  108 +- `devloop.js` 接入 3.2 的间接校验。
  109 +- **`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` 挂载点。
  110 +- **验收**:种子账号登录看到全部存量公司(无缝衔接);新注册账号登录看到空列表;新账号尝试直接拼 URL 访问种子账号的 company id → 404;新建的 company 只有创建者自己看得到;`/okr` 页面按公司切换后数据不串。这一步要重点测"漏了一条路由"这类问题——建议写一个守卫测试,遍历 `company.js`/`graph-run.js`/`okr.js` 里的路由定义,断言每一条都经过了 `router.param('companyId')` 这个收口点(而不是逐条手工检查,人工抽查容易漏)。
  111 +
  112 +### Phase 3 — 聊天记录隔离验证 + 前端收尾体验
  113 +- LemonBoss 侧会话(`data/companies/{id}/agents/{agentId}/{convId}`)理论上 Phase 2 做完就自动隔离了(会话挂在 company 目录下),这一步只是**验证**,不是新写代码:确认没有遗漏的、绕过 company 归属校验就能读会话的接口(比如某个直接按 `convId` 查、不经过 `companyId` 校验的路由)。
  114 +- 前端补全登出后的空状态、加载态、错误提示;确认 18 个路由页面在未登录/无权访问公司时的表现符合预期。
  115 +- **验收**:跨账号真实操作一遍——种子账号和新账号分别登录,交叉尝试访问对方的会话记录,确认全部拒绝。
  116 +
  117 +---
  118 +
  119 +## 五、开放问题(已拍板)
  120 +
  121 +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')` 收口时**顺带**就隔离了,不算独立工作量。
  122 +2. **首次登录是否强制改密 → 不需要。** 种子密码 `hex1234` 保持明文可用,不加强制改密流程。
  123 +3. **`arena.js`/`evolution.js` → 确认为疑似废弃代码,不投入隔离工作量。** 核实结果:`web/src/` 全局搜索零处引用 `/api/arena`(注意与 `open-api.js` 下仍在用的 `/api/open/arena/leaderboard` 是两个不同文件,后者继续保留)、零处引用 `/api/evolution`;两个文件最后一次改动都停在 `2026-03-23`(比仓库其余部分老得多)。Phase 1 按最省事的方式处理:一律挂 `requireAuth`(登录即可访问,不单独判断是否该删),不额外调查、不纳入这次范围——真要确认能不能删是另一件事,不属于这次鉴权改造。
  124 +4. **`dream.js` → 系统级,只有 admin 能看。** 已更新 §四 Phase 1:挂 `requireAdmin` 而不是 `requireAuth`。
  125 +5. **`config.js` 归属未定**:现状调研没有深入,且用户这轮没有明确指示。Phase 1 先按 `arena.js`/`evolution.js` 同款处理——挂 `requireAuth`,不做归属过滤,留到看内部是否有 company 维度数据时再决定要不要提级。
  126 +
  127 +---
  128 +
  129 +## 六、风险
  130 +
  131 +1. **归属校验漏挂**——见 §一.2/§四 Phase 2 的守卫测试建议,这是本次最大的风险点,一条路由漏挂 = 直接的越权数据读写。
  132 +2. **`graph-run.js` 的双挂载点**——`server/index.js:97-98` 同一个文件以两种方式挂载(`/api/graph-runs` 全量 + `/api/company` 下的 `.companyRouter` 子集),改的时候容易只改一处。
  133 +3. **`devloop.js` 的间接校验容易被绕过**——如果新增一条 devloop 路由时忘了走 `:pid` 收口点(比如直接接收 `company_id` 作为 body 参数而不是从已归属校验过的 `:pid` 反查),归属检查会被绕过。建议 code review 时专门盯这一类新路由。
  134 +4. **admin 角色的越权范围**——`requireAdmin`/`role==='admin'` 目前语义是"看得到所有公司",这次种子账号本身就是 admin,等于事实上的超级账号;如果以后要开放更多真实用户注册,需要明确"什么样的用户会被设成 admin"这条边界,避免误操作把普通用户升成了能看所有公司的角色。
... ...
package.json
... ... @@ -23,7 +23,8 @@
23 23 "spec:extract": "node scripts/extract-spec.js",
24 24 "spec:check": "node scripts/extract-spec.js --check",
25 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 28 },
28 29 "keywords": [
29 30 "agent",
... ...
scripts/seed-admin-user.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * seed-admin-user.js — 建出登录用的默认种子账号
  4 + *
  5 + * 依据: docs/todo/system-design/38-auth-multi-tenant-isolation.md §3.4 / Phase 0
  6 + *
  7 + * 账号: admin / hex1234(登录标识存在 users.email 列,不要求真实邮箱格式——
  8 + * 直接走 userDb.createUser 建号,不经过 /api/auth/register 那道邮箱格式校验)。
  9 + *
  10 + * 幂等: admin 已存在就跳过,可重复执行。
  11 + *
  12 + * 用法:
  13 + * node scripts/seed-admin-user.js
  14 + */
  15 +
  16 +const path = require('path');
  17 +// bcryptjs 是 server/package.json 的依赖,只装在 server/node_modules 下(三个 workspace
  18 +// 各自独立 npm install,不是 pnpm 提升到根目录),scripts/ 在这层目录树里够不着它,
  19 +// 用 require.resolve 的 paths 选项显式指去 server/ 底下找。
  20 +const bcrypt = require(require.resolve('bcryptjs', { paths: [path.join(__dirname, '..', 'server')] }));
  21 +const userDb = require('../server/services/user-db');
  22 +
  23 +const SEED_EMAIL = 'admin';
  24 +const SEED_PASSWORD = 'hex1234';
  25 +const SEED_DISPLAY_NAME = 'admin';
  26 +const SALT_ROUNDS = 10;
  27 +
  28 +async function main() {
  29 + const existing = userDb.findByEmail(SEED_EMAIL);
  30 + if (existing) {
  31 + console.log(`[seed-admin-user] 账号 "${SEED_EMAIL}" 已存在(id=${existing.id}),跳过`);
  32 + return;
  33 + }
  34 +
  35 + const passwordHash = await bcrypt.hash(SEED_PASSWORD, SALT_ROUNDS);
  36 + const user = userDb.createUser({
  37 + email: SEED_EMAIL,
  38 + passwordHash,
  39 + displayName: SEED_DISPLAY_NAME,
  40 + provider: 'local',
  41 + });
  42 +
  43 + // role 列默认是 'user'(user-db.js 建表语句),种子账号需要 admin 权限
  44 + // 才能看到全部存量公司(38 号文档 §一.4 / §四 Phase 2)——createUser 不接收
  45 + // role 参数,直接改这一行。
  46 + userDb.getDb().prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', user.id);
  47 +
  48 + console.log(`[seed-admin-user] 已创建账号 "${SEED_EMAIL}"(id=${user.id}, role=admin)`);
  49 +}
  50 +
  51 +main().catch((err) => {
  52 + console.error('[seed-admin-user] 失败:', err.message);
  53 + process.exit(1);
  54 +});
... ...
web/src/App.vue
... ... @@ -3,14 +3,23 @@
3 3 <div class="app-main">
4 4 <router-view />
5 5 </div>
6   - <MasterChatDrawer />
  6 + <MasterChatDrawer v-if="showChrome" />
7 7 </div>
8 8 </template>
9 9  
10 10 <script setup>
  11 +import { computed } from 'vue'
  12 +import { useRoute } from 'vue-router'
11 13 import { useTheme } from '@/composables/useTheme'
  14 +import { useAuth } from '@/composables/useAuth'
12 15 import MasterChatDrawer from '@/components/MasterChatDrawer.vue'
13 16 useTheme()
  17 +
  18 +// 登录页不该露出一个能唤起对话的入口——未登录时点了也只会撞 401,且视觉上暴露了
  19 +// "系统里有个东西叫 Master" 这类不必要的信息。
  20 +const route = useRoute()
  21 +const { user } = useAuth()
  22 +const showChrome = computed(() => route.name !== 'Login' && !!user.value)
14 23 </script>
15 24  
16 25 <style>
... ...
web/src/components/AppNav.vue
... ... @@ -78,6 +78,10 @@
78 78 {{ themeIcon }}
79 79 </button>
80 80 <span class="nav-version">v5.0</span>
  81 + <div class="nav-user" v-if="user">
  82 + <span class="nav-user-name">{{ user.display_name || user.email }}</span>
  83 + <button class="nav-logout-btn" @click="handleLogout" title="退出登录">退出</button>
  84 + </div>
81 85 </div>
82 86  
83 87 <!-- New Company Modal -->
... ... @@ -123,11 +127,18 @@ import { useRoute, useRouter } from &#39;vue-router&#39;
123 127 import axios from 'axios'
124 128 import { useTheme } from '@/composables/useTheme'
125 129 import { useCompanies } from '@/composables/useCompanies'
  130 +import { useAuth } from '@/composables/useAuth'
126 131  
127 132 const route = useRoute()
128 133 const router = useRouter()
129 134 const { themeMode, cycleTheme } = useTheme()
130 135 const { companies, loadCompanies, activeCompanyId, setActiveCompany } = useCompanies()
  136 +const { user, logout } = useAuth()
  137 +
  138 +async function handleLogout() {
  139 + await logout()
  140 + router.push({ name: 'Login' })
  141 +}
131 142  
132 143 const showDropdown = ref(false)
133 144 const showNewCompanyModal = ref(false)
... ... @@ -256,6 +267,14 @@ async function submitNewCompany() {
256 267 .theme-btn:hover { background: var(--bg-surface-hover); transform: scale(1.1); }
257 268 .nav-version { font-size: 10px; color: var(--text-faint); padding: 2px 6px; border: 1px solid var(--border-primary); border-radius: 4px; font-weight:600;}
258 269  
  270 +.nav-user { display: flex; align-items: center; gap: 8px; padding-left: 12px; border-left: 1px solid var(--border-primary); }
  271 +.nav-user-name { font-size: 12px; color: var(--text-secondary); }
  272 +.nav-logout-btn {
  273 + font-size: 12px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--border-primary);
  274 + background: var(--bg-surface); color: var(--text-secondary); cursor: pointer; transition: all 0.2s;
  275 +}
  276 +.nav-logout-btn:hover { background: var(--bg-surface-hover); color: var(--text-primary); }
  277 +
259 278 /* Modal */
260 279 .nav-modal-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 2000; backdrop-filter: blur(4px); }
261 280 .nav-modal-content { background: var(--bg-primary); border-radius: 12px; width: 400px; padding: 20px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); border: 1px solid var(--border-primary); }
... ...
web/src/composables/useAuth.js 0 → 100644
  1 +import { ref } from 'vue'
  2 +import axios from 'axios'
  3 +
  4 +const user = ref(null)
  5 +const isChecked = ref(false) // 是否已经问过一次 /api/auth/me,避免路由守卫重复请求
  6 +let checkPromise = null
  7 +
  8 +export function useAuth() {
  9 + /** 查一次当前登录态,结果缓存到下次显式 refresh。未登录返回 null,不算错误。 */
  10 + async function checkAuth({ force = false } = {}) {
  11 + if (isChecked.value && !force) return user.value
  12 + if (checkPromise && !force) return checkPromise
  13 +
  14 + checkPromise = axios.get('/api/auth/me')
  15 + .then(({ data }) => {
  16 + user.value = data.user || null
  17 + isChecked.value = true
  18 + return user.value
  19 + })
  20 + .catch(() => {
  21 + user.value = null
  22 + isChecked.value = true
  23 + return null
  24 + })
  25 + .finally(() => { checkPromise = null })
  26 +
  27 + return checkPromise
  28 + }
  29 +
  30 + async function login(email, password) {
  31 + const { data } = await axios.post('/api/auth/login', { email, password })
  32 + user.value = data.user
  33 + isChecked.value = true
  34 + return data.user
  35 + }
  36 +
  37 + async function logout() {
  38 + try { await axios.post('/api/auth/logout') } catch { /* cookie 清不掉也要清本地状态 */ }
  39 + user.value = null
  40 + isChecked.value = true
  41 + }
  42 +
  43 + /** 401 拦截器触发时调用:本地登录态失效,不用再打一次 /me 去确认 */
  44 + function clearAuth() {
  45 + user.value = null
  46 + isChecked.value = true
  47 + }
  48 +
  49 + return { user, isChecked, checkAuth, login, logout, clearAuth }
  50 +}
... ...
web/src/main.js
1 1 import { createApp } from 'vue'
  2 +import axios from 'axios'
2 3 import App from './App.vue'
3 4 import router from './router'
  5 +import { useAuth } from './composables/useAuth'
  6 +
  7 +// 38 号文档 Phase 0:任意请求收到 401 → 本地登录态清掉、跳登录页。
  8 +// 覆盖「cookie 在中途过期」这类路由守卫放行之后才发生的情况,不止是页面切换那一刻。
  9 +axios.interceptors.response.use(
  10 + (res) => res,
  11 + (err) => {
  12 + if (err.response?.status === 401 && router.currentRoute.value.name !== 'Login') {
  13 + useAuth().clearAuth()
  14 + router.push({ name: 'Login', query: { redirect: router.currentRoute.value.fullPath } })
  15 + }
  16 + return Promise.reject(err)
  17 + }
  18 +)
4 19  
5 20 const app = createApp(App)
6 21 app.use(router)
... ...
web/src/router/index.js
1 1 import { createRouter, createWebHistory } from 'vue-router'
  2 +import { useAuth } from '@/composables/useAuth'
2 3  
3 4 const routes = [
4 5 {
  6 + path: '/login',
  7 + name: 'Login',
  8 + component: () => import('@/views/Login.vue'),
  9 + },
  10 + {
5 11 path: '/',
6 12 redirect: '/agent/master'
7 13 },
... ... @@ -109,4 +115,19 @@ const router = createRouter({
109 115 routes,
110 116 })
111 117  
  118 +// Phase 0(38 号文档):未登录访问任何非登录页 → 跳登录页;已登录访问登录页 → 跳回首页
  119 +// (或 redirect query 指回的目标)。这一步还不做「你能不能看这个 company」的归属校验
  120 +// (Phase 2 才做),只判断「你登录了没有」。
  121 +router.beforeEach(async (to) => {
  122 + if (to.name === 'Login') return true
  123 +
  124 + const { user, checkAuth } = useAuth()
  125 + await checkAuth()
  126 +
  127 + if (!user.value) {
  128 + return { name: 'Login', query: { redirect: to.fullPath } }
  129 + }
  130 + return true
  131 +})
  132 +
112 133 export default router
... ...
web/src/views/Login.vue 0 → 100644
  1 +<template>
  2 + <div class="login-page">
  3 + <form class="login-card" @submit.prevent="submit">
  4 + <div class="login-brand">
  5 + <span class="login-logo">🧬</span>
  6 + <span class="login-title">LemonBoss</span>
  7 + </div>
  8 +
  9 + <div class="form-group">
  10 + <label>账号</label>
  11 + <input v-model="email" type="text" autocomplete="username" placeholder="账号" autofocus />
  12 + </div>
  13 + <div class="form-group">
  14 + <label>密码</label>
  15 + <input v-model="password" type="password" autocomplete="current-password" placeholder="密码" />
  16 + </div>
  17 +
  18 + <p v-if="error" class="login-error">{{ error }}</p>
  19 +
  20 + <button class="login-submit" type="submit" :disabled="loading || !email || !password">
  21 + {{ loading ? '登录中...' : '登录' }}
  22 + </button>
  23 + </form>
  24 + </div>
  25 +</template>
  26 +
  27 +<script setup>
  28 +import { ref } from 'vue'
  29 +import { useRoute, useRouter } from 'vue-router'
  30 +import { useAuth } from '@/composables/useAuth'
  31 +
  32 +const route = useRoute()
  33 +const router = useRouter()
  34 +const { login } = useAuth()
  35 +
  36 +const email = ref('')
  37 +const password = ref('')
  38 +const error = ref('')
  39 +const loading = ref(false)
  40 +
  41 +async function submit() {
  42 + error.value = ''
  43 + loading.value = true
  44 + try {
  45 + await login(email.value.trim(), password.value)
  46 + const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
  47 + router.replace(redirect)
  48 + } catch (e) {
  49 + error.value = e.response?.data?.error || '登录失败,请检查账号密码'
  50 + } finally {
  51 + loading.value = false
  52 + }
  53 +}
  54 +</script>
  55 +
  56 +<style scoped>
  57 +.login-page {
  58 + min-height: 100vh;
  59 + display: flex;
  60 + align-items: center;
  61 + justify-content: center;
  62 + background: var(--bg-primary);
  63 +}
  64 +
  65 +.login-card {
  66 + width: 320px;
  67 + padding: 32px;
  68 + border-radius: 12px;
  69 + background: var(--bg-secondary);
  70 + border: 1px solid var(--border-primary);
  71 + display: flex;
  72 + flex-direction: column;
  73 + gap: 16px;
  74 +}
  75 +
  76 +.login-brand {
  77 + display: flex;
  78 + align-items: center;
  79 + gap: 8px;
  80 + justify-content: center;
  81 + margin-bottom: 8px;
  82 +}
  83 +.login-logo { font-size: 24px; }
  84 +.login-title { font-size: 18px; font-weight: 700; color: var(--text-primary); }
  85 +
  86 +.form-group { display: flex; flex-direction: column; gap: 6px; }
  87 +.form-group label { font-size: 13px; color: var(--text-secondary); }
  88 +.form-group input {
  89 + padding: 10px 12px;
  90 + border-radius: 8px;
  91 + border: 1px solid var(--border-primary);
  92 + background: var(--bg-input);
  93 + color: var(--text-primary);
  94 + font-size: 14px;
  95 + outline: none;
  96 +}
  97 +.form-group input:focus { background: var(--bg-input-focus); border-color: var(--border-active); }
  98 +
  99 +.login-error {
  100 + font-size: 13px;
  101 + color: #f87171;
  102 + margin: 0;
  103 +}
  104 +
  105 +.login-submit {
  106 + margin-top: 8px;
  107 + padding: 10px;
  108 + border: none;
  109 + border-radius: 8px;
  110 + background: var(--accent);
  111 + color: #05201a;
  112 + font-weight: 600;
  113 + font-size: 14px;
  114 + cursor: pointer;
  115 +}
  116 +.login-submit:disabled { opacity: 0.5; cursor: not-allowed; }
  117 +.login-submit:not(:disabled):hover { background: var(--accent-light); }
  118 +</style>
... ...