chore: land tree view-state, vault, Pi module split, and repo hygiene

Persist PageTree expand state via control-plane view-state and align
chevron/DOM with restored expansion; keep Sidex-style shallow page-tree
scan and drop the unused recursive scanner that only added cargo noise.

Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi
into a module package, and retire Hermes/ACP/OpenHub recycle + root
harness evidence from the index while gitignoring recycle and local
diag dumps.

Archive superseded design/bugs docs under old/, point architecture at
ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor
regressions so the working tree can stay clean.
This commit is contained in:
Agent Board
2026-07-21 05:13:05 +08:00
parent 6f9c7d3b58
commit b798f628ee
264 changed files with 17480 additions and 17314 deletions
+551 -21
View File
@@ -4,9 +4,10 @@ use crate::error::WebError;
use crate::provider_identity_sync::sync_provider_identities;
use crate::routes::local_folder_source::{
create_default_local_workspace_for_actor, ensure_local_workspace_read_access_with_state,
is_local_access_policy_admin_context, load_local_folder_file_tree_children_snapshot,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_scope_snapshot,
load_local_folder_page_tree_snapshot, load_local_trash_entries,
is_local_access_policy_admin_actor, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_scope_snapshot_with_reveal,
load_local_folder_page_tree_snapshot_with_reveal, load_local_trash_entries,
};
use crate::routes::snapshot_support::load_sidebar_dataset;
use crate::routes::web_shell::{
@@ -15,8 +16,8 @@ use crate::routes::web_shell::{
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
render_document_title_controller_script, render_editor_island_adapter_script,
render_editor_runtime_preload_links, render_local_file_tree_html,
render_local_file_tree_html_scoped, render_local_sidebar_tree_html,
render_local_sidebar_tree_html_from_snapshot,
render_local_file_tree_html_scoped, render_local_file_tree_pending_shell_html,
render_local_sidebar_tree_html, render_local_sidebar_tree_html_from_snapshot,
};
use crate::transport::legacy_cloud_guard::execute_retired_mutation_by_name;
use crate::workspace_shell::{
@@ -188,7 +189,7 @@ pub async fn admin_access_policy_entry(
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
if !is_local_access_policy_admin_context(&context) {
if !current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_access_policy_admin_required",
@@ -240,7 +241,7 @@ pub async fn admin_ai_entry(
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
if !is_local_access_policy_admin_context(&context) {
if !current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_required",
@@ -267,7 +268,7 @@ pub async fn settings_entry(
ai_management_response(
&state,
&context,
is_local_access_policy_admin_context(&context),
current_actor_is_local_admin(&state, &context),
)
}
@@ -410,10 +411,15 @@ pub async fn root_entry(
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
// Reveal only the active page path; keep SSR PageTree shallow (Sidex-aligned).
let page_tree_snapshot = if let Some(scope) = file_tree_scope {
load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
load_local_folder_page_tree_scope_snapshot_with_reveal(
root_uri,
scope,
requested_page_id.as_deref(),
)?
} else {
load_local_folder_page_tree_snapshot(root_uri)?
load_local_folder_page_tree_snapshot_with_reveal(root_uri, requested_page_id.as_deref())?
};
let workspace_id = page_tree_snapshot
.dataset
@@ -452,12 +458,19 @@ pub async fn root_entry(
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let file_tree_html = render_local_file_tree_html_scoped(
root_uri,
selected_active_page_id.as_deref(),
restore_focus_row_id,
file_tree_scope,
)?;
// Shell-first: when landing on PageTree (default), do not block home SSR on FileTree scan.
// treeView=filetree (or restore focus into file rows) still needs synchronous FileTree HTML.
let needs_sync_file_tree = requests_filetree_first || restore_focus_row_id.is_some();
let file_tree_html = if needs_sync_file_tree {
render_local_file_tree_html_scoped(
root_uri,
selected_active_page_id.as_deref(),
restore_focus_row_id,
file_tree_scope,
)?
} else {
render_local_file_tree_pending_shell_html()
};
(
workspace_id,
workspace_projection,
@@ -480,7 +493,8 @@ pub async fn root_entry(
WebError::internal("默认本地工作区初始化未返回 rootUri").with_context(&context)
})?
.to_string();
let snapshot = load_local_folder_page_tree_snapshot(&root_uri)?;
let snapshot =
load_local_folder_page_tree_snapshot_with_reveal(&root_uri, requested_page_id.as_deref())?;
let workspace_id = snapshot
.dataset
.get("workspace")
@@ -511,8 +525,12 @@ pub async fn root_entry(
&snapshot,
selected_active_page_id.as_deref(),
);
let file_tree_html =
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?;
// Local-first landing also prefers shell-first FileTree (hydrate after paint).
let file_tree_html = if requests_filetree_first {
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?
} else {
render_local_file_tree_pending_shell_html()
};
(
workspace_id,
workspace_projection,
@@ -593,7 +611,11 @@ pub async fn root_entry(
.active_page_title
.clone()
.unwrap_or_default();
let show_admin_access_policy = is_local_access_policy_admin_context(&context);
let breadcrumb_html = crate::workspace_shell::render_page_breadcrumb_html(
&workspace_projection,
Some(active_page_id.as_str()),
);
let show_admin_access_policy = current_actor_is_local_admin(&state, &context);
let navigation_notice_html = render_navigation_guard_notice(&query);
let navigation_html = if active_page_id.trim().is_empty() {
if active_source_kind.as_deref() == Some("local_folder") {
@@ -618,6 +640,10 @@ pub async fn root_entry(
} else {
None
};
let vault_nav_href = vault_nav_href_for_context(
active_source_kind.as_deref(),
active_root_uri.as_deref(),
);
let render_workspace_entry = || {
crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::home::HomePage
@@ -625,11 +651,13 @@ pub async fn root_entry(
workspace_name={workspace_name.clone()}
workspace_id={workspace_id.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
breadcrumb_html={breadcrumb_html.clone()}
active_page_id={active_page_id.clone()}
active_page_title={active_page_title.clone()}
navigation_html={navigation_html.clone().unwrap_or_default()}
show_admin_access_policy={show_admin_access_policy}
enable_tree_live={active_source_kind.as_deref() == Some("local_folder")}
vault_nav_href={vault_nav_href.clone()}
/>
})
};
@@ -708,6 +736,7 @@ document.body.appendChild(s);
page_subtree_json={page_subtree_json}
show_admin_access_policy={show_admin_access_policy}
enable_tree_live={true}
vault_nav_href={vault_nav_href.clone()}
/>
});
let body_extra = format!(
@@ -1113,6 +1142,304 @@ fn query_escape(value: &str) -> String {
.collect()
}
/// SSR vault nav href with sourceKind+rootUri so first click keeps local_folder context.
pub(crate) fn vault_nav_href_for_context(source_kind: Option<&str>, root_uri: Option<&str>) -> String {
let root_uri = root_uri.map(str::trim).filter(|value| !value.is_empty());
let source_kind = source_kind
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| root_uri.map(|_| "local_folder"));
match (source_kind, root_uri) {
(Some(source_kind), Some(root_uri)) => format!(
"/vault?sourceKind={}&rootUri={}",
query_escape(source_kind),
query_escape(root_uri)
),
(Some(source_kind), None) => {
format!("/vault?sourceKind={}", query_escape(source_kind))
}
(None, Some(root_uri)) => format!(
"/vault?sourceKind=local_folder&rootUri={}",
query_escape(root_uri)
),
(None, None) => "/vault".to_string(),
}
}
/// GET /files → permanent product surface is /vault (left-rail password vault).
pub async fn files_redirect_to_vault(
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
let mut location = String::from("/vault");
let mut parts: Vec<String> = Vec::new();
if let Some(root_uri) = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(format!("rootUri={}", query_escape(root_uri)));
}
if let Some(source_kind) = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(format!("sourceKind={}", query_escape(source_kind)));
} else if !parts.is_empty() {
parts.push("sourceKind=local_folder".to_string());
}
if let Some(workspace_id) = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(format!("workspaceId={}", query_escape(workspace_id)));
}
if !parts.is_empty() {
location.push('?');
location.push_str(&parts.join("&"));
}
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, location)
.body(Body::empty())
.map_err(|error| WebError::internal(format!("/files 跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
Ok(response)
}
/// GET /vault — dedicated password vault workbench (local_folder only for P0).
pub async fn vault_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
if !crate::routes::vault::vault_feature_enabled() {
return Err(crate::routes::vault::vault_disabled_error().with_context(&context));
}
if !has_real_auth_context(&state, &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 source_kind = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let root_uri = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
// Product path: local_folder workspace with rootUri.
if source_kind == Some("local_folder") || root_uri.is_some() {
let root_uri = root_uri.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 workspace_root =
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
// Best-effort ensure vault dirs so first paint has a writable layout.
let _ = crate::routes::vault_store::ensure_vault_directories(&workspace_root);
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_pending_shell_html();
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
"documents": [],
});
attach_sidebar_shortcuts_to_dataset(
&state,
&context,
&workspace_id,
&mut workspace_dataset,
);
let workspace_projection =
build_workspace_shell_projection(&workspace_dataset, &workspace_id, None, "我的空间");
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
None,
None,
);
let bootstrap = crate::routes::vault::bootstrap_list_json(
&workspace_root,
crate::routes::vault_store::VaultItemStatus::Active,
)
.unwrap_or_else(|_| {
json!({
"schema": "mnote.vault.list.v1",
"status": "active",
"revision": 0,
"updatedAt": "",
"items": [],
})
});
let vault_workbench_html =
render_vault_workbench_html(&workspace_id, root_uri, &bootstrap);
let vault_nav_href = vault_nav_href_for_context(Some("local_folder"), Some(root_uri));
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::layout::PageLayout
current_nav="vault"
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={"我的空间".to_string()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"密码箱".to_string()}
enable_tree_live={false}
vault_nav_href={vault_nav_href.clone()}
>
<div inner_html={vault_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>密码箱</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-source-kind="local_folder" data-mnote-root-uri="{}" data-mnote-page="vault">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
escape_html(root_uri),
content,
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
return Ok(response);
}
// Bare /vault without rootUri: friendly local-first shell (no cloud workspace bootstrap).
// Avoid resolve_root_workspace_id → retired Convex ensureDefaultWorkspace → 503.
let default_workspace_name = default_workspace_name_for_context(&state, &context);
let workspace_id = normalize_optional_id(query.workspace_id.as_deref())
.or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref()))
.map(ToOwned::to_owned)
.unwrap_or_else(|| "local-folder".to_string());
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
"documents": [],
});
attach_sidebar_shortcuts_to_dataset(
&state,
&context,
&workspace_id,
&mut workspace_dataset,
);
let workspace_projection = build_workspace_shell_projection(
&workspace_dataset,
&workspace_id,
None,
&default_workspace_name,
);
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(""),
Some(""),
None,
None,
);
let empty_bootstrap = json!({
"schema": "mnote.vault.list.v1",
"status": "active",
"revision": 0,
"updatedAt": "",
"items": [],
"needsLocalFolder": true,
});
let vault_workbench_html =
render_vault_workbench_html(&workspace_id, "", &empty_bootstrap);
let workspace_name = workspace_projection.workspace_name.clone();
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::layout::PageLayout
current_nav="vault"
sidebar_tree_html={String::new()}
workspace_name={workspace_name.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"密码箱".to_string()}
enable_tree_live={false}
>
<div inner_html={vault_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>密码箱</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-page="vault" data-mnote-vault-needs-folder="1">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
content,
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
Ok(response)
}
fn render_vault_workbench_html(workspace_id: &str, root_uri: &str, bootstrap: &Value) -> String {
let bootstrap_raw = bootstrap.to_string();
let bootstrap_json = escape_script_json(&bootstrap_raw);
let runtime_src = crate::routes::web_shell::mnote_browser_runtime_src("vault-workbench-runtime.js");
format!(
r#"<section class="mnote-vault-workbench" data-testid="mnote-vault-workbench" data-workspace-id="{workspace_id}" data-root-uri="{root_uri_esc}" data-status="active">
<header class="mnote-vault-header">
<div class="mnote-vault-header-main">
<h1>密码箱</h1>
<p class="mnote-vault-status" data-vault-status role="status" aria-live="polite"></p>
</div>
<div class="mnote-vault-header-actions">
<button type="button" data-vault-create data-testid="vault-create">新建</button>
<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">密文簿</button>
<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />
<div class="mnote-vault-tabs" role="tablist">
<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>
<button type="button" role="tab" data-vault-tab="deleted" aria-selected="false">已删除</button>
</div>
</div>
</header>
<div class="mnote-vault-body">
<aside class="mnote-vault-list" data-vault-list data-testid="vault-list" aria-label="密码条目列表"></aside>
<main class="mnote-vault-detail" data-vault-detail data-testid="vault-detail" aria-label="条目详情"></main>
</div>
<script type="application/json" id="__MNOTE_VAULT_BOOTSTRAP__">{bootstrap_json}</script>
<script src="{runtime_src}" defer></script>
</section>"#,
workspace_id = escape_html(workspace_id),
root_uri_esc = escape_html(root_uri),
bootstrap_json = bootstrap_json,
runtime_src = runtime_src,
)
}
pub async fn trash_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -1147,7 +1474,7 @@ pub async fn trash_entry(
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_html(root_uri, None, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_pending_shell_html();
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
@@ -2155,6 +2482,16 @@ pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) ->
context.auth.actor_type.trim().to_string()
}
pub(crate) fn current_actor_is_local_admin(state: &AppState, context: &RequestContext) -> bool {
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token);
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
return is_local_access_policy_admin_actor(&resolved.user.id, &resolved.user.role);
}
}
is_local_access_policy_admin_context(context)
}
fn normalize_optional_id(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
@@ -3232,6 +3569,104 @@ mod tests {
assert!(!html.contains("开发用户 的空间"));
}
#[tokio::test]
async fn control_plane_user_session_overrides_stale_admin_actor_cookies_for_settings() {
use control_plane::{session_token_hash, CreateSessionInput};
let app_state = 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,
hermes_base_path: "/api/hermes".into(),
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(),
});
app_state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("normal-user-session".into()),
email: Some("normal@example.com".into()),
username: "normal-user-session".into(),
display_name: "普通用户".into(),
role: Some("user".into()),
password_hash: None,
})
.expect("upsert control-plane user");
app_state
.control_plane()
.create_session(CreateSessionInput {
id: None,
user_id: "normal-user-session".into(),
token_hash: session_token_hash("normal-session-token"),
user_agent: None,
ip_hash: None,
expires_at: None,
})
.expect("create control-plane session");
let app = build_app(app_state);
let stale_admin_cookie =
"mnote_session=normal-session-token; mnote_actor_id=stale-admin; mnote_actor_type=admin";
let settings_response = app
.clone()
.oneshot(
Request::builder()
.uri("/settings")
.header("cookie", stale_admin_cookie)
.body(Body::empty())
.expect("request"),
)
.await
.expect("settings response");
assert_eq!(settings_response.status(), StatusCode::OK);
let body = to_bytes(settings_response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("data-ai-admin-role=\"user\""));
assert!(!html.contains("data-ai-admin-role=\"admin\""));
assert!(!html.contains("href=\"#ai-admin-users\""));
let admin_page_response = app
.clone()
.oneshot(
Request::builder()
.uri("/admin/ai")
.header("cookie", stale_admin_cookie)
.body(Body::empty())
.expect("request"),
)
.await
.expect("admin page response");
assert_eq!(admin_page_response.status(), StatusCode::FORBIDDEN);
let admin_api_response = app
.oneshot(
Request::builder()
.uri("/api/ai-admin/users")
.header("cookie", stale_admin_cookie)
.body(Body::empty())
.expect("request"),
)
.await
.expect("admin api response");
assert_eq!(admin_api_response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_access_policy_entry_requires_admin_actor() {
let user_response = app_with_config("http://127.0.0.1:3100".into(), false)
@@ -3780,10 +4215,105 @@ mod tests {
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"tree-live-ws""#));
// Vault left-rail should carry local_folder context in SSR href (no full refresh to bare /vault).
// Leptos HTML-escapes `&` as `&amp;` in attribute values — match fragments, not full attribute.
let vault_href_ok = html.contains(r#"data-testid="mnote-nav-vault""#)
&& html.contains("/vault?sourceKind=local_folder")
&& html.contains("rootUri=");
assert!(
vault_href_ok,
"vault nav SSR href must include sourceKind+rootUri: {}",
html.lines()
.find(|line| line.contains("mnote-nav-vault"))
.unwrap_or("(no vault nav line)")
);
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn bare_vault_entry_returns_friendly_200_without_convex_bootstrap() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/vault")
.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,
"bare /vault must not 503 via retired cloud workspace bootstrap"
);
assert_ne!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_retired")
);
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-page="vault""#)
|| html.contains(r#"data-testid="mnote-vault-workbench""#)
|| html.contains("密码箱"),
"bare vault should render password vault shell: {}",
&html[..html.len().min(500)]
);
assert!(
html.contains(r#"data-mnote-vault-needs-folder="1""#)
|| html.contains("needsLocalFolder"),
"bare vault should signal needs local folder"
);
}
#[tokio::test]
async fn root_entry_local_folder_accepts_gzip_encoding() {
let root = temp_root("mnote-root-local-folder-gzip");
std::fs::write(root.join("README.md"), "# Gzip\n").expect("write md");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_real",
&root_uri,
)
.expect("init");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri(format!("/?sourceKind=local_folder&rootUri={root_uri}"))
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.header(header::ACCEPT_ENCODING, "gzip, br")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let encoding = response
.headers()
.get(header::CONTENT_ENCODING)
.and_then(|value| value.to_str().ok())
.unwrap_or("");
// CompressionLayer may skip tiny bodies; accept either gzip/br or uncompressed OK.
assert!(
encoding.is_empty() || encoding.contains("gzip") || encoding.contains("br"),
"unexpected content-encoding: {encoding}"
);
}
#[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)