advance 1-8 post-mvp execution batches

This commit is contained in:
lix-2026
2026-05-21 23:53:39 +08:00
parent 3ebcbff728
commit fdb20300e9
67 changed files with 4378 additions and 275 deletions
+117 -7
View File
@@ -41,6 +41,8 @@ const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session";
const COOKIE_MNOTE_ACTOR_ID: &str = "mnote_actor_id";
const COOKIE_MNOTE_ACTOR_TYPE: &str = "mnote_actor_type";
const COOKIE_MNOTE_ACTOR_EMAIL: &str = "mnote_actor_email";
const COOKIE_MNOTE_ACTOR_NAME: &str = "mnote_actor_name";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -121,9 +123,12 @@ pub async fn auth_api(
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
}
if action == "auth:signOut" {
return Ok(build_sign_out_response(&context));
}
let convex_response = run_convex_auth_action(&state, &context, &payload).await?;
Ok(build_auth_proxy_response(&convex_response, &context))
Ok(build_auth_proxy_response(&convex_response, &context, &payload))
}
pub async fn auth_entry(
@@ -1625,6 +1630,7 @@ async fn run_convex_auth_action(
fn build_auth_proxy_response(
convex_response: &serde_json::Value,
context: &RequestContext,
request_payload: &serde_json::Value,
) -> Response {
if convex_response
.get("status")
@@ -1639,7 +1645,7 @@ fn build_auth_proxy_response(
*response.status_mut() = StatusCode::BAD_REQUEST;
clear_auth_cookies(response.headers_mut());
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
apply_trace_response_headers(context, response.headers_mut());
return response;
}
@@ -1679,14 +1685,46 @@ fn build_auth_proxy_response(
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, &actor_id);
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, "user");
}
if let Some(email) = resolve_mnote_actor_email(&value, tokens, request_payload) {
set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_EMAIL, &email);
}
if let Some(name) = resolve_mnote_actor_name(&value, tokens, request_payload) {
set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_NAME, &name);
}
expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION);
}
}
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
apply_trace_response_headers(context, response.headers_mut());
response
}
fn build_sign_out_response(context: &RequestContext) -> Response {
let mut response = axum::Json(json!({ "ok": true, "signedOut": true })).into_response();
clear_auth_cookies(response.headers_mut());
stamp_gateway_headers(response.headers_mut(), false);
apply_trace_response_headers(context, response.headers_mut());
response
}
fn apply_trace_response_headers(context: &RequestContext, headers: &mut axum::http::HeaderMap) {
insert_response_header(headers, "x-request-id", &context.trace.request_id);
insert_response_header(headers, "x-trace-id", &context.trace.trace_id);
if let Some(workspace_id) = &context.workspace.workspace_id {
insert_response_header(headers, "x-mnote-workspace-id", workspace_id);
}
}
fn insert_response_header(headers: &mut axum::http::HeaderMap, name: &str, value: &str) {
let Ok(name) = HeaderName::from_lowercase(name.as_bytes()) else {
return;
};
let Ok(value) = HeaderValue::from_str(value) else {
return;
};
headers.insert(name, value);
}
fn resolve_mnote_actor_id(
value: &serde_json::Value,
tokens: &serde_json::Value,
@@ -1735,6 +1773,69 @@ fn extract_actor_id_from_jwt(token: &str) -> Option<String> {
})
}
fn resolve_mnote_actor_email(
value: &serde_json::Value,
tokens: &serde_json::Value,
request_payload: &serde_json::Value,
) -> Option<String> {
[
"/email",
"/user/email",
"/profile/email",
"/args/params/email",
]
.iter()
.find_map(|pointer| non_empty_json_string(value.pointer(pointer)))
.or_else(|| {
tokens
.get("token")
.and_then(Value::as_str)
.and_then(extract_email_from_jwt)
})
.or_else(|| non_empty_json_string(request_payload.pointer("/args/params/email")))
}
fn resolve_mnote_actor_name(
value: &serde_json::Value,
tokens: &serde_json::Value,
request_payload: &serde_json::Value,
) -> Option<String> {
["/name", "/user/name", "/profile/name", "/args/params/name"]
.iter()
.find_map(|pointer| non_empty_json_string(value.pointer(pointer)))
.or_else(|| {
tokens
.get("token")
.and_then(Value::as_str)
.and_then(extract_name_from_jwt)
})
.or_else(|| non_empty_json_string(request_payload.pointer("/args/params/name")))
}
fn extract_email_from_jwt(token: &str) -> Option<String> {
jwt_string_claim(token, &["email", "preferred_username"])
}
fn extract_name_from_jwt(token: &str) -> Option<String> {
jwt_string_claim(token, &["name", "username"])
}
fn jwt_string_claim(token: &str, keys: &[&str]) -> Option<String> {
let payload_segment = token.split('.').nth(1)?;
let decoded = URL_SAFE_NO_PAD.decode(payload_segment.as_bytes()).ok()?;
let payload: Value = serde_json::from_slice(&decoded).ok()?;
keys.iter()
.find_map(|key| non_empty_json_string(payload.get(key)))
}
fn non_empty_json_string(value: Option<&serde_json::Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
if let Ok(value) = HeaderValue::from_str(&cookie) {
@@ -1742,6 +1843,11 @@ fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, v
}
}
fn set_encoded_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
let encoded = URL_SAFE_NO_PAD.encode(value.as_bytes());
set_literal_cookie(headers, name, &encoded);
}
fn set_auth_cookie_from_value(
headers: &mut axum::http::HeaderMap,
name: &'static str,
@@ -1762,6 +1868,8 @@ fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) {
expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION);
expire_cookie(headers, COOKIE_MNOTE_ACTOR_ID);
expire_cookie(headers, COOKIE_MNOTE_ACTOR_TYPE);
expire_cookie(headers, COOKIE_MNOTE_ACTOR_EMAIL);
expire_cookie(headers, COOKIE_MNOTE_ACTOR_NAME);
}
fn expire_cookie(headers: &mut axum::http::HeaderMap, name: &'static str) {
@@ -2623,9 +2731,9 @@ mod tests {
assert!(html.contains("local_folder"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
assert!(html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(html.contains("当前还没有可显示的本地工作区"));
assert!(html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"disabled""#));
let _ = std::fs::remove_dir_all(&base);
@@ -2797,7 +2905,9 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-mnote-shell="auth""#));
assert!(html.contains("邮箱登录"));
assert!(html.contains("账号登录"));
assert!(html.contains("邮箱或用户名"));
assert!(!html.contains(r#"<span>"用户名"</span>"#));
assert!(html.contains("测试账号快速登录"));
}
@@ -29,6 +29,7 @@ const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
const ACP_RUNTIME_RUN_MUTATION: &str = "aiSessions:upsertRuntimeRun";
const ACP_RUNTIME_EVENT_MUTATION: &str = "aiSessions:appendRuntimeEvent";
const ACP_ABORT_NOTIFICATION_TIMEOUT_MS: u64 = 2_500;
const LOCAL_SHARE_GRANTS_JSON: &str = "/mnt/Data1T/Mnote_data/control-plane/share-grants.json";
const ENV_LOCAL_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE";
@@ -90,6 +91,7 @@ struct AcpActiveRun {
manager: Arc<crate::acp_session_manager::AcpSessionManager>,
mnote_session_id: String,
acp_session_id: String,
event_tx: broadcast::Sender<crate::acp_bridge::SseEvent>,
}
#[derive(Debug, Clone)]
@@ -1484,6 +1486,7 @@ async fn acp_stream_events(
manager: Arc::clone(&mgr),
mnote_session_id: mnote_session_id.clone(),
acp_session_id,
event_tx: event_tx.clone(),
},
);
@@ -1567,6 +1570,12 @@ async fn acp_stream_events(
{
Ok(result) => {
info!("ACP prompt completed: stop_reason={:?}", result.stop_reason);
let prompt_cancelled =
matches!(result.stop_reason, crate::acp_types::StopReason::Cancelled);
let runtime_aborted = matches!(
runtime_status_for_run(&run_id_owned).as_deref(),
Some("aborting" | "aborted")
);
let agent_audit = if audit_payload
.get("sourceKind")
.and_then(Value::as_str)
@@ -1590,7 +1599,11 @@ async fn acp_stream_events(
Value::Null
};
let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent {
event: "run.completed".into(),
event: if prompt_cancelled || runtime_aborted {
"run.aborted".into()
} else {
"run.completed".into()
},
data: json!({
"stopReason": format!("{:?}", result.stop_reason),
"agentAudit": agent_audit,
@@ -1627,15 +1640,16 @@ async fn acp_stream_events(
});
}
}
let was_aborted = matches!(
runtime_status_for_run(&run_id_owned).as_deref(),
Some("aborting" | "aborted")
);
mgr_clone.close().await;
ACP_ACTIVE_RUNS
.lock()
.expect("acp active runs")
.remove(&run_id_owned);
if !matches!(
runtime_status_for_run(&run_id_owned).as_deref(),
Some("aborting" | "aborted")
) {
if !was_aborted {
update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None);
}
});
@@ -1763,11 +1777,37 @@ pub async fn abort_run(
})),
));
};
active.manager.cancel().await.map_err(|error| {
WebError::bad_gateway_code("acp_abort_failed", format!("ACP abort failed: {error}"))
.with_context(&context)
})?;
let cancel_result = tokio::time::timeout(
Duration::from_millis(ACP_ABORT_NOTIFICATION_TIMEOUT_MS),
active.manager.cancel(),
)
.await;
let cancel_status = match cancel_result {
Ok(Ok(())) => json!({"ok": true}),
Ok(Err(error)) => {
warn!(error = ?error, run_id = %run_id, "ACP abort notification failed; marking run aborted");
json!({"ok": false, "error": error.to_string()})
}
Err(_) => {
warn!(
run_id = %run_id,
timeout_ms = ACP_ABORT_NOTIFICATION_TIMEOUT_MS,
"ACP abort notification timed out; marking run aborted"
);
json!({"ok": false, "error": "abort notification timed out"})
}
};
update_runtime_by_run_id(&run_id, "aborted", Some("abort.completed"), None);
let _ = active.event_tx.send(crate::acp_bridge::SseEvent {
event: "run.aborted".into(),
data: json!({
"reason": payload
.get("reason")
.and_then(Value::as_str)
.unwrap_or("client_abort"),
"cancel": cancel_status.clone(),
}),
});
return Ok((
StatusCode::OK,
stamp_client_headers(),
@@ -1778,6 +1818,7 @@ pub async fn abort_run(
"acpSessionId": active.acp_session_id,
"status": "aborted",
"runtime": runtime_state_for_run(&run_id).unwrap_or(Value::Null),
"cancel": cancel_status,
"events": [
{"event": "abort.started", "runId": run_id},
{"event": "abort.completed", "runId": run_id}
@@ -306,6 +306,23 @@ pub(crate) async fn execute_mnote_tool_call(
);
return Err(error);
}
if let Err(error) = ensure_tool_capability_scope(&context, &input) {
audit_push(json!({
"phase": "failed",
"traceId": trace_id,
"sessionId": input.session_id,
"runId": input.run_id,
"toolCallId": tool_call_id,
"toolName": input.tool_name,
"workspaceId": workspace_id,
"documentId": document_id,
"actorId": input.actor_id,
"status": error.status().as_u16(),
"message": error.message(),
"capabilityScope": input.capability_scope
}));
return Err(error);
}
if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
info!(
trace_id = %trace_id,
@@ -449,6 +466,74 @@ pub(crate) async fn execute_mnote_tool_call(
Ok(response_body)
}
fn ensure_tool_capability_scope(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
let required = required_capability_scope(&input.tool_name);
if required.is_empty()
|| declared_capability_scope_covers(input.capability_scope.as_ref(), &required)
{
return Ok(());
}
Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_capability_scope_forbidden",
"调用方声明的 capabilityScope 未覆盖目标 mnote tool 所需能力",
)
.with_context(context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
}
fn required_capability_scope(tool_name: &str) -> Vec<String> {
manifest::manifest()
.get("tools")
.and_then(Value::as_array)
.into_iter()
.flatten()
.find(|tool| tool.get("name").and_then(Value::as_str) == Some(tool_name))
.and_then(|tool| tool.get("capabilityScope").and_then(Value::as_array))
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(normalize_capability_scope)
.filter(|value| !value.is_empty())
.collect()
}
fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[String]) -> bool {
let Some(declared) = declared else {
// 兼容旧调用方:缺省 capabilityScope 不改变既有执行路径。
return true;
};
let declared = declared
.iter()
.map(|value| normalize_capability_scope(value))
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
required.iter().all(|scope| {
declared
.iter()
.any(|candidate| capability_scope_satisfies(candidate, scope))
})
}
fn capability_scope_satisfies(candidate: &str, required: &str) -> bool {
if candidate == required {
return true;
}
required
.strip_suffix(".read")
.map(|prefix| format!("{prefix}.write"))
.as_deref()
== Some(candidate)
}
fn normalize_capability_scope(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn is_read_tool(tool_name: &str) -> bool {
matches!(
tool_name,
@@ -964,6 +1049,46 @@ mod tests {
.collect()
}
#[tokio::test]
async fn hermes_tools_call_rejects_declared_scope_that_does_not_cover_tool() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"toolName": "mnote.page.save",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_scope",
"runId": "run_scope",
"toolCallId": "call_scope",
"traceId": "trace_scope",
"actorId": "user_1",
"capabilityScope": ["page.read"],
"dryRun": true,
"idempotencyKey": "scope-mismatch",
"args": {"content": "不会写入"}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("mnote_tool_capability_scope_forbidden")
);
}
#[tokio::test]
async fn hermes_tools_manifest_returns_first_batch_tools() {
let response = app()
@@ -3,37 +3,65 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
decode_local_id_segment, ensure_local_workspace_read_access,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_workspace_id_from_root_uri,
};
use crate::routes::snapshot_support::ProjectionSnapshot;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::response::sse::{Event as SseEvent, Sse};
use futures_util::stream;
use futures_util::StreamExt;
use serde::Deserialize;
use serde_json::{json, Value};
use std::convert::Infallible;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::path::PathBuf;
use std::pin::Pin;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast::error::RecvError;
type BoxedEventStream = Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalFolderEventsQuery {
pub root_uri: String,
pub document_id: Option<String>,
pub tree_live: Option<bool>,
}
pub async fn local_folder_events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalFolderEventsQuery>,
) -> Result<
(
HeaderMap,
Sse<impl futures_util::Stream<Item = Result<SseEvent, Infallible>>>,
),
WebError,
> {
) -> Result<(HeaderMap, Sse<BoxedEventStream>), WebError> {
let canonical_root = ensure_local_workspace_read_access(&context, &query.root_uri)
.map_err(|error| error.with_context(&context))?;
let (mut headers, stream): (HeaderMap, BoxedEventStream) = if query.tree_live.unwrap_or(false) {
build_tree_live_stream(state, context, canonical_root, query.root_uri).await?
} else {
build_document_events_stream(state, context, canonical_root, &query).await?
};
// Apply keepalive via the same type-erased stream path
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-local-folder-events-owner") {
headers.insert(name, HeaderValue::from_static("rust-web"));
}
Ok((headers, Sse::new(stream)))
}
/// Build the original document-level external-edit event stream (`ready` / `change`).
async fn build_document_events_stream(
state: AppState,
context: RequestContext,
canonical_root: PathBuf,
query: &LocalFolderEventsQuery,
) -> Result<(HeaderMap, BoxedEventStream), WebError> {
let document_relative_path = query
.document_id
.as_deref()
@@ -80,26 +108,129 @@ pub async fn local_folder_events(
}
}
},
)
.boxed();
Ok((HeaderMap::new(), stream))
}
/// Build the tree live stream: emits `snapshot` (initial) and `resync` (on watcher change)
/// with full sidebar + file tree projections.
///
/// Reuses `LocalFolderWatcherRegistry` — no second watcher created.
/// No data is written to Convex command log.
async fn build_tree_live_stream(
state: AppState,
context: RequestContext,
canonical_root: PathBuf,
root_uri: String,
) -> Result<(HeaderMap, BoxedEventStream), WebError> {
let subscription = state
.local_folder_watcher_registry()
.subscribe(&canonical_root)
.map_err(|error| WebError::internal(error).with_context(&context))?;
let workspace_id = local_workspace_id_from_root_uri(&root_uri)
.map_err(|error| error.with_context(&context))?;
// Build initial snapshot
let sidebar_snapshot = load_local_folder_page_tree_snapshot(&root_uri)
.map_err(|error| error.with_context(&context))?;
let file_tree_snapshot = load_local_folder_file_tree_snapshot(&root_uri)
.map_err(|error| error.with_context(&context))?;
let revision = local_folder_watch_revision(&root_uri)
.map_err(|error| error.with_context(&context))?;
let initial_payload = build_tree_snapshot_payload(
&root_uri,
&workspace_id,
&revision.revision,
"snapshot",
&sidebar_snapshot,
&file_tree_snapshot,
);
let mut headers = HeaderMap::new();
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-local-folder-events-owner") {
headers.insert(name, HeaderValue::from_static("rust-web"));
}
let stream = stream::unfold(
(Some(initial_payload), subscription, root_uri, workspace_id),
|(payload, mut subscription, root_uri, workspace_id)| async move {
if let Some(payload) = payload {
return Some((
Ok(stream_event("snapshot", &payload)),
(None, subscription, root_uri, workspace_id),
));
}
Ok((
headers,
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keepalive"),
),
loop {
match subscription.receiver.recv().await {
Ok(_watcher_payload) => {
// Rebuild full snapshot on any filesystem change
if let Some(resync_payload) =
rebuild_tree_resync_payload(&root_uri, &workspace_id)
{
return Some((
Ok(stream_event("resync", &resync_payload)),
(None, subscription, root_uri, workspace_id),
));
}
// Snapshot load failed — continue waiting for next change
continue;
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => return None,
}
}
},
)
.boxed();
Ok((HeaderMap::new(), stream))
}
fn rebuild_tree_resync_payload(root_uri: &str, workspace_id: &str) -> Option<Value> {
let revision = local_folder_watch_revision(root_uri).ok()?;
let sidebar_snapshot = load_local_folder_page_tree_snapshot(root_uri).ok()?;
let file_tree_snapshot = load_local_folder_file_tree_snapshot(root_uri).ok()?;
Some(build_tree_snapshot_payload(
root_uri,
workspace_id,
&revision.revision,
"resync",
&sidebar_snapshot,
&file_tree_snapshot,
))
}
fn build_tree_snapshot_payload(
root_uri: &str,
workspace_id: &str,
revision: &str,
kind: &str,
sidebar_snapshot: &ProjectionSnapshot,
file_tree_snapshot: &ProjectionSnapshot,
) -> Value {
let dataset = json!({
"kernel_sidebar_projection": sidebar_snapshot.projection,
"kernelSidebarProjection": sidebar_snapshot.projection,
"kernel_file_tree_projection": file_tree_snapshot.projection,
"kernelFileTreeProjection": file_tree_snapshot.projection,
});
json!({
"kind": kind,
"revision": revision,
"stream": "workspace",
"projection": "sidebar_tree",
"scope": "workspace",
"sourceKind": "local_folder",
"rootUri": root_uri,
"workspaceId": workspace_id,
"data": {
"dataset": dataset,
"tree": sidebar_snapshot.projection,
},
})
}
fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<String> {
let trimmed = document_id.trim();
let encoded = trimmed.strip_prefix("local-md:")?;
@@ -132,6 +263,101 @@ fn stream_event(event_name: &str, payload: &Value) -> SseEvent {
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{build_app, AppConfig, AppState};
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
use axum::body::Body;
use axum::http::Request;
use tower::util::ServiceExt;
fn test_root(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!(
"mnote-local-folder-events-{name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp root");
root
}
fn app_with_local_workspace(root: &std::path::Path) -> axum::Router {
let root_uri = format!("file://{}", root.display());
initialize_local_workspace_for_actor("dev-user", &root_uri)
.expect("init local workspace");
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,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
/// Build a root_uri query-parameter-safe by percent-encoding (no external crate).
fn encoded_root_uri(raw: &str) -> String {
raw.replace('%', "%25")
.replace(':', "%3A")
.replace('/', "%2F")
}
#[tokio::test]
async fn tree_live_headers_include_mnote_web_owner() {
let root = test_root("tree-live-headers");
std::fs::write(root.join("test.md"), "# Test\n").expect("write test");
let root_uri = format!("file://{}", root.display());
let encoded = encoded_root_uri(&root_uri);
let app = app_with_local_workspace(&root);
let response = app
.oneshot(
Request::builder()
.uri(format!(
"/api/local-folder/events?rootUri={encoded}&treeLive=true"
))
.header("x-mnote-actor-id", "dev-user")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), 200);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|v| v.to_str().ok()),
Some("mnote-web"),
"response should have x-mnote-web-owner header"
);
assert_eq!(
response
.headers()
.get("x-mnote-local-folder-events-owner")
.and_then(|v| v.to_str().ok()),
Some("rust-web"),
"response should have x-mnote-local-folder-events-owner header"
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn local_markdown_document_id_maps_to_relative_path() {
@@ -140,4 +366,52 @@ mod tests {
Some("docs/README.md")
);
}
#[test]
fn build_tree_snapshot_payload_has_required_fields() {
let sidebar_projection = json!({
"projection": "page_tree",
"sourceKind": "local_folder",
"rootUri": "file:///test",
"watchRevision": "abc123",
"items": [],
});
let file_tree_projection = json!({
"projection": "file_tree",
"sourceKind": "local_folder",
"rootUri": "file:///test",
"watchRevision": "abc123",
"items": [],
});
let sidebar = ProjectionSnapshot {
dataset: json!({}),
projection: sidebar_projection,
};
let file_tree = ProjectionSnapshot {
dataset: json!({}),
projection: file_tree_projection,
};
let payload = build_tree_snapshot_payload(
"file:///test",
"local:test_workspace",
"rev_1",
"snapshot",
&sidebar,
&file_tree,
);
assert_eq!(payload["kind"], "snapshot");
assert_eq!(payload["stream"], "workspace");
assert_eq!(payload["projection"], "sidebar_tree");
assert_eq!(payload["sourceKind"], "local_folder");
assert_eq!(payload["rootUri"], "file:///test");
assert_eq!(payload["workspaceId"], "local:test_workspace");
assert_eq!(payload["revision"], "rev_1");
assert!(payload["data"]["dataset"]["kernel_sidebar_projection"].is_object());
assert!(payload["data"]["dataset"]["kernel_file_tree_projection"].is_object());
assert!(payload["data"]["dataset"]["kernelSidebarProjection"].is_object());
assert!(payload["data"]["dataset"]["kernelFileTreeProjection"].is_object());
assert!(payload["data"]["tree"].is_object());
}
}
+62 -2
View File
@@ -4,9 +4,13 @@ use axum::extract::{Extension, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::Serialize;
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
const COOKIE_ACTOR_EMAIL: &str = "mnote_actor_email";
const COOKIE_ACTOR_NAME: &str = "mnote_actor_name";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -63,6 +67,18 @@ pub async fn refresh_session(
fn build_session_response(state: &AppState, context: RequestContext) -> SessionResponse {
let actor_id = context.auth.actor_id.trim();
let has_forwarded_actor = !actor_id.is_empty() && actor_id != "anonymous";
let actor_email = context
.auth
.cookie_header
.as_deref()
.and_then(|cookies| encoded_cookie_value(cookies, COOKIE_ACTOR_EMAIL))
.or_else(|| jwt_cookie_claim(&context, &["email", "preferred_username"]));
let actor_name = context
.auth
.cookie_header
.as_deref()
.and_then(|cookies| encoded_cookie_value(cookies, COOKIE_ACTOR_NAME))
.or_else(|| jwt_cookie_claim(&context, &["name", "username"]));
let user_id = if has_forwarded_actor {
actor_id.to_string()
} else {
@@ -79,12 +95,12 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
owner: "mnote-web",
user_id,
email: if has_forwarded_actor {
String::new()
actor_email.unwrap_or_default()
} else {
state.config().dev_user_email.clone()
},
name: if has_forwarded_actor {
actor_id.to_string()
actor_name.unwrap_or_else(|| actor_id.to_string())
} else {
state.config().dev_user_name.clone()
},
@@ -99,6 +115,50 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
}
}
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 encoded_cookie_value(cookie_header: &str, name: &str) -> Option<String> {
let encoded = raw_cookie_value(cookie_header, name)?;
let decoded = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).ok()?;
String::from_utf8(decoded)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn raw_cookie_value(cookie_header: &str, name: &str) -> Option<String> {
cookie_header.split(';').find_map(|part| {
let (cookie_name, cookie_value) = part.trim().split_once('=')?;
if cookie_name.trim() == name {
let value = cookie_value.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
} else {
None
}
})
}
fn owner_json<T>(payload: Json<T>) -> Response
where
T: Serialize,