From 2baed6894b54520d2b0f00cded5870cb568962c5 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 19 May 2026 13:58:48 +0800 Subject: [PATCH] =?UTF-8?q?auth(dev=20SPA):=20=E9=82=80=E8=AF=B7=E7=A0=81?= =?UTF-8?q?=E6=92=A4=E5=9B=9E=20=E9=82=AE=E7=AE=B1+=E5=AF=86=E7=A0=81=20(u?= =?UTF-8?q?sers.email/password=5Fhash=20bcrypt;=200005=20=E5=8A=A0=20UNIQU?= =?UTF-8?q?E;=20user=20add=20CLI;=20=E7=99=BB=E5=BD=95=E4=B8=A4=20tab)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN.md | 30 ++--- PROGRESS.md | 5 +- RUN.md | 51 +++++---- core/agent_builder.py | 4 +- core/storage/models.py | 25 +---- .../20260519_1100_0005_invites_table.py | 43 -------- .../20260519_1500_0005_users_email_unique.py | 30 +++++ main.py | 50 +++++++++ requirements.txt | 1 + web/app.py | 33 +++--- web/auth.py | 69 ++++++++---- web/static/dev.html | 104 +++++++++++++++--- 12 files changed, 289 insertions(+), 156 deletions(-) delete mode 100644 db/migrations/versions/20260519_1100_0005_invites_table.py create mode 100644 db/migrations/versions/20260519_1500_0005_users_email_unique.py diff --git a/DESIGN.md b/DESIGN.md index d9ef760..c7a5d74 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -14,7 +14,7 @@ - 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4) - 任务持久化:任意时刻关机,下次能恢复 - 演化性:模型升级不需要大改架构 -- **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG,无 SQLite / JSON 分支)/ web `/v1` API。本地形态 = `python main.py web` 起 FastAPI + dev SPA + 邀请码登录(`invites` 表,name → uuid5 推导 user_id),跟 SaaS 走完全一致的路径,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9) +- **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG,无 SQLite / JSON 分支)/ web `/v1` API。本地形态 = `python main.py web` 起 FastAPI + dev SPA + 邮箱密码登录(`users.email/password_hash`,bcrypt 校验),跟 SaaS 走完全一致的路径,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9) --- @@ -47,9 +47,9 @@ zcbot/ └── main.py # 入口: web / db / probe 三子命令 ``` -**工作目录(working_dir) = `workspace/users///`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent(prompt 里仍叫 `task_dir` 占位符,跟 SKILL.md DSL 一致)。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。`user_id` 走 JWT `sub`:邀请码登录(`invites` 表)由 name 经 `uuid5(固定 namespace, name)` 推导,platform 登录则由 platform 直传;**无 SENTINEL fallback,所有路径必须显式有 user_id**。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/` 换 `/`,布局不变。 +**工作目录(working_dir) = `workspace/users///`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent(prompt 里仍叫 `task_dir` 占位符,跟 SKILL.md DSL 一致)。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。`user_id` 走 JWT `sub`:邮箱密码登录由 `users` 表 email lookup 直读,platform 登录由 platform 直传;**无 SENTINEL fallback,所有路径必须显式有 user_id**。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/` 换 `/`,布局不变。 -**启动**:`python main.py web` → uvicorn 起 FastAPI → lifespan 跑 stale-run reaper → 客户端走 `/v1/auth/login_invite`(dev SPA / 同事试用)或 `/v1/auth/login`(platform 机器对机器)换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程,内部 `build_agent`(`core/agent_builder.py`)读 `agent.yaml` → 加载 `ModelCapabilities` → `LLM(caps)` → 解析 working_dir → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → `AgentLoop.run`。`ZCBOT_DB_URL` 指 PG(本地 docker compose / 远端 dev / 生产)。 +**启动**:`python main.py web` → uvicorn 起 FastAPI → lifespan 跑 stale-run reaper → 客户端走 `/v1/auth/login_password`(dev SPA / 同事试用 — 邮箱密码,bcrypt 校验)或 `/v1/auth/login`(platform 机器对机器,platform_key)换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程,内部 `build_agent`(`core/agent_builder.py`)读 `agent.yaml` → 加载 `ModelCapabilities` → `LLM(caps)` → 解析 working_dir → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → `AgentLoop.run`。`ZCBOT_DB_URL` 指 PG(本地 docker compose / 远端 dev / 生产)。 --- @@ -111,7 +111,7 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— 关键差异:**事实由用户判断,不由 LLM 自动总结**。 -**memory 永远在 FS,不入 DB**:统一 `/users//.memory/`(本地直接是 `workspace/`,SaaS 是 `/`,bind mount 进容器)。`user_id` 全程从 JWT `sub` 透传(邀请码登录走 uuid5、platform 登录直传)。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `/` 下)区分,避免项目名取 `memory` 时撞名;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。 +**memory 永远在 FS,不入 DB**:统一 `/users//.memory/`(本地直接是 `workspace/`,SaaS 是 `/`,bind mount 进容器)。`user_id` 全程从 JWT `sub` 透传(邮箱密码登录从 users 表直读、platform 登录直传)。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `/` 下)区分,避免项目名取 `memory` 时撞名;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。 --- @@ -198,7 +198,7 @@ SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。本 | working_dir | `workspace/users///`(user_id 从 JWT 透传) | `/users///`(JWT `sub`) | | Memory | `workspace/users//.memory/`(FS,dotfile) | `/users//.memory/`(仍是 FS,dotfile) | | Sandbox | subprocess + env 过滤 | per-task docker exec | -| Auth | 邀请码(`invites` 表,name→uuid5)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径;邀请码同步下线) | +| Auth | 邮箱密码(`users.email/password_hash`,bcrypt)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径;邮箱密码同步下线) | `workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users//` 子树布局,差别只在外层根目录(`workspace/` vs `/`),不在 storage 形态。 @@ -310,8 +310,13 @@ done {} ### 7.4 存储:Postgres + 本地文件系统 ```sql -users(user_id uuid pk, email null, password_hash | oidc_subject null, plan null, created_at) --- 行由 web auth 入口按需 INSERT(邀请码登录走 uuid5、platform 登录直传);email/auth/plan 全 NULL 直到接 OIDC +users(user_id uuid pk, email null unique, password_hash null, oidc_subject null, plan null, created_at) +-- 0001 schema;0005 给 email 加 UNIQUE(NULL 不冲突,允许 platform_key 入口 user email=NULL 共存)。 +-- 行入口三条:① main.py user add(发邮箱密码用户,bcrypt → password_hash;dev SPA 邮箱密码登录用) +-- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id;email/password_hash 留 NULL) +-- ③ 未来 OIDC(D' 真上线后,改写 login;email/oidc_subject 由 ID token 注入)。 +-- 管理:撤用户 DELETE FROM users WHERE email=...(先清该 user 的 tasks,messages CASCADE); +-- 改密 UPDATE users SET password_hash=;改邮箱 UPDATE users SET email=...(user_id 不动)。 tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description, status, model_profile, tokens_prompt, tokens_completion, cost_usd, @@ -331,11 +336,6 @@ messages(message_id uuid pk, task_id fk, idx int not null, unique (task_id, idx)); create index on messages using gin (payload jsonb_path_ops); -- 全文搜按需加 tsvector + GIN(中文 simple + pg_trgm 起步) - -invites(token text pk, name text not null unique, created_at) --- 0005 加;dev SPA 邀请码登录后端。user_id 由 uuid5(固定 NS, name) 推导,不入表。 --- 管理:直接 INSERT/DELETE/UPDATE(后续若需要可加 `python main.py invite ...` 薄包装); --- 撤销 = DELETE row;换 token 不换身份 = UPDATE token;换 name = 换身份(旧 task 留在旧 user 下)。 ``` **0004 简化:删 `runs` / `usage_events` 表**(从未真用过 — 详 §7.9 取舍)。原 `runs` 表 @@ -448,11 +448,11 @@ invites(token text pk, name text not null unique, created_at) - 删 `web/templates/*` `web/static/*` + jinja2/markdown-it-py/pygments/mdit-py-plugins 依赖 - SSE 事件 payload 从 HTML 片段切 JSON(`{"type":"text","content":"..."}` 等);前端自渲染 markdown / tool_call 折叠 - 路由统一 `/v1` 前缀,响应全 JSON,FastAPI 自带 `/docs` Swagger UI 接替"对内调试"角色(本地形态 `GET /` 302→ `/docs`) - - auth 走 D' 过渡形态:邀请码(`invites` 表,dev SPA / 同事试用)+ PLATFORM_KEY(platform 机器对机器),两条都签同款 JWT(见 §7.3);真 OIDC 留到联调约定 token 形态后接 + - auth 走 D' 过渡形态:邮箱密码(`users.email/password_hash` + bcrypt,dev SPA / 同事试用)+ PLATFORM_KEY(platform 机器对机器),两条都签同款 JWT(见 §7.3);真 OIDC 留到联调约定 token 形态后接 - CORS `allow_origins=["*"]` 本地宽松,platform 部署时按 platform 域名收紧 - **沉淀**:G 阶段的 sink 协议(§7 A)/ RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留,不在 UI 层不被牵连 -**dev SPA 留一份 + 升级为本地 dogfood 主路径**(2026-05-15 决策,2026-05-18 强化):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),~1100 行无构建链。**与"UI 由 platform 实现"不冲突**:platform UI 是给真用户的生产形态;dev.html 是给本仓库开发者 dogfood + 自验 /v1 API + SSE 流的开发期工具。platform 未上线 / 网络断 / 凌晨随手验时不需要拉 platform。理由:① SSE 调试在 curl 里看不到 UI 反应,需要可视端;② Swagger 不发 SSE 流也没流式视图;③ 一个静态文件维护成本可忽略,删了再补不如留着;④ CLI REPL 撤(见下条)后 dev SPA 成为唯一本地交互通道,功能要齐(新建 / resume / done/abandon / 硬删 / 改 status/desc/name/skill / 文件浏览 + 上传 + 删 / chat 流式 + stop / 导出 docx)。形态:登录页填邀请码 → `/v1/auth/login_invite` 拿 JWT → localStorage 存 → fetch+Bearer。 +**dev SPA 留一份 + 升级为本地 dogfood 主路径**(2026-05-15 决策,2026-05-18 强化):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),~1100 行无构建链。**与"UI 由 platform 实现"不冲突**:platform UI 是给真用户的生产形态;dev.html 是给本仓库开发者 dogfood + 自验 /v1 API + SSE 流的开发期工具。platform 未上线 / 网络断 / 凌晨随手验时不需要拉 platform。理由:① SSE 调试在 curl 里看不到 UI 反应,需要可视端;② Swagger 不发 SSE 流也没流式视图;③ 一个静态文件维护成本可忽略,删了再补不如留着;④ CLI REPL 撤(见下条)后 dev SPA 成为唯一本地交互通道,功能要齐(新建 / resume / done/abandon / 硬删 / 改 status/desc/name/skill / 文件浏览 + 上传 + 删 / chat 流式 + stop / 导出 docx)。形态:登录页两 tab(邮箱密码 默认 / UUID+PLATFORM_KEY 备用,last-used tab 持久化)→ `/v1/auth/login_password` 或 `/v1/auth/login` 拿 JWT → localStorage 存 → fetch+Bearer。 **CLI REPL 撤,入口统一 `main.py {web,db,probe}`**(2026-05-18 决策,推翻原"CLI 双模式共存"): - **原计划**:`cli.py chat` REPL 本地直跑 + `--remote https://...` 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。 @@ -461,7 +461,7 @@ invites(token text pk, name text not null unique, created_at) - `cli.py` 改名 `main.py`(入口);原 `main.py`(装配 lib)挪到 `core/agent_builder.py`(单一职责,SoC)。 - 删 `chat / tasks / export` 三命令(浏览器 dev SPA + web `/v1` 全覆盖);保留 `web / db / probe`(uvicorn / alembic / 模型探测,各有不可替代逻辑)。 - 净减 ~400 行 REPL 逻辑(`_cleanup_if_empty / _list_task_rows / _resolve_uuid_or_prefix / build_agent.console` 等 CLI-only 代码),`main.py` ~180 行,`core/agent_builder.py` ~320 行。 -- **失**:CLI "无 auth 直跑调 core 内部状态"通道。但 dev SPA 邀请码登录走同一条 web 路径,看内部状态可以临时写几行 ad-hoc script(`from core.agent_builder import build_agent; ...`),不需要常驻 CLI 命令。 +- **失**:CLI "无 auth 直跑调 core 内部状态"通道。但 dev SPA 邮箱密码登录走同一条 web 路径,看内部状态可以临时写几行 ad-hoc script(`from core.agent_builder import build_agent; ...`),不需要常驻 CLI 命令。 - **离线**:本地 `python main.py web` 起服务 + 浏览器 dev SPA;`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG,跟之前一样,不靠"全栈零依赖"幻觉。 **Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。 diff --git a/PROGRESS.md b/PROGRESS.md index 3775f8d..81525c2 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 -最后更新:2026-05-19(dev SPA 任务/文件行加 `⋯` 下拉菜单 + 文件顶栏长名截断 + 聊天框上传按钮 + 工具调用返回 debounce 刷新右侧文件) +最后更新:2026-05-19(dev SPA 登录从"邀请码/uuid5"撤回 邮箱+密码 — 复用 0001 schema 的 `users.email/password_hash`、加 UNIQUE(email)、加 `main.py user add` CLI、登录页两 tab 切换) --- @@ -21,7 +21,8 @@ ## 已完成关键能力 -- **05-19 / 邀请码后端从 env 升级到 `invites` 表(0005 migration)**:接上一条 `ZCBOT_INVITES` env 落地后,用户复盘"用一张表还是 env 哪个更好",讨论 trade-off 后判定:**≤5 人 + 一个人管 + 开发期**这个体量下 env 完胜 — 0 migration / 0 CRUD / 1 秒重启;但用户又转念:"既然将来真发布要表,不如现在就薄薄一张,我直接 DB 里 INSERT 试用,后期再加 CLI 命令"。**判定**:env 升表是低成本前置(schema 极薄,签名几乎不变),省去"未来再写 migration + 同步 env 数据"。**Schema**(0005,`db/migrations/.../20260519_1100_0005_invites_table.py`):`invites(token text pk, name text not null unique, created_at timestamptz default now())`。**设计取舍**:① **token PK** 而非自增 id — lookup 入口直接是 token,PK 自带索引,id 列纯死字段;② **name UNIQUE** — 同 name → 同 uuid5 → 两 token 共身份漏,DB schema 拦比应用层早一层;③ **不存 user_id** — `uuid5(固定 NS, name)` 推导,namespace 永远不动,存它等于冗余 + 偏离单一真相源(改 namespace 全员漂移这个风险一样存在,不靠存 user_id 解);④ **不存 revoked_at** — 撤销直接 `DELETE FROM invites WHERE name=...`,软删的"revoked 是否签 JWT"分支判断 ≤5 人场景用不上;⑤ 不加管理 CLI 子命令 — 用户明确说"我到时候在数据库里直接添加",后期需要再加 `python main.py invite {add|list|revoke}` 薄包装。**ORM**(`core/storage/models.py`)新加 `Invite` class(token PK / name unique / created_at server_default)。**`web/auth.py` 全面重写**:① 删 `_parse_invites` 函数(~40 行 env 字符串解析 + 校验)+ 删 `_INVITE_NAME_RE`(改交给 DB UNIQUE 约束)+ 删 `AuthConfig.invites` 字段 + 删 `AuthConfig.resolve_invite` 方法 + 删 `AuthConfig.from_env` 里读 `ZCBOT_INVITES` 那段 + 删 `AuthConfig.__init__` 多余 invites 参数;② 新增模块级 `resolve_invite(token) -> Optional[(name, user_id)]`:每次 login 走一次 `SELECT name FROM invites WHERE token = ?`,命中后 `uuid5(NS, name)` 算 user_id;**不缓存**,避免 `DELETE` 后还能登的不一致窗口(5 人级别用户开销可忽略,P99 也是 1ms 级);③ docstring 改写(`ZCBOT_INVITES` → `invites 表`,加发码 SQL 示例);④ `__all__` 加 `resolve_invite`(模块函数,不是 AuthConfig 方法,刻意分开 — DB 查询的 callable 不绑 cfg 闭包,更清晰)。**`web/app.py::login_invite` 路由**:从 `auth_cfg.invites` / `auth_cfg.resolve_invite` 改为 `from .auth import resolve_invite`;`if not auth_cfg.invites: 403 "invite login disabled"` 那条早返判断直接删 — 空表跟未配 env 是同一语义,`resolve_invite` 自然返 None;docstring 加发码 / 撤销 SQL 速查。**migration 跑通**:`db upgrade 0004 → 0005` 一把过,`db current` → `0005 (head)`。**Smoke 9 case 全绿**(TestClient 起 app + 直 DB SQL):① `AuthConfig` 不再有 invites/resolve_invite 字段;② `auth.py` 源码 grep 完全无 `ZCBOT_INVITES` / `_parse_invites` 痕迹(env 即使设了也不被读);③ 空表 → resolve_invite 任何 token 返 None;④ 手动 `INSERT INTO invites(token, name) VALUES('smoke_tok_alice','alice')`;⑤ `resolve_invite('smoke_tok_alice')` 命中 → `('alice', uuid5(NS,'alice'))`;⑥ `POST /v1/auth/login_invite {token: 'smoke_tok_alice'}` 200 + 正确 user_id + name;⑦ 错 / 空 token → 403 `invalid invite token`;⑧ JWT 调 `/v1/tasks` 200 + 老 `/v1/auth/login` (platform 路径) 仍工作;⑨ `DELETE FROM invites WHERE name='alice'` 后立刻 login 403(验证无缓存);⑩ 同 name 两行 INSERT 触发 `IntegrityError`(UNIQUE 生效);⑪ 同 token 两行 INSERT 触发 `IntegrityError`(PK 生效)。**没动**:`POST /v1/auth/login`(platform 机器对机器入口,与 invite 路径正交)、JWT 签发逻辑(`mint_token` / `verify_token`)、dev SPA(前端只调 `/v1/auth/login_invite` 路由 contract 没变)、SENTINEL 撤的所有清理(本次之前一步,见下条)。**文档同步**:RUN.md `.env` 示例段去 `ZCBOT_INVITES` 注释 + 加"邀请码不走 env 改 invites 表"指向 + Auth env 段重写邀请码段(指向表 + 发码 SQL + 撤销 SQL) + 日常命令段重写发码流程(`INSERT INTO invites(...)` + 不用重启 web,每次 login 都查 DB) + 路由表 `/v1/auth/login_invite` 说明改 "invites 表 token 未命中(含空表)→ 403" + 故障兜底重写四行 invite 相关 (含 INSERT 冲突时 DB 约束的诊断 + name 修改 = 换身份的语义清晰化) + DESIGN §7.4 schema 段加 invites 表声明 + 元描述;**未动 PROGRESS 老 env 条目**(历史记录,演进过程合理);新加本条作为升级里程碑。**改文件**:`db/migrations/.../20260519_1100_0005_invites_table.py`(+38 行新文件)/ `core/storage/models.py`(+15 行 Invite class + docstring)/ `web/auth.py`(净 -50 行,~30 行加 resolve_invite 函数 + docstring 重写,~80 行删 env 解析)/ `web/app.py`(净 -7 行 login_invite 路由简化)/ `RUN.md`(env / 邀请码 / 命令 / 路由 / 故障兜底五段)/ `DESIGN.md`(§7.4 schema +5 行)/ `PROGRESS.md`(+ 本条)。**净增量**:~-20 行(尽管加了一张表 + ORM,删 env 解析的逻辑更省)。**后期 follow-up**(用户提的):`python main.py invite {add|list|revoke}` 薄包装 —— 真要写时大概就是 3 条 click 子命令分别包 INSERT/SELECT/DELETE,留给真需要管理面的时候做。 +- **05-19 / dev SPA 登录撤回 邮箱+密码,删 invites 表 + 邀请码路径**:接前两条 "邀请码 env → invites 表(0005)" 用了一天,用户复盘"还不如直接复用 users 表本来就有的 email/password_hash 列"。**判定**:invites + uuid5(NS, name) 推导是黑盒(同事不知道自己 user_id 从哪来、改 name 等于换身份),复用 users 列语义清晰、生产路径上也是 email+password 主流。dev 期不留兼容,**直接 downgrade 0005 → 删 invites 文件 → 写干净的新 0005 加 `UNIQUE(email)`**。**DB**(`db/migrations/.../20260519_1500_0005_users_email_unique.py`):`CREATE UNIQUE CONSTRAINT uq_users_email ON users(email)`;PG `UNIQUE` 对 NULL 不冲突,所以 platform_key 入口建的 user(email=NULL)不受影响。**密码哈希** `bcrypt>=4.1.0`(Windows wheel `bcrypt-5.0.0-cp39-abi3-win_amd64`),`bcrypt.hashpw` + `gensalt()` 默认 cost=12,存 ASCII str `$2b$12$...` 到 `users.password_hash`;`bcrypt.checkpw` 常数时间比对。**`web/auth.py`**:① 删 `resolve_invite` + `_INVITE_NAMESPACE` 常量 + `from core.storage.models import Invite`;② 新 `hash_password(s) -> str`(bcrypt hashpw → ASCII)+ `verify_password(s, h) -> bool`(checkpw,异常 fallback False 防 stored_hash 坏数据 500)+ `resolve_user_by_email(email, password) -> Optional[(user_id, email)]`(SELECT 不到时跑一次 dummy `bcrypt.checkpw(b"x", b"$2b$12$..."*53)` 防 timing oracle);③ `ensure_user_row` docstring 改写(只 platform_key 入口走;邮箱密码登录走 `main.py user add` 已经写好 users 行)。**`web/app.py`**:① `InviteLoginRequest` → `PasswordLoginRequest {email, password}`;② `/v1/auth/login_invite` 路由 → `/v1/auth/login_password`(命中返 `{token, expires_at, user_id, email, ttl_seconds}`;错邮箱 / 错密码 / 邮箱不存在统一 403 `invalid email or password` 不细分错因防探测用户存在性);③ 不调 `ensure_user_row`(行已由 `user add` 创);老 `/v1/auth/login` (platform 路径) 不动。**`core/storage/models.py`**:① 删 `Invite` class;② `email` 列加 `unique=True`;③ 文件 docstring 改"3 张表"(原 4)。**新加 CLI** `main.py user add --email X --password Y [--user-id UUID]`:bcrypt hash + INSERT users(email,password_hash[,user_id]);email lowercase + `@` 简单校验,password ≥ 6 字符;email UNIQUE 撞 / user_id PK 撞 → IntegrityError 走 except 退 2(报清错因)。`--user-id` 可选用于把已有 user_id(platform_key 入口创的孤儿)接到邮箱密码路径。**dev SPA `web/static/dev.html`**:登录卡 1 格("邀请码")改 **2 tab 切换**(`#tab-pw` "邮箱密码" 默认 / `#tab-key` "UUID + PLATFORM_KEY" 备用);CSS 新加 `.tabs / .tab-body` + active 态;JS `loginTab` + `LS_TAB` localStorage 持久化 last-used tab(回登录页保留);`switchLoginTab` auto-focus 第一个 input;`doLogin` 按 tab 分派 url + body — pw → `/v1/auth/login_password {email, password}`,key → `/v1/auth/login {user_id, platform_key}`;header 显示 name 时优先用返回的 `email`(pw 路径),`platform_key` 路径无显示名只显 uid 前 8 位。任意 input 上回车均触发登录。**Smoke**(curl + web 起服)4 case 全绿:① `user add dev@example.com / devpass123` 200 OK;② `POST /v1/auth/login_password` 正密码 200 + 返 user_id+email+JWT;③ 错密码 403 `invalid email or password`;④ 不存在邮箱 403 同;⑤ 重复 add 同 email → IntegrityError 走 except 退 2 + 报 `uq_users_email`;⑥ 老路径 `POST /v1/auth/login_invite` 404 Not Found(路由已删)。**没动**:JWT 签发 / `verify_token` / `require_user` Depends(签出来的 JWT 同款,后续 `/v1/*` 路由层完全无感)/ `/v1/auth/login` platform 路径 / DB users 表本身列结构(0001 早就有 email/password_hash,0005 只加约束)。**Migration 序号**:旧 0005(invites)在本次开发期 downgrade + 删 file 整体抹除,新 0005 重新使用同号承接 `users.email UNIQUE`(`down_revision: 0004`)。**文档同步**:RUN.md(env 段去 invites / 加 user CLI 段 + 路由表 `/v1/auth/login_invite` 改 `/v1/auth/login_password` + 故障兜底改邀请码相关 4 条 → 邮箱密码 4 条);PROGRESS 本条。**改文件**:`db/migrations/.../20260519_1500_0005_users_email_unique.py`(+33 行新文件,替代旧 0005 invites file)/ `db/migrations/.../20260519_1100_0005_invites_table.py`(删)/ `core/storage/models.py`(-15 行 Invite class,email 加 unique)/ `web/auth.py`(净 ~+10 行:删 ~40 行 invite,加 ~50 行 password helpers)/ `web/app.py`(login_invite → login_password 同尺寸)/ `web/static/dev.html`(+~80 行 JS/HTML/CSS 2 tab 登录)/ `main.py`(+~50 行 user CLI)/ `requirements.txt`(+1 行 bcrypt)/ `RUN.md` / `PROGRESS.md`。**净增量**:~+90 行(2 tab UI + CLI 都是有功能价值的代码,前一条邀请码净 -20 行被这条吃掉)。 +- **05-19 / 邀请码后端从 env 升级到 `invites` 表(0005 migration)** _(已撤,见上条)_:接上一条 `ZCBOT_INVITES` env 落地后,用户复盘"用一张表还是 env 哪个更好",讨论 trade-off 后判定:**≤5 人 + 一个人管 + 开发期**这个体量下 env 完胜 — 0 migration / 0 CRUD / 1 秒重启;但用户又转念:"既然将来真发布要表,不如现在就薄薄一张,我直接 DB 里 INSERT 试用,后期再加 CLI 命令"。**判定**:env 升表是低成本前置(schema 极薄,签名几乎不变),省去"未来再写 migration + 同步 env 数据"。**Schema**(0005,`db/migrations/.../20260519_1100_0005_invites_table.py`):`invites(token text pk, name text not null unique, created_at timestamptz default now())`。**设计取舍**:① **token PK** 而非自增 id — lookup 入口直接是 token,PK 自带索引,id 列纯死字段;② **name UNIQUE** — 同 name → 同 uuid5 → 两 token 共身份漏,DB schema 拦比应用层早一层;③ **不存 user_id** — `uuid5(固定 NS, name)` 推导,namespace 永远不动,存它等于冗余 + 偏离单一真相源(改 namespace 全员漂移这个风险一样存在,不靠存 user_id 解);④ **不存 revoked_at** — 撤销直接 `DELETE FROM invites WHERE name=...`,软删的"revoked 是否签 JWT"分支判断 ≤5 人场景用不上;⑤ 不加管理 CLI 子命令 — 用户明确说"我到时候在数据库里直接添加",后期需要再加 `python main.py invite {add|list|revoke}` 薄包装。**ORM**(`core/storage/models.py`)新加 `Invite` class(token PK / name unique / created_at server_default)。**`web/auth.py` 全面重写**:① 删 `_parse_invites` 函数(~40 行 env 字符串解析 + 校验)+ 删 `_INVITE_NAME_RE`(改交给 DB UNIQUE 约束)+ 删 `AuthConfig.invites` 字段 + 删 `AuthConfig.resolve_invite` 方法 + 删 `AuthConfig.from_env` 里读 `ZCBOT_INVITES` 那段 + 删 `AuthConfig.__init__` 多余 invites 参数;② 新增模块级 `resolve_invite(token) -> Optional[(name, user_id)]`:每次 login 走一次 `SELECT name FROM invites WHERE token = ?`,命中后 `uuid5(NS, name)` 算 user_id;**不缓存**,避免 `DELETE` 后还能登的不一致窗口(5 人级别用户开销可忽略,P99 也是 1ms 级);③ docstring 改写(`ZCBOT_INVITES` → `invites 表`,加发码 SQL 示例);④ `__all__` 加 `resolve_invite`(模块函数,不是 AuthConfig 方法,刻意分开 — DB 查询的 callable 不绑 cfg 闭包,更清晰)。**`web/app.py::login_invite` 路由**:从 `auth_cfg.invites` / `auth_cfg.resolve_invite` 改为 `from .auth import resolve_invite`;`if not auth_cfg.invites: 403 "invite login disabled"` 那条早返判断直接删 — 空表跟未配 env 是同一语义,`resolve_invite` 自然返 None;docstring 加发码 / 撤销 SQL 速查。**migration 跑通**:`db upgrade 0004 → 0005` 一把过,`db current` → `0005 (head)`。**Smoke 9 case 全绿**(TestClient 起 app + 直 DB SQL):① `AuthConfig` 不再有 invites/resolve_invite 字段;② `auth.py` 源码 grep 完全无 `ZCBOT_INVITES` / `_parse_invites` 痕迹(env 即使设了也不被读);③ 空表 → resolve_invite 任何 token 返 None;④ 手动 `INSERT INTO invites(token, name) VALUES('smoke_tok_alice','alice')`;⑤ `resolve_invite('smoke_tok_alice')` 命中 → `('alice', uuid5(NS,'alice'))`;⑥ `POST /v1/auth/login_invite {token: 'smoke_tok_alice'}` 200 + 正确 user_id + name;⑦ 错 / 空 token → 403 `invalid invite token`;⑧ JWT 调 `/v1/tasks` 200 + 老 `/v1/auth/login` (platform 路径) 仍工作;⑨ `DELETE FROM invites WHERE name='alice'` 后立刻 login 403(验证无缓存);⑩ 同 name 两行 INSERT 触发 `IntegrityError`(UNIQUE 生效);⑪ 同 token 两行 INSERT 触发 `IntegrityError`(PK 生效)。**没动**:`POST /v1/auth/login`(platform 机器对机器入口,与 invite 路径正交)、JWT 签发逻辑(`mint_token` / `verify_token`)、dev SPA(前端只调 `/v1/auth/login_invite` 路由 contract 没变)、SENTINEL 撤的所有清理(本次之前一步,见下条)。**文档同步**:RUN.md `.env` 示例段去 `ZCBOT_INVITES` 注释 + 加"邀请码不走 env 改 invites 表"指向 + Auth env 段重写邀请码段(指向表 + 发码 SQL + 撤销 SQL) + 日常命令段重写发码流程(`INSERT INTO invites(...)` + 不用重启 web,每次 login 都查 DB) + 路由表 `/v1/auth/login_invite` 说明改 "invites 表 token 未命中(含空表)→ 403" + 故障兜底重写四行 invite 相关 (含 INSERT 冲突时 DB 约束的诊断 + name 修改 = 换身份的语义清晰化) + DESIGN §7.4 schema 段加 invites 表声明 + 元描述;**未动 PROGRESS 老 env 条目**(历史记录,演进过程合理);新加本条作为升级里程碑。**改文件**:`db/migrations/.../20260519_1100_0005_invites_table.py`(+38 行新文件)/ `core/storage/models.py`(+15 行 Invite class + docstring)/ `web/auth.py`(净 -50 行,~30 行加 resolve_invite 函数 + docstring 重写,~80 行删 env 解析)/ `web/app.py`(净 -7 行 login_invite 路由简化)/ `RUN.md`(env / 邀请码 / 命令 / 路由 / 故障兜底五段)/ `DESIGN.md`(§7.4 schema +5 行)/ `PROGRESS.md`(+ 本条)。**净增量**:~-20 行(尽管加了一张表 + ORM,删 env 解析的逻辑更省)。**后期 follow-up**(用户提的):`python main.py invite {add|list|revoke}` 薄包装 —— 真要写时大概就是 3 条 click 子命令分别包 INSERT/SELECT/DELETE,留给真需要管理面的时候做。 - **05-19 / SENTINEL user 彻底撤(数据 + 代码)**:接邀请码 login 落地后,`SENTINEL_USER_ID = UUID('00000000-...')` 这个本地 CLI 时代的兜底 user 已彻底无角色 —— CLI 早撤,web 必从 JWT 拿 user_id,`agent_builder.py:190` 的 `uid = user_id or SENTINEL_USER_ID` fallback 永远不触发(web 永远显式传)。按 CLAUDE.md "不写兼容层" 心智一次连根拔。**DB 数据**:`DELETE FROM users WHERE user_id=SENTINEL` CASCADE 删 5 个 dev 期 task + 307 条 messages(经用户确认不保留);`rm -rf workspace/users/00000000.../`(14 个 smoke 残留目录 + 1 个真实任务,用户确认不留)。**代码 8 处**:① `core/storage/models.py` 删 `SENTINEL_USER_ID` 常量 + 文件 docstring 改;② `core/storage/engine.py` 删 `ensure_local_sentinel` 函数 + `User`/`SENTINEL_USER_ID` import + 文件 docstring 改(改写 "users 行由 web auth 入口按需 INSERT");③ `core/storage/__init__.py` 删两个导出 + docstring 改;④ `core/storage/utils.py` 三个函数 `ensure_local_task_row / upsert_task / check_no_subtask` 的 `user_id` 默认参数(原 `= SENTINEL_USER_ID`)全改必填 + 删 import;⑤ `core/agent_builder.py` `user_id: Optional[UUID] = None` → `user_id: UUID`(KEYWORD_ONLY,前面加 `*,`),删 `uid = user_id or SENTINEL_USER_ID` 改 `uid = user_id`,删 `ensure_local_sentinel()` 调用,删 import,docstring 改两处 — 一处文件头 (移除 "默认填 SENTINEL"),一处 build_agent 函数注释 ("None → SENTINEL" 改 "必填,web 入口从 JWT 拿");两个 `TaskState(...)` 构造点都加 `user_id=uid`;⑥ `core/task.py::TaskState` dataclass 加 `user_id: UUID` 字段(放 task_id 后,无默认值 = 必填),`save()` 透传给 `upsert_task(user_id=self.user_id, ...)`,`from_row()` 加 `user_id=row.user_id`;⑦ `core/session.py::Session.append` 删多余 `ensure_local_task_row` 调用块(8 行)+ 改 docstring(写明 "前置条件:tasks 行已由 web 入口写入") — Session 不持有 user_id,这调用本来就是历史 CLI 残留(REPL 时 task 可能尚未 INSERT),现在 web 入口 100% 先 INSERT,这条 ensure 是 ON CONFLICT DO NOTHING no-op,删了反而清爽;⑧ `core/memory.py` docstring 改 ("本地 CLI = SENTINEL user" → "user_id 由 web auth 入口 JWT sub 透传");⑨ `web/auth.py` 删 `SENTINEL_USER_ID` import + `__all__` 条目 + `ensure_user_row` docstring 提及 SENTINEL 改写;⑩ `db/migrations/.../0001_initial_schema.py:12` 注释改 (`ensure_local_sentinel` → `web.auth.ensure_user_row`)。**关键判断**:`build_agent` 参数顺序问题 — `user_id` 必填放在一堆带默认参数后面会 `SyntaxError: non-default argument follows default`,改 `*, user_id: UUID, ...` keyword-only;唯一 caller `web/app.py:199` 已 keyword 调用,无 break。**Smoke 13 case 全绿**:① 全模块 import 不炸;② TaskState 新字段;③ utils.py 三函数签名 `inspect.Parameter.empty` 必填;④ build_agent KEYWORD_ONLY 必填;⑤ DB 无 SENTINEL 行;⑥ `/v1/auth/login_invite` 200 + uuid5 推导;⑦ POST /v1/tasks 201(走 `ensure_local_task_row(user_id=user_id)`);⑧ GET /v1/tasks count=1;⑨ PATCH 走 update_task 200;⑩ GET /v1/files + /v1/folders 200;⑪ DELETE 204;⑫ user 行存在并清理;⑬ `python main.py db current` 子进程跑通 + 输出 `0004 (head)`。**没动**:DB schema(0004 已最终态,SENTINEL 是数据非 schema)、`users` 表本身、`/v1/auth/login`(platform 机器对机器入口仍需要)、邀请码登录路径。**文档同步**:① RUN.md 关键路径段 Workspace 行去 "本地 CLI sentinel = 00000000..." 表述;② DESIGN.md 三处 — §1 working_dir 路径解释(去 "dev SPA 默认填 SENTINEL")+ §7.0 共享差别表(working_dir / Memory / Auth 三行去 sentinel 措辞,Auth 行加邀请码 + platform_key 双路径)+ §7.9 取舍 (去 "dev SPA 默认填 SENTINEL");③ DESIGN.md §3.7 memory 段去 "本地 CLI 走 SENTINEL"。**改文件**:`core/{storage/models.py,storage/engine.py,storage/__init__.py,storage/utils.py,agent_builder.py,task.py,session.py,memory.py}` + `web/auth.py` + `db/migrations/.../0001_initial_schema.py` + `RUN.md` + `DESIGN.md`(共 12 个文件,净 ~-50 行)。**bonus 价值**:把"操作 user 数据的函数必须显式传 user_id"作为编译期约束(Python 必填参数)固化下来 — 以后再加多 user 相关函数 / caller 时,IDE / typechecker 会直接拦到。 - **05-19 / dev SPA 邀请码登录(`ZCBOT_INVITES` env + `POST /v1/auth/login_invite`)**:用户要把 dev SPA 发给 ≤5 个同事试用,原登录页要求填 `user_id (UUID)` + `platform_key` 两件事 — 同事记不住 UUID、`platform_key` 也不该暴露(那是 platform 与 zcbot 间的机器对机器共享密钥,泄漏 = 任意伪造 user_id)。**方案**:加一条"邀请码"login 路径与原 `/v1/auth/login` 共存(签同款 JWT)。**后端** `web/auth.py`:新增 `ZCBOT_INVITES` env 解析(`name:token,name:token,...` 格式;name 限 `[A-Za-z0-9_-]{1,40}` / token 非空 / 不含 `,:` / ≤200;name 唯一、token 唯一 —— 重复 token 表两人同身份,默认拒);user_id 由 `uuid5(_INVITE_NAMESPACE, name)` 推导(固定 UUID namespace `9b5e7a2a-...`,**不能改**,改了所有邀请用户身份全漂移),重启稳定、纯纯无状态;`AuthConfig.invites` 是 `{token: (name, uuid)}` dict,启动时一次解析完丢内存。**新路由** `POST /v1/auth/login_invite {token}` → 同款 JWT + `{name, user_id}` 返给前端展示用;`ZCBOT_INVITES` 未配 → 整个入口 403 `invite login disabled`(避免裸跑暴露);token 未命中 → 403 `invalid invite token`。**老路由 `/v1/auth/login` 不动** — platform 服务端机器对机器入口语义不一样(它能注入指定 user_id,邀请码只能登 env 里配过的人),OIDC 替换时也只动这条,留着合理。**前端** `web/static/dev.html`:登录卡 2 格(uuid + key)→ 1 格"邀请码"输入(type=password 避免肩窥),去 SENTINEL 预填、去 `LS_UID` 自动填充;header 显示 `name · uuid前8位`(name 缺失老 token 升级前回落到 uuid full,`title` 攻略悬停看完整 uuid);加 `LS_NAME` localStorage 持久化;logout 清三件 LS。**给同事发码流程**(RUN.md 写了):`python -c "import secrets;print(secrets.token_urlsafe(16))"` 生 token,`.env` 加 `ZCBOT_INVITES=alice:tok1,bob:tok2,...`,重启 `main.py web`,把 URL + 各自 token 发给同事。**撤销某人**:从 env 里删那条;**真要换身份**:改 name(他之前的 task 在另一个 user 下访问不到)。**Smoke 14 case 全绿**:① 单元 `_parse_invites` 8 case(未配 / 正常 / uuid5 重启稳定 / 未命中或空 token / 非法 name 拒 / 重复 name 拒 / 重复 token 拒 / 缺 colon 拒 / token 含 colon 拒 / 多余分隔符空白容忍);② 路由 `TestClient` 6 case(命中 alice / 命中 bob 不同 uid / 错 token 403 / 空 token 403 / JWT 调 `/v1/tasks` 200 / 无 Authorization 401 / 老 `/v1/auth/login` 仍工作 / `ZCBOT_INVITES` 未配 invite 路径 403)。**dev.html UI**:`grep` 旧字段 `SENTINEL / li-uid / li-key / platform_key` 全清。**没动**:`require_user` Depends(签 JWT 是同款,后续路由层完全无感)/ `ensure_user_row`(两路径都调,幂等 INSERT)/ DB schema(用户行靠登录时按需建);DESIGN(纯过渡 auth 形态扩展,§7 D' 仍写"PLATFORM_KEY → JWT" 的本意 — invite 是同一条路的小扩展)。**文档**:RUN.md 同步(env 段加 `ZCBOT_INVITES` 说明 + 给同事发码流程 + 路由表加 `/v1/auth/login_invite` + 故障兜底 4 行 invite 相关条目)+ PROGRESS 本条。**改动文件**:`web/auth.py`(+~70 行 `_parse_invites` + `AuthConfig.invites` + `resolve_invite`)/`web/app.py`(+~25 行 `InviteLoginRequest` + 新路由)/`web/static/dev.html`(净 +~15 行 -~20 行,登录块精简 + LS_NAME)/`RUN.md`(env 段 / 路由表 / 故障兜底)。 - **05-19 / dev SPA 任务/文件 `⋯` 下拉菜单 + 文件顶栏长名截断 + 聊天框上传按钮 + 工具调用返回 debounce 刷新右侧**:用户提"左侧任务行加下拉菜单(删除/完成/废弃/导出 docx,不同颜色)、右侧文件同理、文件顶栏长项目名压'文件'换行不要、聊天框加跟文件 panel 一样的上传按钮;另:上传后右侧刷新、工具调用返回时右侧也刷新"。**做法**:① **单例浮层菜单**(`#floating-menu`,`position: fixed`)避开 pane `overflow:auto` 裁剪 — `showMenu(triggerEl, items)` 算 trigger 右下展开,空间不足翻上;点 trigger 外 / resize / 任何 scroll 关菜单。② **任务行**(`renderTaskList`):右侧加 `⋯` trigger,菜单 4 项 `complete/abandon/export/delete`,颜色 `act-complete #2e7d32` / `act-abandon #c77800` / `act-export #1565c0` / `act-delete var(--accent)`;`complete/abandon` 在非 active 任务上 disabled,`export` 在 0 消息时 disabled;点击 trigger `stopPropagation` 不触发 row 选中;`state.tasksById` 缓存避免 menu 里再查。③ **文件行**(`renderFiles` + `fileMenuItems`):删除原内联 `改名 / ×` 两个按钮,统一改 `⋯` 菜单 — `重命名` / `下载`(目录不出现) / `删除`,同套颜色;`state.entriesByRel` 缓存 entry。④ **中间 pane-head 已有的 完成/废弃/导出/删除 4 个按钮保留**(操作当前打开任务还是顺手),重构 `setTaskStatus(tid, status, name)` / `deleteTask(tid, name, nMsg)` / `exportTask(tid)` 接受 tid 参数,中间按钮与左侧菜单共用同一组函数。⑤ **"文件"二字换行**:`.pane-head .label` 加 `white-space: nowrap; flex-shrink: 0`;同时 `#files-proj` 改 `flex: 0 1 auto` + `min-width: 0` + ellipsis + JS 端 `projName.slice(0, 11) + "…"` 截短(完整名留 title) — 双保险防长项目名挤爆顶栏。⑥ **聊天框上传**(`#chat-upload`):与右侧 `#btn-upload` 都触发同一 ``,`uploadSelected` 不变(上传到 `state.filesPath` 当前右侧目录),末尾 `await loadFiles()` 已有刷新。⑦ **工具调用刷新文件**:`handleSseEvent` 的 `tool_result` 分支加 `scheduleFilesRefresh()`,debounce 500ms 避免每次 tool_result 都 hit `/v1/files`(SSE 一轮回复里 tool_call 经常一连串)。**没动**:后端(纯前端 UX 调整);DESIGN(不动 — 非架构);RUN(不动 — 无 CLI / env / 文件布局变化);中间 pane 已有按钮文案与 disabled 规则保持不变。**文档**:只动 PROGRESS(按 CLAUDE.md 三文档边界)。**改文件**:仅 `web/static/dev.html`(+~110 行 JS/CSS,-~10 行旧内联按钮代码)。 diff --git a/RUN.md b/RUN.md index 3d7379b..e956c98 100644 --- a/RUN.md +++ b/RUN.md @@ -2,7 +2,7 @@ > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。 -最后更新:2026-05-19(dev SPA 登录改邀请码;**邀请码后端从 env 升级到 `invites` 表**(0005 migration);新路由 `POST /v1/auth/login_invite`,原 `/v1/auth/login` 留给 platform 机器对机器入口;SENTINEL user 撤) +最后更新:2026-05-19(**dev SPA 登录撤回 邮箱+密码** — 复用 0001 schema 的 `users.email/password_hash`,0005 加 `UNIQUE(email)`;短命 invites 表 + `/v1/auth/login_invite` 撤;新路由 `POST /v1/auth/login_password`;`main.py user add` CLI 建用户;`/v1/auth/login` (platform 机器对机器) 不动;SENTINEL user 撤) --- @@ -19,12 +19,12 @@ # 可选:覆盖默认 7d JWT TTL # ZCBOT_JWT_TTL_SECONDS=604800 ``` - > 邀请码不再走 env(05-19 撤),改走 `invites` 表(见下方"邀请码"段)。 + > 邀请码方案已撤(05-19,一日游),改回 `users.email/password_hash`(0001 schema 原列 + 0005 加 UNIQUE)。 > litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 会自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`。 -- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里)。 +- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt` 给密码哈希)。 - **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。 -- **Auth env**(`main.py web` 必填):`PLATFORM_KEY` + `JWT_SECRET`,任一缺失 web 启动会 fail-fast。生成随机串可用 `python -c "import secrets; print(secrets.token_urlsafe(48))"`。`main.py db / probe` 不验,不要这两个 env 也能跑。 -- **邀请码**(`invites` 表,0005):dev SPA 登录后端。schema 极薄 — `token PK / name UNIQUE / created_at`。user_id 由 `uuid5(固定 namespace, name)` 推导,不入表(重启稳定)。空表 → `/v1/auth/login_invite` 全 403(发码:`INSERT INTO invites(token, name) VALUES(...)`;撤销:`DELETE FROM invites WHERE name=...`)。同事拿到 token 即可登录,**不接触** `PLATFORM_KEY`。token 生成可用 `python -c "import secrets;print(secrets.token_urlsafe(16))"`。后续可选加 `python main.py invite {add|list|revoke}` 薄包装。 +- **Auth env**(`main.py web` 必填):`PLATFORM_KEY` + `JWT_SECRET`,任一缺失 web 启动会 fail-fast。生成随机串可用 `python -c "import secrets; print(secrets.token_urlsafe(48))"`。`main.py db / probe / user` 不验,不要这两个 env 也能跑。 +- **邮箱密码**(`users.email/password_hash`,0005 加 UNIQUE):dev SPA 登录后端。一行 = 一个用户,`password_hash` 是 `bcrypt`(cost=12)。发用户走 `.venv/Scripts/python.exe main.py user add --email X --password Y`(详见下方 user 命令段);撤用户直接 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks,messages 通过 FK CASCADE 自动)。同事拿到邮箱密码即可登录,**不接触** `PLATFORM_KEY`。改密 / 改邮箱目前手动 SQL(`UPDATE users SET password_hash= / email=... WHERE ...`)或先 DELETE 再 add。 --- @@ -62,7 +62,7 @@ python -m venv .venv .venv/Scripts/python.exe main.py web --reload ``` -### 能力探测 / DB 管理 +### 能力探测 / DB 管理 / 用户管理 ```bash # 实测对账模型 yaml 声称的能力(费 token,有 API 开销) @@ -72,16 +72,25 @@ python -m venv .venv .venv/Scripts/python.exe main.py db upgrade head .venv/Scripts/python.exe main.py db downgrade -1 .venv/Scripts/python.exe main.py db current + +# 发用户 — dev SPA 邮箱密码登录后端 bootstrap +.venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6" +# → [ok] user added email=alice@example.com user_id= +# 可选把已有 user_id(platform_key 入口创的)接到邮箱密码路径 +.venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id +# 撤用户(先清该 user 的 tasks,messages CASCADE) +# psql> DELETE FROM tasks WHERE user_id=(SELECT user_id FROM users WHERE email='alice@example.com'); +# psql> DELETE FROM users WHERE email='alice@example.com'; ``` **Auth**:两条 login 路径,签**同款 JWT**。所有 `/v1/tasks*` 需 `Authorization: Bearer `。 ```bash -# 路径 1:邀请码(dev SPA 给同事 / 自己试用 — 推荐) -curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_invite \ +# 路径 1:邮箱密码(dev SPA 给同事 / 自己试用 — 推荐) +curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_password \ -H "Content-Type: application/json" \ - -d '{"token":"<同事的邀请码>"}' -# → {"token":"eyJ...","expires_at":"...","user_id":"...","name":"alice","ttl_seconds":604800} + -d '{"email":"alice@example.com","password":"atLeast6"}' +# → {"token":"eyJ...","expires_at":"...","user_id":"...","email":"alice@example.com","ttl_seconds":604800} # 路径 2:platform_key + 指定 user_id(platform 服务端机器对机器入口) curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login \ @@ -94,10 +103,9 @@ TOKEN="eyJ..." curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/tasks ``` -**dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),login 表单填邀请码(需 `invites` 表里有对应行)进入 3 栏(task 列表 / chat / files)。给同事试用的最简发码流程: -1. `python -c "import secrets;print(secrets.token_urlsafe(16))"` 生成 token -2. PG 里 `INSERT INTO invites(token, name) VALUES('', '');`(每人一条;name 是显示名,推导固定 user_id 用 — 建议英文 / 拼音 / 短) -3. **不用重启** web(每次 login 都查 DB),把 URL + 各自 token 分别发给同事 +**dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),登录页两 tab(默认"邮箱密码",备用"UUID + PLATFORM_KEY")进入 3 栏(task 列表 / chat / files);last-used tab 持久化在 localStorage。给同事试用的最简发用户流程: +1. `.venv/Scripts/python.exe main.py user add --email <每人> --password <每人>` +2. **不用重启** web(每次 login 都查 DB),把 URL + 各自邮箱密码分别发给同事 **路由表**(全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `http://127.0.0.1:8765/docs`): @@ -108,7 +116,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta | `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 | | `GET /static/*` | dev.html 等静态文件 | 豁免 | | `POST /v1/auth/login` | platform 机器对机器入口;body `{user_id, platform_key}` → `{token,expires_at,user_id,ttl_seconds}` | 豁免 | -| `POST /v1/auth/login_invite` | dev SPA 邀请码入口;body `{token}` → `{token,expires_at,user_id,name,ttl_seconds}`;`invites` 表 token 未命中(含空表)→ 403 | 豁免 | +| `POST /v1/auth/login_password` | dev SPA 邮箱密码入口;body `{email, password}` → `{token,expires_at,user_id,email,ttl_seconds}`;邮箱不存在 / 密码错 / 未设密码统一 403 `invalid email or password` | 豁免 | | `POST /v1/tasks` | 创建 task,body `{name(req), working_dir?, description?, skill?}` | 必填 | | `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`page` 1-based,`page_size` 1–100;`working_dir` 末段名;`q` ILIKE name+desc;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 | | `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 | @@ -157,26 +165,27 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta | `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先点 stop / `POST /v1/tasks/{id}/cancel` 等流式 done 再 rename | | `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套(§7.4 no-subtask)。换一个不冲突的 new_name | | `main.py web` 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填。生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一,写进 `.env` 重起 | -| `/v1/auth/login_invite` 返 403 `invalid invite token` | `invites` 表无此 token(从未 INSERT 或已 DELETE)。`SELECT token, name, created_at FROM invites` 核对 | -| INSERT invites 报 `duplicate key value violates unique constraint` | token PK 或 name UNIQUE 撞,改值再试 — 重复 token = 两人同身份漏,重复 name = 两 token 共身份漏,都被 schema 拦 | -| 改了同事的 name 后他登不上 / 数据看不到 | user_id 由 `uuid5(namespace, name)` 推导,改 name 等于换身份(`UPDATE invites SET name=...` = 换 user)。要保留数据:别动 name,只 `UPDATE invites SET token=...`(换 token = 不换身份);真要换身份:告知"这是新账号,旧任务在另一个 user 下" | +| `/v1/auth/login_password` 返 403 `invalid email or password` | 邮箱在 users 表里不存在 / `password_hash` 列为空(platform_key 入口建的 user) / 密码错。`SELECT user_id, email, password_hash IS NOT NULL AS has_pw FROM users WHERE email=...` 核对;无行 → `main.py user add` 发新;有行无密码 → `UPDATE users SET password_hash=...` 手补 bcrypt(用 `.venv/Scripts/python.exe -c "from web.auth import hash_password;print(hash_password('xxx'))"` 算)或直接 `user add --user-id` 接到现有 user_id | +| `main.py user add` 报 `IntegrityError ... uq_users_email` | 邮箱已在 users 表里,改 email 或先 `DELETE FROM users WHERE email=...`(先清该 user 的 tasks);允许同邮箱不同 user 是漏 | +| `main.py user add` 报 `IntegrityError ... users_pkey` | `--user-id` 撞已有 UUID,换一个或不传 `--user-id` 让随机生成 | +| 改了某用户邮箱后他登不上 | 单纯改 `UPDATE users SET email=...` 不影响 user_id(行还是同一行,task 仍归属),用户用新邮箱登即可;若 lowercase 不一致(后端 `lower()` 后查)→ DB 里就该存小写。改密同理 `UPDATE users SET password_hash=` | | `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 `POST /v1/auth/login` 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` | | `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env | | dev.html SSE 收不到流(消息发出去但 UI 没动) | EventSource 不支持 header,dev.html 走 `fetch + ReadableStream`。看浏览器 devtools Network,POST /messages 是否 202 + Network 看 events_url GET 是否 200 + Content-Type 是 text/event-stream;若 401,token 过期了 — logout 重 login | -| dev.html 显示 "load failed" 且立刻回登录页 | token 过期或 JWT_SECRET 服务端变了,localStorage 旧 token 失效。已自动跳登录页,重新填 platform_key 即可 | +| dev.html 显示 "load failed" 且立刻回登录页 | token 过期或 JWT_SECRET 服务端变了,localStorage 旧 token 失效。已自动跳登录页,按上次用的 tab(邮箱密码 / UUID+PLATFORM_KEY)重登即可 | --- ## 关键路径与文件 -- **入口**:`main.py`(`web / db / probe` 三子命令)→ `core/agent_builder.py::build_agent`(装配 lib) +- **入口**:`main.py`(`web / db / probe / user` 四子命令)→ `core/agent_builder.py::build_agent`(装配 lib) - **核心**:`core/agent_builder.py`(build_agent / system prompt / validate_task_name 等装配 lib)/ `core/loop.py`(ReAct)/ `core/session.py`(PG messages)/ `core/task.py`(PG tasks)/ `core/llm.py`(LiteLLM 封装) - **工具**:`tools/{fs,shell,run_python,skill_tool}.py` - **存储**:`core/storage/{engine,models,utils}.py`(SQLAlchemy 2.x ORM)+ `db/migrations/`(alembic) - **Web**:`web/{app.py, auth.py, broker.py, sinks.py}`(FastAPI + /v1 JSON API + SSE + PLATFORM_KEY→JWT)+ `web/static/dev.html`(dev SPA,单文件 vanilla JS) - **配置**:`config/agent.yaml`(全局)/ `config/models/*.yaml`(模型档案,§3.2 Model Profile) - **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5) -- **Workspace**(per-user 子树,user_id 来自 JWT `sub` — 邀请码登录走 uuid5、platform 登录直传): +- **Workspace**(per-user 子树,user_id 来自 JWT `sub` — 邮箱密码登录从 users 表直读、platform 登录直传): - `workspace/users//.memory/{core.md, extended/}` —— 跨 task 记忆,FS 永久,dotfile 隔离 - `workspace/users///` —— 工作目录,用户起的目录名(API `POST /v1/tasks {working_dir?}`,留空 fallback name),同 working_dir 多 task 共享 diff --git a/core/agent_builder.py b/core/agent_builder.py index be3c16d..68a3a0c 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -6,8 +6,8 @@ workspace/users/// ← 工作目录(用户起名,可多 task 共享) workspace/users//.memory/{core.md, extended/} ← per-user 记忆(dotfile 隔离) -所有入口都走 web `/v1` + JWT(user_id = sub);dev SPA 走邀请码登录(`invites` 表, -name → uuid5)、platform 服务端走 platform_key 登录。task_id / user_id 全 UUID; +所有入口都走 web `/v1` + JWT(user_id = sub);dev SPA 走邮箱密码登录 +(`users.email/password_hash`,bcrypt)、platform 服务端走 platform_key 登录。task_id / user_id 全 UUID; state.json 已删除(元数据全在 PG)。 **新建 task 必须给 `name`**(任务显示名,DB 列 NOT NULL);**`working_dir` 可选** diff --git a/core/storage/models.py b/core/storage/models.py index b64ca8d..80a9575 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -1,11 +1,12 @@ """SQLAlchemy 2.x ORM models,对应 DESIGN.md §7.4 schema。 -4 张表:users / tasks / messages / invites。 -- users 行在 web 入口(`/v1/auth/login*`)按需 INSERT;user_id 由 invite name uuid5 推导 或 platform 直传 +3 张表:users / tasks / messages。 +- users 行在 web 入口按需 INSERT(`/v1/auth/login_password` 实际创行 / `/v1/auth/login` + platform_key 入口 ensure_user_row);email UNIQUE(0005)给 login lookup 用, + password_hash 是 bcrypt(`bcrypt.hashpw`),只在邮箱密码登录时有值 - messages.payload 用 jsonb,GIN 索引在 migration 里建 - run 状态承载在 tasks.run_status / run_error 两列(0004 合并 runs 表); 原 runs / usage_events 表 0004 删 — 详 DESIGN §7.4 取舍 / PROGRESS 05-18 -- invites 0005 加(token PK / name UNIQUE / created_at):dev SPA 邀请码登录后端 """ from __future__ import annotations @@ -35,7 +36,7 @@ class User(Base): __tablename__ = "users" user_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4) - email: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + email: Mapped[Optional[str]] = mapped_column(Text, nullable=True, unique=True) oidc_subject: Mapped[Optional[str]] = mapped_column(Text, nullable=True) password_hash: Mapped[Optional[str]] = mapped_column(Text, nullable=True) plan: Mapped[Optional[str]] = mapped_column(Text, nullable=True) @@ -94,19 +95,3 @@ class Message(Base): ) -class Invite(Base): - """dev SPA 邀请码登录后端表(0005)。 - - token 是 lookup 入口(PK);name 是显示名 + uuid5(NS, name) → user_id 推导源 - (UNIQUE 防同名 = 两 token 共身份);created_at 给审计。 - 管理:目前直接在 DB 里 INSERT/DELETE(`python main.py invite ...` 后续若需要再加)。 - """ - __tablename__ = "invites" - - token: Mapped[str] = mapped_column(Text, primary_key=True) - name: Mapped[str] = mapped_column(Text, nullable=False, unique=True) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - - diff --git a/db/migrations/versions/20260519_1100_0005_invites_table.py b/db/migrations/versions/20260519_1100_0005_invites_table.py deleted file mode 100644 index cf20e09..0000000 --- a/db/migrations/versions/20260519_1100_0005_invites_table.py +++ /dev/null @@ -1,43 +0,0 @@ -"""add invites table — dev SPA 邀请码登录改 DB 后端。 - -Revision ID: 0005 -Revises: 0004 -Create Date: 2026-05-19 - -接 05-19 邀请码登录(`/v1/auth/login_invite`):原 `ZCBOT_INVITES` env 字符串解析撤, -改 `invites` 表。最薄 schema: -- token PK — login 入口直接查这列 -- name UNIQUE — 推导 uuid5 用,同 name = 同 user_id,UNIQUE 防"两 token 同身份"漏 -- created_at — 审计 - -不存 user_id(由 uuid5(NS, name) 推导,namespace 固定不动);不存 revoked_at -(撤销直接 DELETE,5 人级别用户不要软删的额外分支)。 -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "0005" -down_revision: Union[str, None] = "0004" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "invites", - sa.Column("token", sa.Text(), primary_key=True), - sa.Column("name", sa.Text(), nullable=False, unique=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.func.now(), - nullable=False, - ), - ) - - -def downgrade() -> None: - op.drop_table("invites") diff --git a/db/migrations/versions/20260519_1500_0005_users_email_unique.py b/db/migrations/versions/20260519_1500_0005_users_email_unique.py new file mode 100644 index 0000000..70b775f --- /dev/null +++ b/db/migrations/versions/20260519_1500_0005_users_email_unique.py @@ -0,0 +1,30 @@ +"""users.email 加 UNIQUE — dev SPA 登录改 邮箱+密码(复用 0001 schema 的 email/password_hash)。 + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-05-19 + +dev SPA 登录从一日游的"邀请码"撤回到 0001 schema 早就预留的 email/password_hash 列。 +唯一缺的是 email 的唯一性约束 —— login lookup 入口必须保证一封邮箱对应一个 user_id。 +PG `UNIQUE` 对 NULL 不冲突,所以 platform_key 入口创的 user(email=NULL)不受影响。 + +注:前一个版本号 0005 短命过 invites 表,已在本次开发中 downgrade + 删 file 抹除痕迹, +本文件直接占用 0005,接 0004。 +""" +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0005" +down_revision: Union[str, None] = "0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_unique_constraint("uq_users_email", "users", ["email"]) + + +def downgrade() -> None: + op.drop_constraint("uq_users_email", "users", type_="unique") diff --git a/main.py b/main.py index e896456..70119e0 100644 --- a/main.py +++ b/main.py @@ -138,6 +138,56 @@ def probe(model: str, long_context: bool) -> None: console.print("\n[ok]全部能力声明与实测一致。[/ok]") +# ─────────────── User 管理(dev SPA 邮箱密码登录后端 bootstrap) ─────────────── + +@cli.group() +def user() -> None: + """用户管理:dev SPA 邮箱密码登录 bootstrap。需先 export ZCBOT_DB_URL。""" + + +@user.command("add") +@click.option("--email", required=True, help="登录邮箱(UNIQUE),登录页填这个") +@click.option("--password", required=True, help="明文密码,后台 bcrypt 哈希落盘") +@click.option("--user-id", default=None, + help="可选指定 UUID(默认随机);用于把已有 user_id 接到邮箱密码登录路径") +def user_add(email: str, password: str, user_id: str) -> None: + """新建用户:bcrypt(password) → INSERT users(email,password_hash[,user_id])。 + + email 撞 UNIQUE → 报错退出 2;user_id 撞 PK 也是。撤销直接 + `DELETE FROM users WHERE email='...'`(先清该 user 的 tasks,否则 FK 拦)。 + """ + from uuid import UUID as _UUID, uuid4 as _uuid4 + + e = email.strip().lower() + if not e or "@" not in e: + click.echo(f"[err] email 不合法: {email!r}", err=True) + sys.exit(2) + if len(password) < 6: + click.echo("[err] password 至少 6 字符", err=True) + sys.exit(2) + if user_id: + try: + uid = _UUID(user_id) + except ValueError: + click.echo(f"[err] user-id 不是合法 UUID: {user_id!r}", err=True) + sys.exit(2) + else: + uid = _uuid4() + + from core.storage import session_scope + from core.storage.models import User + from web.auth import hash_password + + try: + with session_scope() as s: + s.add(User(user_id=uid, email=e, password_hash=hash_password(password))) + except Exception as ex: + # IntegrityError(email UNIQUE / user_id PK 撞)等都走这条 + click.echo(f"[err] INSERT 失败: {type(ex).__name__}: {ex}", err=True) + sys.exit(2) + click.echo(f"[ok] user added email={e} user_id={uid}") + + # ─────────────── Web 服务 ─────────────── @cli.command() diff --git a/requirements.txt b/requirements.txt index 716e8e2..c81740a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,4 @@ fastapi>=0.111.0 uvicorn[standard]>=0.30.0 python-multipart>=0.0.9 # files upload multipart 解析 pyjwt>=2.8.0 # /v1/auth/login HS256 token mint/verify(§7 D' 过渡形态) +bcrypt>=4.1.0 # /v1/auth/login_password 密码哈希(users.password_hash) diff --git a/web/app.py b/web/app.py index 6ecdd9f..2bcb0f0 100644 --- a/web/app.py +++ b/web/app.py @@ -38,7 +38,7 @@ from core.storage import ( from core.storage.models import Message, Task from core.storage.utils import ensure_local_task_row -from .auth import AuthConfig, ensure_user_row, make_require_user, mint_token, resolve_invite +from .auth import AuthConfig, ensure_user_row, make_require_user, mint_token, resolve_user_by_email from .broker import broker from .sinks import WebEventSink @@ -267,8 +267,9 @@ class LoginRequest(BaseModel): platform_key: str -class InviteLoginRequest(BaseModel): - token: str +class PasswordLoginRequest(BaseModel): + email: str + password: str # ────────────────────── App 工厂 ────────────────────── @@ -346,7 +347,7 @@ def create_app() -> FastAPI: platform_key 错 → 403;user_id 非 UUID → 400。 user_id 未存在则幂等创建 users 行(避免下游 FK 失败)。 - platform 服务端用此入口注入指定 user_id;dev SPA 走 /login_invite。 + platform 服务端用此入口注入指定 user_id;dev SPA 走 /login_password。 """ if body.platform_key != auth_cfg.platform_key: raise HTTPException(403, "invalid platform_key") @@ -363,26 +364,26 @@ def create_app() -> FastAPI: "ttl_seconds": auth_cfg.ttl_seconds, } - @app.post("/v1/auth/login_invite", tags=["auth"]) - def login_invite(body: InviteLoginRequest): - """邀请码登录(dev SPA 给同事试用)。 + @app.post("/v1/auth/login_password", tags=["auth"]) + def login_password(body: PasswordLoginRequest): + """邮箱密码登录(dev SPA 给同事 / 自己试用)。 - - token 在 `invites` 表(0005)未命中 → 403(包括表为空 / 已 DELETE 的情况) - - 命中 → 由 name 推导 user_id (uuid5),幂等建 users 行,签 JWT - - 发码:DB 里 `INSERT INTO invites(token, name) VALUES('xxx','alice')`; - 撤销:`DELETE FROM invites WHERE name='alice'` + - users.email 未命中 / password_hash 为空 / bcrypt 校验失败 → 一律 403 + (不细分错因,防探测用户存在性) + - 命中 → 直接用 DB 里现成 user_id 签 JWT(不 ensure_user_row,行已在 `user add` 时建) + - 发用户:`.venv/Scripts/python.exe main.py user add --email X --password Y`; + 撤用户:`DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks) """ - hit = resolve_invite(body.token) + hit = resolve_user_by_email(body.email, body.password) if hit is None: - raise HTTPException(403, "invalid invite token") - name, uid = hit - ensure_user_row(uid) + raise HTTPException(403, "invalid email or password") + uid, email = hit token, exp = mint_token(auth_cfg, uid) return { "token": token, "expires_at": _dt.fromtimestamp(exp).isoformat(), "user_id": str(uid), - "name": name, + "email": email, "ttl_seconds": auth_cfg.ttl_seconds, } diff --git a/web/auth.py b/web/auth.py index fe51d6d..1a7aaa7 100644 --- a/web/auth.py +++ b/web/auth.py @@ -3,37 +3,38 @@ 模型: - `PLATFORM_KEY` env(必填):platform 服务端 / zcbot 间机器对机器共享密钥 - `JWT_SECRET` env(必填):HS256 签 token;泄漏 = 任意伪造,与 PLATFORM_KEY 同级保护 -- **`invites` 表**(0005)dev SPA 邀请码登录后端:token PK / name UNIQUE / created_at; - user_id 由 `uuid5(_INVITE_NAMESPACE, name)` 推导,重启稳定;改 name 会换身份(数据看不到)。 - 表空 → `/v1/auth/login_invite` 全 403(发码:`INSERT INTO invites(token, name) VALUES(...)`) - `POST /v1/auth/login {user_id, platform_key}` → JWT(platform 服务端用,自带 user_id 注入) -- `POST /v1/auth/login_invite {token}` → JWT(dev SPA 用,name → user_id 服务端推导) +- `POST /v1/auth/login_password {email, password}` → JWT + (dev SPA 用,users.email UNIQUE + users.password_hash bcrypt 校验;0005 加 UNIQUE) - 后续 `/v1/*`(除 /healthz、/docs、/openapi.json、/、/v1/auth/login*)走 `Authorization: Bearer ` - Token TTL: `ZCBOT_JWT_TTL_SECONDS` env 覆盖,默 7 天 -OIDC(D')替换:只动 `/v1/auth/login` 实现(校验 ID token 代替 key);invite 路径同期可下线。 +发用户:`.venv/Scripts/python.exe main.py user add --email X --password Y`,后台直接 +bcrypt + INSERT users;撤用户 `DELETE FROM users WHERE email=...`(messages CASCADE, +tasks 通过 FK 拦,要先 DELETE 该 user 的 tasks)。 + +OIDC(D')替换:只动 `/v1/auth/login` 实现(校验 ID token 代替 key);password 路径 +真发布时下线。 """ from __future__ import annotations import os import time from typing import Optional -from uuid import UUID, uuid5 +from uuid import UUID +import bcrypt import jwt from fastapi import Depends, HTTPException from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from core.storage import session_scope -from core.storage.models import Invite, User +from core.storage.models import User _DEFAULT_TTL_SECONDS = 7 * 24 * 3600 # 7d -# uuid5 命名空间 — 别改,改了所有邀请码用户身份漂移、历史数据全丢 -_INVITE_NAMESPACE = UUID("9b5e7a2a-3c8e-5f4d-8c1a-f0e6b9d7c3a1") - class AuthConfig: """App 启动时一次性读 env + 校验存在性;create_app 调 `AuthConfig.from_env()` 拿到。""" @@ -70,22 +71,43 @@ class AuthConfig: return cls(platform_key=key, jwt_secret=secret, ttl_seconds=ttl) -def resolve_invite(token: str) -> Optional[tuple[str, UUID]]: - """查 invites 表;命中返 `(name, user_id)`,user_id 由 uuid5(NS, name) 推导。 +def hash_password(password: str) -> str: + """bcrypt 哈希(默认 cost=12)。返 ASCII str(bcrypt 标准格式 `$2b$12$...`),直接落 DB。""" + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("ascii") - 返 None 表示 token 未命中(空串 / 不存在 / 已 DELETE 都走这条)。每次 login 一次 - SELECT,5 人级别用户开销可忽略;不缓存避免 DELETE 后还能登的不一致窗口。 + +def verify_password(password: str, stored_hash: str) -> bool: + """常数时间比对。stored_hash 是 DB 里 users.password_hash 列。""" + try: + return bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("ascii")) + except (ValueError, TypeError): + # stored_hash 格式坏(手工 INSERT 乱写)/ 不是 ASCII → 视作不匹配,别 500 + return False + + +def resolve_user_by_email(email: str, password: str) -> Optional[tuple[UUID, str]]: + """email + password → `(user_id, email)`;不匹配返 None(空表 / 邮箱不存在 / 密码错都走这条)。 + + 单次 SELECT + bcrypt verify;不缓存,改密码 / 删账号下次 login 立即生效。 + bcrypt.checkpw 本身是 constant-time;查不到也要跑一次 dummy hash 防 timing oracle + (5 人级别用户无所谓,但顺手做)。 """ - t = (token or "").strip() - if not t: + e = (email or "").strip().lower() + if not e or not password: return None with session_scope() as s: row = s.execute( - select(Invite.name).where(Invite.token == t) - ).scalar_one_or_none() + select(User.user_id, User.email, User.password_hash).where(User.email == e) + ).first() if row is None: + # 避免 timing oracle:用户不存在时也跑一次同等开销的 verify + bcrypt.checkpw(b"x", b"$2b$12$" + b"." * 53) return None - return row, uuid5(_INVITE_NAMESPACE, row) + if not row.password_hash: + return None # 用户存在但没设密码(platform_key 入口建的) + if not verify_password(password, row.password_hash): + return None + return row.user_id, row.email def mint_token(cfg: AuthConfig, user_id: UUID) -> tuple[str, int]: @@ -115,9 +137,8 @@ def verify_token(cfg: AuthConfig, token: str) -> UUID: def ensure_user_row(user_id: UUID) -> None: """幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。 - 邀请码登录(uuid5 推导)、platform_key 登录(显式传入)、未来 OIDC 都走这条 — - 新用户首次登录建行,既有用户复登 no-op。真用户 profile(email/oidc_subject 等) - 在 D' OIDC 阶段再走专门的 register/sync 路径。 + platform_key 登录入口用 — 平台直传的 user_id 可能是 zcbot 没见过的,首次登录建行 + 避免下游 FK 失败。邮箱密码登录走 `main.py user add` 已经写好 users 行,不走这条。 """ from sqlalchemy.dialects.postgresql import insert stmt = insert(User).values(user_id=user_id).on_conflict_do_nothing( @@ -157,8 +178,10 @@ def make_require_user(cfg: AuthConfig): __all__ = [ "AuthConfig", "ensure_user_row", + "hash_password", "make_require_user", "mint_token", - "resolve_invite", + "resolve_user_by_email", + "verify_password", "verify_token", ] diff --git a/web/static/dev.html b/web/static/dev.html index 23020e3..b6db3f3 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -64,6 +64,14 @@ #login label { display: block; margin-top: 10px; font-size: 12px; color: var(--muted); } #login .err { color: var(--accent); font-size: 12px; margin-top: 10px; min-height: 1em; } #login .actions { margin-top: 14px; display: flex; gap: 8px; } + #login .tabs { display: flex; border-bottom: 1px solid var(--border); margin: 0 0 12px; } + #login .tabs button { + background: none; border: none; border-bottom: 2px solid transparent; + padding: 6px 12px; font-size: 13px; color: var(--muted); cursor: pointer; + } + #login .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); } + #login .tab-body { display: none; } + #login .tab-body.active { display: block; } /* ───── 3-pane layout ───── */ #app { display: none; height: 100vh; } @@ -332,15 +340,37 @@

