feat: advance local-first workspace checklist

- add admin access-policy UI and local access control surfaces

- add local markdown conflict resolution UI and smoke coverage

- add ACP local agent changed-files audit scaffold and read-only write guard

- document current P0-P2 checklist progress and verification evidence
This commit is contained in:
lix-2026
2026-05-19 08:07:17 +08:00
parent a2cb1338c8
commit 68d321e297
36 changed files with 8643 additions and 235 deletions
+52
View File
@@ -17,6 +17,8 @@ const HEADER_SESSION_ID: &str = "x-mnote-session-id";
const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
const COOKIE_ACTOR_ID: &str = "mnote_actor_id";
const COOKIE_ACTOR_TYPE: &str = "mnote_actor_type";
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -79,8 +81,10 @@ impl RequestContext {
authorization: header_value(headers, axum::http::header::AUTHORIZATION.as_str()),
cookie_header: header_value(headers, axum::http::header::COOKIE.as_str()),
actor_id: header_value(headers, HEADER_ACTOR_ID)
.or_else(|| cookie_value(headers, COOKIE_ACTOR_ID))
.unwrap_or_else(|| "anonymous".into()),
actor_type: header_value(headers, HEADER_ACTOR_TYPE)
.or_else(|| cookie_value(headers, COOKIE_ACTOR_TYPE))
.unwrap_or_else(|| "anonymous".into()),
session_id: header_value(headers, HEADER_SESSION_ID),
},
@@ -106,6 +110,10 @@ impl RequestContext {
if let Some(workspace_id) = &self.workspace.workspace_id {
insert_header(headers, HEADER_WORKSPACE_ID, workspace_id);
}
if self.auth.actor_id.trim() != "anonymous" && !self.auth.actor_id.trim().is_empty() {
append_cookie(headers, COOKIE_ACTOR_ID, self.auth.actor_id.trim());
append_cookie(headers, COOKIE_ACTOR_TYPE, self.auth.actor_type.trim());
}
}
}
@@ -141,6 +149,14 @@ fn insert_header(headers: &mut HeaderMap, key: &str, value: &str) {
headers.insert(name, value);
}
fn append_cookie(headers: &mut HeaderMap, name: &str, value: &str) {
let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
let Ok(header_value) = HeaderValue::from_str(&cookie) else {
return;
};
headers.append(axum::http::header::SET_COOKIE, header_value);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -164,4 +180,40 @@ mod tests {
assert_eq!(context.workspace.workspace_id.as_deref(), Some("ws_demo"));
assert_eq!(context.auth.actor_id, "user_demo");
}
#[test]
fn request_context_falls_back_to_actor_cookies() {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::COOKIE,
HeaderValue::from_static("mnote_actor_id=user_cookie; mnote_actor_type=user"),
);
let context = RequestContext::from_http_parts(
&Method::GET,
&"/".parse::<Uri>().expect("uri"),
&headers,
);
assert_eq!(context.auth.actor_id, "user_cookie");
assert_eq!(context.auth.actor_type, "user");
}
}
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
let cookie_header = headers
.get(axum::http::header::COOKIE)
.and_then(|value| value.to_str().ok())?;
for part in cookie_header.split(';') {
let Some((cookie_name, cookie_value)) = part.trim().split_once('=') else {
continue;
};
if cookie_name.trim() == name {
let value = cookie_value.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}