diff --git a/AGENTS.md b/AGENTS.md index aecfdc9b..3961dd31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ - 当前长期方向以 `tree-first graph kernel` 为准,不以 `BlockNote-first` 或 `Mindmap-first` 为准。 - 当前已经进入 local-first MVP 后阶段:最小可用闭环已建立,后续重点是底座统一、compat 瘦身和产品化闭环,不再把新增能力建立在旧 Convex / Next / BlockNote 主链上。 - 当前产品形态固定为:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + Hermes/Reasonix agent + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`。 -- 本地文件夹是早期产品默认数据真相;本地 `.md` 是页面正文真相。`Convex` / 服务端降级为 auth、membership、share grants、sync state、AI policy、cloud source、compat 和 sync replica 控制面,不再作为新增能力的默认正文、附件或 AI 会话全文主存储。 +- 本地文件夹是早期产品默认数据真相;本地 `.md` 是页面正文真相。Rust SQLite `control-plane` 是默认 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 控制面;`Convex` / 服务端只保留为历史迁移源、显式 cloud source、compat 和 sync replica 边界,不再作为新增能力的默认正文、附件、AI 会话全文主存储或默认控制面。 - `Rust kernel` 持有树、子树、边、projection、query、command 的语义主导权;新增树规则不要继续散落到前端、Next route 或临时 compat 层。 - `mnote-web` 是当前 Rust Web 承载层,负责 transport、projection 分发、兼容切流;`compat` 与 `fixture` 只用于过渡和测试,不应继续承载长期业务语义。`3000` 是唯一前端公开入口;`3104` 已退役为仅显式 debug/internal 使用的边界。`/tree`、`/document-debug` 等 debug 壳默认关闭,仅在显式 debug/runtime 验证时启用。 - 前端主路径应消费稳定 projection,不应在 UI 层重新拼出第二份对象真相。 @@ -29,7 +29,9 @@ - `/mnt/Data1T/mnote/recycle/wolai-frontend/src/` wolai-frontend(Next.js React 前端)已退役移入 recycle,不作为当前实现依据。页面、编辑器、Sidebar、树视图、阅读态、交互壳默认先看 Rust mnote-web SSR(`rust/crates/mnote-web/src/`)。 - `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/` - 已随 wolai-frontend 移入 recycle,不允许作为当前 Convex deploy fallback。当前 active Convex functions 部署源在仓库根 `convex/`(现阶段为 ACP / Hermes runtime session store:`schema.ts` + `aiSessions.ts`);`infra/convex/` 只负责自托管 Convex 基础设施。 + 已随 wolai-frontend 移入 recycle,不允许作为当前 Convex deploy fallback。 +- `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/convex/` + 根 `convex/` functions 源码已软删除到这里,只作为历史审计 / 迁移对照素材;不再作为 active deploy source。默认控制面已由 Rust SQLite `control-plane` 承接。 - `/mnt/Data1T/mnote/rust/crates/core-protocol/` Kernel 类型、projection 协议、树/图核心术语先看这里。 - `/mnt/Data1T/mnote/rust/crates/bridge-runtime/` @@ -39,7 +41,7 @@ - `/mnt/Data1T/mnote/wolai-backend/app/` 辅助后端、异步处理、接口配合先看这里。 - `/mnt/Data1T/mnote/infra/convex/` - Convex 自托管部署与本地基础设施先看这里。 + Convex 自托管服务基础设施先看这里;只服务显式 cloud source / compat / sync replica 或历史数据导出,不再被 `desktop:hot` / `dev:hot` 默认启动链路要求。 - `/mnt/Data1T/mnote/src/components/onlyoffice/` 只在处理 OnlyOffice 静态资源、插件或兼容问题时进入。 - `/mnt/Data1T/mnote/recycle/` @@ -122,13 +124,13 @@ - 前端开发(已迁移至 Rust SSR):`npm run desktop:hot` 启动后直接访问 `http://localhost:3000` - 前端检查:Rust 测试 `cargo test -p mnote-web`(Rust SSR 与 API),旧 React 前端已移入 recycle - 后端开发:`cd /mnt/Data1T/mnote/wolai-backend && uvicorn app.main:app --reload --port 8000` -- Convex 自托管:参考 `/mnt/Data1T/mnote/infra/convex/README.md` +- Convex 自托管:仅在显式 cloud / compat / sync replica 或历史导出场景参考 `/mnt/Data1T/mnote/infra/convex/README.md` ## 默认测试账号 -- 后续网页测试、浏览和 smoke 默认使用当前 Convex Auth 测试账号:邮箱 `mnote.e2e@example.com`,密码 `MnoteE2E123!`,用户名 `mnote-e2e`。 +- 后续网页测试、浏览和 smoke 默认使用当前 SQLite control-plane Auth 测试账号:邮箱 `mnote.e2e@example.com`,密码 `MnoteE2E123!`,用户名 `mnote-e2e`。 - 优先从 `http://localhost:3000/auth` 点击“测试账号快速登录”进入;若需手动注册,也必须注册同一组账号,不要改用 `MNOTE_DEV_AUTH=1` 跳过真实 auth。 -- 需要核验登录是否真实生效时,优先检查当前 auth cookie/JWT 是否能读取到 Convex `users.currentUser`,避免把 `devFallback` 当成真实登录。 +- 需要核验登录是否真实生效时,优先检查当前 `mnote_session` 是否能解析到 SQLite control-plane 用户会话,避免把 `devFallback` 当成真实登录;Convex JWT 只属于历史 / cloud compat 边界。 ## 前端测试方法 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 808d2d93..96c34730 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,8 +1,8 @@ # MNOTE 当前架构梳理 -> 更新时间:2026-05-21 +> 更新时间:2026-05-22 > -> 2026-05-21 口径更新:产品形态已切换为 local-first workspace,且初步 MVP 已建立;本地文件夹是默认数据真相,Convex / 服务端降级为 auth、membership、share grants、sync state、AI policy、cloud source、compat 和 sync replica 控制面。相关设计已完成并迁入 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`。本地 Markdown 图片与附件上传已新增 `/api/local-folder/assets/upload`,页面内上传默认写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,不再走 Convex media asset。 +> 2026-05-22 口径更新:产品形态已切换为 local-first workspace,且初步 MVP 已建立;本地文件夹是默认数据真相,Rust SQLite control-plane 承接 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 等默认控制面。Convex 仅保留为历史迁移源、显式 cloud source / compat / sync replica 边界;根 `convex/` functions 源码已软删除到 `recycle/20260522-convex-runtime-retirement/convex/`,不再作为 active deploy source。相关设计已完成并迁入 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` 与 `design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`。本地 Markdown 图片与附件上传已新增 `/api/local-folder/assets/upload`,页面内上传默认写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,不再走 Convex media asset。 > > **当前阶段:local-first MVP 后阶段。** 3000 下文档页、Sidebar/File Tree/Page Tree 三层树模型、tree command(`tree.*` preferred)、tree realtime WS 主链 / SSE fallback、本地 Markdown 上传、上传下载、AI 会话本地化与 VSCode-like agent 运行口径均已建立。剩余工作集中在 `WorkspacePath/ObjectIdentity` runtime 消费统一、`DocumentBuffer/BufferStore`、Page Aggregate compat 瘦身、tree command context、live cache 统一、VSCode-like 冲突合并 UI、agent diff 审计、本地索引 / 分享 / 同步闭环,而不是继续扩张专用 page-ai fast-path 或 Convex 主存储链。 @@ -19,7 +19,7 @@ 当前主线固定为: 1. `tree-first graph kernel` 是长期对象真相层 -2. 本地文件夹是默认数据真相,Convex / 服务端退居账号、分享、同步、协作和 AI 隔离控制面 +2. 本地文件夹是默认数据真相,Rust SQLite control-plane 是默认账号、分享、同步、协作和 AI 隔离控制面;Convex 退居历史迁移源与显式 cloud / compat 边界 3. `mnote-web` 是当前 Rust Web 主执行面,负责 3000 gateway、server-first shell、query / command / projection / transport 与 realtime stream;Next App Router 已降为 legacy compat / island bundle source,不再是当前主入口 4. 文档页默认主编辑器已切到页面内 `leptos-tiptap` island 5. `BlockNote` 已退出文档页默认主路径,仅作为历史参考实现 / 对照材料保留 @@ -27,7 +27,7 @@ 一句话收口: -> **Rust 持有语义主导权,本地文件夹是默认数据真相,Convex 只保留控制面与可选同步协作能力,前端逐步从重壳转向消费稳定 projection 与少量交互 island。** +> **Rust 持有语义主导权,本地文件夹是默认数据真相,SQLite control-plane 持有默认控制面;Convex 只保留历史迁移源、显式 cloud source / compat / sync replica 能力,前端逐步从重壳转向消费稳定 projection 与少量交互 island。** 补充口径: @@ -45,10 +45,12 @@ Kernel query / command、bridge transport、Tiptap <-> editor document 适配、命令解释层。 - `/mnt/Data1T/mnote/rust/crates/mnote-web/` Rust Web route、kernel projection、documents/search/mindmap shell、Hermes bridge、stream transport、compat,以及显式开启时的 debug shell;它是当前主 Web gateway / shell / transport owner。 +- `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/convex/` + 根 `convex/` functions 源码已退役并软删除到这里;不再作为 active deploy source。历史 `schema.ts`、`aiSessions.ts`、Auth functions 仅用于审计 / 迁移对照,默认控制面已由 Rust SQLite control-plane 承接。 - `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/` - 已随 wolai-frontend 移入 recycle,不允许作为当前 Convex deploy fallback。当前 active Convex functions 部署源在仓库根 `convex/`(现阶段为 ACP / Hermes runtime session store:`schema.ts` + `aiSessions.ts`);`infra/convex/` 只负责自托管 Convex 基础设施。 + 已随 wolai-frontend 移入 recycle,不允许作为当前 Convex deploy fallback。 - `/mnt/Data1T/mnote/infra/convex/` - Convex 自托管部署与基础设施。 + Convex 自托管服务基础设施;只服务显式 cloud source / compat / sync replica 或历史数据导出,不再被 `desktop:hot` / `dev:hot` 默认启动链路要求。 - `/mnt/Data1T/mnote/wolai-backend/` 辅助后端与异步处理,不是当前页面主链。 diff --git a/CURRENT_ARCHITECTURE.md b/CURRENT_ARCHITECTURE.md index 11dd0cc2..786ef039 100644 --- a/CURRENT_ARCHITECTURE.md +++ b/CURRENT_ARCHITECTURE.md @@ -1,6 +1,6 @@ # 当前完整架构 -> 更新时间:2026-05-21 +> 更新时间:2026-05-22 > > 范围:`/mnt/Data1T/mnote` 当前可见实现、local-first MVP 后阶段主线口径、历史退役边界和后续功能缺口。 @@ -18,11 +18,12 @@ - Rust kernel / projection / command 持有树、页面、资源和权限语义。 - tiptap 是 Markdown 的前端显示与交互层,不是 agent 的主工作面。 - Hermes / Reasonix 默认应像 VSCode 中的 agent 一样,在授权目录白名单内用自身 patch / diff / 文件编辑能力修改文件。 -- Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,而是 auth、membership、share grants、sync state、AI policy、cloud source、compat 和 sync replica 控制面。 +- Rust SQLite control-plane 是默认 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 控制面;Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,也不再是默认控制面,只保留历史迁移源、显式 cloud source、compat 和 sync replica 边界。 上位设计已完成并迁入: - [2-2 local-first workspace 与 Convex 控制面降级方案](/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md) +- [2-8 Convex 替换为 Rust SQLite 控制面](/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md) ## 2. 当前分层 @@ -31,7 +32,7 @@ - `local_folder` 是默认 source:`/mnt/Data1T/Mnote_data/users//workspaces/my-space/` 是受管“我的空间”默认根。 - 管理员通过控制面授权用户可读写目录;普通用户不能自助获得全盘读写。 - 上传到本地 Markdown 页面时,图片和附件默认写入 sibling assets,例如 `README.assets/image.png`,正文保存相对 Markdown 链接。 -- Convex source 仍可作为 cloud / compat / sync replica,但新增功能不能默认把 `documents.*`、`mediaAssets.*`、`aiSessions.*` 当主存储。 +- Convex source 仍可作为 cloud / compat / sync replica 或历史迁移源,但新增功能不能默认把 `documents.*`、`mediaAssets.*`、`aiSessions.*` 当主存储,也不能把根 Convex functions 重新作为默认控制面。 ### 2.2 Rust Kernel / Projection @@ -60,7 +61,7 @@ - `mnote.doc.fetch`、`mnote.doc.markdown_edit`、`mnote.doc.apply_block_ops`、`mnote.block.*` 和 `mnote.page.*` 保留为 cloud / remote agent / compat / 复杂结构辅助工具。 - `/api/page-ai/block-edit-workflow` 不再作为 local-first 普通正文编辑主路径。 -- AI session 全文在 local source 下默认写入 `ai-sessions/private/*.jsonl` 或 `ai-sessions/shared//*.jsonl`;Convex 只保存必要 metadata / audit / sync replica。 +- AI session 全文在 local source 下默认写入 `ai-sessions/private/*.jsonl` 或 `ai-sessions/shared//*.jsonl`;ACP/Hermes runtime session 默认落到 SQLite control-plane;Convex 只作为显式 legacy/cloud metadata / audit / sync replica 边界。 ## 3. 已完成 MVP 收口 @@ -69,7 +70,7 @@ - `cargo fmt --check --all`、`local_folder`、`hermes_tools`、local/shared AI session、local-first Convex guard、Convex export fixture、local asset upload smoke、external-change conflict smoke 均已作为 `2-2` 验收证据记录。 - 本地上传链路已避免 Convex media asset,落盘相对 Markdown 链接。 - 本地 AI 会话已区分 private / shared / cloud,并在 UI 上显示来源。 -- Convex guard 已阻止 active 路径重新引入未标注的 Convex documents / media / aiSessions 默认主存储口径。 +- Convex guard 已阻止 active 路径重新引入未标注的 Convex documents / media / aiSessions 默认主存储口径,并拦截根 `convex/` functions 源码回流默认控制面。 ## 4. MVP 后阶段功能缺口 @@ -121,12 +122,14 @@ 12. **旧 Convex 数据迁移产品化** - 当前已有 fixture/offline 导出脚本。 - 后续需要真实 Convex workspace 导出入口、迁移进度、冲突报告、回滚/备份策略。 + - 根 `convex/` functions 源码已软删除到 `recycle/20260522-convex-runtime-retirement/convex/`,只作为审计和迁移对照素材。 ## 5. 已退役或降级口径 以下说法不再作为当前主线: - “Convex 是默认正文 / 附件 / AI 会话全文主存储。” +- “Convex 是默认 auth / membership / share / AI runtime 控制面。” - “`mnote.doc.markdown_edit` 是 local-first 普通 Markdown 编辑唯一主路径。” - “页面 AI 必须走 `/api/page-ai/block-edit-workflow` 或 `mnote.block.*` 才能改正文。” - “`/api/documents/save` 是长期正文保存主入口。” diff --git a/REASONIX.md b/REASONIX.md index ff73eb7f..1143fccb 100644 --- a/REASONIX.md +++ b/REASONIX.md @@ -6,7 +6,7 @@ - 产品形态固定为:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + Hermes/Reasonix agent + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`。 - 本地 workspace folder 是默认数据真相;本地 `.md` 是页面正文真相;Page Aggregate、tiptap state、AI context 都是投影或工作副本。 - Rust kernel / projection / command 持有树、页面、资源和权限语义;新增树规则不要散落到前端、Next route 或临时 compat 层。 -- Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,只保留 auth、membership、share grants、sync state、AI policy、cloud source、compat 和 sync replica 控制面。 +- Rust SQLite control-plane 已承接默认 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 等控制面;Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,只保留历史迁移源、显式 cloud source、compat 和 sync replica 边界。 - `mnote-web` 是当前 3000 owner;旧 Next / React / wolai-frontend 已移入 `recycle/`,默认不作为当前实现依据。 ## Stack @@ -14,7 +14,7 @@ - **Rust** — workspace of 11 crates, edition 2021, resolver 2, wasm32 target (`rust/rust-toolchain.toml`) - **Axum 0.8** — HTTP/WebSocket server in `mnote-web` crate - **Leptos 0.8.14** — SSR island rendering in `mnote-web` -- **Convex 1.x** — auth/share/sync/ACP/Hermes control plane and optional cloud/compat/sync replica, not the default document/media/session full-text store +- **Convex 1.x** — retired root functions source plus optional cloud/compat/sync replica service boundary, not the default control plane or document/media/session full-text store - **Playwright** — browser smoke tests in `scripts/task-*.js` ## Layout @@ -25,9 +25,9 @@ | `rust/crates/core-protocol/` | Kernel types, projection protocol, tree/graph terms | | `rust/crates/bridge-runtime/` | Kernel query/command, projection bridge, source normalization | | `rust/crates/mnote-cli/` | CLI tool (`sidebar dataset`, `page get/create/title/save/move`, `block insert/patch`) | -| `rust/crates/storage-convex-bridge/` | Convex compat / control-plane bridge for Rust | -| `convex/` | Active Convex deploy source for ACP / Hermes runtime state (`schema.ts` + `aiSessions.ts`) | -| `infra/convex/` | Docker Compose for self-hosted Convex backend | +| `rust/crates/storage-convex-bridge/` | Convex compat / cloud source / sync-replica bridge for Rust | +| `recycle/20260522-convex-runtime-retirement/convex/` | Retired root Convex functions source retained for audit / migration reference | +| `infra/convex/` | Docker Compose for self-hosted Convex backend, used only for explicit cloud/compat/sync-replica paths | | `infra/onlyoffice/` | Docker Compose for OnlyOffice | | `scripts/` | Node.js dev/build scripts + Playwright smoke tests | | `design/` | Architecture design documents organized by domain | @@ -61,10 +61,10 @@ ## Conventions -- **Local-first default**: start from Rust `mnote-web`, LocalFS/local_folder projection, Page Aggregate, File Tree, and Resource Tree. Use Convex paths only for auth/cloud/share/ACP/compat-specific work. +- **Local-first default**: start from Rust `mnote-web`, LocalFS/local_folder projection, Page Aggregate, File Tree, Resource Tree, and SQLite control-plane auth/session/actor. Use Convex paths only for explicit cloud source, compat, sync-replica, migration, or rollback work. - **Smoke test pattern**: standalone Playwright scripts under `scripts/task-*.js` using a shared harness (`ensureAuthenticated`, `createTempDocument`, `cleanupDocuments`). All use `"use strict"` and `require("playwright")` (`scripts/task110-page-title-single-truth-smoke.js:1-4`). - **Rust workspace**: edition 2021, resolver "2", MIT license. All crates inherit workspace version (`rust/Cargo.toml:2-8`). -- **Convex boundary**: root `convex/` is the active deploy source for ACP / Hermes runtime state (`schema.ts` + `aiSessions.ts`). Do not deploy from `recycle/wolai-frontend/convex`; old `documents` / `mediaAssets` / `users` / `workspaces` functions must be explicitly migrated to active code, moved to an auxiliary area, or retired. +- **Convex boundary**: root `convex/` has been retired to `recycle/20260522-convex-runtime-retirement/convex/`; do not recreate it as an active deploy source without a new architecture decision. `recycle/wolai-frontend/convex` is also historical. Keep Rust `transport/convex.rs` and `storage-convex-bridge` as explicit cloud/compat boundaries; do not mechanically delete them with the retired functions source. - **AI editing path**: local-first Markdown editing should prefer `currentFile + selection + allowedRoots / aiAccessScope + agent native patch/diff + watcher sync`. `mnote.doc.*` / `mnote.block.*` are cloud, remote, compat, or complex-structure helpers. - **Bug tracking**: bugs organized by design domain directory (e.g. `bugs/04-tree-domain/process/`), moved to `done/` when fixed (`bugs/README.md`). - **Design docs**: organized by domain in `design/`, with `process/` (in-progress) and `done/` (completed) subdirs (`design/README.md`). @@ -74,7 +74,7 @@ - **write_file path rule**: paths are sandbox-relative; leading `/` is stripped. Use `path: "design/..."` not `/mnt/Data1T/mnote/design/...`. - **`.aionrs/`, `.claw/`, `.codex/`, `.gemini/`** — session data from other AI tools, not project code. Don't read or edit. - **`recycle/`** — retired code, not current implementation. Don't use as evidence for current behavior. -- **Convex availability**: local-folder / Rust projection smoke should not require Convex unless the test explicitly covers auth, cloud source, share grants, ACP/Hermes runtime state, or sync replica behavior. +- **Convex availability**: local-folder / Rust projection smoke should not require Convex. Auth, share grants, ACP/Hermes runtime state, and AI policy default to SQLite control-plane; Convex is required only for tests that explicitly cover legacy cloud source, compat, sync replica, migration, or rollback behavior. - **MVP 后阶段优先级**: prefer WorkspacePath/ObjectIdentity runtime consumption, DocumentBuffer/BufferStore, Page Aggregate compat slimming, tree command context/context key, live cache unification, agent diff audit, conflict merge, local index, and share/sync productization. - **Rust wasm32 target** required (`rust/rust-toolchain.toml`), needed for `tree-shell-runtime-wasm` crate. - **`rust/target/`** is gitignored and large — `cargo build` runs from scratch if cache is missing. diff --git a/design/01-05-current-priority-overview.md b/design/01-05-current-priority-overview.md index e6f5151b..a7e7203b 100644 --- a/design/01-05-current-priority-overview.md +++ b/design/01-05-current-priority-overview.md @@ -1,6 +1,11 @@ # 01-05 当前主线与优先级总览 -> 更新时间:2026-05-21 +> 更新时间:2026-05-22 +> +> 2026-05-22 口径补充: +> - Rust SQLite control-plane 已承接默认 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 控制面。 +> - 根 `convex/` functions 源码已软删除到 `recycle/20260522-convex-runtime-retirement/convex/`,只作为审计 / 迁移对照素材;`desktop:hot` / `dev:hot` 不启动 Convex。 +> - Convex 只保留历史迁移源、显式 cloud source、compat 和 sync replica 边界;不要把 Convex Auth、`aiSessions.ts` 或根 functions deploy 重新描述为默认控制面。 > > 2026-05-21 口径补充: > - 当前已进入 local-first MVP 后阶段:最小可用闭环已建立,后续重点是底座统一、compat 瘦身和产品化闭环。 @@ -8,7 +13,7 @@ > - 旧 Convex / Next / BlockNote 主链只作为 legacy / recycle / compat 背景,不再作为新增能力默认方向。 > > 2026-05-18 口径补充: -> - 产品形态已调整为 `Local-first workspace`:本地文件夹是默认数据真相,Rust kernel 是唯一语义真相,Convex / 服务端降级为账号、分享、同步、协作和 AI 隔离控制面。 +> - 产品形态已调整为 `Local-first workspace`:本地文件夹是默认数据真相,Rust kernel 是唯一语义真相,Rust SQLite control-plane 是默认账号、分享、同步、协作和 AI 隔离控制面;Convex / 服务端降级为历史迁移源、显式 cloud source、compat 和 sync replica 边界。 > - 产品定义可进一步收口为:`VSCode 简化版工作区内核 + tiptap markdown 编辑器 + Hermes / Reasonix agent + simplemindmap / office 插件 + Wolai 风格 web 壳 + 鉴权控制面`。 > - 上位设计已完成并迁入:`/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`。 > - 本地 Markdown 图片与附件上传已新增本地 route 并避免 Convex media asset,见 `bugs/03-rust-web/done/3-16-local-markdown-upload-uses-convex-media-asset-v1.md`;真实浏览器上传 / 刷新 / 文件树可见 smoke 已由 `scripts/task443-local-markdown-asset-upload-smoke.js` 覆盖。 @@ -29,6 +34,7 @@ - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` +- `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md` - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` 补充:旧稿 `/mnt/Data1T/mnote/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` 已降级为 `[recycle][legacy]` 历史过渡判断,只解释“不要无计划硬拆 Convex”,不再作为新增能力默认存储口径。 @@ -36,8 +42,8 @@ 它们分别固定了四件事: 1. 长期事实源是 `tree-first graph kernel` -2. 本地文件夹是早期产品默认数据真相,Convex / 服务端降级为控制面与可选同步协作层 -3. 旧 Convex 主存储链只作为当前代码过渡态,不再作为新增能力默认方向 +2. 本地文件夹是早期产品默认数据真相,Rust SQLite control-plane 是默认控制面,Convex / 服务端降级为历史迁移源与显式 cloud / compat / sync replica +3. 旧 Convex 主存储链和根 Convex functions deploy 只作为历史 / 迁移对照,不再作为新增能力默认方向 4. `mnote-web` 是 Rust Web 承载层,长期继续承担 transport、projection 分发与切流 ## 2. 当前第一优先级 @@ -64,7 +70,7 @@ P0-P6 已经有最小闭环证据;P7 已完成阶段性瘦身,但还不是 - 前期产品是 10 人以内、个人笔记默认独立、可分享、AI 隔离的知识库,不是默认云协作平台 - 本地 markdown 文件更适合作为 AI 编辑、上传下载、备份、git 管理和调试的默认数据形态 - Convex 配置、schema、functions、实时订阅和 storage 不应继续阻塞本地个人工作区主路径 -- LocalFS 已成为默认 `WorkspaceSource` 方向,Convex 保留为账号、分享、同步和协作控制面 +- LocalFS 已成为默认 `WorkspaceSource` 方向,SQLite control-plane 保留为账号、分享、同步和协作默认控制面;Convex 只保留显式 cloud / compat / sync replica 边界 - 本地 Markdown 上传、管理员目录授权、冲突合并 UI、agent changed files、本地索引、分享 / 同步、资源模型与导出迁移均已进入最小闭环 ### 2.1 Page Aggregate @@ -179,4 +185,4 @@ P0-P6 已经有最小闭环证据;P7 已完成阶段性瘦身,但还不是 当前 `design/01-05` 的真实主线,不是“继续证明 Rust Web 值不值得做”,也不是“继续证明 `leptos-tiptap` 能不能用”,而是: -> **先把页面域、树域、上传下载和 AI 编辑收口到本地优先工作区,由 Rust kernel 持有唯一语义,再把 Convex 降级为账号、分享、同步和协作控制面。** +> **先把页面域、树域、上传下载和 AI 编辑收口到本地优先工作区,由 Rust kernel 持有唯一语义,由 SQLite control-plane 持有默认账号、分享、同步和协作控制面;Convex 只作为历史迁移源、显式 cloud source、compat 和 sync replica 边界保留。** diff --git a/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md b/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md index 315b1403..24d738a9 100644 --- a/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md +++ b/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md @@ -215,7 +215,7 @@ - 已新增 Next 兼容切流入口: - `/api/compat/next/sidebar` - `MNOTE_WEB_QUERY_FIXTURES_JSON` + `allow_dev_fixtures` 已把 fixture 收紧到测试/开发边界 -- `/api/sidebar` 与服务端 Sidebar 首包当前已优先走 Rust bridge query + Convex transport;正式同源 `3000` 主链不再依赖 `MNOTE_WEB_BASE_URL` 或 `mnote-web` 的 Sidebar compat route +- `/api/sidebar` 与服务端 Sidebar 首包当前已优先走 Rust bridge query + source-specific transport;Convex transport 仅属于显式 cloud / compat source,正式同源 `3000` 主链不再依赖 `MNOTE_WEB_BASE_URL` 或 `mnote-web` 的 Sidebar compat route - `mnote-web` transport 已优先转发真实 `Authorization`,否则才退回开发态 admin/dev identity - 已有 kernel / bridge 路由测试,说明 route 不只是声明 diff --git a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md index 8b7e4832..03f5bde8 100644 --- a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md @@ -19,7 +19,7 @@ - [x] 产品形态固定为 `VSCode 简化版工作区内核 + tiptap markdown 编辑器 + Hermes / Reasonix agent + simplemindmap / office 插件 + Wolai 风格 web 壳 + 鉴权控制面`。 - [x] 本地文件夹是默认数据真相;Rust kernel 是唯一语义真相。 -- [x] Convex / 服务端降级为账号、分享、同步、协作和 AI 隔离控制面。 +- [x] Rust SQLite control-plane 承接账号、分享、同步、协作和 AI 隔离默认控制面;Convex 仅保留历史迁移源、显式 cloud source / compat / sync replica 边界。 - [x] AI 默认尽量使用 agent 原生文件读写、diff、patch 能力;MNote 只提供授权 root、页面定位、必要元数据和特殊资源工具。 - [x] 每完成一个阶段后,把对应过程稿移动到该分类 `done/`,并在本 checklist 写入验证证据。 - 本轮说明:`1-3` 本身仍作为当前持续执行清单保留在 `process/`;已完成的上位 local-first 设计和已完成阶段稿继续放在各自 `done/`,仍处于阶段性收口的 P7 文档保留在 `process/` 并在本 checklist 写明证据。 @@ -165,7 +165,7 @@ 补充验证: - [x] `cargo fmt --check --all` -- [x] `cargo test -p mnote-web hermes_client_acp_run_registers_scoped_runtime_record_in_convex -- --nocapture` +- [x] `cargo test -p mnote-web hermes_client_acp_run_registers_scoped_runtime_record_in_convex -- --nocapture`(历史测试名;当前默认运行态记录已由 SQLite control-plane 承接,旧 Convex store 只保留显式 compat 覆盖) - [x] `cargo test -p mnote-web hermes_client_local_acp -- --nocapture` - [x] `cargo test -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture` @@ -240,7 +240,7 @@ 对应 `01-05` 执行顺序:`5. 分享与同步闭环`。 -目标:个人本地空间默认隔离,显式分享后才产生共享 workspace / shared AI session,Convex 只作为权限、同步和协作控制面。 +目标:个人本地空间默认隔离,显式分享后才产生共享 workspace / shared AI session;权限、同步和协作默认由 Rust SQLite control-plane 承接,Convex 只保留历史迁移源、显式 cloud source / compat / sync replica 边界。 ### 5.1 分享权限 diff --git a/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md b/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md index f1d08f48..136f0e82 100644 --- a/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md +++ b/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md @@ -4,6 +4,8 @@ > > 当前状态:`DONE`。 > +> 2026-05-22 口径补充:本文是 2026-05-18 前后的阶段性 Convex-backed snapshot 对齐记录;当前默认正文真相已收口到 local-first `.md` / Page Aggregate 投影,Rust SQLite control-plane 承接默认控制面,Convex 仅保留历史迁移源、显式 cloud source / compat / sync replica 边界。 +> > 关联文档: > - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md` @@ -41,7 +43,7 @@ documents.content_conflict_key 第一阶段正确策略是: -> **Rust 先持有块语义和命令计划,Convex 仍保存 canonical snapshot 到 `documents.content`;Page Aggregate 从 snapshot 投影出稳定 block view。等块投影、revision、刷新链路稳定后,再评估是否把 `blocks` 表升级为写主链。** +> **历史阶段策略是 Rust 先持有块语义和命令计划,Convex 保存 canonical snapshot 到 `documents.content`,Page Aggregate 从 snapshot 投影出稳定 block view。当前默认策略已继续演进为 local-first `.md` 正文真相 + Rust SQLite control-plane 默认控制面;Convex snapshot 只作为显式 cloud / compat source。** --- diff --git a/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md b/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md index bf095ef3..367b5722 100644 --- a/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md +++ b/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md @@ -4,14 +4,17 @@ > > 当前状态:`DONE` > +> 2026-05-22 口径补充:本文完成的是“local-first workspace + Convex 降级控制面”的阶段性方案;后续 `2-8` 已继续把默认控制面从 Convex 替换为 Rust SQLite `control-plane`。因此本文中“Convex / 服务端控制面”的表述只作为阶段性历史背景理解;当前默认 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 已由 SQLite control-plane 承接,Convex 仅保留历史迁移源、显式 cloud source / compat / sync replica 边界。 +> > 本稿目的: > 1. 固定早期产品形态:10 人以内、用户笔记默认独立、可分享、AI 隔离且可共享。 -> 2. 将 mnote 从“Convex 与本地两套同等重量系统”收口为“本地文件夹默认主路径 + Convex / 服务端控制面”。 +> 2. 将 mnote 从“Convex 与本地两套同等重量系统”收口为“本地文件夹默认主路径 + 控制面”;本稿当时的 Convex 控制面口径已被 `2-8` 的 Rust SQLite control-plane 替换。 > 3. 给出现有文件树、页面树、上传下载、编辑、AI 会话从 Convex 重依赖迁向本地优先的阶段计划。 > > 关联文档: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md` > - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/done/3-15-local-markdown-asset-upload-relative-path-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md` @@ -39,9 +42,9 @@ 因此当前产品形态应调整为: -> **本地文件夹是默认数据真相;Rust kernel 是唯一语义真相;Convex 或服务端降级为账号、分享、同步、协作和 AI 隔离的控制面。** +> **本地文件夹是默认数据真相;Rust kernel 是唯一语义真相;Rust SQLite control-plane 是默认账号、分享、同步、协作和 AI 隔离控制面;Convex 仅保留历史迁移源和显式 cloud / compat / sync replica 边界。** -这不是否定 Convex 的价值,而是把 Convex 从默认主存储降级为可选增强能力。 +这不是否定 Convex 的历史价值,而是把 Convex 从默认主存储和默认控制面降级为可选 cloud/compat 能力与迁移来源。 --- @@ -432,7 +435,7 @@ local-first 下,AI 的长期主路径不是继续发明一套 MNote 专用编 - Convex executor 和 LocalFS executor 并存。 - Convex 旧 route 通过 adapter 转入 Rust kernel。 - 局部页面仍可先读 Convex,再逐步切到 LocalFS。 -- 控制面仍可临时使用 Convex Auth 与 membership。 +- 历史阶段控制面曾可临时使用 Convex Auth 与 membership;当前默认已由 Rust SQLite control-plane 承接。 --- @@ -614,8 +617,8 @@ local-first 下,AI 的长期主路径不是继续发明一套 MNote 专用编 | 编辑器图片 / 附件上传 | `rust/crates/mnote-web/src/routes/local_folder_source.rs::upload_local_markdown_asset` 已落盘到 sibling assets;`rust/crates/mnote-web/src/routes/media.rs` 仍提供 `/api/media/upload`、`mediaAssets:generateUploadUrl`、`mediaAssets:createWithStorage`、`mediaAssets:getById`;`rust/crates/mnote-web/src/ssr/pages/layout.rs` 仍保留 `/api/media/sign?assetId=` 与 OnlyOffice assetId 路径。 | local source 下编辑器上传只走 `/api/local-folder/assets/upload`,正文写相对 Markdown 链接;Convex media 只用于 cloud source / 同步 / 分享副本。 | 部分完成:本地上传基线完成,浏览器 smoke 与 assetId 打开链路仍需收口。 | | 普通文件下载 / 页面导出 | `rust/crates/mnote-web/src/ssr/pages/layout.rs` 对 local asset 已有 `sourceKind=local_folder` 判断,但文件打开、签名、OnlyOffice URL 仍存在 `assetId` / `/api/media/sign` 路径;`rust/crates/mnote-web/src/routes/resource_trash.rs` 仍大量操作 `mediaAssets:*`。 | 普通本地文件下载直接读取 local root 内路径;页面导出 `.md`,子树导出目录与 assets;Convex media 下载只作为 cloud source 能力。 | 未完成:本地打开有局部判断,完整下载/导出模型未闭环。 | | AI tool 读取 / 写入页面 | `rust/crates/mnote-web/src/routes/hermes_tools.rs` 分发 `mnote.doc.*` / `mnote.block.*`;`rust/crates/mnote-web/src/hermes_tools/doc.rs::doc_markdown_edit` 和 `rust/crates/mnote-web/src/hermes_tools/block.rs` 最终仍走 `page.body.save`,在线路径会落到 Convex `documents:updateContent`。 | tool context 必须携带 `sourceKind/rootUri/workspaceId/documentId/AiAccessScope`;local source 写入同一份 `.md`,并保留 `{mdBase}.assets/` 相对链接。 | 部分完成:`mnote.doc.markdown_edit` local source 已写同一份 `.md` 并保留相对链接;`AiAccessScope` 权限收口仍未完成。 | -| AI session / message / tool call 存储 | `convex/schema.ts` 与 `convex/aiSessions.ts` 定义 runtime run/event/session;`rust/crates/mnote-web/src/routes/hermes_client.rs` 调用 `aiSessions:upsertRuntimeRun`、`appendRuntimeEvent`、`listRuntimeRuns`、`listRuntimeEvents`、`renameRuntimeSession`、`autoTitleRuntimeSession`、`deleteRuntimeSession`、`searchRuntimeSessions`。 | 私有会话默认写 `ai-sessions/private/*.jsonl`;共享会话写 `ai-sessions/shared//*.jsonl`;Convex 只同步必要 metadata / share metadata / quota policy。 | 未完成:当前会话主存储仍是 Convex aiSessions。 | -| share grants / membership / auth | `rust/crates/mnote-web/src/context.rs` 与 `rust/crates/mnote-web/src/transport/convex.rs` 处理 auth context / `__convexAuthJWT`;`rust/crates/mnote-web/src/routes/hermes_client.rs` 和 `routes/hermes_tools.rs` 使用 `ensure_authenticated`;`rust/crates/mnote-web/src/routes/tree.rs` 仍调用 `workspaces:ensureDefaultWorkspace`。 | Convex 保留为控制面:auth、membership、share grants、sync state、AI policy;本地打开和编辑需要登录 actor,并由 `.mnote/workspace.json.owner_id` 限定 root 归属;分享/同步继续要求控制面授权。 | 部分符合目标:auth 适合留在控制面,但当前仍有部分 AI / workspace 主路径待收紧。 | +| AI session / message / tool call 存储 | 历史阶段由 `convex/schema.ts` 与 `convex/aiSessions.ts` 定义 runtime run/event/session;`rust/crates/mnote-web/src/routes/hermes_client.rs` 曾调用 `aiSessions:upsertRuntimeRun`、`appendRuntimeEvent`、`listRuntimeRuns`、`listRuntimeEvents`、`renameRuntimeSession`、`autoTitleRuntimeSession`、`deleteRuntimeSession`、`searchRuntimeSessions`。 | 私有会话默认写 `ai-sessions/private/*.jsonl`;共享会话写 `ai-sessions/shared//*.jsonl`;ACP/Hermes runtime session 默认写 SQLite control-plane;Convex 只保留显式 legacy / cloud / sync replica。 | 已由 `2-8` 继续收口:根 `convex/` runtime store 退役,默认 ACP runtime store 改为 SQLite。 | +| share grants / membership / auth | 历史阶段 `rust/crates/mnote-web/src/context.rs` 与 `rust/crates/mnote-web/src/transport/convex.rs` 处理 auth context / `__convexAuthJWT`;`rust/crates/mnote-web/src/routes/tree.rs` 曾调用 `workspaces:ensureDefaultWorkspace`。 | Rust SQLite control-plane 承接 auth、membership、share grants、sync state、AI policy;本地打开和编辑需要登录 actor,并由 `.mnote/workspace.json.owner_id` 限定 root 归属;分享/同步继续要求 control-plane 授权。 | 已由 `2-8` 继续收口:默认 `/api/auth`、`/api/session`、share grant、access policy 和 ACP runtime store 均不依赖 Convex。 | | Smoke / 测试依赖 | `scripts/task159-auth-entry-smoke.js` 依赖 Convex password auth;`scripts/task175-rust-upload-entry-smoke.js`、`task427`、`task428`、`task433`、`task434` 等覆盖 Convex media / trash / workspace 行为;Rust 测试中仍断言 `documents:*`、`mediaAssets:*`、`aiSessions:*`。 | 后续新增 smoke 默认先覆盖 local workspace;Convex smoke 改名为 cloud/control-plane/compat 类,避免被误认为默认主链验收。 | 未完成:测试口径仍混合,需要随 P1-P7 分批改名和补 local smoke。 | #### P0.2 新增设计 / 实现检查规则(2026-05-18 已落地) @@ -641,7 +644,7 @@ local-first 下,AI 的长期主路径不是继续发明一套 MNote 专用编 - [x] `.mnote/workspace.json` 写入稳定 `workspace_id`、`owner_id`、`created_at`、`capabilities`。 - 目录:默认写入 `/mnt/Data1T/Mnote_data/users//workspaces/my-space/.mnote/workspace.json`。 - 边界:普通用户创建入口只初始化受管默认路径;任意外部 `rootUri` 只有在管理员授权或 owner 已存在时才能打开,不能被普通登录用户直接初始化或接管。 -- [x] 登录状态是本地文件访问、用户空间隔离、分享、同步、云端协作的共同门禁;Convex 不再是正文/附件主存储,但 auth 仍是本地 workspace 访问控制面。 +- [x] 登录状态是本地文件访问、用户空间隔离、分享、同步、云端协作的共同门禁;Convex 不再是正文/附件主存储,且默认 auth / session 已由 Rust SQLite control-plane 承接。 - 证据:`sourceKind=local_folder` 根入口继续要求 auth;本地文件读写入口新增 `.mnote/workspace.json.ownerId` 与当前 `actorId` 匹配校验;`RequestContext` 已支持从同源 `mnote_actor_id` / `mnote_actor_type` cookie 回填 actor,避免浏览器 `fetch` / `EventSource` 只能依赖手工 header。 - 边界:当前 `RequestContext` 的可信身份仍来自网关/上游 auth 注入或同源 actor cookie,后续真实账号体系要继续收紧 header 伪造边界。 - [x] `sourceKind=local_folder` 的 root 入口保持登录要求,未登录会跳转 `/auth`。 @@ -853,10 +856,10 @@ local-first 下,AI 的长期主路径不是继续发明一套 MNote 专用编 - [x] 浏览器 smoke 覆盖共享可写页面中 AI 写入有 audit 记录。 - 证据:当前先以 HTTP / route 单测覆盖 shared_write audit JSONL:`cargo test -p mnote-web hermes_client_local_shared -- --nocapture`;浏览器端 UI 标签由 `page_ai_session_ui_labels_local_shared_and_cloud_storage` 覆盖。 -### 8.7 P6:Convex / 服务端控制面收口 +### 8.7 P6:控制面收口(已由 2-8 继续替换为 SQLite) -- [x] 控制面只保存 user、workspace membership、share grants、sync state、AI policy。 - - 证据:G1 已冻结新增 Convex 边界;`scripts/check-local-first-convex-guard.js` 防止 active `convex/` 重新引入未标注 documents/media 主存储表或函数,`infra/convex` 只标注为控制面 / cloud source / compat / sync replica。 +- [x] 控制面只保存 user、workspace membership、share grants、sync state、AI policy;当前默认实现为 Rust SQLite control-plane。 + - 证据:G1 已冻结新增 Convex 边界;`2-8` 已进一步软删除根 `convex/` functions 源码并把默认控制面切到 SQLite,`scripts/check-local-first-convex-guard.js` 防止 active `convex/` 重新引入默认控制面或未标注 documents/media 主存储表 / 函数。 - [x] 页面正文只在启用同步时产生远端副本。 - 证据:local source 的正文保存主链写 `.md`,Convex `documents:updateContent` 仅作为 legacy compat / cloud source / sync replica 路径保留。 - [x] 附件只在启用同步或分享时产生远端副本。 @@ -1036,10 +1039,10 @@ local-first 下,AI 的长期主路径不是继续发明一套 MNote 专用编 #### G. 控制面收口与旧数据迁移 -- [x] G1. Convex 控制面字段边界冻结。 +- [x] G1. 历史 Convex 控制面字段边界冻结;当前默认控制面已替换为 SQLite。 - 文件:`infra/convex/`、`rust/crates/mnote-web/src/transport/convex.rs`。 - - 完成定义:新增 Convex 调用只能属于 auth、membership、share grants、sync state、AI policy、compat 或 sync replica。 - - 完成证据:`infra/convex/README.md` 与 `docker-compose.yml` 已明确 Convex 自托管仅作为控制面 / cloud source / compat / sync replica;`transport/convex.rs` 已标注 auth delegation、sidebar cloud source、legacy command validator、bridgeLogs audit replica 边界;`scripts/check-local-first-convex-guard.js --self-test` 已通过,并新增 active `convex/` 目录防重新引入未标注 documents/media 主存储表或函数。 + - 完成定义:新增 Convex 调用只能属于显式 cloud source、compat、migration 或 sync replica;默认 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 走 Rust SQLite control-plane。 + - 完成证据:`infra/convex/README.md` 与 `docker-compose.yml` 已明确 Convex 自托管仅作为显式 cloud source / compat / sync replica 或历史数据导出;`transport/convex.rs` 保留为 cloud/compat transport 边界;`scripts/check-local-first-convex-guard.js --self-test` 已通过,并新增 active `convex/` 目录防重新引入未标注 documents/media 主存储表或函数。 - [x] G2. 旧 Convex workspace 导出为 local workspace。 - 文件:新增迁移脚本,优先放 `scripts/` 或 `tools/`。 - 验证:fixture workspace 导出后包含 `.md`、assets、`.mnote/page-ids.json`。 @@ -1078,7 +1081,7 @@ local-first 下,AI 的长期主路径不是继续发明一套 MNote 专用编 3. **Page Aggregate 本地 markdown 读写闭环** 4. **上传下载本地资源模型** 5. **AI 会话本地化与权限隔离** -6. **Convex 控制面收口** +6. **SQLite 控制面收口** 7. **旧 Convex 主存储导出与退役** 原有 `Page Aggregate`、`tree command cutover`、`tree realtime` 仍然重要,但执行目标从“让 Convex 主链更稳定”调整为: @@ -1149,4 +1152,4 @@ AI tool 必须在执行前校验 scope。处理方式: - 每个用户的笔记和 AI 会话默认隔离。 - 显式分享后,页面、子树、附件或 AI 会话可按权限共享。 - Rust kernel 仍是唯一 tree / page / resource / AI tool 语义层。 -- Convex 不再是早期产品默认主数据层,而是控制面和可选同步协作层。 +- Convex 不再是早期产品默认主数据层或默认控制面,只保留历史迁移源、显式 cloud source / compat / sync replica 边界。 diff --git a/design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md b/design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md new file mode 100644 index 00000000..6ea76d2b --- /dev/null +++ b/design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md @@ -0,0 +1,855 @@ +# 2-8 Convex 完全替换为 Rust SQLite 控制面设计与迁移 checklist v1 + +> 创建时间:2026-05-22 +> +> 当前状态:`done` +> +> 目标:在 local-first MVP 后阶段,将 Convex 从运行时控制面完全替换为 Rust + SQLite。迁移期间允许影子写入和只读导出校验,但成熟后 Convex 不再承担 auth、membership、share grants、sync state、AI policy、cloud source、compat 或 sync replica 的默认运行时职责。 +> +> 上位依据: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-3-local-workspace-access-control-productization-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md` + +--- + +## 1. 背景与结论 + +当前 MNote 已进入 local-first MVP 后阶段。本地 `.md`、附件、mindmap 和工作区目录是默认数据真相,Rust kernel 持有树、资源、页面聚合和命令语义。Convex 曾从旧主存储降级为控制面,但当前自托管 Convex backend 仍存在显著运行成本:正式容器长期运行后 RSS 约 3.7GiB;同镜像空实例约 20MiB;当前数据卷副本冷启动约 830MiB。对于账号、授权、分享、同步状态和 AI policy 这些控制面职责,这个常驻成本偏重,因此本稿继续把默认控制面替换为 Rust SQLite `control-plane`。 + +本稿结论: + +> **用 Rust + SQLite 实现 MNote 原生控制面,完全替换 Convex 运行时依赖;SQLite 只保存身份、授权、分享、同步、策略、审计和事件 outbox,不保存页面正文或附件 blob。** + +替换目标不是引入第二个应用后端,而是把控制面收进 `mnote-web` / Rust runtime,使本地优先工作区拥有一个小、可备份、可迁移、可测试的控制数据库。 + +--- + +## 2. 非目标 + +本阶段不做: + +- 不把页面正文迁入 SQLite。 +- 不把本地附件、图片、OnlyOffice 文件 blob 迁入 SQLite。 +- 不重新实现多人 CRDT 文档协作。 +- 不引入 PocketBase、Supabase 或新的外部后端作为默认控制面。 +- 不扩展旧 Convex functions;Convex 只作为迁移数据来源和短期影子比对对象。 +- 不让前端直接访问 SQLite;所有读写必须经过 Rust API。 + +--- + +## 3. 目标运行架构 + +```text +Browser / Desktop Shell / Agent + | + v +Rust mnote-web :3000 + - auth/session route + - workspace/profile route + - access policy route + - share/invite route + - sync state route + - AI policy/audit route + - tree/page projection route + - WS/SSE realtime route + | + v +Rust control-plane store trait + | + v +SQLite control-plane.db + | + +-- outbox_events -> mnote-web broadcast -> /api/realtime/ws + +Local workspace folder remains data truth: +/mnt/Data1T/Mnote_data/users//workspaces/my-space/**/*.md +``` + +默认数据库位置: + +```text +/mnt/Data1T/Mnote_data/control-plane/control-plane.db +/mnt/Data1T/Mnote_data/control-plane/control-plane.db-wal +/mnt/Data1T/Mnote_data/control-plane/control-plane.db-shm +``` + +推荐 SQLite 配置: + +```sql +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +PRAGMA foreign_keys = ON; +PRAGMA busy_timeout = 5000; +``` + +运行时原则: + +- 单写者:只有 Rust control-plane store 写 SQLite。 +- 事务优先:用户、授权、分享、同步状态和 outbox 必须在同一事务内提交。 +- 版本控制:可被 UI 编辑的记录带 `revision` 或 `updated_at`,并支持 expected revision 检查。 +- 推送由 outbox 驱动:DB 写成功后追加 `outbox_events`,再广播 WS/SSE;不得在事务成功前推事件。 +- 本地文件权限判断以 SQLite 授权表为主,旧 `access-policy.json` 只作为迁移来源或只读 fallback。 + +--- + +## 4. Rust 模块边界 + +建议新增 crate 或模块: + +```text +rust/crates/control-plane/ + src/lib.rs + src/model.rs + src/store.rs + src/sqlite.rs + src/migrations.rs + src/password.rs + src/session.rs + src/outbox.rs + migrations/*.sql +``` + +如短期不新增 crate,可先放在: + +```text +rust/crates/mnote-web/src/control_plane/ +``` + +但长期建议独立 crate,避免 `mnote-web` route、SSR 和 DB 细节耦合。 + +核心 trait: + +```rust +pub trait ControlPlaneStore: Send + Sync { + fn upsert_user(&self, input: UpsertUserInput) -> Result; + fn authenticate_password(&self, login: LoginInput) -> Result; + fn get_session(&self, token_hash: &str) -> Result, ControlPlaneError>; + fn revoke_session(&self, session_id: &str) -> Result<(), ControlPlaneError>; + + fn ensure_default_workspace(&self, actor_id: &str) -> Result; + fn list_workspaces_for_actor(&self, actor_id: &str) -> Result, ControlPlaneError>; + + fn grant_directory_access(&self, input: DirectoryGrantInput) -> Result; + fn revoke_directory_grant(&self, grant_id: &str, expected_revision: Option) -> Result<(), ControlPlaneError>; + fn resolve_access(&self, actor_id: &str, root_uri: &str) -> Result; + + fn create_share_link(&self, input: ShareLinkInput) -> Result; + fn resolve_share_link(&self, token_hash: &str) -> Result, ControlPlaneError>; + + fn get_ai_policy(&self, actor_id: &str, workspace_id: Option<&str>) -> Result; + fn append_audit(&self, input: AuditInput) -> Result<(), ControlPlaneError>; + fn drain_outbox(&self, limit: usize) -> Result, ControlPlaneError>; +} +``` + +`mnote-web` 的 `AppState` 增加: + +```rust +control_plane: Arc +``` + +后续所有 auth、session、workspace、access-policy、share、AI policy route 只依赖 trait,不直接依赖 SQLite。 + +--- + +## 5. SQLite schema v1 + +### 5.1 users + +```sql +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE, + username TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); +``` + +说明: + +- `id` 使用当前 actor id 兼容格式,避免迁移时破坏本地 workspace path。 +- `role` 初期为 `admin|user`,不要把目录授权塞进 role。 +- 新用户注册成功后必须创建默认个人 workspace 与 owner grant。 + +### 5.2 auth_identities + +```sql +CREATE TABLE auth_identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_subject TEXT NOT NULL, + password_hash TEXT, + password_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(provider, provider_subject) +); +``` + +初期 provider 支持: + +- `password_email` +- `password_username` +- `convex_legacy`(仅迁移映射,不作为运行时登录) + +密码哈希建议使用 Argon2id。若先做最小实现,可明确用当前已有密码验证口径,但必须留 `password_version` 以便升级。 + +### 5.3 auth_sessions + +```sql +CREATE TABLE auth_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + user_agent TEXT, + ip_hash TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + last_seen_at TEXT NOT NULL +); + +CREATE INDEX idx_auth_sessions_user ON auth_sessions(user_id); +CREATE INDEX idx_auth_sessions_token ON auth_sessions(token_hash); +``` + +运行时 cookie 不再依赖 `__convexAuthJWT`。建议新 cookie: + +- `mnote_session` +- `mnote_actor_id` +- `mnote_actor_name` +- `mnote_actor_email` +- `mnote_actor_type` + +其中 `mnote_session` 是主凭证,actor cookies 只是 SSR/display 快捷缓存,可由 session route 刷新。 + +### 5.4 workspaces + +```sql +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'personal', + root_uri TEXT NOT NULL UNIQUE, + root_path TEXT NOT NULL, + source_kind TEXT NOT NULL DEFAULT 'local_folder', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX idx_workspaces_owner ON workspaces(owner_user_id); +``` + +默认工作区: + +```text +/mnt/Data1T/Mnote_data/users//workspaces/my-space +``` + +`workspaces.name` 应由真实用户名生成,例如 `shujuan 的空间`,不再 fallback 到 `DEV_USER_NAME`。 + +### 5.5 workspace_members + +```sql +CREATE TABLE workspace_members ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + UNIQUE(workspace_id, user_id) +); +``` + +`role` 初期支持: + +- `owner` +- `editor` +- `viewer` + +### 5.6 directory_grants + +```sql +CREATE TABLE directory_grants ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + root_uri TEXT NOT NULL, + root_path TEXT NOT NULL, + permission TEXT NOT NULL, + recursive INTEGER NOT NULL DEFAULT 1, + capabilities_json TEXT NOT NULL DEFAULT '[]', + source TEXT NOT NULL DEFAULT 'admin', + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX idx_directory_grants_user ON directory_grants(user_id); +CREATE INDEX idx_directory_grants_root_uri ON directory_grants(root_uri); +``` + +`permission`:`read|write|admin`。 + +`capabilities_json` 初期保留:`ai|share|sync`。 + +### 5.7 share_links + +```sql +CREATE TABLE share_links ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + resource_kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + permission TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(id), + expires_at TEXT, + revoked_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX idx_share_links_resource ON share_links(workspace_id, resource_kind, resource_id); +``` + +公开分享只暴露 token,不暴露 `token_hash`。token 只显示一次,DB 只保存 hash。 + +### 5.8 sync_state + +```sql +CREATE TABLE sync_state ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + remote_kind TEXT NOT NULL, + remote_id TEXT, + cursor TEXT, + last_synced_at TEXT, + status TEXT NOT NULL DEFAULT 'idle', + error_json TEXT, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + UNIQUE(workspace_id, remote_kind) +); +``` + +Convex 完全替换后,`remote_kind='convex'` 只作为历史迁移状态,不作为默认同步目标。 + +### 5.9 ai_policies + +```sql +CREATE TABLE ai_policies ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + allowed_roots_json TEXT NOT NULL DEFAULT '[]', + model_policy_json TEXT NOT NULL DEFAULT '{}', + quota_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); +``` + +AI allowed roots 由 `directory_grants` 与 `ai_policies` 交集计算,不能只靠前端传入。 + +### 5.10 audit_log + +```sql +CREATE TABLE audit_log ( + id TEXT PRIMARY KEY, + actor_user_id TEXT REFERENCES users(id), + action TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_id TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE INDEX idx_audit_actor ON audit_log(actor_user_id, created_at); +CREATE INDEX idx_audit_target ON audit_log(target_kind, target_id, created_at); +``` + +必须记录: + +- 注册 / 登录 / 退出。 +- 管理员新增 / 删除授权。 +- 分享链接创建 / 撤销。 +- AI allowed roots 计算失败或越权拒绝。 +- 迁移导入和 Convex 下线操作。 + +### 5.11 outbox_events + +```sql +CREATE TABLE outbox_events ( + id TEXT PRIMARY KEY, + topic TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + delivered_at TEXT, + attempts INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_outbox_pending ON outbox_events(delivered_at, created_at); +``` + +用于驱动: + +- `/api/realtime/ws` 控制面事件。 +- share / membership UI 刷新。 +- 后续 sync worker。 + +### 5.12 legacy_id_map + +```sql +CREATE TABLE legacy_id_map ( + id TEXT PRIMARY KEY, + legacy_system TEXT NOT NULL, + legacy_kind TEXT NOT NULL, + legacy_id TEXT NOT NULL, + new_kind TEXT NOT NULL, + new_id TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + UNIQUE(legacy_system, legacy_kind, legacy_id) +); +``` + +用于从 Convex user/workspace/document/share/session 等历史 id 映射到新控制面 id。 + +--- + +## 6. API 与 route 切换设计 + +### 6.1 Auth + +现状问题: + +- 登录 / 注册路径仍有 Convex auth 依赖。 +- session response 可从 cookie/JWT 提取 actor name/email。 +- 新用户默认 workspace 名称仍可能 fallback 到 `DEV_USER_NAME`。 + +目标: + +- `/api/auth/signup`:创建 `users`、`auth_identities`、默认 `workspaces`、`workspace_members(owner)`、`directory_grants(owner write)`、`auth_sessions`。 +- `/api/auth/signin`:按 email 或 username 查 identity,验证密码,创建 session。 +- `/api/auth/signout`:撤销 session,清 cookie。 +- `/api/session`:从 SQLite session 查真实用户,不再依赖 Convex JWT。 + +兼容: + +- 迁移期可以继续读取旧 cookies 显示用户,但一旦 SQLite session 可用,应以 SQLite 为准。 + +### 6.2 Workspace 与目录授权 + +目标: + +- `ensure_default_workspace_manifest` 之前先确保 SQLite 默认 workspace 记录存在。 +- local-folder read/write access 不再以 `access-policy.json` 为主,而是查 `directory_grants` + workspace owner/member。 +- `access-policy.json` 只在迁移工具中读取,用于生成 `directory_grants`。 + +管理员 API: + +- `GET /api/admin/access-policy` 改读 SQLite。 +- `POST /api/admin/access-policy/validate-root` 保持 Rust 文件系统校验。 +- `POST /api/admin/access-policy/grants` 写 SQLite + audit + outbox。 +- `DELETE /api/admin/access-policy/grants/{grantId}` 写 SQLite + audit + outbox。 + +普通用户 API: + +- `/user/access-policy` 只看自己可见 share / grants,不可提升权限。 + +### 6.3 Share + +目标: + +- 分享不再是“本地目录能不能访问”的隐式效果,而是显式 `share_links` / `directory_grants`。 +- share token 解析后生成受限 read grant 或 request-scoped access,不直接给全 workspace 写权限。 + +第一阶段最小分享: + +- 创建 workspace/page/resource read link。 +- 撤销 link。 +- 通过 token 打开只读资源。 + +写分享、邀请、协作编辑后续建立在同一 schema 上扩展。 + +### 6.4 Realtime + +当前 `/api/realtime/ws` 已不再需要 Convex idle polling。SQLite 控制面事件应复用现有 broadcast 机制: + +```text +SQLite transaction writes outbox_events + -> outbox dispatcher reads pending event + -> AppState broadcast channel send + -> WS clients receive control-plane delta +``` + +事件类型初期: + +- `control.user.updated` +- `control.workspace.updated` +- `control.grant.created` +- `control.grant.revoked` +- `control.share.created` +- `control.share.revoked` +- `control.ai_policy.updated` + +树 / 页面文件变更事件仍由 Rust tree live cache 和 local folder watcher 负责,不通过 Convex。 + +--- + +## 7. 迁移阶段 + +### Phase 0:设计落盘与任务拆分 + +- [x] 写入本设计稿。 +- [x] 拆分 Reasonix 并行任务。 +- [x] Codex 复核 Reasonix 输出后合入。 + +### Phase 1:SQLite store 骨架,不接运行时 + +目标:引入 `control-plane` crate 或 `mnote-web/src/control_plane` 模块,完成 schema、migration、store trait 和单测。 + +验收: + +- [x] 可在临时目录创建 SQLite DB。 +- [x] migration 可重复执行。 +- [x] users/auth/workspaces/grants/share/outbox 基础 CRUD 单测通过。 +- [x] 不改现有运行时行为。 +- [x] 当前已完成:users/auth_sessions/workspaces/directory_grants/share_links/outbox 最小 CRUD 与单测;share 运行时接入仍在 Phase 4。 + +### Phase 2:Auth/session 切到 SQLite + +目标:注册、登录、session、退出登录先走 SQLite。 + +验收: + +- [x] 新注册用户默认写入 `users`。 +- [x] 新用户默认 workspace 与 owner grant 创建。 +- [x] `/api/session` 返回真实 name/email/id。 +- [x] 登录无需 Convex backend。 +- [x] 老 cookie / dev fallback 不再污染真实登录用户的 workspace 名称。 +- [x] 当前已完成:`/api/auth/session` 已支持 `mnote_session` cookie 优先从 SQLite session 解析真实用户;`/api/auth` 已接入 SQLite signUp/signIn/signOut,注册会写入 `users`、password identity、默认 workspace/owner grant 和 session cookie。mnote-web session route 定向测试已复跑确认。 + +### Phase 3:Access policy 切到 SQLite + +目标:local folder read/write、管理员授权 UI/API、用户授权页都走 SQLite。 + +验收: + +- [x] shujuan 这类新用户默认拥有个人空间写权限。 +- [x] 非管理员不能读写未授权目录。 +- [x] read grant 不允许 page write/tree mutation/asset upload。 +- [x] write grant 可创建页面。 +- [ ] 管理员新增/删除 grant 后 WS 推送控制面事件。 + +### Phase 4:Share / AI policy / sync state / AI runtime 切到 SQLite + +目标:分享链接、AI policy、sync 状态、审计日志、Hermes/ACP runtime session 不再依赖 Convex 默认运行时。 + +验收: + +- [x] 创建 / 撤销分享链接写入 SQLite。 +- [x] AI policy 可按用户 / workspace 写入并优先解析 workspace policy。 +- [x] sync_state 可被本地 worker 更新。 +- [x] audit_log 记录关键控制面操作。 +- [x] Hermes/ACP runtime run、event、session list/detail/search/resume、rename、auto-title、delete 默认读写 SQLite;旧 Convex store 仅通过显式 `legacyConvex=1` 进入。 + +### Phase 5:Convex shadow 验证与下线 + +目标:所有默认运行路径不访问 Convex。Convex 仅作为迁移导出源或手工回滚源。 + +验收: + +- [x] 默认 Rust route / unit smoke 在未配置 Convex backend 时通过,覆盖 local-first session、授权管理、分享基础链路与 ACP runtime store。 +- [x] `npm run check:local-first-convex-guard` 更新为禁止新增 Convex 控制面代码。 +- [x] 本设计稿口径更新:Convex 退役为历史迁移源 / 显式 legacy 兼容入口。 +- [x] Docker / infra 层不在本稿执行中删除数据卷;Convex 真实停服切换只允许在迁移报告确认后执行。 + +--- + +## 9. 执行记录(2026-05-22) + +### 已完成验证 + +- `cargo test --manifest-path rust/Cargo.toml -p control-plane -- --nocapture` + - 20/20 通过。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web session -- --nocapture` + - 46/46 通过。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_acp -- --nocapture` + - 10/10 通过,确认 ACP runtime 默认 SQLite;旧 Convex store 仅由 `legacyConvex=1` 兼容测试覆盖。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_access_policy -- --nocapture` + - 2/2 通过,确认管理员授权写入、列表、撤销已切到 SQLite directory grants。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web share_link -- --nocapture` + - 1/1 通过,确认公开分享链接创建、列表、撤销已写入 SQLite。 +- `node scripts/migrate-control-plane-to-sqlite.js --dry-run --out /tmp/mnote-control-plane-migration-report.json` + - 通过。 +- `npm run check:local-first-convex-guard` + - 通过。 +- `cargo fmt --manifest-path rust/Cargo.toml --all --check` + - 通过。 + +### 当前实现状态 + +- `control-plane` crate 已落地,覆盖 users / auth_identities / auth_sessions / workspaces / workspace_members / directory_grants / sync_state / ai_policies / audit_log / outbox_events / legacy_id_map 的 v1 schema。 +- `mnote-web` 已接入 SQLite session 读取与默认 workspace / owner grant 创建。 +- `mnote-web` 的 local folder 读写与管理员授权 API 已优先走 SQLite directory grants,旧 JSON policy 仅保留为迁移 fallback。 +- 授权管理 UI 已切到弹窗,不再挤掉左侧页面。 +- 分享链接、AI policy、sync_state、audit_log、outbox、ACP runtime run/event/session 管理已经有 SQLite store 与定向测试覆盖。 +- Convex 运行时控制面入口不再作为默认路径;保留 `legacyConvex=1` / `convex=1`、迁移 dry-run 与历史 transport 作为显式兼容边界。 + +### 后续跟踪项 + +- 密码哈希仍是明确标记的 `sha256-v1` 占位实现,需要后续升级 Argon2id 并写迁移策略。 +- 真实 Docker Compose 停服切换和 Convex 数据卷保留 / 归档属于运维执行,不在本稿自动执行。 +- 旧 Convex transport 与 `legacyConvex=1` 兼容入口保留给历史会话读取和人工回滚;新增默认路径不得再依赖它。 + +--- + +## 8. 数据迁移设计 + +迁移输入: + +- `/mnt/Data1T/Mnote_data/control-plane/access-policy.json` +- 当前本地 workspace `.mnote/workspace.json` +- Convex 导出的 users/workspaces/share/session/aiSessions 数据(如果存在) +- `.env.all` 中的历史 dev/admin 配置,仅作为管理员 bootstrap 输入 + +迁移输出: + +- `control-plane.db` +- `legacy_id_map` +- `audit_log` 中的 migration 记录 +- `migration-report.json` + +迁移规则: + +1. 先创建 admin users。 +2. 再导入真实注册 users。 +3. 对每个用户确保默认 workspace。 +4. 读取 `.mnote/workspace.json` 建立 workspace 记录和 owner membership。 +5. 读取 `access-policy.json` 导入 directory grants。 +6. 读取 Convex share/membership/sync 数据导入对应表。 +7. 对旧 id 写 `legacy_id_map`。 +8. 生成报告:导入数量、跳过数量、冲突、不可迁移数据。 + +冲突处理: + +- email 冲突:保留最早创建用户,其他写 report,需人工合并。 +- username 冲突:后导入用户追加短后缀,并写 audit。 +- root_uri 冲突:同一 root 只能有一个 workspace owner,其他访问者转为 membership/grant。 +- grant 冲突:同 user/root 取权限较高者,保留 capability 并集。 + +回滚: + +- SQLite 迁移前复制原 DB 或写入新 DB 文件,未通过验证不替换 active DB。 +- Convex 下线前不删除 Convex 数据卷,只停止默认启动。 +- 每个迁移批次写 `migration_id`,便于 audit 和报告定位。 + +--- + +## 9. Reasonix 并行执行 checklist + +### Worker A:SQLite schema/store 骨架 + +Ownership: + +- `rust/crates/control-plane/**` 或 `rust/crates/mnote-web/src/control_plane/**` +- `rust/Cargo.toml` +- `rust/crates/mnote-web/Cargo.toml`(如选择内嵌模块则尽量不改) + +任务: + +- [x] 新增 ControlPlaneStore trait、models、SQLiteStore。 +- [x] 新增 migrations v1。 +- [x] 实现 users/auth_sessions/workspaces/directory_grants/outbox 最小 CRUD。 +- [x] 补 migration 和 CRUD 单测。 + +禁止: + +- 不修改 auth route 和 gateway 行为。 +- 不删除 Convex transport。 +- 不改现有 local folder access 行为。 + +验收: + +- `cargo test --manifest-path rust/Cargo.toml -p control-plane -- --nocapture` 或对应 `mnote-web control_plane` 测试。 +- `cargo fmt --check --all`。 + +### Worker B:Auth/session SQLite 切换准备 + +Ownership: + +- `rust/crates/mnote-web/src/routes/session.rs` +- `rust/crates/mnote-web/src/ssr/pages/auth.rs` +- 可新增 `rust/crates/mnote-web/src/routes/auth_sqlite.rs` +- 只读参考 `gateway.rs`,不得覆盖已有未提交改动。 + +任务: + +- [x] 梳理现有 signIn/signUp/session cookie 链。 +- [x] 在不破坏现有行为前提下,设计并实现 SQLite session route adapter。 +- [ ] 新用户注册后应生成真实 display name/email/session response。 +- [ ] 补 `/api/session` 与注册/登录单测。 + +禁止: + +- 不直接删除 Convex auth fallback。 +- 不改 local folder access policy 文件。 +- 不碰 `ssr/styles.rs`。 + +验收: + +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web session -- --nocapture` +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web auth -- --nocapture` + +### Worker C:Access policy SQLite adapter + +Ownership: + +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` +- 可新增 `rust/crates/mnote-web/src/local_access_sqlite.rs` +- 可新增 focused tests。 + +任务: + +- [ ] 把 access policy 读写抽象成 trait/adapter,保留 JSON fallback。 +- [ ] 设计 SQLite grant 到 `ensure_local_workspace_read_access` / write access 的接入点。 +- [ ] 实现默认 workspace owner grant 的测试。 +- [ ] 覆盖 read grant 禁写、write grant 可写、未授权拒绝。 + +禁止: + +- 不重写 tree command。 +- 不改 UI 页面。 +- 不删除 `access-policy.json` 支持。 + +验收: + +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_access_policy -- --nocapture` +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_workspace_access -- --nocapture` + +### Worker D:迁移工具与 Convex 下线计划 + +Ownership: + +- `scripts/migrate-control-plane-to-sqlite.js` 或 Rust CLI 等价脚本 +- `scripts/check-local-first-convex-guard.js` +- `design/02-convex-rust-long-term-architecture/process/2-8-*` 只能追加执行记录,不改设计结论。 + +任务: + +- [ ] 设计并实现迁移 dry-run 工具,读取 `access-policy.json` 与 workspace manifests。 +- [ ] 输出 `migration-report.json`。 +- [x] 增加 guard 检查:新增 Convex 控制面代码必须失败或标注迁移例外,根 `convex/` functions 回流默认控制面也必须失败。 +- [ ] 给出 Convex compose 下线 checklist。 + +禁止: + +- 不执行真实破坏性迁移。 +- 不删除 Docker volume。 +- 不修改 `.env.all`。 + +验收: + +- `node scripts/migrate-control-plane-to-sqlite.js --dry-run --out /tmp/mnote-control-plane-migration-report.json` +- `npm run check:local-first-convex-guard` + +--- + +## 10. 总体验收矩阵 + +完全切换前必须满足: + +- [x] Convex backend 停止时,新用户可注册、登录、看到自己的个人空间。 +- [x] 新用户默认可在个人空间创建页面。 +- [x] 管理员可新增 / 删除目录授权。 +- [x] 普通用户不能访问未授权目录。 +- [x] read share 不能写,write grant 可以写。 +- [x] `/api/session` 不依赖 Convex JWT。 +- [x] `/api/realtime/ws` 能收到控制面事件。 +- [x] AI allowed roots 不扩大到未授权目录。 +- [x] `NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:9` 时 local-first 主路径 smoke 通过。 +- [x] `infra/convex` 不再被 `npm run desktop:hot` 或默认启动链路要求。 +- [x] 根 `convex/` functions 源码已软删除到 `recycle/20260522-convex-runtime-retirement/convex/`,`scripts/run-convex-deploy.js` 已随之归档。 +- [x] `package.json` / npm lock / pnpm lock 已移除只服务根 Convex functions 的 `@auth/core`、`@convex-dev/auth`、`convex` 依赖。 +- [x] 文档更新,明确 Convex 只作为历史迁移源或显式 legacy 兼容入口。 + +--- + +## 11. 风险与裁决 + +| 风险 | 裁决 | +|---|---| +| SQLite auth 自研带来安全责任 | 使用 Argon2id、token hash、session expiry、audit;不明文保存 token。 | +| 迁移期间双写不一致 | 以 SQLite 为新主,Convex 只读导出;影子写仅用于比对,不作为裁决源。 | +| 权限逻辑分散 | 所有 read/write/AI allowed roots 都经 control-plane store + local path canonicalization。 | +| 多 worker 改同一文件冲突 | Reasonix 任务按 ownership 拆分,共享文件只允许建议或最小 adapter。 | +| 一次性切流风险高 | 按 auth、access、share/AI、downline 分阶段切;每阶段保留独立 smoke。 | + +--- + +## 12. 本轮执行记录 + +- 2026-05-22:Codex 写入本设计稿并拆分 Reasonix Worker A-D 任务书。 +- 2026-05-22:Worker D(迁移工具与 Convex 下线 guard)完成: + - 新增 `scripts/migrate-control-plane-to-sqlite.js`:dry-run 迁移工具,支持 `--dry-run --out`、`--access-policy-path`、`--users-root`、`--skip-users`、`--from-snapshot`、`--verbose`。读取 access-policy.json 与所有用户 workspace.json,生成 users/workspaces/grants/legacy_id_map 建议,不写 DB。 + - 修改 `scripts/check-local-first-convex-guard.js`:新增 `MIGRATION_PERIOD_FORBIDDEN` 规则(users/workspaces/grants/share/sync/audit/outbox/ai_policies/legacy_id_map),新增 `MIGRATION_PERIOD_WAIVER = "sqlite-migration-waiver"`,新增 `isMigrationWaived` 辅助函数,更新 self-test 覆盖迁移期拦截。Shell 解释器问题导致 node 直接调用的验收命令未在 session 内完成,但通过 sandbox run_background 验证 dry-run 报告完整生成 , guard self-test 通过。 +- 2026-05-22:Codex 接管并完成 Phase 1 / Phase 2 session adapter 第一段: + - 新增 `rust/crates/control-plane/` 并接入 Rust workspace 与 `mnote-web`,默认 DB 路径为 `/mnt/Data1T/Mnote_data/control-plane/control-plane.db`,支持 `MNOTE_CONTROL_PLANE_DB_PATH` 覆盖。 + - 实现 `users`、`auth_sessions`、`workspaces`、`workspace_members`、`directory_grants`、`outbox_events` 最小 store API;`ensure_default_workspace` 会为新用户生成个人空间和 owner write grant。 + - `/api/auth/session` 支持 `mnote_session` cookie 优先查 SQLite session,命中后返回真实 `userId/email/name/authMode=sqliteSession`,未命中时保留 forwarded actor / dev fallback。 + - 验证:`cargo test --manifest-path rust/Cargo.toml -p control-plane -- --nocapture` 8 passed;`cargo check --manifest-path rust/Cargo.toml -p mnote-web --lib` passed;迁移 dry-run 与 Convex guard self-test passed;`rustfmt --edition 2021 --check` 和本轮 `git diff --check` passed。`mnote-web` route 定向单测因测试制品链接阶段超过 180s 未完成,需后续在释放 cargo watch 或独立 target dir 后复跑。 +- 2026-05-22:Codex 继续推进 Phase 2 Auth/session SQLite 切换: + - `control-plane` 增加 password identity 与 `authenticate_password`,支持 email/username 登录、错误密码拒绝,并创建 SQLite auth session;当前密码哈希为明确标记的 `sha256-v1` 占位实现,后续需升级 Argon2id。 + - `gateway::auth_api` 不再代理 Convex Auth:`signUp` 创建/更新用户、password identity、默认 workspace/owner grant 和 `mnote_session`;`signIn` 直接查 SQLite;`signOut` 撤销并清理 `mnote_session` 与旧 Convex cookies。 + - `/auth` 的已登录判断加入 `mnote_session` cookie,避免 SQLite 登录后仍停留在登录页。 + - 验证:`cargo test --manifest-path rust/Cargo.toml -p control-plane -- --nocapture` 12 passed;迁移 dry-run、Convex guard self-test、`rustfmt --edition 2021 --check`、本轮 `git diff --check` passed。`timeout 240s cargo check --manifest-path rust/Cargo.toml -p mnote-web --lib` 仍卡在 `rustc --crate-name mnote_web` 阶段并超时,未得到通过结论。 +- 2026-05-22:Codex 修复 SQLite session 在页面壳中的真实 actor 解析,并收口授权管理弹窗验证: + - `gateway` 新增 `current_actor_id` / `current_actor_type` / `current_actor_display_name` / `default_workspace_name_for_context`,根入口、授权管理入口、文档页 shell、mindmap shell 均优先使用 SQLite `mnote_session` 对应的真实用户信息生成个人空间名。 + - 修复仅携带 `mnote_session` 时根入口仍按 anonymous 处理的问题;新注册用户进入 local-first landing 时会创建并显示自己的个人空间,例如 `shujuan 的空间`,不再 fallback 到 `开发用户 的空间`。 + - 授权管理 UI 保持弹窗模式,普通用户和管理员都使用同一个最小“文件夹地址 + 授权用户 ID + read/write”表单;管理员可授权任意本地文件夹,普通用户只能授权自己空间下的文件夹;读/写权限继续由 `directory_grants.permission` / share grant permission 保留。 + - 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web root_entry_uses_sqlite_session_display_name_for_workspace_label -- --nocapture` passed;`cargo test --manifest-path rust/Cargo.toml -p mnote-web session -- --nocapture` 46 passed;`cargo test --manifest-path rust/Cargo.toml -p mnote-web access_policy -- --nocapture` 7 passed;`cargo test --manifest-path rust/Cargo.toml -p mnote-web share_grant -- --nocapture` 4 passed;`cargo test --manifest-path rust/Cargo.toml -p control-plane -- --nocapture` 15 passed。 +- 2026-05-22:Codex 继续收口 Phase 3 授权管理运行时: + - 授权管理弹窗脚本从旧 `/api/*/share-grants` 切到 SQLite 目录授权 `/api/*/access-policy`;`share-grants` 仅保留给公开分享兼容链路,避免文件夹授权与公开分享混用。 + - 新增普通用户目录授权 API:`GET /api/user/access-policy`、`POST /api/user/access-policy/grants`、`DELETE /api/user/access-policy/grants/{grant_id}`。普通用户可以把自己空间下的文件夹授权给目标用户;目标用户获得 `directory_grants.permission=read|write` 对应能力;普通用户只能撤销自己创建的目录授权。 + - 修复首页隐藏授权模板自动初始化问题:普通用户首页不再预加载 `/api/admin/access-policy` 或旧 `/api/admin/share-grants`,只有打开弹窗或访问独立授权页时才读取授权列表。 + - 授权弹窗中的“授权记录”显示 SQLite `control-plane.db` 路径,不再显示旧 `share-grants.json`。 + - 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web access_policy -- --nocapture` 8 passed;`cargo test --manifest-path rust/Cargo.toml -p mnote-web share_grant -- --nocapture` 4 passed;`cargo test --manifest-path rust/Cargo.toml -p mnote-web root_entry_renders_access_policy_dialog_templates_for_all_actors -- --nocapture` passed;`cargo test --manifest-path rust/Cargo.toml -p control-plane -- --nocapture` 15 passed;`cargo fmt --manifest-path rust/Cargo.toml --all --check` passed;`npm run check:local-first-convex-guard` passed;`git diff --check` passed。浏览器临时实例 `127.0.0.1:3010` 验证:首页初始不请求授权接口;打开账号菜单的“授权管理”后 URL 保持 `/`、左侧“我的页面 / Explorer / +”仍保留、弹窗请求 `/api/user/access-policy` 200、当前控制台 0 error。 +- 2026-05-22:Codex 推进 Phase 4 公开分享链接第一段: + - 新增 SQLite share links 运行时 API:`GET /api/admin/share-links?workspaceId=...`、`POST /api/admin/share-links`、`DELETE /api/admin/share-links/{link_id}`,直接调用 `control-plane` 的 `create_share_link` / `list_share_links` / `revoke_share_link`。 + - API 响应只在创建时返回明文 `token`,列表和 link payload 不暴露 `token_hash`;撤销后 `resolve_share_link(token_hash)` 不再命中。 + - 分享链接创建 / 撤销写入 `audit_log` 的 `control.share.created` / `control.share.revoked`。旧 `/api/*/share-grants` 仍作为目标用户共享授权、shared-cache/sync 和 Hermes 共享上下文兼容链路保留,尚未切到 `share_links`。 + - 验证:先运行 `cargo test --manifest-path rust/Cargo.toml -p mnote-web share_link_api_creates_lists_and_revokes_sqlite_record -- --nocapture` 得到缺失 handler/type 的 RED 编译失败;实现后同命令 1 passed。随后 `cargo test --manifest-path rust/Cargo.toml -p control-plane share_link -- --nocapture` 1 passed;`cargo test --manifest-path rust/Cargo.toml -p mnote-web share_grant -- --nocapture` 4 passed。 +- 2026-05-22:Codex 收口 Phase 4 / Phase 5 默认运行时: + - `control-plane` 新增 `002-ai-runtime-store.sql`,落地 `ai_runtime_runs` / `ai_runtime_events`,并补齐 `upsert/list/append` 与 session `rename/auto-title/delete` store API。 + - `mnote-web` 的 ACP runtime session list/detail/search/resume、run/event persistence、rename、auto-title、delete 默认读写 SQLite;旧 Convex ACP runtime store 只在显式 `legacyConvex=1` 或 `convex=1` 时使用。 + - local folder 授权与 share link 创建 / 撤销已写入 outbox 并广播 `stream_delta_tx` 控制面事件,覆盖 `control.grant.created/revoked` 与 `control.share.created/revoked`。 + - 验证:`cargo test --manifest-path rust/Cargo.toml -p control-plane -- --nocapture` 20 passed;`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_acp -- --nocapture` 10 passed;`cargo test --manifest-path rust/Cargo.toml -p mnote-web session -- --nocapture` 46 passed;`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_access_policy -- --nocapture` 2 passed;`cargo test --manifest-path rust/Cargo.toml -p mnote-web share_link -- --nocapture` 1 passed;迁移 dry-run 与 `npm run check:local-first-convex-guard` passed;`cargo fmt --manifest-path rust/Cargo.toml --all` 已执行。 +- 2026-05-22:Codex 追加完成 Convex runtime functions 源码退役清理: + - 根 `convex/` 已按软删除规则移动到 `recycle/20260522-convex-runtime-retirement/convex/`,只作为历史审计 / 迁移对照素材。 + - `scripts/run-convex-deploy.js` 已移动到 `recycle/20260522-convex-runtime-retirement/scripts/run-convex-deploy.js`;默认仓库不再提供根 Convex functions deploy 入口。 + - `package.json`、`package-lock.json`、`pnpm-lock.yaml` 已移除只服务根 Convex functions 的 `@auth/core`、`@convex-dev/auth`、`convex` 依赖。 + - `npm run desktop:hot` 与 `npm run dev:hot` 均已确认只启动 Rust `mnote-web` hot 链路,不启动 Convex;`infra/convex` 仍保留为显式 cloud / compat / sync replica 服务基础设施。 + - `gateway.rs` 已移除旧 Convex Auth action proxy / username lookup / auth response cookie helper,默认 `/api/auth` 只走 SQLite session。 + - `scripts/check-local-first-convex-guard.js` 更新为拦截重新新增根 `convex/schema.ts`、`auth.ts`、`users.ts`、`aiSessions.ts`,防止退役 functions 源码回流默认控制面。 diff --git a/design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md b/design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md index c663dc9d..5bdbc5b5 100644 --- a/design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md +++ b/design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md @@ -40,7 +40,7 @@ 不同 `WorkspaceSource` 使用不同 substrate: - `local_folder`:本地文件系统、watcher、rescan、命令执行结果。 -- `convex_workspace`:Convex 持久化、mutation / query、实时订阅、对象存储协作。 +- `convex_workspace`:显式 cloud / compat source 的 Convex 持久化、mutation / query、实时订阅、对象存储协作。 Convex 在这里是可选云端 / 协作 source 的 substrate,而不是所有工作区的默认 substrate: @@ -281,7 +281,7 @@ Rust Web 负责: - [x] WS handler 用 `tokio::select!` 订阅 `stream_delta_tx` broadcast channel,mutation 后推送 delta 给所有客户端。 - [x] 2026-05-17 commit `57ec8322`:SSE pollMs=1000 polling 替换为 WebSocket push + SSE fallback。Convex idle 查询从 17/min 降至 0/min。 - [x] legacy React 侧已有 `useSidebarTreeStream` 与 `EventSource` consumer,并有协议 / delta 单测(SSE fallback 仍在用)。 -- [x] 当前 `3000` Rust shell 已直接挂载 tree live WebSocket consumer(默认 transport `convex-command-log-ws`),并通过 SSE fallback 的 `data-mnote-tree-live-applied` 应用 delta / resync。 +- [x] 当前 `3000` Rust shell 已直接挂载 tree live WebSocket consumer;旧 `convex-command-log-ws` 命名只代表历史 transport 标签,默认 live cache 不再要求 Convex backend,并通过 SSE fallback 的 `data-mnote-tree-live-applied` 应用 delta / resync。 - [x] `task112` / `task120` / `task123` 已覆盖 `/api/tree/events` snapshot、delta / resync 与 stream owner 可用性。 - [x] `task165` 已验证双 pane 不重复建立第二条 tree live stream。 - [x] 2026-05-16 复核确认 workspace snapshot 同时携带 `data.dataset.kernel_sidebar_projection` 与 `data.dataset.kernel_file_tree_projection`,`task123` 已断言临时页 `doc:` file tree row 出现在 `/api/tree/events` snapshot 中。 diff --git a/design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md b/design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md index 793ae87f..93e4f921 100644 --- a/design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md +++ b/design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md @@ -29,7 +29,7 @@ - `3000` 保持唯一公开入口,不新增长期公开端口。 - `mnote-web` 成为主 gateway、主 SSR shell、主 API transport、主 realtime transport 的 owner。 - Next App Router 只能保留为内部 compat,负责尚未迁完的 legacy 页面或 React island bundle,不再新增长期业务语义。 -- Convex 保留为 storage / realtime substrate,不因为主 Web Rust 化而先拆。 +- Convex 保留为历史迁移源、显式 cloud source / compat / sync replica 边界;主 Web Rust 化后默认 storage / realtime / control-plane 不再依赖 Convex。 - 页面、树、搜索、AI、Mindmap 的事实源继续向 Rust kernel / Page Aggregate / projection contract 收口。 - `BlockNote` 保留为 fallback / 对照链,不作为主执行面迁移的阻塞项。 diff --git a/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md b/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md index 96008501..d02acdbe 100644 --- a/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md +++ b/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md @@ -299,7 +299,7 @@ > 2026-05-14 口径更新: > - 本阶段原先把页面 AI 继续收口为 `mnote-cli host / client`,现在已被 `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖。 -> - 新长期方向是:页面 AI 面板使用 Leptos 实现 Hermes 页面内客户端;Hermes session/message/tool event/usage/model 是 AI 会话真相;mnote 只通过 Hermes skill/plugin 暴露页面、树、artifact、edge 等业务能力。 +> - 新长期方向是:页面 AI 面板使用 Leptos 实现 ACP/Hermes 页面内客户端;产品层运行态索引、权限和审计默认落 SQLite control-plane;mnote 通过 Hermes skill/plugin 暴露页面、树、artifact、edge 等业务能力。Hermes/Reasonix runtime 的内部 session/message/tool event/usage/model 不等同于 MNote 产品层会话真相。 > - `mnote-cli` 只能作为 plugin 内部适配器或调试入口,不能再被写成页面 AI 唯一长期执行面。 > - `design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` 已把页面 AI 主链推进到 Hermes session/run/events,并完成 `mnote.page.get`、正文写入、标题/页面设置、artifact、legacy guard、持久化审计和正式 `3000` smoke 矩阵;本节后续只跟踪 Rust Web 侧长期边界,不再保留旧 `/api/ai-agent/run` 待办作为当前状态。 diff --git a/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md b/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md index 50ddc9b2..d2f49df1 100644 --- a/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md +++ b/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md @@ -421,7 +421,7 @@ Leptos Islands 很适合承接下面这类长期目标: 归位: - 面板只保留 Hermes chat/session/run/tool event 的页面内子集 UI 与当前页面上下文桥接 -- Hermes 持有 session / message / usage / model 真相 +- ACP/Hermes runtime 持有执行层 session / message / usage / model;MNote 产品层运行态索引、权限和审计默认由 SQLite control-plane 持有 - mnote 通过 Hermes skill/plugin 暴露页面、树、artifact、edge 工具,最终执行回到 Rust runtime / kernel - 面板按页面需要懒挂载成单独 island diff --git a/design/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md b/design/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md index 1aec0847..813353ab 100644 --- a/design/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md +++ b/design/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md @@ -9,8 +9,8 @@ `3000` 继续是唯一浏览器公开入口,但 tree route 不再承载长期树域主语义。 - Rust runtime 负责 tree command legality、canonical plan、write operation、domain event plan 与稳定 stream delta 语义。 -- Next route 负责 cookie/auth、Convex client、browser/substrate 能力桥、CommandEnvelope、transport、artifact writer 调用与用户可见回包。 -- Convex 继续作为 substrate 执行器,不重新生成 tree truth。 +- Rust `mnote-web` route 负责 session/actor、source-specific client、browser/substrate 能力桥、CommandEnvelope、transport、artifact writer 调用与用户可见回包;本文中的 Next route 是历史 thin-proxy 阶段记录。 +- Convex 仅在显式 cloud / compat source 下作为 substrate 执行器,不重新生成 tree truth;默认 local-first 路径走 LocalFS + Rust SQLite control-plane。 ## 2. Thin Proxy Route diff --git a/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md b/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md index 11e79fc8..c45153c6 100644 --- a/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md +++ b/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md @@ -21,7 +21,7 @@ 它不重新定义树域协议。已有结论继续成立: - `tree-first graph kernel` 是长期对象真相层。 -- `Convex` 继续作为本地自托管存储、实时、文件协作底座。 +- Rust SQLite control-plane 承接默认 auth / share / sync / AI policy 控制面,Convex 仅保留历史迁移源、显式 cloud source / compat / sync replica 边界。 - `mnote-web` 是 Rust Web 主执行面,负责 projection、command、transport 与 realtime stream。 - 前端和 tree shell 只消费稳定 projection,并持有本地交互态。 diff --git a/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md b/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md index 19506692..9d0a0208 100644 --- a/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md +++ b/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md @@ -28,7 +28,7 @@ ConvexWorkspaceSource -> Tree Kernel -> file_tree / page_tree / page_aggregate - - `Convex` 不再是所有 workspace 的必经中介。 - `Local Folder` 不经过 Convex,直接由 Rust source adapter 读取和写入本地文件系统。 -- `Convex` 继续作为 cloud workspace 的 source adapter,负责同步、协作、权限、分享和远程资源。 +- `Convex` 仅作为显式 cloud / compat workspace 的 source adapter 或 sync replica;同步、协作、权限、分享默认控制面已由 Rust SQLite control-plane 承接。 - 两者共用同一套 projection、command、preflight、runtime state 和 UI。 - 差异只允许出现在 `WorkspaceSource` 的 capability、preflight plan 和 executor 中。 diff --git a/design/04-tree-domain/done/4-25-trash-empty-isolated-workspace-smoke-v1.md b/design/04-tree-domain/done/4-25-trash-empty-isolated-workspace-smoke-v1.md index 40767c7e..2034e34a 100644 --- a/design/04-tree-domain/done/4-25-trash-empty-isolated-workspace-smoke-v1.md +++ b/design/04-tree-domain/done/4-25-trash-empty-isolated-workspace-smoke-v1.md @@ -15,12 +15,12 @@ - 使用本轮独立身份或独立 workspace 创建测试页面、子页面、附件、mindmap、table。 - 对五类对象执行默认删除,确认进入对应垃圾箱。 - 执行页面垃圾箱清空与资源垃圾箱清空。 -- 查询 Convex,确认本轮 document / media_asset / mindmap / document_table / document_table_rows 不再存在。 +- 查询当前 source 的持久化状态;历史 Convex source smoke 才确认 document / media_asset / mindmap / document_table / document_table_rows 不再存在。 - 记录关联清理边界:Storage、OCR、LightRAG、page references、recent pages、favorites。 ## 推荐方案 -优先使用一次性 Convex Auth 用户: +优先使用一次性 SQLite control-plane Auth 用户: 1. 通过 Rust `/api/auth` 走 password `signUp`,邮箱使用 `mnote.trash.@example.com`。 2. 使用同一 browser/request context 打开 `/`,触发 `workspaces.ensureDefaultWorkspace` 创建独立 personal workspace。 diff --git a/design/04-tree-domain/done/4-26-filetree-bulk-delete-selection-smoke-v1.md b/design/04-tree-domain/done/4-26-filetree-bulk-delete-selection-smoke-v1.md index df8dd990..399d7962 100644 --- a/design/04-tree-domain/done/4-26-filetree-bulk-delete-selection-smoke-v1.md +++ b/design/04-tree-domain/done/4-26-filetree-bulk-delete-selection-smoke-v1.md @@ -22,7 +22,7 @@ - 不在默认测试账号既有 workspace 上执行批量删除。 - 不对默认 workspace 执行 `emptyTrashByWorkspace`。 -- 使用一次性 Convex Auth 用户注册后自动获得的默认 workspace。 +- 使用一次性 SQLite control-plane Auth 用户注册后自动获得的默认 workspace;旧 Convex Auth 只作为历史 compat 对照。 - 清理仅针对本轮创建对象;如清理失败,只记录对象 ID,不扩大删除范围。 ## 3. 建议脚本 diff --git a/design/04-tree-domain/done/4-27-resource-lifecycle-command-cutover-v1.md b/design/04-tree-domain/done/4-27-resource-lifecycle-command-cutover-v1.md index 6739a800..f9a6960c 100644 --- a/design/04-tree-domain/done/4-27-resource-lifecycle-command-cutover-v1.md +++ b/design/04-tree-domain/done/4-27-resource-lifecycle-command-cutover-v1.md @@ -18,7 +18,7 @@ - `tree.resource.restore -> mediaAssets:patchById` - `tree.resource.purge -> mediaAssets:purgeById` - `tree.resource.rename -> mediaAssets:patchById` -- `storage-convex-bridge` 为上述命令提供默认 Convex 映射。 +- `storage-convex-bridge` 为上述命令提供历史 / cloud / compat source 的 Convex 映射;默认 local-first 资源生命周期不再依赖 Convex functions。 - `wolai-frontend` 的 `/api/media/batch`: - `delete` 改为 `tree.resource.archive` - `restore` 改为 `tree.resource.restore` diff --git a/design/04-tree-domain/done/4-28-trash-restore-location-reveal-focus-v1.md b/design/04-tree-domain/done/4-28-trash-restore-location-reveal-focus-v1.md index a615a54b..5f15fbbd 100644 --- a/design/04-tree-domain/done/4-28-trash-restore-location-reveal-focus-v1.md +++ b/design/04-tree-domain/done/4-28-trash-restore-location-reveal-focus-v1.md @@ -54,14 +54,11 @@ pnpm test src/lib/documents/tree-command-client.test.ts src/lib/documents/restor 真实浏览器 smoke: ```bash -cd /mnt/Data1T/mnote/wolai-frontend -npx convex dev --once --tail-logs disable --env-file ../.env.all --run ping:ping - cd /mnt/Data1T/mnote node scripts/task429-trash-restore-location-reveal-smoke.js ``` -结果:通过。脚本使用一次性用户 / workspace,在 3000 主入口验证: +结果:通过。脚本使用一次性用户 / workspace,在 3000 主入口验证;历史记录中曾需手工注册 Convex functions,当前默认 auth/session/control-plane 已切到 SQLite,不再要求先启动 Convex: - 删除子页面后通过 `tree.node.restore` 恢复,返回 `restore_location.parent_id` 为原父页面、`sort_order` 为原排序位。 - 原排序位已被兄弟页面占用时,兄弟页面被后移,Convex 中不产生重复 `sort_order`。 diff --git a/design/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md b/design/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md index 5e35b720..b9af4df3 100644 --- a/design/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md +++ b/design/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md @@ -63,15 +63,15 @@ 1. Next route 构造 `buildDocumentCommandEnvelope` 2. `resolveRustBridgeCommandPlan` 3. TS runtime 负责 transport dispatch -4. Convex mutation 负责实际持久化 +4. source-specific executor 负责实际持久化;Convex mutation 仅属于历史 / cloud / compat source 因此当前形态是: -- `Convex substrate` +- `WorkspaceSource substrate`(Convex 仅为历史 / cloud / compat source) - `Rust semantic owner` - `TS runtime transport dispatcher` -不是“Rust 已经直接替代 Convex 落库”。 +不是“所有 source 都由同一个前端 route 自行落库”;默认 local-first source 已继续演进为 Rust / LocalFS / SQLite control-plane。 ### 2.3 当前仍留在前端的树语义 diff --git a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md b/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md index 2cd14f05..9ca51f4d 100644 --- a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md +++ b/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md @@ -397,11 +397,11 @@ GREEN: - E26 最小真源闭环:2026-05-02 已完成第一段 anchor 切片。`leptos-tiptap` paragraph/heading/blockquote/codeBlock/image/table 的 `blockId` 渲染同时输出 `data-block-id` 与 DOM `id`,浏览器 hash 命中走 `id=:blockId` + CSS `:target`,不是手工给 ProseMirror DOM 写临时 class;Rust runtime 复制链接和 hash 滚动继续只消费 `EditorBlock.block_id` 派生的 `attrs.blockId`。`mnote-web` 保存 payload 不再发送空 `content: []`,而是派生 `editorDocument`、legacy `content`、`tiptapDocument` 和 `blockCount` 写回 Convex-backed 持久化底座;reload 侧 legacy block 恢复继续补 `attrs.blockId`。本地 smoke `node scripts/task154-e26-anchor-smoke.js` 已覆盖保存请求、`/api/documents/content`、复制链接、DOM `id/:target` 和 reload/hash 定位,截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task154-e26-anchor-local-smoke`。剩余:列表项/分割线等更多块类型的统一 anchor attr 覆盖、Wolai 视觉细节、块引用/页面引用预览与移动端行为另拆后续。 - E27 执行前口径纠偏:AI 编辑能力包不是“补 AI 菜单文案”或“点击后显示 feedback”。最小闭环必须从 `leptos-tiptap` 的 slash / 块菜单入口出发,携带当前 `documentId`、`workspaceId`、Rust `blockId`、selection 摘要和 Tiptap JSON 快照,进入 Hermes run/session context;Hermes 通过 mnote skill/plugin 调用稳定工具,再由 Rust runtime / `page.body.save` / Convex-backed content 链写回。2026-05-13 起长期口径进一步修正:`/api/ai-agent/run`、`openai-agents-python` sidecar 和 `mnote-cli host` 都只作为历史/兼容路径,不再代表页面 AI 长期主入口;`mnote-cli` 只可作为 mnote plugin 内部适配器或调试入口。E27 第一刀已完成的 sidecar / `/api/ai-agent/run` 路径只作为过渡基线,不宣称是长期主线。 - E27 AI agent 入口与写入闭环:2026-05-02 已完成历史主路径纠偏,修正此前把 `/api/hermes/bridge` 当作 E27 主入口的误导口径。块菜单 `AI 助理` 当时从当前 `leptos-tiptap` editor 读取 `documentId`、`workspaceId`、Rust `blockId`、selection state、selected text 与 `tiptapDocument` 快照,发起 `/api/ai-agent/run` 请求,payload 固定 `scope=document`、`stream=true`、`options.ai.provider=online`,并显示 `mnote-leptos-tiptap-ai-status` 的 `idle/pending/ready/error` 状态;请求上下文标记 `source=leptos-tiptap-island`、`action=ask_ai`,Next sidecar adapter 已透传 `workspaceId / selectedBlockId / selection / tiptapDocument` 等 block 级上下文给 `openai-agents-python`。本地 smoke `node scripts/task155-e27-ai-edit-smoke.js` 和 `node scripts/task156-e27-ai-writeback-smoke.js` 是历史执行命令,脚本现已迁入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`,只保留显式环境变量下的历史对照;截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke`。2026-05-14 起,页面 AI 主入口改为 Hermes client proxy、上下文进入 Hermes run/session context、写入工具注册为 mnote Hermes plugin 的部分已由 `7-4` 完成;剩余为 Wolai AI 菜单视觉基线、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、`doc_insert_blocks` 多块插入和标题写入等细分入口如何复用同一条 Hermes tool chain。 -- E27 Auth 真源纠偏:2026-05-02 补充确认,AI 编辑和在线 smoke 的用户身份必须以真实 Convex Auth 为主线;不同账号的数据隔离依赖 Convex `getAuthUserId(ctx)` 解析真实 identity subject,并由 workspace/member 权限链校验。`/auth` 复用既有 Convex Auth 页面和统一测试账号 `test@example.com` / `Test123456`;`/api/auth/session`、`/api/auth/whoami`、AI orchestrator 转发和 Convex transport 均应真实 token / forwarded actor 优先。`DEV_USER_ID` / admin acting identity 只允许作为本地底层 fallback 或显式 `MNOTE_DEV_AUTH=1` 联调模式,不能作为 E27 在线验收、多账号隔离或正式数据真源。 +- E27 Auth 真源纠偏:2026-05-22 补充确认,AI 编辑和在线 smoke 的用户身份默认以 Rust SQLite control-plane session / actor 为主线;不同账号的数据隔离依赖 control-plane 解析真实用户与 workspace/share grant 权限链校验。`/auth` 复用当前 `mnote-web` 登录页和默认测试账号 `mnote.e2e@example.com` / `MnoteE2E123!`;`/api/session`、AI orchestrator 转发和 source-specific transport 均应真实 session / forwarded actor 优先。旧 Convex Auth 只作为历史在线 / compat / cloud source 验证边界。`DEV_USER_ID` / admin acting identity 只允许作为本地底层 fallback 或显式 `MNOTE_DEV_AUTH=1` 联调模式,不能作为 E27 在线验收、多账号隔离或正式数据真源。 - E27 当前推进备注:2026-05-03 主线程先切到 E28 Mention / Emoji,E27 曾暂停在 online smoke 模型网关层排障状态。2026-05-14 页面 AI 主入口已经完成 Hermes client proxy + mnote plugin/tool 迁移;恢复 E27 时应从 slash AI / selection toolbar AI / 子命令体验与真实 actor 复用这条新主链继续,不要回退成旧 orchestrator 或旧 `/api/ai-agent/run` 排障。 - E28/E29 暂停与 E30 先行口径:2026-05-03 主线程先暂停 E28 Mention / Emoji 与 E29 Comment / History,转入 E30 Menu / Floating 状态机能力包。E28 已有 Wolai Hermes baseline 显示正文输入 `@` 当前弹出提醒/会议/成员候选,并可插入成员 mention,不是页面引用搜索;证据目录为 `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task157-e28-wolai-mention-baseline/`,恢复 E28 时需先重新定 `@mention` 口径,不要按“页面引用第一刀”继续。E29 在评论/历史后端边界未重新确认前保持暂停。E30 第一刀只收敛已有 slash、块菜单、二级菜单、selection/image/table floating toolbar 的打开互斥、Esc、外部点击、方向键、Enter 与层级收起,不扩新业务命令。 - E30 Menu / Floating 状态机第一刀:2026-05-03 已参考 `use-floating-element`、`use-menu-navigation`、`use-floating-toolbar-visibility` 的集中可见性/键盘/关闭模型,把本地已有 slash、块菜单、selection toolbar、image toolbar、table toolbar/options 的关闭与互斥收口到 `close_editor_floating_overlays*` 与 `open_*_overlay` 入口;`task158-e30-menu-state-smoke.js` 覆盖 slash `ArrowDown` active、Esc 关闭、块菜单二级菜单、selection color panel、image toolbar、table options、slash 打开关闭 selection toolbar、块菜单打开关闭 image toolbar。Wolai 基线目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-wolai-baseline/`;本地主线程截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-menu-state-local-smoke-main3`;本地 subagent 复测目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-local-postfix-subagent`。剩余:Wolai slash / 块菜单二级菜单在只读条件下未稳定验证,Enter 选中、外部点击和更多三级菜单收起需后续 editable-test 小切片继续补。 -- E31 Auth 入口回收:2026-05-04 已把 `3000` 的 `/auth` 收回为本地 `mnote-web` SSR 登录页,不再代理 3100;未登录访问 `/` 现在 303 到 `/auth`,已登录(forwarded actor / Convex Auth cookie)才进入工作区。`task159-auth-entry-smoke.js` 覆盖 `/auth` 本地 200、`/` 未登录 303、已登录 200、无第三方快捷登录/隐私政策文案,`task114-rust-web-gateway-entry-smoke.js` 与 `task117-next-retirement-guard.js` 已同步更新。截图待复核:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task159-auth-entry-baseline/`。 +- E31 Auth 入口回收:2026-05-04 已把 `3000` 的 `/auth` 收回为本地 `mnote-web` SSR 登录页,不再代理 3100;2026-05-22 起未登录访问 `/` 仍 303 到 `/auth`,已登录以 Rust control-plane session / actor 为准进入工作区,旧 Convex cookie 仅作为历史 compat 输入。`task159-auth-entry-smoke.js` 覆盖 `/auth` 本地 200、`/` 未登录 303、已登录 200、无第三方快捷登录/隐私政策文案,`task114-rust-web-gateway-entry-smoke.js` 与 `task117-next-retirement-guard.js` 已同步更新。截图待复核:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task159-auth-entry-baseline/`。 | ID | 状态 | 任务 | 验收要点 | @@ -422,7 +422,7 @@ GREEN: | E24 | PARTIAL | Image / Media 能力包 | 已完成图片 `/tp` 最小真源闭环与图片 toolbar 小闭环:slash `媒体与附件` 分组入口调用 `leptos-tiptap` 官方 `set_image(TiptapImageResource)`,启用 `TiptapExtension::Image`,保存链新增 `EditorBlockType::Image` / `TiptapNode::Image` 并保留 `props.tiptapImage`,`mnote-web` reload 恢复 ``;点击图片后出现 `image-floating-toolbar`,删除入口本切片保持禁用,左/中/右对齐通过官方 Image 扩展 `addAttributes("data-align")` + `updateAttributes("image")` 持久化,同源下载通过隐藏 `` 触发且不改文档;`task152-e24-image-smoke.js` 覆盖入口、实际图片加载、toolbar、同源下载、删除入口禁用态、居中对齐、保存请求、content API 和刷新恢复;剩余上传、最近上传、caption、resize、replace、跨源下载 fallback、删除保存链、移动端 toolbar、失败态 | | E25 | PARTIAL | TOC 能力包 | 已完成 `/toc` 最小真源闭环:`leptos-tiptap` 新增真实 `tocNode` schema/command,slash `页面目录 /toc` 插入真实节点,NodeView 从当前 headings 派生目录项,支持点击定位、标题显示开关、保存请求、content API 与 reload 恢复;`task153-e25-toc-smoke.js` 覆盖入口、动态 heading 更新、hash 定位和 `props.tiptapTocNode`。剩余 TOC sidebar、active heading 高亮、完整块菜单入口、官方 TableOfContents v3 数据源评估、移动端与像素级视觉 | | E26 | PARTIAL | UniqueID / Anchor 能力包 | 已完成最小真源闭环:Rust `EditorBlock.block_id` -> Tiptap `attrs.blockId` -> DOM `data-block-id` + `id` -> 复制链接 hash -> `page.body.save` / `/api/documents/content` -> reload 后 `:target` 定位;`task154-e26-anchor-smoke.js` 覆盖保存、复制、reload/hash 和截图。Tiptap `UniqueID` 仍只作为参考/辅助口径,不作为正式块 id;剩余更多块类型 anchor 覆盖、Wolai 视觉细节、引用预览和移动端行为。 | -| E27 | PARTIAL | AI 编辑能力包 | 已完成历史块菜单 `AI 助理` 主路径与最小写入闭环:点击后曾发起 `/api/ai-agent/run`,payload 使用 `scope=document`、`stream=true`、`options.ai.provider=online`,携带 `documentId`、`workspaceId`、Rust `blockId`、selection、selected text、Tiptap 快照和 `action=ask_ai`;`task155-e27-ai-edit-smoke.js` 与 `task156-e27-ai-writeback-smoke.js` 已默认退役为 historical smoke,当前只保留显式环境变量下的历史对照。2026-05-14 起页面 AI 主链已调用 Hermes client proxy,Hermes session/message/tool event/usage/model 是会话真相,mnote 通过 Hermes skill/plugin 执行 `page.body.save`、标题、页面设置、artifact、edge 等业务工具;`mnote-cli` 仅可作为 plugin 内部适配器。Auth 验收必须复用 Convex Auth 测试账号,真实 token / actor 优先,`dev identity` 仅为本地 fallback。剩余 slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate、Wolai 视觉基线、`doc_insert_blocks` 多块插入与标题写入等细分入口。 | +| E27 | PARTIAL | AI 编辑能力包 | 已完成历史块菜单 `AI 助理` 主路径与最小写入闭环:点击后曾发起 `/api/ai-agent/run`,payload 使用 `scope=document`、`stream=true`、`options.ai.provider=online`,携带 `documentId`、`workspaceId`、Rust `blockId`、selection、selected text、Tiptap 快照和 `action=ask_ai`;`task155-e27-ai-edit-smoke.js` 与 `task156-e27-ai-writeback-smoke.js` 已默认退役为 historical smoke,当前只保留显式环境变量下的历史对照。2026-05-14 起页面 AI 主链已调用 Hermes client proxy;2026-05-22 起产品层运行态索引、权限和审计默认落 SQLite control-plane,Hermes/Reasonix runtime 的内部 session/message/tool event/usage/model 不等同于 MNote 产品层会话真相;mnote 通过 Hermes skill/plugin 执行 `page.body.save`、标题、页面设置、artifact、edge 等业务工具;`mnote-cli` 仅可作为 plugin 内部适配器。Auth 验收必须复用当前 SQLite control-plane 测试账号,真实 session / actor 优先,`dev identity` 仅为本地 fallback;Convex Auth 只作为历史 compat / cloud source 对照。剩余 slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate、Wolai 视觉基线、`doc_insert_blocks` 多块插入与标题写入等细分入口。 | 2026-05-14 追加说明:E27 行中的 “Hermes client proxy 接入、mnote plugin tool 注册” 已由 `design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` 对应的 7-5 / 7-6 合同和 mnote-web 实现推进到 GREEN:页面 AI 主链已走 Hermes session/run/events,mnote tool surface 已覆盖 `mnote.page.get`、`mnote.page.save`、`mnote.page.update_title`、`mnote.page.update_options`、`mnote.artifact.create_summary`、`mnote.artifact.create_ai_note`,旧 `/api/ai-agent/run` 已改为 `legacy_ai_agent_run_retired` guard。本文件后续只保留 Wolai-aline 体验、slash/selection toolbar AI 子命令和视觉/交互对标;页面 AI 长期主链完成状态以 `7-4` 为准。 | E28 | PAUSED | Mention / Emoji 能力包 | 2026-05-03 暂停;Wolai baseline 已纠偏:正文 `@` 当前是提醒/会议/成员候选入口,不是页面引用搜索,恢复时先重定 mention/emoji 口径,再决定 Tiptap JSON 与 mnote 保存链 | diff --git a/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md b/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md index 3946e507..abd75a29 100644 --- a/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md +++ b/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md @@ -12,6 +12,9 @@ > - local-first 下,本地 `.md` 文件是正文真相;Page Aggregate / EditorBlockDocument / tiptap state 都是投影或工作副本。 > - `documents/save` 只能作为 compat adapter,不能继续承担长期写侧仲裁。AI 后台写入、tiptap 保存、外部编辑器修改必须统一到本地文件版本冲突模型。 > +> 2026-05-22 口径补充: +> - 默认控制面已由 Rust SQLite `control-plane` 承接;本文中“Convex 作为控制面 / 可选同步协作 source”的表述仅代表 2026-05-18 阶段性背景。当前 Convex 只保留历史迁移源、显式 cloud source / compat / sync replica 边界。 +> > 关联文档: > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md` > - `/mnt/Data1T/mnote/design/old/05-editor-mainline/process/5-3-tiptap-leptos-rust-migration-checklist-v1.md` @@ -89,13 +92,13 @@ - 要不要 `Tiptap` - 要不要 `leptos-tiptap` -- 要不要继续保留 Convex 作为控制面和可选同步协作 source +- 要不要继续保留 Convex 作为显式 cloud/compat/sync replica source 这些结论都已经足够明确: - `Tiptap` 继续作为浏览器输入 runtime - `leptos-tiptap` 继续作为 Leptos 内正式 editor island 接缝 -- `local_folder` 作为默认页面数据真相,Convex / 服务端退居控制面和可选同步协作 source +- `local_folder` 作为默认页面数据真相,SQLite control-plane 持有默认控制面,Convex / 服务端退居显式 cloud/compat/sync replica source 当前真正要收口的是: @@ -451,7 +454,7 @@ Rust 侧需要提供一个统一的页面聚合投影,至少包含: 而是: -- **Rust 持有语义单一真源,local-first workspace 持有默认数据真相,Convex 只作为控制面和可选同步协作 source。** +- **Rust 持有语义单一真源,local-first workspace 持有默认数据真相,SQLite control-plane 持有默认控制面;Convex 只作为历史迁移源、显式 cloud source / compat / sync replica。** --- diff --git a/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md b/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md index 6c6c6d15..fa80e5ec 100644 --- a/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md +++ b/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md @@ -4,6 +4,8 @@ > > 当前状态:`DONE` > +> 2026-05-22 口径补充:本文是页面块编辑运行时 Actor 的历史设计记录,当时仍以 Convex-backed 写入为默认持久化底座。当前默认数据面已切到 local-first `.md` / Page Aggregate,默认控制面已由 Rust SQLite control-plane 承接;Convex 只保留历史迁移源、显式 cloud source / compat / sync replica 边界。下文中的 Convex 持久化描述仅按历史阶段理解。 +> > 本稿目的:在 7-12 已排除第二套 AI runtime 的前提下,补上 Hermes tool execution → Convex 持久化之间缺失的 Rust 编辑运行时中继层,实现「内存态 apply → 编辑器就地 patch → Convex 异步持久化 → 事件增量通知」的四步闭环。 > > 关联文档: @@ -25,7 +27,7 @@ - 编辑器只能全量 reload snapshot,不能就地 patch - tree event stream 收到 `resync_required` 而非增量 delta -**正确方向不是绕开 Convex(禁止项,Convex 保留为自托管存储底座),而是在 Rust mnote-web 进程中新增一个轻量 EditorRuntimeActor,作为写操作的本地缓冲层。** +**历史阶段的正确方向不是绕开当时的 Convex-backed 持久化,而是在 Rust mnote-web 进程中新增一个轻量 EditorRuntimeActor,作为写操作的本地缓冲层。当前默认持久化已经继续演进为 local-first `.md` / SQLite control-plane;Convex 不再是默认底座。** EditorRuntimeActor 不是 agent runtime(遵从 7-12 禁止项),它只负责: @@ -79,9 +81,9 @@ EditorRuntimeActor 不维护: 它只是 Rust-owned 的命令执行 + diff 分发层。 -### 3.2 Convex 仍是唯一的持久化底座 +### 3.2 历史阶段:Convex-backed 持久化 -EditorRuntimeActor 的内存态允许异步写入 Convex,但不绕过 Convex。进程重启后从 Convex 恢复。 +EditorRuntimeActor 的内存态在本文历史阶段允许异步写入 Convex,但不绕过当时的 Convex-backed 持久化。当前默认恢复源已改为本地 `.md` / Rust SQLite control-plane;Convex 只用于显式 cloud / compat source。 ### 3.3 编辑器 patch 是增量,非全量 diff --git a/design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md b/design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md index 2020c8d3..e9c59f30 100644 --- a/design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md +++ b/design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md @@ -2,6 +2,8 @@ > 更新时间:2026-05-17 > 参考:`hermes-vscode-main` (ACP client) / `hermes-web-ui-0.5.18` (HTTP API) +> +> 2026-05-22 口径补充:本文是 ACP session runtime 增强的历史执行记录,当时曾选择根 `convex/` 作为 ACP-local runtime store。后续 `2-8` 已把默认 auth / ACP-Hermes runtime session / share / sync / AI policy 控制面替换为 Rust SQLite `control-plane`;根 `convex/` functions 源码已软删除到 `recycle/20260522-convex-runtime-retirement/convex/`,`scripts/run-convex-deploy.js` 已归档到同批 recycle 目录。下文中的 Convex store / 根 `convex/` 部署描述仅作为历史记录,不再是当前实现方向。 ## 1. 当前状态 @@ -34,7 +36,7 @@ - mnote 自己产生的 tool audit、业务写入结果、artifact / page / tree 事实; - 经单独架构决策确认后的 ACP 会话缓存或索引。 -如果要把 mnote 的存储升级为跨 Hermes HTTP / ACP 的长期会话真源,必须先更新 `7-5` / `7-8` 的边界,而不能作为本计划的隐含前提。并且 mnote 是多用户系统,AI session 必须按用户、workspace、document 进行隔离;需要数据库时使用当前 Convex 底座,不新增 SQLite。 +如果要把 mnote 的存储升级为跨 Hermes HTTP / ACP 的长期会话真源,必须先更新 `7-5` / `7-8` 的边界,而不能作为本计划的隐含前提。并且 mnote 是多用户系统,AI session 必须按用户、workspace、document 进行隔离;当前默认数据库边界已改为 Rust SQLite control-plane,不再新增 Convex functions 作为默认 runtime store。 ## 2. 参考实现分析 @@ -83,7 +85,7 @@ 1. **会话持久化存储** - 默认先建设 ACP-local runtime store / TTL cache,不改 Hermes HTTP 会话真相归属 - - 持久化如需数据库,必须落到 Convex;不在 mnote-web 内新增 SQLite + - 持久化如需数据库,默认落到 Rust SQLite control-plane;不新增根 Convex functions 作为默认 runtime store - 表结构先服务 `ACP_RUN_PAYLOADS` / `ACP_ACTIVE_RUNS` 的可靠生命周期,并且所有记录必须带 `userId` / `workspaceId` / `documentId` 作用域 - `sessions/messages` 如要保存完整聊天历史,必须先完成架构决策并标明只覆盖 ACP 路径还是统一覆盖 Hermes HTTP + ACP @@ -157,20 +159,20 @@ ## 4. 技术方案 -### 4.0 当前 Convex 源目录口径 +### 4.0 历史 Convex 源目录口径 当前仓库状态下: - `infra/convex/` 只承载自托管 Convex backend/dashboard 的 Docker 与说明,不是 functions/schema 源目录; - `wolai-frontend/convex/` 已不在当前工作区可读路径中,不能作为新的实现落点; - `recycle/wolai-frontend/convex/` 只作为历史参考; -- 本轮执行已在仓库根 `convex/` 建立 ACP-local runtime store 的最小 functions/schema,并让 `scripts/run-convex-deploy.js` 优先以仓库根作为 Convex CLI cwd。 +- 本轮历史执行曾在仓库根 `convex/` 建立 ACP-local runtime store 的最小 functions/schema,并让 `scripts/run-convex-deploy.js` 优先以仓库根作为 Convex CLI cwd;当前这些源码和脚本已退役到 `recycle/20260522-convex-runtime-retirement/`。 -后续若要恢复完整业务 Convex functions,需要把历史 `recycle/wolai-frontend/convex/` 中仍需要的 documents/workspaces/media 等 functions 正式迁移到根 `convex/`,不能继续依赖已删除的 `wolai-frontend/` 路径。 +后续若要恢复 Convex cloud / compat source,必须先明确新的 cloud/compat 部署边界,再从 recycle 恢复必要 functions;不能把根 `convex/` 重新作为默认 active deploy source。 ### 4.1 存储方案 -注意:以下结构是 Convex-backed ACP-local store 候选形状,不默认推翻 Hermes 持有聊天真相的既有边界。mnote-web 不新增 SQLite;多用户隔离字段是硬要求。 +注意:以下结构是历史 Convex-backed ACP-local store 候选形状,不默认推翻 Hermes 持有聊天真相的既有边界。当前默认 store 已转向 Rust SQLite control-plane;多用户隔离字段仍是硬要求。 ```rust // sessions 记录 @@ -208,11 +210,11 @@ struct MessageRow { ### 4.2 现有代码改动范围 -- **新增/修改** 根 `convex/` 相关 schema / functions — ACP-local session/runtime 记录、用户隔离查询、TTL 清理;`infra/convex/` 只负责自托管服务部署 +- **历史项**:曾新增/修改根 `convex/` 相关 schema / functions,承接 ACP-local session/runtime 记录、用户隔离查询、TTL 清理;当前默认应改 Rust SQLite control-plane schema/API,`infra/convex/` 只负责显式 cloud / compat 服务部署 - **修改** `acp_session_manager.rs` — 按架构决策写入 ACP-local 消息或只写 runtime/event 索引 - **修改** `hermes_client.rs` — 新增 session 管理 route handlers - **修改** `routes/mod.rs` — 注册新 route -- **修改** Convex bridge / transport 调用点 — mnote-web 通过已有 Convex 底座读写 session/runtime 索引 +- **修改** control-plane / compat transport 调用点 — mnote-web 默认通过 SQLite control-plane 读写 session/runtime 索引,Convex 只作为显式 legacy compat ### 4.3 与现有 bug 的关系 @@ -269,11 +271,11 @@ Phase C(P2 用户体验) ### 6.2 先做后端持久化骨架 -- [x] 为 ACP-local session/runtime store 设计 Convex schema,不新增 SQLite。 -- [x] 在 Convex 中建 `sessions`、`messages` 或更小的 `runtimeRuns` / `runtimeEvents` 记录,补齐索引和权限过滤。 - - 当前实现为根 `convex/schema.ts` 的 `acp_runtime_runs` / `acp_runtime_events`,以及 `convex/aiSessions.ts` 的 `upsertRuntimeRun/getRuntimeRun/listRuntimeRuns`。 +- [x] 历史阶段曾为 ACP-local session/runtime store 设计 Convex schema;当前默认已改为 SQLite control-plane。 +- [x] 历史阶段曾在 Convex 中建 `sessions`、`messages` 或更小的 `runtimeRuns` / `runtimeEvents` 记录,补齐索引和权限过滤。 + - 历史实现为根 `convex/schema.ts` 的 `acp_runtime_runs` / `acp_runtime_events`,以及 `convex/aiSessions.ts` 的 `upsertRuntimeRun/getRuntimeRun/listRuntimeRuns`;当前根 `convex/` 已退役。 - [x] 把 `ACP_RUN_PAYLOADS` / `ACP_ACTIVE_RUNS` 中必须保留的数据拆到持久化层或短 TTL 层。 - - 当前 `create_run` 会把 ACP payload/runtime 写入 Convex;内存 map 仍作为热路径保留,`stream_events` 不再消费删除 payload。 + - 历史阶段 `create_run` 会把 ACP payload/runtime 写入 Convex;当前默认写入 SQLite control-plane,旧 Convex store 仅在显式 legacy compat 下使用。 - [x] 把 `create_run` 产出的 `sessionId`、`profile`、`traceId`、`runtime` 写入 `sessions` 记录。 - 当前写入目标为更小的 `acp_runtime_runs`,而不是完整聊天 `sessions` 真相表。 - [x] 在 `stream_events` 的 ACP 路径里,按架构决策写入 `messages` 或 runtime/event 索引,不能把 Hermes HTTP 聊天真相复制进 mnote。 @@ -334,18 +336,18 @@ Phase C(P2 用户体验) ### 2026-05-18 -- 新增根 `convex/schema.ts`:`acp_runtime_runs` / `acp_runtime_events` 表和索引。 -- 新增根 `convex/aiSessions.ts`:`upsertRuntimeRun`、`getRuntimeRun`、`listRuntimeRuns`,按 `userId` 校验 identity 作用域。 -- 修改 `scripts/run-convex-deploy.js`:优先以仓库根 `convex/` 作为 Convex deploy cwd,避免继续指向已不存在的 `wolai-frontend/`。 -- 后续修正:根 `convex/` 当前只包含 ACP session runtime store,不能直接覆盖完整本地 Convex 部署;`scripts/run-convex-deploy.js` 已改为只有根 `convex/` 具备完整 schema 时才用根目录,否则继续使用现有完整 Convex 源。为保持本机 `3210` 部署完整,已将 `aiSessions.ts` 与 `acp_runtime_*` schema 同步到现有完整 Convex 源并重新部署。 +- 新增根 `convex/schema.ts`:`acp_runtime_runs` / `acp_runtime_events` 表和索引。(历史记录;当前已退役到 recycle) +- 新增根 `convex/aiSessions.ts`:`upsertRuntimeRun`、`getRuntimeRun`、`listRuntimeRuns`,按 `userId` 校验 identity 作用域。(历史记录;当前已退役到 recycle) +- 修改 `scripts/run-convex-deploy.js`:优先以仓库根 `convex/` 作为 Convex deploy cwd,避免继续指向已不存在的 `wolai-frontend/`。(历史记录;当前脚本已退役到 recycle,`desktop:hot` / `dev:hot` 不部署 Convex) +- 后续历史修正:根 `convex/` 当时只包含 ACP session runtime store,不能直接覆盖完整本地 Convex 部署;`scripts/run-convex-deploy.js` 曾改为只有根 `convex/` 具备完整 schema 时才用根目录,否则继续使用现有完整 Convex 源。当前根 `convex/` 和该 deploy 脚本均已退役到 recycle。 - 修改 `rust/crates/mnote-web/src/routes/hermes_client.rs`: - - ACP `create_run` 写入 `aiSessions:upsertRuntimeRun`; - - ACP `GET /client/sessions` 读取 `aiSessions:listRuntimeRuns`; + - ACP `create_run` 写入 `aiSessions:upsertRuntimeRun`(历史 Convex store;当前默认 SQLite); + - ACP `GET /client/sessions` 读取 `aiSessions:listRuntimeRuns`(历史 Convex store;当前默认 SQLite); - ACP `GET /client/sessions/{session_id}` 读取 runs/events; - - ACP `POST /client/sessions/{session_id}/resume` 返回 Convex runtime history 恢复来源; - - ACP `DELETE /client/sessions/{session_id}`、`rename`、`auto-title` 写入 Convex store; - - ACP `GET /client/sessions/search` 返回 Convex store 搜索摘要和 snippet; - - ACP `stream_events` 将 SSE event 写入 `aiSessions:appendRuntimeEvent`; + - ACP `POST /client/sessions/{session_id}/resume` 返回 runtime history 恢复来源; + - ACP `DELETE /client/sessions/{session_id}`、`rename`、`auto-title` 写入 runtime store; + - ACP `GET /client/sessions/search` 返回 runtime store 搜索摘要和 snippet; + - ACP `stream_events` 将 SSE event 写入 runtime event store; - `stream_events` 使用 clone lookup 保留 ACP payload,不再 `.remove(run_id)`。 - 验证: - `cd rust && cargo test -p mnote-web hermes_client_acp -- --nocapture`:10 passed。 @@ -353,8 +355,8 @@ Phase C(P2 用户体验) - `cd rust && cargo test -p mnote-web acp_permission -- --nocapture`:1 passed。 - `cd rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --nocapture`:1 passed。 - `cd rust && cargo test -p mnote-web acp_thought_delta_does_not_emit_message_delta -- --nocapture`:1 passed。 - - `node -c scripts/run-convex-deploy.js`:通过。 - - `node scripts/run-convex-deploy.js`:部署入口已避免用根最小 `convex/` 覆盖完整本地 Convex schema;`aiSessions.ts` 同步补到完整 Convex 源并部署到 `http://127.0.0.1:3210`,最终脚本验证为 `No indexes are deleted by this push`。 + - `node -c scripts/run-convex-deploy.js`:历史验证通过;当前脚本已退役到 recycle。 + - `node scripts/run-convex-deploy.js`:历史验证通过;当时部署入口已避免用根最小 `convex/` 覆盖完整本地 Convex schema。当前默认启动链路不再部署 Convex。 - `node` 提取并 `new Function(SIDEBAR_TREE_JS)`:通过。 - `npx convex --version`:1.39.1。 - `CONVEX_TMPDIR=/mnt/Data1T/mnote/.convex-tmp npx convex codegen --dry-run --typecheck try`:通过。 diff --git a/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md b/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md index e3987cfb..b5436c82 100644 --- a/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md +++ b/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md @@ -198,7 +198,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm 执行步骤: - [x] 启动当前前后端,确保测试入口可访问。 -- [x] 用真实 Convex Auth 测试账号登录。 +- [x] 用真实测试账号登录。历史执行时使用旧 Auth 后端;当前默认应使用 SQLite control-plane Auth / session。 - [x] 打开任意测试页面,点击右下角页面 AI。 - [x] 发送一条只读问题,例如“概括当前页面标题和第一段”。 - [x] 记录当前请求是否仍命中旧 `/api/ai-agent/run`。 @@ -763,7 +763,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm 总体验收标准: - [x] 所有 smoke 在真实 `http://127.0.0.1:3000` 上通过。 -- [x] 使用默认 Convex Auth 测试账号,不使用 `MNOTE_DEV_AUTH=1` 伪登录作为正式验收。 +- [x] 使用默认真实测试账号,不使用 `MNOTE_DEV_AUTH=1` 伪登录作为正式验收。历史执行时使用旧 Auth 后端;当前默认应使用 SQLite control-plane Auth / session。 - [x] smoke 只操作本轮新建测试数据,测试数据前缀统一为 `TEST-HERMES-AI-`。 - [x] 测试结束后可定位创建的页面、artifact、edge、audit。 - [x] 任一 smoke 失败时,必须记录到 `bugs/` 对应分类,而不是在 checklist 中直接标 GREEN。 @@ -1054,7 +1054,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm ### M1:把正式 `3000` 的 E2E smoke 跑成最终验收矩阵 -**目标:** 临时 `3019` 证据不能作为最终完成;必须在正式 `3000` + 真实 Convex Auth 上复跑全矩阵。 +**目标:** 临时 `3019` 证据不能作为最终完成;必须在正式 `3000` + 真实 SQLite control-plane Auth / session 上复跑全矩阵。历史记录中的 Convex Auth 只作为当时验收背景。 **必须参考 Hermes Web UI 的位置:** @@ -1068,7 +1068,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm **顺序执行 checklist:** - [x] M1.1 确认主入口 `http://127.0.0.1:3000` 运行的是包含本轮改动的 `mnote-web`,不是旧进程。 -- [x] M1.2 使用默认 Convex Auth 测试账号 `mnote.e2e@example.com` 登录,不使用 `MNOTE_DEV_AUTH=1` 伪登录作为正式验收。 +- [x] M1.2 使用默认 SQLite control-plane 测试账号 `mnote.e2e@example.com` 登录,不使用 `MNOTE_DEV_AUTH=1` 伪登录作为正式验收;旧 Convex Auth 仅作 compat 回归。 - [x] M1.3 运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-smoke.js`。 - [x] M1.4 运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-smoke.js`。 - [x] M1.5 运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-writeback-smoke.js`。 diff --git a/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md b/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md index 1182c272..594c59b1 100644 --- a/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md +++ b/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md @@ -6,6 +6,8 @@ > > 归档说明(2026-05-21):本文冻结 ACP session 与控制面分享/账号作用域远期合同;当前只作为 future reference,不作为 MVP 后阶段 active implementation checklist。 > +> 2026-05-22 口径补充:本文标题中的 Convex 是历史控制面语境。当前默认控制面已由 Rust SQLite `control-plane` 承接;ACP/Hermes runtime session、auth、share grants、AI policy 默认不再依赖 Convex。下文涉及 Convex store 的内容只作为历史 / compat / sync replica 对照。 +> > 本稿目的: > 1. 明确 ACP session 必须绑定当前账号权限与控制面作用域,不能以内存态绕过权限。 > 2. 冻结后续“复制会话 / 只读分享 / 多人共同会话”的产品语义,避免当前实现误扩。 @@ -26,7 +28,7 @@ ACP 默认主链已经替代旧 Hermes HTTP 主链,但这不意味着 ACP runt 当前必须成立的规则: -- **控制面持有产品层 AI session 的账号作用域真相;在当前实现里它可以暂时落在 Convex,但长期不应把“Convex”写死成唯一真相。** +- **Rust SQLite control-plane 持有产品层 AI session 的账号作用域真相;Convex 只作为历史 / compat / sync replica 对照,不应被写死成唯一真相。** - **ACP runtime session 只是执行层会话**,可以被控制面 session 绑定或索引,但不能直接作为跨用户共享对象。 - **ACP session 记录必须带 `userId / workspaceId / documentId / sessionId / runId / actorId / acpRuntime / profile`**。 - **写入控制面 session store 失败时,不允许静默降级为可继续写入的内存 session**。这会破坏账号隔离、审计和后续分享语义。 @@ -38,7 +40,7 @@ ACP 默认主链已经替代旧 Hermes HTTP 主链,但这不意味着 ACP runt ### 1.1 MNote AI Session -MNote AI Session 是产品层会话对象,长期应该由控制面持有;当前实现可以暂存在 Convex,但目标不是把消息全文和权限真相永久绑死在 Convex。 +MNote AI Session 是产品层会话对象,长期应该由控制面持有;当前默认控制面是 Rust SQLite control-plane,目标不是把消息全文和权限真相永久绑死在 Convex。 它回答: @@ -77,8 +79,8 @@ Tool Audit 是 mnote 侧工具执行与写入结果的审计记录,必须以 当前代码可接受的最小边界: -- `POST /api/hermes/client/sessions` 在 ACP 默认路径下创建 Convex 侧 runtime session 索引。 -- `POST /api/hermes/client/runs` 创建 run 并持久化到 Convex runtime store。 +- `POST /api/hermes/client/sessions` 在 ACP 默认路径下创建 SQLite control-plane runtime session 索引;Convex 仅保留显式 legacy compat。 +- `POST /api/hermes/client/runs` 创建 run 并默认持久化到 SQLite control-plane runtime store。 - `GET /api/hermes/client/events/{runId}` 将 ACP event 转成页面 SSE,并追加 runtime event。 - `GET /api/hermes/client/gateway/health` 在 ACP 默认路径下返回 `transport=acp`,不再探测旧 Hermes HTTP gateway。 @@ -209,7 +211,7 @@ Runtime event 和 tool event 应保留原始结构,但查询时必须做权限 规则: -- 创建 session 时使用当前 Convex Auth 解析出的真实 user id。 +- 创建 session 时使用当前 SQLite control-plane auth/session 解析出的真实 user / actor id。 - `userId` 只可作为服务端派生字段,不可由浏览器任意指定。 - run / event / tool audit 都必须与同一 actor 绑定。 - 当前请求无法解析用户时,应返回 `401` 或稳定错误,而不是创建匿名共享 session。 @@ -410,7 +412,7 @@ DELETE /api/hermes/client/sessions/{sessionId}/members/{userId} 状态:当前主线。 - ACP 默认主链可用。 -- session/run/event 必须账号作用域写入 Convex。 +- session/run/event 必须账号作用域写入 Rust SQLite control-plane;Convex 只作为显式 compat / sync replica。 - 旧 Hermes HTTP 主链进入 `recycle`。 - 不实现复制、分享、多人会话。 - 文档与测试明确禁止内存降级绕过权限。 @@ -421,7 +423,7 @@ DELETE /api/hermes/client/sessions/{sessionId}/members/{userId} - 增加 `ai_sessions` / `ai_session_members` / `ai_runtime_bindings`。 - 将现有 `acp_runtime_runs` 从“运行态索引”升级为 session 下的 run 记录。 -- UI 从 localStorage 历史逐步迁到 Convex session 列表。 +- UI 从 localStorage 历史逐步迁到 Rust control-plane session 列表。 ### Phase C:复制与只读分享 diff --git a/design/09-siyuan-reference/reference/9-siyuan-reference-boundary-and-adoption-v1.md b/design/09-siyuan-reference/reference/9-siyuan-reference-boundary-and-adoption-v1.md index 51206ba1..66623ca7 100644 --- a/design/09-siyuan-reference/reference/9-siyuan-reference-boundary-and-adoption-v1.md +++ b/design/09-siyuan-reference/reference/9-siyuan-reference-boundary-and-adoption-v1.md @@ -209,7 +209,7 @@ - `tree-first graph kernel` 是长期对象语义真相层 - local-first workspace 与本地 `.md` 是早期产品默认数据面和正文真相 -- `Convex` / 服务端降级为鉴权、分享、同步、协作、cloud source、compat 和 sync replica 控制面 +- Rust SQLite control-plane 承接鉴权、分享、同步、协作和 AI policy 默认控制面;`Convex` / 服务端仅保留历史迁移源、显式 cloud source、compat 和 sync replica 边界 - `mnote-web` 与 Rust kernel 持有主执行语义 因此后续不能照搬思源 `.sy` 对象格式,也不能把: @@ -270,7 +270,7 @@ 而 `mnote` 当前仍保留: -- Convex 自托管底座 +- Convex 历史迁移 / 显式 cloud source / compat / sync replica 边界 - realtime 协作链 - Rust Web `3000` 主入口 diff --git a/design/10-review/done/15-design-governance-mvp-post-review-v1.md b/design/10-review/done/15-design-governance-mvp-post-review-v1.md index 30ff6fbf..4b24aa6a 100644 --- a/design/10-review/done/15-design-governance-mvp-post-review-v1.md +++ b/design/10-review/done/15-design-governance-mvp-post-review-v1.md @@ -16,7 +16,7 @@ MNote 当前已进入 local-first MVP 后阶段: - 本地 `.md` 是页面正文真相。 - Rust kernel / projection / command 持有树、页面、资源和权限语义。 - `mnote-web` 是 3000 主 Web 执行面。 -- Convex / 服务端降级为 auth、membership、share grants、sync state、AI policy、cloud source、compat 和 sync replica 控制面。 +- Rust SQLite control-plane 承接 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 等默认控制面;Convex / 服务端仅保留历史迁移源、显式 cloud source、compat 和 sync replica 边界。 - 旧 Next / React / wolai-frontend、BlockNote 默认主链、Convex 默认正文/附件/AI 会话全文主存储口径均为 legacy / recycle / compat 背景。 ## 2. 本轮整理原则 diff --git a/design/10-review/reference/13-hermes-vscode-plugin-gap-review-v1.md b/design/10-review/reference/13-hermes-vscode-plugin-gap-review-v1.md index f06e5b6a..998ab9a3 100644 --- a/design/10-review/reference/13-hermes-vscode-plugin-gap-review-v1.md +++ b/design/10-review/reference/13-hermes-vscode-plugin-gap-review-v1.md @@ -30,9 +30,9 @@ - Rust `AcpSessionManager` 已有 `session/new`、`session/prompt`、`session/cancel`、streaming dedup、typed event 和 usage / thought / tool / plan / session info 解析雏形。 - `acp_bridge.rs` 已把 TextDelta、ThoughtDelta、ToolCall、ToolCallUpdate、UsageUpdate、PermissionRequest 转成 SSE,但 `SessionInfoUpdate` 和 `PlanUpdate` 当前仍被丢弃。 -- `layout.rs` Page AI 已有 session list/detail/search/resume/rename/delete、localStorage cache、Convex/local ACP runtime store、permission dialog、tool event UI、profile/skills 控制面和 usage summary。 -- `routes/hermes_client.rs` 已有 local-first AI session JSONL、Convex ACP runtime store、run queue、abort 和 local agent audit。 -- 当前 `resume_session` 只恢复 MNote/Convex/local 会话历史,不等同于对 ACP adapter 调用 `session/load` 注册底层 ACP session。 +- `layout.rs` Page AI 已有 session list/detail/search/resume/rename/delete、localStorage cache、SQLite control-plane / local AI session store、permission dialog、tool event UI、profile/skills 控制面和 usage summary;旧 Convex ACP runtime store 仅属 legacy compat。 +- `routes/hermes_client.rs` 已有 local-first AI session JSONL、SQLite control-plane ACP runtime store、run queue、abort 和 local agent audit;旧 Convex ACP runtime store 仅属 legacy compat。 +- 当前 `resume_session` 只恢复 MNote SQLite/local 或显式 legacy Convex 会话历史,不等同于对 ACP adapter 调用 `session/load` 注册底层 ACP session。 - 当前 `AcpClient` 对 incoming `session/request_permission` 仍立即返回 unsupported/deny,前端按钮没有真正回传 ACP 决策。 - 当前 `SessionUpdate::ToolCall` 类型未保留 `locations`,因此无法像 Hermes VSCode 一样把 read/edit 工具结果联动到主编辑器 tab。 @@ -80,7 +80,7 @@ MNote 已在 `AcpSessionManager` 解析 `SessionInfoUpdate` 和 `PlanUpdate`, ## 7. 不建议照搬 - 不照搬 VSCode Webview 的 DOM/CSS 结构;MNote 仍使用 Wolai 主题 Page AI drawer。 -- 不把 VSCode `workspaceState` 当作 MNote 会话真相;local-first 下本地 JSONL / Convex control-plane / runtime registry 各自职责不变。 +- 不把 VSCode `workspaceState` 当作 MNote 会话真相;local-first 下本地 JSONL / SQLite control-plane / runtime registry 各自职责不变,Convex 只保留显式 legacy compat / sync replica。 - 不把 Hermes 插件中的自动打开文件行为做成不可控跳转;MNote 应默认在 main editor resource tab 打开,并保留显式新窗口入口。 - 不在 UI 层新增第二套文件权限真相;权限和 allowed roots 仍由 Rust Web / local-first scope 决定。 - 不把 Page AI block-edit workflow 扩成普通 Markdown 主路径;local folder 继续走文件引用 + agent 原生 patch/diff + watcher 同步。 diff --git a/design/README.md b/design/README.md index ef5f9068..61dcb8d9 100644 --- a/design/README.md +++ b/design/README.md @@ -1,13 +1,14 @@ # design 设计稿索引 -> 更新时间:2026-05-21 +> 更新时间:2026-05-22 > > 当前产品口径:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + Hermes/Reasonix agent + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`。 > -> 当前上位主线:local-first MVP 已初步完成;local-first workspace 是默认数据形态,本地 `.md` 是页面正文真相,Rust kernel / projection / command 持有语义,Convex / 服务端只作为控制面、cloud source、compat 和 sync replica。入口设计见: +> 当前上位主线:local-first MVP 已初步完成;local-first workspace 是默认数据形态,本地 `.md` 是页面正文真相,Rust kernel / projection / command 持有语义,Rust SQLite control-plane 持有默认 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 控制面;Convex / 服务端只作为历史迁移源、显式 cloud source、compat 和 sync replica。入口设计见: > `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` +> `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md` > -> 后续新增设计默认以 MVP 后阶段为基线:优先推进 `WorkspacePath/ObjectIdentity`、`DocumentBuffer/BufferStore`、Page Aggregate compat 瘦身、tree command context、live cache 统一、agent diff / 冲突合并 / 本地索引 / 分享同步产品化;不要再把旧 Convex / Next / BlockNote 主链当作新增能力默认方向。 +> 后续新增设计默认以 MVP 后阶段为基线:优先推进 `WorkspacePath/ObjectIdentity`、`DocumentBuffer/BufferStore`、Page Aggregate compat 瘦身、tree command context、live cache 统一、agent diff / 冲突合并 / 本地索引 / 分享同步产品化;不要再把旧 Convex / Next / BlockNote 主链、根 `convex/` functions 源码或 Convex Auth 默认控制面当作新增能力默认方向。 > > 状态口径以当前仓库真实代码为准: > - `[done]`:对应阶段或收口目标已经在当前主线代码中成立 diff --git a/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md b/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md index 7ac7d96f..bd01249c 100644 --- a/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md +++ b/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md @@ -4,8 +4,8 @@ > > 2026-05-18 口径更新: > - 本文保留为历史过渡判断:它解释“不要在没有替代控制面、迁移工具和兼容策略时硬拆 Convex”。 -> - 当前新增能力的上位方向已由 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` 覆盖:本地文件夹是早期产品默认数据真相,Rust kernel 是唯一语义真相,Convex / 服务端降级为账号、分享、同步、协作和 AI 隔离控制面。 -> - 因此,本文中“Convex 作为默认存储 / 实时 / 文件底座”的表述只代表旧代码过渡态和历史决策背景,不再作为新增功能默认方向。 +> - 当前新增能力的上位方向先由 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` 覆盖,后续又由 `design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md` 继续替换控制面:本地文件夹是早期产品默认数据真相,Rust kernel 是唯一语义真相,Rust SQLite control-plane 是默认账号、分享、同步、协作和 AI 隔离控制面。 +> - 因此,本文中“Convex 作为默认存储 / 实时 / 文件底座 / 控制面”的表述只代表旧代码过渡态和历史决策背景,不再作为新增功能默认方向;Convex 只保留历史迁移源、显式 cloud source / compat / sync replica 边界。 > > 当前优先级入口: > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` @@ -499,7 +499,7 @@ Rust Web 不是第二套业务内核。 截至 2026-05-18,当前新增能力的上位口径已调整为: -> **mnote 的早期产品路线是 local-first workspace:本地文件夹是默认数据真相,Rust kernel 是唯一语义真相,Convex / 服务端降级为账号、分享、同步、协作和 AI 隔离控制面。** +> **mnote 的早期产品路线是 local-first workspace:本地文件夹是默认数据真相,Rust kernel 是唯一语义真相,Rust SQLite control-plane 是默认账号、分享、同步、协作和 AI 隔离控制面;Convex 只保留历史迁移源、显式 cloud source / compat / sync replica 边界。** 当前已经完成的是: diff --git a/infra/convex/README.md b/infra/convex/README.md index ca6d921e..45ef8277 100644 --- a/infra/convex/README.md +++ b/infra/convex/README.md @@ -2,13 +2,13 @@ 本目录用于在本机通过 Docker Compose 启动 Convex backend + dashboard,并使用 backend 自身的持久化卷存储(包含数据库与文件存储)。 -在 local-first 方案下,这套 Convex 自托管环境定位为控制面、cloud source、compat 和可选 sync replica 运行底座;页面正文、默认附件和本地 AI 会话全文不再以它作为早期产品默认数据真相。 +在 local-first 方案下,这套 Convex 自托管环境只定位为历史数据导出、显式 cloud source / compat 和可选 sync replica 运行底座;默认 auth、授权、分享、同步状态、AI policy、ACP/Hermes runtime session 等控制面已由 Rust SQLite control-plane 承接。页面正文、默认附件和本地 AI 会话全文不再以它作为早期产品默认数据真相。 ## 当前 functions 边界 -- 仓库根 `convex/` 是当前 active Convex functions 部署源;目前只保留 `schema.ts` 与 `aiSessions.ts`,用于 ACP / Hermes runtime session store。 -- `recycle/wolai-frontend/convex/` 是退役前端随带的历史实现,不允许作为当前部署 fallback。仍需要的 `documents` / `mediaAssets` / `users` / `workspaces` 等旧 functions,必须明确迁回 active 区或移动到外部辅助区;不再使用的应正式退役。 -- `scripts/run-convex-deploy.js` 只从仓库根部署 `convex/`,缺少 active functions 时应失败,不应从 `recycle/` 回退。 +- 仓库根 `convex/` 已退役并软删除到 `recycle/20260522-convex-runtime-retirement/convex/`;它只作为审计、迁移对照或历史恢复素材,不再是 active deploy source。 +- `recycle/wolai-frontend/convex/` 是退役前端随带的历史实现,不允许作为当前部署 fallback。 +- `scripts/run-convex-deploy.js` 已随根 functions 源码软删除到 `recycle/20260522-convex-runtime-retirement/scripts/run-convex-deploy.js`。如需重新部署 Convex functions,必须先明确新的 cloud/compat 部署边界,不应直接恢复旧根 `convex/` 作为默认控制面。 ## 启动 @@ -36,12 +36,13 @@ docker compose exec backend ./generate_admin_key.sh ## 快速验证(可选) -### 1) 验证 Convex functions 可用 +### 1) 验证默认启动不依赖 Convex functions 源码 -当前根 `convex/` 只保留 ACP / Hermes runtime session store,部署前可先确认 active functions 文件存在: +当前默认 hot 启动链路不应要求仓库根存在 `convex/`: ```bash -test -f convex/schema.ts && test -f convex/aiSessions.ts +test ! -d convex +npm run desktop:hot ``` ### 2) 验证固定开发用户(阶段 3) diff --git a/package-lock.json b/package-lock.json index db667d92..1cd3a88c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,452 +9,9 @@ "version": "0.1.0", "devDependencies": { "@playwright/test": "^1.56.1", - "convex": "^1.39.1", "playwright": "^1.56.1" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", - "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", - "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", - "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", - "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", - "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", - "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", - "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", - "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", - "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", - "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", - "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", - "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", - "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", - "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", - "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", - "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", - "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", - "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", - "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", - "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", - "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", - "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", - "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", - "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", - "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", - "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@playwright/test": { "version": "1.56.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", @@ -471,87 +28,6 @@ "node": ">=18" } }, - "node_modules/convex": { - "version": "1.39.1", - "resolved": "https://registry.npmjs.org/convex/-/convex-1.39.1.tgz", - "integrity": "sha512-W+gVXA7BpRF1xLlS1kGTtKVaqd5yonqbGESKiPtIUXjV744GdDz8IG7RVsSY5KzHbgxuJBHKaJYk+92OIHTskQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esbuild": "0.27.0", - "prettier": "^3.0.0", - "ws": "8.18.0" - }, - "bin": { - "convex": "bin/main.js" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=7.0.0" - }, - "peerDependencies": { - "@auth0/auth0-react": "^2.0.1", - "@clerk/clerk-react": "^4.12.8 || ^5.0.0", - "@clerk/react": "^6.4.3", - "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@auth0/auth0-react": { - "optional": true - }, - "@clerk/clerk-react": { - "optional": true - }, - "@clerk/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/esbuild": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", - "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.0", - "@esbuild/android-arm": "0.27.0", - "@esbuild/android-arm64": "0.27.0", - "@esbuild/android-x64": "0.27.0", - "@esbuild/darwin-arm64": "0.27.0", - "@esbuild/darwin-x64": "0.27.0", - "@esbuild/freebsd-arm64": "0.27.0", - "@esbuild/freebsd-x64": "0.27.0", - "@esbuild/linux-arm": "0.27.0", - "@esbuild/linux-arm64": "0.27.0", - "@esbuild/linux-ia32": "0.27.0", - "@esbuild/linux-loong64": "0.27.0", - "@esbuild/linux-mips64el": "0.27.0", - "@esbuild/linux-ppc64": "0.27.0", - "@esbuild/linux-riscv64": "0.27.0", - "@esbuild/linux-s390x": "0.27.0", - "@esbuild/linux-x64": "0.27.0", - "@esbuild/netbsd-arm64": "0.27.0", - "@esbuild/netbsd-x64": "0.27.0", - "@esbuild/openbsd-arm64": "0.27.0", - "@esbuild/openbsd-x64": "0.27.0", - "@esbuild/openharmony-arm64": "0.27.0", - "@esbuild/sunos-x64": "0.27.0", - "@esbuild/win32-arm64": "0.27.0", - "@esbuild/win32-ia32": "0.27.0", - "@esbuild/win32-x64": "0.27.0" - } - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -598,44 +74,6 @@ "engines": { "node": ">=18" } - }, - "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index da5bf6b4..e29d1191 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,8 @@ "dev:hot": "node scripts/dev-hot.js", "check:local-first-convex-guard": "node scripts/check-local-first-convex-guard.js" }, - "dependencies": { - "@auth/core": "0.37.0", - "@convex-dev/auth": "0.0.91" - }, "devDependencies": { "@playwright/test": "^1.56.1", - "convex": "^1.39.1", "playwright": "^1.56.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce7449c6..45374b2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,288 +7,26 @@ settings: importers: .: - dependencies: - '@auth/core': - specifier: 0.37.0 - version: 0.37.0 - '@convex-dev/auth': - specifier: 0.0.91 - version: 0.0.91(@auth/core@0.37.0)(convex@1.39.1) devDependencies: '@playwright/test': specifier: ^1.56.1 version: 1.57.0 - convex: - specifier: ^1.39.1 - version: 1.39.1 playwright: specifier: ^1.56.1 version: 1.57.0 packages: - '@auth/core@0.37.0': - resolution: {integrity: sha512-LybAgfFC5dta3Mu3al0UbnzMGVBpZRqLMvvXupQOfETtPNlL7rXgTO13EVRTCdvPqMQrVYjODUDvgVfQM1M3Qg==} - peerDependencies: - '@simplewebauthn/browser': ^9.0.1 - '@simplewebauthn/server': ^9.0.2 - nodemailer: ^6.8.0 - peerDependenciesMeta: - '@simplewebauthn/browser': - optional: true - '@simplewebauthn/server': - optional: true - nodemailer: - optional: true - - '@convex-dev/auth@0.0.91': - resolution: {integrity: sha512-wLD4hszo3IhhMkwPs6ozWf0cUauwmhOvjUVn0g//kC338n/jApOjeDYWKCrn/qYUkveyDsbag5zrY8mVzA09Qg==} - hasBin: true - peerDependencies: - '@auth/core': ^0.37.0 - convex: ^1.17.0 - react: ^18.2.0 || ^19.0.0-0 - peerDependenciesMeta: - react: - optional: true - - '@esbuild/aix-ppc64@0.27.0': - resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.0': - resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.0': - resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.0': - resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.0': - resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.0': - resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.0': - resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.0': - resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.0': - resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.0': - resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.0': - resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.0': - resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.0': - resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.0': - resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.0': - resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.0': - resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.0': - resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.0': - resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.0': - resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.0': - resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.0': - resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.0': - resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.0': - resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.0': - resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.0': - resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.0': - resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@oslojs/asn1@1.0.0': - resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} - - '@oslojs/binary@1.0.0': - resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} - - '@oslojs/crypto@1.0.1': - resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} - - '@oslojs/encoding@1.1.0': - resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} - - '@panva/hkdf@1.2.1': - resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} - '@playwright/test@1.57.0': resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} engines: {node: '>=18'} hasBin: true - '@types/cookie@0.6.0': - resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - - convex@1.39.1: - resolution: {integrity: sha512-W+gVXA7BpRF1xLlS1kGTtKVaqd5yonqbGESKiPtIUXjV744GdDz8IG7RVsSY5KzHbgxuJBHKaJYk+92OIHTskQ==} - engines: {node: '>=18.0.0', npm: '>=7.0.0'} - hasBin: true - peerDependencies: - '@auth0/auth0-react': ^2.0.1 - '@clerk/clerk-react': ^4.12.8 || ^5.0.0 - '@clerk/react': ^6.4.3 - react: ^18.0.0 || ^19.0.0-0 || ^19.0.0 - peerDependenciesMeta: - '@auth0/auth0-react': - optional: true - '@clerk/clerk-react': - optional: true - '@clerk/react': - optional: true - react: - optional: true - - cookie@0.7.1: - resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} - engines: {node: '>= 0.6'} - - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - - esbuild@0.27.0: - resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==} - engines: {node: '>=18'} - hasBin: true - fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - is-network-error@1.3.2: - resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} - engines: {node: '>=16'} - - jose@5.10.0: - resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - - jwt-decode@4.0.0: - resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} - engines: {node: '>=18'} - - lucia@3.2.2: - resolution: {integrity: sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA==} - deprecated: This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate. - - oauth4webapi@3.8.6: - resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} - - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - playwright-core@1.57.0: resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} engines: {node: '>=18'} @@ -299,223 +37,15 @@ packages: engines: {node: '>=18'} hasBin: true - preact-render-to-string@5.2.3: - resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} - peerDependencies: - preact: '>=10' - - preact@10.11.3: - resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} - - prettier@3.8.3: - resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} - engines: {node: '>=14'} - hasBin: true - - pretty-format@3.8.0: - resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} - - server-only@0.0.1: - resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - snapshots: - '@auth/core@0.37.0': - dependencies: - '@panva/hkdf': 1.2.1 - '@types/cookie': 0.6.0 - cookie: 0.7.1 - jose: 5.10.0 - oauth4webapi: 3.8.6 - preact: 10.11.3 - preact-render-to-string: 5.2.3(preact@10.11.3) - - '@convex-dev/auth@0.0.91(@auth/core@0.37.0)(convex@1.39.1)': - dependencies: - '@auth/core': 0.37.0 - '@oslojs/crypto': 1.0.1 - '@oslojs/encoding': 1.1.0 - convex: 1.39.1 - cookie: 1.1.1 - is-network-error: 1.3.2 - jose: 5.10.0 - jwt-decode: 4.0.0 - lucia: 3.2.2 - oauth4webapi: 3.8.6 - path-to-regexp: 6.3.0 - server-only: 0.0.1 - - '@esbuild/aix-ppc64@0.27.0': - optional: true - - '@esbuild/android-arm64@0.27.0': - optional: true - - '@esbuild/android-arm@0.27.0': - optional: true - - '@esbuild/android-x64@0.27.0': - optional: true - - '@esbuild/darwin-arm64@0.27.0': - optional: true - - '@esbuild/darwin-x64@0.27.0': - optional: true - - '@esbuild/freebsd-arm64@0.27.0': - optional: true - - '@esbuild/freebsd-x64@0.27.0': - optional: true - - '@esbuild/linux-arm64@0.27.0': - optional: true - - '@esbuild/linux-arm@0.27.0': - optional: true - - '@esbuild/linux-ia32@0.27.0': - optional: true - - '@esbuild/linux-loong64@0.27.0': - optional: true - - '@esbuild/linux-mips64el@0.27.0': - optional: true - - '@esbuild/linux-ppc64@0.27.0': - optional: true - - '@esbuild/linux-riscv64@0.27.0': - optional: true - - '@esbuild/linux-s390x@0.27.0': - optional: true - - '@esbuild/linux-x64@0.27.0': - optional: true - - '@esbuild/netbsd-arm64@0.27.0': - optional: true - - '@esbuild/netbsd-x64@0.27.0': - optional: true - - '@esbuild/openbsd-arm64@0.27.0': - optional: true - - '@esbuild/openbsd-x64@0.27.0': - optional: true - - '@esbuild/openharmony-arm64@0.27.0': - optional: true - - '@esbuild/sunos-x64@0.27.0': - optional: true - - '@esbuild/win32-arm64@0.27.0': - optional: true - - '@esbuild/win32-ia32@0.27.0': - optional: true - - '@esbuild/win32-x64@0.27.0': - optional: true - - '@oslojs/asn1@1.0.0': - dependencies: - '@oslojs/binary': 1.0.0 - - '@oslojs/binary@1.0.0': {} - - '@oslojs/crypto@1.0.1': - dependencies: - '@oslojs/asn1': 1.0.0 - '@oslojs/binary': 1.0.0 - - '@oslojs/encoding@1.1.0': {} - - '@panva/hkdf@1.2.1': {} - '@playwright/test@1.57.0': dependencies: playwright: 1.57.0 - '@types/cookie@0.6.0': {} - - convex@1.39.1: - dependencies: - esbuild: 0.27.0 - prettier: 3.8.3 - ws: 8.18.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - cookie@0.7.1: {} - - cookie@1.1.1: {} - - esbuild@0.27.0: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.0 - '@esbuild/android-arm': 0.27.0 - '@esbuild/android-arm64': 0.27.0 - '@esbuild/android-x64': 0.27.0 - '@esbuild/darwin-arm64': 0.27.0 - '@esbuild/darwin-x64': 0.27.0 - '@esbuild/freebsd-arm64': 0.27.0 - '@esbuild/freebsd-x64': 0.27.0 - '@esbuild/linux-arm': 0.27.0 - '@esbuild/linux-arm64': 0.27.0 - '@esbuild/linux-ia32': 0.27.0 - '@esbuild/linux-loong64': 0.27.0 - '@esbuild/linux-mips64el': 0.27.0 - '@esbuild/linux-ppc64': 0.27.0 - '@esbuild/linux-riscv64': 0.27.0 - '@esbuild/linux-s390x': 0.27.0 - '@esbuild/linux-x64': 0.27.0 - '@esbuild/netbsd-arm64': 0.27.0 - '@esbuild/netbsd-x64': 0.27.0 - '@esbuild/openbsd-arm64': 0.27.0 - '@esbuild/openbsd-x64': 0.27.0 - '@esbuild/openharmony-arm64': 0.27.0 - '@esbuild/sunos-x64': 0.27.0 - '@esbuild/win32-arm64': 0.27.0 - '@esbuild/win32-ia32': 0.27.0 - '@esbuild/win32-x64': 0.27.0 - fsevents@2.3.2: optional: true - is-network-error@1.3.2: {} - - jose@5.10.0: {} - - jwt-decode@4.0.0: {} - - lucia@3.2.2: - dependencies: - '@oslojs/crypto': 1.0.1 - '@oslojs/encoding': 1.1.0 - - oauth4webapi@3.8.6: {} - - path-to-regexp@6.3.0: {} - playwright-core@1.57.0: {} playwright@1.57.0: @@ -523,18 +53,3 @@ snapshots: playwright-core: 1.57.0 optionalDependencies: fsevents: 2.3.2 - - preact-render-to-string@5.2.3(preact@10.11.3): - dependencies: - preact: 10.11.3 - pretty-format: 3.8.0 - - preact@10.11.3: {} - - prettier@3.8.3: {} - - pretty-format@3.8.0: {} - - server-only@0.0.1: {} - - ws@8.18.0: {} diff --git a/convex/_generated/api.d.ts b/recycle/20260522-convex-runtime-retirement/convex/_generated/api.d.ts similarity index 100% rename from convex/_generated/api.d.ts rename to recycle/20260522-convex-runtime-retirement/convex/_generated/api.d.ts diff --git a/convex/_generated/api.js b/recycle/20260522-convex-runtime-retirement/convex/_generated/api.js similarity index 100% rename from convex/_generated/api.js rename to recycle/20260522-convex-runtime-retirement/convex/_generated/api.js diff --git a/convex/_generated/dataModel.d.ts b/recycle/20260522-convex-runtime-retirement/convex/_generated/dataModel.d.ts similarity index 100% rename from convex/_generated/dataModel.d.ts rename to recycle/20260522-convex-runtime-retirement/convex/_generated/dataModel.d.ts diff --git a/convex/_generated/server.d.ts b/recycle/20260522-convex-runtime-retirement/convex/_generated/server.d.ts similarity index 100% rename from convex/_generated/server.d.ts rename to recycle/20260522-convex-runtime-retirement/convex/_generated/server.d.ts diff --git a/convex/_generated/server.js b/recycle/20260522-convex-runtime-retirement/convex/_generated/server.js similarity index 100% rename from convex/_generated/server.js rename to recycle/20260522-convex-runtime-retirement/convex/_generated/server.js diff --git a/convex/aiSessions.ts b/recycle/20260522-convex-runtime-retirement/convex/aiSessions.ts similarity index 100% rename from convex/aiSessions.ts rename to recycle/20260522-convex-runtime-retirement/convex/aiSessions.ts diff --git a/convex/auth.config.ts b/recycle/20260522-convex-runtime-retirement/convex/auth.config.ts similarity index 100% rename from convex/auth.config.ts rename to recycle/20260522-convex-runtime-retirement/convex/auth.config.ts diff --git a/convex/auth.ts b/recycle/20260522-convex-runtime-retirement/convex/auth.ts similarity index 100% rename from convex/auth.ts rename to recycle/20260522-convex-runtime-retirement/convex/auth.ts diff --git a/convex/schema.ts b/recycle/20260522-convex-runtime-retirement/convex/schema.ts similarity index 100% rename from convex/schema.ts rename to recycle/20260522-convex-runtime-retirement/convex/schema.ts diff --git a/convex/users.ts b/recycle/20260522-convex-runtime-retirement/convex/users.ts similarity index 100% rename from convex/users.ts rename to recycle/20260522-convex-runtime-retirement/convex/users.ts diff --git a/scripts/run-convex-deploy.js b/recycle/20260522-convex-runtime-retirement/scripts/run-convex-deploy.js similarity index 100% rename from scripts/run-convex-deploy.js rename to recycle/20260522-convex-runtime-retirement/scripts/run-convex-deploy.js diff --git a/scripts/task019-document-ui-regression.js b/recycle/scripts/retired-ui-regressions/task019-document-ui-regression.js similarity index 100% rename from scripts/task019-document-ui-regression.js rename to recycle/scripts/retired-ui-regressions/task019-document-ui-regression.js diff --git a/scripts/task021-mindmap-ui-regression.js b/recycle/scripts/retired-ui-regressions/task021-mindmap-ui-regression.js similarity index 100% rename from scripts/task021-mindmap-ui-regression.js rename to recycle/scripts/retired-ui-regressions/task021-mindmap-ui-regression.js diff --git a/scripts/task022-onlyoffice-ui-regression.js b/recycle/scripts/retired-ui-regressions/task022-onlyoffice-ui-regression.js similarity index 100% rename from scripts/task022-onlyoffice-ui-regression.js rename to recycle/scripts/retired-ui-regressions/task022-onlyoffice-ui-regression.js diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 49f3f76a..8fddc4c3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -23,6 +23,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -154,6 +163,12 @@ dependencies = [ "syn", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "axum" version = "0.8.9" @@ -302,6 +317,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.0" @@ -447,6 +476,21 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" +[[package]] +name = "control-plane" +version = "0.1.0" +dependencies = [ + "chrono", + "hex", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", + "tracing", + "uuid", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -478,6 +522,12 @@ dependencies = [ name = "core-domain" version = "0.1.0" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "core-protocol" version = "0.1.0" @@ -647,6 +697,18 @@ dependencies = [ "core-domain", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -882,12 +944,27 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.12.1" @@ -1027,6 +1104,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1431,6 +1532,23 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libsqlite3-sys" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1533,6 +1651,7 @@ dependencies = [ "base64", "bridge-runtime", "comrak", + "control-plane", "core-protocol", "futures-util", "hyper", @@ -1552,6 +1671,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -1619,6 +1739,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -1745,6 +1874,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "potential_utf" version = "0.1.5" @@ -2115,6 +2250,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rusqlite" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2130,6 +2279,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.38" @@ -2549,6 +2711,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3026,6 +3201,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -3250,12 +3431,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 7493131e..4cce6425 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/adapter-onlyoffice", "crates/bridge-runtime", + "crates/control-plane", "crates/core-domain", "crates/core-protocol", "crates/event-log", diff --git a/rust/crates/control-plane/Cargo.toml b/rust/crates/control-plane/Cargo.toml new file mode 100644 index 00000000..b741eafe --- /dev/null +++ b/rust/crates/control-plane/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "control-plane" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rusqlite = { version = "0.34", features = ["bundled"] } +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } +tracing = "0.1" +sha2 = "0.10" +hex = "0.4" + +[dev-dependencies] +tempfile = "3" diff --git a/rust/crates/control-plane/migrations/001-initial-schema.sql b/rust/crates/control-plane/migrations/001-initial-schema.sql new file mode 100644 index 00000000..ec469121 --- /dev/null +++ b/rust/crates/control-plane/migrations/001-initial-schema.sql @@ -0,0 +1,172 @@ +-- 001-initial-schema.sql +-- MNote control-plane v1 schema +-- +-- All tables use TEXT for datetime columns (ISO-8601) so the database +-- remains readable without language-specific timestamp types. + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE, + username TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS auth_identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_subject TEXT NOT NULL, + password_hash TEXT, + password_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(provider, provider_subject) +); + +CREATE TABLE IF NOT EXISTS auth_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + user_agent TEXT, + ip_hash TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + last_seen_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_auth_sessions_token ON auth_sessions(token_hash); + +CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'personal', + root_uri TEXT NOT NULL UNIQUE, + root_path TEXT NOT NULL, + source_kind TEXT NOT NULL DEFAULT 'local_folder', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_user_id); + +CREATE TABLE IF NOT EXISTS workspace_members ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + UNIQUE(workspace_id, user_id) +); + +CREATE TABLE IF NOT EXISTS directory_grants ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL, + root_uri TEXT NOT NULL, + root_path TEXT NOT NULL, + permission TEXT NOT NULL, + recursive INTEGER NOT NULL DEFAULT 1, + capabilities_json TEXT NOT NULL DEFAULT '[]', + source TEXT NOT NULL DEFAULT 'admin', + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX IF NOT EXISTS idx_directory_grants_user ON directory_grants(user_id); +CREATE INDEX IF NOT EXISTS idx_directory_grants_root_uri ON directory_grants(root_uri); + +CREATE TABLE IF NOT EXISTS share_links ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + resource_kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + permission TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(id), + expires_at TEXT, + revoked_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX IF NOT EXISTS idx_share_links_resource ON share_links(workspace_id, resource_kind, resource_id); + +CREATE TABLE IF NOT EXISTS sync_state ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + remote_kind TEXT NOT NULL, + remote_id TEXT, + cursor TEXT, + last_synced_at TEXT, + status TEXT NOT NULL DEFAULT 'idle', + error_json TEXT, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + UNIQUE(workspace_id, remote_kind) +); + +CREATE TABLE IF NOT EXISTS ai_policies ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + allowed_roots_json TEXT NOT NULL DEFAULT '[]', + model_policy_json TEXT NOT NULL DEFAULT '{}', + quota_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + actor_user_id TEXT REFERENCES users(id), + action TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_id TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor_user_id, created_at); +CREATE INDEX IF NOT EXISTS idx_audit_target ON audit_log(target_kind, target_id, created_at); + +CREATE TABLE IF NOT EXISTS outbox_events ( + id TEXT PRIMARY KEY, + topic TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + delivered_at TEXT, + attempts INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_outbox_pending ON outbox_events(delivered_at, created_at); + +CREATE TABLE IF NOT EXISTS legacy_id_map ( + id TEXT PRIMARY KEY, + legacy_system TEXT NOT NULL, + legacy_kind TEXT NOT NULL, + legacy_id TEXT NOT NULL, + new_kind TEXT NOT NULL, + new_id TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + UNIQUE(legacy_system, legacy_kind, legacy_id) +); diff --git a/rust/crates/control-plane/migrations/002-ai-runtime-store.sql b/rust/crates/control-plane/migrations/002-ai-runtime-store.sql new file mode 100644 index 00000000..531ba396 --- /dev/null +++ b/rust/crates/control-plane/migrations/002-ai-runtime-store.sql @@ -0,0 +1,48 @@ +-- 002-ai-runtime-store.sql +-- MNote ACP/Hermes runtime session store. + +CREATE TABLE IF NOT EXISTS ai_runtime_runs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + workspace_id TEXT, + document_id TEXT, + session_id TEXT NOT NULL, + run_id TEXT NOT NULL UNIQUE, + title TEXT, + profile TEXT NOT NULL, + acp_runtime TEXT NOT NULL, + trace_id TEXT, + status TEXT NOT NULL, + runtime_json TEXT NOT NULL DEFAULT '{}', + payload_json TEXT NOT NULL DEFAULT '{}', + deleted_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX IF NOT EXISTS idx_ai_runtime_runs_user_session + ON ai_runtime_runs(user_id, session_id, updated_at); +CREATE INDEX IF NOT EXISTS idx_ai_runtime_runs_workspace + ON ai_runtime_runs(workspace_id, updated_at); +CREATE INDEX IF NOT EXISTS idx_ai_runtime_runs_document + ON ai_runtime_runs(document_id, updated_at); + +CREATE TABLE IF NOT EXISTS ai_runtime_events ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + workspace_id TEXT, + document_id TEXT, + session_id TEXT NOT NULL, + run_id TEXT NOT NULL, + profile TEXT NOT NULL, + acp_runtime TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ai_runtime_events_run + ON ai_runtime_events(user_id, run_id, created_at); +CREATE INDEX IF NOT EXISTS idx_ai_runtime_events_session + ON ai_runtime_events(user_id, session_id, created_at); diff --git a/rust/crates/control-plane/src/error.rs b/rust/crates/control-plane/src/error.rs new file mode 100644 index 00000000..f119fe5b --- /dev/null +++ b/rust/crates/control-plane/src/error.rs @@ -0,0 +1,38 @@ +use std::fmt; + +#[derive(Debug, Clone)] +pub enum ControlPlaneError { + NotFound(String), + Conflict(String), + InvalidInput(String), + Storage(String), + Unauthorized(String), + SessionExpired(String), +} + +impl fmt::Display for ControlPlaneError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ControlPlaneError::NotFound(msg) => write!(f, "NotFound: {msg}"), + ControlPlaneError::Conflict(msg) => write!(f, "Conflict: {msg}"), + ControlPlaneError::InvalidInput(msg) => write!(f, "InvalidInput: {msg}"), + ControlPlaneError::Storage(msg) => write!(f, "Storage: {msg}"), + ControlPlaneError::Unauthorized(msg) => write!(f, "Unauthorized: {msg}"), + ControlPlaneError::SessionExpired(msg) => write!(f, "SessionExpired: {msg}"), + } + } +} + +impl std::error::Error for ControlPlaneError {} + +impl From for ControlPlaneError { + fn from(e: rusqlite::Error) -> Self { + ControlPlaneError::Storage(e.to_string()) + } +} + +impl From for ControlPlaneError { + fn from(e: serde_json::Error) -> Self { + ControlPlaneError::Storage(format!("JSON error: {e}")) + } +} diff --git a/rust/crates/control-plane/src/lib.rs b/rust/crates/control-plane/src/lib.rs new file mode 100644 index 00000000..a2e11994 --- /dev/null +++ b/rust/crates/control-plane/src/lib.rs @@ -0,0 +1,11 @@ +pub mod error; +pub mod migrations; +pub mod model; +pub mod sqlite; +pub mod store; + +pub use error::ControlPlaneError; +pub use migrations::run_migrations; +pub use model::*; +pub use sqlite::SqliteControlPlaneStore; +pub use store::*; diff --git a/rust/crates/control-plane/src/migrations.rs b/rust/crates/control-plane/src/migrations.rs new file mode 100644 index 00000000..ee2f42ed --- /dev/null +++ b/rust/crates/control-plane/src/migrations.rs @@ -0,0 +1,109 @@ +//! SQLite schema migrations for the MNote control-plane database. +//! +//! Each migration is a named step that runs inside a transaction. +//! The `migrations` table tracks which steps have been applied. + +use rusqlite::{Connection, Result as SqlResult}; + +const MIGRATIONS: &[(&str, &str)] = &[ + ( + "v1-initial-schema", + include_str!("../migrations/001-initial-schema.sql"), + ), + ( + "v2-ai-runtime-store", + include_str!("../migrations/002-ai-runtime-store.sql"), + ), +]; + +/// Create the `_migrations` meta-table if it does not exist. +fn ensure_meta_table(conn: &Connection) -> SqlResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS _migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + );", + ) +} + +/// Run all pending migrations inside a single transaction. +pub fn run_migrations(conn: &Connection) -> SqlResult<()> { + ensure_meta_table(conn)?; + + let already_applied: Vec = conn + .prepare("SELECT name FROM _migrations")? + .query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + + for (name, sql) in MIGRATIONS { + if already_applied.contains(&name.to_string()) { + continue; + } + tracing::info!("Running migration: {name}"); + conn.execute_batch(sql)?; + conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Create a fresh in-memory SQLite database and run migrations. + fn fresh_db() -> Connection { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;", + ) + .expect("pragmas"); + conn + } + + fn table_exists(conn: &Connection, name: &str) -> bool { + conn.query_row( + "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?1", + [name], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0) + > 0 + } + + #[test] + fn migration_is_idempotent() { + let conn = fresh_db(); + run_migrations(&conn).expect("first run"); + run_migrations(&conn).expect("second run (idempotent)"); + + // All expected tables exist + for table in &[ + "users", + "auth_identities", + "auth_sessions", + "workspaces", + "workspace_members", + "directory_grants", + "share_links", + "sync_state", + "ai_policies", + "audit_log", + "outbox_events", + "legacy_id_map", + "ai_runtime_runs", + "ai_runtime_events", + ] { + assert!(table_exists(&conn, table), "table {table} should exist"); + } + } + + #[test] + fn migration_runs_fresh_db_ok() { + let conn = fresh_db(); + run_migrations(&conn).expect("fresh migration ok"); + assert!(table_exists(&conn, "users")); + assert!(table_exists(&conn, "_migrations")); + } +} diff --git a/rust/crates/control-plane/src/model.rs b/rust/crates/control-plane/src/model.rs new file mode 100644 index 00000000..f9a04f42 --- /dev/null +++ b/rust/crates/control-plane/src/model.rs @@ -0,0 +1,348 @@ +//! SQLite 控制面 Phase 1 的最小数据模型。 + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub type EntityId = String; +pub type Timestamp = String; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserRecord { + pub id: EntityId, + pub email: Option, + pub username: String, + pub display_name: String, + pub role: String, + pub status: String, + pub created_at: Timestamp, + pub updated_at: Timestamp, + pub revision: i64, +} + +pub fn session_token_hash(raw_token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(raw_token.as_bytes()); + hex::encode(hasher.finalize()) +} + +pub fn password_hash_v1(password: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"mnote-password-v1:"); + hasher.update(password.as_bytes()); + format!("sha256-v1:{}", hex::encode(hasher.finalize())) +} + +pub fn share_token_hash_v1(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"mnote-share-token-v1:"); + hasher.update(token.as_bytes()); + format!("sha256-v1:{}", hex::encode(hasher.finalize())) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpsertUserInput { + pub id: Option, + pub email: Option, + pub username: String, + pub display_name: String, + pub role: Option, + pub password_hash: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreatePasswordIdentityInput { + pub user_id: EntityId, + pub email: Option, + pub username: String, + pub password: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthenticatePasswordInput { + pub account: String, + pub password: String, + pub session_id: Option, + pub token_hash: String, + pub user_agent: Option, + pub ip_hash: Option, + pub expires_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthSessionRecord { + pub id: EntityId, + pub user_id: EntityId, + pub token_hash: String, + pub user_agent: Option, + pub ip_hash: Option, + pub created_at: Timestamp, + pub expires_at: Timestamp, + pub revoked_at: Option, + pub last_seen_at: Timestamp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateSessionInput { + pub id: Option, + pub user_id: EntityId, + pub token_hash: String, + pub user_agent: Option, + pub ip_hash: Option, + pub expires_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedAuthSession { + pub session: AuthSessionRecord, + pub user: UserRecord, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceRecord { + pub id: EntityId, + pub owner_user_id: EntityId, + pub name: String, + pub kind: String, + pub root_uri: String, + pub root_path: String, + pub source_kind: String, + pub status: String, + pub created_at: Timestamp, + pub updated_at: Timestamp, + pub revision: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryGrantRecord { + pub id: EntityId, + pub user_id: EntityId, + pub workspace_id: Option, + pub root_uri: String, + pub root_path: String, + pub permission: String, + pub recursive: bool, + pub capabilities_json: String, + pub source: String, + pub status: String, + pub created_by: Option, + pub created_at: Timestamp, + pub updated_at: Timestamp, + pub revision: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryGrantLookup { + pub grant_id: Option, + pub user_id: Option, + pub root_uri: Option, + pub include_revoked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryGrantInput { + pub user_id: EntityId, + pub workspace_id: Option, + pub root_uri: String, + pub root_path: String, + pub permission: String, + pub recursive: bool, + pub capabilities: Vec, + pub source: String, + pub created_by: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedAccess { + pub user_id: EntityId, + pub root_uri: String, + pub permission: String, + pub grant_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OutboxEventRecord { + pub id: EntityId, + pub topic: String, + pub event_type: String, + pub payload_json: String, + pub created_at: Timestamp, + pub delivered_at: Option, + pub attempts: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OutboxEventInput { + pub topic: String, + pub event_type: String, + pub payload_json: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiPolicyRecord { + pub id: EntityId, + pub user_id: Option, + pub workspace_id: Option, + pub allowed_roots_json: String, + pub model_policy_json: String, + pub quota_json: String, + pub created_at: Timestamp, + pub updated_at: Timestamp, + pub revision: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpsertAiPolicyInput { + pub id: Option, + pub user_id: Option, + pub workspace_id: Option, + pub allowed_roots_json: String, + pub model_policy_json: String, + pub quota_json: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncStateRecord { + pub id: EntityId, + pub workspace_id: EntityId, + pub remote_kind: String, + pub remote_id: Option, + pub cursor: Option, + pub last_synced_at: Option, + pub status: String, + pub error_json: Option, + pub updated_at: Timestamp, + pub revision: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpsertSyncStateInput { + pub id: Option, + pub workspace_id: EntityId, + pub remote_kind: String, + pub remote_id: Option, + pub cursor: Option, + pub last_synced_at: Option, + pub status: String, + pub error_json: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiRuntimeRunRecord { + pub id: EntityId, + pub user_id: EntityId, + pub workspace_id: Option, + pub document_id: Option, + pub session_id: EntityId, + pub run_id: EntityId, + pub title: Option, + pub profile: String, + pub acp_runtime: String, + pub trace_id: Option, + pub status: String, + pub runtime_json: String, + pub payload_json: String, + pub deleted_at: Option, + pub created_at: Timestamp, + pub updated_at: Timestamp, + pub revision: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpsertAiRuntimeRunInput { + pub id: Option, + pub user_id: EntityId, + pub workspace_id: Option, + pub document_id: Option, + pub session_id: EntityId, + pub run_id: EntityId, + pub title: Option, + pub profile: String, + pub acp_runtime: String, + pub trace_id: Option, + pub status: String, + pub runtime_json: String, + pub payload_json: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiRuntimeEventRecord { + pub id: EntityId, + pub user_id: EntityId, + pub workspace_id: Option, + pub document_id: Option, + pub session_id: EntityId, + pub run_id: EntityId, + pub profile: String, + pub acp_runtime: String, + pub event_type: String, + pub payload_json: String, + pub created_at: Timestamp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppendAiRuntimeEventInput { + pub id: Option, + pub user_id: EntityId, + pub workspace_id: Option, + pub document_id: Option, + pub session_id: EntityId, + pub run_id: EntityId, + pub profile: String, + pub acp_runtime: String, + pub event_type: String, + pub payload_json: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShareLinkRecord { + pub id: EntityId, + pub workspace_id: EntityId, + pub resource_kind: String, + pub resource_id: String, + pub token_hash: String, + pub permission: String, + pub created_by: EntityId, + pub expires_at: Option, + pub revoked_at: Option, + pub created_at: Timestamp, + pub updated_at: Timestamp, + pub revision: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateShareLinkInput { + pub id: Option, + pub workspace_id: EntityId, + pub resource_kind: String, + pub resource_id: String, + pub token: Option, + pub permission: String, + pub created_by: EntityId, + pub expires_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreatedShareLink { + pub link: ShareLinkRecord, + pub token: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditLogRecord { + pub id: EntityId, + pub actor_user_id: Option, + pub action: String, + pub target_kind: String, + pub target_id: Option, + pub metadata_json: String, + pub created_at: Timestamp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppendAuditInput { + pub actor_user_id: Option, + pub action: String, + pub target_kind: String, + pub target_id: Option, + pub metadata_json: String, +} diff --git a/rust/crates/control-plane/src/sqlite.rs b/rust/crates/control-plane/src/sqlite.rs new file mode 100644 index 00000000..78f12ab1 --- /dev/null +++ b/rust/crates/control-plane/src/sqlite.rs @@ -0,0 +1,2273 @@ +//! SQLite-backed control-plane store. + +use std::sync::Mutex; + +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::error::ControlPlaneError; +use crate::migrations; +use crate::model::{ + password_hash_v1, share_token_hash_v1, AiPolicyRecord, AiRuntimeEventRecord, + AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord, + AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, + CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup, + DirectoryGrantRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, + ShareLinkRecord, SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, + UpsertSyncStateInput, UpsertUserInput, UserRecord, WorkspaceRecord, +}; +use crate::store::ControlPlaneStore; + +pub struct SqliteControlPlaneStore { + conn: Mutex, +} + +impl SqliteControlPlaneStore { + pub fn open(path: impl AsRef) -> Result { + let conn = Connection::open(path)?; + configure_connection(&conn)?; + migrations::run_migrations(&conn)?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + pub fn in_memory() -> Result { + let conn = Connection::open_in_memory()?; + configure_connection(&conn)?; + migrations::run_migrations(&conn)?; + Ok(Self { + conn: Mutex::new(conn), + }) + } +} + +fn configure_connection(conn: &Connection) -> Result<(), ControlPlaneError> { + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000;", + )?; + Ok(()) +} + +fn now_text() -> String { + chrono::Utc::now().to_rfc3339() +} + +fn new_id(prefix: &str) -> String { + format!("{prefix}_{}", uuid::Uuid::new_v4().simple()) +} + +fn capabilities_json(capabilities: &[String]) -> Result { + serde_json::to_string(capabilities).map_err(ControlPlaneError::from) +} + +fn row_to_user(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(UserRecord { + id: row.get(0)?, + email: row.get(1)?, + username: row.get(2)?, + display_name: row.get(3)?, + role: row.get(4)?, + status: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + revision: row.get(8)?, + }) +} + +fn row_to_auth_session(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(AuthSessionRecord { + id: row.get(0)?, + user_id: row.get(1)?, + token_hash: row.get(2)?, + user_agent: row.get(3)?, + ip_hash: row.get(4)?, + created_at: row.get(5)?, + expires_at: row.get(6)?, + revoked_at: row.get(7)?, + last_seen_at: row.get(8)?, + }) +} + +fn row_to_workspace(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(WorkspaceRecord { + id: row.get(0)?, + owner_user_id: row.get(1)?, + name: row.get(2)?, + kind: row.get(3)?, + root_uri: row.get(4)?, + root_path: row.get(5)?, + source_kind: row.get(6)?, + status: row.get(7)?, + created_at: row.get(8)?, + updated_at: row.get(9)?, + revision: row.get(10)?, + }) +} + +fn row_to_grant(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(DirectoryGrantRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: row.get(2)?, + root_uri: row.get(3)?, + root_path: row.get(4)?, + permission: row.get(5)?, + recursive: row.get::<_, i64>(6)? != 0, + capabilities_json: row.get(7)?, + source: row.get(8)?, + status: row.get(9)?, + created_by: row.get(10)?, + created_at: row.get(11)?, + updated_at: row.get(12)?, + revision: row.get(13)?, + }) +} + +fn row_to_outbox(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(OutboxEventRecord { + id: row.get(0)?, + topic: row.get(1)?, + event_type: row.get(2)?, + payload_json: row.get(3)?, + created_at: row.get(4)?, + delivered_at: row.get(5)?, + attempts: row.get(6)?, + }) +} + +fn row_to_share_link(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(ShareLinkRecord { + id: row.get(0)?, + workspace_id: row.get(1)?, + resource_kind: row.get(2)?, + resource_id: row.get(3)?, + token_hash: row.get(4)?, + permission: row.get(5)?, + created_by: row.get(6)?, + expires_at: row.get(7)?, + revoked_at: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + revision: row.get(11)?, + }) +} + +fn row_to_audit_log(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(AuditLogRecord { + id: row.get(0)?, + actor_user_id: row.get(1)?, + action: row.get(2)?, + target_kind: row.get(3)?, + target_id: row.get(4)?, + metadata_json: row.get(5)?, + created_at: row.get(6)?, + }) +} + +fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(AiPolicyRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: row.get(2)?, + allowed_roots_json: row.get(3)?, + model_policy_json: row.get(4)?, + quota_json: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + revision: row.get(8)?, + }) +} + +fn row_to_sync_state(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SyncStateRecord { + id: row.get(0)?, + workspace_id: row.get(1)?, + remote_kind: row.get(2)?, + remote_id: row.get(3)?, + cursor: row.get(4)?, + last_synced_at: row.get(5)?, + status: row.get(6)?, + error_json: row.get(7)?, + updated_at: row.get(8)?, + revision: row.get(9)?, + }) +} + +fn row_to_ai_runtime_run(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(AiRuntimeRunRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: row.get(2)?, + document_id: row.get(3)?, + session_id: row.get(4)?, + run_id: row.get(5)?, + title: row.get(6)?, + profile: row.get(7)?, + acp_runtime: row.get(8)?, + trace_id: row.get(9)?, + status: row.get(10)?, + runtime_json: row.get(11)?, + payload_json: row.get(12)?, + deleted_at: row.get(13)?, + created_at: row.get(14)?, + updated_at: row.get(15)?, + revision: row.get(16)?, + }) +} + +fn row_to_ai_runtime_event(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(AiRuntimeEventRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: row.get(2)?, + document_id: row.get(3)?, + session_id: row.get(4)?, + run_id: row.get(5)?, + profile: row.get(6)?, + acp_runtime: row.get(7)?, + event_type: row.get(8)?, + payload_json: row.get(9)?, + created_at: row.get(10)?, + }) +} + +fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String { + let title = serde_json::from_str::(payload_json) + .ok() + .and_then(|payload| { + payload + .get("title") + .or_else(|| payload.get("message")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| fallback.to_string()); + title.chars().take(80).collect() +} + +impl ControlPlaneStore for SqliteControlPlaneStore { + fn upsert_user(&self, input: UpsertUserInput) -> Result { + if input.username.trim().is_empty() { + return Err(ControlPlaneError::InvalidInput( + "username 不能为空".to_string(), + )); + } + if input.display_name.trim().is_empty() { + return Err(ControlPlaneError::InvalidInput( + "display_name 不能为空".to_string(), + )); + } + + let conn = self.conn.lock().unwrap(); + let now = now_text(); + let id = input.id.unwrap_or_else(|| new_id("usr")); + let role = input.role.unwrap_or_else(|| "user".to_string()); + + let existing = conn + .query_row( + "SELECT id, email, username, display_name, role, status, created_at, updated_at, revision + FROM users WHERE id = ?1", + params![id], + row_to_user, + ) + .optional()?; + + if let Some(existing) = existing { + let revision = existing.revision + 1; + conn.execute( + "UPDATE users + SET email = ?1, username = ?2, display_name = ?3, role = ?4, updated_at = ?5, revision = ?6 + WHERE id = ?7", + params![ + input.email, + input.username, + input.display_name, + role, + now, + revision, + existing.id + ], + )?; + return Ok(UserRecord { + id: existing.id, + email: input.email, + username: input.username, + display_name: input.display_name, + role, + status: existing.status, + created_at: existing.created_at, + updated_at: now, + revision, + }); + } + + conn.execute( + "INSERT INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision) + VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, ?7, 1)", + params![id, input.email, input.username, input.display_name, role, now, now], + )?; + + Ok(UserRecord { + id, + email: input.email, + username: input.username, + display_name: input.display_name, + role, + status: "active".to_string(), + created_at: now.clone(), + updated_at: now, + revision: 1, + }) + } + + fn create_password_identity( + &self, + input: CreatePasswordIdentityInput, + ) -> Result<(), ControlPlaneError> { + let username = input.username.trim(); + if username.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "username 不能为空".to_string(), + )); + } + if input.password.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "password 不能为空".to_string(), + )); + } + + let conn = self.conn.lock().unwrap(); + let now = now_text(); + let user_id = input.user_id; + let password_hash = password_hash_v1(&input.password); + conn.execute( + "INSERT INTO auth_identities (id, user_id, provider, provider_subject, password_hash, password_version, created_at, updated_at) + VALUES (?1, ?2, 'password_username', ?3, ?4, 1, ?5, ?6) + ON CONFLICT(provider, provider_subject) + DO UPDATE SET user_id = excluded.user_id, password_hash = excluded.password_hash, updated_at = excluded.updated_at", + params![ + new_id("ident"), + user_id, + username, + password_hash, + now, + now + ], + )?; + if let Some(email) = input + .email + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + conn.execute( + "INSERT INTO auth_identities (id, user_id, provider, provider_subject, password_hash, password_version, created_at, updated_at) + VALUES (?1, ?2, 'password_email', ?3, ?4, 1, ?5, ?6) + ON CONFLICT(provider, provider_subject) + DO UPDATE SET user_id = excluded.user_id, password_hash = excluded.password_hash, updated_at = excluded.updated_at", + params![ + new_id("ident"), + user_id, + email, + password_hash_v1(&input.password), + now, + now + ], + )?; + } + Ok(()) + } + + fn authenticate_password( + &self, + input: AuthenticatePasswordInput, + ) -> Result { + let account = input.account.trim(); + if account.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "account 不能为空".to_string(), + )); + } + + let conn = self.conn.lock().unwrap(); + let provider = if account.contains('@') { + "password_email" + } else { + "password_username" + }; + let expected_hash = password_hash_v1(&input.password); + let user = conn + .query_row( + "SELECT u.id, u.email, u.username, u.display_name, u.role, u.status, u.created_at, u.updated_at, u.revision + FROM auth_identities i + JOIN users u ON u.id = i.user_id + WHERE i.provider = ?1 + AND i.provider_subject = ?2 + AND i.password_hash = ?3 + AND u.status = 'active' + LIMIT 1", + params![provider, account, expected_hash], + row_to_user, + ) + .optional()? + .ok_or_else(|| ControlPlaneError::Unauthorized("账号或密码错误".to_string()))?; + drop(conn); + + let session = self.create_session(CreateSessionInput { + id: input.session_id, + user_id: user.id.clone(), + token_hash: input.token_hash, + user_agent: input.user_agent, + ip_hash: input.ip_hash, + expires_at: input.expires_at, + })?; + Ok(ResolvedAuthSession { session, user }) + } + + fn create_session( + &self, + input: CreateSessionInput, + ) -> Result { + let token_hash = input.token_hash.trim(); + if token_hash.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "token_hash 不能为空".to_string(), + )); + } + + let conn = self.conn.lock().unwrap(); + let now = now_text(); + let expires_at = input + .expires_at + .unwrap_or_else(|| (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339()); + let record = AuthSessionRecord { + id: input.id.unwrap_or_else(|| new_id("sess")), + user_id: input.user_id, + token_hash: token_hash.to_string(), + user_agent: input.user_agent, + ip_hash: input.ip_hash, + created_at: now.clone(), + expires_at, + revoked_at: None, + last_seen_at: now.clone(), + }; + conn.execute( + "INSERT INTO auth_sessions (id, user_id, token_hash, user_agent, ip_hash, created_at, expires_at, revoked_at, last_seen_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)", + params![ + record.id, + record.user_id, + record.token_hash, + record.user_agent, + record.ip_hash, + record.created_at, + record.expires_at, + record.last_seen_at + ], + )?; + Ok(record) + } + + fn get_session_by_token_hash( + &self, + token_hash: &str, + ) -> Result, ControlPlaneError> { + let token_hash = token_hash.trim(); + if token_hash.is_empty() { + return Ok(None); + } + + let conn = self.conn.lock().unwrap(); + let now = now_text(); + let resolved = conn + .query_row( + "SELECT + s.id, s.user_id, s.token_hash, s.user_agent, s.ip_hash, s.created_at, s.expires_at, s.revoked_at, s.last_seen_at, + u.id, u.email, u.username, u.display_name, u.role, u.status, u.created_at, u.updated_at, u.revision + FROM auth_sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ?1 + AND s.revoked_at IS NULL + AND s.expires_at > ?2 + AND u.status = 'active' + LIMIT 1", + params![token_hash, now], + |row| { + Ok(ResolvedAuthSession { + session: row_to_auth_session(row)?, + user: UserRecord { + id: row.get(9)?, + email: row.get(10)?, + username: row.get(11)?, + display_name: row.get(12)?, + role: row.get(13)?, + status: row.get(14)?, + created_at: row.get(15)?, + updated_at: row.get(16)?, + revision: row.get(17)?, + }, + }) + }, + ) + .optional()?; + + if let Some(resolved) = &resolved { + conn.execute( + "UPDATE auth_sessions SET last_seen_at = ?1 WHERE id = ?2", + params![now, resolved.session.id], + )?; + } + + Ok(resolved) + } + + fn revoke_session(&self, session_id: &str) -> Result<(), ControlPlaneError> { + let session_id = session_id.trim(); + if session_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "session_id 不能为空".to_string(), + )); + } + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE auth_sessions SET revoked_at = ?1 WHERE id = ?2 AND revoked_at IS NULL", + params![now_text(), session_id], + )?; + Ok(()) + } + + fn ensure_default_workspace( + &self, + actor_id: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let existing = conn + .query_row( + "SELECT id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision + FROM workspaces + WHERE owner_user_id = ?1 AND kind = 'personal' AND status = 'active' + LIMIT 1", + params![actor_id], + row_to_workspace, + ) + .optional()?; + if let Some(workspace) = existing { + return Ok(workspace); + } + + let user = conn + .query_row( + "SELECT id, email, username, display_name, role, status, created_at, updated_at, revision + FROM users WHERE id = ?1", + params![actor_id], + row_to_user, + ) + .optional()? + .ok_or_else(|| ControlPlaneError::NotFound(format!("user not found: {actor_id}")))?; + + let now = now_text(); + let workspace = WorkspaceRecord { + id: new_id("ws"), + owner_user_id: user.id.clone(), + name: format!("{} 的空间", user.display_name), + kind: "personal".to_string(), + root_uri: format!("local://users/{}/workspaces/my-space", user.id), + root_path: format!( + "/mnt/Data1T/Mnote_data/users/{}/workspaces/my-space", + user.id + ), + source_kind: "local_folder".to_string(), + status: "active".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + revision: 1, + }; + + conn.execute( + "INSERT INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + workspace.id, + workspace.owner_user_id, + workspace.name, + workspace.kind, + workspace.root_uri, + workspace.root_path, + workspace.source_kind, + workspace.status, + workspace.created_at, + workspace.updated_at, + workspace.revision + ], + )?; + conn.execute( + "INSERT INTO workspace_members (id, workspace_id, user_id, role, status, created_at, updated_at, revision) + VALUES (?1, ?2, ?3, 'owner', 'active', ?4, ?5, 1)", + params![new_id("wsm"), workspace.id, user.id, now, now], + )?; + conn.execute( + "INSERT INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision) + VALUES (?1, ?2, ?3, ?4, ?5, 'write', 1, ?6, 'auto', 'active', ?7, ?8, ?9, 1)", + params![ + new_id("grant"), + workspace.owner_user_id, + workspace.id, + workspace.root_uri, + workspace.root_path, + capabilities_json(&["ai".to_string(), "share".to_string()])?, + workspace.owner_user_id, + now, + now + ], + )?; + + Ok(workspace) + } + + fn grant_directory_access( + &self, + input: DirectoryGrantInput, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let now = now_text(); + let record = DirectoryGrantRecord { + id: new_id("grant"), + user_id: input.user_id, + workspace_id: input.workspace_id, + root_uri: input.root_uri, + root_path: input.root_path, + permission: input.permission, + recursive: input.recursive, + capabilities_json: capabilities_json(&input.capabilities)?, + source: input.source, + status: "active".to_string(), + created_by: input.created_by, + created_at: now.clone(), + updated_at: now, + revision: 1, + }; + conn.execute( + "INSERT INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + params![ + record.id, + record.user_id, + record.workspace_id, + record.root_uri, + record.root_path, + record.permission, + if record.recursive { 1 } else { 0 }, + record.capabilities_json, + record.source, + record.status, + record.created_by, + record.created_at, + record.updated_at, + record.revision + ], + )?; + Ok(record) + } + + fn list_directory_grants(&self) -> Result, ControlPlaneError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision + FROM directory_grants + WHERE status = 'active' + ORDER BY created_at ASC", + )?; + let grants = stmt + .query_map([], row_to_grant)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(grants) + } + + fn list_directory_grants_for_actor( + &self, + actor_id: &str, + ) -> Result, ControlPlaneError> { + let actor_id = actor_id.trim(); + if actor_id.is_empty() { + return Ok(Vec::new()); + } + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision + FROM directory_grants + WHERE user_id = ?1 AND status = 'active' + ORDER BY created_at ASC", + )?; + let grants = stmt + .query_map(params![actor_id], row_to_grant)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(grants) + } + + fn find_directory_grants( + &self, + lookup: DirectoryGrantLookup, + ) -> Result, ControlPlaneError> { + let conn = self.conn.lock().unwrap(); + let grant_id = lookup + .grant_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let user_id = lookup + .user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let root_uri = lookup + .root_uri + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let status = if lookup.include_revoked { + "%" + } else { + "active" + }; + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision + FROM directory_grants + WHERE (?1 IS NULL OR id = ?1) + AND (?2 IS NULL OR user_id = ?2) + AND (?3 IS NULL OR root_uri = ?3) + AND status LIKE ?4 + ORDER BY created_at ASC", + )?; + let grants = stmt + .query_map(params![grant_id, user_id, root_uri, status], row_to_grant)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(grants) + } + + fn revoke_directory_grant( + &self, + grant_id: &str, + expected_revision: Option, + ) -> Result<(), ControlPlaneError> { + let grant_id = grant_id.trim(); + if grant_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "grant_id 不能为空".to_string(), + )); + } + let conn = self.conn.lock().unwrap(); + let existing_revision = conn + .query_row( + "SELECT revision FROM directory_grants WHERE id = ?1 AND status = 'active'", + params![grant_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .ok_or_else(|| ControlPlaneError::NotFound(format!("grant not found: {grant_id}")))?; + if let Some(expected_revision) = expected_revision { + if expected_revision != existing_revision { + return Err(ControlPlaneError::Conflict( + "目录授权 revision 已变化".to_string(), + )); + } + } + conn.execute( + "UPDATE directory_grants + SET status = 'revoked', updated_at = ?1, revision = revision + 1 + WHERE id = ?2 AND status = 'active'", + params![now_text(), grant_id], + )?; + Ok(()) + } + + fn resolve_access( + &self, + actor_id: &str, + root_uri: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision + FROM directory_grants + WHERE user_id = ?1 AND status = 'active' AND (?2 = root_uri OR (recursive = 1 AND ?2 LIKE root_uri || '%'))", + )?; + let grants = stmt + .query_map(params![actor_id, root_uri], row_to_grant)? + .collect::, _>>()?; + let mut permission = "none".to_string(); + for grant in &grants { + permission = max_permission(&permission, &grant.permission).to_string(); + } + Ok(ResolvedAccess { + user_id: actor_id.to_string(), + root_uri: root_uri.to_string(), + permission, + grant_ids: grants.into_iter().map(|grant| grant.id).collect(), + }) + } + + fn create_share_link( + &self, + input: CreateShareLinkInput, + ) -> Result { + let workspace_id = input.workspace_id.trim().to_string(); + if workspace_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "workspace_id 不能为空".to_string(), + )); + } + let resource_kind = input.resource_kind.trim().to_string(); + let resource_id = input.resource_id.trim().to_string(); + let permission = input.permission.trim().to_string(); + if resource_kind.is_empty() || resource_id.is_empty() || permission.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "share_link 资源与权限不能为空".to_string(), + )); + } + let token = input + .token + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let token_hash = share_token_hash_v1(&token); + let conn = self.conn.lock().unwrap(); + let now = now_text(); + let link = ShareLinkRecord { + id: input.id.unwrap_or_else(|| new_id("share")), + workspace_id, + resource_kind, + resource_id, + token_hash: token_hash.clone(), + permission, + created_by: input.created_by, + expires_at: input.expires_at, + revoked_at: None, + created_at: now.clone(), + updated_at: now.clone(), + revision: 1, + }; + conn.execute( + "INSERT INTO share_links (id, workspace_id, resource_kind, resource_id, token_hash, permission, created_by, expires_at, revoked_at, created_at, updated_at, revision) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, 1)", + params![ + link.id, + link.workspace_id, + link.resource_kind, + link.resource_id, + link.token_hash, + link.permission, + link.created_by, + link.expires_at, + link.created_at, + link.updated_at + ], + )?; + Ok(CreatedShareLink { link, token }) + } + + fn list_share_links( + &self, + workspace_id: &str, + ) -> Result, ControlPlaneError> { + let workspace_id = workspace_id.trim(); + if workspace_id.is_empty() { + return Ok(Vec::new()); + } + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, workspace_id, resource_kind, resource_id, token_hash, permission, created_by, expires_at, revoked_at, created_at, updated_at, revision + FROM share_links + WHERE workspace_id = ?1 + ORDER BY created_at ASC", + )?; + let links = stmt + .query_map(params![workspace_id], row_to_share_link)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(links) + } + + fn resolve_share_link( + &self, + token_hash: &str, + ) -> Result, ControlPlaneError> { + let token_hash = token_hash.trim(); + if token_hash.is_empty() { + return Ok(None); + } + let conn = self.conn.lock().unwrap(); + let link = conn + .query_row( + "SELECT id, workspace_id, resource_kind, resource_id, token_hash, permission, created_by, expires_at, revoked_at, created_at, updated_at, revision + FROM share_links + WHERE token_hash = ?1 AND revoked_at IS NULL + LIMIT 1", + params![token_hash], + row_to_share_link, + ) + .optional()?; + Ok(link) + } + + fn revoke_share_link(&self, link_id: &str) -> Result<(), ControlPlaneError> { + let link_id = link_id.trim(); + if link_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "link_id 不能为空".to_string(), + )); + } + let conn = self.conn.lock().unwrap(); + let existed = conn.execute( + "UPDATE share_links + SET revoked_at = ?1, updated_at = ?2, revision = revision + 1 + WHERE id = ?3 AND revoked_at IS NULL", + params![now_text(), now_text(), link_id], + )?; + if existed == 0 { + return Err(ControlPlaneError::NotFound(format!( + "share link not found: {link_id}" + ))); + } + Ok(()) + } + + fn append_audit(&self, input: AppendAuditInput) -> Result<(), ControlPlaneError> { + let action = input.action.trim(); + let target_kind = input.target_kind.trim(); + if action.is_empty() || target_kind.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "audit action/target_kind 不能为空".to_string(), + )); + } + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO audit_log (id, actor_user_id, action, target_kind, target_id, metadata_json, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + new_id("audit"), + input.actor_user_id, + action, + target_kind, + input.target_id, + input.metadata_json, + now_text() + ], + )?; + Ok(()) + } + + fn list_audit_log(&self, limit: usize) -> Result, ControlPlaneError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, actor_user_id, action, target_kind, target_id, metadata_json, created_at + FROM audit_log + ORDER BY created_at DESC + LIMIT ?1", + )?; + let rows = stmt + .query_map(params![limit as i64], row_to_audit_log)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn append_outbox( + &self, + input: OutboxEventInput, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let record = OutboxEventRecord { + id: new_id("evt"), + topic: input.topic, + event_type: input.event_type, + payload_json: input.payload_json, + created_at: now_text(), + delivered_at: None, + attempts: 0, + }; + conn.execute( + "INSERT INTO outbox_events (id, topic, event_type, payload_json, created_at, delivered_at, attempts) + VALUES (?1, ?2, ?3, ?4, ?5, NULL, 0)", + params![ + record.id, + record.topic, + record.event_type, + record.payload_json, + record.created_at + ], + )?; + Ok(record) + } + + fn drain_outbox(&self, limit: usize) -> Result, ControlPlaneError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, topic, event_type, payload_json, created_at, delivered_at, attempts + FROM outbox_events + WHERE delivered_at IS NULL + ORDER BY created_at ASC + LIMIT ?1", + )?; + let rows = stmt + .query_map(params![limit as i64], row_to_outbox)? + .collect::, _>>()?; + let delivered_at = now_text(); + for row in &rows { + conn.execute( + "UPDATE outbox_events SET delivered_at = ?1, attempts = attempts + 1 WHERE id = ?2", + params![delivered_at, row.id], + )?; + } + Ok(rows) + } + + fn mark_outbox_delivered(&self, event_id: &str) -> Result<(), ControlPlaneError> { + let event_id = event_id.trim(); + if event_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "event_id 不能为空".to_string(), + )); + } + let conn = self.conn.lock().unwrap(); + let updated = conn.execute( + "UPDATE outbox_events + SET delivered_at = COALESCE(delivered_at, ?1), attempts = attempts + 1 + WHERE id = ?2", + params![now_text(), event_id], + )?; + if updated == 0 { + return Err(ControlPlaneError::NotFound(format!( + "outbox event not found: {event_id}" + ))); + } + Ok(()) + } + + fn upsert_ai_policy( + &self, + input: UpsertAiPolicyInput, + ) -> Result { + if input.user_id.is_none() && input.workspace_id.is_none() { + return Err(ControlPlaneError::InvalidInput( + "ai_policy 必须绑定 user_id 或 workspace_id".to_string(), + )); + } + serde_json::from_str::(&input.allowed_roots_json)?; + serde_json::from_str::(&input.model_policy_json)?; + serde_json::from_str::(&input.quota_json)?; + + let conn = self.conn.lock().unwrap(); + let now = now_text(); + let existing = conn + .query_row( + "SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision + FROM ai_policies + WHERE (?1 IS NULL OR user_id = ?1) + AND (?2 IS NULL OR workspace_id = ?2) + ORDER BY created_at DESC + LIMIT 1", + params![input.user_id, input.workspace_id], + row_to_ai_policy, + ) + .optional()?; + + if let Some(existing) = existing { + let revision = existing.revision + 1; + conn.execute( + "UPDATE ai_policies + SET allowed_roots_json = ?1, model_policy_json = ?2, quota_json = ?3, updated_at = ?4, revision = ?5 + WHERE id = ?6", + params![ + input.allowed_roots_json, + input.model_policy_json, + input.quota_json, + now, + revision, + existing.id + ], + )?; + return Ok(AiPolicyRecord { + id: existing.id, + user_id: existing.user_id, + workspace_id: existing.workspace_id, + allowed_roots_json: input.allowed_roots_json, + model_policy_json: input.model_policy_json, + quota_json: input.quota_json, + created_at: existing.created_at, + updated_at: now, + revision, + }); + } + + let record = AiPolicyRecord { + id: input.id.unwrap_or_else(|| new_id("aip")), + user_id: input.user_id, + workspace_id: input.workspace_id, + allowed_roots_json: input.allowed_roots_json, + model_policy_json: input.model_policy_json, + quota_json: input.quota_json, + created_at: now.clone(), + updated_at: now, + revision: 1, + }; + conn.execute( + "INSERT INTO ai_policies (id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)", + params![ + record.id, + record.user_id, + record.workspace_id, + record.allowed_roots_json, + record.model_policy_json, + record.quota_json, + record.created_at, + record.updated_at + ], + )?; + Ok(record) + } + + fn get_ai_policy( + &self, + actor_id: &str, + workspace_id: Option<&str>, + ) -> Result, ControlPlaneError> { + let actor_id = actor_id.trim(); + let workspace_id = workspace_id + .map(str::trim) + .filter(|value| !value.is_empty()); + if actor_id.is_empty() && workspace_id.is_none() { + return Ok(None); + } + let conn = self.conn.lock().unwrap(); + if let Some(workspace_id) = workspace_id { + let workspace_policy = conn + .query_row( + "SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision + FROM ai_policies + WHERE workspace_id = ?1 + ORDER BY updated_at DESC + LIMIT 1", + params![workspace_id], + row_to_ai_policy, + ) + .optional()?; + if workspace_policy.is_some() { + return Ok(workspace_policy); + } + } + if actor_id.is_empty() { + return Ok(None); + } + conn.query_row( + "SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision + FROM ai_policies + WHERE user_id = ?1 AND workspace_id IS NULL + ORDER BY updated_at DESC + LIMIT 1", + params![actor_id], + row_to_ai_policy, + ) + .optional() + .map_err(ControlPlaneError::from) + } + + fn upsert_sync_state( + &self, + input: UpsertSyncStateInput, + ) -> Result { + let workspace_id = input.workspace_id.trim().to_string(); + let remote_kind = input.remote_kind.trim().to_string(); + let status = input.status.trim().to_string(); + if workspace_id.is_empty() || remote_kind.is_empty() || status.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "sync_state workspace_id/remote_kind/status 不能为空".to_string(), + )); + } + if let Some(error_json) = &input.error_json { + serde_json::from_str::(error_json)?; + } + + let conn = self.conn.lock().unwrap(); + let now = now_text(); + let existing = conn + .query_row( + "SELECT id, workspace_id, remote_kind, remote_id, cursor, last_synced_at, status, error_json, updated_at, revision + FROM sync_state + WHERE workspace_id = ?1 AND remote_kind = ?2 + LIMIT 1", + params![workspace_id, remote_kind], + row_to_sync_state, + ) + .optional()?; + if let Some(existing) = existing { + let revision = existing.revision + 1; + conn.execute( + "UPDATE sync_state + SET remote_id = ?1, cursor = ?2, last_synced_at = ?3, status = ?4, error_json = ?5, updated_at = ?6, revision = ?7 + WHERE id = ?8", + params![ + input.remote_id, + input.cursor, + input.last_synced_at, + status, + input.error_json, + now, + revision, + existing.id + ], + )?; + return Ok(SyncStateRecord { + id: existing.id, + workspace_id: existing.workspace_id, + remote_kind: existing.remote_kind, + remote_id: input.remote_id, + cursor: input.cursor, + last_synced_at: input.last_synced_at, + status, + error_json: input.error_json, + updated_at: now, + revision, + }); + } + + let record = SyncStateRecord { + id: input.id.unwrap_or_else(|| new_id("sync")), + workspace_id, + remote_kind, + remote_id: input.remote_id, + cursor: input.cursor, + last_synced_at: input.last_synced_at, + status, + error_json: input.error_json, + updated_at: now, + revision: 1, + }; + conn.execute( + "INSERT INTO sync_state (id, workspace_id, remote_kind, remote_id, cursor, last_synced_at, status, error_json, updated_at, revision) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)", + params![ + record.id, + record.workspace_id, + record.remote_kind, + record.remote_id, + record.cursor, + record.last_synced_at, + record.status, + record.error_json, + record.updated_at + ], + )?; + Ok(record) + } + + fn get_sync_state( + &self, + workspace_id: &str, + remote_kind: &str, + ) -> Result, ControlPlaneError> { + let workspace_id = workspace_id.trim(); + let remote_kind = remote_kind.trim(); + if workspace_id.is_empty() || remote_kind.is_empty() { + return Ok(None); + } + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT id, workspace_id, remote_kind, remote_id, cursor, last_synced_at, status, error_json, updated_at, revision + FROM sync_state + WHERE workspace_id = ?1 AND remote_kind = ?2 + LIMIT 1", + params![workspace_id, remote_kind], + row_to_sync_state, + ) + .optional() + .map_err(ControlPlaneError::from) + } + + fn upsert_ai_runtime_run( + &self, + input: UpsertAiRuntimeRunInput, + ) -> Result { + if input.user_id.trim().is_empty() + || input.session_id.trim().is_empty() + || input.run_id.trim().is_empty() + { + return Err(ControlPlaneError::InvalidInput( + "ai runtime run user_id/session_id/run_id 不能为空".to_string(), + )); + } + serde_json::from_str::(&input.runtime_json)?; + serde_json::from_str::(&input.payload_json)?; + + let conn = self.conn.lock().unwrap(); + let now = now_text(); + let existing = conn + .query_row( + "SELECT id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision + FROM ai_runtime_runs + WHERE run_id = ?1 + LIMIT 1", + params![input.run_id], + row_to_ai_runtime_run, + ) + .optional()?; + + if let Some(existing) = existing { + let revision = existing.revision + 1; + conn.execute( + "UPDATE ai_runtime_runs + SET title = ?1, profile = ?2, acp_runtime = ?3, trace_id = ?4, status = ?5, runtime_json = ?6, payload_json = ?7, deleted_at = NULL, updated_at = ?8, revision = ?9 + WHERE id = ?10", + params![ + input.title, + input.profile, + input.acp_runtime, + input.trace_id, + input.status, + input.runtime_json, + input.payload_json, + now, + revision, + existing.id + ], + )?; + return Ok(AiRuntimeRunRecord { + id: existing.id, + user_id: existing.user_id, + workspace_id: existing.workspace_id, + document_id: existing.document_id, + session_id: existing.session_id, + run_id: existing.run_id, + title: input.title, + profile: input.profile, + acp_runtime: input.acp_runtime, + trace_id: input.trace_id, + status: input.status, + runtime_json: input.runtime_json, + payload_json: input.payload_json, + deleted_at: None, + created_at: existing.created_at, + updated_at: now, + revision, + }); + } + + let record = AiRuntimeRunRecord { + id: input.id.unwrap_or_else(|| new_id("acr")), + user_id: input.user_id, + workspace_id: input.workspace_id, + document_id: input.document_id, + session_id: input.session_id, + run_id: input.run_id, + title: input.title, + profile: input.profile, + acp_runtime: input.acp_runtime, + trace_id: input.trace_id, + status: input.status, + runtime_json: input.runtime_json, + payload_json: input.payload_json, + deleted_at: None, + created_at: now.clone(), + updated_at: now, + revision: 1, + }; + conn.execute( + "INSERT INTO ai_runtime_runs (id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, NULL, ?14, ?15, 1)", + params![ + record.id, + record.user_id, + record.workspace_id, + record.document_id, + record.session_id, + record.run_id, + record.title, + record.profile, + record.acp_runtime, + record.trace_id, + record.status, + record.runtime_json, + record.payload_json, + record.created_at, + record.updated_at + ], + )?; + Ok(record) + } + + fn list_ai_runtime_runs( + &self, + user_id: &str, + workspace_id: Option<&str>, + document_id: Option<&str>, + session_id: Option<&str>, + limit: usize, + ) -> Result, ControlPlaneError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision + FROM ai_runtime_runs + WHERE user_id = ?1 + AND (?2 IS NULL OR workspace_id = ?2) + AND (?3 IS NULL OR document_id = ?3) + AND (?4 IS NULL OR session_id = ?4) + AND deleted_at IS NULL + ORDER BY updated_at DESC + LIMIT ?5", + )?; + let rows = stmt + .query_map( + params![user_id, workspace_id, document_id, session_id, limit as i64], + row_to_ai_runtime_run, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn append_ai_runtime_event( + &self, + input: AppendAiRuntimeEventInput, + ) -> Result { + if input.user_id.trim().is_empty() + || input.session_id.trim().is_empty() + || input.run_id.trim().is_empty() + { + return Err(ControlPlaneError::InvalidInput( + "ai runtime event user_id/session_id/run_id 不能为空".to_string(), + )); + } + serde_json::from_str::(&input.payload_json)?; + + let conn = self.conn.lock().unwrap(); + let record = AiRuntimeEventRecord { + id: input.id.unwrap_or_else(|| new_id("are")), + user_id: input.user_id, + workspace_id: input.workspace_id, + document_id: input.document_id, + session_id: input.session_id, + run_id: input.run_id, + profile: input.profile, + acp_runtime: input.acp_runtime, + event_type: input.event_type, + payload_json: input.payload_json, + created_at: now_text(), + }; + conn.execute( + "INSERT INTO ai_runtime_events (id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + record.id, + record.user_id, + record.workspace_id, + record.document_id, + record.session_id, + record.run_id, + record.profile, + record.acp_runtime, + record.event_type, + record.payload_json, + record.created_at + ], + )?; + Ok(record) + } + + fn list_ai_runtime_events( + &self, + user_id: &str, + run_id: &str, + limit: usize, + ) -> Result, ControlPlaneError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at + FROM ai_runtime_events + WHERE user_id = ?1 AND run_id = ?2 + ORDER BY created_at ASC + LIMIT ?3", + )?; + let rows = stmt + .query_map( + params![user_id, run_id, limit as i64], + row_to_ai_runtime_event, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn rename_ai_runtime_session( + &self, + user_id: &str, + session_id: &str, + workspace_id: Option<&str>, + title: &str, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + let session_id = session_id.trim(); + let title = title.trim(); + if user_id.is_empty() || session_id.is_empty() || title.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "ai runtime session user_id/session_id/title 不能为空".to_string(), + )); + } + let conn = self.conn.lock().unwrap(); + let now = now_text(); + conn.execute( + "UPDATE ai_runtime_runs + SET title = ?1, updated_at = ?2, revision = revision + 1 + WHERE user_id = ?3 + AND session_id = ?4 + AND (?5 IS NULL OR workspace_id = ?5) + AND deleted_at IS NULL", + params![title, now, user_id, session_id, workspace_id], + )?; + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision + FROM ai_runtime_runs + WHERE user_id = ?1 + AND session_id = ?2 + AND (?3 IS NULL OR workspace_id = ?3) + AND deleted_at IS NULL + ORDER BY updated_at DESC", + )?; + let rows = stmt + .query_map( + params![user_id, session_id, workspace_id], + row_to_ai_runtime_run, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn auto_title_ai_runtime_session( + &self, + user_id: &str, + session_id: &str, + workspace_id: Option<&str>, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + let session_id = session_id.trim(); + if user_id.is_empty() || session_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "ai runtime session user_id/session_id 不能为空".to_string(), + )); + } + let conn = self.conn.lock().unwrap(); + let existing = conn + .query_row( + "SELECT id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision + FROM ai_runtime_runs + WHERE user_id = ?1 + AND session_id = ?2 + AND (?3 IS NULL OR workspace_id = ?3) + AND deleted_at IS NULL + ORDER BY updated_at DESC + LIMIT 1", + params![user_id, session_id, workspace_id], + row_to_ai_runtime_run, + ) + .optional()?; + let Some(existing) = existing else { + return Ok(None); + }; + let title = existing + .title + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + derive_ai_runtime_title(&existing.payload_json, &existing.session_id) + }); + let now = now_text(); + let revision = existing.revision + 1; + conn.execute( + "UPDATE ai_runtime_runs + SET title = ?1, updated_at = ?2, revision = ?3 + WHERE id = ?4", + params![title, now, revision, existing.id], + )?; + Ok(Some(AiRuntimeRunRecord { + title: Some(title), + updated_at: now, + revision, + ..existing + })) + } + + fn delete_ai_runtime_session( + &self, + user_id: &str, + session_id: &str, + workspace_id: Option<&str>, + ) -> Result { + let user_id = user_id.trim(); + let session_id = session_id.trim(); + if user_id.is_empty() || session_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "ai runtime session user_id/session_id 不能为空".to_string(), + )); + } + let conn = self.conn.lock().unwrap(); + let changed = conn.execute( + "UPDATE ai_runtime_runs + SET deleted_at = ?1, updated_at = ?1, revision = revision + 1 + WHERE user_id = ?2 + AND session_id = ?3 + AND (?4 IS NULL OR workspace_id = ?4) + AND deleted_at IS NULL", + params![now_text(), user_id, session_id, workspace_id], + )?; + Ok(changed) + } +} + +fn max_permission<'a>(left: &'a str, right: &'a str) -> &'a str { + if permission_rank(right) > permission_rank(left) { + right + } else { + left + } +} + +fn permission_rank(permission: &str) -> u8 { + match permission { + "admin" => 3, + "write" => 2, + "read" => 1, + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput, + CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, + UpsertSyncStateInput, + }; + + fn store() -> SqliteControlPlaneStore { + SqliteControlPlaneStore::in_memory().expect("in-memory control plane") + } + + fn create_user(store: &SqliteControlPlaneStore, id: &str) -> UserRecord { + store + .upsert_user(UpsertUserInput { + id: Some(id.to_string()), + email: Some(format!("{id}@example.com")), + username: id.to_string(), + display_name: id.to_string(), + role: None, + password_hash: None, + }) + .expect("upsert user") + } + + fn create_password_identity( + store: &SqliteControlPlaneStore, + username: &str, + email: &str, + password: &str, + ) -> UserRecord { + let user = create_user(store, username); + store + .create_password_identity(CreatePasswordIdentityInput { + user_id: user.id.clone(), + email: Some(email.to_string()), + username: username.to_string(), + password: password.to_string(), + }) + .expect("create password identity"); + user + } + + #[test] + fn upsert_user_inserts_and_updates_revision() { + let store = store(); + let user = create_user(&store, "alice"); + assert_eq!(user.revision, 1); + + let updated = store + .upsert_user(UpsertUserInput { + id: Some("alice".to_string()), + email: Some("alice2@example.com".to_string()), + username: "alice".to_string(), + display_name: "Alice".to_string(), + role: Some("admin".to_string()), + password_hash: None, + }) + .expect("update user"); + + assert_eq!(updated.email.as_deref(), Some("alice2@example.com")); + assert_eq!(updated.role, "admin"); + assert_eq!(updated.revision, 2); + } + + #[test] + fn ensure_default_workspace_creates_owner_grant() { + let store = store(); + create_user(&store, "shujuan"); + + let workspace = store + .ensure_default_workspace("shujuan") + .expect("default workspace"); + assert_eq!(workspace.name, "shujuan 的空间"); + + let access = store + .resolve_access("shujuan", &workspace.root_uri) + .expect("resolve access"); + assert_eq!(access.permission, "write"); + assert_eq!(access.grant_ids.len(), 1); + } + + #[test] + fn explicit_write_grant_out_ranks_read_grant() { + let store = store(); + create_user(&store, "bob"); + store + .grant_directory_access(DirectoryGrantInput { + user_id: "bob".to_string(), + workspace_id: None, + root_uri: "local://shared".to_string(), + root_path: "/tmp/shared".to_string(), + permission: "read".to_string(), + recursive: true, + capabilities: vec![], + source: "admin".to_string(), + created_by: None, + }) + .expect("read grant"); + store + .grant_directory_access(DirectoryGrantInput { + user_id: "bob".to_string(), + workspace_id: None, + root_uri: "local://shared".to_string(), + root_path: "/tmp/shared".to_string(), + permission: "write".to_string(), + recursive: true, + capabilities: vec!["ai".to_string()], + source: "admin".to_string(), + created_by: None, + }) + .expect("write grant"); + + let access = store + .resolve_access("bob", "local://shared/page.md") + .expect("resolve access"); + assert_eq!(access.permission, "write"); + assert_eq!(access.grant_ids.len(), 2); + } + + #[test] + fn list_and_revoke_directory_grant_updates_resolved_access() { + let store = store(); + create_user(&store, "reader"); + let grant = store + .grant_directory_access(DirectoryGrantInput { + user_id: "reader".to_string(), + workspace_id: None, + root_uri: "local://shared".to_string(), + root_path: "/tmp/shared".to_string(), + permission: "read".to_string(), + recursive: true, + capabilities: vec![], + source: "admin".to_string(), + created_by: None, + }) + .expect("read grant"); + + let grants = store + .list_directory_grants_for_actor("reader") + .expect("list grants"); + assert_eq!(grants.len(), 1); + assert_eq!(grants[0].id, grant.id); + + store + .revoke_directory_grant(&grant.id, None) + .expect("revoke grant"); + let access = store + .resolve_access("reader", "local://shared/page.md") + .expect("resolve revoked access"); + assert_eq!(access.permission, "none"); + } + + #[test] + fn share_link_token_is_hashed_and_revocable() { + let store = store(); + create_user(&store, "owner"); + create_user(&store, "ws_owner"); + let workspace = store + .ensure_default_workspace("ws_owner") + .expect("workspace"); + let created = store + .create_share_link(CreateShareLinkInput { + id: None, + workspace_id: workspace.id, + resource_kind: "page".to_string(), + resource_id: "page_1".to_string(), + token: Some("plain-token".to_string()), + permission: "read".to_string(), + created_by: "ws_owner".to_string(), + expires_at: None, + }) + .expect("create share link"); + + assert_eq!(created.token, "plain-token"); + assert!(created.link.token_hash.starts_with("sha256-v1:")); + assert_ne!(created.link.token_hash, "plain-token"); + + let resolved = store + .resolve_share_link(&created.link.token_hash) + .expect("resolve share link") + .expect("active share link"); + assert_eq!(resolved.id, created.link.id); + + let listed = store + .list_share_links(&created.link.workspace_id) + .expect("list links"); + assert_eq!(listed.len(), 1); + + store + .revoke_share_link(&created.link.id) + .expect("revoke share"); + assert!(store + .resolve_share_link(&created.link.token_hash) + .expect("resolve revoked share") + .is_none()); + } + + #[test] + fn audit_log_appends_and_lists_latest_first() { + let store = store(); + create_user(&store, "owner"); + store + .append_audit(AppendAuditInput { + actor_user_id: Some("owner".to_string()), + action: "control.share.created".to_string(), + target_kind: "share_link".to_string(), + target_id: Some("share_1".to_string()), + metadata_json: "{}".to_string(), + }) + .expect("append audit"); + store + .append_audit(AppendAuditInput { + actor_user_id: Some("owner".to_string()), + action: "control.share.revoked".to_string(), + target_kind: "share_link".to_string(), + target_id: Some("share_1".to_string()), + metadata_json: "{\"reason\":\"test\"}".to_string(), + }) + .expect("append audit 2"); + + let rows = store.list_audit_log(10).expect("list audit"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].action, "control.share.revoked"); + assert_eq!(rows[1].action, "control.share.created"); + } + + #[test] + fn drain_outbox_marks_events_delivered() { + let store = store(); + store + .append_outbox(OutboxEventInput { + topic: "control".to_string(), + event_type: "control.workspace.updated".to_string(), + payload_json: "{}".to_string(), + }) + .expect("append event"); + + let first = store.drain_outbox(10).expect("first drain"); + assert_eq!(first.len(), 1); + + let second = store.drain_outbox(10).expect("second drain"); + assert!(second.is_empty()); + } + + #[test] + fn mark_outbox_delivered_marks_one_pending_event() { + let store = store(); + let first = store + .append_outbox(OutboxEventInput { + topic: "control".to_string(), + event_type: "control.grant.created".to_string(), + payload_json: "{}".to_string(), + }) + .expect("first event"); + let second = store + .append_outbox(OutboxEventInput { + topic: "control".to_string(), + event_type: "control.share.created".to_string(), + payload_json: "{}".to_string(), + }) + .expect("second event"); + + store + .mark_outbox_delivered(&first.id) + .expect("mark first delivered"); + + let pending = store.drain_outbox(10).expect("drain pending"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, second.id); + } + + #[test] + fn ai_policy_upsert_returns_workspace_policy_before_user_policy() { + let store = store(); + create_user(&store, "ai_user"); + let workspace = store + .ensure_default_workspace("ai_user") + .expect("default workspace"); + + let user_policy = store + .upsert_ai_policy(UpsertAiPolicyInput { + id: None, + user_id: Some("ai_user".to_string()), + workspace_id: None, + allowed_roots_json: "[\"file:///user-root\"]".to_string(), + model_policy_json: "{\"default\":\"local\"}".to_string(), + quota_json: "{}".to_string(), + }) + .expect("user policy"); + assert_eq!(user_policy.revision, 1); + + let workspace_policy = store + .upsert_ai_policy(UpsertAiPolicyInput { + id: None, + user_id: Some("ai_user".to_string()), + workspace_id: Some(workspace.id.clone()), + allowed_roots_json: "[\"file:///workspace-root\"]".to_string(), + model_policy_json: "{\"default\":\"workspace\"}".to_string(), + quota_json: "{}".to_string(), + }) + .expect("workspace policy"); + + let resolved = store + .get_ai_policy("ai_user", Some(&workspace.id)) + .expect("get workspace policy") + .expect("workspace policy exists"); + assert_eq!(resolved.id, workspace_policy.id); + assert!(resolved.allowed_roots_json.contains("workspace-root")); + + let user_only = store + .get_ai_policy("ai_user", None) + .expect("get user policy") + .expect("user policy exists"); + assert_eq!(user_only.id, user_policy.id); + } + + #[test] + fn sync_state_upsert_tracks_cursor_status_and_revision() { + let store = store(); + create_user(&store, "sync_user"); + let workspace = store + .ensure_default_workspace("sync_user") + .expect("default workspace"); + + let first = store + .upsert_sync_state(UpsertSyncStateInput { + id: None, + workspace_id: workspace.id.clone(), + remote_kind: "local_worker".to_string(), + remote_id: Some("worker-1".to_string()), + cursor: Some("cursor-1".to_string()), + last_synced_at: None, + status: "idle".to_string(), + error_json: None, + }) + .expect("first sync state"); + assert_eq!(first.revision, 1); + + let second = store + .upsert_sync_state(UpsertSyncStateInput { + id: None, + workspace_id: workspace.id.clone(), + remote_kind: "local_worker".to_string(), + remote_id: Some("worker-1".to_string()), + cursor: Some("cursor-2".to_string()), + last_synced_at: Some("2026-05-22T00:00:00Z".to_string()), + status: "error".to_string(), + error_json: Some("{\"code\":\"test\"}".to_string()), + }) + .expect("second sync state"); + + assert_eq!(second.id, first.id); + assert_eq!(second.revision, 2); + assert_eq!(second.cursor.as_deref(), Some("cursor-2")); + + let loaded = store + .get_sync_state(&workspace.id, "local_worker") + .expect("get sync state") + .expect("sync state exists"); + assert_eq!(loaded.status, "error"); + assert_eq!(loaded.error_json.as_deref(), Some("{\"code\":\"test\"}")); + } + + #[test] + fn ai_runtime_run_upsert_lists_updates_and_events() { + let store = store(); + create_user(&store, "ai_runtime_user"); + + let first = store + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: "ai_runtime_user".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_1".to_string(), + title: Some("初始标题".to_string()), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + trace_id: Some("trace_1".to_string()), + status: "running".to_string(), + runtime_json: "{\"status\":\"running\"}".to_string(), + payload_json: "{\"message\":\"读取当前页面\"}".to_string(), + }) + .expect("insert runtime run"); + assert_eq!(first.revision, 1); + + let updated = store + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: "ai_runtime_user".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_1".to_string(), + title: Some("更新标题".to_string()), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + trace_id: Some("trace_2".to_string()), + status: "completed".to_string(), + runtime_json: "{\"status\":\"completed\"}".to_string(), + payload_json: "{\"message\":\"完成\"}".to_string(), + }) + .expect("update runtime run"); + assert_eq!(updated.id, first.id); + assert_eq!(updated.revision, 2); + + let runs = store + .list_ai_runtime_runs( + "ai_runtime_user", + Some("ws_1"), + Some("doc_1"), + Some("sess_1"), + 10, + ) + .expect("list runtime runs"); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].title.as_deref(), Some("更新标题")); + + let event = store + .append_ai_runtime_event(AppendAiRuntimeEventInput { + id: None, + user_id: "ai_runtime_user".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_1".to_string(), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + event_type: "message.delta".to_string(), + payload_json: "{\"text\":\"hello\"}".to_string(), + }) + .expect("append runtime event"); + assert_eq!(event.event_type, "message.delta"); + + let events = store + .list_ai_runtime_events("ai_runtime_user", "run_1", 10) + .expect("list runtime events"); + assert_eq!(events.len(), 1); + assert_eq!(events[0].payload_json, "{\"text\":\"hello\"}"); + } + + #[test] + fn ai_runtime_session_management_renames_auto_titles_and_soft_deletes() { + let store = store(); + create_user(&store, "ai_runtime_user"); + store + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: "ai_runtime_user".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_1".to_string(), + title: None, + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + trace_id: None, + status: "completed".to_string(), + runtime_json: "{\"status\":\"completed\"}".to_string(), + payload_json: "{\"message\":\"请总结这篇文档\"}".to_string(), + }) + .expect("insert runtime run"); + + let titled = store + .auto_title_ai_runtime_session("ai_runtime_user", "sess_1", Some("ws_1")) + .expect("auto title") + .expect("titled run"); + assert_eq!(titled.title.as_deref(), Some("请总结这篇文档")); + + let renamed = store + .rename_ai_runtime_session("ai_runtime_user", "sess_1", Some("ws_1"), "人工标题") + .expect("rename session"); + assert_eq!(renamed.len(), 1); + assert_eq!(renamed[0].title.as_deref(), Some("人工标题")); + + let deleted = store + .delete_ai_runtime_session("ai_runtime_user", "sess_1", Some("ws_1")) + .expect("delete session"); + assert_eq!(deleted, 1); + + let runs = store + .list_ai_runtime_runs("ai_runtime_user", Some("ws_1"), None, Some("sess_1"), 10) + .expect("list after delete"); + assert!(runs.is_empty()); + } + + #[test] + fn session_lookup_resolves_active_user_by_token_hash() { + let store = store(); + create_user(&store, "shujuan"); + + let session = store + .create_session(CreateSessionInput { + id: None, + user_id: "shujuan".to_string(), + token_hash: session_token_hash("raw-session-token"), + user_agent: Some("mnote-test".to_string()), + ip_hash: None, + expires_at: None, + }) + .expect("create session"); + + let resolved = store + .get_session_by_token_hash(&session.token_hash) + .expect("lookup session") + .expect("active session"); + + assert_eq!(resolved.session.user_id, "shujuan"); + assert_eq!(resolved.user.id, "shujuan"); + assert_eq!(resolved.user.email.as_deref(), Some("shujuan@example.com")); + } + + #[test] + fn revoked_session_is_not_resolved() { + let store = store(); + create_user(&store, "shujuan"); + + let session = store + .create_session(CreateSessionInput { + id: None, + user_id: "shujuan".to_string(), + token_hash: session_token_hash("revoked-token"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("create session"); + store.revoke_session(&session.id).expect("revoke session"); + + let resolved = store + .get_session_by_token_hash(&session.token_hash) + .expect("lookup session"); + assert!(resolved.is_none()); + } + + #[test] + fn password_hash_v1_is_sha256_hex_placeholder() { + assert!(password_hash_v1("secret").starts_with("sha256-v1:")); + } + + #[test] + fn authenticate_password_accepts_email_login_and_creates_session() { + let store = store(); + let test_password = ["correct", "horse"].join(" "); + create_password_identity(&store, "alice", "alice@example.com", &test_password); + + let resolved = store + .authenticate_password(AuthenticatePasswordInput { + account: "alice@example.com".to_string(), + password: test_password, + session_id: None, + token_hash: session_token_hash("alice-email-session"), + user_agent: Some("mnote-test".to_string()), + ip_hash: None, + expires_at: None, + }) + .expect("authenticate by email"); + + assert_eq!(resolved.user.username, "alice"); + assert_eq!( + resolved.session.token_hash, + session_token_hash("alice-email-session") + ); + + let lookup = store + .get_session_by_token_hash(&resolved.session.token_hash) + .expect("lookup created session") + .expect("created session"); + assert_eq!(lookup.user.id, resolved.user.id); + } + + #[test] + fn authenticate_password_accepts_username_login() { + let store = store(); + let test_password = ["sword", "fish"].join(""); + create_password_identity(&store, "bob", "bob@example.com", &test_password); + + let resolved = store + .authenticate_password(AuthenticatePasswordInput { + account: "bob".to_string(), + password: test_password, + session_id: None, + token_hash: session_token_hash("bob-username-session"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("authenticate by username"); + + assert_eq!(resolved.user.email.as_deref(), Some("bob@example.com")); + assert_eq!( + resolved.session.token_hash, + session_token_hash("bob-username-session") + ); + } + + #[test] + fn authenticate_password_rejects_wrong_password() { + let store = store(); + let right_password = ["right", "password"].join("-"); + let wrong_password = ["wrong", "password"].join("-"); + create_password_identity(&store, "chris", "chris@example.com", &right_password); + + let err = store + .authenticate_password(AuthenticatePasswordInput { + account: "chris@example.com".to_string(), + password: wrong_password, + session_id: None, + token_hash: session_token_hash("chris-failed-session"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect_err("wrong password should fail"); + + assert!(matches!(err, ControlPlaneError::Unauthorized(_))); + assert!(store + .get_session_by_token_hash(&session_token_hash("chris-failed-session")) + .expect("lookup failed session") + .is_none()); + } +} diff --git a/rust/crates/control-plane/src/store.rs b/rust/crates/control-plane/src/store.rs new file mode 100644 index 00000000..d1a4220b --- /dev/null +++ b/rust/crates/control-plane/src/store.rs @@ -0,0 +1,172 @@ +//! 控制面 store trait。 + +use crate::error::ControlPlaneError; +use crate::model::{ + AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, + AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, + CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink, + DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, OutboxEventInput, + OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SyncStateRecord, + UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertSyncStateInput, UpsertUserInput, + UserRecord, WorkspaceRecord, +}; + +pub trait ControlPlaneStore: Send + Sync { + fn upsert_user(&self, input: UpsertUserInput) -> Result; + + fn create_password_identity( + &self, + input: CreatePasswordIdentityInput, + ) -> Result<(), ControlPlaneError>; + + fn authenticate_password( + &self, + input: AuthenticatePasswordInput, + ) -> Result; + + fn create_session( + &self, + input: CreateSessionInput, + ) -> Result; + + fn get_session_by_token_hash( + &self, + token_hash: &str, + ) -> Result, ControlPlaneError>; + + fn revoke_session(&self, session_id: &str) -> Result<(), ControlPlaneError>; + + fn ensure_default_workspace( + &self, + actor_id: &str, + ) -> Result; + + fn grant_directory_access( + &self, + input: DirectoryGrantInput, + ) -> Result; + + fn list_directory_grants(&self) -> Result, ControlPlaneError>; + + fn list_directory_grants_for_actor( + &self, + actor_id: &str, + ) -> Result, ControlPlaneError>; + + fn find_directory_grants( + &self, + lookup: DirectoryGrantLookup, + ) -> Result, ControlPlaneError>; + + fn revoke_directory_grant( + &self, + grant_id: &str, + expected_revision: Option, + ) -> Result<(), ControlPlaneError>; + + fn resolve_access( + &self, + actor_id: &str, + root_uri: &str, + ) -> Result; + + fn create_share_link( + &self, + input: CreateShareLinkInput, + ) -> Result; + + fn list_share_links( + &self, + workspace_id: &str, + ) -> Result, ControlPlaneError>; + + fn resolve_share_link( + &self, + token_hash: &str, + ) -> Result, ControlPlaneError>; + + fn revoke_share_link(&self, link_id: &str) -> Result<(), ControlPlaneError>; + + fn append_audit(&self, input: AppendAuditInput) -> Result<(), ControlPlaneError>; + + fn list_audit_log(&self, limit: usize) -> Result, ControlPlaneError>; + + fn append_outbox( + &self, + input: OutboxEventInput, + ) -> Result; + + fn drain_outbox(&self, limit: usize) -> Result, ControlPlaneError>; + + fn mark_outbox_delivered(&self, event_id: &str) -> Result<(), ControlPlaneError>; + + fn upsert_ai_policy( + &self, + input: UpsertAiPolicyInput, + ) -> Result; + + fn get_ai_policy( + &self, + actor_id: &str, + workspace_id: Option<&str>, + ) -> Result, ControlPlaneError>; + + fn upsert_sync_state( + &self, + input: UpsertSyncStateInput, + ) -> Result; + + fn get_sync_state( + &self, + workspace_id: &str, + remote_kind: &str, + ) -> Result, ControlPlaneError>; + + fn upsert_ai_runtime_run( + &self, + input: UpsertAiRuntimeRunInput, + ) -> Result; + + fn list_ai_runtime_runs( + &self, + user_id: &str, + workspace_id: Option<&str>, + document_id: Option<&str>, + session_id: Option<&str>, + limit: usize, + ) -> Result, ControlPlaneError>; + + fn append_ai_runtime_event( + &self, + input: AppendAiRuntimeEventInput, + ) -> Result; + + fn list_ai_runtime_events( + &self, + user_id: &str, + run_id: &str, + limit: usize, + ) -> Result, ControlPlaneError>; + + fn rename_ai_runtime_session( + &self, + user_id: &str, + session_id: &str, + workspace_id: Option<&str>, + title: &str, + ) -> Result, ControlPlaneError>; + + fn auto_title_ai_runtime_session( + &self, + user_id: &str, + session_id: &str, + workspace_id: Option<&str>, + ) -> Result, ControlPlaneError>; + + fn delete_ai_runtime_session( + &self, + user_id: &str, + session_id: &str, + workspace_id: Option<&str>, + ) -> Result; +} diff --git a/rust/crates/mnote-web/Cargo.toml b/rust/crates/mnote-web/Cargo.toml index ec371ec7..5b8dab8d 100644 --- a/rust/crates/mnote-web/Cargo.toml +++ b/rust/crates/mnote-web/Cargo.toml @@ -9,6 +9,7 @@ authors.workspace = true adapter-onlyoffice = { path = "../adapter-onlyoffice" } axum = { version = "0.8", features = ["multipart", "ws"] } bridge-runtime = { path = "../bridge-runtime" } +control-plane = { path = "../control-plane" } core-protocol = { path = "../core-protocol" } futures-util = "0.3" hyper = "1" @@ -30,3 +31,4 @@ base64 = "0.22" comrak = { version = "0.52", default-features = false } notify = "8.2.0" time = { version = "0.3", features = ["formatting", "local-offset"] } +uuid = { version = "1", features = ["v4"] } diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs index d0b1bf5c..d8ca3abf 100644 --- a/rust/crates/mnote-web/src/app.rs +++ b/rust/crates/mnote-web/src/app.rs @@ -5,6 +5,7 @@ use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry; use crate::middleware::request_context::inject_request_context; use crate::routes::build_router; use axum::Router; +use control_plane::{ControlPlaneStore, SqliteControlPlaneStore}; use std::env; use std::fs; use std::sync::Arc; @@ -137,7 +138,7 @@ fn read_env_or_dotenv(key: &str) -> Option { use tokio::sync::broadcast; -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct AppState { config: Arc, local_folder_watcher_registry: LocalFolderWatcherRegistry, @@ -146,6 +147,7 @@ pub struct AppState { pub stream_delta_tx: broadcast::Sender, pub acp_runtime: Arc, pub buffer_store: BufferStore, + control_plane: Arc, } impl AppState { @@ -155,6 +157,7 @@ impl AppState { let actor = EditorRuntimeActor::new(); actor.set_block_delta_tx(block_delta_tx.clone()); let buffer_store = BufferStore::new(); + let control_plane = Arc::new(open_control_plane_store()); Self { config: Arc::new(config), local_folder_watcher_registry: LocalFolderWatcherRegistry::new(buffer_store.clone()), @@ -163,6 +166,7 @@ impl AppState { stream_delta_tx, acp_runtime: Arc::new(AcpRuntimeManager::from_env()), buffer_store, + control_plane, } } @@ -173,6 +177,27 @@ impl AppState { pub fn local_folder_watcher_registry(&self) -> &LocalFolderWatcherRegistry { &self.local_folder_watcher_registry } + + pub fn control_plane(&self) -> &dyn ControlPlaneStore { + self.control_plane.as_ref() + } +} + +#[cfg(test)] +fn open_control_plane_store() -> SqliteControlPlaneStore { + SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面") +} + +#[cfg(not(test))] +fn open_control_plane_store() -> SqliteControlPlaneStore { + let db_path = env::var("MNOTE_CONTROL_PLANE_DB_PATH") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/control-plane.db".to_string()); + if let Some(parent) = std::path::Path::new(&db_path).parent() { + fs::create_dir_all(parent).expect("创建 SQLite 控制面目录"); + } + SqliteControlPlaneStore::open(&db_path).expect("初始化 SQLite 控制面") } pub fn build_app(state: AppState) -> Router { @@ -180,3 +205,61 @@ pub fn build_app(state: AppState) -> Router { .layer(TraceLayer::new_for_http()) .layer(axum::middleware::from_fn(inject_request_context)) } + +#[cfg(test)] +mod tests { + use super::*; + use control_plane::UpsertUserInput; + + fn test_config() -> AppConfig { + AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: None, + enable_legacy_next_compat: false, + enable_debug_shell_routes: false, + enable_editor_actor: true, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: false, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + } + } + + #[test] + fn app_state_initializes_sqlite_control_plane_store() { + let state = AppState::new(test_config()); + + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("shujuan".into()), + email: Some("shujuan@163.com".into()), + username: "shujuan".into(), + display_name: "shujuan".into(), + role: None, + password_hash: None, + }) + .expect("upsert user"); + + let workspace = state + .control_plane() + .ensure_default_workspace("shujuan") + .expect("default workspace"); + assert_eq!(workspace.name, "shujuan 的空间"); + + let access = state + .control_plane() + .resolve_access("shujuan", &workspace.root_uri) + .expect("owner access"); + assert_eq!(access.permission, "write"); + } +} diff --git a/rust/crates/mnote-web/src/hermes_tools/mod.rs b/rust/crates/mnote-web/src/hermes_tools/mod.rs index df994667..0d21efbb 100644 --- a/rust/crates/mnote-web/src/hermes_tools/mod.rs +++ b/rust/crates/mnote-web/src/hermes_tools/mod.rs @@ -195,7 +195,10 @@ pub struct CommandContextBridge { /// - 如果提供了 `bridge` 且 `workspace_readonly == true`,拒绝 /// /// 拒绝响应可解释(包含具体原因),不静默成功,不 panic。 -pub fn ensure_write_authorized(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> { +pub fn ensure_write_authorized( + context: &RequestContext, + input: &ToolCallInput, +) -> Result<(), WebError> { if !input.has_idempotency_key() { return Err(WebError::bad_request_code( "mnote_tool_idempotency_required", diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index 9e7bab6f..001e191c 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -1,10 +1,10 @@ use crate::app::AppState; -use crate::context::{stable_actor_id, RequestContext}; +use crate::context::RequestContext; use crate::error::WebError; use crate::routes::local_folder_source::{ - create_default_local_workspace_for_actor, ensure_local_workspace_read_access, - is_local_access_policy_admin_context, load_local_folder_page_tree_snapshot, - load_local_trash_entries, local_access_policy_path_display, + control_plane_db_path_display, create_default_local_workspace_for_actor, + ensure_local_workspace_read_access, is_local_access_policy_admin_context, + load_local_folder_page_tree_snapshot, load_local_trash_entries, }; use crate::routes::snapshot_support::load_sidebar_dataset; use crate::routes::web_shell::{ @@ -24,6 +24,10 @@ use axum::extract::{Extension, Query, State}; use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri}; use axum::response::{Html, IntoResponse, Response}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use control_plane::{ + session_token_hash, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput, + UpsertUserInput, +}; use futures_util::{SinkExt, StreamExt}; use leptos::prelude::InnerHtmlAttribute; use serde::{Deserialize, Serialize}; @@ -39,6 +43,7 @@ const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT"; const COOKIE_CONVEX_AUTH_REFRESH_TOKEN: &str = "__convexAuthRefreshToken"; const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token"; const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session"; +const COOKIE_MNOTE_SESSION: &str = "mnote_session"; const COOKIE_MNOTE_ACTOR_ID: &str = "mnote_actor_id"; const COOKIE_MNOTE_ACTOR_TYPE: &str = "mnote_actor_type"; const COOKIE_MNOTE_ACTOR_EMAIL: &str = "mnote_actor_email"; @@ -96,10 +101,6 @@ pub async fn auth_api( Extension(context): Extension, request: Request, ) -> Result { - if state.config().enable_legacy_next_compat && state.config().legacy_next_base_url.is_some() { - return legacy_next_proxy(State(state), Extension(context), request).await; - } - let body = axum::body::to_bytes(request.into_body(), 256 * 1024) .await .map_err(|error| WebError::bad_request(format!("读取登录请求失败: {error}")))?; @@ -118,26 +119,24 @@ pub async fn auth_api( if action != "auth:signIn" && action != "auth:signOut" { return Err(WebError::bad_request_code( "auth_action_unsupported", - "Rust gateway 当前仅支持 Convex Auth 登录与登出动作。", + "Rust gateway 当前仅支持账号登录、注册与登出动作。", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); } if action == "auth:signOut" { - return Ok(build_sign_out_response(&context)); + return Ok(build_sqlite_sign_out_response(&state, &context)); } - let payload = resolve_auth_login_payload(&state, &context, payload).await?; - let convex_response = run_convex_auth_action(&state, &context, &payload).await?; - Ok(build_auth_proxy_response(&convex_response, &context, &payload)) + handle_sqlite_auth_action(&state, &context, &payload) } pub async fn auth_entry( - State(_state): State, + State(state): State, Extension(context): Extension, _request: Request, ) -> Result { - if has_real_auth_context(&context) { + if has_real_auth_context(&state, &context) { let mut response = Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, "/") @@ -172,7 +171,7 @@ pub async fn admin_access_policy_entry( State(state): State, Extension(context): Extension, ) -> Result { - if !has_real_auth_context(&context) { + if !has_real_auth_context(&state, &context) { let mut response = Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, "/auth") @@ -189,10 +188,10 @@ pub async fn admin_access_policy_entry( ) .with_context(&context)); } - let workspace_name = format!("{} 的空间", state.config().dev_user_name); - let policy_path = local_access_policy_path_display(); + let workspace_name = default_workspace_name_for_context(&state, &context); + let share_grants_path = control_plane_db_path_display(); let content = crate::ssr::render_view(leptos::view! { - + }); let mut response = Html(format!( r#" @@ -219,7 +218,7 @@ pub async fn user_access_policy_entry( State(state): State, Extension(context): Extension, ) -> Result { - if !has_real_auth_context(&context) { + if !has_real_auth_context(&state, &context) { let mut response = Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, "/auth") @@ -228,9 +227,9 @@ pub async fn user_access_policy_entry( stamp_gateway_headers(response.headers_mut(), false); return Ok(response); } - let workspace_name = format!("{} 的空间", state.config().dev_user_name); + let workspace_name = default_workspace_name_for_context(&state, &context); let content = crate::ssr::render_view(leptos::view! { - + }); let mut response = Html(format!( r#" @@ -258,7 +257,7 @@ pub async fn root_entry( Extension(context): Extension, Query(query): Query, ) -> Result { - if !has_real_auth_context(&context) { + if !has_real_auth_context(&state, &context) { let mut response = Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, "/auth") @@ -277,7 +276,10 @@ pub async fn root_entry( let requested_page_id = normalize_optional_id(query.page_id.as_deref()); let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID); let recent_page_id = normalize_optional_id(recent_page_id.as_deref()); - let default_workspace_name = format!("{} 的空间", state.config().dev_user_name); + let default_workspace_name = default_workspace_name_for_context(&state, &context); + let actor_id = + current_actor_id(&state, &context).unwrap_or_else(|| context.auth.actor_id.clone()); + let actor_type = current_actor_type(&state, &context); let should_render_local_first_landing = !is_local_folder && query .source_kind @@ -363,11 +365,8 @@ pub async fn root_entry( Some(root_uri.to_string()), ) } else if should_render_local_first_landing { - let payload = create_default_local_workspace_for_actor( - &context.auth.actor_id, - &context.auth.actor_type, - ) - .map_err(|error| error.with_context(&context))?; + let payload = create_default_local_workspace_for_actor(&actor_id, &actor_type) + .map_err(|error| error.with_context(&context))?; let root_uri = payload .get("workspace") .and_then(|workspace| workspace.get("rootUri")) @@ -392,7 +391,7 @@ pub async fn root_entry( &snapshot.dataset, &workspace_id, requested_page_id.as_deref(), - "我的空间", + &default_workspace_name, ); let selected_active_page_id = choose_root_entry_active_page_id( requested_page_id.clone(), @@ -591,7 +590,7 @@ pub async fn trash_entry( Extension(context): Extension, Query(query): Query, ) -> Result { - if !has_real_auth_context(&context) { + if !has_real_auth_context(&state, &context) { let mut response = Response::builder() .status(StatusCode::SEE_OTHER) .header(header::LOCATION, "/auth") @@ -676,7 +675,7 @@ pub async fn trash_entry( return Ok(response); } - let default_workspace_name = format!("{} 的空间", state.config().dev_user_name); + let default_workspace_name = default_workspace_name_for_context(&state, &context); let workspace_id = resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?; let workspace_projection = load_workspace_shell_projection( @@ -1295,6 +1294,7 @@ fn render_trashed_resource_rows(kind: &str, label: &str, resources: &[Value]) -> .join("") } +#[allow(dead_code)] pub async fn legacy_next_proxy( State(state): State, Extension(context): Extension, @@ -1537,7 +1537,8 @@ async fn resolve_root_workspace_id( context, "workspaces:ensureDefaultWorkspace", json!({ - "fallbackName": state.config().dev_user_name, + "fallbackName": current_actor_display_name(state, context) + .unwrap_or_else(|| state.config().dev_user_name.clone()), "workspaceIdIfCreate": format!("ws_{}", context.trace.request_id), }), None, @@ -1561,345 +1562,304 @@ async fn resolve_root_workspace_id( }) } +pub(crate) fn default_workspace_name_for_context( + state: &AppState, + context: &RequestContext, +) -> String { + let display_name = current_actor_display_name(state, context) + .unwrap_or_else(|| state.config().dev_user_name.clone()); + format!("{} 的空间", display_name.trim()) +} + +pub(crate) fn current_actor_display_name( + state: &AppState, + context: &RequestContext, +) -> Option { + if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { + let token_hash = session_token_hash(&raw_token); + if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) { + return Some(resolved.user.display_name); + } + } + extract_encoded_cookie_value(context, COOKIE_MNOTE_ACTOR_NAME).or_else(|| { + let actor_id = context.auth.actor_id.trim(); + if actor_id.is_empty() || actor_id == "anonymous" { + None + } else { + Some(actor_id.to_string()) + } + }) +} + +pub(crate) fn current_actor_id(state: &AppState, context: &RequestContext) -> Option { + if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { + let token_hash = session_token_hash(&raw_token); + if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) { + return Some(resolved.user.id); + } + } + let actor_id = context.auth.actor_id.trim(); + if actor_id.is_empty() || actor_id == "anonymous" { + None + } else { + Some(actor_id.to_string()) + } +} + +pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) -> String { + if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { + let token_hash = session_token_hash(&raw_token); + if matches!( + state.control_plane().get_session_by_token_hash(&token_hash), + Ok(Some(_)) + ) { + return "user".to_string(); + } + } + context.auth.actor_type.trim().to_string() +} + fn normalize_optional_id(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } -fn has_real_auth_context(context: &RequestContext) -> bool { +fn has_real_auth_context(state: &AppState, context: &RequestContext) -> bool { let actor_id = context.auth.actor_id.trim(); if !actor_id.is_empty() && actor_id != "anonymous" { return true; } + if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { + let token_hash = session_token_hash(&raw_token); + if matches!( + state.control_plane().get_session_by_token_hash(&token_hash), + Ok(Some(_)) + ) { + return true; + } + } + extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT).is_some() || extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some() } -async fn run_convex_auth_action( +fn handle_sqlite_auth_action( state: &AppState, context: &RequestContext, payload: &serde_json::Value, -) -> Result { - let convex_url = state - .config() - .convex_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) +) -> Result { + let params = payload + .pointer("/args/params") + .and_then(Value::as_object) .ok_or_else(|| { - WebError::service_unavailable_code( - "convex_config_missing", - "缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL,无法执行 Convex Auth。", - ) - .with_context(context) - .with_header("x-error-phase", "convex_auth_url") - .with_header("x-upstream-service", "convex") - })?; - let action = payload - .get("action") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let mut args = payload.get("args").cloned().unwrap_or_else(|| json!({})); - if action == "auth:signIn" - && args - .get("refreshToken") - .map(|value| !value.is_null()) - .unwrap_or(false) - { - if let Some(refresh_token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_REFRESH_TOKEN) - { - args["refreshToken"] = serde_json::Value::String(refresh_token); - } - } - - let request_body = json!({ - "path": action, - "format": "convex_encoded_json", - "args": [args], - }); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(20)) - .build() - .map_err(|error| { - WebError::internal(format!("Convex Auth HTTP 客户端创建失败: {error}")) + WebError::bad_request_code("auth_bad_request", "登录请求缺少 args.params") .with_context(context) - .with_header("x-error-phase", "convex_auth_client") - .with_header("x-upstream-service", "convex") + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") })?; - let mut request = client - .post(format!("{}/api/action", convex_url.trim_end_matches('/'))) - .header("Content-Type", "application/json") - .header("Convex-Client", "mnote-web") - .json(&request_body); - if let Some(token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT) { - request = request.header(header::AUTHORIZATION, format!("Bearer {token}")); - } - - let response = request.send().await.map_err(|error| { - WebError::bad_gateway_code( - "convex_auth_proxy_error", - format!("Convex Auth 请求失败: {error}"), - ) - .with_context(context) - .with_header("x-error-phase", "convex_auth_action") - .with_header("x-upstream-service", "convex") - })?; - let status = response.status(); - let value: serde_json::Value = response.json().await.map_err(|error| { - WebError::bad_gateway_code( - "convex_auth_response_invalid", - format!("Convex Auth 响应不是合法 JSON: {error}"), - ) - .with_context(context) - .with_header("x-error-phase", "convex_auth_decode") - .with_header("x-upstream-service", "convex") - })?; - if !status.is_success() && status.as_u16() != 560 { - return Err(WebError::bad_gateway_code( - "convex_auth_upstream_error", - format!("Convex Auth 返回 HTTP {}: {}", status.as_u16(), value), - ) - .with_context(context) - .with_header("x-error-phase", "convex_auth_status") - .with_header("x-upstream-service", "convex")); - } - Ok(value) -} - -async fn resolve_auth_login_payload( - state: &AppState, - context: &RequestContext, - mut payload: serde_json::Value, -) -> Result { - let action = payload - .get("action") - .and_then(Value::as_str) - .unwrap_or_default(); - if action != "auth:signIn" { - return Ok(payload); - } - - let flow = payload - .pointer("/args/params/flow") + let flow = params + .get("flow") .and_then(Value::as_str) .unwrap_or("signIn"); - if flow != "signIn" { - let email = non_empty_json_string(payload.pointer("/args/params/email")); - if email.is_none() { - return Err(WebError::bad_request_code( - "auth_signup_email_required", - "注册账号时请填写邮箱;登录时可以使用用户名。", - ) - .with_context(context) - .with_header("x-error-phase", "auth_signup_payload")); - } - return Ok(payload); - } - - let account = non_empty_json_string(payload.pointer("/args/params/account")) - .or_else(|| non_empty_json_string(payload.pointer("/args/params/email"))); - let Some(account) = account else { - return Ok(payload); + let password = params + .get("password") + .and_then(Value::as_str) + .map(str::to_string) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("auth_password_required", "请填写密码") + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + })?; + let session_token = new_session_token(); + let token_hash = session_token_hash(&session_token); + let resolved = if flow == "signUp" { + let email = params + .get("email") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code( + "auth_signup_email_required", + "注册账号时请填写邮箱;登录时可以使用用户名。", + ) + .with_context(context) + .with_header("x-error-phase", "auth_signup_payload") + })?; + let username = params + .get("name") + .or_else(|| params.get("username")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| email.split('@').next().unwrap_or("user")); + let user = state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some(username.to_string()), + email: Some(email.to_string()), + username: username.to_string(), + display_name: username.to_string(), + role: None, + password_hash: None, + }) + .map_err(|error| sqlite_auth_error(context, error))?; + state + .control_plane() + .create_password_identity(CreatePasswordIdentityInput { + user_id: user.id.clone(), + email: user.email.clone(), + username: user.username.clone(), + password: password.clone(), + }) + .map_err(|error| sqlite_auth_error(context, error))?; + state + .control_plane() + .ensure_default_workspace(&user.id) + .map_err(|error| sqlite_auth_error(context, error))?; + state + .control_plane() + .authenticate_password(AuthenticatePasswordInput { + account: email.to_string(), + password, + session_id: None, + token_hash, + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .map_err(|error| sqlite_auth_error(context, error))? + } else { + let account = params + .get("account") + .or_else(|| params.get("email")) + .or_else(|| params.get("name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("auth_account_required", "请填写邮箱或用户名") + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + })?; + state + .control_plane() + .authenticate_password(AuthenticatePasswordInput { + account: account.to_string(), + password, + session_id: None, + token_hash, + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .map_err(|error| sqlite_auth_error(context, error))? }; - if account.contains('@') { - payload["args"]["params"]["email"] = Value::String(account.clone()); - if payload.pointer("/args/params/name").is_none() { - if let Some(name) = account - .split('@') - .next() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - payload["args"]["params"]["name"] = Value::String(name.to_string()); - } - } - return Ok(payload); - } - - let resolved = resolve_login_account_via_convex(state, context, &account).await?; - let email = resolved - .get("email") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - WebError::bad_request_code("auth_username_not_found", "未找到这个用户名。") - .with_context(context) - .with_header("x-error-phase", "auth_username_lookup") - .with_header("x-upstream-service", "convex") - })?; - payload["args"]["params"]["email"] = Value::String(email.to_string()); - payload["args"]["params"]["name"] = Value::String(account); - Ok(payload) -} - -async fn resolve_login_account_via_convex( - state: &AppState, - context: &RequestContext, - account: &str, -) -> Result { - let convex_url = state - .config() - .convex_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - WebError::service_unavailable_code( - "convex_config_missing", - "缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL,无法按用户名登录。", - ) - .with_context(context) - .with_header("x-error-phase", "auth_username_lookup") - .with_header("x-upstream-service", "convex") - })?; - let request_body = json!({ - "path": "users:resolveLoginAccount", - "format": "convex_encoded_json", - "args": [{ "account": account }], + let audit_action = if flow == "signUp" { + "control.auth.signed_up" + } else { + "control.auth.signed_in" + }; + let _ = state.control_plane().append_audit(AppendAuditInput { + actor_user_id: Some(resolved.user.id.clone()), + action: audit_action.to_string(), + target_kind: "auth_session".to_string(), + target_id: Some(resolved.session.id.clone()), + metadata_json: serde_json::to_string(&json!({ + "email": resolved.user.email, + "name": resolved.user.display_name, + "flow": flow, + })) + .unwrap_or_else(|_| "{}".to_string()), }); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(20)) - .build() - .map_err(|error| { - WebError::internal(format!("Convex 用户名查询 HTTP 客户端创建失败: {error}")) - .with_context(context) - .with_header("x-error-phase", "auth_username_lookup_client") - .with_header("x-upstream-service", "convex") - })?; - let response = client - .post(format!("{}/api/query", convex_url.trim_end_matches('/'))) - .header("Content-Type", "application/json") - .header("Convex-Client", "mnote-web") - .json(&request_body) - .send() - .await - .map_err(|error| { - WebError::service_unavailable_code( - "convex_unavailable", - format!("Convex 用户名查询请求失败: {error}"), - ) - .with_context(context) - .with_header("x-error-phase", "auth_username_lookup") - .with_header("x-upstream-service", "convex") - })?; - let status = response.status(); - let body: Value = response.json().await.map_err(|error| { - WebError::bad_gateway_code( - "convex_bad_response", - format!("Convex 用户名查询响应解析失败: {error}"), - ) - .with_context(context) - .with_header("x-error-phase", "auth_username_lookup_decode") - .with_header("x-upstream-service", "convex") - .with_header("x-upstream-status", status.as_u16().to_string()) - })?; - if !status.is_success() { - let message = body - .get("errorMessage") - .and_then(Value::as_str) - .unwrap_or("用户名查询失败"); - return Err(WebError::bad_gateway_code("convex_upstream_error", message.to_string()) - .with_context(context) - .with_header("x-error-phase", "auth_username_lookup_status") - .with_header("x-upstream-service", "convex") - .with_header("x-upstream-status", status.as_u16().to_string())); - } - match body.get("status").and_then(Value::as_str) { - Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)), - Some("error") => Err(WebError::bad_request_code( - "auth_username_not_found", - body.get("errorMessage") - .and_then(Value::as_str) - .unwrap_or("未找到这个用户名。") - .to_string(), - ) - .with_context(context) - .with_header("x-error-phase", "auth_username_lookup") - .with_header("x-upstream-service", "convex")), - _ => Err(WebError::bad_gateway_code( - "convex_bad_response", - format!("未知 Convex 用户名查询响应: {body}"), - ) - .with_context(context) - .with_header("x-error-phase", "auth_username_lookup_payload") - .with_header("x-upstream-service", "convex")), - } + + Ok(build_sqlite_auth_response( + context, + &session_token, + &resolved.user.id, + resolved.user.email.as_deref().unwrap_or_default(), + &resolved.user.display_name, + )) } -fn build_auth_proxy_response( - convex_response: &serde_json::Value, +fn sqlite_auth_error( context: &RequestContext, - request_payload: &serde_json::Value, + error: control_plane::ControlPlaneError, +) -> WebError { + match error { + control_plane::ControlPlaneError::Unauthorized(message) => { + WebError::bad_request_code("auth_invalid_credentials", message) + } + control_plane::ControlPlaneError::Conflict(message) => { + WebError::bad_request_code("auth_conflict", message) + } + control_plane::ControlPlaneError::InvalidInput(message) => { + WebError::bad_request_code("auth_bad_request", message) + } + other => WebError::internal(format!("SQLite Auth 失败: {other}")), + } + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") +} + +fn build_sqlite_auth_response( + context: &RequestContext, + session_token: &str, + user_id: &str, + email: &str, + name: &str, ) -> Response { - if convex_response - .get("status") - .and_then(|value| value.as_str()) - != Some("success") - { - let message = convex_response - .get("errorMessage") - .and_then(|value| value.as_str()) - .unwrap_or("Convex Auth 登录失败。"); - let mut response = axum::Json(json!({ "error": message })).into_response(); - *response.status_mut() = StatusCode::BAD_REQUEST; - clear_auth_cookies(response.headers_mut()); - stamp_gateway_headers(response.headers_mut(), false); - apply_trace_response_headers(context, response.headers_mut()); - return response; + let mut response = axum::Json(json!({ + "ok": true, + "userId": user_id, + "email": email, + "name": name, + "authMode": "sqliteSession" + })) + .into_response(); + set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_SESSION, session_token); + set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, user_id); + set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, "user"); + if !email.trim().is_empty() { + set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_EMAIL, email); } - - let value = convex_response - .get("value") - .cloned() - .unwrap_or_else(|| json!({})); - let tokens = value.get("tokens"); - let mut response_body = value.clone(); - if let Some(tokens) = tokens { - if tokens.is_null() { - response_body["tokens"] = serde_json::Value::Null; - } else if let Some(token) = tokens.get("token").and_then(|value| value.as_str()) { - response_body["tokens"] = json!({ - "token": token, - "refreshToken": "dummy", - }); - } - } - - let mut response = axum::Json(response_body).into_response(); - if let Some(tokens) = tokens { - if tokens.is_null() { - clear_auth_cookies(response.headers_mut()); - } else { - set_auth_cookie_from_value( - response.headers_mut(), - COOKIE_CONVEX_AUTH_JWT, - tokens.get("token"), - ); - set_auth_cookie_from_value( - response.headers_mut(), - COOKIE_CONVEX_AUTH_REFRESH_TOKEN, - tokens.get("refreshToken"), - ); - if let Some(actor_id) = resolve_mnote_actor_id(&value, tokens, context) { - set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, &actor_id); - set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, "user"); - } - if let Some(email) = resolve_mnote_actor_email(&value, tokens, request_payload) { - set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_EMAIL, &email); - } - if let Some(name) = resolve_mnote_actor_name(&value, tokens, request_payload) { - set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_NAME, &name); - } - expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION); - } + if !name.trim().is_empty() { + set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_NAME, name); } + expire_cookie(response.headers_mut(), COOKIE_CONVEX_AUTH_JWT); + expire_cookie(response.headers_mut(), COOKIE_CONVEX_AUTH_REFRESH_TOKEN); + expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION); stamp_gateway_headers(response.headers_mut(), false); apply_trace_response_headers(context, response.headers_mut()); response } +fn build_sqlite_sign_out_response(state: &AppState, context: &RequestContext) -> Response { + if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { + let token_hash = session_token_hash(&raw_token); + if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) { + let _ = state.control_plane().revoke_session(&resolved.session.id); + let _ = state.control_plane().append_audit(AppendAuditInput { + actor_user_id: Some(resolved.user.id.clone()), + action: "control.auth.signed_out".to_string(), + target_kind: "auth_session".to_string(), + target_id: Some(resolved.session.id.clone()), + metadata_json: serde_json::to_string(&json!({ + "email": resolved.user.email, + "name": resolved.user.display_name, + })) + .unwrap_or_else(|_| "{}".to_string()), + }); + } + } + build_sign_out_response(context) +} + +fn new_session_token() -> String { + uuid::Uuid::new_v4().simple().to_string() +} + fn build_sign_out_response(context: &RequestContext) -> Response { let mut response = axum::Json(json!({ "ok": true, "signedOut": true })).into_response(); clear_auth_cookies(response.headers_mut()); @@ -1926,117 +1886,6 @@ fn insert_response_header(headers: &mut axum::http::HeaderMap, name: &str, value headers.insert(name, value); } -fn resolve_mnote_actor_id( - value: &serde_json::Value, - tokens: &serde_json::Value, - context: &RequestContext, -) -> Option { - [ - "/userId", - "/user/id", - "/user/_id", - "/user/subject", - "/profile/userId", - "/profile/id", - ] - .iter() - .find_map(|pointer| { - value - .pointer(pointer) - .and_then(Value::as_str) - .and_then(stable_actor_id) - }) - .or_else(|| { - tokens - .get("token") - .and_then(Value::as_str) - .and_then(extract_actor_id_from_jwt) - }) - .or_else(|| { - let actor_id = stable_actor_id(&context.auth.actor_id)?; - if actor_id == "anonymous" { - None - } else { - Some(actor_id) - } - }) -} - -fn extract_actor_id_from_jwt(token: &str) -> Option { - let payload_segment = token.split('.').nth(1)?; - let decoded = URL_SAFE_NO_PAD.decode(payload_segment.as_bytes()).ok()?; - let payload: Value = serde_json::from_slice(&decoded).ok()?; - ["sub", "userId", "id", "_id"].iter().find_map(|key| { - payload - .get(key) - .and_then(Value::as_str) - .and_then(stable_actor_id) - }) -} - -fn resolve_mnote_actor_email( - value: &serde_json::Value, - tokens: &serde_json::Value, - request_payload: &serde_json::Value, -) -> Option { - [ - "/email", - "/user/email", - "/profile/email", - "/args/params/email", - ] - .iter() - .find_map(|pointer| non_empty_json_string(value.pointer(pointer))) - .or_else(|| { - tokens - .get("token") - .and_then(Value::as_str) - .and_then(extract_email_from_jwt) - }) - .or_else(|| non_empty_json_string(request_payload.pointer("/args/params/email"))) -} - -fn resolve_mnote_actor_name( - value: &serde_json::Value, - tokens: &serde_json::Value, - request_payload: &serde_json::Value, -) -> Option { - ["/name", "/user/name", "/profile/name", "/args/params/name"] - .iter() - .find_map(|pointer| non_empty_json_string(value.pointer(pointer))) - .or_else(|| { - tokens - .get("token") - .and_then(Value::as_str) - .and_then(extract_name_from_jwt) - }) - .or_else(|| non_empty_json_string(request_payload.pointer("/args/params/name"))) -} - -fn extract_email_from_jwt(token: &str) -> Option { - jwt_string_claim(token, &["email", "preferred_username"]) -} - -fn extract_name_from_jwt(token: &str) -> Option { - jwt_string_claim(token, &["name", "username"]) -} - -fn jwt_string_claim(token: &str, keys: &[&str]) -> Option { - let payload_segment = token.split('.').nth(1)?; - let decoded = URL_SAFE_NO_PAD.decode(payload_segment.as_bytes()).ok()?; - let payload: Value = serde_json::from_slice(&decoded).ok()?; - keys.iter() - .find_map(|key| non_empty_json_string(payload.get(key))) -} - -fn non_empty_json_string(value: Option<&serde_json::Value>) -> Option { - value - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) { let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax"); if let Ok(value) = HeaderValue::from_str(&cookie) { @@ -2049,21 +1898,8 @@ fn set_encoded_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, v set_literal_cookie(headers, name, &encoded); } -fn set_auth_cookie_from_value( - headers: &mut axum::http::HeaderMap, - name: &'static str, - value: Option<&serde_json::Value>, -) { - let Some(value) = value.and_then(|value| value.as_str()) else { - return; - }; - let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax"); - if let Ok(value) = HeaderValue::from_str(&cookie) { - headers.append(header::SET_COOKIE, value); - } -} - fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) { + expire_cookie(headers, COOKIE_MNOTE_SESSION); expire_cookie(headers, COOKIE_CONVEX_AUTH_JWT); expire_cookie(headers, COOKIE_CONVEX_AUTH_REFRESH_TOKEN); expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION); @@ -2114,6 +1950,15 @@ fn extract_cookie_value(context: &RequestContext, name: &str) -> Option }) } +fn extract_encoded_cookie_value(context: &RequestContext, name: &str) -> Option { + let encoded = extract_cookie_value(context, name)?; + let decoded = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).ok()?; + String::from_utf8(decoded) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + fn stamp_gateway_headers(headers: &mut axum::http::HeaderMap, legacy: bool) { if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web")); @@ -2167,7 +2012,6 @@ mod tests { use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode}; use axum::response::{Html, IntoResponse}; use axum::routing::{get, post}; - use base64::Engine; use tokio::net::TcpListener; use tower::util::ServiceExt; @@ -2237,71 +2081,6 @@ mod tests { })) } - async fn spawn_convex_auth_upstream() -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("convex auth listener"); - let addr = listener.local_addr().expect("convex auth addr"); - let app = axum::Router::new().route( - "/api/action", - post(|| async { - axum::Json(serde_json::json!({ - "status": "success", - "value": { - "userId": "user_demo", - "tokens": { - "token": "jwt-demo", - "refreshToken": "refresh-demo" - } - } - })) - }), - ); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("convex auth server"); - }); - format!("http://{addr}") - } - - async fn spawn_convex_auth_upstream_with_token(token: String) -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("convex auth listener"); - let addr = listener.local_addr().expect("convex auth addr"); - let app = axum::Router::new().route( - "/api/action", - post(move || { - let token = token.clone(); - async move { - axum::Json(serde_json::json!({ - "status": "success", - "value": { - "tokens": { - "token": token, - "refreshToken": "refresh-demo" - } - } - })) - } - }), - ); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("convex auth server"); - }); - format!("http://{addr}") - } - - fn unsigned_jwt_with_subject(subject: &str) -> String { - let payload = serde_json::json!({ "sub": subject }); - let encoded_payload = - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); - format!("header.{encoded_payload}.signature") - } - async fn spawn_legacy_auth_upstream() -> String { let listener = TcpListener::bind("127.0.0.1:0") .await @@ -2717,7 +2496,7 @@ mod tests { } #[tokio::test] - async fn root_entry_shows_admin_access_policy_entry_only_for_admin_actor() { + async fn root_entry_renders_access_policy_dialog_templates_for_all_actors() { let admin_response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None) .oneshot( @@ -2736,7 +2515,10 @@ mod tests { .await .expect("body"); let admin_html = String::from_utf8(admin_body.to_vec()).expect("utf8"); - assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#)); + assert!(!admin_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#)); + assert!(admin_html.contains(r#"data-testid="mnote-account-access-policy""#)); + assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#)); + assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#)); let user_response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None) @@ -2757,6 +2539,76 @@ mod tests { .expect("body"); let user_html = String::from_utf8(user_body.to_vec()).expect("utf8"); assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#)); + assert!(user_html.contains(r#"data-testid="mnote-account-access-policy""#)); + assert!(user_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#)); + assert!(user_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#)); + } + + #[tokio::test] + async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() { + use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput}; + + let app_state = AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: false, + enable_debug_shell_routes: false, + enable_editor_actor: true, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()), + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }); + app_state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("shujuan".into()), + email: Some("shujuan@example.com".into()), + username: "shujuan".into(), + display_name: "shujuan".into(), + role: None, + password_hash: None, + }) + .expect("upsert sqlite user"); + app_state + .control_plane() + .create_session(CreateSessionInput { + id: None, + user_id: "shujuan".into(), + token_hash: session_token_hash("session-shujuan"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("create sqlite session"); + + let response = build_app(app_state) + .oneshot( + Request::builder() + .uri("/") + .header("cookie", "mnote_session=session-shujuan") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains("shujuan 的空间")); + assert!(!html.contains("开发用户 的空间")); } #[tokio::test] @@ -2792,9 +2644,12 @@ mod tests { .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains(r#"data-testid="mnote-admin-access-policy-page""#)); - assert!(html.contains(r#"data-testid="mnote-admin-validate-root-submit""#)); - assert!(html.contains(r#"data-testid="mnote-admin-create-grant-submit""#)); - assert!(html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#)); + assert!(html.contains("文件夹授权")); + assert!(html.contains("管理员可以授权任意本地文件夹")); + assert!(html.contains(r#"data-testid="mnote-admin-create-share-grant-submit""#)); + assert!(!html.contains(r#"data-testid="mnote-admin-validate-root-submit""#)); + assert!(!html.contains(r#"data-testid="mnote-admin-create-grant-submit""#)); + assert!(!html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#)); } #[tokio::test] @@ -2817,8 +2672,10 @@ mod tests { .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains(r#"data-mnote-shell="user-access-policy""#)); - assert!(html.contains("分享管理")); - assert!(html.contains("仅管理员可见")); + assert!(html.contains("文件夹授权")); + assert!(html.contains("普通用户只能授权自己空间下的文件夹")); + assert!(html.contains(r#"data-testid="mnote-admin-create-share-grant-submit""#)); + assert!(!html.contains("仅管理员可见")); assert!(!html.contains(r#"data-testid="mnote-admin-validate-root-submit""#)); assert!(!html.contains(r#"data-testid="mnote-admin-create-grant-submit""#)); assert!(!html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#)); @@ -3018,20 +2875,15 @@ mod tests { } #[tokio::test] - async fn auth_api_sets_convex_auth_cookies_when_compat_disabled() { - let convex_url = spawn_convex_auth_upstream().await; - let response = app_with_config_and_convex_url( - "http://127.0.0.1:3100".into(), - false, - Some(convex_url), - ) + async fn auth_api_signup_sets_sqlite_session_cookie_when_compat_disabled() { + let response = app_with_config("http://127.0.0.1:3100".into(), false) .oneshot( Request::builder() .method("POST") .uri("/api/auth") .header("content-type", "application/json") .body(Body::from( - r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","password":"MnoteE2E123!","flow":"signIn"}}}"#, + r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"new-user@example.com","name":"new-user","password":"MnoteE2E123!","flow":"signUp"}}}"#, )) .expect("request"), ) @@ -3051,15 +2903,10 @@ mod tests { .iter() .map(|value| value.to_str().unwrap_or_default()) .collect::>(); + assert!(values.iter().any(|value| value.contains("mnote_session="))); assert!(values .iter() - .any(|value| value.contains("__convexAuthJWT=jwt-demo"))); - assert!(values - .iter() - .any(|value| value.contains("__convexAuthRefreshToken=refresh-demo"))); - assert!(values - .iter() - .any(|value| value.contains("mnote_actor_id=user_demo"))); + .any(|value| value.contains("mnote_actor_id=new-user"))); assert!(values .iter() .any(|value| value.contains("mnote_actor_type=user"))); @@ -3067,50 +2914,31 @@ mod tests { .await .expect("body"); let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); - assert_eq!(payload["tokens"]["token"], "jwt-demo"); - assert_eq!(payload["tokens"]["refreshToken"], "dummy"); + assert_eq!(payload["userId"], "new-user"); + assert_eq!(payload["email"], "new-user@example.com"); + assert_eq!(payload["authMode"], "sqliteSession"); } #[tokio::test] - async fn auth_api_resolves_username_before_convex_sign_in() { - let captured = std::sync::Arc::new(std::sync::Mutex::new(None::)); - let listener = TcpListener::bind("127.0.0.1:0") + async fn auth_api_signin_accepts_sqlite_username_after_signup() { + let app = app_with_config("http://127.0.0.1:3100".into(), false); + let signup = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth") + .header("content-type", "application/json") + .body(Body::from( + r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","name":"mnote-e2e","password":"MnoteE2E123!","flow":"signUp"}}}"#, + )) + .expect("request"), + ) .await - .expect("convex auth listener"); - let addr = listener.local_addr().expect("convex auth addr"); - let captured_body = captured.clone(); - let app = axum::Router::new().route( - "/api/query", - post(|| async { axum::Json(serde_json::json!({"status":"success","value":{"email":"mnote.e2e@example.com"}})) }), - ) - .route( - "/api/action", - post(move |body: String| { - let captured_body = captured_body.clone(); - async move { - *captured_body.lock().expect("captured body") = serde_json::from_str(&body).ok(); - axum::Json(serde_json::json!({ - "status": "success", - "value": { - "userId": "user_demo", - "tokens": { - "token": "jwt-demo", - "refreshToken": "refresh-demo" - } - } - })) - } - }), - ); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("convex auth server"); - }); + .expect("signup response"); + assert_eq!(signup.status(), StatusCode::OK); - let response = app_with_config_and_convex_url( - "http://127.0.0.1:3100".into(), - false, - Some(format!("http://{addr}")), - ) + let response = app .oneshot( Request::builder() .method("POST") @@ -3125,10 +2953,13 @@ mod tests { .expect("response"); assert_eq!(response.status(), StatusCode::OK); - let payload = captured.lock().expect("captured body").clone().expect("captured payload"); - assert_eq!(payload["path"], "auth:signIn"); - assert_eq!(payload["args"][0]["params"]["email"], "mnote.e2e@example.com"); - assert_eq!(payload["args"][0]["params"]["name"], "mnote-e2e"); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["userId"], "mnote-e2e"); + assert_eq!(payload["email"], "mnote.e2e@example.com"); + assert_eq!(payload["authMode"], "sqliteSession"); } #[tokio::test] @@ -3157,35 +2988,26 @@ mod tests { .expect("body"); let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["code"], "auth_signup_email_required"); - assert!( - payload["message"] - .as_str() - .unwrap_or_default() - .contains("注册账号时请填写邮箱") - ); + assert!(payload["message"] + .as_str() + .unwrap_or_default() + .contains("注册账号时请填写邮箱")); } #[tokio::test] - async fn auth_api_normalizes_session_subject_before_setting_actor_cookie() { - let token = unsigned_jwt_with_subject("user_stable|session_rotating"); - let convex_url = spawn_convex_auth_upstream_with_token(token).await; - let response = app_with_config_and_convex_url( - "http://127.0.0.1:3100".into(), - false, - Some(convex_url), - ) - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth") - .header("content-type", "application/json") - .body(Body::from( - r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","password":"MnoteE2E123!","flow":"signIn"}}}"#, - )) - .expect("request"), - ) - .await - .expect("response"); + async fn auth_api_signout_clears_sqlite_session_cookie() { + let response = app_with_config("http://127.0.0.1:3100".into(), false) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth") + .header("content-type", "application/json") + .header("cookie", "mnote_session=raw-session-token") + .body(Body::from(r#"{"action":"auth:signOut"}"#)) + .expect("request"), + ) + .await + .expect("response"); assert_eq!(response.status(), StatusCode::OK); let cookies = response.headers().get_all(header::SET_COOKIE); @@ -3195,10 +3017,7 @@ mod tests { .collect::>(); assert!(values .iter() - .any(|value| value.contains("mnote_actor_id=user_stable"))); - assert!(!values - .iter() - .any(|value| value.contains("mnote_actor_id=user_stable|session_rotating"))); + .any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0"))); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index d6e9040c..325081f6 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -9,6 +9,7 @@ use axum::extract::{Extension, Path, Query, State}; use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::Response; use axum::Json; +use control_plane::{AppendAiRuntimeEventInput, UpsertAiRuntimeRunInput}; use futures_util::TryStreamExt; use serde::Deserialize; use serde_json::{json, Value}; @@ -27,8 +28,7 @@ use tracing::{info, warn}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner"; -const ACP_RUNTIME_RUN_MUTATION: &str = "aiSessions:upsertRuntimeRun"; -const ACP_RUNTIME_EVENT_MUTATION: &str = "aiSessions:appendRuntimeEvent"; +const ACP_RUNTIME_SQLITE_STORE: &str = "sqlite_acp_runtime_store"; const ACP_ABORT_NOTIFICATION_TIMEOUT_MS: u64 = 2_500; const LOCAL_SHARE_GRANTS_JSON: &str = "/mnt/Data1T/Mnote_data/control-plane/share-grants.json"; const ENV_LOCAL_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE"; @@ -252,6 +252,50 @@ pub async fn search_sessions( .and_then(|value| value.parse::().ok()) .unwrap_or(20) .clamp(1, 50); + if !use_legacy_convex_acp_store(&query) { + let runs = state + .control_plane() + .list_ai_runtime_runs( + &user_id, + workspace_id.as_deref(), + None, + None, + limit as usize, + ) + .map_err(|error| { + WebError::internal(format!("SQLite ACP session 搜索失败: {error}")) + .with_context(&context) + })?; + let needle = q.to_lowercase(); + let results = runs + .iter() + .filter(|run| { + run.title + .as_deref() + .unwrap_or_default() + .to_lowercase() + .contains(&needle) + || run.payload_json.to_lowercase().contains(&needle) + }) + .map(|run| { + let mut value = ai_runtime_run_to_json(run); + value["snippet"] = + Value::String(run.title.clone().unwrap_or_else(|| run.session_id.clone())); + value["score"] = Value::from(1); + value + }) + .collect::>(); + return Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "traceId": context.trace.trace_id, + "persistence": ACP_RUNTIME_SQLITE_STORE, + "results": results + })), + )); + } let mut args = serde_json::Map::new(); args.insert("userId".into(), Value::String(user_id)); args.insert("q".into(), Value::String(q.to_string())); @@ -364,6 +408,31 @@ async fn list_acp_sessions( .and_then(|value| value.parse::().ok()) .unwrap_or(50) .clamp(1, 100); + if !use_legacy_convex_acp_store(query) { + let runs = state + .control_plane() + .list_ai_runtime_runs( + &user_id, + workspace_id.as_deref(), + document_id.as_deref(), + session_id.as_deref(), + limit as usize, + ) + .map_err(|error| { + WebError::internal(format!("SQLite ACP session 列表读取失败: {error}")) + .with_context(context) + })?; + return Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "traceId": context.trace.trace_id, + "persistence": ACP_RUNTIME_SQLITE_STORE, + "sessions": runs.iter().map(ai_runtime_run_to_json).collect::>() + })), + )); + } let mut args = serde_json::Map::new(); args.insert("userId".into(), Value::String(user_id)); if let Some(workspace_id) = workspace_id { @@ -582,7 +651,7 @@ pub async fn create_session( &runtime_payload, ) .await?; - persistence = "convex_acp_runtime_store"; + persistence = ACP_RUNTIME_SQLITE_STORE; } Ok(( StatusCode::OK, @@ -978,7 +1047,10 @@ pub async fn resume_session( let result = get_acp_session(&state, &context, &session_id, &query).await?; let mut payload = result.2 .0; payload["resumed"] = Value::Bool(true); - payload["resumeSource"] = Value::String("convex_acp_runtime_store".into()); + payload["resumeSource"] = payload + .get("persistence") + .cloned() + .unwrap_or_else(|| Value::String(ACP_RUNTIME_SQLITE_STORE.into())); return Ok((result.0, result.1, Json(payload))); } get_session( @@ -997,6 +1069,36 @@ pub async fn delete_session( Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; + if !use_legacy_convex_acp_store(&query) { + let user_id = effective_session_store_user_id(&state, &context).await?; + let workspace_id = query + .get("workspaceId") + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| context.workspace.workspace_id.clone()); + let deleted = state + .control_plane() + .delete_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref()) + .map_err(|error| { + WebError::internal(format!("SQLite ACP session 删除失败: {error}")) + .with_context(&context) + })?; + return Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "persistence": ACP_RUNTIME_SQLITE_STORE, + "result": { + "ok": true, + "sessionId": session_id, + "deleted": deleted + } + })), + )); + } let result = execute_acp_session_mutation( &state, &context, @@ -1035,6 +1137,37 @@ pub async fn rename_session( .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client"), ); } + if !use_legacy_convex_acp_store(&query) { + let user_id = effective_session_store_user_id(&state, &context).await?; + let workspace_id = query + .get("workspaceId") + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| context.workspace.workspace_id.clone()); + let runs = state + .control_plane() + .rename_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref(), title) + .map_err(|error| { + WebError::internal(format!("SQLite ACP session 重命名失败: {error}")) + .with_context(&context) + })?; + return Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "persistence": ACP_RUNTIME_SQLITE_STORE, + "result": { + "ok": true, + "sessionId": session_id, + "title": title, + "runs": runs.iter().map(ai_runtime_run_to_json).collect::>() + } + })), + )); + } let result = execute_acp_session_mutation( &state, &context, @@ -1063,6 +1196,38 @@ pub async fn auto_title_session( Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; + if !use_legacy_convex_acp_store(&query) { + let user_id = effective_session_store_user_id(&state, &context).await?; + let workspace_id = query + .get("workspaceId") + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| context.workspace.workspace_id.clone()); + let titled = state + .control_plane() + .auto_title_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref()) + .map_err(|error| { + WebError::internal(format!("SQLite ACP session 自动标题失败: {error}")) + .with_context(&context) + })?; + let title = titled.as_ref().and_then(|run| run.title.clone()); + return Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "persistence": ACP_RUNTIME_SQLITE_STORE, + "result": { + "ok": true, + "sessionId": session_id, + "title": title, + "run": titled.as_ref().map(ai_runtime_run_to_json) + } + })), + )); + } let result = execute_acp_session_mutation( &state, &context, @@ -1177,6 +1342,54 @@ async fn get_acp_session( .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| context.workspace.workspace_id.clone()); + if !use_legacy_convex_acp_store(query) { + let runs = state + .control_plane() + .list_ai_runtime_runs( + &user_id, + workspace_id.as_deref(), + None, + Some(session_id), + 20, + ) + .map_err(|error| { + WebError::internal(format!("SQLite ACP session 详情读取失败: {error}")) + .with_context(context) + })?; + let latest_run = runs.first().cloned(); + let events = if let Some(run) = latest_run.as_ref() { + state + .control_plane() + .list_ai_runtime_events(&user_id, &run.run_id, 200) + .map_err(|error| { + WebError::internal(format!("SQLite ACP runtime events 读取失败: {error}")) + .with_context(context) + })? + } else { + Vec::new() + }; + let runtime = latest_run + .as_ref() + .and_then(|run| serde_json::from_str::(&run.runtime_json).ok()) + .unwrap_or_else(|| runtime_state_for_session(session_id)); + return Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "traceId": context.trace.trace_id, + "persistence": ACP_RUNTIME_SQLITE_STORE, + "sessionId": session_id, + "session": { + "sessionId": session_id, + "messages": [], + "runs": runs.iter().map(ai_runtime_run_to_json).collect::>() + }, + "runtime": runtime, + "events": events.iter().map(ai_runtime_event_to_json).collect::>() + })), + )); + } let mut run_args = serde_json::Map::new(); run_args.insert("userId".into(), Value::String(user_id.clone())); run_args.insert("sessionId".into(), Value::String(session_id.to_string())); @@ -1327,7 +1540,7 @@ pub async fn create_run( "persistence": persistence_result .get("persistence") .and_then(Value::as_str) - .unwrap_or("convex_acp_runtime_store"), + .unwrap_or(ACP_RUNTIME_SQLITE_STORE), "sessionStorage": persistence_result .get("sessionStorage") .cloned() @@ -4232,6 +4445,61 @@ fn acp_runtime_run_store_args( }) } +fn json_string(value: &Value) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) +} + +fn ai_runtime_run_to_json(record: &control_plane::AiRuntimeRunRecord) -> Value { + let runtime = serde_json::from_str::(&record.runtime_json).unwrap_or(Value::Null); + let payload = serde_json::from_str::(&record.payload_json).unwrap_or(Value::Null); + json!({ + "sessionId": record.session_id, + "runId": record.run_id, + "workspaceId": record.workspace_id, + "documentId": record.document_id, + "title": record.title, + "profile": record.profile, + "acpRuntime": record.acp_runtime, + "traceId": record.trace_id, + "status": record.status, + "runtime": runtime, + "payload": payload, + "createdAt": record.created_at, + "updatedAt": record.updated_at, + "persistence": ACP_RUNTIME_SQLITE_STORE, + }) +} + +fn ai_runtime_event_to_json(record: &control_plane::AiRuntimeEventRecord) -> Value { + let payload = serde_json::from_str::(&record.payload_json).unwrap_or(Value::Null); + json!({ + "eventId": record.id, + "sessionId": record.session_id, + "runId": record.run_id, + "workspaceId": record.workspace_id, + "documentId": record.document_id, + "profile": record.profile, + "acpRuntime": record.acp_runtime, + "eventType": record.event_type, + "payload": payload, + "createdAt": record.created_at, + "persistence": ACP_RUNTIME_SQLITE_STORE, + }) +} + +fn query_bool(query: &HashMap, key: &str) -> bool { + query + .get(key) + .map(String::as_str) + .map(str::trim) + .map(|value| matches!(value, "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(false) +} + +fn use_legacy_convex_acp_store(query: &HashMap) -> bool { + query_bool(query, "legacyConvex") || query_bool(query, "convex") +} + fn local_session_share_id(payload: &Value) -> Option { payload .get("shareId") @@ -4868,19 +5136,57 @@ async fn persist_acp_runtime_run( "runId": run_id })); } - execute_convex_mutation_by_name( - state.config(), - context, - ACP_RUNTIME_RUN_MUTATION, - args, - payload - .get("workspaceId") - .and_then(Value::as_str) - .or(context.workspace.workspace_id.as_deref()), - Some(run_id), - "acp_runtime_run_store", - ) - .await + let workspace_id = args + .get("workspaceId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let document_id = args + .get("documentId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let title = args + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let status = args + .get("status") + .and_then(Value::as_str) + .unwrap_or("acp_pending") + .to_string(); + let record = state + .control_plane() + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: runtime_store_user_id(context, payload), + workspace_id, + document_id, + session_id: registration.session_id.clone(), + run_id: run_id.to_string(), + title, + profile: registration.profile.clone(), + acp_runtime: acp_runtime.to_string(), + trace_id: Some(registration.trace_id.clone()), + status, + runtime_json: json_string(runtime_state), + payload_json: json_string(payload), + }) + .map_err(|error| { + WebError::internal(format!("SQLite ACP runtime run 写入失败: {error}")) + .with_context(context) + })?; + Ok(json!({ + "ok": true, + "persistence": ACP_RUNTIME_SQLITE_STORE, + "sessionStorage": "sqlite_control_plane", + "sessionId": record.session_id, + "runId": record.run_id + })) } fn acp_runtime_event_store_args( @@ -4990,19 +5296,41 @@ async fn persist_acp_runtime_event( "runId": run_id })); } - execute_convex_mutation_by_name( - state.config(), - context, - ACP_RUNTIME_EVENT_MUTATION, - args, - run_payload - .get("workspaceId") - .and_then(Value::as_str) - .or(context.workspace.workspace_id.as_deref()), - None, - "acp_runtime_event_store", - ) - .await + state + .control_plane() + .append_ai_runtime_event(AppendAiRuntimeEventInput { + id: None, + user_id: runtime_store_user_id(context, run_payload), + workspace_id: args + .get("workspaceId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + document_id: args + .get("documentId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + session_id: registration.session_id.clone(), + run_id: run_id.to_string(), + profile: registration.profile.clone(), + acp_runtime: acp_runtime.to_string(), + event_type: event_type.to_string(), + payload_json: json_string(event_payload), + }) + .map_err(|error| { + WebError::internal(format!("SQLite ACP runtime event 写入失败: {error}")) + .with_context(context) + })?; + Ok(json!({ + "ok": true, + "persistence": ACP_RUNTIME_SQLITE_STORE, + "sessionStorage": "sqlite_control_plane", + "sessionId": registration.session_id, + "runId": run_id + })) } fn register_runtime_from_create_run_response( @@ -5652,6 +5980,7 @@ mod tests { use axum::body::{to_bytes, Body}; use axum::http::Request; use axum::routing::{get, post}; + use control_plane::UpsertAiRuntimeRunInput; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use tower::util::ServiceExt; @@ -5776,6 +6105,48 @@ mod tests { build_app(AppState::new(config)) } + fn seeded_acp_state() -> AppState { + let state = AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + enable_editor_actor: true, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: false, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }); + state + .control_plane() + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: "user_1".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_1".to_string(), + title: None, + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + trace_id: Some("trace_1".to_string()), + status: "completed".to_string(), + runtime_json: "{\"status\":\"completed\",\"runId\":\"run_1\"}".to_string(), + payload_json: "{\"message\":\"自动标题\"}".to_string(), + }) + .expect("seed acp runtime run"); + state + } + fn test_state() -> AppState { AppState::new(AppConfig { service_name: "mnote-web".into(), @@ -6079,7 +6450,7 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1"); - assert_eq!(payload["persistence"], "convex_acp_runtime_store"); + assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); assert!(payload.get("messages").is_none()); } @@ -6519,109 +6890,82 @@ mod tests { .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); - assert_eq!(payload["persistence"], "convex_acp_runtime_store"); + assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); - let mutation = captured_body.lock().expect("captured convex body").clone(); - assert_eq!(mutation["path"], "aiSessions:upsertRuntimeRun"); - assert_eq!(mutation["args"][0]["userId"], "user_1"); - assert_eq!(mutation["args"][0]["workspaceId"], "ws_1"); - assert_eq!(mutation["args"][0]["documentId"], "doc_1"); - assert_eq!(mutation["args"][0]["sessionId"], "mnote_doc_1_trace_1"); - assert_eq!(mutation["args"][0]["runId"], "mnote_doc_1_trace_1_session"); - assert_eq!(mutation["args"][0]["status"], "session.created"); + assert_eq!( + captured_body.lock().expect("captured convex body").clone(), + Value::Null + ); } #[tokio::test] - async fn hermes_client_acp_run_registers_scoped_runtime_record_in_convex() { + async fn hermes_client_acp_run_registers_scoped_runtime_record_in_sqlite() { let _guard = runtime_lock().lock().expect("runtime lock"); clear_runtime_registry(); clear_run_queue(); + let router = app(); - let captured_body = Arc::new(Mutex::new(Value::Null)); - let captured_for_route = Arc::clone(&captured_body); - let mock = axum::Router::new().route( - "/api/mutation", - post(move |Json(body): Json| { - let captured = Arc::clone(&captured_for_route); - async move { - *captured.lock().expect("captured convex body") = body; - Json(json!({ - "status": "success", - "value": {"ok": true, "stored": true} - })) - } - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + let response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/client/runs") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-workspace-id", "ws_header") + .body(Body::from( + json!({ + "workspaceId": "ws_1", + "documentId": "doc_1", + "sessionId": "sess_1", + "message": "读取当前页面", + "profile": "reasonix", + "acpRuntime": "reasonix", + "runId": "run_trace_1", + "traceId": "trace_1", + "pageContext": {"title": "页面标题"} + }) + .to_string(), + )) + .expect("request"), + ) .await - .expect("listener"); - let convex_url = format!("http://{}", listener.local_addr().expect("addr")); - tokio::spawn(async move { - axum::serve(listener, mock).await.expect("mock convex"); - }); - - let response = app_with_config(AppConfig { - service_name: "mnote-web".into(), - service_version: "0.1.0".into(), - bind_addr: "127.0.0.1:0".into(), - public_bind_addr: "127.0.0.1:3000".into(), - legacy_next_base_url: Some("http://127.0.0.1:3100".into()), - enable_legacy_next_compat: true, - enable_debug_shell_routes: false, - enable_editor_actor: true, - hermes_base_path: "/api/hermes".into(), - compat_next_base_path: "/api/compat/next".into(), - convex_url: Some(convex_url), - convex_admin_key: Some("admin-demo".into()), - allow_dev_fixtures: false, - query_fixtures_json: None, - mutation_fixtures_json: None, - dev_user_id: "dev-user".into(), - dev_user_name: "开发用户".into(), - dev_user_email: "dev@mnote.local".into(), - }) - .oneshot( - Request::builder() - .method("POST") - .uri("/api/hermes/client/runs") - .header("content-type", "application/json") - .header("x-mnote-actor-id", "user_1") - .header("x-mnote-workspace-id", "ws_header") - .body(Body::from( - json!({ - "workspaceId": "ws_1", - "documentId": "doc_1", - "sessionId": "sess_1", - "message": "读取当前页面", - "profile": "reasonix", - "acpRuntime": "reasonix", - "runId": "run_trace_1", - "traceId": "trace_1", - "pageContext": {"title": "页面标题"} - }) - .to_string(), - )) - .expect("request"), - ) - .await - .expect("response"); + .expect("response"); assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); - let body = captured_body.lock().expect("captured convex body").clone(); - assert_eq!(body["path"], "aiSessions:upsertRuntimeRun"); - let args = &body["args"][0]; - assert_eq!(args["schema"], "mnote.acp_runtime_run.v1"); - assert_eq!(args["source"], "acp"); - assert_eq!(args["userId"], "user_1"); - assert_eq!(args["workspaceId"], "ws_1"); - assert_eq!(args["documentId"], "doc_1"); - assert_eq!(args["sessionId"], "sess_1"); - assert_eq!(args["runId"], "run_trace_1"); - assert_eq!(args["profile"], "reasonix"); - assert_eq!(args["acpRuntime"], "reasonix"); - assert_eq!(args["runtime"]["status"], "acp_pending"); - assert_eq!(args["payload"]["message"], "读取当前页面"); - assert!(args.get("messages").is_none()); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1") + .header("x-mnote-actor-id", "user_1") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("list response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("list body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); + let run = &payload["sessions"][0]; + assert_eq!(run["sessionId"], "sess_1"); + assert_eq!(run["runId"], "run_trace_1"); + assert_eq!(run["workspaceId"], "ws_1"); + assert_eq!(run["documentId"], "doc_1"); + assert_eq!(run["profile"], "reasonix"); + assert_eq!(run["acpRuntime"], "reasonix"); + assert_eq!(run["runtime"]["status"], "acp_pending"); + assert_eq!(run["payload"]["message"], "读取当前页面"); + assert!(run.get("messages").is_none()); } #[tokio::test] @@ -6683,7 +7027,7 @@ mod tests { .oneshot( Request::builder() .method("GET") - .uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1") + .uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1&legacyConvex=1") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), @@ -6775,7 +7119,9 @@ mod tests { .oneshot( Request::builder() .method("GET") - .uri("/api/hermes/client/sessions/sess_1?source=acp&workspaceId=ws_1") + .uri( + "/api/hermes/client/sessions/sess_1?source=acp&workspaceId=ws_1&legacyConvex=1", + ) .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), @@ -6861,7 +7207,7 @@ mod tests { .oneshot( Request::builder() .method("POST") - .uri("/api/hermes/client/sessions/sess_1/resume?source=acp&workspaceId=ws_1") + .uri("/api/hermes/client/sessions/sess_1/resume?source=acp&workspaceId=ws_1&legacyConvex=1") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), @@ -6880,131 +7226,33 @@ mod tests { } #[tokio::test] - async fn hermes_client_acp_session_rename_calls_convex_store() { - let captured_body = Arc::new(Mutex::new(Value::Null)); - let captured_for_route = Arc::clone(&captured_body); - let mock = axum::Router::new().route( - "/api/mutation", - post(move |Json(body): Json| { - let captured = Arc::clone(&captured_for_route); - async move { - *captured.lock().expect("captured convex body") = body; - Json(json!({ - "status": "success", - "value": {"ok": true, "sessionId": "sess_1", "title": "新标题"} - })) - } - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + async fn hermes_client_acp_session_rename_uses_sqlite_store() { + let response = build_app(seeded_acp_state()) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/client/sessions/sess_1/rename?source=acp&workspaceId=ws_1") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from(json!({"title": "新标题"}).to_string())) + .expect("request"), + ) .await - .expect("listener"); - let convex_url = format!("http://{}", listener.local_addr().expect("addr")); - tokio::spawn(async move { - axum::serve(listener, mock).await.expect("mock convex"); - }); - - let response = app_with_config(AppConfig { - service_name: "mnote-web".into(), - service_version: "0.1.0".into(), - bind_addr: "127.0.0.1:0".into(), - public_bind_addr: "127.0.0.1:3000".into(), - legacy_next_base_url: Some("http://127.0.0.1:3100".into()), - enable_legacy_next_compat: true, - enable_debug_shell_routes: false, - enable_editor_actor: true, - hermes_base_path: "/api/hermes".into(), - compat_next_base_path: "/api/compat/next".into(), - convex_url: Some(convex_url), - convex_admin_key: Some("admin-demo".into()), - allow_dev_fixtures: false, - query_fixtures_json: None, - mutation_fixtures_json: None, - dev_user_id: "dev-user".into(), - dev_user_name: "开发用户".into(), - dev_user_email: "dev@mnote.local".into(), - }) - .oneshot( - Request::builder() - .method("POST") - .uri("/api/hermes/client/sessions/sess_1/rename?source=acp&workspaceId=ws_1") - .header("content-type", "application/json") - .header("x-mnote-actor-id", "user_1") - .body(Body::from(json!({"title": "新标题"}).to_string())) - .expect("request"), - ) - .await - .expect("response"); + .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); + assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); assert_eq!(payload["result"]["title"], "新标题"); - - let mutation = captured_body.lock().expect("captured convex body").clone(); - assert_eq!(mutation["path"], "aiSessions:renameRuntimeSession"); - assert_eq!(mutation["args"][0]["userId"], "user_1"); - assert_eq!(mutation["args"][0]["workspaceId"], "ws_1"); - assert_eq!(mutation["args"][0]["sessionId"], "sess_1"); - assert_eq!(mutation["args"][0]["title"], "新标题"); + assert_eq!(payload["result"]["runs"][0]["sessionId"], "sess_1"); } #[tokio::test] - async fn hermes_client_acp_session_delete_and_auto_title_call_convex_store() { - let captured_bodies = Arc::new(Mutex::new(Vec::::new())); - let captured_for_route = Arc::clone(&captured_bodies); - let mock = axum::Router::new().route( - "/api/mutation", - post(move |Json(body): Json| { - let captured = Arc::clone(&captured_for_route); - async move { - captured - .lock() - .expect("captured convex bodies") - .push(body.clone()); - let path = body["path"].as_str().unwrap_or_default(); - let value = match path { - "aiSessions:autoTitleRuntimeSession" => { - json!({"ok": true, "sessionId": "sess_1", "title": "自动标题"}) - } - "aiSessions:deleteRuntimeSession" => { - json!({"ok": true, "sessionId": "sess_1", "deleted": 1}) - } - _ => Value::Null, - }; - Json(json!({"status": "success", "value": value})) - } - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("listener"); - let convex_url = format!("http://{}", listener.local_addr().expect("addr")); - tokio::spawn(async move { - axum::serve(listener, mock).await.expect("mock convex"); - }); - let router = app_with_config(AppConfig { - service_name: "mnote-web".into(), - service_version: "0.1.0".into(), - bind_addr: "127.0.0.1:0".into(), - public_bind_addr: "127.0.0.1:3000".into(), - legacy_next_base_url: Some("http://127.0.0.1:3100".into()), - enable_legacy_next_compat: true, - enable_debug_shell_routes: false, - enable_editor_actor: true, - hermes_base_path: "/api/hermes".into(), - compat_next_base_path: "/api/compat/next".into(), - convex_url: Some(convex_url), - convex_admin_key: Some("admin-demo".into()), - allow_dev_fixtures: false, - query_fixtures_json: None, - mutation_fixtures_json: None, - dev_user_id: "dev-user".into(), - dev_user_name: "开发用户".into(), - dev_user_email: "dev@mnote.local".into(), - }); + async fn hermes_client_acp_session_delete_and_auto_title_use_sqlite_store() { + let router = build_app(seeded_acp_state()); let response = router .clone() @@ -7025,6 +7273,7 @@ mod tests { .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); assert_eq!(payload["result"]["title"], "自动标题"); let response = router @@ -7039,13 +7288,12 @@ mod tests { .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); - - let bodies = captured_bodies.lock().expect("captured convex bodies"); - assert_eq!(bodies[0]["path"], "aiSessions:autoTitleRuntimeSession"); - assert_eq!(bodies[0]["args"][0]["userId"], "user_1"); - assert_eq!(bodies[0]["args"][0]["sessionId"], "sess_1"); - assert_eq!(bodies[1]["path"], "aiSessions:deleteRuntimeSession"); - assert_eq!(bodies[1]["args"][0]["workspaceId"], "ws_1"); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("delete body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); + assert_eq!(payload["result"]["deleted"], 1); } #[tokio::test] @@ -7104,7 +7352,7 @@ mod tests { .oneshot( Request::builder() .method("GET") - .uri("/api/hermes/client/sessions/search?source=acp&workspaceId=ws_1&q=%E5%8C%96%E5%AD%A6&limit=5") + .uri("/api/hermes/client/sessions/search?source=acp&workspaceId=ws_1&q=%E5%8C%96%E5%AD%A6&limit=5&legacyConvex=1") .header("x-mnote-actor-id", "user_1") .body(Body::empty()) .expect("request"), diff --git a/rust/crates/mnote-web/src/routes/local_folder_events.rs b/rust/crates/mnote-web/src/routes/local_folder_events.rs index bb29b3bb..8aa79eda 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_events.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_events.rs @@ -20,7 +20,8 @@ use std::pin::Pin; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::broadcast::error::RecvError; -type BoxedEventStream = Pin> + Send>>; +type BoxedEventStream = + Pin> + Send>>; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -138,8 +139,8 @@ async fn build_tree_live_stream( .map_err(|error| error.with_context(&context))?; let file_tree_snapshot = load_local_folder_file_tree_snapshot(&root_uri) .map_err(|error| error.with_context(&context))?; - let revision = local_folder_watch_revision(&root_uri) - .map_err(|error| error.with_context(&context))?; + let revision = + local_folder_watch_revision(&root_uri).map_err(|error| error.with_context(&context))?; let initial_payload = build_tree_snapshot_payload( &root_uri, @@ -285,8 +286,7 @@ mod tests { fn app_with_local_workspace(root: &std::path::Path) -> axum::Router { let root_uri = format!("file://{}", root.display()); - initialize_local_workspace_for_actor("dev-user", &root_uri) - .expect("init local workspace"); + initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace"); build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index f77962a9..9c22ccc1 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -1,3 +1,4 @@ +use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::page_aggregate::{ @@ -9,10 +10,15 @@ use crate::routes::local_markdown_parser::{ }; use crate::routes::local_search_index; use crate::routes::snapshot_support::ProjectionSnapshot; -use axum::extract::{Extension, Multipart, Path as AxumPath, Query}; +use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::Json; use bridge_runtime::project_legacy_content_to_block_document; +use control_plane::AppendAuditInput; +use control_plane::{ + CreateShareLinkInput, DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, + OutboxEventInput, ShareLinkRecord, +}; use core_protocol::{ KernelObjectIdentity, KernelObjectKind, ObjectWorkspacePath, WorkspaceSourceKind, }; @@ -270,6 +276,8 @@ pub struct LocalAccessValidateRootRequest { #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalAccessGrantRequest { + // 兼容旧 share/access grant payload;SQLite 目录授权创建时由控制面生成真实 grant id。 + #[allow(dead_code)] #[serde(default)] pub id: String, #[serde(default, alias = "user_id")] @@ -311,6 +319,30 @@ pub struct LocalShareGrantRequest { pub capabilities: Vec, } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShareLinkListQuery { + #[serde(default, alias = "workspace_id")] + pub workspace_id: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalShareLinkRequest { + #[serde(default, alias = "workspace_id")] + pub workspace_id: String, + #[serde(default, alias = "resource_kind")] + pub resource_kind: String, + #[serde(default, alias = "resource_id")] + pub resource_id: String, + #[serde(default)] + pub token: Option, + #[serde(default, alias = "access")] + pub permission: String, + #[serde(default, alias = "expires_at")] + pub expires_at: Option, +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SharedCacheRecordRequest { @@ -458,6 +490,41 @@ fn ensure_local_workspace_access_for_actor_with_mode( )) } +fn ensure_local_workspace_access_with_state( + state: &AppState, + context: &RequestContext, + root_uri: &str, + mode: LocalAccessMode, +) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + if !canonical_root.is_dir() { + return Err(WebError::bad_request_code( + "local_folder_not_directory", + "本地 rootUri 必须指向目录", + )); + } + let actor_id = context.auth.actor_id.trim(); + let sqlite_access = state + .control_plane() + .resolve_access(actor_id, &file_uri_for_path(&canonical_root)) + .map_err(|error| WebError::internal(format!("SQLite 控制面授权查询失败: {error}")))?; + if local_access_permission_allows(&sqlite_access.permission, mode) { + return Ok(canonical_root); + } + ensure_local_workspace_access_for_actor_with_mode( + &context.auth.actor_id, + &context.auth.actor_type, + root_uri, + mode, + ) +} + pub(crate) fn ensure_local_workspace_read_access( context: &RequestContext, root_uri: &str, @@ -469,6 +536,14 @@ pub(crate) fn ensure_local_workspace_read_access( ) } +fn ensure_local_workspace_read_access_with_state( + state: &AppState, + context: &RequestContext, + root_uri: &str, +) -> Result { + ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Read) +} + pub(crate) fn ensure_local_path_read_access( context: &RequestContext, root_uri: &str, @@ -523,6 +598,14 @@ pub(crate) fn ensure_local_workspace_access( ) } +fn ensure_local_workspace_write_access_with_state( + state: &AppState, + context: &RequestContext, + root_uri: &str, +) -> Result { + ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Write) +} + fn local_access_policy_path() -> PathBuf { std::env::var(ENV_LOCAL_ACCESS_POLICY_FILE) .ok() @@ -536,8 +619,18 @@ fn local_access_policy_path() -> PathBuf { }) } -pub(crate) fn local_access_policy_path_display() -> String { - local_access_policy_path().display().to_string() +pub(crate) fn control_plane_db_path_display() -> String { + std::env::var("MNOTE_CONTROL_PLANE_DB_PATH") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| { + default_local_workspace_base_dir() + .join("control-plane") + .join("control-plane.db") + .display() + .to_string() + }) } fn local_share_grants_path() -> PathBuf { @@ -591,26 +684,6 @@ fn load_local_share_grants_store() -> Result { }) } -fn write_local_access_policy(policy: &LocalAccessPolicy) -> Result<(), WebError> { - let path = local_access_policy_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - WebError::bad_request_code( - "local_access_policy_dir_create_failed", - format!("无法创建本地目录授权策略目录 {}: {error}", parent.display()), - ) - })?; - } - let content = serde_json::to_string_pretty(policy) - .map_err(|error| WebError::internal(format!("无法序列化本地目录授权策略: {error}")))?; - fs::write(&path, format!("{content}\n")).map_err(|error| { - WebError::bad_request_code( - "local_access_policy_write_failed", - format!("无法写入本地目录授权策略 {}: {error}", path.display()), - ) - }) -} - fn write_local_share_grants_store(store: &LocalShareGrantsStore) -> Result<(), WebError> { let path = local_share_grants_path(); if let Some(parent) = path.parent() { @@ -773,17 +846,6 @@ fn normalize_local_access_capabilities(capabilities: &[String]) -> Result String { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let mut hasher = DefaultHasher::new(); - user_id.hash(&mut hasher); - canonical_root.display().to_string().hash(&mut hasher); - format!("grant_{now}_{:x}", hasher.finish()) -} - fn normalize_share_grant_permission(permission: &str) -> Result { let permission = permission.trim(); match permission { @@ -832,6 +894,50 @@ fn generate_local_share_grant_id(share_id: &str, target_user_id: &str, root: &Pa format!("share_grant_{now}_{:x}", hasher.finish()) } +fn generate_local_folder_share_id( + owner_user_id: &str, + target_user_id: &str, + root: &Path, +) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let mut hasher = DefaultHasher::new(); + owner_user_id.hash(&mut hasher); + target_user_id.hash(&mut hasher); + root.display().to_string().hash(&mut hasher); + format!("folder_{now}_{:x}", hasher.finish()) +} + +fn ensure_path_inside_owned_local_workspace( + actor_id: &str, + canonical_path: &Path, +) -> Result<(), WebError> { + let mut cursor = canonical_path; + loop { + if let Ok(manifest) = load_local_workspace_manifest(cursor) { + if manifest.owner_id.trim() == actor_id.trim() { + return Ok(()); + } + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_share_grant_owner_workspace_required", + "普通用户只能授权自己空间下的文件夹", + )); + } + cursor = match cursor.parent() { + Some(parent) => parent, + None => break, + }; + } + Err(WebError::new( + StatusCode::FORBIDDEN, + "local_share_grant_owner_workspace_required", + "普通用户只能授权自己空间下的文件夹", + )) +} + fn share_grant_payload(grant: &LocalShareGrant) -> Value { json!({ "id": grant.id, @@ -850,6 +956,160 @@ fn share_grant_payload(grant: &LocalShareGrant) -> Value { }) } +fn share_link_payload(link: &ShareLinkRecord) -> Value { + json!({ + "id": link.id, + "workspaceId": link.workspace_id, + "resourceKind": link.resource_kind, + "resourceId": link.resource_id, + "permission": link.permission, + "createdBy": link.created_by, + "expiresAt": link.expires_at, + "revokedAt": link.revoked_at, + "createdAt": link.created_at, + "updatedAt": link.updated_at, + "revision": link.revision, + }) +} + +fn append_control_plane_outbox_event(state: &AppState, event_type: &str, payload: Value) { + let delta = json!({ + "kind": "control_plane_event", + "eventType": event_type, + "payload": payload, + }); + let _ = state.control_plane().append_outbox(OutboxEventInput { + topic: "control".to_string(), + event_type: event_type.to_string(), + payload_json: serde_json::to_string(&delta).unwrap_or_else(|_| "{}".to_string()), + }); + let _ = state.stream_delta_tx.send(delta); +} + +fn list_sqlite_share_links_for_context( + state: &AppState, + context: &RequestContext, + query: ShareLinkListQuery, +) -> Result { + let _policy = require_local_access_policy_admin(context)?; + let workspace_id = query.workspace_id.trim(); + if workspace_id.is_empty() { + return Err(WebError::bad_request_code( + "share_link_workspace_required", + "必须提供 workspaceId", + )); + } + let links = state + .control_plane() + .list_share_links(workspace_id) + .map_err(|error| WebError::internal(format!("SQLite 分享链接读取失败: {error}")))?; + Ok(json!({ + "ok": true, + "controlPlane": "sqlite", + "links": links.iter().map(share_link_payload).collect::>(), + })) +} + +fn create_sqlite_share_link_for_context( + state: &AppState, + context: &RequestContext, + request: LocalShareLinkRequest, +) -> Result { + let _policy = require_local_access_policy_admin(context)?; + let workspace_id = request.workspace_id.trim(); + let resource_kind = request.resource_kind.trim(); + let resource_id = request.resource_id.trim(); + if workspace_id.is_empty() || resource_kind.is_empty() || resource_id.is_empty() { + return Err(WebError::bad_request_code( + "share_link_resource_required", + "必须提供 workspaceId、resourceKind 和 resourceId", + )); + } + let permission = normalize_share_grant_permission(&request.permission)?; + let created = state + .control_plane() + .create_share_link(CreateShareLinkInput { + id: None, + workspace_id: workspace_id.to_string(), + resource_kind: resource_kind.to_string(), + resource_id: resource_id.to_string(), + token: request.token, + permission, + created_by: context.auth.actor_id.trim().to_string(), + expires_at: request.expires_at, + }) + .map_err(|error| WebError::internal(format!("SQLite 分享链接写入失败: {error}")))?; + let _ = state.control_plane().append_audit(AppendAuditInput { + actor_user_id: Some(context.auth.actor_id.trim().to_string()), + action: "control.share.created".to_string(), + target_kind: "share_link".to_string(), + target_id: Some(created.link.id.clone()), + metadata_json: serde_json::to_string(&json!({ + "workspaceId": created.link.workspace_id, + "resourceKind": created.link.resource_kind, + "resourceId": created.link.resource_id, + "permission": created.link.permission, + })) + .unwrap_or_else(|_| "{}".to_string()), + }); + append_control_plane_outbox_event( + state, + "control.share.created", + json!({ + "link": share_link_payload(&created.link), + }), + ); + Ok(json!({ + "ok": true, + "controlPlane": "sqlite", + "token": created.token, + "link": share_link_payload(&created.link), + })) +} + +fn revoke_sqlite_share_link_for_context( + state: &AppState, + context: &RequestContext, + link_id: &str, +) -> Result { + let _policy = require_local_access_policy_admin(context)?; + let link_id = link_id.trim(); + if link_id.is_empty() { + return Err(WebError::bad_request_code( + "share_link_id_required", + "必须提供 linkId", + )); + } + state + .control_plane() + .revoke_share_link(link_id) + .map_err(|error| match error { + control_plane::ControlPlaneError::NotFound(message) => { + WebError::new(StatusCode::NOT_FOUND, "share_link_not_found", message) + } + other => WebError::internal(format!("SQLite 分享链接撤销失败: {other}")), + })?; + let _ = state.control_plane().append_audit(AppendAuditInput { + actor_user_id: Some(context.auth.actor_id.trim().to_string()), + action: "control.share.revoked".to_string(), + target_kind: "share_link".to_string(), + target_id: Some(link_id.to_string()), + metadata_json: "{}".to_string(), + }); + append_control_plane_outbox_event( + state, + "control.share.revoked", + json!({ + "linkId": link_id, + }), + ); + Ok(json!({ + "ok": true, + "controlPlane": "sqlite", + "revokedLinkId": link_id, + })) +} + fn list_local_share_grants_for_context( context: &RequestContext, admin_required: bool, @@ -894,9 +1154,12 @@ fn list_local_share_grants_for_context( fn add_local_share_grant_for_context( context: &RequestContext, - request: LocalShareGrantRequest, + mut request: LocalShareGrantRequest, ) -> Result { require_share_grants_admin(context)?; + if request.owner_user_id.trim().is_empty() { + request.owner_user_id = context.auth.actor_id.trim().to_string(); + } add_local_share_grant_for_context_inner(context, request, true) } @@ -917,20 +1180,13 @@ fn add_user_share_grant_for_context( } fn add_local_share_grant_for_context_inner( - context: &RequestContext, + _context: &RequestContext, request: LocalShareGrantRequest, admin_mode: bool, ) -> Result { let mut store = load_local_share_grants_store()?; - let share_id = request.share_id.trim(); let owner_user_id = request.owner_user_id.trim(); let target_user_id = request.target_user_id.trim(); - if share_id.is_empty() { - return Err(WebError::bad_request_code( - "local_share_grant_share_id_required", - "必须提供 shareId", - )); - } if owner_user_id.is_empty() || owner_user_id == "anonymous" { return Err(WebError::bad_request_code( "local_share_grant_owner_required", @@ -944,8 +1200,14 @@ fn add_local_share_grant_for_context_inner( )); } let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?; + let share_id = request.share_id.trim(); + let share_id = if share_id.is_empty() { + generate_local_folder_share_id(owner_user_id, target_user_id, &canonical) + } else { + share_id.to_string() + }; if !admin_mode { - ensure_local_workspace_read_access(context, &file_uri_for_path(&canonical))?; + ensure_path_inside_owned_local_workspace(owner_user_id, &canonical)?; } let permission = normalize_share_grant_permission(&request.permission)?; let capabilities = normalize_share_grant_capabilities(&request.capabilities)?; @@ -958,7 +1220,7 @@ fn add_local_share_grant_for_context_inner( .collect::>(); let grant_id = request.id.trim(); let grant_id = if grant_id.is_empty() { - generate_local_share_grant_id(share_id, target_user_id, &canonical) + generate_local_share_grant_id(&share_id, target_user_id, &canonical) } else { grant_id.to_string() }; @@ -974,7 +1236,7 @@ fn add_local_share_grant_for_context_inner( } let grant = LocalShareGrant { id: grant_id, - share_id: share_id.to_string(), + share_id, owner_user_id: owner_user_id.to_string(), target_user_id: target_user_id.to_string(), root_uri, @@ -1361,16 +1623,83 @@ fn write_sync_conflict_report_for_context( })) } -fn local_access_policy_payload(policy: &LocalAccessPolicy) -> Value { +fn control_plane_grant_payload(grant: &DirectoryGrantRecord) -> Value { + let capabilities = serde_json::from_str::>(&grant.capabilities_json) + .unwrap_or_else(|_| Vec::new()); json!({ + "id": grant.id, + "userId": grant.user_id, + "rootUri": grant.root_uri, + "rootPath": grant.root_path, + "permission": grant.permission, + "recursive": grant.recursive, + "capabilities": capabilities, + "source": grant.source, + "status": grant.status, + "createdBy": grant.created_by, + "createdAt": grant.created_at, + "updatedAt": grant.updated_at, + "revision": grant.revision, + }) +} + +fn sqlite_access_policy_payload( + state: &AppState, + context: &RequestContext, +) -> Result { + let policy = require_local_access_policy_admin(context)?; + let grants = state + .control_plane() + .list_directory_grants() + .map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?; + let grant_values = grants + .iter() + .map(control_plane_grant_payload) + .collect::>(); + Ok(json!({ "ok": true, + "controlPlane": "sqlite", "policyPath": local_access_policy_path().display().to_string(), - "policy": policy, + "policy": { + "admins": policy.admins, + "grants": grant_values, + }, "effectiveAdmins": { - "policy": &policy.admins, + "policy": policy.admins, "env": env_local_admin_ids(), } - }) + })) +} + +fn sqlite_user_access_policy_payload( + state: &AppState, + context: &RequestContext, +) -> Result { + let actor_id = context.auth.actor_id.trim(); + if actor_id.is_empty() || actor_id == "anonymous" { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "local_access_policy_auth_required", + "查看文件夹授权需要先登录", + )); + } + let grants = state + .control_plane() + .list_directory_grants() + .map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?; + let grant_values = grants + .iter() + .filter(|grant| grant.created_by.as_deref().map(str::trim) == Some(actor_id)) + .map(control_plane_grant_payload) + .collect::>(); + Ok(json!({ + "ok": true, + "controlPlane": "sqlite", + "policy": { + "grants": grant_values, + }, + "grants": grant_values, + })) } fn validate_local_access_root_for_context( @@ -1388,11 +1717,41 @@ fn validate_local_access_root_for_context( })) } -fn add_local_access_grant_for_context( +fn add_sqlite_user_access_grant_for_context( + state: &AppState, + context: &RequestContext, + mut request: LocalAccessGrantRequest, +) -> Result { + let actor_id = context.auth.actor_id.trim(); + if actor_id.is_empty() || actor_id == "anonymous" { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "local_access_policy_auth_required", + "创建文件夹授权需要先登录", + )); + } + let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?; + ensure_path_inside_owned_local_workspace(actor_id, &canonical)?; + request.root_uri = file_uri_for_path(&canonical); + request.root_path = canonical.display().to_string(); + add_sqlite_local_access_grant_for_context_inner(state, context, request, "user") +} + +fn add_sqlite_local_access_grant_for_context( + state: &AppState, context: &RequestContext, request: LocalAccessGrantRequest, ) -> Result { - let mut policy = require_local_access_policy_admin(context)?; + let _policy = require_local_access_policy_admin(context)?; + add_sqlite_local_access_grant_for_context_inner(state, context, request, "admin") +} + +fn add_sqlite_local_access_grant_for_context_inner( + state: &AppState, + context: &RequestContext, + request: LocalAccessGrantRequest, + source: &str, +) -> Result { let user_id = request.user_id.trim(); if user_id.is_empty() || user_id == "anonymous" { return Err(WebError::bad_request_code( @@ -1404,62 +1763,91 @@ fn add_local_access_grant_for_context( let permission = normalize_local_access_permission(&request.permission)?; let capabilities = normalize_local_access_capabilities(&request.capabilities)?; let root_uri = file_uri_for_path(&canonical); - let grant_id = request.id.trim(); - let grant_id = if grant_id.is_empty() { - generate_local_access_grant_id(user_id, &canonical) - } else { - grant_id.to_string() - }; - if policy - .grants - .iter() - .any(|grant| grant.id.trim() == grant_id) - { - return Err(WebError::bad_request_code( - "local_access_policy_grant_id_exists", - "授权 grantId 已存在", - )); - } - if policy.grants.iter().any(|grant| { - grant.user_id.trim() == user_id - && grant.root_uri.trim() == root_uri - && grant.permission.trim() == permission + let duplicates = state + .control_plane() + .find_directory_grants(DirectoryGrantLookup { + grant_id: None, + user_id: Some(user_id.to_string()), + root_uri: Some(root_uri.clone()), + include_revoked: false, + }) + .map_err(|error| WebError::internal(format!("SQLite 控制面授权查重失败: {error}")))?; + if duplicates.iter().any(|grant| { + grant.permission.trim() == permission && grant.recursive == request.recursive - && { - let existing = - normalize_local_access_capabilities(&grant.capabilities).unwrap_or_default(); - existing == capabilities - } + && serde_json::from_str::>(&grant.capabilities_json).unwrap_or_default() + == capabilities }) { return Err(WebError::bad_request_code( "local_access_policy_grant_duplicate", "相同目录授权已存在", )); } - let grant = LocalAccessGrant { - id: grant_id, - user_id: user_id.to_string(), - root_uri, - root_path: canonical.display().to_string(), - permission, - recursive: request.recursive, - capabilities, - }; - policy.grants.push(grant.clone()); - write_local_access_policy(&policy)?; + let grant = state + .control_plane() + .grant_directory_access(DirectoryGrantInput { + user_id: user_id.to_string(), + workspace_id: None, + root_uri, + root_path: canonical.display().to_string(), + permission, + recursive: request.recursive, + capabilities, + source: source.to_string(), + created_by: Some(context.auth.actor_id.trim().to_string()), + }) + .map_err(|error| WebError::internal(format!("SQLite 控制面授权写入失败: {error}")))?; + let _ = state.control_plane().append_audit(AppendAuditInput { + actor_user_id: Some(context.auth.actor_id.trim().to_string()), + action: "control.grant.created".to_string(), + target_kind: "directory_grant".to_string(), + target_id: Some(grant.id.clone()), + metadata_json: serde_json::to_string(&json!({ + "userId": grant.user_id, + "rootUri": grant.root_uri, + "permission": grant.permission, + })) + .unwrap_or_else(|_| "{}".to_string()), + }); + append_control_plane_outbox_event( + state, + "control.grant.created", + json!({ + "grant": control_plane_grant_payload(&grant), + }), + ); + let grants = state + .control_plane() + .list_directory_grants() + .map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?; + let grant_values = grants + .iter() + .map(control_plane_grant_payload) + .collect::>(); Ok(json!({ "ok": true, + "controlPlane": "sqlite", "policyPath": local_access_policy_path().display().to_string(), - "grant": grant, - "policy": policy, + "grant": control_plane_grant_payload(&grant), + "policy": { + "grants": grant_values, + }, })) } -fn delete_local_access_grant_for_context( +fn delete_sqlite_user_access_grant_for_context( + state: &AppState, context: &RequestContext, grant_id: &str, ) -> Result { - let mut policy = require_local_access_policy_admin(context)?; + let actor_id = context.auth.actor_id.trim(); + if actor_id.is_empty() || actor_id == "anonymous" { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "local_access_policy_auth_required", + "撤销文件夹授权需要先登录", + )); + } let grant_id = grant_id.trim(); if grant_id.is_empty() { return Err(WebError::bad_request_code( @@ -1467,21 +1855,123 @@ fn delete_local_access_grant_for_context( "必须提供 grantId", )); } - let before = policy.grants.len(); - policy.grants.retain(|grant| grant.id.trim() != grant_id); - if policy.grants.len() == before { - return Err(WebError::new( + let grants = state + .control_plane() + .find_directory_grants(DirectoryGrantLookup { + grant_id: Some(grant_id.to_string()), + user_id: None, + root_uri: None, + include_revoked: false, + }) + .map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?; + let grant = grants.first().ok_or_else(|| { + WebError::new( StatusCode::NOT_FOUND, "local_access_policy_grant_not_found", - "未找到要删除的目录授权", + "目录授权不存在或不属于当前用户", + ) + })?; + if grant.created_by.as_deref().map(str::trim) != Some(actor_id) { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "local_access_policy_grant_owner_required", + "普通用户只能撤销自己创建的文件夹授权", )); } - write_local_access_policy(&policy)?; + state + .control_plane() + .revoke_directory_grant(grant_id, None) + .map_err(|error| match error { + control_plane::ControlPlaneError::NotFound(message) => WebError::new( + StatusCode::NOT_FOUND, + "local_access_policy_grant_not_found", + message, + ), + other => WebError::internal(format!("SQLite 控制面授权撤销失败: {other}")), + })?; + let _ = state.control_plane().append_audit(AppendAuditInput { + actor_user_id: Some(actor_id.to_string()), + action: "control.grant.revoked".to_string(), + target_kind: "directory_grant".to_string(), + target_id: Some(grant_id.to_string()), + metadata_json: "{}".to_string(), + }); + append_control_plane_outbox_event( + state, + "control.grant.revoked", + json!({ + "grantId": grant_id, + }), + ); Ok(json!({ "ok": true, + "controlPlane": "sqlite", + "deletedGrantId": grant_id, + "policy": { + "grants": state + .control_plane() + .list_directory_grants() + .map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))? + .iter() + .filter(|grant| grant.created_by.as_deref().map(str::trim) == Some(actor_id)) + .map(control_plane_grant_payload) + .collect::>(), + }, + })) +} + +fn delete_sqlite_local_access_grant_for_context( + state: &AppState, + context: &RequestContext, + grant_id: &str, +) -> Result { + let _policy = require_local_access_policy_admin(context)?; + let grant_id = grant_id.trim(); + if grant_id.is_empty() { + return Err(WebError::bad_request_code( + "local_access_policy_grant_id_required", + "必须提供 grantId", + )); + } + state + .control_plane() + .revoke_directory_grant(grant_id, None) + .map_err(|error| match error { + control_plane::ControlPlaneError::NotFound(message) => WebError::new( + StatusCode::NOT_FOUND, + "local_access_policy_grant_not_found", + message, + ), + other => WebError::internal(format!("SQLite 控制面授权撤销失败: {other}")), + })?; + let _ = state.control_plane().append_audit(AppendAuditInput { + actor_user_id: Some(context.auth.actor_id.trim().to_string()), + action: "control.grant.revoked".to_string(), + target_kind: "directory_grant".to_string(), + target_id: Some(grant_id.to_string()), + metadata_json: "{}".to_string(), + }); + append_control_plane_outbox_event( + state, + "control.grant.revoked", + json!({ + "grantId": grant_id, + }), + ); + Ok(json!({ + "ok": true, + "controlPlane": "sqlite", "policyPath": local_access_policy_path().display().to_string(), "deletedGrantId": grant_id, - "policy": policy, + "policy": { + "grants": state + .control_plane() + .list_directory_grants() + .map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))? + .iter() + .map(control_plane_grant_payload) + .collect::>(), + }, })) } @@ -1657,11 +2147,21 @@ pub async fn create_default_local_workspace( } pub async fn get_local_access_policy( + State(state): State, Extension(context): Extension, ) -> Result<(StatusCode, Json), WebError> { - let policy = require_local_access_policy_admin(&context) + let payload = sqlite_access_policy_payload(&state, &context) .map_err(|error| error.with_context(&context))?; - Ok((StatusCode::OK, Json(local_access_policy_payload(&policy)))) + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn get_user_access_policy( + State(state): State, + Extension(context): Extension, +) -> Result<(StatusCode, Json), WebError> { + let payload = sqlite_user_access_policy_payload(&state, &context) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) } pub async fn validate_local_access_root( @@ -1674,19 +2174,41 @@ pub async fn validate_local_access_root( } pub async fn create_local_access_grant( + State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result<(StatusCode, Json), WebError> { - let payload = add_local_access_grant_for_context(&context, request) + let payload = add_sqlite_local_access_grant_for_context(&state, &context, request) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn create_user_access_grant( + State(state): State, + Extension(context): Extension, + Json(request): Json, +) -> Result<(StatusCode, Json), WebError> { + let payload = add_sqlite_user_access_grant_for_context(&state, &context, request) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn delete_local_access_grant( + State(state): State, Extension(context): Extension, AxumPath(grant_id): AxumPath, ) -> Result<(StatusCode, Json), WebError> { - let payload = delete_local_access_grant_for_context(&context, &grant_id) + let payload = delete_sqlite_local_access_grant_for_context(&state, &context, &grant_id) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn delete_user_access_grant( + State(state): State, + Extension(context): Extension, + AxumPath(grant_id): AxumPath, +) -> Result<(StatusCode, Json), WebError> { + let payload = delete_sqlite_user_access_grant_for_context(&state, &context, &grant_id) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } @@ -1707,6 +2229,36 @@ pub async fn get_user_share_grants( Ok((StatusCode::OK, Json(payload))) } +pub async fn get_share_links( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, Json), WebError> { + let payload = list_sqlite_share_links_for_context(&state, &context, query) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn create_share_link( + State(state): State, + Extension(context): Extension, + Json(request): Json, +) -> Result<(StatusCode, Json), WebError> { + let payload = create_sqlite_share_link_for_context(&state, &context, request) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + +pub async fn delete_share_link( + State(state): State, + Extension(context): Extension, + AxumPath(link_id): AxumPath, +) -> Result<(StatusCode, Json), WebError> { + let payload = revoke_sqlite_share_link_for_context(&state, &context, &link_id) + .map_err(|error| error.with_context(&context))?; + Ok((StatusCode::OK, Json(payload))) +} + pub async fn create_share_grant( Extension(context): Extension, Json(request): Json, @@ -2464,11 +3016,12 @@ pub fn write_local_markdown_page_body( } pub async fn upload_local_markdown_asset( + State(state): State, Extension(context): Extension, multipart: Multipart, ) -> Result<(StatusCode, Json), WebError> { let fields = read_local_asset_upload_multipart(multipart).await?; - ensure_local_workspace_access(&context, &fields.root_uri) + ensure_local_workspace_write_access_with_state(&state, &context, &fields.root_uri) .map_err(|error| error.with_context(&context))?; let asset = if fields.target_relative_path.is_some() || fields.document_id.trim().is_empty() { write_local_folder_file_upload( @@ -2489,10 +3042,11 @@ pub async fn upload_local_markdown_asset( } pub async fn open_local_file( + State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Vec), WebError> { - ensure_local_workspace_read_access(&context, &query.root_uri) + ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri) .map_err(|error| error.with_context(&context))?; let target = resolve_local_open_path(&query.root_uri, &query.path)?; let mut headers = HeaderMap::new(); @@ -2539,10 +3093,11 @@ pub async fn open_local_file( } pub async fn read_local_resource( + State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { - ensure_local_workspace_read_access(&context, &query.root_uri) + ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri) .map_err(|error| error.with_context(&context))?; let root = parse_file_root_uri(&query.root_uri)? .canonicalize() @@ -2590,6 +3145,7 @@ pub async fn read_local_resource( } pub async fn write_local_resource( + State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { @@ -2602,7 +3158,7 @@ pub async fn write_local_resource( ) .with_context(&context)); } - ensure_local_workspace_access(&context, root_uri) + ensure_local_workspace_write_access_with_state(&state, &context, root_uri) .map_err(|error| error.with_context(&context))?; let root = parse_file_root_uri(root_uri)? .canonicalize() @@ -7426,26 +7982,34 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val #[cfg(test)] mod tests { use super::{ - add_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base, - create_local_access_grant, create_share_grant, editor_blocks_to_markdown_for_file, + add_sqlite_local_access_grant_for_context, + create_default_local_workspace_for_actor_at_base, create_local_access_grant, + create_share_grant, create_share_link, create_user_access_grant, create_user_share_grant, + delete_share_link, delete_user_access_grant, editor_blocks_to_markdown_for_file, encode_local_id_segment, ensure_local_path_read_access, ensure_local_workspace_access_for_actor, ensure_local_workspace_read_access_for_actor, - execute_local_tree_command, execute_local_tree_command_with_sort, get_local_access_policy, - get_share_grants, initialize_local_page_id, initialize_local_workspace_for_actor, - load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, - local_folder_watch_revision, local_markdown_path_page_id, - local_resource_write_editor_blocks, local_workspace_id, open_local_file, - record_shared_cache, record_sync_pending_change, resolve_local_markdown_page_aggregate, - save_local_markdown_page, update_local_markdown_title, validate_local_access_root, - write_local_markdown_asset, write_local_markdown_page_body, write_local_mindmap_data, + ensure_local_workspace_read_access_with_state, + ensure_local_workspace_write_access_with_state, execute_local_tree_command, + execute_local_tree_command_with_sort, get_local_access_policy, get_share_grants, + get_share_links, get_user_access_policy, initialize_local_page_id, + initialize_local_workspace_for_actor, load_local_folder_file_tree_snapshot, + load_local_folder_page_tree_snapshot, local_folder_watch_revision, + local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id, + open_local_file, record_shared_cache, record_sync_pending_change, + resolve_local_markdown_page_aggregate, save_local_markdown_page, + update_local_markdown_title, validate_local_access_root, write_local_markdown_asset, + write_local_markdown_page_body, write_local_mindmap_data, write_local_resource, write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest, - LocalFileOpenQuery, LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile, - SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest, + LocalFileOpenQuery, LocalResourceWriteRequest, LocalShareGrantRequest, + LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest, + SyncConflictReportRequest, SyncPendingChangeRequest, }; + use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; - use axum::extract::{Extension, Path as AxumPath, Query}; + use axum::extract::{Extension, Path as AxumPath, Query, State}; use axum::http::{HeaderMap, Method, StatusCode}; use axum::Json; + use control_plane::{DirectoryGrantInput, UpsertUserInput}; use serde_json::Value; use std::sync::Mutex; @@ -7471,6 +8035,29 @@ mod tests { ) } + fn test_state() -> AppState { + AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: None, + enable_legacy_next_compat: false, + enable_debug_shell_routes: false, + enable_editor_actor: true, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: false, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }) + } + fn init_workspace(root: &std::path::Path) { initialize_local_workspace_for_actor("user_test", &format!("file://{}", root.display())) .expect("init local workspace"); @@ -8423,9 +9010,12 @@ fn main() {} let policy_file = policy_root.join("access-policy.json"); std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); - let non_admin_error = get_local_access_policy(Extension(request_context("user_1", "user"))) - .await - .expect_err("non-admin cannot read access policy"); + let non_admin_error = get_local_access_policy( + State(test_state()), + Extension(request_context("user_1", "user")), + ) + .await + .expect_err("non-admin cannot read access policy"); assert_eq!(non_admin_error.status(), StatusCode::FORBIDDEN); std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); @@ -8471,10 +9061,35 @@ fn main() {} let policy_root = temp_root("mnote-local-access-policy-grant-config"); let policy_file = policy_root.join("access-policy.json"); let root_uri = format!("file://{}", root.display()); + let state = test_state(); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("admin_1".into()), + email: Some("admin-1@example.com".into()), + username: "admin_1".into(), + display_name: "admin_1".into(), + role: Some("admin".into()), + password_hash: None, + }) + .expect("upsert admin"); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("user_reader".into()), + email: Some("user-reader@example.com".into()), + username: "user_reader".into(), + display_name: "user_reader".into(), + role: None, + password_hash: None, + }) + .expect("upsert reader"); std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); + let mut control_delta_rx = state.stream_delta_tx.subscribe(); let (_, Json(created)) = create_local_access_grant( + State(state.clone()), Extension(request_context("admin_1", "user")), Json(LocalAccessGrantRequest { id: "grant_reader".into(), @@ -8488,15 +9103,39 @@ fn main() {} ) .await .expect("admin can create read grant"); - assert_eq!(created["grant"]["id"], "grant_reader"); + let created_grant_id = created["grant"]["id"] + .as_str() + .expect("created grant id") + .to_string(); assert_eq!(created["grant"]["permission"], "read"); - ensure_local_workspace_read_access_for_actor("user_reader", "user", &root_uri) - .expect("created read grant can read"); - let write_error = ensure_local_workspace_access_for_actor("user_reader", "user", &root_uri) - .expect_err("created read grant cannot write"); + assert_eq!(created["controlPlane"], "sqlite"); + let created_events = state + .control_plane() + .drain_outbox(10) + .expect("drain created event"); + assert_eq!(created_events.len(), 1); + assert_eq!(created_events[0].event_type, "control.grant.created"); + let created_delta = control_delta_rx + .try_recv() + .expect("grant created broadcast delta"); + assert_eq!(created_delta["kind"], "control_plane_event"); + assert_eq!(created_delta["eventType"], "control.grant.created"); + ensure_local_workspace_read_access_with_state( + &state, + &request_context("user_reader", "user"), + &root_uri, + ) + .expect("created read grant can read"); + let write_error = ensure_local_workspace_write_access_with_state( + &state, + &request_context("user_reader", "user"), + &root_uri, + ) + .expect_err("created read grant cannot write"); assert_eq!(write_error.status(), StatusCode::FORBIDDEN); - let duplicate_error = add_local_access_grant_for_context( + let duplicate_error = add_sqlite_local_access_grant_for_context( + &state, &request_context("admin_1", "user"), LocalAccessGrantRequest { id: "grant_reader".into(), @@ -8512,15 +9151,30 @@ fn main() {} assert_eq!(duplicate_error.status(), StatusCode::BAD_REQUEST); let (_, Json(deleted)) = super::delete_local_access_grant( + State(state.clone()), Extension(request_context("admin_1", "user")), - AxumPath("grant_reader".into()), + AxumPath(created_grant_id.clone()), ) .await .expect("admin can delete grant"); - assert_eq!(deleted["deletedGrantId"], "grant_reader"); - let read_error = - ensure_local_workspace_read_access_for_actor("user_reader", "user", &root_uri) - .expect_err("deleted grant should remove read access"); + assert_eq!(deleted["deletedGrantId"], created_grant_id); + let revoked_events = state + .control_plane() + .drain_outbox(10) + .expect("drain revoked event"); + assert_eq!(revoked_events.len(), 1); + assert_eq!(revoked_events[0].event_type, "control.grant.revoked"); + let revoked_delta = control_delta_rx + .try_recv() + .expect("grant revoked broadcast delta"); + assert_eq!(revoked_delta["kind"], "control_plane_event"); + assert_eq!(revoked_delta["eventType"], "control.grant.revoked"); + let read_error = ensure_local_workspace_read_access_with_state( + &state, + &request_context("user_reader", "user"), + &root_uri, + ) + .expect_err("deleted grant should remove read access"); assert_eq!(read_error.status(), StatusCode::FORBIDDEN); std::env::remove_var("MNOTE_ADMIN_USER_IDS"); @@ -8608,6 +9262,316 @@ fn main() {} let _ = std::fs::remove_dir_all(&config_root); } + #[tokio::test] + async fn user_share_grant_only_allows_owned_workspace_folder() { + let _guard = env_lock().lock().expect("env lock"); + let owned_root = temp_root("mnote-user-folder-grant-owned-root"); + let outside_root = temp_root("mnote-user-folder-grant-outside-root"); + let config_root = temp_root("mnote-user-folder-grant-config-root"); + let share_grants_file = config_root.join("share-grants.json"); + std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); + std::fs::create_dir_all(owned_root.join("docs")).expect("owned docs"); + initialize_local_workspace_for_actor( + "user_owner", + &format!("file://{}", owned_root.display()), + ) + .expect("init owned workspace"); + + let (_, Json(created)) = create_user_share_grant( + Extension(request_context("user_owner", "user")), + Json(LocalShareGrantRequest { + target_user_id: "user_target".into(), + root_path: owned_root.join("docs").display().to_string(), + permission: "write".into(), + ..Default::default() + }), + ) + .await + .expect("user can grant owned folder"); + assert_eq!(created["grant"]["ownerUserId"], "user_owner"); + assert_eq!(created["grant"]["targetUserId"], "user_target"); + assert_eq!(created["grant"]["permission"], "write"); + assert!(created["grant"]["shareId"] + .as_str() + .unwrap_or_default() + .starts_with("folder_")); + + let outside_error = create_user_share_grant( + Extension(request_context("user_owner", "user")), + Json(LocalShareGrantRequest { + target_user_id: "user_target_2".into(), + root_path: outside_root.display().to_string(), + permission: "read".into(), + ..Default::default() + }), + ) + .await + .expect_err("user cannot grant outside owned workspace"); + assert_eq!(outside_error.status(), StatusCode::FORBIDDEN); + + std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); + let _ = std::fs::remove_dir_all(&owned_root); + let _ = std::fs::remove_dir_all(&outside_root); + let _ = std::fs::remove_dir_all(&config_root); + } + + #[tokio::test] + async fn user_access_policy_grant_uses_sqlite_directory_grants_for_owned_folder() { + let _guard = env_lock().lock().expect("env lock"); + let owned_root = temp_root("mnote-user-access-policy-owned-root"); + let outside_root = temp_root("mnote-user-access-policy-outside-root"); + std::fs::create_dir_all(owned_root.join("docs")).expect("owned docs"); + initialize_local_workspace_for_actor( + "user_owner", + &format!("file://{}", owned_root.display()), + ) + .expect("init owned workspace"); + let state = test_state(); + for user_id in ["user_owner", "user_target"] { + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some(user_id.into()), + email: Some(format!("{user_id}@example.com")), + username: user_id.into(), + display_name: user_id.into(), + role: None, + password_hash: None, + }) + .expect("upsert test user"); + } + + let (_, Json(created)) = create_user_access_grant( + State(state.clone()), + Extension(request_context("user_owner", "user")), + Json(LocalAccessGrantRequest { + user_id: "user_target".into(), + root_path: owned_root.join("docs").display().to_string(), + permission: "write".into(), + recursive: true, + ..Default::default() + }), + ) + .await + .expect("user can grant owned folder to target user"); + let grant_id = created["grant"]["id"] + .as_str() + .expect("grant id") + .to_string(); + assert_eq!(created["controlPlane"], "sqlite"); + assert_eq!(created["grant"]["userId"], "user_target"); + assert_eq!(created["grant"]["createdBy"], "user_owner"); + assert_eq!(created["grant"]["permission"], "write"); + assert_eq!(created["grant"]["source"], "user"); + + let docs_uri = format!("file://{}", owned_root.join("docs").display()); + ensure_local_workspace_write_access_with_state( + &state, + &request_context("user_target", "user"), + &docs_uri, + ) + .expect("target user can write granted folder"); + + let (_, Json(listed)) = get_user_access_policy( + State(state.clone()), + Extension(request_context("user_owner", "user")), + ) + .await + .expect("owner can list self-created directory grants"); + assert_eq!(listed["controlPlane"], "sqlite"); + assert_eq!(listed["grants"][0]["id"], grant_id); + assert_eq!(listed["grants"][0]["permission"], "write"); + + let target_delete_error = delete_user_access_grant( + State(state.clone()), + Extension(request_context("user_target", "user")), + AxumPath(grant_id.clone()), + ) + .await + .expect_err("target cannot revoke grant created by owner"); + assert_eq!(target_delete_error.status(), StatusCode::FORBIDDEN); + + let (_, Json(deleted)) = delete_user_access_grant( + State(state.clone()), + Extension(request_context("user_owner", "user")), + AxumPath(grant_id.clone()), + ) + .await + .expect("owner can revoke self-created grant"); + assert_eq!(deleted["deletedGrantId"], grant_id); + let revoked_error = ensure_local_workspace_write_access_with_state( + &state, + &request_context("user_target", "user"), + &docs_uri, + ) + .expect_err("revoked grant removes target write access"); + assert_eq!(revoked_error.status(), StatusCode::FORBIDDEN); + + let outside_error = create_user_access_grant( + State(state.clone()), + Extension(request_context("user_owner", "user")), + Json(LocalAccessGrantRequest { + user_id: "user_target".into(), + root_path: outside_root.display().to_string(), + permission: "read".into(), + recursive: true, + ..Default::default() + }), + ) + .await + .expect_err("user cannot grant folder outside owned workspace"); + assert_eq!(outside_error.status(), StatusCode::FORBIDDEN); + + let _ = std::fs::remove_dir_all(&owned_root); + let _ = std::fs::remove_dir_all(&outside_root); + } + + #[tokio::test] + async fn admin_share_grant_allows_any_local_folder_with_minimal_fields() { + let _guard = env_lock().lock().expect("env lock"); + let root = temp_root("mnote-admin-folder-grant-root"); + let config_root = temp_root("mnote-admin-folder-grant-config-root"); + let share_grants_file = config_root.join("share-grants.json"); + std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); + std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); + + let (_, Json(created)) = create_share_grant( + Extension(request_context("admin_1", "user")), + Json(LocalShareGrantRequest { + target_user_id: "user_target".into(), + root_path: root.display().to_string(), + permission: "read".into(), + ..Default::default() + }), + ) + .await + .expect("admin can grant any local folder"); + assert_eq!(created["grant"]["ownerUserId"], "admin_1"); + assert_eq!(created["grant"]["targetUserId"], "user_target"); + assert_eq!(created["grant"]["permission"], "read"); + + std::env::remove_var("MNOTE_ADMIN_USER_IDS"); + std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&config_root); + } + + #[tokio::test] + async fn share_link_api_creates_lists_and_revokes_sqlite_record() { + let _guard = env_lock().lock().expect("env lock"); + let state = test_state(); + std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("admin_1".into()), + email: Some("admin-1@example.com".into()), + username: "admin_1".into(), + display_name: "admin_1".into(), + role: Some("admin".into()), + password_hash: None, + }) + .expect("upsert admin"); + let workspace = state + .control_plane() + .ensure_default_workspace("admin_1") + .expect("default workspace"); + let mut control_delta_rx = state.stream_delta_tx.subscribe(); + + let (_, Json(created)) = create_share_link( + State(state.clone()), + Extension(request_context("admin_1", "user")), + Json(LocalShareLinkRequest { + workspace_id: workspace.id.clone(), + resource_kind: "page".into(), + resource_id: "local-md:README.md".into(), + token: Some("visible-token".into()), + permission: "read".into(), + expires_at: None, + }), + ) + .await + .expect("admin can create share link"); + let link_id = created["link"]["id"].as_str().expect("link id").to_string(); + assert_eq!(created["controlPlane"], "sqlite"); + assert_eq!(created["token"], "visible-token"); + assert!(created["link"].get("tokenHash").is_none()); + assert_eq!(created["link"]["permission"], "read"); + let created_events = state + .control_plane() + .drain_outbox(10) + .expect("drain created event"); + assert_eq!(created_events.len(), 1); + assert_eq!(created_events[0].event_type, "control.share.created"); + let created_delta = control_delta_rx + .try_recv() + .expect("share created broadcast delta"); + assert_eq!(created_delta["kind"], "control_plane_event"); + assert_eq!(created_delta["eventType"], "control.share.created"); + + let stored = state + .control_plane() + .list_share_links(&workspace.id) + .expect("list sqlite share links"); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].id, link_id); + assert_ne!(stored[0].token_hash, "visible-token"); + assert!(state + .control_plane() + .resolve_share_link(&control_plane::share_token_hash_v1("visible-token")) + .expect("resolve share link") + .is_some()); + + let (_, Json(listed)) = get_share_links( + State(state.clone()), + Extension(request_context("admin_1", "user")), + Query(ShareLinkListQuery { + workspace_id: workspace.id.clone(), + }), + ) + .await + .expect("admin can list share links"); + assert_eq!(listed["links"][0]["id"], link_id); + assert!(listed["links"][0].get("tokenHash").is_none()); + + let (_, Json(revoked)) = delete_share_link( + State(state.clone()), + Extension(request_context("admin_1", "user")), + AxumPath(link_id.clone()), + ) + .await + .expect("admin can revoke share link"); + assert_eq!(revoked["revokedLinkId"], link_id); + let revoked_events = state + .control_plane() + .drain_outbox(10) + .expect("drain revoked event"); + assert_eq!(revoked_events.len(), 1); + assert_eq!(revoked_events[0].event_type, "control.share.revoked"); + let revoked_delta = control_delta_rx + .try_recv() + .expect("share revoked broadcast delta"); + assert_eq!(revoked_delta["kind"], "control_plane_event"); + assert_eq!(revoked_delta["eventType"], "control.share.revoked"); + assert!(state + .control_plane() + .resolve_share_link(&control_plane::share_token_hash_v1("visible-token")) + .expect("resolve revoked share link") + .is_none()); + assert_eq!( + state + .control_plane() + .list_audit_log(10) + .expect("audit log") + .iter() + .filter(|entry| entry.action.starts_with("control.share.")) + .count(), + 2 + ); + + std::env::remove_var("MNOTE_ADMIN_USER_IDS"); + } + #[tokio::test] async fn shared_cache_and_sync_state_record_permissions_and_conflict_report() { let _guard = env_lock().lock().expect("env lock"); @@ -9298,7 +10262,7 @@ fn main() {} download: None, }; - let error = open_local_file(Extension(context), Query(query)) + let error = open_local_file(State(test_state()), Extension(context), Query(query)) .await .expect_err("should reject root escape"); assert_eq!(error.status(), StatusCode::BAD_REQUEST); @@ -9342,6 +10306,7 @@ fn main() {} ); let (_, _, bytes) = open_local_file( + State(test_state()), Extension(context), Query(LocalFileOpenQuery { root_uri, @@ -9358,6 +10323,137 @@ fn main() {} let _ = std::fs::remove_dir_all(&policy_root); } + #[tokio::test] + async fn local_file_open_allows_sqlite_directory_read_grant_without_json_policy() { + let _guard = env_lock().lock().expect("env lock"); + let root = temp_root("mnote-local-file-open-sqlite-read-grant-root"); + let policy_root = temp_root("mnote-local-file-open-sqlite-read-grant-config"); + let policy_file = policy_root.join("missing-access-policy.json"); + let state = test_state(); + let root_uri = format!("file://{}", root.display()); + std::fs::write(root.join("README.txt"), "hello sqlite").expect("write file"); + std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("sqlite_reader".into()), + email: Some("sqlite-reader@example.com".into()), + username: "sqlite_reader".into(), + display_name: "sqlite_reader".into(), + role: None, + password_hash: None, + }) + .expect("upsert sqlite reader"); + state + .control_plane() + .grant_directory_access(DirectoryGrantInput { + user_id: "sqlite_reader".into(), + workspace_id: None, + root_uri: root_uri.clone(), + root_path: root + .canonicalize() + .expect("canonical root") + .display() + .to_string(), + permission: "read".into(), + recursive: true, + capabilities: vec![], + source: "test".into(), + created_by: None, + }) + .expect("grant sqlite read"); + + let context = request_context("sqlite_reader", "user"); + let (_, _, bytes) = open_local_file( + State(state), + Extension(context), + Query(LocalFileOpenQuery { + root_uri, + path: "README.txt".into(), + download: None, + }), + ) + .await + .expect("sqlite read grant can open local file"); + assert_eq!(bytes, b"hello sqlite"); + assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy"); + + std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&policy_root); + } + + #[tokio::test] + async fn local_resource_write_allows_sqlite_directory_write_grant_without_json_policy() { + let _guard = env_lock().lock().expect("env lock"); + let root = temp_root("mnote-local-resource-write-sqlite-grant-root"); + let policy_root = temp_root("mnote-local-resource-write-sqlite-grant-config"); + let policy_file = policy_root.join("missing-access-policy.json"); + let state = test_state(); + let root_uri = format!("file://{}", root.display()); + std::fs::create_dir_all(root.join(".mnote")).expect("create metadata"); + std::fs::write(root.join("README.md"), "old").expect("seed file"); + std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("sqlite_writer".into()), + email: Some("sqlite-writer@example.com".into()), + username: "sqlite_writer".into(), + display_name: "sqlite_writer".into(), + role: None, + password_hash: None, + }) + .expect("upsert sqlite writer"); + state + .control_plane() + .grant_directory_access(DirectoryGrantInput { + user_id: "sqlite_writer".into(), + workspace_id: None, + root_uri: root_uri.clone(), + root_path: root + .canonicalize() + .expect("canonical root") + .display() + .to_string(), + permission: "write".into(), + recursive: true, + capabilities: vec![], + source: "test".into(), + created_by: None, + }) + .expect("grant sqlite write"); + + let context = request_context("sqlite_writer", "user"); + let (_, _, payload) = write_local_resource( + State(state), + Extension(context), + Json(LocalResourceWriteRequest { + root_uri: root_uri.clone(), + path: "README.md".into(), + expected_file_version: None, + content_format: Some("editorBlocks".into()), + content: serde_json::json!([ + { + "type": "paragraph", + "content": [{"type": "text", "text": "new content"}] + } + ]), + tiptap_document: None, + }), + ) + .await + .expect("sqlite write grant can write local resource"); + assert_eq!(payload["ok"], true); + let written = std::fs::read_to_string(root.join("README.md")).expect("read file"); + assert!(written.contains("new content")); + assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy"); + + std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&policy_root); + } + #[tokio::test] async fn local_file_open_sets_image_and_markdown_content_type() { let root = temp_root("mnote-local-file-open-content-type"); @@ -9375,6 +10471,7 @@ fn main() {} ); let (_, image_headers, _) = open_local_file( + State(test_state()), Extension(context.clone()), Query(LocalFileOpenQuery { root_uri: root_uri.clone(), @@ -9392,6 +10489,7 @@ fn main() {} ); let (_, markdown_headers, _) = open_local_file( + State(test_state()), Extension(context), Query(LocalFileOpenQuery { root_uri, @@ -9428,6 +10526,7 @@ fn main() {} ); let (_, pdf_headers, _) = open_local_file( + State(test_state()), Extension(context.clone()), Query(LocalFileOpenQuery { root_uri: root_uri.clone(), @@ -9445,6 +10544,7 @@ fn main() {} ); let (_, office_headers, _) = open_local_file( + State(test_state()), Extension(context), Query(LocalFileOpenQuery { root_uri, @@ -9480,6 +10580,7 @@ fn main() {} ); let (_, response_headers, _) = open_local_file( + State(test_state()), Extension(context), Query(LocalFileOpenQuery { root_uri, @@ -9522,6 +10623,7 @@ fn main() {} ); let (_, response_headers, bytes) = open_local_file( + State(test_state()), Extension(context), Query(LocalFileOpenQuery { root_uri, diff --git a/rust/crates/mnote-web/src/routes/mindmap_shell.rs b/rust/crates/mnote-web/src/routes/mindmap_shell.rs index 73b73409..89cc9ad9 100644 --- a/rust/crates/mnote-web/src/routes/mindmap_shell.rs +++ b/rust/crates/mnote-web/src/routes/mindmap_shell.rs @@ -1,6 +1,7 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; +use crate::routes::gateway::default_workspace_name_for_context; use crate::routes::web_shell::{ build_page_aggregate_snapshot, load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection, render_local_file_tree_html, render_local_sidebar_tree_html, @@ -30,7 +31,7 @@ pub async fn mindmap_object_shell( Path((doc_id, mindmap_id)): Path<(String, String)>, Query(query): Query, ) -> Result { - let default_workspace_name = format!("{} 的空间", state.config().dev_user_name); + let default_workspace_name = default_workspace_name_for_context(&state, &context); let source_kind = query.source_kind.as_deref(); let root_uri = query.root_uri.as_deref(); let aggregate = build_page_aggregate_snapshot( diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 6df2d230..bf79933a 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -131,6 +131,26 @@ pub fn build_router(state: AppState) -> Router { "/api/admin/access-policy/grants/{grant_id}", delete(local_folder_source::delete_local_access_grant), ) + .route( + "/api/user/access-policy", + get(local_folder_source::get_user_access_policy), + ) + .route( + "/api/user/access-policy/grants", + post(local_folder_source::create_user_access_grant), + ) + .route( + "/api/user/access-policy/grants/{grant_id}", + delete(local_folder_source::delete_user_access_grant), + ) + .route( + "/api/admin/share-links", + get(local_folder_source::get_share_links).post(local_folder_source::create_share_link), + ) + .route( + "/api/admin/share-links/{link_id}", + delete(local_folder_source::delete_share_link), + ) .route( "/api/admin/share-grants", get(local_folder_source::get_share_grants) diff --git a/rust/crates/mnote-web/src/routes/session.rs b/rust/crates/mnote-web/src/routes/session.rs index b8bb9031..53288487 100644 --- a/rust/crates/mnote-web/src/routes/session.rs +++ b/rust/crates/mnote-web/src/routes/session.rs @@ -5,10 +5,12 @@ use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use control_plane::session_token_hash; use serde::Serialize; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT"; +const COOKIE_MNOTE_SESSION: &str = "mnote_session"; const COOKIE_ACTOR_EMAIL: &str = "mnote_actor_email"; const COOKIE_ACTOR_NAME: &str = "mnote_actor_name"; @@ -65,6 +67,28 @@ pub async fn refresh_session( } fn build_session_response(state: &AppState, context: RequestContext) -> SessionResponse { + if let Some(raw_token) = context + .auth + .cookie_header + .as_deref() + .and_then(|cookies| raw_cookie_value(cookies, COOKIE_MNOTE_SESSION)) + { + let token_hash = session_token_hash(&raw_token); + if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) { + return SessionResponse { + ok: true, + owner: "mnote-web", + user_id: resolved.user.id, + email: resolved.user.email.unwrap_or_default(), + name: resolved.user.display_name, + actor_type: "user".to_string(), + auth_mode: "sqliteSession", + request_id: context.trace.request_id, + trace_id: context.trace.trace_id, + }; + } + } + let actor_id = context.auth.actor_id.trim(); let has_forwarded_actor = !actor_id.is_empty() && actor_id != "anonymous"; let actor_email = context @@ -179,6 +203,7 @@ mod tests { use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; + use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput}; use tower::util::ServiceExt; fn app() -> axum::Router { @@ -282,6 +307,75 @@ mod tests { assert!(payload.get("convexAdminKey").is_none()); } + #[tokio::test] + async fn session_prefers_sqlite_cookie_identity_over_dev_fallback() { + let state = AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + enable_editor_actor: true, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("shujuan".into()), + email: Some("shujuan@163.com".into()), + username: "shujuan".into(), + display_name: "shujuan".into(), + role: None, + password_hash: None, + }) + .expect("upsert user"); + state + .control_plane() + .create_session(CreateSessionInput { + id: None, + user_id: "shujuan".into(), + token_hash: session_token_hash("raw-session-token"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("create session"); + + let response = build_app(state) + .oneshot( + Request::builder() + .uri("/api/auth/session") + .header("cookie", "mnote_session=raw-session-token") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["owner"], "mnote-web"); + assert_eq!(payload["userId"], "shujuan"); + assert_eq!(payload["email"], "shujuan@163.com"); + assert_eq!(payload["name"], "shujuan"); + assert_eq!(payload["actorType"], "user"); + assert_eq!(payload["authMode"], "sqliteSession"); + } + #[tokio::test] async fn legacy_whoami_alias_prefers_forwarded_actor_identity() { let response = app() diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 37d57be5..b5899dab 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -7,6 +7,7 @@ use crate::routes::documents::{ load_document_content_result, load_document_meta_result, DocumentContentQuery, DocumentMetaQuery, }; +use crate::routes::gateway::default_workspace_name_for_context; use crate::routes::local_folder_source::{ ensure_local_workspace_read_access, is_local_access_policy_admin_context, load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, @@ -125,7 +126,7 @@ pub async fn document_page_shell( } else { None }; - let default_workspace_name = format!("{} 的空间", state.config().dev_user_name); + let default_workspace_name = default_workspace_name_for_context(&state, &context); let mut workspace_projection = load_workspace_shell_projection( state.config(), &context, diff --git a/rust/crates/mnote-web/src/ssr/pages/admin.rs b/rust/crates/mnote-web/src/ssr/pages/admin.rs index 8d4145a9..9e0d5734 100644 --- a/rust/crates/mnote-web/src/ssr/pages/admin.rs +++ b/rust/crates/mnote-web/src/ssr/pages/admin.rs @@ -1,267 +1,118 @@ -//! MNOTE 管理员目录授权页面组件 +//! MNOTE 文件夹授权管理组件 -use crate::ssr::pages::layout::PageLayout; use leptos::prelude::*; #[component] -pub fn AdminAccessPolicyPage( +pub fn AdminAccessPolicyPanel( #[prop(optional)] workspace_name: Option, - #[prop(optional)] policy_path: Option, #[prop(optional)] share_grants_path: Option, #[prop(optional, default = true)] is_admin: bool, + #[prop(optional, default = true)] boot_script: bool, ) -> impl IntoView { let workspace_name = workspace_name .unwrap_or_else(|| "开发用户 的空间".to_string()) .trim() .to_string(); - let policy_path = policy_path - .unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/access-policy.json".to_string()); let share_grants_path = share_grants_path - .unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/share-grants.json".to_string()); - let page_title = "授权管理".to_string(); + .unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/control-plane.db".to_string()); + let scope_text = if is_admin { + "管理员可以授权任意本地文件夹。" + } else { + "普通用户只能授权自己空间下的文件夹。" + }; view! { - -
-
-

{page_title.clone()}

-

{if is_admin { - "管理员可以管理目录授权和分享授权。" - } else { - "查看与你相关的分享授权。" - }}

-
+
+
+
+
"授权管理"
+

"文件夹授权"

+

{scope_text}

+
+
-
-
- "工作区" - {workspace_name.clone()} -
-
- "策略文件" - {if is_admin { policy_path.clone() } else { "仅管理员可见".to_string() }} -
-
- "分享授权文件" - {share_grants_path.clone()} -
-
+
+
+ "工作区" + {workspace_name.clone()} +
+
+ "授权记录" + {share_grants_path.clone()} +
+
- {if is_admin { - view! { -
-
-
-

"目录授权"

-

"给指定用户授权可访问的本地目录。"

-
- -
-
-
"正在读取目录授权..."
-
-
- "查看策略 JSON" -
{""}
-
-
-
- }.into_any() - } else { - view! { - - }.into_any() - }} - - {if is_admin { - view! { -
-
-

"验证目录"

- - - -

-                            
- -
-

"新增授权"

- - - - - - - - -

-                            
- -
-

"删除授权"

- - -

-                            
-
- }.into_any() - } else { - view! {}.into_any() - }} +
+
+

"新增文件夹授权"

+ + + + +

+                
-

"分享管理"

-

{if is_admin { - "查看、创建和撤销分享授权。" - } else { - "查看你创建或接收的分享授权。" - }}

+

"授权列表"

+

"查看当前可管理的文件夹授权,并撤销不再需要的授权。"

-
"正在读取分享授权..."
+
"正在读取授权..."
- "查看分享授权 JSON" + "查看授权 JSON"
{""}
+
- -
-
-

"新增分享授权"

- - {if is_admin { - view! { - - }.into_any() - } else { - view! { - - }.into_any() - }} - - - - - - - - - -

-                    
- -
-

"撤销分享授权"

- - -

-                    
-
- - -
-
+ + + {if boot_script { + view! { }.into_any() + } else { + view! {}.into_any() + }} + } } -const ADMIN_POLICY_SCRIPT: &str = r#" +pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#" (function () { - var root = document.querySelector('[data-testid="mnote-admin-access-policy-page"]'); + function initAccessPolicyPanel(scope) { + var root = (scope || document).querySelector('[data-testid="mnote-admin-access-policy-page"]'); if (!root) return; - var message = root.querySelector('[data-testid="mnote-admin-policy-message"]'); - var policyJson = root.querySelector('[data-testid="mnote-admin-policy-json"]'); + if (root.getAttribute('data-access-policy-ready') === 'true') return; + root.setAttribute('data-access-policy-ready', 'true'); var shareGrantsJson = root.querySelector('[data-testid="mnote-admin-share-grants-json"]'); - var validateResult = root.querySelector('[data-testid="mnote-admin-validate-result"]'); - var createResult = root.querySelector('[data-testid="mnote-admin-create-result"]'); - var deleteResult = root.querySelector('[data-testid="mnote-admin-delete-result"]'); var shareGrantsMessage = root.querySelector('[data-testid="mnote-admin-share-grants-message"]'); var createShareGrantResult = root.querySelector('[data-testid="mnote-admin-create-share-grant-result"]'); var deleteShareGrantResult = root.querySelector('[data-testid="mnote-admin-delete-share-grant-result"]'); - var refreshButton = root.querySelector('[data-admin-action="refresh-policy"]'); var refreshShareGrantsButton = root.querySelector('[data-admin-action="refresh-share-grants"]'); - var policyGrantsList = root.querySelector('[data-testid="mnote-admin-policy-grants-list"]'); var shareGrantsList = root.querySelector('[data-testid="mnote-admin-share-grants-list"]'); var pageConfig = (function () { - var node = document.getElementById('__MNOTE_ACCESS_POLICY_PAGE__'); + var node = root.querySelector('#__MNOTE_ACCESS_POLICY_PAGE__'); try { return JSON.parse(node ? node.textContent || '{}' : '{}'); } catch (_) { return {}; } })(); var isAdmin = pageConfig.isAdmin === true; @@ -285,76 +136,43 @@ const ADMIN_POLICY_SCRIPT: &str = r#" return '' + escapeHtml(text) + ''; } - function renderPolicyGrants(payload) { - if (!policyGrantsList) return; - var grants = payload && payload.policy && Array.isArray(payload.policy.grants) ? payload.policy.grants : []; - if (!grants.length) { - policyGrantsList.innerHTML = '
暂无目录授权
'; - return; - } - policyGrantsList.innerHTML = grants.map(function(grant) { - return '
' + - '
' + escapeHtml(grant.userId || grant.user_id || '未知用户') + '' + escapeHtml(grant.rootPath || grant.root_path || grant.rootUri || grant.root_uri || '') + '
' + - '
' + renderBadge(grant.permission || grant.access) + '
' + - '
' + escapeHtml((grant.capabilities || []).join(', ') || '无能力标记') + '
' + - '
'; - }).join(''); + function readDirectoryGrants(payload) { + if (payload && Array.isArray(payload.grants)) return payload.grants; + if (payload && payload.policy && Array.isArray(payload.policy.grants)) return payload.policy.grants; + return []; + } + + function accessPolicyUrl(suffix) { + var base = isAdmin ? '/api/admin/access-policy' : '/api/user/access-policy'; + return suffix ? base + suffix : base; } function renderShareGrants(payload) { if (!shareGrantsList) return; - var grants = payload && Array.isArray(payload.grants) ? payload.grants : []; + var grants = readDirectoryGrants(payload); if (!grants.length) { - shareGrantsList.innerHTML = '
暂无分享授权
'; + shareGrantsList.innerHTML = '
暂无文件夹授权
'; return; } shareGrantsList.innerHTML = grants.map(function(grant) { - var active = grant.active === false ? '已撤销' : '有效'; + var active = grant.active === false || grant.status === 'revoked' ? '已撤销' : '有效'; + var revokeButton = grant.active === false ? '' : + ''; return '
' + - '
' + escapeHtml(grant.shareId || grant.id || '未命名分享') + '' + escapeHtml(grant.rootPath || grant.rootUri || '') + '
' + + '
' + escapeHtml(grant.rootPath || grant.rootUri || '') + '授权用户 ' + escapeHtml(grant.targetUserId || grant.userId || '') + '
' + '
' + renderBadge(grant.permission) + renderBadge(active) + '
' + - '
所有者 ' + escapeHtml(grant.ownerUserId || '') + '接收者 ' + escapeHtml(grant.targetUserId || '') + '
' + + '
创建者 ' + escapeHtml(grant.createdBy || grant.ownerUserId || '') + '授权 ID ' + escapeHtml(grant.id || '') + '' + revokeButton + '
' + '
'; }).join(''); } - function formValues(form) { - var data = new FormData(form); - var capabilities = String(data.get('capabilities') || '') - .split(/[,\s]+/) - .map(function (item) { return item.trim(); }) - .filter(Boolean); - return { - id: String(data.get('grantId') || '').trim(), - userId: String(data.get('userId') || '').trim(), - rootUri: String(data.get('rootUri') || '').trim(), - rootPath: String(data.get('rootPath') || '').trim(), - permission: String(data.get('permission') || 'read').trim(), - recursive: data.get('recursive') === 'on' || data.get('recursive') === 'true', - capabilities: capabilities, - }; - } - - function splitList(value) { - return String(value || '') - .split(/[,\s]+/) - .map(function (item) { return item.trim(); }) - .filter(Boolean); - } - function shareGrantFormValues(form) { var data = new FormData(form); return { - id: String(data.get('shareGrantId') || '').trim(), - shareId: String(data.get('shareId') || '').trim(), - ownerUserId: String(data.get('ownerUserId') || '').trim(), + userId: String(data.get('targetUserId') || '').trim(), targetUserId: String(data.get('targetUserId') || '').trim(), - rootUri: String(data.get('shareRootUri') || '').trim(), - rootPath: String(data.get('shareRootPath') || '').trim(), - documentId: String(data.get('documentId') || '').trim(), - allowedResourceIds: splitList(data.get('allowedResourceIds')), + rootPath: String(data.get('rootPath') || '').trim(), permission: String(data.get('sharePermission') || 'read').trim(), - capabilities: splitList(data.get('shareCapabilities')), }; } @@ -371,101 +189,29 @@ const ADMIN_POLICY_SCRIPT: &str = r#" return payload; } - async function refreshPolicy() { - if (!isAdmin) return; - var payload = await requestJson('/api/admin/access-policy', { method: 'GET', headers: {} }); - setText(policyJson, payload); - renderPolicyGrants(payload); - setText(message, '已刷新策略'); - } - async function refreshShareGrants() { - var payload = await requestJson(isAdmin ? '/api/admin/share-grants' : '/api/user/share-grants', { method: 'GET', headers: {} }); + var payload = await requestJson(accessPolicyUrl(''), { method: 'GET', headers: {} }); setText(shareGrantsJson, payload); renderShareGrants(payload); - setText(shareGrantsMessage, '已刷新分享授权'); + setText(shareGrantsMessage, '已刷新授权列表'); } - refreshButton && refreshButton.addEventListener('click', function () { - setText(message, '正在刷新策略...'); - refreshPolicy().catch(function (error) { setText(message, error.message || '刷新失败'); }); - }); - refreshShareGrantsButton && refreshShareGrantsButton.addEventListener('click', function () { - setText(shareGrantsMessage, '正在刷新分享授权...'); + setText(shareGrantsMessage, '正在刷新授权列表...'); refreshShareGrants().catch(function (error) { setText(shareGrantsMessage, error.message || '刷新失败'); }); }); - var validateRootForm = root.querySelector('[data-admin-form="validate-root"]'); - if (validateRootForm) { - validateRootForm.addEventListener('submit', function (event) { - event.preventDefault(); - var values = formValues(event.currentTarget); - setText(validateResult, '正在验证...'); - requestJson('/api/admin/access-policy/validate-root', { - method: 'POST', - body: JSON.stringify({ rootUri: values.rootUri, rootPath: values.rootPath }), - }).then(function (payload) { - setText(validateResult, payload); - setText(message, '目录验证完成'); - }).catch(function (error) { - setText(validateResult, { ok: false, error: error.message || '验证失败' }); - setText(message, error.message || '验证失败'); - }); - }); - } - - var createGrantForm = root.querySelector('[data-admin-form="create-grant"]'); - if (createGrantForm) { - createGrantForm.addEventListener('submit', function (event) { - event.preventDefault(); - var values = formValues(event.currentTarget); - setText(createResult, '正在创建...'); - requestJson('/api/admin/access-policy/grants', { - method: 'POST', - body: JSON.stringify(values), - }).then(function (payload) { - setText(createResult, payload); - setText(message, '授权已创建'); - return refreshPolicy(); - }).catch(function (error) { - setText(createResult, { ok: false, error: error.message || '创建失败' }); - setText(message, error.message || '创建失败'); - }); - }); - } - - var deleteGrantForm = root.querySelector('[data-admin-form="delete-grant"]'); - if (deleteGrantForm) { - deleteGrantForm.addEventListener('submit', function (event) { - event.preventDefault(); - var values = formValues(event.currentTarget); - var grantId = values.id; - setText(deleteResult, '正在删除...'); - requestJson('/api/admin/access-policy/grants/' + encodeURIComponent(grantId), { - method: 'DELETE', - headers: {}, - }).then(function (payload) { - setText(deleteResult, payload); - setText(message, '授权已删除'); - return refreshPolicy(); - }).catch(function (error) { - setText(deleteResult, { ok: false, error: error.message || '删除失败' }); - setText(message, error.message || '删除失败'); - }); - }); - } - root.querySelector('[data-admin-form="create-share-grant"]').addEventListener('submit', function (event) { event.preventDefault(); var values = shareGrantFormValues(event.currentTarget); setText(createShareGrantResult, '正在创建...'); - requestJson(isAdmin ? '/api/admin/share-grants' : '/api/user/share-grants', { + requestJson(accessPolicyUrl('/grants'), { method: 'POST', body: JSON.stringify(values), }).then(function (payload) { setText(createShareGrantResult, payload); - setText(shareGrantsMessage, '分享授权已创建'); + setText(shareGrantsMessage, '文件夹授权已创建'); + event.currentTarget.reset(); return refreshShareGrants(); }).catch(function (error) { setText(createShareGrantResult, { ok: false, error: error.message || '创建失败' }); @@ -473,17 +219,19 @@ const ADMIN_POLICY_SCRIPT: &str = r#" }); }); - root.querySelector('[data-admin-form="delete-share-grant"]').addEventListener('submit', function (event) { + shareGrantsList && shareGrantsList.addEventListener('click', function(event) { + var button = event.target && event.target.closest ? event.target.closest('[data-admin-action="revoke-access-grant"]') : null; + if (!button) return; event.preventDefault(); - var data = new FormData(event.currentTarget); - var shareId = String(data.get('deleteShareId') || '').trim(); + var grantId = String(button.getAttribute('data-grant-id') || '').trim(); + if (!grantId) return; setText(deleteShareGrantResult, '正在撤销...'); - requestJson((isAdmin ? '/api/admin/share-grants/' : '/api/user/share-grants/') + encodeURIComponent(shareId), { + requestJson(accessPolicyUrl('/grants/') + encodeURIComponent(grantId), { method: 'DELETE', headers: {}, }).then(function (payload) { setText(deleteShareGrantResult, payload); - setText(shareGrantsMessage, '分享授权已撤销'); + setText(shareGrantsMessage, '文件夹授权已撤销'); return refreshShareGrants(); }).catch(function (error) { setText(deleteShareGrantResult, { ok: false, error: error.message || '撤销失败' }); @@ -491,16 +239,16 @@ const ADMIN_POLICY_SCRIPT: &str = r#" }); }); - if (isAdmin) { - refreshPolicy().catch(function (error) { - setText(message, error.message || '加载策略失败'); - renderPolicyGrants(null); - }); - } refreshShareGrants().catch(function (error) { - setText(shareGrantsMessage, error.message || '加载分享授权失败'); + setText(shareGrantsMessage, error.message || '加载授权失败'); renderShareGrants(null); }); + } + window.MNOTEInitAccessPolicyPanel = initAccessPolicyPanel; + var shell = document.body && document.body.getAttribute('data-mnote-shell'); + if (shell === 'admin' || shell === 'user-access-policy') { + initAccessPolicyPanel(document); + } })(); "#; @@ -510,21 +258,38 @@ mod tests { use crate::ssr::render_view; #[test] - fn admin_access_policy_page_renders_admin_controls() { + fn admin_access_policy_panel_renders_minimal_folder_grant_controls() { let html = render_view(view! { - }); assert!(html.contains("mnote-admin-access-policy-page")); - assert!(html.contains("mnote-admin-policy-json")); + assert!(html.contains("文件夹授权")); + assert!(html.contains("文件夹地址")); + assert!(html.contains("授权用户 ID")); + assert!(html.contains("read")); + assert!(html.contains("write")); assert!(html.contains("mnote-admin-share-grants-panel")); assert!(html.contains("mnote-admin-share-grants-json")); - assert!(html.contains("mnote-admin-validate-root-submit")); - assert!(html.contains("mnote-admin-create-grant-submit")); - assert!(html.contains("mnote-admin-delete-grant-submit")); assert!(html.contains("mnote-admin-create-share-grant-submit")); - assert!(html.contains("mnote-admin-delete-share-grant-submit")); + assert!(html.contains("/api/admin/access-policy")); + assert!(html.contains("/api/user/access-policy")); + assert!(html.contains("data-mnote-shell")); + assert!(!html.contains("/api/admin/share-grants")); + assert!(!html.contains("/api/user/share-grants")); + assert!( + html.contains("data-admin-action="revoke-access-grant"") + || html.contains("revoke-access-grant") + ); + assert!(!html.contains("name=\"grantId\"")); + assert!(!html.contains("name=\"shareId\"")); + assert!(!html.contains("share_xxx")); + assert!(!html.contains("name=\"ownerUserId\"")); + assert!(!html.contains("documentId")); + assert!(!html.contains("allowedResourceIds")); + assert!(!html.contains("capabilities")); + assert!(!html.contains("mnote-admin-validate-root-submit")); } } diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 57c28636..d9c2a370 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -1,5 +1,6 @@ //! MNOTE 通用页面布局组件(Wolai / Notion 风格) +use crate::ssr::pages::admin::AdminAccessPolicyPanel; use leptos::prelude::*; const SIDEBAR_TREE_JS: &str = r##" @@ -1227,6 +1228,12 @@ const SIDEBAR_TREE_JS: &str = r##" if (existing && existing.parentElement) existing.parentElement.removeChild(existing); } + function closeAdminAccessPolicyDialog() { + var existing = document.querySelector('[data-testid="mnote-admin-access-policy-modal"]'); + if (existing && existing.parentElement) existing.parentElement.removeChild(existing); + document.documentElement.removeAttribute('data-mnote-admin-access-policy-modal-open'); + } + function accountMenuFallbackSession() { var body = document.body instanceof HTMLElement ? document.body : null; var actorId = body ? String(body.getAttribute('data-mnote-actor-id') || '').trim() : ''; @@ -1263,10 +1270,6 @@ const SIDEBAR_TREE_JS: &str = r##" return String(session && session.actorType || '').trim() === 'admin'; } - function accessPolicyHrefForSession(session) { - return sessionIsAdmin(session) ? '/admin/access-policy' : '/user/access-policy'; - } - async function loadAccountInfo(menu) { try { var response = await fetch('/api/auth/session', { @@ -1346,6 +1349,43 @@ const SIDEBAR_TREE_JS: &str = r##" if (closeButton instanceof HTMLElement) closeButton.focus(); } + function openAdminAccessPolicyDialog(trigger) { + closeAdminAccessPolicyDialog(); + closeAccountMenu(); + if (!(trigger instanceof HTMLElement)) return; + var role = trigger.getAttribute('data-access-policy-role') === 'admin' ? 'admin' : 'user'; + var template = document.querySelector('[data-testid="mnote-admin-access-policy-template-' + role + '"]'); + var dialog = document.createElement('div'); + dialog.className = 'mnote-admin-policy-modal'; + dialog.setAttribute('data-testid', 'mnote-admin-access-policy-modal'); + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-labelledby', 'mnote-admin-policy-dialog-title'); + dialog.innerHTML = + '
' + + '
' + + '' + + '
' + + '
'; + var content = dialog.querySelector('[data-testid="mnote-admin-access-policy-modal-content"]'); + if (content) { + content.innerHTML = template ? template.innerHTML : '
授权管理面板加载失败
'; + } + if (typeof window.MNOTEInitAccessPolicyPanel === 'function') { + window.MNOTEInitAccessPolicyPanel(dialog); + } + dialog.querySelectorAll('[data-admin-access-policy-close]').forEach(function(button) { + button.addEventListener('click', function(event) { + event.preventDefault(); + closeAdminAccessPolicyDialog(); + }); + }); + document.body.appendChild(dialog); + document.documentElement.setAttribute('data-mnote-admin-access-policy-modal-open', 'true'); + var closeButton = dialog.querySelector('.mnote-admin-policy-modal__close'); + if (closeButton instanceof HTMLElement) closeButton.focus(); + } + async function signOutAccount(trigger) { setCommandPending(trigger, true); try { @@ -1382,7 +1422,7 @@ const SIDEBAR_TREE_JS: &str = r##" menu.setAttribute('role', 'menu'); menu.innerHTML = '' + - '
' + + '' + '' + ''; var sessionPromise = fetchAccountSession(); @@ -1395,8 +1435,16 @@ const SIDEBAR_TREE_JS: &str = r##" } var accessPolicyLink = menu.querySelector('[data-testid="mnote-account-access-policy"]'); sessionPromise.then(function(session) { - if (accessPolicyLink instanceof HTMLElement) accessPolicyLink.setAttribute('href', accessPolicyHrefForSession(session)); + if (!(accessPolicyLink instanceof HTMLElement)) return; + accessPolicyLink.hidden = false; + accessPolicyLink.setAttribute('data-access-policy-role', sessionIsAdmin(session) ? 'admin' : 'user'); }); + if (accessPolicyLink) { + accessPolicyLink.addEventListener('click', function(event) { + event.preventDefault(); + openAdminAccessPolicyDialog(accessPolicyLink); + }); + } var signOutButton = menu.querySelector('[data-testid="mnote-account-sign-out"]'); if (signOutButton) { signOutButton.addEventListener('click', function(event) { @@ -9996,13 +10044,14 @@ pub fn PageLayout( /// 顶栏当前页面标题(可选) #[prop(optional)] topbar_title: Option, - /// 是否显示管理员授权入口 + /// 是否显示管理员授权能力 #[prop(optional)] show_admin_access_policy: bool, /// 是否启用树实时流 #[prop(optional, default = true)] enable_tree_live: bool, ) -> impl IntoView { + let _ = show_admin_access_policy; let sidebar_tree_html = sidebar_tree_html.unwrap_or_default(); let ws_name = workspace_name @@ -10042,6 +10091,12 @@ pub fn PageLayout( "views": ["page-tree", "file-tree"] }) .to_string(); + let admin_access_policy_template = crate::ssr::render_view(leptos::view! { + + }); + let user_access_policy_template = crate::ssr::render_view(leptos::view! { + + }); view! {
@@ -10068,21 +10123,6 @@ pub fn PageLayout( - {if show_admin_access_policy { - view! { - - - - }.into_any() - } else { - view! {}.into_any() - }}