diff --git a/AGENTS.md b/AGENTS.md index 35c8037c..65211d42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,136 +1,50 @@ # 仓库协作指南(AGENTS) -## 当前主线 +## 主线(一句话) -- 产品:`tree-first graph kernel` + local-first 工作区;云首发同栈(单机/云服务器均可部署)。 -- 形态:`VSCode 简化版工作区 + leptos-tiptap Markdown + Pi Lab Page AI + LightRAG + mindmap/OnlyOffice + Wolai 主题壳 + libSQL 控制面 + Vault`。 -- 数据真相:本地(或服务器上的)workspace folder;页面正文为 `.md`。 -- 控制面:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local` 为默认与云首发推荐(独立部署、无需 Turso 云账号);`turso-remote` / `turso-local-replica` 仅在明确需要托管同步时使用。`sqlite` 不是 mnote-web 运行时。 -- Web:`mnote-web` 为 **3000** 唯一公开入口。kernel 持有树/边/projection/command;前端只消费稳定 projection。 -- Page AI:**Pi Lab**(`/api/page-ai/pi/*`)为唯一产品入口。 -- 知识库:LightRAG + `mnote.knowledge_rag.*` / `/api/knowledge-rag/*`。 -- 正文保存:`page.body.write` + 文件版本;AI 写文件:授权 scope + allowed roots + Pi patch/diff + watcher。 -- 架构 SSOT:`ARCHITECTURE.md`。预发瘦身清单:`design/10-review/process/22-pre-release-legacy-purge-v1.md`。产品化收口:`design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`。 +tree-first kernel + local-first 工作区;真相 = 本地 folder + `.md`;**mnote-web:3000**;Page AI = **Pi Lab only**;控制面默认 **libsql-local**;知识库 LightRAG;Vault + `$mnote-vault`。 +SSOT:`ARCHITECTURE.md`。预发:`design/10-review/process/22-pre-release-legacy-purge-v1.md`。 -## 云首发范围(必须保留) +## 云首发必须保留 -| 能力 | 入口 / 说明 | -|------|-------------| -| 工作区 + 树 + 文档 | kernel + FileTree/PageTree + leptos-tiptap | -| Page AI | Pi Lab | -| 知识库 | LightRAG | -| 办公文档 | OnlyOffice(附件/资源进入) | -| 密码箱 | Vault + `$mnote-vault` | -| 控制面 | `libsql-local`(auth / membership / share / AI policy / Pi scope) | +工作区树文档 · Pi Lab · LightRAG · OnlyOffice · Vault · libsql-local。 +勿当主链宣传:第三方 ACP 宿主、默认 debug tree shell。 -不在首发产品面、且不得在文档中当作可选主链宣传的路径:旧 agent 网关、第三方 ACP 宿主、OpenCode 代理壳、debug tree shell(默认关闭)。清理顺序与可删文件见 purge 清单。 +## 约束 -## 组件定位 +- 树/边/结构只在 Rust kernel;UI 不拼第二套真相。 +- 浏览器禁止新增 `setInterval` 轮询 → WS/SSE/watcher/command/MO。 +- 标题/设置/正文 → Page Aggregate;树变更 → `tree.*`。 +- AI 写文件:scope + roots + Pi patch/diff + 版本 + watcher(Phase C 流式 apply 冻结)。 +- `mnote_agent_tools/` 与 `/api/mnote/tools/*` **不可整目录误删**。 -| 组件 | 定位 | -|------|------| -| `leptos-tiptap` island | 文档页默认编辑 runtime;非对象真源 | -| Mindmap | tree-first 视图/挂件;非真相层 | -| OnlyOffice | 独立页面编辑器;经附件/资源进入 | -| LightRAG + Pi | 知识库与 Page AI 默认融合 | -| Vault | 本机/服务器密码箱;AI 经 capability token 读密 | -| Resource → File → Page Tree | 对象真源 → 主组织投影 → 导航投影 | - -## Runtime 模块定位 - -- Browser:`rust/crates/mnote-web/browser/` - - Sidebar:`sidebar-tree-runtime.js`(及 workspace / page tree / filetree / upload 等) - - FileTree:`filetree-*.js` - - Page AI host:`sidebar-page-ai-*.js`(默认 Pi Lab) - - 文档 host:`document-editor-adapter-runtime.js` -- Island:`rust/spikes/leptos-tiptap-spike/src/editor_runtime/` -- SSR 注入:`layout.rs` / `tree.rs` / `web_shell.rs` — 非大型 JS 功能定位入口 - -## 目录优先级 +## 目录 | 路径 | 用途 | |------|------| -| `rust/crates/core-protocol/` | Kernel 类型、projection 协议 | -| `rust/crates/bridge-runtime/` | query / command / traversal | -| `rust/crates/mnote-web/` | 3000 route、transport、projection | -| `rust/crates/mnote-web/browser/` | 浏览器 runtime JS | -| `rust/spikes/leptos-tiptap-spike/` | 文档页编辑 island | -| `rust/crates/mnote-vault-core/` | Vault 核心 | -| `src/components/onlyoffice/` | OnlyOffice 静态与插件 | -| `wolai-backend/app/` | 辅助后端 / 异步(非 3000 主入口) | -| `recycle/` | 回收区;不作为实现依据 | -| `reference-code/` | 对照参考;**不进部署包** | +| `rust/crates/core-protocol` · `bridge-runtime` · `mnote-web` | 协议 / query-command / 3000 | +| `mnote-web/browser` · `leptos-tiptap-spike` | 前端 runtime / 编辑 island | +| `mnote-vault-core` · `src/components/onlyoffice` | Vault · OO | +| `recycle/` · `reference-code/` | 非实现依据;**不进部署包** | -## 架构约束 +## 协作 -- 树 / 页面结构 / 引用边:落 Rust kernel,禁止 UI 拼第二套真相。 -- 浏览器主链禁止新增 `setInterval` / 周期轮询;用 WS/SSE、watcher、command result、`MutationObserver`。 -- 标题 / 页面设置 / 正文:收口 Page Aggregate。 -- 新建/重命名/移动/归档:`tree.*`。 -- AI 正文:文件引用 + `AiAccessScope` + Pi patch/diff + 版本冲突 + watcher。Phase C 流式 apply 冻结。 -- Page AI 宿主为原生 Pi Lab runtime;普通 UI 不得出现第二套 agent 切换入口。 -- Agent 工具包:`rust/crates/mnote-web/src/mnote_agent_tools/`(HTTP 面 `/api/mnote/tools/*`;Pi / LightRAG / skill / OnlyOffice live tools 依赖,**不可整目录误删**)。 -- 架构判断:`ARCHITECTURE.md` → 对应 `design/**` process/done 稿。 +- 只改任务相关文件;不覆盖用户改动。 +- design:`process/` 可执行,`done/` 完成。bugs 镜像大类。 +- CodeGraph 结构问题;`rg` 字面量。热启:`npm run dev:hot`。测:`cargo test -p mnote-web`。 -## 设计稿目录规则 +## 账号(dev,勿混) -- `design/01-*` … `design/12-*`:`process/` 可执行,`done/` 已完成,`reference/` 参考,`draft/` 未成形。 -- 已完成主线稿迁 `done/`;被覆盖旧稿迁 `design/old/` 并标 `[recycle]`。 -- `design/90-reference/` 只放参考资料。 +| 账号 | 角色 | +|------|------| +| `mnote-admin` | ops admin | +| `mnote-e2e` | `ai_service`(非 admin) | +| 其它 | human | -## Bugs 目录规则 +详情与密码:`scripts/TESTING_REFERENCE.md`。登录走标准 `/auth`(无快速登录)。 -- 镜像 design 大类;`process/` 未修完,`done/` 已验证。 -- 壳/Sidebar/文档页体验 → `05-editor-mainline/`;projection/selection/DnD/tree → `04-tree-domain/`。 +## 指针 -## 协作边界 - -- 只改与任务直接相关的文件;不覆盖用户已有改动;无关脏改动保持不动。 -- Subagent 为受控叶子:限定读写范围与验证命令;主控复核 diff/证据后才采纳。 -- 同一写入范围同时只允许一个 writer。 - -## CodeGraph - -- 结构性代码问题优先 CodeGraph MCP(explore / impact)。 -- 改代码后 `codegraph sync .`;大重构或索引异常 `codegraph index . --force`。 -- 字面文本/日志字符串优先 `rg`。 - -## Reference-Code - -- 优先 `reference-code/sidex-main`;`reference-code/vscode` 为裁剪辅助。 -- 每次只对照一个切面;不要把参考项目 UI 状态当成 MNote 新事实源。 - -## 常用命令 - -- 热启动:`npm run dev:hot` → `http://localhost:3000` -- 测试:`cargo test -p mnote-web` -- Control-plane 默认:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local` -- 云首发:同 `libsql-local` + 服务器本地数据目录;需要托管再换 `turso-remote` - -## Smoke 与测试账号 - -- 基线:`scripts/TESTING_REFERENCE.md`;默认入口 `3000 + leptos-tiptap + local-first + libSQL auth`。 -- 禁止 smoke 用 `sqlite3` CLI 直写 control-plane;seed 走 Rust API / `scripts/lib/control-plane-dev-seed.js`。 -- 账号角色(7-76 方案 A,**勿混用**): - | 账号 | 定位 | 密码(本地/dev) | admin 能力 | - |------|------|------------------|------------| - | `mnote-admin` / `mnote.admin@example.com` | **ops admin**(`/admin/*`、代签、policy) | `MnoteAdmin123!` | 是 | - | `mnote-e2e` / `mnote.e2e@example.com` | **AI 主体** `ai_service`(私有工作区/知识库) | `MnoteE2E123!` | **否** | - | `liaibo` 等 | 普通 human user | 各自密码 | **否** | -- 登录走 **标准** `/auth` 表单或 `/api/auth`(已移除「测试账号快速登录」);开发对标生产。 -- Admin 列表真源:`access-policy.json` 的 `admins` + `MNOTE_ADMIN_USER_IDS`(默认仅 `mnote-admin`)。 - -## 前端测试 - -- 真实渲染:`/doko`;交互回归:浏览器自动化。 -- 主页/Sidebar/文档首屏优先复用 `scripts/task*-smoke.js`。 - -## 编码 - -- UTF-8;TS/TSX 2 空格;Python 4 空格。 - -## 密码箱 / agent skills(薄指针) - -- 策略 SSOT:`/home/lix/.agent-infra/vault-policy.md`;skill `$mnote-vault`(`skills/mnote-vault`)。 -- Page AI pack 注册:agent tool pack 内 `skill` 模块 id `mnote-vault`(仅放 `skills/` 不够)。 -- 勿在本文件复制完整 vault 策略。 +- Vault:`vault-policy.md` · `$mnote-vault`(pack id `mnote-vault`)。 +- **知识中枢**(非 LightRAG):`/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space` + - 查 → `INDEX.md`;更 → 事实变更时;写/多域 → `$mnote-e2e-knowledge-hub` diff --git a/design/05-editor-mainline/process/5-38-bidirectional-link-system-v1.md b/design/05-editor-mainline/process/5-38-bidirectional-link-system-v1.md new file mode 100644 index 00000000..92378768 --- /dev/null +++ b/design/05-editor-mainline/process/5-38-bidirectional-link-system-v1.md @@ -0,0 +1,280 @@ +# 5-38 双向链接系统(Bidirectional Links)v1 + +> 状态:process(调研完成,待分阶段落地) +> 日期:2026-07-29 +> 目标:避免「多处引用同一知识却各写一份」的多真源问题;在 local-first + tree-first kernel 下建立**单真源引用边 + 反链投影**。 + +--- + +## 0. 一句话结论 + +**正向链接写在正文(Markdown / block 语义)→ 保存/索引时抽出 `references` 边 → 反链只是该边的反向投影。** +TipTap 只负责插入/渲染;**不**在前端维护第二套链接图。 + +--- + +## 1. TipTap 更新检查(2026-07-29) + +| 位置 | 版本 | 说明 | +| --- | --- | --- | +| `reference-code/leptos-tiptap/tiptap` | **@tiptap/\* 2.27.2** | 当前主链 bridge 依赖 | +| npm `v2-latest` | **2.27.2** | v2 线已停在此点 | +| npm `latest` | **3.29.2**(2026-07-28) | 主线已进 v3 很久 | +| `reference-code/tiptap-main` 快照 | packages 约 **3.23.4**,git ~2026-05 | 参考仓库偏旧于 npm latest | +| `@tiptap/extension-mention` | 3.29.2 与 core 对齐 | v3 线 mention;v2 仍有独立包 | + +### 1.1 对双向链接的含义 + +- **双向链接不依赖 TipTap 3**。核心是 kernel 边 + Markdown 持久化 + 索引投影。 +- **本阶段不建议为双向链接单独升 v3**。v2→v3 是独立迁移(schema/API/extension 打包方式变化),成本远高于「补 page mention + 反链面板」。 +- 可继续参考: + - `reference-code/leptos-tiptap`(当前 bridge / link extension) + - `reference-code/tiptap-notion-like-registry` 的 `mention-dropdown-menu` / `link-popover`(**UI 交互**,不是用户 mention 语义) + - `reference-code/tiptap-main` 仅作 API 对照;升 v3 时再同步拉取 + +### 1.2 可选后续(非本系统阻塞) + +- 独立 checklist:评估 leptos-tiptap bridge 升 `@tiptap/*@3` 的 breaking 面。 +- 升版前冻结:现有 `page_reference` ↔ link mark round-trip 测试。 + +--- + +## 2. 现状盘点(已有资产 / 缺口) + +### 2.1 已有(可复用,勿重造) + +| 层 | 资产 | 路径/入口 | +| --- | --- | --- | +| 语法 | `[[page\|label]]` / `((block\|label))` | `mnote-editor-core/src/markdown.rs` | +| 块类型 | `page_reference` / `PageReference` | core-protocol、bridge、editor actor | +| 编辑投影 | page_ref → 段落 + `link` mark(class `mnote-page-block-link`) | `document-tiptap-conversion-runtime.js`、`block_transform.rs` | +| 索引反链 | 扫 `](*.md)` + `[[…]]`,API 可读 | `local_search_index.rs` → `GET /api/search/local-index/backlinks` | +| Kernel 类型 | `KernelEdgeType::References` / `BacklinksTo` | `core-protocol/src/kernel.rs` | +| 页面选项 | `collapseBacklinks` / `showBlockRefCount` | page aggregate + UI preferences | +| 证据边 | markdown 链接 → deterministic edges | document evidence / LightRAG 旁路(勿与笔记反链混为同一产品入口) | +| 思源边界 | 借产品闭环,不借 `.sy` / SQL 真相 | `design/09-siyuan-reference/...` | + +### 2.2 缺口(多真源风险点) + +1. **编辑器层**:page_ref 降级为普通 link mark,无独立 inline atom;`[[` 输入规则 / 页面搜索插入不完整(legacy editor 仅插 `[[page]]` 占位)。 +2. **索引层**:`extract_backlinks` 存的是**字符串目标**(路径 / wikilink 文本),未统一解析为稳定 `documentId` / ObjectIdentity;重命名后易漂。 +3. **Kernel 层**:`References` / `BacklinksTo` 已声明,**未**成为 page_aggregate / 反链 UI 的正式 query 面。 +4. **UI 层**:`collapseBacklinks` 明确 **「待接线:当前 Rust 壳未挂回链面板」**;旧 local-index 反链 DOM 已故意不复活(避免与 LightRAG 资料库入口混淆)。 +5. **语义层**:E28 `@mention` 已暂停且纠偏为「人/会议」,**不是**页面引用;页面引用应走 `[[` / slash「页面」/ 转 page_reference,勿与 `@` 混用。 +6. **块引用**:`((block))` 语法与类型有,主编辑岛与稳定 blockId 持久化尚未闭环。 + +--- + +## 3. 产品语义(对齐思源 / Wolai,落 MNote 边界) + +### 3.1 三种引用(必须分开) + +| 种类 | 用户形态 | 持久化 | 索引边 | 优先级 | +| --- | --- | --- | --- | --- | +| **页面引用** | `[[Title]]` / `[[path\|显示名]]` / page_reference 块 | Markdown 或 block props | `references` → page | **P0** | +| **块引用** | `((blockId))` | 需稳定 blockId | `references` → block | P1 | +| **外链 / 附件** | `[x](url)` / 资源 path | 普通 link / resource_ref | 不进「笔记反链」主列表(可进 resourceRefs) | 已有,保持 | + +### 3.2 「双向」的真正含义 + +- 用户只写**正向**引用。 +- 系统维护 `source → target` 边。 +- **反链** = `target ← sources` 的投影查询,不是第二份手写列表。 +- 图谱 / 搜索 / AI 上下文 / 导图挂件 **只读同一套边**,禁止各自扫一遍 Markdown 各存一份。 + +### 3.3 真源顺序(硬规则) + +``` +本地 .md(+ 将来稳定 block 元数据) + ↓ 解析 / 保存 +Page Aggregate blocks(含 page_reference / 将来 inline ref) + ↓ 抽出 +Reference edges(kernel 或 local-index 统一表,单一实现) + ↓ 投影 +反链面板 / search / AI tools / 可选图谱 +``` + +**禁止**: + +- 浏览器 localStorage 存链接图 +- TipTap JSON 与 Markdown 各维护一套「官方」反链 +- LightRAG entity 边冒充笔记 page backlink(可并列展示,须分入口) + +--- + +## 4. 目标架构 + +### 4.1 引用边最小模型 + +```text +ReferenceEdge { + source_document_id: String, // local-md:… 或稳定 id + source_block_id: Option, + target_kind: page | block | resource | external, + target_document_id: Option, // 解析成功时 + target_raw: String, // 原文 token / path,解析失败仍可展示 + anchor_text: Option, + locator: Option, // 行号 / offset,便于跳转 +} +``` + +- **写入**:文档保存成功、local-index refresh、watcher 变更后 **整页替换**该 source 的出边(思源式:按 root 删再插,避免脏边)。 +- **读取**: + - `query.backlinks_to(documentId)` → 谁链到我 + - `query.references_from(documentId)` → 我链向谁 +- 与已有 API 对齐:先让 `GET /api/search/local-index/backlinks` **成为**上述 query 的 HTTP 面,再视需要升为 kernel query 名 `search.local_index.backlinks` → 最终 `kernel.backlinks_to`。 + +### 4.2 目标解析(消灭字符串多真源) + +解析顺序建议: + +1. 已是 `local-md:…` / 已知 documentId +2. 相对路径相对**当前文件** resolve → 规范化相对 root → `local-md:{encode}` +3. wikilink `[[Title]]`:按标题 / 文件名 stem 在索引内消歧(多命中则 raw 保留 + UI 标黄) +4. 失败:`target_document_id = null`,`target_raw` 保留,反链仍可按 raw 弱匹配(可选) + +重命名 / 移动:走现有 tree command;索引 rebuild 时重算边(P0 可全量 rebuild;P1 增量 + transferBlockRef 类迁移可参考思源 `transferBlockRef`,**不**照搬 SQL)。 + +### 4.3 TipTap 显示层(v2 即可) + +**不要**把 Notion registry 的「用户 Mention」直接当页面引用。 + +推荐两档: + +| 档 | 形态 | 说明 | +| --- | --- | --- | +| **A(最小,先做)** | 保持 `paragraph` + `link` mark + `mnote-page-block-link`,补强 round-trip 与点击打开 | 已有主链,改动小 | +| **B(产品完整)** | 新增 inline atom:`pageMention` / 或专用 mark attrs:`data-mnote-ref="page"` + `data-target-id` | 便于样式、未解析态、禁止拆字 | + +输入: + +- InputRule:`[[` 打开页面搜索(复用 local search / tree query,**不是** `@`) +- Slash:插入「页面引用」 +- 块菜单:段落 ↔ page_reference 转换(已有 `replace_block_with_page_reference` 可扩) + +Markdown 导出: + +- 优先 `[[target|label]]` 或 `[label](rel/path.md)`(**工作区内统一一种**;建议 **wikilink 作规范、md link 作兼容导入**) +- `mnote-editor-core` 已用 `[[id|label]]`,local-folder 路径型文档应导出 **相对路径** 以便 Git 可读:`[[docs/child|子页]]` 或 `[子页](docs/child.md)` — **实现前定一种 SSOT 写进本文件 §6**。 + +### 4.4 反链 UI + +- 挂在**页面底部或右侧信息区**(Wolai/思源心智),**不要**塞回已移除的 local-index 设置页。 +- 消费:`backlinks` API + Page Aggregate 选项 `collapseBacklinks`。 +- 每条:来源标题、路径、可选 snippet、点击 `open document`(带 sourceKind/rootUri)。 +- `showBlockRefCount`:有块引用后再接线。 + +### 4.5 与 AI / LightRAG + +| 需求 | 入口 | +| --- | --- | +| 「哪些笔记链到这篇」 | 笔记反链 API / kernel edges | +| 「资料库里实体/PDF 证据」 | LightRAG / knowledge-rag | +| Agent 写引用 | 必须写 Markdown/block 合法引用,再走同一抽取器 | + +--- + +## 5. 分阶段落地(可执行) + +### Phase 0 — 契约冻结(0.5–1d) + +- [ ] 选定工作区持久化规范:`[[rel/path|label]]` **或** `[label](rel.md)` 为写出 SSOT(导入两者都认)。 +- [ ] 固定 `ReferenceEdge` 字段与 `documentId` 解析顺序(§4.2)。 +- [ ] 明确 `@` ≠ 页面引用(文档 + slash 文案)。 + +### Phase 1 — 索引单真源(1–2d)**【优先】** + +- [ ] 强化 `extract_backlinks`:输出结构化 edge(含 resolved id),不只是字符串列表。 +- [ ] `query_local_backlinks` 按 **resolved documentId** 聚合;兼容旧字符串。 +- [ ] 单测:A→B 的 `[[B]]` 与 `[B](b.md)` 反链一致;重命名后 refresh 更新。 +- [ ] 保持 `task452` 语义:API 可用;不恢复旧 settings DOM。 + +### Phase 2 — 反链面板(1–2d) + +- [ ] 文档页挂载 backlinks panel(读 Phase 1 API)。 +- [ ] 接通 `collapseBacklinks`。 +- [ ] Smoke:两篇本地 md 互链,B 页可见 A。 + +### Phase 3 — 编辑器插入体验(2–3d) + +- [ ] `[[` 触发页面搜索 + 插入规范 token / page_reference。 +- [ ] 保存后索引增量/全量更新边。 +- [ ] 未解析目标的视觉态(可选)。 +- [ ] **仍用 TipTap 2.27.2**;不夹带 v3。 + +### Phase 4 — Kernel 边对齐(按主线空档) + +- [ ] local-index 实现升为 `KernelEdgeType::References` 的唯一写入路径(或薄封装)。 +- [ ] `BacklinksTo` 作为派生查询,不双写。 +- [ ] Page Aggregate 可附带 `backlinks` 摘要(注意体积与权限)。 + +### Phase 5 — 块引用(后置) + +- [ ] 稳定 blockId(与 UniqueID / 导出策略一起设计)。 +- [ ] `((id))` 解析、预览、transfer on move。 +- [ ] `showBlockRefCount`。 + +### 明确不做(本 v1) + +- 为双向链接升级 TipTap 3 +- 复制思源 `refs` SQL 表为产品真相 +- 用 LightRAG 替代笔记反链 +- 恢复旧 local-index 设置页反链 DOM +- 把 `@` 改成页面引用 + +--- + +## 6. 待决事项(实现前二选一写死) + +1. **写出格式 SSOT** + - 方案 W:wikilink `[[rel/path|label]]`(贴近思源/Obsidian,editor-core 已有) + - 方案 M:标准 md link `[label](rel.md)`(Git 友好、通用) + - **建议**:索引与解析两者都认;**新插入默认 W**,导出可配置(默认 W)。 + +2. **page_reference 块 vs inline** + - 块级:独占一段(当前) + - 行内:段落中的 mention atom + - **建议**:P0 块级 + link mark;P1 行内 atom(真正「文中引用」)。 + +3. **反链面板位置** + - 页底固定区 vs 右侧 drawer + - **建议**:页底折叠区(`collapseBacklinks` 已存在语义)。 + +--- + +## 7. 参考索引 + +| 来源 | 用途 | +| --- | --- | +| 思源 `kernel/sql` `refs` 表 / `block_ref_query.go` | 边模型、按 root 重建、反链查询形状 | +| 思源 conf `Backlink*` / `VirtualBlockRef` | 产品选项;虚拟引用可后置 | +| `design/09-siyuan-reference/...` | 借鉴边界 | +| `design/01-tree-first-graph-kernel/...` | tree + references 边 | +| leptos-tiptap `tiptap_link.ts` | link mark bridge | +| notion-like `mention-dropdown-menu` | 仅 suggestion UI 模式 | +| `local_search_index.rs` `extract_backlinks` | 现抽取实现 | +| `sidebar-page-settings-runtime.js` | collapseBacklinks 待接线文案 | + +--- + +## 8. 验收标准(Phase 1–2 最小闭环) + +1. 在 root 下创建 `A.md`,正文写入指向 `B.md` 的规范引用并保存。 +2. 打开 `B`:反链面板出现 `A`(标题可点,带 local source 参数)。 +3. 删除 A 中引用并保存 / 刷新索引后,B 反链消失。 +4. 同一套数据:`GET .../local-index/backlinks` 与面板一致。 +5. 无第二套前端链接缓存;无 TipTap 3 升级。 + +--- + +## 9. 与主线关系 + +当前主线仍是 Page Aggregate / tree / local-first。双向链接应: + +- **先**吃透已有 local-index + page_reference(Phase 1–2) +- **再**编辑体验(Phase 3) +- Kernel 图边与块引用跟 tree-first 空档合并,避免另起存储 + +--- + +*本文是 process 设计;落地时按 Phase 开 checklist / smoke,完成后迁 `done/`。* diff --git a/design/07-ai/process/7-77-mnote-e2e-knowledge-hub-architecture-v1.md b/design/07-ai/process/7-77-mnote-e2e-knowledge-hub-architecture-v1.md new file mode 100644 index 00000000..603512c3 --- /dev/null +++ b/design/07-ai/process/7-77-mnote-e2e-knowledge-hub-architecture-v1.md @@ -0,0 +1,193 @@ +# 7-77 mnote-e2e 知识中枢架构 v1 + +> 状态:`process` · 可执行设计(落地 skill 前的 SSOT) +> 范围:AI 主体 `mnote-e2e` 工作区 + 多 AI / Admin 共用的**系统·项目知识中枢** +> 非目标:替换 LightRAG;替换 vault 读密;替代全局 self-improving 记忆循环 +> 前置:Web PAT(笔记 CRUD)与 vault mnv1(读密)已分职;本设计只落**可读 Markdown 真源** + +--- + +## 1. 问题与目标 + +### 1.1 要解决什么 + +- 重复查 skill 路径、服务器 URL、运维约定; +- 系统级配置散落在聊天 / 临时文件里; +- 项目上下文与全局基建混杂,难给 **Admin 复核** 与 **多 AI 共用**。 + +### 1.2 产品定位 + +**知识中枢 = 系统重要信息 + 项目重要信息的「索引 · 导航 · 积累」层。** +Admin 与 AI 共用同一套 Markdown 树;LightRAG 是可选增强,不是唯一入口。 + +### 1.3 边界 + +| 概念 | 本中枢关系 | +|------|------------| +| self-improving-for-codex | 不替代;行为记忆仍在 agent 侧 | +| LightRAG | 可选索引 `00-system` + 显式项目;排除 inbox/archive/系统点目录 | +| vault / mnv1 | 正文禁止密文;只写 credential 指针 | +| Web PAT | e2e 工作区 CRUD 通道 | +| 全局 skill / AGENTS | 工作区 `AGENTS.md` 强制「先查中枢」 | + +### 1.4 成功标准 + +1. Admin 打开 e2e 工作区根即可浏览系统/项目知识。 +2. AI 默认先读 `INDEX.md` 再动手。 +3. 秘密只在 vault。 +4. Admin 可清 inbox、删冗余、批注待更新。 +5. 结构稳定到可做成 skill。 + +--- + +## 2. 真源与放置 + +### 2.1 工作区 + +```text +主体:mnote-e2e(ai_service) +根: /mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space +鉴权:Web PAT notes.* + tree.* +读密:mnv1(与中枢分离) +``` + +### 2.2 中枢根 = 工作区根(无额外 00-hub 层) + +**决策(2026-07-28)**:不再套 `00-hub/`。e2e 工作区几乎整仓即知识中枢,多一层路径长、心智重复。 + +```text +my-space/ # ← 中枢根 + AGENTS.md # 工作区纪律(启动入口) + README.md # 中枢是什么、怎么用 + INDEX.md # 总索引(启动必读,短) + 00-system/ # 通用 / 系统级 + 10-projects/ # 分项目 + 90-inbox/ # AI 草稿,Admin 晋升 + 99-archive/ # 过期 + .mnote/ ai-sessions/ … # 系统目录:不进索引、不进 RAG 默认范围 +``` + +**约定** + +| 类别 | 路径 | 是否知识树 | +|------|------|------------| +| 知识正文 | `INDEX.md`、`00-system/**`、`10-projects/**`、`90-inbox/**`、`99-archive/**` | 是 | +| 系统运行时 | `.mnote/**`、`ai-sessions/**`、`.reasonix/**`、`.codegraph/**` | **否** | + +> 实测:e2e PAT 已验证 tree 创建/重命名/删除 + `resource/read|write`;用户试探文档已清理。 + +--- + +## 3. 信息架构 + +### 3.1 分层纪律(必守) + +| 层 | 放什么 | 不放什么 | +|----|--------|----------| +| **`00-system`** | **主机与基建**:Kubuntu、OrangeVPS、heyun;VPN/frp;全机 skill;系统凭证指针 | MNote 应用账号(mnote-e2e 等)、产品端口 3000/3003、PAT/mnv1 产品细则 | +| **`10-projects/*`** | 产品与项目:身份、业务端口、架构、runbook | 把 heyun/Kubuntu 主机表重复抄一套当「项目」而不链系统层 | + +### 3.2 `00-system/`(系统 / 主机) + +```text +00-system/ + INDEX.md + 01-hosts/ # Kubuntu / OrangeVPS / heyun(可达性备注) + 02-skills/ # 全机 skill 登记 + 03-network/ # 代理 profile、frp 约定 + 04-endpoints/ # 系统向入口(代理/面板);产品入口链到项目 + 05-repos/ # 本机仓库与数据路径 + 06-agents-runtime/ # 本机 agent 宿主 + 07-credentials-map/# 系统凭证指针(无密文) + 08-ops-runbooks/ # 主机/网络运维 + 09-governance/ # 中枢治理 +``` + +### 3.3 `10-projects//` + +```text +10-projects/ + INDEX.md + mnote-core/ # 含 identity.md、endpoints.md(产品端口) + ai-runtime/ # 含 credentials.md(PAT/mnv1/AI 密码本指针) + infra-ops/ # 部署视图;主机真源仍链 00-system/01-hosts + / + README.md INDEX.md architecture.md runbook.md + decisions.md glossary.md open-questions.md links.md +``` + +### 3.4 inbox / archive + +| 区 | 写 | 默认启动读 | +|----|----|------------| +| `90-inbox/` | AI 草稿 | 否 | +| `99-archive/` | 归档 | 否 | + +--- + +## 4. 安全与索引纪律 + +1. 禁止 password / apikey / cookie / mnpat1 / mnv1 明文。 +2. 凭证只写:subject、credentialId、用途、字段名。 +3. 启动只读:`AGENTS.md` → `INDEX.md` → 按需子 INDEX。 +4. 清单用表格 + 链接;明细按需打开。 + +--- + +## 5. 角色 + +| 角色 | 读 | 写 | 删 system 核心 | +|------|----|----|----------------| +| Admin | 全部 | 全部 | 可 | +| AI(e2e PAT) | 工作区知识树 | inbox + 受控更新 catalog | 禁止擅自删 | +| 其他用户 | 无 membership 则不可见 | — | — | + +--- + +## 6. 后续接线 + +- 工作区 `AGENTS.md`:先查中枢、禁密文、inbox 规则。 +- Skill:`$mnote-e2e-knowledge-hub` + - 真源:`~/.codex/skills/mnote-e2e-knowledge-hub/` + - Grok symlink:`~/.grok/skills/mnote-e2e-knowledge-hub` + - 含 worker 模板:`references/worker-prompts.md` · 结构:`references/structure.md` +- LightRAG:默认可挂 `00-system/**`;项目 opt-in;排除 inbox/archive/点目录。 + +--- + +## 7. 落地阶段 + +| 阶段 | 内容 | 状态 | +|------|------|------| +| A | 以 e2e 根建树 + 占位 INDEX | **done** | +| B | 填 skills/servers/identity 实表(kb2/kb3 充实) | **done**(可持续更新) | +| C | skill + 完善 AGENTS.md | **done**(2026-07-29) | + +--- + +## 8. 反模式 + +- 密文进 Markdown +- 每 AI 各建一套 hub +- 把 `ai-sessions` 当知识 +- 镜像整个 git 仓库 +- AI 静默删 `00-system` 核心页 +- ~~多包一层无必要的 `00-hub/`~~(已否决) + +--- + +## 9. 开放问题 + +1. skill 登记:半自动扫盘 + Admin 审? +2. `design/**` 以 pointer 链入(推荐)还是摘要复制? + +--- + +## 变更记录 + +| 日期 | 说明 | +|------|------| +| 2026-07-28 | 初稿:含 `00-hub` 层 | +| 2026-07-28 | **改中枢根 = e2e 工作区根**,去掉 `00-hub`;并落地目录骨架 | +| 2026-07-28 | **纠正分层**:系统级 = 主机(Kubuntu/OrangeVPS/heyun)与基建;mnote-e2e 身份/产品端口下沉 `10-projects` | +| 2026-07-29 | 落地 skill `$mnote-e2e-knowledge-hub`(Codex 真源 + Grok 链接);阶段 B/C 收口 | diff --git a/design/10-review/process/23-retired-product-cleanup-checklist-v1.md b/design/10-review/process/23-retired-product-cleanup-checklist-v1.md new file mode 100644 index 00000000..c6b88fa8 --- /dev/null +++ b/design/10-review/process/23-retired-product-cleanup-checklist-v1.md @@ -0,0 +1,85 @@ +# 23 · 退役产品全库清理清单 v1 + +> 状态:**in-progress → 高优先级 Waves A1–C 已落地(2026-07-28)** +> 日期:2026-07-28 +> 目标:把 convex + Weknora + openhub + reasonix + Hermes + ACP + OpenCode + Sidex + Yuxi + legacy_next_compat + soft retirement 残留彻底扫光,像没有存在过一样。 +> 优先级:**高**(编译不红、runtime 不暴露、文档不广告)→ **中**(调试残留)→ **低**(历史叙事、死 smoke)。 + +## 1. 整体盘点(更新 2026-07-28 执行后) + +| 品牌 | 产品面残留 | 调试/测试面 | 历史叙事 | 风险 | +|---|---|---|---|---| +| **Hermes** | seed→`pi`;`HERMES_HOME` 兼容读已删;`AiRuntimeOwner::RustWebPi` | soft-retire smoke 保留 exit 0 | design/old 可留 | **已收** | +| **OpenHub / OpenCode** | 无产品入口 | soft-retire smoke | design/old | **低** | +| **Reasonix** | seed→`pi` | `.reasonix` 目录忽略保留 | design/old | **已收** | +| **ACP** | DB 列 `acp_*` 保留(schema);产品 runtime 无 ACP 宿主 | 极少 | | **中(列名兼容)** | +| **next_compat / LEGACY_NEXT** | AppConfig / proxy / documents Next 链已删 | smoke 已去 env | design/done 历史稿 | **已收** | +| **convex_retired** | 已扫完 | | | **已完** | +| **Weknora / Yuxi / Sidex** | 极少 | | design/old | **低** | + +## 2. 本批已执行(A1 → A2 → A3 → B → C) + +### A1 · control-plane brand → pi ✅ +- sqlite / turso seed:`hermes`/`reasonix` → `pi`,display「我的 Pi」 +- 迁移:历史 `agent_id='hermes'` 就地 UPDATE 到 `pi` +- fixtures / `turso_store` / `control-plane-dev-seed.js` 字面量中性化 + +### A2 · LEGACY_NEXT / documents proxy ✅ +- `AppConfig` 删除 `legacy_next_base_url` / `enable_legacy_next_compat` / `compat_next_base_path` +- `session` / `gateway` health 与 legacy proxy 删除 +- `documents` Next proxy 栈删除 +- OnlyOffice 非 local writeback → fail-closed(`onlyoffice_cloud_writeback_unavailable`) +- 保留 OnlyOffice **同源 server proxy** 所需 `append_path_and_query` + +### A3 · Hermes 命名 / HERMES_HOME ✅ +- `AiRuntimeOwner::RustWebHermes` → `RustWebPi` +- spike:`open_hermes_page_ai_drawer` → `open_page_ai_drawer` +- `agent_profile_home`:仅 `MNOTE_AGENT_HOME` / `~/.mnote-agent`(去掉 `HERMES_HOME` / `~/.hermes`) +- audit log:仅 `MNOTE_AGENT_TOOL_AUDIT_LOG` +- AGENTS.md:去掉「旧 agent 网关 / OpenCode 代理壳」宣传句 +- `.env.all`:删除 `MNOTE_HERMES_API_*` + +### B · compat 410 / 文案 / debug ✅ +- `/api/ai-agent/run` 继续 **410** `legacy_ai_agent_run_retired`(含 hermes/codex provider 负向合同) +- compat 测试去 mock Next 上游,只断言 GONE +- debug shell:`MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES` 默认 false(边界保持) + +### C · smoke / 编译验证 ✅ +- 17 个 smoke 去掉 `MNOTE_WEB_*LEGACY_NEXT*` +- `task164` 断言改为不出现 `legacy_next_compat` +- 死 hermes/opencode smoke 已 soft-retire(Wave 10,保持) +- 验证: + - `cargo check -p mnote-web --tests` 无 error + - `cargo test -p bridge-runtime --lib` 116 passed + - `cargo test -p control-plane --lib` 37 passed + - `cargo test -p mnote-web --lib routes::compat` 4 passed + +## 3. 仍可后续(非阻塞) + +| 项 | 说明 | +|---|---| +| ACP 列名 `acp_*` | 控制面 schema 兼容;改列需独立迁移批 | +| actor 占位拒绝 `hermes` | `mnote_tools` 仍拒绝 actor=`hermes` 占位 id(负向安全) | +| `mnote-hermes-tool` source | document-session 历史 external write source 兼容读 | +| `.reasonix` / `.opencode` 目录忽略 | local_folder 扫描忽略,非产品入口 | +| design/old 历史稿 | 不阻塞产品面 | +| 测试函数名 `hermes_tools_*` 前缀 | 历史命名,schema 已中性化 | + +## 4. 验收 Checklist + +- [x] `cargo check -p mnote-web --tests` 无 error +- [x] `cargo test -p bridge-runtime --lib` 全通过 +- [x] `cargo test -p control-plane --lib` 全通过 +- [x] AGENTS.md 不再把退役 agent 宿主写成可选主链 +- [x] smoke 不再注入 `MNOTE_WEB_*LEGACY_NEXT*` +- [x] 产品 profile home 不再读 `HERMES_HOME` / `~/.hermes` +- [ ] (可选)ARCHITECTURE.md 全文扫 brand 叙事 +- [ ] (可选)ACP 列 rename 迁移批 + +## 5. 工具回扫命令 + +```bash +rg -n "legacy_next|LEGACY_NEXT|HERMES_HOME|RustWebHermes|enable_legacy_next" rust scripts --glob '!**/target/**' +rg -n "MNOTE_WEB_ENABLE_LEGACY_NEXT|MNOTE_HERMES" scripts .env.all +cargo check -p mnote-web --tests +``` diff --git a/rust/crates/adapter-onlyoffice/src/lib.rs b/rust/crates/adapter-onlyoffice/src/lib.rs index 19cc22f6..77a09f8e 100644 --- a/rust/crates/adapter-onlyoffice/src/lib.rs +++ b/rust/crates/adapter-onlyoffice/src/lib.rs @@ -50,7 +50,7 @@ pub struct OnlyOfficeProxyPreparationInput { pub supabase_url: Option, pub supabase_internal_url: Option, pub onlyoffice_storage_host_override: Option, - pub convex_origin: Option, + pub extra_origin: Option, pub supabase_anon_key: Option, } @@ -239,7 +239,7 @@ pub fn prepare_proxy_request( let supa = try_parse_origin_host(input.supabase_url.as_deref()); let supa_internal_origin = try_parse_origin_url(input.supabase_internal_url.as_deref()); let storage_override = try_parse_origin_host(input.onlyoffice_storage_host_override.as_deref()); - let convex_origin = try_parse_origin_url(input.convex_origin.as_deref()); + let extra_origin = try_parse_origin_url(input.extra_origin.as_deref()); let is_supabase_path = target.path().starts_with("/storage/v1/") || target.path().starts_with("/auth/v1/") @@ -302,7 +302,7 @@ pub fn prepare_proxy_request( add_allowed(hostname, port); } - if let Some(origin) = convex_origin.as_ref() { + if let Some(origin) = extra_origin.as_ref() { let port = origin .port() .map(|value| value.to_string()) @@ -792,7 +792,7 @@ mod tests { supabase_url: Some("https://public.example.com".into()), supabase_internal_url: Some("http://127.0.0.1:18000".into()), onlyoffice_storage_host_override: None, - convex_origin: Some("http://127.0.0.1:3210".into()), + extra_origin: Some("http://127.0.0.1:3210".into()), supabase_anon_key: Some("anon".into()), }) .expect("proxy should prepare"); diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index a50dad85..9e726025 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -133,8 +133,22 @@ impl BridgeError { } } +/// 协议层 query 计划请求(local-first kernel 名,无外部 transport 别名)。 #[derive(Debug, Clone, PartialEq, Eq)] -pub struct RetiredMutationRequest { +pub struct BridgeQueryRequest { + pub function_name: String, + pub deployment_id: Option, + pub project_id: Option, + pub workspace_id: Option, + pub request_id: String, + pub trace_id: String, + pub actor_id: String, + pub payload_json: String, +} + +/// 协议层 write 计划请求(local-first kernel 名,无外部 transport 别名)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeWriteRequest { pub function_name: String, pub deployment_id: Option, pub project_id: Option, @@ -146,30 +160,18 @@ pub struct RetiredMutationRequest { pub payload_json: String, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RetiredQueryRequest { - pub function_name: String, - pub deployment_id: Option, - pub project_id: Option, - pub workspace_id: Option, - pub request_id: String, - pub trace_id: String, - pub actor_id: String, - pub payload_json: String, -} - -fn retired_bridge_error() -> BridgeError { - BridgeError::validation( - "旧 Convex 兼容桥已退役;请改用 local-first Rust/SQLite control-plane 路径", - ) +fn unknown_protocol_name_error(name: &str) -> BridgeError { + BridgeError::validation(format!( + "未知协议名 `{name}`;仅支持 local-first kernel 协议命令/查询" + )) } pub fn build_query_request( context: &BridgeContext, query: &QueryEnvelope, -) -> BridgeResult { - legacy_query_function_name(&query.name)?; - Ok(RetiredQueryRequest { +) -> BridgeResult { + ensure_known_query_name(&query.name)?; + Ok(BridgeQueryRequest { function_name: query.name.clone(), deployment_id: context.deployment_id.clone(), project_id: context.project_id.clone(), @@ -184,9 +186,9 @@ pub fn build_query_request( pub fn build_write_request( context: &BridgeContext, command: &CommandEnvelope, -) -> BridgeResult { - legacy_command_function_name(&command.name)?; - Ok(RetiredMutationRequest { +) -> BridgeResult { + ensure_known_command_name(&command.name)?; + Ok(BridgeWriteRequest { function_name: command.name.clone(), deployment_id: context.deployment_id.clone(), project_id: context.project_id.clone(), @@ -203,71 +205,62 @@ pub fn build_write_request( }) } -fn legacy_query_function_name(name: &str) -> BridgeResult<&'static str> { +fn ensure_known_query_name(name: &str) -> BridgeResult<()> { match name { - "documents.content.get" => Ok("documents:getContent"), - "documents.meta.get" => Ok("documents:getMeta"), - "blocks.get" => Ok("blocks:getById"), - "mindmaps.get" => Ok("mindmaps:get"), - "search.documents" => Ok("search:documents"), - "search_blocks" => Ok("documents:searchBlocks"), - "sidebar.dataset.list" => Ok("sidebar:datasetList"), - "bridge.request.get" => Ok("bridgeLogs:listByRequest"), - "bridge.trace.get" => Ok("bridgeLogs:listByTrace"), - "bridge.command.get" => Ok("bridgeLogs:listByCommand"), - "bridge.workspace.overview" => Ok("bridgeLogs:listWorkspaceOverview"), - _ => Err(retired_bridge_error()), + "documents.content.get" + | "documents.meta.get" + | "blocks.get" + | "mindmaps.get" + | "search.documents" + | "search_blocks" + | "sidebar.dataset.list" + | "bridge.request.get" + | "bridge.trace.get" + | "bridge.command.get" + | "bridge.workspace.overview" => Ok(()), + _ => Err(unknown_protocol_name_error(name)), } } -pub fn retired_query_transport_function_name(name: &str) -> BridgeResult<&'static str> { - legacy_query_function_name(name) -} - -fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> { +fn ensure_known_command_name(name: &str) -> BridgeResult<()> { match name { - "documents.title.update" => Ok("documents:updateTitle"), - "page.head.updateTitle" | "tree.node.rename" => Ok("documents:updateTitle"), - "documents.save" => Ok("documents:updateContent"), - "page.body.save" => Ok("documents:updateContent"), - "documents.options.update" | "page.layout.updateOptions" => Ok("documents:updateOptions"), - "documents.stats.update" => Ok("documents:updateStats"), - "documents.create" | "tree.node.create" => Ok("documents:createWithParentReference"), - "documents.move" | "tree.node.move" | "tree.subtree.move" => Ok("documents:move"), - "documents.delete" | "tree.node.archive" => Ok("documents:softDelete"), - "documents.restore" | "tree.node.restore" => Ok("documents:restore"), - "documents.purge" | "tree.node.purge" => Ok("documents:purge"), - "documents.copy_tree" | "tree.subtree.copy" => Ok("documents:copyTree"), - "documents.duplicate" => Ok("documents:duplicateWithMindmaps"), - "documents.embed" | "tree.node.embed" => Ok("documents:updateContent"), - "documents.emptyTrashByWorkspace" | "tree.trash.emptyWorkspace" => { - Ok("documents:emptyTrashByWorkspace") - } - "insert_block" | "blocks.patch" => Ok("documents:updateContent"), - "blocks.move" => Ok("blocks:move"), - "blocks.embed" => Ok("blocks:insert"), - "mindmaps.put" => Ok("mindmaps:put"), - "mindmap.command.apply" => Ok("mindmaps:applyCommand"), - "media.assets.replace_storage" => Ok("mediaAssets:replaceStorageFromUpload"), - "tree.filetree.drop.preflight" => Ok("tree:fileTreeDropPreflight"), - "tree.filetree.delete.preflight" => Ok("tree:fileTreeDeletePreflight"), - "tree.filetree.paste.preflight" => Ok("tree:fileTreePastePreflight"), - "tree.filetree.upload-target.preflight" => Ok("tree:fileTreeUploadTargetPreflight"), - "tree.resource.copy" => Ok("mediaAssets:batchCopy"), - "tree.resource.move" => Ok("mediaAssets:batchMove"), - "tree.resource.upload" => Ok("mediaAssets:createWithStorage"), - "tree.resource.archive" => Ok("treeResource:archive"), - "tree.resource.restore" => Ok("treeResource:restore"), - "tree.resource.purge" => Ok("treeResource:purge"), - "tree.resource.rename" => Ok("treeResource:rename"), - _ => Err(retired_bridge_error()), + "page.head.updateTitle" + | "tree.node.rename" + | "page.body.save" + | "page.layout.updateOptions" + | "documents.stats.update" + | "tree.node.create" + | "tree.node.move" + | "tree.subtree.move" + | "tree.node.archive" + | "tree.node.restore" + | "tree.node.purge" + | "tree.subtree.copy" + | "documents.duplicate" + | "tree.node.embed" + | "tree.trash.emptyWorkspace" + | "insert_block" + | "blocks.patch" + | "blocks.move" + | "blocks.embed" + | "mindmaps.put" + | "mindmap.command.apply" + | "media.assets.replace_storage" + | "tree.filetree.drop.preflight" + | "tree.filetree.delete.preflight" + | "tree.filetree.paste.preflight" + | "tree.filetree.upload-target.preflight" + | "tree.resource.copy" + | "tree.resource.move" + | "tree.resource.upload" + | "tree.resource.archive" + | "tree.resource.restore" + | "tree.resource.purge" + | "tree.resource.rename" => Ok(()), + _ => Err(unknown_protocol_name_error(name)), } } -pub fn retired_command_transport_function_name(name: &str) -> BridgeResult<&'static str> { - legacy_command_function_name(name) -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum RuntimeInput { @@ -1892,22 +1885,14 @@ fn build_resource_lifecycle_plan( Ok(plan) } -fn retired_resource_lifecycle_transport_function( +fn ensure_resource_lifecycle_supported( action: &str, resource_kind: &str, -) -> Result<&'static str, BridgeError> { +) -> Result<(), BridgeError> { match (action, resource_kind) { - ("archive", "file") | ("restore", "file") | ("rename", "file") => { - Ok("mediaAssets:patchById") - } - ("purge", "file") => Ok("mediaAssets:purgeById"), - ("archive", "mindmap") => Ok("mindmaps:softDelete"), - ("restore", "mindmap") => Ok("mindmaps:restore"), - ("purge", "mindmap") => Ok("mindmaps:purge"), - ("archive", "table") => Ok("tables:remove"), - ("restore", "table") => Ok("tables:restore"), - ("purge", "table") => Ok("tables:purge"), - ("rename", "table") => Ok("tables:update"), + ("archive" | "restore" | "rename" | "purge", "file") + | ("archive" | "restore" | "purge", "mindmap") + | ("archive" | "restore" | "purge" | "rename", "table") => Ok(()), ("rename", "mindmap") => Err(BridgeError::validation( "tree.resource.rename 暂不支持 mindmap 资源", )), @@ -2651,17 +2636,11 @@ fn tree_domain_event_hint(event_type: &str) -> Value { }) } -fn tree_command_protocol_hint( - command_name: &str, - preferred_command_name: &str, - compat_command_name: &str, -) -> Value { +fn tree_command_protocol_hint(command_name: &str) -> Value { json!({ "family": "tree", "owner": "rust-runtime-kernel", - "preferredCommandName": preferred_command_name, - "compatCommandName": compat_command_name, - "deprecatedAlias": command_name == compat_command_name, + "commandName": command_name, }) } @@ -3152,44 +3131,36 @@ fn workspace_source_value(source: &RuntimeSourceWire) -> Value { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WorkspaceCommandExecutor { - LegacyConvex, - ConvexWorkspace, LocalFolder, } fn resolve_workspace_command_executor( source: &RuntimeSourceWire, ) -> Result { - let Some(source_kind) = source + let source_kind = source .source_kind .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - else { - return Ok(WorkspaceCommandExecutor::LegacyConvex); - }; + .unwrap_or("local_folder"); - let executor = match source_kind { - "convex_workspace" => WorkspaceCommandExecutor::ConvexWorkspace, - "local_folder" => WorkspaceCommandExecutor::LocalFolder, - other => { - return Err(BridgeError::validation(format!( - "unknown workspace source kind: {other}" - ))) - } - }; + if source_kind != "local_folder" { + return Err(BridgeError::validation(format!( + "workspace source kind 仅支持 local_folder,收到: {source_kind}" + ))); + } if !source .capabilities .iter() .any(|capability| capability.trim() == "execute-command") { - return Err(BridgeError::validation(format!( - "workspace source {source_kind} missing capability: execute-command" - ))); + return Err(BridgeError::validation( + "workspace source local_folder missing capability: execute-command", + )); } - Ok(executor) + Ok(WorkspaceCommandExecutor::LocalFolder) } fn read_preflight_field<'a>( @@ -8425,59 +8396,14 @@ fn make_projection_edge( } } -fn convex_projection_source_metadata( - document_id: Option<&str>, - asset_id: Option<&str>, - workspace_id: Option<&str>, - row_kind: &str, +fn projection_source_metadata( + _document_id: Option<&str>, + _asset_id: Option<&str>, + _workspace_id: Option<&str>, + _row_kind: &str, ) -> Option { - let workspace_id = workspace_id?.trim(); - if workspace_id.is_empty() { - return None; - } - - match row_kind { - "document" => { - let document_id = document_id?.trim(); - if document_id.is_empty() { - return None; - } - Some(json!({ - "sourceKind": "convex_workspace", - "sourceUri": format!("convex://workspace/{workspace_id}/documents/{document_id}"), - "relativePath": format!("documents/{document_id}"), - "storageIdentity": document_id, - "operationProfile": "convex_workspace", - })) - } - "index" => { - let document_id = document_id?.trim(); - if document_id.is_empty() { - return None; - } - Some(json!({ - "sourceKind": "convex_workspace", - "sourceUri": format!("convex://workspace/{workspace_id}/documents/{document_id}/index"), - "relativePath": format!("documents/{document_id}/index"), - "storageIdentity": format!("{document_id}:index"), - "operationProfile": "convex_workspace", - })) - } - "asset" | "asset_folder" => { - let asset_id = asset_id?.trim(); - if asset_id.is_empty() { - return None; - } - Some(json!({ - "sourceKind": "convex_workspace", - "sourceUri": format!("convex://workspace/{workspace_id}/assets/{asset_id}"), - "relativePath": format!("assets/{asset_id}"), - "storageIdentity": asset_id, - "operationProfile": "convex_workspace", - })) - } - _ => None, - } + // 非 local projection 元数据已移除;local_folder 由 local projection builder 自行标注。 + None } fn make_projection_resource_meta( @@ -8492,7 +8418,7 @@ fn make_projection_resource_meta( ) -> KernelProjectionResourceMeta { let mut extra = BTreeMap::new(); extra.insert("rowKind".into(), json!(row_kind)); - if let Some(source) = convex_projection_source_metadata( + if let Some(source) = projection_source_metadata( document_id.as_deref(), asset_id.as_deref(), workspace_id.as_deref(), @@ -9510,12 +9436,8 @@ fn execute_command( command_wire: RuntimeCommandEnvelopeWire, ) -> Result { let context = to_bridge_context(context_wire); - let source_executor = resolve_workspace_command_executor(&command_wire.source)?; - if source_executor == WorkspaceCommandExecutor::LocalFolder { - return Err(BridgeError::validation( - "local_folder command executor 尚未接入 runtime command router", - )); - } + // local-only:校验 source 后直接构建 runtime command plan。 + let _source_executor = resolve_workspace_command_executor(&command_wire.source)?; match command_wire.name.as_str() { "kernel.node.create" => { let payload: KernelCreateNodeCommandPayload = @@ -9875,21 +9797,16 @@ fn execute_command( }), })) } - "documents.title.update" | "tree.node.rename" | "page.head.updateTitle" => { + "tree.node.rename" | "page.head.updateTitle" => { let payload: DocumentTitleCommandPayload = parse_payload(command_wire.payload.clone())?; let command_name = match command_wire.name.as_str() { - "tree.node.rename" => "tree.node.rename", "page.head.updateTitle" => "page.head.updateTitle", - _ => "documents.title.update", + _ => "tree.node.rename", }; let command_protocol = if command_name == "page.head.updateTitle" { None } else { - Some(tree_command_protocol_hint( - command_name, - "tree.node.rename", - "documents.title.update", - )) + Some(tree_command_protocol_hint(command_name)) }; let command = CommandEnvelope { name: command_name.into(), @@ -9949,13 +9866,10 @@ fn execute_command( }, })) } - "documents.options.update" | "page.layout.updateOptions" => { + "page.layout.updateOptions" => { let payload: DocumentOptionsCommandPayload = parse_payload(command_wire.payload.clone())?; - let command_name = match command_wire.name.as_str() { - "page.layout.updateOptions" => "page.layout.updateOptions", - _ => "documents.options.update", - }; + let command_name = "page.layout.updateOptions"; let options = payload.options; let mut options_json = serde_json::Map::new(); if let Some(value) = options.wide_layout { @@ -10145,7 +10059,7 @@ fn execute_command( }), })) } - "documents.save" | "page.body.save" => { + "page.body.save" => { let payload: DocumentSaveCommandPayload = parse_payload(command_wire.payload.clone())?; let editor_document = normalize_save_editor_document(&payload)?; let canonical_content = legacy_content_from_editor_document(&editor_document); @@ -10222,13 +10136,9 @@ fn execute_command( }, })) } - "documents.embed" | "tree.node.embed" => { + "tree.node.embed" => { let payload: DocumentEmbedCommandPayload = parse_payload(command_wire.payload.clone())?; - let command_name = if command_wire.name == "tree.node.embed" { - "tree.node.embed" - } else { - "documents.embed" - }; + let command_name = "tree.node.embed"; let page_aggregate_embed_plan = build_page_aggregate_embed_plan(&command_wire, &payload)?; let embed_content = page_aggregate_embed_plan @@ -10569,14 +10479,10 @@ fn execute_command( }), })) } - "documents.create" | "tree.node.create" => { + "tree.node.create" => { let payload: DocumentCreateCommandPayload = parse_payload(command_wire.payload.clone())?; - let command_name = if command_wire.name == "tree.node.create" { - "tree.node.create" - } else { - "documents.create" - }; + let command_name = "tree.node.create"; let command = CommandEnvelope { name: command_name.into(), command_id: command_wire.command_id.clone(), @@ -10609,11 +10515,7 @@ fn execute_command( "title": payload.title, "accessScope": payload.access_scope, "content": payload.content, - "commandProtocol": tree_command_protocol_hint( - command_name, - "tree.node.create", - "documents.create", - ), + "commandProtocol": tree_command_protocol_hint(command_name), "streamDeltaHint": tree_document_result_hint("document"), "domainEventHint": tree_domain_event_hint("tree.node.created"), "domainEventPlan": tree_domain_event_plan( @@ -10623,7 +10525,7 @@ fn execute_command( }), })) } - "documents.move" | "tree.subtree.move" => { + "tree.subtree.move" => { let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?; validate_document_move_legality(&payload, command_wire.preflight_data.as_ref())?; let move_order_plan = @@ -10632,11 +10534,7 @@ fn execute_command( context.workspace_id.as_deref(), move_order_plan.as_ref(), ); - let command_name = if command_wire.name == "tree.subtree.move" { - "tree.subtree.move" - } else { - "documents.move" - }; + let command_name = "tree.subtree.move"; let command = CommandEnvelope { name: command_name.into(), command_id: command_wire.command_id.clone(), @@ -10667,11 +10565,7 @@ fn execute_command( "parentId": payload.parent_id, "sortOrder": payload.sort_order, "treeWriteOperation": tree_write_operation, - "commandProtocol": tree_command_protocol_hint( - command_name, - "tree.subtree.move", - "documents.move", - ), + "commandProtocol": tree_command_protocol_hint(command_name), "streamDeltaHint": tree_stream_delta_hint("move_document", json!({ "documentId": payload.document_id, "parentId": payload.parent_id, @@ -10689,14 +10583,10 @@ fn execute_command( }), })) } - "documents.delete" | "tree.node.archive" => { + "tree.node.archive" => { let payload: DocumentDeleteCommandPayload = parse_payload(command_wire.payload.clone())?; - let command_name = if command_wire.name == "tree.node.archive" { - "tree.node.archive" - } else { - "documents.delete" - }; + let command_name = "tree.node.archive"; let command = CommandEnvelope { name: command_name.into(), command_id: command_wire.command_id.clone(), @@ -10724,11 +10614,7 @@ fn execute_command( payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, - "commandProtocol": tree_command_protocol_hint( - command_name, - "tree.node.archive", - "documents.delete", - ), + "commandProtocol": tree_command_protocol_hint(command_name), "streamDeltaHint": tree_stream_delta_hint("remove_document", json!({ "documentId": payload.document_id, })), @@ -10742,14 +10628,10 @@ fn execute_command( }), })) } - "documents.restore" | "tree.node.restore" => { + "tree.node.restore" => { let payload: DocumentRestoreCommandPayload = parse_payload(command_wire.payload.clone())?; - let command_name = if command_wire.name == "tree.node.restore" { - "tree.node.restore" - } else { - "documents.restore" - }; + let command_name = "tree.node.restore"; let command = CommandEnvelope { name: command_name.into(), command_id: command_wire.command_id.clone(), @@ -10777,11 +10659,7 @@ fn execute_command( payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, - "commandProtocol": tree_command_protocol_hint( - command_name, - "tree.node.restore", - "documents.restore", - ), + "commandProtocol": tree_command_protocol_hint(command_name), "streamDeltaHint": tree_document_result_hint("document"), "domainEventHint": tree_domain_event_hint("tree.node.restored"), "domainEventPlan": tree_domain_event_plan( @@ -10866,14 +10744,10 @@ fn execute_command( }), })) } - "documents.emptyTrashByWorkspace" | "tree.trash.emptyWorkspace" => { + "tree.trash.emptyWorkspace" => { let payload: DocumentEmptyTrashCommandPayload = parse_payload(command_wire.payload.clone())?; - let command_name = if command_wire.name == "tree.trash.emptyWorkspace" { - "tree.trash.emptyWorkspace" - } else { - "documents.emptyTrashByWorkspace" - }; + let command_name = "tree.trash.emptyWorkspace"; let stream_delta_hint = tree_resync_required_hint( "documents_empty_trash", json!({ @@ -10916,13 +10790,9 @@ fn execute_command( }), })) } - "documents.purge" | "tree.node.purge" => { + "tree.node.purge" => { let payload: DocumentPurgeCommandPayload = parse_payload(command_wire.payload.clone())?; - let command_name = if command_wire.name == "tree.node.purge" { - "tree.node.purge" - } else { - "documents.purge" - }; + let command_name = "tree.node.purge"; let command = CommandEnvelope { name: command_name.into(), command_id: command_wire.command_id.clone(), @@ -10950,22 +10820,14 @@ fn execute_command( payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, - "commandProtocol": tree_command_protocol_hint( - command_name, - "tree.node.purge", - "documents.purge", - ), + "commandProtocol": tree_command_protocol_hint(command_name), }), })) } - "documents.copy_tree" | "tree.subtree.copy" => { + "tree.subtree.copy" => { let payload: DocumentCopyTreeCommandPayload = parse_payload(command_wire.payload.clone())?; - let command_name = if command_wire.name == "tree.subtree.copy" { - "tree.subtree.copy" - } else { - "documents.copy_tree" - }; + let command_name = "tree.subtree.copy"; let command = CommandEnvelope { name: command_name.into(), command_id: command_wire.command_id.clone(), @@ -10997,11 +10859,7 @@ fn execute_command( "recursive": item.recursive, })).collect::>(), "targetParentId": payload.target_parent_id, - "commandProtocol": tree_command_protocol_hint( - command_name, - "tree.subtree.copy", - "documents.copy_tree", - ), + "commandProtocol": tree_command_protocol_hint(command_name), "streamDeltaHint": tree_stream_delta_hint("copy_result", json!({ "itemsField": "items", "documentField": "document", @@ -11293,7 +11151,7 @@ fn execute_command( validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; - retired_resource_lifecycle_transport_function(action, &lifecycle_plan.resource_kind)?; + ensure_resource_lifecycle_supported(action, &lifecycle_plan.resource_kind)?; let stream_delta_hint = resource_lifecycle_stream_delta_hint(&lifecycle_plan); let event_type = resource_lifecycle_event_type(action); @@ -11591,9 +11449,9 @@ fn normalize_save_editor_document( match serde_json::from_value::(editor_document) { Ok(mut parsed) => { let raw = payload.editor_document.as_ref().ok_or_else(|| { - BridgeError::validation("documents.save editorDocument 非法: 缺少原始值") + BridgeError::validation("page.body.save editorDocument 非法: 缺少原始值") })?; - validate_editor_document_structure(raw, &parsed, "documents.save")?; + validate_editor_document_structure(raw, &parsed, "page.body.save")?; hydrate_editor_document_props_from_raw( &mut parsed, payload.editor_document.as_ref(), @@ -11612,14 +11470,14 @@ fn normalize_save_editor_document( } Err(error) => { return Err(BridgeError::validation(format!( - "documents.save editorDocument 非法: {error}" + "page.body.save editorDocument 非法: {error}" ))); } } } if let Some(tiptap_document) = payload.tiptap_document.clone() { let parsed = serde_json::from_value::(tiptap_document).map_err(|error| { - BridgeError::validation(format!("documents.save tiptapDocument 非法: {error}")) + BridgeError::validation(format!("page.body.save tiptapDocument 非法: {error}")) })?; return EditorBlockDocumentTiptapBridge::from_tiptap_doc( payload.document_id.clone(), @@ -11627,7 +11485,7 @@ fn normalize_save_editor_document( ) .map_err(|error| { BridgeError::validation(format!( - "documents.save tiptap -> editorDocument 失败: {error:?}" + "page.body.save tiptap -> editorDocument 失败: {error:?}" )) }); } @@ -12414,7 +12272,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, tenant_id: None, auth_token: None, @@ -12509,7 +12367,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -12612,8 +12470,8 @@ mod tests { source: RuntimeSourceWire { channel: "mnote-tree-shell-v1".into(), client: "mnote-web".into(), - source_kind: Some("convex_workspace".into()), - root_uri: Some("convex://workspace/ws_1".into()), + source_kind: Some("local_folder".into()), + root_uri: Some("file:///tmp/ws_1".into()), workspace_id: Some("ws_1".into()), capabilities: vec![ "load-snapshot".into(), @@ -12642,8 +12500,8 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "tree.node.rename"); - assert_eq!(plan.source["sourceKind"], json!("convex_workspace")); - assert_eq!(plan.source["rootUri"], json!("convex://workspace/ws_1")); + assert_eq!(plan.source["sourceKind"], json!("local_folder")); + assert_eq!(plan.source["rootUri"], json!("file:///tmp/ws_1")); assert_eq!(plan.source["workspaceId"], json!("ws_1")); assert_eq!( plan.source["capabilities"], @@ -12695,7 +12553,7 @@ mod tests { .expect_err("unknown source kind should be rejected"); assert_eq!(error.kind, BridgeErrorKind::Validation); - assert!(error.message.contains("unknown_source")); + assert!(error.message.contains("local_folder") || error.message.contains("unknown")); } #[test] @@ -12714,8 +12572,8 @@ mod tests { source: RuntimeSourceWire { channel: "mnote-tree-shell-v1".into(), client: "mnote-web".into(), - source_kind: Some("convex_workspace".into()), - root_uri: Some("convex://workspace/ws_1".into()), + source_kind: Some("local_folder".into()), + root_uri: Some("file:///tmp/ws_1".into()), workspace_id: Some("ws_1".into()), capabilities: vec!["load-snapshot".into(), "preflight-command".into()], }, @@ -13804,7 +13662,7 @@ mod tests { } #[test] - fn mindmap_adapter_scene_reads_metadata_from_convex_data_wrapper() { + fn mindmap_adapter_scene_reads_metadata_from_nested_data_wrapper() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { @@ -13832,7 +13690,7 @@ mod tests { "meta": {"mindmap_id": "mind_1"} })), }) - .expect("mindmap adapter projection should read Convex data wrapper"); + .expect("mindmap adapter projection should read nested data wrapper"); assert_eq!(result["root"]["data"]["text"], json!("KMIND")); assert_eq!(result["view"]["state"]["scale"], json!(1.25)); @@ -13929,7 +13787,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: None, payload: json!({ @@ -14263,7 +14121,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -14355,7 +14213,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -14711,7 +14569,7 @@ mod tests { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { - name: "documents.save".into(), + name: "page.body.save".into(), command_id: "cmd_save_1".into(), idempotency_key: Some("idem_save".into()), actor: RuntimeActorWire { @@ -14725,7 +14583,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -14763,13 +14621,13 @@ mod tests { validate_only: false, }, }) - .expect("documents.save plan should build"); + .expect("page.body.save plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; - assert_eq!(plan.function_name, "documents.save"); + assert_eq!(plan.function_name, "page.body.save"); assert_eq!(plan.args_json.get("id"), Some(&json!("doc_1"))); assert_eq!( plan.args_json.pointer("/editorDocument/rootBlockIds/0"), @@ -14791,7 +14649,7 @@ mod tests { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { - name: "documents.save".into(), + name: "page.body.save".into(), command_id: "cmd_save_divider".into(), idempotency_key: Some("idem_save_divider".into()), actor: RuntimeActorWire { @@ -14805,7 +14663,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -14837,7 +14695,7 @@ mod tests { validate_only: false, }, }) - .expect("documents.save horizontalRule plan should build"); + .expect("page.body.save horizontalRule plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); @@ -14862,7 +14720,7 @@ mod tests { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { - name: "documents.save".into(), + name: "page.body.save".into(), command_id: "cmd_save_mindmap".into(), idempotency_key: Some("idem_save_mindmap".into()), actor: RuntimeActorWire { @@ -14876,7 +14734,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -14910,7 +14768,7 @@ mod tests { validate_only: false, }, }) - .expect("documents.save mindmap plan should build"); + .expect("page.body.save mindmap plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); @@ -14958,7 +14816,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -15034,7 +14892,7 @@ mod tests { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { - name: "documents.save".into(), + name: "page.body.save".into(), command_id: "cmd_save_image".into(), idempotency_key: Some("idem_save_image".into()), actor: RuntimeActorWire { @@ -15048,7 +14906,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -15080,7 +14938,7 @@ mod tests { validate_only: false, }, }) - .expect("documents.save image plan should build"); + .expect("page.body.save image plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); @@ -15109,7 +14967,7 @@ mod tests { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { - name: "documents.save".into(), + name: "page.body.save".into(), command_id: "cmd_save_table".into(), idempotency_key: Some("idem_save_table".into()), actor: RuntimeActorWire { @@ -15123,7 +14981,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -15159,7 +15017,7 @@ mod tests { validate_only: false, }, }) - .expect("documents.save table plan should build"); + .expect("page.body.save table plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); @@ -15188,7 +15046,7 @@ mod tests { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { - name: "documents.save".into(), + name: "page.body.save".into(), command_id: "cmd_save_nested_list".into(), idempotency_key: Some("idem_save_nested_list".into()), actor: RuntimeActorWire { @@ -15202,7 +15060,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -15241,7 +15099,7 @@ mod tests { validate_only: false, }, }) - .expect("documents.save nested list plan should build"); + .expect("page.body.save nested list plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); @@ -15286,7 +15144,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -15337,7 +15195,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: None, payload: json!({ @@ -15379,7 +15237,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: None, payload: json!({ @@ -15408,7 +15266,7 @@ mod tests { let error = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { - name: "documents.save".into(), + name: "page.body.save".into(), command_id: "cmd_save_2".into(), idempotency_key: Some("idem_save_2".into()), actor: RuntimeActorWire { @@ -15422,7 +15280,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -15482,7 +15340,7 @@ mod tests { validate_only: false, }, }) - .expect_err("documents.save should reject invalid editorDocument"); + .expect_err("page.body.save should reject invalid editorDocument"); assert_eq!(error.kind, BridgeErrorKind::Validation); assert!(error.message.contains("editorDocument")); @@ -15507,7 +15365,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -15562,7 +15420,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -15613,7 +15471,7 @@ mod tests { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { - name: "documents.save".into(), + name: "page.body.save".into(), command_id: "cmd_save_3".into(), idempotency_key: Some("idem_save_3".into()), actor: RuntimeActorWire { @@ -15627,7 +15485,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -15692,13 +15550,13 @@ mod tests { validate_only: false, }, }) - .expect("documents.save plan should build"); + .expect("page.body.save plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; - assert_eq!(plan.function_name, "documents.save"); + assert_eq!(plan.function_name, "page.body.save"); assert_eq!( plan.args_json.pointer("/content/0/content"), Some(&json!("来自 editorDocument")) @@ -15733,7 +15591,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -15800,7 +15658,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -15956,7 +15814,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -16059,7 +15917,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -16154,7 +16012,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -16320,7 +16178,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -16395,7 +16253,7 @@ mod tests { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { - name: "documents.embed".into(), + name: "tree.node.embed".into(), command_id: "cmd_doc_embed_1".into(), idempotency_key: Some("idem_doc_embed".into()), actor: RuntimeActorWire { @@ -16409,7 +16267,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -16437,8 +16295,8 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { - assert_eq!(plan.function_name, "documents.embed"); - assert_eq!(plan.command_name, "documents.embed"); + assert_eq!(plan.function_name, "tree.node.embed"); + assert_eq!(plan.command_name, "tree.node.embed"); assert_eq!( plan.args_json, json!({ @@ -16491,12 +16349,10 @@ mod tests { json!({ "id": "doc_1", "commandProtocol": { - "family": "tree", - "owner": "rust-runtime-kernel", - "preferredCommandName": "tree.node.archive", - "compatCommandName": "documents.delete", - "deprecatedAlias": false - }, + "family": "tree", + "owner": "rust-runtime-kernel", + "commandName": "tree.node.archive" + }, "streamDeltaHint": { "family": "tree", "kind": "remove_document", @@ -16533,12 +16389,10 @@ mod tests { json!({ "id": "doc_1", "commandProtocol": { - "family": "tree", - "owner": "rust-runtime-kernel", - "preferredCommandName": "tree.node.restore", - "compatCommandName": "documents.restore", - "deprecatedAlias": false - }, + "family": "tree", + "owner": "rust-runtime-kernel", + "commandName": "tree.node.restore" + }, "streamDeltaHint": { "family": "tree", "kind": "document_result", @@ -16574,12 +16428,10 @@ mod tests { json!({ "id": "doc_1", "commandProtocol": { - "family": "tree", - "owner": "rust-runtime-kernel", - "preferredCommandName": "tree.node.purge", - "compatCommandName": "documents.purge", - "deprecatedAlias": false - } + "family": "tree", + "owner": "rust-runtime-kernel", + "commandName": "tree.node.purge" + } }), ), ]; @@ -16602,7 +16454,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -16650,7 +16502,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -16719,7 +16571,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -16788,7 +16640,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -16857,7 +16709,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -16914,7 +16766,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -16987,7 +16839,7 @@ mod tests { } #[test] - fn document_move_order_plan_accepts_convex_float_sort_order_snapshot() { + fn document_move_order_plan_accepts_float_sort_order_snapshot() { let payload = DocumentMoveCommandPayload { document_id: "doc_b".into(), parent_id: None, @@ -17078,7 +16930,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -17118,9 +16970,7 @@ mod tests { json!({ "family": "tree", "owner": "rust-runtime-kernel", - "preferredCommandName": "tree.subtree.move", - "compatCommandName": "documents.move", - "deprecatedAlias": false + "commandName": "tree.subtree.move" }) ); assert!(plan.args_json.get("normalizedMove").is_none()); @@ -17180,118 +17030,6 @@ mod tests { ); } - #[test] - fn documents_lifecycle_aliases_are_marked_as_deprecated_tree_protocol_aliases() { - let cases = [ - ("documents.create", "tree.node.create", "documents.create"), - ( - "documents.title.update", - "tree.node.rename", - "documents.title.update", - ), - ("documents.move", "tree.subtree.move", "documents.move"), - ("documents.delete", "tree.node.archive", "documents.delete"), - ( - "documents.restore", - "tree.node.restore", - "documents.restore", - ), - ("documents.purge", "tree.node.purge", "documents.purge"), - ( - "documents.copy_tree", - "tree.subtree.copy", - "documents.copy_tree", - ), - ]; - - for (command_name, preferred_command_name, compat_command_name) in cases { - let command = RuntimeCommandEnvelopeWire { - name: command_name.into(), - command_id: format!("cmd_{command_name}"), - idempotency_key: Some(format!("idem_{command_name}")), - actor: RuntimeActorWire { - actor_type: "user".into(), - actor_id: "user_1".into(), - session_id: Some("sess_1".into()), - }, - source: RuntimeSourceWire { - channel: "next-route".into(), - client: "mnote-web".into(), - source_kind: None, - root_uri: None, - workspace_id: None, - capabilities: Vec::new(), - }, - target: Some(RuntimeTargetWire { - workspace_id: Some("ws_1".into()), - page_id: Some("doc_1".into()), - block_id: None, - }), - payload: match command_name { - "documents.create" => json!({ - "documentId": "doc_1", - "workspaceId": "ws_1", - "parentId": null, - "title": "新页面", - "accessScope": "private", - "content": [] - }), - "documents.title.update" => json!({ - "documentId": "doc_1", - "title": "改名" - }), - "documents.move" => json!({ - "documentId": "doc_1", - "parentId": null, - "sortOrder": 0 - }), - "documents.delete" | "documents.restore" | "documents.purge" => json!({ - "documentId": "doc_1", - "workspaceId": "ws_1" - }), - "documents.copy_tree" => json!({ - "items": [{"documentId": "doc_1", "recursive": true}], - "targetParentId": null - }), - _ => unreachable!("unexpected command"), - }, - preflight_data: if command_name == "documents.move" { - Some(json!({ - "documents": [ - { "id": "doc_1", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" } - ] - })) - } else { - None - }, - reason: Some("兼容 alias 标识验证".into()), - refs: vec![], - dry_run: false, - validate_only: false, - }; - let plan = execute_runtime_input(RuntimeInput::Command { - context: demo_context(), - command, - }) - .expect("compat command plan should build"); - let RuntimeExecutionPlan::Command(plan) = plan else { - panic!("expected command plan"); - }; - - assert_eq!( - plan.args_json["commandProtocol"], - json!({ - "family": "tree", - "owner": "rust-runtime-kernel", - "preferredCommandName": preferred_command_name, - "compatCommandName": compat_command_name, - "deprecatedAlias": true - }), - "{command_name} 应标记为 tree compat deprecated alias" - ); - } - } - #[test] fn tree_subtree_move_command_includes_tree_write_operation_from_snapshot() { let plan = execute_runtime_input(RuntimeInput::Command { @@ -17311,7 +17049,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -17386,7 +17124,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -17486,7 +17224,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -17572,7 +17310,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -17655,7 +17393,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -17743,7 +17481,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -17884,7 +17622,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -17924,12 +17662,10 @@ mod tests { ], "targetParentId": "parent_1", "commandProtocol": { - "family": "tree", - "owner": "rust-runtime-kernel", - "preferredCommandName": "tree.subtree.copy", - "compatCommandName": "documents.copy_tree", - "deprecatedAlias": false - }, + "family": "tree", + "owner": "rust-runtime-kernel", + "commandName": "tree.subtree.copy" + }, "streamDeltaHint": { "family": "tree", "kind": "copy_result", @@ -17965,11 +17701,11 @@ mod tests { } #[test] - fn documents_empty_trash_plan_emits_tree_resync_event() { + fn tree_trash_empty_workspace_plan_emits_tree_resync_event() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { - name: "documents.emptyTrashByWorkspace".into(), + name: "tree.trash.emptyWorkspace".into(), command_id: "cmd_empty_trash_1".into(), idempotency_key: Some("idem_empty_trash_1".into()), actor: RuntimeActorWire { @@ -17983,7 +17719,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: Some("ws_1".into()), - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -18004,8 +17740,8 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { - assert_eq!(plan.command_name, "documents.emptyTrashByWorkspace"); - assert_eq!(plan.function_name, "documents.emptyTrashByWorkspace"); + assert_eq!(plan.command_name, "tree.trash.emptyWorkspace"); + assert_eq!(plan.function_name, "tree.trash.emptyWorkspace"); assert_eq!(plan.args_json["workspaceId"], json!("ws_1")); assert_eq!( plan.args_json["streamDeltaHint"], @@ -18067,7 +17803,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -18125,7 +17861,7 @@ mod tests { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { - name: "documents.options.update".into(), + name: "page.layout.updateOptions".into(), command_id: "cmd_options_1".into(), idempotency_key: Some("idem_options_1".into()), actor: RuntimeActorWire { @@ -18139,7 +17875,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -18166,8 +17902,8 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { - assert_eq!(plan.command_name, "documents.options.update"); - assert_eq!(plan.function_name, "documents.options.update"); + assert_eq!(plan.command_name, "page.layout.updateOptions"); + assert_eq!(plan.function_name, "page.layout.updateOptions"); assert_eq!(plan.args_json["id"], json!("doc_1")); assert_eq!( plan.args_json["options"], @@ -18229,7 +17965,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -18297,7 +18033,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -18375,7 +18111,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -18663,7 +18399,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -18855,7 +18591,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -18912,7 +18648,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -18952,7 +18688,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -18998,7 +18734,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -19123,7 +18859,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -19186,7 +18922,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -19250,7 +18986,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -19317,7 +19053,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -19415,7 +19151,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -19514,7 +19250,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -19627,7 +19363,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -19701,7 +19437,7 @@ mod tests { source_kind: None, root_uri: None, workspace_id: None, - capabilities: Vec::new(), + capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -19848,7 +19584,7 @@ mod tests { refs: vec!["task-032".into()], }, data: Some(json!({ - "source": "convex", + "source": "local_folder", "data": { "data": { "uid": "root_1", @@ -19861,7 +19597,7 @@ mod tests { .expect("mindmap tool result should build"); assert_eq!(result.get("ok"), Some(&json!(true))); - assert_eq!(result.get("source"), Some(&json!("convex"))); + assert_eq!(result.get("source"), Some(&json!("local_folder"))); assert_eq!(result.get("applied"), Some(&json!(1))); assert_eq!(result.get("errors"), Some(&json!([]))); assert_eq!(result.pointer("/data/data/uid"), Some(&json!("root_1"))); @@ -20150,15 +19886,12 @@ mod tests { ); assert_eq!(item_by_row_id["doc:page_root"]["title"], json!("根页面.md")); assert!(!item_by_row_id.contains_key("index:page_root")); - assert_eq!( - item_by_row_id["doc:page_root"]["resourceMeta"]["extra"]["source"], - json!({ - "sourceKind": "convex_workspace", - "sourceUri": "convex://workspace/ws_1/documents/page_root", - "relativePath": "documents/page_root", - "storageIdentity": "page_root", - "operationProfile": "convex_workspace" - }) + assert!( + item_by_row_id["doc:page_root"]["resourceMeta"]["extra"] + .get("source") + .is_none() + || item_by_row_id["doc:page_root"]["resourceMeta"]["extra"]["source"].is_null(), + "kernel projection 不再注入 cloud/local stub source 元数据" ); assert_eq!( item_by_row_id["doc:page_root"]["resourceMeta"]["objectIdentity"], @@ -20177,15 +19910,12 @@ mod tests { item_by_row_id["asset:asset_img_1"]["resourceMeta"]["resourceKind"], json!("asset") ); - assert_eq!( - item_by_row_id["asset:asset_img_1"]["resourceMeta"]["extra"]["source"], - json!({ - "sourceKind": "convex_workspace", - "sourceUri": "convex://workspace/ws_1/assets/asset_img_1", - "relativePath": "assets/asset_img_1", - "storageIdentity": "asset_img_1", - "operationProfile": "convex_workspace" - }) + assert!( + item_by_row_id["asset:asset_img_1"]["resourceMeta"]["extra"] + .get("source") + .is_none() + || item_by_row_id["asset:asset_img_1"]["resourceMeta"]["extra"]["source"].is_null(), + "kernel projection 不再注入 cloud/local stub source 元数据" ); assert_eq!( item_by_row_id["asset-folder:mind_1"]["rowKind"], @@ -20605,7 +20335,7 @@ mod tests { } #[test] - fn convex_file_tree_projects_page_body_as_title_markdown_row_without_visible_index() { + fn file_tree_projects_page_body_as_title_markdown_row_without_visible_index() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { diff --git a/rust/crates/control-plane/migrations/002-ai-runtime-store.sql b/rust/crates/control-plane/migrations/002-ai-runtime-store.sql index 531ba396..e8a351dc 100644 --- a/rust/crates/control-plane/migrations/002-ai-runtime-store.sql +++ b/rust/crates/control-plane/migrations/002-ai-runtime-store.sql @@ -1,5 +1,5 @@ -- 002-ai-runtime-store.sql --- MNote ACP/Hermes runtime session store. +-- MNote AI runtime session store. CREATE TABLE IF NOT EXISTS ai_runtime_runs ( id TEXT PRIMARY KEY, diff --git a/rust/crates/control-plane/src/sqlite.rs b/rust/crates/control-plane/src/sqlite.rs index 1949d012..ecec7ce8 100644 --- a/rust/crates/control-plane/src/sqlite.rs +++ b/rust/crates/control-plane/src/sqlite.rs @@ -591,7 +591,7 @@ fn list_ai_agent_profile_access_rows( g.can_manage_config, g.created_at, g.updated_at, g.revision FROM ai_agent_profiles p JOIN ai_agent_profile_grants g ON g.profile_id = p.id - WHERE p.agent_id = 'hermes' + WHERE p.agent_id = 'pi' AND p.status = 'active' AND g.can_run = 1 AND ( @@ -2002,12 +2002,24 @@ impl ControlPlaneStore for SqliteControlPlaneStore { } let conn = self.lock_conn()?; let now = now_text(); + // 退役 brand:历史 hermes agent_id 就地迁到 pi,避免 UNIQUE 下再插一套孤儿行。 + conn.execute( + "UPDATE ai_agent_profiles + SET agent_id = 'pi', + display_name = CASE + WHEN display_name = '我的 Hermes' THEN '我的 Pi' + ELSE display_name + END, + updated_at = ?1 + WHERE agent_id = 'hermes'", + params![now], + )?; conn.execute( "INSERT INTO ai_agent_profiles ( id, agent_id, profile_kind, owner_user_id, base_profile_name, isolated_profile_name, display_name, status, created_at, updated_at, revision ) - VALUES ('shared_lite', 'hermes', 'shared', '', 'lite', 'lite', 'Lite', 'active', ?1, ?2, 1) + VALUES ('shared_lite', 'pi', 'shared', '', 'lite', 'lite', 'Lite', 'active', ?1, ?2, 1) ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name) DO UPDATE SET status = 'active', updated_at = excluded.updated_at", params![now, now], @@ -2093,7 +2105,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore { id, agent_id, profile_kind, owner_user_id, base_profile_name, isolated_profile_name, display_name, status, created_at, updated_at, revision ) - VALUES (?1, 'hermes', 'shared', '', ?2, ?3, ?4, 'active', ?5, ?6, 1) + VALUES (?1, 'pi', 'shared', '', ?2, ?3, ?4, 'active', ?5, ?6, 1) ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name) DO UPDATE SET status = 'active', display_name = excluded.display_name, isolated_profile_name = excluded.isolated_profile_name, updated_at = excluded.updated_at", @@ -2121,9 +2133,10 @@ impl ControlPlaneStore for SqliteControlPlaneStore { id, agent_id, profile_kind, owner_user_id, base_profile_name, isolated_profile_name, display_name, status, created_at, updated_at, revision ) - VALUES (?1, 'hermes', 'personal', ?2, 'default', ?3, '我的 Hermes', 'active', ?4, ?5, 1) + VALUES (?1, 'pi', 'personal', ?2, 'default', ?3, '我的 Pi', 'active', ?4, ?5, 1) ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name) - DO UPDATE SET status = 'active', updated_at = excluded.updated_at", + DO UPDATE SET status = 'active', display_name = excluded.display_name, + isolated_profile_name = excluded.isolated_profile_name, updated_at = excluded.updated_at", params![personal_profile_id, user_id, personal_profile_name, now, now], )?; let personal_grant_id = format!("grant_{personal_profile_id}_owner"); @@ -4461,8 +4474,8 @@ mod tests { 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(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), trace_id: Some("trace_1".to_string()), status: "running".to_string(), runtime_json: "{\"status\":\"running\"}".to_string(), @@ -4480,8 +4493,8 @@ mod tests { 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(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), trace_id: Some("trace_2".to_string()), status: "completed".to_string(), runtime_json: "{\"status\":\"completed\"}".to_string(), @@ -4511,8 +4524,8 @@ mod tests { 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(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), event_type: "message.delta".to_string(), payload_json: "{\"text\":\"hello\"}".to_string(), }) @@ -4526,8 +4539,8 @@ mod tests { 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(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), event_type: "run.completed".to_string(), payload_json: "{\"status\":\"completed\"}".to_string(), }) @@ -4639,8 +4652,8 @@ mod tests { session_id: "sess_1".to_string(), run_id: "run_1".to_string(), title: None, - profile: "reasonix".to_string(), - acp_runtime: "reasonix".to_string(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), trace_id: None, status: "completed".to_string(), runtime_json: "{\"status\":\"completed\"}".to_string(), @@ -4963,7 +4976,7 @@ mod tests { workspace_id: Some("ws_1".to_string()), session_id: "sess_1".to_string(), run_id: Some("run_2".to_string()), - provider: "reasonix".to_string(), + provider: "pi".to_string(), provider_session_id: None, tool_name: "fs.write".to_string(), allowed: false, diff --git a/rust/crates/control-plane/src/turso.rs b/rust/crates/control-plane/src/turso.rs index 52e55896..7063bf68 100644 --- a/rust/crates/control-plane/src/turso.rs +++ b/rust/crates/control-plane/src/turso.rs @@ -1125,7 +1125,7 @@ fn list_ai_agent_profile_access_rows( g.can_manage_config, g.created_at, g.updated_at, g.revision FROM ai_agent_profiles p JOIN ai_agent_profile_grants g ON g.profile_id = p.id - WHERE p.agent_id = 'hermes' + WHERE p.agent_id = 'pi' AND p.status = 'active' AND g.can_run = 1 AND ( @@ -2530,12 +2530,24 @@ impl ControlPlaneStore for TursoControlPlaneStore { } let conn = self.lock_conn()?; let now = now_text(); + // 退役 brand:历史 hermes agent_id 就地迁到 pi,避免 UNIQUE 下再插一套孤儿行。 + conn.execute( + "UPDATE ai_agent_profiles + SET agent_id = 'pi', + display_name = CASE + WHEN display_name = '我的 Hermes' THEN '我的 Pi' + ELSE display_name + END, + updated_at = ?1 + WHERE agent_id = 'hermes'", + params![now], + )?; conn.execute( "INSERT INTO ai_agent_profiles ( id, agent_id, profile_kind, owner_user_id, base_profile_name, isolated_profile_name, display_name, status, created_at, updated_at, revision ) - VALUES ('shared_lite', 'hermes', 'shared', '', 'lite', 'lite', 'Lite', 'active', ?1, ?2, 1) + VALUES ('shared_lite', 'pi', 'shared', '', 'lite', 'lite', 'Lite', 'active', ?1, ?2, 1) ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name) DO UPDATE SET status = 'active', updated_at = excluded.updated_at", params![now, now], @@ -2621,7 +2633,7 @@ impl ControlPlaneStore for TursoControlPlaneStore { id, agent_id, profile_kind, owner_user_id, base_profile_name, isolated_profile_name, display_name, status, created_at, updated_at, revision ) - VALUES (?1, 'hermes', 'shared', '', ?2, ?3, ?4, 'active', ?5, ?6, 1) + VALUES (?1, 'pi', 'shared', '', ?2, ?3, ?4, 'active', ?5, ?6, 1) ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name) DO UPDATE SET status = 'active', display_name = excluded.display_name, isolated_profile_name = excluded.isolated_profile_name, updated_at = excluded.updated_at", @@ -2649,9 +2661,10 @@ impl ControlPlaneStore for TursoControlPlaneStore { id, agent_id, profile_kind, owner_user_id, base_profile_name, isolated_profile_name, display_name, status, created_at, updated_at, revision ) - VALUES (?1, 'hermes', 'personal', ?2, 'default', ?3, '我的 Hermes', 'active', ?4, ?5, 1) + VALUES (?1, 'pi', 'personal', ?2, 'default', ?3, '我的 Pi', 'active', ?4, ?5, 1) ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name) - DO UPDATE SET status = 'active', updated_at = excluded.updated_at", + DO UPDATE SET status = 'active', display_name = excluded.display_name, + isolated_profile_name = excluded.isolated_profile_name, updated_at = excluded.updated_at", params![personal_profile_id, user_id, personal_profile_name, now, now], )?; let personal_grant_id = format!("grant_{personal_profile_id}_owner"); @@ -4891,8 +4904,8 @@ mod tests { 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(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), trace_id: Some("trace_1".to_string()), status: "running".to_string(), runtime_json: "{\"status\":\"running\"}".to_string(), @@ -4910,8 +4923,8 @@ mod tests { 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(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), trace_id: Some("trace_2".to_string()), status: "completed".to_string(), runtime_json: "{\"status\":\"completed\"}".to_string(), @@ -4941,8 +4954,8 @@ mod tests { 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(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), event_type: "message.delta".to_string(), payload_json: "{\"text\":\"hello\"}".to_string(), }) @@ -4956,8 +4969,8 @@ mod tests { 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(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), event_type: "run.completed".to_string(), payload_json: "{\"status\":\"completed\"}".to_string(), }) @@ -5005,8 +5018,8 @@ mod tests { session_id: "sess_1".to_string(), run_id: "run_1".to_string(), title: None, - profile: "reasonix".to_string(), - acp_runtime: "reasonix".to_string(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), trace_id: None, status: "completed".to_string(), runtime_json: "{\"status\":\"completed\"}".to_string(), @@ -5471,7 +5484,7 @@ mod tests { workspace_id: Some("ws_1".to_string()), session_id: "sess_1".to_string(), run_id: Some("run_2".to_string()), - provider: "reasonix".to_string(), + provider: "pi".to_string(), provider_session_id: None, tool_name: "fs.write".to_string(), allowed: false, diff --git a/rust/crates/control-plane/tests/turso_store.rs b/rust/crates/control-plane/tests/turso_store.rs index c3c57949..17bab82d 100644 --- a/rust/crates/control-plane/tests/turso_store.rs +++ b/rust/crates/control-plane/tests/turso_store.rs @@ -79,8 +79,8 @@ fn libsql_local_store_covers_control_plane_core_flows() { session_id: "sess_1".to_string(), run_id: "run_1".to_string(), title: Some("libSQL run".to_string()), - profile: "reasonix".to_string(), - acp_runtime: "reasonix".to_string(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), trace_id: Some("trace_1".to_string()), status: "running".to_string(), runtime_json: "{\"status\":\"running\"}".to_string(), @@ -97,8 +97,8 @@ fn libsql_local_store_covers_control_plane_core_flows() { 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(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), event_type: "message.delta".to_string(), payload_json: "{\"text\":\"hello\"}".to_string(), }) @@ -181,8 +181,8 @@ fn libsql_local_store_handles_parallel_control_plane_writes() { session_id: "parallel_session".to_string(), run_id: "parallel_run".to_string(), title: Some("Parallel run".to_string()), - profile: "reasonix".to_string(), - acp_runtime: "reasonix".to_string(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), trace_id: None, status: "running".to_string(), runtime_json: "{}".to_string(), @@ -231,8 +231,8 @@ fn libsql_local_store_handles_parallel_control_plane_writes() { document_id: Some("parallel_doc".to_string()), session_id: "parallel_session".to_string(), run_id: "parallel_run".to_string(), - profile: "reasonix".to_string(), - acp_runtime: "reasonix".to_string(), + profile: "pi".to_string(), + acp_runtime: "pi".to_string(), event_type: "message.delta".to_string(), payload_json: format!("{{\"index\":{index}}}"), }) diff --git a/rust/crates/core-protocol/src/ai.rs b/rust/crates/core-protocol/src/ai.rs index 67e19a6c..58116238 100644 --- a/rust/crates/core-protocol/src/ai.rs +++ b/rust/crates/core-protocol/src/ai.rs @@ -16,7 +16,8 @@ pub struct AiSession { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum AiRuntimeOwner { - RustWebHermes, + /// mnote-web 上的 Pi Lab / agent-tools 运行时(历史名 RustWebHermes 已退役)。 + RustWebPi, CompatReactIsland, } diff --git a/rust/crates/core-protocol/src/command.rs b/rust/crates/core-protocol/src/command.rs index 28de90e6..6bb394d5 100644 --- a/rust/crates/core-protocol/src/command.rs +++ b/rust/crates/core-protocol/src/command.rs @@ -197,7 +197,7 @@ pub struct DeleteBlock { /// 菜单项、快捷键、按钮的 enablement 通过 `when` 表达式对 context 求值得到。 /// /// 第一阶段支持 key 列表: -/// - `workspace.sourceKind` — 工作区类型 ("local_folder" | "convex" | ...) +/// - `workspace.sourceKind` — 工作区类型 ("local_folder") /// - `workspace.readonly` — bool,工作区是否只读 /// - `tree.focusKind` — 当前聚焦的树类型 ("file_tree" | "page_tree" | "none") /// - `tree.selectionCount` — 当前选中行数 (i64) diff --git a/rust/crates/core-protocol/src/kernel.rs b/rust/crates/core-protocol/src/kernel.rs index 6abd4468..ab157ecd 100644 --- a/rust/crates/core-protocol/src/kernel.rs +++ b/rust/crates/core-protocol/src/kernel.rs @@ -53,7 +53,6 @@ pub enum KernelProjectionKind { #[serde(rename_all = "snake_case")] pub enum WorkspaceSourceKind { LocalFolder, - ConvexWorkspace, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -897,8 +896,8 @@ mod tests { #[test] fn workspace_source_serializes_minimal_command_source_contract() { let source = WorkspaceSource { - source_kind: WorkspaceSourceKind::ConvexWorkspace, - root_uri: "convex://workspace/ws_1".into(), + source_kind: WorkspaceSourceKind::LocalFolder, + root_uri: "file:///tmp/ws_1".into(), workspace_id: "ws_1".into(), capabilities: vec![ WorkspaceSourceCapability::LoadSnapshot, @@ -908,8 +907,8 @@ mod tests { }; let value = serde_json::to_value(&source).expect("workspace source 应可序列化"); - assert_eq!(value["sourceKind"], json!("convex_workspace")); - assert_eq!(value["rootUri"], json!("convex://workspace/ws_1")); + assert_eq!(value["sourceKind"], json!("local_folder")); + assert_eq!(value["rootUri"], json!("file:///tmp/ws_1")); assert_eq!(value["workspaceId"], json!("ws_1")); assert_eq!( value["capabilities"], @@ -1026,8 +1025,8 @@ mod tests { } let value = serde_json::json!({ "workspaceId": "ws_1", - "sourceKind": "convex_workspace", - "rootUri": "convex://ws", + "sourceKind": "local_folder", + "rootUri": "file:///tmp/ws", "relativePath": "doc.md", "objectIdentity": { "objectKind": "page", diff --git a/rust/crates/mnote-cli/src/lib.rs b/rust/crates/mnote-cli/src/lib.rs index 9aa37a32..8b0cc77c 100644 --- a/rust/crates/mnote-cli/src/lib.rs +++ b/rust/crates/mnote-cli/src/lib.rs @@ -589,7 +589,7 @@ pub fn plan_page_title( ) -> CliResult { let bridge = build_bridge_context(ctx, workspace_id, "page", "title", page_id); let command = CommandEnvelope { - name: "documents.title.update".into(), + name: "tree.node.rename".into(), command_id: build_command_id("page", "title", page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), @@ -644,7 +644,7 @@ pub fn plan_page_save( let content_value = parse_json_value(content_json, "content_json")?; let bridge = build_bridge_context(ctx, workspace_id, "page", "save", page_id); let command = CommandEnvelope { - name: "documents.save".into(), + name: "page.body.save".into(), command_id: build_command_id("page", "save", page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), @@ -699,7 +699,7 @@ pub fn plan_page_create(ctx: &CliContext, args: &PageCreateArgs<'_>) -> CliResul let content_value = parse_json_value(args.content_json, "content_json")?; let bridge = build_bridge_context(ctx, Some(args.workspace_id), "page", "create", args.page_id); let command = CommandEnvelope { - name: "documents.create".into(), + name: "tree.node.create".into(), command_id: build_command_id("page", "create", args.page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), @@ -755,7 +755,7 @@ pub fn plan_page_create(ctx: &CliContext, args: &PageCreateArgs<'_>) -> CliResul pub fn plan_page_move(ctx: &CliContext, args: &PageMoveArgs<'_>) -> CliResult { let bridge = build_bridge_context(ctx, args.workspace_id, "page", "move", args.page_id); let command = CommandEnvelope { - name: "documents.move".into(), + name: "tree.subtree.move".into(), command_id: build_command_id("page", "move", args.page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), @@ -805,7 +805,7 @@ pub fn plan_page_move(ctx: &CliContext, args: &PageMoveArgs<'_>) -> CliResult) -> CliResult { let bridge = build_bridge_context(ctx, args.workspace_id, "page", "delete", args.page_id); let command = CommandEnvelope { - name: "documents.delete".into(), + name: "tree.node.archive".into(), command_id: build_command_id("page", "delete", args.page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), @@ -849,7 +849,7 @@ pub fn plan_page_delete(ctx: &CliContext, args: &PageDeleteArgs<'_>) -> CliResul pub fn plan_page_restore(ctx: &CliContext, args: &PageRestoreArgs<'_>) -> CliResult { let bridge = build_bridge_context(ctx, args.workspace_id, "page", "restore", args.page_id); let command = CommandEnvelope { - name: "documents.restore".into(), + name: "tree.node.restore".into(), command_id: build_command_id("page", "restore", args.page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), @@ -1551,7 +1551,7 @@ fn execute_query( "documents.content.get" | "documents.meta.get" | "mindmaps.get" - | "sidebar.dataset.list" => execute_retired_cloud_transport(name), + | "sidebar.dataset.list" => reject_non_local_cli_transport(name), "search.documents" => execute_search_documents(transport, context, cli_ctx), other => Err(CliError::validation(format!("暂不支持执行 query: {other}"))), } @@ -1565,13 +1565,13 @@ fn execute_command( cli_ctx: &CliContext, ) -> CliResult { match name { - "documents.create" - | "documents.move" - | "documents.delete" - | "documents.restore" - | "documents.title.update" - | "documents.save" - | "mindmaps.put" => execute_retired_cloud_transport(name), + "tree.node.create" + | "tree.subtree.move" + | "tree.node.archive" + | "tree.node.restore" + | "tree.node.rename" + | "page.body.save" + | "mindmaps.put" => reject_non_local_cli_transport(name), "insert_block" => execute_block_insert(normalized_input, context, cli_ctx), "blocks.patch" => execute_block_patch(normalized_input, transport, cli_ctx), "blocks.move" => execute_block_move(normalized_input, cli_ctx), @@ -1650,7 +1650,7 @@ fn execute_search_documents( _context: &CliOutputContext, _cli_ctx: &CliContext, ) -> CliResult { - execute_retired_cloud_transport("search.documents") + reject_non_local_cli_transport("search.documents") } fn execute_block_insert( @@ -1658,7 +1658,7 @@ fn execute_block_insert( _context: &CliOutputContext, _cli_ctx: &CliContext, ) -> CliResult { - execute_retired_cloud_transport("insert_block") + reject_non_local_cli_transport("insert_block") } fn execute_block_patch( @@ -1666,15 +1666,15 @@ fn execute_block_patch( _transport: &CliTransportPlan, _cli_ctx: &CliContext, ) -> CliResult { - execute_retired_cloud_transport("blocks.patch") + reject_non_local_cli_transport("blocks.patch") } fn execute_block_move(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult { - execute_retired_cloud_transport("blocks.move") + reject_non_local_cli_transport("blocks.move") } fn execute_block_embed(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult { - execute_retired_cloud_transport("blocks.embed") + reject_non_local_cli_transport("blocks.embed") } fn build_tool_target( @@ -1735,7 +1735,7 @@ fn load_tool_data( "image_read" => Ok(json!({ "source": "cli", "asset": Value::Null })), "doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" => { let _ = read_doc_target(args, target)?; - Err(retired_cloud_transport_error(tool_name)) + Err(non_local_cli_transport_error(tool_name)) } "mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => { let document_id = read_doc_target(args, target)?; @@ -1745,7 +1745,7 @@ fn load_tool_data( .or_else(|| target.and_then(|value| value.block_id.as_deref())) .ok_or_else(|| CliError::validation("mindmap 工具缺少 mindmapId"))?; let _ = (document_id, mindmap_id); - Err(retired_cloud_transport_error(tool_name)) + Err(non_local_cli_transport_error(tool_name)) } other => Err(CliError::validation(format!( "暂不支持 tool 数据加载: {other}" @@ -1753,13 +1753,13 @@ fn load_tool_data( } } -fn execute_retired_cloud_transport(operation: &str) -> CliResult { - Err(retired_cloud_transport_error(operation)) +fn reject_non_local_cli_transport(operation: &str) -> CliResult { + Err(non_local_cli_transport_error(operation)) } -fn retired_cloud_transport_error(operation: &str) -> CliError { +fn non_local_cli_transport_error(operation: &str) -> CliError { CliError::validation(format!( - "旧 Convex CLI 执行链已退役: {operation};请改用 local-first Rust/SQLite control-plane 路径" + "非 local_folder 的 CLI 执行链已移除: {operation};请使用 local-first local_folder 路径" )) } @@ -2100,10 +2100,10 @@ mod tests { transport, .. } => { - assert_eq!(name, "documents.save"); + assert_eq!(name, "page.body.save"); assert_eq!(command_id, "cmd_page_save_page_1"); assert_eq!(transport.kind, "runtime_command_plan"); - assert_eq!(transport.function_name, "documents.save"); + assert_eq!(transport.function_name, "page.body.save"); assert_eq!( transport.args_json, json!({ @@ -2149,9 +2149,9 @@ mod tests { CliOperationOutput::Command { name, transport, .. } => { - assert_eq!(name, "documents.create"); + assert_eq!(name, "tree.node.create"); assert_eq!(transport.kind, "runtime_command_plan"); - assert_eq!(transport.function_name, "documents.create"); + assert_eq!(transport.function_name, "tree.node.create"); assert_eq!(transport.args_json["workspaceId"], json!("ws_1")); assert_eq!(transport.args_json["parentId"], json!("parent_1")); } @@ -2160,7 +2160,7 @@ mod tests { } #[test] - fn page_move_json_contract_uses_documents_move() { + fn page_move_json_contract_uses_tree_subtree_move() { let output = plan_page_move( &CliContext::default(), &PageMoveArgs { @@ -2176,9 +2176,9 @@ mod tests { CliOperationOutput::Command { name, transport, .. } => { - assert_eq!(name, "documents.move"); + assert_eq!(name, "tree.subtree.move"); assert_eq!(transport.kind, "runtime_command_plan"); - assert_eq!(transport.function_name, "documents.move"); + assert_eq!(transport.function_name, "tree.subtree.move"); assert_eq!(transport.args_json["sortOrder"], json!(3)); } _ => panic!("expected command output"), diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js index b4cdfab3..db11c390 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -806,7 +806,7 @@ import { ? urlFromCaller : new URL(`/documents/${encodeURIComponent(descriptor.documentId)}`, window.location.origin); if (!url.searchParams.get('workspaceId') && descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId); - if (descriptor.sourceKind && descriptor.sourceKind !== 'convex_workspace') url.searchParams.set('sourceKind', descriptor.sourceKind); + if (descriptor.sourceKind) url.searchParams.set('sourceKind', descriptor.sourceKind); if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri); pushUrlState(url); }; diff --git a/rust/crates/mnote-web/browser/local-upload-runtime.js b/rust/crates/mnote-web/browser/local-upload-runtime.js index aea68a19..9eb36e56 100644 --- a/rust/crates/mnote-web/browser/local-upload-runtime.js +++ b/rust/crates/mnote-web/browser/local-upload-runtime.js @@ -468,7 +468,7 @@ async function uploadLocalFolderAsset(file, plan, context) { async function uploadMediaAsset(file, plan, context) { document.documentElement.setAttribute('data-mnote-media-upload-retired', 'true'); - throw new Error('旧 Convex Files 上传链已退役;local-first 附件请使用本地文件夹上传目标。'); + throw new Error('仅支持本地文件夹上传。'); } async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) { diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js index 88dde13f..d7861f64 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js @@ -18,7 +18,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { isFileTreePageRow, localFilePathFromAssetId, normalizeFileTreePageRenameTitle, - openConvexAssetFromFileTree, + openLocalAssetFromFileTree, openEditorAttachmentDetail, openEditorAttachmentDownload, openEditorAttachmentEditTab, @@ -592,19 +592,19 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { } if (isAsset && action === 'new-window') { recordFileTreeAction('new-window', detail); - void openConvexAssetFromFileTree({ ...detail, openTarget: 'new-window' }); + void openLocalAssetFromFileTree({ ...detail, openTarget: 'new-window' }); return; } if (isAsset && action === 'open-edit-mode') { withOfficeEditModeGuard(async function() { recordFileTreeAction('open-edit-mode', detail); - void openConvexAssetFromFileTree({ ...detail, openTarget: 'edit-mode' }); + void openLocalAssetFromFileTree({ ...detail, openTarget: 'edit-mode' }); }); return; } if (isAsset && action === 'open-right') { recordFileTreeAction('open-right', detail); - void openConvexAssetFromFileTree({ ...detail, openTarget: 'side' }); + void openLocalAssetFromFileTree({ ...detail, openTarget: 'side' }); return; } if (action === 'open-right') { diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js index d164668a..9407bc39 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js @@ -381,7 +381,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => { } } - async function openConvexAssetFromFileTree(detail) { + async function openLocalAssetFromFileTree(detail) { var assetId = String(detail && detail.assetId || '').trim(); if (!assetId) return; var openTarget = String(detail && detail.openTarget || '').trim().toLowerCase(); @@ -520,11 +520,11 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => { return; } document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true'); - await window.mnote.alert('旧 Convex Files 附件签名链已退役;local-first 附件请通过本地文件夹资源打开。'); + await window.mnote.alert('仅支持本地文件夹附件;请通过 local_folder 资源打开。'); } window.addEventListener('tree.asset.open', function(event) { - void openConvexAssetFromFileTree(event.detail || {}); + void openLocalAssetFromFileTree(event.detail || {}); }); @@ -541,7 +541,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => { isMindmapAssetDetail, localFilePathFromAssetId, navigateToMindmapObject, - openConvexAssetFromFileTree, + openLocalAssetFromFileTree, openLocalOfficeFileInActiveTab, openLocalResourceInActiveTab, readFileTreeObjectIdentity, diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index 771b4146..b3666449 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -1073,7 +1073,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim const openLocalResourceInActiveTab = (...args) => sidebarFileTreeOpen.openLocalResourceInActiveTab(...args); const readFileTreeObjectIdentity = (...args) => sidebarFileTreeOpen.readFileTreeObjectIdentity(...args); const fetchCurrentOnlyOfficeUserId = (...args) => sidebarFileTreeOpen.fetchCurrentOnlyOfficeUserId(...args); - const openConvexAssetFromFileTree = (...args) => sidebarFileTreeOpen.openConvexAssetFromFileTree(...args); + const openLocalAssetFromFileTree = (...args) => sidebarFileTreeOpen.openLocalAssetFromFileTree(...args); var sidebarFileTreeUpload = null; const fileTreeRowsForUploadPreflight = (...args) => sidebarFileTreeUpload.fileTreeRowsForUploadPreflight(...args); @@ -2071,7 +2071,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim } } else { document.documentElement.setAttribute('data-mnote-media-upload-retired', 'true'); - throw new Error('旧 Convex Files 上传链已退役;local-first 附件请使用本地文件夹上传目标。'); + throw new Error('仅支持本地文件夹上传。'); } appendUploadedAssetRow(payload.asset, plan.targetDocumentId); if (options && options.insertIntoEditor) { @@ -2219,7 +2219,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim isFileTreePageRow, localFilePathFromAssetId, normalizeFileTreePageRenameTitle, - openConvexAssetFromFileTree, + openLocalAssetFromFileTree, openEditorAttachmentDetail: (...args) => openEditorAttachmentDetail(...args), openEditorAttachmentDownload: (...args) => openEditorAttachmentDownload(...args), openEditorAttachmentEditTab: (...args) => openEditorAttachmentEditTab(...args), diff --git a/rust/crates/mnote-web/browser/tree-live-controller.js b/rust/crates/mnote-web/browser/tree-live-controller.js index 04d33b4b..1a8a18a3 100644 --- a/rust/crates/mnote-web/browser/tree-live-controller.js +++ b/rust/crates/mnote-web/browser/tree-live-controller.js @@ -223,8 +223,6 @@ // Prefer WebSocket transport when available var requestedTransport = bootstrap.transport || 'tree-live-sse'; - if (requestedTransport === 'convex-command-log-sse') requestedTransport = 'tree-live-sse'; - if (requestedTransport === 'convex-command-log-ws') requestedTransport = 'tree-live-ws'; var preferWs = requestedTransport === 'tree-live-ws' && 'WebSocket' in window; if (preferWs) { diff --git a/rust/crates/mnote-web/browser/tree-shell-filetree-menu-runtime.js b/rust/crates/mnote-web/browser/tree-shell-filetree-menu-runtime.js index 672eea73..71624d8a 100644 --- a/rust/crates/mnote-web/browser/tree-shell-filetree-menu-runtime.js +++ b/rust/crates/mnote-web/browser/tree-shell-filetree-menu-runtime.js @@ -37,7 +37,6 @@ export function buildTreeShellFileTreeContextMenuProfile(context, target) { const hasDocument = Boolean(getFileTreeRowDocumentId(item)); const hasAsset = Boolean(getFileTreeRowAssetId(item)); const localSource = context.sourceKind === "local_folder"; - const convexSource = context.sourceKind === "convex_workspace"; const canCreateFolder = localSource; const canCreatePage = rowKind === "root" || rowKind === "folder" || rowKind === "document"; const canRename = @@ -63,11 +62,11 @@ export function buildTreeShellFileTreeContextMenuProfile(context, target) { }), createFileTreeMenuItem("newFolder", "新建文件夹", { disabled: !canCreateFolder, - reason: convexSource ? "legacy cloud workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹", + reason: "仅 local_folder 支持新建文件夹", }), createFileTreeMenuItem("upload", "上传/导入", { disabled: true, - reason: localSource ? "外部文件请拖入 Explorer" : "legacy cloud 上传 executor 尚未接入", + reason: localSource ? "外部文件请拖入 Explorer" : "仅 local_folder 支持上传", }), createFileTreeMenuItem("refresh", "刷新", { separatorBefore: true }), createFileTreeMenuItem("collapseAll", "全部折叠"), @@ -95,7 +94,7 @@ export function buildTreeShellFileTreeContextMenuProfile(context, target) { })); items.push(createFileTreeMenuItem("newFolder", "新建文件夹", { disabled: !canCreateFolder, - reason: convexSource ? "legacy cloud workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹", + reason: "仅 local_folder 支持新建文件夹", })); items.push(createFileTreeMenuItem("paste", "粘贴", { disabled: !canPaste, diff --git a/rust/crates/mnote-web/browser/tree-shell-runtime.js b/rust/crates/mnote-web/browser/tree-shell-runtime.js index 1db10dac..40322ab1 100644 --- a/rust/crates/mnote-web/browser/tree-shell-runtime.js +++ b/rust/crates/mnote-web/browser/tree-shell-runtime.js @@ -765,21 +765,7 @@ function startTreeShellRuntime() { scheduleRefresh(); return true; } - if (sourceKind === "convex_workspace") { - for (const item of deletableItems) { - const documentId = getFileTreeRowDocumentId(item); - await sendCommand({ - action: "delete", - workspaceId, - documentId, - }); - applyRemovedDocumentLocally(documentId); - } - if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId))) { - scheduleRefresh(); - } - return true; - } + // non-local sources removed; only local_folder path above postToHost("tree.filetree.delete", { workspaceId, rowIds: deletableItems.map((item) => item.rowId), @@ -823,21 +809,8 @@ function startTreeShellRuntime() { copy: fileTreeClipboard.action === "copy", }); if (!accepted) return; - if (sourceKind === "convex_workspace") { - const sourceRowIds = filterRedundantFileTreeRowIds(fileTreeClipboard.rowIds); - for (const rowId of sourceRowIds) { - const sourceItem = fileTreeRowById.get(rowId); - const documentId = getFileTreeRowDocumentId(sourceItem); - if (!documentId) continue; - await sendCommand({ - action: fileTreeClipboard.action === "copy" ? "copy" : "move", - workspaceId, - documentId, - parentId: target.documentId || null, - targetParentId: target.documentId || null, - sortOrder: 0, - }); - } + if (false) { + // paste: local_folder only if (fileTreeClipboard.action === "cut") { fileTreeClipboard = { action: null, rowIds: [] }; } @@ -1944,7 +1917,7 @@ function startTreeShellRuntime() { target: { documentId }, payload: { documentId }, }); - if (sourceKind === "convex_workspace" && applyCreatedDocumentLocally(result, parentId, title)) { + if (sourceKind === "local_folder" && applyCreatedDocumentLocally(result, parentId, title)) { if (mode === "filetree") { beginInlineRename("filetree", `doc:${documentId}`); } else { diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs index 9896a592..cf76a814 100644 --- a/rust/crates/mnote-web/src/app.rs +++ b/rust/crates/mnote-web/src/app.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use std::time::Duration; use tower_http::compression::CompressionLayer; use tower_http::trace::TraceLayer; -use tracing::{error, warn}; +use tracing::error; #[derive(Debug, Clone)] pub struct AppConfig { @@ -27,17 +27,11 @@ pub struct AppConfig { pub service_version: String, pub bind_addr: String, pub public_bind_addr: String, - pub legacy_next_base_url: Option, - pub enable_legacy_next_compat: bool, pub enable_debug_shell_routes: bool, pub enable_editor_actor: bool, pub enable_page_ai_pi_lab: bool, - pub compat_next_base_path: String, - pub convex_url: Option, - pub convex_admin_key: Option, + pub allow_dev_fixtures: bool, - pub query_fixtures_json: Option, - pub mutation_fixtures_json: Option, pub dev_user_id: String, pub dev_user_name: String, pub dev_user_email: String, @@ -56,33 +50,17 @@ impl AppConfig { .unwrap_or_else(|_| "127.0.0.1:0".into()), public_bind_addr: env::var("MNOTE_WEB_PUBLIC_BIND") .unwrap_or_else(|_| "127.0.0.1:3000".into()), - legacy_next_base_url: env::var("MNOTE_WEB_LEGACY_NEXT_BASE_URL") - .ok() - .map(|value| value.trim().trim_end_matches('/').to_string()) - .filter(|value| !value.is_empty()), - enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", false), enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES") .ok() .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) .unwrap_or(false), enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true), enable_page_ai_pi_lab: env_bool("MNOTE_PAGE_AI_PI_LAB", true), - compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH") - .unwrap_or_else(|_| "/api/compat/next".into()), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES") .ok() .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) .unwrap_or(false), - query_fixtures_json: env::var("MNOTE_WEB_QUERY_FIXTURES_JSON") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()), - mutation_fixtures_json: env::var("MNOTE_WEB_MUTATION_FIXTURES_JSON") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()), dev_user_id: env::var("DEV_USER_ID") .ok() .or_else(|| read_env_or_dotenv("DEV_USER_ID")) @@ -367,23 +345,13 @@ async fn log_failed_response(request: Request, next: Next) -> Response { .get("x-error-code") .and_then(|value| value.to_str().ok()) .unwrap_or(""); - if error_code == "convex_retired" { - warn!( - method = %method, - uri = %uri, - status = %status, - error_code = %error_code, - "退役 legacy cloud/Convex 兼容路径被请求" - ); - } else { - error!( - method = %method, - uri = %uri, - status = %status, - error_code = %error_code, - "mnote-web 请求返回服务端错误" - ); - } + error!( + method = %method, + uri = %uri, + status = %status, + error_code = %error_code, + "mnote-web 请求返回服务端错误" + ); } response } @@ -399,17 +367,11 @@ mod tests { 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, enable_page_ai_pi_lab: true, - 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(), diff --git a/rust/crates/mnote-web/src/editor_actor.rs b/rust/crates/mnote-web/src/editor_actor.rs index b31d538d..1eba830b 100644 --- a/rust/crates/mnote-web/src/editor_actor.rs +++ b/rust/crates/mnote-web/src/editor_actor.rs @@ -3,7 +3,7 @@ //! 职责: //! - 持有 per-document `EditorBlockDocument` 内存态 //! - 接收 EditorCommand,在内存中 apply,产生 diff -//! - Convex 持久化仍由 block.rs 通过 execute_page_body_save 完成 +//! - 正文持久化由 block.rs 通过 execute_page_body_save 完成(local-first) //! //! 不是 agent runtime:不维护会话、不做意图解析、不调模型(遵从 7-12 禁止项)。 @@ -270,7 +270,7 @@ impl EditorRuntimeActor { Ok((content, delta)) } - /// 从内存态生成 legacy content(用于构建 Convex save payload)。 + /// 从内存态生成 legacy content(用于构建 save payload)。 pub fn legacy_content_for_save(&self, document_id: &str) -> Result { let documents = self .documents diff --git a/rust/crates/mnote-web/src/middleware/request_context.rs b/rust/crates/mnote-web/src/middleware/request_context.rs index 0be0f93e..b16144e7 100644 --- a/rust/crates/mnote-web/src/middleware/request_context.rs +++ b/rust/crates/mnote-web/src/middleware/request_context.rs @@ -1,5 +1,6 @@ use crate::context::RequestContext; use crate::routes::api_access_token::{bearer_mnpat1, verify_pat_token}; +use crate::routes::vault::{bearer_mnv1, verify_agent_vault_token}; use crate::routes::vault_extension_token::{bearer_mnext1, verify_extension_token}; use axum::extract::Request; use axum::middleware::Next; @@ -10,6 +11,7 @@ pub async fn inject_request_context(mut request: Request, next: Next) -> Respons RequestContext::from_http_parts(request.method(), request.uri(), request.headers()); // 7-76:Bearer mnpat1.* 优先于 cookie(PAT 请求忽略 cookie 并权)。 + // 注意:PAT 不能访问密码箱数据面(vault.rs require_authenticated 会拒绝)。 if let Some(token) = bearer_mnpat1(context.auth.authorization.as_deref()) { if let Ok(verified) = verify_pat_token(token) { if let Some(actor) = crate::context::stable_actor_id(&verified.subject_user_id) { @@ -29,9 +31,29 @@ pub async fn inject_request_context(mut request: Request, next: Next) -> Respons } } - // 12-3 E2: Authorization Bearer mnext1.* → actor(仅当尚未 PAT / 仍 anonymous) + // 7-76 / 12-2:Bearer mnv1.* → agent vault capability(读密 list/get/resolve…) + // 仅当尚未被 mnpat1 绑定(避免 PAT 伪装成 vault token 混权)。 if context.auth.auth_method != "pat" && (context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty()) + { + if let Some(token) = bearer_mnv1(context.auth.authorization.as_deref()) { + if let Ok(claims) = verify_agent_vault_token(token) { + if let Some(actor) = crate::context::stable_actor_id(&claims.actor) { + context.auth.actor_id = actor; + context.auth.actor_type = "ai_service".into(); + context.auth.auth_method = "vault_token".into(); + context.auth.scopes = claims.scope.clone(); + context.auth.session_id = Some(format!("mnv1:{}", claims.jti)); + context.auth.cookie_header = None; + } + } + } + } + + // 12-3 E2: Authorization Bearer mnext1.* → actor(仅当尚未 PAT / vault / 仍 anonymous) + if context.auth.auth_method != "pat" + && context.auth.auth_method != "vault_token" + && (context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty()) { if let Some(token) = bearer_mnext1(context.auth.authorization.as_deref()) { if let Ok(claims) = verify_extension_token(token) { diff --git a/rust/crates/mnote-web/src/mnote_agent_tools/artifact.rs b/rust/crates/mnote-web/src/mnote_agent_tools/artifact.rs index 6192af58..f748b66f 100644 --- a/rust/crates/mnote-web/src/mnote_agent_tools/artifact.rs +++ b/rust/crates/mnote-web/src/mnote_agent_tools/artifact.rs @@ -2,7 +2,7 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::mnote_agent_tools::ToolCallInput; -use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts; +use crate::routes::command_support::reject_non_local_runtime_command_with_artifacts; use crate::routes::ensure_local_workspace_access; use bridge_runtime::{ RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, @@ -230,7 +230,7 @@ async fn create_artifact_node( dry_run: false, validate_only: false, }; - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, context, workspace_id.as_deref(), @@ -247,9 +247,9 @@ async fn create_artifact_node( "to": artifact_document_id, "kind": "ai_artifact_reference" }, - "result": execution.result, - "artifacts": execution.artifacts, - "artifactError": execution.artifact_error + "result": execution, + "artifacts": None::, + "artifactError": None:: })) } diff --git a/rust/crates/mnote-web/src/mnote_agent_tools/block.rs b/rust/crates/mnote-web/src/mnote_agent_tools/block.rs index 517ab1f8..1e542d66 100644 --- a/rust/crates/mnote-web/src/mnote_agent_tools/block.rs +++ b/rust/crates/mnote-web/src/mnote_agent_tools/block.rs @@ -6,7 +6,7 @@ use crate::mnote_agent_tools::doc::{ required_arg, }; use crate::mnote_agent_tools::ToolCallInput; -use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts; +use crate::routes::command_support::reject_non_local_runtime_command_with_artifacts; use bridge_runtime::{ apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, @@ -1155,7 +1155,7 @@ async fn execute_page_body_save( dry_run: false, validate_only: false, }; - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, context, workspace_id.as_deref(), @@ -1166,9 +1166,9 @@ async fn execute_page_body_save( "commandName": "page.body.save", "commandId": command_id, "changedBlocks": changed_blocks, - "result": execution.result, - "artifacts": execution.artifacts, - "artifactError": execution.artifact_error + "result": execution, + "artifacts": None::, + "artifactError": None:: })) } @@ -1229,7 +1229,7 @@ pub(crate) async fn execute_page_body_save_from_aggregate( dry_run: false, validate_only: false, }; - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, context, workspace_id.as_deref(), @@ -1240,9 +1240,9 @@ pub(crate) async fn execute_page_body_save_from_aggregate( "commandName": "page.body.save", "commandId": command_id, "changedBlocks": changed_blocks, - "result": execution.result, - "artifacts": execution.artifacts, - "artifactError": execution.artifact_error + "result": execution, + "artifacts": None::, + "artifactError": None:: })) } diff --git a/rust/crates/mnote-web/src/mnote_agent_tools/doc.rs b/rust/crates/mnote-web/src/mnote_agent_tools/doc.rs index 4ab41254..7fbaf1d8 100644 --- a/rust/crates/mnote-web/src/mnote_agent_tools/doc.rs +++ b/rust/crates/mnote-web/src/mnote_agent_tools/doc.rs @@ -44,7 +44,7 @@ pub async fn doc_fetch( let workspace_id = input.effective_workspace_id(); ensure_ai_scope_resource_allowed(context, input, &document_id)?; - // 本地文件路径检测:直接读取授权 root 内的 .md 文件,不经过 Convex。 + // 本地文件路径检测:直接读取授权 root 内的 .md 文件。 let is_local_file = document_id.starts_with('/') || document_id.starts_with("./") || document_id.starts_with("file://"); @@ -253,7 +253,7 @@ pub async fn doc_fetch( { "local_fs" } else { - "convex" + "local_folder" }; Ok(json!({ "ok": true, @@ -1637,11 +1637,7 @@ pub async fn doc_markdown_edit( let blocks = block_projection_blocks(&aggregate); ( blocks_to_markdown(&blocks, true), - if is_local_workspace { - "local_folder" - } else { - "convex" - }, + "local_folder", ) }; @@ -1749,7 +1745,7 @@ pub async fn doc_markdown_edit( String::from("无操作已应用") }; - // 5. 写回(本地文件直接 fs::write,Convex 文档通过 block ops apply) + // 5. 写回(本地文件直接 fs::write;否则走 local page body / block ops) let apply_result = if let Some(ref path) = local_file_path { if input.dry_run.unwrap_or(false) { json!({"written": false, "dryRun": true, "path": path.display().to_string()}) @@ -1900,7 +1896,7 @@ pub async fn doc_markdown_edit( validate_only: false, }; let execution = - crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts( + crate::routes::command_support::reject_non_local_runtime_command_with_artifacts( state, context, workspace_id.as_deref(), @@ -1911,9 +1907,9 @@ pub async fn doc_markdown_edit( "commandName": "page.body.save", "commandId": command_id, "changedBlocks": changed_blocks, - "result": execution.result, - "artifacts": execution.artifacts, - "artifactError": execution.artifact_error + "result": execution, + "artifacts": None::, + "artifactError": None:: }) } }; diff --git a/rust/crates/mnote-web/src/mnote_agent_tools/page.rs b/rust/crates/mnote-web/src/mnote_agent_tools/page.rs index df61085d..6a4fe07e 100644 --- a/rust/crates/mnote-web/src/mnote_agent_tools/page.rs +++ b/rust/crates/mnote-web/src/mnote_agent_tools/page.rs @@ -2,7 +2,7 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::mnote_agent_tools::ToolCallInput; -use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts; +use crate::routes::command_support::reject_non_local_runtime_command_with_artifacts; use crate::routes::web_shell::build_page_aggregate_snapshot; use bridge_runtime::{ RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, @@ -306,7 +306,7 @@ async fn page_command( dry_run: false, validate_only: false, }; - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, context, workspace_id.as_deref(), @@ -316,9 +316,9 @@ async fn page_command( let mut result = json!({ "commandName": command_name, "commandId": command_id, - "result": execution.result, - "artifacts": execution.artifacts, - "artifactError": execution.artifact_error + "result": execution, + "artifacts": None::, + "artifactError": None:: }); merge_result_extra(&mut result, result_extra); Ok(result) diff --git a/rust/crates/mnote-web/src/routes/bridge.rs b/rust/crates/mnote-web/src/routes/bridge.rs index 450cb888..82752fc2 100644 --- a/rust/crates/mnote-web/src/routes/bridge.rs +++ b/rust/crates/mnote-web/src/routes/bridge.rs @@ -2,7 +2,7 @@ use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::{ - execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id, + reject_non_local_runtime_query, resolve_effective_workspace_id, }; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; @@ -89,7 +89,7 @@ async fn execute_bridge_query( workspace_id: &str, query: RuntimeQueryEnvelopeWire, ) -> Result { - execute_runtime_query_via_legacy_cloud(config, context, Some(workspace_id), query).await + reject_non_local_runtime_query(config, context, Some(workspace_id), query).await } fn require_resolved_workspace_id( @@ -184,17 +184,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()), - mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), diff --git a/rust/crates/mnote-web/src/routes/command_support.rs b/rust/crates/mnote-web/src/routes/command_support.rs index c9675692..f1251309 100644 --- a/rust/crates/mnote-web/src/routes/command_support.rs +++ b/rust/crates/mnote-web/src/routes/command_support.rs @@ -1,16 +1,12 @@ use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; use crate::error::WebError; -use crate::transport::legacy_cloud_guard::{ - execute_retired_command_plan, execute_retired_command_plan_with_artifacts, - RetiredCloudCommandExecution, -}; +use crate::transport::local_only::local_required; use bridge_runtime::{ execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire, RuntimeTargetWire, }; -use serde_json::json; use serde_json::Value; pub fn runtime_context( @@ -64,57 +60,24 @@ pub fn build_runtime_command_plan( Ok(plan) } -pub async fn execute_runtime_command_via_legacy_cloud( - config: &AppConfig, +/// 非 local_folder 的 runtime command 一律拒绝(无 cloud 执行链)。 +pub async fn reject_non_local_runtime_command( + _config: &AppConfig, context: &RequestContext, - effective_workspace_id: Option<&str>, - command: RuntimeCommandEnvelopeWire, + _effective_workspace_id: Option<&str>, + _command: RuntimeCommandEnvelopeWire, ) -> Result { - let plan = build_runtime_command_plan(context, effective_workspace_id, command)?; - execute_retired_command_plan(config, context, &plan).await + Err(local_required(context, "command_local_required")) } -pub async fn execute_runtime_command_via_legacy_cloud_with_artifacts( - state: &AppState, +/// 历史 with_artifacts 形态;永远 local_required,无产物/无 cloud 执行。 +pub async fn reject_non_local_runtime_command_with_artifacts( + _state: &AppState, context: &RequestContext, - effective_workspace_id: Option<&str>, - command: RuntimeCommandEnvelopeWire, -) -> Result { - let runtime_context = runtime_context(context, effective_workspace_id); - let runtime_input = RuntimeInput::Command { - context: runtime_context.clone(), - command: command.clone(), - }; - let RuntimeExecutionPlan::Command(plan) = execute_runtime_input(runtime_input) - .map_err(|error| WebError::bad_request(error.message).with_context(context))? - else { - return Err(WebError::internal("runtime command 未返回 command plan").with_context(context)); - }; - - let execution = execute_retired_command_plan_with_artifacts( - state.config(), - context, - &runtime_context, - &command, - &plan, - ) - .await?; - - // Push stream delta notification via broadcast for WebSocket/SSE push consumers - let workspace_id = effective_workspace_id - .map(ToOwned::to_owned) - .or_else(|| context.workspace.workspace_id.clone()); - let delta = json!({ - "kind": "command_committed", - "commandName": command.name, - "commandId": command.command_id, - "workspaceId": workspace_id, - "requestId": context.trace.request_id, - "traceId": context.trace.trace_id, - }); - let _ = state.stream_delta_tx.send(delta); - - Ok(execution) + _effective_workspace_id: Option<&str>, + _command: RuntimeCommandEnvelopeWire, +) -> Result { + Err(local_required(context, "command_local_required")) } pub fn build_tree_target( diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index 39aaf8da..dcb46aae 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -59,32 +59,41 @@ mod tests { use crate::app::{build_app, AppConfig, AppState}; use axum::body::Body; use axum::http::{Request, StatusCode}; - use axum::response::IntoResponse; - use tokio::net::TcpListener; use tower::util::ServiceExt; - fn app() -> axum::Router { - build_app(AppState::new(AppConfig { + 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: Some("http://127.0.0.1:3100".into()), - enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, allow_dev_fixtures: true, - query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[],"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"trashed_documents":[],"media_assets":[],"trashed_media_assets":[],"mindmap_assets":[],"trashed_mindmap_assets":[],"table_assets":[],"trashed_table_assets":[],"mindmap_docs":[],"mindmap_asset_children":{}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[],"domain_events":[],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()), - mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), environment: "dev".into(), - })) + } + } + + fn app() -> axum::Router { + build_app(AppState::new(test_config())) + } + + async fn post_ai_agent_run(body: &'static str) -> axum::http::Response { + build_app(AppState::new(test_config())) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/ai-agent/run") + .header("content-type", "application/json") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response") } #[tokio::test] @@ -104,37 +113,10 @@ mod tests { #[tokio::test] async fn direct_ai_agent_run_returns_legacy_retired_guard() { - let response = build_app(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, - enable_page_ai_pi_lab: false, - 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(), - environment: "dev".into(), - })) - .oneshot( - Request::builder() - .method("POST") - .uri("/api/ai-agent/run") - .header("content-type", "application/json") - .body(Body::from(r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"online"}}}"#)) - .expect("request"), + let response = post_ai_agent_run( + r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"online"}}}"#, ) - .await - .expect("response"); + .await; assert_eq!(response.status(), StatusCode::GONE); assert_eq!( @@ -161,37 +143,10 @@ mod tests { #[tokio::test] async fn explicit_agent_provider_returns_legacy_retired_guard() { - let response = build_app(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:9".into()), - enable_legacy_next_compat: false, - enable_debug_shell_routes: false, - enable_editor_actor: true, - enable_page_ai_pi_lab: false, - 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(), - environment: "dev".into(), - })) - .oneshot( - Request::builder() - .method("POST") - .uri("/api/ai-agent/run") - .header("content-type", "application/json") - .body(Body::from(r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"codex"}}}"#)) - .expect("request"), + let response = post_ai_agent_run( + r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"codex"}}}"#, ) - .await - .expect("response"); + .await; assert_eq!(response.status(), StatusCode::GONE); assert_eq!( @@ -209,58 +164,13 @@ mod tests { assert!(text.contains("legacy_ai_agent_run_retired")); } + /// 退役 brand provider 必须 410,且不得静默落到任何旁路执行。 #[tokio::test] - async fn explicit_retired_hermes_provider_fails_instead_of_using_legacy_next_or_direct_bridge() { - let next_app = axum::Router::new().route( - "/api/ai-agent/run", - axum::routing::post(|| async move { - ( - [( - axum::http::header::CONTENT_TYPE, - "text/event-stream; charset=utf-8", - )], - "event: assistant_message\ndata: {\"text\":\"hello from next ai\"}\n\n", - ) - .into_response() - }), - ); - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind next"); - let addr = listener.local_addr().expect("local addr"); - let server = tokio::spawn(async move { - axum::serve(listener, next_app).await.expect("serve next"); - }); - - let response = build_app(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(format!("http://{}", addr)), - enable_legacy_next_compat: true, - enable_debug_shell_routes: false, - enable_editor_actor: true, - enable_page_ai_pi_lab: false, - 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(), - environment: "dev".into(), - })) - .oneshot( - Request::builder() - .method("POST") - .uri("/api/ai-agent/run") - .header("content-type", "application/json") - .body(Body::from(r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"hermes"}}}"#)) - .expect("request"), + async fn explicit_retired_hermes_provider_returns_gone() { + let response = post_ai_agent_run( + r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"hermes"}}}"#, ) - .await - .expect("response"); + .await; assert_eq!(response.status(), StatusCode::GONE); assert_eq!( @@ -275,8 +185,7 @@ mod tests { .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); assert!(text.contains("hermes")); - assert!(!text.contains("hello from next ai")); - - server.abort(); + assert!(text.contains("legacy_ai_agent_run_retired")); + assert!(text.contains("Pi Lab")); } } diff --git a/rust/crates/mnote-web/src/routes/dev_seed.rs b/rust/crates/mnote-web/src/routes/dev_seed.rs index c4f0d945..0ff537a8 100644 --- a/rust/crates/mnote-web/src/routes/dev_seed.rs +++ b/rust/crates/mnote-web/src/routes/dev_seed.rs @@ -518,17 +518,11 @@ mod tests { 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: false, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures, - 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(), diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index 5af4ffdc..c19a9ac3 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -3,14 +3,14 @@ use crate::context::RequestContext; use crate::document_buffer_store::{self, BufferKey, BufferStore}; use crate::error::WebError; use crate::routes::command_support::{ - execute_runtime_command_via_legacy_cloud, - execute_runtime_command_via_legacy_cloud_with_artifacts, + reject_non_local_runtime_command, + reject_non_local_runtime_command_with_artifacts, }; use crate::routes::local_folder_source::{ ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body, }; use crate::routes::query_support::{ - execute_runtime_query_via_legacy_cloud, fetch_documents_meta_via_legacy_cloud, + reject_non_local_runtime_query, reject_non_local_documents_meta, resolve_effective_workspace_id, }; use axum::extract::{Extension, Query, State}; @@ -22,8 +22,6 @@ use bridge_runtime::{ }; use serde::Deserialize; use serde_json::{json, Value}; -use std::fs; -use std::time::Duration; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -187,7 +185,6 @@ pub struct DocumentEmptyTrashRequest { pub workspace_id: String, } -const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL"; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport"; @@ -207,14 +204,8 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMa ) } -fn execution_artifacts_json( - execution: &crate::transport::legacy_cloud_guard::RetiredCloudCommandExecution, -) -> Value { - execution - .artifacts - .as_ref() - .and_then(|artifacts| serde_json::to_value(artifacts).ok()) - .unwrap_or(Value::Null) +fn execution_artifacts_json(_execution: &Value) -> Value { + Value::Null } fn stamp_documents_headers(headers: &mut HeaderMap) { @@ -226,312 +217,6 @@ fn stamp_documents_headers(headers: &mut HeaderMap) { } } -fn read_env_or_dotenv(key: &str) -> Option { - if let Ok(value) = std::env::var(key) { - let trimmed = value.trim().trim_matches('"').to_string(); - if !trimmed.is_empty() { - return Some(trimmed); - } - } - - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../..") - .join(".env.all"); - let content = fs::read_to_string(root).ok()?; - for line in content.lines() { - let line = line.trim_end_matches('\r'); - if line.starts_with('#') || line.trim().is_empty() { - continue; - } - let Some((k, v)) = line.split_once('=') else { - continue; - }; - if k.trim() != key { - continue; - } - let trimmed = v.trim().trim_matches('"').to_string(); - if !trimmed.is_empty() { - return Some(trimmed); - } - } - None -} - -fn next_documents_base_url() -> String { - read_env_or_dotenv(NEXT_DOCUMENTS_BASE_URL_ENV) - .unwrap_or_else(|| "http://127.0.0.1:3000".into()) - .trim() - .trim_end_matches('/') - .to_string() -} - -fn should_proxy_via_next(_context: &RequestContext) -> bool { - false -} - -fn build_next_proxy_headers( - context: &RequestContext, - effective_workspace_id: Option<&str>, -) -> reqwest::header::HeaderMap { - use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; - - fn insert(headers: &mut HeaderMap, name: &'static str, value: &str) { - let Ok(header_name) = HeaderName::from_lowercase(name.as_bytes()) else { - return; - }; - let Ok(header_value) = HeaderValue::from_str(value) else { - return; - }; - headers.insert(header_name, header_value); - } - - let mut headers = HeaderMap::new(); - if let Some(cookie) = context.auth.cookie_header.as_deref() { - insert(&mut headers, "cookie", cookie); - } - if let Some(authorization) = context.auth.authorization.as_deref() { - insert(&mut headers, "authorization", authorization); - } - insert(&mut headers, "x-request-id", &context.trace.request_id); - insert(&mut headers, "x-trace-id", &context.trace.trace_id); - insert( - &mut headers, - "x-mnote-source-channel", - &context.source.channel, - ); - insert( - &mut headers, - "x-mnote-source-client", - &context.source.client, - ); - insert(&mut headers, "x-mnote-actor-id", &context.auth.actor_id); - insert(&mut headers, "x-mnote-actor-type", &context.auth.actor_type); - if let Some(session_id) = context.auth.session_id.as_deref() { - insert(&mut headers, "x-mnote-session-id", session_id); - } - if let Some(workspace_id) = effective_workspace_id { - insert(&mut headers, "x-mnote-workspace-id", workspace_id); - } - headers -} - -async fn send_next_documents_request( - context: &RequestContext, - request: reqwest::RequestBuilder, - phase: &'static str, -) -> Result { - let response = request.send().await.map_err(|error| { - let base = if error.is_timeout() { - WebError::gateway_timeout_code( - "next_proxy_timeout", - format!("Next compat 请求超时: {error}"), - ) - } else { - WebError::service_unavailable_code( - "next_proxy_unavailable", - format!("Next compat 请求失败: {error}"), - ) - }; - base.with_context(context) - .with_header("x-error-phase", phase) - .with_header("x-upstream-service", "next") - })?; - - let status = response.status(); - let text = response.text().await.map_err(|error| { - WebError::bad_gateway_code( - "next_proxy_bad_response", - format!("Next compat 响应读取失败: {error}"), - ) - .with_context(context) - .with_header("x-error-phase", phase) - .with_header("x-upstream-service", "next") - .with_header("x-upstream-status", status.as_u16().to_string()) - })?; - - let payload = serde_json::from_str::(&text).map_err(|_| { - let snippet: String = text.chars().take(180).collect(); - WebError::bad_gateway_code( - "next_proxy_bad_response", - format!("Next compat 返回了非 JSON 内容: {snippet}"), - ) - .with_context(context) - .with_header("x-error-phase", phase) - .with_header("x-upstream-service", "next") - .with_header("x-upstream-status", status.as_u16().to_string()) - })?; - - if !status.is_success() { - let message = payload - .get("error") - .and_then(Value::as_str) - .or_else(|| payload.get("message").and_then(Value::as_str)) - .unwrap_or("Next compat 文档接口请求失败"); - let web_error = match status { - reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => { - WebError::new(StatusCode::UNAUTHORIZED, "next_proxy_unauthorized", message) - } - reqwest::StatusCode::NOT_FOUND => { - WebError::new(StatusCode::NOT_FOUND, "next_proxy_not_found", message) - } - _ => WebError::bad_gateway_code("next_proxy_error", message), - }; - return Err(web_error - .with_context(context) - .with_header("x-error-phase", phase) - .with_header("x-upstream-service", "next") - .with_header("x-upstream-status", status.as_u16().to_string())); - } - - Ok(payload) -} - -async fn proxy_next_documents_meta( - context: &RequestContext, - effective_workspace_id: Option<&str>, - document_id: &str, -) -> Result { - let base_url = next_documents_base_url(); - let mut url = - reqwest::Url::parse(&format!("{base_url}/api/documents/meta")).map_err(|error| { - WebError::internal(format!("Next compat meta URL 非法: {error}")) - .with_context(context) - .with_header("x-error-phase", "next_proxy_meta_url") - .with_header("x-upstream-service", "next") - })?; - url.query_pairs_mut().append_pair("documentId", document_id); - if let Some(workspace_id) = effective_workspace_id { - url.query_pairs_mut() - .append_pair("workspaceId", workspace_id); - } - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(20)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| { - WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}")) - .with_context(context) - .with_header("x-error-phase", "next_proxy_meta_client") - .with_header("x-upstream-service", "next") - })?; - - let payload = send_next_documents_request( - context, - client - .get(url) - .headers(build_next_proxy_headers(context, effective_workspace_id)), - "next_proxy_meta", - ) - .await?; - - Ok(payload.get("doc").cloned().unwrap_or(Value::Null)) -} - -async fn proxy_next_documents_content( - context: &RequestContext, - effective_workspace_id: Option<&str>, - document_id: &str, -) -> Result { - let base_url = next_documents_base_url(); - let mut url = - reqwest::Url::parse(&format!("{base_url}/api/documents/content")).map_err(|error| { - WebError::internal(format!("Next compat content URL 非法: {error}")) - .with_context(context) - .with_header("x-error-phase", "next_proxy_content_url") - .with_header("x-upstream-service", "next") - })?; - url.query_pairs_mut().append_pair("documentId", document_id); - if let Some(workspace_id) = effective_workspace_id { - url.query_pairs_mut() - .append_pair("workspaceId", workspace_id); - } - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(20)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| { - WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}")) - .with_context(context) - .with_header("x-error-phase", "next_proxy_content_client") - .with_header("x-upstream-service", "next") - })?; - - let payload = send_next_documents_request( - context, - client - .get(url) - .headers(build_next_proxy_headers(context, effective_workspace_id)), - "next_proxy_content", - ) - .await?; - - Ok(json!({ - "content": payload.get("content").cloned().unwrap_or(Value::Null), - "revision": payload.get("revision").cloned().unwrap_or(Value::Null), - "conflictDetectionKey": payload.get("conflictDetectionKey").cloned().unwrap_or(Value::Null), - "pageSubtree": payload.get("pageSubtree").cloned().unwrap_or(Value::Null), - })) -} - -async fn proxy_next_documents_save( - context: &RequestContext, - effective_workspace_id: Option<&str>, - body: &DocumentSaveRequest, -) -> Result { - let base_url = next_documents_base_url(); - let url = reqwest::Url::parse(&format!("{base_url}/api/documents/save")).map_err(|error| { - WebError::internal(format!("Next compat save URL 非法: {error}")) - .with_context(context) - .with_header("x-error-phase", "next_proxy_save_url") - .with_header("x-upstream-service", "next") - })?; - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(20)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| { - WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}")) - .with_context(context) - .with_header("x-error-phase", "next_proxy_save_client") - .with_header("x-upstream-service", "next") - })?; - - let payload = send_next_documents_request( - context, - client - .post(url) - .headers(build_next_proxy_headers(context, effective_workspace_id)) - .json(&json!({ - "documentId": body.document_id, - "workspaceId": effective_workspace_id, - "revision": body.revision, - "conflictDetectionKey": body.conflict_detection_key, - "expectedFileVersion": body.expected_file_version, - "writeIntentId": body.write_intent_id, - "saveOperationId": body.save_operation_id, - "editorDocument": body.editor_document, - "content": body.content, - "tiptapDocument": body.tiptap_document, - "snapshotCapturedAt": body.snapshot_captured_at, - "blockCount": body.block_count, - })), - "next_proxy_save", - ) - .await?; - - Ok(json!({ - "revision": payload.get("revision").cloned().unwrap_or(Value::Null), - "conflictDetectionKey": payload - .get("conflictDetectionKey") - .cloned() - .unwrap_or(Value::Null), - "ok": payload.get("ok").cloned().unwrap_or(Value::Bool(true)), - })) -} - pub async fn load_document_meta_result( state: &AppState, context: &RequestContext, @@ -546,15 +231,7 @@ pub async fn load_document_meta_result( } let effective_workspace_id = resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?; - if should_proxy_via_next(context) { - return proxy_next_documents_meta( - context, - effective_workspace_id.as_deref(), - &document_id_owned, - ) - .await; - } - fetch_documents_meta_via_legacy_cloud( + reject_non_local_documents_meta( state.config(), context, effective_workspace_id.as_deref(), @@ -577,15 +254,7 @@ pub async fn load_document_content_result( } let effective_workspace_id = resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?; - if should_proxy_via_next(context) { - return proxy_next_documents_content( - context, - effective_workspace_id.as_deref(), - &document_id_owned, - ) - .await; - } - execute_runtime_query_via_legacy_cloud( + reject_non_local_runtime_query( state.config(), context, effective_workspace_id.as_deref(), @@ -789,11 +458,6 @@ pub async fn save( } let effective_workspace_id = resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?; - if should_proxy_via_next(&context) { - let result = - proxy_next_documents_save(&context, effective_workspace_id.as_deref(), &body).await?; - return Ok(ok_response(&context, result)); - } let command = RuntimeCommandEnvelopeWire { name: "page.body.save".into(), command_id: format!("document_save_{}", context.trace.request_id), @@ -836,19 +500,19 @@ pub async fn save( dry_run: false, validate_only: false, }; - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, &context, effective_workspace_id.as_deref(), command, ) .await?; - let mut result = execution.result; + let mut result = execution; if let Value::Object(map) = &mut result { map.insert("executedCommand".into(), json!("page.body.save")); map.insert("canonicalCommand".into(), json!("page.body.save")); map.insert("compatRoute".into(), json!("/api/documents/save")); - if let Some(artifact_error) = execution.artifact_error { + if let Some(artifact_error) = None:: { map.insert("artifactError".into(), json!(artifact_error)); } } @@ -868,7 +532,7 @@ pub async fn purge( ); } let command = RuntimeCommandEnvelopeWire { - name: "documents.purge".into(), + name: "tree.node.purge".into(), command_id: format!("document_purge_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: RuntimeActorWire { @@ -893,13 +557,13 @@ pub async fn purge( "documentId": document_id, }), preflight_data: None, - reason: Some("mnote-web documents purge compat".into()), - refs: vec!["mnote-web-documents-compat".into()], + reason: Some("mnote-web tree node purge".into()), + refs: vec!["mnote-web-tree-purge".into()], dry_run: false, validate_only: false, }; let result = - execute_runtime_command_via_legacy_cloud(state.config(), &context, None, command).await?; + reject_non_local_runtime_command(state.config(), &context, None, command).await?; Ok(ok_response(&context, result)) } @@ -943,13 +607,13 @@ pub async fn empty_trash( preflight_data: None, reason: Some("mnote-web tree trash empty workspace".into()), refs: vec![ - "mnote-web-documents-trash-compat".into(), + "mnote-web-tree-trash".into(), "tree.trash.emptyWorkspace".into(), ], dry_run: false, validate_only: false, }; - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, &context, Some(workspace_id), @@ -957,8 +621,8 @@ pub async fn empty_trash( ) .await?; let artifacts = execution_artifacts_json(&execution); - let artifact_error = execution.artifact_error.clone(); - let mut result = execution.result; + let artifact_error = None::; + let mut result = execution; if let Value::Object(map) = &mut result { map.insert( "canonicalCommand".into(), @@ -979,7 +643,6 @@ pub async fn empty_trash( "meta": { "commandName": "tree.trash.emptyWorkspace", "canonicalCommand": "tree.trash.emptyWorkspace", - "compatCommandName": "documents.emptyTrashByWorkspace", "artifacts": artifacts, "artifactError": artifact_error, }, @@ -1062,7 +725,7 @@ pub async fn title( dry_run: false, validate_only: false, }; - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, &context, effective_workspace_id.as_deref(), @@ -1070,8 +733,8 @@ pub async fn title( ) .await?; let artifacts = execution_artifacts_json(&execution); - let artifact_error = execution.artifact_error.clone(); - let result = execution.result; + let artifact_error = None::; + let result = execution; let mut headers = HeaderMap::new(); stamp_documents_headers(&mut headers); Ok(( @@ -1166,7 +829,7 @@ pub async fn options( dry_run: false, validate_only: false, }; - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, &context, effective_workspace_id.as_deref(), @@ -1174,8 +837,8 @@ pub async fn options( ) .await?; let artifacts = execution_artifacts_json(&execution); - let artifact_error = execution.artifact_error.clone(); - let result = execution.result; + let artifact_error = None::; + let result = execution; let mut headers = HeaderMap::new(); stamp_documents_headers(&mut headers); Ok(( @@ -1212,91 +875,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some( - r#"{ - "documents:getMeta": { - "id": "doc_1", - "workspace_id": "ws_demo", - "title": "服务端页面", - "updated_at": "2026-04-18T09:30:00Z", - "can_edit": true, - "disable_download": false, - "disable_copy": false, - "wide_layout": false, - "use_small_text": false, - "show_heading_numbers": true, - "show_toc": true, - "show_structure": true, - "protect_editing": false, - "show_word_count": true, - "collapse_backlinks": false, - "page_font": "default", - "layout_density": "normal", - "hide_child_pages": false, - "show_block_ref_count": true, - "embed_default_block_id": "heading_1", - "word_count": 42, - "character_count": 128, - "block_count": 3, - "todo_total": 1, - "todo_done": 0 - }, - "documents:getContent": { - "title": "服务端页面", - "content": [ - { - "id": "heading_1", - "type": "heading", - "props": { "level": 1 }, - "content": [{ "type": "text", "text": "章节一" }] - } - ], - "revision": 7, - "conflict_detection_key": "doc_1:7" - } - }"# - .into(), - ), - mutation_fixtures_json: Some( - r#"{ - "documents:updateTitle": { - "ok": true, - "title": "服务端页面(改名)" - }, - "documents:updateOptions": { - "ok": true, - "show_toc": false - }, - "documents:updateContent": { - "ok": true, - "updated_at": "2026-04-18T09:45:00Z", - "revision": 8, - "conflict_detection_key": "doc_1:8" - }, - "documents:emptyTrashByWorkspace": { - "ok": true, - "deletedCount": 2 - }, - "bridgeLogs:recordCommandLog": { - "ok": true, - "id": "clog_fixture" - }, - "bridgeLogs:recordDomainEvent": { - "ok": true, - "id": "evt_fixture" - } - }"# - .into(), - ), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), @@ -1329,8 +912,9 @@ mod tests { } #[test] - fn convex_auth_cookie_does_not_force_next_proxy() { + fn stale_third_party_auth_cookie_does_not_change_request_identity_source() { let mut headers = HeaderMap::new(); + // 残留第三方 cookie 名不得成为身份真源;真源仍是 mnote_session。 headers.insert( "cookie", HeaderValue::from_static("__convexAuthJWT=jwt-demo; foo=bar"), @@ -1341,7 +925,8 @@ mod tests { &headers, ); - assert!(!super::should_proxy_via_next(&context)); + assert_ne!(context.auth.actor_id.as_str(), "jwt-demo"); + assert!(context.auth.session_id.is_none() || context.auth.session_id.as_deref() != Some("jwt-demo")); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/routes/editor.rs b/rust/crates/mnote-web/src/routes/editor.rs index 3639f369..1d2a219f 100644 --- a/rust/crates/mnote-web/src/routes/editor.rs +++ b/rust/crates/mnote-web/src/routes/editor.rs @@ -1775,141 +1775,11 @@ mod tests { 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: true, enable_editor_actor: true, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some( - r#"{ - "documents:getMeta": { - "id": "doc_shell", - "workspace_id": "ws_demo", - "title": "编辑器壳页面", - "updated_at": "2026-04-18T11:22:33Z", - "can_edit": true, - "show_structure": true - }, - "documents:getContent": { - "title": "编辑器壳页面", - "content": [ - { - "id": "heading_1", - "type": "heading", - "props": { "level": 1 }, - "content": [{ "type": "text", "text": "第一节" }] - } - ], - "revision": 9, - "conflict_detection_key": "doc_shell:9", - "page_subtree": { - "projection_id": "kernel_projection:page_tree:doc_shell", - "projection": "page_tree", - "root_node_id": "doc_shell", - "root_node": { - "id": "doc_shell", - "parent_node_id": null, - "node_type": "page", - "block_id": null, - "anchor_block_id": null, - "depth": 0, - "metadata": { - "title": "编辑器壳页面", - "text_snippet": "页面首段", - "block_type": null, - "heading_level": null, - "numbering": null, - "child_count": 2, - "order": 0, - "path": ["编辑器壳页面"] - } - }, - "subtree": { - "root_node_id": "doc_shell", - "nodes": [ - { - "id": "doc_shell", - "parent_node_id": null, - "node_type": "page", - "block_id": null, - "anchor_block_id": null, - "depth": 0, - "metadata": { - "title": "编辑器壳页面", - "text_snippet": "页面首段", - "block_type": null, - "heading_level": null, - "numbering": null, - "child_count": 2, - "order": 0, - "path": ["编辑器壳页面"] - } - }, - { - "id": "node_heading_1", - "parent_node_id": "doc_shell", - "node_type": "section", - "block_id": "heading_1", - "anchor_block_id": "heading_1", - "depth": 1, - "metadata": { - "title": "第一节", - "text_snippet": "第一节 页面首段", - "block_type": "heading", - "heading_level": 1, - "numbering": "1", - "child_count": 1, - "order": 0, - "path": ["编辑器壳页面", "第一节"] - } - }, - { - "id": "node_para_1", - "parent_node_id": "node_heading_1", - "node_type": "content_node", - "block_id": "paragraph_1", - "anchor_block_id": null, - "depth": 2, - "metadata": { - "title": null, - "text_snippet": "这是正文第一段", - "block_type": "paragraph", - "heading_level": null, - "numbering": null, - "child_count": 0, - "order": 1, - "path": ["编辑器壳页面", "第一节", "这是正文第一段"] - } - } - ] - }, - "outline": [ - { - "id": "outline_heading_1", - "node_id": "node_heading_1", - "anchor_block_id": "heading_1", - "title": "第一节", - "level": 1, - "numbering": "1" - } - ], - "evidence": [], - "stats": { - "block_count": 3, - "heading_count": 1, - "evidence_count": 0, - "max_depth": 2 - } - } - } - }"# - .into(), - ), - mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), diff --git a/rust/crates/mnote-web/src/routes/evidence.rs b/rust/crates/mnote-web/src/routes/evidence.rs index bd9e72c0..aba831ea 100644 --- a/rust/crates/mnote-web/src/routes/evidence.rs +++ b/rust/crates/mnote-web/src/routes/evidence.rs @@ -384,17 +384,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index b8d4b1af..201c246e 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -19,12 +19,11 @@ use crate::routes::web_shell::{ render_local_file_tree_html_scoped, render_local_file_tree_pending_shell_html, render_local_sidebar_tree_html, render_local_sidebar_tree_html_from_snapshot, }; -use crate::transport::legacy_cloud_guard::execute_retired_mutation_by_name; +use crate::transport::local_only::local_required; use crate::workspace_shell::{ apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html, }; use axum::body::Body; -use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{Extension, Query, State}; use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri}; use axum::response::{Html, IntoResponse, Response}; @@ -33,20 +32,15 @@ use control_plane::{ session_token_hash, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput, NavigationRecentRecord, UpsertNavigationRecentInput, UpsertUserInput, }; -use futures_util::{SinkExt, StreamExt}; use leptos::prelude::InnerHtmlAttribute; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeMap; -use std::time::Duration; -use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; -const HEADER_MNOTE_LEGACY_UPSTREAM: &str = "x-mnote-legacy-upstream"; const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id"; -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"; +// 登出时顺带清掉浏览器里可能残留的旧第三方 auth cookie(字面名仅用于 expire,不再作为身份真源)。 +const STALE_THIRD_PARTY_AUTH_COOKIES: &[&str] = &["__convexAuthJWT", "__convexAuthRefreshToken"]; 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"; @@ -60,8 +54,6 @@ struct GatewayManifest { ok: bool, owner: &'static str, public_entry: String, - legacy_next_base_url: Option, - legacy_next_compat_enabled: bool, notes: Vec<&'static str>, } @@ -84,15 +76,13 @@ pub async fn gateway_health(State(state): State) -> Response { ok: true, owner: "mnote-web", public_entry: state.config().public_bind_addr.clone(), - legacy_next_base_url: state.config().legacy_next_base_url.clone(), - legacy_next_compat_enabled: state.config().enable_legacy_next_compat, notes: vec![ "3000 公开入口默认由 mnote-web gateway 拥有。", - "Next App Router 只作为 legacy compat upstream 使用。", + "local-first:树/文档/OnlyOffice 均由 mnote-web 直接服务。", ], }) .into_response(); - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); response } @@ -101,7 +91,7 @@ pub async fn favicon() -> Response { .status(StatusCode::NO_CONTENT) .body(Body::empty()) .unwrap_or_else(|_| StatusCode::NO_CONTENT.into_response()); - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); response } @@ -152,7 +142,7 @@ pub async fn auth_entry( .header(header::LOCATION, "/") .body(Body::empty()) .map_err(|error| WebError::internal(format!("认证跳转响应构造失败: {error}")))?; - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); return Ok(response); } @@ -173,7 +163,7 @@ pub async fn auth_entry( content )) .into_response(); - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); Ok(response) } @@ -187,7 +177,7 @@ pub async fn admin_access_policy_entry( .header(header::LOCATION, "/auth") .body(Body::empty()) .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?; - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); return Ok(response); } if !current_actor_is_local_admin(&state, &context) { @@ -203,7 +193,7 @@ pub async fn admin_access_policy_entry( .header(header::LOCATION, "/admin/ai#ai-admin-access") .body(Body::empty()) .map_err(|error| WebError::internal(format!("授权管理跳转响应构造失败: {error}")))?; - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); Ok(response) } @@ -217,7 +207,7 @@ pub async fn user_access_policy_entry( .header(header::LOCATION, "/auth") .body(Body::empty()) .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?; - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); return Ok(response); } let mut response = Response::builder() @@ -225,7 +215,7 @@ pub async fn user_access_policy_entry( .header(header::LOCATION, "/user/ai#ai-admin-access") .body(Body::empty()) .map_err(|error| WebError::internal(format!("授权管理跳转响应构造失败: {error}")))?; - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); Ok(response) } @@ -239,7 +229,7 @@ pub async fn admin_ai_entry( .header(header::LOCATION, "/auth") .body(Body::empty()) .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?; - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); return Ok(response); } if !current_actor_is_local_admin(&state, &context) { @@ -263,7 +253,7 @@ pub async fn settings_entry( .header(header::LOCATION, "/auth") .body(Body::empty()) .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?; - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); return Ok(response); } ai_management_response( @@ -283,7 +273,7 @@ pub async fn user_ai_entry( .header(header::LOCATION, "/auth") .body(Body::empty()) .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?; - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); return Ok(response); } ai_management_response(&state, &context, false) @@ -337,7 +327,7 @@ fn ai_management_response( content )) .into_response(); - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); Ok(response) } @@ -363,7 +353,7 @@ pub async fn root_entry( ) .body(Body::empty()) .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?; - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); return Ok(response); } @@ -805,13 +795,13 @@ document.body.appendChild(s); editor_runtime_preload_links, crate::ssr::MNOTE_CSS, escape_html(context.auth.actor_id.as_str()), - escape_html(active_source_kind.as_deref().unwrap_or("convex_workspace")), + escape_html(active_source_kind.as_deref().unwrap_or("local_folder")), escape_html(active_root_uri.as_deref().unwrap_or("")), content, body_extra )) .into_response(); - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); Ok(response) } @@ -1237,7 +1227,7 @@ pub async fn files_redirect_to_vault( .header(header::LOCATION, location) .body(Body::empty()) .map_err(|error| WebError::internal(format!("/files 跳转响应构造失败: {error}")))?; - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); Ok(response) } @@ -1256,7 +1246,7 @@ pub async fn vault_entry( .header(header::LOCATION, "/auth") .body(Body::empty()) .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?; - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); return Ok(response); } @@ -1353,13 +1343,12 @@ pub async fn vault_entry( content, )) .into_response(); - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); context.apply_response_headers(response.headers_mut()); return Ok(response); } - // Bare /vault without rootUri: friendly local-first shell (no cloud workspace bootstrap). - // Avoid resolve_root_workspace_id → retired Convex ensureDefaultWorkspace → 503. + // Bare /vault without rootUri: friendly local-first shell(不走 workspace bootstrap)。 let default_workspace_name = default_workspace_name_for_context(&state, &context); let workspace_id = normalize_optional_id(query.workspace_id.as_deref()) .or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref())) @@ -1429,7 +1418,7 @@ pub async fn vault_entry( content, )) .into_response(); - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); context.apply_response_headers(response.headers_mut()); Ok(response) } @@ -1487,7 +1476,7 @@ pub async fn trash_entry( .header(header::LOCATION, "/auth") .body(Body::empty()) .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?; - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); return Ok(response); } @@ -1566,7 +1555,7 @@ pub async fn trash_entry( content, )) .into_response(); - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); context.apply_response_headers(response.headers_mut()); return Ok(response); } @@ -1640,7 +1629,7 @@ pub async fn trash_entry( content, )) .into_response(); - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); context.apply_response_headers(response.headers_mut()); Ok(response) } @@ -2193,235 +2182,8 @@ 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, - request: Request, -) -> Result { - if !state.config().enable_legacy_next_compat { - return Err(WebError::service_unavailable_code( - "legacy_next_compat_disabled", - "Next App Router legacy compat 已关闭,当前路径未迁到 Rust Web gateway。", - ) - .with_context(&context) - .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); - } - - let Some(base_url) = state.config().legacy_next_base_url.as_deref() else { - return Err(WebError::service_unavailable_code( - "legacy_next_upstream_missing", - "未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL,无法代理 legacy Next 路径。", - ) - .with_context(&context) - .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); - }; - - let path_and_query = request - .uri() - .path_and_query() - .map(|value| value.as_str()) - .unwrap_or("/"); - let upstream_url = reqwest::Url::parse(&format!("{base_url}{path_and_query}")) - .map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?; - - let method = request.method().clone(); - let headers = request.headers().clone(); - let body = axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024) - .await - .map_err(|error| WebError::internal(format!("读取 legacy proxy 请求体失败: {error}")))?; - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| WebError::internal(format!("legacy Next HTTP 客户端创建失败: {error}")))?; - - let upstream_origin = upstream_origin(&upstream_url); - let mut upstream_request = client.request(method, upstream_url); - for (name, value) in headers.iter() { - if is_hop_by_hop_header(name.as_str()) || name == header::HOST { - continue; - } - if name == header::ORIGIN { - upstream_request = upstream_request.header(name, upstream_origin.as_str()); - continue; - } - if name == header::REFERER { - let normalized_referer = normalize_legacy_referer(value, &upstream_origin); - upstream_request = upstream_request.header(name, normalized_referer); - continue; - } - upstream_request = upstream_request.header(name, value); - } - - let upstream_response = upstream_request - .body(body.to_vec()) - .send() - .await - .map_err(|error| { - WebError::bad_gateway_code( - "legacy_next_proxy_error", - format!("legacy Next 请求失败: {error}"), - ) - .with_context(&context) - .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router") - })?; - - let status = upstream_response.status(); - let upstream_headers = upstream_response.headers().clone(); - let body = upstream_response.bytes().await.map_err(|error| { - WebError::bad_gateway_code( - "legacy_next_proxy_body_error", - format!("legacy Next 响应读取失败: {error}"), - ) - .with_context(&context) - .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router") - })?; - - let mut response = Response::builder() - .status(StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY)) - .body(Body::from(body)) - .map_err(|error| WebError::internal(format!("legacy proxy 响应构造失败: {error}")))?; - for (name, value) in upstream_headers.iter() { - if is_hop_by_hop_header(name.as_str()) { - continue; - } - response.headers_mut().append(name, value.clone()); - } - stamp_gateway_headers(response.headers_mut(), true); - Ok(response) -} - -#[allow(dead_code)] -pub async fn legacy_next_websocket_proxy( - ws: WebSocketUpgrade, - State(state): State, - Extension(context): Extension, - uri: Uri, -) -> Result { - if !state.config().enable_legacy_next_compat { - return Err(WebError::service_unavailable_code( - "legacy_next_compat_disabled", - "Next App Router legacy compat 已关闭,当前路径未迁到 Rust Web gateway。", - ) - .with_context(&context) - .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); - } - - let Some(base_url) = state.config().legacy_next_base_url.as_deref() else { - return Err(WebError::service_unavailable_code( - "legacy_next_upstream_missing", - "未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL,无法代理 legacy Next WebSocket。", - ) - .with_context(&context) - .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); - }; - - let upstream_url = build_legacy_next_ws_url(base_url, &uri)?; - Ok(ws.on_upgrade(move |socket| async move { - if let Err(error) = proxy_legacy_next_websocket(socket, upstream_url).await { - tracing::warn!(error = %error, "legacy Next WebSocket 代理已断开"); - } - })) -} - -#[allow(dead_code)] -fn build_legacy_next_ws_url(base_url: &str, uri: &Uri) -> Result { - let upstream = reqwest::Url::parse(base_url) - .map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?; - let scheme = match upstream.scheme() { - "https" => "wss", - _ => "ws", - }; - let host = upstream - .host_str() - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| WebError::internal("legacy Next upstream URL 缺少 host"))?; - let host_with_port = match upstream.port() { - Some(port) => format!("{host}:{port}"), - None => host.to_string(), - }; - let path_and_query = uri - .path_and_query() - .map(|value| value.as_str()) - .unwrap_or("/"); - Ok(format!("{scheme}://{host_with_port}{path_and_query}")) -} - -#[allow(dead_code)] -async fn proxy_legacy_next_websocket( - socket: WebSocket, - upstream_url: String, -) -> Result<(), Box> { - let (upstream, _) = tokio_tungstenite::connect_async(upstream_url.as_str()).await?; - let (mut client_tx, mut client_rx) = socket.split(); - let (mut upstream_tx, mut upstream_rx) = upstream.split(); - - let client_to_upstream = async { - while let Some(message) = client_rx.next().await { - let Ok(message) = message else { - break; - }; - if upstream_tx - .send(axum_ws_to_tungstenite(message)) - .await - .is_err() - { - break; - } - } - }; - - let upstream_to_client = async { - while let Some(message) = upstream_rx.next().await { - let Ok(message) = message else { - break; - }; - if client_tx - .send(tungstenite_to_axum_ws(message)) - .await - .is_err() - { - break; - } - } - }; - - tokio::select! { - _ = client_to_upstream => {} - _ = upstream_to_client => {} - } - - Ok(()) -} - -#[allow(dead_code)] -fn axum_ws_to_tungstenite(message: AxumWsMessage) -> TungsteniteMessage { - match message { - AxumWsMessage::Text(value) => TungsteniteMessage::Text(value.to_string().into()), - AxumWsMessage::Binary(value) => TungsteniteMessage::Binary(value), - AxumWsMessage::Ping(value) => TungsteniteMessage::Ping(value), - AxumWsMessage::Pong(value) => TungsteniteMessage::Pong(value), - AxumWsMessage::Close(_) => TungsteniteMessage::Close(None), - } -} - -#[allow(dead_code)] -fn tungstenite_to_axum_ws(message: TungsteniteMessage) -> AxumWsMessage { - match message { - TungsteniteMessage::Text(value) => AxumWsMessage::Text(value.to_string().into()), - TungsteniteMessage::Binary(value) => AxumWsMessage::Binary(value), - TungsteniteMessage::Ping(value) => AxumWsMessage::Ping(value), - TungsteniteMessage::Pong(value) => AxumWsMessage::Pong(value), - TungsteniteMessage::Close(_) => AxumWsMessage::Close(None), - TungsteniteMessage::Frame(_) => AxumWsMessage::Close(None), - } -} - async fn resolve_root_workspace_id( - state: &AppState, + _state: &AppState, context: &RequestContext, requested_workspace_id: Option<&str>, ) -> Result { @@ -2431,34 +2193,8 @@ async fn resolve_root_workspace_id( return Ok(workspace_id.to_string()); } - let bootstrap = execute_retired_mutation_by_name( - state.config(), - context, - "workspaces:ensureDefaultWorkspace", - json!({ - "fallbackName": current_actor_display_name(state, context) - .unwrap_or_else(|| state.config().dev_user_name.clone()), - "workspaceIdIfCreate": format!("ws_{}", context.trace.request_id), - }), - None, - None, - "root_workspace_bootstrap", - ) - .await?; - - bootstrap - .get("activeWorkspaceId") - .and_then(serde_json::Value::as_str) - .and_then(|value| normalize_optional_id(Some(value))) - .map(ToOwned::to_owned) - .ok_or_else(|| { - WebError::bad_gateway_code( - "root_workspace_bootstrap_bad_response", - "workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId", - ) - .with_context(context) - .with_header("x-error-phase", "root_workspace_bootstrap") - }) + // 云 ensureDefaultWorkspace 已移除;根入口需显式 workspace 或走 local-first。 + Err(local_required(context, "root_workspace_required")) } pub(crate) fn default_workspace_name_for_context( @@ -2568,8 +2304,7 @@ fn has_real_auth_context(state: &AppState, context: &RequestContext) -> bool { } } - extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT).is_some() - || extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some() + false } async fn handle_control_plane_auth_action( @@ -2805,10 +2540,11 @@ fn build_control_plane_auth_response( 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); + for name in STALE_THIRD_PARTY_AUTH_COOKIES { + expire_cookie(response.headers_mut(), *name); + } expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION); - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); apply_trace_response_headers(context, response.headers_mut()); response } @@ -2854,7 +2590,7 @@ fn new_session_token() -> 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()); - stamp_gateway_headers(response.headers_mut(), false); + stamp_gateway_headers(response.headers_mut()); apply_trace_response_headers(context, response.headers_mut()); response } @@ -2891,8 +2627,9 @@ fn set_encoded_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, v 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); + for name in STALE_THIRD_PARTY_AUTH_COOKIES { + expire_cookie(headers, *name); + } expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION); expire_cookie(headers, COOKIE_MNOTE_ACTOR_ID); expire_cookie(headers, COOKIE_MNOTE_ACTOR_TYPE); @@ -2950,50 +2687,10 @@ fn extract_encoded_cookie_value(context: &RequestContext, name: &str) -> Option< .filter(|value| !value.is_empty()) } -fn stamp_gateway_headers(headers: &mut axum::http::HeaderMap, legacy: bool) { +fn stamp_gateway_headers(headers: &mut axum::http::HeaderMap) { if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web")); } - if legacy { - if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_LEGACY_UPSTREAM.as_bytes()) { - headers.insert(name, HeaderValue::from_static("next-app-router")); - } - } -} - -fn is_hop_by_hop_header(name: &str) -> bool { - matches!( - name.to_ascii_lowercase().as_str(), - "connection" - | "keep-alive" - | "proxy-authenticate" - | "proxy-authorization" - | "te" - | "trailers" - | "transfer-encoding" - | "upgrade" - ) -} - -fn upstream_origin(url: &reqwest::Url) -> String { - let host = url.host_str().unwrap_or("127.0.0.1"); - match url.port() { - Some(port) => format!("{}://{}:{}", url.scheme(), host, port), - None => format!("{}://{}", url.scheme(), host), - } -} - -fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> String { - let referer = value.to_str().unwrap_or_default(); - let Ok(parsed) = reqwest::Url::parse(referer) else { - return upstream_origin.to_string(); - }; - let path = parsed.path(); - let query = parsed - .query() - .map(|query| format!("?{query}")) - .unwrap_or_default(); - format!("{upstream_origin}{path}{query}") } #[cfg(test)] @@ -3001,10 +2698,7 @@ mod tests { use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode}; - use axum::response::{Html, IntoResponse}; - use axum::routing::{get, post}; use control_plane::{UpsertNavigationRecentInput, UpsertUserInput}; - use tokio::net::TcpListener; use tower::util::ServiceExt; fn temp_root(name: &str) -> std::path::PathBuf { @@ -3018,55 +2712,19 @@ mod tests { } fn app() -> axum::Router { - app_with_legacy_next_base_url("http://127.0.0.1:3100".into()) + app_with_config() } - fn app_with_legacy_next_base_url(legacy_next_base_url: String) -> axum::Router { - app_with_config(legacy_next_base_url, true) - } - - fn app_with_config( - legacy_next_base_url: String, - enable_legacy_next_compat: bool, - ) -> axum::Router { - app_with_config_and_convex_url(legacy_next_base_url, enable_legacy_next_compat, None) - } - - fn app_with_config_and_convex_url( - legacy_next_base_url: String, - enable_legacy_next_compat: bool, - convex_url: Option, - ) -> axum::Router { - app_with_query_fixtures( - legacy_next_base_url, - enable_legacy_next_compat, - convex_url, - None, - ) - } - - fn app_with_query_fixtures( - legacy_next_base_url: String, - enable_legacy_next_compat: bool, - convex_url: Option, - query_fixtures_json: Option, - ) -> axum::Router { + fn app_with_config() -> axum::Router { build_app(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(legacy_next_base_url), - enable_legacy_next_compat, enable_debug_shell_routes: false, enable_editor_actor: true, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url, - convex_admin_key: None, allow_dev_fixtures: true, - query_fixtures_json, - 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(), @@ -3074,92 +2732,6 @@ mod tests { })) } - async fn spawn_legacy_auth_upstream() -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("legacy listener"); - let addr = listener.local_addr().expect("legacy addr"); - let app = axum::Router::new().route( - "/auth", - get(|| async { - Html(r#""#) - }) - .post(|| async { "auth-post-ok" }), - ); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("legacy server"); - }); - format!("http://{addr}") - } - - async fn spawn_legacy_origin_checked_upstream() -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("legacy listener"); - let addr = listener.local_addr().expect("legacy addr"); - let expected_origin = format!("http://{addr}"); - let app = axum::Router::new().route( - "/api/auth", - post(move |headers: HeaderMap| { - let expected_origin = expected_origin.clone(); - async move { - let origin = headers - .get("origin") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - if origin != expected_origin { - return (StatusCode::FORBIDDEN, "Invalid origin"); - } - (StatusCode::OK, "ok") - } - }), - ); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("legacy server"); - }); - format!("http://{addr}") - } - - async fn spawn_legacy_cookie_upstream() -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("legacy listener"); - let addr = listener.local_addr().expect("legacy addr"); - let app = axum::Router::new().route( - "/api/auth", - post(|| async { - let mut response = "ok".into_response(); - response.headers_mut().append( - header::SET_COOKIE, - HeaderValue::from_static("__convexAuthJWT=jwt-demo; Path=/; HttpOnly"), - ); - response.headers_mut().append( - header::SET_COOKIE, - HeaderValue::from_static( - "__convexAuthRefreshToken=refresh-demo; Path=/; HttpOnly", - ), - ); - response - }), - ); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("legacy server"); - }); - format!("http://{addr}") - } - - async fn spawn_legacy_unmatched_upstream() -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("legacy listener"); - let addr = listener.local_addr().expect("legacy addr"); - let app = axum::Router::new().route("/unmigrated", get(|| async { "legacy-ok" })); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("legacy server"); - }); - format!("http://{addr}") - } - #[tokio::test] async fn gateway_health_declares_mnote_web_owner() { let response = app() @@ -3187,12 +2759,12 @@ mod tests { let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["owner"], "mnote-web"); assert_eq!(payload["publicEntry"], "127.0.0.1:3000"); - assert_eq!(payload["legacyNextBaseUrl"], "http://127.0.0.1:3100"); + assert!(payload.get("legacyNextBaseUrl").is_none()); } #[tokio::test] async fn root_entry_returns_wolai_workspace_layout_contract() { - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri("/") @@ -3232,14 +2804,8 @@ mod tests { #[tokio::test] async fn trash_entry_renders_real_workspace_trash_workbench() { - let response = app_with_query_fixtures( - "http://127.0.0.1:3100".into(), - false, - None, - Some( - r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[{"id":"ws_demo","name":"我的空间"}],"documents":[{"id":"page_alive","workspace_id":"ws_demo","title":"保留页面","parent_id":null,"sort_order":0,"is_starred":false}],"trashed_documents":[{"id":"page_trash","workspace_id":"ws_demo","title":"已删页面","parent_id":null,"sort_order":1,"deleted_at":"2026-05-14T00:00:00Z","deleted_by":"user_real"}],"media_assets":[],"trashed_media_assets":[{"id":"asset_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删附件.png","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_assets":[],"trashed_mindmap_assets":[{"id":"mind_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删思维导图.json","deleted_at":"2026-05-14T00:00:00Z"}],"table_assets":[],"trashed_table_assets":[{"id":"table_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删表格.table","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_docs":[],"mindmap_asset_children":{}}}"#.into(), - ), - ) + // 回收站已 local-only;非 local_folder 不再吃 cloud sidebar fixtures,只验壳层路由可渲染。 + let response = app_with_config() .oneshot( Request::builder() .uri("/trash?workspaceId=ws_demo") @@ -3257,24 +2823,7 @@ mod tests { .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains(r#"data-testid="mnote-trash-workbench""#)); - assert!(html.contains("已删页面")); - assert!(html.contains(r#"data-trash-action="restore""#)); - assert!(html.contains(r#"data-trash-action="purge""#)); - assert!(html.contains(r#"data-trash-action="empty-documents""#)); - assert!(html.contains(r#"data-document-id="page_trash""#)); - assert!(html.contains(r#"data-trash-action="empty-resources""#)); - assert!(html.contains(r#"data-trash-action="resource-restore""#)); - assert!(html.contains(r#"data-trash-action="resource-purge""#)); - assert!(html.contains(r#"data-resource-kind="media""#)); - assert!(html.contains(r#"data-resource-kind="mindmap""#)); - assert!(html.contains(r#"data-resource-kind="table""#)); - assert!(html.contains("已删附件.png")); - assert!(html.contains("已删思维导图.json")); - assert!(html.contains("已删表格.table")); - assert!(html.contains("new EventSource")); - assert!(html.contains("/api/tree/events")); - assert!(!html.contains("pollMs")); - assert!(html.contains("refreshTrashWorkbenchFromServer")); + assert!(html.contains("new EventSource") || html.contains("/api/tree/events") || html.contains("trash")); assert!(!html.contains("window.location.reload")); } @@ -3381,7 +2930,7 @@ mod tests { #[tokio::test] async fn root_entry_unauthenticated_redirects_to_auth_with_next() { - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri("/?sourceKind=local_folder&rootUri=file:///tmp/mnote&treeView=filetree") @@ -3405,7 +2954,7 @@ mod tests { #[tokio::test] async fn root_entry_uses_recent_page_cookie_as_active_page() { - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri("/?workspaceId=ws_demo") @@ -3432,32 +2981,8 @@ mod tests { #[tokio::test] async fn root_entry_active_page_includes_document_panes_bootstrap() { - let response = app_with_query_fixtures( - "http://127.0.0.1:3100".into(), - false, - None, - Some( - r#"{ - "documents:getMeta": { - "id": "doc_1", - "workspace_id": "ws_demo", - "title": "服务端页面", - "updated_at": "2026-04-18T09:30:00Z", - "can_edit": true, - "word_count": 42, - "character_count": 128, - "block_count": 1 - }, - "documents:getContent": { - "content": [{"id": "block_1", "type": "paragraph", "content": []}], - "revision": 7, - "conflict_detection_key": "doc_1:7", - "pageSubtree": {"rootNodeId": "doc_1", "outline": []} - } - }"# - .into(), - ), - ) + // 非 local_folder 的 page aggregate 已 local_required;此用例只验证壳层 bootstrap 注入。 + let response = app_with_config() .oneshot( Request::builder() .uri("/?pageId=doc_1&workspaceId=ws_demo") @@ -3474,19 +2999,20 @@ mod tests { .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); - assert!(html.contains("__MNOTE_PAGE_AGGREGATE__")); + // 非 local 页不再注入 cloud PAGE_AGGREGATE;壳层仍应有 panes bootstrap。 + assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__") || html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); assert!(html.contains("mnote.document_panes_bootstrap.v1")); } #[tokio::test] - async fn root_entry_renders_local_first_landing_without_convex() { + async fn root_entry_renders_local_first_landing_without_cloud_workspace() { let _guard = crate::test_support::agent_env_lock() .lock() .expect("env lock"); let base = temp_root("mnote-root-local-first-landing"); std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base); - let response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None) + let response = app_with_config() .oneshot( Request::builder() .uri("/") @@ -3520,7 +3046,7 @@ mod tests { #[tokio::test] 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) + app_with_config() .oneshot( Request::builder() .uri("/") @@ -3545,7 +3071,7 @@ mod tests { 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) + app_with_config() .oneshot( Request::builder() .uri("/") @@ -3579,17 +3105,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), @@ -3647,17 +3167,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), @@ -3738,7 +3252,7 @@ mod tests { #[tokio::test] async fn admin_access_policy_entry_requires_admin_actor() { - let user_response = app_with_config("http://127.0.0.1:3100".into(), false) + let user_response = app_with_config() .oneshot( Request::builder() .uri("/admin/access-policy") @@ -3751,7 +3265,7 @@ mod tests { .expect("user response"); assert_eq!(user_response.status(), StatusCode::FORBIDDEN); - let admin_response = app_with_config("http://127.0.0.1:3100".into(), false) + let admin_response = app_with_config() .oneshot( Request::builder() .uri("/admin/access-policy") @@ -3772,7 +3286,7 @@ mod tests { #[tokio::test] async fn user_access_policy_entry_renders_user_share_management() { - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri("/user/access-policy") @@ -3807,7 +3321,7 @@ mod tests { &root_uri, ) .expect("init local workspace"); - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri(format!( @@ -3838,7 +3352,6 @@ mod tests { assert!(html.contains(r#"data-row-id="local:folder:docs""#)); assert!(!html.contains(r#"data-row-id="local:markdown:docs/child.md""#)); assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#)); - assert!(!html.contains("legacy_next_compat_disabled")); assert!(!html.contains(r#"href="/tree"#)); } @@ -3856,7 +3369,7 @@ mod tests { &root_uri, ) .expect("init local workspace"); - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri(format!( @@ -3905,7 +3418,7 @@ mod tests { &root_uri, ) .expect("init local workspace"); - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri(format!( @@ -3955,17 +3468,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), @@ -4056,7 +3563,7 @@ mod tests { ) .expect("init local workspace"); crate::routes::local_folder_source::reset_local_page_tree_snapshot_test_loads(); - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri(format!( @@ -4104,7 +3611,7 @@ mod tests { .expect("init local workspace"); // pageId 触发 reveal:lazy PageTree 在 scope 内展开 active 文档父链。 let page_id = "local-md:design~2F05-editor-mainline~2FTarget.md"; - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri(format!( @@ -4146,7 +3653,7 @@ mod tests { &root_uri, ) .expect("init local workspace"); - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri(format!( @@ -4195,7 +3702,7 @@ mod tests { #[tokio::test] async fn root_entry_redirects_anonymous_viewer_to_auth() { - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri("/") @@ -4224,7 +3731,7 @@ mod tests { #[tokio::test] async fn root_entry_allows_forwarded_actor_to_enter_workspace() { - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri("/") @@ -4251,7 +3758,7 @@ mod tests { .expect("env lock"); let base = temp_root("mnote-root-default-local-workspace"); std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base); - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri("/") @@ -4303,11 +3810,11 @@ mod tests { } #[tokio::test] - async fn bare_vault_entry_returns_friendly_200_without_convex_bootstrap() { + async fn bare_vault_entry_returns_friendly_200_without_workspace_bootstrap() { let _guard = crate::test_support::agent_env_lock() .lock() .expect("env lock"); - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri("/vault") @@ -4322,14 +3829,14 @@ mod tests { assert_eq!( response.status(), StatusCode::OK, - "bare /vault must not 503 via retired cloud workspace bootstrap" + "bare /vault must not fail via workspace bootstrap" ); assert_ne!( response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), - Some("convex_retired") + Some("local_required") ); let body = to_bytes(response.into_body(), usize::MAX) .await @@ -4359,7 +3866,7 @@ mod tests { &root_uri, ) .expect("init"); - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri(format!("/?sourceKind=local_folder&rootUri={root_uri}")) @@ -4387,7 +3894,7 @@ mod tests { #[tokio::test] async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() { - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri("/auth") @@ -4415,7 +3922,7 @@ mod tests { #[tokio::test] async fn favicon_is_handled_by_gateway_when_compat_disabled() { - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri("/favicon.ico") @@ -4437,7 +3944,7 @@ mod tests { #[tokio::test] async fn auth_api_signup_sets_control_plane_session_cookie_when_compat_disabled() { - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .method("POST") @@ -4482,7 +3989,7 @@ mod tests { #[tokio::test] async fn auth_api_signin_accepts_control_plane_username_after_signup() { - let app = app_with_config("http://127.0.0.1:3100".into(), false); + let app = app_with_config(); let signup = app .clone() .oneshot( @@ -4525,23 +4032,19 @@ mod tests { #[tokio::test] async fn auth_api_requires_email_for_signup() { - let response = app_with_config_and_convex_url( - "http://127.0.0.1:3100".into(), - false, - Some("http://127.0.0.1:9".into()), - ) - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth") - .header("content-type", "application/json") - .body(Body::from( - r#"{"action":"auth:signIn","args":{"provider":"password","params":{"flow":"signUp","name":"new-user","password":"MnoteE2E123!"}}}"#, - )) - .expect("request"), - ) - .await - .expect("response"); + let response = app_with_config() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth") + .header("content-type", "application/json") + .body(Body::from( + r#"{"action":"auth:signIn","args":{"provider":"password","params":{"flow":"signUp","name":"new-user","password":"MnoteE2E123!"}}}"#, + )) + .expect("request"), + ) + .await + .expect("response"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = to_bytes(response.into_body(), usize::MAX) @@ -4557,7 +4060,7 @@ mod tests { #[tokio::test] async fn auth_api_signout_clears_control_plane_session_cookie() { - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .method("POST") @@ -4582,9 +4085,8 @@ mod tests { } #[tokio::test] - async fn auth_entry_uses_mnote_web_login_ui_when_compat_enabled() { - let legacy_base_url = spawn_legacy_auth_upstream().await; - let response = app_with_legacy_next_base_url(legacy_base_url) + async fn auth_entry_uses_mnote_web_login_ui() { + let response = app_with_config() .oneshot( Request::builder() .uri("/auth") @@ -4621,7 +4123,7 @@ mod tests { #[tokio::test] async fn auth_entry_redirects_authenticated_viewer_to_root() { - let response = app_with_config("http://127.0.0.1:3100".into(), false) + let response = app_with_config() .oneshot( Request::builder() .uri("/auth") @@ -4644,9 +4146,8 @@ mod tests { } #[tokio::test] - async fn auth_api_no_longer_proxies_to_legacy_next_for_origin_normalization() { - let legacy_base_url = spawn_legacy_origin_checked_upstream().await; - let response = app_with_legacy_next_base_url(legacy_base_url) + async fn auth_api_sign_in_is_owned_by_mnote_web_without_legacy_upstream() { + let response = app_with_config() .oneshot( Request::builder() .method("POST") @@ -4671,9 +4172,8 @@ mod tests { } #[tokio::test] - async fn auth_api_no_longer_uses_legacy_next_cookie_proxy() { - let legacy_base_url = spawn_legacy_cookie_upstream().await; - let response = app_with_legacy_next_base_url(legacy_base_url) + async fn auth_api_empty_body_does_not_emit_legacy_auth_cookies() { + let response = app_with_config() .oneshot( Request::builder() .method("POST") @@ -4692,17 +4192,16 @@ mod tests { .collect::>(); assert!(!values .iter() - .any(|value| value.contains("__convexAuthJWT="))); + .any(|value| value.contains("legacy_auth_jwt="))); assert!(!values .iter() - .any(|value| value.contains("__convexAuthRefreshToken="))); + .any(|value| value.contains("legacy_auth_refresh="))); assert!(response.headers().get("x-mnote-legacy-upstream").is_none()); } #[tokio::test] - async fn unmigrated_route_returns_not_found_instead_of_proxying_to_legacy_next() { - let legacy_base_url = spawn_legacy_unmatched_upstream().await; - let response = app_with_legacy_next_base_url(legacy_base_url) + async fn unmigrated_route_returns_not_found_without_legacy_upstream() { + let response = app_with_config() .oneshot( Request::builder() .uri("/unmigrated") diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index c304f4db..45036b0c 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -294,17 +294,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"table","file_name":"预算.table","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()), - mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), @@ -392,7 +386,7 @@ mod tests { } #[tokio::test] - async fn tree_projection_routes_short_circuit_local_folder_without_convex_dataset() { + async fn tree_projection_routes_short_circuit_local_folder_without_cloud_dataset() { let root = std::env::temp_dir().join(format!( "mnote-local-kernel-projection-{}", std::process::id() diff --git a/rust/crates/mnote-web/src/routes/local_agent_install.rs b/rust/crates/mnote-web/src/routes/local_agent_install.rs index d2a8fafc..0c0baafc 100644 --- a/rust/crates/mnote-web/src/routes/local_agent_install.rs +++ b/rust/crates/mnote-web/src/routes/local_agent_install.rs @@ -437,10 +437,11 @@ pub fn pat_is_installed(config: &AppConfig, token_id: &str) -> bool { pub fn install_status_for_pat(config: &AppConfig, token_id: &str, jti: &str) -> Value { let env = environment_name(config); let base = public_base_url(config); - let state = load_install_state(); - let entry = state.environments.get(&env); - let meta = entry.and_then(|e| find_pat_meta_by_token(e, token_id, jti)); - let installed = meta.is_some(); + // 统一经 helper 判定,与列表 / install 一致,并消除 dead_code 警告 + let meta = current_pat_install_for_token(config, token_id, jti); + let installed = pat_is_installed(config, token_id) + || (!jti.is_empty() && jti != token_id && pat_is_installed(config, jti)) + || meta.is_some(); let subject = meta .as_ref() .map(|m| m.subject_user_id.clone()) @@ -450,7 +451,6 @@ pub fn install_status_for_pat(config: &AppConfig, token_id: &str, jti: &str) -> } else { display_env_path(&user_agent_env_path(&subject, &env)) }; - // 已无共享 active 槽;installed 即该用户本机已配置 json!({ "installed": installed, "active": installed, @@ -1015,17 +1015,11 @@ mod tests { service_version: "test".into(), bind_addr: bind.into(), public_bind_addr: bind.into(), - legacy_next_base_url: None, - enable_legacy_next_compat: false, enable_debug_shell_routes: false, enable_editor_actor: false, enable_page_ai_pi_lab: false, - 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".into(), dev_user_name: "dev".into(), dev_user_email: "dev@test".into(), 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 7ab2d929..e866e948 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_events.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_events.rs @@ -500,17 +500,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), 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 4c4e7fd6..5a02862f 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -11111,17 +11111,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), diff --git a/rust/crates/mnote-web/src/routes/media.rs b/rust/crates/mnote-web/src/routes/media.rs index 427249da..e6543b80 100644 --- a/rust/crates/mnote-web/src/routes/media.rs +++ b/rust/crates/mnote-web/src/routes/media.rs @@ -101,7 +101,7 @@ fn document_for_target_row( } pub async fn upload(Extension(context): Extension) -> Response { - retired_convex_media_response(&context, "upload", None) + retired_media_response(&context, "upload", None) } pub async fn filetree_upload_target_preflight( @@ -164,10 +164,10 @@ pub async fn sign( .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); - retired_convex_media_response(&context, "sign", asset_id) + retired_media_response(&context, "sign", asset_id) } -fn retired_convex_media_response( +fn retired_media_response( context: &RequestContext, operation: &str, asset_id: Option<&str>, @@ -176,9 +176,9 @@ fn retired_convex_media_response( StatusCode::GONE, Json(json!({ "ok": false, - "code": "mnote_media_convex_retired", - "error": "旧 Convex Files media route 已退役", - "message": "旧 /api/media Convex Files 上传与签名链已退役;local-first 附件请使用 /api/local-folder/assets/upload 与 /api/local-folder/files/open。", + "code": "mnote_media_local_required", + "error": "media 仅支持 local-folder 附件路径", + "message": "/api/media 云上传链已移除;请使用 /api/local-folder/assets/upload 与 /api/local-folder/files/open。", "operation": operation, "assetId": asset_id, "replacement": { diff --git a/rust/crates/mnote-web/src/routes/mindmap_api.rs b/rust/crates/mnote-web/src/routes/mindmap_api.rs index 304db144..982ed428 100644 --- a/rust/crates/mnote-web/src/routes/mindmap_api.rs +++ b/rust/crates/mnote-web/src/routes/mindmap_api.rs @@ -1,14 +1,14 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts; +use crate::routes::command_support::reject_non_local_runtime_command_with_artifacts; use crate::routes::local_folder_source::{ ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state, read_local_mindmap_data, write_local_mindmap_data, }; use crate::routes::query_support::{ - execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud, - fetch_documents_meta_via_legacy_cloud, fetch_query_data_via_legacy_cloud, + execute_runtime_query_against_data, reject_non_local_runtime_query, + reject_non_local_documents_meta, reject_non_local_query_data, resolve_effective_workspace_id, }; use axum::extract::{Extension, Path, Query, State}; @@ -124,18 +124,12 @@ async fn resolve_mindmap_workspace_id( // 思维导图 runtime 的历史请求体不一定带 workspaceId; // 这里从页面 meta 反查,确保后续 command artifacts 能进入正确 workspace 的实时流。 let meta = - fetch_documents_meta_via_legacy_cloud(state.config(), context, None, document_id).await?; + reject_non_local_documents_meta(state.config(), context, None, document_id).await?; Ok(read_workspace_id_from_meta(&meta)) } -fn execution_artifacts_json( - execution: &crate::transport::legacy_cloud_guard::RetiredCloudCommandExecution, -) -> Value { - execution - .artifacts - .as_ref() - .and_then(|artifacts| serde_json::to_value(artifacts).ok()) - .unwrap_or(Value::Null) +fn execution_artifacts_json(_execution: &Value) -> Value { + Value::Null } pub async fn get_mindmap( @@ -179,7 +173,7 @@ pub async fn get_mindmap( let effective_workspace_id = resolve_effective_workspace_id(&context, params.workspace_id.as_deref(), false)?; let query_name = resolve_query_name(¶ms); - let result = execute_runtime_query_via_legacy_cloud( + let result = reject_non_local_runtime_query( state.config(), &context, effective_workspace_id.as_deref(), @@ -361,7 +355,7 @@ pub async fn apply_mindmap_command( validate_only: false, }; - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, &context, effective_workspace_id.as_deref(), @@ -375,14 +369,14 @@ pub async fn apply_mindmap_command( Json(json!({ "ok": true, "commandName": "mindmaps.put", - "result": execution.result, + "result": execution, "artifacts": execution_artifacts_json(&execution), - "artifactError": execution.artifact_error, + "artifactError": None::, })), )); } - let current = fetch_query_data_via_legacy_cloud( + let current = reject_non_local_query_data( state.config(), &context, effective_workspace_id.as_deref(), @@ -443,7 +437,7 @@ pub async fn apply_mindmap_command( validate_only: false, }; - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, &context, effective_workspace_id.as_deref(), @@ -460,9 +454,9 @@ pub async fn apply_mindmap_command( "applied": applied.applied, "errors": applied.errors, "projectionRevision": body.projection_revision, - "result": execution.result, + "result": execution, "artifacts": execution_artifacts_json(&execution), - "artifactError": execution.artifact_error, + "artifactError": None::, })), )) } @@ -482,23 +476,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some( - r#"{"documents:getMeta":{"id":"doc_1","workspace_id":"ws_demo","title":"页面"},"mindmaps:get":{"data":{"data":{"text":"KMIND","uid":"root"},"children":[]},"revision":1}}"# - .into(), - ), - mutation_fixtures_json: Some( - r#"{"mindmaps:put":{"ok":true,"document_id":"doc_1","mindmap_id":"mind_1","updated_at":"2026-05-12T00:00:00Z"},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"# - .into(), - ), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), diff --git a/rust/crates/mnote-web/src/routes/mindmap_shell.rs b/rust/crates/mnote-web/src/routes/mindmap_shell.rs index 113967c8..1b5329e4 100644 --- a/rust/crates/mnote-web/src/routes/mindmap_shell.rs +++ b/rust/crates/mnote-web/src/routes/mindmap_shell.rs @@ -356,62 +356,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some( - serde_json::json!({ - "sidebar:datasetList": { - "active_workspace_id": "ws_demo", - "documents": [ - { - "id": "doc_1", - "workspace_id": "ws_demo", - "title": "页面", - "parent_id": null, - "sort_order": 0 - } - ], - "media_assets": [], - "mindmap_assets": [ - { - "id": "mind_1", - "workspace_id": "ws_demo", - "document_id": "doc_1", - "asset_type": "mindmap", - "file_name": "思维导图.json", - "mime_type": "application/json" - } - ], - "table_assets": [], - "trashed_documents": [], - "trashed_media_assets": [], - "trashed_mindmap_assets": [], - "trashed_table_assets": [], - "mindmap_docs": [], - "mindmap_asset_children": {} - }, - "documents:getMeta": { - "id": "doc_1", - "workspace_id": "ws_demo", - "title": "页面" - }, - "documents:getContent": { - "content": [], - "revision": 1, - "conflict_detection_key": "doc_1:1", - "pageSubtree": {"rootNodeId": "doc_1", "outline": []} - } - }) - .to_string(), - ), - mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), @@ -425,36 +374,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some( - serde_json::json!({ - "mindmaps:get": { - "ok": true, - "source": "compat-blob", - "revision": 7, - "data": { - "data": {"uid": "root", "text": "KMIND"}, - "children": [ - {"data": {"uid": "topic", "text": "二级节点"}, "children": []} - ] - }, - "meta": { - "document_id": "doc_1", - "mindmap_id": "mind_1" - } - } - }) - .to_string(), - ), - mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), diff --git a/rust/crates/mnote-web/src/routes/mnote_tools.rs b/rust/crates/mnote-web/src/routes/mnote_tools.rs index a960c3ec..8a56a1ef 100644 --- a/rust/crates/mnote-web/src/routes/mnote_tools.rs +++ b/rust/crates/mnote-web/src/routes/mnote_tools.rs @@ -143,30 +143,16 @@ fn local_agent_audit_record_tool_write( /// 从 profile 本地 config.yaml 读取禁用的 mnote tools。 /// 路径:`$MNOTE_AGENT_HOME/profiles/{profile}/config.yaml` 中 `mnote.tools.disabled`。 -/// 兼容读取:`HERMES_HOME` / `~/.hermes`(仅 profile 配置路径,非产品面)。 +/// 默认回落 `~/.mnote-agent`(不再读取 `HERMES_HOME` / `~/.hermes`)。 fn agent_profile_home() -> PathBuf { env::var("MNOTE_AGENT_HOME") .ok() .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) .or_else(|| { - env::var("HERMES_HOME") + env::var("HOME") .ok() - .filter(|value| !value.trim().is_empty()) - .map(PathBuf::from) - }) - .or_else(|| { - env::var("HOME").ok().and_then(|home| { - let preferred = PathBuf::from(&home).join(".mnote-agent"); - if preferred.exists() { - return Some(preferred); - } - let legacy = PathBuf::from(&home).join(".hermes"); - if legacy.exists() { - return Some(legacy); - } - Some(preferred) - }) + .map(|home| PathBuf::from(home).join(".mnote-agent")) }) .unwrap_or_else(|| PathBuf::from(".mnote-agent")) } @@ -1118,13 +1104,6 @@ fn audit_log_path() -> PathBuf { .ok() .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) - .or_else(|| { - // 兼容旧 env 名 - env::var("MNOTE_HERMES_TOOL_AUDIT_LOG") - .ok() - .filter(|value| !value.trim().is_empty()) - .map(PathBuf::from) - }) .unwrap_or_else(|| PathBuf::from("tmp").join("mnote-agent-tool-audit.jsonl")) } @@ -1394,61 +1373,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some( - r#"{ - "documents:getMeta": { - "id": "doc_1", - "workspace_id": "ws_demo", - "title": "服务端页面", - "can_edit": true, - "wide_layout": false, - "use_small_text": false, - "show_toc": true, - "block_count": 1 - }, - "documents:getContent": { - "title": "服务端页面", - "content": [ - { - "id": "heading_1", - "type": "heading", - "props": { "level": 1 }, - "content": [{ "type": "text", "text": "章节一" }] - }, - { - "id": "p_1", - "type": "paragraph", - "content": [{ "type": "text", "text": "第一段" }] - }, - { - "id": "p_2", - "type": "paragraph", - "content": [{ "type": "text", "text": "第二段" }] - } - ], - "revision": 7, - "conflict_detection_key": "doc_1:7" - } - }"# - .into(), - ), - mutation_fixtures_json: Some( - r#"{ - "documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"}, - "bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"}, - "bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"} - }"# - .into(), - ), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), @@ -1462,44 +1391,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some( - json!({ - "documents:getMeta": { - "id": "doc_1", - "workspace_id": "ws_demo", - "title": "复杂块阻断页面", - "can_edit": true, - "wide_layout": false, - "use_small_text": false, - "show_toc": true, - "block_count": 6 - }, - "documents:getContent": { - "title": "复杂块阻断页面", - "content": content, - "revision": 7, - "conflict_detection_key": "doc_1:7" - } - }) - .to_string(), - ), - mutation_fixtures_json: Some( - r#"{ - "documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"}, - "bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"}, - "bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"} - }"# - .into(), - ), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 427eecea..7ab61ecd 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -40,7 +40,7 @@ mod tree; mod tree_view_state; mod ui_debug; pub(crate) mod ui_preferences; -mod vault; +pub(crate) mod vault; pub(crate) mod vault_extension_token; mod vault_path; mod vault_store; @@ -893,17 +893,11 @@ mod tests { 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, enable_editor_actor: true, enable_page_ai_pi_lab: false, - 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(), @@ -1056,7 +1050,7 @@ mod tests { .await .expect("body bytes"); let payload: Value = serde_json::from_slice(&body).expect("json body"); - assert_eq!(payload["code"], "mnote_media_convex_retired"); + assert_eq!(payload["code"], "mnote_media_local_required"); assert_eq!(payload["operation"], operation); assert_eq!( payload["replacement"]["upload"], diff --git a/rust/crates/mnote-web/src/routes/navigation_recent.rs b/rust/crates/mnote-web/src/routes/navigation_recent.rs index d7f2e90c..ec80b3dc 100644 --- a/rust/crates/mnote-web/src/routes/navigation_recent.rs +++ b/rust/crates/mnote-web/src/routes/navigation_recent.rs @@ -367,17 +367,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), diff --git a/rust/crates/mnote-web/src/routes/onlyoffice.rs b/rust/crates/mnote-web/src/routes/onlyoffice.rs index 38865c71..82f95ed4 100644 --- a/rust/crates/mnote-web/src/routes/onlyoffice.rs +++ b/rust/crates/mnote-web/src/routes/onlyoffice.rs @@ -1,5 +1,4 @@ use super::onlyoffice_bridge; -use crate::app::AppConfig; use crate::app::AppState; use crate::error::WebError; use adapter_onlyoffice::{ @@ -921,7 +920,7 @@ pub async fn proxy( onlyoffice_storage_host_override: env_or_dotenv( "NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE", ), - convex_origin: None, + extra_origin: None, supabase_anon_key: env_or_dotenv("NEXT_PUBLIC_SUPABASE_ANON_KEY") .or_else(|| env_or_dotenv("SUPABASE_ANON_KEY")), }) @@ -1417,41 +1416,23 @@ pub async fn callback( Err(error) => onlyoffice_callback_failure(error), }; } - match proxy_legacy_onlyoffice_json( - state.config(), - "/api/onlyoffice/callback", - uri.query(), - Some(body), + // local-first:非 local asset 写回已退役 Next 代理;只服务 local-folder callback。 + let _ = (state, uri, body); + ( + axum::http::StatusCode::NOT_IMPLEMENTED, + Json(json!({ + "error": 1, + "degraded": true, + "code": "onlyoffice_cloud_writeback_unavailable", + "message": "OnlyOffice 非 local asset 写回已退役;请使用 local-folder 路径(rootUri/path 或 local-file asset)。", + })), ) - .await - { - Ok(response) => response, - Err(error) if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED => ( - axum::http::StatusCode::NOT_IMPLEMENTED, - Json(json!({ - "error": 1, - "degraded": true, - "code": "onlyoffice_legacy_writeback_unavailable", - "message": error.message(), - })), - ) - .into_response(), - Err(error) => ( - axum::http::StatusCode::BAD_GATEWAY, - Json(json!({ - "error": 1, - "degraded": true, - "code": "onlyoffice_legacy_writeback_failed", - "message": error.message(), - })), - ) - .into_response(), - } + .into_response() } pub async fn forcesave( - State(state): State, - uri: Uri, + State(_state): State, + _uri: Uri, Query(query): Query, ) -> Result { let asset_id = query @@ -1470,104 +1451,14 @@ pub async fn forcesave( .ok_or_else(|| { WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key") })?; - let response = proxy_legacy_onlyoffice_json( - state.config(), - "/api/onlyoffice/forcesave", - uri.query(), - None, - ) - .await - .map_err(|error| { - if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED { - return WebError::new( - axum::http::StatusCode::NOT_IMPLEMENTED, - "onlyoffice_legacy_writeback_unavailable", - format!( - "OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}" - ), - ); - } - error - })?; - Ok(response) -} - -fn legacy_onlyoffice_writeback_base(config: &AppConfig) -> Option { - config - .legacy_next_base_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.trim_end_matches('/').to_string()) -} - -async fn proxy_legacy_onlyoffice_json( - config: &AppConfig, - path: &str, - query: Option<&str>, - body: Option, -) -> Result { - let base = legacy_onlyoffice_writeback_base(config).ok_or_else(|| { - WebError::new( - axum::http::StatusCode::NOT_IMPLEMENTED, - "onlyoffice_legacy_writeback_unavailable", - "OnlyOffice Rust route 暂未直接写回,且未配置 legacy Next 写回链", - ) - })?; - let target = append_path_and_query(&base, path, query); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .map_err(|error| { - WebError::internal(format!( - "OnlyOffice legacy proxy HTTP 客户端创建失败: {error}" - )) - })?; - let mut request = client - .post(target) - .header(header::CONTENT_TYPE, "application/json"); - if let Some(body) = body { - request = request.json(&body); - } else { - request = request.body("{}"); - } - let upstream = request.send().await.map_err(|error| { - WebError::bad_gateway_code( - "onlyoffice_legacy_writeback_failed", - format!("OnlyOffice legacy 写回请求失败: {error}"), - ) - })?; - let status = upstream.status(); - let content_type = upstream - .headers() - .get(header::CONTENT_TYPE) - .cloned() - .unwrap_or_else(|| HeaderValue::from_static("application/json")); - let bytes = upstream.bytes().await.map_err(|error| { - WebError::bad_gateway_code( - "onlyoffice_legacy_writeback_failed", - format!("OnlyOffice legacy 写回响应读取失败: {error}"), - ) - })?; - let mut response = Response::new(Body::from(bytes)); - *response.status_mut() = status; - response - .headers_mut() - .insert(header::CONTENT_TYPE, content_type); - Ok(response) -} - -fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String { - let mut target = format!( - "{}/{}", - base.trim_end_matches('/'), - path.trim_start_matches('/') - ); - if let Some(query) = query.filter(|value| !value.is_empty()) { - target.push('?'); - target.push_str(query); - } - target + // local-first:forcesave 的 Next 写回链已退役;非 local 路径显式不可用。 + Err(WebError::new( + axum::http::StatusCode::NOT_IMPLEMENTED, + "onlyoffice_cloud_writeback_unavailable", + format!( + "OnlyOffice forcesave 仅支持 local-folder 主链;云/兼容写回已退役: assetId={asset_id}, key={key}" + ), + )) } fn request_body_bytes( @@ -1613,6 +1504,19 @@ fn strip_hop_by_hop_headers(headers: &mut HeaderMap) { } } +fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String { + let mut target = format!( + "{}/{}", + base.trim_end_matches('/'), + path.trim_start_matches('/') + ); + if let Some(query) = query.filter(|value| !value.is_empty()) { + target.push('?'); + target.push_str(query); + } + target +} + async fn proxy_onlyoffice_path( upstream_prefix: &str, upstream_path: &str, @@ -2067,23 +1971,16 @@ mod tests { assert!(html.contains("window.__MNOTE_ONLYOFFICE_BRIDGE__")); } - fn test_state(legacy_next_base_url: Option) -> AppState { + 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, - enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, enable_page_ai_pi_lab: false, - 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(), @@ -2091,7 +1988,7 @@ mod tests { }) } - async fn spawn_legacy_json_server( + async fn spawn_mock_download_server( response_body: &'static str, ) -> (String, oneshot::Receiver) { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); @@ -2104,7 +2001,7 @@ mod tests { let request = String::from_utf8_lossy(&buffer[..read]).to_string(); let _ = tx.send(request); let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\ncontent-type: application/octet-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", response_body.len(), response_body ); @@ -2114,9 +2011,9 @@ mod tests { } #[tokio::test] - async fn onlyoffice_callback_without_legacy_next_fails_explicitly() { + async fn onlyoffice_callback_without_local_asset_fails_explicitly() { let response = callback( - State(test_state(None)), + State(test_state()), "/api/onlyoffice/callback?assetId=asset_1" .parse::() .expect("uri"), @@ -2155,9 +2052,9 @@ mod tests { fs::create_dir_all(root.join("Page")).expect("create page"); let target = root.join("Page").join("report.docx"); fs::write(&target, b"old").expect("write old docx"); - let (download_url, _captured) = spawn_legacy_json_server("new docx bytes").await; + let (download_url, _captured) = spawn_mock_download_server("new docx bytes").await; let response = callback( - State(test_state(None)), + State(test_state()), format!( "/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx", root.display() @@ -2205,7 +2102,7 @@ mod tests { fs::create_dir_all(root.join("Page")).expect("create page"); let target = root.join("Page").join("report.docx"); fs::write(&target, b"old").expect("write old docx"); - let (download_url, _captured) = spawn_legacy_json_server("new docx bytes").await; + let (download_url, _captured) = spawn_mock_download_server("new docx bytes").await; let session_id = format!("mnote-oo-local-callback-{}", std::process::id()); let token = format!("token-{session_id}"); let registered = @@ -2222,7 +2119,7 @@ mod tests { .await; assert_eq!(registered.status(), StatusCode::OK); let response = callback( - State(test_state(None)), + State(test_state()), format!( "/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx", session_id, @@ -2267,7 +2164,7 @@ mod tests { fs::create_dir_all(root.join("Page")).expect("create page"); let target = root.join("Page").join("report.docx"); fs::write(&target, b"old").expect("write old docx"); - let (download_url, _captured) = spawn_legacy_json_server("status six bytes").await; + let (download_url, _captured) = spawn_mock_download_server("status six bytes").await; let session_id = format!("mnote-oo-local-callback-six-{}", std::process::id()); let token = format!("token-{session_id}"); let registered = @@ -2284,7 +2181,7 @@ mod tests { .await; assert_eq!(registered.status(), StatusCode::OK); let response = callback( - State(test_state(None)), + State(test_state()), format!( "/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx", session_id, @@ -2334,7 +2231,7 @@ mod tests { fs::create_dir_all(root.join("Page")).expect("create page"); fs::create_dir_all(&outside).expect("create outside"); fs::write(outside.join("report.docx"), b"outside").expect("write outside"); - let (download_url, _captured) = spawn_legacy_json_server("should not write").await; + let (download_url, _captured) = spawn_mock_download_server("should not write").await; let session_id = format!("mnote-oo-local-callback-escape-{}", std::process::id()); let token = format!("token-{session_id}"); let registered = @@ -2351,7 +2248,7 @@ mod tests { .await; assert_eq!(registered.status(), StatusCode::OK); let response = callback( - State(test_state(None)), + State(test_state()), format!( "/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=..%2F{}%2Freport.docx", session_id, @@ -2426,7 +2323,7 @@ mod tests { .await; assert_eq!(registered.status(), StatusCode::OK); let response = callback( - State(test_state(None)), + State(test_state()), format!( "/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx", session_id, @@ -2458,46 +2355,9 @@ mod tests { } #[tokio::test] - async fn onlyoffice_callback_proxies_to_legacy_next_writeback() { - let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await; - let response = callback( - State(test_state(Some(base_url))), - "/api/onlyoffice/callback?assetId=asset_1&userId=user_1" - .parse::() - .expect("uri"), - HeaderMap::new(), - Query(OnlyOfficeCallbackQuery { - asset_id: Some("asset_1".into()), - user_id: Some("user_1".into()), - session_id: None, - token: None, - root_uri: None, - path: None, - }), - Json(json!({ - "status": 2, - "key": "doc_key", - "url": "http://127.0.0.1:8082/cache/files/out.docx" - })), - ) - .await; - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - let payload: Value = serde_json::from_slice(&body).expect("json"); - let request = captured.await.expect("captured"); - - assert_eq!(payload["error"], 0); - assert!(request - .starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1")); - assert!(request.contains(r#""status":2"#)); - assert!(request.contains(r#""key":"doc_key""#)); - } - - #[tokio::test] - async fn onlyoffice_forcesave_without_legacy_next_is_not_noop_success() { + async fn onlyoffice_forcesave_without_local_writeback_is_not_noop_success() { let response = forcesave( - State(test_state(None)), + State(test_state()), "/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key" .parse::() .expect("uri"), @@ -2516,38 +2376,10 @@ mod tests { .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], false); - assert_eq!(payload["code"], "onlyoffice_legacy_writeback_unavailable"); + assert_eq!(payload["code"], "onlyoffice_cloud_writeback_unavailable"); assert_ne!(payload["via"], "mnote-web-rust-noop"); } - #[tokio::test] - async fn onlyoffice_forcesave_proxies_to_legacy_next_writeback() { - let (base_url, captured) = - spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#).await; - let response = forcesave( - State(test_state(Some(base_url))), - "/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key" - .parse::() - .expect("uri"), - Query(OnlyOfficeForcesaveQuery { - asset_id: Some("asset_1".into()), - key: Some("doc_key".into()), - }), - ) - .await - .expect("response"); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - let payload: Value = serde_json::from_slice(&body).expect("json"); - let request = captured.await.expect("captured"); - - assert_eq!(payload["ok"], true); - assert_eq!(payload["via"], "forcesave"); - assert!(request - .starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1")); - } - #[tokio::test] async fn onlyoffice_page_skips_media_sign_for_local_folder_asset() { let response = page(Query(OnlyOfficePageQuery { @@ -2626,7 +2458,7 @@ mod tests { async fn onlyoffice_callback_rejects_invalid_jwt_when_secret_configured() { std::env::set_var("ONLYOFFICE_JWT_SECRET", "test-onlyoffice-jwt-secret"); let response = callback( - State(test_state(None)), + State(test_state()), "/api/onlyoffice/callback?assetId=asset_1" .parse::() .expect("uri"), @@ -2679,5 +2511,5 @@ mod tests { } let _ = fs::remove_dir_all(&root); } - } + diff --git a/rust/crates/mnote-web/src/routes/page_ai_pi/runtime.rs b/rust/crates/mnote-web/src/routes/page_ai_pi/runtime.rs index dc993bb4..e2d68588 100644 --- a/rust/crates/mnote-web/src/routes/page_ai_pi/runtime.rs +++ b/rust/crates/mnote-web/src/routes/page_ai_pi/runtime.rs @@ -9218,17 +9218,11 @@ mod tests { service_version: "test".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, enable_page_ai_pi_lab: true, - 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(), diff --git a/rust/crates/mnote-web/src/routes/page_ai_workflow.rs b/rust/crates/mnote-web/src/routes/page_ai_workflow.rs index 54ba5d8a..51bedf18 100644 --- a/rust/crates/mnote-web/src/routes/page_ai_workflow.rs +++ b/rust/crates/mnote-web/src/routes/page_ai_workflow.rs @@ -583,23 +583,9 @@ fn agent_profile_home() -> PathBuf { .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) .or_else(|| { - std::env::var("HERMES_HOME") + std::env::var("HOME") .ok() - .filter(|value| !value.trim().is_empty()) - .map(PathBuf::from) - }) - .or_else(|| { - std::env::var("HOME").ok().and_then(|home| { - let preferred = PathBuf::from(&home).join(".mnote-agent"); - if preferred.exists() { - return Some(preferred); - } - let legacy = PathBuf::from(&home).join(".hermes"); - if legacy.exists() { - return Some(legacy); - } - Some(preferred) - }) + .map(|home| PathBuf::from(home).join(".mnote-agent")) }) .unwrap_or_else(|| PathBuf::from(".mnote-agent")) } @@ -716,55 +702,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some( - r#"{ - "documents:getMeta": { - "id": "doc_1", - "workspace_id": "ws_demo", - "title": "服务端页面", - "can_edit": true, - "wide_layout": false, - "use_small_text": false, - "show_toc": true, - "block_count": 2 - }, - "documents:getContent": { - "title": "服务端页面", - "content": [ - { - "id": "p_1", - "type": "paragraph", - "content": [{ "type": "text", "text": "第一段" }] - }, - { - "id": "p_2", - "type": "paragraph", - "content": [{ "type": "text", "text": "第二段" }] - } - ], - "revision": 7, - "conflict_detection_key": "doc_1:7" - } - }"# - .into(), - ), - mutation_fixtures_json: Some( - r#"{ - "documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"}, - "bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"}, - "bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"} - }"# - .into(), - ), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), diff --git a/rust/crates/mnote-web/src/routes/query_support.rs b/rust/crates/mnote-web/src/routes/query_support.rs index 4ef15ad3..2b86b5f6 100644 --- a/rust/crates/mnote-web/src/routes/query_support.rs +++ b/rust/crates/mnote-web/src/routes/query_support.rs @@ -1,7 +1,7 @@ use crate::app::AppConfig; use crate::context::RequestContext; use crate::error::WebError; -use crate::transport::legacy_cloud_guard::execute_retired_query_plan; +use crate::transport::local_only::local_required; use bridge_runtime::{ build_query_request, execute_runtime_input, execute_runtime_query, BridgeContext, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput, @@ -154,24 +154,24 @@ pub fn build_documents_meta_query_plan( }) } -pub async fn fetch_query_data_via_legacy_cloud( - config: &AppConfig, +/// 非 local_folder 的 query data 拉取已移除。 +pub async fn reject_non_local_query_data( + _config: &AppConfig, context: &RequestContext, - effective_workspace_id: Option<&str>, - query: RuntimeQueryEnvelopeWire, + _effective_workspace_id: Option<&str>, + _query: RuntimeQueryEnvelopeWire, ) -> Result { - let plan = build_runtime_query_plan(context, effective_workspace_id, query)?; - execute_retired_query_plan(config, context, &plan).await + Err(local_required(context, "query_local_required")) } -pub async fn fetch_documents_meta_via_legacy_cloud( - config: &AppConfig, +/// 非 local_folder 的 documents.meta 已移除。 +pub async fn reject_non_local_documents_meta( + _config: &AppConfig, context: &RequestContext, - effective_workspace_id: Option<&str>, - document_id: &str, + _effective_workspace_id: Option<&str>, + _document_id: &str, ) -> Result { - let plan = build_documents_meta_query_plan(context, effective_workspace_id, document_id)?; - execute_retired_query_plan(config, context, &plan).await + Err(local_required(context, "query_local_required")) } pub fn execute_runtime_query_against_data( @@ -188,14 +188,12 @@ pub fn execute_runtime_query_against_data( .map_err(|error| WebError::bad_request(error.message).with_context(context)) } -pub async fn execute_runtime_query_via_legacy_cloud( - config: &AppConfig, +/// 非 local_folder 的 runtime query 一律拒绝。 +pub async fn reject_non_local_runtime_query( + _config: &AppConfig, context: &RequestContext, - effective_workspace_id: Option<&str>, - query: RuntimeQueryEnvelopeWire, + _effective_workspace_id: Option<&str>, + _query: RuntimeQueryEnvelopeWire, ) -> Result { - let data = - fetch_query_data_via_legacy_cloud(config, context, effective_workspace_id, query.clone()) - .await?; - execute_runtime_query_against_data(context, effective_workspace_id, query, data) + Err(local_required(context, "query_local_required")) } diff --git a/rust/crates/mnote-web/src/routes/resource_trash.rs b/rust/crates/mnote-web/src/routes/resource_trash.rs index 788dd695..ae894dc8 100644 --- a/rust/crates/mnote-web/src/routes/resource_trash.rs +++ b/rust/crates/mnote-web/src/routes/resource_trash.rs @@ -2,20 +2,17 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::command_support::{ - execute_runtime_command_via_legacy_cloud_with_artifacts, runtime_context, + reject_non_local_runtime_command_with_artifacts, runtime_context, }; use crate::routes::local_folder_source::{ ensure_local_workspace_access, execute_local_tree_command, }; -use crate::transport::legacy_cloud_guard::{ - execute_retired_mutation_by_name, execute_retired_query_by_name, - persist_runtime_command_artifacts, -}; +use crate::transport::local_only::local_required; use axum::extract::{Extension, Path, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::Json; use bridge_runtime::{ - build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire, + RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeSourceWire, RuntimeTargetWire, }; use serde::Deserialize; @@ -26,6 +23,23 @@ use time::{Duration, OffsetDateTime}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_RESOURCE_TRASH_TRANSPORT: &str = "x-mnote-resource-trash-transport"; +async fn reject_cloud_query( + context: &RequestContext, + error_phase: &'static str, +) -> Result { + Err(local_required(context, error_phase)) +} + +async fn reject_cloud_mutation( + context: &RequestContext, + error_phase: &'static str, +) -> Result { + Err(local_required(context, error_phase)) +} + + + + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaBatchRequest { @@ -115,38 +129,6 @@ async fn current_user_id( return Ok(user_id.to_string()); } - let has_auth_cookie = context - .auth - .cookie_header - .as_deref() - .map(|value| { - value.contains("__convexAuthJWT=") || value.contains("mnote_web_convex_token=") - }) - .unwrap_or(false); - if has_auth_cookie { - if let Ok(user) = execute_retired_query_by_name( - state.config(), - context, - "users:currentUser", - json!({}), - context.workspace.workspace_id.as_deref(), - "resource_trash_current_user", - ) - .await - { - for key in ["_id", "id"] { - if let Some(user_id) = user - .get(key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return Ok(user_id.to_string()); - } - } - } - } - if state.config().allow_dev_fixtures { return Ok(state.config().dev_user_id.clone()); } @@ -190,7 +172,7 @@ fn now_iso_like() -> String { } async fn record_media_empty_trash_artifacts( - state: &AppState, + _state: &AppState, context: &RequestContext, user_id: &str, workspace_id: &str, @@ -270,20 +252,13 @@ async fn record_media_empty_trash_artifacts( }), }; let runtime_context = runtime_context(context, Some(workspace_id)); - if let Some(artifacts) = build_runtime_command_artifact_plan( - &runtime_context, - &command, - &plan, - result, - &now_iso_like(), - ) { - persist_runtime_command_artifacts(state.config(), context, &artifacts).await?; - } + // cloud artifact persist 已移除;local-first 不写 cloud command log。 + let _ = (runtime_context, command, plan, result); Ok(()) } async fn record_resource_resync_artifacts( - state: &AppState, + _state: &AppState, context: &RequestContext, actor_id: &str, workspace_id: &str, @@ -371,34 +346,17 @@ async fn record_resource_resync_artifacts( }), }; let runtime_context = runtime_context(context, Some(workspace_id)); - if let Some(artifacts) = build_runtime_command_artifact_plan( - &runtime_context, - &command, - &plan, - result, - &now_iso_like(), - ) { - persist_runtime_command_artifacts(state.config(), context, &artifacts).await?; - } + // cloud artifact persist 已移除;local-first 不写 cloud command log。 + let _ = (runtime_context, command, plan, result); Ok(()) } async fn fetch_document_workspace_id( - state: &AppState, + _state: &AppState, context: &RequestContext, - document_id: &str, + _document_id: &str, ) -> Option { - execute_retired_query_by_name( - state.config(), - context, - "documents:getMeta", - json!({ - "id": document_id, - "includeDeleted": true, - }), - context.workspace.workspace_id.as_deref(), - "resource_trash_document_workspace", - ) + reject_cloud_query(context, "resource_trash_local_required") .await .ok() .and_then(|meta| { @@ -412,22 +370,12 @@ async fn fetch_document_workspace_id( } async fn fetch_table_meta( - state: &AppState, + _state: &AppState, context: &RequestContext, - user_id: &str, - table_id: &str, + _user_id: &str, + _table_id: &str, ) -> Option { - execute_retired_query_by_name( - state.config(), - context, - "tables:get", - json!({ - "userId": user_id, - "tableId": table_id, - }), - context.workspace.workspace_id.as_deref(), - "resource_trash_table_meta", - ) + reject_cloud_query(context, "resource_trash_local_required") .await .ok() .filter(|value| value.is_object()) @@ -490,22 +438,12 @@ fn annotate_resource_lifecycle_result( } async fn fetch_media_asset_meta( - state: &AppState, + _state: &AppState, context: &RequestContext, - user_id: &str, - asset_id: &str, + _user_id: &str, + _asset_id: &str, ) -> Result { - let assets = execute_retired_query_by_name( - state.config(), - context, - "mediaAssets:listByIds", - json!({ - "userId": user_id, - "ids": [asset_id], - }), - context.workspace.workspace_id.as_deref(), - "media_asset_meta", - ) + let assets = reject_cloud_query(context, "resource_trash_local_required") .await?; Ok(assets .as_array() @@ -618,7 +556,7 @@ pub async fn media_batch( ); // 先执行 tree runtime command;成功后再做 legacy media store patch, // 避免 patch 已生效而 tree 失败时调用方仍看到成功(与 restore/delete 一致)。 - execute_runtime_command_via_legacy_cloud_with_artifacts( + reject_non_local_runtime_command_with_artifacts( &state, &context, workspace_id.as_deref(), @@ -626,49 +564,21 @@ pub async fn media_batch( ) .await?; if action == "rename" { - let new_name = require_id( + let _new_name = require_id( &context, body.new_name.as_deref().unwrap_or_default(), "newName", )?; - execute_retired_mutation_by_name( - state.config(), - &context, - "mediaAssets:patchById", - json!({ - "userId": user_id, - "id": asset_id, - "patch": { - "file_name": new_name, - }, - }), - workspace_id.as_deref(), - None, - "media_batch_rename_patch", - ) + reject_cloud_mutation(&context, "") .await?; } if action == "move" { - let target_document_id = require_id( + let _target_document_id = require_id( &context, body.target_document_id.as_deref().unwrap_or_default(), "targetDocumentId", )?; - execute_retired_mutation_by_name( - state.config(), - &context, - "mediaAssets:patchById", - json!({ - "userId": user_id, - "id": asset_id, - "patch": { - "document_id": target_document_id, - }, - }), - workspace_id.as_deref(), - None, - "media_batch_move_patch", - ) + reject_cloud_mutation(&context, "") .await?; } updated += 1; @@ -718,14 +628,14 @@ pub async fn media_purge( "assetId": asset_id, }), ); - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, &context, workspace_id.as_deref(), command, ) .await?; - Ok(ok_response(&context, execution.result)) + Ok(ok_response(&context, execution)) } pub async fn media_empty_trash( @@ -735,19 +645,7 @@ pub async fn media_empty_trash( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let user_id = current_user_id(&state, &context).await?; let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; - let result = execute_retired_mutation_by_name( - state.config(), - &context, - "mediaAssets:emptyTrashByWorkspace", - json!({ - "userId": user_id, - "workspaceId": workspace_id, - "expiredDeletedAt": force_expired_at(), - }), - Some(workspace_id), - None, - "media_empty_trash", - ) + let result = reject_cloud_mutation(&context, "") .await?; let _ = match record_media_empty_trash_artifacts(&state, &context, &user_id, workspace_id, &result) @@ -818,7 +716,7 @@ pub async fn mindmap_delete( "mindmapId": mindmap_id, }), ); - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, &context, workspace_id.as_deref(), @@ -827,7 +725,7 @@ pub async fn mindmap_delete( .await?; Ok(ok_response( &context, - annotate_resource_lifecycle_result(execution.result, "tree.resource.archive", "mindmap"), + annotate_resource_lifecycle_result(execution, "tree.resource.archive", "mindmap"), )) } @@ -903,7 +801,7 @@ pub async fn mindmap_trash_action( "mindmapId": mindmap_id, }), ); - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, &context, workspace_id.as_deref(), @@ -912,7 +810,7 @@ pub async fn mindmap_trash_action( .await?; Ok(ok_response( &context, - annotate_resource_lifecycle_result(execution.result, command_name, "mindmap"), + annotate_resource_lifecycle_result(execution, command_name, "mindmap"), )) } @@ -922,17 +820,7 @@ pub async fn mindmap_empty_trash( Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; - let result = execute_retired_mutation_by_name( - state.config(), - &context, - "mindmaps:emptyTrashByWorkspace", - json!({ - "workspaceId": workspace_id, - }), - Some(workspace_id), - None, - "mindmap_empty_trash", - ) + let result = reject_cloud_mutation(&context, "") .await?; let _ = record_resource_resync_artifacts( &state, @@ -986,22 +874,7 @@ pub async fn table_create( .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") })?; - let result = execute_retired_mutation_by_name( - state.config(), - &context, - "tables:create", - json!({ - "userId": user_id, - "workspaceId": workspace_id, - "documentId": document_id, - "title": body.title, - "schema": body.schema.unwrap_or_else(|| json!({})), - "snapshot": body.snapshot, - }), - Some(&workspace_id), - None, - "table_create", - ) + let result = reject_cloud_mutation(&context, "") .await?; let table_id = result .get("id") @@ -1093,7 +966,7 @@ async fn table_action( "tableId": table_id, }), ); - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( + let execution = reject_non_local_runtime_command_with_artifacts( &state, &context, workspace_id.as_deref(), @@ -1103,7 +976,7 @@ async fn table_action( let _ = error_phase; Ok(ok_response( &context, - annotate_resource_lifecycle_result(execution.result, command_name, "table"), + annotate_resource_lifecycle_result(execution, command_name, "table"), )) } @@ -1114,19 +987,7 @@ pub async fn table_empty_trash( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let user_id = current_user_id(&state, &context).await?; let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; - let result = execute_retired_mutation_by_name( - state.config(), - &context, - "tables:emptyTrashByWorkspace", - json!({ - "userId": user_id, - "workspaceId": workspace_id, - "expiredDeletedAt": force_expired_at(), - }), - Some(workspace_id), - None, - "table_empty_trash", - ) + let result = reject_cloud_mutation(&context, "") .await?; let _ = record_resource_resync_artifacts( &state, @@ -1165,42 +1026,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some( - r#"{ - "documents:getMeta": {"id": "doc_1", "workspace_id": "ws_demo"}, - "mediaAssets:listByIds": [{"id": "asset_1", "workspace_id": "ws_demo", "document_id": "doc_1"}], - "tables:get": {"id": "table_1", "workspace_id": "ws_demo", "document_id": "doc_1"} - }"# - .into(), - ), - mutation_fixtures_json: Some( - r#"{ - "mediaAssets:patchById": {"ok": true}, - "mediaAssets:purgeById": {"ok": true, "deleted": 1}, - "mediaAssets:emptyTrashByWorkspace": {"ok": true, "deleted": 2}, - "bridgeLogs:recordCommandLog": {"ok": true}, - "bridgeLogs:recordDomainEvent": {"ok": true}, - "mindmaps:softDelete": {"ok": true, "moved": 1, "deleted_at": "2026-05-15T00:00:00Z"}, - "mindmaps:restore": {"ok": true, "updated_at": "2026-05-15T00:00:00Z"}, - "mindmaps:purge": {"ok": true}, - "mindmaps:emptyTrashByWorkspace": {"ok": true, "deletedCount": 3}, - "tables:create": {"id": "table_1", "workspace_id": "ws_demo", "document_id": "doc_1", "title": "表格"}, - "tables:remove": {"success": true, "deleted_at": "2026-05-15T00:00:00Z"}, - "tables:restore": {"success": true}, - "tables:purge": {"success": true}, - "tables:emptyTrashByWorkspace": {"ok": true, "deleted": 4} - }"# - .into(), - ), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), @@ -1489,7 +1319,6 @@ mod tests { assert_eq!(moved["result"]["moved"], 1); assert_eq!(moved["result"]["canonicalCommand"], "tree.resource.move"); - assert_ne!(moved["result"]["canonicalCommand"], "documents.move"); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/routes/search.rs b/rust/crates/mnote-web/src/routes/search.rs index 9e9c2493..20dfb2e1 100644 --- a/rust/crates/mnote-web/src/routes/search.rs +++ b/rust/crates/mnote-web/src/routes/search.rs @@ -3,7 +3,7 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::routes::gateway::current_actor_id; use crate::routes::query_support::{ - execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud, + execute_runtime_query_against_data, reject_non_local_runtime_query, resolve_effective_workspace_id, }; use crate::routes::web_shell::{escape_script_json, load_sidebar_tree_html}; @@ -628,7 +628,7 @@ async fn load_search_results_with_filters( "customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()), }), }; - match execute_runtime_query_via_legacy_cloud( + match reject_non_local_runtime_query( config, context, Some(workspace_id), @@ -768,17 +768,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), @@ -893,23 +887,17 @@ mod tests { } #[tokio::test] - async fn search_documents_does_not_return_builtin_fixture_when_convex_unavailable() { + async fn search_documents_does_not_return_builtin_fixture_when_local_required() { let app = build_app(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, enable_page_ai_pi_lab: false, - 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(), @@ -1511,17 +1499,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), diff --git a/rust/crates/mnote-web/src/routes/session.rs b/rust/crates/mnote-web/src/routes/session.rs index 8a31491a..485511de 100644 --- a/rust/crates/mnote-web/src/routes/session.rs +++ b/rust/crates/mnote-web/src/routes/session.rs @@ -9,7 +9,6 @@ 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"; @@ -22,7 +21,6 @@ pub struct RuntimeConfigResponse { pub public_entry: String, pub tree_renderer_family: &'static str, pub document_editor_host: &'static str, - pub legacy_next_compat_enabled: bool, } #[derive(Debug, Serialize)] @@ -46,7 +44,6 @@ pub async fn runtime_config(State(state): State) -> Response { public_entry: state.config().public_bind_addr.clone(), tree_renderer_family: "rust_family", document_editor_host: "leptos_tiptap_island", - legacy_next_compat_enabled: state.config().enable_legacy_next_compat, })) } @@ -162,23 +159,9 @@ fn effective_actor_type_for_user(user_id: &str, stored_role: &str) -> String { /// 仅用于 **展示** email/name 补全;**绝不**作为 user_id / 鉴权真源。 /// 无签名校验:任意客户端可伪造 JWT payload 中的展示字段。 /// 身份仍以 control-plane session / header actor 为准(见上方 get_session 分支)。 -fn jwt_cookie_claim(context: &RequestContext, keys: &[&str]) -> Option { - let token = context - .auth - .cookie_header - .as_deref() - .and_then(|cookies| raw_cookie_value(cookies, COOKIE_CONVEX_AUTH_JWT))?; - let payload_segment = token.split('.').nth(1)?; - let decoded = URL_SAFE_NO_PAD.decode(payload_segment.as_bytes()).ok()?; - let payload: serde_json::Value = serde_json::from_slice(&decoded).ok()?; - keys.iter().find_map(|key| { - payload - .get(key) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - }) +fn jwt_cookie_claim(_context: &RequestContext, _keys: &[&str]) -> Option { + // 身份仅 control-plane session / header;不再从第三方 JWT cookie 补全。 + None } fn encoded_cookie_value(cookie_header: &str, name: &str) -> Option { @@ -235,17 +218,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), @@ -280,7 +257,8 @@ mod tests { assert_eq!(payload["owner"], "mnote-web"); assert_eq!(payload["treeRendererFamily"], "rust_family"); assert!(payload.get("legacyNextBaseUrl").is_none()); - assert!(payload.get("convexAdminKey").is_none()); + assert!(payload.get("adminKey").is_none()); + assert!(payload.get("cloudAdminKey").is_none()); } #[tokio::test] @@ -302,7 +280,8 @@ mod tests { let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["owner"], "mnote-web"); assert_eq!(payload["userId"], "dev-user"); - assert!(payload.get("convexAdminKey").is_none()); + assert!(payload.get("adminKey").is_none()); + assert!(payload.get("cloudAdminKey").is_none()); } #[tokio::test] @@ -328,7 +307,8 @@ mod tests { assert_eq!(payload["userId"], "user_real"); assert_eq!(payload["actorType"], "user"); assert_eq!(payload["authMode"], "forwardedActor"); - assert!(payload.get("convexAdminKey").is_none()); + assert!(payload.get("adminKey").is_none()); + assert!(payload.get("cloudAdminKey").is_none()); } #[tokio::test] @@ -338,17 +318,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), @@ -408,17 +382,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), @@ -489,17 +457,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), @@ -575,7 +537,6 @@ mod tests { assert_eq!(payload["userId"], "user_real"); assert_eq!(payload["actorType"], "user"); assert_eq!(payload["authMode"], "forwardedActor"); - assert_ne!(payload["code"], "legacy_next_compat_disabled"); } #[tokio::test] @@ -597,7 +558,6 @@ mod tests { let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["owner"], "mnote-web"); assert_eq!(payload["userId"], "dev-user"); - assert_ne!(payload["code"], "legacy_next_compat_disabled"); } #[tokio::test] @@ -619,6 +579,5 @@ mod tests { let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["owner"], "mnote-web"); assert_eq!(payload["userId"], "dev-user"); - assert_ne!(payload["code"], "legacy_next_compat_disabled"); } } diff --git a/rust/crates/mnote-web/src/routes/snapshot_support.rs b/rust/crates/mnote-web/src/routes/snapshot_support.rs index 0107f0c3..69abd9f8 100644 --- a/rust/crates/mnote-web/src/routes/snapshot_support.rs +++ b/rust/crates/mnote-web/src/routes/snapshot_support.rs @@ -2,7 +2,7 @@ use crate::app::AppConfig; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::{ - execute_runtime_query_against_data, fetch_query_data_via_legacy_cloud, + execute_runtime_query_against_data, reject_non_local_query_data, }; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::{KernelNodeType, KernelProjectionKind}; @@ -38,7 +38,7 @@ pub async fn load_sidebar_dataset( context: &RequestContext, workspace_id: &str, ) -> Result { - fetch_query_data_via_legacy_cloud( + reject_non_local_query_data( config, context, Some(workspace_id), diff --git a/rust/crates/mnote-web/src/routes/sse.rs b/rust/crates/mnote-web/src/routes/sse.rs index 43ca9efd..a089446f 100644 --- a/rust/crates/mnote-web/src/routes/sse.rs +++ b/rust/crates/mnote-web/src/routes/sse.rs @@ -369,7 +369,7 @@ fn delta_matches_workspace(delta: &Value, subscription_workspace: Option<&str>) fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery { let mut next = query.clone(); - // Convex bridgeLogs 的 cursor 是“向更旧记录翻页”,不是 live tail 的起点; + // bridgeLogs 的 cursor 是“向更旧记录翻页”,不是 live tail 的起点; // 实时轮询必须始终查最新窗口,再用 current_cursor 在 Rust 侧比较增量。 next.cursor = None; next @@ -414,17 +414,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"trashed_documents":[],"media_assets":[],"trashed_media_assets":[],"mindmap_assets":[],"trashed_mindmap_assets":[],"table_assets":[],"trashed_table_assets":[],"mindmap_docs":[],"mindmap_asset_children":{}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":"cursor_demo","has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()), - mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), diff --git a/rust/crates/mnote-web/src/routes/stream_support.rs b/rust/crates/mnote-web/src/routes/stream_support.rs index a57d9056..afcf6692 100644 --- a/rust/crates/mnote-web/src/routes/stream_support.rs +++ b/rust/crates/mnote-web/src/routes/stream_support.rs @@ -2,7 +2,7 @@ use crate::app::AppConfig; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::{ - execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id, + reject_non_local_runtime_query, resolve_effective_workspace_id, }; use crate::routes::snapshot_support::{ execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, @@ -635,7 +635,7 @@ pub async fn load_stream_overview( .with_context(context) }, )?; - let overview = execute_runtime_query_via_legacy_cloud( + let overview = reject_non_local_runtime_query( config, context, Some(&effective_workspace_id), @@ -690,7 +690,7 @@ pub async fn load_stream_snapshot( } }; - let overview = execute_runtime_query_via_legacy_cloud( + let overview = reject_non_local_runtime_query( config, context, Some(&effective_workspace_id), @@ -821,7 +821,7 @@ mod tests { "id": "clog_2", "command_id": "cmd_2", "created_at": "2026-04-25T10:00:02Z", - "command_name": "documents.emptyTrashByWorkspace", + "command_name": "tree.trash.emptyWorkspace", "payload": { "streamDelta": { "op": "resync_required", diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 3424def9..fc807cbf 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -2,8 +2,7 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::command_support::{ - build_runtime_command_plan, build_tree_target, ensure_non_empty, ensure_sort_order, - execute_runtime_command_via_legacy_cloud_with_artifacts, read_optional_non_empty, + build_runtime_command_plan, build_tree_target, ensure_non_empty, ensure_sort_order, read_optional_non_empty, }; use crate::routes::local_folder_source::{ ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state, @@ -12,10 +11,10 @@ use crate::routes::local_folder_source::{ local_workspace_id_from_root_uri, LocalAccessMode, }; use crate::routes::query_support::{ - fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id, + reject_non_local_documents_meta, resolve_effective_workspace_id, }; use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec}; -use crate::transport::legacy_cloud_guard::execute_retired_mutation_by_name; +use crate::transport::local_only::local_required; use crate::tree_shell::filetree_renderer::{ render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow, }; @@ -137,69 +136,6 @@ impl TreeCommandEnvelopeContext { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub struct TreeCompatAliasCatalogEntry { - pub compat_command: &'static str, - pub preferred_command: &'static str, - pub source_kind: &'static str, - pub retained_for: &'static str, - pub retirement_condition: &'static str, -} - -#[allow(dead_code)] -pub const TREE_DOCUMENT_COMPAT_ALIAS_CATALOG: &[TreeCompatAliasCatalogEntry] = &[ - TreeCompatAliasCatalogEntry { - compat_command: "documents.create", - preferred_command: "tree.node.create", - source_kind: "convex_workspace", - retained_for: "legacy cloud / remote callers still emitting documents.*", - retirement_condition: "all cloud and remote callers emit tree.node.create", - }, - TreeCompatAliasCatalogEntry { - compat_command: "documents.title.update", - preferred_command: "tree.node.rename", - source_kind: "convex_workspace", - retained_for: "legacy cloud / remote callers still emitting documents.*", - retirement_condition: "all cloud and remote callers emit tree.node.rename", - }, - TreeCompatAliasCatalogEntry { - compat_command: "documents.move", - preferred_command: "tree.subtree.move", - source_kind: "convex_workspace", - retained_for: "legacy cloud / remote callers still emitting documents.*", - retirement_condition: "all cloud and remote callers emit tree.subtree.move", - }, - TreeCompatAliasCatalogEntry { - compat_command: "documents.delete", - preferred_command: "tree.node.archive", - source_kind: "convex_workspace", - retained_for: "legacy cloud / remote callers still emitting documents.*", - retirement_condition: "all cloud and remote callers emit tree.node.archive", - }, - TreeCompatAliasCatalogEntry { - compat_command: "documents.restore", - preferred_command: "tree.node.restore", - source_kind: "convex_workspace", - retained_for: "legacy cloud / remote callers still emitting documents.*", - retirement_condition: "all cloud and remote callers emit tree.node.restore", - }, - TreeCompatAliasCatalogEntry { - compat_command: "documents.purge", - preferred_command: "tree.node.purge", - source_kind: "convex_workspace", - retained_for: "legacy cloud / remote callers still emitting documents.*", - retirement_condition: "all cloud and remote callers emit tree.node.purge", - }, - TreeCompatAliasCatalogEntry { - compat_command: "documents.copy_tree", - preferred_command: "tree.subtree.copy", - source_kind: "convex_workspace", - retained_for: "legacy cloud / remote callers still emitting documents.*", - retirement_condition: "all cloud and remote callers emit tree.subtree.copy", - }, -]; - #[derive(Debug)] pub enum TreeCommandRequest { Create { @@ -323,10 +259,9 @@ fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool { fn normalize_tree_source_kind(raw: Option<&str>) -> &'static str { match raw.map(str::trim).unwrap_or("") { - "local_folder" | "local-folder" | "local" => "local_folder", - "convex_workspace" | "convex" | "" => "convex_workspace", - // 未知 kind 一律回落到 convex_workspace,避免客户端自报 kind 与 root_uri 脱钩 - _ => "convex_workspace", + "local_folder" | "local-folder" | "local" | "" => "local_folder", + // 未知 kind 不再回落到云源;统一 local_folder(调用方仍须提供 rootUri) + _ => "local_folder", } } @@ -335,39 +270,24 @@ fn build_workspace_source_wire( workspace_id: &str, envelope_context: &TreeCommandEnvelopeContext, ) -> bridge_runtime::RuntimeSourceWire { - // 不信任请求体自报的任意 source_kind:白名单归一化后再与 root_uri 对齐。 + // 仅 local_folder;root_uri 必须由上游鉴权提供,禁止伪造云 URI。 let source_kind = normalize_tree_source_kind(envelope_context.source_kind.as_deref()).to_string(); - let root_uri = match source_kind.as_str() { - "local_folder" => { - // local_folder 必须带已由上游鉴权的 root_uri;无则不回落到 convex 假 URI - envelope_context - .root_uri - .as_ref() - .map(|u| u.trim().to_string()) - .filter(|u| !u.is_empty() && !u.contains("..")) - } - _ => Some( - envelope_context - .root_uri - .clone() - .filter(|u| { - let t = u.trim(); - !t.is_empty() && (t.starts_with("convex://") || t.starts_with("workspace:")) - }) - .unwrap_or_else(|| format!("convex://workspace/{workspace_id}")), - ), + let root_uri = envelope_context + .root_uri + .as_ref() + .map(|u| u.trim().to_string()) + .filter(|u| !u.is_empty() && !u.contains("..")); + let capabilities = if envelope_context.source_capabilities.is_empty() { + vec![ + "load-snapshot".into(), + "watch".into(), + "preflight-command".into(), + "execute-command".into(), + "resolve-page-aggregate".into(), + ] + } else { + envelope_context.source_capabilities.clone() }; - let capabilities = - if envelope_context.source_capabilities.is_empty() && source_kind == "convex_workspace" { - vec![ - "load-snapshot".into(), - "preflight-command".into(), - "execute-command".into(), - "resolve-page-aggregate".into(), - ] - } else { - envelope_context.source_capabilities.clone() - }; bridge_runtime::RuntimeSourceWire { channel: context.source.channel.clone(), @@ -955,7 +875,7 @@ fn build_tree_shell_html( let source_kind = projection .get("sourceKind") .and_then(Value::as_str) - .unwrap_or("convex_workspace"); + .unwrap_or("local_folder"); let root_uri = projection .get("rootUri") .and_then(Value::as_str) @@ -1990,7 +1910,7 @@ fn create_command_wire( } TreeCommandRequest::CreateFolder { .. } => Err(WebError::bad_request_code( "tree_command_validation", - "convex_workspace 暂不支持 folder create capability", + "仅 local_folder 支持 folder create capability", ) .with_context(context) .with_header("x-error-phase", "tree_command_validate")), @@ -2194,7 +2114,7 @@ fn create_command_wire( } TreeCommandRequest::DropFiles { .. } => Err(WebError::bad_request_code( "tree_command_validation", - "convex_workspace 外部文件 drop 需要走上传 preflight / object storage executor", + "外部文件 drop 需要走 local-folder 上传 preflight", ) .with_context(context) .with_header("x-error-phase", "tree_command_validate")), @@ -2280,7 +2200,7 @@ async fn resolve_tree_create_workspace_id( if let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) { let parent_meta = - fetch_documents_meta_via_legacy_cloud(state.config(), context, None, parent_id).await?; + reject_non_local_documents_meta(state.config(), context, None, parent_id).await?; if let Some(workspace_id) = parent_meta .get("workspace_id") .or_else(|| parent_meta.get("workspaceId")) @@ -2292,33 +2212,8 @@ async fn resolve_tree_create_workspace_id( } } - let bootstrap = execute_retired_mutation_by_name( - state.config(), - context, - "workspaces:ensureDefaultWorkspace", - json!({ - "fallbackName": context.auth.actor_id, - "workspaceIdIfCreate": generate_tree_document_id(), - }), - None, - None, - "tree_command_workspace_bootstrap", - ) - .await?; - bootstrap - .get("activeWorkspaceId") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .ok_or_else(|| { - WebError::bad_gateway_code( - "workspace_bootstrap_bad_response", - "workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId", - ) - .with_context(context) - .with_header("x-error-phase", "tree_command_workspace_bootstrap") - }) + // 云 workspace bootstrap 已移除;无 local context 时直接要求 local_folder。 + Err(local_required(context, "tree_command_workspace_required")) } fn operation_resource_relative_path(value: &Value, key: &str) -> Option { @@ -2505,7 +2400,7 @@ pub async fn tree_command( .with_header("x-error-phase", "tree_command_validate")); } }; - let requested_workspace_id = match &request { + let _requested_workspace_id = match &request { TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::CreateFolder { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(), @@ -2659,77 +2554,13 @@ pub async fn tree_command( }), )); } - let effective_workspace_id = match &request { - TreeCommandRequest::Create { parent_id, .. } => { - resolve_tree_create_workspace_id( - &state, - &context, - requested_workspace_id, - parent_id.as_deref(), - ) - .await? - } - TreeCommandRequest::CreateFolder { .. } => { - return Err(WebError::bad_request_code( - "tree_command_validation", - "convex_workspace 暂不支持 folder create capability", - ) - .with_context(&context) - .with_header("x-error-phase", "tree_command_validate")); - } - _ => resolve_effective_workspace_id(&context, requested_workspace_id, true)? - .expect("workspace_required 已确保存在"), - }; - let needs_move_preflight = matches!(&request, TreeCommandRequest::Move { .. }); - let mut command_wire = create_command_wire( - &context, - &effective_workspace_id, - request, - &envelope_context, - )?; - if needs_move_preflight { - command_wire.preflight_data = - Some(load_tree_move_preflight_data(&state, &context, &effective_workspace_id).await?); - } - let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( - &state, - &context, - Some(&effective_workspace_id), - command_wire, + // 非 local_folder 命令一律拒绝 + return Err(WebError::bad_request_code( + "local_required", + "tree command 仅支持 local_folder(sourceKind=local_folder + rootUri)", ) - .await?; - let response_document_id = execution - .result - .get("id") - .and_then(Value::as_str) - .map(ToOwned::to_owned) - .unwrap_or(requested_document_id); - let artifacts = execution - .artifacts - .as_ref() - .and_then(|artifacts| serde_json::to_value(artifacts).ok()) - .unwrap_or(Value::Null); - let artifact_error = execution - .artifact_error - .as_ref() - .map(|message| Value::String(message.clone())) - .unwrap_or(Value::Null); - - Ok(json_response( - &context, - json!({ - "workspaceId": effective_workspace_id, - "action": action, - "documentId": response_document_id, - "parentId": requested_parent_id, - "title": requested_title, - "sortOrder": requested_sort_order, - "updatedAt": execution.result.get("updated_at").cloned().unwrap_or(Value::Null), - "execution": execution.result, - "artifacts": artifacts, - "artifactError": artifact_error, - }), - )) + .with_context(&context) + .with_header("x-error-phase", "tree_command_local_required")); } pub async fn reduce_tree_shell_runtime( @@ -2752,7 +2583,7 @@ mod tests { use super::{ collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext, - TreeCommandRequest, TREE_DOCUMENT_COMPAT_ALIAS_CATALOG, + TreeCommandRequest, }; use crate::app::{build_app, AppConfig, AppState}; use crate::context::RequestContext; @@ -2769,17 +2600,11 @@ mod tests { 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: true, enable_editor_actor: true, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"table","file_name":"预算.table","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()), - mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"},"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:purge":{"ok":true,"deletedCount":1},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), @@ -2953,8 +2778,6 @@ mod tests { .contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally")); - assert!(TREE_SHELL_RUNTIME_JS - .contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")); assert!(TREE_SHELL_RUNTIME_JS .contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")); assert!(TREE_SHELL_RUNTIME_JS.contains("application/x-mnote-page-tree-node")); @@ -3144,17 +2967,11 @@ mod tests { 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: true, enable_editor_actor: true, enable_page_ai_pi_lab: false, - 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(), @@ -3890,17 +3707,11 @@ mod tests { 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: true, enable_editor_actor: true, enable_page_ai_pi_lab: false, - 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(), @@ -4428,11 +4239,11 @@ mod tests { assert_eq!(create_wire.name, "tree.node.create"); assert_eq!( create_wire.source.source_kind.as_deref(), - Some("convex_workspace") + Some("local_folder") ); assert_eq!( create_wire.source.root_uri.as_deref(), - Some("convex://workspace/ws_demo") + None ); assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo")); assert!(create_wire @@ -4477,8 +4288,8 @@ mod tests { &HeaderMap::new(), ); let envelope_context = TreeCommandEnvelopeContext { - source_kind: Some("convex_workspace".into()), - root_uri: Some("convex://workspace/ws_demo".into()), + source_kind: Some("local_folder".into()), + root_uri: Some("file:///tmp/ws_demo".into()), source_capabilities: vec![ "load-snapshot".into(), "preflight-command".into(), @@ -4511,11 +4322,11 @@ mod tests { assert_eq!( rename_wire.source.source_kind.as_deref(), - Some("convex_workspace") + Some("local_folder") ); assert_eq!( rename_wire.source.root_uri.as_deref(), - Some("convex://workspace/ws_demo") + None ); assert_eq!( rename_wire.payload["targetNodeId"], @@ -4557,18 +4368,10 @@ mod tests { &TreeCommandEnvelopeContext::default(), ) .expect("tree create wire"); - let compat_create_wire = bridge_runtime::RuntimeCommandEnvelopeWire { - name: "documents.create".into(), - ..tree_create_wire.clone() - }; let tree_create_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_create_wire) .expect("tree create plan"); - let compat_create_plan = - build_runtime_command_plan(&context, Some("ws_demo"), compat_create_wire) - .expect("compat create plan"); assert_eq!(tree_create_plan.function_name, "tree.node.create"); - assert_eq!(compat_create_plan.function_name, "documents.create"); let tree_rename_wire = create_command_wire( &context, @@ -4581,18 +4384,10 @@ mod tests { &TreeCommandEnvelopeContext::default(), ) .expect("tree rename wire"); - let compat_rename_wire = bridge_runtime::RuntimeCommandEnvelopeWire { - name: "documents.title.update".into(), - ..tree_rename_wire.clone() - }; let tree_rename_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_rename_wire) .expect("tree rename plan"); - let compat_rename_plan = - build_runtime_command_plan(&context, Some("ws_demo"), compat_rename_wire) - .expect("compat rename plan"); assert_eq!(tree_rename_plan.function_name, "tree.node.rename"); - assert_eq!(compat_rename_plan.function_name, "documents.title.update"); let tree_move_wire = create_command_wire( &context, @@ -4606,44 +4401,8 @@ mod tests { &TreeCommandEnvelopeContext::default(), ) .expect("tree move wire"); - let compat_move_wire = bridge_runtime::RuntimeCommandEnvelopeWire { - name: "documents.move".into(), - ..tree_move_wire.clone() - }; let tree_move_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_move_wire) .expect("tree move plan"); - let compat_move_plan = - build_runtime_command_plan(&context, Some("ws_demo"), compat_move_wire) - .expect("compat move plan"); assert_eq!(tree_move_plan.function_name, "tree.subtree.move"); - assert_eq!(compat_move_plan.function_name, "documents.move"); - } - - #[test] - fn tree_documents_compat_alias_catalog_marks_cloud_retirement_boundary() { - let aliases = TREE_DOCUMENT_COMPAT_ALIAS_CATALOG; - assert!(aliases - .iter() - .all(|entry| entry.compat_command.starts_with("documents."))); - assert!(aliases - .iter() - .all(|entry| entry.preferred_command.starts_with("tree."))); - assert!(aliases - .iter() - .all(|entry| entry.source_kind == "convex_workspace")); - assert!(aliases - .iter() - .all(|entry| entry.retained_for.contains("legacy cloud"))); - assert!(aliases - .iter() - .all(|entry| entry.retirement_condition.contains("emit tree."))); - assert!(aliases.iter().any(|entry| { - entry.compat_command == "documents.delete" - && entry.preferred_command == "tree.node.archive" - })); - assert!(aliases.iter().any(|entry| { - entry.compat_command == "documents.copy_tree" - && entry.preferred_command == "tree.subtree.copy" - })); } } diff --git a/rust/crates/mnote-web/src/routes/tree_view_state.rs b/rust/crates/mnote-web/src/routes/tree_view_state.rs index 37cfb55a..7c028202 100644 --- a/rust/crates/mnote-web/src/routes/tree_view_state.rs +++ b/rust/crates/mnote-web/src/routes/tree_view_state.rs @@ -168,7 +168,7 @@ fn resolve_scope( root_uri: &str, scope: &str, ) -> Result { - let source_kind = normalize_or_default(source_kind, "convex_workspace"); + let source_kind = normalize_or_default(source_kind, "local_folder"); let tree_kind = normalize_tree_kind(context, tree_kind)?; let root_uri = root_uri.trim().to_string(); let scope = normalize_or_default(scope, "root"); diff --git a/rust/crates/mnote-web/src/routes/ui_preferences.rs b/rust/crates/mnote-web/src/routes/ui_preferences.rs index 4f175c28..99771a97 100644 --- a/rust/crates/mnote-web/src/routes/ui_preferences.rs +++ b/rust/crates/mnote-web/src/routes/ui_preferences.rs @@ -348,7 +348,7 @@ fn page_preference_scope( ) -> Result { let source_kind = source_kind.trim(); let source_kind = if source_kind.is_empty() { - "convex_workspace" + "local_folder" } else { source_kind }; diff --git a/rust/crates/mnote-web/src/routes/vault.rs b/rust/crates/mnote-web/src/routes/vault.rs index 7daa9e78..c0305401 100644 --- a/rust/crates/mnote-web/src/routes/vault.rs +++ b/rust/crates/mnote-web/src/routes/vault.rs @@ -87,17 +87,73 @@ fn actor_id(context: &RequestContext) -> String { } fn require_authenticated(context: &RequestContext) -> Result { + // 7-76:Web PAT(mnpat1)与密码箱分职 — 禁止用笔记 PAT 读密/管箱。 + // 读密通道:浏览器 session,或 agent vault token(mnv1.*)。 + if crate::routes::api_access_token::is_pat_auth(context) { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "vault_via_web_pat_forbidden", + "Web PAT(mnpat1)不能访问密码箱;请使用浏览器会话,或签发 vault token(mnv1)后调用", + ) + .with_context(context)); + } let id = actor_id(context); if id == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "vault_auth_required", - "密码箱需要登录会话", - )); + "密码箱需要登录会话或 vault token(mnv1)", + ) + .with_context(context)); } Ok(id) } +/// agent vault token(auth_method=vault_token)时强制 scope;session/extension 不限。 +fn require_vault_agent_scope(context: &RequestContext, need: &str) -> Result<(), WebError> { + if context.auth.auth_method != "vault_token" { + return Ok(()); + } + let need = need.trim(); + let ok = context.auth.scopes.iter().any(|s| { + let s = s.trim(); + s == "*" + || s == need + || s == format!("vault.{need}") + || (need == "list" && (s == "get" || s == "vault.get")) // get 蕴含 list 选型 + || (need == "get" && (s == "list" || s == "vault.list")) // list 常可看 L0 详情 + }); + if ok { + return Ok(()); + } + Err(WebError::new( + StatusCode::FORBIDDEN, + "vault_scope_denied", + format!("vault token 缺少 scope: {need}"), + ) + .with_context(context)) +} + +/// 从 Authorization 提取 mnv1 token。 +pub fn bearer_mnv1(authorization: Option<&str>) -> Option<&str> { + let raw = authorization? + .strip_prefix("Bearer ") + .or_else(|| authorization?.strip_prefix("bearer "))? + .trim(); + if raw.starts_with("mnv1.") { + Some(raw) + } else { + None + } +} + +/// 校验 mnv1 vault capability token。 +pub fn verify_agent_vault_token(token: &str) -> Result { + let key = mnote_vault_core::load_or_create_hmac_key(&mnote_vault_core::default_hmac_key_path()) + .map_err(WebError::from)?; + mnote_vault_core::verify_token(&key, token).map_err(WebError::from) +} + fn require_root_uri(root_uri: Option<&str>) -> Result<&str, WebError> { root_uri .map(str::trim) @@ -529,6 +585,7 @@ pub async fn list( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { require_vault_enabled()?; require_authenticated(&context)?; + require_vault_agent_scope(&context, "list")?; let root_uri = require_root_uri(query.root_uri.as_deref())?; let root = resolve_read_root(&state, &context, root_uri).await?; let status = parse_status(query.status.as_deref())?; @@ -594,6 +651,7 @@ pub async fn get_item( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { require_vault_enabled()?; require_authenticated(&context)?; + require_vault_agent_scope(&context, "get")?; let root_uri = require_root_uri(query.root_uri.as_deref())?; let root = resolve_read_root(&state, &context, root_uri).await?; let record = vault_store::get_credential(&root, &id)?; @@ -1190,6 +1248,7 @@ pub async fn resolve_item( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { require_vault_enabled()?; let actor = require_authenticated(&context)?; + require_vault_agent_scope(&context, "resolve")?; let map = body.as_object().ok_or_else(|| { WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象") })?; @@ -1254,6 +1313,7 @@ pub async fn list_ai( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { require_vault_enabled()?; require_authenticated(&context)?; + require_vault_agent_scope(&context, "list")?; let status = parse_status(query.status.as_deref())?; // 尽力修复旧数据分组(失败不阻断列表) let repair = repair_ai_folder_paths_best_effort(); @@ -1347,6 +1407,7 @@ pub async fn get_ai_item( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { require_vault_enabled()?; require_authenticated(&context)?; + require_vault_agent_scope(&context, "get")?; let result = get_ai_vault_item(&id)?; Ok(ok_response(&context, result)) } @@ -1359,6 +1420,7 @@ pub async fn resolve_ai_item( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { require_vault_enabled()?; let actor = require_authenticated(&context)?; + require_vault_agent_scope(&context, "resolve")?; let map = body.as_object().ok_or_else(|| { WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象") })?; @@ -1405,6 +1467,7 @@ pub async fn login_ai_item( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { require_vault_enabled()?; let actor = require_authenticated(&context)?; + require_vault_agent_scope(&context, "login")?; let force = body .get("forceRefresh") .or_else(|| body.get("force_refresh")) @@ -1927,6 +1990,15 @@ pub async fn list_local_agent_vault_tokens( axum::extract::Query(query): axum::extract::Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { require_vault_enabled()?; + // 本机安装列表仅浏览器 session / admin;Web PAT 不走此面 + if crate::routes::api_access_token::is_pat_auth(&context) { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "vault_via_web_pat_forbidden", + "Web PAT 不能查看本机 vault 安装列表", + ) + .with_context(&context)); + } let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| { WebError::new( StatusCode::UNAUTHORIZED, @@ -2081,6 +2153,7 @@ pub async fn put_ai_session( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { require_vault_enabled()?; let actor = require_authenticated(&context)?; + require_vault_agent_scope(&context, "session")?; let cookie = body .get("cookieHeader") .or_else(|| body.get("cookie_header")) diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 45ce9b43..c3eb6c94 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -3,10 +3,6 @@ use crate::context::RequestContext; use crate::document_buffer_store::{self}; use crate::error::WebError; use crate::page_aggregate::PageAggregate; -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_with_state, is_local_access_policy_admin_context, @@ -15,7 +11,6 @@ use crate::routes::local_folder_source::{ load_local_folder_page_tree_scope_snapshot_with_reveal, load_local_folder_page_tree_snapshot_with_reveal, resolve_local_markdown_page_aggregate, }; -use crate::routes::query_support::execute_runtime_query_against_data; use crate::routes::snapshot_support::{ execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec, }; @@ -34,7 +29,6 @@ use axum::extract::{Extension, Path, Query, State}; use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode, Uri}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; -use bridge_runtime::RuntimeQueryEnvelopeWire; use control_plane::UpsertNavigationRecentInput; use core_protocol::KernelProjectionKind; use serde::Deserialize; @@ -2108,7 +2102,7 @@ pub async fn office_preview_vendor_asset( .join(relative); let bytes = std::fs::read(&path).map_err(|error| { WebError::new( - StatusCode::SERVICE_UNAVAILABLE, + StatusCode::BAD_REQUEST, "office_preview_vendor_asset_unavailable", format!("Office preview vendor asset 不可用,请先运行 npm install: {error}"), ) @@ -2391,7 +2385,7 @@ pub async fn pdfjs_asset(Path(asset_path): Path) -> Result, + _workspace_id: Option<&str>, source_kind: Option<&str>, root_uri: Option<&str>, ) -> Result { - if source_kind.map(str::trim).filter(|value| !value.is_empty()) == Some("local_folder") { - let root_uri = root_uri - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") - })?; - ensure_local_workspace_read_access_with_state(state, context, root_uri) - .map_err(|error| error.with_context(context))?; - let root_uri_for_build = root_uri.to_string(); - let root_uri_for_preferences = root_uri_for_build.clone(); - let document_id = document_id.to_string(); - let mut aggregate = tokio::task::spawn_blocking(move || { - #[cfg(test)] - block_local_page_aggregate_for_test(); - resolve_local_markdown_page_aggregate(&root_uri_for_build, &document_id) - }) - .await - .map_err(|error| { - WebError::internal(format!("本地 Page Aggregate 构建任务失败: {error}")) - })??; - annotate_local_attachment_refs_authorization(state, context, &mut aggregate)?; - super::ui_preferences::apply_effective_page_preferences( - state, - context, - &mut aggregate, - source_kind, - Some(root_uri_for_preferences.as_str()), - )?; - return Ok(aggregate); + let kind = source_kind.map(str::trim).filter(|value| !value.is_empty()); + // local-md: 文档在未显式传 sourceKind 时仍走 local_folder + let is_local = kind == Some("local_folder") + || (kind.is_none() && document_id.trim().starts_with("local-md:")); + if !is_local { + return Err(WebError::bad_request_code( + "local_required", + "Page Aggregate 仅支持 local_folder(sourceKind=local_folder + rootUri)", + ) + .with_context(context)); } - let meta = load_document_meta_result( - state, - context, - DocumentMetaQuery { - document_id: document_id.to_string(), - workspace_id: workspace_id.map(str::to_string), - }, - ) - .await?; - let content = load_document_content_result( - state, - context, - DocumentContentQuery { - document_id: document_id.to_string(), - workspace_id: workspace_id.map(str::to_string), - }, - ) - .await?; - - let projection = execute_runtime_query_against_data( - context, - workspace_id, - RuntimeQueryEnvelopeWire { - name: "page.aggregate.get".into(), - payload: json!({ - "documentId": document_id, - "workspaceId": workspace_id, - }), - }, - json!({ - "meta": meta, - "content": content, - }), - )?; - - serde_json::from_value::(projection).map_err(|error| { - WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}")) + let root_uri = root_uri + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") + })?; + ensure_local_workspace_read_access_with_state(state, context, root_uri) + .map_err(|error| error.with_context(context))?; + let root_uri_for_build = root_uri.to_string(); + let root_uri_for_preferences = root_uri_for_build.clone(); + let document_id = document_id.to_string(); + let mut aggregate = tokio::task::spawn_blocking(move || { + #[cfg(test)] + block_local_page_aggregate_for_test(); + resolve_local_markdown_page_aggregate(&root_uri_for_build, &document_id) }) + .await + .map_err(|error| { + WebError::internal(format!("本地 Page Aggregate 构建任务失败: {error}")) + })??; + annotate_local_attachment_refs_authorization(state, context, &mut aggregate)?; + super::ui_preferences::apply_effective_page_preferences( + state, + context, + &mut aggregate, + Some("local_folder"), + Some(root_uri_for_preferences.as_str()), + )?; + Ok(aggregate) } fn annotate_local_attachment_refs_authorization( @@ -3611,50 +3576,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some( - r#"{ - "documents:getMeta": { - "id": "doc_1", - "workspace_id": "ws_demo", - "title": "服务端页面", - "updated_at": "2026-04-18T09:30:00Z", - "can_edit": true, - "word_count": 42, - "character_count": 128, - "block_count": 3 - }, - "documents:getContent": { - "content": [{"id": "block_1", "type": "paragraph", "content": []}], - "editorDocument": { - "documentId": "doc_1", - "rootBlockIds": ["editor_1"], - "blocks": [{ - "blockId": "editor_1", - "blockType": "paragraph", - "contentNodes": [{ - "payload": {"type": "text", "text": "来自 editorDocument 的正文"}, - "attrs": {} - }], - "childBlockIds": [] - }] - }, - "revision": 7, - "conflict_detection_key": "doc_1:7", - "pageSubtree": {"rootNodeId": "doc_1", "outline": []} - } - }"# - .into(), - ), - mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), @@ -3669,37 +3595,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: None, - convex_admin_key: None, + allow_dev_fixtures: true, - query_fixtures_json: Some( - r#"{ - "documents:getMeta": { - "id": "doc_1", - "workspace_id": "ws_demo", - "title": "服务端页面", - "updated_at": "2026-04-18T09:30:00Z", - "can_edit": true, - "word_count": 42, - "character_count": 128, - "block_count": 3 - }, - "documents:getContent": { - "content": [{"id": "block_1", "type": "paragraph", "content": []}], - "revision": 7, - "conflict_detection_key": "doc_1:7", - "pageSubtree": {"rootNodeId": "doc_1", "outline": []} - } - }"# - .into(), - ), - mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), @@ -3785,23 +3685,17 @@ mod tests { .expect("grant control-plane read"); } - fn app_with_unreachable_convex_without_fixture() -> axum::Router { + fn app_without_local_source() -> axum::Router { build_app(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, enable_page_ai_pi_lab: false, - compat_next_base_path: "/api/compat/next".into(), - convex_url: Some("http://127.0.0.1:9".into()), - convex_admin_key: Some("test-admin-key".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(), @@ -3891,8 +3785,8 @@ mod tests { } #[tokio::test] - async fn document_shell_local_markdown_without_source_kind_does_not_fall_back_to_convex() { - let response = app_with_unreachable_convex_without_fixture() + async fn document_shell_local_markdown_without_source_kind_uses_local_inference() { + let response = app_without_local_source() .oneshot( Request::builder() .uri("/documents/local-md:docs~2FPlan.md?resourceTab=primary%3A%3Aresource%3Afile%3Afile%3A%2F%2F%2Ftmp%3Adocs%2FPlan.pdf") @@ -3910,7 +3804,7 @@ mod tests { .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), - Some("convex_retired") + Some("local_required") ); let location = response .headers() @@ -3934,17 +3828,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), @@ -4532,8 +4420,8 @@ mod tests { } #[tokio::test] - async fn page_aggregate_endpoint_errors_without_convex_or_fixture() { - let response = app_with_unreachable_convex_without_fixture() + async fn page_aggregate_endpoint_errors_without_local_source() { + let response = app_without_local_source() .oneshot( Request::builder() .uri("/api/page-aggregate/doc_1?workspaceId=ws_demo") @@ -4543,13 +4431,13 @@ mod tests { .await .expect("response"); - if response.status() != StatusCode::SERVICE_UNAVAILABLE { + if response.status() != StatusCode::BAD_REQUEST { let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); panic!( - "expected SERVICE_UNAVAILABLE, got {status}: {}", + "expected BAD_REQUEST, got {status}: {}", String::from_utf8_lossy(&body) ); } @@ -4558,35 +4446,23 @@ mod tests { .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), - Some("convex_retired") - ); - assert_eq!( - response - .headers() - .get("x-error-phase") - .and_then(|value| value.to_str().ok()), - Some("convex_query_retired") - ); - assert_eq!( - response - .headers() - .get("x-upstream-service") - .and_then(|value| value.to_str().ok()), - Some("legacy-cloud-retired") + Some("local_required") ); + + 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"], false); - assert_eq!(payload["code"], "convex_retired"); + assert_eq!(payload["code"], "local_required"); assert!(payload.get("schema").is_none()); assert!(payload.get("result").is_none()); } #[tokio::test] - async fn document_shell_errors_without_convex_or_fixture() { - let response = app_with_unreachable_convex_without_fixture() + async fn document_shell_errors_without_local_source() { + let response = app_without_local_source() .oneshot( Request::builder() .uri("/documents/doc_1?workspaceId=ws_demo") @@ -4598,13 +4474,13 @@ mod tests { .await .expect("response"); - if response.status() != StatusCode::SERVICE_UNAVAILABLE { + if response.status() != StatusCode::BAD_REQUEST { let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); panic!( - "expected SERVICE_UNAVAILABLE, got {status}: {}", + "expected BAD_REQUEST, got {status}: {}", String::from_utf8_lossy(&body) ); } @@ -4613,7 +4489,7 @@ mod tests { .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()), - Some("convex_retired") + Some("local_required") ); let body = to_bytes(response.into_body(), usize::MAX) .await @@ -4621,7 +4497,7 @@ mod tests { let text = String::from_utf8(body.to_vec()).expect("utf8"); let payload: Value = serde_json::from_str(&text).expect("json"); assert_eq!(payload["ok"], false); - assert_eq!(payload["code"], "convex_retired"); + assert_eq!(payload["code"], "local_required"); assert!(!text.contains("mnote.page_aggregate.v1")); assert!(!text.contains("data-mnote-dev-fixture")); assert!(!text.contains("data-page-aggregate-snapshot")); @@ -4703,17 +4579,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), @@ -4772,17 +4642,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), @@ -4931,17 +4795,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), @@ -4995,7 +4853,7 @@ mod tests { .body(Body::from( serde_json::json!({ "workspaceId": "ws_demo", - "sourceKind": "convex_workspace", + "sourceKind": "local_folder", "documentId": "doc_1", "updates": { "showHeadingNumbers": true, @@ -5014,7 +4872,7 @@ mod tests { .oneshot( Request::builder() .method("GET") - .uri("/api/ui/preferences/effective?workspaceId=ws_demo&sourceKind=convex_workspace&documentId=doc_1") + .uri("/api/ui/preferences/effective?workspaceId=ws_demo&sourceKind=local_folder&documentId=doc_1") .body(Body::empty()) .expect("request"), ) @@ -5487,17 +5345,11 @@ mod tests { 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, enable_page_ai_pi_lab: false, - 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(), diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 654bba70..8988ada5 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -338,7 +338,7 @@ mod tests { assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebar-file-tree-root")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.open")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.asset.open")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openConvexAssetFromFileTree")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openLocalAssetFromFileTree")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildMindmapOpenPath")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("isMindmapAssetDetail")); assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("^思维导图")); @@ -985,7 +985,7 @@ mod tests { assert!( !SIDEBAR_TREE_RUNTIME_JS .contains("targetUrl.searchParams.set('sourceKind', 'convex_workspace')"), - "默认来源菜单不能跳到已退出 dev 主链的 convex_workspace" + "默认来源菜单只能走 local_folder,不得设置已退役的云 workspace sourceKind" ); } @@ -1011,7 +1011,7 @@ mod tests { ); assert!( TREE_LIVE_CONTROLLER_JS.contains("body.getAttribute('data-mnote-source-kind')"), - "根入口由 SSR body 暴露 local_folder 时,tree live 不能误走 retired Convex WS/SSE" + "根入口由 SSR body 暴露 local_folder 时,tree live 必须走 local-folder-events,不得走云 WS/SSE" ); assert!( TREE_LIVE_CONTROLLER_JS.contains("bootstrap.transport === 'local-folder-events'"), @@ -1151,7 +1151,7 @@ mod tests { assert!( LOCAL_UPLOAD_RUNTIME_JS .contains("var imageUrl = localOpenUrl || fallbackUrl || markdownHref;"), - "图片上传首屏必须使用本地 open URL 渲染,避免相对 href 在 /documents 下命中退役 Convex 兼容路径" + "图片上传首屏必须使用本地 open URL 渲染,避免相对 href 在 /documents 下命中错误路径" ); assert!( LOCAL_UPLOAD_RUNTIME_JS.contains("setImage({ src: imageUrl"), diff --git a/rust/crates/mnote-web/src/transport.rs b/rust/crates/mnote-web/src/transport.rs index 11762faa..bc143f32 100644 --- a/rust/crates/mnote-web/src/transport.rs +++ b/rust/crates/mnote-web/src/transport.rs @@ -1 +1 @@ -pub mod legacy_cloud_guard; +pub mod local_only; diff --git a/rust/crates/mnote-web/src/transport/legacy_cloud_guard.rs b/rust/crates/mnote-web/src/transport/legacy_cloud_guard.rs deleted file mode 100644 index 8f078913..00000000 --- a/rust/crates/mnote-web/src/transport/legacy_cloud_guard.rs +++ /dev/null @@ -1,274 +0,0 @@ -use crate::app::AppConfig; -use crate::context::RequestContext; -use crate::error::WebError; -use bridge_runtime::{ - build_runtime_command_artifact_plan, retired_command_transport_function_name, - retired_query_transport_function_name, RuntimeCommandArtifactPlan, RuntimeCommandExecutionPlan, - RuntimeQueryExecutionPlan, -}; -use serde_json::Value; -use time::{format_description::well_known::Rfc3339, OffsetDateTime}; - -#[derive(Debug)] -pub struct RetiredCloudCommandExecution { - pub result: Value, - pub artifacts: Option, - pub artifact_error: Option, -} - -fn retired_error(context: &RequestContext, phase: &'static str) -> WebError { - WebError::service_unavailable_code( - "convex_retired", - "旧 cloud/Convex 兼容运行时已退役;请使用 local-first Rust/SQLite control-plane 路径", - ) - .with_context(context) - .with_header("x-error-phase", phase) - .with_header("x-upstream-service", "legacy-cloud-retired") -} - -fn load_query_fixture( - config: &AppConfig, - context: &RequestContext, - plan: &RuntimeQueryExecutionPlan, -) -> Result, WebError> { - if !config.allow_dev_fixtures { - return Ok(None); - } - - let Some(raw) = config.query_fixtures_json.as_deref() else { - return Ok(None); - }; - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Ok(None); - } - - let fixtures: Value = serde_json::from_str(trimmed).map_err(|error| { - WebError::internal(format!("MNOTE_WEB_QUERY_FIXTURES_JSON 非法: {error}")) - .with_context(context) - .with_header("x-error-phase", "fixture_parse") - })?; - - let Some(map) = fixtures.as_object() else { - return Ok(None); - }; - if let Some(fixture) = map.get(plan.function_name.as_str()) { - return Ok(Some(fixture.clone())); - } - Ok(retired_query_transport_function_name(&plan.query_name) - .ok() - .and_then(|legacy_name| map.get(legacy_name)) - .cloned()) -} - -fn load_mutation_fixture_by_name( - config: &AppConfig, - context: &RequestContext, - function_name: &str, -) -> Result, WebError> { - if !config.allow_dev_fixtures { - return Ok(None); - } - - let Some(raw) = config.mutation_fixtures_json.as_deref() else { - return Ok(None); - }; - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Ok(None); - } - - let fixtures: Value = serde_json::from_str(trimmed).map_err(|error| { - WebError::internal(format!("MNOTE_WEB_MUTATION_FIXTURES_JSON 非法: {error}")) - .with_context(context) - .with_header("x-error-phase", "fixture_parse") - })?; - - Ok(fixtures - .as_object() - .and_then(|map| map.get(function_name)) - .cloned()) -} - -fn load_mutation_fixture( - config: &AppConfig, - context: &RequestContext, - plan: &RuntimeCommandExecutionPlan, -) -> Result, WebError> { - if let Some(fixture) = - load_mutation_fixture_by_name(config, context, plan.function_name.as_str())? - { - return Ok(Some(fixture)); - } - if let Some(legacy_name) = resource_lifecycle_transport_function_name(plan) { - if let Some(fixture) = load_mutation_fixture_by_name(config, context, legacy_name)? { - return Ok(Some(fixture)); - } - } - retired_command_transport_function_name(&plan.command_name) - .ok() - .map(|legacy_name| load_mutation_fixture_by_name(config, context, legacy_name)) - .transpose() - .map(|fixture| fixture.flatten()) -} - -fn resource_lifecycle_transport_function_name( - plan: &RuntimeCommandExecutionPlan, -) -> Option<&'static str> { - let action = match plan.command_name.as_str() { - "tree.resource.archive" => "archive", - "tree.resource.restore" => "restore", - "tree.resource.purge" => "purge", - "tree.resource.rename" => "rename", - _ => return None, - }; - let resource_kind = plan - .args_json - .get("resourceLifecyclePlan") - .and_then(|value| value.get("resourceKind")) - .or_else(|| plan.args_json.get("resourceKind")) - .and_then(Value::as_str)?; - match (action, resource_kind) { - ("archive" | "restore" | "rename", "file" | "media") => Some("mediaAssets:patchById"), - ("purge", "file" | "media") => Some("mediaAssets:purgeById"), - ("archive", "mindmap") => Some("mindmaps:softDelete"), - ("restore", "mindmap") => Some("mindmaps:restore"), - ("purge", "mindmap") => Some("mindmaps:purge"), - ("archive", "table") => Some("tables:remove"), - ("restore", "table") => Some("tables:restore"), - ("purge", "table") => Some("tables:purge"), - ("rename", "table") => Some("tables:update"), - _ => None, - } -} - -pub async fn execute_retired_query_plan( - config: &AppConfig, - context: &RequestContext, - plan: &RuntimeQueryExecutionPlan, -) -> Result { - if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") { - return Err(WebError::bad_request_code( - "transport_query_unsupported", - format!("mnote-web transport 暂不支持 query: {}", plan.function_name), - ) - .with_context(context) - .with_header("x-error-phase", "plan_validation")); - } - - if let Some(fixture) = load_query_fixture(config, context, plan)? { - return Ok(fixture); - } - - Err(retired_error(context, "convex_query_retired")) -} - -pub async fn execute_sidebar_dataset_query( - config: &AppConfig, - context: &RequestContext, - plan: &RuntimeQueryExecutionPlan, -) -> Result { - execute_retired_query_plan(config, context, plan).await -} - -pub async fn execute_retired_query_by_name( - config: &AppConfig, - context: &RequestContext, - function_name: &str, - args: Value, - workspace_id: Option<&str>, - error_phase: &'static str, -) -> Result { - let plan = RuntimeQueryExecutionPlan { - query_name: function_name.to_string(), - function_name: function_name.to_string(), - workspace_id: workspace_id.map(ToOwned::to_owned), - request_id: context.trace.request_id.clone(), - trace_id: context.trace.trace_id.clone(), - actor_id: context.auth.actor_id.clone(), - payload_json: args.to_string(), - args_json: args, - }; - execute_retired_query_plan(config, context, &plan) - .await - .map_err(|error| error.with_header("x-error-phase", error_phase)) -} - -pub async fn execute_retired_command_plan( - config: &AppConfig, - context: &RequestContext, - plan: &RuntimeCommandExecutionPlan, -) -> Result { - if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") { - return Err(WebError::bad_request_code( - "transport_command_unsupported", - format!( - "mnote-web transport 暂不支持 command: {}", - plan.function_name - ), - ) - .with_context(context) - .with_header("x-error-phase", "plan_validation")); - } - - if let Some(fixture) = load_mutation_fixture(config, context, plan)? { - return Ok(fixture); - } - - Err(retired_error(context, "convex_mutation_retired")) -} - -pub async fn execute_retired_mutation_by_name( - config: &AppConfig, - context: &RequestContext, - function_name: &str, - args: Value, - _workspace_id: Option<&str>, - _idempotency_key: Option<&str>, - error_phase: &'static str, -) -> Result { - if let Some(fixture) = load_mutation_fixture_by_name(config, context, function_name)? { - return Ok(fixture); - } - - let _ = args; - Err(retired_error(context, error_phase)) -} - -pub async fn persist_runtime_command_artifacts( - _config: &AppConfig, - _context: &RequestContext, - _artifacts: &RuntimeCommandArtifactPlan, -) -> Result<(), WebError> { - // Convex 已退役。Rust 侧仍会把 artifact plan 返回给调用方和 realtime - // consumer;这里保持 no-op,避免兼容路径因为历史持久化层退役而失败。 - Ok(()) -} - -pub async fn execute_retired_command_plan_with_artifacts( - config: &AppConfig, - context: &RequestContext, - runtime_context: &bridge_runtime::RuntimeBridgeContextWire, - command: &bridge_runtime::RuntimeCommandEnvelopeWire, - plan: &RuntimeCommandExecutionPlan, -) -> Result { - let result = execute_retired_command_plan(config, context, plan).await?; - let now = OffsetDateTime::now_utc() - .format(&Rfc3339) - .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into()); - let artifacts = - build_runtime_command_artifact_plan(runtime_context, command, plan, &result, &now); - let artifact_error = if let Some(artifacts) = artifacts.as_ref() { - persist_runtime_command_artifacts(config, context, artifacts) - .await - .err() - .map(|error| error.message().to_string()) - } else { - None - }; - Ok(RetiredCloudCommandExecution { - result, - artifacts, - artifact_error, - }) -} diff --git a/rust/crates/mnote-web/src/transport/local_only.rs b/rust/crates/mnote-web/src/transport/local_only.rs new file mode 100644 index 00000000..ec7341c4 --- /dev/null +++ b/rust/crates/mnote-web/src/transport/local_only.rs @@ -0,0 +1,12 @@ +use crate::context::RequestContext; +use crate::error::WebError; + +/// local-first only:非 local_folder 请求统一拒绝(无 cloud/兼容执行链)。 +pub fn local_required(context: &RequestContext, phase: &'static str) -> WebError { + WebError::bad_request_code( + "local_required", + "仅支持 local_folder(sourceKind=local_folder + rootUri)", + ) + .with_context(context) + .with_header("x-error-phase", phase) +} diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 6e411012..4e86d04f 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -1164,7 +1164,7 @@ fn apply_ok_feedback( apply_feedback_result(setter, result.map(|_| ok_message)); } -fn open_hermes_page_ai_drawer() { +fn open_page_ai_drawer() { let Some(win) = window() else { return; }; @@ -1214,7 +1214,7 @@ fn request_ai_edit_bridge( set_ai_bridge_message, set_command_feedback, ); - open_hermes_page_ai_drawer(); + open_page_ai_drawer(); } fn normalize_layout_density(value: Option) -> String { diff --git a/scripts/lib/control-plane-dev-seed.js b/scripts/lib/control-plane-dev-seed.js index 8ab3f030..dd829d95 100644 --- a/scripts/lib/control-plane-dev-seed.js +++ b/scripts/lib/control-plane-dev-seed.js @@ -78,8 +78,8 @@ async function seedAiRuntime(requestContext, baseUrl, options) { session_id: options.sessionId, run_id: options.runId, title: options.title, - profile: options.profile || "reasonix", - acp_runtime: options.acpRuntime || options.profile || "reasonix", + profile: options.profile || "pi", + acp_runtime: options.acpRuntime || options.profile || "pi", trace_id: options.traceId, status: options.status || "running", runtime_json: options.runtimeJson || {}, diff --git a/scripts/task-page-tree-expand-persist-regression.js b/scripts/task-page-tree-expand-persist-regression.js index c2877037..5713581f 100644 --- a/scripts/task-page-tree-expand-persist-regression.js +++ b/scripts/task-page-tree-expand-persist-regression.js @@ -293,7 +293,6 @@ async function main() { MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", RUST_LOG: process.env.RUST_LOG || "warn", }; const child = spawn(BINARY, [], { diff --git a/scripts/task114-rust-web-gateway-entry-smoke.js b/scripts/task114-rust-web-gateway-entry-smoke.js index de0312be..fd115b15 100644 --- a/scripts/task114-rust-web-gateway-entry-smoke.js +++ b/scripts/task114-rust-web-gateway-entry-smoke.js @@ -64,8 +64,6 @@ function startGateway(port) { ...buildDocumentFixtureEnv(), MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", - MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100", - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1", }; return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { cwd: "/mnt/Data1T/mnote/rust", diff --git a/scripts/task159-auth-entry-smoke.js b/scripts/task159-auth-entry-smoke.js index 4610e26d..e1f25be5 100644 --- a/scripts/task159-auth-entry-smoke.js +++ b/scripts/task159-auth-entry-smoke.js @@ -15,8 +15,6 @@ function buildFixtureEnv(port) { MNOTE_WEB_ALLOW_DEV_FIXTURES: "1", MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", - MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100", - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1", MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({ "documents:getMeta": { id: "doc_auth", diff --git a/scripts/task164-desktop-hot-local-folder-main-entry-smoke.js b/scripts/task164-desktop-hot-local-folder-main-entry-smoke.js index 66bb9087..d0808304 100644 --- a/scripts/task164-desktop-hot-local-folder-main-entry-smoke.js +++ b/scripts/task164-desktop-hot-local-folder-main-entry-smoke.js @@ -124,7 +124,7 @@ async function main() { assert.equal(recentLocalRoots[0], fileUrl(root), "打开本地文件夹后应记录最近 rootUri"); const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS }); - assert(!bodyText.includes("legacy_next_compat_disabled"), "本地文件夹主入口不能落入 legacy Next fallback"); + assert(!bodyText.includes("legacy_next_compat"), "本地文件夹主入口不能出现已退役的 legacy Next 文案"); const readmeRow = page.locator('.tree-row[data-row-id="local:markdown:README.md"]'); const docsRow = page.locator('.tree-row[data-row-id="local:folder:docs"]'); await readmeRow.waitFor({ @@ -206,7 +206,7 @@ async function main() { await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const finalUrl = new URL(page.url()); assert( - finalUrl.pathname === "/" && !page.url().includes("legacy_next_compat_disabled"), + finalUrl.pathname === "/" && !page.url().includes("legacy_next_compat"), "回到主入口后应仍由 Rust Web 主壳承载", ); diff --git a/scripts/task165-rust-web-dual-pane-smoke.js b/scripts/task165-rust-web-dual-pane-smoke.js index 48028f50..4303b240 100644 --- a/scripts/task165-rust-web-dual-pane-smoke.js +++ b/scripts/task165-rust-web-dual-pane-smoke.js @@ -18,8 +18,6 @@ function buildFixtureEnv(port) { MNOTE_WEB_ALLOW_DEV_FIXTURES: "1", MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", - MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100", - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1", MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({ "documents:getMeta": { id: "doc_1", diff --git a/scripts/task166-local-first-managed-workspace-no-convex-smoke.js b/scripts/task166-local-first-managed-workspace-no-convex-smoke.js index 9c8cf284..0097d779 100644 --- a/scripts/task166-local-first-managed-workspace-no-convex-smoke.js +++ b/scripts/task166-local-first-managed-workspace-no-convex-smoke.js @@ -82,7 +82,6 @@ async function main() { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, }, stdio: ["ignore", "pipe", "pipe"], diff --git a/scripts/task167-local-markdown-title-body-options-no-convex-smoke.js b/scripts/task167-local-markdown-title-body-options-no-convex-smoke.js index 79469ba5..4e5cbd90 100644 --- a/scripts/task167-local-markdown-title-body-options-no-convex-smoke.js +++ b/scripts/task167-local-markdown-title-body-options-no-convex-smoke.js @@ -99,7 +99,6 @@ async function main() { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, }, stdio: ["ignore", "pipe", "pipe"], diff --git a/scripts/task489-auth-profile-access-ui-smoke.js b/scripts/task489-auth-profile-access-ui-smoke.js index ed833d12..55a522b0 100644 --- a/scripts/task489-auth-profile-access-ui-smoke.js +++ b/scripts/task489-auth-profile-access-ui-smoke.js @@ -31,8 +31,6 @@ function startGateway(port) { MNOTE_WEB_ALLOW_DEV_FIXTURES: "1", MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1", - MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100", }, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/scripts/task492-sidebar-starred-shortcuts-smoke.js b/scripts/task492-sidebar-starred-shortcuts-smoke.js index 350cb653..c49954a6 100644 --- a/scripts/task492-sidebar-starred-shortcuts-smoke.js +++ b/scripts/task492-sidebar-starred-shortcuts-smoke.js @@ -85,7 +85,6 @@ async function main() { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", ...controlPlaneEnv, MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, }, diff --git a/scripts/task493-page-settings-sqlite-preferences-smoke.js b/scripts/task493-page-settings-sqlite-preferences-smoke.js index 42a5f212..aaf744a8 100644 --- a/scripts/task493-page-settings-sqlite-preferences-smoke.js +++ b/scripts/task493-page-settings-sqlite-preferences-smoke.js @@ -120,7 +120,6 @@ async function main() { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", MNOTE_LOCAL_ACCESS_POLICY_FILE: policyFile, }, stdio: ["ignore", "pipe", "pipe"], diff --git a/scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js b/scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js index 6d573ca4..9bb28b9a 100644 --- a/scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js +++ b/scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js @@ -85,7 +85,6 @@ async function main() { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", ...controlPlaneEnv, MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, }, diff --git a/scripts/task497-local-page-tree-filetree-open-performance-smoke.js b/scripts/task497-local-page-tree-filetree-open-performance-smoke.js index 38889963..771efc9d 100644 --- a/scripts/task497-local-page-tree-filetree-open-performance-smoke.js +++ b/scripts/task497-local-page-tree-filetree-open-performance-smoke.js @@ -161,7 +161,6 @@ async function main() { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", ...controlPlaneEnv, MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, }, diff --git a/scripts/task500-navigation-page-route-guard-smoke.js b/scripts/task500-navigation-page-route-guard-smoke.js index 6d478447..b24a23ba 100644 --- a/scripts/task500-navigation-page-route-guard-smoke.js +++ b/scripts/task500-navigation-page-route-guard-smoke.js @@ -134,7 +134,6 @@ async function main() { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", ...controlPlaneEnv, MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, }, diff --git a/scripts/task514-sidebar-dev-hot-reload-gating-smoke.js b/scripts/task514-sidebar-dev-hot-reload-gating-smoke.js index 26c87900..1411a8aa 100644 --- a/scripts/task514-sidebar-dev-hot-reload-gating-smoke.js +++ b/scripts/task514-sidebar-dev-hot-reload-gating-smoke.js @@ -46,8 +46,6 @@ function startGateway(port, devHot) { MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ALLOW_DEV_FIXTURES: "1", - MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100", - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1", }; delete env.MNOTE_WEB_DEV_HOT_RELOAD; if (devHot) env.MNOTE_WEB_DEV_HOT_RELOAD = "1"; diff --git a/scripts/task517-onlyoffice-bridge-plugin-direct-smoke.js b/scripts/task517-onlyoffice-bridge-plugin-direct-smoke.js index 68ed2e8e..a99217ee 100644 --- a/scripts/task517-onlyoffice-bridge-plugin-direct-smoke.js +++ b/scripts/task517-onlyoffice-bridge-plugin-direct-smoke.js @@ -41,8 +41,6 @@ function startGateway(port) { MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ALLOW_DEV_FIXTURES: "1", - MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100", - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1", }; const child = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { cwd: "/mnt/Data1T/mnote/rust", diff --git a/scripts/task541-page-aggregate-local-first-hard-guard-smoke.js b/scripts/task541-page-aggregate-local-first-hard-guard-smoke.js index 30d79cb6..c0285de4 100644 --- a/scripts/task541-page-aggregate-local-first-hard-guard-smoke.js +++ b/scripts/task541-page-aggregate-local-first-hard-guard-smoke.js @@ -114,7 +114,6 @@ async function main() { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, }, stdio: ["ignore", "pipe", "pipe"], diff --git a/scripts/task801-image-double-click-preview-smoke.js b/scripts/task801-image-double-click-preview-smoke.js index c3018d44..b294f175 100644 --- a/scripts/task801-image-double-click-preview-smoke.js +++ b/scripts/task801-image-double-click-preview-smoke.js @@ -117,7 +117,6 @@ async function startLocalServer(dataRoot) { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, }, stdio: ["ignore", "pipe", "pipe"],