diff --git a/Cargo.lock b/Cargo.lock index 339d94123..cf2485336 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1266,7 +1266,7 @@ dependencies = [ "tantivy 0.26.1", "text-splitter", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.29.0", "tonic", "tracing", "tracing-actix-web", @@ -1308,6 +1308,7 @@ dependencies = [ "tauri-build", "tauri-plugin-barcode-scanner", "tauri-plugin-deep-link", + "tauri-plugin-mcp-bridge", "tauri-plugin-opener", "tauri-plugin-process", "tokio", @@ -1377,7 +1378,7 @@ dependencies = [ "sha2 0.10.9", "sled", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.29.0", "toml 1.1.2+spec-1.1.0", "tracing", "tracing-chrome", @@ -1724,13 +1725,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.4", ] [[package]] @@ -4171,9 +4181,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -8560,10 +8570,10 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "once_cell", "png 0.18.1", "serde", @@ -9134,6 +9144,22 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.4" @@ -9144,6 +9170,22 @@ dependencies = [ "objc2-exception-helper", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + [[package]] name = "objc2-app-kit" version = "0.3.2" @@ -9151,10 +9193,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.1", - "block2", - "objc2", + "block2 0.6.2", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -9164,8 +9219,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.11.1", - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -9174,8 +9252,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] @@ -9186,7 +9264,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.1", "dispatch2", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -9197,19 +9275,43 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.11.1", "dispatch2", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + [[package]] name = "objc2-core-image" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", ] [[package]] @@ -9218,8 +9320,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] @@ -9229,7 +9331,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.11.1", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", ] @@ -9249,6 +9351,18 @@ dependencies = [ "cc", ] +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" @@ -9256,8 +9370,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.1", - "block2", - "objc2", + "block2 0.6.2", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -9268,10 +9382,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.11.1", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", ] +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -9279,9 +9430,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.11.1", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications 0.2.2", ] [[package]] @@ -9291,18 +9473,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.1", - "block2", - "objc2", - "objc2-cloud-kit", - "objc2-core-data", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image", - "objc2-core-location", + "objc2-core-image 0.3.2", + "objc2-core-location 0.3.2", "objc2-core-text", - "objc2-foundation", - "objc2-quartz-core", - "objc2-user-notifications", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-user-notifications 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -9311,8 +9517,21 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68bc69301064cebefc6c4c90ce9cba69225239e4b8ff99d445a2b5563797da65" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -9322,11 +9541,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ "bitflags 2.11.1", - "block2", - "objc2", - "objc2-app-kit", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -12548,11 +12767,11 @@ dependencies = [ "bytemuck", "js-sys", "ndk", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "raw-window-handle", "redox_syscall 0.5.18", "tracing", @@ -13271,7 +13490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.6.2", "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", @@ -13287,10 +13506,10 @@ dependencies = [ "log", "ndk", "ndk-sys", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-ui-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", "once_cell", "parking_lot 0.12.5", "percent-encoding", @@ -13356,11 +13575,11 @@ dependencies = [ "log", "mime", "muda", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "objc2-web-kit 0.3.2", "percent-encoding", "plist", "raw-window-handle", @@ -13498,6 +13717,35 @@ dependencies = [ "windows-result 0.3.4", ] +[[package]] +name = "tauri-plugin-mcp-bridge" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee9c726f2293f54dc9a61739886967a75c9f1701d17a3dcb8a4544f7353d4861" +dependencies = [ + "base64 0.22.1", + "block2 0.5.1", + "futures-util", + "image", + "jni 0.21.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "objc2-web-kit 0.2.2", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite 0.28.0", + "uuid", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -13506,8 +13754,8 @@ checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" dependencies = [ "dunce", "glob", - "objc2-app-kit", - "objc2-foundation", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "open", "schemars 0.8.22", "serde", @@ -13541,9 +13789,9 @@ dependencies = [ "gtk", "http 1.4.0", "jni 0.21.1", - "objc2", - "objc2-ui-kit", - "objc2-web-kit", + "objc2 0.6.4", + "objc2-ui-kit 0.3.2", + "objc2-web-kit 0.3.2", "raw-window-handle", "serde", "serde_json", @@ -13565,8 +13813,8 @@ dependencies = [ "http 1.4.0", "jni 0.21.1", "log", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "once_cell", "percent-encoding", "raw-window-handle", @@ -13944,6 +14192,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", +] + [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -13957,7 +14217,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.29.0", ] [[package]] @@ -14344,11 +14604,11 @@ dependencies = [ "dirs 6.0.0", "libappindicator", "muda", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", "once_cell", "png 0.18.1", "serde", @@ -14362,6 +14622,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.4", + "sha1 0.10.6", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.29.0" @@ -15762,10 +16039,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "raw-window-handle", "windows-sys 0.59.0", "windows-version", @@ -16692,7 +16969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", - "block2", + "block2 0.6.2", "cookie 0.18.1", "crossbeam-channel", "dirs 6.0.0", @@ -16706,12 +16983,12 @@ dependencies = [ "jni 0.21.1", "libc", "ndk", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "objc2-web-kit 0.3.2", "once_cell", "percent-encoding", "raw-window-handle", diff --git a/TESTING_COVERAGE.md b/TESTING_COVERAGE.md index aea6d5385..1a1f5f89f 100644 --- a/TESTING_COVERAGE.md +++ b/TESTING_COVERAGE.md @@ -259,3 +259,27 @@ must compute the same differing set on either end of the wire. Both carry the same test names. A fix to one is a fix to the other; the golden-vector tests (`item_fingerprint_matches_golden_vector`) pin the hashing, but the *traversal* is only kept in step by mirroring the tests, so do that deliberately. + +`lib/src/genesis.rs` ↔ `browser/lib/src/genesis.ts` also share a personal-drive +derivation (`personal_drive_subject` / `personalDriveSubject`). The cross-lang +vector (`personal_drive_cross_lang_vector`) pins the nonce, signature, and DID. + +## Personal drive identity + +| Flow | Where | +|---|---| +| Same agent key → same personal-drive DID | `lib/src/genesis.rs`, `browser/lib/src/genesis.test.ts` | +| Cross-language personal-drive vector | `genesis.rs` + `genesis.test.ts` | +| Repeat genesis for that DID merges Loro state | `lib/src/commit.rs::repeat_personal_drive_genesis_merges` | +| Repeat genesis without a cert is still rejected | `lib/src/commit.rs::repeat_genesis_without_cert_is_still_rejected` | +| `createDrive({ personal: true })` uses the derived DID | `browser/lib/src/store.personal-drive.test.ts` | +| Two stores with the same key mint the same subject | `store.personal-drive.test.ts` | +| Extra drives are listed on the derived personal drive | `store.personal-drive.test.ts` | +| Lists from a previous random-DID home are unioned onto the derived drive | `store.personal-drive.test.ts` | +| `Agent.personalDriveSubject` matches the genesis helper | `agent.test.ts` | +| `Db::setup` / `ensure_personal_drive` use the derived DID and are idempotent | `lib/src/db.rs::personal_drive_tests` | +| Extra `Db::create_drive` is listed on the personal drive | `lib/src/db.rs::personal_drive_tests` | + +Not covered: Flutter `create_drive` still mints a random DID (the Rust +`ensure_personal_drive` helper exists for `setup()`). E2E sign-in on a second +machine with the old machine offline. diff --git a/browser/data-browser/package.json b/browser/data-browser/package.json index 05018abfd..51bc8ebf3 100644 --- a/browser/data-browser/package.json +++ b/browser/data-browser/package.json @@ -134,10 +134,10 @@ }, "scripts": { "build": "if [ \"$SKIP_WASM_BUILD\" = \"1\" ]; then echo 'SKIP_WASM_BUILD=1, skipping wasm-pack'; else pnpm build:wasm; fi && vite build", - "build:tauri": "pnpm build:wasm && TAURI=1 vite build", + "build:tauri": "pnpm --filter \"@tomic/lib\" --filter \"@tomic/react\" run build && pnpm build:wasm && TAURI=1 vite build", "build:wasm": "cd ../../wasm && export CARGO_TARGET_DIR=../target/wasm-pack && cargo bin wasm-pack --version && env -u CARGO_ENCODED_RUSTFLAGS -u RUSTFLAGS cargo bin wasm-pack build --target web --out-dir pkg && cp pkg/atomic_wasm.js pkg/atomic_wasm_bg.wasm ../browser/data-browser/public/wasm/", "dev": "vite", - "dev:tauri": "TAURI=1 vite", + "dev:tauri": "pnpm --filter \"@tomic/lib\" --filter \"@tomic/react\" run build && TAURI=1 vite", "format-check": "oxfmt -c ../.oxfmtrc.json --check ./src", "format": "oxfmt -c ../.oxfmtrc.json ./src", "lint": "oxlint -c ../.oxlintrc.json . && pnpm format-check", diff --git a/browser/data-browser/src/App.tsx b/browser/data-browser/src/App.tsx index 8f3f6509c..b0fcbb16e 100644 --- a/browser/data-browser/src/App.tsx +++ b/browser/data-browser/src/App.tsx @@ -112,7 +112,28 @@ const store = new Store({ const initialDrive = driveStorage.get(); if (initialDrive) { - store.setDrive(initialDrive); + // `setDrive` reads an http(s) subject as a SERVER ORIGIN and repoints the + // app at it. That is intended when the user opens an external drive, but + // restoring the last session is not that choice — and a stored URL from a + // previous switch would re-apply it on every launch, stranding the app on a + // server it cannot authenticate against with no obvious way back. + const restoresForeignServer = + /^https?:\/\//.test(initialDrive) && + (() => { + try { + return new URL(initialDrive).origin !== new URL(serverUrl).origin; + } catch { + return true; + } + })(); + + if (restoresForeignServer) { + console.warn( + `[atomic] Ignoring stored drive '${initialDrive}': opening it would move this app to another server. Pick it from the drive switcher to switch deliberately.`, + ); + } else { + store.setDrive(initialDrive); + } } // A deep link into a resource (share/show `?subject=` entry URL) starts the diff --git a/browser/data-browser/src/chunks/RTE/useLoroSync.ts b/browser/data-browser/src/chunks/RTE/useLoroSync.ts index 890447c66..0722fee17 100644 --- a/browser/data-browser/src/chunks/RTE/useLoroSync.ts +++ b/browser/data-browser/src/chunks/RTE/useLoroSync.ts @@ -73,7 +73,23 @@ export function useLoroSync( const unsub = store.subscribeLoroEphemeral( subject, (update: Uint8Array) => { - ephemeralStore.apply(update); + try { + ephemeralStore.apply(update); + } catch (e) { + // A cursor can arrive before the content it points into. Positions + // reference Loro containers, and a peer editing a document this + // device has not caught up on yet names containers the local doc + // does not have — Loro throws "The container does not exist in the + // doc". That became routine once presence started crossing peer + // links: the update travels on its own channel and does not wait for + // document state. + // + // Dropping it is correct. Presence is a snapshot of right now, so + // there is nothing to replay — the next update after the document + // catches up applies cleanly. Throwing here only produced an uncaught + // error per keystroke of someone else's typing. + console.debug('[presence] skipped a cursor for unsynced content:', e); + } }, ); diff --git a/browser/data-browser/src/chunks/TablePage/QuickAddBar.tsx b/browser/data-browser/src/chunks/TablePage/QuickAddBar.tsx index d2cb9a801..b9ecba88f 100644 --- a/browser/data-browser/src/chunks/TablePage/QuickAddBar.tsx +++ b/browser/data-browser/src/chunks/TablePage/QuickAddBar.tsx @@ -1,5 +1,5 @@ import { useStore, type Property, type Resource } from '@tomic/react'; -import { useState, type JSX } from 'react'; +import { useRef, useState, type JSX } from 'react'; import { styled } from 'styled-components'; import { FaPlus } from 'react-icons/fa6'; import toast from 'react-hot-toast'; @@ -36,20 +36,20 @@ export function QuickAddBar({ const store = useStore(); const [typed, setTyped] = useState(''); const [busy, setBusy] = useState(false); + /** + * Named while a save was still in flight, waiting its turn. + * + * A ref, not state: `submit` below reads it from inside a promise callback, + * where a state value would be whatever it was when that callback was + * created. + */ + const queued = useRef([]); // A spec with a field is asking for a value; creating a blank row instead // would be a worse guess than doing nothing. const ready = !spec.field || typed.trim() !== ''; - const create = () => { - if (!ready || busy) { - return; - } - - const value = typed.trim(); - // Cleared up front, not after the save: anything typed while the save is in - // flight would otherwise be wiped when the reset landed. - setTyped(''); + const submit = (value: string) => { setBusy(true); void createQuickAddRow(store, { @@ -66,7 +66,41 @@ export function QuickAddBar({ console.error('Failed to quick-add a row', error); toast.error(`Could not add the ${tableClass.title.toLowerCase()}`); }) - .finally(() => setBusy(false)); + .finally(() => { + const next = queued.current.shift(); + + if (next === undefined) { + setBusy(false); + + return; + } + + submit(next); + }); + }; + + const create = () => { + if (!ready) { + return; + } + + const value = typed.trim(); + // Cleared up front, not after the save: anything typed while the save is in + // flight would otherwise be wiped when the reset landed. + setTyped(''); + + // Queued rather than dropped. This used to return early while a save was in + // flight, which lost the keystroke in silence — type, press enter, and + // nothing happens: no row, no error, the text still sitting in the field. + // Queueing keeps that from happening without letting the bar fire saves + // concurrently, which is load this runs under on the slowest devices. + if (busy) { + queued.current.push(value); + + return; + } + + submit(value); }; return ( diff --git a/browser/data-browser/src/chunks/TablePage/TableResource.tsx b/browser/data-browser/src/chunks/TablePage/TableResource.tsx index c13ec6dd3..6453d1a3b 100644 --- a/browser/data-browser/src/chunks/TablePage/TableResource.tsx +++ b/browser/data-browser/src/chunks/TablePage/TableResource.tsx @@ -679,6 +679,33 @@ export const TableResource: React.FC = ({ // missing rows (surfacing as an unhandled rejection in // `useMemberFromCollection`). The clamp guards that instant; the filter // rebase effect below then recaptures a fresh baseline. + // A row can also arrive from somewhere this session knows nothing about: a + // paired peer, or another tab on the same drive. That grows the collection + // without going through the new-row flow, so the frozen baseline never moves + // and the grid keeps rendering the count it captured at load — the row is in + // the collection, in the store, complete, and simply never drawn. Measured + // against a paired node: `totalMembers` 8, `aria-setsize` 5. + // + // Freezing exists to stop a materialising session row from remounting; it was + // never meant to hide other people's rows. So account for what this session + // contributed — each materialised draft adds a member while still rendering + // from `newRowSubjects` — and let anything beyond that raise the baseline. + // + // Only ever raises it. Shrink stays with `decrementMemberCount` and the clamp + // below, and session rows keep their `_new:` key through the index shift + // (`itemKey` offsets by `memberCount`), so nothing remounts. + if (baselineMemberCountRef.current !== null) { + const materialisedSessionRows = newRowSubjects.filter(subject => + store.isAliased(subject), + ).length; + const accountedFor = + baselineMemberCountRef.current + materialisedSessionRows; + + if (collection.totalMembers > accountedFor) { + baselineMemberCountRef.current += collection.totalMembers - accountedFor; + } + } + const memberCount = Math.min( baselineMemberCountRef.current ?? collection.totalMembers, collection.totalMembers, diff --git a/browser/data-browser/src/helpers/agentStorage.ts b/browser/data-browser/src/helpers/agentStorage.ts index 685287c36..fb6beeeee 100644 --- a/browser/data-browser/src/helpers/agentStorage.ts +++ b/browser/data-browser/src/helpers/agentStorage.ts @@ -1,4 +1,9 @@ -import { Agent, SubtleCryptoProvider, JSCryptoProvider } from '@tomic/react'; +import { + Agent, + SubtleCryptoProvider, + JSCryptoProvider, + legacySubjectFromSecret, +} from '@tomic/react'; import { del, get, set } from 'idb-keyval'; import { adoptAgentOnDevice } from './adoptAgent'; import { clearSessionDbKeys, ensureDbKeyOnSignIn } from './localDbKey'; @@ -8,6 +13,24 @@ const AGENT_IDB_KEY = 'atomic.agent'; interface StoredAgent { keyPair: CryptoKeyPair; subject: string; + /** + * Carried across restarts because they only exist on the secret, and the + * secret is read exactly once — at sign-in. Without them a restored Agent + * looks brand-new to the pre-DID migration, which reads both and returns at + * its first line, so a returning user's drives are never adopted. The + * migration then appears to do nothing forever, having run only in the + * session where the secret was pasted. + */ + legacySubject?: string; + initialDrive?: string; + /** + * The derived personal-drive DID. Stored because it cannot be recomputed + * from the non-extractable keypair beside it: deriving it means signing, and + * WebCrypto signatures are not reproducible (see + * `Agent.personalDriveSubject`). Written at sign-in, while the secret is + * still readable. + */ + personalDrive?: string; } /** @@ -17,6 +40,11 @@ interface StoredAgent { interface StoredAgentFallback { privateKey: string; subject: string; + /** See {@link StoredAgent}. */ + legacySubject?: string; + initialDrive?: string; + /** See {@link StoredAgent}. */ + personalDrive?: string; } const AGENT_FALLBACK_KEY = 'atomic.agent.fallback'; @@ -44,7 +72,13 @@ export async function getAgentFromIDB(): Promise { // readable copy below — a corrupt keypair must not lock the user out. await provider.sign('atomic-key-check'); - const agent = new Agent(provider, storedAgent.subject); + const agent = new Agent( + provider, + storedAgent.subject, + storedAgent.initialDrive, + ); + agent.legacySubject = storedAgent.legacySubject; + agent.personalDrive = storedAgent.personalDrive; // Heal installs written while the readable key was saved // unconditionally: a plaintext copy beside a non-extractable key hands @@ -68,10 +102,15 @@ export async function getAgentFromIDB(): Promise { if (fallback) { try { - return new Agent( + const agent = new Agent( new JSCryptoProvider(fallback.privateKey), fallback.subject, + fallback.initialDrive, ); + agent.legacySubject = fallback.legacySubject; + agent.personalDrive = fallback.personalDrive; + + return agent; } catch (e) { console.error('Failed to load agent from fallback:', e); } @@ -135,9 +174,20 @@ export async function saveAgentToIDB( throw new Error('Subject is required'); } + // Preserve the secret-only fields: this overload re-stores a keypair and + // has no secret to re-derive them from, and dropping them would silently + // disable the migration for a returning user. + const previous = (await get(AGENT_IDB_KEY)) as StoredAgent | undefined; + await set(AGENT_IDB_KEY, { keyPair: keyPairOrSecret, subject, + legacySubject: + previous?.subject === subject ? previous.legacySubject : undefined, + initialDrive: + previous?.subject === subject ? previous.initialDrive : undefined, + personalDrive: + previous?.subject === subject ? previous.personalDrive : undefined, } satisfies StoredAgent); } @@ -149,6 +199,9 @@ async function storeSecret(secret: string): Promise { // the only moment it passes through JS once the keypair is stored // non-extractably. const decoded = JSON.parse(atob(secret)); + // Derived here, once, from the raw key — the stored keypair cannot + // reproduce it. See `StoredAgent.personalDrive`. + const personalDrive = await Agent.personalDriveSubjectFromSecret(secret); { // Prefer the non-extractable keypair. Once stored this way the private key @@ -161,6 +214,9 @@ async function storeSecret(secret: string): Promise { await set(AGENT_IDB_KEY, { keyPair, subject: resolvedSubject, + legacySubject: legacySubjectFromSecret(secret), + initialDrive: decoded.initialDrive, + personalDrive, } satisfies StoredAgent); await del(AGENT_FALLBACK_KEY); @@ -179,6 +235,9 @@ async function storeSecret(secret: string): Promise { await set(AGENT_FALLBACK_KEY, { privateKey: decoded.privateKey, subject: newSubject, + legacySubject: legacySubjectFromSecret(secret), + initialDrive: decoded.initialDrive, + personalDrive, } satisfies StoredAgentFallback); // Drop a keypair from a previous account, so it can't be loaded instead. await del(AGENT_IDB_KEY); diff --git a/browser/data-browser/src/helpers/managedServer.ts b/browser/data-browser/src/helpers/managedServer.ts index 8bca54b29..041e2b5dd 100644 --- a/browser/data-browser/src/helpers/managedServer.ts +++ b/browser/data-browser/src/helpers/managedServer.ts @@ -9,6 +9,11 @@ export type ServerPeer = { deviceName: string | null; /** Whether it holds a connection to the server right now. */ live: boolean; + /** Unix millis of the last successful sync, if it has ever synced. */ + lastSeen?: number; + /** Resources moved by that last sync, each way. Not lifetime totals. */ + lastSent?: number; + lastReceived?: number; }; /** @@ -90,6 +95,18 @@ export async function fetchManagedInfo( nodeId, deviceName: readString(p?.[peerProps.deviceName]), live: p?.[peerProps.live] === true, + lastSeen: + typeof p?.[peerProps.lastSeen] === 'number' + ? (p[peerProps.lastSeen] as number) + : undefined, + lastSent: + typeof p?.[peerProps.lastSent] === 'number' + ? (p[peerProps.lastSent] as number) + : undefined, + lastReceived: + typeof p?.[peerProps.lastReceived] === 'number' + ? (p[peerProps.lastReceived] as number) + : undefined, } : null; }) diff --git a/browser/data-browser/src/helpers/personalDrive.ts b/browser/data-browser/src/helpers/personalDrive.ts index 01bfb7180..ae98e89a2 100644 --- a/browser/data-browser/src/helpers/personalDrive.ts +++ b/browser/data-browser/src/helpers/personalDrive.ts @@ -1,36 +1,11 @@ -import { Agent, Store, core } from '@tomic/react'; +import { Agent, Store } from '@tomic/react'; /** - * Resolves the agent's personal home drive: `personalDrive` on the Agent - * resource when present, else `initialDrive` from the secret. + * Resolves the agent's personal home drive: the DID derived from the Agent + * key. Same secret → same subject on every device. The Agent's + * `personalDrive` pointer is not identity. * - * Deliberately does NOT fall back to the first entry of `drives`. That list is - * every drive the user owns, in no meaningful order, and on a pre-DID account - * the server synthesises the DID Agent from the legacy one — so `drives` is - * populated while `personalDrive` is not. Taking `drives[0]` then promotes an - * arbitrary drive to "Private drive" and redirects sign-in into it, which is - * both wrong and destructive-looking: the user is dropped somewhere unexpected - * and their real list appears to have collapsed to one entry. - * - * Returning undefined is the honest answer, and it is handled: the caller - * falls through to whatever lives at `/`, and `adoptLegacyDriveList` - * provisions a real private drive on sign-in. - * - * Always fetches the agent resource fresh from the server. Signing in on a - * device that has none of the account's data resolves the agent to a - * synthesized stub (derived from the DID's public key) that carries no - * `personalDrive` — and that stub gets cached. When the drive later lands on - * the server (a paired device pushed the real agent resource with it), a - * cached read would keep returning the stub, so the workspace would never be - * found. A forced read sees the real one. - * - * Skip that forced fetch for local-only agents (e.g. the demo's guest - * identity, see `chunks/Demo/guestAgent.ts`): they never exist on any - * server, so the fetch is a guaranteed failure. Worse, `fetchResourceFromServer` - * writes straight into the store's shared resource cache — every OTHER - * consumer of `useResource(agent.subject)` (message authors, avatars) reads - * that same entry, so this call's failure flashes "Error loading resource" - * everywhere that subject is displayed, not just here. + * Local-only / unsigned agents still fall back to `initialDrive`. */ export async function fetchPersonalDriveSubject( store: Store, @@ -45,22 +20,27 @@ export async function fetchPersonalDriveSubject( } try { - const r = await store.fetchResourceFromServer(agent.subject, { - noWebSocket: true, - }); + return await agent.personalDriveSubject(); + } catch { + // `initialDrive` travels with an old secret and is usually an http(s) URL + // on the server the account is migrating away from. Handing that to + // `setDrive` does not open a drive — it is read as a SERVER ORIGIN and + // repoints the whole app at that server, which then cannot authenticate + // this agent or serve any of its `did:ad:` resources. Signing in must not + // be able to move the app's server as a side effect of a failed + // derivation, so only a same-origin fallback is usable here. + if (!agent.initialDrive) return undefined; - if (r.error) { - return agent.initialDrive; - } + if (!/^https?:\/\//.test(agent.initialDrive)) return agent.initialDrive; - const personal = r.get(core.properties.personalDrive); + try { + const sameServer = + new URL(agent.initialDrive).origin === + new URL(store.getServerUrl()).origin; - if (typeof personal === 'string' && personal.length > 0) { - return personal; + return sameServer ? agent.initialDrive : undefined; + } catch { + return undefined; } - } catch { - // ignore fetch errors; fall back below } - - return agent.initialDrive; } diff --git a/browser/data-browser/src/helpers/serverOntology.ts b/browser/data-browser/src/helpers/serverOntology.ts index b4f03dc6c..5aaeb6fbf 100644 --- a/browser/data-browser/src/helpers/serverOntology.ts +++ b/browser/data-browser/src/helpers/serverOntology.ts @@ -20,6 +20,11 @@ export const peerProps = { nodeId: 'https://atomicdata.dev/properties/peer/nodeId', deviceName: 'https://atomicdata.dev/properties/peer/deviceName', live: 'https://atomicdata.dev/properties/peer/live', + /** Unix millis of the last successful sync. Absent if it has never synced. */ + lastSeen: 'https://atomicdata.dev/properties/peer/lastSeen', + /** Resources moved by the LAST sync — not a lifetime total. */ + lastSent: 'https://atomicdata.dev/properties/peer/lastSent', + lastReceived: 'https://atomicdata.dev/properties/peer/lastReceived', } as const; export const NODE_DID_PREFIX = 'did:ad:node:'; diff --git a/browser/data-browser/src/helpers/withDeadline.ts b/browser/data-browser/src/helpers/withDeadline.ts index 497bcde13..433ac249f 100644 --- a/browser/data-browser/src/helpers/withDeadline.ts +++ b/browser/data-browser/src/helpers/withDeadline.ts @@ -1,37 +1,6 @@ /** - * Resolve to `fallback` if `work` has not finished within `ms`. - * - * For the places where a slow answer and no answer are the same thing to the - * user, and where the caller already has a correct behaviour for "could not - * find out". Signing in is the motivating case: it asks a server which drive - * belongs to this agent, and a device that just restored a secret may have no - * server that knows — the embedded node in the desktop and Android apps - * answers, but not about an account it has never seen. Those fetches have no - * timeout of their own, so the await never settled and sign-in sat on - * "Restoring…" indefinitely, on exactly the device that had nothing. - * - * Deliberately not a cancellation: `work` keeps running and its result is - * discarded. Rejections are swallowed for the same reason — once we have - * stopped waiting, a late failure is not the caller's problem and must not - * surface as an unhandled rejection. + * Moved into `@tomic/lib` so the Store can bound its own migration fetches + * (see `Store.fetchLegacyAgentResource`). Re-exported here so existing + * imports keep working. */ -export function withDeadline( - work: Promise, - ms: number, - fallback: T, -): Promise { - return new Promise(resolve => { - const timer = setTimeout(() => resolve(fallback), ms); - - work.then( - value => { - clearTimeout(timer); - resolve(value); - }, - () => { - clearTimeout(timer); - resolve(fallback); - }, - ); - }); -} +export { withDeadline } from '@tomic/lib'; diff --git a/browser/data-browser/src/locales/de.po b/browser/data-browser/src/locales/de.po index f81d7f9e5..0db416bc7 100644 --- a/browser/data-browser/src/locales/de.po +++ b/browser/data-browser/src/locales/de.po @@ -597,6 +597,7 @@ msgstr "" #: src/components/SideBar/SyncMenuItem.tsx #: src/components/SideBar/SyncMenuItem.tsx #: src/routes/SyncRoute.tsx +#: src/routes/SyncRoute.tsx msgid "Connected" msgstr "" @@ -995,12 +996,10 @@ msgid "This collection is empty" msgstr "" #: src/components/NewIdentitySection.tsx -#: src/views/InvitePage.tsx msgid "Personal" msgstr "" #: src/components/NewIdentitySection.tsx -#: src/views/InvitePage.tsx msgid "Your private space on this server. Only you can read and write here." msgstr "" @@ -1757,15 +1756,11 @@ msgstr "" msgid "Error: Expected {0}" msgstr "" -#. 0: data.count; 1: data.count !== 1 ? 's' \\: ''; 2: peerName -#: src/routes/SyncRoute.tsx -msgid "Synced {0} resource{1} with {2}" -msgstr "" +#~ msgid "Synced {0} resource{1} with {2}" +#~ msgstr "" -#. 0: data.count; 1: data.count !== 1 ? 's' \\: '' -#: src/routes/SyncRoute.tsx -msgid "Synced {0} resource{1}" -msgstr "" +#~ msgid "Synced {0} resource{1}" +#~ msgstr "" #: src/helpers/managed/deviceLink.ts #: src/routes/SettingsAgent.tsx @@ -5274,12 +5269,15 @@ msgstr "" msgid "Copy Node ID" msgstr "" +#: src/routes/SyncRoute.tsx #: src/routes/SyncRoute.tsx #: src/routes/SyncRoute.tsx msgid "Node ID copied" msgstr "" #. 0: rawToNodeDid(serverNodeId) +#. 0: peer.nodeId +#: src/routes/SyncRoute.tsx #: src/routes/SyncRoute.tsx msgid "Copy {0}" msgstr "" @@ -7001,3 +6999,17 @@ msgstr "" #: src/components/Vault/VaultPanel.tsx msgid "Checking this workspace’s backup…" msgstr "" + +#. 0: withWhom +#: src/routes/SyncRoute.tsx +msgid "Already up to date{0}" +msgstr "" + +#. 0: withWhom; 1: sent; 2: received +#: src/routes/SyncRoute.tsx +msgid "Synced{0} — sent {1}, received {2}" +msgstr "" + +#: src/routes/SyncRoute.tsx +msgid "Paired" +msgstr "" diff --git a/browser/data-browser/src/locales/en.po b/browser/data-browser/src/locales/en.po index 4171561b7..f292da74f 100644 --- a/browser/data-browser/src/locales/en.po +++ b/browser/data-browser/src/locales/en.po @@ -597,6 +597,7 @@ msgstr "Not configured" #: src/components/SideBar/SyncMenuItem.tsx #: src/components/SideBar/SyncMenuItem.tsx #: src/routes/SyncRoute.tsx +#: src/routes/SyncRoute.tsx msgid "Connected" msgstr "Connected" @@ -995,12 +996,10 @@ msgid "This collection is empty" msgstr "This collection is empty" #: src/components/NewIdentitySection.tsx -#: src/views/InvitePage.tsx msgid "Personal" msgstr "Personal" #: src/components/NewIdentitySection.tsx -#: src/views/InvitePage.tsx msgid "Your private space on this server. Only you can read and write here." msgstr "Your private space on this server. Only you can read and write here." @@ -1757,15 +1756,11 @@ msgstr "In sync" msgid "Error: Expected {0}" msgstr "Error: Expected {0}" -#. 0: data.count; 1: data.count !== 1 ? 's' \\: ''; 2: peerName -#: src/routes/SyncRoute.tsx -msgid "Synced {0} resource{1} with {2}" -msgstr "Synced {0} resource{1} with {2}" +#~ msgid "Synced {0} resource{1} with {2}" +#~ msgstr "Synced {0} resource{1} with {2}" -#. 0: data.count; 1: data.count !== 1 ? 's' \\: '' -#: src/routes/SyncRoute.tsx -msgid "Synced {0} resource{1}" -msgstr "Synced {0} resource{1}" +#~ msgid "Synced {0} resource{1}" +#~ msgstr "Synced {0} resource{1}" #: src/helpers/managed/deviceLink.ts #: src/routes/SettingsAgent.tsx @@ -5308,12 +5303,15 @@ msgstr "Switching to {0}…" msgid "Copy Node ID" msgstr "Copy Node ID" +#: src/routes/SyncRoute.tsx #: src/routes/SyncRoute.tsx #: src/routes/SyncRoute.tsx msgid "Node ID copied" msgstr "Node ID copied" #. 0: rawToNodeDid(serverNodeId) +#. 0: peer.nodeId +#: src/routes/SyncRoute.tsx #: src/routes/SyncRoute.tsx msgid "Copy {0}" msgstr "Copy {0}" @@ -7035,3 +7033,17 @@ msgstr "This workspace already lives on {0}. Moving it here is a migration, not #: src/components/Vault/VaultPanel.tsx msgid "Checking this workspace’s backup…" msgstr "Checking this workspace’s backup…" + +#. 0: withWhom +#: src/routes/SyncRoute.tsx +msgid "Already up to date{0}" +msgstr "Already up to date{0}" + +#. 0: withWhom; 1: sent; 2: received +#: src/routes/SyncRoute.tsx +msgid "Synced{0} — sent {1}, received {2}" +msgstr "Synced{0} — sent {1}, received {2}" + +#: src/routes/SyncRoute.tsx +msgid "Paired" +msgstr "Paired" diff --git a/browser/data-browser/src/locales/es.po b/browser/data-browser/src/locales/es.po index 5d6c931dc..d74923a02 100644 --- a/browser/data-browser/src/locales/es.po +++ b/browser/data-browser/src/locales/es.po @@ -597,6 +597,7 @@ msgstr "No configurado" #: src/components/SideBar/SyncMenuItem.tsx #: src/components/SideBar/SyncMenuItem.tsx #: src/routes/SyncRoute.tsx +#: src/routes/SyncRoute.tsx msgid "Connected" msgstr "Conectado" @@ -995,12 +996,10 @@ msgid "This collection is empty" msgstr "Esta colección está vacía" #: src/components/NewIdentitySection.tsx -#: src/views/InvitePage.tsx msgid "Personal" msgstr "Personal" #: src/components/NewIdentitySection.tsx -#: src/views/InvitePage.tsx msgid "Your private space on this server. Only you can read and write here." msgstr "Tu espacio privado en este servidor. Solo tú puedes leer y escribir aquí." @@ -1757,15 +1756,11 @@ msgstr "Sincronizado" msgid "Error: Expected {0}" msgstr "Error: Se esperaba {0}" -#. 0: data.count; 1: data.count !== 1 ? 's' \\: ''; 2: peerName -#: src/routes/SyncRoute.tsx -msgid "Synced {0} resource{1} with {2}" -msgstr "Sincronizados {0} recurso{1} con {2}" +#~ msgid "Synced {0} resource{1} with {2}" +#~ msgstr "Sincronizados {0} recurso{1} con {2}" -#. 0: data.count; 1: data.count !== 1 ? 's' \\: '' -#: src/routes/SyncRoute.tsx -msgid "Synced {0} resource{1}" -msgstr "{0} recurso{1} sincronizado{1}" +#~ msgid "Synced {0} resource{1}" +#~ msgstr "{0} recurso{1} sincronizado{1}" #: src/helpers/managed/deviceLink.ts #: src/routes/SettingsAgent.tsx @@ -5274,12 +5269,15 @@ msgstr "" msgid "Copy Node ID" msgstr "" +#: src/routes/SyncRoute.tsx #: src/routes/SyncRoute.tsx #: src/routes/SyncRoute.tsx msgid "Node ID copied" msgstr "" #. 0: rawToNodeDid(serverNodeId) +#. 0: peer.nodeId +#: src/routes/SyncRoute.tsx #: src/routes/SyncRoute.tsx msgid "Copy {0}" msgstr "" @@ -7001,3 +6999,17 @@ msgstr "" #: src/components/Vault/VaultPanel.tsx msgid "Checking this workspace’s backup…" msgstr "" + +#. 0: withWhom +#: src/routes/SyncRoute.tsx +msgid "Already up to date{0}" +msgstr "" + +#. 0: withWhom; 1: sent; 2: received +#: src/routes/SyncRoute.tsx +msgid "Synced{0} — sent {1}, received {2}" +msgstr "" + +#: src/routes/SyncRoute.tsx +msgid "Paired" +msgstr "" diff --git a/browser/data-browser/src/locales/fr.po b/browser/data-browser/src/locales/fr.po index cccdcf6c6..efd65b70a 100644 --- a/browser/data-browser/src/locales/fr.po +++ b/browser/data-browser/src/locales/fr.po @@ -597,6 +597,7 @@ msgstr "Non configuré" #: src/components/SideBar/SyncMenuItem.tsx #: src/components/SideBar/SyncMenuItem.tsx #: src/routes/SyncRoute.tsx +#: src/routes/SyncRoute.tsx msgid "Connected" msgstr "Connecté" @@ -995,12 +996,10 @@ msgid "This collection is empty" msgstr "Cette collection est vide" #: src/components/NewIdentitySection.tsx -#: src/views/InvitePage.tsx msgid "Personal" msgstr "Personnel" #: src/components/NewIdentitySection.tsx -#: src/views/InvitePage.tsx msgid "Your private space on this server. Only you can read and write here." msgstr "Votre espace privé sur ce serveur. Seul vous pouvez y lire et écrire." @@ -1757,15 +1756,11 @@ msgstr "Synchronisé" msgid "Error: Expected {0}" msgstr "Erreur : {0} attendu" -#. 0: data.count; 1: data.count !== 1 ? 's' \\: ''; 2: peerName -#: src/routes/SyncRoute.tsx -msgid "Synced {0} resource{1} with {2}" -msgstr "Ressource{1} {0} synchronisée avec {2}" +#~ msgid "Synced {0} resource{1} with {2}" +#~ msgstr "Ressource{1} {0} synchronisée avec {2}" -#. 0: data.count; 1: data.count !== 1 ? 's' \\: '' -#: src/routes/SyncRoute.tsx -msgid "Synced {0} resource{1}" -msgstr "{0} ressource{1} synchronisée" +#~ msgid "Synced {0} resource{1}" +#~ msgstr "{0} ressource{1} synchronisée" #: src/helpers/managed/deviceLink.ts #: src/routes/SettingsAgent.tsx @@ -5274,12 +5269,15 @@ msgstr "" msgid "Copy Node ID" msgstr "" +#: src/routes/SyncRoute.tsx #: src/routes/SyncRoute.tsx #: src/routes/SyncRoute.tsx msgid "Node ID copied" msgstr "" #. 0: rawToNodeDid(serverNodeId) +#. 0: peer.nodeId +#: src/routes/SyncRoute.tsx #: src/routes/SyncRoute.tsx msgid "Copy {0}" msgstr "" @@ -7001,3 +6999,17 @@ msgstr "" #: src/components/Vault/VaultPanel.tsx msgid "Checking this workspace’s backup…" msgstr "" + +#. 0: withWhom +#: src/routes/SyncRoute.tsx +msgid "Already up to date{0}" +msgstr "" + +#. 0: withWhom; 1: sent; 2: received +#: src/routes/SyncRoute.tsx +msgid "Synced{0} — sent {1}, received {2}" +msgstr "" + +#: src/routes/SyncRoute.tsx +msgid "Paired" +msgstr "" diff --git a/browser/data-browser/src/routes/SyncRoute.tsx b/browser/data-browser/src/routes/SyncRoute.tsx index 0047b3853..1cf49f28f 100644 --- a/browser/data-browser/src/routes/SyncRoute.tsx +++ b/browser/data-browser/src/routes/SyncRoute.tsx @@ -1092,9 +1092,17 @@ function SyncPage() { ? data.peerName.trim() : undefined; const didFallback = `${NODE_DID_PREFIX}${rawNodeId.slice(0, 8)}...`; - const msg = peerName - ? `Synced ${data.count} resource${data.count !== 1 ? 's' : ''} with ${peerName}` - : `Synced ${data.count} resource${data.count !== 1 ? 's' : ''}`; + // Say what moved in each direction. A pass that sends 49 and receives 1 + // is not "1 resource synced", and reporting it that way hides whether + // the link works at all. + const received: number = + typeof data.count === 'number' ? data.count : 0; + const sent: number = typeof data.pushed === 'number' ? data.pushed : 0; + const withWhom = peerName ? ` with ${peerName}` : ''; + const msg = + received === 0 && sent === 0 + ? `Already up to date${withWhom}` + : `Synced${withWhom} — sent ${sent}, received ${received}`; setPeerSyncResult(msg); const existing = knownPeers.findIndex( @@ -1477,39 +1485,66 @@ function SyncPage() { /> ))} - {/* Paired devices (Iroh peers) */} - {pairedPeers.map(peer => ( - - - - - - - {peer.label} - - Paired - syncWithPeer(peer.nodeId)} - disabled={peerSyncing} - > - {peerSyncing ? 'Syncing…' : 'Sync now'} - - - - - Paired device - {peer.lastSync - ? ` · synced ${formatTimeAgo(new Date(peer.lastSync)) ?? 'just now'}` - : ''} - - - removePeer(peer.nodeId)}> - Remove - - - - - ))} + {/* Paired devices (Iroh peers). + + Status comes from THIS device's own server rather than the local + `atomic-peers` record: that record only updates when the user + presses "Sync now", so it reported "synced 5 hours ago" about a + link that was live and exchanging data. The server knows whether + the peer is connected right now and when it last synced. */} + {pairedPeers.map(peer => { + const reported = serverPeers.find( + p => nodeDidToRaw(p.nodeId) === nodeDidToRaw(peer.nodeId), + ); + const lastSynced = reported?.lastSeen ?? peer.lastSync; + + return ( + + + + + + + {peer.label} + + + {reported?.live ? 'Connected' : 'Paired'} + + syncWithPeer(peer.nodeId)} + disabled={peerSyncing} + > + {peerSyncing ? 'Syncing…' : 'Sync now'} + + + + + Paired device + {lastSynced + ? ` · synced ${formatTimeAgo(new Date(lastSynced)) ?? 'just now'}` + : ' · not synced yet'} + {/* Only what this side actually counted — the accepting node + answers frames through the engine and does not tally what + it served, and a fabricated 0 would be the same lie this + is meant to remove. */} + {reported?.lastSent !== undefined + ? ` · sent ${reported.lastSent}` + : ''} + {reported?.lastReceived !== undefined + ? ` · received ${reported.lastReceived}` + : ''} + + + removePeer(peer.nodeId)}> + Remove + + + + + ); + })} {/* Devices paired with the server this browser reads from — a phone that scanned the code. The server reports them; this tab only @@ -1535,7 +1570,42 @@ function SyncPage() { Paired with {serverLabel(status.serverUrl ?? '')} + {/* "Connected" only says a socket is open. Say when data + last actually moved, so a link that is up but carrying + nothing is distinguishable from a healthy one. */} + {peer.lastSeen + ? ` · synced ${ + formatTimeAgo(new Date(peer.lastSeen)) ?? 'just now' + }` + : ' · not synced yet'} + {/* What that sync actually moved. A timestamp alone cannot + tell a working link from one being refused every + subject. Deliberately the last pass, not a lifetime + total — see `KnownPeer::last_sent`. */} + {peer.lastSeen && + (peer.lastSent !== undefined || + peer.lastReceived !== undefined) + ? ` · sent ${peer.lastSent ?? 0}, received ${ + peer.lastReceived ?? 0 + }` + : ''} + + Node ID + { + try { + await navigator.clipboard.writeText(peer.nodeId); + toast.success('Node ID copied'); + } catch (e) { + store.notifyError(e as Error); + } + }} + > + {peer.nodeId} + + disconnectServerPeer(peer.nodeId)} diff --git a/browser/data-browser/src/views/InvitePage.tsx b/browser/data-browser/src/views/InvitePage.tsx index 9bceb0101..28a0caf36 100644 --- a/browser/data-browser/src/views/InvitePage.tsx +++ b/browser/data-browser/src/views/InvitePage.tsx @@ -76,12 +76,21 @@ function InvitePage({ resource }: ResourcePageProps): JSX.Element { ); }; - const goToRedirect = (destination?: string) => { + const goToRedirect = (destination?: string, activated?: boolean) => { const url = destination ?? redirectURL; if (!url) return; queueMicrotask(() => { navigate(constructOpenURL(url)); void store.fetchResourceFromServer(url).finally(() => { + // The invite's own drive is already the active one — leave it alone. + // Falling through here is what used to send an invitee straight back + // to their private drive: they'd land on the shared resource with a + // sidebar showing their own drive, and no live subscription to the + // one they were invited to. + if (activated) { + return; + } + const signedIn = store.getAgent(); if (!signedIn?.subject) { @@ -107,15 +116,19 @@ function InvitePage({ resource }: ResourcePageProps): JSX.Element { * 2. the personal drive (the lists). * Order matters: the Agent's `personalDrive` must be saved before the drive's * lists, so the sidebar can resolve agent → personalDrive → lists. + * + * Returns both drives the caller has to choose between: the invitee's own + * `personalDrive`, and `hostDrive` — the drive the invited resource lives on, + * which is the one they should land in. */ const persistAgentAfterInvite = async ( subject: string, destination: string | undefined, name?: string, - ): Promise => { + ): Promise<{ personalDrive?: string; hostDrive?: string }> => { store.getResourceLoading(subject); let personalDriveSubject: string | undefined; - let createdDrive = false; + let hostDriveSubject: string | undefined; try { // --- 1. Agent identity: name, isA, personalDrive pointer --- @@ -133,75 +146,55 @@ function InvitePage({ resource }: ResourcePageProps): JSX.Element { ]); } - const existingPersonal = agentResource.get( - core.properties.personalDrive, - ) as string | undefined; - - if (existingPersonal) { - personalDriveSubject = existingPersonal; - } else { - const driveLabel = name?.trim() ? `${name.trim()}'s Drive` : 'Personal'; - const pd = await store.newResource({ - isA: server.classes.drive, - noParent: true, - propVals: { - [core.properties.name]: driveLabel, - [core.properties.description]: - 'Your private space on this server. Only you can read and write here.', - [core.properties.write]: [subject], - [core.properties.read]: [subject], - }, - }); - - await pd.save(); - await agentResource.set(core.properties.personalDrive, pd.subject); - personalDriveSubject = pd.subject; - createdDrive = true; - } - await agentResource.save(); - // --- 2. Home-index lists, stored on the PRIVATE DRIVE --- - if (personalDriveSubject) { - const driveResource = store.getResourceLoading(personalDriveSubject); - - // The personal drive itself belongs in the switcher. - if (createdDrive) { - driveResource.push( - server.properties.drives, - [personalDriveSubject], - true, - ); - } + // The home is DERIVED from the agent's key, so it is the one drive this + // flow must not invent. Minting a fresh one here and pointing the Agent + // at it wrote the lists below to a drive nothing reads: the sidebar + // resolves the home from the key (`usePersonalDrive`), not from the + // pointer, so "Shared with me" stayed empty after accepting an invite. + // + // `ensurePersonalDrive` also seeds the switcher list and writes the + // pointer for older clients, which is why neither happens here anymore. + // Saved first, so it links against a complete Agent rather than a + // half-written one. + // No literal for the unnamed case: `ensurePersonalDrive` already + // defaults it, inside the library, where the i18n extractor cannot turn + // a plain string into an injected hook in this non-component function. + const driveResource = name?.trim() + ? await store.ensurePersonalDrive(`${name.trim()}'s Drive`) + : await store.ensurePersonalDrive(); + personalDriveSubject = driveResource.subject; - if (destination) { - // sharedWithMe is what the sidebar's "Shared with me" panel reads. - // Set it first so a failure in the drive-bookmark code below doesn't - // bubble to the outer catch and skip the drive `save()`. - driveResource.push(core.properties.sharedWithMe, [destination], true); - - // Drive bookmark (so the destination's drive shows in the switcher) - // is best-effort — walking the ancestry can fail transiently right - // after invite acceptance while the server propagates the rights - // grant. Log so we notice if it stops working entirely. - try { - await store.fetchResourceFromServer(destination); - const target = store.getResourceLoading(destination); - const hostDrive = await getResourcesDrive(target, store); - - if (hostDrive && hostDrive !== personalDriveSubject) { - driveResource.push(server.properties.drives, [hostDrive], true); - } - } catch (e) { - console.warn( - '[invite] could not bookmark host drive (sharedWithMe still set):', - e, - ); + // --- 2. Home-index lists, stored on the PRIVATE DRIVE --- + if (destination) { + // sharedWithMe is what the sidebar's "Shared with me" panel reads. + // Set it first so a failure in the drive-bookmark code below doesn't + // bubble to the outer catch and skip the drive `save()`. + driveResource.push(core.properties.sharedWithMe, [destination], true); + + // Drive bookmark (so the destination's drive shows in the switcher) + // is best-effort — walking the ancestry can fail transiently right + // after invite acceptance while the server propagates the rights + // grant. Log so we notice if it stops working entirely. + try { + await store.fetchResourceFromServer(destination); + const target = store.getResourceLoading(destination); + const hostDrive = await getResourcesDrive(target, store); + + if (hostDrive && hostDrive !== personalDriveSubject) { + hostDriveSubject = hostDrive; + driveResource.push(server.properties.drives, [hostDrive], true); } + } catch (e) { + console.warn( + '[invite] could not bookmark host drive (sharedWithMe still set):', + e, + ); } - - await driveResource.save(); } + + await driveResource.save(); } catch (e) { store.notifyError( e instanceof Error @@ -210,7 +203,30 @@ function InvitePage({ resource }: ResourcePageProps): JSX.Element { ); } - return personalDriveSubject; + return { personalDrive: personalDriveSubject, hostDrive: hostDriveSubject }; + }; + + /** + * Make the invite's drive the active one, so the sidebar shows what the + * invitee was actually invited to. Falls back to their own drive when the + * destination's drive can't be resolved (a bare resource, or the ancestry + * walk failed) — which is also the new-agent case, where `drive` would + * otherwise still be `baseURL`. Reports whether it set anything, so + * `goToRedirect` knows not to overwrite it. + */ + const activateDrive = (drives: { + personalDrive?: string; + hostDrive?: string; + }): boolean => { + const target = drives.hostDrive ?? drives.personalDrive; + + if (!target) { + return false; + } + + setDrive(target); + + return true; }; const [dialogProps, show, hide] = useDialog({ @@ -224,20 +240,13 @@ function InvitePage({ resource }: ResourcePageProps): JSX.Element { return; } - const personalDrive = await persistAgentAfterInvite( + const drives = await persistAgentAfterInvite( agentSubject, redirectURL, agentName, ); - // Point the sidebar at the new personal drive. Without this, the - // default `drive` in AppSettings is still `baseURL` (or whatever was - // active pre-invite) and the sidebar shows that instead. - if (personalDrive) { - setDrive(personalDrive); - } - - goToRedirect(); + goToRedirect(undefined, activateDrive(drives)); }, }); @@ -263,6 +272,11 @@ function InvitePage({ resource }: ResourcePageProps): JSX.Element { const subject = `did:ad:agent:${keypair.publicKey}`; const newAgent = new Agent(provider, subject); + // Same reason as in `handleAccept`: a WebCrypto key cannot reproduce + // this later, and this agent goes into the store before that runs. + newAgent.personalDrive = await Agent.personalDriveSubjectFromSecret( + Agent.buildSecret(keypair.privateKey, subject), + ); store.setAgent(newAgent); // Create the initial Agent resource using the Store instance, @@ -317,9 +331,25 @@ function InvitePage({ resource }: ResourcePageProps): JSX.Element { : new JSCryptoProvider(keys.real.privateKey); const newAgent = new Agent(provider, newAgentSubject, destination); - if (keys.crypto) { - saveAgentToIDB(keys.crypto, newAgentSubject); - } + // The home drive is the signature this key makes over its genesis cert, + // and a WebCrypto key signs differently every time — so it can only be + // computed here, while the raw key is still in hand, and must then be + // carried. Skipping it left the agent permanently unable to name its own + // home: the sidebar's home-index panels resolve the drive from the key, + // found nothing, and rendered as though nothing had ever been shared. + newAgent.personalDrive = + await Agent.personalDriveSubjectFromSecret(secret); + + // Stored as the secret, not as the keypair. The keypair overload has + // nothing to derive the above from and writes it as undefined, which is + // what threw it away a line after it was computed. This path still + // prefers the non-extractable keypair, and it also covers the JS-crypto + // fallback, which previously persisted no agent at all. + // `adoptOnDevice: false` keeps this to storage. Signing in is the moment + // a device takes on an identity; opening an invite link is not — this + // runs on a desktop that may already hold its owner's agent, and the + // embedded node should not start signing as whoever accepted an invite. + await saveAgentToIDB(secret, { adoptOnDevice: false }); setAgentSecret(secret); setAgent(newAgent); @@ -329,17 +359,13 @@ function InvitePage({ resource }: ResourcePageProps): JSX.Element { setRedirectURL(destination); void (async () => { - const personalDrive = await persistAgentAfterInvite( + const drives = await persistAgentAfterInvite( agentSubject!, destination, undefined, ); - if (personalDrive) { - setDrive(personalDrive); - } - - goToRedirect(destination); + goToRedirect(destination, activateDrive(drives)); })(); return; diff --git a/browser/data-browser/src/views/getting-started/GettingStartedFlow.tsx b/browser/data-browser/src/views/getting-started/GettingStartedFlow.tsx index e8543a219..3efd55f8e 100644 --- a/browser/data-browser/src/views/getting-started/GettingStartedFlow.tsx +++ b/browser/data-browser/src/views/getting-started/GettingStartedFlow.tsx @@ -602,6 +602,37 @@ export function GettingStartedFlow({ undefined, )); + // A secret restores who you are, not what you have. So the app only + // opens once the workspace is here to read: opening one we cannot read + // shows an empty shell wearing its name, which reads as data loss. + // + // Asked before anything writes the drive, deliberately. Materializing it + // first — which is what this flow used to do — makes every "do I have my + // data?" check answer yes about data the device does not have. + const canRead = (subject: string) => + withDeadline( + deviceHasDriveData(store, subject), + SIGN_IN_LOOKUP_TIMEOUT_MS, + false, + ); + + // The home a pre-derivation secret was made for. Its data has not moved + // to the derived home yet — that is what materializing below does, by + // adopting its drive lists — so the derived subject alone reports "no + // data" for an account whose workspace is sitting on the very server + // they just authenticated against. Answering "your data is on another + // device" there would be false, and it would skip the adoption that + // makes it true. Costs nothing for accounts that never had one: a + // secret minted after derivation carries no `initialDrive`. + const legacyHome = newAgent.initialDrive; + + const hasData = + !!target && + ((await canRead(target)) || + (!!legacyHome && + legacyHome !== target && + (await canRead(legacyHome)))); + // Name the account's drive even when its data hasn't arrived: the Sync // page says "your data is on another device" about *that* drive, which // is true and useful. But when the account's drive cannot be named at @@ -609,20 +640,29 @@ export function GettingStartedFlow({ // back to whatever was last open, or to the default, which is the // server's own root. Showing that as your workspace is how signing in // ends with somebody else's data on screen. - setDrive(target ?? ''); - - // A secret restores who you are, not what you have. So the app only - // opens once the workspace is here to read: opening one we cannot read - // shows an empty shell wearing its name, which reads as data loss. - if ( - target && - (await withDeadline( - deviceHasDriveData(store, target), + setDrive(hasData ? target! : ''); + + if (hasData) { + // The home drive is derived from the key rather than looked up, so + // nothing else will ever write it — `fetchPersonalDriveSubject` above + // computes the subject but does not materialize it. Signing in is the + // one deliberate moment to do it; leaving it to whichever render-time + // resolver asked first is what let a bad derivation mint hundreds of + // drives instead of one. + // + // Here it usually finds the drive the gate just read and returns it + // untouched — but that path still seeds the switcher list and adopts + // drives from an older, pre-derivation home, which is why it runs on + // the "we have it" branch rather than only on the "we don't" one. It + // must not run before the gate: a drive written a moment ago is not + // evidence that this device has the account's data. + await withDeadline( + store.ensurePersonalDrive().then(() => undefined), SIGN_IN_LOOKUP_TIMEOUT_MS, - false, - )) - ) { - navigate(constructOpenURL(target)); + undefined, + ); + + navigate(constructOpenURL(target!)); } else { setMissingDrive(target); setStep('connect-device'); diff --git a/browser/e2e/playwright.config.ts b/browser/e2e/playwright.config.ts index 9325f6085..0435e12da 100644 --- a/browser/e2e/playwright.config.ts +++ b/browser/e2e/playwright.config.ts @@ -22,6 +22,18 @@ const config: PlaywrightTestConfig = { storageState: { cookies: [], origins: [ + // `FRONTEND_URL` / `SERVER_URL` are overridable, but this list was not, + // so running the suite on any other port silently lost + // `viewTransitionsDisabled` — the flake-reducer below — for the origin + // actually under test. Derive those two first; the fixed entries stay + // for the default and dagger setups. + ...[process.env.FRONTEND_URL, process.env.SERVER_URL] + .filter((url): url is string => !!url) + .map(url => new URL(url).origin) + .map(origin => ({ + origin, + localStorage: [{ name: 'viewTransitionsDisabled', value: 'true' }], + })), { origin: 'http://localhost:6747', localStorage: [{ name: 'viewTransitionsDisabled', value: 'true' }], diff --git a/browser/e2e/tests/e2e.spec.ts b/browser/e2e/tests/e2e.spec.ts index b64441637..27ca7802d 100644 --- a/browser/e2e/tests/e2e.spec.ts +++ b/browser/e2e/tests/e2e.spec.ts @@ -145,6 +145,13 @@ test.describe('data-browser', async () => { await page3.waitForURL(/\/app\/show/, { timeout: 15000 }); await page3.reload(); await expect(page3.getByText(driveTitle).first()).toBeVisible(); + // Accepting must also make the invited drive the ACTIVE one. Showing the + // resource while the sidebar still points at the invitee's own drive is + // the failure this guards: the sidebar lists the wrong children and the + // drive-wide subscription goes to a drive nobody is looking at. + await expect(page3.getByTestId('current-drive-title')).toHaveText( + driveTitle, + ); }); test('chatroom', async ({ page, browser, context }) => { diff --git a/browser/e2e/tests/offline-create-then-online.spec.ts b/browser/e2e/tests/offline-create-then-online.spec.ts index e931ae313..c731a0f7f 100644 --- a/browser/e2e/tests/offline-create-then-online.spec.ts +++ b/browser/e2e/tests/offline-create-then-online.spec.ts @@ -67,6 +67,11 @@ test.describe('offline create → online sync → disable localDB', () => { const store = window.store; const drive = await store.createDrive('Offline-Created Drive', { description: 'Created while offline — must survive disabling localDB.', + // An ADDITIONAL drive. `personal` defaults to true, and a personal + // drive is now the agent's derived home — one subject per key — so + // without this the call returns the dev drive this test already has + // instead of creating anything. + personal: false, }); return drive.subject as string; diff --git a/browser/e2e/tests/quick-add.spec.ts b/browser/e2e/tests/quick-add.spec.ts index 21aea9e4b..af9da80aa 100644 --- a/browser/e2e/tests/quick-add.spec.ts +++ b/browser/e2e/tests/quick-add.spec.ts @@ -99,6 +99,66 @@ test.describe('quick add', () => { await expect(row(page, 'Bread')).toBeVisible(); }); + /** + * Naming a second item while the first is still saving. + * + * This is the normal way the bar gets used — it exists to be typed into at + * speed — and it used to drop the keystroke silently: no row, no error, the + * text still sitting in the field. On a fast machine the save lands between + * the two, so the gap only opened under load, which is where it was found. + * + * Held open deliberately here rather than hoped for: the commit POST is + * delayed so the second item is always typed mid-save. + */ + test('a second item typed while the first is still saving is not lost', async ({ + page, + }) => { + await page.setViewportSize({ width: 1800, height: 900 }); + await createFromTemplate(page, /Grocery list/, 'Shopping', 'List'); + + // Commits travel over the websocket while connected, so delaying the HTTP + // /commit route holds nothing — an earlier version of this test did that + // and passed against the bug it was written for. + let holdCommits = true; + // Scoped to the server's own socket (`/ws`, see `websockets.ts`) rather + // than every socket the page opens. + await page.routeWebSocket('**/ws', ws => { + const server = ws.connectToServer(); + ws.onMessage(async message => { + if (holdCommits) { + await new Promise(resolve => setTimeout(resolve, 2500)); + } + + server.send(message); + }); + server.onMessage(message => ws.send(message)); + }); + + const input = page.getByTestId('quick-add-input'); + + await input.fill('Milk'); + await page.getByTestId('quick-add-button').click(); + + // No wait for Milk's row: the point is to type while its save is in + // flight. The field clearing is the signal that the create was accepted. + await expect(input).toHaveValue(''); + + await input.fill('Bread'); + await input.press('Enter'); + // If the keystroke was swallowed, the text stays put — assert on that + // directly, so a failure names the actual symptom. + await expect(input).toHaveValue(''); + + holdCommits = false; + + await expect(row(page, 'Milk')).toBeVisible({ timeout: 20_000 }); + await expect(row(page, 'Bread')).toBeVisible({ timeout: 20_000 }); + + await reloadGrid(page); + await expect(row(page, 'Milk')).toBeVisible(); + await expect(row(page, 'Bread')).toBeVisible(); + }); + test('the Workout log records the moment with no field at all', async ({ page, }) => { diff --git a/browser/lib/src/CryptoProvider.ts b/browser/lib/src/CryptoProvider.ts index 7095677fe..8c5bb8da7 100644 --- a/browser/lib/src/CryptoProvider.ts +++ b/browser/lib/src/CryptoProvider.ts @@ -6,6 +6,15 @@ hashes.sha512 = sha512; export interface CryptoProvider { type: string; + /** + * Whether `signBytes` returns the same signature for the same bytes every + * time. RFC 8032 Ed25519 is deterministic, but that is a property of the + * *implementation*, not something a caller may assume: WebCrypto is free to + * randomize the nonce, and WebKit does. Anything that derives an identity + * from a signature must refuse to run on a provider that says `false` — + * see `Agent.personalDriveSubject`. + */ + signsDeterministically: boolean; sign(data: string): Promise; /** Sign raw bytes (not a UTF-8 string). Needed for the binary genesis * certificate, whose signature mints a resource's DID. Works even with a @@ -35,6 +44,11 @@ export class JSCryptoProvider implements CryptoProvider { return 'js'; } + /** noble's Ed25519 is RFC 8032 deterministic. */ + public get signsDeterministically(): boolean { + return true; + } + static fromSecret( secret: string, ): [provider: JSCryptoProvider, subject: string, initialDrive?: string] { @@ -80,6 +94,14 @@ export class SubtleCryptoProvider implements CryptoProvider { return 'subtle'; } + /** + * WebCrypto makes no determinism guarantee, and WKWebView (the Tauri + * desktop webview) returns a different valid signature on every call. + */ + public get signsDeterministically(): boolean { + return false; + } + static async createKeysFromSecret( secret: string, ): Promise<[keyPair: CryptoKeyPair, subject: string, initialDrive?: string]> { diff --git a/browser/lib/src/agent.test.ts b/browser/lib/src/agent.test.ts index e59c5b9c6..3e3960fa5 100644 --- a/browser/lib/src/agent.test.ts +++ b/browser/lib/src/agent.test.ts @@ -1,6 +1,8 @@ import { describe, it } from 'vitest'; import { Agent } from './agent.js'; +import { decodeB64 } from './base64.js'; import { JSCryptoProvider, legacySubjectFromSecret } from './CryptoProvider.js'; +import { personalDriveSubject } from './genesis.js'; describe('Agent', () => { const validPrivateKey = 'CapMWIhFUT+w7ANv9oCPqrHrwZpkP2JhzF9JnyT6WcI='; @@ -42,6 +44,19 @@ describe('Agent', () => { '7LsjMW5gOfDdJzK_atgjQ1t20J_rw8MjVg6xwqm-h8U', ); }); + + it('derives a stable personal-drive DID from the key', async ({ expect }) => { + const agent = new Agent( + new JSCryptoProvider(validPrivateKey), + validSubject, + ); + const first = await agent.personalDriveSubject(); + const second = await agent.personalDriveSubject(); + expect(first).toBe(second); + expect(first).toBe(await personalDriveSubject(decodeB64(validPrivateKey))); + expect(first.startsWith('did:ad:')).toBe(true); + expect(first.startsWith('did:ad:agent:')).toBe(false); + }); }); describe('legacySubjectFromSecret', () => { diff --git a/browser/lib/src/agent.ts b/browser/lib/src/agent.ts index 98606eef4..109d6af23 100644 --- a/browser/lib/src/agent.ts +++ b/browser/lib/src/agent.ts @@ -1,12 +1,20 @@ import { Client } from './client.js'; import { + decodeSecret, generateKeyPair, JSCryptoProvider, legacySubjectFromSecret, SubtleCryptoProvider, type CryptoProvider, } from './CryptoProvider.js'; +import { decodeB64 } from './base64.js'; import { AtomicError, ErrorType } from './error.js'; +import { + encodeGenesisCert, + personalDriveCert, + personalDriveSubject as derivePersonalDriveSubject, + subjectForSignature, +} from './genesis.js'; import { core } from './ontologies/core.js'; export interface StoredAgent { @@ -30,6 +38,13 @@ export class Agent implements AgentInterface { * way of knowing about. */ public legacySubject?: string; + /** + * The derived personal-drive DID, computed once from the raw private key at + * sign-in (see {@link personalDriveSubject} for why it cannot be recomputed + * from a non-extractable key). Persisted alongside the agent so a restored + * session still knows which drive is its home. + */ + public personalDrive?: string; #cryptoProvider: CryptoProvider; @@ -73,10 +88,14 @@ export class Agent implements AgentInterface { return new Promise((resolve, reject) => { SubtleCryptoProvider.createKeysFromSecret(secretB64) - .then(([keys, subject, initialDrive]) => { + .then(async ([keys, subject, initialDrive]) => { const provider = new SubtleCryptoProvider(keys); const agent = new Agent(provider, subject, initialDrive); agent.legacySubject = legacySubjectFromSecret(secretB64); + // Last moment the raw key is in hand: the keypair above is + // non-extractable, and this provider cannot reproduce the subject. + agent.personalDrive = + await Agent.personalDriveSubjectFromSecret(secretB64); resolve(agent); }) @@ -150,6 +169,61 @@ export class Agent implements AgentInterface { return this.#cryptoProvider.signBytes(data); } + /** + * Deterministic personal-drive DID for this agent. Same key → same subject + * on every device; no pointer to read. + * + * The subject IS an Ed25519 signature over the fixed personal-drive + * certificate, so this is only stable if the signer is. WebCrypto is not: + * WKWebView returns a different valid signature every call, which minted a + * brand-new "My drive" on every lookup (411 of them in one session) because + * the reuse check in `Store.createDrive` searched for a DID that had never + * existed. + * + * So the value is derived ONCE from the raw private key — with noble's + * deterministic implementation, matching `ed25519_dalek` on the server — and + * cached. When neither a cached value nor a deterministic signer is + * available we throw rather than sign: an unreproducible subject is worse + * than no subject, because minting under it silently creates junk. + */ + public async personalDriveSubject(): Promise { + if (this.personalDrive) { + return this.personalDrive; + } + + if (!this.#cryptoProvider.signsDeterministically) { + throw new AtomicError( + "Cannot derive this agent's personal drive: its key signs " + + 'non-deterministically and no derived subject was stored. ' + + 'Sign in with the secret again to recompute it.', + ErrorType.Client, + ); + } + + const cert = personalDriveCert(decodeB64(await this.getPublicKey())); + const subject = subjectForSignature( + await this.signBytes(encodeGenesisCert(cert)), + ); + + this.personalDrive = subject; + + return subject; + } + + /** + * The personal-drive DID implied by a raw private key, independent of which + * provider ends up holding it. This is the only derivation that works for a + * SubtleCrypto agent, whose key is non-extractable once stored — hence + * computing it at sign-in, while the secret is still in hand. + */ + public static async personalDriveSubjectFromSecret( + secretB64: string, + ): Promise { + const { privateKey } = decodeSecret(secretB64); + + return derivePersonalDriveSubject(new Uint8Array(decodeB64(privateKey))); + } + public createSignature(subject: string, timestamp: number): Promise { const message = `${subject} ${timestamp}`; diff --git a/browser/lib/src/auth-deadlock.test.ts b/browser/lib/src/auth-deadlock.test.ts new file mode 100644 index 000000000..18cd24834 --- /dev/null +++ b/browser/lib/src/auth-deadlock.test.ts @@ -0,0 +1,144 @@ +import { beforeAll, describe, it } from 'vitest'; +import { Store } from './store.js'; +import { Agent } from './agent.js'; +import { JSCryptoProvider } from './CryptoProvider.js'; +import { LoroLoader } from './loro-loader.js'; +import { WSClient } from './websockets.js'; + +/** + * The desktop app boots its webview before its embedded server binds, so the + * first websocket connect fails. It then sat on "Offline" indefinitely next to + * a server answering in ~1ms, with every fetch hanging and only a reload + * clearing it. + * + * The retry loop was never the problem — it fires with correct backoff, and a + * socket killed after opening reconnects fine. What broke was narrower: + * `openPromise` only ever resolved. A socket that died before opening left it + * pending forever, and `authenticate()` awaits it while holding + * `isAuthenticating`, whose `finally` sits downstream of that await. The flag + * stayed set for the life of the client, so when the retry's socket opened, its + * `authenticate()` waited on the dead promise instead. + */ + +class FakeSocket { + public static last: FakeSocket | undefined; + public readyState = 0; + public protocol = ''; + public binaryType = 'arraybuffer'; + private listeners = new Map void>>(); + + constructor(public url: string) { + FakeSocket.last = this; + } + + public addEventListener(type: string, cb: (e: unknown) => void) { + const arr = this.listeners.get(type) ?? []; + arr.push(cb); + this.listeners.set(type, arr); + } + + public removeEventListener() { + /* no-op */ + } + + public send() { + /* no-op */ + } + + public close() { + this.emit('close', { code: 1000, reason: '', wasClean: true }); + } + + public emit(type: string, ev: unknown) { + for (const cb of this.listeners.get(type) ?? []) cb(ev); + } + + /** What a connect to a port nothing is listening on looks like. */ + public failBeforeOpening() { + this.readyState = 3; + this.emit('error', {}); + this.emit('close', { code: 1006, reason: '', wasClean: false }); + } +} + +beforeAll(async () => { + await LoroLoader.initializeLoro(); +}); + +async function makeAgent(): Promise { + const keys = await Agent.generateKeyPair(); + + return new Agent( + new JSCryptoProvider(keys.privateKey), + `did:ad:agent:${keys.publicKey}`, + ); +} + +describe('a connect that never opens must not pin the auth flag', () => { + it('rejects the open promise instead of leaving it pending', async ({ + expect, + }) => { + const realWS = globalThis.WebSocket; + (globalThis as { WebSocket: unknown }).WebSocket = + FakeSocket as unknown as typeof WebSocket; + + try { + const store = new Store({ serverUrl: 'https://example.com' }); + store.setAgent(await makeAgent()); + const client = new WSClient('wss://example.com/ws', store); + const socket = FakeSocket.last!; + + // Whoever is first to need the connection — a boot fetch, the presence + // manager — starts awaiting it here. + const waiting = ( + client as unknown as { openPromise: Promise } + ).openPromise.then( + () => 'resolved', + () => 'rejected', + ); + + socket.failBeforeOpening(); + + // Pending forever is the bug. Either outcome that SETTLES is fine — + // settling is what lets `authenticate`'s `finally` run. + const outcome = await Promise.race([ + waiting, + new Promise(r => setTimeout(() => r('still-pending'), 250)), + ]); + + expect(outcome).toBe('rejected'); + client.close(); + } finally { + (globalThis as { WebSocket: unknown }).WebSocket = realWS; + } + }); + + it('lets a later authenticate run after a failed first connect', async ({ + expect, + }) => { + const realWS = globalThis.WebSocket; + (globalThis as { WebSocket: unknown }).WebSocket = + FakeSocket as unknown as typeof WebSocket; + + try { + const store = new Store({ serverUrl: 'https://example.com' }); + store.setAgent(await makeAgent()); + const client = new WSClient('wss://example.com/ws', store); + + // An auth attempt that starts while the first socket is still + // connecting. It holds `isAuthenticating` until it settles. + const first = client.authenticate().catch(() => undefined); + FakeSocket.last!.failBeforeOpening(); + await first; + + // The flag must be clear, or the reconnect's authenticate never runs. + const stuck = (client as unknown as { isAuthenticating: boolean }) + .isAuthenticating; + + expect(stuck).toBe(false); + client.close(); + } finally { + (globalThis as { WebSocket: unknown }).WebSocket = realWS; + } + }); +}); diff --git a/browser/lib/src/child-order.test.ts b/browser/lib/src/child-order.test.ts new file mode 100644 index 000000000..4c19abd29 --- /dev/null +++ b/browser/lib/src/child-order.test.ts @@ -0,0 +1,93 @@ +import { describe, it } from 'vitest'; +import { orderChildren, type ChildSortEntry } from './child-order.js'; + +/** + * A table created in the sidebar rendered FIRST rather than last, which reads + * as the resource having gone missing when you look for it at the bottom of a + * list whose shape you know. It had neither `sortOrder` nor `createdAt`, so it + * fell back to its array index — 3 against timestamps around 1.7e12. + */ + +const at = ( + subject: string, + key: number | undefined, + index: number, +): ChildSortEntry => ({ + subject, + key, + index, +}); + +// Real values from the drive where this was found. +const HEY_WERELD = 1786819135632; +const HOUSEPLANTS = 1786870649083; +const SYNC_PROBE = 1786878205251; + +describe('children with no sort key stay where the server put them', () => { + it('does not float a keyless resource to the top', ({ expect }) => { + const order = orderChildren([ + at('hey-wereld', HEY_WERELD, 0), + at('houseplants', HOUSEPLANTS, 1), + at('relay-table', undefined, 2), + at('sync-probe', SYNC_PROBE, 3), + ]); + + expect(order[0]).toBe('hey-wereld'); + expect(order).toEqual([ + 'hey-wereld', + 'houseplants', + 'relay-table', + 'sync-probe', + ]); + }); + + it('keeps several keyless members in server order', ({ expect }) => { + const order = orderChildren([ + at('a', HEY_WERELD, 0), + at('b', undefined, 1), + at('c', undefined, 2), + at('d', HOUSEPLANTS, 3), + ]); + + expect(order).toEqual(['a', 'b', 'c', 'd']); + }); + + it('leaves keyless members at the front when the server put them there', ({ + expect, + }) => { + const order = orderChildren([ + at('first', undefined, 0), + at('second', HEY_WERELD, 1), + at('third', HOUSEPLANTS, 2), + ]); + + expect(order).toEqual(['first', 'second', 'third']); + }); + + it('still honours an explicit sortOrder against createdAt neighbours', ({ + expect, + }) => { + // What a drag-and-drop writes: a fractional key between two neighbours, + // which is why the two properties share a number space. + const between = (HEY_WERELD + HOUSEPLANTS) / 2; + const order = orderChildren([ + at('hey-wereld', HEY_WERELD, 0), + at('houseplants', HOUSEPLANTS, 1), + at('dragged', between, 2), + ]); + + expect(order).toEqual(['hey-wereld', 'dragged', 'houseplants']); + }); + + it('orders a list where nothing has a key by server position', ({ + expect, + }) => { + const order = orderChildren([ + at('x', undefined, 0), + at('y', undefined, 1), + at('z', undefined, 2), + ]); + + expect(order).toEqual(['x', 'y', 'z']); + }); +}); diff --git a/browser/lib/src/child-order.ts b/browser/lib/src/child-order.ts new file mode 100644 index 000000000..287a4f338 --- /dev/null +++ b/browser/lib/src/child-order.ts @@ -0,0 +1,54 @@ +/** + * Ordering for a resource's children in the sidebar tree. + * + * `sortOrder` and `createdAt` deliberately share one number space: a + * drag-and-drop mints a fractional `sortOrder` BETWEEN two neighbours' keys, + * and the server sorts by the same fallback, so the two are comparable by + * construction. + * + * A member carrying neither is the awkward case. It used to fall back to its + * array index, which is a different space entirely — an index of 3 against + * `createdAt` timestamps around 1.7e12 sorts to the very front — so any + * resource missing `createdAt` jumped to the top of the tree. + */ + +export interface ChildSortEntry { + subject: string; + /** `sortOrder`, else `createdAt`, else `undefined` when neither exists. */ + key: number | undefined; + /** Position in the server's `createdAt`-ordered response. */ + index: number; +} + +/** + * Order children by their sort key, keeping keyless members where the server + * put them rather than letting them fall into a foreign number space. + * + * The server already returns members in `createdAt` order, so a keyless member + * inherits the preceding member's key and the index tie-break preserves their + * relative order. Keyless members before the first known key inherit that key, + * so they stay at the front instead of sorting as zero. + */ +export function orderChildren(entries: readonly ChildSortEntry[]): string[] { + const keyed = entries.map(entry => ({ ...entry })); + + let carried: number | undefined; + + for (const entry of keyed) { + if (entry.key === undefined) { + entry.key = carried; + } else { + carried = entry.key; + } + } + + const firstKnown = keyed.find(entry => entry.key !== undefined)?.key ?? 0; + + for (const entry of keyed) { + if (entry.key === undefined) entry.key = firstKnown; + } + + keyed.sort((a, b) => (a.key === b.key ? a.index - b.index : a.key! - b.key!)); + + return keyed.map(entry => entry.subject); +} diff --git a/browser/lib/src/gap-recovery.test.ts b/browser/lib/src/gap-recovery.test.ts new file mode 100644 index 000000000..d2142e99a --- /dev/null +++ b/browser/lib/src/gap-recovery.test.ts @@ -0,0 +1,216 @@ +import { beforeAll, describe, it } from 'vitest'; +import { Store } from './store.js'; +import { Agent } from './agent.js'; +import { JSCryptoProvider } from './CryptoProvider.js'; +import { LoroLoader } from './loro-loader.js'; +import { Resource } from './resource.js'; +import { commits, core } from './index.js'; + +/** + * The live channel is deltas, and nothing in it can say "you are missing + * something". A receiver that misses one update parks it as pending and every + * later update parks behind it — no error, no indicator, the document just + * quietly stops being live until someone reloads. + * + * Measured in the field: two paired nodes, same document open, one side typed + * `awdawdawad oawdinawiodawoi dn` and the other kept showing `awd`, with the + * sender's cursor still blinking in it the whole time. A reload pulled the full + * text immediately, so the server had it throughout. + * + * What made it silent rather than loud: an unappliable delta on a resource that + * already had content fell through to "applied", stamping `lastCommit` for a + * commit that was never applied. + */ + +const NAME = 'https://atomicdata.dev/properties/name'; + +beforeAll(async () => { + await LoroLoader.initializeLoro(); +}); + +async function makeStore(): Promise { + const store = new Store({ serverUrl: 'https://example.com' }); + const keys = await Agent.generateKeyPair(); + store.setAgent( + new Agent( + new JSCryptoProvider(keys.privateKey), + `did:ad:agent:${keys.publicKey}`, + ), + ); + + return store; +} + +/** Build the exact shape of the field failure: a seed the receiver has, a + * commit it never receives, and then a delta that depends on that missing + * commit. Exporting a fresh doc as `update` does NOT reproduce this — with no + * prior version it carries every op from the start and applies cleanly. The + * gap only exists if the delta is exported `from` a version the receiver + * never reached. */ +function withheldCommit(): { seed: Uint8Array; orphaned: Uint8Array[] } { + const { LoroDoc } = LoroLoader.Loro; + const doc = new LoroDoc(); + const map = doc.getMap('properties'); + + map.set(core.properties.isA, [core.classes.class]); + map.set(NAME, 'awd'); + doc.commit(); + const seed = doc.export({ mode: 'snapshot' }); + + // The update that goes missing on the wire. + map.set(NAME, 'the delta that never arrived'); + doc.commit(); + const missed = doc.version(); + + // Everything after it depends on it, so none of it can apply. + const orphaned: Uint8Array[] = []; + + for (const text of [ + 'awdawdawad oawdinawiodawoi dn', + 'aw', + 'd', + 'more', + 'and more', + ]) { + map.set(NAME, text); + doc.commit(); + orphaned.push( + doc.export({ mode: 'update', from: missed } as never) as Uint8Array, + ); + } + + return { seed, orphaned }; +} + +describe('a delta that cannot apply triggers a catch-up fetch', () => { + const subject = 'did:ad:gapRecoveryReproAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; + + /** Seed a resource with real, usable content — the case the old code let + * through silently, because a resource with an `isA` was assumed healthy. */ + async function seeded(store: Store): Promise<{ orphaned: Uint8Array[] }> { + const { seed, orphaned } = withheldCommit(); + const r = new Resource(subject); + r.setStore(store); + r.loading = true; + store.applyIncoming({ + subject, + loroBytes: seed, + source: 'ws-pending-get', + replaceLoroDocsFromRemote: true, + }); + + return { orphaned }; + } + + it('asks the server for full state instead of reporting success', async ({ + expect, + }) => { + const store = await makeStore(); + const { orphaned } = await seeded(store); + + const asked: string[] = []; + + ( + store as unknown as { + fetchResourceFromServer: (s: string, o?: unknown) => Promise; + } + ).fetchResourceFromServer = async (s: string) => { + asked.push(s); + + return store.resources.get(s)!; + }; + + const outcome = store.applyIncoming({ + subject, + loroBytes: orphaned[0], + commitId: 'did:ad:commit:neverApplied', + source: 'ws-sub-push', + }); + + expect(outcome).not.toBe('applied'); + expect(asked).toEqual([subject]); + }); + + it('does not claim a commit it never applied', async ({ expect }) => { + const store = await makeStore(); + const { orphaned } = await seeded(store); + + ( + store as unknown as { fetchResourceFromServer: () => Promise } + ).fetchResourceFromServer = async () => undefined; + + store.applyIncoming({ + subject, + loroBytes: orphaned[0], + commitId: 'did:ad:commit:neverApplied', + source: 'ws-sub-push', + }); + + // Stamping it would make the echo-dedup at the top of `applyIncoming` + // drop the very fetch issued to repair the gap — the fix would then be + // a no-op that still looks like it works. + const resource = store.resources.get(subject)!; + expect(resource.get(commits.properties.lastCommit)).not.toBe( + 'did:ad:commit:neverApplied', + ); + }); + + it('keeps the content it already had rather than blanking the document', async ({ + expect, + }) => { + const store = await makeStore(); + const { orphaned } = await seeded(store); + + ( + store as unknown as { fetchResourceFromServer: () => Promise } + ).fetchResourceFromServer = async () => undefined; + + store.applyIncoming({ + subject, + loroBytes: orphaned[0], + commitId: 'did:ad:commit:neverApplied', + source: 'ws-sub-push', + }); + + // Failing the resource outright would be the other way to be loud about + // this, and it would throw away a document the user can still read. + const resource = store.resources.get(subject)!; + expect(resource.get(NAME)).toBe('awd'); + expect(resource.error).toBeUndefined(); + }); + + it('fires one catch-up fetch for a burst of unappliable deltas', async ({ + expect, + }) => { + const store = await makeStore(); + const { orphaned } = await seeded(store); + + let pending: (() => void) | undefined; + let calls = 0; + + ( + store as unknown as { fetchResourceFromServer: () => Promise } + ).fetchResourceFromServer = () => { + calls++; + + return new Promise(resolve => { + pending = () => resolve(undefined); + }); + }; + + // Every later delta parks behind the first missing one, so they arrive as + // a burst. One repair fetch is enough for all of them. + orphaned.forEach((bytes, i) => { + store.applyIncoming({ + subject, + loroBytes: bytes, + commitId: `did:ad:commit:burst${i}`, + source: 'ws-sub-push', + }); + }); + + expect(calls).toBe(1); + + pending?.(); + }); +}); diff --git a/browser/lib/src/genesis.test.ts b/browser/lib/src/genesis.test.ts index a629de1cc..4c477a819 100644 --- a/browser/lib/src/genesis.test.ts +++ b/browser/lib/src/genesis.test.ts @@ -11,6 +11,10 @@ import { verifyGenesisCert, subjectForSignature, genesisSignerDid, + personalDriveCert, + personalDriveSubject, + domainSeparatorNonce, + PERSONAL_DRIVE_PURPOSE, type GenesisCert, } from './genesis.js'; @@ -140,6 +144,31 @@ describe('GenesisCert', () => { trailing.set(bytes); expect(() => decodeGenesisCert(trailing)).toThrow(); }); + + it('personal drive subject is deterministic and matches Rust', async ({ + expect, + }) => { + const seed = new Uint8Array(32).fill(9); + const pub = await getPublicKey(seed); + const cert = personalDriveCert(pub); + expect(hex(cert.nonce)).toBe('5f62397980dc34a685e5ee57fa0ac058'); + expect(hex(domainSeparatorNonce(PERSONAL_DRIVE_PURPOSE))).toBe( + hex(cert.nonce), + ); + expect(cert.createdAt).toBe(0); + expect(cert.parent).toBe(''); + expect(cert.drive).toBe(''); + + const first = await personalDriveSubject(seed); + const second = await personalDriveSubject(seed); + expect(first).toBe(second); + expect(first).toBe( + 'did:ad:uv-2o7-7LBEo69T8gj2ncUWOXgNn9oG_rwqJAqHeM0O2GQjE8236RjthBrYuIXQbO_b0TCkU41f-auIx-1AjBw', + ); + + const other = await personalDriveSubject(new Uint8Array(32).fill(10)); + expect(other).not.toBe(first); + }); }); // The single cross-language source of truth. The SAME file is asserted by the diff --git a/browser/lib/src/genesis.ts b/browser/lib/src/genesis.ts index 840121911..72b0d96f2 100644 --- a/browser/lib/src/genesis.ts +++ b/browser/lib/src/genesis.ts @@ -12,14 +12,46 @@ * * See `planning/genesis-self-verifying.md`. */ -import { sha512 } from '@noble/hashes/sha2.js'; -import { hashes, sign, verify } from '@noble/ed25519'; +import { sha256, sha512 } from '@noble/hashes/sha2.js'; +import { getPublicKey, hashes, sign, verify } from '@noble/ed25519'; import { decodeB64, encodeB64Url } from './base64.js'; // Match `CryptoProvider.ts`: the synchronous noble API needs sha512 installed. hashes.sha512 = sha512; export const GENESIS_VERSION_V1 = 0x01; + +/** Purpose string for the per-agent personal drive singleton. Version the + * suffix to derive a different subject without colliding. */ +export const PERSONAL_DRIVE_PURPOSE = 'atomic-personal-drive-v1'; + +/** First 16 bytes of SHA-256(`purpose`) — must match + * `lib/src/genesis.rs::domain_separator_nonce`. */ +export function domainSeparatorNonce(purpose: string): Uint8Array { + return sha256(new TextEncoder().encode(purpose)).slice(0, 16); +} + +/** Genesis certificate for the agent's personal drive: `createdAt = 0`, + * empty parent/drive, nonce = SHA-256(`atomic-personal-drive-v1`)[..16]. */ +export function personalDriveCert(signerPubkey: Uint8Array): GenesisCert { + return { + signerPubkey, + createdAt: 0, + nonce: domainSeparatorNonce(PERSONAL_DRIVE_PURPOSE), + parent: '', + drive: '', + }; +} + +/** `did:ad:` of {@link personalDriveCert} signed by `privateKey`. */ +export async function personalDriveSubject( + privateKey: Uint8Array, +): Promise { + const cert = personalDriveCert(await getPublicKey(privateKey)); + + return subjectForSignature(await signGenesisCert(cert, privateKey)); +} + /** `flags` bit 0: a 32-byte `stateHash` is present after the nonce. */ const FLAG_HAS_STATE_HASH = 0b0000_0001; diff --git a/browser/lib/src/index.ts b/browser/lib/src/index.ts index dfb4a0173..7b68085cc 100644 --- a/browser/lib/src/index.ts +++ b/browser/lib/src/index.ts @@ -50,6 +50,7 @@ export * from './client.js'; export * from './genesis.js'; export * from './commit.js'; export * from './error.js'; +export * from './withDeadline.js'; export * from './datatypes.js'; export * from './parse.js'; export * from './search.js'; @@ -88,3 +89,4 @@ export { type PerfEvent, type PerfSnapshot, } from './perf-trace.js'; +export * from './child-order.js'; diff --git a/browser/lib/src/is-aliased.test.ts b/browser/lib/src/is-aliased.test.ts new file mode 100644 index 000000000..3466af322 --- /dev/null +++ b/browser/lib/src/is-aliased.test.ts @@ -0,0 +1,69 @@ +import { beforeAll, describe, it } from 'vitest'; +import { Store } from './store.js'; +import { Agent } from './agent.js'; +import { JSCryptoProvider } from './CryptoProvider.js'; +import { LoroLoader } from './loro-loader.js'; +import { Resource } from './resource.js'; + +/** + * A view that renders a collection needs to tell apart the two reasons the + * collection can grow: one of its own drafts materialising, or a resource + * arriving from a peer or another tab. The table got this wrong in one + * direction — it assumed all growth was its own — and rows from a paired node + * were in the store, complete, and never drawn. + */ + +beforeAll(async () => { + await LoroLoader.initializeLoro(); +}); + +async function makeStore(): Promise { + const store = new Store({ serverUrl: 'https://example.com' }); + const keys = await Agent.generateKeyPair(); + store.setAgent( + new Agent( + new JSCryptoProvider(keys.privateKey), + `did:ad:agent:${keys.publicKey}`, + ), + ); + + return store; +} + +describe('isAliased tells a materialised draft from a stranger', () => { + it('is false for a placeholder that has not been persisted', async ({ + expect, + }) => { + const store = await makeStore(); + + expect(store.isAliased('_new:draftA')).toBe(false); + }); + + it('is true once the placeholder has been aliased to a real subject', async ({ + expect, + }) => { + const store = await makeStore(); + const real = 'did:ad:realSubjectForTheDraftAAAAAAAAAAAAAAAAAAAA=='; + const resource = new Resource(real); + resource.setStore(store); + + store.addResource(resource, { alias: '_new:draftA' }); + + expect(store.isAliased('_new:draftA')).toBe(true); + }); + + it('is false for a subject that simply arrived from elsewhere', async ({ + expect, + }) => { + const store = await makeStore(); + const fromPeer = 'did:ad:rowThatArrivedFromAPeerAAAAAAAAAAAAAAAAAA=='; + const resource = new Resource(fromPeer); + resource.setStore(store); + + // No alias: nothing in this session stood in for it. This is the case the + // table has to notice, because it is the one that must raise the row count. + store.addResource(resource); + + expect(store.isAliased(fromPeer)).toBe(false); + }); +}); diff --git a/browser/lib/src/local-outbox.test.ts b/browser/lib/src/local-outbox.test.ts index 99cd5d546..f0aeb4c83 100644 --- a/browser/lib/src/local-outbox.test.ts +++ b/browser/lib/src/local-outbox.test.ts @@ -364,6 +364,36 @@ describe('isTerminalCommitError (F5: code-first, planning/unified-sync.md)', () }); }); +describe('a commit naming a class the server lacks', () => { + // Verbatim from the field: every row of a shared table was refused with this, + // the rows rendered as though saved, and the outbox retried each one forever + // because nothing classified the failure. + const MESSAGE = + 'Failed getting class did:ad:ViKExaq3nm6tVE5UCaCzEQhe7lwOrd. Resource not found. ' + + 'DID Resource did:ad:ViKExaq3nm6tVE5UCaCzEQhe7lwOrd not found locally'; + + it('blocks, so the entry stops retrying and stays visible', ({ expect }) => { + expect(isUnrecoverableCommitError(MESSAGE, ErrorCode.MISSING_CLASS)).toBe( + true, + ); + }); + + it('is NOT terminal — the row is good and must not be discarded', ({ + expect, + }) => { + // The class can still arrive, and the same commit would then apply. + // Dropping the entry would throw away a write the user believes they made. + expect(isTerminalCommitError(MESSAGE, ErrorCode.MISSING_CLASS)).toBe(false); + }); + + it('blocks on the message alone, for a server too old to send the code', ({ + expect, + }) => { + expect(isUnrecoverableCommitError(MESSAGE, undefined)).toBe(true); + expect(isUnrecoverableCommitError(MESSAGE, ErrorCode.UNKNOWN)).toBe(true); + }); +}); + describe('isUnrecoverableCommitError (F5: code-first, planning/unified-sync.md)', () => { it('trusts a recognized code even with unrelated message text', ({ expect, diff --git a/browser/lib/src/local-outbox.ts b/browser/lib/src/local-outbox.ts index d1d956261..eb698f01e 100644 --- a/browser/lib/src/local-outbox.ts +++ b/browser/lib/src/local-outbox.ts @@ -159,6 +159,18 @@ export function isUnrecoverableCommitErrorMessage(message: string): boolean { return true; } + // Server emits: "Failed getting class . Resource not found..." + // (`storelike.rs`). The commit names a class this server does not hold, so + // validation cannot run and every retry fails identically. + // + // Blocking, not terminal. The row itself is fine — this fires when a table's + // class never reached the server the row is being written to — so the entry + // has to stay. Found in the field as two people adding table rows that + // rendered locally, were refused one at a time, and were reported nowhere. + if (message.includes('Failed getting class')) { + return true; + } + return false; } @@ -179,6 +191,7 @@ const KNOWN_ERROR_CODES: ReadonlySet = new Set([ ErrorCode.GENESIS_COLLISION, ErrorCode.MISSING_REQUIRED_PROPERTY, ErrorCode.UNAUTHORIZED_WRITE, + ErrorCode.MISSING_CLASS, ]); /** @@ -207,7 +220,9 @@ export function isUnrecoverableCommitError( code?: number, ): boolean { if (code !== undefined && KNOWN_ERROR_CODES.has(code)) { - return code === ErrorCode.UNAUTHORIZED_WRITE; + return ( + code === ErrorCode.UNAUTHORIZED_WRITE || code === ErrorCode.MISSING_CLASS + ); } return isUnrecoverableCommitErrorMessage(message); diff --git a/browser/lib/src/nondeterministic-signer.test.ts b/browser/lib/src/nondeterministic-signer.test.ts new file mode 100644 index 000000000..a415b2ca4 --- /dev/null +++ b/browser/lib/src/nondeterministic-signer.test.ts @@ -0,0 +1,113 @@ +import { describe, it } from 'vitest'; +import { Agent } from './agent.js'; +import { JSCryptoProvider, type CryptoProvider } from './CryptoProvider.js'; +import { encodeB64Url, decodeB64 } from './base64.js'; +import { getPublicKey, sign } from '@noble/ed25519'; + +/** + * WebCrypto does not promise that signing the same bytes twice yields the same + * signature, and WKWebView's Ed25519 does not: one session minted 411 distinct + * personal drives, each a valid signature over one byte-identical certificate. + * + * Node's WebCrypto *is* deterministic, so a test using a real provider passes + * here no matter how broken the product is. Reproducing it needs a signer that + * randomizes on purpose. + */ +class RandomizingProvider implements CryptoProvider { + #inner: JSCryptoProvider; + + constructor(privateKey: string) { + this.#inner = new JSCryptoProvider(privateKey); + } + + public get type(): string { + return 'randomizing'; + } + + public get signsDeterministically(): boolean { + return false; + } + + public async sign(message: string): Promise { + return this.signBytes(new TextEncoder().encode(message)); + } + + /** + * A signature over `data` that differs on every call. WebKit gets there by + * randomizing Ed25519's nonce; signing under a throwaway key reproduces the + * property that matters here — the caller cannot predict the result. + */ + public async signBytes(data: Uint8Array): Promise { + const throwaway = crypto.getRandomValues(new Uint8Array(32)); + + return encodeB64Url(await sign(data, throwaway)); + } + + public async getPublicKey(): Promise { + return this.#inner.getPublicKey(); + } +} + +describe('personal drive derivation under a non-deterministic signer', () => { + it('the stub really does sign differently each call', async ({ expect }) => { + const keys = await Agent.generateKeyPair(); + const provider = new RandomizingProvider(keys.privateKey); + const bytes = new TextEncoder().encode('same bytes every time'); + + expect(await provider.signBytes(bytes)).not.toBe( + await provider.signBytes(bytes), + ); + }); + + it('refuses to invent a subject it cannot reproduce', async ({ expect }) => { + const keys = await Agent.generateKeyPair(); + const agent = new Agent( + new RandomizingProvider(keys.privateKey), + `did:ad:agent:${keys.publicKey}`, + ); + + await expect(agent.personalDriveSubject()).rejects.toThrow( + /personal drive/i, + ); + }); + + it('uses the subject derived at sign-in, not the live signer', async ({ + expect, + }) => { + const keys = await Agent.generateKeyPair(); + const secret = Agent.buildSecret( + keys.privateKey, + `did:ad:agent:${keys.publicKey}`, + ); + const derived = await Agent.personalDriveSubjectFromSecret(secret); + + const agent = new Agent( + new RandomizingProvider(keys.privateKey), + `did:ad:agent:${keys.publicKey}`, + ); + agent.personalDrive = derived; + + // Stable across calls, and equal to what any other device would compute. + expect(await agent.personalDriveSubject()).toBe(derived); + expect(await agent.personalDriveSubject()).toBe(derived); + }); + + it('derives the same subject from the secret as from the raw key', async ({ + expect, + }) => { + const keys = await Agent.generateKeyPair(); + const secret = Agent.buildSecret( + keys.privateKey, + `did:ad:agent:${keys.publicKey}`, + ); + + const fromSecret = await Agent.personalDriveSubjectFromSecret(secret); + const jsAgent = Agent.fromSecret(secret, 'js'); + + expect(await jsAgent.personalDriveSubject()).toBe(fromSecret); + // And it is a signature by this key, not some other value. + expect( + await getPublicKey(new Uint8Array(decodeB64(keys.privateKey))), + ).toEqual(new Uint8Array(decodeB64(keys.publicKey))); + }); +}); diff --git a/browser/lib/src/personal-drive-idempotence.test.ts b/browser/lib/src/personal-drive-idempotence.test.ts new file mode 100644 index 000000000..4c5ce7cef --- /dev/null +++ b/browser/lib/src/personal-drive-idempotence.test.ts @@ -0,0 +1,83 @@ +import { describe, it } from 'vitest'; +import { server } from './ontologies/server.js'; +import { testStore } from './test-store.js'; + +/** + * The product minted a fresh "My drive" on each sign-in. Derivation itself is + * stable (see `subtle-personal-drive.test.ts`), so the churn has to come from + * the path around it: a second call that does not recognise the drive the + * first one made. + */ +describe('ensurePersonalDrive is idempotent', () => { + it('returns the same subject when called twice', async ({ expect }) => { + const { store } = await testStore(); + + const first = await store.ensurePersonalDrive(); + const second = await store.ensurePersonalDrive(); + + expect(second.subject).toBe(first.subject); + }); + + it('returns the same subject after the in-memory cache is dropped', async ({ + expect, + }) => { + const { store } = await testStore(); + + const first = await store.ensurePersonalDrive(); + + // What a reload looks like: same key, same store config, nothing cached. + ( + store as unknown as { _resources: Map } + )._resources.clear(); + + const second = await store.ensurePersonalDrive(); + + expect(second.subject).toBe(first.subject); + }); + + it('does not accumulate drives on the list when called repeatedly', async ({ + expect, + }) => { + const { store } = await testStore(); + + const first = await store.ensurePersonalDrive(); + await store.ensurePersonalDrive(); + const third = await store.ensurePersonalDrive(); + + const listed = third.getSubjects(server.properties.drives); + const own = listed.filter(s => s === first.subject); + + expect(own.length).toBeLessThanOrEqual(1); + }); + + it('migrates once per agent even when setAgent fires repeatedly', async ({ + expect, + }) => { + const { store, agentDID } = await testStore(); + const agent = store.getAgent()!; + + agent.legacySubject = 'https://atomicdata.dev/agents/QmExample='; + + let fetches = 0; + const internals = store as unknown as { + adoptLegacyAgentIdentity: (a: unknown) => Promise; + fetchLegacyAgentResource: (s: string) => Promise; + }; + + internals.fetchLegacyAgentResource = async () => { + fetches++; + + return undefined; + }; + + // Booting sets an agent more than once; each pass used to migrate again. + await Promise.all([ + internals.adoptLegacyAgentIdentity(agent), + internals.adoptLegacyAgentIdentity(agent), + ]); + await internals.adoptLegacyAgentIdentity(agent); + + expect(fetches).toBe(1); + expect(agentDID).toBeTruthy(); + }); +}); diff --git a/browser/lib/src/readonly-hydration.test.ts b/browser/lib/src/readonly-hydration.test.ts new file mode 100644 index 000000000..cbe18ee00 --- /dev/null +++ b/browser/lib/src/readonly-hydration.test.ts @@ -0,0 +1,138 @@ +import { describe, it } from 'vitest'; +import { Store } from './store.js'; +import { Agent } from './agent.js'; +import { JSCryptoProvider } from './CryptoProvider.js'; +import { LoroLoader } from './loro-loader.js'; +import { Resource } from './resource.js'; +import { core, commits } from './index.js'; + +/** The WS/HTTP ordering: something renders the subject first, so a loading + * placeholder is in the store before the server's bytes arrive. */ +function seedLoadingPlaceholder(store: Store, subject: string): Resource { + const r = new Resource(subject); + r.setStore(store); + r.loading = true; + (store as unknown as { resources: Map }).resources.set( + subject, + r, + ); + + return r; +} + +/** `markDirty` is a no-op while offline, and the whole point here is what the + * outbox does, so the store has to believe it is connected. */ +function pretendConnected(store: Store): void { + (store as unknown as { _serverConnected: boolean })._serverConnected = true; +} + +/** + * Stand-in for what the server sends back: a Loro snapshot plus the JSON-AD + * propvals alongside it. `createdBy` is the interesting one — the server + * derives it from the genesis certificate and ships it as a propval, but it is + * NOT in the snapshot, because nobody ever wrote it there. + */ +function serverResource(subject: string, name: string): Resource { + const { LoroDoc } = LoroLoader.Loro; + const doc = new LoroDoc(); + const props = doc.getMap('properties'); + props.set(core.properties.isA, ['https://atomicdata.dev/classes/Drive']); + props.set(core.properties.name, name); + doc.commit(); + + const incoming = new Resource(subject); + incoming.applyHydratedValues([ + [core.properties.isA, ['https://atomicdata.dev/classes/Drive']], + [core.properties.name, name], + ['https://atomicdata.dev/properties/createdBy', 'did:ad:agent:someone'], + [commits.properties.loroUpdate, doc.export({ mode: 'snapshot' })], + ]); + + return incoming; +} + +/** + * Reading someone else's resource must not queue a write to it. + * + * Field bug (2026-08-17): right after accepting a VIEW invite, the invitee's + * client signed a commit against the shared drive and the server refused it — + * correctly, they have no write right. The commit carried the drive's entire + * contents, which is what a Loro export from an empty version vector looks + * like: the drive's doc had been hydrated through operations that count as + * LOCAL, and the outbox dutifully tried to push them. The invitee, who had + * written nothing, was left with a blocked queue entry and a permanent + * "changes pending". + * + * The dirty filter (`isOwnedSubject`) only asks whether a subject belongs to + * our server, never whether we may write it — so the guard has to be that + * hydrating from the server produces no local operations in the first place. + */ +describe('hydrating a fetched resource', () => { + /** + * The path that produced the bug. `JSONADParser` fills the cache from the + * JSON-AD and then calls `getLoroDoc()`, whose heal pass writes every cached + * key the snapshot lacks into the document — `createdBy` among them, because + * the server derives it and never stores it there. + */ + it('keeps a server-derived propval out of the document', async ({ + expect, + }) => { + await LoroLoader.initializeLoro(); + + const incoming = serverResource('did:ad:whatever', 'Their Drive'); + const props = incoming.getLoroDoc()?.getMap('properties'); + + expect(props?.get(core.properties.name)).toBe('Their Drive'); + expect( + props?.get('https://atomicdata.dev/properties/createdBy'), + ).toBeUndefined(); + // Still readable — it lives in the cache. + expect(incoming.get('https://atomicdata.dev/properties/createdBy')).toBe( + 'did:ad:agent:someone', + ); + }); + + it('does not mark a subject dirty when the server hydrates it', async ({ + expect, + }) => { + await LoroLoader.initializeLoro(); + + const store = new Store({ serverUrl: 'https://example.com' }); + const keys = await Agent.generateKeyPair(); + store.setAgent( + new Agent( + new JSCryptoProvider(keys.privateKey), + `did:ad:agent:${keys.publicKey}`, + ), + ); + pretendConnected(store); + + const subject = 'did:ad:someone-elses-drive'; + seedLoadingPlaceholder(store, subject); + + store.applyIncoming({ + subject, + resource: serverResource(subject, 'Their Drive'), + source: 'http-fetch', + }); + + const hydrated = store.getResourceLoading(subject); + + expect(hydrated.get(core.properties.name)).toBe('Their Drive'); + // The load-bearing assertion: a propval the server derived must not have + // become an operation in our copy of the document. Everything else here + // is downstream of that. + expect( + hydrated + .getLoroDoc() + ?.getMap('properties') + .get('https://atomicdata.dev/properties/createdBy'), + ).toBeUndefined(); + // The derived propval still reads back — it just lives in the cache + // rather than in the CRDT. + expect(hydrated.get('https://atomicdata.dev/properties/createdBy')).toBe( + 'did:ad:agent:someone', + ); + expect(store.outbox.hasPending(subject)).toBe(false); + }); +}); diff --git a/browser/lib/src/real-account-migration.test.ts b/browser/lib/src/real-account-migration.test.ts new file mode 100644 index 000000000..0d43c55b3 --- /dev/null +++ b/browser/lib/src/real-account-migration.test.ts @@ -0,0 +1,113 @@ +import { describe, it } from 'vitest'; +import { core } from './ontologies/core.js'; +import { server } from './ontologies/server.js'; +import { testStore } from './test-store.js'; + +/** + * A real pre-DID account's `drives` list, taken verbatim. The account's Agent + * lives on atomicdata.dev; the client signing in is a desktop node on + * localhost, which is the situation the whole legacy path exists for and the + * one where it used to adopt nothing at all. + */ +const REAL_LEGACY_DRIVES = [ + 'https://atomicdata.dev/drive/xzpv34r5ibr', + 'https://atomicdata.dev/drive/uiwpr85ie5', + 'https://staging.atomicdata.dev/drive/ckggjb1d3md', + 'https://atomicdata.dev/drive/wd22yxmxw4j', + 'https://staging.atomicdata.dev/drive/hu3zyo48hrk', + 'https://atomicdata.dev/drive/yf0pzh8v8f', + 'https://atomicdata.dev/drive/92yq9rh8tto', + 'https://atomicdata.dev/drive/x7tkn0udybl', + 'https://atomicdata.dev/drive/7eqsy7w84eo', + 'https://atomicdata.dev/drive/tlqc9jtz5oj', + 'https://atomicdata.dev/drive/dxbdhd48i9r', + 'https://staging.atomicdata.dev/drive/o6kr36n355g', + 'https://staging.atomicdata.dev/drive/41w8ah24nx', + 'https://atomicdata.dev/drive/hms3fnoue08', + 'https://atomicdata.dev/drive/g9fuuv7qcej', + 'https://atomicdata.dev/drive/9k62ef0eldg', + 'https://atomicdata.dev/drive/xpectln6zf', + 'https://atomicdata.dev/drive/xguajxank6b', + 'https://atomicdata.dev/drive/ehznq6mz7vm', + 'https://atomicdata.dev/drive/8by8zw4olv2', + 'https://atomicdata.dev/drive/9wtmc8uk1il', + 'https://atomicdata.dev/drive/9u3s8h3dp89', + 'https://atomicdata.dev/drive/xacbwbumnkd', + 'https://atomicdata.dev/drive/c4us75xed3o', + 'https://atomicdata.dev/drive/fw18lanhoi7', + 'https://atomicdata.dev/drive/ubvghassjtn', + 'https://atomicdata.dev/drive/vbjfy176h4', + 'https://staging.atomicdata.dev/drive/5up73z98e0p', + 'https://staging.atomicdata.dev/drive/zeg5rq7lz4d', + 'https://staging.atomicdata.dev/drive/KrOMdgvZ', + 'https://staging.atomicdata.dev/drive/ggNbDEd5', + 'https://staging.atomicdata.dev/drive/wlU0ptWe', + 'https://staging.atomicdata.dev/drive/nRQpoN51', + 'https://atomicdata.dev/drive/0PXBdxdX', + 'https://atomicdata.dev/drive/e1wEOaJv', + 'https://staging.atomicdata.dev/01hw5n6kxbpa3j9hpgy7h6z94x', + 'https://staging.atomicdata.dev/drive/DbYX0DLO', + 'http://dawdawda.localhost:9883/01j3fqv30nqa0nevd3rnjnrsse', + 'http://dawdawda.localhost:9883/01j44gjrm4tm8fjx3cvfv7edes', + 'https://staging.atomicdata.dev/01j52zyrw2ca3rm6v1wvkvy8k2', + 'http://localhost:9883/01j71grbnyq2w922g2ttt7w46e', + 'https://staging.atomicdata.dev/drive/GMQMKO1C', + 'http://localhost:9883/01j9h0q1664ypfjr05nk0276mk', + 'http://localhost:9883/01j9nkgzrfa3bh9zyqb2j6z8c4', + 'https://staging.atomicdata.dev/drive/fbNcKEt0', + 'https://atomicdata.dev/drive/hTPiNoiY', + 'https://atomicdata.dev/01jbf1try7vyh1xrtbvdt6ez78', + 'http://localhost:9883/01jv2aqzqc1g1wpq4ywpfxq89n', + 'http://localhost:9883/01jvpst7jqw4j462e9c51gctcw', + 'http://localhost:9883/01jy16kjbvqam7xtg1b5scxtxz', + 'http://localhost:9883/01jy1rx81v2scdb7ejgjcnvm5f', + 'http://localhost:9883/01jykhqdbx4p949tyd83hecnqg', + 'http://localhost:9883/01kgqcsm59zjhc83fajcxkxpz6', +]; + +const LEGACY_AGENT = 'https://atomicdata.dev/agents/QmExample='; + +describe('migrating a real pre-DID account', () => { + it('adopts the account origin and nothing else', async ({ expect }) => { + const { store, agentDID } = await testStore(); + const agent = store.getAgent()!; + + agent.legacySubject = LEGACY_AGENT; + + const legacy = store.getResourceLoading(LEGACY_AGENT, { + newResource: true, + }); + await legacy.set(core.properties.isA, [core.classes.agent], false); + await legacy.set(core.properties.name, 'joep.io', false); + await legacy.set(server.properties.drives, REAL_LEGACY_DRIVES, false); + + const didAgent = store.getResourceLoading(agentDID, { newResource: true }); + await didAgent.set(core.properties.isA, [core.classes.agent], false); + + await ( + store as unknown as { + adoptLegacyDriveList: ( + a: unknown, + l: unknown, + d: unknown, + ) => Promise; + } + ).adoptLegacyDriveList(agent, legacy, didAgent); + + const derived = await store.getResource(await agent.personalDriveSubject()); + const listed = derived.getSubjects(server.properties.drives); + + const from = (host: string) => listed.filter(s => s.includes(host)).length; + + // The account's own server: these are the drives worth having. + expect(from('//atomicdata.dev/')).toBe(27); + + // A different origin from the Agent, so not covered by the account's own + // trust. Adopting these needs an origin set, not a single origin. + expect(from('//staging.atomicdata.dev/')).toBe(0); + + // The class `legacy-drive-adoption.test.ts` exists for: a stale entry that + // would point a hosted app at the signed-in user's own machine. + expect(from('localhost:9883')).toBe(0); + }); +}); diff --git a/browser/lib/src/resource.ts b/browser/lib/src/resource.ts index d1ac782f0..19d54047b 100644 --- a/browser/lib/src/resource.ts +++ b/browser/lib/src/resource.ts @@ -47,6 +47,33 @@ import { /** Contains the PropertyURL / Value combinations */ export type PropVals = Map; +/** + * Propvals the server derives and ships alongside a resource, which must never + * be written into its CRDT. + * + * They all have another source of truth: `lastCommit` is commit metadata, + * `createdAt` and `createdBy` come from the genesis certificate (see + * {@link Resource.getCreatedBy}). Writing one into Loro produces a LOCAL + * operation, and a local operation means a dirty subject and a commit — for a + * value the client never authored. On a resource you may write that is a + * redundant commit per hydration; on one you may only READ, the server refuses + * it forever, which is how an invitee who had written nothing ended up with a + * blocked outbox entry and a permanent "changes pending". + * + * They stay in the read cache, so `resource.get()` and JSON-AD round-trips are + * unaffected — which is why every `serverManaged` preservation list below has + * to include them. + */ +const DERIVED_BY_SERVER: ReadonlySet = new Set([ + properties.commit.lastCommit, + commits.properties.createdAt, + properties.createdBy, +]); + +/** True for a propval the server derives — see {@link DERIVED_BY_SERVER}. */ +const isDerivedByServer = (prop: string): boolean => + DERIVED_BY_SERVER.has(prop); + export interface MergeOptions { replaceLoroDocs?: boolean; /** @@ -267,8 +294,7 @@ export class Resource { // Object.create(null) #cache → Object.keys is the cheap own-prop scan. const hasRenderable = Object.keys(this.#cache).some( k => - k !== properties.commit.lastCommit && - k !== commits.properties.createdAt && + !isDerivedByServer(k) && k !== core.properties.parent && k !== 'https://atomicdata.dev/properties/drive', ); @@ -441,10 +467,7 @@ export class Resource { this._loroDoc.import(stored); } else { for (const [key, value] of Object.entries(this.#cache)) { - if ( - key !== properties.commit.lastCommit && - key !== commits.properties.createdAt - ) { + if (!isDerivedByServer(key)) { this.loroSetProperty(key, value); } } @@ -464,13 +487,8 @@ export class Resource { // behaviour is unchanged. if (initializedFromSnapshot && this._loroMap) { for (const [key, value] of Object.entries(this.#cache)) { - if ( - key !== properties.commit.lastCommit && - key !== commits.properties.createdAt - ) { - if (this._loroMap.get(key) === undefined) { - this.loroSetProperty(key, value); - } + if (!isDerivedByServer(key) && this._loroMap.get(key) === undefined) { + this.loroSetProperty(key, value); } } } @@ -584,10 +602,7 @@ export class Resource { } private applyRawValue(prop: string, val: AtomicValue): void { - if ( - prop === properties.commit.lastCommit || - prop === commits.properties.createdAt - ) { + if (isDerivedByServer(prop)) { if (val === undefined) { delete this.#cache[prop]; } else { @@ -739,6 +754,7 @@ export class Resource { const serverManaged = [ properties.commit.lastCommit, commits.properties.createdAt, + properties.createdBy, 'https://atomicdata.dev/properties/drive', // The inline genesis certificate: set once at creation, immutable, and // must not be dropped when the cache is rebuilt from a delta-only doc — @@ -1451,6 +1467,7 @@ export class Resource { const serverManaged = [ properties.commit.lastCommit, commits.properties.createdAt, + properties.createdBy, ]; for (const key of serverManaged) { @@ -1481,6 +1498,7 @@ export class Resource { const serverManaged = [ properties.commit.lastCommit, commits.properties.createdAt, + properties.createdBy, ]; for (const key of serverManaged) { @@ -3303,10 +3321,7 @@ export class Resource { return; } - if ( - prop === properties.commit.lastCommit || - prop === commits.properties.createdAt - ) { + if (isDerivedByServer(prop)) { delete this.#cache[prop]; return; diff --git a/browser/lib/src/serverCapabilities.ts b/browser/lib/src/serverCapabilities.ts index f624feff6..4ea7e3301 100644 --- a/browser/lib/src/serverCapabilities.ts +++ b/browser/lib/src/serverCapabilities.ts @@ -78,16 +78,21 @@ export function recordServerVersionFromResponse( /** * Records server version and DID auth support based on WebSocket protocol. - * V1 protocol ('atomicdata-ws.v0.1') supports DID auth, legacy does not. + * + * Any negotiated `atomicdata-ws.*` subprotocol means a server new enough to + * speak DID auth; only a server that selects none is treated as legacy. The + * previous check compared against `atomicdata-ws.v0.1`, a name the client + * stopped sending when it moved to `v2` (see `WS_PROTOCOL`), so every server + * — including the app's own embedded one — was recorded as not supporting it. */ export function recordServerVersionFromWsProtocol( protocol: string | undefined, origin: string, ): void { - const isV1 = protocol === 'atomicdata-ws.v0.1'; + const speaksAtomicWs = protocol?.startsWith('atomicdata-ws.') ?? false; serverVersionByOrigin.set(origin, protocol ?? 'legacy'); - supportsDidAuthByOrigin.set(origin, isV1); + supportsDidAuthByOrigin.set(origin, speaksAtomicWs); } function versionSupportsDidAuth(version: string): boolean { diff --git a/browser/lib/src/store.personal-drive.test.ts b/browser/lib/src/store.personal-drive.test.ts new file mode 100644 index 000000000..49eae4fb1 --- /dev/null +++ b/browser/lib/src/store.personal-drive.test.ts @@ -0,0 +1,256 @@ +import { describe, it, vi } from 'vitest'; +import { Agent } from './agent.js'; +import { JSCryptoProvider } from './CryptoProvider.js'; +import { decodeB64 } from './base64.js'; +import { + decodeGenesisCert, + domainSeparatorNonce, + PERSONAL_DRIVE_PURPOSE, + personalDriveSubject, +} from './genesis.js'; +import { core } from './ontologies/core.js'; +import { server } from './ontologies/server.js'; +import { Store } from './store.js'; +import { testStore } from './test-store.js'; + +const GENESIS = 'https://atomicdata.dev/properties/genesis'; +const FAVORITES = 'https://atomicdata.dev/properties/favorites'; +const PERSONAL_DRIVE_NONCE_HEX = '5f62397980dc34a685e5ee57fa0ac058'; + +const hex = (bytes: Uint8Array): string => + Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); + +describe('deterministic personal drive', () => { + it('createDrive({ personal: true }) uses the derived DID and cert', async ({ + expect, + }) => { + const { store, agentDID } = await testStore(); + const agent = store.getAgent()!; + const expected = await agent.personalDriveSubject(); + + const drive = await store.createDrive('Home', { personal: true }); + + expect(drive.subject).toBe(expected); + expect(drive.get(core.properties.name)).toBe('Home'); + expect(drive.get(core.properties.write)).toEqual([agentDID]); + + const cert = decodeGenesisCert(decodeB64(drive.get(GENESIS) as string)); + expect(cert.createdAt).toBe(0); + expect(hex(cert.nonce)).toBe(PERSONAL_DRIVE_NONCE_HEX); + expect(hex(domainSeparatorNonce(PERSONAL_DRIVE_PURPOSE))).toBe( + PERSONAL_DRIVE_NONCE_HEX, + ); + }); + + it('the same key on two stores mints the same personal-drive subject', async ({ + expect, + }) => { + const keys = await Agent.generateKeyPair(); + const expected = await personalDriveSubject(decodeB64(keys.privateKey)); + + const make = async () => { + const store = new Store({ serverUrl: 'https://example.com' }); + store.setServerConnected(true); + store.setAgent( + new Agent( + new JSCryptoProvider(keys.privateKey), + `did:ad:agent:${keys.publicKey}`, + ), + ); + store.injectFetch(async () => { + throw new Error('test: network disabled'); + }); + vi.spyOn(store, 'getProperty').mockRejectedValue( + new Error('test: property validation skipped'), + ); + + ( + store as unknown as { + client: { postCommit: (c: unknown) => Promise }; + } + ).client.postCommit = async (commit: unknown) => { + return { ...(commit as object), id: 'https://example.com/commits/x' }; + }; + + return store.createDrive('Home', { personal: true }); + }; + + const a = await make(); + const b = await make(); + expect(a.subject).toBe(expected); + expect(b.subject).toBe(expected); + expect(a.subject).toBe(b.subject); + }); + + it('a second createDrive(personal) on the same store returns the same resource', async ({ + expect, + }) => { + const { store } = await testStore(); + const first = await store.createDrive('Home', { personal: true }); + const second = await store.ensurePersonalDrive('Other name'); + expect(second.subject).toBe(first.subject); + expect(second).toBe(first); + }); + + it('does not rename a personal drive the user has titled', async ({ + expect, + }) => { + const { store } = await testStore(); + const drive = await store.createDrive('Home', { personal: true }); + + await drive.set(core.properties.name, "Joep's stuff", false); + await drive.save(); + + // Boot and sign-in both call this; neither may rewrite what is there. + const again = await store.ensurePersonalDrive('My drive'); + + expect(again.subject).toBe(drive.subject); + expect(again.get(core.properties.name)).toBe("Joep's stuff"); + }); + + it('an additional drive is recorded on the derived personal drive', async ({ + expect, + }) => { + const { store } = await testStore(); + const extra = await store.createDrive('Project', { personal: false }); + const personal = await store.ensurePersonalDrive(); + const listed = personal.getSubjects(server.properties.drives); + expect(listed).toContain(extra.subject); + expect(extra.subject).not.toBe(personal.subject); + }); + + it('unions lists from a previous random-DID home onto the derived drive', async ({ + expect, + }) => { + const { store, agentDID } = await testStore(); + const agent = store.getAgent()!; + + const oldHome = await store.newResource({ + isA: server.classes.drive, + noParent: true, + propVals: { + [core.properties.name]: 'Old home', + [core.properties.write]: [agentDID], + }, + }); + await oldHome.save(); + + const leftover = 'did:ad:leftover-workspace'; + const starred = 'did:ad:starred-doc'; + oldHome.push(server.properties.drives, [leftover], true); + oldHome.push(FAVORITES, [starred], true); + await oldHome.save(); + + const agentResource = store.getResourceLoading(agentDID, { + newResource: true, + }); + await agentResource.set( + core.properties.personalDrive, + oldHome.subject, + false, + ); + await agentResource.set(core.properties.isA, [core.classes.agent], false); + + const derived = await store.ensurePersonalDrive('Home'); + + expect(derived.subject).toBe(await agent.personalDriveSubject()); + expect(derived.subject).not.toBe(oldHome.subject); + + const listed = derived.getSubjects(server.properties.drives); + expect(listed).toContain(oldHome.subject); + expect(listed).toContain(leftover); + expect(derived.getSubjects(FAVORITES)).toContain(starred); + }); + + it('unions lists from the home named in the agent secret when the Agent resource has no pointer', async ({ + expect, + }) => { + const { store, agentDID } = await testStore(); + const agent = store.getAgent()!; + + const oldHome = await store.newResource({ + isA: server.classes.drive, + noParent: true, + propVals: { + [core.properties.name]: 'Old home', + [core.properties.write]: [agentDID], + }, + }); + await oldHome.save(); + + const leftover = 'did:ad:secret-only-workspace'; + const starred = 'did:ad:secret-only-doc'; + oldHome.push(server.properties.drives, [leftover], true); + oldHome.push(FAVORITES, [starred], true); + await oldHome.save(); + + // No `personalDrive` on the Agent resource — the shape of a self-hosted + // account whose server never wrote one. `initialDrive`, which travels with + // the secret rather than with any server's data, is the only record left. + const agentResource = store.getResourceLoading(agentDID, { + newResource: true, + }); + await agentResource.set(core.properties.isA, [core.classes.agent], false); + agent.initialDrive = oldHome.subject; + + const derived = await store.ensurePersonalDrive('Home'); + + expect(derived.subject).toBe(await agent.personalDriveSubject()); + expect(derived.subject).not.toBe(oldHome.subject); + + const listed = derived.getSubjects(server.properties.drives); + expect(listed).toContain(oldHome.subject); + expect(listed).toContain(leftover); + expect(derived.getSubjects(FAVORITES)).toContain(starred); + }); + + it('adopts a legacy drive list hosted on another origin', async ({ + expect, + }) => { + const { store, agentDID } = await testStore(); + const agent = store.getAgent()!; + + // The shape of a pre-DID account: the drives live on the server being + // migrated away from, while this client points at the new home. Under an + // origin check these are all dropped and the user arrives with nothing. + const legacy = store.getResourceLoading( + 'https://atomicdata.dev/agents/QmExample=', + { newResource: true }, + ); + await legacy.set(core.properties.isA, [core.classes.agent], false); + await legacy.set(core.properties.name, 'joep.io', false); + await legacy.set( + server.properties.drives, + [ + 'https://atomicdata.dev/drive/xzpv34r5ibr', + 'https://staging.atomicdata.dev/drive/ckggjb1d3md', + 'http://localhost:9883/01j71grbnyq2w9', + ], + false, + ); + + const didAgent = store.getResourceLoading(agentDID, { newResource: true }); + await didAgent.set(core.properties.isA, [core.classes.agent], false); + + await ( + store as unknown as { + adoptLegacyDriveList: ( + a: unknown, + l: unknown, + d: unknown, + ) => Promise; + } + ).adoptLegacyDriveList(agent, legacy, didAgent); + + const derived = await store.getResource(await agent.personalDriveSubject()); + const listed = derived.getSubjects(server.properties.drives); + + expect(listed).toContain('https://atomicdata.dev/drive/xzpv34r5ibr'); + // A different origin from the legacy agent, so still dropped — this is the + // class that made a hosted app fetch from the user's own machine. + expect(listed).not.toContain('http://localhost:9883/01j71grbnyq2w9'); + expect(listed).not.toContain( + 'https://staging.atomicdata.dev/drive/ckggjb1d3md', + ); + }); +}); diff --git a/browser/lib/src/store.ts b/browser/lib/src/store.ts index ec30bc40d..a89bf876e 100644 --- a/browser/lib/src/store.ts +++ b/browser/lib/src/store.ts @@ -32,11 +32,13 @@ import { import { stringToSlug } from './stringToSlug.js'; import { bytesToHex, hexToBytes, type JSONValue } from './value.js'; import { WSClient } from './websockets.js'; +import { withDeadline } from './withDeadline.js'; import { BLOB, endpoints, INTERNAL_ID } from './urls.js'; import { initOntologies } from './ontologies/index.js'; import { decodeB64, encodeB64, encodeB64Url } from './base64.js'; import { encodeGenesisCert, + personalDriveCert, subjectForSignature, type GenesisCert, } from './genesis.js'; @@ -78,6 +80,12 @@ type DriveCallback = (drive: string) => void; /** Called when the server reports vector index activity for a drive (via websocket). */ export type IndexingStatusCallback = (indexing: boolean) => void; + +/** How long a migration fetch may take before the answer stops being useful. + * These are servers the device may never reach; sign-in must not wait on + * them. */ +const LEGACY_FETCH_DEADLINE_MS = 5_000; + type Fetch = typeof fetch; type CreateResourceOptions = { /** Optional subject of the new resource, if not given the store will generate a random subject */ @@ -92,6 +100,9 @@ type CreateResourceOptions = { propVals?: Record; /** Set to true if the resource should have a DID as subject. Defaults to `true` for `did:ad` agents, otherwise `false`. */ did?: boolean; + /** When set, the resource is minted from this cert (deterministic DID) + * instead of a random-nonce certificate. */ + genesisCert?: GenesisCert; }; export interface StoreOpts { @@ -270,10 +281,10 @@ export interface CreateDriveOpts { * separate save for callers (e.g. dev-drive) that want the agent to be * renderable as a named resource right away. */ agentName?: string; - /** A personal drive becomes the agent's home: it is linked as the agent's - * `personalDrive` and hosts the saved-drives switcher list (seeded with - * itself). A non-personal (additional) drive is instead pushed onto the - * agent's EXISTING personal drive's list. Defaults to true. */ + /** A personal drive is the agent's derived home DID (same subject on every + * device). It is linked as `personalDrive` for older clients and hosts the + * saved-drives switcher list. A non-personal drive is pushed onto that + * list. Defaults to true. */ personal?: boolean; } @@ -351,6 +362,7 @@ const CLIENT_DB_ATTACH_GRACE = 5000; const SERVER_MANAGED_SKELETON_PROPS: ReadonlySet = new Set([ commits.properties.lastCommit, commits.properties.createdAt, + 'https://atomicdata.dev/properties/createdBy', 'https://atomicdata.dev/properties/drive', core.properties.parent, ]); @@ -433,6 +445,15 @@ export class Store { * can re-fetch. Keyed by normalized subject. */ private _inFlightFetches: Map> = new Map(); + /** Subjects with a gap-recovery fetch in flight. A delta that cannot apply + * triggers one full-state fetch; this stops a burst of unappliable deltas + * for the same subject from firing one fetch each. */ + private _gapRecoveries: Set = new Set(); + + /** Agent subjects already re-checked against the server this session — see + * the agent branch in {@link fetchResourceWithLocalFallback}. */ + private _revalidatedAgents: Set = new Set(); + /** Current Agent, used for signing commits. Is required for posting things. */ private agent?: Agent; /** Mapped from origin to websocket */ @@ -1685,6 +1706,19 @@ export class Store { return this.aliases.get(normalized) ?? normalized; } + /** + * True when `subject` is a placeholder (`_new:…`) that has since been aliased + * to a real subject — i.e. the draft it stood for has been persisted. + * + * Lets a view tell apart the two reasons a collection can grow: one of its + * own drafts materialising, versus a resource arriving from elsewhere (a + * peer, or another tab). Those need opposite handling, and without a way to + * distinguish them a view has to guess. + */ + public isAliased(subject: string): boolean { + return this.aliases.has(this.normalizeSubject(subject)); + } + /** Resolve a (possibly aliased) subject to its cached Resource. */ private getResolved(subject: string): Resource | undefined { return this.resources.get(this.resolveSubject(subject)); @@ -1789,6 +1823,39 @@ export class Store { } } + /** + * Repair a document that has fallen behind: fetch full state and replace the + * local doc with it. + * + * The live channel is deltas, and it has no way to say "you are missing + * something". A receiver that misses one update queues every later one as + * pending, which looks exactly like a document that quietly stopped syncing. + * `SYNC_VV` already handles this at connect time; this is the mid-session + * equivalent, triggered by the one signal available — an import that could + * not fully apply. + * + * Failures are swallowed on purpose. This runs off the back of an incoming + * update, the caller keeps its existing (stale but usable) state either way, + * and a network error here should not surface as an error on a document the + * user can still read. + */ + private recoverFromIncompleteImport(subject: string, source?: string): void { + if (this._gapRecoveries.has(subject)) return; + + this._gapRecoveries.add(subject); + console.warn( + `[Store] incomplete Loro import for ${subject.slice(0, 60)} ` + + `(source: ${source ?? 'unknown'}) — missing base state, fetching a full ` + + `snapshot to catch up.`, + ); + + this.fetchResourceFromServer(subject, { forceOverride: true }) + .catch(() => undefined) + .finally(() => { + this._gapRecoveries.delete(subject); + }); + } + /** * Single ingress for resource state from any source: subject * normalisation, commit-id dedup, Loro hydration, atomic OPFS @@ -1873,21 +1940,44 @@ export class Store { // no error anywhere. Surface it instead. Skip the failure if the // resource already had usable content from a prior good import // (a late/partial live push shouldn't blow away a good state). - if (!complete && !isCommitDetail && !resource.get(core.properties.isA)) { - console.warn( - `[Store] applyIncoming: incomplete Loro import for ${subject.slice(0, 60)} ` + - `(source: ${change.source}) — server sent a delta this client can't apply ` + - `(missing base state). Surfacing as error.`, - ); - if (change.commitId) resource.setLastCommitValue(change.commitId); - this.failResource( - subject, - new Error( - 'Sync error: received an incomplete update for this resource ' + - '(missing base state). Try reloading; if it persists, the ' + - "local cache may be out of sync with the server's history.", - ), - ); + if (!complete && !isCommitDetail) { + if (!resource.get(core.properties.isA)) { + console.warn( + `[Store] applyIncoming: incomplete Loro import for ${subject.slice(0, 60)} ` + + `(source: ${change.source}) — server sent a delta this client can't apply ` + + `(missing base state). Surfacing as error.`, + ); + if (change.commitId) resource.setLastCommitValue(change.commitId); + this.failResource( + subject, + new Error( + 'Sync error: received an incomplete update for this resource ' + + '(missing base state). Try reloading; if it persists, the ' + + "local cache may be out of sync with the server's history.", + ), + ); + + return 'invalid'; + } + + // The resource already has usable content, so failing it would throw + // away good state for the sake of an update we couldn't apply. The old + // code took the other extreme and fell through to `return 'applied'`, + // which is how a collaborator's text went missing in silence: one + // delta arrives whose base ops we never saw, Loro parks it as pending, + // and every later delta parks behind it. No error, no indicator — the + // document simply stops being live until someone reloads. + // + // Ask for full state instead. `forceOverride` replaces rather than + // merges, which is what closes the gap; `applyIncoming`'s own guards + // still refuse to clobber unsaved local edits or a pending outbox. + // + // Deliberately NOT stamping `lastCommit` here. We did not apply that + // commit, and claiming it would make the echo-dedup at the top of this + // method drop the very fetch being issued to repair the gap. + this.recoverFromIncompleteImport(subject, change.source); + resource.loading = false; + this.addResource(resource, { skipCommitCompare: true }); return 'invalid'; } @@ -2073,6 +2163,7 @@ export class Store { propVals, noParent, did, + genesisCert, }: CreateResourceOptions = {}): Promise> { const shouldUseDid = did ?? this.getAgent()?.subject?.startsWith('did:ad:agent:') ?? false; @@ -2103,7 +2194,11 @@ export class Store { let newSubject: string; let genesisCertB64: string | undefined; - if (subject) { + if (genesisCert) { + const minted = await this.mintFromCert(genesisCert); + newSubject = subject ?? minted.did; + genesisCertB64 = minted.certB64; + } else if (subject) { newSubject = subject; } else if (shouldUseDid) { const minted = await this.mintCertDid( @@ -2149,7 +2244,7 @@ export class Store { // only `save()` does (it moves the stash into the outbox). So a // created-but-never-saved resource (e.g. an unfilled `TableNewRow`) is never // POSTed. This is the only remaining `signChanges` call site. - if (shouldUseDid && !subject) { + if ((shouldUseDid && !subject) || genesisCert) { const genesisCommit = await resource.signChanges(this.getAgent()!); resource.stashGenesis(genesisCommit); } @@ -2195,24 +2290,69 @@ export class Store { propVals[SUBDOMAIN_PROP] = subdomain; } + if (personal) { + const existingDid = await agent.personalDriveSubject(); + const existing = this._resources.get(existingDid); + + if (existing && !existing.new && !existing.error) { + const oldPointer = await this.linkPersonalDrive( + existing, + agent.subject, + agentName, + ); + await this.maybeMigrateOldPersonalDrive(existing, [ + oldPointer, + agent.initialDrive, + ]); + + return existing; + } + } + + const genesisCert = personal + ? personalDriveCert(decodeB64(await agent.getPublicKey())) + : undefined; + + // Pin the personal drive to the derived subject. `newResource` would + // otherwise mint one by signing the certificate through the agent's + // provider — the same non-deterministic signature this method just looked + // the drive up by, so the drive would be created under a subject no + // subsequent lookup could ever find. + const personalSubject = personal + ? await agent.personalDriveSubject() + : undefined; + const drive = await this.newResource({ isA: server.classes.drive, noParent: true, propVals, + genesisCert, + subject: personalSubject, }); await drive.save(); if (personal) { - await this.linkPersonalDrive(drive, agent.subject, agentName); + const oldPointer = await this.linkPersonalDrive( + drive, + agent.subject, + agentName, + ); + await this.maybeMigrateOldPersonalDrive(drive, [ + oldPointer, + agent.initialDrive, + ]); } else { await this.addToSavedDrives(drive, agent.subject); } // Every drive gets a default Ontology: the home for classes and // properties created inside the drive (e.g. table Row classes), so they - // don't pile up directly under the drive itself. - await this.createDefaultOntology(drive); + // don't pile up directly under the drive itself. Skip if a previous + // device already created one — two random ontology DIDs would fork. + if (!drive.get(server.properties.defaultOntology)) { + await this.createDefaultOntology(drive); + } return drive; } @@ -2222,11 +2362,20 @@ export class Store { * switcher list with itself and links it on the Agent resource (optionally * naming the agent in the same commit). */ + /** + * Seeds the switcher list on `drive` and, when a complete Agent resource is + * available, writes `personalDrive` for older clients. + * + * Returns a previous `personalDrive` pointer when it named a different + * subject, so migration can union that drive's lists. The pointer is + * captured before this write — otherwise migration would only ever see + * the derived DID it just stored. + */ private async linkPersonalDrive( drive: Resource, agentSubject: string, agentName?: string, - ): Promise { + ): Promise { // The user's saved-drives switcher list lives on the personal DRIVE itself // (the per-user home index), not on the Agent. Seed it with this drive so // it shows up in the switcher. This must be a second commit: the drive's @@ -2235,27 +2384,50 @@ export class Store { drive.push(server.properties.drives, [drive.subject], true); await drive.save(); - // Link the drive to the Agent resource. We MUST force a fresh fetch - // from the server here. The agent may already be in the store (from a - // stale clientDb cache or a previous partial load) with `loading=false`, - // in which case `getResource()` short-circuits and returns the cached - // stub. Then `set/push/save` would commit a Loro snapshot that only - // carries the locally-set properties (personalDrive, drives) — the - // server-side state (isA, publicKey, read, etc.) was never merged into - // the local Loro doc, so it isn't part of the outgoing snapshot, and - // isn't written to clientDb. On reload the SPA reads the partial cache - // and the agent's edit form errors with " is not a Class" - // because `isA` is missing. Forcing a fetch seeds the local resource - // with the full server state before we layer the new properties on top. - // - // Use HTTP (not WS): the WS may still be authenticated as a previous - // agent (e.g. onboarding switches from a dev-drive agent to a freshly- - // created one — the WS auth is fire-and-forget and races the GET). - // HTTP signs each request with the current agent and never has stale - // session state. - const agentResource = await this.fetchResourceFromServer(agentSubject, { - noWebSocket: true, - }); + // Capture any local pointer before the server fetch. A failed GET + // applies an error stub into the same map slot and would otherwise + // erase the only copy of the old home subject. + const prior = this._resources.get(agentSubject); + const localPointer = prior?.isReady() + ? (prior.get(core.properties.personalDrive) as string | undefined) + : undefined; + + // Prefer a fresh server copy when we can get one. A stale/partial + // local Agent (clientDb cache, previous incomplete load) would let + // `set/save` commit a Loro snapshot that only carries the properties + // we write here — `isA` / `publicKey` / `read` never make it into + // the outgoing snapshot, and the edit form later errors with + // " is not a Class". HTTP (not WS): onboarding can still be + // authenticated as a previous agent on the socket. + let agentResource: Resource | undefined; + + try { + agentResource = await this.fetchResourceFromServer(agentSubject, { + noWebSocket: true, + }); + + if (agentResource.error) { + throw agentResource.error; + } + } catch { + // Offline / local-only. An error stub is not usable — writing on + // it would mint a partial Agent Loro doc. The derived DID is + // identity; the pointer is only a cache for older clients. + agentResource = prior?.isReady() ? prior : undefined; + } + + const oldPointer = agentResource?.isReady() + ? ((agentResource.get(core.properties.personalDrive) as + | string + | undefined) ?? localPointer) + : localPointer; + + if (!agentResource || !agentResource.isReady()) { + return oldPointer && oldPointer !== drive.subject + ? oldPointer + : undefined; + } + await agentResource.set( core.properties.personalDrive, drive.subject, @@ -2277,30 +2449,126 @@ export class Store { } await agentResource.save(); + + return oldPointer && oldPointer !== drive.subject ? oldPointer : undefined; } /** * Adds a non-personal (additional) drive to the saved-drives switcher list - * on the agent's personal drive. Best-effort: the agent may not have - * provisioned a personal drive yet. + * on the agent's derived personal drive. Materializes that drive if needed. */ private async addToSavedDrives( drive: Resource, - agentSubject: string, + _agentSubject: string, ): Promise { + // An agent whose personal drive cannot be derived (see + // `Agent.personalDriveSubject`) must still be able to make ordinary + // drives; it just has nowhere to list them. + let personalDriveResource: Resource; + try { - const agentResource = await this.getResource(agentSubject); - const personalDrive = agentResource.get(core.properties.personalDrive) as - | string - | undefined; + personalDriveResource = await this.ensurePersonalDrive(); + } catch (e) { + console.warn('Could not record the new drive on the personal drive:', e); + + return; + } + + const already = personalDriveResource.getSubjects(server.properties.drives); + + if (!already.includes(drive.subject)) { + personalDriveResource.push(server.properties.drives, [drive.subject]); + await personalDriveResource.save(); + } + } + + /** + * Union any previous home's lists onto the derived drive and keep the old + * drive as an ordinary workspace. Best-effort: does not block sign-in or + * first write. + * + * Takes several candidates because the old home is recorded in two places + * that go missing independently: + * + * - `personalDrive` on the Agent **resource** — absent whenever the server + * holding the account never wrote one, which is the common case for a + * self-hosted account whose drives were made ad hoc. + * - `initialDrive` from the agent **secret** — the only record left on a + * device that has never seen the old drive, since it travels with the key + * rather than with any server's data. + * + * An account predating the derivation can have either, both, or (for a + * genuinely new agent) neither. + */ + private async maybeMigrateOldPersonalDrive( + derived: Resource, + candidates: (string | undefined)[], + ): Promise { + const seen = new Set([derived.subject]); + + for (const candidate of candidates) { + if (!candidate || seen.has(candidate)) { + continue; + } + + seen.add(candidate); + + // Per candidate: one unreachable old home must not strand the other. + await this.migrateOneOldPersonalDrive(derived, candidate); + } + } + + /** Union one old home's lists onto `derived`. See the caller for why. */ + private async migrateOneOldPersonalDrive( + derived: Resource, + oldPointer: string, + ): Promise { + try { + let old = this._resources.get(oldPointer); + + if (!old || !old.isReady()) { + try { + old = await withDeadline( + this.fetchResourceFromServer(oldPointer, { noWebSocket: true }), + LEGACY_FETCH_DEADLINE_MS, + undefined, + ); + } catch { + return; + } + } - if (personalDrive) { - const personalDriveResource = await this.getResource(personalDrive); - personalDriveResource.push(server.properties.drives, [drive.subject]); - await personalDriveResource.save(); + if (!old || old.error) { + return; } - } catch (_e) { - // Ignore (e.g. no personal drive yet, or an unwritable agent resource). + + const listProps = [ + server.properties.drives, + core.properties.sharedWithMe, + 'https://atomicdata.dev/properties/favorites', + ]; + + for (const prop of listProps) { + const incoming = old.getSubjects(prop); + const have = new Set(derived.getSubjects(prop)); + const missing = incoming.filter(s => s && !have.has(s)); + + if (missing.length > 0) { + derived.push(prop, missing, true); + } + } + + const drives = new Set(derived.getSubjects(server.properties.drives)); + + if (!drives.has(oldPointer)) { + derived.push(server.properties.drives, [oldPointer], true); + } + + if (derived.hasUnsavedChanges()) { + await derived.save(); + } + } catch { + // Migration is nice, not load-bearing. } } @@ -2556,6 +2824,56 @@ export class Store { }; } + private async mintFromCert( + cert: GenesisCert, + ): Promise<{ did: string; certB64: string }> { + const agent = this.getAgent(); + + if (!agent) { + throw new Error( + 'Cannot create a DID resource without an agent. Set an agent on the store first.', + ); + } + + const certBytes = encodeGenesisCert(cert); + + return { + did: subjectForSignature(await agent.signBytes(certBytes)), + certB64: encodeB64Url(certBytes), + }; + } + + /** The agent's derived personal-drive DID. Same key → same subject. */ + public async personalDriveSubject(): Promise { + const agent = this.getAgent(); + + if (!agent) { + throw new Error('Cannot derive a personal drive without an agent'); + } + + return agent.personalDriveSubject(); + } + + /** + * Materialize the derived personal drive if needed and return it. + * Repeat genesis for the same subject merges on the server. + */ + public async ensurePersonalDrive( + name = 'My drive', + opts: Omit = {}, + ): Promise { + // `createDrive` returns the cached drive untouched when it already has + // one, which is what keeps this from renaming a home the user has titled. + // Deliberately no lookup for a drive that is not in memory: a bounded + // `getResource` cannot be cancelled, so the abandoned fetch outlives the + // deadline and fails the drive this call then creates. Materializing is + // therefore a sign-in action, not something boot repeats. + return this.createDrive(name, { + ...opts, + personal: true, + }); + } + /** * Try the local WASM DB (OPFS) for a persisted copy of `subject` and hydrate * the store from it. @@ -2809,6 +3127,21 @@ export class Store { // narrow case it covered (a destroy commit that landed while // we were disconnected AND not covered by SUB on reconnect) // is rare and recovers on the next live update. + // + // Agents are the exception, because that premise fails for them: they + // belong to no drive, so no SUB covers them and nothing ever refreshes + // the local copy. A cached agent can therefore be wrong forever — which + // is what kept other people's names from appearing after the server + // started allowing the read: every client already held a cached stub + // and stopped asking. Re-check each agent once per session; after that + // it is trusted like anything else. + if ( + subject.startsWith('did:ad:agent:') && + !this._revalidatedAgents.has(subject) + ) { + this._revalidatedAgents.add(subject); + await this.fetchResourceFromServer(subject, opts); + } } else { // Online, no local data — server is our only source. await this.fetchResourceFromServer(subject, opts); @@ -3805,6 +4138,10 @@ export class Store { * *this* server is tried first. The original is kept as a fallback for the * case the secret really does describe another, still-live server. */ + /** Agents whose legacy migration has already been started — see + * `adoptLegacyAgentIdentity`. */ + private legacyAdoptionRuns = new Set(); + private async fetchLegacyAgentResource( legacySubject: string, ): Promise { @@ -3820,9 +4157,22 @@ export class Store { for (const candidate of candidates) { try { - const resource = await this.getResource(candidate); + // Bounded: these are servers this device may never reach, and an + // unbounded await here is felt as a frozen sign-in rather than a slow + // one. A late answer is no use once we have stopped waiting. + const resource = await withDeadline( + this.getResource(candidate), + LEGACY_FETCH_DEADLINE_MS, + undefined, + ); + + if (resource && !resource.error) return resource; - if (!resource.error) return resource; + console.warn( + `[atomic] Previous account not readable at '${candidate}'${ + resource ? '' : ' (timed out)' + }.`, + ); } catch { // Try the next candidate. } @@ -3831,19 +4181,50 @@ export class Store { return undefined; } + /** + * Everything an agent needs on the way in: a home drive that exists, and — + * for a pre-DID account — the name and drives the old server holds. + * + * `setAgent` fires this and forgets it, and an app sets an agent more than + * once while booting (a node's own agent, then the signed-in one, then a + * rehydrate). Keyed on the agent, not a bare boolean: signing out and into a + * different account must still run. The key is added before the first await + * so concurrent callers collapse onto one run rather than racing. + */ private async adoptLegacyAgentIdentity(agent: Agent): Promise { const legacySubject = agent.legacySubject; + // Checked before the guard is claimed: an agent that gains a legacy + // subject after the first `setAgent` must still be able to migrate. if (!legacySubject || !agent.subject) return; + if (this.legacyAdoptionRuns.has(agent.subject)) return; + + this.legacyAdoptionRuns.add(agent.subject); + try { const legacy = await this.fetchLegacyAgentResource(legacySubject); - if (!legacy) return; + if (!legacy) { + // Loud on purpose. This used to return in silence, so a migration that + // never ran was indistinguishable from one that found nothing to do — + // and the once-per-agent guard means it will not be retried. + console.warn( + `[atomic] Migration skipped: could not read the previous account at '${legacySubject}'.`, + ); + + return; + } const current = await this.getResource(agent.subject); - if (current.error) return; + if (current.error) { + console.warn( + `[atomic] Migration skipped: this agent's own resource did not load (${current.error.message}).`, + ); + + return; + } // Identity only. `drives` is deliberately NOT carried onto the Agent: // the two models keep that list in different places, and putting it back @@ -3877,9 +4258,12 @@ export class Store { } await this.adoptLegacyDriveList(agent, legacy, current); - } catch { + } catch (e) { // Best effort. A failure here leaves the user signed in with an empty - // profile — bad, but not as bad as failing the sign-in itself. + // profile — bad, but not as bad as failing the sign-in itself. Say so: + // an empty profile with no explanation reads as "migration is broken" + // when it is usually one unreachable server. + console.warn('[atomic] Migrating the previous account failed:', e); } } @@ -3931,7 +4315,23 @@ export class Store { * origin; drop everything else. A drive that genuinely lives elsewhere is not * lost — it is still on the legacy Agent, and "Open by URL" reaches it. */ - private isAdoptableDriveSubject(subject: string): boolean { + /** + * `legacyOrigin` is the origin of the legacy Agent resource this list came + * from. Drives sitting there are adoptable even though they are not on the + * current server: that origin is the account being migrated away from, and + * the user just authenticated against it, so it is not an arbitrary + * third party. Without this a pre-DID account migrating to a desktop node + * or a self-hosted server loses its entire drive list, since none of the + * subjects can match the new home's origin. + * + * Everything else still has to be same-origin. That keeps the case this + * filter exists for: a stale list naming `http://localhost:9883` must never + * make a hosted app issue requests at the user's own machine. + */ + private isAdoptableDriveSubject( + subject: string, + legacyOrigin?: string, + ): boolean { if (subject.startsWith('did:')) return true; // The migration's mangled spelling. It survives serialization as a path @@ -3940,7 +4340,11 @@ export class Store { if (/\/[^/]+:\//.test(subject.replace(/^https?:\/\//, ''))) return false; try { - return new URL(subject).origin === new URL(this.serverUrl).origin; + const { origin } = new URL(subject); + + if (origin === new URL(this.serverUrl).origin) return true; + + return legacyOrigin !== undefined && origin === legacyOrigin; } catch { // Unparseable, and not a DID: nothing can be done with it. return false; @@ -3955,27 +4359,25 @@ export class Store { // An earlier build parked the list on the Agent. Treat that as a source // too, so an account migrated by that version is repaired rather than // stranded. + // Where the legacy list came from — see `isAdoptableDriveSubject`. + let legacyOrigin: string | undefined; + + try { + legacyOrigin = new URL(legacy.subject).origin; + } catch { + legacyOrigin = undefined; + } + const inherited = [ ...legacy.getSubjects(server.properties.drives), ...didAgent.getSubjects(server.properties.drives), - ].filter(subject => this.isAdoptableDriveSubject(subject)); + ].filter(subject => this.isAdoptableDriveSubject(subject, legacyOrigin)); if (inherited.length === 0) return; - const existingSubject = - (didAgent.get(core.properties.personalDrive) as string | undefined) ?? - agent.initialDrive; - - // `createDrive(personal: true)` seeds the switcher list with itself and - // links `personalDrive` on the Agent, so afterwards this is an ordinary - // union into an existing list. It is only reached when the account has no - // private drive at all, so it cannot displace one the user already has. - const personalDrive = existingSubject - ? await this.getResource(existingSubject) - : await this.createDrive('My drive', { - personal: true, - agentName: legacy.get(core.properties.name) as string | undefined, - }); + const personalDrive = await this.ensurePersonalDrive('My drive', { + agentName: legacy.get(core.properties.name) as string | undefined, + }); if (personalDrive.error) return; diff --git a/browser/lib/src/subtle-personal-drive.test.ts b/browser/lib/src/subtle-personal-drive.test.ts new file mode 100644 index 000000000..9a1f22386 --- /dev/null +++ b/browser/lib/src/subtle-personal-drive.test.ts @@ -0,0 +1,60 @@ +import { describe, it } from 'vitest'; +import { Agent } from './agent.js'; +import { encodeB64 } from './base64.js'; + +/** + * The determinism tests elsewhere build their Agent from `JSCryptoProvider`. + * The app does not: in a secure context it stores a non-extractable + * `CryptoKey` and signs through `SubtleCryptoProvider`. A derived subject that + * is stable under one provider and not the other would look correct in CI and + * mint a fresh personal drive on every sign-in in the product, so the guarantee + * is only worth as much as its coverage of the provider actually used. + */ +describe('derived personal drive under SubtleCrypto', () => { + const secretFor = async () => { + const keys = await Agent.generateKeyPair(); + + return encodeB64( + new TextEncoder().encode( + JSON.stringify({ + privateKey: keys.privateKey, + subject: 'did:ad:agent:test', + }), + ), + ); + }; + + it('is stable across two Agents built from the same secret', async ({ + expect, + }) => { + const secret = await secretFor(); + + const a = await Agent.fromSecret(secret); + const b = await Agent.fromSecret(secret); + + const first = await a.personalDriveSubject(); + const second = await b.personalDriveSubject(); + + expect(first).toBe(second); + }); + + it('is stable across repeated calls on one Agent', async ({ expect }) => { + const agent = await Agent.fromSecret(await secretFor()); + + const first = await agent.personalDriveSubject(); + const second = await agent.personalDriveSubject(); + + expect(first).toBe(second); + }); + + it('agrees with the JS provider for the same key', async ({ expect }) => { + const secret = await secretFor(); + + const subtle = await Agent.fromSecret(secret); + const js = Agent.fromSecret(secret, 'js'); + + expect(await subtle.personalDriveSubject()).toBe( + await js.personalDriveSubject(), + ); + }); +}); diff --git a/browser/lib/src/test-store.ts b/browser/lib/src/test-store.ts index a6250ca7c..a1504b91f 100644 --- a/browser/lib/src/test-store.ts +++ b/browser/lib/src/test-store.ts @@ -58,5 +58,12 @@ export async function testStore(): Promise { new Error('test-store: property validation skipped'), ); + // Keep unit tests off the network. A failed GET still returns an error + // resource (see `Client.fetchResourceHTTP`); callers must not then wait + // on `getResource` for that stub to become ready. + store.injectFetch(async () => { + throw new Error('test-store: network disabled'); + }); + return { store, agentDID, posted, postCommitSpy }; } diff --git a/browser/lib/src/websockets.ts b/browser/lib/src/websockets.ts index 2522d8782..e197feffc 100644 --- a/browser/lib/src/websockets.ts +++ b/browser/lib/src/websockets.ts @@ -307,7 +307,7 @@ export class WSClient { console.warn('[WS] Connection failed'); } - this.store.setServerConnected(false, connectionFailedMessage(wsURL)); + this.reportConnected(false, connectionFailedMessage(wsURL)); // Some environments fire error without an immediately-following // close. Reject anyway — if close does fire later, the second // rejectAllPending sees an empty Map and is a no-op. @@ -334,7 +334,7 @@ export class WSClient { ? `Connection to ${wsURL.origin} closed (code=${ev.code}${ev.reason ? `, reason=${ev.reason}` : ''}).` : connectionFailedMessage(wsURL); - this.store.setServerConnected(false, error); + this.reportConnected(false, error); this.rejectAllPending('WebSocket closed before response arrived'); if (!this._closed) { @@ -345,7 +345,32 @@ export class WSClient { this._retryDelay = Math.min(this._retryDelay * 2, 30000); } }); - this.openPromise = new Promise(resolve => { + this.openPromise = new Promise((resolve, reject) => { + // A socket that dies before opening must REJECT this, not leave it + // pending forever. `authenticate()` awaits it while holding + // `isAuthenticating`, and its `finally` — the only thing that clears + // that flag — is downstream of this await. A never-settling promise + // therefore pins the flag for the lifetime of the client: the retry + // loop reconnects, the new socket opens, and its `authenticate()` + // takes the `if (this.isAuthenticating) await this.authPromise` branch + // onto the dead promise and waits forever. Auth never completes, so + // `reportConnected(true)` never fires and the app sits on "Offline" + // next to a server answering in milliseconds, with every `ws.fetch` + // hung because `REQUEST_TIMEOUT` only starts after auth. + // + // Reproduced on a cold start where the webview is ready before the + // embedded server binds: one `close code=1006 opened=false`, and no + // recovery until a reload builds a fresh client. + ws.addEventListener('close', () => { + if (!opened) { + reject( + new AtomicError( + `WebSocket to ${wsURL.origin} closed before it opened`, + ErrorType.Server, + ), + ); + } + }); ws.addEventListener('open', () => { opened = true; this._retryDelay = 1000; @@ -364,6 +389,10 @@ export class WSClient { this.handleOpen(); }); }); + // Nobody awaits `openPromise` until an auth or fetch needs it, so a + // rejection that arrives first would surface as an unhandled rejection. + // This derived catch absorbs that; awaiters still see the rejection. + this.openPromise.catch(() => undefined); this.ws = ws; }; @@ -432,7 +461,7 @@ export class WSClient { // `serverConnected` stuck at true and pending GETs/commits hanging // until their own timeouts. The event handler still runs if it fires, // but both calls are idempotent (flag re-set to false, empty maps). - this.store.setServerConnected(false); + this.reportConnected(false); this.rejectAllPending('WebSocket closed by client'); this.ws.close(); @@ -476,8 +505,8 @@ export class WSClient { this.authenticatedWith = agent.subject; recordServerVersionFromWsProtocol( - this.serverOrigin, this.ws.protocol || WS_PROTOCOL, + this.serverOrigin, ); // Re-subscribe to the drive + active Loro sync and ephemeral channels. @@ -759,6 +788,34 @@ export class WSClient { return url.origin; } + /** + * Report this socket's health as the app's connection state — but only if + * this socket IS the app's server. + * + * A client holds one socket per origin it talks to, and adopting drives from + * a previous account adds origins the app does not depend on. Those servers + * can be old (no v2 websocket protocol), unreachable, or simply gone, and + * their sockets fail and retry forever. Ungated, each failure flipped the + * whole store to disconnected — so the app showed "Working offline" and + * queued writes while its own server sat there answering in under a + * millisecond. On desktop, where no ClientDb backs the outbox, those queued + * writes are then lost on restart. + */ + private reportConnected(connected: boolean, error?: string): void { + let isPrimary = false; + + try { + isPrimary = + new URL(this.store.getServerUrl()).origin === this.serverOrigin; + } catch { + isPrimary = false; + } + + if (!isPrimary) return; + + this.store.setServerConnected(connected, error); + } + private handleMessage(ev: MessageEvent) { if (ev.data instanceof ArrayBuffer) { this.handleBinary(new Uint8Array(ev.data)); @@ -1136,7 +1193,7 @@ export class WSClient { authClose('ok'); // Only flip `_serverConnected` AFTER AUTH_OK arrives. See the // comment in the `open` handler above for the race this closes. - this.store.setServerConnected(true); + this.reportConnected(true); // Re-run subscriptions AFTER the connected flag flips. The // AUTH_OK handler already called reSubscribeAll(), but that runs // with `_serverConnected` still false — a subscription created @@ -1157,12 +1214,12 @@ export class WSClient { // pending GETs already rejected via `rejectAllPending` if the // socket died; if it's still up, subsequent fetches will fail // unauthenticated and surface a 401 error to the user. - this.store.setServerConnected(true); + this.reportConnected(true); }); } else { // No agent to authenticate — the socket is open and we're ready // to serve anonymous fetches. Flip immediately. - this.store.setServerConnected(true); + this.reportConnected(true); this.reSubscribeAll(); doSync().catch(() => undefined); } diff --git a/browser/lib/src/withDeadline.ts b/browser/lib/src/withDeadline.ts new file mode 100644 index 000000000..497bcde13 --- /dev/null +++ b/browser/lib/src/withDeadline.ts @@ -0,0 +1,37 @@ +/** + * Resolve to `fallback` if `work` has not finished within `ms`. + * + * For the places where a slow answer and no answer are the same thing to the + * user, and where the caller already has a correct behaviour for "could not + * find out". Signing in is the motivating case: it asks a server which drive + * belongs to this agent, and a device that just restored a secret may have no + * server that knows — the embedded node in the desktop and Android apps + * answers, but not about an account it has never seen. Those fetches have no + * timeout of their own, so the await never settled and sign-in sat on + * "Restoring…" indefinitely, on exactly the device that had nothing. + * + * Deliberately not a cancellation: `work` keeps running and its result is + * discarded. Rejections are swallowed for the same reason — once we have + * stopped waiting, a late failure is not the caller's problem and must not + * surface as an unhandled rejection. + */ +export function withDeadline( + work: Promise, + ms: number, + fallback: T, +): Promise { + return new Promise(resolve => { + const timer = setTimeout(() => resolve(fallback), ms); + + work.then( + value => { + clearTimeout(timer); + resolve(value); + }, + () => { + clearTimeout(timer); + resolve(fallback); + }, + ); + }); +} diff --git a/browser/lib/src/ws-connection-scope.test.ts b/browser/lib/src/ws-connection-scope.test.ts new file mode 100644 index 000000000..cc8bfa375 --- /dev/null +++ b/browser/lib/src/ws-connection-scope.test.ts @@ -0,0 +1,93 @@ +import { describe, it, vi, afterEach } from 'vitest'; +import { testStore } from './test-store.js'; +import { WSClient } from './websockets.js'; + +/** + * A client holds one socket per origin it talks to. Adopting a previous + * account's drives adds origins the app does not depend on — often old servers + * that cannot speak this protocol at all. Their sockets fail and retry forever, + * and each failure used to mark the whole store disconnected: the app reported + * "Working offline" and queued writes while its own server answered in under a + * millisecond. On desktop those queued writes do not survive a restart. + */ +class FakeWebSocket { + public static readonly OPEN = 1; + public url: string; + public protocol = 'atomicdata-ws.v2'; + public binaryType = 'arraybuffer'; + public readyState = 0; + readonly #listeners = new Map void>>(); + + constructor(url: string) { + this.url = url; + } + + public addEventListener(type: string, cb: (e: unknown) => void): void { + const set = this.#listeners.get(type) ?? new Set(); + set.add(cb); + this.#listeners.set(type, set); + } + + public removeEventListener(type: string, cb: (e: unknown) => void): void { + this.#listeners.get(type)?.delete(cb); + } + + public send(): void {} + public close(): void {} + + public fire(type: string, event: unknown): void { + for (const cb of this.#listeners.get(type) ?? []) cb(event); + } +} + +const CLOSE_1006 = { code: 1006, reason: '', wasClean: false }; + +const socketOf = (client: WSClient): FakeWebSocket => + (client as unknown as { ws: FakeWebSocket }).ws; + +describe('a socket only reports connection state for its own server', () => { + const original = globalThis.WebSocket; + + afterEach(() => { + globalThis.WebSocket = original; + vi.useRealTimers(); + }); + + it('a foreign origin closing does not take the app offline', async ({ + expect, + }) => { + vi.useFakeTimers(); + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; + + const { store } = await testStore(); // serverUrl: https://example.com + const foreign = new WSClient('wss://atomicdata.dev/ws', store); + + store.setServerConnected(true); + socketOf(foreign).fire('close', CLOSE_1006); + + expect( + (store as unknown as { _serverConnected: boolean })._serverConnected, + ).toBe(true); + + foreign.close(); + }); + + it("the app's own server closing does take it offline", async ({ + expect, + }) => { + vi.useFakeTimers(); + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; + + const { store } = await testStore(); + const own = new WSClient('wss://example.com/ws', store); + + store.setServerConnected(true); + socketOf(own).fire('close', CLOSE_1006); + + expect( + (store as unknown as { _serverConnected: boolean })._serverConnected, + ).toBe(false); + + own.close(); + }); +}); diff --git a/browser/lib/src/ws-v2.ts b/browser/lib/src/ws-v2.ts index 00eb3c588..5ecdbd804 100644 --- a/browser/lib/src/ws-v2.ts +++ b/browser/lib/src/ws-v2.ts @@ -68,6 +68,10 @@ export const ErrorCode = { GENESIS_COLLISION: 1, MISSING_REQUIRED_PROPERTY: 2, UNAUTHORIZED_WRITE: 3, + /** The commit names a class the server does not hold, so it cannot validate. + * Blocking rather than terminal: the write is well-formed and would apply + * once the class arrives, so it must not be discarded. */ + MISSING_CLASS: 4, } as const; // ---- Low-level read/write helpers ---- diff --git a/browser/react/src/useChildren.ts b/browser/react/src/useChildren.ts index 5fcdc222d..8aa31c21d 100644 --- a/browser/react/src/useChildren.ts +++ b/browser/react/src/useChildren.ts @@ -1,4 +1,10 @@ -import { commits, core, dataBrowser, StoreEvents } from '@tomic/lib'; +import { + commits, + core, + dataBrowser, + orderChildren, + StoreEvents, +} from '@tomic/lib'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useCollection } from './useCollection.js'; import { useStore } from './hooks.js'; @@ -59,8 +65,8 @@ export function useChildren(parentSubject: string | undefined): { // RESILIENT: a single child that fails to load (a fetch timeout / // transient error during the WS-reconnect race) must NOT reject the // whole `Promise.all` — that throws out of `extractMembers` and the - // sidebar goes fully empty. Keep the member with a fallback key - // (server creation-order index) instead. + // sidebar goes fully empty. Keep the member with no key instead; + // `orderChildren` holds it in server position. try { const resource = await store.getResource(subject); const explicit = resource.get(dataBrowser.properties.sortOrder); @@ -70,20 +76,20 @@ export function useChildren(parentSubject: string | undefined): { ? explicit : typeof createdAt === 'number' ? createdAt - : index; + : undefined; return { subject, key, index }; } catch { - return { subject, key: index, index }; + return { subject, key: undefined, index }; } }), ); - keyed.sort((a, b) => - a.key === b.key ? a.index - b.index : a.key - b.key, - ); - - return keyed.map(s => s.subject); + // Ordering lives in `@tomic/lib` (`orderChildren`) so it can be tested + // directly: this package has no test setup, and the rule it encodes — + // a member with neither `sortOrder` nor `createdAt` must not fall into + // the array-index number space — is the whole bug. + return orderChildren(keyed); }, [store], ); diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index a36322065..9434e7d6f 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -33,6 +33,11 @@ tauri-plugin-process = "2" # `body` it wins the race against this one and calls `shell.open`, which is # denied by our ACL and fails on Android with EACCES regardless. tauri-plugin-opener = "2" +# Drives the running app over a WebSocket so an agent can open documents, +# type, and read the DOM — used to test collaborative editing end to end +# without a human at the keyboard. Registered only under `debug_assertions` +# (see `lib.rs`), so it is compiled but inert in release builds. +tauri-plugin-mcp-bridge = "0.12" tokio = { version = "1", features = ["rt", "sync", "time"] } # Mobile-only: the in-app QR scanner for device pairing. The Android WebView diff --git a/desktop/README.md b/desktop/README.md index 0b5476f37..18be2ddf2 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -19,6 +19,30 @@ If you only want to work on the _server side_ of things, you can remove `devUrl` `cargo tauri build` likewise runs `beforeBuildCommand` to produce `browser/data-browser/dist-tauri` before bundling. +### Driving the app from an agent (MCP bridge) + +Debug builds register `tauri-plugin-mcp-bridge` on `127.0.0.1:9223`, which lets +an MCP client open documents, type, and read the DOM — enough to test things +like collaborative editing end to end without a person at the keyboard. + +The bridge also needs `withGlobalTauri`, which is static config rather than +something `debug_assertions` can gate. Rather than ship it, it lives in a +separate file you opt into: + +```sh +cargo tauri dev --config tauri.dev.conf.json +``` + +Without that flag the plugin still starts, but the webview has no +`window.__TAURI__` for it to talk through. Keep `withGlobalTauri` out of +`tauri.conf.json`: this app runs with `csp: null` and renders data from drives +the user may not control, so exposing the Tauri API to page content in shipped +builds widens a surface that is already wide. + +The channel is unauthenticated — anything that can reach the port can execute +JS in the webview — which is why it binds to loopback rather than the plugin's +default `0.0.0.0`. + ### Environment overrides are baked in at build time `beforeBuildCommand` is a normal vite production build, so it reads `.env` and diff --git a/desktop/capabilities/default.json b/desktop/capabilities/default.json index 1085e6d15..4f325ea23 100644 --- a/desktop/capabilities/default.json +++ b/desktop/capabilities/default.json @@ -8,6 +8,7 @@ "core:webview:allow-create-webview-window", "core:window:allow-close", "core:window:allow-set-focus", + "mcp-bridge:default", { "identifier": "opener:allow-open-url", "allow": [ diff --git a/desktop/gen/schemas/acl-manifests.json b/desktop/gen/schemas/acl-manifests.json index 21ba60ceb..8034c261b 100644 --- a/desktop/gen/schemas/acl-manifests.json +++ b/desktop/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"deep-link":{"default_permission":{"identifier":"default","description":"Allows reading the opened deep link via the get_current command","permissions":["allow-get-current"]},"permissions":{"allow-get-current":{"identifier":"allow-get-current","description":"Enables the get_current command without any pre-configured scope.","commands":{"allow":["get_current"],"deny":[]}},"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"deny-get-current":{"identifier":"deny-get-current","description":"Denies the get_current command without any pre-configured scope.","commands":{"allow":[],"deny":["get_current"]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"process":{"default_permission":{"identifier":"default","description":"This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n","permissions":["allow-exit","allow-restart"]},"permissions":{"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-restart":{"identifier":"allow-restart","description":"Enables the restart command without any pre-configured scope.","commands":{"allow":["restart"],"deny":[]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-restart":{"identifier":"deny-restart","description":"Denies the restart command without any pre-configured scope.","commands":{"allow":[],"deny":["restart"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"deep-link":{"default_permission":{"identifier":"default","description":"Allows reading the opened deep link via the get_current command","permissions":["allow-get-current"]},"permissions":{"allow-get-current":{"identifier":"allow-get-current","description":"Enables the get_current command without any pre-configured scope.","commands":{"allow":["get_current"],"deny":[]}},"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"deny-get-current":{"identifier":"deny-get-current","description":"Denies the get_current command without any pre-configured scope.","commands":{"allow":[],"deny":["get_current"]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}}},"permission_sets":{},"global_scope_schema":null},"mcp-bridge":{"default_permission":{"identifier":"default","description":"Default permissions for MCP Bridge plugin","permissions":["allow-capture-native-screenshot","allow-emit-event","allow-execute-command","allow-execute-js","allow-get-backend-state","allow-get-ipc-events","allow-get-window-info","allow-list-windows","allow-report-ipc-event","allow-request-script-injection","allow-script-result","allow-start-ipc-monitor","allow-stop-ipc-monitor"]},"permissions":{"allow-capture-native-screenshot":{"identifier":"allow-capture-native-screenshot","description":"Enables the capture_native_screenshot command without any pre-configured scope.","commands":{"allow":["capture_native_screenshot"],"deny":[]}},"allow-emit-event":{"identifier":"allow-emit-event","description":"Enables the emit_event command without any pre-configured scope.","commands":{"allow":["emit_event"],"deny":[]}},"allow-execute-command":{"identifier":"allow-execute-command","description":"Enables the execute_command command without any pre-configured scope.","commands":{"allow":["execute_command"],"deny":[]}},"allow-execute-js":{"identifier":"allow-execute-js","description":"Enables the execute_js command without any pre-configured scope.","commands":{"allow":["execute_js"],"deny":[]}},"allow-get-backend-state":{"identifier":"allow-get-backend-state","description":"Enables the get_backend_state command without any pre-configured scope.","commands":{"allow":["get_backend_state"],"deny":[]}},"allow-get-ipc-events":{"identifier":"allow-get-ipc-events","description":"Enables the get_ipc_events command without any pre-configured scope.","commands":{"allow":["get_ipc_events"],"deny":[]}},"allow-get-window-info":{"identifier":"allow-get-window-info","description":"Enables the get_window_info command without any pre-configured scope.","commands":{"allow":["get_window_info"],"deny":[]}},"allow-list-windows":{"identifier":"allow-list-windows","description":"Enables the list_windows command without any pre-configured scope.","commands":{"allow":["list_windows"],"deny":[]}},"allow-report-ipc-event":{"identifier":"allow-report-ipc-event","description":"Enables the report_ipc_event command without any pre-configured scope.","commands":{"allow":["report_ipc_event"],"deny":[]}},"allow-request-script-injection":{"identifier":"allow-request-script-injection","description":"Enables the request_script_injection command without any pre-configured scope.","commands":{"allow":["request_script_injection"],"deny":[]}},"allow-script-result":{"identifier":"allow-script-result","description":"Enables the script_result command without any pre-configured scope.","commands":{"allow":["script_result"],"deny":[]}},"allow-start-ipc-monitor":{"identifier":"allow-start-ipc-monitor","description":"Enables the start_ipc_monitor command without any pre-configured scope.","commands":{"allow":["start_ipc_monitor"],"deny":[]}},"allow-stop-ipc-monitor":{"identifier":"allow-stop-ipc-monitor","description":"Enables the stop_ipc_monitor command without any pre-configured scope.","commands":{"allow":["stop_ipc_monitor"],"deny":[]}},"deny-capture-native-screenshot":{"identifier":"deny-capture-native-screenshot","description":"Denies the capture_native_screenshot command without any pre-configured scope.","commands":{"allow":[],"deny":["capture_native_screenshot"]}},"deny-emit-event":{"identifier":"deny-emit-event","description":"Denies the emit_event command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_event"]}},"deny-execute-command":{"identifier":"deny-execute-command","description":"Denies the execute_command command without any pre-configured scope.","commands":{"allow":[],"deny":["execute_command"]}},"deny-execute-js":{"identifier":"deny-execute-js","description":"Denies the execute_js command without any pre-configured scope.","commands":{"allow":[],"deny":["execute_js"]}},"deny-get-backend-state":{"identifier":"deny-get-backend-state","description":"Denies the get_backend_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_backend_state"]}},"deny-get-ipc-events":{"identifier":"deny-get-ipc-events","description":"Denies the get_ipc_events command without any pre-configured scope.","commands":{"allow":[],"deny":["get_ipc_events"]}},"deny-get-window-info":{"identifier":"deny-get-window-info","description":"Denies the get_window_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_window_info"]}},"deny-list-windows":{"identifier":"deny-list-windows","description":"Denies the list_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["list_windows"]}},"deny-report-ipc-event":{"identifier":"deny-report-ipc-event","description":"Denies the report_ipc_event command without any pre-configured scope.","commands":{"allow":[],"deny":["report_ipc_event"]}},"deny-request-script-injection":{"identifier":"deny-request-script-injection","description":"Denies the request_script_injection command without any pre-configured scope.","commands":{"allow":[],"deny":["request_script_injection"]}},"deny-script-result":{"identifier":"deny-script-result","description":"Denies the script_result command without any pre-configured scope.","commands":{"allow":[],"deny":["script_result"]}},"deny-start-ipc-monitor":{"identifier":"deny-start-ipc-monitor","description":"Denies the start_ipc_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["start_ipc_monitor"]}},"deny-stop-ipc-monitor":{"identifier":"deny-stop-ipc-monitor","description":"Denies the stop_ipc_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_ipc_monitor"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"process":{"default_permission":{"identifier":"default","description":"This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n","permissions":["allow-exit","allow-restart"]},"permissions":{"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-restart":{"identifier":"allow-restart","description":"Enables the restart command without any pre-configured scope.","commands":{"allow":["restart"],"deny":[]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-restart":{"identifier":"deny-restart","description":"Denies the restart command without any pre-configured scope.","commands":{"allow":[],"deny":["restart"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/desktop/gen/schemas/capabilities.json b/desktop/gen/schemas/capabilities.json index 066c08af6..9c593dd9c 100644 --- a/desktop/gen/schemas/capabilities.json +++ b/desktop/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Core permissions for the main window.","local":true,"windows":["main"],"permissions":["core:default","core:webview:allow-create-webview-window","core:window:allow-close","core:window:allow-set-focus",{"identifier":"opener:allow-open-url","allow":[{"url":"https://*"},{"url":"http://*"},{"url":"mailto:*"},{"url":"tel:*"}]}]},"mobile-pairing":{"identifier":"mobile-pairing","description":"In-app QR scanner for device pairing (Android/iOS only).","local":true,"windows":["main"],"permissions":["barcode-scanner:allow-scan","barcode-scanner:allow-cancel","barcode-scanner:allow-check-permissions","barcode-scanner:allow-request-permissions"],"platforms":["android","iOS"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Core permissions for the main window.","local":true,"windows":["main"],"permissions":["core:default","core:webview:allow-create-webview-window","core:window:allow-close","core:window:allow-set-focus","mcp-bridge:default",{"identifier":"opener:allow-open-url","allow":[{"url":"https://*"},{"url":"http://*"},{"url":"mailto:*"},{"url":"tel:*"}]}]},"mobile-pairing":{"identifier":"mobile-pairing","description":"In-app QR scanner for device pairing (Android/iOS only).","local":true,"windows":["main"],"permissions":["barcode-scanner:allow-scan","barcode-scanner:allow-cancel","barcode-scanner:allow-check-permissions","barcode-scanner:allow-request-permissions"],"platforms":["android","iOS"]}} \ No newline at end of file diff --git a/desktop/gen/schemas/desktop-schema.json b/desktop/gen/schemas/desktop-schema.json index 2f15396f7..b8d0afc1f 100644 --- a/desktop/gen/schemas/desktop-schema.json +++ b/desktop/gen/schemas/desktop-schema.json @@ -2414,6 +2414,168 @@ "const": "deep-link:deny-unregister", "markdownDescription": "Denies the unregister command without any pre-configured scope." }, + { + "description": "Default permissions for MCP Bridge plugin\n#### This default permission set includes:\n\n- `allow-capture-native-screenshot`\n- `allow-emit-event`\n- `allow-execute-command`\n- `allow-execute-js`\n- `allow-get-backend-state`\n- `allow-get-ipc-events`\n- `allow-get-window-info`\n- `allow-list-windows`\n- `allow-report-ipc-event`\n- `allow-request-script-injection`\n- `allow-script-result`\n- `allow-start-ipc-monitor`\n- `allow-stop-ipc-monitor`", + "type": "string", + "const": "mcp-bridge:default", + "markdownDescription": "Default permissions for MCP Bridge plugin\n#### This default permission set includes:\n\n- `allow-capture-native-screenshot`\n- `allow-emit-event`\n- `allow-execute-command`\n- `allow-execute-js`\n- `allow-get-backend-state`\n- `allow-get-ipc-events`\n- `allow-get-window-info`\n- `allow-list-windows`\n- `allow-report-ipc-event`\n- `allow-request-script-injection`\n- `allow-script-result`\n- `allow-start-ipc-monitor`\n- `allow-stop-ipc-monitor`" + }, + { + "description": "Enables the capture_native_screenshot command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-capture-native-screenshot", + "markdownDescription": "Enables the capture_native_screenshot command without any pre-configured scope." + }, + { + "description": "Enables the emit_event command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-emit-event", + "markdownDescription": "Enables the emit_event command without any pre-configured scope." + }, + { + "description": "Enables the execute_command command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-execute-command", + "markdownDescription": "Enables the execute_command command without any pre-configured scope." + }, + { + "description": "Enables the execute_js command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-execute-js", + "markdownDescription": "Enables the execute_js command without any pre-configured scope." + }, + { + "description": "Enables the get_backend_state command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-get-backend-state", + "markdownDescription": "Enables the get_backend_state command without any pre-configured scope." + }, + { + "description": "Enables the get_ipc_events command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-get-ipc-events", + "markdownDescription": "Enables the get_ipc_events command without any pre-configured scope." + }, + { + "description": "Enables the get_window_info command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-get-window-info", + "markdownDescription": "Enables the get_window_info command without any pre-configured scope." + }, + { + "description": "Enables the list_windows command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-list-windows", + "markdownDescription": "Enables the list_windows command without any pre-configured scope." + }, + { + "description": "Enables the report_ipc_event command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-report-ipc-event", + "markdownDescription": "Enables the report_ipc_event command without any pre-configured scope." + }, + { + "description": "Enables the request_script_injection command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-request-script-injection", + "markdownDescription": "Enables the request_script_injection command without any pre-configured scope." + }, + { + "description": "Enables the script_result command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-script-result", + "markdownDescription": "Enables the script_result command without any pre-configured scope." + }, + { + "description": "Enables the start_ipc_monitor command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-start-ipc-monitor", + "markdownDescription": "Enables the start_ipc_monitor command without any pre-configured scope." + }, + { + "description": "Enables the stop_ipc_monitor command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-stop-ipc-monitor", + "markdownDescription": "Enables the stop_ipc_monitor command without any pre-configured scope." + }, + { + "description": "Denies the capture_native_screenshot command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-capture-native-screenshot", + "markdownDescription": "Denies the capture_native_screenshot command without any pre-configured scope." + }, + { + "description": "Denies the emit_event command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-emit-event", + "markdownDescription": "Denies the emit_event command without any pre-configured scope." + }, + { + "description": "Denies the execute_command command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-execute-command", + "markdownDescription": "Denies the execute_command command without any pre-configured scope." + }, + { + "description": "Denies the execute_js command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-execute-js", + "markdownDescription": "Denies the execute_js command without any pre-configured scope." + }, + { + "description": "Denies the get_backend_state command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-get-backend-state", + "markdownDescription": "Denies the get_backend_state command without any pre-configured scope." + }, + { + "description": "Denies the get_ipc_events command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-get-ipc-events", + "markdownDescription": "Denies the get_ipc_events command without any pre-configured scope." + }, + { + "description": "Denies the get_window_info command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-get-window-info", + "markdownDescription": "Denies the get_window_info command without any pre-configured scope." + }, + { + "description": "Denies the list_windows command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-list-windows", + "markdownDescription": "Denies the list_windows command without any pre-configured scope." + }, + { + "description": "Denies the report_ipc_event command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-report-ipc-event", + "markdownDescription": "Denies the report_ipc_event command without any pre-configured scope." + }, + { + "description": "Denies the request_script_injection command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-request-script-injection", + "markdownDescription": "Denies the request_script_injection command without any pre-configured scope." + }, + { + "description": "Denies the script_result command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-script-result", + "markdownDescription": "Denies the script_result command without any pre-configured scope." + }, + { + "description": "Denies the start_ipc_monitor command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-start-ipc-monitor", + "markdownDescription": "Denies the start_ipc_monitor command without any pre-configured scope." + }, + { + "description": "Denies the stop_ipc_monitor command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-stop-ipc-monitor", + "markdownDescription": "Denies the stop_ipc_monitor command without any pre-configured scope." + }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/desktop/gen/schemas/macOS-schema.json b/desktop/gen/schemas/macOS-schema.json index 2f15396f7..b8d0afc1f 100644 --- a/desktop/gen/schemas/macOS-schema.json +++ b/desktop/gen/schemas/macOS-schema.json @@ -2414,6 +2414,168 @@ "const": "deep-link:deny-unregister", "markdownDescription": "Denies the unregister command without any pre-configured scope." }, + { + "description": "Default permissions for MCP Bridge plugin\n#### This default permission set includes:\n\n- `allow-capture-native-screenshot`\n- `allow-emit-event`\n- `allow-execute-command`\n- `allow-execute-js`\n- `allow-get-backend-state`\n- `allow-get-ipc-events`\n- `allow-get-window-info`\n- `allow-list-windows`\n- `allow-report-ipc-event`\n- `allow-request-script-injection`\n- `allow-script-result`\n- `allow-start-ipc-monitor`\n- `allow-stop-ipc-monitor`", + "type": "string", + "const": "mcp-bridge:default", + "markdownDescription": "Default permissions for MCP Bridge plugin\n#### This default permission set includes:\n\n- `allow-capture-native-screenshot`\n- `allow-emit-event`\n- `allow-execute-command`\n- `allow-execute-js`\n- `allow-get-backend-state`\n- `allow-get-ipc-events`\n- `allow-get-window-info`\n- `allow-list-windows`\n- `allow-report-ipc-event`\n- `allow-request-script-injection`\n- `allow-script-result`\n- `allow-start-ipc-monitor`\n- `allow-stop-ipc-monitor`" + }, + { + "description": "Enables the capture_native_screenshot command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-capture-native-screenshot", + "markdownDescription": "Enables the capture_native_screenshot command without any pre-configured scope." + }, + { + "description": "Enables the emit_event command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-emit-event", + "markdownDescription": "Enables the emit_event command without any pre-configured scope." + }, + { + "description": "Enables the execute_command command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-execute-command", + "markdownDescription": "Enables the execute_command command without any pre-configured scope." + }, + { + "description": "Enables the execute_js command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-execute-js", + "markdownDescription": "Enables the execute_js command without any pre-configured scope." + }, + { + "description": "Enables the get_backend_state command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-get-backend-state", + "markdownDescription": "Enables the get_backend_state command without any pre-configured scope." + }, + { + "description": "Enables the get_ipc_events command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-get-ipc-events", + "markdownDescription": "Enables the get_ipc_events command without any pre-configured scope." + }, + { + "description": "Enables the get_window_info command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-get-window-info", + "markdownDescription": "Enables the get_window_info command without any pre-configured scope." + }, + { + "description": "Enables the list_windows command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-list-windows", + "markdownDescription": "Enables the list_windows command without any pre-configured scope." + }, + { + "description": "Enables the report_ipc_event command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-report-ipc-event", + "markdownDescription": "Enables the report_ipc_event command without any pre-configured scope." + }, + { + "description": "Enables the request_script_injection command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-request-script-injection", + "markdownDescription": "Enables the request_script_injection command without any pre-configured scope." + }, + { + "description": "Enables the script_result command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-script-result", + "markdownDescription": "Enables the script_result command without any pre-configured scope." + }, + { + "description": "Enables the start_ipc_monitor command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-start-ipc-monitor", + "markdownDescription": "Enables the start_ipc_monitor command without any pre-configured scope." + }, + { + "description": "Enables the stop_ipc_monitor command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:allow-stop-ipc-monitor", + "markdownDescription": "Enables the stop_ipc_monitor command without any pre-configured scope." + }, + { + "description": "Denies the capture_native_screenshot command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-capture-native-screenshot", + "markdownDescription": "Denies the capture_native_screenshot command without any pre-configured scope." + }, + { + "description": "Denies the emit_event command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-emit-event", + "markdownDescription": "Denies the emit_event command without any pre-configured scope." + }, + { + "description": "Denies the execute_command command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-execute-command", + "markdownDescription": "Denies the execute_command command without any pre-configured scope." + }, + { + "description": "Denies the execute_js command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-execute-js", + "markdownDescription": "Denies the execute_js command without any pre-configured scope." + }, + { + "description": "Denies the get_backend_state command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-get-backend-state", + "markdownDescription": "Denies the get_backend_state command without any pre-configured scope." + }, + { + "description": "Denies the get_ipc_events command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-get-ipc-events", + "markdownDescription": "Denies the get_ipc_events command without any pre-configured scope." + }, + { + "description": "Denies the get_window_info command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-get-window-info", + "markdownDescription": "Denies the get_window_info command without any pre-configured scope." + }, + { + "description": "Denies the list_windows command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-list-windows", + "markdownDescription": "Denies the list_windows command without any pre-configured scope." + }, + { + "description": "Denies the report_ipc_event command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-report-ipc-event", + "markdownDescription": "Denies the report_ipc_event command without any pre-configured scope." + }, + { + "description": "Denies the request_script_injection command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-request-script-injection", + "markdownDescription": "Denies the request_script_injection command without any pre-configured scope." + }, + { + "description": "Denies the script_result command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-script-result", + "markdownDescription": "Denies the script_result command without any pre-configured scope." + }, + { + "description": "Denies the start_ipc_monitor command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-start-ipc-monitor", + "markdownDescription": "Denies the start_ipc_monitor command without any pre-configured scope." + }, + { + "description": "Denies the stop_ipc_monitor command without any pre-configured scope.", + "type": "string", + "const": "mcp-bridge:deny-stop-ipc-monitor", + "markdownDescription": "Denies the stop_ipc_monitor command without any pre-configured scope." + }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index bc4df0d7a..c66fbabb8 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -458,6 +458,20 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_opener::init()); + // Lets an agent drive this window over a WebSocket — open a document, type, + // read back the DOM — so collaborative editing can be tested end to end + // without a person at the keyboard. Debug builds only. + // + // Bound to loopback rather than the plugin's default `0.0.0.0`: this is an + // unauthenticated channel that can execute JS in the webview, and on a café + // network the default would offer that to everyone on the subnet. + #[cfg(debug_assertions)] + let builder = builder.plugin( + tauri_plugin_mcp_bridge::Builder::new() + .bind_address("127.0.0.1") + .build(), + ); + // In-app QR scanner for device pairing (Android/iOS only). #[cfg(mobile)] let builder = builder.plugin(tauri_plugin_barcode_scanner::init()); diff --git a/desktop/tauri.dev.conf.json b/desktop/tauri.dev.conf.json new file mode 100644 index 000000000..287c61f1d --- /dev/null +++ b/desktop/tauri.dev.conf.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "withGlobalTauri": true + } +} diff --git a/lib/src/commit.rs b/lib/src/commit.rs index 86fc0861f..99d3173bf 100644 --- a/lib/src/commit.rs +++ b/lib/src/commit.rs @@ -213,9 +213,22 @@ impl Commit { /// Creates a new Commit with a `did:ad` Subject. /// The ID of the Subject is the signature of the Commit. pub async fn create_did( + commit_builder: CommitBuilder, + agent: &crate::agents::Agent, + store: &impl Storelike, + ) -> AtomicResult { + Self::create_did_with_cert(commit_builder, agent, store, None).await + } + + /// Like [`Self::create_did`], but uses `cert` when given instead of a + /// random-nonce certificate. The personal drive path passes + /// [`crate::genesis::GenesisCert::for_personal_drive`] so every device + /// mints the same subject. + pub async fn create_did_with_cert( mut commit_builder: CommitBuilder, agent: &crate::agents::Agent, store: &impl Storelike, + cert: Option, ) -> AtomicResult { let now = crate::utils::now(); // Create a temporary commit with empty signature and subject @@ -256,28 +269,40 @@ impl Commit { let signer_pubkey: [u8; 32] = crate::agents::decode_base64(&agent.public_key)? .try_into() .map_err(|_| "Agent public key must be 32 bytes for the genesis certificate")?; - let mut nonce = [0u8; 16]; - { - use rand::RngCore; - rand::thread_rng().fill_bytes(&mut nonce); - } - let parent = commit_builder - .set - .get(urls::PARENT) - .map(|v| v.to_string()) - .unwrap_or_default(); - let drive = commit_builder - .set - .get(urls::DRIVE_PROP) - .map(|v| v.to_string()) - .unwrap_or_default(); - let cert = crate::genesis::GenesisCert { - signer_pubkey, - created_at: now, - nonce, - state_hash: None, - parent, - drive, + let cert = match cert { + Some(cert) => { + if cert.signer_pubkey != signer_pubkey { + return Err( + "Genesis certificate signer does not match the creating agent".into(), + ); + } + cert + } + None => { + let mut nonce = [0u8; 16]; + { + use rand::RngCore; + rand::thread_rng().fill_bytes(&mut nonce); + } + let parent = commit_builder + .set + .get(urls::PARENT) + .map(|v| v.to_string()) + .unwrap_or_default(); + let drive = commit_builder + .set + .get(urls::DRIVE_PROP) + .map(|v| v.to_string()) + .unwrap_or_default(); + crate::genesis::GenesisCert { + signer_pubkey, + created_at: now, + nonce, + state_hash: None, + parent, + drive, + } + } }; let cert_b64 = crate::agents::encode_base64(&cert.encode()); let genesis_signature = cert.sign(&private_key)?; @@ -443,6 +468,45 @@ impl Commit { Ok(()) } + /// A second genesis for an existing subject is mergeable when it carries a + /// self-verifying cert for this subject whose signer is this commit's + /// signer. That is the personal-drive case: every device mints the same + /// cert, so the same DID, and Loro merges the two docs. + fn repeat_genesis_is_mergeable(&self) -> AtomicResult { + if !self.subject.is_did() || self.subject.is_agent_did() { + return Ok(false); + } + let subject_val = self + .subject + .as_str() + .strip_prefix("did:ad:") + .ok_or("Invalid did:ad subject")?; + let Some(cert_b64) = self + .loro_update + .as_ref() + .and_then(|u| crate::Resource::genesis_cert_b64_from_loro_update(u)) + else { + return Ok(false); + }; + let cert_bytes = decode_base64(&cert_b64)?; + let cert = crate::genesis::GenesisCert::decode(&cert_bytes)?; + if cert.verify(subject_val).is_err() { + return Ok(false); + } + let signer_key = self + .signer + .as_str() + .strip_prefix("did:ad:agent:") + .ok_or("Repeat genesis requires a did:ad:agent signer")?; + let signer_bytes: [u8; 32] = decode_base64(signer_key)? + .try_into() + .map_err(|_| "Agent public key must be 32 bytes")?; + if cert.signer_pubkey != signer_bytes { + return Ok(false); + } + Ok(true) + } + /// Performs the checks specified in CommitOpts and constructs a new Resource. /// Warning: Does not save the new resource to the Store - doet not delete if it `destroy: true`. /// Use [Storelike::apply_commit] to save the resource to the Store. @@ -525,11 +589,16 @@ impl Commit { if let Some(explicit_genesis) = commit.is_genesis { if explicit_genesis && !is_new { - return Err(format!( - "Commit for {} has is_genesis: true, but the resource already exists.", - commit.subject - ) - .into()); + // Deterministic subjects (personal drive) emit a repeat genesis + // from every device. Accept when the cert verifies and names + // this commit's signer; apply_changes merges the Loro update. + if !commit.repeat_genesis_is_mergeable()? { + return Err(format!( + "Commit for {} has is_genesis: true, but the resource already exists.", + commit.subject + ) + .into()); + } } if !explicit_genesis && is_new { return Err(format!( @@ -619,8 +688,29 @@ impl Commit { // - destroy commits (no Loro merge to evaluate). // - tiny/empty loroUpdate (client didn't really try to write). // - genesis commits (is_new): no stored state to lose to. + // - REPEAT genesis: a second genesis for a subject that already exists + // is legitimate (`repeat_genesis_is_mergeable`) — every device mints + // the same cert for a personal drive, so the same DID. Its propvals + // are the creation defaults, and losing them to whatever the resource + // has since become is the expected outcome, not evidence that the + // client failed to seed from server state. Without this, a device + // that renamed its home drive rejected its own stashed genesis + // forever: the intent says `name = "My drive"`, the stored state says + // the chosen name, they do not match, and the outbox retries every + // 30s for as long as the app is open. if opts.validate_loro_causality && !is_new + && commit.is_genesis != Some(true) + // ...and not a repeat materialization that merely forgot to say so. + // A second device deriving the same personal drive builds its doc + // from the creation defaults; whether that reaches us flagged + // `is_genesis` or as an ordinary commit is an accident of which + // client path drained it. The cert decides, not the flag: it has to + // verify against this subject AND name this signer, which for a + // `did:ad:` subject can only be the same author. Observed in the + // field as a 500 loop on the owner's own home drive, from their + // second browser. + && !commit.repeat_genesis_is_mergeable().unwrap_or(false) && !commit.destroy.unwrap_or(false) && commit.loro_update.as_ref().map(|b| b.len()).unwrap_or(0) > 16 && applied.add_atoms.is_empty() @@ -1677,6 +1767,306 @@ mod test { ); } + /// Two devices holding the same key mint the same personal-drive DID. + /// Applying both geneses merges the Loro docs instead of rejecting the + /// second as "already exists". + #[tokio::test] + async fn repeat_personal_drive_genesis_merges() { + let (store, agent) = store_with_known_agent().await; + let pubkey: [u8; 32] = crate::agents::decode_base64(&agent.public_key) + .unwrap() + .try_into() + .unwrap(); + let cert = crate::genesis::GenesisCert::for_personal_drive(pubkey); + let opts = CommitOpts { + validate_signature: true, + validate_timestamp: false, + validate_previous_commit: false, + validate_rights: false, + validate_loro_causality: true, + ..CommitOpts::no_validations_no_index() + }; + + let mut first = CommitBuilder::new("placeholder".into()); + first.set( + crate::urls::IS_A.into(), + Value::ResourceArray(vec![crate::urls::DRIVE.into()]), + ); + first.set( + crate::urls::NAME.into(), + Value::String("Device A home".into()), + ); + first.set( + crate::urls::WRITE.into(), + Value::ResourceArray(vec![agent.subject.to_string().into()]), + ); + let commit_a = Commit::create_did_with_cert(first, &agent, &store, Some(cert.clone())) + .await + .unwrap(); + let subject = commit_a.subject.clone(); + assert_eq!( + subject.to_string(), + crate::genesis::GenesisCert::personal_drive_subject( + agent.private_key.as_ref().unwrap() + ) + .unwrap() + ); + store.apply_commit(commit_a, &opts).await.unwrap(); + + let mut second = CommitBuilder::new("placeholder".into()); + second.set( + crate::urls::IS_A.into(), + Value::ResourceArray(vec![crate::urls::DRIVE.into()]), + ); + second.set( + crate::urls::NAME.into(), + Value::String("Device B home".into()), + ); + second.set( + crate::urls::DESCRIPTION.into(), + Value::String("from the second device".into()), + ); + second.set( + crate::urls::WRITE.into(), + Value::ResourceArray(vec![agent.subject.to_string().into()]), + ); + let commit_b = Commit::create_did_with_cert(second, &agent, &store, Some(cert)) + .await + .unwrap(); + assert_eq!(commit_b.subject, subject); + store + .apply_commit(commit_b, &opts) + .await + .expect("repeat genesis for the same personal-drive DID must merge"); + + let merged = store.get_resource(&subject).await.unwrap(); + assert_eq!( + merged.get(crate::urls::DESCRIPTION).unwrap().to_string(), + "from the second device", + "property only set on the second device must survive the merge" + ); + assert!(merged.get(crate::urls::NAME).is_ok()); + assert_eq!( + merged + .get(crate::urls::IS_A) + .unwrap() + .to_subjects(None) + .unwrap()[0], + crate::urls::DRIVE + ); + } + + /// A repeat genesis whose every value loses to the resource's current + /// state must still be accepted. + /// + /// The device that created the drive keeps a stashed genesis commit. Rename + /// the drive, and that stash now says `name = "My drive"` while the stored + /// state says the chosen name. It contributes new ops but changes no atom, + /// which is exactly the shape the causality guard rejects — so the client + /// re-posted it every 30 seconds for as long as the app stayed open. + #[tokio::test] + async fn repeat_genesis_losing_every_value_is_accepted() { + let (store, agent) = store_with_known_agent().await; + let pubkey: [u8; 32] = crate::agents::decode_base64(&agent.public_key) + .unwrap() + .try_into() + .unwrap(); + let cert = crate::genesis::GenesisCert::for_personal_drive(pubkey); + let opts = CommitOpts { + validate_signature: true, + validate_timestamp: false, + validate_previous_commit: false, + validate_rights: false, + validate_loro_causality: true, + ..CommitOpts::no_validations_no_index() + }; + + let mut genesis = CommitBuilder::new("placeholder".into()); + genesis.set( + crate::urls::IS_A.into(), + Value::ResourceArray(vec![crate::urls::DRIVE.into()]), + ); + genesis.set(crate::urls::NAME.into(), Value::String("My drive".into())); + genesis.set( + crate::urls::WRITE.into(), + Value::ResourceArray(vec![agent.subject.to_string().into()]), + ); + let first = Commit::create_did_with_cert(genesis, &agent, &store, Some(cert.clone())) + .await + .unwrap(); + let subject = first.subject.clone(); + store.apply_commit(first, &opts).await.unwrap(); + + // The user names their home drive. + let stored = store.get_resource(&subject).await.unwrap(); + let mut rename = CommitBuilder::new(subject.clone()); + rename.set( + crate::urls::NAME.into(), + Value::String("Joeps drijf".into()), + ); + let rename = rename.sign(&agent, &store, &stored).await.unwrap(); + store.apply_commit(rename, &opts).await.unwrap(); + + // Another device mints the same cert — same DID, creation defaults. + // Every propval it carries now loses to the rename. + let mut stale = CommitBuilder::new("placeholder".into()); + stale.set( + crate::urls::IS_A.into(), + Value::ResourceArray(vec![crate::urls::DRIVE.into()]), + ); + stale.set(crate::urls::NAME.into(), Value::String("My drive".into())); + stale.set( + crate::urls::WRITE.into(), + Value::ResourceArray(vec![agent.subject.to_string().into()]), + ); + let stale = Commit::create_did_with_cert(stale, &agent, &store, Some(cert)) + .await + .unwrap(); + assert_eq!(stale.subject, subject); + + store + .apply_commit(stale, &opts) + .await + .expect("a repeat genesis that changes nothing must be accepted, not retried forever"); + + let merged = store.get_resource(&subject).await.unwrap(); + assert_eq!( + merged.get(crate::urls::NAME).unwrap().to_string(), + "Joeps drijf", + "the chosen name must survive the repeat genesis" + ); + } + + /// The same materialization, arriving WITHOUT the `is_genesis` flag, must + /// merge too. + /// + /// Whether a second device's from-scratch doc drains as a genesis or as an + /// ordinary commit is an accident of which client path exported it. Seen in + /// the field as a 500 loop on the owner's own home drive, posted from their + /// second browser: the commit carried the creation defaults, every one lost + /// to stored state, and the causality guard read that as silent data loss. + /// The cert is what decides — it verifies against this subject and names + /// this signer, so it can only be the same author. + #[tokio::test] + async fn repeat_materialization_merges_even_when_not_flagged_genesis() { + let (store, agent) = store_with_known_agent().await; + let pubkey: [u8; 32] = crate::agents::decode_base64(&agent.public_key) + .unwrap() + .try_into() + .unwrap(); + let cert = crate::genesis::GenesisCert::for_personal_drive(pubkey); + let opts = CommitOpts { + validate_signature: true, + validate_timestamp: false, + validate_previous_commit: false, + validate_rights: false, + validate_loro_causality: true, + ..CommitOpts::no_validations_no_index() + }; + + let defaults = |builder: &mut CommitBuilder| { + builder.set( + crate::urls::IS_A.into(), + Value::ResourceArray(vec![crate::urls::DRIVE.into()]), + ); + builder.set(crate::urls::NAME.into(), Value::String("My drive".into())); + builder.set( + crate::urls::WRITE.into(), + Value::ResourceArray(vec![agent.subject.to_string().into()]), + ); + }; + + let mut genesis = CommitBuilder::new("placeholder".into()); + defaults(&mut genesis); + let first = Commit::create_did_with_cert(genesis, &agent, &store, Some(cert.clone())) + .await + .unwrap(); + let subject = first.subject.clone(); + store.apply_commit(first, &opts).await.unwrap(); + + let stored = store.get_resource(&subject).await.unwrap(); + let mut rename = CommitBuilder::new(subject.clone()); + rename.set(crate::urls::NAME.into(), Value::String("Home".into())); + let rename = rename.sign(&agent, &store, &stored).await.unwrap(); + store.apply_commit(rename, &opts).await.unwrap(); + + // Second device: same cert, same defaults — but drained as an ordinary + // commit, so `is_genesis` never gets set. + let mut second = CommitBuilder::new("placeholder".into()); + defaults(&mut second); + let mut second = Commit::create_did_with_cert(second, &agent, &store, Some(cert)) + .await + .unwrap(); + assert_eq!(second.subject, subject); + second.is_genesis = None; + // Re-sign: the flag is part of the signed payload. + let stringified = second + .serialize_deterministically_json_ad(&store) + .await + .unwrap(); + second.signature = Some( + sign_message( + &stringified, + &agent.private_key.clone().unwrap(), + &agent.public_key, + ) + .unwrap(), + ); + + store.apply_commit(second, &opts).await.expect( + "a repeat materialization is the same author by construction — merge it, do not \ + refuse it as silent data loss", + ); + + let merged = store.get_resource(&subject).await.unwrap(); + assert_eq!( + merged.get(crate::urls::NAME).unwrap().to_string(), + "Home", + "the chosen name must survive the second device's defaults" + ); + } + + /// A genesis retry without a verifiable cert still fails — only a + /// same-signer, same-subject cert is treated as a merge. + #[tokio::test] + async fn repeat_genesis_without_cert_is_still_rejected() { + let (store, agent) = store_with_known_agent().await; + let mut builder = CommitBuilder::new("placeholder".into()); + builder.set(crate::urls::NAME.into(), Value::String("once".into())); + let genesis = Commit::create_did(builder, &agent, &store).await.unwrap(); + let subject = genesis.subject.clone(); + let opts = CommitOpts { + validate_signature: false, + validate_timestamp: false, + validate_previous_commit: false, + validate_rights: false, + ..CommitOpts::no_validations_no_index() + }; + store.apply_commit(genesis, &opts).await.unwrap(); + + let loro_doc = crate::loro::AtomicLoroDoc::new(); + loro_doc + .set_property(crate::urls::NAME, &Value::String("twice".into())) + .unwrap(); + let retry = Commit { + subject: subject.clone(), + signer: agent.subject.clone(), + loro_update: Some(loro_doc.export_snapshot()), + destroy: Some(false), + created_at: crate::utils::now(), + previous_commit: None, + is_genesis: Some(true), + signature: Some("not-checked".into()), + url: None, + }; + let err = store.apply_commit(retry, &opts).await.unwrap_err(); + assert!( + err.to_string() + .contains("is_genesis: true, but the resource already exists"), + "expected the old reject, got: {err}" + ); + } + /// Loro-only genesis commit (empty set map, only loroUpdate) — mimics browser behavior. /// The resource should be stored and retrievable with materialized properties. #[tokio::test] diff --git a/lib/src/db.rs b/lib/src/db.rs index 2fd5b13ce..40502097e 100644 --- a/lib/src/db.rs +++ b/lib/src/db.rs @@ -70,6 +70,35 @@ use self::{ // A function called by the Store when a Commit is accepted type HandleCommit = Box; +/// Live-collaboration state received from a peer over the sync link: +/// presence, cursors, or the ops of an edit someone has not saved yet. +/// +/// Separate from [`DbEvent`] because none of it is written here: it exists only +/// to be handed to whatever is currently rendering (websocket clients), then +/// forgotten. Uncommitted ops become durable only if a local user saves the +/// document they land in, which produces a signed commit under that user's own +/// identity. The originating agent travels with it because a peer link is +/// node-to-node while this state is per-agent — one node may relay several +/// people's cursors and edits. +#[derive(Debug, Clone)] +pub struct EphemeralEvent { + /// Which channel this belongs to — per-document Loro ephemeral, + /// drive-scoped presence, or an edit in progress. They fan out to different + /// subscribers, so the distinction has to survive the trip. See + /// `protocol::ephemeral_kind`. + pub kind: u8, + /// What the state is scoped to: the drive for presence, the resource for + /// the other two. + pub drive: String, + /// The agent this came from. + pub agent: String, + /// Opaque Loro update — `EphemeralStore` bytes for presence and cursors, + /// document ops for an edit in progress. + pub payload: Vec, + /// The peer that relayed it, so it is not sent straight back. + pub from_peer: String, +} + /// Event emitted when a resource is created, updated, or deleted. #[derive(Debug, Clone)] pub enum DbEvent { @@ -248,6 +277,14 @@ pub struct Db { on_commit: Option>, /// Broadcast channel for all resource mutations. db_events: tokio::sync::broadcast::Sender, + /// Presence arriving from a peer. Deliberately NOT `db_events`: presence is + /// ephemeral and must never reach the store, and every consumer of + /// `DbEvent` writes or indexes. Cursor positions merged into the CRDT would + /// be persisted and synced forever. + /// + /// Small buffer on purpose — presence is worth dropping under load, unlike + /// a resource change. A lagging subscriber loses cursors, not data. + ephemeral_events: tokio::sync::broadcast::Sender, /// In-memory authoritative map of watched query filters, keyed by drive /// prefix (e.g. `"https://example.com"` for HTTP drives, the DID for /// DID-form drives) and routed by property within each drive (see @@ -370,6 +407,7 @@ impl Db { on_commit: None, db_events: tokio::sync::broadcast::channel(64).0, + ephemeral_events: tokio::sync::broadcast::channel(32).0, watched_queries_by_drive: Arc::new(RwLock::new(HashMap::new())), subject_locks: Default::default(), base_domain, @@ -404,6 +442,7 @@ impl Db { on_commit: None, db_events: tokio::sync::broadcast::channel(64).0, + ephemeral_events: tokio::sync::broadcast::channel(32).0, watched_queries_by_drive: Arc::new(RwLock::new(HashMap::new())), subject_locks: Default::default(), base_domain, @@ -436,6 +475,7 @@ impl Db { on_commit: None, db_events: tokio::sync::broadcast::channel(64).0, + ephemeral_events: tokio::sync::broadcast::channel(32).0, watched_queries_by_drive: Arc::new(RwLock::new(HashMap::new())), subject_locks: Default::default(), base_domain, @@ -529,6 +569,7 @@ impl Db { on_commit: None, db_events: tokio::sync::broadcast::channel(64).0, + ephemeral_events: tokio::sync::broadcast::channel(32).0, watched_queries_by_drive: Arc::new(RwLock::new(HashMap::new())), subject_locks: Default::default(), base_domain, @@ -678,6 +719,7 @@ impl Db { on_commit: None, db_events: tokio::sync::broadcast::channel(64).0, + ephemeral_events: tokio::sync::broadcast::channel(32).0, watched_queries_by_drive: Arc::new(RwLock::new(HashMap::new())), subject_locks: Default::default(), base_domain, @@ -815,10 +857,90 @@ impl Db { self.apply_commit(commit, &opts).await?; self.set_active_drive(&did)?; - // Add the new drive to the agent's `drives` array and persist. + // Record the new drive on the personal drive's `drives` list when + // that home already exists. Otherwise keep writing to the Agent so + // `create_drive` in tests / first-run does not mint a second drive. + let listed = if let Ok(personal) = self.personal_drive_subject() { + self.get_resource(&personal.as_str().into()) + .await + .ok() + .map(|_| personal) + } else { + None + }; + if let Some(personal) = listed { + if personal != did { + self.push_drive_to_list(&personal, &did).await?; + } + } else { + let agent = self.get_default_agent()?; + self.push_drive_to_list(&agent.subject.to_string(), &did) + .await?; + } + + Ok(did) + } + + /// The agent's derived personal-drive DID. Same key → same subject. + pub fn personal_drive_subject(&self) -> AtomicResult { let agent = self.get_default_agent()?; - let mut agent_resource = self.get_resource(&agent.subject).await?; - let mut drives: Vec = agent_resource + let private_key = agent + .private_key + .as_ref() + .ok_or("Cannot derive a personal drive without a private key")?; + crate::genesis::GenesisCert::personal_drive_subject(private_key) + } + + /// Materialize the derived personal drive if it is not already stored. + /// Repeat genesis for the same subject merges. + pub async fn ensure_personal_drive(&self) -> AtomicResult { + let agent = self.get_default_agent()?; + let did = self.personal_drive_subject()?; + if self.get_resource(&did.as_str().into()).await.is_ok() { + return Ok(did); + } + + let signer_pubkey: [u8; 32] = crate::agents::decode_base64(&agent.public_key)? + .try_into() + .map_err(|_| "Agent public key must be 32 bytes")?; + let cert = crate::genesis::GenesisCert::for_personal_drive(signer_pubkey); + + let mut builder = crate::commit::CommitBuilder::new("placeholder".into()); + builder.set( + urls::IS_A.into(), + Value::ResourceArray(vec![urls::DRIVE.into()]), + ); + builder.set(urls::NAME.into(), Value::String("My drive".into())); + builder.set( + urls::WRITE.into(), + Value::ResourceArray(vec![agent.subject.to_string().into()]), + ); + builder.set( + urls::READ.into(), + Value::ResourceArray(vec![agent.subject.to_string().into()]), + ); + builder.set( + urls::DESCRIPTION.into(), + Value::String("Your personal drive.".into()), + ); + + let commit = + crate::commit::Commit::create_did_with_cert(builder, &agent, self, Some(cert)).await?; + let opts = crate::commit::CommitOpts { + validate_signature: true, + validate_timestamp: false, + validate_previous_commit: false, + validate_rights: false, + update_index: true, + ..crate::commit::CommitOpts::no_validations_no_index() + }; + self.apply_commit(commit, &opts).await?; + Ok(did) + } + + async fn push_drive_to_list(&self, list_subject: &str, drive_did: &str) -> AtomicResult<()> { + let mut resource = self.get_resource(&list_subject.into()).await?; + let mut drives: Vec = resource .get(urls::DRIVES) .ok() .and_then(|v| match v { @@ -826,14 +948,12 @@ impl Db { _ => None, }) .unwrap_or_default(); - if !drives.iter().any(|d| d.to_string() == did) { - drives.push(did.clone().into()); - agent_resource.set_unsafe(urls::DRIVES.into(), Value::ResourceArray(drives))?; - self.add_resource_opts(&agent_resource, false, true, true) - .await?; + if !drives.iter().any(|d| d.to_string() == drive_did) { + drives.push(drive_did.to_string().into()); + resource.set_unsafe(urls::DRIVES.into(), Value::ResourceArray(drives))?; + self.add_resource_opts(&resource, false, true, true).await?; } - - Ok(did) + Ok(()) } /// Create a new resource with a `did:ad:` subject via genesis commit. @@ -1100,9 +1220,21 @@ impl Db { pub async fn setup(&self, agent_name: &str) -> AtomicResult<(crate::agents::Agent, String)> { let mut agent = self.create_agent(Some(agent_name)).await?; self.set_default_agent(agent.clone()); - let drive = self - .create_drive(&format!("{}'s Drive", agent_name)) - .await?; + let drive = self.ensure_personal_drive().await?; + // `create_drive` — which this used to call — set the active drive as + // part of creating one. `ensure_personal_drive` deliberately does not: + // materializing a home is not the same act as switching to it. Setup IS + // that act, so it records it here; without this every later call fails + // with "No drive set. Call setup() first." + self.set_active_drive(&drive)?; + + if let Ok(mut personal) = self.get_resource(&drive.as_str().into()).await { + personal.set_unsafe( + urls::NAME.into(), + Value::String(format!("{}'s Drive", agent_name)), + )?; + self.add_resource_opts(&personal, false, true, true).await?; + } // Set initial_drive so the secret contains the drive DID. // This lets other devices find this drive via DHT when restoring from secret. @@ -1662,6 +1794,17 @@ impl Db { self.db_events.subscribe() } + /// Presence arriving from peers. See [`EphemeralEvent`]. + pub fn subscribe_ephemeral(&self) -> tokio::sync::broadcast::Receiver { + self.ephemeral_events.subscribe() + } + + /// Publish presence received from a peer. Send failure means nobody is + /// listening (no websocket clients on this node) — expected, not an error. + pub fn publish_ephemeral(&self, event: EphemeralEvent) { + let _ = self.ephemeral_events.send(event); + } + /// Finds resource by Subject, return PropVals HashMap #[instrument(skip_all)] fn get_propvals(&self, subject: &str) -> AtomicResult { @@ -2782,7 +2925,10 @@ impl Storelike for Db { let _ = self.db_events.send(DbEvent::Changed { subject: resource.get_subject().without_params(), delta: None, - source_id: None, + // Attributed here, while the importing write is still on the stack: + // the live push loop uses it to avoid sending an update straight + // back to the peer it came from. + source_id: crate::sync::ws_apply::current_import_source(), is_new: false, from_commit: false, }); @@ -3571,3 +3717,39 @@ mod pending_blob_request_ttl_tests { ); } } + +#[cfg(test)] +mod personal_drive_tests { + use super::*; + use crate::Storelike; + + #[tokio::test] + async fn setup_uses_the_derived_personal_drive_did() { + let store = Db::init_temp("personal_drive_setup").await.unwrap(); + let (_agent, drive) = store.setup("Alice").await.unwrap(); + let expected = store.personal_drive_subject().unwrap(); + assert_eq!(drive, expected); + assert_eq!(store.ensure_personal_drive().await.unwrap(), expected); + } + + #[tokio::test] + async fn extra_drive_is_listed_on_the_personal_drive() { + let store = Db::init_temp("personal_drive_list").await.unwrap(); + let (_agent, personal) = store.setup("Alice").await.unwrap(); + let extra = store.create_drive("Project").await.unwrap(); + assert_ne!(extra, personal); + + let listed = store + .get_resource(&personal.as_str().into()) + .await + .unwrap() + .get(urls::DRIVES) + .unwrap() + .to_subjects(None) + .unwrap(); + assert!( + listed.iter().any(|s| s == &extra), + "personal drive should list {extra}, got {listed:?}" + ); + } +} diff --git a/lib/src/genesis.rs b/lib/src/genesis.rs index f9d37f72f..2a92e0766 100644 --- a/lib/src/genesis.rs +++ b/lib/src/genesis.rs @@ -17,6 +17,19 @@ use crate::agents::{decode_base64, encode_base64}; use crate::errors::AtomicResult; +/// Purpose string for the per-agent personal drive singleton. +/// Version the suffix to derive a different subject without colliding. +pub const PERSONAL_DRIVE_PURPOSE: &str = "atomic-personal-drive-v1"; + +/// First 16 bytes of SHA-256(`purpose`) — a stable, cross-language nonce. +pub fn domain_separator_nonce(purpose: &str) -> [u8; 16] { + use sha2::{Digest, Sha256}; + let hash = Sha256::digest(purpose.as_bytes()); + let mut nonce = [0u8; 16]; + nonce.copy_from_slice(&hash[..16]); + nonce +} + /// Current certificate format version. A signed layout can never change /// retroactively — only new versions may be added, and verifiers dispatch on /// this byte. @@ -164,6 +177,31 @@ impl GenesisCert { format!("did:ad:{signature}") } + /// Genesis certificate for the agent's personal drive: `created_at = 0`, + /// empty parent/drive, nonce = SHA-256(`atomic-personal-drive-v1`)[..16]. + /// The same key always signs the same subject. + pub fn for_personal_drive(signer_pubkey: [u8; 32]) -> Self { + Self { + signer_pubkey, + created_at: 0, + nonce: domain_separator_nonce(PERSONAL_DRIVE_PURPOSE), + state_hash: None, + parent: String::new(), + drive: String::new(), + } + } + + /// `did:ad:` of [`Self::for_personal_drive`] signed by `private_key`. + pub fn personal_drive_subject(private_key: &str) -> AtomicResult { + let seed: [u8; 32] = decode_base64(private_key)? + .try_into() + .map_err(|_| "Ed25519 private key must be 32 bytes")?; + let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); + let cert = Self::for_personal_drive(*signing_key.verifying_key().as_bytes()); + let signature = cert.sign(private_key)?; + Ok(Self::subject_for_signature(&signature)) + } + /// Sign the certificate with an Ed25519 private key (32-byte seed, base64). /// Returns the signature (base64url); the resource subject is /// `did:ad:`. Errors if the key does not match `signer_pubkey`. @@ -536,4 +574,63 @@ mod test { ); } } + + #[test] + fn personal_drive_subject_is_deterministic() { + let (private_key, pubkey) = test_key(9); + let first = GenesisCert::personal_drive_subject(&private_key).unwrap(); + let second = GenesisCert::personal_drive_subject(&private_key).unwrap(); + assert_eq!(first, second); + assert!(first.starts_with("did:ad:")); + assert!(!first.starts_with("did:ad:agent:")); + + let cert = GenesisCert::for_personal_drive(pubkey); + assert_eq!(cert.created_at, 0); + assert_eq!(cert.parent, ""); + assert_eq!(cert.drive, ""); + assert_eq!(cert.nonce, domain_separator_nonce(PERSONAL_DRIVE_PURPOSE)); + let sig = cert.sign(&private_key).unwrap(); + assert_eq!(GenesisCert::subject_for_signature(&sig), first); + cert.verify(&sig).unwrap(); + } + + #[test] + fn personal_drive_subject_differs_per_agent() { + let (pk_a, _) = test_key(9); + let (pk_b, _) = test_key(10); + let a = GenesisCert::personal_drive_subject(&pk_a).unwrap(); + let b = GenesisCert::personal_drive_subject(&pk_b).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn personal_drive_cross_lang_vector() { + // Pinned identically in `browser/lib/src/genesis.test.ts`. Seed [9; 32] + // is unused by the existing golden vectors. Change only with a new + // PERSONAL_DRIVE_PURPOSE version. + let seed = [9u8; 32]; + let signing_key = SigningKey::from_bytes(&seed); + let pubkey = *signing_key.verifying_key().as_bytes(); + let private_key = encode_base64(&seed); + let cert = GenesisCert::for_personal_drive(pubkey); + assert_eq!(hex(&cert.nonce), "5f62397980dc34a685e5ee57fa0ac058"); + assert_eq!( + encode_base64(&pubkey), + "_RckOFqgx1tk-3jNYC-h2ZH96_drE8WO1wLqyDXp9hg" + ); + let sig = cert.sign(&private_key).unwrap(); + assert_eq!( + sig, + "uv-2o7-7LBEo69T8gj2ncUWOXgNn9oG_rwqJAqHeM0O2GQjE8236RjthBrYuIXQbO_b0TCkU41f-auIx-1AjBw" + ); + assert_eq!( + GenesisCert::subject_for_signature(&sig), + "did:ad:uv-2o7-7LBEo69T8gj2ncUWOXgNn9oG_rwqJAqHeM0O2GQjE8236RjthBrYuIXQbO_b0TCkU41f-auIx-1AjBw" + ); + assert_eq!( + GenesisCert::personal_drive_subject(&private_key).unwrap(), + GenesisCert::subject_for_signature(&sig) + ); + cert.verify(&sig).unwrap(); + } } diff --git a/lib/src/hierarchy.rs b/lib/src/hierarchy.rs index b431f2b1b..9056b9530 100644 --- a/lib/src/hierarchy.rs +++ b/lib/src/hierarchy.rs @@ -251,6 +251,21 @@ fn check_rights_impl<'a, S: Storelike>( } } + // Anyone may READ an Agent. An agent resource is an identity — a name, + // a public key, a pointer to a home drive — and every place that shows + // who did something has to resolve one: chat avatars, member lists, + // "who created this". Agents belong to no drive, so no drive grant can + // reach them, and nobody but the owner is ever listed in their `read`. + // The result was that only presence, which carries the name inside its + // own payload, could show a name at all; everything else read a 401 and + // rendered a stub. Writing is untouched — still owner-only. + if matches!(right, Right::Read) + && crate::agents::migrate_legacy_agent_subject(resource.get_subject().as_str()) + .starts_with("did:ad:agent:") + { + return Ok("Agents are publicly readable.".into()); + } + // Handle Commits. if let Ok(commit_subject) = resource.get(urls::SUBJECT) { return match right { @@ -437,6 +452,51 @@ mod test { .expect("the legacy grant names this very key — its owner must keep write access"); } + /// Anyone can read an agent; only its owner can write it. + /// + /// Field bug (2026-08-17): two people on a shared drive saw each other's + /// names only in presence, which carries the name in its own payload. + /// Everywhere else — chat avatars, member lists, "Show profile" — resolves + /// the agent resource, and that read was refused: an agent grants `read` to + /// nobody but itself and belongs to no drive, so no grant could reach it. + #[tokio::test] + async fn agents_are_readable_by_anyone_but_writable_only_by_their_owner() { + use crate::agents::ForAgent; + use crate::hierarchy::{check_rights, Right}; + + let store = crate::db::Db::init_temp("agents_are_public").await.unwrap(); + crate::test_utils::setup_test_env(&store).await.unwrap(); + + let owner_key = "+/UHiCrMCWr7O5waaKRPJ5Pq90T8ncocNkH0kYihCFM="; + let owner = format!("did:ad:agent:{owner_key}"); + + let mut agent_resource = crate::Resource::new(owner.as_str().into()); + agent_resource + .set( + crate::urls::NAME.into(), + Value::String("Their Display Name".into()), + &store, + ) + .await + .unwrap(); + agent_resource.save_locally(&store).await.unwrap(); + + let someone_else = ForAgent::AgentSubject( + "did:ad:agent:9Bx1xRXvB1jVHYqYcSCbnR3T9pMGHYnvXQFmJ4wMPBw=".into(), + ); + + check_rights(&store, &agent_resource, &someone_else, Right::Read) + .await + .expect("an agent's identity is public — otherwise nobody can show anyone's name"); + + assert!( + check_rights(&store, &agent_resource, &someone_else, Right::Write) + .await + .is_err(), + "reading an agent must not imply editing it", + ); + } + // TODO: Add tests for: // - basic check_write (should be false for newly created agent) // - Malicious Commit (which grants itself write rights) diff --git a/lib/src/resources.rs b/lib/src/resources.rs index c5658170e..3593202f1 100644 --- a/lib/src/resources.rs +++ b/lib/src/resources.rs @@ -566,11 +566,37 @@ impl Resource { /// Checks if the classes are there, if not, fetches them. /// Returns an empty vector if there are no classes found. + /// + /// A class this store does not hold is SKIPPED, not an error. That is what + /// the line above always claimed, but the `?` that used to be here made one + /// unresolvable class abort the lot — and since `check_required_props` calls + /// this, that turned "I have never seen this class" into a rejected commit. + /// + /// Cost of getting it wrong, measured in the field: a table was created on + /// one node and its row class never reached another, so every row written + /// against that node was refused, forever, with the rows still rendering + /// locally as though saved. Two people lost a session to it. + /// + /// Skipping weakens required-property validation for classes we cannot see, + /// which is the right trade: a store cannot enforce a contract it does not + /// have, the same write is already possible with no class at all, and this + /// is a data-integrity check rather than an access-control one — rights are + /// enforced separately and are not affected. In a system where resources + /// arrive at different times by different routes, an unknown class is a + /// normal state, not a malformed write. pub async fn get_classes(&self, store: &impl Storelike) -> AtomicResult> { let mut classes: Vec = Vec::new(); if let Ok(val) = self.get(crate::urls::IS_A) { for class in val.to_subjects(None)? { - classes.push(store.get_class(&class).await?) + match store.get_class(&class).await { + Ok(resolved) => classes.push(resolved), + Err(e) => tracing::warn!( + "Class {} is not available here, so {} is not validated against it: {}", + class, + self.get_subject(), + e + ), + } } } Ok(classes) @@ -1671,6 +1697,29 @@ mod test { .unwrap_err(); } + /// A table's row class created on one node may not have reached this one. + /// Refusing the write in that case cost two people a session: every row was + /// rejected with "Failed getting class …", the rows rendered locally as + /// though saved, and nothing surfaced it. + #[tokio::test] + async fn a_class_this_store_does_not_have_is_skipped_not_fatal() { + let store: crate::Db = init_store().await; + let mut row = Resource::new("did:ad:rowAgainstAnAbsentClass".into()); + row.set_unsafe( + crate::urls::IS_A.into(), + crate::Value::ResourceArray(vec![crate::values::SubResource::Subject( + "did:ad:aClassThisStoreHasNeverSeen".into(), + )]), + ) + .unwrap(); + + // Unresolvable, so there is nothing to validate against — and nothing + // to validate against is not the same as invalid. + let classes = row.get_classes(&store).await.unwrap(); + assert!(classes.is_empty()); + row.check_required_props(&store).await.unwrap(); + } + #[tokio::test] async fn check_required_props() { let store: crate::Db = init_store().await; diff --git a/lib/src/sync/engine.rs b/lib/src/sync/engine.rs index 2139322bd..2906ec3b6 100644 --- a/lib/src/sync/engine.rs +++ b/lib/src/sync/engine.rs @@ -1127,6 +1127,19 @@ pub async fn import_sync_push( (count, blob_requests) } +/// Whether the owner deliberately dialled this node. Peer-to-peer sync only +/// exists with the `iroh` feature — the WASM build of this crate has no peer +/// module — so without it nothing is ever treated as paired. +#[cfg(feature = "iroh")] +fn peer_is_paired(store: &Db, node_id: &str) -> bool { + crate::sync::peer::is_paired_peer(store, node_id) +} + +#[cfg(not(feature = "iroh"))] +fn peer_is_paired(_store: &Db, _node_id: &str) -> bool { + false +} + /// Serve a remote-supplied `pull` list from local Loro snapshots — gated per /// subject on `check_read` for the identity the remote proved. /// This is the initiator-side mirror of the acceptor's `handle_sync_vv`, @@ -1136,20 +1149,59 @@ pub async fn import_sync_push( /// drive and receive it regardless of read rights. Dialing a peer never /// established that peer's rights. Fail closed: a subject that doesn't /// materialize into a resource can't be rights-checked, so it isn't served. +/// +/// `paired_peer` is the node id of a peer this node's user deliberately dialled +/// (see `peer::is_paired_peer`). Such a peer may replicate anything WE can +/// read, even though its own agent holds no rights: pairing is an authenticated +/// choice by the owner, and it is the authority a replica should run on. The +/// alternative — the owner hand-writing an ACL entry naming each device's agent +/// on each drive — is what made two of the same person's nodes sync nothing at +/// all while the UI reported "In sync". +/// +/// Note this deliberately does NOT widen what gets served: a paired replica is +/// served exactly the subjects this node can read, never more. pub async fn collect_readable_snapshots( store: &Db, agent: &crate::agents::ForAgent, subjects: &[String], + paired_peer: Option<&str>, ) -> Vec<(String, Vec)> { + // Resolved once: a paired peer's entitlement is "whatever we ourselves may + // read", so it is our own identity that answers, not the peer's. + let own_agent = if paired_peer.is_some_and(|node| peer_is_paired(store, node)) { + store + .get_default_agent() + .ok() + .map(crate::agents::ForAgent::from) + } else { + None + }; + let mut entries = Vec::new(); for subject in subjects { let subj = crate::Subject::from_raw(subject, store.get_base_domain().as_deref()); match store.get_resource(&subj).await { Ok(resource) => { - if crate::hierarchy::check_read(store, &resource, agent) + let mut readable = crate::hierarchy::check_read(store, &resource, agent) .await - .is_err() - { + .is_ok(); + + if !readable { + if let Some(own) = own_agent.as_ref() { + readable = crate::hierarchy::check_read(store, &resource, own) + .await + .is_ok(); + + if readable { + tracing::debug!( + "[sync] serving {} to a paired replica", + &subject[..subject.len().min(30)] + ); + } + } + } + + if !readable { tracing::warn!( "[sync] refusing to serve {} to peer: no read access for {:?}", &subject[..subject.len().min(30)], diff --git a/lib/src/sync/iroh_e2e.rs b/lib/src/sync/iroh_e2e.rs index 102404618..fed0d4d95 100644 --- a/lib/src/sync/iroh_e2e.rs +++ b/lib/src/sync/iroh_e2e.rs @@ -232,6 +232,96 @@ async fn e2e_bidirectional_bulk_sync() { } /// After bulk sync, an edit on A reaches B via the live stream. +/// An idle link stays up. +/// +/// The read loop now treats silence as a dead connection, which is what makes a +/// half-open link recoverable — one side's stream can die while the other keeps +/// broadcasting into it, and until this it took ~15 minutes for the second side +/// to notice, during which every local change was silently dropped and +/// `auto_connect` would not redial (it skips peers it believes are connected). +/// +/// The hazard in that fix is tearing down healthy connections that simply have +/// nothing to say, so this waits past the keepalive interval with no traffic at +/// all and asserts the peer is still there and still syncing. +#[tokio::test] +async fn e2e_an_idle_link_survives_on_keepalives() { + let pair = setup_pair("e2e_idle_link").await; + + sync_b_from_a(&pair).await; + wait_for_live_peers(1, std::time::Duration::from_secs(3)).await; + + // Quiet for longer than a keepalive interval — but inside the liveness + // timeout, so only the keepalives are holding it open. + tokio::time::sleep(crate::sync::protocol::KEEPALIVE_INTERVAL * 2).await; + + assert!( + crate::sync::peer::live_peer_count() >= 1, + "an idle connection must be held open by keepalives, not torn down" + ); + + // And it still works, rather than merely appearing registered. + let canvas = pair + .db_a + .create_resource( + CANVAS_CLASS, + &pair.drive, + "After idling", + Some(vec![( + STROKE_DATA, + crate::Value::Json(serde_json::Value::Array(vec![ + serde_json::json!({"color": 2, "path": [[1.0, 1.0]]}), + ])), + )]), + ) + .await + .unwrap(); + + sync_b_from_a(&pair).await; + assert_eq!(stroke_count(&pair.db_b, &canvas).await, 1); +} + +/// Presence crosses a peer link — and never reaches the store. +/// +/// Both halves matter. Before this, `EPHEMERAL` (0x40) was a reserved tag with +/// no sender and no handler, so two machines syncing the same drive could not +/// see each other's cursors at all. And presence must stay out of the store: +/// every other frame on this link ends in a write, and cursor positions merged +/// into the CRDT would be persisted and synced forever. +#[tokio::test] +async fn e2e_presence_crosses_the_link_without_being_stored() { + let pair = setup_pair("e2e_presence").await; + + sync_b_from_a(&pair).await; + wait_for_live_peers(1, std::time::Duration::from_secs(3)).await; + + let before = pair.db_b.all_resources(true).count(); + let mut presence = pair.db_b.subscribe_ephemeral(); + + let payload = b"cursor-position-blob".to_vec(); + crate::sync::peer::broadcast_ephemeral( + crate::sync::protocol::ephemeral_kind::PRESENCE, + &pair.drive, + "did:ad:agent:someone", + &payload, + None, + ); + + let received = tokio::time::timeout(std::time::Duration::from_secs(5), presence.recv()) + .await + .expect("presence must arrive before the timeout") + .expect("the presence channel must stay open"); + + assert_eq!(received.drive, pair.drive); + assert_eq!(received.agent, "did:ad:agent:someone"); + assert_eq!(received.payload, payload); + + let after = pair.db_b.all_resources(true).count(); + assert_eq!( + before, after, + "presence must not create resources — it is not data" + ); +} + #[tokio::test] async fn e2e_stroke_append_after_sync() { let pair = setup_pair("e2e_stroke").await; @@ -407,13 +497,22 @@ async fn e2e_engine_pull_after_iroh_bulk_sync() { let subjects = crate::sync::engine::collect_drive_subjects(&pair.db_b, &drive_subject).await; let vvs = crate::sync::engine::build_drive_vvs(&pair.db_b, &subjects); let hash = crate::sync::engine::compute_drive_hash(&vvs); + // Pull as the drive's own agent, not `Public`. Since the personal drive is + // derived and provisioned private, nothing on it is world-readable, and the + // push side of `handle_sync_vv` filters by `check_read` — so a `Public` + // pull now returns SYNC_DIFF with no SYNC_PUSH behind it and imports + // nothing. `Public` passed here only while test drives happened to be + // readable by anyone, which is not the shape this fallback runs in: the + // device doing the pull is the owner's other device, holding the owner's + // agent. + let owner = crate::agents::ForAgent::from(pair.db_a.get_default_agent().unwrap()); let frames = crate::sync::engine::handle_sync_vv( &pair.drive, &hash, &[], &std::collections::HashMap::new(), &pair.db_a, - &ForAgent::Public, + &owner, ) .await; diff --git a/lib/src/sync/peer.rs b/lib/src/sync/peer.rs index e81c4ba19..be17ada99 100644 --- a/lib/src/sync/peer.rs +++ b/lib/src/sync/peer.rs @@ -324,9 +324,19 @@ use std::sync::Mutex; /// Active outgoing send streams keyed by peer NodeID. /// Used to push UPDATE frames to connected peers. -static LIVE_PEERS: Mutex>>>> = +/// Value is `(connection generation, sender)`. The generation exists because a +/// reconnect installs a new entry under the SAME node id, and the old +/// connection's loops then tear down moments later. Keyed only by node id, that +/// teardown removed the entry the *new* connection had just installed — live +/// sync went silent while both ends still displayed "Connected", and nothing +/// recovered it until the next reconnect. Removal now only applies if the entry +/// still belongs to the connection asking to remove it. +static LIVE_PEERS: Mutex>)>>> = Mutex::new(None); +/// Monotonic id per live connection; see [`LIVE_PEERS`]. +static LIVE_PEER_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + /// Keep QUIC connections alive so live streams don't drop. static LIVE_CONNECTIONS: Mutex>> = Mutex::new(None); @@ -378,21 +388,44 @@ pub fn live_peer_ids() -> Vec { } /// Drop a live peer entry (dead write loop, closed channel, or reconnect). -pub fn remove_live_peer(peer_id: &str) { - remove_live_peer_inner(peer_id, true); +/// +/// `generation` is the connection doing the removing: a stale connection must +/// not evict the entry a newer one installed under the same node id. +pub fn remove_live_peer(peer_id: &str, generation: u64) { + remove_live_peer_inner(peer_id, Some(generation), true); } -/// Drop without notifying the UI (intentional reconnect / replace). -fn remove_live_peer_quiet(peer_id: &str) { - remove_live_peer_inner(peer_id, false); +/// Evict whatever connection is current, whoever installed it. For a deliberate +/// reconnect, where the caller means "drop the existing link" rather than "my +/// own connection ended". +fn remove_live_peer_any_quiet(peer_id: &str) { + remove_live_peer_inner(peer_id, None, false); } -fn remove_live_peer_inner(peer_id: &str, notify: bool) { +/// Evict the current connection and tell the UI. For an explicit user action — +/// "Disconnect" / forget this peer — where the intent is to drop the link +/// whichever connection happens to hold it. +pub fn remove_live_peer_any(peer_id: &str) { + remove_live_peer_inner(peer_id, None, true); +} + +fn remove_live_peer_inner(peer_id: &str, generation: Option, notify: bool) { let key = normalize_node_id(peer_id); let mut removed = false; if let Ok(mut guard) = LIVE_PEERS.lock() { if let Some(map) = guard.as_mut() { - removed = map.remove(&key).is_some(); + let is_current = match generation { + Some(generation) => map.get(&key).is_some_and(|(gen, _)| *gen == generation), + None => true, + }; + if is_current { + removed = map.remove(&key).is_some(); + } else if map.contains_key(&key) { + tracing::debug!( + "[live] stale connection for {} tried to deregister a newer one — ignored", + &key[..key.len().min(12)] + ); + } } } if removed { @@ -454,30 +487,18 @@ fn own_agent_update_frame(store: &Db) -> Option> { Some(encode_live_update_wire_msg(&key, &snapshot)) } -/// Whether `subject` is this device's own agent — the only agent resource a -/// peer is allowed to reconcile with us (a same-agent link authenticated as -/// exactly this agent), and the one whose live UPDATE must be applied without -/// echoing back out (see the read loop). -fn is_our_agent_subject(store: &Db, subject: &str) -> bool { - if !subject.starts_with("did:ad:agent:") { - return false; - } - let Ok(agent) = store.get_default_agent() else { - return false; - }; - let ours = crate::Subject::from_raw( - &agent.subject.to_string(), - store.get_base_domain().as_deref(), - ) - .pure_id(); - crate::Subject::from_raw(subject, store.get_base_domain().as_deref()).pure_id() == ours -} - -fn send_live_update_wire_msg(msg: Vec) { +/// Fan an UPDATE out to live peers, except `skip_peer` — the peer an imported +/// update came from. Sending it back is what made two idle nodes trade the same +/// snapshot indefinitely. +fn send_live_update_wire_msg_except(msg: Vec, skip_peer: Option<&str>) { let mut dead_peers = Vec::new(); let peers = LIVE_PEERS.lock().unwrap(); if let Some(map) = peers.as_ref() { - for (peer_id, tx) in map { + for (peer_id, (generation, tx)) in map { + if skip_peer.is_some_and(|skip| normalize_node_id(skip) == *peer_id) { + continue; + } + match tx.try_send(msg.clone()) { Ok(_) => {} Err(tokio::sync::mpsc::error::TrySendError::Full(m)) => { @@ -493,15 +514,51 @@ fn send_live_update_wire_msg(msg: Vec) { }); } Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { - dead_peers.push(peer_id.clone()); + dead_peers.push((peer_id.clone(), *generation)); } } } } drop(peers); - for peer_id in dead_peers { - remove_live_peer(&peer_id); + for (peer_id, generation) in dead_peers { + remove_live_peer(&peer_id, generation); + } +} + +fn send_live_update_wire_msg(msg: Vec) { + send_live_update_wire_msg_except(msg, None); +} + +/// Push live-collaboration state — presence, cursors, or the ops of an edit in +/// progress — to connected live peers. `kind` says which; see +/// `protocol::ephemeral_kind`. +/// +/// `skip_peer` is the peer it arrived from, if this is a relay rather than a +/// local update — the same echo suppression `UPDATE` frames use, which matters +/// more here: these arrive at keystroke frequency, so a loop would saturate the +/// link far faster than resource changes ever could. +/// +/// Nothing is stored and nothing is retried. If the channel is full the frame +/// is dropped, on the grounds that a stale cursor is better than a delayed +/// document — and that a dropped op costs a moment of divergence, which the +/// sender's next save repairs with a full snapshot. +pub fn broadcast_ephemeral( + kind: u8, + drive: &str, + agent: &str, + payload: &[u8], + skip_peer: Option<&str>, +) { + if payload.is_empty() || payload.len() > super::protocol::max_payload_for_kind(kind) { + return; } + + let frame = super::protocol::encode_ephemeral(kind, drive, agent, payload); + let len = frame.len() as u32; + let mut msg = Vec::with_capacity(4 + frame.len()); + msg.extend_from_slice(&len.to_be_bytes()); + msg.extend_from_slice(&frame); + send_live_update_wire_msg_except(msg, skip_peer); } /// Push an UPDATE frame to all connected live peers immediately (e.g. after a stroke save). @@ -555,6 +612,13 @@ fn start_live_sync(store: Db) { _ => continue, }; + // Never send an update back to the peer it arrived from. + let from_peer: Option = match &event { + crate::DbEvent::Changed { source_id, .. } + | crate::DbEvent::Destroyed { source_id, .. } => source_id.clone(), + _ => None, + }; + let loro_bytes: Option> = match &event { crate::DbEvent::Changed { delta: Some(delta), .. @@ -574,7 +638,7 @@ fn start_live_sync(store: Db) { let mut msg = Vec::with_capacity(4 + frame.len()); msg.extend_from_slice(&len.to_be_bytes()); msg.extend_from_slice(&frame); - send_live_update_wire_msg(msg); + send_live_update_wire_msg_except(msg, from_peer.as_deref()); continue; } _ => None, @@ -582,7 +646,7 @@ fn start_live_sync(store: Db) { if let Some(bytes) = loro_bytes { let msg = encode_live_update_wire_msg(&subject_key, &bytes); - send_live_update_wire_msg(msg); + send_live_update_wire_msg_except(msg, from_peer.as_deref()); } } }); @@ -727,6 +791,7 @@ fn register_live_peer( let tx_for_read = tx.clone(); // Add to peer map — replace if already connected (incoming may supersede outgoing) + let generation = LIVE_PEER_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; let is_new_peer = { let mut map = LIVE_PEERS.lock().unwrap(); if let Some(m) = map.as_mut() { @@ -737,7 +802,7 @@ fn register_live_peer( &key[..key.len().min(12)] ); } - m.insert(key.clone(), tx); + m.insert(key.clone(), (generation, tx)); !replacing } else { false @@ -772,7 +837,20 @@ fn register_live_peer( "[live] write loop started for {}", &write_peer_id[..write_peer_id.len().min(12)] ); - while let Some(msg) = rx.recv().await { + loop { + // Send a KEEPALIVE whenever there is nothing else to say, so the + // peer can tell an idle link from a dead one. Without traffic the + // far side has no way to distinguish "quiet" from "gone", and a + // half-open connection survives until some lower layer eventually + // notices — 15 minutes, in the case this was written for. + let msg = + match tokio::time::timeout(super::protocol::KEEPALIVE_INTERVAL, rx.recv()).await { + Ok(Some(msg)) => msg, + // Channel closed: the peer was deregistered. + Ok(None) => break, + Err(_) => super::protocol::encode_keepalive_wire_msg(), + }; + match send.write_all(&msg).await { Ok(_) => { tracing::trace!( @@ -794,7 +872,7 @@ fn register_live_peer( "[live] write loop ended for {}", &write_peer_id[..write_peer_id.len().min(12)] ); - remove_live_peer(&write_peer_id); + remove_live_peer(&write_peer_id, generation); }); // Read loop: receives UPDATE frames from the peer, imports them @@ -812,9 +890,27 @@ fn register_live_peer( let mut agent = agent; let mut drive_cache: std::collections::HashMap = std::collections::HashMap::new(); + // Whether this peer has ever sent a KEEPALIVE. Older builds do not, and + // silence from them means nothing — tearing the link down on a timeout + // would turn every working idle connection to an older peer into a + // 35-second reconnect loop. Observed for real against a peer that had + // not been upgraded yet: 10 teardowns in as many minutes, each followed + // by a full reconnect. So the timeout only becomes a liveness signal + // once the peer has shown it speaks it. + let mut peer_sends_keepalives = false; loop { - let len = match recv.read_u32().await { - Ok(n) => { + // Silence is treated as death, not idleness. A half-open link is + // otherwise invisible: this side keeps believing it is connected, + // so `auto_connect` will not redial it (it skips peers already in + // `live_peer_ids`) and every local change is broadcast into a + // socket nobody reads — with no error anywhere. The peer sends a + // KEEPALIVE every `KEEPALIVE_INTERVAL`, so hearing nothing for + // `LIVENESS_TIMEOUT` means the connection is gone. + let read = + tokio::time::timeout(super::protocol::LIVENESS_TIMEOUT, recv.read_u32()).await; + + let len = match read { + Ok(Ok(n)) => { tracing::trace!( "[live] received frame {} bytes from {}", n, @@ -822,13 +918,31 @@ fn register_live_peer( ); n as usize } - Err(e) => { + Ok(Err(e)) => { tracing::info!( "[live] read error from {}: {e}", &read_peer_id[..read_peer_id.len().min(12)] ); break; } + Err(_) if peer_sends_keepalives => { + tracing::info!( + "[live] no traffic from {} for {:?} — treating the link as dead", + &read_peer_id[..read_peer_id.len().min(12)], + super::protocol::LIVENESS_TIMEOUT + ); + break; + } + Err(_) => { + // Never heard a keepalive from this peer, so its silence + // carries no information. Keep waiting rather than + // manufacture a disconnect. + tracing::debug!( + "[live] {} is quiet and sends no keepalives — not assuming it is dead", + &read_peer_id[..read_peer_id.len().min(12)] + ); + continue; + } }; // Same "no proven identity → tight budget" rule as the accept-side // dispatch loop (`handle_stream`): a connection can reach live @@ -854,6 +968,85 @@ fn register_live_peer( continue; } + // Keepalive: nothing to do. Receiving it already did the job — + // it reset the liveness timeout above. + if buf[0] == super::protocol::tag::KEEPALIVE { + peer_sends_keepalives = true; + continue; + } + + // Live collaboration: presence, cursors, and the ops of an edit in + // progress. Handled before every other frame type and returned from + // immediately, because the one thing none of it may do is reach the + // store: the paths below all end in a write, and cursor positions + // merged into the CRDT would persist and sync forever. Uncommitted + // ops become durable only if a local user saves the document they + // land in, which produces a signed commit under that user's own + // identity. + // + // Two different gates, because the kinds ask for different things. + // Presence and cursors disclose who is looking at what, so a peer + // that cannot read must not receive them — but they author nothing, + // so read is enough. Uncommitted ops are somebody else's characters + // appearing in a document, so those need write: a peer with read + // access has no business putting text in front of an editor as + // though it belonged there. + if buf[0] == super::protocol::tag::EPHEMERAL { + if let Some(decoded) = super::protocol::decode_ephemeral(&buf[1..]) { + let scope_subj = crate::Subject::from_raw( + &decoded.drive, + store.get_base_domain().as_deref(), + ); + + let admitted = if decoded.kind == super::protocol::ephemeral_kind::DOC { + // `None` means the resource isn't stored here: nothing + // to check rights against, and no local editor that + // could have it open either. + match super::ws_apply::resolve_destroy_drive(&store, &decoded.drive).await { + Some(drive_subject) => { + admitted_for_drive( + &store, + &agent, + &drive_subject, + initiated_by_us, + &mut drive_cache, + ) + .await + } + None => false, + } + } else { + match store.get_resource(&scope_subj).await { + Ok(scope_resource) => { + crate::hierarchy::check_read(&store, &scope_resource, &agent) + .await + .is_ok() + } + // A drive we do not hold has nothing to disclose. + Err(_) => false, + } + }; + + if admitted { + store.publish_ephemeral(crate::db::EphemeralEvent { + kind: decoded.kind, + drive: decoded.drive, + agent: decoded.agent, + payload: decoded.payload, + from_peer: read_peer_id.clone(), + }); + } else { + tracing::debug!( + "[live] dropped a kind-{} frame {} is not admitted for", + decoded.kind, + &read_peer_id[..read_peer_id.len().min(12)] + ); + } + } + + continue; + } + // Handle DESTROY frames. Gated: a live connection has no // established rights beyond whatever `agent` proved during the // handshake (Public if it proved nothing) — the same admission + @@ -916,28 +1109,48 @@ fn register_live_peer( ) .await { - // Our own agent resource is account state that - // both same-agent devices push on connect. Apply - // it with the importing flag held so the live - // push loop doesn't re-broadcast it back — an - // unconditional re-send of an identical snapshot - // would ping-pong between the two. Same - // suppression bulk SYNC_PUSH imports use; the - // WS announcer ignores the flag, so the local - // browser still sees the merged name / drives. - let own_agent = is_our_agent_subject(&store, &decoded.subject); - if own_agent { - super::ws_apply::set_importing(true); - } + // Hold the importing flag across EVERY live + // import so the push loop doesn't re-broadcast + // what we just received — an unconditional + // re-send of an identical snapshot ping-pongs + // between the two nodes. + // + // This used to apply only when the subject was + // this node's own agent, which suppressed the + // echo on exactly one side: the device whose + // agent it is stays quiet, the peer for whom it + // is a stranger's agent re-sends it, and a drive + // — nobody's own agent — echoes on both. Two + // idle nodes then traded ~8.6KB frames + // indefinitely (measured: 355 frames in 58s, + // ~50KB/s, for one agent resource and one + // drive). + // + // The WS announcer ignores this flag, so the + // local browser still sees the merged state. + // + // A global mute is the blunt version of this: + // `DbEvent` already carries `source_id`, so the + // push loop could instead skip only the peer the + // update came from, and never mute a concurrent + // local edit. That needs `persist_update` to + // take a source and the push loop to read it. + // Attribute this write to the peer it came + // from. `add_resource_opts` reads it while the + // write is still on the stack and stamps it on + // the `DbEvent`, so the push loop can skip that + // one peer rather than muting every broadcast + // for the duration of an import. + super::ws_apply::set_import_source(Some(read_peer_id.clone())); + super::ws_apply::set_importing(true); let _ = super::ws_apply::persist_update( &store, &decoded.subject, resolved, ) .await; - if own_agent { - super::ws_apply::set_importing(false); - } + super::ws_apply::set_importing(false); + super::ws_apply::set_import_source(None); tracing::trace!( "[live] imported update for {} from {}", &decoded.subject[..decoded.subject.len().min(20)], @@ -990,7 +1203,7 @@ fn register_live_peer( } } - remove_live_peer(&read_peer_id); + remove_live_peer(&read_peer_id, generation); }); } @@ -1210,7 +1423,7 @@ pub async fn sync_drive_with_peer_using_outcome( } if force && live_peer_ids().contains(&remote_key) { - remove_live_peer_quiet(&remote_key); + remove_live_peer_any_quiet(&remote_key); } let my_node_id = endpoint.node_id(); @@ -1445,6 +1658,7 @@ pub async fn sync_drive_with_peer_using_outcome( store, &remote_agent, &diff.pull, + Some(&remote_key), ) .await; if !entries.is_empty() { @@ -1501,6 +1715,7 @@ pub async fn sync_drive_with_peer_using_outcome( store, &remote_agent, &pull_subjects, + Some(&remote_key), ) .await; if !entries.is_empty() { @@ -1602,7 +1817,12 @@ pub async fn sync_drive_with_peer_using_outcome( // The exchange completed and this is a real peer we hold a drive with — // stamp it so the UI can show when we last synced, and remember where it // lives now (relay + direct addrs) so the next dial skips discovery. - mark_peer_synced(store, &remote_key); + mark_peer_synced( + store, + &remote_key, + Some(total_pushed as u32), + Some(total_imported as u32), + ); if let Some(info) = endpoint.remote_info(node_id) { let addr: iroh::NodeAddr = info.into(); remember_peer_addr( @@ -1674,6 +1894,15 @@ const KNOWN_PEERS_KEY: &[u8] = b"_iroh_known_peers_v2"; pub struct KnownPeer { pub node_id: String, pub name: String, + /// What the LAST completed sync with this peer moved, in resources. A + /// per-sync figure rather than a lifetime total on purpose: a running + /// counter has to survive re-pairs, store resets and partial syncs, and + /// quietly becomes fiction the first time one of those is missed. These + /// two are checkable against the number the sync itself reported. + #[serde(default)] + pub last_sent: Option, + #[serde(default)] + pub last_received: Option, /// Unix millis of the last successful sync with this peer, if ever. Absent /// for peers stored before this was tracked (serde default), so the UI shows /// "not yet" rather than a bogus epoch time. @@ -1691,6 +1920,22 @@ pub struct KnownPeer { } /// Get all known peers from the DB. +/// Whether this node's user deliberately paired with `node_id`. +/// +/// Only the initiator side records a peer (`add_known_peer` is called after we +/// dial and the remote says HELLO); the accept side deliberately does not, on +/// the grounds that the local user never chose an unsolicited inbound +/// connection. So this answers "did the owner of this node choose to sync with +/// that one", which is the authority a replica needs — rather than an ACL entry +/// the owner has to write by hand for every device and every drive. +pub fn is_paired_peer(store: &Db, node_id: &str) -> bool { + let key = normalize_node_id(node_id); + + get_known_peers(store) + .iter() + .any(|p| normalize_node_id(&p.node_id) == key) +} + pub fn get_known_peers(store: &Db) -> Vec { if let Ok(Some(bytes)) = store .kv @@ -1716,6 +1961,8 @@ pub fn add_known_peer(store: &Db, node_id: &str, name: &str) { } else { peers.push(KnownPeer { node_id: key, + last_sent: None, + last_received: None, name: name.to_string(), last_synced: None, relay_url: None, @@ -1755,6 +2002,8 @@ pub fn remember_peer_addr( } else { peers.push(KnownPeer { node_id: key.clone(), + last_sent: None, + last_received: None, name: String::new(), last_synced: None, relay_url: None, @@ -1809,7 +2058,29 @@ fn dial_target(store: &Db, node_id: NodeId) -> iroh::NodeAddr { /// Stamp a peer's `last_synced` to now, upserting it if unknown. Called when a /// sync exchange with the peer completes, so the UI can say "synced 2m ago" /// without a separate bookkeeping path. -pub fn mark_peer_synced(store: &Db, node_id: &str) { +/// Record a completed sync with `node_id`. +/// +/// `sent` / `received` are optional because the two sides know different +/// things: the dialling side tallies both directions itself, while the +/// accepting side answers frames through the engine and only counts what came +/// in. Reporting an unknown figure as 0 would be the same class of lie this +/// status work exists to remove, so absent stays absent. +/// Record a sync against a peer we ALREADY know, and do nothing otherwise. +/// +/// For the accepting side. `mark_peer_synced` inserts a `KnownPeer` when it +/// finds none, which is right when we did the dialling — choosing to dial is +/// the consent. On the accept side the peer chose us, so inserting would hand +/// any stranger who completes a handshake a permanent entry in known-peers, and +/// with it an auto-reconnect slot that no pairing ever granted. +pub fn mark_known_peer_synced(store: &Db, node_id: &str, sent: Option, received: Option) { + if !is_paired_peer(store, node_id) { + return; + } + + mark_peer_synced(store, node_id, sent, received); +} + +pub fn mark_peer_synced(store: &Db, node_id: &str, sent: Option, received: Option) { let key = normalize_node_id(node_id); let mut peers = get_known_peers(store); let now = crate::utils::now(); @@ -1818,10 +2089,14 @@ pub fn mark_peer_synced(store: &Db, node_id: &str) { .find(|p| normalize_node_id(&p.node_id) == key) { existing.last_synced = Some(now); + existing.last_sent = sent; + existing.last_received = received; } else { peers.push(KnownPeer { node_id: key, name: String::new(), + last_sent: sent, + last_received: received, last_synced: Some(now), relay_url: None, direct_addrs: Vec::new(), @@ -2059,6 +2334,17 @@ async fn handle_stream( "[accept] sync complete, transitioning to live mode with {}", &remote_key[..remote_key.len().min(12)] ); + // Record it here too. This used to run only on the dialling side, + // so an always-on node — which is always the one being dialled — + // reported "not synced yet" about a peer it had been exchanging + // data with for hours. The counts are from this node's point of + // view: what it served, and what it took in. + // + // `mark_known_peer_synced`, not `mark_peer_synced`: this side did + // not choose the connection, so recording a sync must never be what + // introduces the peer. Updating a peer we already paired with is + // reporting; inserting one we have not is granting access. + mark_known_peer_synced(&store, &remote_key, None, Some(total_imported as u32)); // Accept side: the peer dialed us. No owned-drive relaxation — it // must hold real write rights to touch anything here. register_live_peer(remote_key, send, recv, store, agent, false); @@ -2354,6 +2640,7 @@ mod initiator_trust_tests { &db, &ForAgent::Public, &[child.clone()], + None, ) .await; assert!( @@ -2367,6 +2654,7 @@ mod initiator_trust_tests { &db, &ForAgent::AgentSubject(alice.subject.clone()), &[child.clone()], + None, ) .await; assert_eq!( @@ -2391,6 +2679,7 @@ mod initiator_trust_tests { &db, &ForAgent::AgentSubject(mallory.subject.clone()), &[child], + None, ) .await; assert!( @@ -2399,6 +2688,57 @@ mod initiator_trust_tests { ); } + /// A node the owner deliberately paired with may replicate what the owner + /// can read, even though its own agent holds no rights on the drive. + /// + /// Without this, two of the same person's machines sync nothing: the + /// serving node refuses every subject because the peer's node agent is a + /// stranger to the drive, and the only remedy is hand-writing an ACL entry + /// naming that agent — per device, per drive, with no prompt and no error. + /// Pairing is already an authenticated choice by the owner; this treats it + /// as one. + #[tokio::test] + async fn a_paired_replica_receives_what_the_owner_can_read() { + let db = Db::init_temp("paired_replica_serves").await.unwrap(); + let alice = crate::agents::Agent::new(Some("Alice")).unwrap(); + db.set_default_agent(alice.clone()); + let (_drive, child) = private_drive_with_child(&db, &alice).await; + + // The replica's own identity: a node agent, granted nothing. + let replica_node = "aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999"; + let replica_agent = db.create_agent(Some("Replica")).await.unwrap(); + let as_replica = ForAgent::AgentSubject(replica_agent.subject.clone()); + + // Not paired yet — rights only, so nothing is served. + let unpaired = crate::sync::engine::collect_readable_snapshots( + &db, + &as_replica, + &[child.clone()], + Some(replica_node), + ) + .await; + assert!( + unpaired.is_empty(), + "a node the owner never dialled must not be served" + ); + + // The owner pairs with it. + add_known_peer(&db, replica_node, "Replica"); + + let paired = crate::sync::engine::collect_readable_snapshots( + &db, + &as_replica, + &[child], + Some(replica_node), + ) + .await; + assert_eq!( + paired.len(), + 1, + "a paired replica must receive what the owner can read" + ); + } + /// A peer we dialed that isn't admitted for the drive must NOT be able to /// delete + tombstone a subject we legitimately hold via `remove[]`. The /// old code called `apply_destroy` (unchecked) for every remove entry. @@ -2459,3 +2799,103 @@ mod initiator_trust_tests { ); } } + +#[cfg(test)] +mod live_peer_registry_tests { + use super::*; + + fn register(key: &str) -> u64 { + let generation = + LIVE_PEER_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + let (tx, _rx) = tokio::sync::mpsc::channel(4); + let mut guard = LIVE_PEERS.lock().unwrap(); + if guard.is_none() { + *guard = Some(HashMap::new()); + } + guard + .as_mut() + .unwrap() + .insert(normalize_node_id(key), (generation, tx)); + + generation + } + + fn is_registered(key: &str) -> bool { + LIVE_PEERS + .lock() + .unwrap() + .as_ref() + .is_some_and(|m| m.contains_key(&normalize_node_id(key))) + } + + /// A reconnect installs a new connection under the same node id, and the + /// old one's loops tear down a moment later. That teardown must not evict + /// the live connection that replaced it — otherwise sync goes silent while + /// both ends still show "Connected", and nothing recovers it until the next + /// reconnect. + #[test] + fn a_stale_connection_does_not_deregister_its_replacement() { + let peer = "test-peer-stale-vs-replacement"; + let old = register(peer); + let new = register(peer); + assert_ne!(old, new); + + remove_live_peer(peer, old); + assert!( + is_registered(peer), + "the replacement connection must survive the old one's teardown" + ); + + remove_live_peer(peer, new); + assert!( + !is_registered(peer), + "the current connection must still be able to deregister itself" + ); + } + + #[test] + fn a_deliberate_reconnect_evicts_whoever_is_current() { + let peer = "test-peer-forced-reconnect"; + register(peer); + remove_live_peer_any_quiet(peer); + assert!(!is_registered(peer)); + } +} + +#[cfg(all(test, feature = "db-redb"))] +mod peer_sync_volume_tests { + use super::*; + + /// The card needs to say what a sync moved, not just that one happened — + /// a timestamp cannot distinguish a link carrying data from one being + /// refused every subject. + #[tokio::test] + async fn a_completed_sync_records_what_it_moved() { + let db = Db::init_temp("peer_sync_volume").await.unwrap(); + let node = "1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff"; + + mark_peer_synced(&db, node, Some(49), Some(1)); + + let peer = get_known_peers(&db) + .into_iter() + .find(|p| normalize_node_id(&p.node_id) == normalize_node_id(node)) + .expect("the sync must record the peer"); + + assert_eq!(peer.last_sent, Some(49)); + assert_eq!(peer.last_received, Some(1)); + assert!(peer.last_synced.is_some()); + + // A later, quieter sync replaces the figures rather than accumulating: + // these describe the last pass, so they must be checkable against what + // that pass reported. + mark_peer_synced(&db, node, Some(0), Some(2)); + + let peer = get_known_peers(&db) + .into_iter() + .find(|p| normalize_node_id(&p.node_id) == normalize_node_id(node)) + .unwrap(); + + assert_eq!(peer.last_sent, Some(0)); + assert_eq!(peer.last_received, Some(2)); + } +} diff --git a/lib/src/sync/protocol.rs b/lib/src/sync/protocol.rs index e6c7994ca..084705958 100644 --- a/lib/src/sync/protocol.rs +++ b/lib/src/sync/protocol.rs @@ -43,6 +43,60 @@ pub mod tag { /// authorization (the authenticated agent + Iroh NodeId are). pub const HELLO: u8 = 0x37; pub const EPHEMERAL: u8 = 0x40; + /// Liveness probe. Payload-free, never answered — its only job is to give + /// the peer's read loop something to receive, so silence can be treated as + /// a dead link rather than an idle one. + pub const KEEPALIVE: u8 = 0x41; +} + +/// How often an otherwise-idle live connection sends a `KEEPALIVE`. +pub const KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); + +/// How long a live connection may hear nothing at all before it is considered +/// dead. Comfortably more than [`KEEPALIVE_INTERVAL`], so a couple of dropped +/// probes do not tear down a working link. +/// +/// This exists because a half-open connection is invisible: one side's stream +/// dies and the other keeps queueing writes into it, believing it is live — +/// which also stops the reconnect loop, since that skips peers it thinks are +/// connected. Observed gap between the two sides noticing: 15 minutes, during +/// which every local change was silently dropped. +pub const LIVENESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(35); + +/// Which live-collaboration channel a frame belongs to. +/// +/// They are not interchangeable: each fans out to a different subscriber set on +/// the far side, so the frame has to say which. [`DOC`] additionally differs in +/// what it is *allowed* to do — see its own note. +pub mod ephemeral_kind { + /// `LORO_EPHEMERAL_UPDATE` — ephemeral state for one subject. + pub const LORO: u8 = 0; + /// `PRESENCE_UPDATE` — drive-scoped presence. + pub const PRESENCE: u8 = 1; + /// `LORO_SYNC_UPDATE` — the ops of an edit in progress, before anyone has + /// saved. + /// + /// The odd one out, and the reason this frame is no longer only about + /// presence: this is content, not a cursor. Committed state crosses the + /// link as an `UPDATE` frame when the store is written, which happens on + /// save — so without this, a peer sees nothing of an edit until it is + /// finished, while cursors cross the instant they move. The result was + /// carets pointing into text the receiving document had never heard of, + /// which Loro rejects, so remote cursors never appeared at all. + /// + /// Because it is content it gets a stricter gate and a looser size limit + /// than the other two: see [`max_payload_for_kind`] and the read loop in + /// `peer.rs`, which admits it on *write* rights rather than read. + pub const DOC: u8 = 2; +} + +/// A single `KEEPALIVE` frame, length-prefixed and ready to send. +pub fn encode_keepalive_wire_msg() -> Vec { + let frame = vec![tag::KEEPALIVE]; + let mut msg = Vec::with_capacity(4 + frame.len()); + msg.extend_from_slice(&(frame.len() as u32).to_be_bytes()); + msg.extend_from_slice(&frame); + msg } /// Structured error codes carried on `ERROR` frames (and the HTTP `/commit` @@ -72,6 +126,15 @@ pub mod error_code { /// Retrying floods the server; only a rights change or a fresh edit /// helps. Blocking — stop retrying, keep the entry visible. pub const UNAUTHORIZED_WRITE: u16 = 3; + /// The commit names a class this server does not hold, so validation + /// cannot run. Seen in the field as a table whose rows were refused one at + /// a time because the table's row class had never reached the server: every + /// row looked saved locally and none of them were. + /// + /// Blocking, NOT terminal. The write itself is well-formed — the class may + /// still arrive, at which point the same commit would apply — so dropping + /// it would discard a good edit. Keep it, stop retrying, and say so. + pub const MISSING_CLASS: u16 = 4; } /// Classify a commit-application error message into a structured code for @@ -93,6 +156,13 @@ pub fn classify_commit_error(message: &str) -> u16 { return error_code::UNAUTHORIZED_WRITE; } + // `storelike.rs` wraps the lookup failure as + // "Failed getting class . ", so the prefix is the stable + // part regardless of why the class could not be read. + if message.contains("Failed getting class") { + return error_code::MISSING_CLASS; + } + error_code::UNKNOWN } @@ -405,6 +475,119 @@ pub fn encode_hello(name: &str) -> Vec { buf } +/// Largest ephemeral payload accepted from a peer. Presence is cursor +/// positions and selections, not documents — anything larger is a bug or an +/// attempt to push real data down a channel that skips every rights check a +/// write would face. +pub const EPHEMERAL_MAX_PAYLOAD: usize = 64 * 1024; + +/// Largest [`ephemeral_kind::DOC`] payload accepted from a peer. +/// +/// Roomier than [`EPHEMERAL_MAX_PAYLOAD`] because this kind carries content: a +/// keystroke is tens of bytes, but pasting a section of a document is a single +/// op and can be far larger. Still bounded — a whole document arrives as a +/// snapshot through the sync handshake, not through here. +pub const LIVE_DOC_MAX_PAYLOAD: usize = 1024 * 1024; + +/// The payload ceiling for one [`ephemeral_kind`]. +pub fn max_payload_for_kind(kind: u8) -> usize { + if kind == ephemeral_kind::DOC { + LIVE_DOC_MAX_PAYLOAD + } else { + EPHEMERAL_MAX_PAYLOAD + } +} + +/// Encode an EPHEMERAL frame: `drive`, the agent it originated from, and an +/// opaque payload (a Loro `EphemeralStore` update). +/// +/// The agent travels with the frame because a peer link is node-to-node while +/// presence is per-agent: one node may relay several agents' cursors, and the +/// receiver needs to know whose it is — to attribute it, and to decide whether +/// it may be shown at all. +pub fn encode_ephemeral(kind: u8, drive: &str, agent: &str, payload: &[u8]) -> Vec { + let drive_bytes = drive.as_bytes(); + let agent_bytes = agent.as_bytes(); + let drive_len = drive_bytes.len().min(u16::MAX as usize); + let agent_len = agent_bytes.len().min(u16::MAX as usize); + + let mut buf = Vec::with_capacity(2 + 2 + drive_len + 2 + agent_len + payload.len()); + buf.push(tag::EPHEMERAL); + buf.push(kind); + buf.extend_from_slice(&(drive_len as u16).to_be_bytes()); + buf.extend_from_slice(&drive_bytes[..drive_len]); + buf.extend_from_slice(&(agent_len as u16).to_be_bytes()); + buf.extend_from_slice(&agent_bytes[..agent_len]); + buf.extend_from_slice(payload); + buf +} + +/// A decoded EPHEMERAL frame. Never persisted — see the read loop in `peer.rs`. +#[derive(Debug, Clone)] +pub struct DecodedEphemeral { + /// See [`ephemeral_kind`]. + pub kind: u8, + pub drive: String, + pub agent: String, + pub payload: Vec, +} + +/// Decode the payload of an EPHEMERAL frame (slice *after* the tag byte). +/// +/// Returns `None` on truncation, invalid UTF-8, or a payload beyond +/// [`max_payload_for_kind`]. Fail closed rather than attempt recovery — for +/// presence because it is the least important thing on the link, and for +/// [`ephemeral_kind::DOC`] because a half-read op is worse than a missing one: +/// the sender's next save pushes a full snapshot, so a dropped frame costs a +/// moment of divergence, not the edit. +pub fn decode_ephemeral(data: &[u8]) -> Option { + if data.len() < 3 { + return None; + } + + let kind = data[0]; + let drive_len = u16::from_be_bytes([data[1], data[2]]) as usize; + let mut cursor = 3; + + if data.len() < cursor + drive_len { + return None; + } + + let drive = std::str::from_utf8(&data[cursor..cursor + drive_len]) + .ok()? + .to_string(); + cursor += drive_len; + + if data.len() < cursor + 2 { + return None; + } + + let agent_len = u16::from_be_bytes([data[cursor], data[cursor + 1]]) as usize; + cursor += 2; + + if data.len() < cursor + agent_len { + return None; + } + + let agent = std::str::from_utf8(&data[cursor..cursor + agent_len]) + .ok()? + .to_string(); + cursor += agent_len; + + let payload = data[cursor..].to_vec(); + + if payload.len() > max_payload_for_kind(kind) { + return None; + } + + Some(DecodedEphemeral { + kind, + drive, + agent, + payload, + }) +} + /// Decode the payload of a HELLO frame (slice *after* the tag byte). /// /// Returns `None` if the frame is malformed (truncated, invalid UTF-8, or @@ -960,6 +1143,21 @@ mod tests { ); } + /// The message a table row gets when its class never reached this server. + /// Verbatim from the field, where every row of a shared table was refused + /// with it and nothing surfaced that to either person. + #[test] + fn a_missing_class_is_classified_rather_than_left_unknown() { + assert_eq!( + classify_commit_error( + "Failed getting class did:ad:ViKExaq3nm6tVE5UCaCzEQhe7lwOrd. \ + Resource not found. DID Resource did:ad:ViKExaq3nm6tVE5UCaCzEQhe7lwOrd \ + not found locally" + ), + error_code::MISSING_CLASS + ); + } + #[test] fn encode_sub_frame() { let encoded = encode_sub("did:ad:drive:abc"); @@ -967,3 +1165,94 @@ mod tests { assert_eq!(&encoded[1..], b"did:ad:drive:abc"); } } + +#[cfg(test)] +mod ephemeral_frame_tests { + use super::*; + + #[test] + fn an_ephemeral_frame_round_trips() { + let payload = vec![0xAA, 0xBB, 0x00, 0xFF]; + let frame = encode_ephemeral( + ephemeral_kind::PRESENCE, + "did:ad:drive123", + "did:ad:agent:abc", + &payload, + ); + + assert_eq!(frame[0], tag::EPHEMERAL); + + let decoded = decode_ephemeral(&frame[1..]).expect("must decode"); + assert_eq!(decoded.kind, ephemeral_kind::PRESENCE); + assert_eq!(decoded.drive, "did:ad:drive123"); + assert_eq!(decoded.agent, "did:ad:agent:abc"); + assert_eq!(decoded.payload, payload); + } + + /// Presence skips every check a write faces, so an oversized payload is + /// either a bug or an attempt to move real data down it. Drop, don't parse. + #[test] + fn an_oversized_payload_is_refused() { + let payload = vec![0u8; EPHEMERAL_MAX_PAYLOAD + 1]; + let frame = encode_ephemeral( + ephemeral_kind::PRESENCE, + "did:ad:drive123", + "did:ad:agent:abc", + &payload, + ); + + assert!(decode_ephemeral(&frame[1..]).is_none()); + } + + /// Fail closed on anything malformed: presence is the least important + /// thing on the link, so a truncated frame is dropped rather than guessed. + #[test] + fn a_truncated_frame_is_refused() { + let frame = encode_ephemeral( + ephemeral_kind::LORO, + "did:ad:drive123", + "did:ad:agent:abc", + &[1, 2, 3], + ); + + for cut in 1..frame.len().min(24) { + let _ = decode_ephemeral(&frame[1..cut]); + } + + assert!(decode_ephemeral(&[]).is_none()); + assert!(decode_ephemeral(&[0xFF]).is_none()); + } + + /// An edit in progress is content, not a cursor: a paste is one op and can + /// be far bigger than any selection. Holding it to the presence ceiling + /// would drop exactly the edits most worth relaying. + #[test] + fn an_edit_may_carry_more_than_a_cursor() { + let payload = vec![7u8; EPHEMERAL_MAX_PAYLOAD + 1]; + let frame = encode_ephemeral( + ephemeral_kind::DOC, + "did:ad:doc123", + "did:ad:agent:abc", + &payload, + ); + + let decoded = decode_ephemeral(&frame[1..]).expect("must decode"); + assert_eq!(decoded.kind, ephemeral_kind::DOC); + assert_eq!(decoded.payload, payload); + } + + /// Roomier is not unbounded — a whole document arrives as a snapshot + /// through the sync handshake, never through this channel. + #[test] + fn an_edit_past_its_own_ceiling_is_refused() { + let payload = vec![7u8; LIVE_DOC_MAX_PAYLOAD + 1]; + let frame = encode_ephemeral( + ephemeral_kind::DOC, + "did:ad:doc123", + "did:ad:agent:abc", + &payload, + ); + + assert!(decode_ephemeral(&frame[1..]).is_none()); + } +} diff --git a/lib/src/sync/replicate.rs b/lib/src/sync/replicate.rs index 9defb30e4..74883eedd 100644 --- a/lib/src/sync/replicate.rs +++ b/lib/src/sync/replicate.rs @@ -184,7 +184,11 @@ async fn drive_exchange( break; } - let entries = engine::collect_readable_snapshots(store, export_as, &pull).await; + // No paired-replica relaxation here: this is the relayed + // WebSocket path, where there is no dialled node identity to + // stand in for the owner's pairing choice. Rights only. + let entries = + engine::collect_readable_snapshots(store, export_as, &pull, None).await; if entries.is_empty() { tracing::warn!( diff --git a/lib/src/sync/tests.rs b/lib/src/sync/tests.rs index e0f350a85..854ef838c 100644 --- a/lib/src/sync/tests.rs +++ b/lib/src/sync/tests.rs @@ -66,7 +66,10 @@ mod peer_sync_tests { &peers_b, &resources_b, &db_a, - &ForAgent::Public, + // Both devices belong to the same person and the drive is private, + // so the pull has to identify itself as its owner. Asking as + // `Public` gets a correct, empty answer. + &ForAgent::AgentSubject(agent_a.subject.clone()), ) .await; @@ -159,7 +162,7 @@ mod peer_sync_tests { let db_b = Db::init_temp("undo_sync_b").await.unwrap(); db_b.load_agent_from_secret(&secret).await.unwrap(); - async fn pull_from_a(db_a: &Db, db_b: &Db, drive_a: &str) -> usize { + async fn pull_from_a(db_a: &Db, db_b: &Db, drive_a: &str, owner: &crate::Subject) -> usize { let drive_subject = crate::Subject::from_raw(drive_a, db_b.get_base_domain().as_deref()); let subjects = crate::sync::engine::collect_drive_subjects(db_b, &drive_subject).await; @@ -171,7 +174,8 @@ mod peer_sync_tests { &[], &std::collections::HashMap::new(), db_a, - &ForAgent::Public, + // Same person, private drive — see the sibling test. + &ForAgent::AgentSubject(owner.clone()), ) .await; let mut imported = 0; @@ -217,13 +221,13 @@ mod peer_sync_tests { ) .unwrap(); resource_a.save_locally(&db_a).await.unwrap(); - assert!(pull_from_a(&db_a, &db_b, &drive_a).await > 0); + assert!(pull_from_a(&db_a, &db_b, &drive_a, &agent_a.subject).await > 0); assert_eq!(stroke_count_on(&db_b, &canvas).await, 2); // A: undo last stroke, persist, replicate to B again assert!(resource_a.undo().unwrap()); resource_a.save_locally(&db_a).await.unwrap(); - pull_from_a(&db_a, &db_b, &drive_a).await; + pull_from_a(&db_a, &db_b, &drive_a, &agent_a.subject).await; assert_eq!( stroke_count_on(&db_b, &canvas).await, 1, diff --git a/lib/src/sync/ws_apply.rs b/lib/src/sync/ws_apply.rs index 968b2142d..6a0c51d1d 100644 --- a/lib/src/sync/ws_apply.rs +++ b/lib/src/sync/ws_apply.rs @@ -24,6 +24,28 @@ pub(crate) fn set_importing(v: bool) { IMPORTING.store(v, Ordering::Relaxed); } +/// The peer an import is currently being applied from, if any. +/// +/// Read synchronously by the write that emits `DbEvent::Changed`, so the event +/// carries the peer it came from. That is what makes echo suppression +/// deterministic: the alternative — a bool the live push loop checks when it +/// eventually processes the event — cannot work, because the push loop is a +/// separate task consuming a broadcast channel and may not be scheduled until +/// after the flag is cleared. Two idle nodes then trade the same snapshot +/// forever (see `peer.rs`'s live read loop). +static IMPORT_SOURCE: std::sync::Mutex> = std::sync::Mutex::new(None); + +pub(crate) fn set_import_source(source: Option) { + if let Ok(mut guard) = IMPORT_SOURCE.lock() { + *guard = source; + } +} + +/// The peer id to attribute a write to, for echo suppression. +pub fn current_import_source() -> Option { + IMPORT_SOURCE.lock().ok().and_then(|g| g.clone()) +} + /// Import a remote UPDATE frame into the local store. Trusted callers only — /// merges and persists unconditionally, with no admission check. Live-sync /// transports that receive data from a peer whose write rights aren't already diff --git a/lib/src/urls.rs b/lib/src/urls.rs index 0bbb67f43..10c500157 100644 --- a/lib/src/urls.rs +++ b/lib/src/urls.rs @@ -121,6 +121,10 @@ pub const PEER_AGENT: &str = "https://atomicdata.dev/properties/peer/agent"; pub const PEER_LAST_SEEN: &str = "https://atomicdata.dev/properties/peer/lastSeen"; /// True while the peer holds an open connection to the node reporting it. pub const PEER_LIVE: &str = "https://atomicdata.dev/properties/peer/live"; +/// Resources moved by the LAST completed sync with this peer — not a lifetime +/// total. See `KnownPeer::last_sent`. +pub const PEER_LAST_SENT: &str = "https://atomicdata.dev/properties/peer/lastSent"; +pub const PEER_LAST_RECEIVED: &str = "https://atomicdata.dev/properties/peer/lastReceived"; // ... for Collections pub const COLLECTION_PROPERTY: &str = "https://atomicdata.dev/properties/collection/property"; pub const COLLECTION_VALUE: &str = "https://atomicdata.dev/properties/collection/value"; diff --git a/planning/README.md b/planning/README.md index 0571e2f05..af0efd52f 100644 --- a/planning/README.md +++ b/planning/README.md @@ -24,6 +24,7 @@ scratch document. When a plan becomes obsolete, delete it. | [`json-schema-code-first.md`](./json-schema-code-first.md) | Proposal: JSON Schema compatible, code-first schema definitions that create local DID-backed Atomic Class and Property resources. | | [`commit-retention-and-state-certificates.md`](./commit-retention-and-state-certificates.md) | Proposal: commits remain signed write certificates, but commit retention is optional node policy. | | [`genesis-self-verifying.md`](./genesis-self-verifying.md) | Proposal: a resource carries its own genesis as an inline, binary, self-verifying certificate; verify authorship offline, no commit fetch. | +| [`deterministic-personal-drive.md`](./deterministic-personal-drive.md) | **Proposal (revised 2026-08-15):** personal drive DID is derived from the Agent key; existing `personalDrive` pointers are not identity. Repeat genesis merges via Loro. Best-effort list union only. | | [`s3-blob-storage.md`](./s3-blob-storage.md) | Pluggable blob backend design for redb/S3/hybrid storage. | | [`disk-storage-and-persistence-optimization.md`](./disk-storage-and-persistence-optimization.md) | **Proposal:** why store size + boot time degrade with age (full-snapshot writes, no auto-compaction, O(file) open fsync) and how to fix it. | | [`virtual-drive.md`](./virtual-drive.md) | Expose Atomic as a mountable filesystem (NFS / FUSE / native cloud-sync APIs); shared VFS backend trait for desktop and mobile. | diff --git a/planning/deterministic-personal-drive.md b/planning/deterministic-personal-drive.md index 98b828b52..d16c40a07 100644 --- a/planning/deterministic-personal-drive.md +++ b/planning/deterministic-personal-drive.md @@ -1,6 +1,6 @@ # Deterministic Personal Drive -> **Status:** Proposal (2026-07-26). Builds on +> **Status:** Implementing (2026-08-15). Builds on > [`genesis-self-verifying.md`](./genesis-self-verifying.md). > > Every Agent needs a personal drive — it is the home index for `drives`, @@ -9,6 +9,11 @@ > drive's subject **deterministically from the Agent's key** instead of > recording it in a pointer, so it always exists, is the same on every device, > and merges rather than conflicts. +> +> **2026-08-15 — derived subject is identity, not a fallback.** An existing +> `personalDrive` pointer or a random-DID home created on another machine does +> not stay authoritative. A best-effort list union is enough; do not design +> around preserving those drives. ## Thesis @@ -172,18 +177,31 @@ purpose string (`"atomic-personal-drive-v1"`) rather than randomness. Versioning the string leaves room to derive other per-identity singletons later without re-deriving this one. -### 3. Resolution order and migration +### 3. Derived subject is always the personal drive + +The derived DID **is** the personal drive, on every device, including accounts +that already have a `personalDrive` pointer or a random-DID home. Do not keep +the pointer as identity. Do not special-case "this account already has one." -`personalDrive` stops being authoritative but must not be ignored: +That is the point of a computed subject: sign-in on a new machine with only +the secret, old machine unavailable, still lands on the same home. Two devices +that never saw each other both materialize that subject; Loro merges when they +meet. A pointer that "wins" for existing accounts reintroduces the split this +design exists to close. -1. If the Agent has a `personalDrive` pointer, that drive **is** the personal - drive. Accounts that already have one keep it; nothing is re-derived and no - second drive appears. -2. Otherwise the derived subject is the personal drive, materialized on first - write. +`personalDrive` is deprecated. New writes may set it to the derived subject so +older clients keep resolving *something*, but readers that know the derivation +ignore the pointer. Stop writing it once those clients are gone. -New accounts can keep writing the pointer for a release or two so older clients -continue to resolve it, then stop. +**Migration is nice, not load-bearing.** If a device can see an old pointer +drive (or lists parked on the Agent), union `drives` / `favorites` / +`sharedWithMe` onto the derived drive and keep the old drive as an ordinary +workspace in that list. Do not rewrite child `parent` / `drive` stamps. Do not +block sign-in or first write on that union. If the old home is unreachable, +the derived drive starts empty — same as today — and the old one remains a +normal drive if it ever shows up. Stranded lists on a machine that never +returns are acceptable; vault / another replica is how content survives, not +the pointer. ### 4. Collapse the `drives` divergence @@ -218,11 +236,9 @@ worth landing on its own, ahead of the rest. - Should the derived drive be created eagerly at sign-in, or lazily on first write? Lazily avoids creating drives for read-only sessions; eagerly means features never have to handle "not yet materialized". -- What should happen when a device holds an offline personal drive and the - Agent *also* has a pointer to a different one? Resolution order says the - pointer wins, which could strand the offline drive's contents. Merging both - into the pointer's drive is possible but needs a defined rule. - Does anything assume a drive root's subject is unpredictable? Nothing found, but worth a sweep before implementing. -- Should other per-identity singletons (a default ontology, an inbox) use the - same derivation, given the versioned domain separator makes room for it? +- Should other per-identity singletons (a default ontology, drafts folder, AI + chats, an inbox) use the same derivation, given the versioned domain + separator makes room for it? Eager children with random DIDs would still + fork if both devices create them before sync. diff --git a/planning/pairing-ux-field-test.md b/planning/pairing-ux-field-test.md index c9305cec1..45a80ff23 100644 --- a/planning/pairing-ux-field-test.md +++ b/planning/pairing-ux-field-test.md @@ -420,6 +420,559 @@ tick, and the desktop goes through that same path. Either the outbox must persist content without the ClientDb on this platform, or the app must not claim local safety it does not have. +### M14 — Cursors crossed the peer link but the text did not (fixed) + +Remote carets never appeared between the desktop and the HA node, and the +receiving browser logged `The container does not exist in the doc` on every +keystroke the other side typed. + +The transport was not the problem, which is what made this take a while. Frames +were measured going both ways (190–200 bytes out, 371–531 in), HA rejected +nothing, and the payload survives the trip intact — it is base64, so the +`as_bytes()` / `from_utf8_lossy()` round-trip in the relay is lossless. + +The gap was that only *half* of a collaborative edit was crossing. Two separate +client channels carry an edit, and they are easy to mistake for one: + +| client call | carries | crossed the link? | +| --- | --- | --- | +| `broadcastLoroEphemeralUpdate` | `CursorEphemeralStore` bytes — cursor positions | yes | +| `broadcastLoroSyncUpdate` | `LoroDoc` ops — the actual characters | **no** | + +`broadcast_ephemeral` had exactly two callers, `Handler` +and `Handler`. `Handler` fanned out to local +websocket subscribers and stopped there. A caret pointing into text the +receiving document has never heard of is exactly what Loro refuses to place. + +**Fixed** by relaying document ops too, as a third `ephemeral_kind` (`DOC`) on +the existing frame rather than a new frame type. Two things differ from presence +and are switched on the kind byte: + +- **Admission is on write, not read.** Presence discloses who is looking at + what and authors nothing, so read is enough. Uncommitted ops are somebody + else's characters appearing in a document; a peer with read access has no + business putting text in front of an editor as though it belonged there. +- **A looser size ceiling** (1 MB vs 64 KB). A keystroke is tens of bytes but a + paste is one op, and the presence ceiling would drop exactly the edits most + worth relaying. + +Nothing is written to the store on receipt: relayed ops go to open editors and +become durable only if a local user saves, which produces a signed commit under +that user's own identity. Worth stating plainly, because it means a paired peer +can put text in a document you have open, and your save signs it. + +### M14a — what the A/B actually showed, and what the first write-up got wrong + +The first version of this note said committed state "does cross on save", which +implied a save would make an edit appear for the other user. It does not. A/B on +one variable, same page instance on both sides, only the `DOC` relay toggled: + +| | relay on | relay off | +| --- | --- | --- | +| text in the peer's **store** | yes | **yes** (`imported update`, 190ms) | +| text in the peer's **open editor** | yes, ~2.5s | **never** (still absent at 12s) | +| caret renders | yes | yes | + +So a save puts the edit on the other node's disk and no further. The open page +does not re-render — `ExternalChange` carries a Loro snapshot to subscribers, +but it does not reach a live editor. Only a reload shows it. That the caret +still rendered in the control run is what rules out a dropped websocket: the +channel was up, and content specifically was not travelling it. + +This is a better fit for the reported symptoms than the original story. It is +not a narrow race — live collaboration between two nodes did not work in an open +window at all, in either direction. It is worth re-testing "table rows do not +sync" against this: same shape (content on disk, not in the open page), so it +may be the same root cause rather than an unrelated bug. + +### M14b — a client that misses one delta stops updating, silently (fixed) + +Found while confirming the fix, and not fixed by it. The live channel is +deltas with no gap recovery. A client that misses one op — link down, or the +control run above — queues every subsequent delta as pending, because their +dependencies never arrive. The editor then silently stops updating: no error, +no indicator, just a document that quietly stops being live. + +Measured: after the control run left the HA page one op behind, the next two +relayed edits did not appear. A reload pulled a fresh snapshot and showed all of +them, and live updates resumed immediately. + +So the earlier claim that "a dropped frame costs a moment of divergence, not the +edit — the sender's next save pushes a full snapshot" is wrong for an editor +that is already open. The snapshot reaches the store; the open editor stays +stuck until someone reloads. + +**Reproduced on a real document, 2026-08-16.** Two clients on the paired nodes, +same document open. One had typed a line the other never received: + +| | line 3 of the document | +| --- | --- | +| receiving editor, open | `awd` | +| same editor, after reload | `awdawdawad oawdinawiodawoi dn` | + +The full text was on the server throughout — the reload fetched it immediately. +The open editor had diverged and stayed diverged, with no error and no +indicator, while the other client's cursor kept rendering in it the whole time. + +That last detail is why this reads to a user as "presence works but content does +not": presence is stateless, so it cannot get stuck, while content is a delta +stream that can. Both channels are up; only one of them can silently fall behind. + +**Fixed and verified in the running app (2026-08-16).** An unappliable delta +injected at a live resource on the receiving device: + +| check | result | +| --- | --- | +| `applyIncoming` outcome | `invalid`, not `applied` | +| recovery | `[Store] incomplete Loro import … fetching a full snapshot to catch up` | +| claimed the unapplied commit | no | +| document blanked or failed | no — content intact, no error | + +Not stamping `lastCommit` is what makes it work: the echo-dedup at the top of +`applyIncoming` drops updates matching the cached commit, so claiming a commit +we never applied would have discarded the very fetch issued to repair the gap. +The fix would have looked right and done nothing. + +The original design note follows, since it still describes what is needed if the +delta stream is ever made to detect gaps itself rather than inferring them from +a failed import: + +Fixing it needs the receiver to notice a version gap and ask for a snapshot, +rather than assuming deltas always arrive in order. The `SYNC_VV` handshake +already does exactly this at connect time — it is the reconnect/gap case that +has no equivalent. + +### M15 — A peer's new row arrives without its contents (fixed) + +Tested because "adding things to a table does not sync" had the same shape as +the document bug fixed above. It is a real bug, but a narrower one than the +first version of this note claimed. + +**The first measurement was invalid, and the correction is the interesting +part.** Row added on the desktop, table open on both nodes: the peer's page +received *nothing at all* — zero websocket frames, verified with a hook on the +live socket and a positive control (a document edit on the same page and socket +captured two frames). That looked conclusive. + +It was an artefact of the test account. The client subscribes drive-wide via +`subscribeToDrive()` → `encodeSub(store.getDrive())`, and this session's drive +was `https://atomic.ontola.io` — the server root — rather than the DID drive, +because the account was created through an invite and never had a drive set. So +`Handler`'s `owner.is_within_drive(...)` check correctly refused +to fan out, and "zero frames" was the server behaving properly on a wrongly +subscribed connection. The giveaway was in view the whole time: the sidebar +showed `/` rather than the drive name, and `/search?parents=https://atomic.ontola.io` +was returning 500. + +With the drive set correctly, the same test gives a different and much more +specific result: + +| | desktop | HA, page open | HA, after reload | +| --- | --- | --- | --- | +| row exists | yes | **yes** — row 3 renders, footer count 2 → 3 | yes | +| row's `name` | `RowGamma-5514` | **empty** | `RowGamma-5514` | + +So membership propagates live and content does not. The peer's page learns a +third row exists, renders it, and leaves the cell blank until a reload. + +That rules out the drive-fanout path being dead, which is what the first version +of this note asserted. What it does not yet explain is why the `UPDATE` frame +that carries the row — `Handler` encodes a full Loro snapshot +plus `commit_id` — leaves the client with a resource that has no `name`. Two +candidates worth separating: the frame for the row subject is never sent (only +the parent table's own change is, which alone would move the count), or it is +sent and the client stores it in a way the table's cell does not read. + +### M15a — corrected again: the data arrives, the table does not re-render + +The version above said "membership propagates live and content does not". Also +wrong. Content propagates. Read directly out of the live store on the receiving +page, while those same cells rendered blank: + +| subject | `name` | `isA` | `loading` | `error` | +| --- | --- | --- | --- | --- | +| `ZkDx6E0iQs6…` | `RowEta-FLAGS-7791` | set | false | none | +| `f3R9qMqKjOc…` | `RowTheta-CONSOLE-6203` | set | false | none | + +Complete, class set, not loading, no error. So the row crosses the peer link, +imports cleanly, and is in the store with its name — and the table shows an empty +cell. **This is a render bug, not a sync bug.** Everything below the UI is +working. + +Supporting detail from a frame capture on the receiving socket: each new row +arrives as one `SNAPSHOT|PUSH` frame followed by several `HAS_COMMIT_ID|PUSH` +deltas from the commit path, and nothing calls `failResource` — consistent with +imports reporting `complete`, which the store contents confirm. + +**Two dead ends recorded so the next person does not repeat them.** Both looked +compelling and both were aimed at the wrong layer: + +1. *The server mislabels the frame.* `Handler` sent a payload read + from `Tree::LoroSnapshots` without `flags::SNAPSHOT`, unlike the normal push + path. Fixed, verified on the wire (`flags: 5`), symptom unchanged. The fix is + kept because labelling a snapshot as a snapshot is correct on its own terms, + not because it fixes anything here. +2. *The client ignores `SNAPSHOT` on the push branch.* Real — `applyIncoming` is + called without `replaceLoroDocsFromRemote` there, only on the pending-GET + branch. But imports complete and resources are correct without it, so it is + not implicated in this symptom. + +**Fixed and verified end-to-end (2026-08-16).** A row created on the paired node +appeared on the other device with its name, live, no reload — `aria-setsize` +9 → 10, footer count 8 → 9. + +The cause was not the cell at all. `memberCount` is frozen at the count the +collection had when it first became ready, deliberately, so a materialising +session row never remounts and drops keystrokes. Rows below that index render as +collection members; rows above come from `newRowSubjects`. A row from a peer is +neither — it grows the collection but not the baseline, and it is not one of this +session's drafts, so nothing draws it. `totalMembers` 8 against `aria-setsize` 5. +The baseline now accounts for what this session contributed and lets anything +beyond that raise it. + +Worth recording that the earlier reading here — "the cell is empty" — was itself +wrong. Five named rows, one empty row and a footer count of eight meant the three +new rows were not being rendered at all; the single empty row was the trailing +placeholder. Reading "empty cell" instead of "missing row" sent the next two +hours at the wrong layer. +Prime suspect is the React Compiler memoisation pitfall this repo has hit before +— reading `resource.get(...)` into a variable during render memoises on the proxy +identity, and internal mutation does not invalidate it. A reload rebuilds the +component tree, which is exactly why reloading "fixes" it. + +Cheapest harness for the next attempt: two atomic-server instances on one +machine rather than deploying to the Pi — `cargo build` instead of an 11-minute +cross-build. One constraint: do not start a second vite dev server, as two +wuchale extractors race on `src/locales/*.po` and corrupt the catalog; have the +second instance serve built assets. + +Related to M14b: both are a peer's changes reaching an open page only partly. + +### M16 — A connect that never opened pins the auth flag forever (fixed) + +The desktop app and its embedded server start together, and the webview is ready +first. Any fetch issued in that window fails, and the app settles on: + +> Could not reach the server +> `Offline: resource not available locally. Reconnect to fetch.` + +It then stays there. Measured: the server bound at 16:23:40 and answered `HTTP +200 in 1.2ms`, while the webview still reported the error screen two minutes +later. Nothing re-fetches; the only way out is the Retry button. + +Surfaced constantly during this session because `cargo tauri dev` watches +`desktop/`, `server/` **and** `lib/`, so every Rust edit rebuilds and restarts +the app — but the packaged app has the same shape, and a cold start on a slow +machine lands a user on a dead-end error for a resource that is about to be +available. + +The wording compounds it. "Offline" and "Reconnect to fetch" describe a network +that is gone. The machine is fine, the server is fine, and the resource is on +disk — it simply was not listening yet at the instant the page asked. Someone +reading this checks their wifi. + +**Two thirds of the first write-up here was wrong, and the correction is the +finding.** It said the reconnect never runs. It does: backoff 2/4/8/16/30s, +indefinitely, and a socket killed *after* opening reconnects within a minute. +One of the runs that seemed to prove otherwise was C3 in disguise — three app +instances alive at once, the oldest holding the redb lock +(`Failed to create redb … Database already open`), so there was no server to +reach and the retries were correctly failing. + +**The real mechanism.** `openPromise` only ever resolved: + +```js +this.openPromise = new Promise(resolve => { + ws.addEventListener('open', () => { … resolve(); }); +}); +``` + +A socket that dies before opening leaves it pending forever. `authenticate()` +awaits it while holding `isAuthenticating`, and the `finally` that clears that +flag is downstream of the await — so the flag is pinned for the life of the +client. The retry then works perfectly and makes no difference: the new socket +opens, its `authenticate()` takes the `if (this.isAuthenticating) await +this.authPromise` branch onto the dead promise, and waits forever. Auth never +completes, `reportConnected(true)` never fires, and every `ws.fetch` hangs +because `REQUEST_TIMEOUT` only starts after auth. + +That is why Retry does nothing (it re-issues the fetch, which queues behind the +same dead auth), why a reload fixes it (fresh client), and why the app can show +"Offline" while holding an ESTABLISHED socket to a server answering in ~1ms. + +**Fixed and verified end-to-end.** `openPromise` now rejects when a socket closes +before opening, so the stuck auth settles and the flag clears. Same cold-start +race, measured before and after: + +| | before | after | +| --- | --- | --- | +| WS connect | `close code=1006 opened=false` | same | +| server binds | 1s later | 1s later | +| sync status | **Offline, indefinitely** | **Connected** | +| error screen | shown until reload | none | + +Unit tests fail without the fix; the second one times out at 5002ms, which is +the deadlock reproducing. + +Three things to separate when fixing: a connect that never opened must still +schedule a retry (this is boot ordering, not connectivity); Retry should re-establish +the connection rather than only re-issue the request; and a queued fetch needs a +timeout that runs whether or not auth ever completed, so it can fail honestly +instead of hanging. The message should also stop claiming the network is down when +what it observed was one failed connect. + +Same family as M8 and the false-offline work earlier in this note: a transient +condition recorded as a permanent verdict. + +### M13 — A newly created resource sorts to the top of the sidebar (fixed) + +`RelayTableTest`, created during the M10 work, renders first in the drive tree +rather than last: + +``` +Joeps drijf | RelayTableTest | D | Tekenign | Ontology | Hey wereld | ChatRoom | ... +``` + +Noted because it first looked like the resource was missing from the device that +created it, which would have been serious. It is not: the store holds it with +`loading: false` and no error, and it is in the DOM — just in an unexpected +position, so the eye slides past it in a list you know the shape of. + +**Fixed.** `sortOrder` and `createdAt` share a number space on purpose — +drag-and-drop mints a fractional key between two neighbours' keys and the server +sorts by the same fallback. A member carrying neither fell back to its array +index, which is not in that space at all: an index of 3 against timestamps +around 1.7e12 sorts to the very front. Measured on the affected drive, +`RelayTableTest` and `Tekenign` had neither property. + +Keyless members now inherit the preceding member's key, so they stay where the +server put them. Verified in the running app: the tree ends +"Meetings | Tekenign | Ontology | RelayTableTest" where it previously began +"RelayTableTest | Tekenign". + +(This session's findings are numbered M13-M16: `develop` independently used +M9-M12 for different findings while this branch was open, and the note now +carries both sets.) + +## Two-person session, 2026-08-17 + +Joep invited a colleague who also holds a legacy secret. Four reports, which +turned into five findings once the fourth was traced. + +**State of play** + +| | status | +| --- | --- | +| M17 invite does not switch drive | **fixed** | +| M18 agent names do not propagate | **fixed** | +| M19 "Show profile" opens the wrong resource | **resolved by M18** — the page was right, its contents were a stub | +| M20 colleague sees no rows | explained by M21, not its own bug | +| M21 rows refused because the class is missing | **cause fixed and deployed**, origin still unexplained | +| M22 read-only invitee gets a stuck outbox entry | **fixed** | +| M23 sign-in on a device with no data opens an empty workspace | **fixed** | + +Two fixes went in for M21 and are on the branch: + +- `fix(validation): an unknown class must not reject the write` — the write is no + longer refused for a reason the writer cannot act on. +- `fix(outbox): stop discarding table rows in silence when the class is missing` + — if a commit IS refused structurally, it blocks visibly instead of retrying + forever in silence. + +Neither was deployed at the time of writing, so a console from before the next +deploy still shows `errorCode: 0` and the old behaviour. The two stuck rows +("Henk", "Blaa") are still in the reporter's outbox and should drain on their own +once the server carries the validation fix — no re-typing — which is the cleanest +single check that it worked. + +**What is still not explained**, and matters most for the next session: the table +reached the server and its row class did not, on the same drive. M21 records the +parenting asymmetry that probably causes it, as a hypothesis with the test that +would settle it. The fixes above mean this no longer costs data; they do not mean +it is understood. + +Joep invited a colleague who also holds a legacy secret. Four distinct problems, +recorded here so each can be reproduced and fixed on its own rather than as one +vague "sharing is broken". + +### M17 — Accepting an invite shows the drive but does not switch to it (open) + +The invite link opened the shared drive's contents, while the sidebar kept +showing the accepting user's own private drive. + +**Already reproduced, by accident, during the M15 work.** After accepting the +same invite in a fresh browser, `localStorage['drive']` held +`"https://atomic.ontola.io"` — the server root — rather than the target's +`did:ad:W2Q3m…`. Two visible consequences: the sidebar rendered `/` instead of +the drive name, and `/search?parents=https://atomic.ontola.io` returned 500, +because the server root is not a resource. Setting the key by hand and reloading +fixed everything. + +That also means the invite path leaves the client subscribed drive-wide to the +WRONG drive, which is its own class of bug: `subscribeToDrive()` sends +`encodeSub(store.getDrive())`, so nothing on the shared drive fans out live to +that session. It is very likely why the fourth item below was invisible even +before the deploy gap. + +**Reproduce:** accept an invite as a second agent, then read +`localStorage['drive']`. It should be the invite's target. + +**First place to look:** whatever the invite accept flow calls after minting the +agent — it navigates to the drive without going through the same drive-setting +path the switcher uses. Related to M6, which is the same class: what "current +drive" means is decided in more than one place. + +### M18 — Agent names do not propagate, except through presence (open) + +Both users' names migrated correctly from their legacy agents, and presence +shows them correctly. Everywhere else — chatroom avatars, for example — the +other person's name does not appear or does not update live. + +Presence is the odd one out because it carries the name IN the payload: the +agent announces itself, so the receiver never has to resolve anything. Every +other surface resolves the agent subject to a resource and reads `name` off it, +which needs that resource to be fetchable and to update live. + +An agent resource lives on no drive. So it is outside the drive-wide +subscription that everything else relies on, which fits the symptom exactly: +correct after a fetch, never updated after that, and missing entirely where the +fetch is not attempted. + +**Worth deciding before coding**, and Joep's instinct is the right shape: agent +identity needs a home. Options to weigh — a per-drive "profile" resource (the +name as it appears on THIS drive, ACL'd with the drive), a server-level profile +collection, or making agent resources first-class subscribable objects. The +first keeps the existing rights model; the last is the smallest code change and +the largest privacy question, since it makes every agent readable to anyone who +can name it. + +**Reproduce:** two agents in one chatroom, rename one, watch the other's UI. + +### M19 — "Show profile" opens the wrong resource (open) + +The avatar menu's "Show profile" opens the *following* resource instead of the +agent's profile. + +`PresenceAvatarMenu.tsx:48` does `navigate(constructOpenURL(agentSubject))`, +which looks right — so the likely fault is what `agentSubject` holds at that +point, not the navigation. The same string is also used by +`FollowStatus.tsx`, so check which component actually rendered the clicked menu +before assuming. + +**Reproduce:** open the sidebar avatar menu for another live session, click Show +profile, compare the opened subject against that agent's subject. + +### M20 — A colleague's browser does not show rows this session created (explained by M21) + +**Cause found — see M21.** The rows were never saved: the server rejects every +row commit because the table's class is missing there. Nothing was going to +show them, on any bundle. The deploy-gap reasoning below was wrong, and it was +wrong in a way worth noticing — it explained the symptom plausibly enough that +I deployed before checking whether the writes had succeeded at all. + +The colleague loads the app from the Home Assistant add-on, whose binary — and +therefore whose embedded bundle — was built at 18:35. Every client-side fix from +this session landed at 20:21-20:22: + +| fix | lands | +| --- | --- | +| rows that arrive from a peer are drawn (M15) | 20:22 | +| a live update that cannot apply is recovered (M14b) | 20:22 | +| a connect that never opened no longer pins auth (M16) | 20:22 | +| keyless children stop sorting to the top (M13) | 20:22 | + +M15 is exactly this symptom, measured and fixed on this branch. So the first +step is to redeploy HA from a build that contains it and retest — not to open a +new investigation. + +If it still reproduces after that deploy, the next suspect is M17: a session +subscribed to the wrong drive receives no live fan-out for the shared one. + +### M21 — Rows are silently discarded when their class is missing server-side (cause fixed; origin open) + +This supersedes M20's "probably the deploy gap" guess. The rows never reached +anyone because they were never saved. From the console of the person who typed +them, repeating for every row: + +``` +[postCommit] Server error: Failed getting class did:ad:ViKExaq3nm6t… not found locally +[Outbox] drain failed for subject: did:ad:9SWmXNZ… (name: "Henk") +[Outbox] drain failed for subject: did:ad:I4h_29uw… (name: "Blaa") +``` + +Confirmed against the server: that class returns `Resource not found`, not +`Unauthorized`, so it is genuinely absent rather than hidden by rights. The +table resource itself IS there. So a table exists on the server whose row class +does not, and every row commit referencing it is rejected. + +**The failure is silent, and that is the part to fix first.** The row appears in +the grid as you type it. Nothing marks it unsaved, nothing surfaces the rejection, +and the outbox retries the same commit forever. The only way to find out is to +open the console. Two people spent a session believing sync was broken when in +fact their writes were being refused one layer down and the UI was telling them +everything was fine. + +Note the outbox HAS the vocabulary for this: `error_code::MISSING_REQUIRED_PROPERTY` +and friends classify terminal errors so an entry can stop retrying and stay +visible. "Class not found" is not in that registry, so it falls through to +retry-forever with no surface. + +**Two separate fixes, both now made:** + +1. *Visible instead of silent.* `MISSING_CLASS` added to the shared error + registry, the server's "Failed getting class " classified into it, + and the client matching on both code and message so an older server behaves + the same. Blocking rather than terminal: the row is well-formed and would + apply once the class exists, so discarding it would throw away a write the + user believes they made. +2. *Not refused in the first place.* `Resource::get_classes` had always + documented "Returns an empty vector if there are no classes found", but a `?` + made one unresolvable class abort the call — and `check_required_props` runs + it on every commit, so an unknown class became a rejected write. It now skips, + with a warning naming the class and the unvalidated resource. + + The trade: required-property validation does not run for classes this store + cannot see. A store cannot enforce a contract it does not hold, the same write + already succeeds with no class at all, and this is data integrity rather than + access control — rights are enforced elsewhere and unaffected. + +**Still to do:** whatever makes a table's class reachable wherever the table is. +Creating a table on one node and using it on another should not depend on the +class having travelled by luck. The fixes above stop that costing data; they do +not make the class arrive. + +**Where the class goes, and why that is the likely asymmetry.** The two +resources are NOT parented alike. `NewTableDialog` puts the table under the +folder the user picked, but the row class under +`resolveOntologyParent(store, driveSubject)` — the drive's `defaultOntology`, +falling back to the drive itself. So creating one table writes to two different +places, with two different sets of rights. + +The rows in the field case carry `drive = did:ad:kgOPf15k…`, which is a drive +shared WITH that user, not their own. That fits: the table landed somewhere they +could write, and the class had to go into that drive's ontology, which they may +not be able to write. The table commit succeeds, the class commit does not, and +the table is left naming a `classtype` the server has never heard of. + +Consistent with what the server reports now: the table returns +`not publicly readable` (present, private) while the class returns +`not found` (absent). + +**Not yet confirmed** — this is a hypothesis with the shape of the evidence +behind it, not a diagnosis. What would settle it: create a table on a drive +shared with you and watch the console at CREATION time, not at row time. If a +class commit is rejected there, this is it. If the class commit succeeds and the +class still never appears, the fault is in sync rather than in rights, and the +ontology-parenting is a red herring. + +Worth noting either way: with the outbox fix above, a rejected class commit is +now blocking-and-visible rather than silent, so the next attempt should say what +went wrong instead of leaving it to be reconstructed from a console. + +**Reproduce:** create a table on one node, add a row from a browser talking to +another node, watch the console. Expect `Failed getting class`, a row that looks +saved, and an outbox that never drains. + ### P1 — Proposal: show discovered-but-unpaired nodes on the Sync page The Sync page shows two lists, and neither is discovery: @@ -728,3 +1281,527 @@ Two things would have caught it immediately: Worth keeping in mind for the Android app too, where the same "install an old bundle, reason from current source" trap is one `adb install` away. + +### Reproduced locally, 2026-08-17 afternoon + +Two scratch agents (Alice, Bob) against one local server, in two browser +origins so each has its own store. Everything below is from that run, not from +reading the code. + +**M17 — fixed.** Accepting an invite left `drive` pointing at the invitee's own +private drive: `persistAgentAfterInvite` returned only the personal drive, the +caller activated it, and `goToRedirect` activated it a second time after the +redirect had already happened. The drive the invited resource lives on was +already being computed one line away, to bookmark it in the switcher — it was +just discarded. Now returned and preferred, with the personal drive as the +fallback (which is the new-agent case, where the current drive would otherwise +still be `baseURL`). Verified: after accepting, `drive` is the shared drive and +the sidebar reads its name. The e2e invite test now asserts +`current-drive-title`, which it did not before — the old assertion passed with +the bug, because the *page* showed the drive all along. + +This cost more than a wrong label. The drive-wide subscription follows the +current drive, so an invitee sat on a shared drive with no live fan-out, and +never joined its presence channel. + +**M18 — root cause found.** Not a resolution or live-update problem. Alice +asking the server for Bob's agent gets: + + Unauthorized. No .../properties/read right has been found for + did:ad:agent:d9Im… in this resource or its parents + +Agent resources are created with no read grant for anyone but their owner, and +they live on no drive, so no drive-level grant reaches them either. Every +consumer that resolves an agent to show a name — chat avatars, member lists, +"Show profile" — is reading a resource it is not allowed to read. Presence is +the sole exception because the name travels inside the presence payload. + +The client hides this instead of surfacing it: it renders a locally-derived +stub with `isA: agent`, the `publicKey` recovered from the DID suffix, and a +`createdAt` of *now*. That stub looks renderable, so the local-first fallback +never retries the server. Hence "names don't propagate" rather than "you can't +read this agent". + +Fixing it is a design decision, not a patch — see the options recorded with the +question to Joep. Whatever we choose, the client should stop fabricating a +stub that is indistinguishable from a real profile. + +**M19 — not what it looked like.** `Show profile` navigates to +`constructOpenURL(agentSubject)`, which resolves to `/app/show?subject=` and renders the agent page. Verified working for an agent you *can* read +(your own). For anyone else you land on the M18 stub: no name, no personal +drive, a fabricated timestamp. Keeping M19 open only to confirm against the +reporter's exact wording ("opens the following resource"), since the +navigation target itself is correct. + +**M22 — new: a read-only invitee gets a permanently stuck outbox entry.** +Right after accepting a *view* invite, Bob's client signs a commit against the +shared drive and the server refuses it, correctly: + + Unauthorized. No .../properties/write right ... for did:ad:agent:WA6I… + +The commit carries the whole drive — `name`, `isA`, `read` (both agents), +`write`, `genesis` — which is what a Loro export from an empty version vector +looks like. So this is not a stray edit: the drive's doc was hydrated locally +through operations that count as *local*, and the outbox then tried to push +them. + +Traced far enough to be sure of the shape: `markDirty` for the drive is called +from the `subscribeLocalUpdates` handler (the stack runs through loro wasm), so +a genuine local op is being applied to a doc the invitee only has read on. It +only happens on the resource's FIRST hydration in that store — visiting the +same drive afterwards produces only `setLastCommitValue`, which is correctly +exempt. `isOwnedSubject` returns true for every `did:` subject, so "ours to +POST" is decided by namespace and never by rights. + +The retries are bounded (`BLOCK_AFTER_FAILURES`), so it parks rather than +hammers — but it parks as a blocked entry, which is why an invitee who has +written nothing still sees "Changes pending" forever. Not yet fixed; the cold +repro to finish it is a second drive + invite with `Resource.prototype` +instrumented before accepting. + +### M22, root cause and fix + +The heal pass in `Resource.getLoroDoc()` writes every JSON-AD propval the +incoming snapshot lacks into the CRDT. `createdBy` is always one of those: the +server derives it from the genesis certificate and ships it as a propval, but +never stores it in the document. So every hydration wrote it — a LOCAL +operation — which marked the subject dirty and signed a commit for a value the +client never authored. + +`lastCommit` and `createdAt` were already exempt for exactly this reason. +`createdBy` now joins them in one named set, `DERIVED_BY_SERVER`. They stay in +the read cache, so `get()` and JSON-AD round-trips are unchanged. + +Verified by repeating the identical action four times with two agents on one +server: the first three accepts (before the fix) each queued a commit for the +shared drive and collected 401s; the fourth (after) left the outbox empty. + +Worth noting what this was costing on resources you CAN write: a redundant +commit on every hydration, which is part of the write amplification we have +been chasing separately. + +### M18, the shape of a fix + +Decision taken: a **public Profile resource per agent**. The agent resource +keeps the private things — keys, the `personalDrive` pointer — and a separate, +publicly readable resource carries the display name (avatar later). One grant +fixes chat avatars, member lists and "Show profile" together, and publishes +nothing but the name the user chose to show. + +The open question is **discovery**: given an agent DID, how does another client +find that agent's profile without reading the agent? + +- *Deterministic DID* does not work. A resource's DID is the owner's signature + over its genesis certificate, so only the owner can compute it. +- *Well-known HTTP subject* (`/agents//profile`) is + constructible by anyone from the DID suffix and needs no index or endpoint. + It reintroduces an HTTP subject in a codebase deliberately moving to DIDs. +- *Server-side index* — the server records `profileOf` on apply and answers + `GET /profile?agent=`. Cleanest fit with DID subjects; costs an + endpoint, an index and a migration for existing agents. + +Recommendation: the server-side index. It is the only one that stays honest +about subjects, and the endpoint is small. + +Whichever we pick, the client should stop fabricating an agent stub that is +indistinguishable from a real profile. Today an unreadable agent renders with +`isA: agent`, the `publicKey` recovered from the DID suffix and a `createdAt` +of *now* — which is why this read as "names don't update" rather than "you are +not allowed to read this". + +### M18, fixed: agents are public + +Joep's call, and it dissolves the discovery problem the Profile design ran +into: if the agent resource is readable, nothing needs to find a second +resource. No new class, no index, no migration. + +Two halves, and the second is the one that would have been easy to miss. + +**Server.** Anyone may READ an agent; writing stays owner-only, asserted in +the same test. This is stated once in `check_rights_impl` rather than granted +per agent at creation, so it covers every agent that already exists — +including legacy `internal:/agents/…` spellings, which normalise to the same +DID. + +**Client.** Opening the read on the server changes nothing by itself, because +every client already holds a cached stub for the agents it has seen and has +stopped asking for them. "Trust the local copy while online" is sound for +anything under a drive — the drive's SUB delivers deltas — but agents are +under no drive, so nothing ever refreshes them. A cached agent could stay +wrong forever. Now re-checked once per agent per session, then trusted. + +Verified with two agents on one server: the presence avatar's label goes from +`did:ad:agent:WA6IORw…` to `Bob`, and "Show profile" renders a real profile, +including Bob's real creation time — where the synthesized stub reported +*now*. + +**M19 falls out of this.** The navigation was always correct; the page just +had nothing in it. Closing it with M18. + +One loose end worth a look: the profile now shows `personal-drive: Server +error` to someone who can read the agent but not the drive it points at. The +pointer is visible (that is the consequence of making agents public) but a +resource you may not read should not render as an error. + +## Running the pipeline locally, 2026-08-17 evening + +Every CI run on this branch sat queued for most of the day, so nothing had +validated it. Running the pipeline locally found two Rust failures CI would +have reported first, and then a much longer thread. + +### Two peer-sync tests were failing on the branch — fixed + +`two_devices_sync_via_engine` and `undo_syncs_to_peer_via_engine` pull a +private drive as `ForAgent::Public` and assert something comes back. +`69fc5a7e` ("always hand a peer your own agent resource on connect") +tightened what an unidentified pull sees; the iroh end-to-end test was +updated with it and these two were missed. Both model two devices of the +SAME person — the test asserts the agents share an `initialDrive` — so the +owner is who the pull should claim to be. + +### Repeat materialization merges, flagged genesis or not — fixed + +Joep's call: deterministic personal drives mean two devices mint the same DID +for the same drive, so a second materialization is normal and should merge. +The branch already did that via `repeat_genesis_is_mergeable` — but only for +commits declaring `is_genesis`. Whether a second device's from-scratch doc +drains as a genesis or as an ordinary commit is an accident of which client +path exported it, and the ordinary path hit the causality guard: creation +defaults, every value losing to stored state, read as silent data loss and +refused. Observed live as a 500 loop on the owner's own home drive from their +second browser. + +The cert decides, not the flag. It must verify against the subject AND name +the signer — and for a `did:ad:` subject the DID *is* the signature over that +cert, so a repeat can only be the same author. Nothing an attacker can reach. + +### M23 — signing in on a device with no data opens an empty workspace + +`sign-in-without-data.spec.ts` fails on the branch, and on CI. It encodes: +signing in with a secret whose workspace this device has never held must stop +and say so, not present an empty workspace under your name. + +Cause, established by instrumenting `localStorage.setItem('drive')` with stack +traces during a live sign-in — three layers, each hiding the next: + +1. `0409e86e` added `store.ensurePersonalDrive()` to sign-in, so **the drive + resource always exists**, on every device, including one holding none of + the account's data. Every "do I have my data?" check reads that as yes. + Proven by stubbing `ensurePersonalDrive` at runtime: the card appears. +2. Fixing only the sign-in gate does nothing. `ConnectDeviceStep`'s arrival + poll (`driveIsHere`) asks the same question again and calls `onConnected`, + which navigates straight back out of the card. The traces show `""` written + by the sign-in flow, then the drive DID written by `onConnected`. +3. Switching the predicate to "the drive has children" **also** fails: + `createDrive` creates the drive's default Ontology, so a freshly + materialized drive has a child immediately. + +**A fix that looked right and was not.** Predicate = "has a child that isn't +the default ontology". The two target specs passed. The full suite went from +161 passed / 7 failed to **60 passed / 106 failed**, with 11 specs parked on +the connect-device card: that predicate asks the server's query index, on the +sign-in path, and an index that isn't warm answers "empty" for a drive that +does have data. Reverted. + +**The approach that should work**: ask the local ClientDb whether this device +held the drive BEFORE this sign-in materialized it. No server round trip, no +search index — that dependency is exactly what broke the attempt above. Both +consumers already funnel through `deviceHasDriveData`, so it is one edit. + +### Baseline to measure against + +CI on the current HEAD: `fmt`, `clippy`, `nextest`, `pnpm build`, `pnpm lint` +and `pnpm test` all pass; it fails only in e2e, with **3 failed and 1 flaky** +in the shard that failed and 40 passed in the other. Local full-suite is 161 +passed / 7 failed. These are different measurements — CI shards, and CI serves +the production bundle behind a service worker while local runs hit the vite +dev server. `plugin › install a plugin` fails locally on every run and does +not appear in CI's list at all, which is that divergence, not a bug. + +Rust is 570/570 locally. + +### Two ways to waste an hour, both hit today + +- The browser bundle resolves `@tomic/lib` to `lib/dist`. Editing + `browser/lib/src` and reloading proves nothing until `pnpm build` runs in + `browser/lib` — HMR does not cover it. +- `cargo build -p atomic-server --features db-redb` exits **0** while building + nothing: that feature belongs to `atomic_lib`, not the server. The stale + binary keeps serving. + +Both produced confident, wrong verifications. Check the artifact's mtime +before trusting a result — and prefer a runtime experiment (stub the function, +hook the setter) over reading code to decide what is happening. + +## M23 fixed — ask before writing, 2026-08-17 late + +The predicate was never the problem. The **order** was. + +`0409e86e` put `store.ensurePersonalDrive()` ahead of the gate, so by the time +anything asked "does this device have my data?", the drive resource existed — +because sign-in had just written it. Every check downstream reads that as yes. +The two layers underneath it were consequences, not separate bugs: +`ConnectDeviceStep`'s arrival poll asks the same question and navigates back +out of the card, and `createDrive` also makes the default Ontology so "the +drive has children" is true as well. Nothing writes a decoy drive now, so +there is nothing for either to find, and both dissolve untouched. + +The materialization stays — on the branch where the answer is yes. There it is +*nearly* a no-op, and the "nearly" is the point: `createDrive`'s existing-drive +path still seeds the switcher list and calls `maybeMigrateOldPersonalDrive`, +which is what adopts an older, pre-derivation home's `drives`, `sharedWithMe` +and `favorites` lists onto the derived one. + +Which forced a second change. A pre-derivation account's data is not under the +derived home yet — the adoption above is what moves it — so asking only about +the derived subject reports "no data" for someone whose workspace is sitting on +the server they just authenticated against. Telling them their data is on +another device would be false, and it would skip the adoption that makes it +true. So the gate also asks about `agent.initialDrive`, which the old secret +carries. This is not a widening of what counts as "your data": it is the same +reachability question, asked about the drive the secret was actually made for. +Secrets minted after derivation carry no `initialDrive`, so a stranger's +sign-in is unaffected — which is why the two target specs still pass. + +Worth recording that the earlier attempt failed for a reason that had nothing +to do with the diagnosis. "Has a child that isn't the default ontology" is a +*correct* description of a materialized-but-empty drive. It broke the suite +because of where it asked: the server's query index, on the sign-in path, which +answers "empty" for a populated drive whenever it isn't warm. A right predicate +in the wrong place cost 100 specs. This change adds no query the plain +reachability check wasn't already making. + +### Measured, not assumed + +Local full suite on a fresh store: **162 passed / 6 failed**, against the +161/7 baseline, with both target specs flipping green. + +Every one of the six is attributed by A/B — same specs, fresh store, with the +change and with it reverted: + +| spec | without | with | +| --- | --- | --- | +| `sign-in-without-data` (×2) | fail | **pass** | +| `chatroom` | fail | fail | +| `offline-create-then-online` | fail | fail | +| `plugin › install a plugin` | fail | fail | +| `template › apply sveltekit template` | fail | fail | +| `delete resource` | pass in isolation | pass in isolation | +| `sign in with secret, edit profile, sign out` | pass in isolation | pass in isolation | + +The last two fail only under full-suite load and pass alone — `delete resource` +carries a comment about exactly that (a success toast that expires in ~2s, so +under load it is gone before the first poll). `apply sveltekit template` fails +on a SvelteKit **build** error, before the browser is involved at all. + +Unit tests 591/591. + +**A measurement trap worth the note.** The first full run of this change +reported 8 failures; four of them evaporated on a fresh store. The e2e store +had reached 141MB — right at the ~150MB line where specs start failing on +timing rather than on bugs. `du -sm .e2e-store` before believing a failure +list, and re-run with `--fresh` before believing a regression. + +## Getting CI green — and a local harness that can actually see it + +Merging needs green, and "attributed as pre-existing" is not green. CI's last +real run (4 shards, retries on) had **five hard failures**: the two +`sign-in-without-data` specs, `chatroom`, `offline-create-then-online` and +`quick-add`. Four others — `aggregates`, `documents`, `offline-chatroom`, +`perf-sidebar-reload` — fail and pass on retry, so they do not fail the +pipeline. `plugin › install a plugin` and `apply sveltekit template` fail +locally and appear nowhere in CI's list. + +### The thing that made these debuggable + +**Half the suite never touches the vite dev server.** Any page reached through +a server-issued URL — an invite link, `/app/dev-drive` — loads the SPA that +`atomic-server` has embedded in its own binary. `FRONTEND_URL` only points the +*first* page. So `chatroom` opens page1 on vite and page2 on the production +bundle, which is why it failed identically on CI and locally while three +separate source fixes changed nothing at all: none of them were ever loaded. + +Two settings reproduce CI's serving path locally: + + VITE_E2E=true cargo build -p atomic-server # /app/dev-drive only exists + # in a prod build with this + FRONTEND_URL=http://localhost:9886 SERVER_URL=http://localhost:9886 \ + npx playwright test + +Without `VITE_E2E` the dev routes are absent from the bundle and every spec +dies in `before()` on a 30s `waitForURL` — which looks nothing like its cause. +With them, the suite runs against the same artifact CI serves. This retires +"local and CI serve different things" as a standing excuse: they no longer +have to. + +The cost is that a source change now needs a ~50s rebuild to reach page2. That +is the price of testing the artifact rather than the sources. + +### chatroom — the invite flow threw away the home drive + +Root-caused with `window.store` diagnostics at the failing assertion, which +reported the drive present, `sharedWithMe` holding the chatroom, and: + + Cannot derive this agent's personal drive: its key signs + non-deterministically and no derived subject was stored. + +A drive's subject IS its owner's signature over its genesis cert, and a +WebCrypto key signs differently every time — so it can only be computed while +the raw private key is in hand. `Agent.fromSecret` does that and stores it. +The invite path had the raw key, built a secret from it, then discarded the +result twice: it constructed the Agent without `personalDrive`, and persisted +through the keypair overload of `saveAgent`, which writes that field as +`undefined` because it has no secret to re-derive from. + +After that the derivation is impossible forever. `usePersonalDrive` fell back +to `initialDrive`, a new agent has none, and `usePersonalDriveList` returned +an empty list — the panel renders *nothing* when the list is empty, so a +resolution failure looked exactly like "nobody shared anything with you". + +Fixed by deriving it at both agent-minting sites and storing the secret rather +than the keypair. `persistAgentAfterInvite` also stopped minting a drive of +its own: it wrote the home index to a drive the sidebar never reads, since the +sidebar resolves the home from the key and not from the Agent's pointer. + +### offline-create-then-online — a test that predates derived homes + +`store.createDrive('Offline-Created Drive', …)` never said `personal: false`, +and `personal` defaults to true. A personal drive is now the derived home, +returned untouched when it exists — so the call handed back the dev drive and +the assertion compared it against a name it never got to use. Same class as +the two peer-sync tests in `b2d168dd`. + +## Desktop ↔ Home Assistant, on today's builds — 2026-08-17 evening + +Both ends rebuilt from this branch: the HA add-on binary cross-compiled +(`aarch64-unknown-linux-musl`, checksum-verified on install, previous kept as +`atomic-server.prev`), the desktop run from the same tree. The served bundle +hash on `https://atomic.ontola.io` matches the local build exactly, so what was +tested is what was built. + +``` +HA node did:ad:node:5066634d0786d35c927f2ec099e911fe4924c035ec463c8a3c6cb6ff37aad9bb +desktop node did:ad:node:6041773d78f964b03087801b602be64c3558d2b8dea7221453135468df4e574a +``` + +### Sync works in both directions + +Pairing survived the upgrade — the desktop still lists `local-atomic-server` as +paired, and a manual sync ran the full handshake: authenticated as the agent, +version vectors compared (`hashes match, in sync`), live mode, read and write +loops up. + +Live propagation, no manual sync in either case: + +| direction | created | arrived | +| --- | --- | --- | +| desktop → HA | 16:13:19 | 16:13:24 | +| HA → desktop | 16:30:44 | 16:30:49 | + +The second one was written by a *different agent* on the HA server, which makes +it a real test of the relayed-write path rather than an echo. + +### The invite flow, against a real replica + +An invite token minted on the desktop and opened against `atomic.ontola.io` +verified there — proof the drive and the signer's agent had both synced across. +Accepting produced, on the HA server: + +- a new agent with its **derived personal drive set** — the fix in `bd6af81c`. + Before it, an invite-created agent could never name its own home, which is + what left "Shared with me" permanently empty; +- the **invited drive** as the active one (M17); +- the new agent added to the drive's `write` list by `add_rights`, which uses + `save_locally` and so is not itself subject to the rights check. + +### Two things worth following up + +**Write amplification reaches the relayed path.** One resource creation caused +**seven** `INDEXING` passes on the HA server. Same shape as the OPFS finding, +now on the server's search index — and this one runs on a Raspberry Pi 5. + +**The post-accept unauthorized window is reliable, not occasional.** Between +accepting and `add_rights` landing, the client's writes to the target drive are +refused with `No write right has been found for did:ad:agent:…`. `InvitePage` +already treats the drive-bookmark as best-effort for exactly this reason, but +against a remote replica the window fires every time rather than rarely, so +anything in that path that is *not* best-effort will fail there. + +### quick-add — the keystroke that lands mid-save + +CI's snapshot said it precisely: the field still held "Bread" and no row had +been added. `create()` clears the field before it awaits anything, so had it +run at all the field would be empty — it took the early return, and with text +present the only remaining cause was `busy`. The bar is used at speed and the +window is wider than it looks: a row becomes visible when `onRowCreated` fires +in `.then()`, while `busy` clears later in `.finally()`. + +Removing that gate is safe on its own terms — submitting the same item twice +is already impossible, because the field is cleared before anything is awaited +and `ready` is false until something new is typed. + +**The test that proved it, and the one that didn't.** The first attempt delayed +the HTTP `/commit` route and passed against the bug: commits travel over the +**websocket** while connected, so it held nothing open. `routeWebSocket` is +what creates a real in-flight window. Only running the new test with the fix +reverted exposed this — it had already gone green and looked like proof. + +Final: 3/3 red without the fix, 5/5 green with it, all four quick-add specs +passing. + +### Reading CI honestly + +Two ways a run lied today, both worth checking before debugging anything: + +- **Cache replay** (below) — the one that is actually provable. +- **Cache replay.** A docs-only commit does not touch the e2e cache key, so the + run replays the previous result — identical failures *and* identical shard + times to the tenth of a minute, which re-executed tests cannot produce. To + force a real e2e run, change something under `browser/`. + +**It was host contention after all — and the instrument could not see it.** +The scattered failures were first blamed on runner contention, then withdrawn +because `uptime` inside the WSL2 runner reported an idle box. That check was +worthless: **WSL2's loadavg reports the Linux VM's own load and cannot see the +Windows host starving it.** Joep was gaming on that desktop. Measure +throughput instead — `ssh mancave "time (for i in $(seq 1 3000000); do :; done)"`, +~2.5-2.9s on a quiet host — which does reflect starvation. + +The numbers, once the host was free: **9 failures -> 2**, shard 1 from 18.4m to +13.7m, with `calendar`, `aggregates`, `kanban` x2, `filePicker`, `onboarding` +and `e2e > folder` all passing. Nothing was wrong with them. + +Two lessons worth more than the incident. `gh run rerun` **replays the dagger +cache** — the re-run returned byte-identical failures *and* identical shard +times, which re-executed tests cannot produce, so it is never a way to retest +after a bad-environment run; only a change under `browser/` forces execution. +And a correctness A/B cannot clear a change of *load* effects, which is why +the quick-add fix was still rewritten to queue rather than to remove the +in-flight gate: keeping exactly one save open is what the original code +guaranteed, and that guarantee should not be dropped by accident. + + +### What is left, and what it is not + +On a quiet runner the branch fails two specs, both the same shape: + + dashboard:311 "Total spent—Sum amount" expected 946.5 + dashboard:340 "Expenses—Count" expected 4 + +That em-dash is the placeholder shown while a measure has not computed, so +neither is about adding a row — `340` fails on its Count block before the +row-add matters. It is the totals path missing a 15s budget, the same family +as `aggregates.spec.ts:50`, and the same fragility already on record: acked +rows re-draining on every reload plus OPFS write amplification. The relayed +path shows it too — one resource created on the Home Assistant box produced +**seven** index writes. + +This PR did not cause it and should not be asked to fix it; it is the +write-amplification work, which is its own piece. + +**One thing this PR did cause, found late.** Persisting the invite agent as a +secret rather than a keypair also adopts that agent on the device by default — +so accepting an invite on a desktop that already holds its owner's agent would +have repointed the embedded node's identity. Storage was what the fix needed; +the adoption was not. Now `adoptOnDevice: false`. \ No newline at end of file diff --git a/server/src/actor_messages.rs b/server/src/actor_messages.rs index d6505b46d..a9cea961b 100644 --- a/server/src/actor_messages.rs +++ b/server/src/actor_messages.rs @@ -108,6 +108,21 @@ pub struct PresenceUpdate { pub addr: Option>, } +/// Drive presence that arrived from a PEER, not from a local websocket. +/// +/// Distinct from [`PresenceUpdate`] because that one is gated on the sender +/// being a subscriber — which is where the drive read-access check happens for +/// local clients, and is exactly the gate that should NOT be faked for relayed +/// traffic. A peer's presence has already passed its own read check in the sync +/// read loop, and there is no local connection to attribute it to, so it fans +/// out to every subscriber with nobody to exclude. +#[derive(Message, Clone, Debug, serde::Deserialize, serde::Serialize)] +#[rtype(result = "()")] +pub struct RemotePresenceUpdate { + pub subject: atomic_lib::Subject, + pub update: String, +} + /// Subscribe to all commits on resources living under a drive. Every /// commit under the drive fans out to this connection as a `CommitMessage` /// (encoded as UPDATE / DESTROY by the WebSocketConnection handler). diff --git a/server/src/commit_monitor.rs b/server/src/commit_monitor.rs index 523726e94..6d9122312 100644 --- a/server/src/commit_monitor.rs +++ b/server/src/commit_monitor.rs @@ -567,6 +567,42 @@ impl Handler for CommitMonitor { /// subscribers of its drive — the same two audiences, and the same /// drive-boundary check, that `Handler` serves. fn handle(&mut self, msg: ExternalChange, _ctx: &mut Context) { + // Keep search in step with the store. A change that no commit produced + // — a peer sync writing straight through `add_resource_opts` — never + // reaches `Handler`, which is where indexing lives. So + // resources arriving over Iroh were stored and listed (the query index + // IS updated) but invisible to search: 49 resources synced, zero + // INDEXING events. Someone who reaches for search first concludes their + // data never arrived. + if !msg.destroyed { + let search_state = self.search_state.clone(); + let store = self.store.clone(); + let subject_for_index = msg.subject.clone(); + tokio::spawn(async move { + let subject = atomic_lib::Subject::from_raw( + &subject_for_index, + store.get_base_domain().as_deref(), + ); + + match store.get_resource(&subject).await { + Ok(resource) => { + let _ = search_state.remove_resource(&subject_for_index); + + if let Err(e) = search_state.add_resource(&resource, &store).await { + tracing::warn!( + "CommitMonitor: could not index peer-synced {}: {e}", + &subject_for_index[..subject_for_index.len().min(40)] + ); + } + } + Err(e) => tracing::debug!( + "CommitMonitor: peer-synced {} not indexable: {e}", + &subject_for_index[..subject_for_index.len().min(40)] + ), + } + }); + } + let base_domain = self.store.get_base_domain(); let subject = atomic_lib::Subject::from_raw(&msg.subject, base_domain.as_deref()); let resolved = subject.resolve( @@ -581,10 +617,18 @@ impl Handler for CommitMonitor { let Some(snapshot) = msg.loro_snapshot.as_ref() else { return; }; + // SNAPSHOT, because that is what this payload is: `external_change` + // reads it straight out of `Tree::LoroSnapshots`, unlike the commit + // path above which carries a commit's delta and correctly omits the + // flag. Sending full state labelled as a delta made the client merge + // it into a document it does not have, seeding the partial doc its + // own GET handler warns about — "can keep only the seed's props and + // render the resource class-less". Visible as a peer's new table row + // appearing with every cell empty until a reload. let flags = if msg.commit_id.is_some() { - ws_v2::flags::HAS_COMMIT_ID | ws_v2::flags::PUSH + ws_v2::flags::SNAPSHOT | ws_v2::flags::HAS_COMMIT_ID | ws_v2::flags::PUSH } else { - ws_v2::flags::PUSH + ws_v2::flags::SNAPSHOT | ws_v2::flags::PUSH }; Arc::from( ws_v2::encode_update(flags, 0, &resolved, msg.commit_id.as_deref(), snapshot) diff --git a/server/src/handlers/forget_peer.rs b/server/src/handlers/forget_peer.rs index db7a02d8b..9a0eeb86c 100644 --- a/server/src/handlers/forget_peer.rs +++ b/server/src/handlers/forget_peer.rs @@ -45,7 +45,10 @@ pub async fn handle_forget_peer( } let node = params.node.clone(); - crate::iroh_transport::remove_live_peer(&node); + // Unconditional: the user asked to drop this device, so whichever + // connection currently holds the link should go. `remove_live_peer` is for + // a connection retiring itself and refuses if a newer one has replaced it. + crate::iroh_transport::remove_live_peer_any(&node); crate::iroh_transport::remove_known_peer(store, &node); Ok(HttpResponse::Ok().json(serde_json::json!({ "ok": true }))) diff --git a/server/src/loro_sync_broadcaster.rs b/server/src/loro_sync_broadcaster.rs index 939070df1..16a2115a9 100644 --- a/server/src/loro_sync_broadcaster.rs +++ b/server/src/loro_sync_broadcaster.rs @@ -166,8 +166,15 @@ impl Handler for LoroSyncBroadcaster { return; }; + // No sender address means a peer relayed this in. There is no local + // connection to attribute it to and none to exclude from the fan-out; + // the sending node ran its own write check before relaying, and this + // one ran another when the frame arrived. let Some(addr) = &msg.addr else { - tracing::warn!("no addr in LoroSync update for {}", msg.subject); + for subscriber in subscribers { + subscriber.addr.do_send(msg.clone()); + } + return; }; @@ -176,6 +183,20 @@ impl Handler for LoroSyncBroadcaster { return; } + // Out to peers as well as to local subscribers, so an edit in progress + // reaches the other device rather than waiting for a save. Only local + // updates get here (the branch above returns early for relayed ones), + // so there is no echo to guard against. + if let Ok(agent) = self.store.get_default_agent() { + atomic_lib::sync::peer::broadcast_ephemeral( + atomic_lib::sync::protocol::ephemeral_kind::DOC, + msg.subject.as_str(), + &agent.subject.to_string(), + msg.update.as_bytes(), + None, + ); + } + // Broadcast to all subscribers except the sender for subscriber in subscribers { if subscriber.addr == *addr { @@ -191,6 +212,22 @@ impl Handler for LoroSyncBroadcaster { type Result = (); fn handle(&mut self, msg: LoroEphemeralUpdate, _ctx: &mut Context) { + // Relay to peers before the local fan-out below, and only for presence + // that originated here (`addr` is the websocket it came from; a frame + // we relayed IN from a peer has none, and must not be sent back out or + // two nodes trade cursors forever). + if msg.addr.is_some() { + if let Ok(agent) = self.store.get_default_agent() { + atomic_lib::sync::peer::broadcast_ephemeral( + atomic_lib::sync::protocol::ephemeral_kind::LORO, + msg.subject.as_str(), + &agent.subject.to_string(), + msg.update.as_bytes(), + None, + ); + } + } + let Some(subscribers) = self.subscriptions.get(&msg.subject) else { return; }; @@ -320,6 +357,18 @@ impl Handler for LoroSyncBroadcaster { }; *cached = Some(msg.update.clone()); + // Relay to peers. Only local presence reaches here (the handler above + // requires a sender address), so there is no echo to guard against. + if let Ok(agent) = self.store.get_default_agent() { + atomic_lib::sync::peer::broadcast_ephemeral( + atomic_lib::sync::protocol::ephemeral_kind::PRESENCE, + msg.subject.as_str(), + &agent.subject.to_string(), + msg.update.as_bytes(), + None, + ); + } + for subscriber in subscribers.keys() { if subscriber == sender { continue; @@ -330,6 +379,35 @@ impl Handler for LoroSyncBroadcaster { } } +impl Handler for LoroSyncBroadcaster { + type Result = (); + + /// Fan a peer's presence out to everyone watching that drive here. + /// + /// No sender to exclude and no subscriber check: the frame came from + /// another node, which applied its own read gate before relaying it, and + /// there is no local connection it could be attributed to. + fn handle( + &mut self, + msg: crate::actor_messages::RemotePresenceUpdate, + _ctx: &mut Context, + ) { + let Some(subscribers) = self.presence.get(&msg.subject) else { + return; + }; + + let local = PresenceUpdate { + subject: msg.subject.clone(), + update: msg.update, + addr: None, + }; + + for subscriber in subscribers.keys() { + subscriber.do_send(local.clone()); + } + } +} + pub fn create_loro_sync_broadcaster(store: Db) -> Addr { LoroSyncBroadcaster::create( |_ctx: &mut Context| LoroSyncBroadcaster { diff --git a/server/src/plugins/server_info.rs b/server/src/plugins/server_info.rs index 9e74765d1..cc842d182 100644 --- a/server/src/plugins/server_info.rs +++ b/server/src/plugins/server_info.rs @@ -61,7 +61,24 @@ fn peer_resources(store: &Db) -> Vec { let live = crate::iroh_transport::live_peer_ids(); let known = crate::iroh_transport::get_known_peers(store); - let mut seen: Vec<(String, Option, bool)> = Vec::new(); + // (node id, name, live, last synced). `last_synced` is already tracked on + // the stored peer; reporting it is what lets a device card say when it last + // exchanged anything instead of only whether a socket is open. "Connected" + // alone cannot distinguish a healthy link from one that has moved nothing. + let mut seen: Vec<( + String, + Option, + bool, + Option, + Option, + Option, + )> = Vec::new(); + + let stored_for = |id: &str| -> Option<&atomic_lib::sync::peer::KnownPeer> { + known + .iter() + .find(|p| crate::iroh_transport::normalize_node_id(&p.node_id) == id) + }; for id in &live { let name = crate::iroh_transport::live_peer_name(id).or_else(|| { @@ -71,37 +88,72 @@ fn peer_resources(store: &Db) -> Vec { .map(|p| p.name.clone()) .filter(|n| !n.is_empty()) }); - seen.push((id.clone(), name, true)); + let stored = stored_for(id); + seen.push(( + id.clone(), + name, + true, + stored.and_then(|p| p.last_synced), + stored.and_then(|p| p.last_sent), + stored.and_then(|p| p.last_received), + )); } for peer in &known { let id = crate::iroh_transport::normalize_node_id(&peer.node_id); - if seen.iter().any(|(known_id, _, _)| known_id == &id) { + if seen.iter().any(|(known_id, ..)| known_id == &id) { continue; } let name = Some(peer.name.clone()).filter(|n| !n.is_empty()); - seen.push((id, name, false)); + seen.push(( + id, + name, + false, + peer.last_synced, + peer.last_sent, + peer.last_received, + )); } seen.into_iter() - .map(|(node_id, name, is_live)| { - let mut propvals = atomic_lib::resources::PropVals::new(); - propvals.insert(urls::IS_A.into(), vec![urls::PEER.to_string()].into()); - propvals.insert( - urls::PEER_NODE_ID.into(), - Value::String(format!("did:ad:node:{node_id}")), - ); - - if let Some(name) = name { - propvals.insert(urls::PEER_DEVICE_NAME.into(), Value::String(name)); - } - - propvals.insert(urls::PEER_LIVE.into(), Value::Boolean(is_live)); - - atomic_lib::values::SubResource::Nested(propvals) - }) + .map( + |(node_id, name, is_live, last_synced, last_sent, last_received)| { + let mut propvals = atomic_lib::resources::PropVals::new(); + propvals.insert(urls::IS_A.into(), vec![urls::PEER.to_string()].into()); + propvals.insert( + urls::PEER_NODE_ID.into(), + Value::String(format!("did:ad:node:{node_id}")), + ); + + if let Some(name) = name { + propvals.insert(urls::PEER_DEVICE_NAME.into(), Value::String(name)); + } + + propvals.insert(urls::PEER_LIVE.into(), Value::Boolean(is_live)); + + if let Some(last_synced) = last_synced { + propvals.insert(urls::PEER_LAST_SEEN.into(), Value::Timestamp(last_synced)); + } + + // What the last sync moved, each way. A timestamp plus + // "Connected" still cannot distinguish a link carrying data + // from one being refused every subject; these can. + if let Some(sent) = last_sent { + propvals.insert(urls::PEER_LAST_SENT.into(), Value::Integer(sent as i64)); + } + + if let Some(received) = last_received { + propvals.insert( + urls::PEER_LAST_RECEIVED.into(), + Value::Integer(received as i64), + ); + } + + atomic_lib::values::SubResource::Nested(propvals) + }, + ) .collect() } diff --git a/server/src/routes.rs b/server/src/routes.rs index 98a7a4bc5..dcbf04e2d 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -126,6 +126,11 @@ async fn iroh_sync_handler( { Ok(outcome) => actix_web::HttpResponse::Ok().json(serde_json::json!({ "count": outcome.count, + // Both directions. Reporting only `count` (what we pulled) made a + // pass that pushed 49 resources and pulled 1 read as + // "Synced 1 resource" — the user cannot tell a working sync from a + // stalled one if the number only describes half of it. + "pushed": outcome.pushed, // `peerName` is the remote's self-reported `HELLO` label. // Older peers that don't speak HELLO yet send `null`; the UI // falls back to a truncated Node DID in that case. diff --git a/server/src/serve.rs b/server/src/serve.rs index 11e04c5f4..83f1a991d 100644 --- a/server/src/serve.rs +++ b/server/src/serve.rs @@ -263,6 +263,68 @@ where atomic_lib::sync::peer::set_device_name(&appstate.store, name); } + // Presence arriving from peers → the local websocket clients. + // + // Deliberately its own channel rather than `db_events`: presence must never + // be written, and every consumer of a DbEvent writes or indexes. Cursor + // positions merged into the CRDT would persist and sync forever. + // + // `addr: None` marks the update as relayed, which is what stops the + // broadcaster sending it straight back out to peers — presence arrives at + // cursor frequency, so an echo here would saturate the link far faster than + // resource changes could. + { + let store = appstate.store.clone(); + let broadcaster = appstate.loro_sync_broadcaster.clone(); + let mut rx = store.subscribe_ephemeral(); + actix_web::rt::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + let subject = atomic_lib::Subject::from_raw( + &event.drive, + store.get_base_domain().as_deref(), + ); + let update = String::from_utf8_lossy(&event.payload).to_string(); + + // Each channel fans out to a different subscriber set, + // so a relayed frame has to re-enter through the same + // one it left by. + match event.kind { + atomic_lib::sync::protocol::ephemeral_kind::PRESENCE => { + broadcaster.do_send(crate::actor_messages::RemotePresenceUpdate { + subject, + update, + }); + } + atomic_lib::sync::protocol::ephemeral_kind::DOC => { + broadcaster.do_send(crate::actor_messages::LoroSyncUpdate { + subject, + update, + addr: None, + }); + } + _ => { + broadcaster.do_send(crate::actor_messages::LoroEphemeralUpdate { + subject, + update, + addr: None, + }); + } + } + } + // Presence is the first thing worth dropping under load, so + // lagging is expected and not an error: skip what was + // missed and carry on with the current cursors. + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::debug!("[presence] dropped {n} frames under load"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + } + // Durable-flush tick. Per-commit writes use Durability::None (no fsync) // for throughput; this background flush makes them durable on a fixed // cadence (100ms), bounding crash data-loss to the interval while