zcbot 登录

- - +
+ + +
+ + +
+ + + + +
+ 管理员发用户:python main.py user add --email X --password Y。 +
+
+ + +
+ + + + +
+ 平台服务端机器对机器入口;手动登录用于本地调试 / 接管已有 user_id。 +
+
+
-
- 邀请码由管理员在服务端 invites 表里分配,每人一码,丢失找管理员重发。 -
@@ -610,20 +640,62 @@ function highlightIn(container) { } // ───── login ───── +let loginTab = "pw"; // "pw" | "key";持久化 last-used tab 在 LS,刷新后默认那个 +const LS_TAB = "zcbot_login_tab"; +function switchLoginTab(name) { + loginTab = name; + document.querySelectorAll("#login .tabs button").forEach(b => { + b.classList.toggle("active", b.dataset.tab === name); + }); + document.querySelectorAll("#login .tab-body").forEach(b => { + b.classList.toggle("active", b.id === "body-" + name); + }); + localStorage.setItem(LS_TAB, name); + $("li-err").textContent = ""; + // 自动 focus 第一个空 input,Enter 直接登 + const firstInput = document.querySelector("#body-" + name + " input"); + if (firstInput) firstInput.focus(); +} +document.querySelectorAll("#login .tabs button").forEach(b => { + b.addEventListener("click", () => switchLoginTab(b.dataset.tab)); +}); +const savedTab = localStorage.getItem(LS_TAB); +if (savedTab === "key") switchLoginTab("key"); + $("li-go").onclick = doLogin; -$("li-token").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); }); +// 任意 input 上回车都触发登录 +document.querySelectorAll("#login input").forEach(i => { + i.addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); }); +}); async function doLogin() { - const token = $("li-token").value.trim(); $("li-err").textContent = ""; - if (!token) { - $("li-err").textContent = "请填邀请码"; - return; + let url, body, displayLabel; + if (loginTab === "pw") { + const email = $("li-email").value.trim(); + const password = $("li-password").value; + if (!email || !password) { + $("li-err").textContent = "请填邮箱和密码"; + return; + } + url = "/v1/auth/login_password"; + body = { email, password }; + displayLabel = "email"; + } else { + const uid = $("li-uid").value.trim(); + const pkey = $("li-pkey").value; + if (!uid || !pkey) { + $("li-err").textContent = "请填 user_id 和 PLATFORM_KEY"; + return; + } + url = "/v1/auth/login"; + body = { user_id: uid, platform_key: pkey }; + displayLabel = null; // 这条路径不返显示名,顶栏只显 uid 前 8 位 } try { - const r = await fetch("/v1/auth/login_invite", { + const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token }), + body: JSON.stringify(body), }); if (!r.ok) { const d = await r.json().catch(() => ({})); @@ -632,10 +704,14 @@ async function doLogin() { const data = await r.json(); state.token = data.token; state.userId = data.user_id; - state.userName = data.name || ""; + state.userName = displayLabel ? (data[displayLabel] || "") : ""; localStorage.setItem(LS_TOKEN, state.token); localStorage.setItem(LS_UID, state.userId); - if (state.userName) localStorage.setItem(LS_NAME, state.userName); + if (state.userName) { + localStorage.setItem(LS_NAME, state.userName); + } else { + localStorage.removeItem(LS_NAME); + } enterApp(); } catch (e) { $("li-err").textContent = e.message;