chore: align sqlite control plane architecture
- replace default Convex control-plane wording with Rust SQLite control-plane across architecture, AGENTS, Reasonix, and design docs - retire root Convex functions source and deploy script into recycle while keeping explicit cloud/compat/sync-replica boundaries - add control-plane migration guard/docs and keep CodeGraph refreshed after the SQLite control-plane cutover
This commit is contained in:
Generated
+234
@@ -23,6 +23,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -154,6 +163,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
@@ -302,6 +317,20 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
@@ -447,6 +476,21 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
|
||||
|
||||
[[package]]
|
||||
name = "control-plane"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
@@ -478,6 +522,12 @@ dependencies = [
|
||||
name = "core-domain"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core-protocol"
|
||||
version = "0.1.0"
|
||||
@@ -647,6 +697,18 @@ dependencies = [
|
||||
"core-domain",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -882,12 +944,27 @@ version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
@@ -1027,6 +1104,30 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
@@ -1431,6 +1532,23 @@ version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
@@ -1533,6 +1651,7 @@ dependencies = [
|
||||
"base64",
|
||||
"bridge-runtime",
|
||||
"comrak",
|
||||
"control-plane",
|
||||
"core-protocol",
|
||||
"futures-util",
|
||||
"hyper",
|
||||
@@ -1552,6 +1671,7 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1619,6 +1739,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
@@ -1745,6 +1874,12 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -2115,6 +2250,20 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -2130,6 +2279,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.38"
|
||||
@@ -2549,6 +2711,19 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -3026,6 +3201,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -3250,12 +3431,65 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
members = [
|
||||
"crates/adapter-onlyoffice",
|
||||
"crates/bridge-runtime",
|
||||
"crates/control-plane",
|
||||
"crates/core-domain",
|
||||
"crates/core-protocol",
|
||||
"crates/event-log",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "control-plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.34", features = ["bundled"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tracing = "0.1"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,172 @@
|
||||
-- 001-initial-schema.sql
|
||||
-- MNote control-plane v1 schema
|
||||
--
|
||||
-- All tables use TEXT for datetime columns (ISO-8601) so the database
|
||||
-- remains readable without language-specific timestamp types.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_identities (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
provider_subject TEXT NOT NULL,
|
||||
password_hash TEXT,
|
||||
password_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(provider, provider_subject)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
user_agent TEXT,
|
||||
ip_hash TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
last_seen_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_token ON auth_sessions(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'personal',
|
||||
root_uri TEXT NOT NULL UNIQUE,
|
||||
root_path TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL DEFAULT 'local_folder',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(workspace_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS directory_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
root_uri TEXT NOT NULL,
|
||||
root_path TEXT NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
recursive INTEGER NOT NULL DEFAULT 1,
|
||||
capabilities_json TEXT NOT NULL DEFAULT '[]',
|
||||
source TEXT NOT NULL DEFAULT 'admin',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_by TEXT REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_directory_grants_user ON directory_grants(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_directory_grants_root_uri ON directory_grants(root_uri);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
resource_kind TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
permission TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
expires_at TEXT,
|
||||
revoked_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_resource ON share_links(workspace_id, resource_kind, resource_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
remote_kind TEXT NOT NULL,
|
||||
remote_id TEXT,
|
||||
cursor TEXT,
|
||||
last_synced_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
error_json TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(workspace_id, remote_kind)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
allowed_roots_json TEXT NOT NULL DEFAULT '[]',
|
||||
model_policy_json TEXT NOT NULL DEFAULT '{}',
|
||||
quota_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_user_id TEXT REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
target_kind TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor_user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_target ON audit_log(target_kind, target_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbox_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
topic TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
delivered_at TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_pending ON outbox_events(delivered_at, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS legacy_id_map (
|
||||
id TEXT PRIMARY KEY,
|
||||
legacy_system TEXT NOT NULL,
|
||||
legacy_kind TEXT NOT NULL,
|
||||
legacy_id TEXT NOT NULL,
|
||||
new_kind TEXT NOT NULL,
|
||||
new_id TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(legacy_system, legacy_kind, legacy_id)
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
-- 002-ai-runtime-store.sql
|
||||
-- MNote ACP/Hermes runtime session store.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_runtime_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
workspace_id TEXT,
|
||||
document_id TEXT,
|
||||
session_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
profile TEXT NOT NULL,
|
||||
acp_runtime TEXT NOT NULL,
|
||||
trace_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
runtime_json TEXT NOT NULL DEFAULT '{}',
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
deleted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_runtime_runs_user_session
|
||||
ON ai_runtime_runs(user_id, session_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_runtime_runs_workspace
|
||||
ON ai_runtime_runs(workspace_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_runtime_runs_document
|
||||
ON ai_runtime_runs(document_id, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_runtime_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
workspace_id TEXT,
|
||||
document_id TEXT,
|
||||
session_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
profile TEXT NOT NULL,
|
||||
acp_runtime TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_runtime_events_run
|
||||
ON ai_runtime_events(user_id, run_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_runtime_events_session
|
||||
ON ai_runtime_events(user_id, session_id, created_at);
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ControlPlaneError {
|
||||
NotFound(String),
|
||||
Conflict(String),
|
||||
InvalidInput(String),
|
||||
Storage(String),
|
||||
Unauthorized(String),
|
||||
SessionExpired(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ControlPlaneError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ControlPlaneError::NotFound(msg) => write!(f, "NotFound: {msg}"),
|
||||
ControlPlaneError::Conflict(msg) => write!(f, "Conflict: {msg}"),
|
||||
ControlPlaneError::InvalidInput(msg) => write!(f, "InvalidInput: {msg}"),
|
||||
ControlPlaneError::Storage(msg) => write!(f, "Storage: {msg}"),
|
||||
ControlPlaneError::Unauthorized(msg) => write!(f, "Unauthorized: {msg}"),
|
||||
ControlPlaneError::SessionExpired(msg) => write!(f, "SessionExpired: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ControlPlaneError {}
|
||||
|
||||
impl From<rusqlite::Error> for ControlPlaneError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
ControlPlaneError::Storage(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for ControlPlaneError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
ControlPlaneError::Storage(format!("JSON error: {e}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod error;
|
||||
pub mod migrations;
|
||||
pub mod model;
|
||||
pub mod sqlite;
|
||||
pub mod store;
|
||||
|
||||
pub use error::ControlPlaneError;
|
||||
pub use migrations::run_migrations;
|
||||
pub use model::*;
|
||||
pub use sqlite::SqliteControlPlaneStore;
|
||||
pub use store::*;
|
||||
@@ -0,0 +1,109 @@
|
||||
//! SQLite schema migrations for the MNote control-plane database.
|
||||
//!
|
||||
//! Each migration is a named step that runs inside a transaction.
|
||||
//! The `migrations` table tracks which steps have been applied.
|
||||
|
||||
use rusqlite::{Connection, Result as SqlResult};
|
||||
|
||||
const MIGRATIONS: &[(&str, &str)] = &[
|
||||
(
|
||||
"v1-initial-schema",
|
||||
include_str!("../migrations/001-initial-schema.sql"),
|
||||
),
|
||||
(
|
||||
"v2-ai-runtime-store",
|
||||
include_str!("../migrations/002-ai-runtime-store.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
/// Create the `_migrations` meta-table if it does not exist.
|
||||
fn ensure_meta_table(conn: &Connection) -> SqlResult<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS _migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);",
|
||||
)
|
||||
}
|
||||
|
||||
/// Run all pending migrations inside a single transaction.
|
||||
pub fn run_migrations(conn: &Connection) -> SqlResult<()> {
|
||||
ensure_meta_table(conn)?;
|
||||
|
||||
let already_applied: Vec<String> = conn
|
||||
.prepare("SELECT name FROM _migrations")?
|
||||
.query_map([], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
for (name, sql) in MIGRATIONS {
|
||||
if already_applied.contains(&name.to_string()) {
|
||||
continue;
|
||||
}
|
||||
tracing::info!("Running migration: {name}");
|
||||
conn.execute_batch(sql)?;
|
||||
conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Create a fresh in-memory SQLite database and run migrations.
|
||||
fn fresh_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;",
|
||||
)
|
||||
.expect("pragmas");
|
||||
conn
|
||||
}
|
||||
|
||||
fn table_exists(conn: &Connection, name: &str) -> bool {
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?1",
|
||||
[name],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
> 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_is_idempotent() {
|
||||
let conn = fresh_db();
|
||||
run_migrations(&conn).expect("first run");
|
||||
run_migrations(&conn).expect("second run (idempotent)");
|
||||
|
||||
// All expected tables exist
|
||||
for table in &[
|
||||
"users",
|
||||
"auth_identities",
|
||||
"auth_sessions",
|
||||
"workspaces",
|
||||
"workspace_members",
|
||||
"directory_grants",
|
||||
"share_links",
|
||||
"sync_state",
|
||||
"ai_policies",
|
||||
"audit_log",
|
||||
"outbox_events",
|
||||
"legacy_id_map",
|
||||
"ai_runtime_runs",
|
||||
"ai_runtime_events",
|
||||
] {
|
||||
assert!(table_exists(&conn, table), "table {table} should exist");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_runs_fresh_db_ok() {
|
||||
let conn = fresh_db();
|
||||
run_migrations(&conn).expect("fresh migration ok");
|
||||
assert!(table_exists(&conn, "users"));
|
||||
assert!(table_exists(&conn, "_migrations"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! SQLite 控制面 Phase 1 的最小数据模型。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub type EntityId = String;
|
||||
pub type Timestamp = String;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UserRecord {
|
||||
pub id: EntityId,
|
||||
pub email: Option<String>,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub role: String,
|
||||
pub status: String,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
pub fn session_token_hash(raw_token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(raw_token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn password_hash_v1(password: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"mnote-password-v1:");
|
||||
hasher.update(password.as_bytes());
|
||||
format!("sha256-v1:{}", hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
pub fn share_token_hash_v1(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"mnote-share-token-v1:");
|
||||
hasher.update(token.as_bytes());
|
||||
format!("sha256-v1:{}", hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpsertUserInput {
|
||||
pub id: Option<EntityId>,
|
||||
pub email: Option<String>,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub role: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreatePasswordIdentityInput {
|
||||
pub user_id: EntityId,
|
||||
pub email: Option<String>,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthenticatePasswordInput {
|
||||
pub account: String,
|
||||
pub password: String,
|
||||
pub session_id: Option<EntityId>,
|
||||
pub token_hash: String,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip_hash: Option<String>,
|
||||
pub expires_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthSessionRecord {
|
||||
pub id: EntityId,
|
||||
pub user_id: EntityId,
|
||||
pub token_hash: String,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip_hash: Option<String>,
|
||||
pub created_at: Timestamp,
|
||||
pub expires_at: Timestamp,
|
||||
pub revoked_at: Option<Timestamp>,
|
||||
pub last_seen_at: Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreateSessionInput {
|
||||
pub id: Option<EntityId>,
|
||||
pub user_id: EntityId,
|
||||
pub token_hash: String,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip_hash: Option<String>,
|
||||
pub expires_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolvedAuthSession {
|
||||
pub session: AuthSessionRecord,
|
||||
pub user: UserRecord,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceRecord {
|
||||
pub id: EntityId,
|
||||
pub owner_user_id: EntityId,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub root_uri: String,
|
||||
pub root_path: String,
|
||||
pub source_kind: String,
|
||||
pub status: String,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DirectoryGrantRecord {
|
||||
pub id: EntityId,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub root_uri: String,
|
||||
pub root_path: String,
|
||||
pub permission: String,
|
||||
pub recursive: bool,
|
||||
pub capabilities_json: String,
|
||||
pub source: String,
|
||||
pub status: String,
|
||||
pub created_by: Option<EntityId>,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DirectoryGrantLookup {
|
||||
pub grant_id: Option<EntityId>,
|
||||
pub user_id: Option<EntityId>,
|
||||
pub root_uri: Option<String>,
|
||||
pub include_revoked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DirectoryGrantInput {
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub root_uri: String,
|
||||
pub root_path: String,
|
||||
pub permission: String,
|
||||
pub recursive: bool,
|
||||
pub capabilities: Vec<String>,
|
||||
pub source: String,
|
||||
pub created_by: Option<EntityId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolvedAccess {
|
||||
pub user_id: EntityId,
|
||||
pub root_uri: String,
|
||||
pub permission: String,
|
||||
pub grant_ids: Vec<EntityId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OutboxEventRecord {
|
||||
pub id: EntityId,
|
||||
pub topic: String,
|
||||
pub event_type: String,
|
||||
pub payload_json: String,
|
||||
pub created_at: Timestamp,
|
||||
pub delivered_at: Option<Timestamp>,
|
||||
pub attempts: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OutboxEventInput {
|
||||
pub topic: String,
|
||||
pub event_type: String,
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiPolicyRecord {
|
||||
pub id: EntityId,
|
||||
pub user_id: Option<EntityId>,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub allowed_roots_json: String,
|
||||
pub model_policy_json: String,
|
||||
pub quota_json: String,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpsertAiPolicyInput {
|
||||
pub id: Option<EntityId>,
|
||||
pub user_id: Option<EntityId>,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub allowed_roots_json: String,
|
||||
pub model_policy_json: String,
|
||||
pub quota_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SyncStateRecord {
|
||||
pub id: EntityId,
|
||||
pub workspace_id: EntityId,
|
||||
pub remote_kind: String,
|
||||
pub remote_id: Option<String>,
|
||||
pub cursor: Option<String>,
|
||||
pub last_synced_at: Option<Timestamp>,
|
||||
pub status: String,
|
||||
pub error_json: Option<String>,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpsertSyncStateInput {
|
||||
pub id: Option<EntityId>,
|
||||
pub workspace_id: EntityId,
|
||||
pub remote_kind: String,
|
||||
pub remote_id: Option<String>,
|
||||
pub cursor: Option<String>,
|
||||
pub last_synced_at: Option<Timestamp>,
|
||||
pub status: String,
|
||||
pub error_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiRuntimeRunRecord {
|
||||
pub id: EntityId,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub document_id: Option<String>,
|
||||
pub session_id: EntityId,
|
||||
pub run_id: EntityId,
|
||||
pub title: Option<String>,
|
||||
pub profile: String,
|
||||
pub acp_runtime: String,
|
||||
pub trace_id: Option<String>,
|
||||
pub status: String,
|
||||
pub runtime_json: String,
|
||||
pub payload_json: String,
|
||||
pub deleted_at: Option<Timestamp>,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpsertAiRuntimeRunInput {
|
||||
pub id: Option<EntityId>,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub document_id: Option<String>,
|
||||
pub session_id: EntityId,
|
||||
pub run_id: EntityId,
|
||||
pub title: Option<String>,
|
||||
pub profile: String,
|
||||
pub acp_runtime: String,
|
||||
pub trace_id: Option<String>,
|
||||
pub status: String,
|
||||
pub runtime_json: String,
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiRuntimeEventRecord {
|
||||
pub id: EntityId,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub document_id: Option<String>,
|
||||
pub session_id: EntityId,
|
||||
pub run_id: EntityId,
|
||||
pub profile: String,
|
||||
pub acp_runtime: String,
|
||||
pub event_type: String,
|
||||
pub payload_json: String,
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AppendAiRuntimeEventInput {
|
||||
pub id: Option<EntityId>,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub document_id: Option<String>,
|
||||
pub session_id: EntityId,
|
||||
pub run_id: EntityId,
|
||||
pub profile: String,
|
||||
pub acp_runtime: String,
|
||||
pub event_type: String,
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ShareLinkRecord {
|
||||
pub id: EntityId,
|
||||
pub workspace_id: EntityId,
|
||||
pub resource_kind: String,
|
||||
pub resource_id: String,
|
||||
pub token_hash: String,
|
||||
pub permission: String,
|
||||
pub created_by: EntityId,
|
||||
pub expires_at: Option<Timestamp>,
|
||||
pub revoked_at: Option<Timestamp>,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreateShareLinkInput {
|
||||
pub id: Option<EntityId>,
|
||||
pub workspace_id: EntityId,
|
||||
pub resource_kind: String,
|
||||
pub resource_id: String,
|
||||
pub token: Option<String>,
|
||||
pub permission: String,
|
||||
pub created_by: EntityId,
|
||||
pub expires_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreatedShareLink {
|
||||
pub link: ShareLinkRecord,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuditLogRecord {
|
||||
pub id: EntityId,
|
||||
pub actor_user_id: Option<EntityId>,
|
||||
pub action: String,
|
||||
pub target_kind: String,
|
||||
pub target_id: Option<EntityId>,
|
||||
pub metadata_json: String,
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AppendAuditInput {
|
||||
pub actor_user_id: Option<EntityId>,
|
||||
pub action: String,
|
||||
pub target_kind: String,
|
||||
pub target_id: Option<EntityId>,
|
||||
pub metadata_json: String,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
//! 控制面 store trait。
|
||||
|
||||
use crate::error::ControlPlaneError;
|
||||
use crate::model::{
|
||||
AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput,
|
||||
AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput,
|
||||
CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink,
|
||||
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, OutboxEventInput,
|
||||
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SyncStateRecord,
|
||||
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertSyncStateInput, UpsertUserInput,
|
||||
UserRecord, WorkspaceRecord,
|
||||
};
|
||||
|
||||
pub trait ControlPlaneStore: Send + Sync {
|
||||
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError>;
|
||||
|
||||
fn create_password_identity(
|
||||
&self,
|
||||
input: CreatePasswordIdentityInput,
|
||||
) -> Result<(), ControlPlaneError>;
|
||||
|
||||
fn authenticate_password(
|
||||
&self,
|
||||
input: AuthenticatePasswordInput,
|
||||
) -> Result<ResolvedAuthSession, ControlPlaneError>;
|
||||
|
||||
fn create_session(
|
||||
&self,
|
||||
input: CreateSessionInput,
|
||||
) -> Result<AuthSessionRecord, ControlPlaneError>;
|
||||
|
||||
fn get_session_by_token_hash(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
) -> Result<Option<ResolvedAuthSession>, ControlPlaneError>;
|
||||
|
||||
fn revoke_session(&self, session_id: &str) -> Result<(), ControlPlaneError>;
|
||||
|
||||
fn ensure_default_workspace(
|
||||
&self,
|
||||
actor_id: &str,
|
||||
) -> Result<WorkspaceRecord, ControlPlaneError>;
|
||||
|
||||
fn grant_directory_access(
|
||||
&self,
|
||||
input: DirectoryGrantInput,
|
||||
) -> Result<DirectoryGrantRecord, ControlPlaneError>;
|
||||
|
||||
fn list_directory_grants(&self) -> Result<Vec<DirectoryGrantRecord>, ControlPlaneError>;
|
||||
|
||||
fn list_directory_grants_for_actor(
|
||||
&self,
|
||||
actor_id: &str,
|
||||
) -> Result<Vec<DirectoryGrantRecord>, ControlPlaneError>;
|
||||
|
||||
fn find_directory_grants(
|
||||
&self,
|
||||
lookup: DirectoryGrantLookup,
|
||||
) -> Result<Vec<DirectoryGrantRecord>, ControlPlaneError>;
|
||||
|
||||
fn revoke_directory_grant(
|
||||
&self,
|
||||
grant_id: &str,
|
||||
expected_revision: Option<i64>,
|
||||
) -> Result<(), ControlPlaneError>;
|
||||
|
||||
fn resolve_access(
|
||||
&self,
|
||||
actor_id: &str,
|
||||
root_uri: &str,
|
||||
) -> Result<ResolvedAccess, ControlPlaneError>;
|
||||
|
||||
fn create_share_link(
|
||||
&self,
|
||||
input: CreateShareLinkInput,
|
||||
) -> Result<CreatedShareLink, ControlPlaneError>;
|
||||
|
||||
fn list_share_links(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<ShareLinkRecord>, ControlPlaneError>;
|
||||
|
||||
fn resolve_share_link(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
) -> Result<Option<ShareLinkRecord>, ControlPlaneError>;
|
||||
|
||||
fn revoke_share_link(&self, link_id: &str) -> Result<(), ControlPlaneError>;
|
||||
|
||||
fn append_audit(&self, input: AppendAuditInput) -> Result<(), ControlPlaneError>;
|
||||
|
||||
fn list_audit_log(&self, limit: usize) -> Result<Vec<AuditLogRecord>, ControlPlaneError>;
|
||||
|
||||
fn append_outbox(
|
||||
&self,
|
||||
input: OutboxEventInput,
|
||||
) -> Result<OutboxEventRecord, ControlPlaneError>;
|
||||
|
||||
fn drain_outbox(&self, limit: usize) -> Result<Vec<OutboxEventRecord>, ControlPlaneError>;
|
||||
|
||||
fn mark_outbox_delivered(&self, event_id: &str) -> Result<(), ControlPlaneError>;
|
||||
|
||||
fn upsert_ai_policy(
|
||||
&self,
|
||||
input: UpsertAiPolicyInput,
|
||||
) -> Result<AiPolicyRecord, ControlPlaneError>;
|
||||
|
||||
fn get_ai_policy(
|
||||
&self,
|
||||
actor_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
) -> Result<Option<AiPolicyRecord>, ControlPlaneError>;
|
||||
|
||||
fn upsert_sync_state(
|
||||
&self,
|
||||
input: UpsertSyncStateInput,
|
||||
) -> Result<SyncStateRecord, ControlPlaneError>;
|
||||
|
||||
fn get_sync_state(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
remote_kind: &str,
|
||||
) -> Result<Option<SyncStateRecord>, ControlPlaneError>;
|
||||
|
||||
fn upsert_ai_runtime_run(
|
||||
&self,
|
||||
input: UpsertAiRuntimeRunInput,
|
||||
) -> Result<AiRuntimeRunRecord, ControlPlaneError>;
|
||||
|
||||
fn list_ai_runtime_runs(
|
||||
&self,
|
||||
user_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
document_id: Option<&str>,
|
||||
session_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AiRuntimeRunRecord>, ControlPlaneError>;
|
||||
|
||||
fn append_ai_runtime_event(
|
||||
&self,
|
||||
input: AppendAiRuntimeEventInput,
|
||||
) -> Result<AiRuntimeEventRecord, ControlPlaneError>;
|
||||
|
||||
fn list_ai_runtime_events(
|
||||
&self,
|
||||
user_id: &str,
|
||||
run_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AiRuntimeEventRecord>, ControlPlaneError>;
|
||||
|
||||
fn rename_ai_runtime_session(
|
||||
&self,
|
||||
user_id: &str,
|
||||
session_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
title: &str,
|
||||
) -> Result<Vec<AiRuntimeRunRecord>, ControlPlaneError>;
|
||||
|
||||
fn auto_title_ai_runtime_session(
|
||||
&self,
|
||||
user_id: &str,
|
||||
session_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
) -> Result<Option<AiRuntimeRunRecord>, ControlPlaneError>;
|
||||
|
||||
fn delete_ai_runtime_session(
|
||||
&self,
|
||||
user_id: &str,
|
||||
session_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
) -> Result<usize, ControlPlaneError>;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ authors.workspace = true
|
||||
adapter-onlyoffice = { path = "../adapter-onlyoffice" }
|
||||
axum = { version = "0.8", features = ["multipart", "ws"] }
|
||||
bridge-runtime = { path = "../bridge-runtime" }
|
||||
control-plane = { path = "../control-plane" }
|
||||
core-protocol = { path = "../core-protocol" }
|
||||
futures-util = "0.3"
|
||||
hyper = "1"
|
||||
@@ -30,3 +31,4 @@ base64 = "0.22"
|
||||
comrak = { version = "0.52", default-features = false }
|
||||
notify = "8.2.0"
|
||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
|
||||
use crate::middleware::request_context::inject_request_context;
|
||||
use crate::routes::build_router;
|
||||
use axum::Router;
|
||||
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
@@ -137,7 +138,7 @@ fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
config: Arc<AppConfig>,
|
||||
local_folder_watcher_registry: LocalFolderWatcherRegistry,
|
||||
@@ -146,6 +147,7 @@ pub struct AppState {
|
||||
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
|
||||
pub acp_runtime: Arc<AcpRuntimeManager>,
|
||||
pub buffer_store: BufferStore,
|
||||
control_plane: Arc<SqliteControlPlaneStore>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -155,6 +157,7 @@ impl AppState {
|
||||
let actor = EditorRuntimeActor::new();
|
||||
actor.set_block_delta_tx(block_delta_tx.clone());
|
||||
let buffer_store = BufferStore::new();
|
||||
let control_plane = Arc::new(open_control_plane_store());
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(buffer_store.clone()),
|
||||
@@ -163,6 +166,7 @@ impl AppState {
|
||||
stream_delta_tx,
|
||||
acp_runtime: Arc::new(AcpRuntimeManager::from_env()),
|
||||
buffer_store,
|
||||
control_plane,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +177,27 @@ impl AppState {
|
||||
pub fn local_folder_watcher_registry(&self) -> &LocalFolderWatcherRegistry {
|
||||
&self.local_folder_watcher_registry
|
||||
}
|
||||
|
||||
pub fn control_plane(&self) -> &dyn ControlPlaneStore {
|
||||
self.control_plane.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn open_control_plane_store() -> SqliteControlPlaneStore {
|
||||
SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面")
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn open_control_plane_store() -> SqliteControlPlaneStore {
|
||||
let db_path = env::var("MNOTE_CONTROL_PLANE_DB_PATH")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/control-plane.db".to_string());
|
||||
if let Some(parent) = std::path::Path::new(&db_path).parent() {
|
||||
fs::create_dir_all(parent).expect("创建 SQLite 控制面目录");
|
||||
}
|
||||
SqliteControlPlaneStore::open(&db_path).expect("初始化 SQLite 控制面")
|
||||
}
|
||||
|
||||
pub fn build_app(state: AppState) -> Router {
|
||||
@@ -180,3 +205,61 @@ pub fn build_app(state: AppState) -> Router {
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(axum::middleware::from_fn(inject_request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use control_plane::UpsertUserInput;
|
||||
|
||||
fn test_config() -> AppConfig {
|
||||
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: None,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_initializes_sqlite_control_plane_store() {
|
||||
let state = AppState::new(test_config());
|
||||
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("shujuan".into()),
|
||||
email: Some("shujuan@163.com".into()),
|
||||
username: "shujuan".into(),
|
||||
display_name: "shujuan".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
|
||||
let workspace = state
|
||||
.control_plane()
|
||||
.ensure_default_workspace("shujuan")
|
||||
.expect("default workspace");
|
||||
assert_eq!(workspace.name, "shujuan 的空间");
|
||||
|
||||
let access = state
|
||||
.control_plane()
|
||||
.resolve_access("shujuan", &workspace.root_uri)
|
||||
.expect("owner access");
|
||||
assert_eq!(access.permission, "write");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,10 @@ pub struct CommandContextBridge {
|
||||
/// - 如果提供了 `bridge` 且 `workspace_readonly == true`,拒绝
|
||||
///
|
||||
/// 拒绝响应可解释(包含具体原因),不静默成功,不 panic。
|
||||
pub fn ensure_write_authorized(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
pub fn ensure_write_authorized(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::Response;
|
||||
use axum::Json;
|
||||
use control_plane::{AppendAiRuntimeEventInput, UpsertAiRuntimeRunInput};
|
||||
use futures_util::TryStreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
@@ -27,8 +28,7 @@ use tracing::{info, warn};
|
||||
|
||||
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_RUNTIME_SQLITE_STORE: &str = "sqlite_acp_runtime_store";
|
||||
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";
|
||||
@@ -252,6 +252,50 @@ pub async fn search_sessions(
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(20)
|
||||
.clamp(1, 50);
|
||||
if !use_legacy_convex_acp_store(&query) {
|
||||
let runs = state
|
||||
.control_plane()
|
||||
.list_ai_runtime_runs(
|
||||
&user_id,
|
||||
workspace_id.as_deref(),
|
||||
None,
|
||||
None,
|
||||
limit as usize,
|
||||
)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite ACP session 搜索失败: {error}"))
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let needle = q.to_lowercase();
|
||||
let results = runs
|
||||
.iter()
|
||||
.filter(|run| {
|
||||
run.title
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase()
|
||||
.contains(&needle)
|
||||
|| run.payload_json.to_lowercase().contains(&needle)
|
||||
})
|
||||
.map(|run| {
|
||||
let mut value = ai_runtime_run_to_json(run);
|
||||
value["snippet"] =
|
||||
Value::String(run.title.clone().unwrap_or_else(|| run.session_id.clone()));
|
||||
value["score"] = Value::from(1);
|
||||
value
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"results": results
|
||||
})),
|
||||
));
|
||||
}
|
||||
let mut args = serde_json::Map::new();
|
||||
args.insert("userId".into(), Value::String(user_id));
|
||||
args.insert("q".into(), Value::String(q.to_string()));
|
||||
@@ -364,6 +408,31 @@ async fn list_acp_sessions(
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(50)
|
||||
.clamp(1, 100);
|
||||
if !use_legacy_convex_acp_store(query) {
|
||||
let runs = state
|
||||
.control_plane()
|
||||
.list_ai_runtime_runs(
|
||||
&user_id,
|
||||
workspace_id.as_deref(),
|
||||
document_id.as_deref(),
|
||||
session_id.as_deref(),
|
||||
limit as usize,
|
||||
)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite ACP session 列表读取失败: {error}"))
|
||||
.with_context(context)
|
||||
})?;
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"sessions": runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
|
||||
})),
|
||||
));
|
||||
}
|
||||
let mut args = serde_json::Map::new();
|
||||
args.insert("userId".into(), Value::String(user_id));
|
||||
if let Some(workspace_id) = workspace_id {
|
||||
@@ -582,7 +651,7 @@ pub async fn create_session(
|
||||
&runtime_payload,
|
||||
)
|
||||
.await?;
|
||||
persistence = "convex_acp_runtime_store";
|
||||
persistence = ACP_RUNTIME_SQLITE_STORE;
|
||||
}
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
@@ -978,7 +1047,10 @@ pub async fn resume_session(
|
||||
let result = get_acp_session(&state, &context, &session_id, &query).await?;
|
||||
let mut payload = result.2 .0;
|
||||
payload["resumed"] = Value::Bool(true);
|
||||
payload["resumeSource"] = Value::String("convex_acp_runtime_store".into());
|
||||
payload["resumeSource"] = payload
|
||||
.get("persistence")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::String(ACP_RUNTIME_SQLITE_STORE.into()));
|
||||
return Ok((result.0, result.1, Json(payload)));
|
||||
}
|
||||
get_session(
|
||||
@@ -997,6 +1069,36 @@ pub async fn delete_session(
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
if !use_legacy_convex_acp_store(&query) {
|
||||
let user_id = effective_session_store_user_id(&state, &context).await?;
|
||||
let workspace_id = query
|
||||
.get("workspaceId")
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| context.workspace.workspace_id.clone());
|
||||
let deleted = state
|
||||
.control_plane()
|
||||
.delete_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref())
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite ACP session 删除失败: {error}"))
|
||||
.with_context(&context)
|
||||
})?;
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"sessionId": session_id,
|
||||
"deleted": deleted
|
||||
}
|
||||
})),
|
||||
));
|
||||
}
|
||||
let result = execute_acp_session_mutation(
|
||||
&state,
|
||||
&context,
|
||||
@@ -1035,6 +1137,37 @@ pub async fn rename_session(
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client"),
|
||||
);
|
||||
}
|
||||
if !use_legacy_convex_acp_store(&query) {
|
||||
let user_id = effective_session_store_user_id(&state, &context).await?;
|
||||
let workspace_id = query
|
||||
.get("workspaceId")
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| context.workspace.workspace_id.clone());
|
||||
let runs = state
|
||||
.control_plane()
|
||||
.rename_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref(), title)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite ACP session 重命名失败: {error}"))
|
||||
.with_context(&context)
|
||||
})?;
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"sessionId": session_id,
|
||||
"title": title,
|
||||
"runs": runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
|
||||
}
|
||||
})),
|
||||
));
|
||||
}
|
||||
let result = execute_acp_session_mutation(
|
||||
&state,
|
||||
&context,
|
||||
@@ -1063,6 +1196,38 @@ pub async fn auto_title_session(
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
if !use_legacy_convex_acp_store(&query) {
|
||||
let user_id = effective_session_store_user_id(&state, &context).await?;
|
||||
let workspace_id = query
|
||||
.get("workspaceId")
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| context.workspace.workspace_id.clone());
|
||||
let titled = state
|
||||
.control_plane()
|
||||
.auto_title_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref())
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite ACP session 自动标题失败: {error}"))
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let title = titled.as_ref().and_then(|run| run.title.clone());
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"sessionId": session_id,
|
||||
"title": title,
|
||||
"run": titled.as_ref().map(ai_runtime_run_to_json)
|
||||
}
|
||||
})),
|
||||
));
|
||||
}
|
||||
let result = execute_acp_session_mutation(
|
||||
&state,
|
||||
&context,
|
||||
@@ -1177,6 +1342,54 @@ async fn get_acp_session(
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| context.workspace.workspace_id.clone());
|
||||
if !use_legacy_convex_acp_store(query) {
|
||||
let runs = state
|
||||
.control_plane()
|
||||
.list_ai_runtime_runs(
|
||||
&user_id,
|
||||
workspace_id.as_deref(),
|
||||
None,
|
||||
Some(session_id),
|
||||
20,
|
||||
)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite ACP session 详情读取失败: {error}"))
|
||||
.with_context(context)
|
||||
})?;
|
||||
let latest_run = runs.first().cloned();
|
||||
let events = if let Some(run) = latest_run.as_ref() {
|
||||
state
|
||||
.control_plane()
|
||||
.list_ai_runtime_events(&user_id, &run.run_id, 200)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite ACP runtime events 读取失败: {error}"))
|
||||
.with_context(context)
|
||||
})?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let runtime = latest_run
|
||||
.as_ref()
|
||||
.and_then(|run| serde_json::from_str::<Value>(&run.runtime_json).ok())
|
||||
.unwrap_or_else(|| runtime_state_for_session(session_id));
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"sessionId": session_id,
|
||||
"session": {
|
||||
"sessionId": session_id,
|
||||
"messages": [],
|
||||
"runs": runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
|
||||
},
|
||||
"runtime": runtime,
|
||||
"events": events.iter().map(ai_runtime_event_to_json).collect::<Vec<_>>()
|
||||
})),
|
||||
));
|
||||
}
|
||||
let mut run_args = serde_json::Map::new();
|
||||
run_args.insert("userId".into(), Value::String(user_id.clone()));
|
||||
run_args.insert("sessionId".into(), Value::String(session_id.to_string()));
|
||||
@@ -1327,7 +1540,7 @@ pub async fn create_run(
|
||||
"persistence": persistence_result
|
||||
.get("persistence")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("convex_acp_runtime_store"),
|
||||
.unwrap_or(ACP_RUNTIME_SQLITE_STORE),
|
||||
"sessionStorage": persistence_result
|
||||
.get("sessionStorage")
|
||||
.cloned()
|
||||
@@ -4232,6 +4445,61 @@ fn acp_runtime_run_store_args(
|
||||
})
|
||||
}
|
||||
|
||||
fn json_string(value: &Value) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn ai_runtime_run_to_json(record: &control_plane::AiRuntimeRunRecord) -> Value {
|
||||
let runtime = serde_json::from_str::<Value>(&record.runtime_json).unwrap_or(Value::Null);
|
||||
let payload = serde_json::from_str::<Value>(&record.payload_json).unwrap_or(Value::Null);
|
||||
json!({
|
||||
"sessionId": record.session_id,
|
||||
"runId": record.run_id,
|
||||
"workspaceId": record.workspace_id,
|
||||
"documentId": record.document_id,
|
||||
"title": record.title,
|
||||
"profile": record.profile,
|
||||
"acpRuntime": record.acp_runtime,
|
||||
"traceId": record.trace_id,
|
||||
"status": record.status,
|
||||
"runtime": runtime,
|
||||
"payload": payload,
|
||||
"createdAt": record.created_at,
|
||||
"updatedAt": record.updated_at,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
})
|
||||
}
|
||||
|
||||
fn ai_runtime_event_to_json(record: &control_plane::AiRuntimeEventRecord) -> Value {
|
||||
let payload = serde_json::from_str::<Value>(&record.payload_json).unwrap_or(Value::Null);
|
||||
json!({
|
||||
"eventId": record.id,
|
||||
"sessionId": record.session_id,
|
||||
"runId": record.run_id,
|
||||
"workspaceId": record.workspace_id,
|
||||
"documentId": record.document_id,
|
||||
"profile": record.profile,
|
||||
"acpRuntime": record.acp_runtime,
|
||||
"eventType": record.event_type,
|
||||
"payload": payload,
|
||||
"createdAt": record.created_at,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
})
|
||||
}
|
||||
|
||||
fn query_bool(query: &HashMap<String, String>, key: &str) -> bool {
|
||||
query
|
||||
.get(key)
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.map(|value| matches!(value, "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn use_legacy_convex_acp_store(query: &HashMap<String, String>) -> bool {
|
||||
query_bool(query, "legacyConvex") || query_bool(query, "convex")
|
||||
}
|
||||
|
||||
fn local_session_share_id(payload: &Value) -> Option<String> {
|
||||
payload
|
||||
.get("shareId")
|
||||
@@ -4868,19 +5136,57 @@ async fn persist_acp_runtime_run(
|
||||
"runId": run_id
|
||||
}));
|
||||
}
|
||||
execute_convex_mutation_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
ACP_RUNTIME_RUN_MUTATION,
|
||||
args,
|
||||
payload
|
||||
.get("workspaceId")
|
||||
.and_then(Value::as_str)
|
||||
.or(context.workspace.workspace_id.as_deref()),
|
||||
Some(run_id),
|
||||
"acp_runtime_run_store",
|
||||
)
|
||||
.await
|
||||
let workspace_id = args
|
||||
.get("workspaceId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let document_id = args
|
||||
.get("documentId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let title = args
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let status = args
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("acp_pending")
|
||||
.to_string();
|
||||
let record = state
|
||||
.control_plane()
|
||||
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
|
||||
id: None,
|
||||
user_id: runtime_store_user_id(context, payload),
|
||||
workspace_id,
|
||||
document_id,
|
||||
session_id: registration.session_id.clone(),
|
||||
run_id: run_id.to_string(),
|
||||
title,
|
||||
profile: registration.profile.clone(),
|
||||
acp_runtime: acp_runtime.to_string(),
|
||||
trace_id: Some(registration.trace_id.clone()),
|
||||
status,
|
||||
runtime_json: json_string(runtime_state),
|
||||
payload_json: json_string(payload),
|
||||
})
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite ACP runtime run 写入失败: {error}"))
|
||||
.with_context(context)
|
||||
})?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"sessionStorage": "sqlite_control_plane",
|
||||
"sessionId": record.session_id,
|
||||
"runId": record.run_id
|
||||
}))
|
||||
}
|
||||
|
||||
fn acp_runtime_event_store_args(
|
||||
@@ -4990,19 +5296,41 @@ async fn persist_acp_runtime_event(
|
||||
"runId": run_id
|
||||
}));
|
||||
}
|
||||
execute_convex_mutation_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
ACP_RUNTIME_EVENT_MUTATION,
|
||||
args,
|
||||
run_payload
|
||||
.get("workspaceId")
|
||||
.and_then(Value::as_str)
|
||||
.or(context.workspace.workspace_id.as_deref()),
|
||||
None,
|
||||
"acp_runtime_event_store",
|
||||
)
|
||||
.await
|
||||
state
|
||||
.control_plane()
|
||||
.append_ai_runtime_event(AppendAiRuntimeEventInput {
|
||||
id: None,
|
||||
user_id: runtime_store_user_id(context, run_payload),
|
||||
workspace_id: args
|
||||
.get("workspaceId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
document_id: args
|
||||
.get("documentId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
session_id: registration.session_id.clone(),
|
||||
run_id: run_id.to_string(),
|
||||
profile: registration.profile.clone(),
|
||||
acp_runtime: acp_runtime.to_string(),
|
||||
event_type: event_type.to_string(),
|
||||
payload_json: json_string(event_payload),
|
||||
})
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite ACP runtime event 写入失败: {error}"))
|
||||
.with_context(context)
|
||||
})?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"sessionStorage": "sqlite_control_plane",
|
||||
"sessionId": registration.session_id,
|
||||
"runId": run_id
|
||||
}))
|
||||
}
|
||||
|
||||
fn register_runtime_from_create_run_response(
|
||||
@@ -5652,6 +5980,7 @@ mod tests {
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::Request;
|
||||
use axum::routing::{get, post};
|
||||
use control_plane::UpsertAiRuntimeRunInput;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tower::util::ServiceExt;
|
||||
@@ -5776,6 +6105,48 @@ mod tests {
|
||||
build_app(AppState::new(config))
|
||||
}
|
||||
|
||||
fn seeded_acp_state() -> AppState {
|
||||
let 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: true,
|
||||
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(),
|
||||
});
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
|
||||
id: None,
|
||||
user_id: "user_1".to_string(),
|
||||
workspace_id: Some("ws_1".to_string()),
|
||||
document_id: Some("doc_1".to_string()),
|
||||
session_id: "sess_1".to_string(),
|
||||
run_id: "run_1".to_string(),
|
||||
title: None,
|
||||
profile: "reasonix".to_string(),
|
||||
acp_runtime: "reasonix".to_string(),
|
||||
trace_id: Some("trace_1".to_string()),
|
||||
status: "completed".to_string(),
|
||||
runtime_json: "{\"status\":\"completed\",\"runId\":\"run_1\"}".to_string(),
|
||||
payload_json: "{\"message\":\"自动标题\"}".to_string(),
|
||||
})
|
||||
.expect("seed acp runtime run");
|
||||
state
|
||||
}
|
||||
|
||||
fn test_state() -> AppState {
|
||||
AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
@@ -6079,7 +6450,7 @@ mod tests {
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
|
||||
assert_eq!(payload["persistence"], "convex_acp_runtime_store");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert!(payload.get("messages").is_none());
|
||||
}
|
||||
|
||||
@@ -6519,109 +6890,82 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], "convex_acp_runtime_store");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
|
||||
let mutation = captured_body.lock().expect("captured convex body").clone();
|
||||
assert_eq!(mutation["path"], "aiSessions:upsertRuntimeRun");
|
||||
assert_eq!(mutation["args"][0]["userId"], "user_1");
|
||||
assert_eq!(mutation["args"][0]["workspaceId"], "ws_1");
|
||||
assert_eq!(mutation["args"][0]["documentId"], "doc_1");
|
||||
assert_eq!(mutation["args"][0]["sessionId"], "mnote_doc_1_trace_1");
|
||||
assert_eq!(mutation["args"][0]["runId"], "mnote_doc_1_trace_1_session");
|
||||
assert_eq!(mutation["args"][0]["status"], "session.created");
|
||||
assert_eq!(
|
||||
captured_body.lock().expect("captured convex body").clone(),
|
||||
Value::Null
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_acp_run_registers_scoped_runtime_record_in_convex() {
|
||||
async fn hermes_client_acp_run_registers_scoped_runtime_record_in_sqlite() {
|
||||
let _guard = runtime_lock().lock().expect("runtime lock");
|
||||
clear_runtime_registry();
|
||||
clear_run_queue();
|
||||
let router = app();
|
||||
|
||||
let captured_body = Arc::new(Mutex::new(Value::Null));
|
||||
let captured_for_route = Arc::clone(&captured_body);
|
||||
let mock = axum::Router::new().route(
|
||||
"/api/mutation",
|
||||
post(move |Json(body): Json<Value>| {
|
||||
let captured = Arc::clone(&captured_for_route);
|
||||
async move {
|
||||
*captured.lock().expect("captured convex body") = body;
|
||||
Json(json!({
|
||||
"status": "success",
|
||||
"value": {"ok": true, "stored": true}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/client/runs")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-workspace-id", "ws_header")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_1",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"message": "读取当前页面",
|
||||
"profile": "reasonix",
|
||||
"acpRuntime": "reasonix",
|
||||
"runId": "run_trace_1",
|
||||
"traceId": "trace_1",
|
||||
"pageContext": {"title": "页面标题"}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("listener");
|
||||
let convex_url = format!("http://{}", listener.local_addr().expect("addr"));
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, mock).await.expect("mock convex");
|
||||
});
|
||||
|
||||
let response = app_with_config(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: true,
|
||||
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: Some(convex_url),
|
||||
convex_admin_key: Some("admin-demo".into()),
|
||||
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(),
|
||||
})
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/client/runs")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-workspace-id", "ws_header")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_1",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"message": "读取当前页面",
|
||||
"profile": "reasonix",
|
||||
"acpRuntime": "reasonix",
|
||||
"runId": "run_trace_1",
|
||||
"traceId": "trace_1",
|
||||
"pageContext": {"title": "页面标题"}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
|
||||
let body = captured_body.lock().expect("captured convex body").clone();
|
||||
assert_eq!(body["path"], "aiSessions:upsertRuntimeRun");
|
||||
let args = &body["args"][0];
|
||||
assert_eq!(args["schema"], "mnote.acp_runtime_run.v1");
|
||||
assert_eq!(args["source"], "acp");
|
||||
assert_eq!(args["userId"], "user_1");
|
||||
assert_eq!(args["workspaceId"], "ws_1");
|
||||
assert_eq!(args["documentId"], "doc_1");
|
||||
assert_eq!(args["sessionId"], "sess_1");
|
||||
assert_eq!(args["runId"], "run_trace_1");
|
||||
assert_eq!(args["profile"], "reasonix");
|
||||
assert_eq!(args["acpRuntime"], "reasonix");
|
||||
assert_eq!(args["runtime"]["status"], "acp_pending");
|
||||
assert_eq!(args["payload"]["message"], "读取当前页面");
|
||||
assert!(args.get("messages").is_none());
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("list response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("list body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
let run = &payload["sessions"][0];
|
||||
assert_eq!(run["sessionId"], "sess_1");
|
||||
assert_eq!(run["runId"], "run_trace_1");
|
||||
assert_eq!(run["workspaceId"], "ws_1");
|
||||
assert_eq!(run["documentId"], "doc_1");
|
||||
assert_eq!(run["profile"], "reasonix");
|
||||
assert_eq!(run["acpRuntime"], "reasonix");
|
||||
assert_eq!(run["runtime"]["status"], "acp_pending");
|
||||
assert_eq!(run["payload"]["message"], "读取当前页面");
|
||||
assert!(run.get("messages").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -6683,7 +7027,7 @@ mod tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1")
|
||||
.uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1&legacyConvex=1")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
@@ -6775,7 +7119,9 @@ mod tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/hermes/client/sessions/sess_1?source=acp&workspaceId=ws_1")
|
||||
.uri(
|
||||
"/api/hermes/client/sessions/sess_1?source=acp&workspaceId=ws_1&legacyConvex=1",
|
||||
)
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
@@ -6861,7 +7207,7 @@ mod tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/client/sessions/sess_1/resume?source=acp&workspaceId=ws_1")
|
||||
.uri("/api/hermes/client/sessions/sess_1/resume?source=acp&workspaceId=ws_1&legacyConvex=1")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
@@ -6880,131 +7226,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_acp_session_rename_calls_convex_store() {
|
||||
let captured_body = Arc::new(Mutex::new(Value::Null));
|
||||
let captured_for_route = Arc::clone(&captured_body);
|
||||
let mock = axum::Router::new().route(
|
||||
"/api/mutation",
|
||||
post(move |Json(body): Json<Value>| {
|
||||
let captured = Arc::clone(&captured_for_route);
|
||||
async move {
|
||||
*captured.lock().expect("captured convex body") = body;
|
||||
Json(json!({
|
||||
"status": "success",
|
||||
"value": {"ok": true, "sessionId": "sess_1", "title": "新标题"}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
async fn hermes_client_acp_session_rename_uses_sqlite_store() {
|
||||
let response = build_app(seeded_acp_state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/client/sessions/sess_1/rename?source=acp&workspaceId=ws_1")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(json!({"title": "新标题"}).to_string()))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("listener");
|
||||
let convex_url = format!("http://{}", listener.local_addr().expect("addr"));
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, mock).await.expect("mock convex");
|
||||
});
|
||||
|
||||
let response = app_with_config(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: true,
|
||||
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: Some(convex_url),
|
||||
convex_admin_key: Some("admin-demo".into()),
|
||||
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(),
|
||||
})
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/client/sessions/sess_1/rename?source=acp&workspaceId=ws_1")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(json!({"title": "新标题"}).to_string()))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["result"]["title"], "新标题");
|
||||
|
||||
let mutation = captured_body.lock().expect("captured convex body").clone();
|
||||
assert_eq!(mutation["path"], "aiSessions:renameRuntimeSession");
|
||||
assert_eq!(mutation["args"][0]["userId"], "user_1");
|
||||
assert_eq!(mutation["args"][0]["workspaceId"], "ws_1");
|
||||
assert_eq!(mutation["args"][0]["sessionId"], "sess_1");
|
||||
assert_eq!(mutation["args"][0]["title"], "新标题");
|
||||
assert_eq!(payload["result"]["runs"][0]["sessionId"], "sess_1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_acp_session_delete_and_auto_title_call_convex_store() {
|
||||
let captured_bodies = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||
let captured_for_route = Arc::clone(&captured_bodies);
|
||||
let mock = axum::Router::new().route(
|
||||
"/api/mutation",
|
||||
post(move |Json(body): Json<Value>| {
|
||||
let captured = Arc::clone(&captured_for_route);
|
||||
async move {
|
||||
captured
|
||||
.lock()
|
||||
.expect("captured convex bodies")
|
||||
.push(body.clone());
|
||||
let path = body["path"].as_str().unwrap_or_default();
|
||||
let value = match path {
|
||||
"aiSessions:autoTitleRuntimeSession" => {
|
||||
json!({"ok": true, "sessionId": "sess_1", "title": "自动标题"})
|
||||
}
|
||||
"aiSessions:deleteRuntimeSession" => {
|
||||
json!({"ok": true, "sessionId": "sess_1", "deleted": 1})
|
||||
}
|
||||
_ => Value::Null,
|
||||
};
|
||||
Json(json!({"status": "success", "value": value}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener");
|
||||
let convex_url = format!("http://{}", listener.local_addr().expect("addr"));
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, mock).await.expect("mock convex");
|
||||
});
|
||||
let router = app_with_config(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: true,
|
||||
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: Some(convex_url),
|
||||
convex_admin_key: Some("admin-demo".into()),
|
||||
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(),
|
||||
});
|
||||
async fn hermes_client_acp_session_delete_and_auto_title_use_sqlite_store() {
|
||||
let router = build_app(seeded_acp_state());
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
@@ -7025,6 +7273,7 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["result"]["title"], "自动标题");
|
||||
|
||||
let response = router
|
||||
@@ -7039,13 +7288,12 @@ mod tests {
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let bodies = captured_bodies.lock().expect("captured convex bodies");
|
||||
assert_eq!(bodies[0]["path"], "aiSessions:autoTitleRuntimeSession");
|
||||
assert_eq!(bodies[0]["args"][0]["userId"], "user_1");
|
||||
assert_eq!(bodies[0]["args"][0]["sessionId"], "sess_1");
|
||||
assert_eq!(bodies[1]["path"], "aiSessions:deleteRuntimeSession");
|
||||
assert_eq!(bodies[1]["args"][0]["workspaceId"], "ws_1");
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("delete body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["result"]["deleted"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -7104,7 +7352,7 @@ mod tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/hermes/client/sessions/search?source=acp&workspaceId=ws_1&q=%E5%8C%96%E5%AD%A6&limit=5")
|
||||
.uri("/api/hermes/client/sessions/search?source=acp&workspaceId=ws_1&q=%E5%8C%96%E5%AD%A6&limit=5&legacyConvex=1")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
|
||||
@@ -20,7 +20,8 @@ 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>>;
|
||||
type BoxedEventStream =
|
||||
Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -138,8 +139,8 @@ async fn build_tree_live_stream(
|
||||
.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 revision =
|
||||
local_folder_watch_revision(&root_uri).map_err(|error| error.with_context(&context))?;
|
||||
|
||||
let initial_payload = build_tree_snapshot_payload(
|
||||
&root_uri,
|
||||
@@ -285,8 +286,7 @@ mod tests {
|
||||
|
||||
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");
|
||||
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(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::default_workspace_name_for_context;
|
||||
use crate::routes::web_shell::{
|
||||
build_page_aggregate_snapshot, load_file_tree_html, load_sidebar_tree_html,
|
||||
load_workspace_shell_projection, render_local_file_tree_html, render_local_sidebar_tree_html,
|
||||
@@ -30,7 +31,7 @@ pub async fn mindmap_object_shell(
|
||||
Path((doc_id, mindmap_id)): Path<(String, String)>,
|
||||
Query(query): Query<MindmapShellQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let default_workspace_name = default_workspace_name_for_context(&state, &context);
|
||||
let source_kind = query.source_kind.as_deref();
|
||||
let root_uri = query.root_uri.as_deref();
|
||||
let aggregate = build_page_aggregate_snapshot(
|
||||
|
||||
@@ -131,6 +131,26 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/admin/access-policy/grants/{grant_id}",
|
||||
delete(local_folder_source::delete_local_access_grant),
|
||||
)
|
||||
.route(
|
||||
"/api/user/access-policy",
|
||||
get(local_folder_source::get_user_access_policy),
|
||||
)
|
||||
.route(
|
||||
"/api/user/access-policy/grants",
|
||||
post(local_folder_source::create_user_access_grant),
|
||||
)
|
||||
.route(
|
||||
"/api/user/access-policy/grants/{grant_id}",
|
||||
delete(local_folder_source::delete_user_access_grant),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/share-links",
|
||||
get(local_folder_source::get_share_links).post(local_folder_source::create_share_link),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/share-links/{link_id}",
|
||||
delete(local_folder_source::delete_share_link),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/share-grants",
|
||||
get(local_folder_source::get_share_grants)
|
||||
|
||||
@@ -5,10 +5,12 @@ 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 control_plane::session_token_hash;
|
||||
use serde::Serialize;
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
|
||||
const COOKIE_MNOTE_SESSION: &str = "mnote_session";
|
||||
const COOKIE_ACTOR_EMAIL: &str = "mnote_actor_email";
|
||||
const COOKIE_ACTOR_NAME: &str = "mnote_actor_name";
|
||||
|
||||
@@ -65,6 +67,28 @@ pub async fn refresh_session(
|
||||
}
|
||||
|
||||
fn build_session_response(state: &AppState, context: RequestContext) -> SessionResponse {
|
||||
if let Some(raw_token) = context
|
||||
.auth
|
||||
.cookie_header
|
||||
.as_deref()
|
||||
.and_then(|cookies| raw_cookie_value(cookies, 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 SessionResponse {
|
||||
ok: true,
|
||||
owner: "mnote-web",
|
||||
user_id: resolved.user.id,
|
||||
email: resolved.user.email.unwrap_or_default(),
|
||||
name: resolved.user.display_name,
|
||||
actor_type: "user".to_string(),
|
||||
auth_mode: "sqliteSession",
|
||||
request_id: context.trace.request_id,
|
||||
trace_id: context.trace.trace_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
let has_forwarded_actor = !actor_id.is_empty() && actor_id != "anonymous";
|
||||
let actor_email = context
|
||||
@@ -179,6 +203,7 @@ mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -282,6 +307,75 @@ mod tests {
|
||||
assert!(payload.get("convexAdminKey").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_prefers_sqlite_cookie_identity_over_dev_fallback() {
|
||||
let 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: true,
|
||||
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: 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(),
|
||||
});
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("shujuan".into()),
|
||||
email: Some("shujuan@163.com".into()),
|
||||
username: "shujuan".into(),
|
||||
display_name: "shujuan".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
state
|
||||
.control_plane()
|
||||
.create_session(CreateSessionInput {
|
||||
id: None,
|
||||
user_id: "shujuan".into(),
|
||||
token_hash: session_token_hash("raw-session-token"),
|
||||
user_agent: None,
|
||||
ip_hash: None,
|
||||
expires_at: None,
|
||||
})
|
||||
.expect("create session");
|
||||
|
||||
let response = build_app(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/session")
|
||||
.header("cookie", "mnote_session=raw-session-token")
|
||||
.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 payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["userId"], "shujuan");
|
||||
assert_eq!(payload["email"], "shujuan@163.com");
|
||||
assert_eq!(payload["name"], "shujuan");
|
||||
assert_eq!(payload["actorType"], "user");
|
||||
assert_eq!(payload["authMode"], "sqliteSession");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_whoami_alias_prefers_forwarded_actor_identity() {
|
||||
let response = app()
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::routes::documents::{
|
||||
load_document_content_result, load_document_meta_result, DocumentContentQuery,
|
||||
DocumentMetaQuery,
|
||||
};
|
||||
use crate::routes::gateway::default_workspace_name_for_context;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
@@ -125,7 +126,7 @@ pub async fn document_page_shell(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let default_workspace_name = default_workspace_name_for_context(&state, &context);
|
||||
let mut workspace_projection = load_workspace_shell_projection(
|
||||
state.config(),
|
||||
&context,
|
||||
|
||||
@@ -1,267 +1,118 @@
|
||||
//! MNOTE 管理员目录授权页面组件
|
||||
//! MNOTE 文件夹授权管理组件
|
||||
|
||||
use crate::ssr::pages::layout::PageLayout;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn AdminAccessPolicyPage(
|
||||
pub fn AdminAccessPolicyPanel(
|
||||
#[prop(optional)] workspace_name: Option<String>,
|
||||
#[prop(optional)] policy_path: Option<String>,
|
||||
#[prop(optional)] share_grants_path: Option<String>,
|
||||
#[prop(optional, default = true)] is_admin: bool,
|
||||
#[prop(optional, default = true)] boot_script: bool,
|
||||
) -> impl IntoView {
|
||||
let workspace_name = workspace_name
|
||||
.unwrap_or_else(|| "开发用户 的空间".to_string())
|
||||
.trim()
|
||||
.to_string();
|
||||
let policy_path = policy_path
|
||||
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/access-policy.json".to_string());
|
||||
let share_grants_path = share_grants_path
|
||||
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/share-grants.json".to_string());
|
||||
let page_title = "授权管理".to_string();
|
||||
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/control-plane.db".to_string());
|
||||
let scope_text = if is_admin {
|
||||
"管理员可以授权任意本地文件夹。"
|
||||
} else {
|
||||
"普通用户只能授权自己空间下的文件夹。"
|
||||
};
|
||||
view! {
|
||||
<PageLayout current_nav={if is_admin { "admin" } else { "home" }} workspace_name={workspace_name.clone()} topbar_title={page_title.clone()} show_admin_access_policy={is_admin}>
|
||||
<main class="mnote-admin-policy-page" data-testid="mnote-admin-access-policy-page">
|
||||
<header class="mnote-admin-policy-header">
|
||||
<h1>{page_title.clone()}</h1>
|
||||
<p>{if is_admin {
|
||||
"管理员可以管理目录授权和分享授权。"
|
||||
} else {
|
||||
"查看与你相关的分享授权。"
|
||||
}}</p>
|
||||
</header>
|
||||
<div
|
||||
class="mnote-admin-policy-dialog__content"
|
||||
data-testid="mnote-admin-access-policy-page"
|
||||
data-access-policy-role={if is_admin { "admin" } else { "user" }}
|
||||
>
|
||||
<header class="mnote-admin-policy-header">
|
||||
<div>
|
||||
<div class="mnote-admin-policy-eyebrow">"授权管理"</div>
|
||||
<h2 id="mnote-admin-policy-dialog-title">"文件夹授权"</h2>
|
||||
<p>{scope_text}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mnote-admin-policy-summary">
|
||||
<div class="mnote-admin-policy-summary-item">
|
||||
<span class="mnote-admin-policy-summary-label">"工作区"</span>
|
||||
<strong data-testid="mnote-admin-workspace-name">{workspace_name.clone()}</strong>
|
||||
</div>
|
||||
<div class="mnote-admin-policy-summary-item">
|
||||
<span class="mnote-admin-policy-summary-label">"策略文件"</span>
|
||||
<code data-testid="mnote-admin-policy-path">{if is_admin { policy_path.clone() } else { "仅管理员可见".to_string() }}</code>
|
||||
</div>
|
||||
<div class="mnote-admin-policy-summary-item">
|
||||
<span class="mnote-admin-policy-summary-label">"分享授权文件"</span>
|
||||
<code data-testid="mnote-admin-share-grants-path">{share_grants_path.clone()}</code>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mnote-admin-policy-summary">
|
||||
<div class="mnote-admin-policy-summary-item">
|
||||
<span class="mnote-admin-policy-summary-label">"工作区"</span>
|
||||
<strong data-testid="mnote-admin-workspace-name">{workspace_name.clone()}</strong>
|
||||
</div>
|
||||
<div class="mnote-admin-policy-summary-item">
|
||||
<span class="mnote-admin-policy-summary-label">"授权记录"</span>
|
||||
<code data-testid="mnote-admin-share-grants-path">{share_grants_path.clone()}</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{if is_admin {
|
||||
view! {
|
||||
<section class="mnote-admin-policy-panel" data-admin-section="directory">
|
||||
<header class="mnote-admin-policy-panel-header">
|
||||
<div>
|
||||
<h2>"目录授权"</h2>
|
||||
<p class="mnote-admin-policy-note">"给指定用户授权可访问的本地目录。"</p>
|
||||
</div>
|
||||
<button type="button" data-testid="mnote-admin-policy-refresh" data-admin-action="refresh-policy">"刷新"</button>
|
||||
</header>
|
||||
<div class="mnote-admin-policy-table" data-testid="mnote-admin-policy-grants-list">
|
||||
<div class="mnote-admin-policy-empty">"正在读取目录授权..."</div>
|
||||
</div>
|
||||
<details class="mnote-admin-policy-debug">
|
||||
<summary>"查看策略 JSON"</summary>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-policy-json">{""}</pre>
|
||||
</details>
|
||||
<div class="mnote-admin-policy-note" data-testid="mnote-admin-policy-message"></div>
|
||||
</section>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<section class="mnote-admin-policy-panel" data-admin-section="directory" hidden>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-policy-json">{""}</pre>
|
||||
<div class="mnote-admin-policy-note" data-testid="mnote-admin-policy-message"></div>
|
||||
</section>
|
||||
}.into_any()
|
||||
}}
|
||||
|
||||
{if is_admin {
|
||||
view! {
|
||||
<section class="mnote-admin-policy-grid" data-admin-only="true">
|
||||
<form class="mnote-admin-policy-form" data-admin-form="validate-root">
|
||||
<header><h2>"验证目录"</h2></header>
|
||||
<label>
|
||||
<span>"rootUri"</span>
|
||||
<input data-testid="mnote-admin-root-uri" name="rootUri" type="text" placeholder="file:///mnt/Data1T/Mnote_data/users/..." />
|
||||
</label>
|
||||
<label>
|
||||
<span>"rootPath"</span>
|
||||
<input data-testid="mnote-admin-root-path" name="rootPath" type="text" placeholder="/mnt/Data1T/Mnote_data/..." />
|
||||
</label>
|
||||
<button type="submit" data-testid="mnote-admin-validate-root-submit">"验证"</button>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-validate-result"></pre>
|
||||
</form>
|
||||
|
||||
<form class="mnote-admin-policy-form" data-admin-form="create-grant">
|
||||
<header><h2>"新增授权"</h2></header>
|
||||
<label>
|
||||
<span>"grantId"</span>
|
||||
<input data-testid="mnote-admin-grant-id" name="grantId" type="text" placeholder="可留空自动生成" />
|
||||
</label>
|
||||
<label>
|
||||
<span>"userId"</span>
|
||||
<input data-testid="mnote-admin-grant-user-id" name="userId" type="text" placeholder="user_123" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>"rootUri"</span>
|
||||
<input data-testid="mnote-admin-grant-root-uri" name="rootUri" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>"rootPath"</span>
|
||||
<input data-testid="mnote-admin-grant-root-path" name="rootPath" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>"permission"</span>
|
||||
<select data-testid="mnote-admin-grant-permission" name="permission">
|
||||
<option value="read">"read"</option>
|
||||
<option value="write">"write"</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>"recursive"</span>
|
||||
<input data-testid="mnote-admin-grant-recursive" name="recursive" type="checkbox" checked=true />
|
||||
</label>
|
||||
<label>
|
||||
<span>"capabilities"</span>
|
||||
<input data-testid="mnote-admin-grant-capabilities" name="capabilities" type="text" placeholder="ai,share" />
|
||||
</label>
|
||||
<button type="submit" data-testid="mnote-admin-create-grant-submit">"创建"</button>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-create-result"></pre>
|
||||
</form>
|
||||
|
||||
<form class="mnote-admin-policy-form" data-admin-form="delete-grant">
|
||||
<header><h2>"删除授权"</h2></header>
|
||||
<label>
|
||||
<span>"grantId"</span>
|
||||
<input data-testid="mnote-admin-delete-grant-id" name="grantId" type="text" placeholder="grant_xxx" required />
|
||||
</label>
|
||||
<button type="submit" data-testid="mnote-admin-delete-grant-submit">"删除"</button>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-delete-result"></pre>
|
||||
</form>
|
||||
</section>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
<section class="mnote-admin-policy-grid">
|
||||
<form class="mnote-admin-policy-form" data-admin-form="create-share-grant">
|
||||
<header><h3>"新增文件夹授权"</h3></header>
|
||||
<label>
|
||||
<span>"文件夹地址"</span>
|
||||
<input data-testid="mnote-admin-share-root-path" name="rootPath" type="text" placeholder="/mnt/Data1T/Mnote_data/users/..." required=true />
|
||||
</label>
|
||||
<label>
|
||||
<span>"授权用户 ID"</span>
|
||||
<input data-testid="mnote-admin-share-target-user-id" name="targetUserId" type="text" placeholder="user_123" required=true />
|
||||
</label>
|
||||
<label>
|
||||
<span>"权限"</span>
|
||||
<select data-testid="mnote-admin-share-permission" name="sharePermission">
|
||||
<option value="read">"read"</option>
|
||||
<option value="write">"write"</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" data-testid="mnote-admin-create-share-grant-submit">"创建授权"</button>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-create-share-grant-result"></pre>
|
||||
</form>
|
||||
|
||||
<section class="mnote-admin-policy-panel" data-testid="mnote-admin-share-grants-panel">
|
||||
<header class="mnote-admin-policy-panel-header">
|
||||
<div>
|
||||
<h2>"分享管理"</h2>
|
||||
<p class="mnote-admin-policy-note">{if is_admin {
|
||||
"查看、创建和撤销分享授权。"
|
||||
} else {
|
||||
"查看你创建或接收的分享授权。"
|
||||
}}</p>
|
||||
<h3>"授权列表"</h3>
|
||||
<p class="mnote-admin-policy-note">"查看当前可管理的文件夹授权,并撤销不再需要的授权。"</p>
|
||||
</div>
|
||||
<button type="button" data-testid="mnote-admin-share-grants-refresh" data-admin-action="refresh-share-grants">"刷新"</button>
|
||||
</header>
|
||||
<div class="mnote-admin-policy-table" data-testid="mnote-admin-share-grants-list">
|
||||
<div class="mnote-admin-policy-empty">"正在读取分享授权..."</div>
|
||||
<div class="mnote-admin-policy-empty">"正在读取授权..."</div>
|
||||
</div>
|
||||
<details class="mnote-admin-policy-debug">
|
||||
<summary>"查看分享授权 JSON"</summary>
|
||||
<summary>"查看授权 JSON"</summary>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-share-grants-json">{""}</pre>
|
||||
</details>
|
||||
<div class="mnote-admin-policy-note" data-testid="mnote-admin-share-grants-message"></div>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-delete-share-grant-result" hidden></pre>
|
||||
</section>
|
||||
|
||||
<section class="mnote-admin-policy-grid" data-admin-only={if is_admin { "true" } else { "false" }}>
|
||||
<form class="mnote-admin-policy-form" data-admin-form="create-share-grant">
|
||||
<header><h2>"新增分享授权"</h2></header>
|
||||
<label>
|
||||
<span>"grantId"</span>
|
||||
<input data-testid="mnote-admin-share-grant-id" name="shareGrantId" type="text" placeholder="可留空自动生成" />
|
||||
</label>
|
||||
{if is_admin {
|
||||
view! {
|
||||
<label>
|
||||
<span>"ownerUserId"</span>
|
||||
<input data-testid="mnote-admin-share-owner-user-id" name="ownerUserId" type="text" placeholder="owner_123" required />
|
||||
</label>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<input type="hidden" data-testid="mnote-admin-share-owner-user-id" name="ownerUserId" value="" />
|
||||
}.into_any()
|
||||
}}
|
||||
<label>
|
||||
<span>"shareId"</span>
|
||||
<input data-testid="mnote-admin-share-id" name="shareId" type="text" placeholder="share_xxx" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>"targetUserId"</span>
|
||||
<input data-testid="mnote-admin-share-target-user-id" name="targetUserId" type="text" placeholder="target_123" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>"rootUri"</span>
|
||||
<input data-testid="mnote-admin-share-root-uri" name="shareRootUri" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>"rootPath"</span>
|
||||
<input data-testid="mnote-admin-share-root-path" name="shareRootPath" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>"documentId"</span>
|
||||
<input data-testid="mnote-admin-share-document-id" name="documentId" type="text" placeholder="doc_123" />
|
||||
</label>
|
||||
<label>
|
||||
<span>"allowedResourceIds"</span>
|
||||
<input data-testid="mnote-admin-share-resource-ids" name="allowedResourceIds" type="text" placeholder="doc_123,mindmap_456" />
|
||||
</label>
|
||||
<label>
|
||||
<span>"permission"</span>
|
||||
<select data-testid="mnote-admin-share-permission" name="sharePermission">
|
||||
<option value="read">"read"</option>
|
||||
<option value="write">"write"</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>"capabilities"</span>
|
||||
<input data-testid="mnote-admin-share-capabilities" name="shareCapabilities" type="text" placeholder="ai,share" />
|
||||
</label>
|
||||
<button type="submit" data-testid="mnote-admin-create-share-grant-submit">"创建分享授权"</button>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-create-share-grant-result"></pre>
|
||||
</form>
|
||||
|
||||
<form class="mnote-admin-policy-form" data-admin-form="delete-share-grant">
|
||||
<header><h2>"撤销分享授权"</h2></header>
|
||||
<label>
|
||||
<span>"shareId 或 grantId"</span>
|
||||
<input data-testid="mnote-admin-delete-share-id" name="deleteShareId" type="text" placeholder="share_xxx" required />
|
||||
</label>
|
||||
<button type="submit" data-testid="mnote-admin-delete-share-grant-submit">"撤销"</button>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-delete-share-grant-result"></pre>
|
||||
</form>
|
||||
</section>
|
||||
<script id="__MNOTE_ACCESS_POLICY_PAGE__" type="application/json">
|
||||
{format!(r#"{{"isAdmin":{}}}"#, if is_admin { "true" } else { "false" })}
|
||||
</script>
|
||||
<script>{ADMIN_POLICY_SCRIPT}</script>
|
||||
</main>
|
||||
</PageLayout>
|
||||
</section>
|
||||
<script id="__MNOTE_ACCESS_POLICY_PAGE__" type="application/json">
|
||||
{format!(r#"{{"isAdmin":{}}}"#, if is_admin { "true" } else { "false" })}
|
||||
</script>
|
||||
{if boot_script {
|
||||
view! { <script>{ADMIN_POLICY_SCRIPT}</script> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
const ADMIN_POLICY_SCRIPT: &str = r#"
|
||||
pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
var root = document.querySelector('[data-testid="mnote-admin-access-policy-page"]');
|
||||
function initAccessPolicyPanel(scope) {
|
||||
var root = (scope || document).querySelector('[data-testid="mnote-admin-access-policy-page"]');
|
||||
if (!root) return;
|
||||
var message = root.querySelector('[data-testid="mnote-admin-policy-message"]');
|
||||
var policyJson = root.querySelector('[data-testid="mnote-admin-policy-json"]');
|
||||
if (root.getAttribute('data-access-policy-ready') === 'true') return;
|
||||
root.setAttribute('data-access-policy-ready', 'true');
|
||||
var shareGrantsJson = root.querySelector('[data-testid="mnote-admin-share-grants-json"]');
|
||||
var validateResult = root.querySelector('[data-testid="mnote-admin-validate-result"]');
|
||||
var createResult = root.querySelector('[data-testid="mnote-admin-create-result"]');
|
||||
var deleteResult = root.querySelector('[data-testid="mnote-admin-delete-result"]');
|
||||
var shareGrantsMessage = root.querySelector('[data-testid="mnote-admin-share-grants-message"]');
|
||||
var createShareGrantResult = root.querySelector('[data-testid="mnote-admin-create-share-grant-result"]');
|
||||
var deleteShareGrantResult = root.querySelector('[data-testid="mnote-admin-delete-share-grant-result"]');
|
||||
var refreshButton = root.querySelector('[data-admin-action="refresh-policy"]');
|
||||
var refreshShareGrantsButton = root.querySelector('[data-admin-action="refresh-share-grants"]');
|
||||
var policyGrantsList = root.querySelector('[data-testid="mnote-admin-policy-grants-list"]');
|
||||
var shareGrantsList = root.querySelector('[data-testid="mnote-admin-share-grants-list"]');
|
||||
var pageConfig = (function () {
|
||||
var node = document.getElementById('__MNOTE_ACCESS_POLICY_PAGE__');
|
||||
var node = root.querySelector('#__MNOTE_ACCESS_POLICY_PAGE__');
|
||||
try { return JSON.parse(node ? node.textContent || '{}' : '{}'); } catch (_) { return {}; }
|
||||
})();
|
||||
var isAdmin = pageConfig.isAdmin === true;
|
||||
@@ -285,76 +136,43 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
|
||||
return '<span class="mnote-admin-policy-badge" data-value="' + escapeHtml(text) + '">' + escapeHtml(text) + '</span>';
|
||||
}
|
||||
|
||||
function renderPolicyGrants(payload) {
|
||||
if (!policyGrantsList) return;
|
||||
var grants = payload && payload.policy && Array.isArray(payload.policy.grants) ? payload.policy.grants : [];
|
||||
if (!grants.length) {
|
||||
policyGrantsList.innerHTML = '<div class="mnote-admin-policy-empty">暂无目录授权</div>';
|
||||
return;
|
||||
}
|
||||
policyGrantsList.innerHTML = grants.map(function(grant) {
|
||||
return '<article class="mnote-admin-policy-row">' +
|
||||
'<div><strong>' + escapeHtml(grant.userId || grant.user_id || '未知用户') + '</strong><span>' + escapeHtml(grant.rootPath || grant.root_path || grant.rootUri || grant.root_uri || '') + '</span></div>' +
|
||||
'<div>' + renderBadge(grant.permission || grant.access) + '</div>' +
|
||||
'<div><span>' + escapeHtml((grant.capabilities || []).join(', ') || '无能力标记') + '</span></div>' +
|
||||
'</article>';
|
||||
}).join('');
|
||||
function readDirectoryGrants(payload) {
|
||||
if (payload && Array.isArray(payload.grants)) return payload.grants;
|
||||
if (payload && payload.policy && Array.isArray(payload.policy.grants)) return payload.policy.grants;
|
||||
return [];
|
||||
}
|
||||
|
||||
function accessPolicyUrl(suffix) {
|
||||
var base = isAdmin ? '/api/admin/access-policy' : '/api/user/access-policy';
|
||||
return suffix ? base + suffix : base;
|
||||
}
|
||||
|
||||
function renderShareGrants(payload) {
|
||||
if (!shareGrantsList) return;
|
||||
var grants = payload && Array.isArray(payload.grants) ? payload.grants : [];
|
||||
var grants = readDirectoryGrants(payload);
|
||||
if (!grants.length) {
|
||||
shareGrantsList.innerHTML = '<div class="mnote-admin-policy-empty">暂无分享授权</div>';
|
||||
shareGrantsList.innerHTML = '<div class="mnote-admin-policy-empty">暂无文件夹授权</div>';
|
||||
return;
|
||||
}
|
||||
shareGrantsList.innerHTML = grants.map(function(grant) {
|
||||
var active = grant.active === false ? '已撤销' : '有效';
|
||||
var active = grant.active === false || grant.status === 'revoked' ? '已撤销' : '有效';
|
||||
var revokeButton = grant.active === false ? '' :
|
||||
'<button type="button" data-admin-action="revoke-access-grant" data-grant-id="' + escapeHtml(grant.id || '') + '">撤销</button>';
|
||||
return '<article class="mnote-admin-policy-row">' +
|
||||
'<div><strong>' + escapeHtml(grant.shareId || grant.id || '未命名分享') + '</strong><span>' + escapeHtml(grant.rootPath || grant.rootUri || '') + '</span></div>' +
|
||||
'<div><strong>' + escapeHtml(grant.rootPath || grant.rootUri || '') + '</strong><span>授权用户 ' + escapeHtml(grant.targetUserId || grant.userId || '') + '</span></div>' +
|
||||
'<div>' + renderBadge(grant.permission) + renderBadge(active) + '</div>' +
|
||||
'<div><span>所有者 ' + escapeHtml(grant.ownerUserId || '') + '</span><span>接收者 ' + escapeHtml(grant.targetUserId || '') + '</span></div>' +
|
||||
'<div><span>创建者 ' + escapeHtml(grant.createdBy || grant.ownerUserId || '') + '</span><span>授权 ID ' + escapeHtml(grant.id || '') + '</span>' + revokeButton + '</div>' +
|
||||
'</article>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function formValues(form) {
|
||||
var data = new FormData(form);
|
||||
var capabilities = String(data.get('capabilities') || '')
|
||||
.split(/[,\s]+/)
|
||||
.map(function (item) { return item.trim(); })
|
||||
.filter(Boolean);
|
||||
return {
|
||||
id: String(data.get('grantId') || '').trim(),
|
||||
userId: String(data.get('userId') || '').trim(),
|
||||
rootUri: String(data.get('rootUri') || '').trim(),
|
||||
rootPath: String(data.get('rootPath') || '').trim(),
|
||||
permission: String(data.get('permission') || 'read').trim(),
|
||||
recursive: data.get('recursive') === 'on' || data.get('recursive') === 'true',
|
||||
capabilities: capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
function splitList(value) {
|
||||
return String(value || '')
|
||||
.split(/[,\s]+/)
|
||||
.map(function (item) { return item.trim(); })
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function shareGrantFormValues(form) {
|
||||
var data = new FormData(form);
|
||||
return {
|
||||
id: String(data.get('shareGrantId') || '').trim(),
|
||||
shareId: String(data.get('shareId') || '').trim(),
|
||||
ownerUserId: String(data.get('ownerUserId') || '').trim(),
|
||||
userId: String(data.get('targetUserId') || '').trim(),
|
||||
targetUserId: String(data.get('targetUserId') || '').trim(),
|
||||
rootUri: String(data.get('shareRootUri') || '').trim(),
|
||||
rootPath: String(data.get('shareRootPath') || '').trim(),
|
||||
documentId: String(data.get('documentId') || '').trim(),
|
||||
allowedResourceIds: splitList(data.get('allowedResourceIds')),
|
||||
rootPath: String(data.get('rootPath') || '').trim(),
|
||||
permission: String(data.get('sharePermission') || 'read').trim(),
|
||||
capabilities: splitList(data.get('shareCapabilities')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -371,101 +189,29 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function refreshPolicy() {
|
||||
if (!isAdmin) return;
|
||||
var payload = await requestJson('/api/admin/access-policy', { method: 'GET', headers: {} });
|
||||
setText(policyJson, payload);
|
||||
renderPolicyGrants(payload);
|
||||
setText(message, '已刷新策略');
|
||||
}
|
||||
|
||||
async function refreshShareGrants() {
|
||||
var payload = await requestJson(isAdmin ? '/api/admin/share-grants' : '/api/user/share-grants', { method: 'GET', headers: {} });
|
||||
var payload = await requestJson(accessPolicyUrl(''), { method: 'GET', headers: {} });
|
||||
setText(shareGrantsJson, payload);
|
||||
renderShareGrants(payload);
|
||||
setText(shareGrantsMessage, '已刷新分享授权');
|
||||
setText(shareGrantsMessage, '已刷新授权列表');
|
||||
}
|
||||
|
||||
refreshButton && refreshButton.addEventListener('click', function () {
|
||||
setText(message, '正在刷新策略...');
|
||||
refreshPolicy().catch(function (error) { setText(message, error.message || '刷新失败'); });
|
||||
});
|
||||
|
||||
refreshShareGrantsButton && refreshShareGrantsButton.addEventListener('click', function () {
|
||||
setText(shareGrantsMessage, '正在刷新分享授权...');
|
||||
setText(shareGrantsMessage, '正在刷新授权列表...');
|
||||
refreshShareGrants().catch(function (error) { setText(shareGrantsMessage, error.message || '刷新失败'); });
|
||||
});
|
||||
|
||||
var validateRootForm = root.querySelector('[data-admin-form="validate-root"]');
|
||||
if (validateRootForm) {
|
||||
validateRootForm.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var values = formValues(event.currentTarget);
|
||||
setText(validateResult, '正在验证...');
|
||||
requestJson('/api/admin/access-policy/validate-root', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rootUri: values.rootUri, rootPath: values.rootPath }),
|
||||
}).then(function (payload) {
|
||||
setText(validateResult, payload);
|
||||
setText(message, '目录验证完成');
|
||||
}).catch(function (error) {
|
||||
setText(validateResult, { ok: false, error: error.message || '验证失败' });
|
||||
setText(message, error.message || '验证失败');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var createGrantForm = root.querySelector('[data-admin-form="create-grant"]');
|
||||
if (createGrantForm) {
|
||||
createGrantForm.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var values = formValues(event.currentTarget);
|
||||
setText(createResult, '正在创建...');
|
||||
requestJson('/api/admin/access-policy/grants', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
}).then(function (payload) {
|
||||
setText(createResult, payload);
|
||||
setText(message, '授权已创建');
|
||||
return refreshPolicy();
|
||||
}).catch(function (error) {
|
||||
setText(createResult, { ok: false, error: error.message || '创建失败' });
|
||||
setText(message, error.message || '创建失败');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var deleteGrantForm = root.querySelector('[data-admin-form="delete-grant"]');
|
||||
if (deleteGrantForm) {
|
||||
deleteGrantForm.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var values = formValues(event.currentTarget);
|
||||
var grantId = values.id;
|
||||
setText(deleteResult, '正在删除...');
|
||||
requestJson('/api/admin/access-policy/grants/' + encodeURIComponent(grantId), {
|
||||
method: 'DELETE',
|
||||
headers: {},
|
||||
}).then(function (payload) {
|
||||
setText(deleteResult, payload);
|
||||
setText(message, '授权已删除');
|
||||
return refreshPolicy();
|
||||
}).catch(function (error) {
|
||||
setText(deleteResult, { ok: false, error: error.message || '删除失败' });
|
||||
setText(message, error.message || '删除失败');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
root.querySelector('[data-admin-form="create-share-grant"]').addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var values = shareGrantFormValues(event.currentTarget);
|
||||
setText(createShareGrantResult, '正在创建...');
|
||||
requestJson(isAdmin ? '/api/admin/share-grants' : '/api/user/share-grants', {
|
||||
requestJson(accessPolicyUrl('/grants'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
}).then(function (payload) {
|
||||
setText(createShareGrantResult, payload);
|
||||
setText(shareGrantsMessage, '分享授权已创建');
|
||||
setText(shareGrantsMessage, '文件夹授权已创建');
|
||||
event.currentTarget.reset();
|
||||
return refreshShareGrants();
|
||||
}).catch(function (error) {
|
||||
setText(createShareGrantResult, { ok: false, error: error.message || '创建失败' });
|
||||
@@ -473,17 +219,19 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelector('[data-admin-form="delete-share-grant"]').addEventListener('submit', function (event) {
|
||||
shareGrantsList && shareGrantsList.addEventListener('click', function(event) {
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-admin-action="revoke-access-grant"]') : null;
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
var data = new FormData(event.currentTarget);
|
||||
var shareId = String(data.get('deleteShareId') || '').trim();
|
||||
var grantId = String(button.getAttribute('data-grant-id') || '').trim();
|
||||
if (!grantId) return;
|
||||
setText(deleteShareGrantResult, '正在撤销...');
|
||||
requestJson((isAdmin ? '/api/admin/share-grants/' : '/api/user/share-grants/') + encodeURIComponent(shareId), {
|
||||
requestJson(accessPolicyUrl('/grants/') + encodeURIComponent(grantId), {
|
||||
method: 'DELETE',
|
||||
headers: {},
|
||||
}).then(function (payload) {
|
||||
setText(deleteShareGrantResult, payload);
|
||||
setText(shareGrantsMessage, '分享授权已撤销');
|
||||
setText(shareGrantsMessage, '文件夹授权已撤销');
|
||||
return refreshShareGrants();
|
||||
}).catch(function (error) {
|
||||
setText(deleteShareGrantResult, { ok: false, error: error.message || '撤销失败' });
|
||||
@@ -491,16 +239,16 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
|
||||
});
|
||||
});
|
||||
|
||||
if (isAdmin) {
|
||||
refreshPolicy().catch(function (error) {
|
||||
setText(message, error.message || '加载策略失败');
|
||||
renderPolicyGrants(null);
|
||||
});
|
||||
}
|
||||
refreshShareGrants().catch(function (error) {
|
||||
setText(shareGrantsMessage, error.message || '加载分享授权失败');
|
||||
setText(shareGrantsMessage, error.message || '加载授权失败');
|
||||
renderShareGrants(null);
|
||||
});
|
||||
}
|
||||
window.MNOTEInitAccessPolicyPanel = initAccessPolicyPanel;
|
||||
var shell = document.body && document.body.getAttribute('data-mnote-shell');
|
||||
if (shell === 'admin' || shell === 'user-access-policy') {
|
||||
initAccessPolicyPanel(document);
|
||||
}
|
||||
})();
|
||||
"#;
|
||||
|
||||
@@ -510,21 +258,38 @@ mod tests {
|
||||
use crate::ssr::render_view;
|
||||
|
||||
#[test]
|
||||
fn admin_access_policy_page_renders_admin_controls() {
|
||||
fn admin_access_policy_panel_renders_minimal_folder_grant_controls() {
|
||||
let html = render_view(view! {
|
||||
<AdminAccessPolicyPage
|
||||
<AdminAccessPolicyPanel
|
||||
workspace_name={"我的空间".to_string()}
|
||||
policy_path={"/mnt/Data1T/Mnote_data/control-plane/access-policy.json".to_string()}
|
||||
is_admin=true
|
||||
/>
|
||||
});
|
||||
assert!(html.contains("mnote-admin-access-policy-page"));
|
||||
assert!(html.contains("mnote-admin-policy-json"));
|
||||
assert!(html.contains("文件夹授权"));
|
||||
assert!(html.contains("文件夹地址"));
|
||||
assert!(html.contains("授权用户 ID"));
|
||||
assert!(html.contains("read"));
|
||||
assert!(html.contains("write"));
|
||||
assert!(html.contains("mnote-admin-share-grants-panel"));
|
||||
assert!(html.contains("mnote-admin-share-grants-json"));
|
||||
assert!(html.contains("mnote-admin-validate-root-submit"));
|
||||
assert!(html.contains("mnote-admin-create-grant-submit"));
|
||||
assert!(html.contains("mnote-admin-delete-grant-submit"));
|
||||
assert!(html.contains("mnote-admin-create-share-grant-submit"));
|
||||
assert!(html.contains("mnote-admin-delete-share-grant-submit"));
|
||||
assert!(html.contains("/api/admin/access-policy"));
|
||||
assert!(html.contains("/api/user/access-policy"));
|
||||
assert!(html.contains("data-mnote-shell"));
|
||||
assert!(!html.contains("/api/admin/share-grants"));
|
||||
assert!(!html.contains("/api/user/share-grants"));
|
||||
assert!(
|
||||
html.contains("data-admin-action="revoke-access-grant"")
|
||||
|| html.contains("revoke-access-grant")
|
||||
);
|
||||
assert!(!html.contains("name=\"grantId\""));
|
||||
assert!(!html.contains("name=\"shareId\""));
|
||||
assert!(!html.contains("share_xxx"));
|
||||
assert!(!html.contains("name=\"ownerUserId\""));
|
||||
assert!(!html.contains("documentId"));
|
||||
assert!(!html.contains("allowedResourceIds"));
|
||||
assert!(!html.contains("capabilities"));
|
||||
assert!(!html.contains("mnote-admin-validate-root-submit"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! MNOTE 通用页面布局组件(Wolai / Notion 风格)
|
||||
|
||||
use crate::ssr::pages::admin::AdminAccessPolicyPanel;
|
||||
use leptos::prelude::*;
|
||||
|
||||
const SIDEBAR_TREE_JS: &str = r##"
|
||||
@@ -1227,6 +1228,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
|
||||
}
|
||||
|
||||
function closeAdminAccessPolicyDialog() {
|
||||
var existing = document.querySelector('[data-testid="mnote-admin-access-policy-modal"]');
|
||||
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
|
||||
document.documentElement.removeAttribute('data-mnote-admin-access-policy-modal-open');
|
||||
}
|
||||
|
||||
function accountMenuFallbackSession() {
|
||||
var body = document.body instanceof HTMLElement ? document.body : null;
|
||||
var actorId = body ? String(body.getAttribute('data-mnote-actor-id') || '').trim() : '';
|
||||
@@ -1263,10 +1270,6 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return String(session && session.actorType || '').trim() === 'admin';
|
||||
}
|
||||
|
||||
function accessPolicyHrefForSession(session) {
|
||||
return sessionIsAdmin(session) ? '/admin/access-policy' : '/user/access-policy';
|
||||
}
|
||||
|
||||
async function loadAccountInfo(menu) {
|
||||
try {
|
||||
var response = await fetch('/api/auth/session', {
|
||||
@@ -1346,6 +1349,43 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (closeButton instanceof HTMLElement) closeButton.focus();
|
||||
}
|
||||
|
||||
function openAdminAccessPolicyDialog(trigger) {
|
||||
closeAdminAccessPolicyDialog();
|
||||
closeAccountMenu();
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
var role = trigger.getAttribute('data-access-policy-role') === 'admin' ? 'admin' : 'user';
|
||||
var template = document.querySelector('[data-testid="mnote-admin-access-policy-template-' + role + '"]');
|
||||
var dialog = document.createElement('div');
|
||||
dialog.className = 'mnote-admin-policy-modal';
|
||||
dialog.setAttribute('data-testid', 'mnote-admin-access-policy-modal');
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
dialog.setAttribute('aria-labelledby', 'mnote-admin-policy-dialog-title');
|
||||
dialog.innerHTML =
|
||||
'<div class="mnote-admin-policy-modal__backdrop" data-admin-access-policy-close></div>' +
|
||||
'<section class="mnote-admin-policy-modal__panel">' +
|
||||
'<button type="button" class="mnote-admin-policy-modal__close" data-admin-access-policy-close aria-label="关闭授权管理"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
|
||||
'<div class="mnote-admin-policy-modal__content" data-testid="mnote-admin-access-policy-modal-content"></div>' +
|
||||
'</section>';
|
||||
var content = dialog.querySelector('[data-testid="mnote-admin-access-policy-modal-content"]');
|
||||
if (content) {
|
||||
content.innerHTML = template ? template.innerHTML : '<div class="mnote-admin-policy-empty">授权管理面板加载失败</div>';
|
||||
}
|
||||
if (typeof window.MNOTEInitAccessPolicyPanel === 'function') {
|
||||
window.MNOTEInitAccessPolicyPanel(dialog);
|
||||
}
|
||||
dialog.querySelectorAll('[data-admin-access-policy-close]').forEach(function(button) {
|
||||
button.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
closeAdminAccessPolicyDialog();
|
||||
});
|
||||
});
|
||||
document.body.appendChild(dialog);
|
||||
document.documentElement.setAttribute('data-mnote-admin-access-policy-modal-open', 'true');
|
||||
var closeButton = dialog.querySelector('.mnote-admin-policy-modal__close');
|
||||
if (closeButton instanceof HTMLElement) closeButton.focus();
|
||||
}
|
||||
|
||||
async function signOutAccount(trigger) {
|
||||
setCommandPending(trigger, true);
|
||||
try {
|
||||
@@ -1382,7 +1422,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.innerHTML =
|
||||
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-profile" role="menuitem"><span class="material-symbols-outlined" data-icon="account_circle" aria-hidden="true"></span><span>个人信息</span></button>' +
|
||||
'<a class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-access-policy" role="menuitem" href="/user/access-policy"><span class="material-symbols-outlined" data-icon="admin_panel_settings" aria-hidden="true"></span><span>授权管理</span></a>' +
|
||||
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-access-policy" role="menuitem" hidden><span class="material-symbols-outlined" data-icon="admin_panel_settings" aria-hidden="true"></span><span>授权管理</span></button>' +
|
||||
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__logout" data-testid="mnote-account-sign-out" role="menuitem">退出登录</button>' +
|
||||
'<div class="mnote-account-menu__error" data-account-error hidden></div>';
|
||||
var sessionPromise = fetchAccountSession();
|
||||
@@ -1395,8 +1435,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
var accessPolicyLink = menu.querySelector('[data-testid="mnote-account-access-policy"]');
|
||||
sessionPromise.then(function(session) {
|
||||
if (accessPolicyLink instanceof HTMLElement) accessPolicyLink.setAttribute('href', accessPolicyHrefForSession(session));
|
||||
if (!(accessPolicyLink instanceof HTMLElement)) return;
|
||||
accessPolicyLink.hidden = false;
|
||||
accessPolicyLink.setAttribute('data-access-policy-role', sessionIsAdmin(session) ? 'admin' : 'user');
|
||||
});
|
||||
if (accessPolicyLink) {
|
||||
accessPolicyLink.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
openAdminAccessPolicyDialog(accessPolicyLink);
|
||||
});
|
||||
}
|
||||
var signOutButton = menu.querySelector('[data-testid="mnote-account-sign-out"]');
|
||||
if (signOutButton) {
|
||||
signOutButton.addEventListener('click', function(event) {
|
||||
@@ -9996,13 +10044,14 @@ pub fn PageLayout(
|
||||
/// 顶栏当前页面标题(可选)
|
||||
#[prop(optional)]
|
||||
topbar_title: Option<String>,
|
||||
/// 是否显示管理员授权入口
|
||||
/// 是否显示管理员授权能力
|
||||
#[prop(optional)]
|
||||
show_admin_access_policy: bool,
|
||||
/// 是否启用树实时流
|
||||
#[prop(optional, default = true)]
|
||||
enable_tree_live: bool,
|
||||
) -> impl IntoView {
|
||||
let _ = show_admin_access_policy;
|
||||
let sidebar_tree_html = sidebar_tree_html.unwrap_or_default();
|
||||
|
||||
let ws_name = workspace_name
|
||||
@@ -10042,6 +10091,12 @@ pub fn PageLayout(
|
||||
"views": ["page-tree", "file-tree"]
|
||||
})
|
||||
.to_string();
|
||||
let admin_access_policy_template = crate::ssr::render_view(leptos::view! {
|
||||
<AdminAccessPolicyPanel workspace_name={ws_name.clone()} is_admin=true boot_script=false />
|
||||
});
|
||||
let user_access_policy_template = crate::ssr::render_view(leptos::view! {
|
||||
<AdminAccessPolicyPanel workspace_name={ws_name.clone()} is_admin=false boot_script=false />
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="mnote-shell wolai-workspace-shell" data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
|
||||
@@ -10068,21 +10123,6 @@ pub fn PageLayout(
|
||||
<a href="/help" title="帮助" aria-label="帮助"><span class="material-symbols-outlined nav-icon" data-icon="help" aria-hidden="true"></span></a>
|
||||
<a href="/files" title="文件" aria-label="文件"><span class="material-symbols-outlined nav-icon" data-icon="inventory_2" aria-hidden="true"></span></a>
|
||||
<button type="button" title="打开本地文件夹" aria-label="打开本地文件夹" data-mnote-action="open-local-folder"><span class="material-symbols-outlined nav-icon" data-icon="folder_open" aria-hidden="true"></span></button>
|
||||
{if show_admin_access_policy {
|
||||
view! {
|
||||
<a
|
||||
href="/admin/access-policy"
|
||||
class:active={current_nav == "admin"}
|
||||
title="目录授权"
|
||||
aria-label="目录授权"
|
||||
data-testid="mnote-admin-access-policy-entry"
|
||||
>
|
||||
<span class="material-symbols-outlined nav-icon" data-icon="admin_panel_settings" aria-hidden="true"></span>
|
||||
</a>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
title="更多"
|
||||
@@ -10097,6 +10137,9 @@ pub fn PageLayout(
|
||||
</nav>
|
||||
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
|
||||
<script id="__MNOTE_TREE_LIVE_BOOTSTRAP__" type="application/json" inner_html={tree_live_bootstrap}></script>
|
||||
<div hidden data-testid="mnote-admin-access-policy-template-admin" inner_html={admin_access_policy_template}></div>
|
||||
<div hidden data-testid="mnote-admin-access-policy-template-user" inner_html={user_access_policy_template}></div>
|
||||
<script inner_html={crate::ssr::pages::admin::ADMIN_POLICY_SCRIPT.to_string()}></script>
|
||||
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
|
||||
<script id="mnote-tree-live-controller" inner_html={TREE_LIVE_CONTROLLER_JS.to_string()}></script>
|
||||
</aside>
|
||||
|
||||
@@ -1900,7 +1900,7 @@ body {
|
||||
background: var(--atelier-document);
|
||||
}
|
||||
|
||||
.mnote-admin-policy-page{width:min(1080px,calc(100vw - 64px));margin:40px auto 64px}.mnote-admin-policy-header{margin-bottom:24px}.mnote-admin-policy-header h1{font-size:28px;font-weight:650}.mnote-admin-policy-header p,.mnote-admin-policy-note{color:var(--wolai-text-secondary);font-size:14px}.mnote-admin-policy-summary,.mnote-admin-policy-panel,.mnote-admin-policy-form{border:1px solid var(--wolai-border);border-radius:8px;background:#fff}.mnote-admin-policy-summary{display:grid;grid-template-columns:1fr 2fr;gap:16px;padding:16px;margin-bottom:16px}.mnote-admin-policy-summary-label{display:block;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-summary code,.mnote-admin-policy-json{white-space:pre-wrap;overflow-wrap:anywhere;font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px}.mnote-admin-policy-panel,.mnote-admin-policy-form{padding:16px;margin-bottom:16px}.mnote-admin-policy-panel-header{display:flex;justify-content:space-between;gap:12px;margin-bottom:12px}.mnote-admin-policy-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.mnote-admin-policy-form{display:flex;flex-direction:column;gap:12px}.mnote-admin-policy-form label{display:flex;flex-direction:column;gap:6px;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-form input,.mnote-admin-policy-form select{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 8px;background:#fff}.mnote-admin-policy-form button,.mnote-admin-policy-panel button{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 12px;background:var(--wolai-bg-sidebar);cursor:pointer}.mnote-admin-policy-json{min-height:48px;max-height:320px;overflow:auto;border-radius:6px;background:var(--wolai-bg-sidebar);padding:10px}.mnote-admin-policy-debug{margin-top:12px}.mnote-admin-policy-table{display:grid;gap:8px}.mnote-admin-policy-row{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(120px,.6fr) minmax(0,1fr);gap:12px;border:1px solid var(--wolai-border);border-radius:8px;padding:12px}.mnote-admin-policy-row strong,.mnote-admin-policy-row span{display:block;min-width:0}.mnote-admin-policy-row span{color:var(--wolai-text-secondary);font-size:12px;overflow-wrap:anywhere}.mnote-admin-policy-badge{display:inline-flex!important;margin:2px 4px 2px 0;border-radius:999px;padding:2px 8px;background:var(--wolai-bg-sidebar);font-size:12px}.mnote-admin-policy-badge[data-value="write"]{background:#DCFCE7}.mnote-admin-policy-badge[data-value="read"]{background:#DBEAFE}.mnote-admin-policy-badge[data-value="已撤销"]{background:#FEE2E2}.mnote-admin-policy-empty{border:1px dashed var(--wolai-border);border-radius:8px;padding:18px;color:var(--wolai-text-secondary);background:var(--wolai-bg-sidebar);font-size:13px}@media(max-width:960px){.mnote-admin-policy-summary,.mnote-admin-policy-grid,.mnote-admin-policy-row{grid-template-columns:1fr}}
|
||||
.mnote-admin-policy-page{width:min(1080px,calc(100vw - 64px));margin:40px auto 64px}.mnote-admin-policy-modal{position:fixed;inset:0;z-index:1200;display:grid;place-items:center}.mnote-admin-policy-modal__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.22)}.mnote-admin-policy-modal__panel{position:relative;width:min(920px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;border:1px solid var(--wolai-border);border-radius:8px;background:#fff;box-shadow:0 18px 54px rgba(15,23,42,.18);padding:20px}.mnote-admin-policy-modal__close{position:absolute;right:14px;top:14px;width:32px;height:32px;border:0;border-radius:6px;background:transparent;color:var(--wolai-text-secondary);cursor:pointer}.mnote-admin-policy-dialog__content{padding-right:28px}.mnote-admin-policy-header{margin-bottom:20px}.mnote-admin-policy-eyebrow{color:var(--wolai-text-secondary);font-size:13px}.mnote-admin-policy-header h1,.mnote-admin-policy-header h2{font-size:22px;font-weight:650}.mnote-admin-policy-header p,.mnote-admin-policy-note{color:var(--wolai-text-secondary);font-size:14px}.mnote-admin-policy-summary,.mnote-admin-policy-panel,.mnote-admin-policy-form{border:1px solid var(--wolai-border);border-radius:8px;background:#fff}.mnote-admin-policy-summary{display:grid;grid-template-columns:1fr 2fr;gap:16px;padding:16px;margin-bottom:16px}.mnote-admin-policy-summary-label{display:block;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-summary code,.mnote-admin-policy-json{white-space:pre-wrap;overflow-wrap:anywhere;font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px}.mnote-admin-policy-panel,.mnote-admin-policy-form{padding:16px;margin-bottom:16px}.mnote-admin-policy-panel-header{display:flex;justify-content:space-between;gap:12px;margin-bottom:12px}.mnote-admin-policy-grid{display:grid;grid-template-columns:minmax(260px,.8fr) minmax(0,1.4fr);gap:16px}.mnote-admin-policy-form{display:flex;flex-direction:column;gap:12px}.mnote-admin-policy-form label{display:flex;flex-direction:column;gap:6px;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-form input,.mnote-admin-policy-form select{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 8px;background:#fff}.mnote-admin-policy-form button,.mnote-admin-policy-panel button,.mnote-admin-policy-row button{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 12px;background:var(--wolai-bg-sidebar);cursor:pointer}.mnote-admin-policy-row button{margin-top:8px;color:#B91C1C}.mnote-admin-policy-json{min-height:48px;max-height:320px;overflow:auto;border-radius:6px;background:var(--wolai-bg-sidebar);padding:10px}.mnote-admin-policy-debug{margin-top:12px}.mnote-admin-policy-table{display:grid;gap:8px}.mnote-admin-policy-row{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(120px,.5fr) minmax(0,.8fr);gap:12px;border:1px solid var(--wolai-border);border-radius:8px;padding:12px}.mnote-admin-policy-row strong,.mnote-admin-policy-row span{display:block;min-width:0}.mnote-admin-policy-row span{color:var(--wolai-text-secondary);font-size:12px;overflow-wrap:anywhere}.mnote-admin-policy-badge{display:inline-flex!important;margin:2px 4px 2px 0;border-radius:999px;padding:2px 8px;background:var(--wolai-bg-sidebar);font-size:12px}.mnote-admin-policy-badge[data-value="write"]{background:#DCFCE7}.mnote-admin-policy-badge[data-value="read"]{background:#DBEAFE}.mnote-admin-policy-badge[data-value="已撤销"]{background:#FEE2E2}.mnote-admin-policy-empty{border:1px dashed var(--wolai-border);border-radius:8px;padding:18px;color:var(--wolai-text-secondary);background:var(--wolai-bg-sidebar);font-size:13px}@media(max-width:960px){.mnote-admin-policy-summary,.mnote-admin-policy-grid,.mnote-admin-policy-row{grid-template-columns:1fr}.mnote-admin-policy-dialog__content{padding-right:0}}
|
||||
|
||||
.mnote-trash-workbench {
|
||||
width: min(860px, calc(100vw - 64px));
|
||||
|
||||
Reference in New Issue
Block a user