Retire Convex paths and standardize local-first Pi runtime

This commit is contained in:
Agent Board
2026-07-29 01:49:53 +08:00
parent 26ff1a9c9a
commit a40fa25ae6
85 changed files with 1653 additions and 3971 deletions
+4 -4
View File
@@ -50,7 +50,7 @@ pub struct OnlyOfficeProxyPreparationInput {
pub supabase_url: Option<String>,
pub supabase_internal_url: Option<String>,
pub onlyoffice_storage_host_override: Option<String>,
pub convex_origin: Option<String>,
pub extra_origin: Option<String>,
pub supabase_anon_key: Option<String>,
}
@@ -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");
File diff suppressed because it is too large Load Diff
@@ -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,
+29 -16
View File
@@ -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,
+29 -16
View File
@@ -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,
@@ -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}}}"),
})
+2 -1
View File
@@ -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,
}
+1 -1
View File
@@ -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)
+6 -7
View File
@@ -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",
+32 -32
View File
@@ -589,7 +589,7 @@ pub fn plan_page_title(
) -> CliResult<CliJsonOutput> {
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<CliJsonOutput> {
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<Cl
pub fn plan_page_delete(ctx: &CliContext, args: &PageDeleteArgs<'_>) -> CliResult<CliJsonOutput> {
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<CliJsonOutput> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
execute_retired_cloud_transport("blocks.patch")
reject_non_local_cli_transport("blocks.patch")
}
fn execute_block_move(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult<Value> {
execute_retired_cloud_transport("blocks.move")
reject_non_local_cli_transport("blocks.move")
}
fn execute_block_embed(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult<Value> {
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<Value> {
Err(retired_cloud_transport_error(operation))
fn reject_non_local_cli_transport(operation: &str) -> CliResult<Value> {
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"),
@@ -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);
};
@@ -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) {
@@ -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') {
@@ -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,
@@ -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),
@@ -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) {
@@ -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,
@@ -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 {
+11 -49
View File
@@ -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<String>,
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<String>,
pub convex_admin_key: Option<String>,
pub allow_dev_fixtures: bool,
pub query_fixtures_json: Option<String>,
pub mutation_fixtures_json: Option<String>,
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(),
+2 -2
View File
@@ -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<Value, WebError> {
let documents = self
.documents
@@ -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-76Bearer mnpat1.* 优先于 cookiePAT 请求忽略 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-2Bearer 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) {
@@ -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::<serde_json::Value>,
"artifactError": None::<String>
}))
}
@@ -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::<serde_json::Value>,
"artifactError": None::<String>
}))
}
@@ -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::<serde_json::Value>,
"artifactError": None::<String>
}))
}
@@ -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::writeConvex 文档通过 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::<serde_json::Value>,
"artifactError": None::<String>
})
}
};
@@ -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::<serde_json::Value>,
"artifactError": None::<String>
});
merge_result_extra(&mut result, result_extra);
Ok(result)
+3 -9
View File
@@ -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<Value, WebError> {
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(),
@@ -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<Value, WebError> {
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<RetiredCloudCommandExecution, WebError> {
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<Value, WebError> {
Err(local_required(context, "command_local_required"))
}
pub fn build_tree_target(
+34 -125
View File
@@ -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<Body> {
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"));
}
}
+1 -7
View File
@@ -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(),
+29 -444
View File
@@ -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<String> {
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<Value, WebError> {
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::<Value>(&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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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::<String> {
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::<String>;
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::<String>;
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::<String>;
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]
+1 -131
View File
@@ -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(),
+1 -7
View File
@@ -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(),
File diff suppressed because it is too large Load Diff
+2 -8
View File
@@ -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()
@@ -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(),
@@ -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(),
@@ -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(),
+6 -6
View File
@@ -101,7 +101,7 @@ fn document_for_target_row(
}
pub async fn upload(Extension(context): Extension<RequestContext>) -> 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": {
+15 -33
View File
@@ -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(&params);
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::<String>,
})),
));
}
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::<String>,
})),
))
}
@@ -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(),
@@ -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(),
+5 -109
View File
@@ -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(),
+3 -9
View File
@@ -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"],
@@ -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(),
+54 -222
View File
@@ -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<AppState>,
uri: Uri,
State(_state): State<AppState>,
_uri: Uri,
Query(query): Query<OnlyOfficeForcesaveQuery>,
) -> Result<Response, WebError> {
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<String> {
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<Value>,
) -> Result<Response, WebError> {
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-firstforcesave 的 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<String>) -> 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<String>) {
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::<Uri>()
.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::<Uri>()
.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::<Uri>()
.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::<Uri>()
.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::<Uri>()
.expect("uri"),
@@ -2679,5 +2511,5 @@ mod tests {
}
let _ = fs::remove_dir_all(&root);
}
}
@@ -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(),
@@ -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(),
@@ -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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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"))
}
@@ -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<Value, WebError> {
Err(local_required(context, error_phase))
}
async fn reject_cloud_mutation(
context: &RequestContext,
error_phase: &'static str,
) -> Result<Value, WebError> {
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<String> {
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<Value> {
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<Value, WebError> {
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<Value>), 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<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<Value>), 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]
+6 -24
View File
@@ -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(),
+13 -54
View File
@@ -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<AppState>) -> 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<String> {
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<String> {
// 身份仅 control-plane session / header;不再从第三方 JWT cookie 补全。
None
}
fn encoded_cookie_value(cookie_header: &str, name: &str) -> Option<String> {
@@ -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");
}
}
@@ -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<Value, WebError> {
fetch_query_data_via_legacy_cloud(
reject_non_local_query_data(
config,
context,
Some(workspace_id),
+2 -8
View File
@@ -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(),
@@ -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",
+45 -286
View File
@@ -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_folderroot_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<String> {
@@ -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_foldersourceKind=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"
}));
}
}
@@ -168,7 +168,7 @@ fn resolve_scope(
root_uri: &str,
scope: &str,
) -> Result<TreeViewStateScope, WebError> {
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");
@@ -348,7 +348,7 @@ fn page_preference_scope(
) -> Result<PagePreferenceScope, WebError> {
let source_kind = source_kind.trim();
let source_kind = if source_kind.is_empty() {
"convex_workspace"
"local_folder"
} else {
source_kind
};
+75 -2
View File
@@ -87,17 +87,73 @@ fn actor_id(context: &RequestContext) -> String {
}
fn require_authenticated(context: &RequestContext) -> Result<String, WebError> {
// 7-76Web PATmnpat1)与密码箱分职 — 禁止用笔记 PAT 读密/管箱。
// 读密通道:浏览器 session,或 agent vault tokenmnv1.*)。
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 tokenmnv1)后调用",
)
.with_context(context));
}
let id = actor_id(context);
if id == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"密码箱需要登录会话",
));
"密码箱需要登录会话或 vault tokenmnv1",
)
.with_context(context));
}
Ok(id)
}
/// agent vault tokenauth_method=vault_token)时强制 scopesession/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<mnote_vault_core::VaultTokenClaims, WebError> {
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<Value>), 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<Value>), 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<Value>), 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<Value>), 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<Value>), 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<Value>), 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<Value>), 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<std::collections::HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
// 本机安装列表仅浏览器 session / adminWeb 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<Value>), 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"))
+69 -217
View File
@@ -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<String>) -> Result<Response, Web
.join(relative);
let bytes = std::fs::read(&path).map_err(|error| {
WebError::new(
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::BAD_REQUEST,
"pdfjs_asset_unavailable",
format!("PDF.js asset 不可用,请先运行 npm install: {error}"),
)
@@ -3084,80 +3078,51 @@ pub(crate) async fn build_page_aggregate_snapshot(
state: &AppState,
context: &RequestContext,
document_id: &str,
workspace_id: Option<&str>,
_workspace_id: Option<&str>,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Result<PageAggregate, WebError> {
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_foldersourceKind=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::<PageAggregate>(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(),
@@ -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"),
+1 -1
View File
@@ -1 +1 @@
pub mod legacy_cloud_guard;
pub mod local_only;
@@ -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<RuntimeCommandArtifactPlan>,
pub artifact_error: Option<String>,
}
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<Option<Value>, 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<Option<Value>, 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<Option<Value>, 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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<RetiredCloudCommandExecution, WebError> {
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,
})
}
@@ -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_foldersourceKind=local_folder + rootUri",
)
.with_context(context)
.with_header("x-error-phase", phase)
}
+2 -2
View File
@@ -1164,7 +1164,7 @@ fn apply_ok_feedback<E>(
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>) -> String {