advance 1-8 post-mvp execution batches
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user