收口 Rust Web 入口与 AI 写入链

- 将 3000 主入口继续收口到 mnote-web,补齐 /favicon.ico、/api/auth、session alias、AI run 等 Rust Web 路由边界。

- 更新登录页与 Convex Auth 代理,支持测试账号快速登录写入真实 Convex Auth cookie。

- 推进页面设置、Wolai 对齐、Phase 7 AI kernel/CLI-first 设计文档与相关 smoke 脚本。

- 更新 leptos-tiptap 生成资产、mnote-cli/bridge-runtime、前端依赖和 dev/prod 启动脚本。
This commit is contained in:
lix-2026
2026-05-06 21:44:20 +08:00
parent 98b6360595
commit e8ba12e461
86 changed files with 8872 additions and 1716 deletions
+428 -18
View File
@@ -19,6 +19,10 @@ use std::time::Duration;
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_LEGACY_UPSTREAM: &str = "x-mnote-legacy-upstream";
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
const COOKIE_CONVEX_AUTH_REFRESH_TOKEN: &str = "__convexAuthRefreshToken";
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -55,7 +59,16 @@ pub async fn gateway_health(State(state): State<AppState>) -> Response {
response
}
pub async fn auth_entry(
pub async fn favicon() -> Response {
let mut response = Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap_or_else(|_| StatusCode::NO_CONTENT.into_response());
stamp_gateway_headers(response.headers_mut(), false);
response
}
pub async fn auth_api(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
request: Request<Body>,
@@ -64,6 +77,49 @@ pub async fn auth_entry(
return legacy_next_proxy(State(state), Extension(context), request).await;
}
let body = axum::body::to_bytes(request.into_body(), 256 * 1024)
.await
.map_err(|error| WebError::bad_request(format!("读取登录请求失败: {error}")))?;
let payload: serde_json::Value = serde_json::from_slice(&body).map_err(|error| {
WebError::bad_request_code(
"auth_bad_request",
format!("登录请求不是合法 JSON: {error}"),
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
})?;
let action = payload
.get("action")
.and_then(|value| value.as_str())
.unwrap_or_default();
if action != "auth:signIn" && action != "auth:signOut" {
return Err(WebError::bad_request_code(
"auth_action_unsupported",
"Rust gateway 当前仅支持 Convex Auth 登录与登出动作。",
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
}
let convex_response = run_convex_auth_action(&state, &context, &payload).await?;
Ok(build_auth_proxy_response(&convex_response, &context))
}
pub async fn auth_entry(
State(_state): State<AppState>,
Extension(context): Extension<RequestContext>,
_request: Request<Body>,
) -> Result<Response, WebError> {
if has_real_auth_context(&context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
let content = crate::ssr::render_view(crate::ssr::pages::auth::AuthPage());
let mut response = Html(format!(
r#"<!doctype html>
@@ -90,6 +146,16 @@ pub async fn root_entry(
Extension(context): Extension<RequestContext>,
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
if !has_real_auth_context(&context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/auth")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
let workspace_id =
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
let requested_page_id = normalize_optional_id(query.page_id.as_deref());
@@ -365,6 +431,195 @@ fn normalize_optional_id(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn has_real_auth_context(context: &RequestContext) -> bool {
let actor_id = context.auth.actor_id.trim();
if !actor_id.is_empty() && actor_id != "anonymous" {
return true;
}
extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT).is_some()
|| extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some()
}
async fn run_convex_auth_action(
state: &AppState,
context: &RequestContext,
payload: &serde_json::Value,
) -> Result<serde_json::Value, WebError> {
let convex_url = state
.config()
.convex_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::service_unavailable_code(
"convex_config_missing",
"缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL,无法执行 Convex Auth。",
)
.with_context(context)
.with_header("x-error-phase", "convex_auth_url")
.with_header("x-upstream-service", "convex")
})?;
let action = payload
.get("action")
.and_then(|value| value.as_str())
.unwrap_or_default();
let mut args = payload.get("args").cloned().unwrap_or_else(|| json!({}));
if action == "auth:signIn"
&& args
.get("refreshToken")
.map(|value| !value.is_null())
.unwrap_or(false)
{
if let Some(refresh_token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_REFRESH_TOKEN)
{
args["refreshToken"] = serde_json::Value::String(refresh_token);
}
}
let request_body = json!({
"path": action,
"format": "convex_encoded_json",
"args": [args],
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.map_err(|error| {
WebError::internal(format!("Convex Auth HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "convex_auth_client")
.with_header("x-upstream-service", "convex")
})?;
let mut request = client
.post(format!("{}/api/action", convex_url.trim_end_matches('/')))
.header("Content-Type", "application/json")
.header("Convex-Client", "mnote-web")
.json(&request_body);
if let Some(token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT) {
request = request.header(header::AUTHORIZATION, format!("Bearer {token}"));
}
let response = request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"convex_auth_proxy_error",
format!("Convex Auth 请求失败: {error}"),
)
.with_context(context)
.with_header("x-error-phase", "convex_auth_action")
.with_header("x-upstream-service", "convex")
})?;
let status = response.status();
let value: serde_json::Value = response.json().await.map_err(|error| {
WebError::bad_gateway_code(
"convex_auth_response_invalid",
format!("Convex Auth 响应不是合法 JSON: {error}"),
)
.with_context(context)
.with_header("x-error-phase", "convex_auth_decode")
.with_header("x-upstream-service", "convex")
})?;
if !status.is_success() && status.as_u16() != 560 {
return Err(WebError::bad_gateway_code(
"convex_auth_upstream_error",
format!("Convex Auth 返回 HTTP {}: {}", status.as_u16(), value),
)
.with_context(context)
.with_header("x-error-phase", "convex_auth_status")
.with_header("x-upstream-service", "convex"));
}
Ok(value)
}
fn build_auth_proxy_response(
convex_response: &serde_json::Value,
context: &RequestContext,
) -> Response {
if convex_response
.get("status")
.and_then(|value| value.as_str())
!= Some("success")
{
let message = convex_response
.get("errorMessage")
.and_then(|value| value.as_str())
.unwrap_or("Convex Auth 登录失败。");
let mut response = axum::Json(json!({ "error": message })).into_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());
return response;
}
let value = convex_response
.get("value")
.cloned()
.unwrap_or_else(|| json!({}));
let tokens = value.get("tokens");
let mut response_body = value.clone();
if let Some(tokens) = tokens {
if tokens.is_null() {
response_body["tokens"] = serde_json::Value::Null;
} else if let Some(token) = tokens.get("token").and_then(|value| value.as_str()) {
response_body["tokens"] = json!({
"token": token,
"refreshToken": "dummy",
});
}
}
let mut response = axum::Json(response_body).into_response();
if let Some(tokens) = tokens {
if tokens.is_null() {
clear_auth_cookies(response.headers_mut());
} else {
set_auth_cookie_from_value(
response.headers_mut(),
COOKIE_CONVEX_AUTH_JWT,
tokens.get("token"),
);
set_auth_cookie_from_value(
response.headers_mut(),
COOKIE_CONVEX_AUTH_REFRESH_TOKEN,
tokens.get("refreshToken"),
);
expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION);
}
}
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
response
}
fn set_auth_cookie_from_value(
headers: &mut axum::http::HeaderMap,
name: &'static str,
value: Option<&serde_json::Value>,
) {
let Some(value) = value.and_then(|value| value.as_str()) else {
return;
};
let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
if let Ok(value) = HeaderValue::from_str(&cookie) {
headers.append(header::SET_COOKIE, value);
}
}
fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) {
expire_cookie(headers, COOKIE_CONVEX_AUTH_JWT);
expire_cookie(headers, COOKIE_CONVEX_AUTH_REFRESH_TOKEN);
expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION);
}
fn expire_cookie(headers: &mut axum::http::HeaderMap, name: &'static str) {
let cookie = format!("{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
if let Ok(value) = HeaderValue::from_str(&cookie) {
headers.append(header::SET_COOKIE, value);
}
}
fn choose_root_entry_active_page_id(
requested_page_id: Option<&str>,
recent_page_id: Option<&str>,
@@ -466,6 +721,14 @@ mod tests {
fn app_with_config(
legacy_next_base_url: String,
enable_legacy_next_compat: bool,
) -> axum::Router {
app_with_config_and_convex_url(legacy_next_base_url, enable_legacy_next_compat, None)
}
fn app_with_config_and_convex_url(
legacy_next_base_url: String,
enable_legacy_next_compat: bool,
convex_url: Option<String>,
) -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
@@ -477,7 +740,7 @@ mod tests {
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_url,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
@@ -488,6 +751,33 @@ mod tests {
}))
}
async fn spawn_convex_auth_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("convex auth listener");
let addr = listener.local_addr().expect("convex auth addr");
let app = axum::Router::new().route(
"/api/action",
post(|| async {
axum::Json(serde_json::json!({
"status": "success",
"value": {
"tokens": {
"token": "jwt-demo",
"refreshToken": "refresh-demo"
}
}
}))
}),
);
tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("convex auth server");
});
format!("http://{addr}")
}
async fn spawn_legacy_auth_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
@@ -598,6 +888,8 @@ mod tests {
.oneshot(
Request::builder()
.uri("/")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
@@ -674,6 +966,8 @@ mod tests {
.uri("/")
.header("cookie", "mnote_recent_page_id=page_child")
.header("x-mnote-workspace-id", "ws_demo")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
@@ -691,6 +985,57 @@ mod tests {
assert!(html.contains(r#"data-root-active-page-id="page_child""#));
}
#[tokio::test]
async fn root_entry_redirects_anonymous_viewer_to_auth() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("/auth")
);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
}
#[tokio::test]
async fn root_entry_allows_forwarded_actor_to_enter_workspace() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-mnote-shell="workspace""#));
}
#[tokio::test]
async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
@@ -720,7 +1065,77 @@ mod tests {
}
#[tokio::test]
async fn auth_entry_uses_legacy_next_login_ui_when_compat_enabled() {
async fn favicon_is_handled_by_gateway_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/favicon.ico")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
}
#[tokio::test]
async fn auth_api_sets_convex_auth_cookies_when_compat_disabled() {
let convex_url = spawn_convex_auth_upstream().await;
let response = app_with_config_and_convex_url(
"http://127.0.0.1:3100".into(),
false,
Some(convex_url),
)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","password":"MnoteE2E123!","flow":"signIn"}}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
let cookies = response.headers().get_all(header::SET_COOKIE);
let values = cookies
.iter()
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(values
.iter()
.any(|value| value.contains("__convexAuthJWT=jwt-demo")));
assert!(values
.iter()
.any(|value| value.contains("__convexAuthRefreshToken=refresh-demo")));
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["tokens"]["token"], "jwt-demo");
assert_eq!(payload["tokens"]["refreshToken"], "dummy");
}
#[tokio::test]
async fn auth_entry_uses_mnote_web_login_ui_when_compat_enabled() {
let legacy_base_url = spawn_legacy_auth_upstream().await;
let response = app_with_legacy_next_base_url(legacy_base_url)
.oneshot(
@@ -738,44 +1153,39 @@ mod tests {
.headers()
.get("x-mnote-legacy-upstream")
.and_then(|value| value.to_str().ok()),
Some("next-app-router")
None
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.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("测试账号快速登录"));
}
#[tokio::test]
async fn auth_entry_proxies_post_to_legacy_next_when_compat_enabled() {
let legacy_base_url = spawn_legacy_auth_upstream().await;
let response = app_with_legacy_next_base_url(legacy_base_url)
async fn auth_entry_redirects_authenticated_viewer_to_root() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.method("POST")
.uri("/auth")
.header("origin", "http://127.0.0.1:3000")
.header("referer", "http://127.0.0.1:3000/auth")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response
.headers()
.get("x-mnote-legacy-upstream")
.get(header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("next-app-router")
Some("/")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert_eq!(text, "auth-post-ok");
}
#[tokio::test]