diff --git a/channel-party/Cargo.lock b/channel-party/Cargo.lock index f3ed49a..6d65612 100644 --- a/channel-party/Cargo.lock +++ b/channel-party/Cargo.lock @@ -11,6 +11,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -23,6 +38,28 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -107,18 +144,56 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -128,6 +203,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "brotli-decompressor" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -156,6 +241,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -165,12 +260,41 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cp-basic" version = "0.1.0" dependencies = [ "async-trait", "cp-model", + "serde", + "serde_json", ] [[package]] @@ -194,6 +318,9 @@ version = "0.1.0" dependencies = [ "async-trait", "cp-model", + "serde", + "serde_json", + "sqlx", ] [[package]] @@ -201,11 +328,17 @@ name = "cp-core" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "async-trait", "cp-model", + "rand_core 0.6.4", + "serde_json", + "sha2", "sqlx", + "tempfile", "tokio", "tracing", + "ulid", ] [[package]] @@ -213,7 +346,16 @@ name = "cp-discord" version = "0.1.0" dependencies = [ "async-trait", + "cp-core", "cp-model", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "twilight-http", + "twilight-model", + "wiremock", ] [[package]] @@ -222,9 +364,19 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "axum-extra", + "cp-basic", + "cp-canvas", "cp-core", + "cp-model", + "cp-space", + "http-body-util", + "serde", "serde_json", + "tempfile", "tokio", + "tokio-stream", + "tower", "tower-http", "tracing", ] @@ -237,6 +389,7 @@ dependencies = [ "axum", "serde", "serde_json", + "sqlx", "thiserror", "ulid", ] @@ -247,6 +400,8 @@ version = "0.1.0" dependencies = [ "async-trait", "cp-model", + "serde", + "serde_json", ] [[package]] @@ -298,6 +453,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -306,6 +485,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -347,7 +527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -361,6 +541,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -378,6 +564,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -393,6 +585,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -437,6 +644,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -455,8 +673,10 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -474,6 +694,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -486,6 +717,25 @@ dependencies = [ "wasip2", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -518,6 +768,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -585,6 +841,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -593,6 +850,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-platform-verifier", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] @@ -602,12 +876,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "libc", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -729,6 +1008,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -763,6 +1091,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -829,7 +1163,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -838,9 +1172,15 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -850,12 +1190,37 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "parking" version = "2.2.1" @@ -885,6 +1250,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -912,6 +1288,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -952,7 +1334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -962,7 +1344,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -971,7 +1362,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] @@ -983,6 +1374,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1000,6 +1403,115 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1012,12 +1524,59 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1028,6 +1587,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1072,6 +1641,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1120,6 +1700,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1139,7 +1735,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1261,6 +1857,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.118" @@ -1289,6 +1891,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1318,6 +1933,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1341,7 +1986,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1355,6 +2000,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -1364,6 +2019,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -1494,6 +2150,68 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twilight-http" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9a2176638fd8bfeb867e7a2f644fee0db905eba6f7decfdcd75c8171109b74" +dependencies = [ + "brotli-decompressor", + "fastrand", + "http", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "tokio", + "tracing", + "twilight-http-ratelimiting", + "twilight-model", + "twilight-validate", +] + +[[package]] +name = "twilight-http-ratelimiting" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a36945d949920d6bb6aef30547e06ea645eefaa5575a14f56da608bf09d07ec8" +dependencies = [ + "tokio", + "tracing", +] + +[[package]] +name = "twilight-model" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191e2efa051dfbd9bed4c9f6bd3f5e007bda909c687a1db2760371a3d566617d" +dependencies = [ + "bitflags", + "serde", + "serde-value", + "serde_repr", + "time", +] + +[[package]] +name = "twilight-validate" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f8d106028ede53708526364b03318bbf846babe146e3ff9e39821a0ca25ff4" +dependencies = [ + "twilight-model", +] + [[package]] name = "typenum" version = "1.20.1" @@ -1523,6 +2241,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1559,6 +2283,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1629,12 +2372,39 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1644,6 +2414,93 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -1720,6 +2577,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/channel-party/Cargo.toml b/channel-party/Cargo.toml index 1b57975..824df25 100644 --- a/channel-party/Cargo.toml +++ b/channel-party/Cargo.toml @@ -36,22 +36,37 @@ cp-canvas = { path = "kinds/canvas" } # External crates anyhow = "1" +argon2 = "0.5" async-trait = "0.1" axum = "0.8" +axum-extra = { version = "0.10", features = ["cookie"] } +# Matches the rand_core the argon2/password-hash stack uses; `getrandom` exposes `OsRng` for salts + +# session tokens. +rand_core = { version = "0.6", features = ["getrandom"] } serde = { version = "1", features = ["derive"] } +sha2 = "0.10" serde_json = "1" sqlx = { version = "0.8", default-features = false, features = [ "runtime-tokio", "sqlite", ] } thiserror = "2" +# Discord REST client for the discord-compatible slice (#10). Default features use rustls, matching the +# rest of the workspace. `twilight-model` carries the Discord types; tests mock it via twilight's proxy. +twilight-http = "0.16" +twilight-model = "0.16" +wiremock = "0.6" tokio = { version = "1", features = [ + "io-std", + "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", + "time", ] } +tokio-stream = { version = "0.1", features = ["sync"] } tower-http = { version = "0.6", features = ["fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/channel-party/DESIGN.md b/channel-party/DESIGN.md index 25d7fa0..4a9bc8a 100644 --- a/channel-party/DESIGN.md +++ b/channel-party/DESIGN.md @@ -1,7 +1,10 @@ # channel-party — Design -Status: scaffolded — `cp-model` interface complete; core mechanisms and kind logic stubbed -Last updated: 2026-07-04 +Status: core mechanisms implemented (write path · read primitives · index substrates · runtime +supervisor · event bus · migrator · debug shell · auth · authorization · linked-users) with the +`basic`/`space`/`canvas` slices real and `discord-compatible` ingestion + structure + contents landed +(#10 a+b+d); the rest of #10 (semantic index · webhook · outbound · membership) remains. See `TODO.md`. +Last updated: 2026-07-11 See `TODO.md` for the deferred implementation work, each item tagged with its design-readiness. @@ -15,11 +18,31 @@ headless; a web frontend is merely the first consumer. Everything below is desig so that adding a new kind of channel or message touches **zero lines of core or frontend-shell code** — it adds a self-contained *vertical slice* instead. -> **Implementation status (scaffolded 2026-07-04).** The §10 structure exists and -> `nix flake check` is green. `cp-model` is implemented in full; `cp-core`, -> `cp-frontend`, `cp-bin`, and the four kinds are warning-clean stubs that compile, -> boot, and serve (the API returns `501` until the store lands). Deferred work — and how -> much of it still needs a design pass — lives in **`TODO.md`** (keep it in sync). +> **Implementation status.** The §10 structure exists and `nix flake check` is green. +> Implemented: `cp-model`; the `cp-core` **write path** (`WriteCtx` — validate → persist → +> transactional index → change event; `external_key` upsert; the `channel_members` substrate), +> per `design/write-path.md` (`TODO.md` #1); **all four read primitives** (`StoreCtx` — +> `children`/`descendants`/`seek_time`/`search`), per `design/read-path.md` + `design/index-search.md` +> (#2/#3); the **FTS5 index substrate** feeding `search` (#3); the **runtime supervisor** — +> `spawn_runtime` supervises each `RuntimeComponent` (backfill-then-stream, restart/backoff, the real +> `RuntimeCtx` with `WriteScope` confinement + `version()` reset), per `design/runtime.md` (#4); +> **three vertical slices** — `basic`'s feed, `space`'s subtree search, and `canvas`'s viewport-bbox over +> its *own* R-tree (the reference §6 escape-hatch slice: an R-tree in `canvas_*` tables maintained by a +> `Derived` `SpatialIndex` component, #11) — on the generic HTTP API (`GET /api/channels|items/:id`, +> `POST .../contents` dispatch), end-to-end tested (#8/#9/#11/#12); +> **live updates** — `GET /api/events` streams the change bus over SSE, scope-filterable (#13); the +> **frontend** — `basic`'s live message list, `space`'s search box, and `canvas`'s pannable viewport, +> delegating rendering via the registry, on a fully-static, client-side-routed Astro shell (#15/#16); and +> the **gated debug shell** (`channel-party shell`) — read + envelope-CRUD + `reparent` + capability-gated +> membership + `set-password` through the mutation API (#6); and **native-user auth** — password login +> (argon2, provisioned accounts) + server-side sessions (`/api/auth/login|logout|me`, a `CurrentUser` +> extractor) with the shell's login state in the frontend, per `design/auth.md` (#17). Still stubbed: +> the `sort_key` index substrate (no consumer yet), the `discord` slice (#10), and per-channel +> permissions (#18). Deferred work and its design-readiness live in **`TODO.md`**. +> +> **Notable coupling** discovered building #4/#11: `cp-model` depends on `sqlx` — the price of the §6 +> escape hatch (handing an escape-hatch kind a DB handle through `StoreCtx`/`RuntimeCtx`). Pure kinds +> never use it. > > Deltas from the illustrative code below: `StoreCtx` and `RuntimeCtx` are **traits** > (passed as `&dyn`) implemented by `cp-core`, so kind crates depend only on `cp-model`; @@ -86,6 +109,15 @@ Invariant enforced by core: **only a native `User` can be a principal; items are inert content.** Auth, sessions, ownership, and permission checks resolve exclusively against `users`. +**Auth is implemented** (`design/auth.md`, `TODO.md` #17): password login (argon2id hash in +`users.password_hash`) with **provisioned accounts** — no public registration; a login is granted via +the debug shell's `set-password`. Sessions are server-side (opaque token in an HttpOnly cookie; the DB +stores only its SHA-256), exposed as `POST /api/auth/login|logout` + `GET /api/auth/me` and a +`CurrentUser` extractor. **Per-channel authorization is implemented** (§18, `design/permissions.md`): a +`Permission` capability on `ChannelKind` (deny-by-default), enforced at the authenticated write endpoint; +authorship is stamped server-side per kind (`with_author`) — no core author column, honoring the +polymorphic authorship below. + This makes authorship cleanly polymorphic (which is fine precisely *because* there is no base class): @@ -95,6 +127,13 @@ is no base class): - `item-type:discord-compatible/message` (originates on channel-party, pushed to Discord via webhook) → author is a native `User`. +**The `linked-users` edge + authorship resolution are implemented** (§19, `design/linked-users.md`): +`cp-core::links` links a native user to the external `cached-user` items that represent it (a cached-user +maps to ≤1 native user) and resolves an item *up* the link to its native user; links are +**operator-provisioned** (debug shell), HTTP exposes only reads. Self-service linking awaits a *per-kind +proof-of-ownership* mechanism (each external kind verifies ownership its own way — Discord OAuth, etc.), +the future form of §14's OAuth-linking question. + --- ## 3. Data model @@ -103,7 +142,7 @@ Three tables — **two super-types, plus the fixed `users` substrate.** ``` users (id, handle, auth…, created_at) -- first-class, native principals only -user_external_links (user_id → users.id, item_id → items.id) -- the `linked-users` edge, bidirectional +user_external_links (user_id → users.id, item_id → items.id UNIQUE) -- `linked-users` edge; one native user per external item (§19) channels (id, type_id, container?, payload_json) -- container = parent channel (null = root) items (id, type_id, container?, external_key?, payload_json) ``` @@ -126,8 +165,12 @@ items (id, type_id, container?, external_key?, payload_json) free; "jump to timestamp T" is just seeking to the ULID whose time prefix is T (see §5). -Writes go through core's mutation API, which calls the kind's `validate` before -committing — so no path (including the debug shell) can persist an invalid envelope. +Writes go through core's mutation API — the `WriteCtx` trait (implemented, see +`design/write-path.md`): each mutation calls the kind's `validate`, then persists the +envelope + inline `index()` in one transaction, then emits a change event on commit. So no +path (including the debug shell) can persist an invalid envelope. `upsert_item` keyed on +`external_key` gives idempotent mirroring with a stable id; the key is an opaque string the +kind constructs to encode its own uniqueness grain (resolving §14's namespacing question). --- @@ -155,6 +198,7 @@ pub trait ChannelKind: Send + Sync { fn index(&self, p: &Json) -> Option { None } // inline projection, §6 fn membership(&self) -> Option<&dyn Membership> { None } // §8 + fn permission(&self) -> Option<&dyn Permission> { None } // authorization; None = deny, §18 fn routes(&self) -> Option { None } // extra HTTP routes, mounted /ext/ fn debug_commands(&self) -> Vec { vec![] } // §8 fn debug_summary(&self, c: &Channel) -> Option { None } // §8 @@ -166,6 +210,7 @@ pub trait ItemKind: Send + Sync { // same sh fn type_id(&self) -> &TypeId; fn validate(&self, p: &Json) -> Result<()> { Ok(()) } fn index(&self, p: &Json) -> Option { None } + fn with_author(&self, p: Json, u: UserId) -> Json { p } // stamp server-side authorship, §2/§18 fn debug_summary(&self, i: &Item) -> Option { None } } ``` @@ -184,6 +229,7 @@ great deal (one rate-limited client, one search index). This mirrors the | `contents` (channel) | list+paginate | name search | fetch-all subtree | viewport bbox | | `index` (inline) | name → FTS | – | (via RuntimeComponent) | coord → spatial | | `membership` | ✓ | ✓ | reject / proxy to Discord | – | +| `permission` | members may post | – (deny) | Discord's model | – (deny) | | `RuntimeComponent` | – | – | sync (Primary) + semantic index (Derived) | spatial index (Derived) | | `routes` | – | – | webhook receiver | – | | island (frontend) | message list | search UI | threaded view | pan/zoom canvas | @@ -223,7 +269,7 @@ The three worked examples all reduce to these: | Channel kind | `contents(query)` | | --- | --- | | `basic` | `children(id, {Item, [basic]}, page, TimeDesc)`; `query.at` → `seek_time` first ⇒ jump-to-timestamp is free | -| `space` | `search(descendants(id), query.q, {Channel, [basic]}, page)` → paginated name matches | +| `space` | `search(self, query.q, {Channel}, page)` → paginated name matches over the subtree (impl drops the design's `[basic]` restriction so `space` isn't coupled to a peer type) | | `discord/guild` | `descendants(id, {Channel, [channel, section]})` → whole subtree at once; island builds the tree | | `discord/section` | same, scoped to itself | @@ -231,6 +277,18 @@ Discovery is **recursive**: a guild island renders channel *references*; opening mounts that child's island, which calls its *own* `contents`. Containers delegate to children exactly as they delegate item rendering (§9). +All four primitives are **implemented** (see `design/read-path.md` + `design/index-search.md`): +`children` is a `UNION ALL` keyset query across the two super-type tables ordered by id +(`LIMIT n+1` derives the next cursor); `descendants` is a recursive CTE over the channel +containment edge (root = depth 0, optional depth cap); `seek_time` is a *pure* ULID +computation (the id just below time `T`'s floor), no query. A `Cursor` is an opaque string +wrapping a bare ULID keyset boundary — results resume strictly beyond it in the `Order` +direction, and because a ULID's base32 text sorts like its value the pagination is a plain +`id ?`. **`search`** MATCHes the FTS5 projection (§6), joins back to channels/items, and +scopes to `scope`'s subtree via the *same* CTE as `descendants`; it ranks by bm25, so it pages by an +**offset** cursor — deliberately a different opaque encoding from the id-keyset one (which is why +`Cursor` is per-primitive, not globally structured). + --- ## 6. Indexing @@ -245,12 +303,17 @@ hardcodes no field; it only calls a kind-supplied function. Two tiers: `index(payload) -> IndexEntry` is a pure function applied **transactionally on write** into core's built-in substrates: -- **FTS5** (trigram tokenizer → true substring search) for `name` / body text. -- Expression indexes for declared **sort keys**. -- A **2D / R-tree** substrate for coordinates (`canvas-text-box`). +- **FTS5** (trigram tokenizer → true substring search) for `name` / body text. **Implemented** + (`search_index`, `design/index-search.md`): a standalone FTS5 table keyed by `envelope_id` + + `super_type`, written delete-then-insert by `index::upsert`; `StoreCtx::search` MATCHes it and + joins back to channels/items (orphaned rows from cascaded deletes are invisible to the join). +- Expression indexes for declared **sort keys** — *deferred* (no consumer yet; `TODO.md` #11). +- A **2D / R-tree** substrate for coordinates (`canvas-text-box`) — *deferred* to `canvas` (#11); + RTREE is confirmed available in the build. `IndexEntry { name?, text?, sort_key?, coord? }`. Cheap, deterministic, no I/O, no -task. A kind that needs no indexing returns `None` and costs nothing. +task. A kind that needs no indexing returns `None` and costs nothing. `index::upsert` projects +`name`/`text` today and ignores `sort_key`/`coord` until #11 builds their substrates. ### Tier 2 — async indexing via `RuntimeComponent` (§7) @@ -262,8 +325,12 @@ owns. **Type-owned index tables** are the escape hatch: a crate may ship namespaced migrations (`discord_*`, `canvas_*`) registered with core's migrator, giving it a fully self-contained search stack — *own table (schema) + RuntimeComponent (writer) + -`contents` (reader)* — that core never learns the shape of. Cost: those schemas must -be in each crate's `.sqlx` offline cache for compile-time query checking (§13). +`contents` (reader)* — that core never learns the shape of. **`canvas` is the reference +implementation** (`TODO.md` #11, `design/runtime.md`): `canvas_box` + `canvas_box_rtree` +(an R-tree), a `SpatialIndex` `Derived` component that maintains it off the change stream, and a +viewport-bbox `contents` reader — none of which core sees. The reader/writer reach their tables via +`type_owned_db()` on `StoreCtx`/`RuntimeCtx`, which is why `cp-model` depends on `sqlx`. Cost: those +schemas must be in each crate's `.sqlx` offline cache for compile-time query checking (§13). --- @@ -289,6 +356,18 @@ pub trait RuntimeComponent: Send + Sync { `RuntimeCtx` provides the store handle (restricted per `writes()`), the change-stream subscription filtered by `interests`, the scheduler, and shutdown. +**Implemented** (`design/runtime.md`, `TODO.md` #4): the supervisor runs one task per component +(restart + capped backoff, `RuntimeHandle` shutdown); `RuntimeCtx` exposes `next_event` (the filtered +change stream merged with the scheduler; `None` on shutdown), point reads, `scan` (the backfill +enumerator), `writer() -> Option<&dyn WriteCtx>` (**`None` for `Derived`** — confinement is structural), +`type_owned_db` (the §6 escape hatch), and `reset_requested()` (a `version()` bump vs the +`runtime_component_state` table). The change-event types live in `cp-model`. First `Derived` consumer: +`canvas`'s `SpatialIndex` (§6, #11); first `Primary` consumer: `discord-compatible`'s `DiscordSync` +(#10 (a)+(b), `design/discord.md`) — it fetches Discord messages and upserts `cached-user`/`cached-message` +envelopes through `writer()` (which returns `Some` only for `Primary`). *Delta from the sketch above:* +`run` takes `cx: &dyn RuntimeCtx` (dyn-safe), and giving escape-hatch kinds a DB handle made `cp-model` +depend on `sqlx`. + Two facets that are **behavior, not interface**: - **Reset semantics differ by input source.** A derived component resets by replaying @@ -338,9 +417,9 @@ capability on `ChannelKind`: ```rust pub trait Membership: Send + Sync { - async fn add_user(&self, cx: &StoreCtx, ch: &Channel, u: UserId) -> Result<()>; - async fn remove_user(&self, cx: &StoreCtx, ch: &Channel, u: UserId) -> Result<()>; - async fn members(&self, cx: &StoreCtx, ch: &Channel) -> Result>; + async fn add_user(&self, cx: &dyn WriteCtx, ch: &Channel, u: UserId) -> Result<()>; + async fn remove_user(&self, cx: &dyn WriteCtx, ch: &Channel, u: UserId) -> Result<()>; + async fn members(&self, cx: &dyn WriteCtx, ch: &Channel) -> Result>; } ``` @@ -363,6 +442,13 @@ applies uniformly without the shell knowing what they do. Built-in capability-backed commands (membership) + kind-registered commands, all behind the one gate. The shell never grows per type. +**Implemented** (`TODO.md` #6, `cp_core::debug`, run via `channel-party shell`): the mode gate, the +direct-DB reads, and envelope-CRUD + membership through the mutation API. `create-user` bootstraps the +`users` substrate (raw insert) until auth (#17); `set-password` provisions logins (#17); `link-user` / +`unlink-user` / `show links` provision the `linked-users` edge (#19). *Executing* kind-registered +`debug_commands()` is deferred until the first kind ships one (needs a registry enumerator + a per-kind +execution hook). + --- ## 9. Frontend (`channel-party-frontend`) @@ -399,11 +485,21 @@ language boundary. ``` GET /api/channels/:id -> { id, type_id, container } (generic) POST /api/channels/:id/contents {q} -> type-defined contents (dispatch, §5) +POST /api/channels/:id/items {type_id, payload} -> 201 { id } (authenticated write, §18) GET /api/items/:id -> envelope (generic) +GET /api/users/:id/links -> { items: […] } (linked-users, §2/§19) +GET /api/items/:id/linked-user -> User | 404 (authorship resolution, §2/§19) GET /api/events?scope=… -> SSE change stream (generic) +POST /api/auth/login|logout · GET /api/auth/me (native-user auth, §2/§17) /ext//… -> kind-contributed routes (webhooks, etc.) (§4) ``` +`POST …/items` is the first authenticated write (§18, `design/permissions.md`): it requires a session +(the `CurrentUser` extractor → 401), the channel kind's `Permission` to grant `Post` (→ 403, +deny-by-default), and a known item type (→ 400); the author is stamped server-side via the item kind's +`with_author` (§2), then `validate` + persist run in the write path. It stays type-agnostic — the client +names the `type_id` and the payload is opaque to core. + --- ## 10. Component / crate layout @@ -509,6 +605,8 @@ opts into only the capabilities it needs: | `index` (inline) | ChannelKind / ItemKind | core write path (transactional) | | `RuntimeComponent` | registry | core runtime (supervised) | | `membership` | ChannelKind | core / debug shell | +| `permission` | ChannelKind | core write path (authz dispatch) | +| `with_author` | ItemKind | frontend write endpoint | | `debug_commands` | ChannelKind | debug shell | | `debug_summary` | ChannelKind / ItemKind | debug shell | | `routes` | ChannelKind | frontend server | @@ -523,14 +621,23 @@ Adding a type touches none of these mechanisms — only the slice. These are unresolved **design** questions, distinct from the implementation backlog in `TODO.md` (which flags which items still need a design pass before coding): -- **Permissions model.** Beyond "only native users are principals," the shape of - per-channel authorization is unspecified. Likely another capability, but deferred. -- **Membership storage.** Whether the generic `channel_members` substrate is - sufficient, or membership-heavy kinds should own their edge tables. +- ~~**Permissions model.**~~ **Resolved (#18, `design/permissions.md`):** a `Permission` capability on + `ChannelKind` (the "another capability" this predicted), deny-by-default, enforced at the authenticated + write endpoint; the fixed `Action` vocabulary is `View`/`Post`/`Manage` (only `Post` gated so far — + reads stay open). Authorship is stamped per-kind server-side (`ItemKind::with_author`), no core column. +- **Membership storage.** Partly resolved by #18: a `Permission` policy can ride the generic + `channel_members` substrate (`basic` = "members may post"), so it is sufficient for the common case; + membership-heavy kinds that outgrow it own their own edge tables via the §6 escape hatch (no core + change). Still open only as "when to reach for a kind-owned table." - **`external_key` namespacing.** Exact uniqueness scope for cached objects (per-guild vs global Discord user identity). -- **Auth / session mechanism** for native users (out of scope for the core data - model; needed before the frontend is usable). +- ~~**Auth / session mechanism** for native users.~~ **Resolved (#17, `design/auth.md`):** password + login + provisioned accounts + server-side sessions. +- ~~**`linked-users` linking.**~~ **Resolved (#19, `design/linked-users.md`):** `cp-core::links` + + operator-provisioned links (shell) + read/resolution endpoints. Open self-signup and **self-service + linking** remain future extensions — the latter gated by a **per-kind proof-of-ownership** capability + (each external `cached-user` kind verifies ownership its own way, e.g. Discord OAuth), layered on the + same edge without changing storage. - **Registry ergonomics.** Whether to keep explicit registration or adopt `inventory`/`linkme` distributed registration as the type count grows. ``` diff --git a/channel-party/TODO.md b/channel-party/TODO.md index 97483c6..fc63540 100644 --- a/channel-party/TODO.md +++ b/channel-party/TODO.md @@ -9,6 +9,8 @@ out before code — leaning toward *more* design/prompting, not less. - **`DESIGN.md`** — the architecture source of truth. Update it whenever an item here changes the design (especially §3 data model, §4 traits, §5 contents, §6 indexing, §7 runtime, §9 HTTP surface). This file and `DESIGN.md` should never disagree. +- **`design/*.md`** — per-topic design notes drafted for review before implementing a 🔴/🟡 + item; once ratified, each folds into `DESIGN.md` and is referenced from its TODO entry. - **`crates/cp-bin/src/main.rs`** — the *only* composition root. Every new kind, runtime component, and migration set is registered here; nothing auto-registers. - **`flake.nix`** — `npmDepsHash` must be refreshed when `web/package-lock.json` changes @@ -30,6 +32,7 @@ out before code — leaning toward *more* design/prompting, not less. - 🟡 **Iron out** — the architecture is clear but specific decisions are unspecified; do a short design pass *before/with* the implementation. - 🔴 **Design first** — genuinely underspecified; needs a design discussion before any code. +- ✅ **Done** — implemented + verified; kept here for the dependency graph and cross-references. **Nothing here is a one-shot.** Even 🟢 items warrant focused, iterative claude-code prompting, an end-to-end check (`nix flake check` + a boot smoke test), and a write-back to @@ -39,43 +42,61 @@ prompting, an end-to-end check (`nix flake check` + a boot smoke test), and a wr ## Core (`cp-core`) -### 1. Mutation / write API — 🔴 Design first -DESIGN leans on "core's mutation API" (§3, §8) as the single write path -(validate → commit → `index()` → emit event), but never specifies its shape. Almost -everything else depends on it (debug writes, ingestion, the generic API, the event bus). -Decide: the method surface (create/update/delete, upsert-by-`external_key`), transaction -boundaries, how `validate` + inline `index()` + event emission compose in one transaction, -and how `WriteScope` yields a restricted handle. **Nothing exists yet.** - -### 2. Store primitives — 🟡 Iron out -`children` / `descendants` / `seek_time` / `search` are `todo!()` in -`crates/cp-core/src/store.rs`. §5 gives intent; the encodings are open: cursor format -(ULID-derived?), `descendants` traversal (recursive CTE vs application-side), how `search` -scopes to a subtree, filter→SQL. Iron out cursor + FTS-join design, then implement. - -### 3. Index substrates — 🟡 Iron out -FTS5 (trigram), sort-key expression indexes, and the 2D/R-tree — §6 gives intent, not -schema. Decide how each substrate keys back to envelopes and how `search`/bbox join them, -then write `index()` transactionally (`crates/cp-core/src/index.rs` is a stub). Coupled to -#1 and #2. - -### 4. RuntimeComponent supervisor + `RuntimeCtx` — 🔴 Design first -`spawn_runtime` only logs (`crates/cp-core/src/runtime.rs`). Needs real task spawning + -restart/backoff supervision, the concrete `RuntimeCtx` (store handle restricted by -`writes()`, change-stream subscription filtered by `interests`, scheduler, shutdown), and -`version()`-triggered reset semantics (§7). The restricted-handle mechanism and reset -behavior are unspecified — design first. - -### 5. Event-bus wiring — 🟢 Ready (after #1) -`EventBus` exists (`events.rs`) but nothing publishes. Emit `ChangeEvent` from the mutation -path; consumers are SSE (#13) and runtime components (#4). - -### 6. Debug shell REPL — 🟡 Iron out -`DebugShell` has the mode gate + prompt (`debug.rs`); the REPL loop, read commands (show -channels/items/users/envelope + `debug_summary`), and write commands (through #1, -capability-gated `Membership`, kind-registered `debug_commands`) are unbuilt. §8 is -detailed; iron out the command parser/dispatch and how a debug write invokes a -kind-contributed command. +### 1. Mutation / write API — ✅ Done (`design/write-path.md`, ratified 2026-07-09) +`WriteCtx` (create / upsert-by-`external_key` / update / reparent / delete + the +`channel_members` substrate) is implemented in `cp-core::Store`: validate → transactional +persist + inline `index()` → change event on commit; `upsert_item` returns a stable id. +Covered by `crates/cp-core/tests/write_path.rs`; folded into `DESIGN.md` §3/§8. **Deferred to +#4:** `WriteScope` confinement + the `RuntimeCtx` write handle (not pre-committed here). + +### 2. Store primitives — ✅ Done (`design/read-path.md` + `design/index-search.md`) +`children` / `descendants` / `seek_time` / `search` are all implemented in `cp-core::Store`, covered by +`crates/cp-core/tests/{read_path,search}.rs`: a ULID-keyset `Cursor` (results resume strictly beyond a +bare-ULID boundary in the `Order` direction), `children` a `UNION ALL` keyset query over the two +super-type tables (`LIMIT n+1` → next cursor), `descendants` a recursive CTE over channel containment +(root = depth 0, optional cap), `seek_time` a pure ULID time-floor computation. `search` is FTS-backed +(#3). Folded into `DESIGN.md` §5. + +### 3. Index substrates — ✅ Done: FTS5 (sort-key/R-tree deferred to #11) (`design/index-search.md`, ratified 2026-07-10) +The **FTS5** substrate (`search_index`, trigram) is live: `index::upsert`/`delete` write it +transactionally by envelope, and `StoreCtx::search` MATCHes it, joins back to channels/items (so +orphaned index rows are invisible), scopes to the `scope` subtree via the same CTE as `descendants`, +and pages by an **offset** cursor (distinct from #2's id-keyset — search ranks by bm25, not id). +Covered by `crates/cp-core/tests/search.rs` + the `space` slice. **Deferred (no consumer yet):** the +`sort_key` expression index and the `coord` **R-tree** — `IndexEntry` carries both but `index::upsert` +ignores them until `canvas` (#11) needs them (RTREE confirmed available in the build, so #11 is +derisked). FTS5 availability was verified empirically before committing the schema. + +### 4. RuntimeComponent supervisor + `RuntimeCtx` — ✅ Done (`design/runtime.md`, ratified 2026-07-10) +`spawn_runtime` supervises one task per component (JoinSet + `CancellationToken`-style watch shutdown, +restart with capped exponential backoff), returning a `RuntimeHandle` the caller holds. The real +`CoreRuntimeCtx` gives a component: `next_event` (the `interests`-filtered change stream merged with the +`schedule_secs` scheduler, `None` on shutdown), point reads, `scan` (the backfill enumerator), a +`WriteScope`-confined `writer()` (**`None` for `Derived`** — structural §7 confinement), the +`type_owned_db` escape hatch, and `reset_requested()` (a `version()` bump vs `runtime_component_state`). +Change-event types (`ChangeOp`/`EnvelopeRef`/`ChangeEvent`) moved to `cp-model` so a kind's component +consumes them without depending on `cp-core`. Validated generically by `crates/cp-core/tests/runtime.rs` +(throwaway components) and end-to-end by the `canvas` slice (#11). Folded into `DESIGN.md` §7. +**Finding:** `cp-model` now depends on `sqlx` — the escape hatch's cost (see #11). + +### 5. Event-bus wiring — ✅ Done (all three consumers) +The write path publishes a `ChangeEvent { op, target, type_id, container }` after every committed +mutation; the SSE endpoint (#13) and the runtime-component change-subscription (filtered by `interests`, +#4) both consume it. The event types now live in `cp-model` (so a kind's component can consume them); +`EventBus` stays in `cp-core` as the mechanism. + +### 6. Debug shell REPL — ✅ Done (kind-contributed command execution deferred) +`cp_core::debug` is a full REPL: the write-mode gate + prompt, direct-DB reads (`show +channels|items|users`, `inspect`, `members`, with `debug_summary`), and envelope CRUD through the +mutation API (`create-channel`/`create-item`/`set-payload`/`delete`/`reparent`) + capability-gated +membership (`add-user-to-channel` → `Membership`; `basic` now implements it, riding `channel_members`). +`reparent ` is the only way to build a hierarchy interactively — e.g. nest `basic` +rooms inside a `space` so its search finds them (added with #3/#9). +`create-user` bootstraps the fixed `users` substrate (raw insert — a deliberate exception, pre-auth +#17). Wired as `channel-party shell` (`cp-bin`), sharing `CP_DB` with a running server so seeded writes +surface live over SSE. Covered by `crates/cp-core/tests/debug_shell.rs`. **Deferred:** executing +*kind-contributed* `debug_commands()` — needs a registry enumerator + a kind execution hook, unbuilt +because no kind ships a command yet (e.g. canvas `move-box`, #11); `help` lists built-ins only. ### 7. Migration tracking — 🟡 Iron out Migrations re-run every boot via idempotent `CREATE ... IF NOT EXISTS` (`migrate.rs`). Real @@ -84,45 +105,80 @@ a `_migrations` table), including how kind-owned migrations version independentl ## Kinds -### 8. `basic` contents — 🟢 Ready (after #2) -`children(id, {Item,[basic]}, page, TimeDesc)` with `query.at → seek_time`; serialize -`NodePage`. A pure consumer of the store primitives. - -### 9. `space` contents — 🟡 Iron out (after #2/#3) -`search(descendants(id), q, {Channel,[basic]}, page)`. Depends on the search/index design; -pin the query/response shape the island consumes. - -### 10. `discord-compatible` runtime — 🔴 Design first (large; split before starting) -The heaviest slice; every method is `todo!()`. Warrants its own design effort, split into: -- **(a)** Shared Discord client/runtime: serenity, one rate-limited client + token bucket - across all bridged guilds. -- **(b)** Sync ingestion (Primary): backfill-then-poll; write cached-message / cached-user / - cached-reaction envelopes; `external_key` dedup (§3 — exactly one cached-user per Discord - user); reset = re-fetch from Discord. -- **(c)** Semantic index (Derived): **requires choosing an embedding provider/model + vector - store** (the type-owned table is a placeholder today); driven off the change stream. -- **(d)** `guild`/`section`/`channel`/`forum` contents: whole-subtree fetch; the island - builds the tree. -- **(e)** Webhook receiver `routes()` → `/ext/discord-compatible/…` (§4/§9). -- **(f)** Outbound: an `item-type:discord-compatible/message` originating on channel-party, - pushed to Discord via webhook. -- **(g)** Membership: reject (owned by Discord) vs proxy an outbound invite. -- Tests: a **`wiremock`** mock Discord (§12) — never the live API. - -### 11. `canvas` — 🟡 Iron out (after #3) -Viewport-bbox `contents` over the R-tree; the `SpatialIndex` component; the -`canvas-text-box` island (pan/zoom/placement, delegating box drawing). Settle the -coordinate space + bbox-query API and the spatial schema (a flat placeholder table today). +### 8. `basic` contents — ✅ Done +`BasicChannel::contents` runs `children(id, {Item,[basic]}, {cursor|at→seek_time, limit}, TimeDesc)` +and serializes the `NodePage`; `BasicQuery {at?, cursor?, limit?}` is the kind-owned query type. The +feed is newest-first, so `at` selects items at/before T (scroll-back-to-date). Covered end-to-end by +`crates/cp-frontend/tests/contents_slice.rs` (with #12). A pure consumer of the store primitives — +core and the frontend shell needed no change to add it, exactly as §1/§13 intends. + +### 9. `space` contents — ✅ Done (`design/index-search.md`) +`Space::contents` runs `search(scope = self, q, {Channel}, page)` and serializes the `NodePage`; +`SpaceQuery {q, cursor?, limit?}` is the kind-owned query type; an empty/short `q` yields an empty page. +**Deviation from §5's `{Channel,[basic]}`:** no `[basic]` type restriction — hardcoding a peer kind's +string would couple `space` to `basic`; `super_type: Channel` already excludes messages. Covered +end-to-end by `crates/cp-frontend/tests/space_search.rs` (with a throwaway kind, proving genericity) + +a real search-box island (#15). A pure consumer of the store primitives — core/shell unchanged. + +### 10. `discord-compatible` runtime — 🟡 In progress: (a)+(b)+(d) done (`design/discord.md`, ratified 2026-07-11) +The heaviest slice, split into sub-parts. **Client: `twilight-http`** (chosen); tests mock it via +twilight's **proxy** (`Client::builder().proxy(host, true)` → `wiremock`) — never the live API (§12). +- **(a)** ✅ Shared client / bridge: `DiscordBridge` holds one `Arc` (twilight); every + runtime component in the slice is spun from it (`bridge.sync()`) so they share it. Config + (`BridgeConfig`, `from_env`) passed from the composition root; **components register only when a token + is configured** (a tokenless boot runs none — this also removed the old `todo!()` crashloop). Rides + twilight's built-in rate limiter (bespoke cross-guild token bucket deferred). +- **(b)** ✅ Sync ingestion (`Primary`): `DiscordSync` backfills then re-polls a channel's messages → + **upserts** `cached-user` (`external_key = discord:user:`, one per user, §3) + `cached-message` + through `writer()`; reset = re-fetch (idempotent). **First proof of the `Primary` write path** (§7 — + canvas only exercised `Derived`). Container is operator-provided (channel-envelope creation is (d)). + Covered by `kinds/discord-compatible/tests/sync_ingest.rs` (wiremock — 3 msgs → 3 cached-messages + 2 + deduped cached-users, idempotent re-poll). Reactions deferred. +- **(c)** 🔴 Semantic index (`Derived`): **requires choosing an embedding provider/model + vector + store** (the type-owned table is a placeholder). Currently *not registered* (so it can't crashloop); + its stub was removed and lands with (c) via the bridge. +- **(d)** ✅ Structure + contents: the sync now creates the `guild` + `channel` envelopes itself, + **deduped without a mapping table** — derived from the source of truth by `scan`ning existing envelopes + and matching the Discord id in their payload (idempotent, no crash window). `contents` branches on the + slice's own type: leaf `channel`/`forum` → `children` message feed (first use of `descendants` too: + structural `guild`/`section` → whole channel subtree so the island builds the tree). Real discord + island (guild → channel links; channel → message feed). Covered by the wiremock test (guild + 2 + channels built + deduped on re-poll; contents both ways). Config is now `guild` + channel ids (the + bridge builds the envelopes; no operator-provided container). Deferred: full section/forum structure + (the `GET /guilds/:id/channels` fetch → categories/parent_id), guild-name fetch, threads. +- **(e)** 🟡 Webhook receiver `routes()` → `/ext/discord-compatible/…` (§4/§9) — the first `/ext` mount. +- **(f)** 🟡 Outbound: an `item-type:discord-compatible/message` originating here, pushed via webhook. +- **(g)** 🟡 Membership: reject (owned by Discord) vs proxy an outbound invite. + +### 11. `canvas` — ✅ Done (the reference escape-hatch slice) (`design/runtime.md`) +The self-contained §6 slice: `canvas` owns namespaced `canvas_*` tables core knows nothing of — a +denormalized box projection + an **R-tree** (`canvas_box_rtree USING rtree`). `SpatialIndex` (`Derived` +`RuntimeComponent`, #4's first real consumer) backfills + streams box changes into it; `Canvas::contents` +is a viewport **bbox** query over it (via the `type_owned_db` escape hatch, reconstructing box envelopes +without touching core's `items`); `CanvasTextBox` carries `{x,y,w,h,text}` and has *no* `index()` (its +projection is the kind's own table, not a core substrate). Real pan + live island. Covered by +`crates/cp-frontend/tests/canvas_spatial.rs` (backfill, streaming, viewport filtering, move, delete over +HTTP). Chosen over "bbox as a 5th core primitive" to prove the escape hatch. **Cost accepted:** `cp-model` +depends on `sqlx` (the only way to hand a kind a DB handle through `StoreCtx`/`RuntimeCtx`). The +`sort_key` expression index remains deferred (no consumer). ## Frontend (`cp-frontend` + `web/`) -### 12. Generic API handlers — 🟢 Ready (after #1/#2) -`GET /api/channels/:id`, `GET /api/items/:id` (read the envelope), `POST .../contents` -(→ `cp_core::contents::dispatch`). All return `501` today (`crates/cp-frontend/src/api.rs`). - -### 13. SSE live updates — 🟢 Ready (after #5) -`GET /api/events` → `tokio_stream::wrappers::BroadcastStream` over the event bus → -`axum::response::sse` (currently `501` in `sse.rs`); islands subscribe per channel scope. +### 12. Generic API handlers — ✅ Done +`GET /api/channels/:id` + `GET /api/items/:id` return the envelope; `POST .../contents` loads the +channel and calls `cp_core::contents::dispatch` (opaque query in, opaque JSON out). Error mapping: bad +id → 400, missing → 404, kind `Validation` → 400, else 500. `serve` now builds via a testable +`cp_frontend::router(state)`. Covered by `crates/cp-frontend/tests/contents_slice.rs`. Remaining `501`s +are SSE (#13) and the empty `/ext` per-kind mount (no kind contributes routes yet). + +### 13. SSE live updates — ✅ Done +`GET /api/events[?scope=]` streams the change bus: `BroadcastStream` over +`core.events().subscribe()` → `axum::response::sse` with keep-alive. Each `ChangeEvent` becomes a +`change` SSE event `{op, super_type, id, type_id, container}` (wire shape built in `sse.rs`, not by +serde on core's type); `scope` keeps events whose container is that channel or that target the channel +itself; a lagged slow client gets a `lagged` event to resync. Covered by +`crates/cp-frontend/tests/sse_live.rs` (live delivery + scope filtering). Islands still need to +consume it (part of #15). ### 14. `ts-rs` Rust→TS types — 🟡 Iron out §9 wants each kind's payload/query/response structs generated into its `web/`. Decide where @@ -130,32 +186,69 @@ coordinate space + bbox-query API and the spatial schema (a flat placeholder tab import them, and how it slots into the npm build ordering. A placeholder TODO is noted in `crates/cp-model/src/lib.rs`. -### 15. Island implementations — 🟡 Iron out (per island) -Placeholders in `kinds/*/web/island.ts`. Each real island — basic message list, space -search UI, discord threaded view, canvas pan/zoom — needs its data-fetch query/response -shape (tied to that kind's `contents`) and UI designed. Channel islands delegate item -rendering to item islands via the registry. - -### 16. Astro SSR / adapter — 🟡 Iron out -Static today with a placeholder `getStaticPaths` for `channels/[id]`. Real channel routing -needs a server/hybrid adapter (e.g. `@astrojs/node`) or an explicit -static-shell-plus-client-resolve decision. Affects deployment (#20) and how `cp-frontend` -serves the build. +### 15. Island implementations — 🟡 Iron out (`basic` done; per remaining island) +`kinds/basic/web/island.ts` is real: a live message list that fetches `POST .../contents`, renders each +item through its item island via the registry (the §9 recursive-render path — for basic it resolves to +itself), and reflects the `/api/events` SSE stream in place (created/updated/deleted). The +`IslandModule` contract now carries channel `mount` + item `renderItem`, both optional +(`web/scripts/gen-registry.mjs`). `kinds/space/web/island.ts` is also real: a search box that POSTs +`{q}` to `.../contents` and lists matching channels as links the shell opens (recursive discovery §9). +`kinds/canvas/web/island.ts` is real too: a pannable viewport that POSTs the visible rect to `contents`, +draws each box via the item island through the registry, re-queries on pan, and reflects SSE changes. +Remaining: `discord` threaded view — a placeholder needing its contents query/response shape + UI (#10). +Island DOM tests are #22. + +### 16. Astro routing — ✅ Decided: fully static + client-side SPA routing +No SSR adapter. Astro static mode can't prerender an arbitrary `/channels/`, so the single +`index.astro` shell routes on `location.pathname`; `cp-frontend` already falls back to `index.html` for +unknown paths (`static_files.rs`), so deep links resolve client-side and any id works with no per-id +prerender. `channels/[id].astro` (and its `getStaticPaths` placeholder) is deleted. This keeps the Rust +server a plain static file server — simplest Nix build/deploy (#20). Revisit only if per-request server +rendering is ever needed. ## Cross-cutting (see also DESIGN §14) -### 17. Native `User` auth + sessions — 🔴 Design first -§14: out of scope for the data model, but "needed before the frontend is usable." Nothing -exists beyond the `users` table + `User` struct. Decide auth method, session storage, and -login flow. - -### 18. Permissions model — 🔴 Design first -§14: beyond "only native users are principals," per-channel authorization is unspecified -("likely another capability"). Design the capability + its storage before implementing. - -### 19. `linked-users` API — 🟡 Iron out -`user_external_links` + `UserExternalLink` exist; the API to link a native user to a -`cached-user` item and to resolve authorship up a link (§2) is unspecified. +### 17. Native `User` auth + sessions — ✅ Done (identity/sessions; `design/auth.md`, ratified 2026-07-11) +Password auth against the `users` substrate with **provisioned accounts** (no public registration) + +server-side sessions. `cp-core::auth`: argon2id hashing (`users.password_hash`), `provision_user` / +`set_password` / `authenticate`, and opaque-token sessions (the DB stores only `SHA-256(token)`; +`create`/`resolve`/`delete`). `cp-frontend::auth`: `POST /api/auth/login|logout` + `GET /api/auth/me` +(HttpOnly `SameSite=Lax` cookie; `Secure` via `CP_SECURE_COOKIES=1`) and a `CurrentUser` extractor to +reuse on protected routes. Shell `set-password` provisions a login. Frontend shell shows login state. +Covered by `crates/cp-core/tests/auth.rs` + `crates/cp-frontend/tests/auth_flow.rs`. Folded into DESIGN +§2/§14. **Deferred (needs #18):** authenticated *write* endpoints; open self-signup + Discord-OAuth +linking (#19) are additive and don't disturb this session model. `cp-model` unchanged (auth is not a +kind capability). + +### 18. Permissions model — ✅ Done (`design/permissions.md`, ratified 2026-07-11) +Per-channel authorization is a `Permission` capability on `ChannelKind` (opt-in like `Membership`), +**deny-by-default** — a kind with no `Permission` is not writable over HTTP. The fixed `Action` +vocabulary is `View`/`Post`/`Manage`; #18 enforces `Post` only (reads stay open — `View` is defined so +that later change needs no signature churn). Core's `authz::authorize` mirrors `contents::dispatch` +(resolve kind → its policy → default), holding no policy itself; a policy consults the generic +`channel_members` substrate via the new read primitive `StoreCtx::is_member` (`basic` = "members may +post" — answering §14's "is `channel_members` sufficient?" → yes). The first authenticated write endpoint +`POST /api/channels/:id/items` gates on it (`CurrentUser` → 401, authz → 403, unknown type → 400) and +stamps authorship server-side via `ItemKind::with_author` (§2 polymorphic authorship, no core column; +client-supplied author overwritten). Covered by `crates/cp-core/tests/authz.rs` (throwaway kinds, §12) + +`crates/cp-frontend/tests/authenticated_write.rs` (real `basic`/`space` slices, end-to-end) + a compose +box in the `basic` island. Folded into `DESIGN.md` §2/§4/§8/§9/§13/§14. **Deferred (additive):** read +gating (`View`), sub-channel creation over HTTP (`Manage`), a channel kind vetoing child item types, and +resolving authorship up a `linked-users` edge (#19). + +### 19. `linked-users` API — ✅ Done (`design/linked-users.md`, ratified 2026-07-11) +`cp-core::links` (sibling to `auth`) is the edge logic: `link` / `unlink` (type-agnostic — a user ↔ *any* +item, no hardcoded "cached-user" type; validates item existence; idempotent; conflict if the item is +already linked, since a cached-user maps to **≤1** native user via `item_id UNIQUE`), `linked_items` +(forward) and `user_for_item` (reverse — authorship resolution *up* the link, §2). Links are +**operator-provisioned** via the debug shell (`link-user` / `unlink-user`, write-gated; `show links +`) — an identity assertion, so no self-service HTTP write pre-OAuth (same posture as #17). HTTP +exposes reads only: `GET /api/users/:id/links` + `GET /api/items/:id/linked-user`. The `item_id` FK +(`ON DELETE CASCADE`) required moving the table below `items` in the migration. Covered by +`crates/cp-core/tests/links.rs` (throwaway kinds, §12), `crates/cp-frontend/tests/linked_users.rs`, and a +`debug_shell.rs` case. Folded into `DESIGN.md` §2/§3/§9/§14. **Deferred (additive):** **self-service +linking** gated by a **per-kind proof-of-ownership** capability (each external kind verifies ownership its +own way — Discord OAuth, etc.), layered on this same edge without changing storage. ### 20. Deployment wiring — 🟢 Ready (when there is something worth shipping) Follow the `andref-ipfs-depot` precedent: a `path:` flake input from a consumer (the `whale` diff --git a/channel-party/crates/cp-bin/src/main.rs b/channel-party/crates/cp-bin/src/main.rs index 8cb16b1..dc9a74a 100644 --- a/channel-party/crates/cp-bin/src/main.rs +++ b/channel-party/crates/cp-bin/src/main.rs @@ -13,25 +13,40 @@ async fn main() -> anyhow::Result<()> { .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); - let registry = Registry::builder() + let mut builder = Registry::builder() .item(cp_basic::item()) .channel(cp_basic::channel()) .channel(cp_space::channel()) .channels(cp_discord::channels()) .items(cp_discord::items()) - .runtime(cp_discord::sync()) // WriteScope::Primary — ingests messages/users/reactions - .runtime(cp_discord::semantic_index()) // WriteScope::Derived — embeddings, off the stream .channel(cp_canvas::channel()) .item(cp_canvas::text_box()) .runtime(cp_canvas::spatial_index()) // WriteScope::Derived .migrations(cp_discord::MIGRATIONS) - .migrations(cp_canvas::MIGRATIONS) - .build(); + .migrations(cp_canvas::MIGRATIONS); + + // The Discord bridge (#10) registers its Primary ingestor only when configured (a token is set), so + // a tokenless dev/CI boot runs no Discord component. The Derived semantic index (§7c) is deferred. + if let Some(config) = cp_discord::BridgeConfig::from_env() { + builder = builder.runtime(cp_discord::bridge(config).sync()); + } + let registry = builder.build(); // `sqlite:channel-party.db` (created if missing) or `sqlite::memory:`; override with CP_DB. let db_url = std::env::var("CP_DB").unwrap_or_else(|_| "sqlite:channel-party.db".to_owned()); let core = Core::open(&db_url, registry.clone()).await?; - core.spawn_runtime(); + + // `channel-party shell` opens the gated debug REPL against the same DB, then exits (§8). Seed or + // inspect here. A concurrent server on the same DB sees these writes on its next read (direct-read + // `contents` like basic/space hits sqlite), but NOT live: the event bus is per-process, so SSE and + // derived indexes (canvas's R-tree) only reflect writes from the server's own process — or, for a + // shell-seeded DB, at the server's next boot via each component's backfill. + if std::env::args().nth(1).as_deref() == Some("shell") { + return cp_core::debug::run(&core).await; + } + + // Hold the handle for the process lifetime: dropping it aborts the supervised components. + let _runtime = core.spawn_runtime(); let addr: SocketAddr = std::env::var("CP_BIND") .unwrap_or_else(|_| "127.0.0.1:8080".to_owned()) diff --git a/channel-party/crates/cp-core/Cargo.toml b/channel-party/crates/cp-core/Cargo.toml index 57f9e7a..3ebdc9d 100644 --- a/channel-party/crates/cp-core/Cargo.toml +++ b/channel-party/crates/cp-core/Cargo.toml @@ -8,7 +8,20 @@ license.workspace = true cp-model.workspace = true anyhow.workspace = true +argon2.workspace = true async-trait.workspace = true +rand_core.workspace = true +serde_json.workspace = true +sha2.workspace = true sqlx.workspace = true tokio.workspace = true tracing.workspace = true +ulid.workspace = true + +[dev-dependencies] +cp-model.workspace = true +async-trait.workspace = true +serde_json.workspace = true +sqlx.workspace = true +tempfile = "3" +tokio.workspace = true diff --git a/channel-party/crates/cp-core/migrations/0001_init.sql b/channel-party/crates/cp-core/migrations/0001_init.sql index d511d69..4a488f5 100644 --- a/channel-party/crates/cp-core/migrations/0001_init.sql +++ b/channel-party/crates/cp-core/migrations/0001_init.sql @@ -3,16 +3,10 @@ -- so there is no migration-tracking table yet. CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, -- ULID - handle TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -); - --- The `linked-users` edge, bidirectional: a native user <-> a cached-user item. -CREATE TABLE IF NOT EXISTS user_external_links ( - user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, - item_id TEXT NOT NULL, -- references items(id) - PRIMARY KEY (user_id, item_id) + id TEXT PRIMARY KEY, -- ULID + handle TEXT NOT NULL UNIQUE, + password_hash TEXT, -- argon2 PHC string; NULL = no password set (can't log in). §17 + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE TABLE IF NOT EXISTS channels ( @@ -35,3 +29,52 @@ CREATE INDEX IF NOT EXISTS items_container ON items (container); -- Exactly one item per external object (e.g. one cached-user per Discord user). §3. CREATE UNIQUE INDEX IF NOT EXISTS items_external_key ON items (external_key) WHERE external_key IS NOT NULL; + +-- The `linked-users` edge, bidirectional: a native user <-> a cached-user item (§2/§19, +-- `design/linked-users.md`). Created after `items` for the FK. `item_id` is UNIQUE — one external +-- identity resolves up to at most one native user (a user still has many links, one per platform). +CREATE TABLE IF NOT EXISTS user_external_links ( + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + item_id TEXT NOT NULL UNIQUE REFERENCES items (id) ON DELETE CASCADE, + PRIMARY KEY (user_id, item_id) +); + +-- Generic membership substrate (§8), written by ChannelKind::membership() impls via WriteCtx. +-- Members are native principals only (§2): the user_id FK requires a real users row. +CREATE TABLE IF NOT EXISTS channel_members ( + channel_id TEXT NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + PRIMARY KEY (channel_id, user_id) +); + +-- The FTS5 search substrate (DESIGN §6, `design/index-search.md`). A kind's `index(payload)` projects +-- its searchable fields here transactionally on write; `StoreCtx::search` MATCHes it then joins back to +-- channels/items. Standalone (not `content=`) because it spans both super-types. Trigram tokenizer ⇒ +-- true substring ("search box") matching. UNINDEXED routing columns: `envelope_id` keys a row to its +-- envelope, `super_type` selects the join table. No FK, so cascaded deletes orphan rows — harmless, the +-- INNER JOIN in `search` drops orphans (see the design note). +CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( + name, + text, + envelope_id UNINDEXED, + super_type UNINDEXED, + tokenize = 'trigram' +); + +-- Supervisor bookkeeping (DESIGN §7): the last `version()` core observed per runtime component. On +-- boot a differing version means the component should reset (rebuild its derived state). +CREATE TABLE IF NOT EXISTS runtime_component_state ( + name TEXT PRIMARY KEY, + version INTEGER NOT NULL +); + +-- Server-side sessions (DESIGN §2, `design/auth.md`, #17). A login mints an opaque random token given +-- to the browser in an HttpOnly cookie; only its SHA-256 is stored here, so a DB leak exposes no live +-- token. Revocation = delete the row. Expiry is a plain string comparison (sqlite `datetime`). +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, -- SHA-256 hex of the cookie token + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS sessions_user ON sessions (user_id); diff --git a/channel-party/crates/cp-core/src/auth.rs b/channel-party/crates/cp-core/src/auth.rs new file mode 100644 index 0000000..029b262 --- /dev/null +++ b/channel-party/crates/cp-core/src/auth.rs @@ -0,0 +1,159 @@ +//! Native-user authentication + server-side sessions (DESIGN §2, `design/auth.md`, `TODO.md` #17). +//! Auth resolves exclusively against the `users` substrate — the one principal (§2). Passwords are +//! argon2id PHC strings in `users.password_hash`; a login mints an opaque random token handed to the +//! browser in a cookie, of which only the SHA-256 is stored (a DB leak exposes no live token). The +//! HTTP/cookie layer lives in `cp-frontend`; this module is the store logic both it and the debug +//! shell call. Accounts are *provisioned* (shell `set-password`) — there is no public registration. + +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; +use cp_model::{Error, Result, User, UserId}; +use rand_core::{OsRng, RngCore}; +use sha2::{Digest, Sha256}; +use sqlx::sqlite::SqliteRow; +use sqlx::{Row, SqlitePool}; + +fn db(e: sqlx::Error) -> Error { + Error::Other(e.to_string()) +} + +fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| Error::Other(format!("password hashing failed: {e}"))) +} + +/// Verify a password against a stored PHC string; any parse/verify failure is a plain non-match. +fn verify_password(password: &str, phc: &str) -> bool { + PasswordHash::new(phc) + .and_then(|parsed| Argon2::default().verify_password(password.as_bytes(), &parsed)) + .is_ok() +} + +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write; + bytes + .iter() + .fold(String::with_capacity(bytes.len() * 2), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex(&hasher.finalize()) +} + +/// A fresh 256-bit opaque token (hex), from the OS CSPRNG. This is the cookie value; the DB keys on its +/// hash, never this. +fn random_token() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + hex(&bytes) +} + +fn user_from_row(row: &SqliteRow) -> Result { + Ok(User { + id: row + .try_get::("id") + .map_err(db)? + .parse::() + .map_err(|_| Error::Other("invalid user id".to_owned()))?, + handle: row.try_get("handle").map_err(db)?, + }) +} + +/// Insert a native user (no password yet — inert until `set_password`). The provisioning primitive +/// behind the shell's `create-user`; users are the fixed substrate, minted here rather than through the +/// envelope API (§2/§8). Errors (e.g. a duplicate handle) surface as `Other`. +pub async fn provision_user(pool: &SqlitePool, handle: &str) -> Result { + let id = UserId::generate(); + sqlx::query("INSERT INTO users (id, handle) VALUES (?, ?)") + .bind(id.to_string()) + .bind(handle) + .execute(pool) + .await + .map_err(db)?; + Ok(id) +} + +/// Set (or replace) a user's password. `NotFound` if no user has that handle. The provisioning path +/// (debug shell `set-password`) — there is no public registration. §17. +pub async fn set_password(pool: &SqlitePool, handle: &str, password: &str) -> Result<()> { + let hash = hash_password(password)?; + let affected = sqlx::query("UPDATE users SET password_hash = ? WHERE handle = ?") + .bind(hash) + .bind(handle) + .execute(pool) + .await + .map_err(db)? + .rows_affected(); + if affected == 0 { + return Err(Error::NotFound); + } + Ok(()) +} + +/// Verify credentials, returning the `User` on success. `None` for an unknown handle, a wrong password, +/// or a user with no password set (a bare `create-user` account is inert until `set-password`). +pub async fn authenticate(pool: &SqlitePool, handle: &str, password: &str) -> Result> { + let row = sqlx::query("SELECT id, handle, password_hash FROM users WHERE handle = ?") + .bind(handle) + .fetch_optional(pool) + .await + .map_err(db)?; + let Some(row) = row else { + return Ok(None); + }; + let hash: Option = row.try_get("password_hash").map_err(db)?; + let Some(hash) = hash else { + return Ok(None); + }; + if verify_password(password, &hash) { + Ok(Some(user_from_row(&row)?)) + } else { + Ok(None) + } +} + +/// Mint a session for a user, returning the plaintext token to set as the cookie. §17. +pub async fn create_session(pool: &SqlitePool, user_id: UserId) -> Result { + let token = random_token(); + sqlx::query( + "INSERT INTO sessions (token_hash, user_id, expires_at) \ + VALUES (?, ?, datetime('now', '+30 days'))", + ) + .bind(sha256_hex(token.as_bytes())) + .bind(user_id.to_string()) + .execute(pool) + .await + .map_err(db)?; + Ok(token) +} + +/// Resolve a cookie token to its user, or `None` if the session is unknown or expired. §17. +pub async fn resolve_session(pool: &SqlitePool, token: &str) -> Result> { + let row = sqlx::query( + "SELECT u.id, u.handle FROM sessions s JOIN users u ON u.id = s.user_id \ + WHERE s.token_hash = ? AND s.expires_at > datetime('now')", + ) + .bind(sha256_hex(token.as_bytes())) + .fetch_optional(pool) + .await + .map_err(db)?; + row.as_ref().map(user_from_row).transpose() +} + +/// Revoke a session (logout). A no-op if the token is unknown. §17. +pub async fn delete_session(pool: &SqlitePool, token: &str) -> Result<()> { + sqlx::query("DELETE FROM sessions WHERE token_hash = ?") + .bind(sha256_hex(token.as_bytes())) + .execute(pool) + .await + .map_err(db)?; + Ok(()) +} diff --git a/channel-party/crates/cp-core/src/authz.rs b/channel-party/crates/cp-core/src/authz.rs new file mode 100644 index 0000000..78891fb --- /dev/null +++ b/channel-party/crates/cp-core/src/authz.rs @@ -0,0 +1,25 @@ +//! Authorization dispatch (DESIGN §18, `design/permissions.md`). One generic resolver, mirroring +//! `contents::dispatch`: it resolves the channel's kind and asks its `Permission` capability. Core holds +//! no policy — the answer is the kind's. Deny-by-default: a kind with no `Permission` authorizes no one. + +use cp_model::{Action, Channel, Result, StoreCtx, UserId}; + +use crate::registry::Registry; + +/// May `user` perform `action` on `channel`? `Ok(false)` when the channel's kind is unknown or declares +/// no `Permission` capability (deny-by-default, §18); otherwise the kind's own decision. +pub async fn authorize( + registry: &Registry, + store: &dyn StoreCtx, + channel: &Channel, + user: UserId, + action: Action, +) -> Result { + match registry + .channel(&channel.type_id) + .and_then(|kind| kind.permission()) + { + Some(policy) => policy.authorize(store, channel, user, action).await, + None => Ok(false), + } +} diff --git a/channel-party/crates/cp-core/src/debug.rs b/channel-party/crates/cp-core/src/debug.rs index c996ca5..ce8e2b4 100644 --- a/channel-party/crates/cp-core/src/debug.rs +++ b/channel-party/crates/cp-core/src/debug.rs @@ -1,13 +1,21 @@ //! The gated debug shell: a thin read wrapper over the database by default, with an -//! explicitly-gated write surface that is off by default, per-session, and never persisted. Writes -//! route through the mutation API + the kind's `validate`, never raw SQL. See DESIGN §8. -//! -//! The scaffold provides the mode gate and prompt; command evaluation (read commands over the -//! store, capability-backed + kind-registered write commands) is deferred. +//! explicitly-gated write surface that is off by default, per-session, and never persisted. Reads are +//! direct DB queries (an honest view of what is actually stored); writes route through core's mutation +//! API + the kind's `validate`, never raw SQL — the one exception is `create-user`, a bootstrap for the +//! fixed `users` substrate until auth (`TODO.md` #17). See DESIGN §8. -use cp_model::DebugAccess; +use std::sync::Arc; + +use cp_model::{ + Channel, ChannelId, DebugAccess, Item, ItemId, Json, Membership, NewChannel, NewItem, TypeId, + UserId, WriteCtx, +}; +use sqlx::sqlite::SqliteRow; +use sqlx::Row; use crate::registry::Registry; +use crate::store::Store; +use crate::Core; /// The shell's per-session write mode. A fresh shell is always read-only. §8. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -19,13 +27,15 @@ pub enum Mode { /// A read-only-by-default REPL over the store with a gated write surface. §8. pub struct DebugShell { registry: Registry, + store: Arc, mode: Mode, } impl DebugShell { - pub fn new(registry: Registry) -> Self { + pub fn new(registry: Registry, store: Arc) -> Self { Self { registry, + store, mode: Mode::ReadOnly, } } @@ -58,4 +68,638 @@ impl DebugShell { pub fn registry(&self) -> &Registry { &self.registry } + + /// Parse and run one input line, returning the text to print. Never panics on bad input; errors + /// (including the write-mode gate) come back as a message. + pub async fn eval(&mut self, line: &str) -> String { + match self.run(line).await { + Ok(out) => out, + Err(msg) => msg, + } + } + + async fn run(&mut self, line: &str) -> Result { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return Ok(String::new()); + } + let (cmd, rest) = split_first(line); + match cmd { + "help" => Ok(help_text()), + "enable-write-mode" => { + self.mode = Mode::Write; + Ok("write mode ON — mutations enabled for this session".to_owned()) + } + "disable-write-mode" => { + self.mode = Mode::ReadOnly; + Ok("write mode OFF".to_owned()) + } + "show" => self.cmd_show(rest).await, + "inspect" => self.cmd_inspect(rest.trim()).await, + "members" => self.cmd_members(rest.trim()).await, + "create-user" => { + self.require_write()?; + self.cmd_create_user(rest.trim()).await + } + "set-password" => { + self.require_write()?; + self.cmd_set_password(rest).await + } + "create-channel" => { + self.require_write()?; + self.cmd_create_channel(rest).await + } + "create-item" => { + self.require_write()?; + self.cmd_create_item(rest).await + } + "set-payload" => { + self.require_write()?; + self.cmd_set_payload(rest).await + } + "delete" => { + self.require_write()?; + self.cmd_delete(rest.trim()).await + } + "reparent" => { + self.require_write()?; + self.cmd_reparent(rest).await + } + "add-user-to-channel" => { + self.require_write()?; + self.cmd_add_user(rest, true).await + } + "remove-user-from-channel" => { + self.require_write()?; + self.cmd_add_user(rest, false).await + } + "link-user" => { + self.require_write()?; + self.cmd_link(rest, true).await + } + "unlink-user" => { + self.require_write()?; + self.cmd_link(rest, false).await + } + other => Err(format!("unknown command `{other}` — try `help`")), + } + } + + fn require_write(&self) -> Result<(), String> { + if self.permits(DebugAccess::Write) { + Ok(()) + } else { + Err("read-only; run enable-write-mode first".to_owned()) + } + } + + // --- reads (direct DB queries, §8) ------------------------------------------------------------ + + async fn cmd_show(&self, rest: &str) -> Result { + let (sub, arg) = split_first(rest.trim()); + match sub { + "channels" => self.show_channels().await, + "items" => self.show_items(arg.trim()).await, + "users" => self.show_users().await, + "links" => self.show_links(arg.trim()).await, + "" => Err( + "usage: show | users | links >".to_owned(), + ), + _ => Err(format!("unknown `show {sub}` — try `help`")), + } + } + + async fn show_channels(&self) -> Result { + let rows = sqlx::query("SELECT id, type_id, container, payload FROM channels ORDER BY id") + .fetch_all(self.store.pool()) + .await + .map_err(sql_err)?; + if rows.is_empty() { + return Ok("(no channels)".to_owned()); + } + let mut out = Vec::new(); + for row in &rows { + let ch = channel_from_row(row)?; + let container = ch + .container + .map_or_else(|| "(root)".to_owned(), |c| c.to_string()); + let summary = self + .registry + .channel(&ch.type_id) + .and_then(|k| k.debug_summary(&ch)); + let mut line = format!("{} type={} container={container}", ch.id, ch.type_id); + if let Some(s) = summary { + line.push_str(&format!(" — {s}")); + } + out.push(line); + } + Ok(out.join("\n")) + } + + async fn show_items(&self, cid: &str) -> Result { + let container = parse_channel_id(cid)?; + let rows = sqlx::query( + "SELECT id, type_id, container, external_key, payload FROM items \ + WHERE container = ? ORDER BY id", + ) + .bind(container.to_string()) + .fetch_all(self.store.pool()) + .await + .map_err(sql_err)?; + if rows.is_empty() { + return Ok("(no items)".to_owned()); + } + let mut out = Vec::new(); + for row in &rows { + let item = item_from_row(row)?; + let summary = self + .registry + .item(&item.type_id) + .and_then(|k| k.debug_summary(&item)); + let mut line = format!("{} type={}", item.id, item.type_id); + if let Some(s) = summary { + line.push_str(&format!(" — {s}")); + } + out.push(line); + } + Ok(out.join("\n")) + } + + async fn show_users(&self) -> Result { + let rows = sqlx::query("SELECT id, handle FROM users ORDER BY id") + .fetch_all(self.store.pool()) + .await + .map_err(sql_err)?; + if rows.is_empty() { + return Ok("(no users)".to_owned()); + } + rows.iter() + .map(|r| Ok(format!("{} @{}", str_col(r, "id")?, str_col(r, "handle")?))) + .collect::, String>>() + .map(|v| v.join("\n")) + } + + async fn show_links(&self, handle: &str) -> Result { + if handle.is_empty() { + return Err("usage: show links ".to_owned()); + } + let user = self.user_id_by_handle(handle).await?; + let items = crate::links::linked_items(self.store.pool(), user) + .await + .map_err(core_err)?; + if items.is_empty() { + return Ok("(no links)".to_owned()); + } + Ok(items + .iter() + .map(|i| format!("{} type={}", i.id, i.type_id)) + .collect::>() + .join("\n")) + } + + async fn cmd_inspect(&self, id: &str) -> Result { + if id.is_empty() { + return Err("usage: inspect ".to_owned()); + } + if let Ok(cid) = id.parse::() { + if let Some(ch) = self.store.get_channel(cid).await.map_err(core_err)? { + let summary = self + .registry + .channel(&ch.type_id) + .and_then(|k| k.debug_summary(&ch)); + let container = ch + .container + .map_or_else(|| "(root)".to_owned(), |c| c.to_string()); + return Ok(format_envelope( + "channel", + &ch.id.to_string(), + &ch.type_id, + &container, + None, + summary, + &ch.payload, + )); + } + } + if let Ok(iid) = id.parse::() { + if let Some(item) = self.store.get_item(iid).await.map_err(core_err)? { + let summary = self + .registry + .item(&item.type_id) + .and_then(|k| k.debug_summary(&item)); + let container = item + .container + .map_or_else(|| "(none)".to_owned(), |c| c.to_string()); + return Ok(format_envelope( + "item", + &item.id.to_string(), + &item.type_id, + &container, + item.external_key.as_deref(), + summary, + &item.payload, + )); + } + } + Err(format!("no channel or item with id `{id}`")) + } + + async fn cmd_members(&self, cid: &str) -> Result { + let (ch, membership) = self.resolve_membership(cid).await?; + let users = membership + .members(self.store.as_ref(), &ch) + .await + .map_err(core_err)?; + if users.is_empty() { + return Ok("(no members)".to_owned()); + } + Ok(users + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n")) + } + + // --- writes (through the mutation API, §8) ---------------------------------------------------- + + async fn cmd_create_channel(&self, rest: &str) -> Result { + let (type_id, payload) = split_first(rest.trim()); + if type_id.is_empty() { + return Err("usage: create-channel ".to_owned()); + } + let id = self + .store + .create_channel(NewChannel { + type_id: TypeId::new(type_id), + container: None, + payload: parse_json(payload)?, + }) + .await + .map_err(core_err)?; + Ok(format!("created channel {id}")) + } + + async fn cmd_create_item(&self, rest: &str) -> Result { + let (cid, tail) = split_first(rest.trim()); + let (type_id, payload) = split_first(tail); + if cid.is_empty() || type_id.is_empty() { + return Err("usage: create-item ".to_owned()); + } + let id = self + .store + .create_item(NewItem { + type_id: TypeId::new(type_id), + container: Some(parse_channel_id(cid)?), + external_key: None, + payload: parse_json(payload)?, + }) + .await + .map_err(core_err)?; + Ok(format!("created item {id}")) + } + + async fn cmd_set_payload(&self, rest: &str) -> Result { + let (id, payload) = split_first(rest.trim()); + if id.is_empty() { + return Err("usage: set-payload ".to_owned()); + } + let payload = parse_json(payload)?; + if let Ok(cid) = id.parse::() { + if self + .store + .get_channel(cid) + .await + .map_err(core_err)? + .is_some() + { + self.store + .set_channel_payload(cid, payload) + .await + .map_err(core_err)?; + return Ok(format!("updated channel {cid}")); + } + } + if let Ok(iid) = id.parse::() { + if self.store.get_item(iid).await.map_err(core_err)?.is_some() { + self.store + .set_item_payload(iid, payload) + .await + .map_err(core_err)?; + return Ok(format!("updated item {iid}")); + } + } + Err(format!("no channel or item with id `{id}`")) + } + + async fn cmd_delete(&self, id: &str) -> Result { + if let Ok(cid) = id.parse::() { + if self + .store + .get_channel(cid) + .await + .map_err(core_err)? + .is_some() + { + self.store.delete_channel(cid).await.map_err(core_err)?; + return Ok(format!("deleted channel {cid} (and its subtree)")); + } + } + if let Ok(iid) = id.parse::() { + if self.store.get_item(iid).await.map_err(core_err)?.is_some() { + self.store.delete_item(iid).await.map_err(core_err)?; + return Ok(format!("deleted item {iid}")); + } + } + Err(format!("no channel or item with id `{id}`")) + } + + async fn cmd_reparent(&self, rest: &str) -> Result { + // Move a channel/item under a new container (or `root` for none). The only way to build a + // hierarchy interactively — e.g. put `basic` rooms inside a `space` so its search finds them. + let (id, target) = split_first(rest.trim()); + if id.is_empty() || target.trim().is_empty() { + return Err("usage: reparent ".to_owned()); + } + let container = match target.trim() { + "root" => None, + c => Some(parse_channel_id(c)?), + }; + let dest = container.map_or_else(|| "(root)".to_owned(), |c| c.to_string()); + // A ULID parses as both id types, so confirm which table it is (get_* is a point read) before + // choosing the mutation, exactly as `set-payload`/`delete` do. + if let Ok(cid) = id.parse::() { + if self + .store + .get_channel(cid) + .await + .map_err(core_err)? + .is_some() + { + self.store + .reparent_channel(cid, container) + .await + .map_err(core_err)?; + return Ok(format!("reparented channel {cid} -> {dest}")); + } + } + if let Ok(iid) = id.parse::() { + if self.store.get_item(iid).await.map_err(core_err)?.is_some() { + self.store + .reparent_item(iid, container) + .await + .map_err(core_err)?; + return Ok(format!("reparented item {iid} -> {dest}")); + } + } + Err(format!("no channel or item with id `{id}`")) + } + + async fn cmd_set_password(&self, rest: &str) -> Result { + // Provision login for a native user (accounts are shell-provisioned; there is no public + // registration — §17). The password is the remainder of the line. + let (handle, password) = split_first(rest.trim()); + if handle.is_empty() || password.trim().is_empty() { + return Err("usage: set-password ".to_owned()); + } + crate::auth::set_password(self.store.pool(), handle, password.trim()) + .await + .map_err(core_err)?; + Ok(format!("password set for @{handle}")) + } + + async fn cmd_create_user(&self, handle: &str) -> Result { + if handle.is_empty() || handle.contains(char::is_whitespace) { + return Err("usage: create-user ".to_owned()); + } + // Bootstrap a native user (the fixed substrate, not an extensible envelope). Give it a login + // with `set-password` (§2/§8/§17). + let id = crate::auth::provision_user(self.store.pool(), handle) + .await + .map_err(core_err)?; + Ok(format!("created user {id} (@{handle})")) + } + + async fn cmd_add_user(&self, rest: &str, add: bool) -> Result { + let (cid, uid) = split_first(rest.trim()); + if cid.is_empty() || uid.trim().is_empty() { + return Err(format!( + "usage: {} ", + if add { + "add-user-to-channel" + } else { + "remove-user-from-channel" + } + )); + } + let user = parse_user_id(uid.trim())?; + let (ch, membership) = self.resolve_membership(cid).await?; + let cx: &dyn WriteCtx = self.store.as_ref(); + if add { + membership.add_user(cx, &ch, user).await.map_err(core_err)?; + Ok(format!("added user {user} to channel {}", ch.id)) + } else { + membership + .remove_user(cx, &ch, user) + .await + .map_err(core_err)?; + Ok(format!("removed user {user} from channel {}", ch.id)) + } + } + + async fn cmd_link(&self, rest: &str, add: bool) -> Result { + // Operator-provisioned `linked-users` (§2/§19): link a native user to an external cached-user + // item. Pre-OAuth this is an operator-trusted assertion — there is no self-service HTTP write. + let (handle, item) = split_first(rest.trim()); + if handle.is_empty() || item.trim().is_empty() { + return Err(format!( + "usage: {} ", + if add { "link-user" } else { "unlink-user" } + )); + } + let user = self.user_id_by_handle(handle).await?; + let item_id = item + .trim() + .parse::() + .map_err(|_| format!("invalid item id `{}`", item.trim()))?; + if add { + crate::links::link(self.store.pool(), user, item_id) + .await + .map_err(core_err)?; + Ok(format!("linked @{handle} -> item {item_id}")) + } else { + crate::links::unlink(self.store.pool(), user, item_id) + .await + .map_err(core_err)?; + Ok(format!("unlinked @{handle} -> item {item_id}")) + } + } + + /// Resolve a user handle to its id, or a "no user" refusal. + async fn user_id_by_handle(&self, handle: &str) -> Result { + let row = sqlx::query("SELECT id FROM users WHERE handle = ?") + .bind(handle) + .fetch_optional(self.store.pool()) + .await + .map_err(sql_err)? + .ok_or_else(|| format!("no user with handle `{handle}`"))?; + parse_user_id(&str_col(&row, "id")?) + } + + /// Load a channel and its `Membership` capability, or the §8 "does not accept users" refusal. + async fn resolve_membership(&self, cid: &str) -> Result<(Channel, &dyn Membership), String> { + let channel_id = parse_channel_id(cid)?; + let ch = self + .store + .get_channel(channel_id) + .await + .map_err(core_err)? + .ok_or_else(|| format!("no channel with id `{cid}`"))?; + let kind = self + .registry + .channel(&ch.type_id) + .ok_or_else(|| format!("unregistered channel type `{}`", ch.type_id))?; + match kind.membership() { + Some(m) => Ok((ch, m)), + None => Err(format!( + "channel-type `{}` does not accept users", + ch.type_id + )), + } + } +} + +/// Run the interactive REPL against a core, reading stdin until EOF. Prompts + banner go to stderr so +/// stdout carries only command output (scriptable: `printf '…' | channel-party shell`). §8. +pub async fn run(core: &Core) -> anyhow::Result<()> { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let mut shell = DebugShell::new(core.registry().clone(), core.store()); + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + let mut err = tokio::io::stderr(); + err.write_all(b"channel-party debug shell. `help` for commands; Ctrl-D to exit.\n") + .await?; + err.write_all(shell.prompt().as_bytes()).await?; + err.flush().await?; + while let Some(line) = lines.next_line().await? { + let out = shell.eval(&line).await; + if !out.is_empty() { + println!("{out}"); + } + err.write_all(shell.prompt().as_bytes()).await?; + err.flush().await?; + } + err.write_all(b"\n").await?; + Ok(()) +} + +fn help_text() -> String { + // Built-in commands only. Kind-contributed `debug_commands()` (§8) list/execute once a kind ships + // one (e.g. canvas `move-box`, #11); that needs a registry enumerator + a kind execution hook, + // deferred until then. + [ + "reads (always available):", + " show channels list every channel", + " show items list a channel's items", + " show users list native users", + " show links list a user's linked external items (#19)", + " inspect dump one envelope (channel or item)", + " members list a channel's members", + "mode:", + " enable-write-mode / disable-write-mode", + "writes (require write mode):", + " create-channel ", + " create-item ", + " set-payload replace a channel/item payload", + " delete delete a channel (subtree) or item", + " reparent move a channel/item under a new container", + " create-user bootstrap a native user", + " set-password set a user's login password (#17)", + " add-user-to-channel ", + " remove-user-from-channel ", + " link-user link a user to an external cached-user item (#19)", + " unlink-user remove that link", + ] + .join("\n") +} + +fn format_envelope( + kind: &str, + id: &str, + type_id: &TypeId, + container: &str, + external_key: Option<&str>, + summary: Option, + payload: &Json, +) -> String { + let mut out = format!("{kind} {id}\n type_id: {type_id}\n container: {container}"); + if let Some(key) = external_key { + out.push_str(&format!("\n ext_key: {key}")); + } + if let Some(s) = summary { + out.push_str(&format!("\n summary: {s}")); + } + let json = serde_json::to_string_pretty(payload).unwrap_or_else(|_| payload.to_string()); + out.push_str(&format!("\n payload: {json}")); + out +} + +/// Split a line into its first whitespace-delimited word and the trimmed remainder (which may itself +/// contain whitespace, e.g. a JSON payload). +fn split_first(s: &str) -> (&str, &str) { + let s = s.trim_start(); + match s.find(char::is_whitespace) { + Some(i) => (&s[..i], s[i..].trim_start()), + None => (s, ""), + } +} + +fn sql_err(e: sqlx::Error) -> String { + e.to_string() +} + +fn core_err(e: cp_model::Error) -> String { + e.to_string() +} + +fn parse_json(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err("missing JSON payload".to_owned()); + } + serde_json::from_str(s).map_err(|e| format!("invalid JSON: {e}")) +} + +fn parse_channel_id(s: &str) -> Result { + s.parse().map_err(|_| format!("invalid channel id `{s}`")) +} + +fn parse_user_id(s: &str) -> Result { + s.parse().map_err(|_| format!("invalid user id `{s}`")) +} + +fn str_col(row: &SqliteRow, col: &str) -> Result { + row.try_get::(col).map_err(sql_err) +} + +fn channel_from_row(row: &SqliteRow) -> Result { + let container: Option = row.try_get("container").map_err(sql_err)?; + Ok(Channel { + id: parse_channel_id(&str_col(row, "id")?)?, + type_id: TypeId::new(str_col(row, "type_id")?), + container: container.as_deref().map(parse_channel_id).transpose()?, + payload: parse_json(&str_col(row, "payload")?)?, + }) +} + +fn item_from_row(row: &SqliteRow) -> Result { + let container: Option = row.try_get("container").map_err(sql_err)?; + Ok(Item { + id: str_col(row, "id")? + .parse() + .map_err(|_| "invalid item id".to_owned())?, + type_id: TypeId::new(str_col(row, "type_id")?), + container: container.as_deref().map(parse_channel_id).transpose()?, + external_key: row.try_get("external_key").map_err(sql_err)?, + payload: parse_json(&str_col(row, "payload")?)?, + }) } diff --git a/channel-party/crates/cp-core/src/events.rs b/channel-party/crates/cp-core/src/events.rs index fcef174..801e062 100644 --- a/channel-party/crates/cp-core/src/events.rs +++ b/channel-party/crates/cp-core/src/events.rs @@ -1,16 +1,12 @@ -//! The core event bus. The sync component writes envelopes and emits change events; derived -//! indexers and the frontend SSE consume them — independent tasks, so a slow pipeline never blocks -//! ingestion. See DESIGN §7/§9. +//! The core event bus. The write path emits a `ChangeEvent` after every committed mutation; +//! derived indexers and the frontend SSE consume the stream — independent tasks, so a slow +//! pipeline never blocks the write. The event *types* live in `cp-model` (so a kind's runtime +//! component can consume them without depending on `cp-core`); the bus is the mechanism. §7/§9. -use cp_model::{ChannelId, TypeId}; use tokio::sync::broadcast; -/// A change event: which envelope type changed, and the channel scope it belongs to (if any). §7. -#[derive(Clone, Debug)] -pub struct ChangeEvent { - pub type_id: TypeId, - pub scope: Option, -} +// Re-exported so existing `cp_core::events::ChangeEvent` / `cp_core::ChangeEvent` paths keep working. +pub use cp_model::{ChangeEvent, ChangeOp, EnvelopeRef}; /// A multi-producer / multi-consumer change bus. §7/§9. #[derive(Clone)] diff --git a/channel-party/crates/cp-core/src/index.rs b/channel-party/crates/cp-core/src/index.rs index ca2f981..561ff6f 100644 --- a/channel-party/crates/cp-core/src/index.rs +++ b/channel-party/crates/cp-core/src/index.rs @@ -1,13 +1,62 @@ -//! Index substrates. `index(payload) -> IndexEntry` (DESIGN §6) is a pure function applied -//! transactionally on write into core's built-in substrates: FTS5 (trigram tokenizer -> true -//! substring search) for name/body text, expression indexes for declared sort keys, and a 2D / -//! R-tree substrate for coordinates. The projection type lives in `cp-model`; wiring it into the -//! substrates is deferred slice work. - -use cp_model::IndexEntry; - -/// Write a kind's inline projection into the index substrates, transactionally with the envelope -/// write. §6. Stub: the substrates are not yet created. -pub fn write_entry(_entry: &IndexEntry) { - // TODO(§6): FTS5 upsert (name/text), sort-key expression index, R-tree coord. +//! Index substrates. `index(payload) -> IndexEntry` (DESIGN §6) is written transactionally with the +//! envelope by the write path. This implements the **FTS5** substrate (`search_index`, trigram) for +//! `name`/`text`, which `StoreCtx::search` queries. The other two `IndexEntry` fields have no consumer +//! yet: `sort_key` (expression index) and `coord` (R-tree) wait on `canvas` (`TODO.md` #11) and are +//! ignored here rather than built speculatively. See `design/index-search.md`. + +use cp_model::{IndexEntry, Result}; + +use crate::events::EnvelopeRef; + +/// The `(super_type, envelope_id)` a row is keyed by — both derived from the `EnvelopeRef`, so no +/// extra argument is needed on the write path. +fn key(target: EnvelopeRef) -> (&'static str, String) { + match target { + EnvelopeRef::Channel(id) => ("channel", id.to_string()), + EnvelopeRef::Item(id) => ("item", id.to_string()), + } +} + +fn db(e: sqlx::Error) -> cp_model::Error { + cp_model::Error::Other(e.to_string()) +} + +/// Write a kind's inline projection into `search_index`, in the caller's transaction. §6. FTS5 has no +/// unique constraint, so this is delete-then-insert keyed by envelope; an entry with neither `name` nor +/// `text` inserts no row (nothing to search). `sort_key`/`coord` are #11's substrates, ignored here. +pub async fn upsert( + tx: &mut sqlx::SqliteConnection, + target: EnvelopeRef, + entry: &IndexEntry, +) -> Result<()> { + delete(tx, target).await?; + if entry.name.is_none() && entry.text.is_none() { + return Ok(()); + } + let (super_type, id) = key(target); + sqlx::query( + "INSERT INTO search_index (name, text, envelope_id, super_type) VALUES (?, ?, ?, ?)", + ) + .bind(entry.name.as_deref()) + .bind(entry.text.as_deref()) + .bind(id) + .bind(super_type) + .execute(&mut *tx) + .await + .map_err(db)?; + Ok(()) +} + +/// Remove an envelope's `search_index` row (on delete/before re-upsert), in the caller's transaction. +/// Only the directly-targeted envelope is purged; FK-cascaded children orphan their rows, which the +/// INNER JOIN in `search` renders invisible (see the design note). §6. +pub async fn delete(tx: &mut sqlx::SqliteConnection, target: EnvelopeRef) -> Result<()> { + let (super_type, id) = key(target); + sqlx::query("DELETE FROM search_index WHERE envelope_id = ? AND super_type = ?") + .bind(id) + .bind(super_type) + .execute(&mut *tx) + .await + .map_err(db)?; + Ok(()) } diff --git a/channel-party/crates/cp-core/src/lib.rs b/channel-party/crates/cp-core/src/lib.rs index 46286af..2b1cb75 100644 --- a/channel-party/crates/cp-core/src/lib.rs +++ b/channel-party/crates/cp-core/src/lib.rs @@ -7,10 +7,13 @@ //! wired in exactly one place, the composition root (`cp-bin`). Store primitives and several //! mechanisms are stubbed in the scaffold; DESIGN §5/§6/§7/§8 fill them in. +pub mod auth; +pub mod authz; pub mod contents; pub mod debug; pub mod events; pub mod index; +pub mod links; pub mod migrate; pub mod registry; pub mod runtime; @@ -23,7 +26,7 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::SqlitePool; pub use cp_model::{Migration, Migrations}; -pub use events::{ChangeEvent, EventBus}; +pub use events::{ChangeEvent, ChangeOp, EnvelopeRef, EventBus}; pub use registry::{Registry, RegistryBuilder}; pub use store::Store; @@ -39,15 +42,20 @@ impl Core { /// Open the store at `db_url` (a sqlite URL, e.g. `sqlite:channel-party.db` or /// `sqlite::memory:`), run the core + kind migrations, and return a ready Core. §3/§10. pub async fn open(db_url: &str, registry: Registry) -> anyhow::Result { - let opts = SqliteConnectOptions::from_str(db_url)?.create_if_missing(true); + // `foreign_keys(true)` makes the `container` FK + `ON DELETE CASCADE` (and the + // channel_members FKs) actually enforce — the write path relies on it. §3. + let opts = SqliteConnectOptions::from_str(db_url)? + .create_if_missing(true) + .foreign_keys(true); let pool = SqlitePoolOptions::new().connect_with(opts).await?; migrate::run(&pool, ®istry).await?; - let store = Arc::new(Store::new(pool.clone(), registry.clone())); + let events = EventBus::new(); + let store = Arc::new(Store::new(pool.clone(), registry.clone(), events.clone())); Ok(Self { pool, registry, store, - events: EventBus::new(), + events, }) } @@ -68,8 +76,15 @@ impl Core { &self.pool } - /// Supervise every registered `RuntimeComponent` (backfill-then-stream). §7/§10. - pub fn spawn_runtime(&self) { - runtime::spawn(self.registry.clone()); + /// Supervise every registered `RuntimeComponent` (backfill-then-stream). Returns a handle that + /// keeps the tasks alive — dropping it aborts them, so the caller must hold it. §7/§10. + #[must_use] + pub fn spawn_runtime(&self) -> runtime::RuntimeHandle { + runtime::spawn( + self.registry.clone(), + self.store.clone(), + self.events.clone(), + self.pool.clone(), + ) } } diff --git a/channel-party/crates/cp-core/src/links.rs b/channel-party/crates/cp-core/src/links.rs new file mode 100644 index 0000000..3fa3006 --- /dev/null +++ b/channel-party/crates/cp-core/src/links.rs @@ -0,0 +1,117 @@ +//! The `linked-users` edge + authorship resolution (DESIGN §2/§3, `design/linked-users.md`, `TODO.md` +//! #19). A native `User` links to the external `cached-user` items that represent it; authorship on a +//! `cached-message` resolves *up* such a link to the native user. Core is type-agnostic here — it links a +//! user to *an item*, never checking the item is a "cached-user" (that is the caller's semantics, §13). +//! Links are operator-provisioned (the debug shell); this module is the store logic it and the read +//! endpoints call. Sibling to `auth`. + +use cp_model::{Error, Item, ItemId, Result, TypeId, User, UserId}; +use sqlx::sqlite::SqliteRow; +use sqlx::{Row, SqlitePool}; + +fn db(e: sqlx::Error) -> Error { + Error::Other(e.to_string()) +} + +fn item_from_row(row: &SqliteRow) -> Result { + let container: Option = row.try_get("container").map_err(db)?; + Ok(Item { + id: row + .try_get::("id") + .map_err(db)? + .parse::() + .map_err(|_| Error::Other("invalid item id".to_owned()))?, + type_id: TypeId::new(row.try_get::("type_id").map_err(db)?), + container: container + .map(|c| c.parse()) + .transpose() + .map_err(|_| Error::Other("invalid container id".to_owned()))?, + external_key: row.try_get("external_key").map_err(db)?, + payload: serde_json::from_str(&row.try_get::("payload").map_err(db)?) + .map_err(|e| Error::Other(e.to_string()))?, + }) +} + +fn user_from_row(row: &SqliteRow) -> Result { + Ok(User { + id: row + .try_get::("id") + .map_err(db)? + .parse::() + .map_err(|_| Error::Other("invalid user id".to_owned()))?, + handle: row.try_get("handle").map_err(db)?, + }) +} + +/// Link a native user to an external item. `NotFound` if the item does not exist. Idempotent for the +/// *same* user; a `Validation` conflict if the item is already linked to a *different* user (an external +/// identity resolves up to at most one native user — §2). Never silently ignores that conflict. +pub async fn link(pool: &SqlitePool, user: UserId, item: ItemId) -> Result<()> { + if !item_exists(pool, item).await? { + return Err(Error::NotFound); + } + if let Some(existing) = user_for_item(pool, item).await? { + if existing.id == user { + return Ok(()); // already linked to this user — idempotent + } + return Err(Error::Validation(format!( + "item {item} is already linked to @{}", + existing.handle + ))); + } + sqlx::query("INSERT INTO user_external_links (user_id, item_id) VALUES (?, ?)") + .bind(user.to_string()) + .bind(item.to_string()) + .execute(pool) + .await + .map_err(db)?; + Ok(()) +} + +/// Remove a link. A no-op if it does not exist. +pub async fn unlink(pool: &SqlitePool, user: UserId, item: ItemId) -> Result<()> { + sqlx::query("DELETE FROM user_external_links WHERE user_id = ? AND item_id = ?") + .bind(user.to_string()) + .bind(item.to_string()) + .execute(pool) + .await + .map_err(db)?; + Ok(()) +} + +/// Forward: the external items a native user is linked to (its `linked-users`). §2. +pub async fn linked_items(pool: &SqlitePool, user: UserId) -> Result> { + let rows = sqlx::query( + "SELECT i.id, i.type_id, i.container, i.external_key, i.payload \ + FROM user_external_links l JOIN items i ON i.id = l.item_id \ + WHERE l.user_id = ? ORDER BY i.id", + ) + .bind(user.to_string()) + .fetch_all(pool) + .await + .map_err(db)?; + rows.iter().map(item_from_row).collect() +} + +/// Reverse: the native user an external item is linked to, if any — authorship resolution *up* the link +/// (§2). `None` when the item is unlinked (or does not exist). +pub async fn user_for_item(pool: &SqlitePool, item: ItemId) -> Result> { + let row = sqlx::query( + "SELECT u.id, u.handle FROM user_external_links l JOIN users u ON u.id = l.user_id \ + WHERE l.item_id = ?", + ) + .bind(item.to_string()) + .fetch_optional(pool) + .await + .map_err(db)?; + row.as_ref().map(user_from_row).transpose() +} + +async fn item_exists(pool: &SqlitePool, item: ItemId) -> Result { + let row = sqlx::query("SELECT 1 FROM items WHERE id = ?") + .bind(item.to_string()) + .fetch_optional(pool) + .await + .map_err(db)?; + Ok(row.is_some()) +} diff --git a/channel-party/crates/cp-core/src/runtime.rs b/channel-party/crates/cp-core/src/runtime.rs index 5a100bf..e60bf6a 100644 --- a/channel-party/crates/cp-core/src/runtime.rs +++ b/channel-party/crates/cp-core/src/runtime.rs @@ -1,28 +1,260 @@ //! The one `RuntimeComponent` supervisor. Each component is a supervised, long-lived task that -//! backfills then reacts to the change stream. Components compose via the event bus. See DESIGN §7. +//! backfills then reacts to the change stream ("backfill-then-stream"). This module provides the +//! concrete `RuntimeCtx` core hands each component and the supervisor that keeps it alive (restart + +//! backoff), confines its writes by `WriteScope`, and drives `version()`-triggered resets. See DESIGN +//! §7 and `design/runtime.md`. -use cp_model::{RuntimeCtx, WriteScope}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use cp_model::{ + ChangeEvent, Channel, ChannelId, Interests, Item, ItemId, Node, Result, RuntimeComponent, + RuntimeCtx, RuntimeEvent, TypeId, WriteCtx, WriteScope, +}; +use sqlx::SqlitePool; +use tokio::sync::{broadcast, watch, Mutex}; +use tokio::task::JoinSet; +use tokio::time::{interval, Interval}; + +use crate::events::EventBus; use crate::registry::Registry; +use crate::store::Store; + +/// Backoff floor + ceiling for restarting a failed component. +const BACKOFF_MIN: Duration = Duration::from_millis(200); +const BACKOFF_MAX: Duration = Duration::from_secs(30); +/// A run that lasted at least this long is treated as healthy: its next failure restarts from the floor. +const HEALTHY_RUN: Duration = Duration::from_secs(30); + +/// The concrete context core hands a component: the interests-filtered change stream + scheduler, point +/// reads, the `WriteScope`-confined write surface, the type-owned DB handle, and the reset flag. §7. +pub struct CoreRuntimeCtx { + types: Vec, + writes: WriteScope, + store: Arc, + changes: Mutex>, + interval: Option>, + pool: SqlitePool, + shutdown: watch::Receiver, + reset: bool, +} + +impl CoreRuntimeCtx { + fn new( + interests: Interests, + writes: WriteScope, + store: Arc, + changes: broadcast::Receiver, + pool: SqlitePool, + shutdown: watch::Receiver, + reset: bool, + ) -> Self { + // `interval` must be built inside the runtime (it is: the supervisor task). + let interval = interests + .schedule_secs + .map(|s| Mutex::new(interval(Duration::from_secs(s.max(1))))); + Self { + types: interests.types, + writes, + store, + changes: Mutex::new(changes), + interval, + pool, + shutdown, + reset, + } + } + + fn interested(&self, ev: &ChangeEvent) -> bool { + // Empty `types` ⇒ reacts to no change events (a schedule-only or pure-backfill component). + self.types.contains(&ev.type_id) + } +} + +#[async_trait::async_trait] +impl RuntimeCtx for CoreRuntimeCtx { + async fn next_event(&self) -> Option { + let mut shutdown = self.shutdown.clone(); + loop { + if *shutdown.borrow() { + return None; + } + let mut rx = self.changes.lock().await; + tokio::select! { + biased; + () = wait_flag(&mut shutdown) => return None, + () = tick(self.interval.as_ref()) => return Some(RuntimeEvent::Tick), + recv = rx.recv() => match recv { + Ok(ev) if self.interested(&ev) => return Some(RuntimeEvent::Change(ev)), + Ok(_) => {} // not of interest → keep waiting + Err(broadcast::error::RecvError::Lagged(_)) => {} // fell behind → skip, re-sync + Err(broadcast::error::RecvError::Closed) => return None, + }, + } + } + } + + async fn get_item(&self, id: ItemId) -> Result> { + self.store.get_item(id).await + } + + async fn get_channel(&self, id: ChannelId) -> Result> { + self.store.get_channel(id).await + } + + async fn scan(&self, types: &[TypeId]) -> Result> { + self.store.scan_by_types(types).await + } + + fn writer(&self) -> Option<&dyn WriteCtx> { + // Structural §7 confinement: a Derived component cannot obtain the envelope mutation API. + match self.writes { + WriteScope::Primary => Some(self.store.as_ref()), + WriteScope::Derived => None, + } + } + + fn type_owned_db(&self) -> &SqlitePool { + &self.pool + } + + fn reset_requested(&self) -> bool { + self.reset + } +} + +/// Await until the watch flag is `true` (or the sender is dropped). Used as the shutdown arm. +async fn wait_flag(rx: &mut watch::Receiver) { + loop { + if *rx.borrow() { + return; + } + if rx.changed().await.is_err() { + return; // sender dropped ⇒ treat as shutdown + } + } +} + +/// The scheduler arm: fire on the interval, or never (pending) when unscheduled. +async fn tick(interval: Option<&Mutex>) { + match interval { + Some(m) => { + m.lock().await.tick().await; + } + None => std::future::pending::<()>().await, + } +} -/// The concrete context core hands a component: store (restricted per `writes()`), change-stream -/// subscription, scheduler, shutdown. A marker in the scaffold. §7. -pub struct CoreRuntimeCtx; +/// A handle to the running components: shut them down (graceful) or drop it (aborts them). §7. +pub struct RuntimeHandle { + shutdown: watch::Sender, + tasks: JoinSet<()>, +} -impl RuntimeCtx for CoreRuntimeCtx {} +impl RuntimeHandle { + /// Signal shutdown and wait for every component to exit its loop. + pub async fn shutdown(mut self) { + let _ = self.shutdown.send(true); + while self.tasks.join_next().await.is_some() {} + } +} -/// Supervise every registered component. §7/§10. Stub: it logs each component and the write scope -/// core would confine it to; actual spawn + restart supervision + the change stream are deferred. -pub fn spawn(registry: Registry) { +/// Supervise every registered component: one task each, restarted with capped backoff on failure. §7/§10. +pub fn spawn( + registry: Registry, + store: Arc, + events: EventBus, + pool: SqlitePool, +) -> RuntimeHandle { + let (sd_tx, sd_rx) = watch::channel(false); + let mut tasks = JoinSet::new(); for component in registry.runtimes() { - let scope = match component.writes() { - WriteScope::Primary => "primary", - WriteScope::Derived => "derived", - }; - tracing::info!( - name = component.name(), - write_scope = scope, - "runtime component registered (supervision stubbed — DESIGN §7)" + let component = component.clone(); + let store = store.clone(); + let events = events.clone(); + let pool = pool.clone(); + let sd_rx = sd_rx.clone(); + tasks.spawn(supervise(component, store, events, pool, sd_rx)); + } + RuntimeHandle { + shutdown: sd_tx, + tasks, + } +} + +async fn supervise( + component: Arc, + store: Arc, + events: EventBus, + pool: SqlitePool, + mut shutdown: watch::Receiver, +) { + let name = component.name().to_owned(); + // A version bump since last boot means reset; persist the new version either way. + let reset = match reconcile_version(&pool, &name, component.version()).await { + Ok(reset) => reset, + Err(e) => { + tracing::error!(component = name, error = %e, "runtime: version reconcile failed"); + false + } + }; + + let mut backoff = BACKOFF_MIN; + loop { + if *shutdown.borrow() { + break; + } + let cx = CoreRuntimeCtx::new( + component.interests(), + component.writes(), + store.clone(), + events.subscribe(), + pool.clone(), + shutdown.clone(), + reset, ); + let started = Instant::now(); + match component.run(&cx).await { + Ok(()) => break, // clean exit (driven by shutdown via next_event → None) + Err(e) => { + tracing::error!(component = name, error = %e, "runtime component failed; restarting") + } + } + if *shutdown.borrow() { + break; + } + // A long, healthy run resets the backoff; a fast-failing one keeps escalating. + if started.elapsed() >= HEALTHY_RUN { + backoff = BACKOFF_MIN; + } + tokio::select! { + () = tokio::time::sleep(backoff) => {} + () = wait_flag(&mut shutdown) => break, + } + backoff = (backoff * 2).min(BACKOFF_MAX); } } + +/// Compare the component's `version()` to the stored one and persist the new value. Returns whether a +/// reset is due — true only when a *different* prior version was recorded (a first-ever boot has nothing +/// to reset). §7. +async fn reconcile_version(pool: &SqlitePool, name: &str, version: u32) -> Result { + let db = |e: sqlx::Error| cp_model::Error::Other(e.to_string()); + let stored: Option = + sqlx::query_scalar("SELECT version FROM runtime_component_state WHERE name = ?") + .bind(name) + .fetch_optional(pool) + .await + .map_err(db)?; + let reset = matches!(stored, Some(v) if v as u32 != version); + sqlx::query( + "INSERT INTO runtime_component_state (name, version) VALUES (?, ?) \ + ON CONFLICT(name) DO UPDATE SET version = excluded.version", + ) + .bind(name) + .bind(i64::from(version)) + .execute(pool) + .await + .map_err(db)?; + Ok(reset) +} diff --git a/channel-party/crates/cp-core/src/store.rs b/channel-party/crates/cp-core/src/store.rs index b2b6ada..cccaa78 100644 --- a/channel-party/crates/cp-core/src/store.rs +++ b/channel-party/crates/cp-core/src/store.rs @@ -1,23 +1,66 @@ -//! The envelope store: it persists envelopes and implements the closed `StoreCtx` primitive set -//! (DESIGN §5) over sqlite. The primitive bodies are stubbed in the scaffold — the schema -//! (`migrations/0001_init.sql`) and the registry wiring exist so kinds can be registered and -//! dispatched to; filling these in is §5/§6 slice work. +//! The envelope store: it persists envelopes (the single write path, implementing `cp_model::WriteCtx`) +//! and reads them back — point reads plus the kind-facing discovery primitives `cp_model::StoreCtx` +//! (`children`/`descendants`/`seek_time`, DESIGN §5). `search` is the one primitive still stubbed: it +//! needs the FTS index substrate (`TODO.md` #3). See `design/write-path.md` and `design/read-path.md`. +//! +//! Queries use runtime-checked `sqlx::query` / `QueryBuilder` (not the `query!` macros), so no `.sqlx` +//! offline cache is needed yet (`TODO.md` #21). use async_trait::async_trait; -use cp_model::{ChannelId, Cursor, Filter, Node, NodePage, Order, Page, Result, StoreCtx}; -use sqlx::SqlitePool; +use cp_model::{ + Channel, ChannelId, Cursor, Error, Filter, Item, ItemId, Json, NewChannel, NewItem, Node, + NodePage, Order, Page, Result, StoreCtx, SuperType, TypeId, Upsert, UserId, WriteCtx, +}; +use sqlx::sqlite::SqliteRow; +use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool}; +use ulid::Ulid; +use crate::events::{ChangeEvent, ChangeOp, EnvelopeRef, EventBus}; +use crate::index; use crate::registry::Registry; -/// The sqlite-backed store. Holds the pool and the registry (for `index()` on write). §5/§6. +/// The sqlite-backed store. Holds the pool, the registry (for `validate`/`index` on write), and the +/// event bus (to emit after commit). pub struct Store { pool: SqlitePool, registry: Registry, + events: EventBus, +} + +fn db(e: sqlx::Error) -> Error { + Error::Other(e.to_string()) +} + +fn to_text(payload: &Json) -> Result { + serde_json::to_string(payload).map_err(|e| Error::Other(e.to_string())) +} + +fn from_text(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| Error::Other(e.to_string())) +} + +fn channel_id(s: &str) -> Result { + s.parse() + .map_err(|_| Error::Other(format!("invalid channel id: {s}"))) +} + +fn item_id(s: &str) -> Result { + s.parse() + .map_err(|_| Error::Other(format!("invalid item id: {s}"))) +} + +fn user_id(s: &str) -> Result { + s.parse() + .map_err(|_| Error::Other(format!("invalid user id: {s}"))) } impl Store { - pub fn new(pool: SqlitePool, registry: Registry) -> Self { - Self { pool, registry } + pub fn new(pool: SqlitePool, registry: Registry, events: EventBus) -> Self { + Self { + pool, + registry, + events, + } } pub fn pool(&self) -> &SqlitePool { @@ -27,40 +70,686 @@ impl Store { pub fn registry(&self) -> &Registry { &self.registry } + + /// Point read of a channel envelope by id. Used by the generic API (§9) and internally by the + /// write path; not part of the kind-facing `StoreCtx` discovery set. + pub async fn get_channel(&self, id: ChannelId) -> Result> { + let row = sqlx::query("SELECT type_id, container, payload FROM channels WHERE id = ?") + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + let Some(row) = row else { + return Ok(None); + }; + let container: Option = row.try_get("container").map_err(db)?; + Ok(Some(Channel { + id, + type_id: TypeId::new(row.try_get::("type_id").map_err(db)?), + container: container.as_deref().map(channel_id).transpose()?, + payload: from_text(&row.try_get::("payload").map_err(db)?)?, + })) + } + + /// Every channel + item of the given types, id-ordered. The backfill enumerator behind + /// `RuntimeCtx::scan` (§7) — unscoped by container, unlike the discovery primitives. Empty + /// `types` ⇒ empty result (a component with no interest types has nothing to backfill). + pub async fn scan_by_types(&self, types: &[TypeId]) -> Result> { + if types.is_empty() { + return Ok(Vec::new()); + } + let mut qb = QueryBuilder::::new(""); + select_channels(&mut qb); + qb.push(" WHERE 1 = 1"); + push_type_ids(&mut qb, Some(types)); + qb.push(" UNION ALL "); + select_items(&mut qb); + qb.push(" WHERE 1 = 1"); + push_type_ids(&mut qb, Some(types)); + qb.push(" ORDER BY id ASC"); + let rows = qb.build().fetch_all(&self.pool).await.map_err(db)?; + rows.iter().map(row_to_node).collect() + } + + /// Point read of an item envelope by id. §9. + pub async fn get_item(&self, id: ItemId) -> Result> { + let row = + sqlx::query("SELECT type_id, container, external_key, payload FROM items WHERE id = ?") + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + let Some(row) = row else { + return Ok(None); + }; + let container: Option = row.try_get("container").map_err(db)?; + Ok(Some(Item { + id, + type_id: TypeId::new(row.try_get::("type_id").map_err(db)?), + container: container.as_deref().map(channel_id).transpose()?, + external_key: row.try_get("external_key").map_err(db)?, + payload: from_text(&row.try_get::("payload").map_err(db)?)?, + })) + } +} + +// The write path. See `design/write-path.md`: validate -> tx -> persist -> index -> commit -> emit. +#[async_trait] +impl WriteCtx for Store { + async fn create_channel(&self, spec: NewChannel) -> Result { + let entry = { + let kind = self + .registry + .channel(&spec.type_id) + .ok_or(Error::NotFound)?; + kind.validate(&spec.payload)?; + kind.index(&spec.payload) + }; + let id = ChannelId::generate(); + + let mut tx = self.pool.begin().await.map_err(db)?; + sqlx::query("INSERT INTO channels (id, type_id, container, payload) VALUES (?, ?, ?, ?)") + .bind(id.to_string()) + .bind(spec.type_id.as_str()) + .bind(spec.container.map(|c| c.to_string())) + .bind(to_text(&spec.payload)?) + .execute(&mut *tx) + .await + .map_err(db)?; + if let Some(entry) = entry { + index::upsert(&mut tx, EnvelopeRef::Channel(id), &entry).await?; + } + tx.commit().await.map_err(db)?; + + self.events.publish(ChangeEvent { + op: ChangeOp::Created, + target: EnvelopeRef::Channel(id), + type_id: spec.type_id, + container: spec.container, + }); + Ok(id) + } + + async fn create_item(&self, spec: NewItem) -> Result { + let entry = { + let kind = self.registry.item(&spec.type_id).ok_or(Error::NotFound)?; + kind.validate(&spec.payload)?; + kind.index(&spec.payload) + }; + let id = ItemId::generate(); + + let mut tx = self.pool.begin().await.map_err(db)?; + sqlx::query( + "INSERT INTO items (id, type_id, container, external_key, payload) VALUES (?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(spec.type_id.as_str()) + .bind(spec.container.map(|c| c.to_string())) + .bind(spec.external_key.as_deref()) + .bind(to_text(&spec.payload)?) + .execute(&mut *tx) + .await + .map_err(db)?; + if let Some(entry) = entry { + index::upsert(&mut tx, EnvelopeRef::Item(id), &entry).await?; + } + tx.commit().await.map_err(db)?; + + self.events.publish(ChangeEvent { + op: ChangeOp::Created, + target: EnvelopeRef::Item(id), + type_id: spec.type_id, + container: spec.container, + }); + Ok(id) + } + + async fn upsert_item(&self, spec: NewItem) -> Result> { + let key = spec + .external_key + .as_deref() + .ok_or_else(|| Error::Other("upsert_item requires an external_key".to_owned()))?; + let entry = { + let kind = self.registry.item(&spec.type_id).ok_or(Error::NotFound)?; + kind.validate(&spec.payload)?; + kind.index(&spec.payload) + }; + let fresh = ItemId::generate(); + + let mut tx = self.pool.begin().await.map_err(db)?; + // One atomic statement (no read-then-write race). On conflict the *existing* id is returned, + // so it is stable across updates (§3). The partial unique index needs its WHERE echoed here. + let row = sqlx::query( + "INSERT INTO items (id, type_id, container, external_key, payload) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(external_key) WHERE external_key IS NOT NULL + DO UPDATE SET payload = excluded.payload, container = excluded.container + RETURNING id", + ) + .bind(fresh.to_string()) + .bind(spec.type_id.as_str()) + .bind(spec.container.map(|c| c.to_string())) + .bind(key) + .bind(to_text(&spec.payload)?) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + let id = item_id(&row.try_get::("id").map_err(db)?)?; + let inserted = id == fresh; + if let Some(entry) = entry { + index::upsert(&mut tx, EnvelopeRef::Item(id), &entry).await?; + } + tx.commit().await.map_err(db)?; + + self.events.publish(ChangeEvent { + op: if inserted { + ChangeOp::Created + } else { + ChangeOp::Updated + }, + target: EnvelopeRef::Item(id), + type_id: spec.type_id, + container: spec.container, + }); + Ok(if inserted { + Upsert::Inserted(id) + } else { + Upsert::Updated(id) + }) + } + + async fn set_channel_payload(&self, id: ChannelId, payload: Json) -> Result<()> { + let ch = self.get_channel(id).await?.ok_or(Error::NotFound)?; + let entry = { + let kind = self.registry.channel(&ch.type_id).ok_or(Error::NotFound)?; + kind.validate(&payload)?; + kind.index(&payload) + }; + + let mut tx = self.pool.begin().await.map_err(db)?; + sqlx::query("UPDATE channels SET payload = ? WHERE id = ?") + .bind(to_text(&payload)?) + .bind(id.to_string()) + .execute(&mut *tx) + .await + .map_err(db)?; + if let Some(entry) = entry { + index::upsert(&mut tx, EnvelopeRef::Channel(id), &entry).await?; + } + tx.commit().await.map_err(db)?; + + self.events.publish(ChangeEvent { + op: ChangeOp::Updated, + target: EnvelopeRef::Channel(id), + type_id: ch.type_id, + container: ch.container, + }); + Ok(()) + } + + async fn set_item_payload(&self, id: ItemId, payload: Json) -> Result<()> { + let item = self.get_item(id).await?.ok_or(Error::NotFound)?; + let entry = { + let kind = self.registry.item(&item.type_id).ok_or(Error::NotFound)?; + kind.validate(&payload)?; + kind.index(&payload) + }; + + let mut tx = self.pool.begin().await.map_err(db)?; + sqlx::query("UPDATE items SET payload = ? WHERE id = ?") + .bind(to_text(&payload)?) + .bind(id.to_string()) + .execute(&mut *tx) + .await + .map_err(db)?; + if let Some(entry) = entry { + index::upsert(&mut tx, EnvelopeRef::Item(id), &entry).await?; + } + tx.commit().await.map_err(db)?; + + self.events.publish(ChangeEvent { + op: ChangeOp::Updated, + target: EnvelopeRef::Item(id), + type_id: item.type_id, + container: item.container, + }); + Ok(()) + } + + async fn reparent_channel(&self, id: ChannelId, container: Option) -> Result<()> { + let ch = self.get_channel(id).await?.ok_or(Error::NotFound)?; + // Container FK validates the new parent exists; payload/index unchanged (index is over payload). + sqlx::query("UPDATE channels SET container = ? WHERE id = ?") + .bind(container.map(|c| c.to_string())) + .bind(id.to_string()) + .execute(&self.pool) + .await + .map_err(db)?; + self.events.publish(ChangeEvent { + op: ChangeOp::Updated, + target: EnvelopeRef::Channel(id), + type_id: ch.type_id, + container, + }); + Ok(()) + } + + async fn reparent_item(&self, id: ItemId, container: Option) -> Result<()> { + let item = self.get_item(id).await?.ok_or(Error::NotFound)?; + sqlx::query("UPDATE items SET container = ? WHERE id = ?") + .bind(container.map(|c| c.to_string())) + .bind(id.to_string()) + .execute(&self.pool) + .await + .map_err(db)?; + self.events.publish(ChangeEvent { + op: ChangeOp::Updated, + target: EnvelopeRef::Item(id), + type_id: item.type_id, + container, + }); + Ok(()) + } + + async fn delete_channel(&self, id: ChannelId) -> Result<()> { + // Fetch first so the event can carry type_id/container; also confirms existence. + let ch = self.get_channel(id).await?.ok_or(Error::NotFound)?; + let mut tx = self.pool.begin().await.map_err(db)?; + // FK ON DELETE CASCADE removes child channels + items; their index rows are #3's concern. + sqlx::query("DELETE FROM channels WHERE id = ?") + .bind(id.to_string()) + .execute(&mut *tx) + .await + .map_err(db)?; + index::delete(&mut tx, EnvelopeRef::Channel(id)).await?; + tx.commit().await.map_err(db)?; + + self.events.publish(ChangeEvent { + op: ChangeOp::Deleted, + target: EnvelopeRef::Channel(id), + type_id: ch.type_id, + container: ch.container, + }); + Ok(()) + } + + async fn delete_item(&self, id: ItemId) -> Result<()> { + let item = self.get_item(id).await?.ok_or(Error::NotFound)?; + let mut tx = self.pool.begin().await.map_err(db)?; + sqlx::query("DELETE FROM items WHERE id = ?") + .bind(id.to_string()) + .execute(&mut *tx) + .await + .map_err(db)?; + index::delete(&mut tx, EnvelopeRef::Item(id)).await?; + tx.commit().await.map_err(db)?; + + self.events.publish(ChangeEvent { + op: ChangeOp::Deleted, + target: EnvelopeRef::Item(id), + type_id: item.type_id, + container: item.container, + }); + Ok(()) + } + + async fn add_member(&self, channel: ChannelId, user: UserId) -> Result<()> { + // FKs enforce that both the channel and the (native) user exist. §2/§8. + sqlx::query("INSERT OR IGNORE INTO channel_members (channel_id, user_id) VALUES (?, ?)") + .bind(channel.to_string()) + .bind(user.to_string()) + .execute(&self.pool) + .await + .map_err(db)?; + Ok(()) + } + + async fn remove_member(&self, channel: ChannelId, user: UserId) -> Result<()> { + sqlx::query("DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?") + .bind(channel.to_string()) + .bind(user.to_string()) + .execute(&self.pool) + .await + .map_err(db)?; + Ok(()) + } + + async fn members(&self, channel: ChannelId) -> Result> { + let rows = sqlx::query("SELECT user_id FROM channel_members WHERE channel_id = ?") + .bind(channel.to_string()) + .fetch_all(&self.pool) + .await + .map_err(db)?; + rows.iter() + .map(|r| user_id(&r.try_get::("user_id").map_err(db)?)) + .collect() + } +} + +// The read path / discovery primitives (`cp_model::StoreCtx`, DESIGN §5). A cursor is an opaque string +// wrapping a bare ULID keyset boundary; results resume *strictly beyond* it in the query's `Order` +// direction. Because ULIDs are time-ordered and their Crockford-base32 text sorts identically to the +// binary value, a plain `id :cursor` gives time pagination for free, and `seek_time` is a pure +// timestamp→id computation. `search` alone is deferred (needs the FTS substrate, #3). See +// `design/read-path.md`. + +/// Cap on one page, so an unbounded `limit` reaching these primitives from an HTTP query can't ask the +/// database to materialize everything at once. Callers keep paging via the returned cursor. +const MAX_LIMIT: u32 = 1000; + +/// Keyset comparator for resuming after a cursor: `<` walks older ids (`TimeDesc`), `>` newer (`TimeAsc`). +fn cursor_cmp(order: Order) -> &'static str { + match order { + Order::TimeDesc => "<", + Order::TimeAsc => ">", + } +} + +fn order_sql(order: Order) -> &'static str { + match order { + Order::TimeDesc => "DESC", + Order::TimeAsc => "ASC", + } +} + +/// The shared discovery `SELECT` list for one super-type, tagged so both UNION arms round-trip through +/// [`row_to_node`]. Channels have no `external_key`, so it is a literal `NULL` there. Caller appends the +/// `WHERE`/filters. +fn select_channels(qb: &mut QueryBuilder<'_, Sqlite>) { + qb.push( + "SELECT 'channel' AS super_type, id, type_id, container, NULL AS external_key, payload \ + FROM channels", + ); +} +fn select_items(qb: &mut QueryBuilder<'_, Sqlite>) { + qb.push( + "SELECT 'item' AS super_type, id, type_id, container, external_key, payload FROM items", + ); +} + +/// `AND type_id IN (...)` for a non-empty type filter; a no-op otherwise (`None`/empty ⇒ unfiltered). +fn push_type_ids(qb: &mut QueryBuilder<'_, Sqlite>, type_ids: Option<&[TypeId]>) { + let Some(ids) = type_ids.filter(|v| !v.is_empty()) else { + return; + }; + qb.push(" AND type_id IN ("); + for (i, t) in ids.iter().enumerate() { + if i > 0 { + qb.push(", "); + } + qb.push_bind(t.as_str().to_owned()); + } + qb.push(")"); +} + +/// The keyset predicate, when resuming from a cursor. +fn push_cursor(qb: &mut QueryBuilder<'_, Sqlite>, cursor: &Cursor, order: Order) { + if let Some(id) = &cursor.0 { + qb.push(" AND id ") + .push(cursor_cmp(order)) + .push(" ") + .push_bind(id.clone()); + } +} + +/// Rebuild a [`Node`] from a discovery row (either UNION arm), keyed on the tagged `super_type`. +fn row_to_node(row: &SqliteRow) -> Result { + let id: String = row.try_get("id").map_err(db)?; + let type_id = TypeId::new(row.try_get::("type_id").map_err(db)?); + let container: Option = row.try_get("container").map_err(db)?; + let container = container.as_deref().map(channel_id).transpose()?; + let payload = from_text(&row.try_get::("payload").map_err(db)?)?; + match row.try_get::("super_type").map_err(db)?.as_str() { + "channel" => Ok(Node::Channel(Channel { + id: channel_id(&id)?, + type_id, + container, + payload, + })), + "item" => Ok(Node::Item(Item { + id: item_id(&id)?, + type_id, + container, + external_key: row.try_get("external_key").map_err(db)?, + payload, + })), + other => Err(Error::Other(format!("unknown super_type in row: {other}"))), + } +} + +fn node_id(node: &Node) -> String { + match node { + Node::Channel(c) => c.id.to_string(), + Node::Item(i) => i.id.to_string(), + } } #[async_trait] impl StoreCtx for Store { async fn children( &self, - _container: ChannelId, - _filter: Filter, - _page: Page, - _order: Order, + container: ChannelId, + filter: Filter, + page: Page, + order: Order, ) -> Result { - todo!("cp-core store: children — one level, cursor-paginated (DESIGN §5)") + if page.limit == 0 { + return Ok(NodePage { + nodes: Vec::new(), + next: Cursor(None), + }); + } + let limit = page.limit.min(MAX_LIMIT); + let want_channels = filter.super_type != Some(SuperType::Item); + let want_items = filter.super_type != Some(SuperType::Channel); + let type_ids = filter.type_ids.as_deref(); + + // One UNION arm per wanted super-type; the trailing ORDER BY/LIMIT applies to the whole + // compound. Fetch limit+1 to learn whether a further page exists without a second query. + let mut qb = QueryBuilder::::new(""); + if want_channels { + select_channels(&mut qb); + qb.push(" WHERE container = "); + qb.push_bind(container.to_string()); + push_type_ids(&mut qb, type_ids); + push_cursor(&mut qb, &page.cursor, order); + } + if want_channels && want_items { + qb.push(" UNION ALL "); + } + if want_items { + select_items(&mut qb); + qb.push(" WHERE container = "); + qb.push_bind(container.to_string()); + push_type_ids(&mut qb, type_ids); + push_cursor(&mut qb, &page.cursor, order); + } + qb.push(" ORDER BY id ") + .push(order_sql(order)) + .push(" LIMIT ") + .push_bind(i64::from(limit) + 1); + + let rows = qb.build().fetch_all(&self.pool).await.map_err(db)?; + let mut nodes = rows.iter().map(row_to_node).collect::>>()?; + let next = if nodes.len() > limit as usize { + nodes.truncate(limit as usize); + Cursor(Some(node_id(nodes.last().expect("limit >= 1")))) + } else { + Cursor(None) + }; + Ok(NodePage { nodes, next }) } async fn descendants( &self, - _root: ChannelId, - _filter: Filter, - _depth: Option, + root: ChannelId, + filter: Filter, + depth: Option, ) -> Result> { - todo!("cp-core store: descendants — whole subtree (DESIGN §5)") + let want_channels = filter.super_type != Some(SuperType::Item); + let want_items = filter.super_type != Some(SuperType::Channel); + let type_ids = filter.type_ids.as_deref(); + + // Walk the channel containment edge from `root` (depth 0) with a recursive CTE, an optional + // `depth` capping the hops. A node's depth = its container's depth + 1, so descendant channels + // are the subtree minus root (depth >= 1) and an item qualifies when its container sits within + // `depth - 1` hops. Fetch-all (no pagination): the primitive is "whole subtree" by contract. + let mut qb = QueryBuilder::::new( + "WITH RECURSIVE subtree(id, depth) AS (SELECT id, 0 FROM channels WHERE id = ", + ); + qb.push_bind(root.to_string()); + qb.push( + " UNION ALL SELECT c.id, s.depth + 1 FROM channels c \ + JOIN subtree s ON c.container = s.id", + ); + if let Some(max) = depth { + qb.push(" WHERE s.depth + 1 <= "); + qb.push_bind(i64::from(max)); + } + qb.push(") "); + + if want_channels { + select_channels(&mut qb); + qb.push(" WHERE id IN (SELECT id FROM subtree WHERE depth >= 1)"); + push_type_ids(&mut qb, type_ids); + } + if want_channels && want_items { + qb.push(" UNION ALL "); + } + if want_items { + select_items(&mut qb); + qb.push(" WHERE container IN (SELECT id FROM subtree"); + if let Some(max) = depth { + qb.push(" WHERE depth <= "); + qb.push_bind(i64::from(max) - 1); + } + qb.push(")"); + push_type_ids(&mut qb, type_ids); + } + qb.push(" ORDER BY id ASC"); + + let rows = qb.build().fetch_all(&self.pool).await.map_err(db)?; + rows.iter().map(row_to_node).collect() } - async fn seek_time(&self, _container: ChannelId, _timestamp_ms: u64) -> Result { - todo!("cp-core store: seek_time — ULID time-jump to a cursor (DESIGN §3/§5)") + async fn seek_time(&self, _container: ChannelId, timestamp_ms: u64) -> Result { + // ULIDs carry a 48-bit millisecond time prefix, so the earliest id possible at time T is + // `from_parts(T, 0)`. Return the id one below it: with `children`'s exclusive-beyond cursor, a + // following `children(.., TimeAsc)` then yields exactly the rows created at/after T (and + // `TimeDesc` those strictly before T). Pure — no query, no need to read the container. §3/§5. + let floor = Ulid::from_parts(timestamp_ms, 0).0; + let boundary = Ulid(floor.saturating_sub(1)); + Ok(Cursor(Some(boundary.to_string()))) } async fn search( &self, - _scope: ChannelId, - _text: &str, - _filter: Filter, - _page: Page, + scope: ChannelId, + text: &str, + filter: Filter, + page: Page, ) -> Result { - todo!("cp-core store: search — FTS over the index() projection (DESIGN §5/§6)") + // FTS over the `index()` projection (`search_index`), scoped to `scope`'s subtree. See + // `design/index-search.md`. Trigram needs >= 3 code points; a shorter needle forms no trigram, + // so there is nothing to match — an empty page, not an FTS error. + let needle = text.trim(); + if page.limit == 0 || needle.chars().count() < 3 { + return Ok(NodePage { + nodes: Vec::new(), + next: Cursor(None), + }); + } + let limit = page.limit.min(MAX_LIMIT); + let offset = decode_search_cursor(&page.cursor)?; + // Match the needle as a literal FTS5 phrase: wrap in quotes, double any embedded quote. This + // treats user input verbatim — its `AND`/`OR`/`NEAR`/`*`/column-filter operators are text, not + // FTS query syntax (no injection). + let match_expr = format!("\"{}\"", needle.replace('"', "\"\"")); + + let want_channels = filter.super_type != Some(SuperType::Item); + let want_items = filter.super_type != Some(SuperType::Channel); + let type_ids = filter.type_ids.as_deref(); + + // The subtree CTE is the *same* recursion as `descendants(scope)`, so search scoping and listing + // scoping share one mental model. Each arm MATCHes the FTS row then INNER JOINs its envelope + // table (rebuilding the Node and dropping orphaned index rows). `search_index.rank` is bm25 (more + // negative ⇒ more relevant); `id` breaks ties into a total order for stable offset paging. The + // FTS table is referenced unaliased: FTS5's table-level `MATCH` needs the real table name. + let mut qb = QueryBuilder::::new( + "WITH RECURSIVE subtree(id, depth) AS (SELECT id, 0 FROM channels WHERE id = ", + ); + qb.push_bind(scope.to_string()); + qb.push( + " UNION ALL SELECT c.id, s.depth + 1 FROM channels c \ + JOIN subtree s ON c.container = s.id) ", + ); + + if want_channels { + qb.push( + "SELECT 'channel' AS super_type, c.id, c.type_id, c.container, NULL AS external_key, \ + c.payload, search_index.rank AS score \ + FROM search_index JOIN channels c ON c.id = search_index.envelope_id \ + WHERE search_index.super_type = 'channel' AND search_index MATCH ", + ); + qb.push_bind(match_expr.clone()); + qb.push(" AND c.id IN (SELECT id FROM subtree WHERE depth >= 1)"); + push_type_ids(&mut qb, type_ids); + } + if want_channels && want_items { + qb.push(" UNION ALL "); + } + if want_items { + qb.push( + "SELECT 'item' AS super_type, i.id, i.type_id, i.container, i.external_key, i.payload, \ + search_index.rank AS score \ + FROM search_index JOIN items i ON i.id = search_index.envelope_id \ + WHERE search_index.super_type = 'item' AND search_index MATCH ", + ); + qb.push_bind(match_expr.clone()); + qb.push(" AND i.container IN (SELECT id FROM subtree)"); + push_type_ids(&mut qb, type_ids); + } + qb.push(" ORDER BY score ASC, id ASC LIMIT ") + .push_bind(i64::from(limit) + 1) + .push(" OFFSET ") + .push_bind(i64::from(offset)); + + let rows = qb.build().fetch_all(&self.pool).await.map_err(db)?; + let mut nodes = rows.iter().map(row_to_node).collect::>>()?; + let next = if nodes.len() > limit as usize { + nodes.truncate(limit as usize); + Cursor(Some((offset + limit).to_string())) + } else { + Cursor(None) + }; + Ok(NodePage { nodes, next }) + } + + async fn is_member(&self, channel: ChannelId, user: UserId) -> Result { + // The read side of the `channel_members` substrate (§8), consulted by `Permission` policies. §18. + let row = sqlx::query("SELECT 1 FROM channel_members WHERE channel_id = ? AND user_id = ?") + .bind(channel.to_string()) + .bind(user.to_string()) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + Ok(row.is_some()) + } + + fn type_owned_db(&self) -> &SqlitePool { + // The §6 escape hatch: an escape-hatch kind's `contents` reads its own namespaced tables + // through this (e.g. `canvas` its R-tree). It is the same pool; kinds are trusted to touch only + // their `_*` tables, never core's `channels`/`items`. See `design/runtime.md`. + &self.pool + } +} + +/// Decode a search cursor: a bare decimal **offset** (`None` ⇒ 0). Distinct from the id-keyset cursor +/// `children` mints — search ranks by relevance, not id, so it pages by offset (`design/index-search.md`). +fn decode_search_cursor(cursor: &Cursor) -> Result { + match &cursor.0 { + None => Ok(0), + Some(s) => s + .parse::() + .map_err(|_| Error::Validation(format!("invalid search cursor: {s}"))), } } diff --git a/channel-party/crates/cp-core/tests/auth.rs b/channel-party/crates/cp-core/tests/auth.rs new file mode 100644 index 0000000..8145e77 --- /dev/null +++ b/channel-party/crates/cp-core/tests/auth.rs @@ -0,0 +1,73 @@ +//! Integration tests for native-user auth + sessions (`TODO.md` #17): password provisioning + +//! verification and the session lifecycle, against a real tempfile sqlite. + +use cp_core::{auth, Core, Registry}; + +async fn core() -> (tempfile::TempDir, Core) { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let core = Core::open(&url, Registry::builder().build()).await.unwrap(); + (dir, core) +} + +#[tokio::test] +async fn provision_set_password_and_authenticate() { + let (_dir, core) = core().await; + let store = core.store(); + let pool = store.pool(); + + let uid = auth::provision_user(pool, "alice").await.unwrap(); + + // Provisioned but no password yet ⇒ inert (can't authenticate). + assert!(auth::authenticate(pool, "alice", "hunter2") + .await + .unwrap() + .is_none()); + + auth::set_password(pool, "alice", "hunter2").await.unwrap(); + let user = auth::authenticate(pool, "alice", "hunter2").await.unwrap(); + assert_eq!(user.expect("correct password authenticates").id, uid); + + // Wrong password / unknown handle ⇒ no user. + assert!(auth::authenticate(pool, "alice", "wrong") + .await + .unwrap() + .is_none()); + assert!(auth::authenticate(pool, "nobody", "hunter2") + .await + .unwrap() + .is_none()); + + // set-password on an unknown handle is NotFound (no silent create). + assert!(auth::set_password(pool, "nobody", "x").await.is_err()); +} + +#[tokio::test] +async fn sessions_resolve_and_revoke() { + let (_dir, core) = core().await; + let store = core.store(); + let pool = store.pool(); + + let uid = auth::provision_user(pool, "bob").await.unwrap(); + auth::set_password(pool, "bob", "pw").await.unwrap(); + + let token = auth::create_session(pool, uid).await.unwrap(); + assert_eq!( + auth::resolve_session(pool, &token) + .await + .unwrap() + .expect("live session resolves") + .handle, + "bob" + ); + + // A bogus token resolves to nothing. + assert!(auth::resolve_session(pool, "deadbeef") + .await + .unwrap() + .is_none()); + + // After logout the token is dead. + auth::delete_session(pool, &token).await.unwrap(); + assert!(auth::resolve_session(pool, &token).await.unwrap().is_none()); +} diff --git a/channel-party/crates/cp-core/tests/authz.rs b/channel-party/crates/cp-core/tests/authz.rs new file mode 100644 index 0000000..ae6398a --- /dev/null +++ b/channel-party/crates/cp-core/tests/authz.rs @@ -0,0 +1,162 @@ +//! Authorization dispatch (`TODO.md` #18, `design/permissions.md`) against a real tempfile sqlite. +//! Throwaway kinds (DESIGN §12) exercise core's genericity: deny-by-default for a kind with no +//! `Permission`, an "allow" policy, and a membership-riding policy over the `channel_members` substrate. + +use async_trait::async_trait; +use cp_core::{auth, authz, Core, Registry}; +use cp_model::{ + Action, Channel, ChannelKind, Json, NewChannel, Permission, Result, StoreCtx, TypeId, UserId, + WriteCtx, +}; + +/// Grants `Post` to anyone; denies everything else. +struct OpenChannel(TypeId); +#[async_trait] +impl ChannelKind for OpenChannel { + fn type_id(&self) -> &TypeId { + &self.0 + } + async fn contents(&self, _: &dyn StoreCtx, _: &Channel, _: Json) -> Result { + unreachable!("contents is not exercised by the authz test") + } + fn permission(&self) -> Option<&dyn Permission> { + Some(self) + } +} +#[async_trait] +impl Permission for OpenChannel { + async fn authorize( + &self, + _: &dyn StoreCtx, + _: &Channel, + _: UserId, + action: Action, + ) -> Result { + Ok(action == Action::Post) + } +} + +/// Grants `Post` only to members of the channel — rides the generic `channel_members` substrate. +struct MembersChannel(TypeId); +#[async_trait] +impl ChannelKind for MembersChannel { + fn type_id(&self) -> &TypeId { + &self.0 + } + async fn contents(&self, _: &dyn StoreCtx, _: &Channel, _: Json) -> Result { + unreachable!("contents is not exercised by the authz test") + } + fn permission(&self) -> Option<&dyn Permission> { + Some(self) + } +} +#[async_trait] +impl Permission for MembersChannel { + async fn authorize( + &self, + cx: &dyn StoreCtx, + ch: &Channel, + user: UserId, + action: Action, + ) -> Result { + match action { + Action::Post => cx.is_member(ch.id, user).await, + _ => Ok(false), + } + } +} + +/// Declares no `Permission` — the deny-by-default case. +struct ClosedChannel(TypeId); +#[async_trait] +impl ChannelKind for ClosedChannel { + fn type_id(&self) -> &TypeId { + &self.0 + } + async fn contents(&self, _: &dyn StoreCtx, _: &Channel, _: Json) -> Result { + unreachable!("contents is not exercised by the authz test") + } +} + +async fn test_core() -> (tempfile::TempDir, Core) { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder() + .channel(OpenChannel(TypeId::new("open"))) + .channel(MembersChannel(TypeId::new("members"))) + .channel(ClosedChannel(TypeId::new("closed"))) + .build(); + let core = Core::open(&url, registry).await.unwrap(); + (dir, core) +} + +async fn channel_of(core: &Core, type_id: &str) -> Channel { + let store = core.store(); + let cid = store + .create_channel(NewChannel { + type_id: TypeId::new(type_id), + container: None, + payload: serde_json::json!({}), + }) + .await + .unwrap(); + store.get_channel(cid).await.unwrap().unwrap() +} + +#[tokio::test] +async fn deny_by_default_and_allow() { + let (_dir, core) = test_core().await; + let store = core.store(); + let user = auth::provision_user(store.pool(), "alice").await.unwrap(); + + // A kind with no Permission capability authorizes no one (deny-by-default). + let closed = channel_of(&core, "closed").await; + assert!( + !authz::authorize(core.registry(), &*store, &closed, user, Action::Post) + .await + .unwrap() + ); + + // An "allow" policy grants the action it declares, and nothing else. + let open = channel_of(&core, "open").await; + assert!( + authz::authorize(core.registry(), &*store, &open, user, Action::Post) + .await + .unwrap() + ); + assert!( + !authz::authorize(core.registry(), &*store, &open, user, Action::Manage) + .await + .unwrap() + ); +} + +#[tokio::test] +async fn membership_gates_post() { + let (_dir, core) = test_core().await; + let store = core.store(); + let user = auth::provision_user(store.pool(), "bob").await.unwrap(); + let ch = channel_of(&core, "members").await; + + // Not a member yet → denied. + assert!( + !authz::authorize(core.registry(), &*store, &ch, user, Action::Post) + .await + .unwrap() + ); + + // After joining → allowed. + store.add_member(ch.id, user).await.unwrap(); + assert!( + authz::authorize(core.registry(), &*store, &ch, user, Action::Post) + .await + .unwrap() + ); + + // The grant is scoped to `Post`, not other actions. + assert!( + !authz::authorize(core.registry(), &*store, &ch, user, Action::View) + .await + .unwrap() + ); +} diff --git a/channel-party/crates/cp-core/tests/debug_shell.rs b/channel-party/crates/cp-core/tests/debug_shell.rs new file mode 100644 index 0000000..a7414e8 --- /dev/null +++ b/channel-party/crates/cp-core/tests/debug_shell.rs @@ -0,0 +1,311 @@ +//! Integration tests for the gated debug shell (`TODO.md` #6): the write-mode gate, direct-read +//! commands, envelope CRUD through the mutation API, and capability-gated membership. Uses throwaway +//! test kinds (one with membership, one without) rather than a concrete kind crate (DESIGN §12). + +use async_trait::async_trait; +use cp_core::debug::DebugShell; +use cp_core::{Core, Registry}; +use cp_model::{ + Channel, ChannelKind, Item, ItemKind, Json, Membership, Result, StoreCtx, TypeId, UserId, + WriteCtx, +}; + +/// A channel that accepts users (membership via the generic edge substrate) and summarizes its name. +struct Room(TypeId); + +#[async_trait] +impl ChannelKind for Room { + fn type_id(&self) -> &TypeId { + &self.0 + } + async fn contents(&self, _cx: &dyn StoreCtx, _ch: &Channel, _q: Json) -> Result { + unreachable!("contents is not exercised by the debug-shell test") + } + fn debug_summary(&self, ch: &Channel) -> Option { + ch.payload + .get("name") + .and_then(|v| v.as_str()) + .map(|n| format!("#{n}")) + } + fn membership(&self) -> Option<&dyn Membership> { + Some(self) + } +} + +#[async_trait] +impl Membership for Room { + async fn add_user(&self, cx: &dyn WriteCtx, ch: &Channel, u: UserId) -> Result<()> { + cx.add_member(ch.id, u).await + } + async fn remove_user(&self, cx: &dyn WriteCtx, ch: &Channel, u: UserId) -> Result<()> { + cx.remove_member(ch.id, u).await + } + async fn members(&self, cx: &dyn WriteCtx, ch: &Channel) -> Result> { + cx.members(ch.id).await + } +} + +/// A channel that does not accept users (no membership capability). +struct Locked(TypeId); + +#[async_trait] +impl ChannelKind for Locked { + fn type_id(&self) -> &TypeId { + &self.0 + } + async fn contents(&self, _cx: &dyn StoreCtx, _ch: &Channel, _q: Json) -> Result { + unreachable!("contents is not exercised by the debug-shell test") + } +} + +struct Msg(TypeId); + +impl ItemKind for Msg { + fn type_id(&self) -> &TypeId { + &self.0 + } + fn debug_summary(&self, item: &Item) -> Option { + item.payload + .get("body") + .and_then(|v| v.as_str()) + .map(str::to_owned) + } +} + +async fn shell() -> (tempfile::TempDir, DebugShell) { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder() + .channel(Room(TypeId::new("room"))) + .channel(Locked(TypeId::new("locked"))) + .item(Msg(TypeId::new("msg"))) + .build(); + let core = Core::open(&url, registry.clone()).await.unwrap(); + // The shell holds an Arc (keeping the pool alive), so dropping `core` here is fine. + (dir, DebugShell::new(registry, core.store())) +} + +/// The id printed after `created ` on a create line. +fn created_id(line: &str, kind: &str) -> String { + line.strip_prefix(&format!("created {kind} ")) + .unwrap_or_else(|| panic!("unexpected create output: {line}")) + .split_whitespace() + .next() + .unwrap() + .to_owned() +} + +#[tokio::test] +async fn read_only_by_default_and_gates_writes() { + let (_dir, mut sh) = shell().await; + assert_eq!(sh.prompt(), "cp[ro]> "); + assert_eq!(sh.eval("show channels").await, "(no channels)"); + + let refused = sh.eval("create-channel room {\"name\":\"general\"}").await; + assert_eq!(refused, "read-only; run enable-write-mode first"); + assert_eq!( + sh.eval("show channels").await, + "(no channels)", + "nothing was written" + ); + + assert!(sh.eval("enable-write-mode").await.contains("write mode ON")); + assert_eq!(sh.prompt(), "cp[write]> "); + assert!(sh + .eval("disable-write-mode") + .await + .contains("write mode OFF")); + assert_eq!(sh.prompt(), "cp[ro]> "); +} + +#[tokio::test] +async fn create_inspect_update_and_delete() { + let (_dir, mut sh) = shell().await; + sh.enable_write_mode(); + + let cid = created_id( + &sh.eval("create-channel room {\"name\":\"general\"}").await, + "channel", + ); + let channels = sh.eval("show channels").await; + assert!(channels.contains(&cid), "{channels}"); + assert!(channels.contains("type=room"), "{channels}"); + assert!( + channels.contains("#general"), + "debug_summary shown: {channels}" + ); + + let iid = created_id( + &sh.eval(&format!("create-item {cid} msg {{\"body\":\"hi\"}}")) + .await, + "item", + ); + let items = sh.eval(&format!("show items {cid}")).await; + assert!(items.contains(&iid) && items.contains("hi"), "{items}"); + + let inspected = sh.eval(&format!("inspect {iid}")).await; + assert!(inspected.contains("item"), "{inspected}"); + assert!( + inspected.contains("\"body\": \"hi\""), + "payload dumped: {inspected}" + ); + + assert!(sh + .eval(&format!("set-payload {iid} {{\"body\":\"edited\"}}")) + .await + .contains("updated item")); + assert!(sh.eval(&format!("inspect {iid}")).await.contains("edited")); + + assert!(sh + .eval(&format!("delete {iid}")) + .await + .contains("deleted item")); + assert_eq!(sh.eval(&format!("show items {cid}")).await, "(no items)"); +} + +#[tokio::test] +async fn membership_is_capability_gated() { + let (_dir, mut sh) = shell().await; + sh.enable_write_mode(); + + let room = created_id(&sh.eval("create-channel room {}").await, "channel"); + let locked = created_id(&sh.eval("create-channel locked {}").await, "channel"); + let uid = created_id(&sh.eval("create-user alice").await, "user"); + + let added = sh.eval(&format!("add-user-to-channel {room} {uid}")).await; + assert!(added.contains("added user"), "{added}"); + assert!(sh.eval(&format!("members {room}")).await.contains(&uid)); + + // A channel type with no `membership()` refuses with the §8 message — this *is* the "accepts + // users" check (capability presence). + let refused = sh + .eval(&format!("add-user-to-channel {locked} {uid}")) + .await; + assert!(refused.contains("does not accept users"), "{refused}"); + + assert!(sh + .eval(&format!("remove-user-from-channel {room} {uid}")) + .await + .contains("removed user")); + assert_eq!(sh.eval(&format!("members {room}")).await, "(no members)"); +} + +#[tokio::test] +async fn bad_input_is_a_clean_message_not_a_panic() { + let (_dir, mut sh) = shell().await; + sh.enable_write_mode(); + + // Unregistered type -> the mutation API's NotFound, surfaced as text. + assert!(sh + .eval("create-channel nope {}") + .await + .contains("not found")); + // Malformed JSON payload. + assert!(sh + .eval("create-channel room not-json") + .await + .contains("invalid JSON")); + // Unknown command / id. + assert!(sh.eval("frobnicate x").await.contains("unknown command")); + assert!(sh.eval("inspect 0000").await.contains("no channel or item")); + // help lists the gate. + assert!(sh.eval("help").await.contains("enable-write-mode")); +} + +#[tokio::test] +async fn reparent_moves_a_channel_under_a_container() { + let (_dir, mut sh) = shell().await; + sh.enable_write_mode(); + + let parent = created_id( + &sh.eval("create-channel room {\"name\":\"space\"}").await, + "channel", + ); + let child = created_id( + &sh.eval("create-channel room {\"name\":\"room\"}").await, + "channel", + ); + // Both start at root — the only hierarchy the create commands can express on their own. + assert!(sh.eval("show channels").await.contains("container=(root)")); + + let moved = sh.eval(&format!("reparent {child} {parent}")).await; + assert!( + moved.contains("reparented channel") && moved.contains(&parent), + "{moved}" + ); + assert!( + sh.eval("show channels") + .await + .contains(&format!("container={parent}")), + "child now nested under the parent" + ); + + // And back out to root. + assert!(sh + .eval(&format!("reparent {child} root")) + .await + .contains("(root)")); +} + +#[tokio::test] +async fn set_password_is_gated_and_provisions_login() { + let (_dir, mut sh) = shell().await; + sh.enable_write_mode(); + + assert!(sh.eval("create-user carol").await.contains("created user")); + assert!(sh + .eval("set-password carol s3cret") + .await + .contains("password set for @carol")); + // Unknown handle → a clean NotFound message, not a panic. + assert!(sh.eval("set-password nobody x").await.contains("not found")); + + // The write-mode gate applies. + sh.disable_write_mode(); + assert!(sh + .eval("set-password carol s3cret") + .await + .contains("read-only")); +} + +#[tokio::test] +async fn link_user_provisions_and_lists_external_links() { + let (_dir, mut sh) = shell().await; + sh.enable_write_mode(); + + let room = created_id(&sh.eval("create-channel room {}").await, "channel"); + let item = created_id( + &sh.eval(&format!( + "create-item {room} msg {{\"body\":\"cached-user\"}}" + )) + .await, + "item", + ); + sh.eval("create-user dave").await; + assert_eq!(sh.eval("show links dave").await, "(no links)"); + + // Provision a link, then it lists. + assert!(sh + .eval(&format!("link-user dave {item}")) + .await + .contains("linked @dave")); + assert!(sh.eval("show links dave").await.contains(&item)); + + // Unknown handle → a clean message, not a panic. + assert!(sh + .eval(&format!("link-user nobody {item}")) + .await + .contains("no user with handle")); + + // Unlink removes it; and the write-mode gate applies to link-user. + assert!(sh + .eval(&format!("unlink-user dave {item}")) + .await + .contains("unlinked @dave")); + assert_eq!(sh.eval("show links dave").await, "(no links)"); + sh.disable_write_mode(); + assert!(sh + .eval(&format!("link-user dave {item}")) + .await + .contains("read-only")); +} diff --git a/channel-party/crates/cp-core/tests/links.rs b/channel-party/crates/cp-core/tests/links.rs new file mode 100644 index 0000000..8212571 --- /dev/null +++ b/channel-party/crates/cp-core/tests/links.rs @@ -0,0 +1,127 @@ +//! The `linked-users` edge + authorship resolution (`TODO.md` #19, `design/linked-users.md`) against a +//! real tempfile sqlite. Throwaway kinds (DESIGN §12) prove core links a user to *any* item, not a +//! hardcoded "cached-user" type: link both directions, idempotency, the one-user-per-item conflict, the +//! missing-item error, unlink, and FK cascade on item delete. + +use async_trait::async_trait; +use cp_core::{auth, links, Core, Registry}; +use cp_model::{ + Channel, ChannelKind, Error, ItemKind, Json, NewChannel, NewItem, Result, StoreCtx, TypeId, + WriteCtx, +}; + +struct TestChannel(TypeId); +#[async_trait] +impl ChannelKind for TestChannel { + fn type_id(&self) -> &TypeId { + &self.0 + } + async fn contents(&self, _: &dyn StoreCtx, _: &Channel, _: Json) -> Result { + unreachable!("contents is not exercised by the links test") + } +} + +struct TestItem(TypeId); +impl ItemKind for TestItem { + fn type_id(&self) -> &TypeId { + &self.0 + } +} + +async fn test_core() -> (tempfile::TempDir, Core) { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder() + .channel(TestChannel(TypeId::new("test"))) + .item(TestItem(TypeId::new("test"))) + .build(); + let core = Core::open(&url, registry).await.unwrap(); + (dir, core) +} + +/// Create a root channel and an item inside it; return the item id (stands in for a `cached-user`). +async fn an_item(core: &Core) -> cp_model::ItemId { + let store = core.store(); + let cid = store + .create_channel(NewChannel { + type_id: TypeId::new("test"), + container: None, + payload: serde_json::json!({}), + }) + .await + .unwrap(); + store + .create_item(NewItem { + type_id: TypeId::new("test"), + container: Some(cid), + external_key: None, + payload: serde_json::json!({ "external": "discord:user:42" }), + }) + .await + .unwrap() +} + +#[tokio::test] +async fn link_resolves_both_directions_and_is_idempotent() { + let (_dir, core) = test_core().await; + let pool = core.pool(); + let alice = auth::provision_user(pool, "alice").await.unwrap(); + let item = an_item(&core).await; + + links::link(pool, alice, item).await.unwrap(); + links::link(pool, alice, item).await.unwrap(); // idempotent for the same user + + // Forward: alice's linked items include it (exactly once). + let items = links::linked_items(pool, alice).await.unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, item); + + // Reverse: the item resolves up to alice (authorship resolution). + let resolved = links::user_for_item(pool, item).await.unwrap().unwrap(); + assert_eq!(resolved.id, alice); + assert_eq!(resolved.handle, "alice"); +} + +#[tokio::test] +async fn one_native_user_per_item_and_missing_item() { + let (_dir, core) = test_core().await; + let pool = core.pool(); + let alice = auth::provision_user(pool, "alice").await.unwrap(); + let bob = auth::provision_user(pool, "bob").await.unwrap(); + let item = an_item(&core).await; + + links::link(pool, alice, item).await.unwrap(); + + // A second user claiming the same external item is a conflict, not a silent second row. + let conflict = links::link(pool, bob, item).await; + assert!(matches!(conflict, Err(Error::Validation(_)))); + assert_eq!(links::linked_items(pool, bob).await.unwrap().len(), 0); + + // Linking a nonexistent item is NotFound. + let ghost = cp_model::ItemId::generate(); + assert!(matches!( + links::link(pool, alice, ghost).await, + Err(Error::NotFound) + )); +} + +#[tokio::test] +async fn unlink_and_cascade_on_item_delete() { + let (_dir, core) = test_core().await; + let pool = core.pool(); + let store = core.store(); + let alice = auth::provision_user(pool, "alice").await.unwrap(); + + // unlink removes the edge. + let item = an_item(&core).await; + links::link(pool, alice, item).await.unwrap(); + links::unlink(pool, alice, item).await.unwrap(); + assert!(links::user_for_item(pool, item).await.unwrap().is_none()); + + // Deleting the item cascades the link away (FK ON DELETE CASCADE). + let item2 = an_item(&core).await; + links::link(pool, alice, item2).await.unwrap(); + store.delete_item(item2).await.unwrap(); + assert!(links::user_for_item(pool, item2).await.unwrap().is_none()); + assert_eq!(links::linked_items(pool, alice).await.unwrap().len(), 0); +} diff --git a/channel-party/crates/cp-core/tests/read_path.rs b/channel-party/crates/cp-core/tests/read_path.rs new file mode 100644 index 0000000..4cfe861 --- /dev/null +++ b/channel-party/crates/cp-core/tests/read_path.rs @@ -0,0 +1,350 @@ +//! Integration tests for the read/discovery primitives (`StoreCtx`, `TODO.md` #2) against a real +//! tempfile sqlite. Data is seeded through the write path, so these exercise reads and writes together. +//! Uses throwaway test kinds rather than a concrete kind crate (DESIGN §12). + +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use cp_core::{Core, Registry}; +use cp_model::{ + Channel, ChannelId, ChannelKind, Cursor, Filter, ItemKind, Json, NewChannel, NewItem, Node, + NodePage, Order, Page, Result, StoreCtx, SuperType, TypeId, WriteCtx, +}; + +struct TestChannel(TypeId); + +#[async_trait] +impl ChannelKind for TestChannel { + fn type_id(&self) -> &TypeId { + &self.0 + } + async fn contents(&self, _cx: &dyn StoreCtx, _ch: &Channel, _query: Json) -> Result { + unreachable!("contents is not exercised by the read-path test") + } +} + +struct TestItem(TypeId); + +impl ItemKind for TestItem { + fn type_id(&self) -> &TypeId { + &self.0 + } +} + +async fn test_core() -> (tempfile::TempDir, Core) { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + // Two channel types (to test type filtering) and one item type. + let registry = Registry::builder() + .channel(TestChannel(room())) + .channel(TestChannel(space())) + .item(TestItem(msg())) + .build(); + let core = Core::open(&url, registry).await.unwrap(); + (dir, core) +} + +fn room() -> TypeId { + TypeId::new("room") +} +fn space() -> TypeId { + TypeId::new("space") +} +fn msg() -> TypeId { + TypeId::new("msg") +} + +fn ch(type_id: TypeId, container: Option) -> NewChannel { + NewChannel { + type_id, + container, + payload: serde_json::json!({}), + } +} +fn item(container: ChannelId) -> NewItem { + NewItem { + type_id: msg(), + container: Some(container), + external_key: None, + payload: serde_json::json!({}), + } +} + +fn page(limit: u32) -> Page { + Page { + cursor: Cursor(None), + limit, + } +} + +fn node_id(node: &Node) -> String { + match node { + Node::Channel(c) => c.id.to_string(), + Node::Item(i) => i.id.to_string(), + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64 +} + +/// Walk every page of `children` and return the node ids in feed order. +async fn collect_all( + store: &impl StoreCtx, + container: ChannelId, + filter: Filter, + order: Order, + limit: u32, +) -> Vec { + let mut ids = Vec::new(); + let mut cursor = Cursor(None); + loop { + let p: NodePage = store + .children( + container, + filter.clone(), + Page { + cursor: cursor.clone(), + limit, + }, + order, + ) + .await + .unwrap(); + ids.extend(p.nodes.iter().map(node_id)); + if p.next.0.is_none() { + break; + } + cursor = p.next; + } + ids +} + +#[tokio::test] +async fn children_are_time_ordered_and_paginate() { + let (_dir, core) = test_core().await; + let store = core.store(); + let room_id = store.create_channel(ch(room(), None)).await.unwrap(); + + // Seed items. Ids are time-ordered ULIDs, but two minted in the same millisecond tie on the time + // prefix and order by their random bits — so assert on id order, never on insertion order. + let mut created = Vec::new(); + for _ in 0..5 { + created.push(store.create_item(item(room_id)).await.unwrap().to_string()); + } + + let items = Filter { + super_type: Some(SuperType::Item), + type_ids: None, + }; + // Page size 2 forces multiple pages over 5 items. + let desc = collect_all(&*store, room_id, items.clone(), Order::TimeDesc, 2).await; + assert_eq!(desc.len(), 5, "every item recovered across pages"); + assert!( + desc.windows(2).all(|w| w[0] > w[1]), + "strictly descending id order, no dups" + ); + let mut got = desc.clone(); + got.sort(); + let mut want = created.clone(); + want.sort(); + assert_eq!(got, want, "exactly the created set — no gaps, no dups"); + + let asc = collect_all(&*store, room_id, items, Order::TimeAsc, 3).await; + assert!(asc.windows(2).all(|w| w[0] < w[1]), "strictly ascending"); + let desc_rev: Vec = desc.iter().rev().cloned().collect(); + assert_eq!(asc, desc_rev, "ascending is descending reversed"); +} + +#[tokio::test] +async fn children_filter_by_super_type_and_type() { + let (_dir, core) = test_core().await; + let store = core.store(); + let parent = store.create_channel(ch(room(), None)).await.unwrap(); + + let sub_room = store + .create_channel(ch(room(), Some(parent))) + .await + .unwrap(); + let sub_space = store + .create_channel(ch(space(), Some(parent))) + .await + .unwrap(); + store.create_item(item(parent)).await.unwrap(); + store.create_item(item(parent)).await.unwrap(); + + let all = store + .children(parent, Filter::default(), page(100), Order::TimeAsc) + .await + .unwrap(); + assert_eq!(all.nodes.len(), 4, "2 sub-channels + 2 items, mixed freely"); + + let channels = store + .children( + parent, + Filter { + super_type: Some(SuperType::Channel), + type_ids: None, + }, + page(100), + Order::TimeAsc, + ) + .await + .unwrap(); + assert_eq!(channels.nodes.len(), 2); + assert!(channels.nodes.iter().all(|n| matches!(n, Node::Channel(_)))); + let channel_ids: Vec = channels.nodes.iter().map(node_id).collect(); + assert!(channel_ids.contains(&sub_room.to_string())); + assert!(channel_ids.contains(&sub_space.to_string())); + + let items = store + .children( + parent, + Filter { + super_type: Some(SuperType::Item), + type_ids: None, + }, + page(100), + Order::TimeAsc, + ) + .await + .unwrap(); + assert_eq!(items.nodes.len(), 2); + assert!(items.nodes.iter().all(|n| matches!(n, Node::Item(_)))); + + let spaces = store + .children( + parent, + Filter { + super_type: Some(SuperType::Channel), + type_ids: Some(vec![space()]), + }, + page(100), + Order::TimeAsc, + ) + .await + .unwrap(); + assert_eq!(spaces.nodes.len(), 1, "narrowed to the one `space` channel"); + assert_eq!(node_id(&spaces.nodes[0]), sub_space.to_string()); +} + +#[tokio::test] +async fn descendants_span_the_subtree_and_respect_depth() { + let (_dir, core) = test_core().await; + let store = core.store(); + let root = store.create_channel(ch(room(), None)).await.unwrap(); + let child = store.create_channel(ch(room(), Some(root))).await.unwrap(); + let grand = store.create_channel(ch(room(), Some(child))).await.unwrap(); + store.create_item(item(root)).await.unwrap(); + store.create_item(item(child)).await.unwrap(); + store.create_item(item(grand)).await.unwrap(); + + let all = store + .descendants(root, Filter::default(), None) + .await + .unwrap(); + assert_eq!( + all.len(), + 5, + "2 descendant channels + 3 items (root excluded, its items included)" + ); + + let channels = store + .descendants( + root, + Filter { + super_type: Some(SuperType::Channel), + type_ids: None, + }, + None, + ) + .await + .unwrap(); + assert_eq!(channels.len(), 2); + let ids: Vec = channels.iter().map(node_id).collect(); + assert!(ids.contains(&child.to_string()) && ids.contains(&grand.to_string())); + assert!( + !ids.contains(&root.to_string()), + "root is not its own descendant" + ); + + let depth1 = store + .descendants(root, Filter::default(), Some(1)) + .await + .unwrap(); + assert_eq!( + depth1.len(), + 2, + "one hop: the direct child channel + root's direct item" + ); + let d1: Vec = depth1.iter().map(node_id).collect(); + assert!(d1.contains(&child.to_string())); + assert!( + !d1.contains(&grand.to_string()), + "grandchild is two hops down" + ); + + let none = store + .descendants(root, Filter::default(), Some(0)) + .await + .unwrap(); + assert!(none.is_empty(), "depth 0 = nothing below root"); +} + +#[tokio::test] +async fn seek_time_bounds_the_feed_by_timestamp() { + let (_dir, core) = test_core().await; + let store = core.store(); + let room_id = store.create_channel(ch(room(), None)).await.unwrap(); + + let before = now_ms(); + for _ in 0..3 { + store.create_item(item(room_id)).await.unwrap(); + } + + let items = Filter { + super_type: Some(SuperType::Item), + type_ids: None, + }; + // A boundary at the pre-seed timestamp includes everything created since. + let cur = store.seek_time(room_id, before).await.unwrap(); + let since = store + .children( + room_id, + items.clone(), + Page { + cursor: cur, + limit: 100, + }, + Order::TimeAsc, + ) + .await + .unwrap(); + assert_eq!( + since.nodes.len(), + 3, + "all three items are at/after `before`" + ); + + // A boundary far in the future is past every id, so the forward feed is empty. + let future = store.seek_time(room_id, now_ms() + 60_000).await.unwrap(); + let none = store + .children( + room_id, + items, + Page { + cursor: future, + limit: 100, + }, + Order::TimeAsc, + ) + .await + .unwrap(); + assert!( + none.nodes.is_empty(), + "nothing was created after a future timestamp" + ); +} diff --git a/channel-party/crates/cp-core/tests/runtime.rs b/channel-party/crates/cp-core/tests/runtime.rs new file mode 100644 index 0000000..1734d55 --- /dev/null +++ b/channel-party/crates/cp-core/tests/runtime.rs @@ -0,0 +1,240 @@ +//! Integration tests for the `RuntimeComponent` supervisor + `RuntimeCtx` (`TODO.md` #4) against a real +//! tempfile sqlite. Uses throwaway components that record what the ctx hands them into a shared log +//! (DESIGN §12) — so these assert the supervisor's behavior generically, without a concrete kind: +//! backfill-then-stream, `interests` filtering, `WriteScope` confinement, `version()` reset, shutdown. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use cp_core::{Core, Registry}; +use cp_model::{ + ChangeOp, EnvelopeRef, Interests, ItemKind, NewItem, Node, Result, RuntimeComponent, + RuntimeCtx, RuntimeEvent, TypeId, WriteCtx, WriteScope, +}; +use tokio::sync::Mutex; + +const ITEM: &str = "test-item"; +const OTHER: &str = "other-item"; + +/// An item kind with no behavior — just something to create + enumerate. +struct TestItem(TypeId); +impl ItemKind for TestItem { + fn type_id(&self) -> &TypeId { + &self.0 + } +} + +/// A `Derived` component that records everything the ctx gives it (writer scope, reset, backfill, each +/// streamed change) into a shared log, filtered to `ITEM`. +struct Recorder { + log: Arc>>, + version: u32, +} + +impl Recorder { + async fn push(&self, s: String) { + self.log.lock().await.push(s); + } +} + +#[async_trait] +impl RuntimeComponent for Recorder { + fn name(&self) -> &str { + "recorder" + } + fn writes(&self) -> WriteScope { + WriteScope::Derived + } + fn version(&self) -> u32 { + self.version + } + fn interests(&self) -> Interests { + Interests { + schedule_secs: None, + types: vec![TypeId::new(ITEM)], + } + } + async fn run(&self, cx: &dyn RuntimeCtx) -> Result<()> { + // A Derived component must NOT get the envelope write API (§7 confinement). + self.push(format!( + "writer:{}", + if cx.writer().is_some() { + "some" + } else { + "none" + } + )) + .await; + if cx.reset_requested() { + self.push("reset".to_owned()).await; + } + // Backfill: everything of interest that already exists. + for node in cx.scan(&[TypeId::new(ITEM)]).await? { + if let Node::Item(item) = node { + self.push(format!("backfill:{}", item.id)).await; + } + } + // Stream: react to changes (already filtered to `interests.types`). + while let Some(event) = cx.next_event().await { + match event { + RuntimeEvent::Change(c) => { + let EnvelopeRef::Item(id) = c.target else { + continue; + }; + let op = match c.op { + ChangeOp::Created => "created", + ChangeOp::Updated => "updated", + ChangeOp::Deleted => "deleted", + }; + self.push(format!("change:{op}:{id}")).await; + } + RuntimeEvent::Tick => self.push("tick".to_owned()).await, + } + } + Ok(()) + } +} + +/// A `Primary` component: records that it *does* get the write API, then idles until shutdown. +struct PrimaryProbe { + log: Arc>>, +} +#[async_trait] +impl RuntimeComponent for PrimaryProbe { + fn name(&self) -> &str { + "primary-probe" + } + fn writes(&self) -> WriteScope { + WriteScope::Primary + } + async fn run(&self, cx: &dyn RuntimeCtx) -> Result<()> { + let scope = if cx.writer().is_some() { + "some" + } else { + "none" + }; + self.log + .lock() + .await + .push(format!("primary-writer:{scope}")); + while cx.next_event().await.is_some() {} + Ok(()) + } +} + +fn item(type_id: &str) -> NewItem { + NewItem { + type_id: TypeId::new(type_id), + container: None, + external_key: None, + payload: serde_json::json!({}), + } +} + +/// Poll the log until `pred` holds (or time out), returning a snapshot. +async fn wait_until( + log: &Arc>>, + pred: impl Fn(&[String]) -> bool, +) -> Vec { + for _ in 0..150 { + { + let l = log.lock().await; + if pred(&l) { + return l.clone(); + } + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("timed out waiting; log = {:?}", log.lock().await); +} + +async fn open(url: &str, log: Arc>>, version: u32) -> Core { + let registry = Registry::builder() + .item(TestItem(TypeId::new(ITEM))) + .item(TestItem(TypeId::new(OTHER))) + .runtime(Recorder { + log: log.clone(), + version, + }) + .runtime(PrimaryProbe { log }) + .build(); + Core::open(url, registry).await.unwrap() +} + +#[tokio::test] +async fn supervisor_backfills_streams_filters_and_confines() { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let log = Arc::new(Mutex::new(Vec::new())); + let core = open(&url, log.clone(), 0).await; + let store = core.store(); + + // One item exists BEFORE the runtime starts → it must arrive via backfill (there were no + // subscribers when it was created, so it can't have come from the stream). + let before = store.create_item(item(ITEM)).await.unwrap(); + + let handle = core.spawn_runtime(); + + // Wait until BOTH components have started (they are independent tasks with no ordering between + // them): the Recorder has run its backfill, and the Primary probe has reported its writer scope. + // Once the Recorder's backfill has run it has already subscribed (subscription precedes backfill), + // so a change created after this cannot be missed. + let snap = wait_until(&log, |l| { + l.iter().any(|s| s == &format!("backfill:{before}")) + && l.contains(&"primary-writer:some".to_owned()) + }) + .await; + assert!( + snap.contains(&"writer:none".to_owned()), + "Derived component is denied the write API: {snap:?}" + ); + assert!( + snap.contains(&"primary-writer:some".to_owned()), + "Primary component gets the write API: {snap:?}" + ); + assert!( + !snap.contains(&"reset".to_owned()), + "first boot is not a reset" + ); + + // A streamed change of interest is delivered... + let after = store.create_item(item(ITEM)).await.unwrap(); + // ...while a change of a non-interesting type is filtered out. + let ignored = store.create_item(item(OTHER)).await.unwrap(); + + let snap = wait_until(&log, |l| { + l.iter().any(|s| s == &format!("change:created:{after}")) + }) + .await; + assert!( + !snap.iter().any(|s| s.contains(&ignored.to_string())), + "the `other-item` change was filtered by interests: {snap:?}" + ); + + handle.shutdown().await; +} + +#[tokio::test] +async fn version_bump_requests_reset() { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + + // First boot at version 0: records the version, no reset (nothing prior to reset). + let log1 = Arc::new(Mutex::new(Vec::new())); + let core = open(&url, log1.clone(), 0).await; + let h = core.spawn_runtime(); + let snap = wait_until(&log1, |l| l.iter().any(|s| s.starts_with("writer:"))).await; + assert!( + !snap.contains(&"reset".to_owned()), + "v0 first boot: {snap:?}" + ); + h.shutdown().await; + + // Second boot at version 1 against the same DB: the bump requests a reset. + let log2 = Arc::new(Mutex::new(Vec::new())); + let core = open(&url, log2.clone(), 1).await; + let h = core.spawn_runtime(); + wait_until(&log2, |l| l.contains(&"reset".to_owned())).await; + h.shutdown().await; +} diff --git a/channel-party/crates/cp-core/tests/search.rs b/channel-party/crates/cp-core/tests/search.rs new file mode 100644 index 0000000..a12cefa --- /dev/null +++ b/channel-party/crates/cp-core/tests/search.rs @@ -0,0 +1,354 @@ +//! Integration tests for the FTS search substrate + `StoreCtx::search` (`TODO.md` #3) against a real +//! tempfile sqlite. Data is seeded through the write path, so `index()` actually populates `search_index`. +//! Uses throwaway test kinds (a name-indexing channel, a text-indexing item) rather than a concrete +//! kind crate (DESIGN §12) — which also proves search is generic over type, not tied to `basic`. + +use async_trait::async_trait; +use cp_core::{Core, Registry}; +use cp_model::{ + Channel, ChannelId, ChannelKind, Cursor, Filter, IndexEntry, ItemKind, Json, NewChannel, + NewItem, Node, NodePage, Page, Result, StoreCtx, SuperType, TypeId, WriteCtx, +}; +use serde_json::json; + +/// A channel that projects `payload.name` into the FTS `name` column. +struct Room(TypeId); + +#[async_trait] +impl ChannelKind for Room { + fn type_id(&self) -> &TypeId { + &self.0 + } + async fn contents(&self, _cx: &dyn StoreCtx, _ch: &Channel, _q: Json) -> Result { + unreachable!("contents is not exercised by the search test") + } + fn index(&self, payload: &Json) -> Option { + let name = payload.get("name")?.as_str()?; + Some(IndexEntry { + name: Some(name.to_owned()), + ..Default::default() + }) + } +} + +/// An item that projects `payload.body` into the FTS `text` column. +struct Msg(TypeId); + +impl ItemKind for Msg { + fn type_id(&self) -> &TypeId { + &self.0 + } + fn index(&self, payload: &Json) -> Option { + let body = payload.get("body")?.as_str()?; + Some(IndexEntry { + text: Some(body.to_owned()), + ..Default::default() + }) + } +} + +async fn test_core() -> (tempfile::TempDir, Core) { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + // One channel kind registered under two type strings (to test the type filter) + one item kind. + let registry = Registry::builder() + .channel(Room(TypeId::new("room"))) + .channel(Room(TypeId::new("board"))) + .item(Msg(TypeId::new("msg"))) + .build(); + let core = Core::open(&url, registry).await.unwrap(); + (dir, core) +} + +fn ch(type_id: &str, name: &str, container: Option) -> NewChannel { + NewChannel { + type_id: TypeId::new(type_id), + container, + payload: json!({ "name": name }), + } +} +fn msg(body: &str, container: ChannelId) -> NewItem { + NewItem { + type_id: TypeId::new("msg"), + container: Some(container), + external_key: None, + payload: json!({ "body": body }), + } +} + +fn channels(f: Option, type_ids: Option>) -> Filter { + Filter { + super_type: f, + type_ids, + } +} +fn page(limit: u32) -> Page { + Page { + cursor: Cursor(None), + limit, + } +} + +/// The `name`/`body` payload field of each node, as a sorted set (order-independent assertions). +fn names(p: &NodePage) -> Vec { + let mut v: Vec = p + .nodes + .iter() + .filter_map(|n| { + let payload = match n { + Node::Channel(c) => &c.payload, + Node::Item(i) => &i.payload, + }; + payload + .get("name") + .or_else(|| payload.get("body")) + .and_then(|v| v.as_str()) + .map(str::to_owned) + }) + .collect(); + v.sort(); + v +} + +#[tokio::test] +async fn channel_name_search_scopes_to_the_subtree_and_filters_by_type() { + let (_dir, core) = test_core().await; + let store = core.store(); + + let root = store + .create_channel(ch("room", "root", None)) + .await + .unwrap(); + store + .create_channel(ch("room", "general", Some(root))) + .await + .unwrap(); + store + .create_channel(ch("room", "genesis", Some(root))) + .await + .unwrap(); + store + .create_channel(ch("board", "genboard", Some(root))) + .await + .unwrap(); + // A sibling of `root`, sharing the "gen" substring but *outside* the searched subtree. + store + .create_channel(ch("room", "generosity", None)) + .await + .unwrap(); + + let hits = store + .search( + root, + "gen", + channels(Some(SuperType::Channel), None), + page(100), + ) + .await + .unwrap(); + assert_eq!( + names(&hits), + vec!["genboard", "general", "genesis"], + "every in-subtree name containing `gen`; the outside `generosity` is excluded" + ); + + // Narrow to the `room` type: the `board`-typed `genboard` drops out. + let rooms_only = store + .search( + root, + "gen", + channels(Some(SuperType::Channel), Some(vec![TypeId::new("room")])), + page(100), + ) + .await + .unwrap(); + assert_eq!(names(&rooms_only), vec!["general", "genesis"]); +} + +#[tokio::test] +async fn item_body_search_uses_the_second_arm_and_scopes() { + let (_dir, core) = test_core().await; + let store = core.store(); + + let root = store + .create_channel(ch("room", "root", None)) + .await + .unwrap(); + let chat = store + .create_channel(ch("room", "chat", Some(root))) + .await + .unwrap(); + store.create_item(msg("hello world", chat)).await.unwrap(); + store.create_item(msg("goodbye now", chat)).await.unwrap(); + store.create_item(msg("hello again", chat)).await.unwrap(); + // An item under a channel outside `root`'s subtree. + let elsewhere = store + .create_channel(ch("room", "elsewhere", None)) + .await + .unwrap(); + store + .create_item(msg("hello outsider", elsewhere)) + .await + .unwrap(); + + let hits = store + .search( + root, + "hello", + channels(Some(SuperType::Item), None), + page(100), + ) + .await + .unwrap(); + assert_eq!( + names(&hits), + vec!["hello again", "hello world"], + "both in-subtree bodies match; the outside item is excluded" + ); +} + +#[tokio::test] +async fn search_paginates_by_offset_cursor() { + let (_dir, core) = test_core().await; + let store = core.store(); + + let root = store + .create_channel(ch("room", "root", None)) + .await + .unwrap(); + for name in ["match-a", "match-b", "match-c"] { + store + .create_channel(ch("room", name, Some(root))) + .await + .unwrap(); + } + + // Page size 2 over 3 matches → two pages, then exhausted. + let mut seen = Vec::new(); + let mut cursor = Cursor(None); + let mut pages = 0; + loop { + let p = store + .search( + root, + "match", + channels(Some(SuperType::Channel), None), + Page { + cursor: cursor.clone(), + limit: 2, + }, + ) + .await + .unwrap(); + pages += 1; + seen.extend(names(&p)); + match p.next.0 { + Some(_) => cursor = p.next, + None => break, + } + } + assert_eq!(pages, 2, "3 matches at page size 2 = two pages"); + seen.sort(); + assert_eq!( + seen, + vec!["match-a", "match-b", "match-c"], + "no gaps or dups" + ); +} + +#[tokio::test] +async fn short_query_no_match_and_bad_cursor() { + let (_dir, core) = test_core().await; + let store = core.store(); + let root = store + .create_channel(ch("room", "root", None)) + .await + .unwrap(); + store + .create_channel(ch("room", "general", Some(root))) + .await + .unwrap(); + + // Under the trigram floor (3 code points) ⇒ empty page, not an FTS error. + let short = store + .search( + root, + "ge", + channels(Some(SuperType::Channel), None), + page(100), + ) + .await + .unwrap(); + assert!(short.nodes.is_empty() && short.next.0.is_none()); + + // A well-formed query that matches nothing ⇒ empty page. + let miss = store + .search( + root, + "zzzzz", + channels(Some(SuperType::Channel), None), + page(100), + ) + .await + .unwrap(); + assert!(miss.nodes.is_empty()); + + // A non-numeric cursor is a clean Validation error, not a silent reset. + let bad = store + .search( + root, + "gen", + channels(Some(SuperType::Channel), None), + Page { + cursor: Cursor(Some("not-a-number".to_owned())), + limit: 10, + }, + ) + .await; + assert!(bad.is_err()); +} + +#[tokio::test] +async fn update_and_delete_reindex() { + let (_dir, core) = test_core().await; + let store = core.store(); + let root = store + .create_channel(ch("room", "root", None)) + .await + .unwrap(); + let c = store + .create_channel(ch("room", "alpha", Some(root))) + .await + .unwrap(); + + let found = |needle: &'static str| { + let store = store.clone(); + async move { + store + .search( + root, + needle, + channels(Some(SuperType::Channel), None), + page(100), + ) + .await + .unwrap() + .nodes + .len() + } + }; + + assert_eq!(found("alpha").await, 1); + + // Renaming re-projects: the old name stops matching, the new one starts. + store + .set_channel_payload(c, json!({ "name": "omega" })) + .await + .unwrap(); + assert_eq!(found("alpha").await, 0, "old name purged from the index"); + assert_eq!(found("omega").await, 1, "new name indexed"); + + // Deleting purges the index row. + store.delete_channel(c).await.unwrap(); + assert_eq!(found("omega").await, 0); +} diff --git a/channel-party/crates/cp-core/tests/write_path.rs b/channel-party/crates/cp-core/tests/write_path.rs new file mode 100644 index 0000000..a19a2dc --- /dev/null +++ b/channel-party/crates/cp-core/tests/write_path.rs @@ -0,0 +1,203 @@ +//! Integration tests for the core write path (`TODO.md` #1) against a real tempfile sqlite. Uses a +//! throwaway test kind so core's genericity is exercised, not a concrete kind crate (DESIGN §12). + +use async_trait::async_trait; +use cp_core::{ChangeOp, Core, EnvelopeRef, Registry}; +use cp_model::{ + Channel, ChannelKind, Error, IndexEntry, ItemKind, Json, NewChannel, NewItem, Result, StoreCtx, + TypeId, Upsert, UserId, WriteCtx, +}; + +struct TestChannel(TypeId); + +#[async_trait] +impl ChannelKind for TestChannel { + fn type_id(&self) -> &TypeId { + &self.0 + } + + fn validate(&self, payload: &Json) -> Result<()> { + require_object(payload) + } + + async fn contents(&self, _cx: &dyn StoreCtx, _ch: &Channel, _query: Json) -> Result { + unreachable!("contents is not exercised by the write-path test") + } + + fn index(&self, payload: &Json) -> Option { + payload + .get("name") + .and_then(|v| v.as_str()) + .map(|name| IndexEntry { + name: Some(name.to_owned()), + ..Default::default() + }) + } +} + +struct TestItem(TypeId); + +impl ItemKind for TestItem { + fn type_id(&self) -> &TypeId { + &self.0 + } + + fn validate(&self, payload: &Json) -> Result<()> { + require_object(payload) + } +} + +fn require_object(payload: &Json) -> Result<()> { + if payload.is_object() { + Ok(()) + } else { + Err(Error::Validation("expected a JSON object".to_owned())) + } +} + +async fn test_core() -> (tempfile::TempDir, Core) { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder() + .channel(TestChannel(TypeId::new("test"))) + .item(TestItem(TypeId::new("test"))) + .build(); + let core = Core::open(&url, registry).await.unwrap(); + (dir, core) +} + +fn new_channel(payload: Json) -> NewChannel { + NewChannel { + type_id: TypeId::new("test"), + container: None, + payload, + } +} + +#[tokio::test] +async fn create_read_update_delete() { + let (_dir, core) = test_core().await; + let store = core.store(); + + let cid = store + .create_channel(new_channel(serde_json::json!({ "name": "general" }))) + .await + .unwrap(); + let ch = store.get_channel(cid).await.unwrap().unwrap(); + assert_eq!(ch.type_id.as_str(), "test"); + assert_eq!(ch.payload["name"], "general"); + + let iid = store + .create_item(NewItem { + type_id: TypeId::new("test"), + container: Some(cid), + external_key: None, + payload: serde_json::json!({ "body": "hi" }), + }) + .await + .unwrap(); + assert!(store.get_item(iid).await.unwrap().is_some()); + + store + .set_item_payload(iid, serde_json::json!({ "body": "edited" })) + .await + .unwrap(); + assert_eq!( + store.get_item(iid).await.unwrap().unwrap().payload["body"], + "edited" + ); + + store.delete_item(iid).await.unwrap(); + assert!(store.get_item(iid).await.unwrap().is_none()); +} + +#[tokio::test] +async fn upsert_is_idempotent_with_stable_id() { + let (_dir, core) = test_core().await; + let store = core.store(); + let cid = store + .create_channel(new_channel(serde_json::json!({}))) + .await + .unwrap(); + + let mirror = |seen: i32| NewItem { + type_id: TypeId::new("test"), + container: Some(cid), + external_key: Some("discord:user:42".to_owned()), + payload: serde_json::json!({ "seen": seen }), + }; + + let first = store.upsert_item(mirror(1)).await.unwrap(); + let second = store.upsert_item(mirror(2)).await.unwrap(); + assert!(matches!(first, Upsert::Inserted(_))); + assert!(matches!(second, Upsert::Updated(_))); + assert_eq!( + first.id(), + second.id(), + "one item per external_key; the id is stable across updates" + ); + assert_eq!( + store.get_item(second.id()).await.unwrap().unwrap().payload["seen"], + 2 + ); +} + +#[tokio::test] +async fn validation_and_unregistered_type_are_rejected() { + let (_dir, core) = test_core().await; + let store = core.store(); + + let invalid = store + .create_channel(new_channel(serde_json::json!("not an object"))) + .await; + assert!(matches!(invalid, Err(Error::Validation(_)))); + + let unregistered = store + .create_channel(NewChannel { + type_id: TypeId::new("nope"), + container: None, + payload: serde_json::json!({}), + }) + .await; + assert!(matches!(unregistered, Err(Error::NotFound))); +} + +#[tokio::test] +async fn mutations_emit_change_events() { + let (_dir, core) = test_core().await; + let store = core.store(); + let mut rx = core.events().subscribe(); + + let cid = store + .create_channel(new_channel(serde_json::json!({ "name": "x" }))) + .await + .unwrap(); + + let event = rx.try_recv().unwrap(); + assert!(matches!(event.op, ChangeOp::Created)); + assert!(matches!(event.target, EnvelopeRef::Channel(id) if id == cid)); +} + +#[tokio::test] +async fn membership_substrate() { + let (_dir, core) = test_core().await; + let store = core.store(); + let cid = store + .create_channel(new_channel(serde_json::json!({}))) + .await + .unwrap(); + + // Users come from auth (TODO #17); insert one directly to satisfy the channel_members FK. + let uid = UserId::generate(); + sqlx::query("INSERT INTO users (id, handle) VALUES (?, ?)") + .bind(uid.to_string()) + .bind("alice") + .execute(core.pool()) + .await + .unwrap(); + + store.add_member(cid, uid).await.unwrap(); + assert_eq!(store.members(cid).await.unwrap(), vec![uid]); + store.remove_member(cid, uid).await.unwrap(); + assert!(store.members(cid).await.unwrap().is_empty()); +} diff --git a/channel-party/crates/cp-frontend/Cargo.toml b/channel-party/crates/cp-frontend/Cargo.toml index f1b5136..4f21eaf 100644 --- a/channel-party/crates/cp-frontend/Cargo.toml +++ b/channel-party/crates/cp-frontend/Cargo.toml @@ -6,10 +6,25 @@ license.workspace = true [dependencies] cp-core.workspace = true +cp-model.workspace = true anyhow.workspace = true axum.workspace = true +axum-extra.workspace = true +serde = { workspace = true } serde_json.workspace = true tokio.workspace = true +tokio-stream.workspace = true tower-http.workspace = true tracing.workspace = true + +[dev-dependencies] +cp-basic.workspace = true +cp-canvas.workspace = true +cp-model.workspace = true +cp-space.workspace = true +http-body-util = "0.1" +serde_json.workspace = true +tempfile = "3" +tokio.workspace = true +tower = { version = "0.5", default-features = false, features = ["util"] } diff --git a/channel-party/crates/cp-frontend/src/api.rs b/channel-party/crates/cp-frontend/src/api.rs index e510659..e1c8a9a 100644 --- a/channel-party/crates/cp-frontend/src/api.rs +++ b/channel-party/crates/cp-frontend/src/api.rs @@ -1,43 +1,191 @@ -//! The generic HTTP API (DESIGN §9). These endpoints are type-agnostic: `contents` dispatches to -//! the channel's kind, and the envelope reads return the universal fields. Scaffold: all return -//! `501` so the server boots without a store implementation; the wiring points are marked below. +//! The generic HTTP API (DESIGN §9). These endpoints are type-agnostic: the envelope reads return the +//! universal fields, and `contents` resolves the channel's kind and dispatches to it — core never +//! `match`es on a concrete type. `query` and the contents response are opaque to core (§5). use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; +use cp_model::{Action, ChannelId, Error, ItemId, NewItem, TypeId, UserId, WriteCtx}; +use serde::Deserialize; +use serde_json::{json, Value}; +use crate::auth::CurrentUser; use crate::AppState; -fn not_implemented(endpoint: &str) -> (StatusCode, Json) { +/// Map a core `Error` to an HTTP status: missing → 404, bad payload → 400, else 500. +fn error_response(e: Error) -> (StatusCode, Json) { + let status = match e { + Error::NotFound => StatusCode::NOT_FOUND, + Error::Validation(_) => StatusCode::BAD_REQUEST, + Error::Other(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, Json(json!({ "error": e.to_string() }))) +} + +fn bad_request(msg: &str) -> (StatusCode, Json) { + (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))) +} + +fn not_found(resource: &str) -> (StatusCode, Json) { ( - StatusCode::NOT_IMPLEMENTED, - Json(serde_json::json!({ "error": "not implemented", "endpoint": endpoint })), + StatusCode::NOT_FOUND, + Json(json!({ "error": "not found", "resource": resource })), ) } -/// `GET /api/channels/:id` -> `{ id, type_id, container }` (generic). §9. +fn forbidden() -> (StatusCode, Json) { + (StatusCode::FORBIDDEN, Json(json!({ "error": "forbidden" }))) +} + +/// Serialize an envelope for a 200 response (serialization can't realistically fail, but is not +/// unwrapped so a bug surfaces as a 500 rather than a panic). +fn ok(value: &T) -> (StatusCode, Json) { + match serde_json::to_value(value) { + Ok(v) => (StatusCode::OK, Json(v)), + Err(e) => error_response(Error::Other(e.to_string())), + } +} + +/// `GET /api/channels/:id` -> the channel envelope (generic: `id`, `type_id`, `container`, `payload`). +/// §9. The `type_id` is what lets the type-agnostic shell mount the right island. pub async fn get_channel( - State(_state): State, - Path(_id): Path, -) -> (StatusCode, Json) { - // TODO(§9): read the channel envelope from the store; return { id, type_id, container }. - not_implemented("GET /api/channels/{id}") + State(state): State, + Path(id): Path, +) -> (StatusCode, Json) { + let Ok(cid) = id.parse::() else { + return bad_request("invalid channel id"); + }; + match state.core.store().get_channel(cid).await { + Ok(Some(ch)) => ok(&ch), + Ok(None) => not_found("channel"), + Err(e) => error_response(e), + } } -/// `POST /api/channels/:id/contents { query }` -> type-defined contents (dispatch). §5/§9. +/// `POST /api/channels/:id/contents { query }` -> the channel kind's type-defined contents. §5/§9. +/// The request body is the (opaque) query; the channel's kind interprets it. pub async fn channel_contents( - State(_state): State, - Path(_id): Path, -) -> (StatusCode, Json) { - // TODO(§5): load the channel, then `cp_core::contents::dispatch(registry, store, &ch, query)`. - not_implemented("POST /api/channels/{id}/contents") + State(state): State, + Path(id): Path, + Json(query): Json, +) -> (StatusCode, Json) { + let Ok(cid) = id.parse::() else { + return bad_request("invalid channel id"); + }; + let store = state.core.store(); + let ch = match store.get_channel(cid).await { + Ok(Some(ch)) => ch, + Ok(None) => return not_found("channel"), + Err(e) => return error_response(e), + }; + match cp_core::contents::dispatch(&state.registry, &*store, &ch, query).await { + Ok(value) => (StatusCode::OK, Json(value)), + Err(e) => error_response(e), + } } -/// `GET /api/items/:id` -> envelope (generic). §9. +/// The body of `POST /api/channels/:id/items`: the item's type and its opaque payload. Type-agnostic — +/// the client's island names the `type_id`; core validates it via that kind and never inspects `payload`. +#[derive(Deserialize)] +pub struct PostItemBody { + type_id: String, + #[serde(default)] + payload: Value, +} + +/// `POST /api/channels/:id/items { type_id, payload }` -> create an item in the channel as the current +/// user (§18, `design/permissions.md`). Requires a session (the `CurrentUser` extractor → 401), the +/// channel kind's `Permission` to allow `Post` (→ 403, deny-by-default), and a known item type (→ 400). +/// The author is stamped server-side via the item kind's `with_author` (§2); `validate` + persist happen +/// in the write path. On success: 201 with the new id. +pub async fn post_item( + CurrentUser(user): CurrentUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> (StatusCode, Json) { + let Ok(cid) = id.parse::() else { + return bad_request("invalid channel id"); + }; + let store = state.core.store(); + let ch = match store.get_channel(cid).await { + Ok(Some(ch)) => ch, + Ok(None) => return not_found("channel"), + Err(e) => return error_response(e), + }; + match cp_core::authz::authorize(&state.registry, &*store, &ch, user.id, Action::Post).await { + Ok(true) => {} + Ok(false) => return forbidden(), + Err(e) => return error_response(e), + } + let type_id = TypeId::new(&body.type_id); + let Some(kind) = state.registry.item(&type_id) else { + return bad_request("unknown item type"); + }; + let payload = kind.with_author(body.payload, user.id); + match store + .create_item(NewItem { + type_id, + container: Some(cid), + external_key: None, + payload, + }) + .await + { + Ok(item_id) => ( + StatusCode::CREATED, + Json(json!({ "id": item_id.to_string() })), + ), + Err(e) => error_response(e), + } +} + +/// `GET /api/items/:id` -> the item envelope (generic). §9. pub async fn get_item( - State(_state): State, - Path(_id): Path, -) -> (StatusCode, Json) { - // TODO(§9): read the item envelope from the store. - not_implemented("GET /api/items/{id}") + State(state): State, + Path(id): Path, +) -> (StatusCode, Json) { + let Ok(iid) = id.parse::() else { + return bad_request("invalid item id"); + }; + match state.core.store().get_item(iid).await { + Ok(Some(item)) => ok(&item), + Ok(None) => not_found("item"), + Err(e) => error_response(e), + } +} + +/// `GET /api/users/:id/links` -> the external cached-user items this native user is linked to (§2/§19, +/// `design/linked-users.md`). Read-only — links are shell-provisioned. Bad id -> 400. +pub async fn get_user_links( + State(state): State, + Path(id): Path, +) -> (StatusCode, Json) { + let Ok(uid) = id.parse::() else { + return bad_request("invalid user id"); + }; + match cp_core::links::linked_items(state.core.pool(), uid).await { + Ok(items) => match serde_json::to_value(&items) { + Ok(v) => (StatusCode::OK, Json(json!({ "items": v }))), + Err(e) => error_response(Error::Other(e.to_string())), + }, + Err(e) => error_response(e), + } +} + +/// `GET /api/items/:id/linked-user` -> the native user an external cached-user item resolves up to, or +/// 404 if it is unlinked (authorship resolution, §2/§19). The `cached-message` island calls this to show +/// "this external author = native user Alice." +pub async fn get_item_linked_user( + State(state): State, + Path(id): Path, +) -> (StatusCode, Json) { + let Ok(iid) = id.parse::() else { + return bad_request("invalid item id"); + }; + match cp_core::links::user_for_item(state.core.pool(), iid).await { + Ok(Some(user)) => ok(&user), + Ok(None) => not_found("linked user"), + Err(e) => error_response(e), + } } diff --git a/channel-party/crates/cp-frontend/src/auth.rs b/channel-party/crates/cp-frontend/src/auth.rs new file mode 100644 index 0000000..6f8a4e8 --- /dev/null +++ b/channel-party/crates/cp-frontend/src/auth.rs @@ -0,0 +1,112 @@ +//! HTTP auth: login / logout / me + the `CurrentUser` extractor (DESIGN §2, `design/auth.md`, #17). +//! The store logic (hashing, sessions) is `cp_core::auth`; this is the cookie + endpoint layer. Accounts +//! are shell-provisioned — there is no registration route. + +use axum::extract::{FromRequestParts, State}; +use axum::http::request::Parts; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use cp_model::User; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; + +/// The session cookie name. +const COOKIE: &str = "cp_session"; + +#[derive(Deserialize)] +pub struct LoginBody { + handle: String, + password: String, +} + +fn unauthorized() -> Response { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "unauthenticated" })), + ) + .into_response() +} + +fn internal_error(e: cp_model::Error) -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ) + .into_response() +} + +/// Build the session cookie. `HttpOnly` + `SameSite=Lax` + `Path=/` always; `Secure` only when +/// `CP_SECURE_COOKIES=1` (so it isn't dropped over plain http in local dev, but is set behind TLS). +fn session_cookie(token: String) -> Cookie<'static> { + let mut cookie = Cookie::new(COOKIE, token); + cookie.set_http_only(true); + cookie.set_same_site(SameSite::Lax); + cookie.set_path("/"); + if std::env::var("CP_SECURE_COOKIES").as_deref() == Ok("1") { + cookie.set_secure(true); + } + cookie +} + +/// `POST /api/auth/login {handle, password}` → 200 + `Set-Cookie` on success, 401 on bad credentials. +pub async fn login( + State(state): State, + jar: CookieJar, + Json(body): Json, +) -> Response { + let pool = state.core.store(); + let pool = pool.pool(); + match cp_core::auth::authenticate(pool, &body.handle, &body.password).await { + Ok(Some(user)) => match cp_core::auth::create_session(pool, user.id).await { + Ok(token) => (jar.add(session_cookie(token)), Json(user)).into_response(), + Err(e) => internal_error(e), + }, + Ok(None) => unauthorized(), + Err(e) => internal_error(e), + } +} + +/// `POST /api/auth/logout` → revokes the session (if any) and clears the cookie. Always 204. +pub async fn logout(State(state): State, jar: CookieJar) -> Response { + if let Some(cookie) = jar.get(COOKIE) { + let store = state.core.store(); + let _ = cp_core::auth::delete_session(store.pool(), cookie.value()).await; + } + let mut removal = Cookie::new(COOKIE, ""); + removal.set_path("/"); + removal.make_removal(); + (jar.add(removal), StatusCode::NO_CONTENT).into_response() +} + +/// `GET /api/auth/me` → the current user, or 401 (the extractor rejects an absent/expired session). +pub async fn me(CurrentUser(user): CurrentUser) -> Response { + Json(user).into_response() +} + +/// The authenticated principal, resolved from the session cookie. Rejects `401` when there is no valid +/// session. Reuse this on any future protected route (writes, permissions #18). §2/§17. +pub struct CurrentUser(pub User); + +impl FromRequestParts for CurrentUser { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let jar = CookieJar::from_headers(&parts.headers); + let Some(token) = jar.get(COOKIE).map(|c| c.value().to_owned()) else { + return Err(unauthorized()); + }; + let store = state.core.store(); + match cp_core::auth::resolve_session(store.pool(), &token).await { + Ok(Some(user)) => Ok(CurrentUser(user)), + Ok(None) => Err(unauthorized()), + Err(e) => Err(internal_error(e)), + } + } +} diff --git a/channel-party/crates/cp-frontend/src/lib.rs b/channel-party/crates/cp-frontend/src/lib.rs index 30a54f6..92cc1ce 100644 --- a/channel-party/crates/cp-frontend/src/lib.rs +++ b/channel-party/crates/cp-frontend/src/lib.rs @@ -3,10 +3,12 @@ //! frontend shell is type-agnostic; type-specific rendering happens in client-side islands. See //! DESIGN §9. //! -//! Scaffold: the generic API handlers return `501 Not Implemented` (the server boots and serves -//! the static site); wiring them to the store/dispatch is §5/§9 slice work. +//! The generic API handlers read envelopes and dispatch `contents` to the channel's kind, and +//! `/api/events` streams the change bus over SSE (§5/§9). The only remaining `501`-free-but-empty +//! surface is the `/ext` per-kind mount (no kind contributes routes yet). pub mod api; +pub mod auth; pub mod sse; pub mod static_files; @@ -26,6 +28,33 @@ pub struct AppState { pub web_dir: PathBuf, } +/// Build the router for a given state. Split out from [`serve`] so integration tests can drive the +/// real routes via `tower::ServiceExt::oneshot` without binding a socket. §9. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/api/channels/{id}", get(api::get_channel)) + .route("/api/channels/{id}/contents", post(api::channel_contents)) + // Authenticated write: post an item into a channel, gated by the kind's Permission. §18. + .route("/api/channels/{id}/items", post(api::post_item)) + .route("/api/items/{id}", get(api::get_item)) + // linked-users reads: a user's external links, and an item's authorship resolution. §2/§19. + .route("/api/users/{id}/links", get(api::get_user_links)) + .route( + "/api/items/{id}/linked-user", + get(api::get_item_linked_user), + ) + .route("/api/events", get(sse::events)) + // Native-user auth (provisioned accounts; §2/§17). No registration route. + .route("/api/auth/login", post(auth::login)) + .route("/api/auth/logout", post(auth::logout)) + .route("/api/auth/me", get(auth::me)) + // Channel kinds may contribute extra routes (webhooks, etc.) under /ext/. None do yet; + // the mount point exists so the surface is stable. §4/§9. + .nest("/ext", Router::::new()) + .fallback_service(static_files::service(&state.web_dir)) + .with_state(state) +} + /// Boot the server. The static Astro build is resolved from `CP_WEB_DIR` (the Nix package wraps the /// binary to point at the bundled static output; defaults to `web/dist` for local dev). §9/§11. pub async fn serve(core: Core, registry: Registry, addr: SocketAddr) -> anyhow::Result<()> { @@ -38,17 +67,7 @@ pub async fn serve(core: Core, registry: Registry, addr: SocketAddr) -> anyhow:: registry, web_dir: web_dir.clone(), }; - - let app = Router::new() - .route("/api/channels/{id}", get(api::get_channel)) - .route("/api/channels/{id}/contents", post(api::channel_contents)) - .route("/api/items/{id}", get(api::get_item)) - .route("/api/events", get(sse::events)) - // Channel kinds may contribute extra routes (webhooks, etc.) under /ext/. None do in - // the scaffold; the mount point exists so the surface is stable. §4/§9. - .nest("/ext", Router::::new()) - .fallback_service(static_files::service(&web_dir)) - .with_state(state); + let app = router(state); let listener = tokio::net::TcpListener::bind(addr).await?; tracing::info!(%addr, web_dir = %web_dir.display(), "channel-party frontend listening"); diff --git a/channel-party/crates/cp-frontend/src/sse.rs b/channel-party/crates/cp-frontend/src/sse.rs index 54ff35a..84e3d9c 100644 --- a/channel-party/crates/cp-frontend/src/sse.rs +++ b/channel-party/crates/cp-frontend/src/sse.rs @@ -1,13 +1,95 @@ -//! Live updates: SSE backed by the core event bus. Islands subscribe to changes for their channel. -//! See DESIGN §9. Scaffold: the bus exists (`core.events()`); forwarding it as an SSE stream -//! (`tokio_stream::wrappers::BroadcastStream` -> `axum::response::sse`) is deferred. +//! Live updates over Server-Sent Events, backed by the core change bus (DESIGN §7/§9). The write path +//! emits a `ChangeEvent` after every committed mutation; this forwards each to subscribed clients as an +//! SSE `change` event, optionally filtered to one channel scope. The wire shape is a frontend concern, +//! so it is built here rather than by deriving serde onto core's event type. -use axum::extract::State; +use std::convert::Infallible; + +use axum::extract::{Query, State}; use axum::http::StatusCode; +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use cp_core::{ChangeEvent, ChangeOp, EnvelopeRef}; +use cp_model::ChannelId; +use serde::Deserialize; +use serde_json::json; +use tokio_stream::wrappers::errors::BroadcastStreamRecvError; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::StreamExt; use crate::AppState; -/// `GET /api/events?scope=…` -> SSE change stream (generic). §9. -pub async fn events(State(_state): State) -> StatusCode { - StatusCode::NOT_IMPLEMENTED +/// `?scope=` restricts the stream to one channel; absent = the whole firehose. +#[derive(Debug, Deserialize)] +pub struct EventsQuery { + scope: Option, +} + +/// `GET /api/events[?scope=…]` -> an SSE stream of change events. §9. A `scope` keeps events whose +/// container is that channel, plus changes to the channel envelope itself (so a channel view learns +/// both "my contents changed" and "I was renamed/deleted"). +pub async fn events(State(state): State, Query(q): Query) -> Response { + let scope = match q.scope { + Some(s) => match s.parse::() { + Ok(id) => Some(id), + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "invalid scope" })), + ) + .into_response() + } + }, + None => None, + }; + + // Subscribing here (before the handler returns) means any write committed after the client has the + // response is guaranteed to reach this receiver via the broadcast buffer. + let rx = state.core.events().subscribe(); + let stream = BroadcastStream::new(rx).filter_map(move |result| match result { + Ok(event) => { + if scope.is_some_and(|s| !in_scope(&event, s)) { + None + } else { + Some(Ok::(change_event(&event))) + } + } + // A client too slow for the 1024-deep buffer misses events; tell it to resync rather than drop + // silently. It stays subscribed and resumes with live events. + Err(BroadcastStreamRecvError::Lagged(n)) => { + Some(Ok(Event::default().event("lagged").data(n.to_string()))) + } + }); + + Sse::new(stream) + .keep_alive(KeepAlive::default()) + .into_response() +} + +/// Whether an event is visible to a scoped subscriber: a change within the channel, or to it. +fn in_scope(event: &ChangeEvent, scope: ChannelId) -> bool { + event.container == Some(scope) + || matches!(event.target, EnvelopeRef::Channel(id) if id == scope) +} + +/// The SSE `change` frame for one event — the wire shape islands consume. +fn change_event(event: &ChangeEvent) -> Event { + let (super_type, id) = match event.target { + EnvelopeRef::Channel(id) => ("channel", id.to_string()), + EnvelopeRef::Item(id) => ("item", id.to_string()), + }; + let op = match event.op { + ChangeOp::Created => "created", + ChangeOp::Updated => "updated", + ChangeOp::Deleted => "deleted", + }; + let data = json!({ + "op": op, + "super_type": super_type, + "id": id, + "type_id": event.type_id.as_str(), + "container": event.container.map(|c| c.to_string()), + }); + Event::default().event("change").data(data.to_string()) } diff --git a/channel-party/crates/cp-frontend/tests/auth_flow.rs b/channel-party/crates/cp-frontend/tests/auth_flow.rs new file mode 100644 index 0000000..76586a1 --- /dev/null +++ b/channel-party/crates/cp-frontend/tests/auth_flow.rs @@ -0,0 +1,128 @@ +//! End-to-end auth flow (`TODO.md` #17): login → me → logout over the real router via `oneshot`, +//! propagating the session cookie. A user is provisioned with a password up front (the shell path, +//! called directly), since there is no registration endpoint. + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use axum::response::Response; +use axum::Router; +use cp_core::{auth, Core, Registry}; +use cp_frontend::{router, AppState}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use tower::ServiceExt; + +async fn json_body(res: Response) -> Value { + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} + +async fn app() -> (tempfile::TempDir, Router) { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder().build(); + let core = Arc::new(Core::open(&url, registry.clone()).await.unwrap()); + + // Provision alice with a password (accounts are shell-provisioned; here we call the same store API). + let store = core.store(); + auth::provision_user(store.pool(), "alice").await.unwrap(); + auth::set_password(store.pool(), "alice", "hunter2") + .await + .unwrap(); + + let app = router(AppState { + core, + registry, + web_dir: dir.path().to_path_buf(), + }); + (dir, app) +} + +fn login_req(handle: &str, password: &str) -> Request { + Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("content-type", "application/json") + .body(Body::from( + json!({ "handle": handle, "password": password }).to_string(), + )) + .unwrap() +} + +fn me_req(cookie: Option<&str>) -> Request { + let mut b = Request::builder().method("GET").uri("/api/auth/me"); + if let Some(c) = cookie { + b = b.header(header::COOKIE, c); + } + b.body(Body::empty()).unwrap() +} + +fn logout_req(cookie: &str) -> Request { + Request::builder() + .method("POST") + .uri("/api/auth/logout") + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap() +} + +/// The `cp_session=` pair from a `Set-Cookie` header (dropping the attributes). +fn session_cookie(res: &Response) -> String { + res.headers() + .get(header::SET_COOKIE) + .expect("login sets a cookie") + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned() +} + +#[tokio::test] +async fn login_me_logout_flow() { + let (_dir, app) = app().await; + + // Wrong password → 401, no cookie. + let res = app + .clone() + .oneshot(login_req("alice", "wrong")) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert!(res.headers().get(header::SET_COOKIE).is_none()); + + // Correct → 200 + Set-Cookie + the user. + let res = app + .clone() + .oneshot(login_req("alice", "hunter2")) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let cookie = session_cookie(&res); + assert!(cookie.starts_with("cp_session=")); + assert_eq!(json_body(res).await["handle"], "alice"); + + // /me without the cookie → 401. + let res = app.clone().oneshot(me_req(None)).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + + // /me with the cookie → 200 alice. + let res = app.clone().oneshot(me_req(Some(&cookie))).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(json_body(res).await["handle"], "alice"); + + // Logout → 204. + let res = app.clone().oneshot(logout_req(&cookie)).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + + // The now-revoked cookie no longer authenticates. + let res = app.oneshot(me_req(Some(&cookie))).await.unwrap(); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "session was revoked on logout" + ); +} diff --git a/channel-party/crates/cp-frontend/tests/authenticated_write.rs b/channel-party/crates/cp-frontend/tests/authenticated_write.rs new file mode 100644 index 0000000..e1c0ac8 --- /dev/null +++ b/channel-party/crates/cp-frontend/tests/authenticated_write.rs @@ -0,0 +1,200 @@ +//! End-to-end authenticated write (`TODO.md` #18, `design/permissions.md`): `POST /api/channels/:id/items` +//! over the real router via `oneshot`. Proves the full gate with the real `basic` slice — a session is +//! required (401), the kind's `Permission` must allow `Post` (basic = members-only → 403 for a +//! non-member; deny-by-default → 403 for a kind with no `Permission`), and on success the item is created +//! with its author stamped server-side (a client-supplied author is overwritten). + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use axum::response::Response; +use axum::Router; +use cp_core::{auth, Core, Registry}; +use cp_frontend::{router, AppState}; +use cp_model::{ChannelId, NewChannel, TypeId, UserId, WriteCtx}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use tower::ServiceExt; + +async fn json_body(res: Response) -> Value { + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} + +struct Harness { + _dir: tempfile::TempDir, + app: Router, + core: Arc, +} + +async fn harness() -> Harness { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder() + .channel(cp_basic::channel()) + .item(cp_basic::item()) + .channel(cp_space::channel()) // no Permission → deny-by-default + .build(); + let core = Arc::new(Core::open(&url, registry.clone()).await.unwrap()); + let app = router(AppState { + core: core.clone(), + registry, + web_dir: dir.path().to_path_buf(), + }); + Harness { + _dir: dir, + app, + core, + } +} + +/// Provision a user with a password, returning their id (the login path is shell-provisioned). +async fn user(core: &Core, handle: &str) -> UserId { + let pool = core.store(); + let id = auth::provision_user(pool.pool(), handle).await.unwrap(); + auth::set_password(pool.pool(), handle, "pw").await.unwrap(); + id +} + +async fn channel(core: &Core, type_id: &str) -> ChannelId { + core.store() + .create_channel(NewChannel { + type_id: TypeId::new(type_id), + container: None, + payload: json!({ "name": type_id }), + }) + .await + .unwrap() +} + +/// Log in and return the `cp_session=…` cookie pair. +async fn login(app: &Router, handle: &str) -> String { + let res = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("content-type", "application/json") + .body(Body::from( + json!({ "handle": handle, "password": "pw" }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + res.headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned() +} + +fn post_item( + channel: ChannelId, + cookie: Option<&str>, + type_id: &str, + payload: Value, +) -> Request { + let body = json!({ "type_id": type_id, "payload": payload }); + let mut b = Request::builder() + .method("POST") + .uri(format!("/api/channels/{channel}/items")) + .header("content-type", "application/json"); + if let Some(c) = cookie { + b = b.header(header::COOKIE, c); + } + b.body(Body::from(body.to_string())).unwrap() +} + +#[tokio::test] +async fn member_posts_and_author_is_stamped() { + let h = harness().await; + let alice = user(&h.core, "alice").await; + let room = channel(&h.core, "basic").await; + h.core.store().add_member(room, alice).await.unwrap(); + let cookie = login(&h.app, "alice").await; + + // A client-supplied author is present but must be ignored — the server stamps the real principal. + let res = h + .app + .clone() + .oneshot(post_item( + room, + Some(&cookie), + "basic", + json!({ "body": "hello", "author": "spoofed" }), + )) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::CREATED); + let id = json_body(res).await["id"].as_str().unwrap().to_owned(); + + // The stored item carries the body and the authenticated author, not the spoofed one. + let res = h + .app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/items/{id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let item = json_body(res).await; + assert_eq!(item["payload"]["body"], "hello"); + assert_eq!(item["payload"]["author"], alice.to_string()); +} + +#[tokio::test] +async fn unauthenticated_and_unauthorized_are_rejected() { + let h = harness().await; + let _alice = user(&h.core, "alice").await; + let _bob = user(&h.core, "bob").await; + let room = channel(&h.core, "basic").await; + let space = channel(&h.core, "space").await; + + // No session → 401 (the CurrentUser extractor rejects before any handler logic). + let res = h + .app + .clone() + .oneshot(post_item(room, None, "basic", json!({ "body": "hi" }))) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + + // Logged in but not a member of the basic room → 403. + let bob_cookie = login(&h.app, "bob").await; + let res = h + .app + .clone() + .oneshot(post_item( + room, + Some(&bob_cookie), + "basic", + json!({ "body": "hi" }), + )) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + + // A kind with no Permission capability (space) is deny-by-default, even for a member-less action → 403. + let res = h + .app + .oneshot(post_item( + space, + Some(&bob_cookie), + "basic", + json!({ "body": "hi" }), + )) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); +} diff --git a/channel-party/crates/cp-frontend/tests/canvas_spatial.rs b/channel-party/crates/cp-frontend/tests/canvas_spatial.rs new file mode 100644 index 0000000..83ef464 --- /dev/null +++ b/channel-party/crates/cp-frontend/tests/canvas_spatial.rs @@ -0,0 +1,228 @@ +//! End-to-end slice for `canvas` (`TODO.md` #4 + #11): the reference escape-hatch kind. A real axum +//! request → `contents` → a viewport bbox query over the kind's *own* R-tree, which its `SpatialIndex` +//! `RuntimeComponent` maintains off the change stream. Proves the §6 type-owned-table escape hatch and +//! the §7 supervisor together: core knows nothing of `canvas_*`, yet backfill + live streaming + viewport +//! filtering + move/delete all work over HTTP. Boxes are seeded through the write path; the runtime is +//! actually spawned (in the test's tokio runtime), so the tests poll for its async convergence. + +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::http::Request; +use axum::response::Response; +use axum::Router; +use cp_core::{Core, Registry}; +use cp_frontend::{router, AppState}; +use cp_model::{ChannelId, ItemId, NewChannel, NewItem, TypeId, WriteCtx}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use tower::ServiceExt; + +async fn json_body(res: Response) -> Value { + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} + +/// POST a viewport query to a canvas's `contents` and return the box nodes. +async fn view(app: &Router, canvas: ChannelId, viewport: Value) -> Vec { + let req = Request::builder() + .method("POST") + .uri(format!("/api/channels/{canvas}/contents")) + .header("content-type", "application/json") + .body(Body::from(viewport.to_string())) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + json_body(res).await["nodes"].as_array().cloned().unwrap() +} + +/// Poll a full-plane query until `pred` holds over the returned boxes (the R-tree is maintained +/// asynchronously by the `SpatialIndex`, so writes converge after a short delay). +async fn wait_for(app: &Router, canvas: ChannelId, pred: impl Fn(&[Value]) -> bool) -> Vec { + for _ in 0..150 { + let boxes = view(app, canvas, json!({})).await; + if pred(&boxes) { + return boxes; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!( + "timed out; boxes = {:?}", + view(app, canvas, json!({})).await + ); +} + +fn box_x(node: &Value) -> f64 { + node["payload"]["x"].as_f64().unwrap() +} + +async fn add_box( + store: &Arc, + canvas: ChannelId, + x: f64, + y: f64, + w: f64, + h: f64, +) -> ItemId { + store + .create_item(NewItem { + type_id: TypeId::new("canvas-text-box"), + container: Some(canvas), + external_key: None, + payload: json!({ "x": x, "y": y, "w": w, "h": h, "text": "box" }), + }) + .await + .unwrap() +} + +/// A canvas, its wired app, the spawned runtime handle, and the shared store. +struct Fixture { + _dir: tempfile::TempDir, + app: Router, + handle: cp_core::runtime::RuntimeHandle, + store: Arc, + canvas: ChannelId, +} + +async fn setup() -> Fixture { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder() + .channel(cp_canvas::channel()) + .item(cp_canvas::text_box()) + .runtime(cp_canvas::spatial_index()) + .migrations(cp_canvas::MIGRATIONS) + .build(); + let core = Arc::new(Core::open(&url, registry.clone()).await.unwrap()); + let store = core.store(); + let canvas = store + .create_channel(NewChannel { + type_id: TypeId::new("canvas"), + container: None, + payload: json!({ "name": "board" }), + }) + .await + .unwrap(); + let handle = core.spawn_runtime(); + let app = router(AppState { + core, + registry, + web_dir: dir.path().to_path_buf(), + }); + Fixture { + _dir: dir, + app, + handle, + store, + canvas, + } +} + +#[tokio::test] +async fn backfill_stream_and_viewport_filtering() { + // One box exists BEFORE the runtime starts → arrives via backfill; the rest are streamed. + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder() + .channel(cp_canvas::channel()) + .item(cp_canvas::text_box()) + .runtime(cp_canvas::spatial_index()) + .migrations(cp_canvas::MIGRATIONS) + .build(); + let core = Arc::new(Core::open(&url, registry.clone()).await.unwrap()); + let store = core.store(); + let canvas = store + .create_channel(NewChannel { + type_id: TypeId::new("canvas"), + container: None, + payload: json!({ "name": "board" }), + }) + .await + .unwrap(); + let near = add_box(&store, canvas, 10.0, 10.0, 5.0, 5.0).await; // covers [10,15]² + + let handle = core.spawn_runtime(); + let app = router(AppState { + core, + registry, + web_dir: dir.path().to_path_buf(), + }); + + // Backfill picks up the pre-existing box. + wait_for(&app, canvas, |b| b.len() == 1).await; + + // Streamed boxes: one nearby, one far away. + let far = add_box(&store, canvas, 1000.0, 1000.0, 10.0, 10.0).await; + add_box(&store, canvas, 100.0, 100.0, 10.0, 10.0).await; // covers [100,110]² + wait_for(&app, canvas, |b| b.len() == 3).await; + + // Viewport [0,0]-[50,50] sees only the near box (100² and 1000² boxes don't overlap it). + let small = view( + &app, + canvas, + json!({ "x0": 0, "y0": 0, "x1": 50, "y1": 50 }), + ) + .await; + assert_eq!( + small.len(), + 1, + "only the box at (10,10) overlaps: {small:?}" + ); + assert_eq!(small[0]["id"], near.to_string()); + assert!((box_x(&small[0]) - 10.0).abs() < 0.01); + assert!(small.iter().all(|n| n["super_type"] == "item")); + + // A viewport around (100,100) sees only that box, not the far one. + let mid = view( + &app, + canvas, + json!({ "x0": 90, "y0": 90, "x1": 120, "y1": 120 }), + ) + .await; + assert_eq!(mid.len(), 1, "only the (100,100) box: {mid:?}"); + assert!(!mid.iter().any(|n| n["id"] == far.to_string())); + + handle.shutdown().await; +} + +#[tokio::test] +async fn reflects_move_and_delete() { + let fx = setup().await; + let a = add_box(&fx.store, fx.canvas, 10.0, 10.0, 5.0, 5.0).await; + let b = add_box(&fx.store, fx.canvas, 20.0, 20.0, 5.0, 5.0).await; + wait_for(&fx.app, fx.canvas, |boxes| boxes.len() == 2).await; + + // Move `a` far away: it leaves the [0,50] viewport and reappears at its new spot. + fx.store + .set_item_payload( + a, + json!({ "x": 900.0, "y": 900.0, "w": 5.0, "h": 5.0, "text": "moved" }), + ) + .await + .unwrap(); + wait_for(&fx.app, fx.canvas, |boxes| { + boxes + .iter() + .any(|n| n["id"] == a.to_string() && box_x(n) > 800.0) + }) + .await; + let near = view( + &fx.app, + fx.canvas, + json!({ "x0": 0, "y0": 0, "x1": 50, "y1": 50 }), + ) + .await; + assert!( + near.iter().all(|n| n["id"] != a.to_string()), + "moved box left the near viewport: {near:?}" + ); + + // Delete `b`: it disappears from the index. + fx.store.delete_item(b).await.unwrap(); + wait_for(&fx.app, fx.canvas, |boxes| { + boxes.iter().all(|n| n["id"] != b.to_string()) + }) + .await; + + fx.handle.shutdown().await; +} diff --git a/channel-party/crates/cp-frontend/tests/contents_slice.rs b/channel-party/crates/cp-frontend/tests/contents_slice.rs new file mode 100644 index 0000000..d7d805b --- /dev/null +++ b/channel-party/crates/cp-frontend/tests/contents_slice.rs @@ -0,0 +1,199 @@ +//! End-to-end vertical slice (`TODO.md` #8 + #12): a real axum request → generic handler → `contents` +//! dispatch → `basic`'s `StoreCtx` composition → JSON. Data is seeded through the write path against a +//! tempfile sqlite; requests hit the actual `Router` via `oneshot` (no socket). This is the first test +//! that wires a concrete kind, core, and the HTTP layer together — the proof the seams compose. + +use std::collections::HashSet; +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::response::Response; +use axum::Router; +use cp_core::{Core, Registry}; +use cp_frontend::{router, AppState}; +use cp_model::{ChannelId, NewChannel, NewItem, TypeId, WriteCtx}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +async fn json_body(res: Response) -> serde_json::Value { + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} + +fn get(uri: String) -> Request { + Request::builder().uri(uri).body(Body::empty()).unwrap() +} + +fn post_json(uri: String, body: serde_json::Value) -> Request { + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap() +} + +/// Seed a `basic` channel with three message items and return the wired app + the seeded ids. +async fn seeded() -> (tempfile::TempDir, Router, ChannelId, Vec) { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder() + .channel(cp_basic::channel()) + .item(cp_basic::item()) + .build(); + let core = Arc::new(Core::open(&url, registry.clone()).await.unwrap()); + + let store = core.store(); + let cid = store + .create_channel(NewChannel { + type_id: TypeId::new("basic"), + container: None, + payload: serde_json::json!({ "name": "general" }), + }) + .await + .unwrap(); + let mut items = Vec::new(); + for body in ["first", "second", "third"] { + items.push( + store + .create_item(NewItem { + type_id: TypeId::new("basic"), + container: Some(cid), + external_key: None, + payload: serde_json::json!({ "body": body }), + }) + .await + .unwrap(), + ); + } + + let app = router(AppState { + core, + registry, + web_dir: dir.path().to_path_buf(), + }); + (dir, app, cid, items) +} + +#[tokio::test] +async fn get_channel_returns_the_envelope() { + let (_dir, app, cid, _items) = seeded().await; + let res = app + .oneshot(get(format!("/api/channels/{cid}"))) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = json_body(res).await; + assert_eq!(body["id"], cid.to_string()); + assert_eq!( + body["type_id"], "basic", + "the shell mounts the island off this" + ); + assert_eq!(body["payload"]["name"], "general"); +} + +#[tokio::test] +async fn channel_contents_paginate_newest_first() { + let (_dir, app, cid, _items) = seeded().await; + + // Page 1 (limit 2): a NodePage of items with a continuation cursor. + let res = app + .clone() + .oneshot(post_json( + format!("/api/channels/{cid}/contents"), + serde_json::json!({ "limit": 2 }), + )) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let p1 = json_body(res).await; + let p1_nodes = p1["nodes"].as_array().unwrap(); + assert_eq!(p1_nodes.len(), 2, "limit honored"); + assert!(p1_nodes.iter().all(|n| n["super_type"] == "item")); + assert!(p1["next"].is_string(), "a further page exists"); + + // Page 2 via the cursor: the remaining item, then end-of-feed. + let res = app + .oneshot(post_json( + format!("/api/channels/{cid}/contents"), + serde_json::json!({ "cursor": p1["next"], "limit": 2 }), + )) + .await + .unwrap(); + let p2 = json_body(res).await; + let p2_nodes = p2["nodes"].as_array().unwrap(); + assert_eq!(p2_nodes.len(), 1, "one item left"); + assert!(p2["next"].is_null(), "end of feed"); + + // The two pages together are exactly the three seeded items, in strictly descending id order + // (TimeDesc). Assert on id order, not insertion order: same-ms ULIDs tie on the time prefix. + let all: Vec<&serde_json::Value> = p1_nodes.iter().chain(p2_nodes).collect(); + let bodies: HashSet<&str> = all + .iter() + .map(|n| n["payload"]["body"].as_str().unwrap()) + .collect(); + assert_eq!(bodies, HashSet::from(["first", "second", "third"])); + let ids: Vec<&str> = all.iter().map(|n| n["id"].as_str().unwrap()).collect(); + assert!( + ids.windows(2).all(|w| w[0] > w[1]), + "strictly descending id across pages" + ); +} + +#[tokio::test] +async fn contents_at_timestamp_seeks_the_feed() { + let (_dir, app, cid, _items) = seeded().await; + // `basic` reads newest-first (TimeDesc), so `query.at` selects items *at/before* T (scroll back to + // a date). Two bracketing timestamps prove seek_time is wired through the handler, in the right + // direction: nothing exists before the epoch; everything exists before the far future. + let count_before = |at: u64| { + let app = app.clone(); + async move { + let res = app + .oneshot(post_json( + format!("/api/channels/{cid}/contents"), + serde_json::json!({ "at": at }), + )) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + json_body(res).await["nodes"].as_array().unwrap().len() + } + }; + assert_eq!(count_before(0).await, 0, "no items before the epoch"); + assert_eq!( + count_before(32_503_680_000_000).await, // ~year 3000 + 3, + "all items are before the far future" + ); +} + +#[tokio::test] +async fn get_item_returns_the_envelope() { + let (_dir, app, _cid, items) = seeded().await; + let iid = items[0]; + let res = app.oneshot(get(format!("/api/items/{iid}"))).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(json_body(res).await["payload"]["body"], "first"); +} + +#[tokio::test] +async fn absent_and_malformed_ids_are_clean_errors() { + let (_dir, app, _cid, _items) = seeded().await; + + // A well-formed but absent id → 404 (not 501, not a panic). + let res = app + .clone() + .oneshot(get(format!("/api/channels/{}", ChannelId::generate()))) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + + // A garbage id → 400. + let res = app + .oneshot(get("/api/items/not-a-ulid".to_owned())) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); +} diff --git a/channel-party/crates/cp-frontend/tests/linked_users.rs b/channel-party/crates/cp-frontend/tests/linked_users.rs new file mode 100644 index 0000000..21619fb --- /dev/null +++ b/channel-party/crates/cp-frontend/tests/linked_users.rs @@ -0,0 +1,90 @@ +//! `linked-users` HTTP reads (`TODO.md` #19, `design/linked-users.md`) over the real router via +//! `oneshot`: list a user's external links, and resolve an item up to its native user (authorship +//! resolution). Links are seeded through the core API (they are shell-provisioned — no HTTP write). + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::response::Response; +use cp_core::{auth, links, Core, Registry}; +use cp_frontend::{router, AppState}; +use cp_model::{NewChannel, NewItem, TypeId, WriteCtx}; +use http_body_util::BodyExt; +use serde_json::Value; +use tower::ServiceExt; + +async fn json_body(res: Response) -> Value { + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} + +fn get(uri: String) -> Request { + Request::builder().uri(uri).body(Body::empty()).unwrap() +} + +#[tokio::test] +async fn list_links_and_resolve_authorship() { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder() + .channel(cp_basic::channel()) + .item(cp_basic::item()) + .build(); + let core = Arc::new(Core::open(&url, registry.clone()).await.unwrap()); + + // Seed: alice, a channel, a linked item (stands in for a cached-user) and an unlinked one. + let alice = auth::provision_user(core.pool(), "alice").await.unwrap(); + let store = core.store(); + let cid = store + .create_channel(NewChannel { + type_id: TypeId::new("basic"), + container: None, + payload: serde_json::json!({ "name": "general" }), + }) + .await + .unwrap(); + let mk_item = |body: &str| NewItem { + type_id: TypeId::new("basic"), + container: Some(cid), + external_key: None, + payload: serde_json::json!({ "body": body }), + }; + let linked = store.create_item(mk_item("i-am-alice")).await.unwrap(); + let unlinked = store.create_item(mk_item("nobody")).await.unwrap(); + links::link(core.pool(), alice, linked).await.unwrap(); + + let app = router(AppState { + core: core.clone(), + registry, + web_dir: dir.path().to_path_buf(), + }); + + // GET /api/users/:id/links -> the linked item envelope. + let res = app + .clone() + .oneshot(get(format!("/api/users/{alice}/links"))) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = json_body(res).await; + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["id"], linked.to_string()); + + // GET /api/items/:id/linked-user -> alice (authorship resolution). + let res = app + .clone() + .oneshot(get(format!("/api/items/{linked}/linked-user"))) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(json_body(res).await["handle"], "alice"); + + // The unlinked item resolves to nobody -> 404. + let res = app + .oneshot(get(format!("/api/items/{unlinked}/linked-user"))) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} diff --git a/channel-party/crates/cp-frontend/tests/space_search.rs b/channel-party/crates/cp-frontend/tests/space_search.rs new file mode 100644 index 0000000..71b0715 --- /dev/null +++ b/channel-party/crates/cp-frontend/tests/space_search.rs @@ -0,0 +1,162 @@ +//! End-to-end slice for `space` (`TODO.md` #3/#9): a real axum request → `contents` dispatch → +//! `space`'s `search` composition → FTS → JSON. A `space` holds `basic` channels; the search finds them +//! by name *without `space` knowing the `basic` type* — the vertical-slice + genericity proof (DESIGN +//! §5/§12). Data is seeded through the write path against a tempfile sqlite; requests hit the `Router` +//! via `oneshot`. + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::response::Response; +use axum::Router; +use cp_core::{Core, Registry}; +use cp_frontend::{router, AppState}; +use cp_model::{ChannelId, NewChannel, TypeId, WriteCtx}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +async fn json_body(res: Response) -> serde_json::Value { + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} + +fn search(space: ChannelId, query: serde_json::Value) -> Request { + Request::builder() + .method("POST") + .uri(format!("/api/channels/{space}/contents")) + .header("content-type", "application/json") + .body(Body::from(query.to_string())) + .unwrap() +} + +/// A `space` containing five named `basic` rooms, plus a same-named room *outside* it. Returns the app +/// and the space id. +async fn seeded() -> (tempfile::TempDir, Router, ChannelId) { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder() + .channel(cp_space::channel()) + .channel(cp_basic::channel()) + .build(); + let core = Arc::new(Core::open(&url, registry.clone()).await.unwrap()); + + let store = core.store(); + let mk = |name: &'static str, container: Option| { + let store = store.clone(); + async move { + store + .create_channel(NewChannel { + type_id: TypeId::new("basic"), + container, + payload: serde_json::json!({ "name": name }), + }) + .await + .unwrap() + } + }; + + let space = store + .create_channel(NewChannel { + type_id: TypeId::new("space"), + container: None, + payload: serde_json::json!({ "name": "server" }), + }) + .await + .unwrap(); + for name in ["general", "genesis", "random", "gardening"] { + mk(name, Some(space)).await; + } + // Outside the space: shares the "gen" substring but must not be found. + mk("generosity", None).await; + + let app = router(AppState { + core, + registry, + web_dir: dir.path().to_path_buf(), + }); + (dir, app, space) +} + +fn names(page: &serde_json::Value) -> Vec { + let mut v: Vec = page["nodes"] + .as_array() + .unwrap() + .iter() + .map(|n| n["payload"]["name"].as_str().unwrap().to_owned()) + .collect(); + v.sort(); + v +} + +#[tokio::test] +async fn space_search_finds_descendant_channels_by_name() { + let (_dir, app, space) = seeded().await; + let res = app + .oneshot(search(space, serde_json::json!({ "q": "gen" }))) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let page = json_body(res).await; + assert_eq!( + names(&page), + vec!["general", "genesis"], + "in-space `gen` channels; `gardening`/`random` don't contain it, `generosity` is outside" + ); + assert!( + page["nodes"] + .as_array() + .unwrap() + .iter() + .all(|n| n["super_type"] == "channel"), + "results are channel references the island mounts" + ); +} + +#[tokio::test] +async fn space_search_paginates_and_empty_query_is_empty() { + let (_dir, app, space) = seeded().await; + + // Empty query → empty page (the <3-char guard), not an error: a freshly opened search box. + let res = app + .clone() + .oneshot(search(space, serde_json::json!({ "q": "" }))) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert!(json_body(res).await["nodes"].as_array().unwrap().is_empty()); + + // "gen" matches two rooms; page size 1 walks them across two pages via the offset cursor. + let p1 = json_body( + app.clone() + .oneshot(search(space, serde_json::json!({ "q": "gen", "limit": 1 }))) + .await + .unwrap(), + ) + .await; + assert_eq!(p1["nodes"].as_array().unwrap().len(), 1); + assert!(p1["next"].is_string(), "a further page exists"); + + let p2 = json_body( + app.oneshot(search( + space, + serde_json::json!({ "q": "gen", "limit": 1, "cursor": p1["next"] }), + )) + .await + .unwrap(), + ) + .await; + assert_eq!(p2["nodes"].as_array().unwrap().len(), 1); + assert!(p2["next"].is_null(), "two matches exhausted"); + + let mut both = vec![ + p1["nodes"][0]["payload"]["name"].as_str().unwrap(), + p2["nodes"][0]["payload"]["name"].as_str().unwrap(), + ]; + both.sort(); + assert_eq!( + both, + vec!["general", "genesis"], + "no gaps or dups across pages" + ); +} diff --git a/channel-party/crates/cp-frontend/tests/sse_live.rs b/channel-party/crates/cp-frontend/tests/sse_live.rs new file mode 100644 index 0000000..6121471 --- /dev/null +++ b/channel-party/crates/cp-frontend/tests/sse_live.rs @@ -0,0 +1,175 @@ +//! SSE live-update slice (`TODO.md` #13): subscribe to `GET /api/events?scope=…`, then a committed +//! write on the same core must surface as a `change` event on the stream. Drives the real Router + +//! broadcast bus, bounded by timeouts so a wiring regression fails fast instead of hanging. + +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::http::header::CONTENT_TYPE; +use axum::http::{Request, StatusCode}; +use cp_core::{Core, Registry}; +use cp_frontend::{router, AppState}; +use cp_model::{NewChannel, NewItem, TypeId, WriteCtx}; +use tokio::time::timeout; +use tokio_stream::StreamExt; +use tower::ServiceExt; + +#[tokio::test] +async fn write_surfaces_as_scoped_sse_change_event() { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder() + .channel(cp_basic::channel()) + .item(cp_basic::item()) + .build(); + let core = Arc::new(Core::open(&url, registry.clone()).await.unwrap()); + let cid = core + .store() + .create_channel(NewChannel { + type_id: TypeId::new("basic"), + container: None, + payload: serde_json::json!({}), + }) + .await + .unwrap(); + + let app = router(AppState { + core: core.clone(), + registry, + web_dir: dir.path().to_path_buf(), + }); + + // Open the stream scoped to the channel. The handler subscribes to the bus before returning, so a + // write issued after this await is guaranteed to be buffered for us. + let res = timeout( + Duration::from_secs(5), + app.oneshot( + Request::builder() + .uri(format!("/api/events?scope={cid}")) + .body(Body::empty()) + .unwrap(), + ), + ) + .await + .expect("handler responded") + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let ct = res.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap(); + assert!(ct.starts_with("text/event-stream"), "got content-type {ct}"); + + // Commit a write on the same core -> emits a ChangeEvent whose container is this channel. + let iid = core + .store() + .create_item(NewItem { + type_id: TypeId::new("basic"), + container: Some(cid), + external_key: None, + payload: serde_json::json!({ "body": "hello" }), + }) + .await + .unwrap(); + + // Read frames until the change event for our item arrives (its id appears only in that frame). + let mut stream = res.into_body().into_data_stream(); + let mut buf = String::new(); + let found = timeout(Duration::from_secs(5), async { + while let Some(chunk) = stream.next().await { + buf.push_str(&String::from_utf8_lossy(&chunk.unwrap())); + if buf.contains(&iid.to_string()) { + return true; + } + } + false + }) + .await + .expect("event arrived before timeout"); + + assert!(found, "the committed item surfaced on the SSE stream"); + assert!(buf.contains("\"op\":\"created\""), "frame: {buf}"); + assert!(buf.contains("\"super_type\":\"item\""), "frame: {buf}"); +} + +#[tokio::test] +async fn scope_filters_out_other_channels() { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let registry = Registry::builder() + .channel(cp_basic::channel()) + .item(cp_basic::item()) + .build(); + let core = Arc::new(Core::open(&url, registry.clone()).await.unwrap()); + let watched = core + .store() + .create_channel(NewChannel { + type_id: TypeId::new("basic"), + container: None, + payload: serde_json::json!({}), + }) + .await + .unwrap(); + let other = core + .store() + .create_channel(NewChannel { + type_id: TypeId::new("basic"), + container: None, + payload: serde_json::json!({}), + }) + .await + .unwrap(); + + let app = router(AppState { + core: core.clone(), + registry, + web_dir: dir.path().to_path_buf(), + }); + let res = app + .oneshot( + Request::builder() + .uri(format!("/api/events?scope={watched}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // A write into a *different* channel, then one into the watched channel. The first must not appear; + // seeing the second's id first proves the earlier out-of-scope event was filtered, not merely late. + let noise = core + .store() + .create_item(NewItem { + type_id: TypeId::new("basic"), + container: Some(other), + external_key: None, + payload: serde_json::json!({}), + }) + .await + .unwrap(); + let wanted = core + .store() + .create_item(NewItem { + type_id: TypeId::new("basic"), + container: Some(watched), + external_key: None, + payload: serde_json::json!({}), + }) + .await + .unwrap(); + + let mut stream = res.into_body().into_data_stream(); + let mut buf = String::new(); + timeout(Duration::from_secs(5), async { + while let Some(chunk) = stream.next().await { + buf.push_str(&String::from_utf8_lossy(&chunk.unwrap())); + if buf.contains(&wanted.to_string()) { + return; + } + } + }) + .await + .expect("scoped event arrived"); + assert!( + !buf.contains(&noise.to_string()), + "out-of-scope channel's event leaked through: {buf}" + ); +} diff --git a/channel-party/crates/cp-model/Cargo.toml b/channel-party/crates/cp-model/Cargo.toml index 4a5f8ab..ec0889d 100644 --- a/channel-party/crates/cp-model/Cargo.toml +++ b/channel-party/crates/cp-model/Cargo.toml @@ -9,5 +9,8 @@ async-trait.workspace = true axum.workspace = true serde.workspace = true serde_json.workspace = true +# The interface crate is committed to sqlite: the §6 type-owned-table escape hatch hands escape-hatch +# kinds a `&SqlitePool` through `StoreCtx`/`RuntimeCtx` (see `design/runtime.md`). Pure kinds ignore it. +sqlx.workspace = true thiserror.workspace = true ulid.workspace = true diff --git a/channel-party/crates/cp-model/src/envelope.rs b/channel-party/crates/cp-model/src/envelope.rs index 43754ec..6f249c3 100644 --- a/channel-party/crates/cp-model/src/envelope.rs +++ b/channel-party/crates/cp-model/src/envelope.rs @@ -32,7 +32,8 @@ pub struct Item { /// The one native principal. Not a super-type because it is not extensible: there is exactly one /// native user representation, and it is the only thing that authenticates, owns, or holds -/// permissions. Auth material and `created_at` live here; elided in the scaffold. §2. +/// permissions. Auth material (`password_hash`) and `created_at` live on the `users` *table*, not this +/// struct — deliberately, so a `User` sent to a client never carries the hash (§2/§17, `cp-core::auth`). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct User { pub id: UserId, diff --git a/channel-party/crates/cp-model/src/events.rs b/channel-party/crates/cp-model/src/events.rs new file mode 100644 index 0000000..9f35a8a --- /dev/null +++ b/channel-party/crates/cp-model/src/events.rs @@ -0,0 +1,30 @@ +//! Change events — the domain type the write path emits and runtime components + the SSE layer +//! consume. Lives here (not in `cp-core`) so a kind's `RuntimeComponent` can react to changes without +//! depending on `cp-core`. The broadcast bus that carries them is `cp-core`'s mechanism. See DESIGN §7. + +use crate::ids::{ChannelId, ItemId, TypeId}; + +/// What happened to an envelope. §7. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChangeOp { + Created, + Updated, + Deleted, +} + +/// Which envelope changed. §7. +#[derive(Clone, Copy, Debug)] +pub enum EnvelopeRef { + Channel(ChannelId), + Item(ItemId), +} + +/// A change event, emitted after a mutation commits. `container` is the scope SSE clients and runtime +/// component `interests` filter on. Carries no payload — a consumer that needs it does a point read. §7. +#[derive(Clone, Debug)] +pub struct ChangeEvent { + pub op: ChangeOp, + pub target: EnvelopeRef, + pub type_id: TypeId, + pub container: Option, +} diff --git a/channel-party/crates/cp-model/src/kind.rs b/channel-party/crates/cp-model/src/kind.rs index 08a5662..72c66fb 100644 --- a/channel-party/crates/cp-model/src/kind.rs +++ b/channel-party/crates/cp-model/src/kind.rs @@ -9,6 +9,7 @@ use crate::debug::DebugCommand; use crate::envelope::{Channel, Item, Json}; use crate::ids::{TypeId, UserId}; use crate::store::StoreCtx; +use crate::write::WriteCtx; use crate::Result; /// A kind-declared projection of searchable / sortable fields, applied transactionally on write @@ -48,6 +49,12 @@ pub trait ChannelKind: Send + Sync { None } + /// Authorization capability. `None` = deny-by-default: the channel is not writable over HTTP + /// until its kind grants an action. Opt-in like `membership`. §18. + fn permission(&self) -> Option<&dyn Permission> { + None + } + /// Extra HTTP routes, mounted under `/ext/` (e.g. a webhook receiver). §4/§9. fn routes(&self) -> Option { None @@ -78,16 +85,53 @@ pub trait ItemKind: Send + Sync { None } + /// Stamp server-known authorship into a new item's payload before it is persisted. The write + /// endpoint calls this with the authenticated principal; a client-supplied `author` is overwritten + /// — provenance is not client-trusted (§2). Default: unchanged (a kind with no notion of an author, + /// e.g. a canvas box). §18. + fn with_author(&self, payload: Json, _author: UserId) -> Json { + payload + } + fn debug_summary(&self, _item: &Item) -> Option { None } } -/// Optional channel capability backing `add-user-to-channel`. What "add a user" means is the kind's -/// choice: a basic channel writes a membership edge; a Discord channel may reject or proxy. §8. +/// Optional channel capability backing `add-user-to-channel`. It receives a [`WriteCtx`] because it +/// mutates: what "add a user" means is the kind's choice — a basic channel calls +/// `cx.add_member(...)` on the generic substrate; a Discord channel may reject or proxy an outbound +/// invite. §8. #[async_trait] pub trait Membership: Send + Sync { - async fn add_user(&self, cx: &dyn StoreCtx, ch: &Channel, user: UserId) -> Result<()>; - async fn remove_user(&self, cx: &dyn StoreCtx, ch: &Channel, user: UserId) -> Result<()>; - async fn members(&self, cx: &dyn StoreCtx, ch: &Channel) -> Result>; + async fn add_user(&self, cx: &dyn WriteCtx, ch: &Channel, user: UserId) -> Result<()>; + async fn remove_user(&self, cx: &dyn WriteCtx, ch: &Channel, user: UserId) -> Result<()>; + async fn members(&self, cx: &dyn WriteCtx, ch: &Channel) -> Result>; +} + +/// A permission-checked action on a channel. A small, fixed, core-owned vocabulary (like +/// [`SuperType`](crate::store::SuperType)), distinct from the open-ended kind set. §18. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + /// Read a channel's contents. Defined for completeness; reads are not gated yet (§18 enforces writes). + View, + /// Create an item (post a message) in the channel. + Post, + /// Administer the channel: membership, structure, configuration. + Manage, +} + +/// Optional per-channel authorization (§18). Its absence (`ChannelKind::permission() -> None`) means +/// deny-by-default — the channel declines to authorize anyone. The policy is the kind's own: it may +/// consult the generic `channel_members` substrate ([`StoreCtx::is_member`]) or its own tables. It +/// takes a read `cx`, never [`WriteCtx`] — deciding never mutates. +#[async_trait] +pub trait Permission: Send + Sync { + async fn authorize( + &self, + cx: &dyn StoreCtx, + ch: &Channel, + user: UserId, + action: Action, + ) -> Result; } diff --git a/channel-party/crates/cp-model/src/lib.rs b/channel-party/crates/cp-model/src/lib.rs index 8373afe..35a2a2d 100644 --- a/channel-party/crates/cp-model/src/lib.rs +++ b/channel-party/crates/cp-model/src/lib.rs @@ -9,19 +9,23 @@ pub mod debug; pub mod envelope; +pub mod events; pub mod ids; pub mod kind; pub mod migration; pub mod runtime; pub mod store; +pub mod write; pub use debug::{DebugAccess, DebugCommand}; pub use envelope::{Channel, Item, Json, User, UserExternalLink}; +pub use events::{ChangeEvent, ChangeOp, EnvelopeRef}; pub use ids::{ChannelId, ItemId, TypeId, UserId}; -pub use kind::{ChannelKind, IndexEntry, ItemKind, Membership}; +pub use kind::{Action, ChannelKind, IndexEntry, ItemKind, Membership, Permission}; pub use migration::{Migration, Migrations}; -pub use runtime::{Interests, RuntimeComponent, RuntimeCtx, WriteScope}; +pub use runtime::{Interests, RuntimeComponent, RuntimeCtx, RuntimeEvent, WriteScope}; pub use store::{Cursor, Filter, Node, NodePage, Order, Page, StoreCtx, SuperType}; +pub use write::{NewChannel, NewItem, Upsert, WriteCtx}; /// Crate-wide result type. Kind capabilities and store primitives return this. pub type Result = std::result::Result; diff --git a/channel-party/crates/cp-model/src/runtime.rs b/channel-party/crates/cp-model/src/runtime.rs index e96ef97..44c6aae 100644 --- a/channel-party/crates/cp-model/src/runtime.rs +++ b/channel-party/crates/cp-model/src/runtime.rs @@ -1,11 +1,13 @@ //! `RuntimeComponent` — the one supervised-task abstraction. Ingesting workers and derived indexers //! are the same machine: a long-lived task that does an initial batch pass, then reacts to a stream -//! ("backfill-then-stream"). There is no separate `Worker` and `Indexer`. See DESIGN §7. +//! ("backfill-then-stream"). There is no separate `Worker` and `Indexer`. See DESIGN §7 and +//! `design/runtime.md`. use async_trait::async_trait; -use crate::ids::TypeId; -use crate::Result; +use crate::events::ChangeEvent; +use crate::ids::{ChannelId, ItemId, TypeId}; +use crate::{Channel, Item, Node, Result, WriteCtx}; /// What a component reacts to: a schedule, and/or the change stream filtered by envelope type. §7. #[derive(Clone, Debug, Default)] @@ -31,10 +33,43 @@ pub enum WriteScope { Derived, } -/// The handle a component receives: the store (restricted per `writes()`), the change-stream -/// subscription (filtered by `interests`), the scheduler, and shutdown. Concretely provided by -/// `cp-core`; a marker trait in the scaffold. §7. -pub trait RuntimeCtx: Send + Sync {} +/// One thing a component's loop reacts to, from [`RuntimeCtx::next_event`]. §7. +pub enum RuntimeEvent { + /// An interests-filtered change committed to the store. + Change(ChangeEvent), + /// The `interests.schedule_secs` interval fired. + Tick, +} + +/// The handle a component receives (concretely provided by `cp-core`): the interests-filtered change +/// stream + scheduler, point reads, a `WriteScope`-confined write surface, the type-owned DB escape +/// hatch, and the reset signal. See `design/runtime.md`. §7. +#[async_trait] +pub trait RuntimeCtx: Send + Sync { + /// Await the next change (pre-filtered to `interests.types`) or scheduler tick; `None` once the + /// supervisor shuts the component down (a clean loop exit). Broadcast lag is skipped silently. + async fn next_event(&self) -> Option; + + /// Point-read an item — a `ChangeEvent` carries no payload, so a `Derived` indexer fetches the + /// changed envelope to project it. + async fn get_item(&self, id: ItemId) -> Result>; + async fn get_channel(&self, id: ChannelId) -> Result>; + + /// Every existing envelope of the given types, id-ordered — the backfill enumerator. The change + /// stream carries only *new* changes, so a component reconstructs prior state through this. §7. + async fn scan(&self, types: &[TypeId]) -> Result>; + + /// The `Primary` write surface (envelope mutations), or `None` for a `Derived` component — which + /// therefore *cannot* write core envelopes. This is the §7 confinement, enforced structurally. + fn writer(&self) -> Option<&dyn WriteCtx>; + + /// A handle to the kind's own namespaced tables (the §6 escape hatch). Used to read/write a + /// type-owned index; not for touching core's `channels`/`items`. + fn type_owned_db(&self) -> &sqlx::SqlitePool; + + /// Whether `version()` was bumped since the last boot — the component resets (idempotently). §7. + fn reset_requested(&self) -> bool; +} /// A supervised, long-lived component: backfill then steady-state, all in one loop. Components are /// crate-contributed singletons (one Discord component manages all bridged guilds), not diff --git a/channel-party/crates/cp-model/src/store.rs b/channel-party/crates/cp-model/src/store.rs index f62dd2e..d6616d6 100644 --- a/channel-party/crates/cp-model/src/store.rs +++ b/channel-party/crates/cp-model/src/store.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use crate::envelope::{Channel, Item}; -use crate::ids::{ChannelId, TypeId}; +use crate::ids::{ChannelId, TypeId, UserId}; use crate::Result; /// Which super-type a query targets. §2/§5. @@ -90,4 +90,15 @@ pub trait StoreCtx: Send + Sync { filter: Filter, page: Page, ) -> Result; + + /// Membership-substrate read: is `user` a member of `channel`? The read companion to `WriteCtx`'s + /// `add_member` / `remove_member`, so a `Permission` policy (e.g. "members may post") can consult + /// it. §8/§18. + async fn is_member(&self, channel: ChannelId, user: UserId) -> Result; + + /// The §6 escape hatch: a handle to the kind's *own* namespaced tables, for a `contents` strategy + /// the closed primitives above can't express (e.g. `canvas`'s viewport bbox over its R-tree). Pure + /// primitive-consumers never call it; using it to read core's `channels`/`items` is a design + /// violation — the primitives are the supported read path. See `design/runtime.md`. + fn type_owned_db(&self) -> &sqlx::SqlitePool; } diff --git a/channel-party/crates/cp-model/src/write.rs b/channel-party/crates/cp-model/src/write.rs new file mode 100644 index 0000000..8189f38 --- /dev/null +++ b/channel-party/crates/cp-model/src/write.rs @@ -0,0 +1,72 @@ +//! The write path: `WriteCtx` — the single mutation surface. See `design/write-path.md` and +//! DESIGN §3/§8. Every mutation validates via the owning kind, writes the inline `index()` +//! projection (§6), and emits a change event — all transactionally. Implemented by `cp-core`'s +//! store; kinds receive it through the `Membership` capability. No other code writes envelope +//! tables. + +use async_trait::async_trait; + +use crate::envelope::Json; +use crate::ids::{ChannelId, ItemId, TypeId, UserId}; +use crate::store::StoreCtx; +use crate::Result; + +/// Fields for a new channel envelope. `id` is minted by core (a fresh ULID). +pub struct NewChannel { + pub type_id: TypeId, + pub container: Option, + pub payload: Json, +} + +/// Fields for a new (or upserted) item envelope. The kind owns its `external_key` uniqueness grain +/// by constructing the key string (e.g. `"discord:user:456"`); core never parses it. §3. +pub struct NewItem { + pub type_id: TypeId, + pub container: Option, + pub external_key: Option, + pub payload: Json, +} + +/// Outcome of `upsert_item`: whether the row was created or updated in place. The id is stable +/// across updates, so references (e.g. cached-messages pointing at a cached-user) never break. §3. +pub enum Upsert { + Inserted(Id), + Updated(Id), +} + +impl Upsert { + /// The id, whichever branch occurred. + pub fn id(&self) -> Id { + match self { + Upsert::Inserted(id) | Upsert::Updated(id) => *id, + } + } +} + +/// The single write path. A superset of [`StoreCtx`] (a mutation may read first — e.g. an update +/// looks up the target's immutable `type_id` to pick the right kind's `validate`). Every method +/// validates via the owning kind, writes the envelope + inline index in one transaction, and emits +/// a change event on commit. See DESIGN §3/§8 and `design/write-path.md`. +#[async_trait] +pub trait WriteCtx: StoreCtx { + async fn create_channel(&self, spec: NewChannel) -> Result; + async fn create_item(&self, spec: NewItem) -> Result; + + /// Insert-or-update keyed on `external_key` (required). The id is stable across updates. §3. + async fn upsert_item(&self, spec: NewItem) -> Result>; + + async fn set_channel_payload(&self, id: ChannelId, payload: Json) -> Result<()>; + async fn set_item_payload(&self, id: ItemId, payload: Json) -> Result<()>; + + async fn reparent_channel(&self, id: ChannelId, container: Option) -> Result<()>; + async fn reparent_item(&self, id: ItemId, container: Option) -> Result<()>; + + async fn delete_channel(&self, id: ChannelId) -> Result<()>; + async fn delete_item(&self, id: ItemId) -> Result<()>; + + /// The generic `channel_members` substrate, used by `ChannelKind::membership()` impls. The + /// user must already exist (members are native principals — §2); user creation is auth's job. §8. + async fn add_member(&self, channel: ChannelId, user: UserId) -> Result<()>; + async fn remove_member(&self, channel: ChannelId, user: UserId) -> Result<()>; + async fn members(&self, channel: ChannelId) -> Result>; +} diff --git a/channel-party/design/auth.md b/channel-party/design/auth.md new file mode 100644 index 0000000..d54a86e --- /dev/null +++ b/channel-party/design/auth.md @@ -0,0 +1,99 @@ +# Design note: native user auth + sessions (`TODO.md` #17) + +Status: **ratified & implemented (2026-07-11).** Password auth against the `users` substrate with +**provisioned accounts only** (no public registration) + server-side sessions. Folds into `DESIGN.md` +§2 (users are the one principal; "auth material lives here"). Scope: the identity/session *foundation* — +who is logged in — not authenticated write endpoints (those need the permissions model, #18) and not +open signup (a deliberate posture choice). + +## Decisions + +- **Provisioned accounts, no public signup.** A native `User` is created by the operator via the debug + shell (`create-user ` already exists) and given a password with a new gated `set-password + ` command. The HTTP surface exposes **only** `login` / `logout` / `me` — there is + no `/register` route. Suits a private instance; invite/self-signup can be layered on later without + disturbing this. +- **Password hashing: argon2** (argon2id, the current best practice), via the `argon2` crate. The PHC + string (`$argon2id$…`) is stored in a new `users.password_hash` column; a `NULL` hash means "no + password set" → cannot log in (so a bare `create-user` account is inert until `set-password`). +- **Server-side sessions**, not JWT: a login mints a 256-bit random opaque token, returned to the + browser in an **HttpOnly** cookie; the server stores only **`SHA-256(token)`** in a `sessions` table + (a DB leak never exposes a live token). Revocation = delete the row (logout, or future admin kill). + Chosen over stateless JWT because revocation is trivial and there's no signing-key/rotation footgun. +- **Where the code lives.** `cp-core::auth` owns the store logic (hashing, session CRUD — it touches + core's `users`/`sessions` tables, core's data per §2). `cp-frontend` owns the HTTP/cookie layer + (endpoints + a `CurrentUser` extractor) and calls `cp-core::auth`. Auth is *not* a kind capability + (§13 is unchanged) — it resolves against `users`, exactly as §2 mandates. + +## Schema (added to core's `0001_init.sql`) + +```sql +ALTER-equivalent: users gains password_hash TEXT -- PHC string; NULL = no password (can't log in) + +CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, -- SHA-256 hex of the opaque cookie token + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL -- datetime('now','+30 days'); string-comparable +); +``` + +Note: `password_hash` is added *inside* the `CREATE TABLE users` (fresh DBs get it). A real +column-add on a populated table needs the versioned migrator (#7, still open); the scaffold's DBs are +throwaway, so this is consistent with "idempotent CREATE-IF-NOT-EXISTS, re-run every boot" for now. +Sessions store `expires_at` via sqlite `datetime()`, so expiry is a plain string comparison in SQL — no +Rust clock handling. + +## `cp-core::auth` API (free fns over `&SqlitePool`, used by both the shell and the frontend) + +``` +set_password(pool, handle, password) -> Result<()> // argon2 hash → users.password_hash; NotFound if no such handle +authenticate(pool, handle, password) -> Result> // verify; None on bad handle / bad pw / no pw set +create_session(pool, user_id) -> Result // returns the plaintext token (the cookie value) +resolve_session(pool, token) -> Result> // hash → join sessions⋈users, WHERE not expired +delete_session(pool, token) -> Result<()> // logout +``` + +Token: 32 bytes from the OS CSPRNG (`argon2`'s re-exported `rand_core::OsRng`), hex-encoded for the +cookie; the DB row keys on its SHA-256 hex. `authenticate` runs argon2 verification, which is constant- +time within the hash; a missing user still returns `None` (a tiny timing asymmetry from skipping the +hash is acceptable for a private instance). + +## HTTP surface (`cp-frontend`) + +``` +POST /api/auth/login {handle, password} -> 200 {id, handle} + Set-Cookie: cp_session= (HttpOnly, SameSite=Lax, Path=/) + | 401 on bad credentials +POST /api/auth/logout -> 204, clears the cookie + deletes the session row +GET /api/auth/me -> 200 {id, handle} | 401 (drives the frontend's login state) +``` + +- **`CurrentUser` extractor** (`FromRequestParts`): reads the `cp_session` cookie, calls + `resolve_session`, yields the `User` or rejects `401`. `/me` uses it directly; future protected + routes (writes, #18) reuse it. Cookies via `axum-extra`'s `CookieJar`. +- **Cookie flags:** `HttpOnly` + `SameSite=Lax` + `Path=/` always; `Secure` is gated on + `CP_SECURE_COOKIES=1` (off for local http dev, on behind TLS) so the cookie isn't dropped over plain + http during testing. + +## Frontend (shell, not a kind island) + +The type-agnostic shell (`index.astro`) gains a header auth widget: on load it `GET`s `/api/auth/me`; +`401` → a **login form** (handle + password → `POST /login`); `200` → "signed in as *handle*" + a +logout button. No register form (provisioned accounts). Login/logout re-render the widget. + +## What this unblocks / defers + +- **Unblocks #18 (permissions):** every request can now resolve a principal; a permission capability + reads `CurrentUser`. +- **Deferred:** authenticated *write* endpoints (posting as a user) — they need #18 + a generic write + API, out of scope here. Open self-signup, password reset, and Discord-OAuth linking (#19) are all + additive and don't disturb this session model. + +## What this touches + +- **`cp-core`:** new `auth.rs` (argon2 + `sha2` deps); `users.password_hash` + a `sessions` table in + `0001_init.sql`; `debug.rs` gains `set-password`. New `crates/cp-core/tests/auth.rs`. +- **`cp-frontend`:** new `auth.rs` (endpoints + `CurrentUser`), wired into the router; `axum-extra` + (cookie) dep. New `crates/cp-frontend/tests/auth_flow.rs`. +- **`web/`:** the shell's auth widget. +- **Not touched:** kinds, the store's envelope path, the runtime. diff --git a/channel-party/design/discord.md b/channel-party/design/discord.md new file mode 100644 index 0000000..be3a2c2 --- /dev/null +++ b/channel-party/design/discord.md @@ -0,0 +1,76 @@ +# `discord-compatible` slice (`TODO.md` #10, DESIGN §4/§5/§7) + +Status: in progress — **(a)+(b)** ratified 2026-07-11; **(d)** landed same day (structure + contents). +The rest stays stubbed/deferred. + +**(d) as built** (a refinement of the plan below): the sync creates the `guild` + `channel` envelopes +itself, **deduped without a mapping table** — it `scan`s existing envelopes and matches the Discord id in +their payload, creating only the missing ones (idempotent; no crash window a separate map would add; no +escape-hatch table needed). Config became `guild` + channel ids (not the operator-provided container of +(b)). `contents` branches on the slice's own type: leaf `channel`/`forum` → `children` feed; structural +`guild`/`section` → `descendants` subtree (first real use of that primitive). Full section/forum +structure (the `GET /guilds/:id/channels` fetch, categories via `parent_id`, threads) is still deferred — +today's tree is guild → channels (flat). + +The heaviest slice, split into sub-parts so each lands independently. This note plans the whole and +pins the decisions for the first chunk. **Client library: `twilight-http`** (chosen); tests mock Discord +via twilight's **proxy** support (`Client::builder().proxy(host, true)` → a local `wiremock` server) — +never the live API (§12). + +## Sub-parts + +- **(a) Shared client / bridge.** One `twilight-http` client, rate-limited, shared across the slice's + runtime components. Exposed as a `DiscordBridge` the composition root builds once; `bridge.sync()` + (and later `bridge.semantic_index()`) produce components holding the same `Arc`. *This is the + architecture point of (a): a namespace crate holds shared state across its runtime components.* +- **(b) Sync ingestion (`Primary`).** `DiscordSync` (`WriteScope::Primary`) backfills, then re-polls on a + schedule: fetch a bridged channel's messages → **upsert** `cached-user` (`external_key = + "discord:user:"` — one per Discord user, §3) and `cached-message` (`external_key = + "discord:message:"`) envelopes through `writer()`. Reset = re-fetch. *The architecture point of + (b): the `Primary` write path — `writer() → Some` and a component actually writing envelopes with + `external_key` dedup — the last unproven §7 path (canvas only exercised `Derived`).* +- **(c) Semantic index (`Derived`).** `DiscordSemanticIndex` off the change stream into the type-owned + `discord_message_embeddings` table. **Blocked on choosing an embedding provider/model + vector store** + — deferred; the component stays a stub and is *not registered* (so it can't crashloop). +- **(d) Contents.** `channel`/`forum` list their cached-messages (`children`); `guild`/`section` fetch + the subtree (`descendants`); the island builds the tree. Deferred. +- **(e) Webhook receiver.** `routes()` → `/ext/discord-compatible/…` (§4/§9) — the first real `/ext` + mount. Deferred. +- **(f) Outbound.** `item-type:discord-compatible/message` originating here, pushed to Discord via + webhook. Deferred. +- **(g) Membership.** `channel` rejects (membership is Discord's) vs proxies an outbound invite. + Deferred. + +## First chunk: (a) + (b) + +**Decisions** + +- **Config is passed from the composition root** (`BridgeConfig { token, proxy, poll_secs, channels }`), + env-populated in `main.rs`. `channels` maps a Discord channel id → the channel-party `ChannelId` + container that holds its messages. For this chunk the container is **operator-provided** (a + `discord-compatible/channel` created via the shell) — creating/deduping the channel *envelope* is + structural work that belongs to (d), and channels have no `external_key` to upsert on. Keeping it out + focuses the proof on message/user ingestion. +- **Components register only when configured.** `main.rs` adds `bridge.sync()` only if a token is set; + an unconfigured instance contributes no component. This also removes the `todo!()` crashloop the + semantic-index stub caused (it is no longer registered until (c)). +- **Ingestion shape.** Per fetched message: upsert the author as `cached-user` (`container = None` — a + guild-scoped cached-user may have none, §3) then the message as `cached-message` (`container =` the + mapped channel). `cached-message` payload carries `{ discord_id, author_discord_id, author_name, + content, timestamp }`; its author is the *reference* to the cached-user (by Discord id — the slice's + convention, resolvable to a native user via #19's `linked-users` when a link exists). No reactions yet + (defer to a later pass). +- **Reset = re-fetch** (an ingesting component's reset re-pulls from Discord, per §7). A `version()` bump + requests it; because ingestion is idempotent upserts, a re-fetch converges without duplication. +- **Rate limiting** rides twilight's built-in limiter; a bespoke cross-guild token bucket is deferred. + +**Test (`wiremock`, never live).** A mock Discord serves `GET /api/v10/channels//messages` with two +messages from one author + one from another. Build a `Core` with the discord kinds + `bridge.sync()` +pointed at the mock (proxy), create the container channel, `spawn_runtime`, and poll the store until the +cached-messages land: assert **3 cached-messages, 2 cached-users** (author dedup within a sync), and that +a second poll tick leaves the counts unchanged (upsert idempotency, via a short test `poll_secs`). + +## Not in scope (this chunk) + +Reactions; contents (d); the semantic index (c); webhook routes (e); outbound (f); membership (g); +channel-envelope creation/dedup; a bespoke rate limiter; live-gateway streaming (we poll, per §7). diff --git a/channel-party/design/index-search.md b/channel-party/design/index-search.md new file mode 100644 index 0000000..2401613 --- /dev/null +++ b/channel-party/design/index-search.md @@ -0,0 +1,145 @@ +# Design note: the index substrate + `search` (`TODO.md` #3, `space`) + +Status: **ratified & implemented (2026-07-10).** The FTS5 substrate (`crates/cp-core/src/index.rs`) +and `StoreCtx::search` (`store.rs`) are live, covered by `crates/cp-core/tests/search.rs`; `space` +composes them (`kinds/space`, integration test `crates/cp-frontend/tests/space_search.rs`). Folds into +`DESIGN.md` §5/§6 and supersedes the "planned shape" placeholder in `design/read-path.md`. + +## The gap + +`index(payload) -> IndexEntry` (§6) is called transactionally on write, but the substrate behind it was +a no-op, so `StoreCtx::search` (§5) returned a clear error and `space::contents` (whose whole job is +search) could not be built. This note pins the substrate schema, how `search` matches + joins + scopes, +and the search cursor — the pieces `design/read-path.md` deliberately deferred. + +## Availability check (load-bearing, done first) + +The Nix-built `sqlx` (0.8, `sqlite` feature ⇒ `libsqlite3-sys` **bundled** 0.30) compiles in **FTS5** +and **RTREE** — confirmed empirically before committing to the schema (a throwaway probe created an +`fts5(...)` + `rtree(...)` table and ran a `MATCH`). If a future build ever swaps to a system SQLite +without FTS5, this substrate is where it breaks, loudly, at migrate time. + +## Scope of this pass + +`IndexEntry { name?, text?, sort_key?, coord? }` has three substrates (§6). Only the one with a live +consumer is built now: **FTS5 for `name`/`text`**, which `space` searches. `sort_key` (expression +index) and `coord` (R-tree) have no consumer until `canvas` (`TODO.md` #11); `index::upsert` ignores +them today with a one-line note, rather than building speculative substrates. RTREE is proven available +(above) so #11 is derisked. + +## The FTS substrate + +One **standalone** (self-contentful) FTS5 table, not an `external content` one: + +```sql +CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( + name, text, -- indexed: IndexEntry.name / IndexEntry.text + envelope_id UNINDEXED, -- ULID of the channel/item this row projects + super_type UNINDEXED, -- 'channel' | 'item' — selects the join-back table + tokenize = 'trigram' +); +``` + +- **Why standalone, not external-content.** FTS5 `content=` external tables bind to exactly *one* base + table; we project *two* super-types (channels + items) into one search space. A standalone table + stores its own copy of the projected text — a few bytes per envelope — and sidesteps that mismatch. +- **Trigram tokenizer** ⇒ true substring matching (a "search box", not word-prefix). Its floor is 3 + code points: a query shorter than that forms no trigram, so `search` short-circuits to an empty page + before touching FTS (avoids relying on trigram's exact under-length behavior). +- **No `type_id` column.** The `{super_type, type_id}` filter is applied on the *joined* channels/items + row (which carries `type_id`), not on the FTS row — so `index::upsert` needs no extra argument beyond + what `EnvelopeRef` already carries. + +### upsert / delete — keyed by `envelope_id` + +FTS5 has no unique constraint or `ON CONFLICT`, so a write is **delete-then-insert** by `envelope_id` +(+ `super_type`, guarding the ~80-bit cross-table id-collision case `read-path.md` flags). `upsert` +skips the insert entirely when both `name` and `text` are absent — an envelope with nothing to index +leaves no row. Both run in the caller's transaction, so the FTS projection commits atomically with the +envelope (§6). + +### Orphan rows are correctness-safe (the load-bearing invariant) + +`search` **INNER JOINs** every FTS row back to `channels`/`items` to (a) rebuild the full `Node` and +(b) scope it. A stale FTS row whose envelope no longer exists simply fails the join and never appears. +This matters because `delete_channel` relies on FK `ON DELETE CASCADE` to remove child envelopes, and +that cascade does **not** touch the FTS table — so a subtree delete orphans its descendants' FTS rows. +Those orphans are invisible to results; the only cost is bounded dead storage. A future GC (a sweep +`WHERE envelope_id NOT IN (SELECT id FROM channels UNION SELECT id FROM items)`, or a `RuntimeComponent` +— nice §7 symmetry) can reclaim it. `index::delete` still runs for the *directly* deleted envelope, so +only cascaded children leak. + +## `search(scope, text, filter, page)` + +A `UNION ALL` of one arm per wanted super-type, each **matching FTS then joining back** and scoped to +the `scope` subtree via the *same* recursive CTE as `descendants` (so scoping semantics are identical): + +``` +WITH RECURSIVE subtree(id, depth) AS ( -- identical to descendants(scope) + SELECT id, 0 FROM channels WHERE id = :scope + UNION ALL SELECT c.id, s.depth+1 FROM channels c JOIN subtree s ON c.container = s.id +) +SELECT 'channel', c.*, f.rank AS score FROM search_index f JOIN channels c ON c.id = f.envelope_id + WHERE f.super_type='channel' AND f MATCH :q AND c.id IN (SELECT id FROM subtree WHERE depth >= 1) + [AND c.type_id IN (...)] +UNION ALL +SELECT 'item', i.*, f.rank AS score FROM search_index f JOIN items i ON i.id = f.envelope_id + WHERE f.super_type='item' AND f MATCH :q AND i.container IN (SELECT id FROM subtree) + [AND i.type_id IN (...)] +ORDER BY score ASC, id ASC LIMIT :n+1 OFFSET :off +``` + +- **Scope semantics mirror `descendants(scope)`**: channels at `depth >= 1` (scope itself excluded); + items whose container is any subtree channel (scope included). A kind gets the same subtree whether it + lists (`descendants`) or searches (`search`) — one mental model. +- **Query is a literal phrase**: the user string is wrapped `"…"` with embedded `"` doubled, so FTS5 + query operators (`AND`/`OR`/`NEAR`/`*`/column filters) in user input are matched verbatim, not + executed. No FTS-syntax injection. +- **Ranking**: FTS5 `rank` (bm25; more negative = more relevant) → `ORDER BY score ASC`, `id` breaking + ties into a total order for deterministic paging. Scores from the two arms share one table+tokenizer, + so a global sort across super-types is meaningful enough for a search box. + +### The search cursor is an **offset**, distinct from the id-keyset cursor + +`read-path.md` kept `Cursor` opaque and per-primitive precisely so `search` could pick its own encoding. +It does: a plain decimal **offset** (`None` ⇒ 0; next page ⇒ `off + limit`), read via `LIMIT n+1 OFFSET`. +Not the `(rank, id)` keyset that note *sketched* — chosen deliberately: + +- bm25 `rank` is a float; a keyset boundary on it means binding a float and exact-equality tiebreaks — + fragile, and it composes awkwardly with the MATCH+JOIN. Offset needs none of that. +- Search result sets are small and shallow (nobody pages to result 900), and search is not a live feed, + so keyset's two wins — O(seek) depth and concurrent-insert stability — barely apply. Offset's O(off) + scan and possible drift across pages are acceptable here. + +Keyset-on-rank stays available as a future optimization without disturbing the id-keyset cursor. A +malformed search cursor is a `Validation` error, not a silent reset. + +## `space` + +`space::contents` = `search(scope = self.id, query.q, {super_type: Channel}, page)` → serialize the +`NodePage`. **Deviation from `DESIGN.md` §5's `{Channel, [basic]}`**: it does *not* restrict to the +`basic` type. Hardcoding a peer kind's type string would couple `space` to `basic` and defeat the +"kinds gain nothing per type" invariant (§1); `super_type = Channel` already excludes messages, and a +space may legitimately contain any channel kind. The integration test relies on this — it uses a +throwaway channel kind (not `basic`) and still gets hits (DESIGN §12 genericity). + +An empty/short `query.q` yields an empty page (the <3-char guard), so a freshly opened space search box +is `[]`, not an error. + +## What this touches + +- **`cp-core`:** `migrations/0001_init.sql` (+ `search_index`), `index.rs` (real upsert/delete), + `store.rs` (real `search`). New `crates/cp-core/tests/search.rs`. +- **`kinds/space`:** real `contents` + a search-box island. New `crates/cp-frontend/tests/space_search.rs`. +- **Not touched:** `cp-model` (the `search`/`Filter`/`Cursor` surface already fit — another sign the + read surface was right), the write path's `index::upsert`/`delete` call sites (already wired in #2). + +## Assumptions that could still change (flagged, not blocking) + +- **Cross-table id collision** (§read-path.md): guarded here by the `super_type` predicate on both the + FTS row and its join, so a colliding channel/item id can't cross arms. +- **Orphan FTS rows** from cascaded subtree deletes accumulate until a future GC; results are unaffected. +- **Global bm25 across super-types** is treated as comparable; if a kind ever needs per-super-type + ranking that becomes a new consideration, not a change to the primitive. +- **Offset drift** under concurrent writes to a searched subtree can skip/repeat a result across pages; + acceptable for search, revisit with keyset-on-rank if it ever bites. diff --git a/channel-party/design/linked-users.md b/channel-party/design/linked-users.md new file mode 100644 index 0000000..153f7fb --- /dev/null +++ b/channel-party/design/linked-users.md @@ -0,0 +1,84 @@ +# `linked-users` API + authorship resolution (`TODO.md` #19, DESIGN §2/§3) + +Status: ratified 2026-07-11. Folds into `DESIGN.md` §2/§3/§8/§9/§14. + +## Problem + +A native `User` carries **`linked-users`**: references to the `cached-user` items that represent it on +external platforms (§2). The edge table `user_external_links (user_id, item_id)` and the +`UserExternalLink` struct exist, but nothing reads or writes them. #19 supplies: + +1. **Linking** a native user to an external `cached-user` item (and unlinking). +2. **Authorship resolution**: given a `cached-user` item (the author a `cached-message` payload points + at), resolve *up the link* to the native `User`, if any (§2's polymorphic authorship). + +## Decisions + +1. **Links are operator-provisioned (shell only).** Linking asserts an identity ("this Discord user is + Alice"); pre-OAuth there is no proof of ownership, so a self-service link would be an unverified + claim — a logged-in user could claim someone else's `cached-user` and inherit its authorship. This is + the same trust posture as #17 (provisioned accounts, no self-signup). So the write path is the debug + shell (`link-user` / `unlink-user`, write-gated); **HTTP exposes only reads**. + + *Future (noted, not built):* self-service linking gated by a **per-kind proof-of-ownership** + mechanism — each external `cached-user` kind verifies ownership its own way (Discord OAuth, etc.). + That is where §14's "Discord-OAuth linking" lands: **another kind capability**, consistent with the + rest of the model, layered on this same edge — the storage does not change, only who may write it. + +2. **Core stays type-agnostic.** `link` joins a native user to *an item* — core never checks the item is + a "cached-user" (that would hardcode a kind string, against §13). The cached-user semantics are the + caller's; the mechanism is "user ↔ item." (The tests link to a throwaway item, proving this.) + +3. **A cached-user maps to ≤1 native user.** §2: a user has *many* linked items (one per platform), but + an external identity resolves up to *a* (single) native user. So `item_id` is **`UNIQUE`** — a second + user claiming the same item is a conflict, not a silent second row; `user_for_item` returns `Option` + (0 or 1), never a list. + +4. **Referential integrity.** `item_id` gets a real FK `REFERENCES items(id) ON DELETE CASCADE` (deleting + a cached-user drops its links); `user_id` already cascades from `users`. This requires the table to be + created *after* `items` — the block moves below `items` in the migration. + +## Core module (`cp-core::links`, sibling to `auth`) + +Pool-based (no `Store` dependency), mirroring `auth`: + +```rust +pub async fn link(pool, user: UserId, item: ItemId) -> Result<()>; // validate item exists; + // idempotent; conflict if the + // item is already linked elsewhere +pub async fn unlink(pool, user: UserId, item: ItemId) -> Result<()>; +pub async fn linked_items(pool, user: UserId) -> Result>; // forward: a user's cached-users +pub async fn user_for_item(pool, item: ItemId) -> Result>; // reverse: authorship resolution +``` + +`link` semantics: item missing → `NotFound`; already linked to *this* user → `Ok` (idempotent); already +linked to a *different* user → `Validation` conflict (never silently ignored, unlike a plain +`INSERT OR IGNORE`). + +## Shell (`link-user` / `unlink-user` / `show links`) + +Operator provisioning, write-gated like the other mutations (§8). A handle is resolved to its user id +(operator-friendly, like `set-password`): + +``` +link-user link a native user to an external cached-user item (write) +unlink-user remove the link (write) +show links list the cached-user items a user is linked to (read) +``` + +## HTTP surface (reads only) + +``` +GET /api/users/:id/links -> { items: [ , … ] } the user's linked cached-users +GET /api/items/:id/linked-user -> | 404 resolve a cached-user up to its native user +``` + +Open like all other reads (a trusted self-hosted instance; attribution is not secret). The second +endpoint is what an island rendering a `cached-message` calls to show "this external author = native +user Alice." No write endpoints — links are shell-provisioned (decision 1). + +## Not in scope (additive later) + +Self-service linking + per-kind proof-of-ownership (the OAuth path above); resolving a *message's* author +field to a user in one hop (the message kind knows where its author id lives — this gives it the +`item → user` primitive to compose with); listing links being access-controlled (reads are open). diff --git a/channel-party/design/permissions.md b/channel-party/design/permissions.md new file mode 100644 index 0000000..f759ab2 --- /dev/null +++ b/channel-party/design/permissions.md @@ -0,0 +1,128 @@ +# Permissions model (`TODO.md` #18, DESIGN §14) + +Status: ratified 2026-07-11. Folds into `DESIGN.md` §2/§4/§8/§9/§13/§14. + +## Problem + +Auth (#17) established the one principal — a native `User` with a session. What it did *not* +settle is **per-channel authorization**: who, among authenticated users, may act on a given +channel. §14 flagged the shape as unspecified ("likely another capability"). This note settles +it and wires the **first authenticated write endpoint** as its proof. + +## Decisions + +1. **Authorization is a capability, not core policy.** A new `Permission` trait on `ChannelKind`, + opt-in exactly like `Membership`. Core holds *no* authorization policy — it resolves the + capability and dispatches. This keeps §13's invariant ("adding a type touches none of these + mechanisms — only the slice"): a kind's authorization model is part of its slice. + +2. **Deny-by-default.** A channel whose kind returns `permission() -> None` is **not authorizable** + over HTTP — the write endpoint refuses it. `None` is a structural "declines to authorize anyone," + the same way `membership() -> None` is "does not accept users." Secure-by-default: a kind is + unwritable until it deliberately grants. (This is the one place we diverge from "trivial kind = + two lines" — a writable kind must implement `Permission`. Accepted: authorization is exactly the + thing that should be explicit.) + +3. **Writes are enforced now; reads stay open.** The `Action` vocabulary includes `View` for + completeness, but #18 only gates the write endpoint. Threading an optional session through every + read path + `contents` dispatch is a larger change deferred to a later item; the `Action::View` + variant means that later change needs no signature churn here. + +4. **Authorship is stamped server-side, per kind.** Per §2, authorship is polymorphic — there is no + core author column. The write endpoint calls a new `ItemKind::with_author(payload, user)` hook + (default: unchanged) so the kind embeds provenance however it likes; `basic` sets + `payload.author = `. The client's own `author` field, if any, is **overwritten** — + provenance is never client-trusted. + +## The vocabulary + +```rust +/// A permission-checked action on a channel. A small, fixed, core-owned vocabulary (like `SuperType`), +/// distinct from the open-ended kind set. +pub enum Action { View, Post, Manage } +``` + +- `View` — read a channel's contents. Defined, **not yet gated** (reads are open). +- `Post` — create an item (send a message) in the channel. **The action #18 enforces.** +- `Manage` — administer the channel (membership, structure, config). Defined; HTTP does not expose a + managing endpoint yet (structure is shell-only), so no kind need grant it now. + +Adding a variant later is a core change — but `Action` is authorization *vocabulary*, a cross-cutting +core concern, not a per-type slice; a small stable enum like `SuperType`/`Order` is the right home. + +## The capability + +```rust +#[async_trait] +pub trait Permission: Send + Sync { + async fn authorize(&self, cx: &dyn StoreCtx, ch: &Channel, user: UserId, action: Action) + -> Result; +} + +// on ChannelKind: +fn permission(&self) -> Option<&dyn Permission> { None } // None = deny-by-default +``` + +`authorize` is a **read** — it takes `&dyn StoreCtx`, never `WriteCtx`; deciding never mutates. To let +a policy consult the generic membership substrate as a read, `StoreCtx` gains one primitive: + +```rust +async fn is_member(&self, channel: ChannelId, user: UserId) -> Result; +``` + +the read companion to `WriteCtx`'s `add_member`/`remove_member`. This answers §14's *"is +`channel_members` sufficient?"* — **yes**: a `Permission` policy rides it; membership-heavy kinds that +outgrow it own their own tables (the same escape hatch `canvas` uses), no core change. + +## `basic`'s policy — the reference + +`basic` becomes the first real `Permission` implementor, riding `channel_members`: + +| Action | `basic` policy | +| --- | --- | +| `View` | allow (contents are public; not enforced yet anyway) | +| `Post` | **members only** — `cx.is_member(ch.id, user)` | +| `Manage` | deny over HTTP (structure is shell-only for now) | + +So the end-to-end proof is: provision + log in `alice` → `add-user-to-channel` → `POST …/items` +succeeds and the item records `author = alice`; a logged-in **non-member** gets `403`; **no session** +gets `401` (the extractor); a channel of a kind with no `Permission` gets `403` (deny-default). + +## Core `authorize` helper + +Mirrors `contents::dispatch` — one generic resolver, no per-type logic: + +```rust +// cp-core::authz +pub async fn authorize(registry, store, channel, user, action) -> Result { + match registry.channel(&channel.type_id).and_then(|k| k.permission()) { + Some(policy) => policy.authorize(store, channel, user, action).await, + None => Ok(false), // deny-default (unknown kind or no capability) + } +} +``` + +## The endpoint + +``` +POST /api/channels/:id/items { type_id, payload } (requires a session) +``` + +Flow: `CurrentUser` extractor (→ `401` if no session) → load channel (`404`) → +`authz::authorize(Post)` (→ `403` if false) → resolve the item kind (`400` if unknown) → +`kind.with_author(payload, user)` → `WriteCtx::create_item` → `201 { id }`. The endpoint stays +type-agnostic: it names no concrete kind and never inspects the payload; `type_id` + the opaque +payload come from the client's island, `validate` and authorship are the kind's. + +Not in scope (documented, additive later): sub-channel creation over HTTP (`Manage`); a channel kind +vetoing *which* item types it accepts as children (a `validate`/child-policy concern, orthogonal to +who-may-act); read gating (`View`); resolving authorship up a `linked-users` edge (#19). + +## Why not the alternatives + +- **Core-generic grants table** (a `permissions(channel, user, role)` core enforces) — puts policy in + core, against §1/§13. A Discord channel's authorization is Discord's, a canvas's is its own; a single + core table can't model that without becoming a policy engine. +- **Membership = permission** (a member may do anything) — conflates joining with authorization and + can't express `View` vs `Manage` tiers or public-read. `basic` *chooses* to equate them for `Post`, + but that is `basic`'s policy, not a core law. diff --git a/channel-party/design/read-path.md b/channel-party/design/read-path.md new file mode 100644 index 0000000..f3e39dd --- /dev/null +++ b/channel-party/design/read-path.md @@ -0,0 +1,124 @@ +# Design note: the store read primitives (`TODO.md` #2) + +Status: **ratified & implemented (2026-07-09).** `children`, `descendants`, and `seek_time` are live +in `cp-core::Store`'s `StoreCtx` impl, covered by `crates/cp-core/tests/read_path.rs`. `search` landed +in #3 (`design/index-search.md`, `crates/cp-core/tests/search.rs`) — see its section below. The key +points are folded into `DESIGN.md` §5. + +## The gap + +`DESIGN.md` §5 names the closed primitive set a kind composes `contents` from — +`children`/`descendants`/`seek_time`/`search` — and gives their *intent*, but leaves the *encodings* +open (called out in `TODO.md` #2): the cursor format, how `descendants` traverses, how filters become +SQL, and how the two super-type tables combine into one ordered result. This note pins those down. + +## Invariants + +1. **Closed set, unchanged per type.** Implementing these adds no per-type surface to core (DESIGN + §1/§5). A kind that wants a novel traversal composes it from these four; it never asks core for a + new primitive. +2. **Time order is id order.** IDs are ULIDs (§3), so "sort a feed by time" is "sort by id" and needs + no mandated `timestamp` payload field. The Crockford-base32 *text* encoding of a ULID sorts + identically to its 128-bit value, so a plain `ORDER BY id` / `id ?` over the `TEXT` id column + is correct — no numeric decode. +3. **Uniform containment.** Children = every envelope (channel *or* item) whose `container` is this + channel (§3). A discovery result is therefore a heterogeneous `Vec` unioned across the two + tables, not one table's rows. +4. **Reads never mutate.** These take `&self` on the read context; `contents` gets `&dyn StoreCtx`, so + a discovery strategy structurally cannot write (the write surface is `WriteCtx`, a separate trait). + +## Cursor encoding (the load-bearing decision) + +**A `Cursor` is an opaque string wrapping a bare ULID — a keyset boundary.** Results resume *strictly +beyond* it in the query's `Order` direction: + +- `TimeDesc` → `WHERE id < :cursor ORDER BY id DESC` (walk toward older ids) +- `TimeAsc` → `WHERE id > :cursor ORDER BY id ASC` (walk toward newer ids) + +Keyset (not `OFFSET`) because it is O(index-seek) regardless of depth and is stable under concurrent +inserts. `children` returns `next = Cursor(Some())`; the next call +resumes strictly after it. To decide whether a *further* page exists in a single round-trip, the query +fetches `LIMIT n + 1`: if `n + 1` rows come back, the extra one is dropped and `next` is set; otherwise +`next = Cursor(None)` (end of feed). `limit == 0` short-circuits to an empty page. + +The cursor is deliberately a bare id string, not a struct: it is direction-agnostic (the `Order` is +supplied per call, not baked into the cursor) and works uniformly across both super-type tables. It is +opaque to callers — only `cp-core` mints and interprets it. If a future primitive needs a richer cursor +(e.g. FTS relevance-rank pagination, which is *not* id-ordered — see `search`), that primitive can +adopt a versioned/tagged encoding without disturbing this one. + +### `seek_time` is a pure computation + +A ULID is a 48-bit millisecond time prefix followed by 80 random bits, so the earliest id that can +exist at time `T` is `from_parts(T, 0)`. `seek_time(container, T)` returns the id *one below* that +floor: `Ulid(from_parts(T, 0).0 - 1)`, as a cursor. Feeding it to `children(.., TimeAsc)` then yields +exactly the rows created **at/after T** (exclusive-beyond `> boundary` includes the whole `T` floor); +`TimeDesc` yields those strictly before `T`. This needs **no query** — hence the `container` argument +is currently unused (kept for the trait contract and for a future substrate-backed implementation that +might scope differently). This is the concrete form of §3's "jump to timestamp T is a seek to the ULID +whose time prefix is T" and makes `basic`'s `query.at → seek_time` free. + +## `children` — one level, cursor-paginated + +A `UNION ALL` of one arm per *wanted* super-type (selected by `filter.super_type`; `None` ⇒ both), +each tagged with a literal `super_type` column so a row round-trips back into the right `Node` variant. +Both arms filter `container = ?` and, when `filter.type_ids` is a non-empty list, `type_id IN (...)` +(empty/`None` ⇒ unfiltered). A trailing `ORDER BY id LIMIT n+1` applies to the whole compound. + +## `descendants` — whole subtree, via a recursive CTE + +Traversal is a single **recursive CTE over the channel containment edge**, not application-side N+1: + +``` +WITH RECURSIVE subtree(id, depth) AS ( + SELECT id, 0 FROM channels WHERE id = :root + UNION ALL + SELECT c.id, s.depth + 1 FROM channels c JOIN subtree s ON c.container = s.id + [WHERE s.depth + 1 <= :max] -- only when depth-limited +) +``` + +Only channels can contain, so the recursion walks channels; items are then attached by container. +**Depth semantics:** root is depth 0, its direct children depth 1, etc. A node's depth = its +container's depth + 1. So descendant *channels* are the subtree minus root (`depth >= 1`), and an +*item* qualifies when its container is within `max - 1` hops (uncapped ⇒ any subtree channel; `depth 0` +⇒ empty). `descendants` is fetch-all by contract (no pagination) and returns nodes in ascending id +order; a missing `root` yields an empty vec, not an error (existence checks are the caller's to do via +a point read). + +## `search` — implemented in #3 (see `design/index-search.md`) + +`search` is substring/relevance search over the projection `index()` (§6) populates into an FTS5 +(trigram) table. It is now **live** — the full substrate schema, the MATCH-then-join-back query, the +subtree scoping (reusing this note's `descendants` CTE), and the cursor decision are in +`design/index-search.md`. + +One decision there **revises the sketch left here:** search pages by a plain **offset** cursor, *not* +the `(rank, id)` keyset this note originally guessed — bm25 rank is a float (fragile as a keyset +boundary) and search sets are shallow, so offset's simplicity wins. The point that mattered holds: the +search cursor is a *different opaque encoding* from the id-keyset one, which is exactly why `Cursor` is +kept opaque and per-primitive rather than globally structured. + +## Safety valve + +`children` clamps `limit` to `MAX_LIMIT` (1000) so an unbounded value arriving from an HTTP query +(#12) can't ask sqlite to materialize an entire table at once; callers keep paging via the cursor. +`descendants` has no such clamp — "whole subtree" is its contract; a kind bounds cost with `depth`. + +## What this touches + +- **`cp-core`:** `store.rs` — the real `StoreCtx` impl + row/query helpers; `Cargo.toml` gains `ulid` + (for the `seek_time` time-floor computation). New `crates/cp-core/tests/read_path.rs`. +- **Not touched:** `cp-model` (the `StoreCtx` trait and `Cursor`/`Filter`/`Order`/`Node` types were + already defined in the scaffold and needed no change — a good sign the read surface was right), + kind crates, the frontend. + +## Assumptions that could still change (flagged, not blocking) + +- **Cross-table id collisions.** Channel and item ids are minted independently; a shared ULID across + the two tables (needed to confuse keyset pagination) is an ~80-bit-per-ms coincidence. Not guarded. +- **`descendants` unordered-by-depth.** It returns id (time) order, not a pre-order tree walk; the + island reconstructs hierarchy from each node's `container`. If a kind ever needs server-side tree + order, that is a new consideration, not a change to this primitive. +- **`seek_time` ignores `container`.** Correct while ordering is globally by ULID; a per-container time + index (if ever added) would make the argument meaningful. diff --git a/channel-party/design/runtime.md b/channel-party/design/runtime.md new file mode 100644 index 0000000..79742b4 --- /dev/null +++ b/channel-party/design/runtime.md @@ -0,0 +1,121 @@ +# Design note: the RuntimeComponent supervisor + `RuntimeCtx` (`TODO.md` #4) and the `canvas` slice (#11) + +Status: **ratified & implemented (2026-07-10).** Built together on purpose: a `Derived` runtime +component's reason to exist is maintaining a kind's **type-owned index** off the change stream, so #4 is +only honestly validated by a real type-owned-table consumer — `canvas`'s `SpatialIndex` is that first +consumer. This note pins #4's `RuntimeCtx` surface, supervision, and confinement, plus how `canvas` +becomes a fully self-contained slice (own R-tree table + own writer + own reader). Folds into +`DESIGN.md` §6/§7. + +## Why the two are one task + +`canvas` (chosen over the "bbox as a 5th core primitive" alternative) is the smallest vehicle that +proves the design's single most load-bearing *unproven* claim: the §6 **type-owned table** escape hatch +— a kind with an index shape core knows nothing about (own migration + own `RuntimeComponent` writer + +own `contents` reader). That same slice is the first real `RuntimeComponent`, so it validates #4's +supervisor and `RuntimeCtx` end-to-end instead of against a contrived stub. One slice, three claims: +escape hatch, runtime supervisor, kind generality. + +## The load-bearing finding: `cp-model` gains a `sqlx` dependency + +An escape-hatch kind needs a DB handle in **both** directions: +- `SpatialIndex::run` (a `RuntimeComponent`) **writes** `canvas_box_coords`. +- `Canvas::contents` **reads** it for the viewport query (a bbox query is *not* expressible via the + closed `StoreCtx` set — that is exactly why it's an escape-hatch table, not a core primitive). + +Kinds only ever receive `&dyn StoreCtx` (in `contents`) and `&dyn RuntimeCtx` (in `run`), both defined +in `cp-model`. There is **no** way to hand a kind a DB handle through those without `cp-model` naming a +DB type. So `cp-model` now depends on `sqlx` and both traits expose: + +```rust +fn type_owned_db(&self) -> &sqlx::SqlitePool; // the §6 escape hatch, on StoreCtx AND RuntimeCtx +``` + +Cost, stated honestly: the interface crate is **no longer DB-agnostic** — it's committed to sqlite. The +project's store is sqlite throughout, so this trades a portability we never promised for the escape +hatch the thesis *does* promise. Pure primitive-consumers (`basic`/`space`) never call `type_owned_db`; +using it to touch core's own `channels`/`items` tables (rather than a kind's namespaced ones) is a +design violation — the closed primitives are the supported read path. + +## `RuntimeCtx` surface + +The handle `run(&self, cx: &dyn RuntimeCtx)` receives: + +```rust +async fn next_event(&self) -> Option; // filtered change | scheduler tick | None on shutdown +async fn get_item(&self, id: ItemId) -> Result>; // point reads: fetch a changed +async fn get_channel(&self, id: ChannelId) -> Result>; // envelope's payload +fn writer(&self) -> Option<&dyn WriteCtx>; // Some only for WriteScope::Primary — confinement +fn type_owned_db(&self) -> &sqlx::SqlitePool; // the kind's namespaced tables +fn reset_requested(&self) -> bool; // version() bumped since last boot +``` + +- **`next_event`** merges the interests-filtered change stream and the `schedule_secs` interval into one + awaitable, returning `None` when the supervisor cancels (clean loop exit). Change events arrive + pre-filtered to `interests.types`; a `broadcast` *lag* is skipped (the component re-syncs on its own + terms), not surfaced. The receiver sits behind a mutex so the method can take `&self` (the component + holds `&dyn RuntimeCtx`, so `&mut` is impossible). +- **Point reads** because a `ChangeEvent` carries only `{op, target, type_id, container}` — not the + payload. A `Derived` indexer sees "box X changed", then reads X's `x`/`y`. (This is why `ChangeOp` / + `EnvelopeRef` / `ChangeEvent` **move to `cp-model`**: the runtime-facing consumer and the core emitter + now share one type; `EventBus` stays in `cp-core` as mechanism, re-exporting the moved types.) + +### WriteScope confinement — structural, where it counts + +`writer()` returns `Option<&dyn WriteCtx>`: `Some` for `Primary`, **`None` for `Derived`**. A `Derived` +component *structurally cannot* obtain the envelope-mutation API, so a bug in an indexer can't corrupt +the `Primary` source of truth — the §7 guarantee. Residual: `Derived` still has `type_owned_db` (a raw +pool) and is *trusted* not to raw-SQL into core tables; enforcing that would need per-table ACLs sqlite +doesn't offer. The meaningful, enforced boundary is "no envelope writes for `Derived`." + +## Supervision (`cp-core::runtime::spawn`) + +One supervised `tokio` task per registered component, tracked in a `JoinSet`, cancellable via a +`CancellationToken` returned to the caller (so a server runs them forever; a test starts, drives, and +stops them): + +- **Backfill-then-stream is the component's own shape** (§7): `run` does its batch pass, then loops on + `next_event`. The supervisor just keeps `run` alive. +- **Restart with backoff**: if `run` returns `Err` or panics, log and retry after a capped exponential + delay (reset to the floor on a clean/long-lived run). A clean `Ok`/`None`-driven exit (shutdown) is + not restarted. +- **`version()` reset**: core keeps `runtime_component_state(name, version)`. At spawn, if the stored + version differs from `component.version()` (or is absent), `reset_requested()` is `true` for this + boot and core records the new version. The component decides what reset means (`SpatialIndex` rebuilds + its table); doing it on every restart this boot is fine because reset is idempotent. + +## The `canvas` slice (#11) + +- **`canvas_box_coords`** becomes a real **R-tree** (`CREATE VIRTUAL TABLE … USING rtree(id, minX, + maxX, minY, maxY)`) — RTREE confirmed available in the build (`design/index-search.md`). rtree's `id` + is an integer, so a side `canvas_box(item_id TEXT PK, rowid INTEGER)` maps the ULID item id to the + rtree rowid (rtree can't key on text). Both are `canvas_*`, core-invisible. +- **`SpatialIndex`** (`Derived`): on `reset_requested`, truncates + rebuilds; backfills all existing + `canvas-text-box` items; then for each change fetches the item, and upserts (Created/Updated) or + deletes (Deleted) its rect. `interests.types = [canvas-text-box]`. +- **`Canvas::contents`**: parses a `{x0,y0,x1,y1}` viewport, runs the rtree overlap query joined back to + `items` (a *point* box has minX=maxX=x), returns a `NodePage` of the boxes in view. Reads via + `cx.type_owned_db()`. Pagination: reuse the id-keyset cursor over the returned item ids (rtree gives a + set, ordered by id for a stable page) — the viewport is the real filter, so deep paging is rare. +- **`CanvasTextBox`** drops its `index()` (it does *not* feed a core substrate — its projection lives in + the kind's own table, written by the component). Payload: `{x, y, w, h, text}`. +- **Island**: a pan/zoom viewport that POSTs the visible rect to `contents` and draws each box via the + item island (the §9 recursive-render path). + +## Eventual consistency (flagged) + +The R-tree lags a box write by the time the `SpatialIndex` processes the change — the honest cost of the +`Derived` (async) tier vs. inline indexing. Fine for a spatial canvas; tests poll for convergence. A +kind needing read-your-write spatial results would use inline indexing instead (a different §6 tier). + +## What this touches + +- **`cp-model`:** new `events` module (moved `ChangeOp`/`EnvelopeRef`/`ChangeEvent`); `sqlx` dep; + `RuntimeCtx` fleshed out + `RuntimeEvent`; `type_owned_db` on `StoreCtx`. +- **`cp-core`:** `runtime.rs` (real `CoreRuntimeCtx` + supervisor + backoff/reset); a + `runtime_component_state` migration; `Store::type_owned_db`; `events.rs` re-exports the moved types; + `Core::spawn_runtime` returns a `RuntimeHandle`. +- **`kinds/canvas`:** real R-tree migration, `SpatialIndex::run`, `Canvas::contents`, `CanvasTextBox`, + island; `Cargo.toml` gains `sqlx`. New `crates/cp-core/tests/runtime.rs` (throwaway component, + genericity) + `kinds/canvas` integration coverage via `cp-frontend`. +- **Not touched:** `basic`/`space` (still pure primitive-consumers — the escape hatch is opt-in). diff --git a/channel-party/design/write-path.md b/channel-party/design/write-path.md new file mode 100644 index 0000000..1d5fdd3 --- /dev/null +++ b/channel-party/design/write-path.md @@ -0,0 +1,224 @@ +# Design note: the core write path (`TODO.md` #1) + +Status: **ratified & implemented (2026-07-09).** `WriteCtx`, the extended `ChangeEvent`, the +`Store` write impl, and `channel_members` are live in `cp-model`/`cp-core`, covered by +`crates/cp-core/tests/write_path.rs`; the key points are folded into `DESIGN.md` (§3, §8). The +open decisions below were resolved as recommended, with one deviation decided during +implementation: + +- **`RuntimeCtx` was left as-is (an empty marker trait).** Its real shape — `reads()`/`writer()`/ + `derived()`/`events()` and the `WriteScope`-gated write handle sketched below — is `TODO.md` #4, + not #1; defining it now would pre-commit #4's assumptions (exactly what we're avoiding). So + `WriteScope` **confinement lands with #4** too. For #1, `cp-core::Store` is the trusted Primary + writer, used directly by the debug shell and the generic API. + +## The gap + +`DESIGN.md` leans on "core's mutation API" (§3, §8) as the single write path, but never +gives it a shape. It is the linchpin: `TODO.md` #5 (events), #6 (debug writes), #8/#12 +(reads that need data to exist), and all ingestion (#10) sit on top of it. This note +specifies it. + +## Invariants the write path must hold + +1. **One path.** Debug shell, generic API, `Membership` impls, and ingesting runtime + components all mutate through the same API. No caller writes envelope tables via raw SQL + (§8). Type-owned derived tables are the one exception (see §WriteScope). +2. **Validated.** Every write calls the owning kind's `validate(payload)` first; an + unregistered `type_id` is rejected (you cannot persist an envelope core can't validate). +3. **Transactional index.** The inline `index()` projection (§6) is written in the *same* + transaction as the envelope, or not at all. +4. **Event after commit.** A `ChangeEvent` is published only after the transaction commits, + so subscribers (SSE, indexers) never observe uncommitted state. +5. **Idempotent mirror.** `external_key` upsert keeps one item per external object with a + *stable* id across updates (§3 — one cached-user per Discord user, referenced by + thousands of cached-messages). +6. **Principals are inert-safe.** The write path never creates a `User`; users come from + auth (#17). Items stay inert content (§2). + +## Context taxonomy (what each caller gets) + +Today `StoreCtx` (read-only: `children`/`descendants`/`seek_time`/`search`) is the only +context. Add a **write** context that extends it, and make the runtime context real: + +```rust +// cp-model — read stays as-is; writes are a superset so a mutation can read first (upsert). +#[async_trait] +pub trait WriteCtx: StoreCtx { + async fn create_channel(&self, spec: NewChannel) -> Result; + async fn create_item(&self, spec: NewItem) -> Result; + + /// Insert-or-update keyed on `external_key`; the id is stable across updates. §3. + async fn upsert_item(&self, spec: NewItem) -> Result>; + + async fn set_channel_payload(&self, id: ChannelId, payload: Json) -> Result<()>; + async fn set_item_payload(&self, id: ItemId, payload: Json) -> Result<()>; + + async fn reparent_channel(&self, id: ChannelId, container: Option) -> Result<()>; + async fn reparent_item(&self, id: ItemId, container: Option) -> Result<()>; + + async fn delete_channel(&self, id: ChannelId) -> Result<()>; // cascades via FK + async fn delete_item(&self, id: ItemId) -> Result<()>; + + // Generic `channel_members` substrate (§8), used by `ChannelKind::membership()` impls. + async fn add_member(&self, channel: ChannelId, user: UserId) -> Result<()>; + async fn remove_member(&self, channel: ChannelId, user: UserId) -> Result<()>; + async fn members(&self, channel: ChannelId) -> Result>; +} + +pub struct NewChannel { + pub type_id: TypeId, + pub container: Option, + pub payload: Json, +} +pub struct NewItem { + pub type_id: TypeId, + pub container: Option, + pub external_key: Option, + pub payload: Json, +} +pub enum Upsert { + Inserted(Id), + Updated(Id), +} +``` + +- **`ChannelKind::contents`** keeps `&dyn StoreCtx` (read-only — a reader can't mutate). +- **`Membership`** changes from `&dyn StoreCtx` to **`&dyn WriteCtx`** (it writes edges; + small interface change — Open decision 1). What "add a user" means stays the kind's + choice: `basic`/`space` call `cx.add_member(...)`; `discord` may reject or proxy an invite. +- `cp-core::Store` implements both `StoreCtx` and `WriteCtx`. The debug shell and generic + API hold the concrete `Store` and thus have the full write surface. + +Runtime components get scope-gated access — this sketches the shape `TODO.md` #4 will build. +It is **not** implemented by #1 (see the status note): `RuntimeCtx` is still a marker trait, +and `Store` (the Primary writer) is used directly by the shell + generic API for now. + +```rust +// cp-model — RuntimeCtx becomes real (today it is an empty marker trait). +#[async_trait] +pub trait RuntimeCtx: Send + Sync { + fn reads(&self) -> &dyn StoreCtx; // always + fn writer(&self) -> Option<&dyn WriteCtx>; // Some iff writes() == Primary + fn derived(&self) -> &dyn DerivedStore; // this crate's type-owned tables + fn events(&self) -> broadcast::Receiver; // filtered by interests (§7) + // + a schedule tick and a shutdown token — detailed in #4. +} +``` + +## Write algorithm (per mutation) + +``` +create_item(spec): + kind = registry.item(&spec.type_id).ok_or(NotFound)? # invariant 2 + kind.validate(&spec.payload)? # pure, no I/O (§6) + id = ItemId::generate() # ULID, time-ordered + tx = pool.begin() + INSERT INTO items(id, type_id, container, external_key, payload) VALUES (...) + # container FK enforces "parent exists"; failure -> abort (invariant: cheap ref integrity) + if let Some(entry) = kind.index(&spec.payload): # invariant 3 + upsert_index(tx, Item, id, entry) # FTS / sort-key / R-tree + tx.commit() + events.publish(ChangeEvent { op: Created, super_type: Item, id, type_id, container }) # invariant 4 + Ok(id) +``` + +`upsert_item` is one atomic statement, not a read-then-write race: + +```sql +INSERT INTO items (id, type_id, container, external_key, payload) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT(external_key) DO UPDATE SET payload = excluded.payload, container = excluded.container +RETURNING id, (id = excluded.id) AS inserted; +``` + +On conflict the *existing* id is returned (stable — invariant 5); `inserted` distinguishes +`Inserted` vs `Updated` for the return value and the emitted `ChangeOp`. Channels have no +`external_key`, so there is no `upsert_channel`. + +`delete_channel` relies on the schema's `ON DELETE CASCADE` for the subtree; index rows are +removed in the same tx. Deletes emit `ChangeOp::Deleted`. + +## `external_key` uniqueness (resolves DESIGN §14) + +The scaffold schema has one global `UNIQUE(external_key) WHERE external_key IS NOT NULL`. +**Proposal:** keep it global and push namespacing *into the key string* — the kind builds +an opaque key that encodes the scope it wants (`"discord:user:456"` for a global Discord +identity, or `"discord:guild:123:user:456"` if per-guild). Core never parses it; the kind +owns the uniqueness grain. This keeps the schema trivial and answers §14's open question +without a composite index (Open decision 2). + +## WriteScope enforcement (§7) + +`writes()` returns `Primary` or `Derived`. Enforcement is by *which context the component +holds*, not a per-row check: + +- **Primary** (e.g. Discord sync): `writer()` is `Some` — full `WriteCtx`. +- **Derived** (semantic/spatial index): `writer()` is `None`; it gets only `reads()` + (to backfill) + `derived()` (its own `discord_*` / `canvas_*` tables). + +Two honesty levels for "a Derived bug can't corrupt the source of truth": + +- **Option A (single DB, convention).** `derived()` is a scoped SQL executor over the one + database. A Derived component is handed *no* envelope-mutation API, but raw SQL *could* + technically reach `channels`/`items`. Confinement is API-level + code review. Simple. +- **Option B (separate DB, airtight).** Type-owned derived tables live in a second SQLite + file `ATTACH`ed for reads; `derived()` writes only that file, so a bug physically cannot + touch envelopes. `contents` cross-DB-joins index ⇄ envelopes. Honors §7 literally, at the + cost of attach/join plumbing. + +Recommend **A** now, **B** as an upgrade if the guarantee needs teeth (Open decision 3). + +## `ChangeEvent` extension (§7/§9) + +Today `ChangeEvent { type_id, scope }` carries too little for SSE/indexers to react +precisely. Extend: + +```rust +pub enum ChangeOp { Created, Updated, Deleted } +pub enum EnvelopeRef { Channel(ChannelId), Item(ItemId) } + +pub struct ChangeEvent { + pub op: ChangeOp, + pub target: EnvelopeRef, + pub type_id: TypeId, + pub container: Option, // the scope SSE clients / interests filter on +} +``` + +## Referential integrity (a deliberate boundary) + +- **Container existence** is enforced free by the `container REFERENCES channels(id)` FK. +- **`validate` stays pure** (§6: no I/O), so it *cannot* check that a payload's author + `UserId` or any cross-envelope reference exists. Authorship lives in the payload (core is + schemaless); DESIGN §2's "real FK" for a `basic` author is therefore enforced by *kind + convention*, not a DB constraint or `validate`. Accept this for now (Open decision 5); a + later optional async `validate_with(&self, cx, payload)` hook could add referential checks + where a kind wants them, without making the common path do I/O. + +## What this touches + +- **`cp-model`:** add `WriteCtx`, `NewChannel`/`NewItem`/`Upsert`; make `RuntimeCtx` real; + extend `ChangeEvent`/add `ChangeOp`/`EnvelopeRef`; change `Membership` to `&dyn WriteCtx`. +- **`cp-core`:** `Store` implements `WriteCtx`; new `index::upsert_index`/`delete_index` + (still stubbed substrates — real FTS/R-tree is #3); `events` gains the richer event; + `channel_members` migration (add to `0001_init.sql` or a new core migration). +- **Not touched:** kind crates (they gain behavior only when their `contents`/`membership` + land), the frontend shell. + +## Open decisions (confirm before I code) + +1. **Interface change:** `Membership` takes `&dyn WriteCtx` (was `&dyn StoreCtx`). OK? +2. **`external_key`:** global unique + namespacing-in-the-key (vs a composite + `(namespace, external_key)` index). OK to resolve §14 this way? +3. **WriteScope confinement:** ship **Option A** (convention) now, note B as the upgrade? +4. **`ChangeEvent`** extended to `{ op, target, type_id, container }`. OK? +5. **`validate` stays pure**; cross-envelope reference integrity (author exists) is *not* + enforced at write. Accept, with an optional async hook deferred? +6. **Scope of #1:** land `WriteCtx` + `ChangeEvent` + the `Store` impl + `channel_members` + now; leave the full `RuntimeCtx`/supervisor wiring to #4 (I'll define the trait but stub + its provider). Agree? +7. **Frontend writes:** how the web app *triggers* a write (generic REST `POST` endpoints + vs per-kind `/ext` routes) is unspecified in §9 — **defer** to #12/#15, or decide here? + (Recommend defer; #1 is the internal mechanism, exercised first via the debug shell.) +``` diff --git a/channel-party/kinds/basic/Cargo.toml b/channel-party/kinds/basic/Cargo.toml index 352c5b1..22b482a 100644 --- a/channel-party/kinds/basic/Cargo.toml +++ b/channel-party/kinds/basic/Cargo.toml @@ -8,3 +8,5 @@ license.workspace = true cp-model.workspace = true async-trait.workspace = true +serde = { workspace = true } +serde_json.workspace = true diff --git a/channel-party/kinds/basic/src/lib.rs b/channel-party/kinds/basic/src/lib.rs index a9f620b..8c4bf22 100644 --- a/channel-party/kinds/basic/src/lib.rs +++ b/channel-party/kinds/basic/src/lib.rs @@ -3,7 +3,29 @@ //! content object (the most common kind: a chat message). See DESIGN §2/§4/§5. use async_trait::async_trait; -use cp_model::{Channel, ChannelKind, IndexEntry, ItemKind, Json, Result, StoreCtx, TypeId}; +use cp_model::{ + Action, Channel, ChannelKind, Cursor, Error, Filter, IndexEntry, ItemKind, Json, Membership, + Order, Page, Permission, Result, StoreCtx, SuperType, TypeId, UserId, WriteCtx, +}; +use serde::Deserialize; + +/// The type string shared by the `basic` channel and item kinds. Channels and items live in two +/// separate registries, so one string keys both without collision. §4. +pub const TYPE: &str = "basic"; + +/// Page size when a query omits `limit`. +const DEFAULT_LIMIT: u32 = 50; + +/// The `contents` query for a `basic` channel — every field optional. Opaque to core; this kind and +/// its island agree on the shape (DESIGN §5/§9). `at` jumps to a UNIX-ms point in the feed before +/// paging; `cursor` resumes a prior page; `limit` caps the page. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct BasicQuery { + at: Option, + cursor: Option, + limit: Option, +} /// `channel-type:basic`. struct BasicChannel { @@ -16,10 +38,35 @@ impl ChannelKind for BasicChannel { &self.type_id } - async fn contents(&self, _cx: &dyn StoreCtx, _ch: &Channel, _query: Json) -> Result { - // DESIGN §5: children(id, {Item, [basic]}, page, TimeDesc); a `query.at` seeks via - // seek_time first, so jump-to-timestamp is free. Then serialize the NodePage to Json. - todo!("basic channel contents (DESIGN §5)") + async fn contents(&self, cx: &dyn StoreCtx, ch: &Channel, query: Json) -> Result { + // DESIGN §5: children(id, {Item, [basic]}, page, TimeDesc); a `query.at` seeks via seek_time + // first, so jump-to-timestamp is free. Because the feed is newest-first, `at` selects items + // at/before that time (scroll back to a date). Then serialize the NodePage back to Json. + let q: BasicQuery = if query.is_null() { + BasicQuery::default() + } else { + serde_json::from_value(query).map_err(|e| Error::Validation(e.to_string()))? + }; + + let cursor = match q.at { + Some(at) => cx.seek_time(ch.id, at).await?, + None => Cursor(q.cursor), + }; + let page = cx + .children( + ch.id, + Filter { + super_type: Some(SuperType::Item), + type_ids: Some(vec![TypeId::new(TYPE)]), + }, + Page { + cursor, + limit: q.limit.unwrap_or(DEFAULT_LIMIT), + }, + Order::TimeDesc, + ) + .await?; + serde_json::to_value(page).map_err(|e| Error::Other(e.to_string())) } fn index(&self, payload: &Json) -> Option { @@ -30,6 +77,51 @@ impl ChannelKind for BasicChannel { ..Default::default() }) } + + fn membership(&self) -> Option<&dyn Membership> { + // `basic` accepts native users, backed by core's generic `channel_members` substrate. §8. + Some(self) + } + + fn permission(&self) -> Option<&dyn Permission> { + // `basic` authorizes posts by membership (see the `Permission` impl). §18. + Some(self) + } +} + +/// `basic`'s authorization rides the same `channel_members` substrate as its membership: a member may +/// post, contents are public, and structural admin isn't exposed over HTTP. §18. +#[async_trait] +impl Permission for BasicChannel { + async fn authorize( + &self, + cx: &dyn StoreCtx, + ch: &Channel, + user: UserId, + action: Action, + ) -> Result { + Ok(match action { + Action::View => true, + Action::Post => cx.is_member(ch.id, user).await?, + Action::Manage => false, + }) + } +} + +/// A `basic` channel's membership rides the generic edge table; "add a user" is a plain edge. §8. +#[async_trait] +impl Membership for BasicChannel { + async fn add_user(&self, cx: &dyn WriteCtx, ch: &Channel, user: UserId) -> Result<()> { + cx.add_member(ch.id, user).await + } + + async fn remove_user(&self, cx: &dyn WriteCtx, ch: &Channel, user: UserId) -> Result<()> { + cx.remove_member(ch.id, user).await + } + + async fn members(&self, cx: &dyn WriteCtx, ch: &Channel) -> Result> { + cx.members(ch.id).await + } } /// `item-type:basic`. @@ -50,18 +142,26 @@ impl ItemKind for BasicItem { ..Default::default() }) } + + fn with_author(&self, mut payload: Json, author: UserId) -> Json { + // A `basic` message's author is a native user id, stamped server-side (never client-trusted). §2/§18. + if let Some(obj) = payload.as_object_mut() { + obj.insert("author".to_owned(), Json::String(author.to_string())); + } + payload + } } /// The `channel-type:basic` kind, for the composition root. §10. pub fn channel() -> impl ChannelKind { BasicChannel { - type_id: TypeId::new("basic"), + type_id: TypeId::new(TYPE), } } /// The `item-type:basic` kind, for the composition root. §10. pub fn item() -> impl ItemKind { BasicItem { - type_id: TypeId::new("basic"), + type_id: TypeId::new(TYPE), } } diff --git a/channel-party/kinds/basic/web/island.ts b/channel-party/kinds/basic/web/island.ts index afdd326..880c89a 100644 --- a/channel-party/kinds/basic/web/island.ts +++ b/channel-party/kinds/basic/web/island.ts @@ -1,6 +1,138 @@ -// `basic` channel island: a message list. Owns its own data fetching (POST -// /api/channels/:id/contents with a page query), rendering, and live updates via SSE. Scaffold -// placeholder. See DESIGN §9. -export function mount(el: HTMLElement, ctx: { id: string; type_id: string }): void { - el.textContent = `[${ctx.type_id}] message-list island for channel ${ctx.id} — not yet implemented`; +// `basic` island, serving both roles for type_id "basic" (channel and item share the string). +// - channel: `mount` renders a live, newest-at-bottom message list — fetch this channel's contents, +// render each item through *its own* item island (via the registry), then reflect the SSE change +// stream in place. +// - item: `renderItem` renders one message. +// The channel delegating to `renderItem` through the registry (rather than rendering items directly) +// is the recursive rendering of DESIGN §9 — for basic it resolves to this same module, but the path +// is generic. The registry sits two levels up from the copied kind dir (generated/kinds/basic/). +import { islands, type IslandModule, type ItemNode } from '../../island-registry'; + +const PAGE = 50; + +type NodePage = { nodes: ItemNode[]; next: string | null }; +type Change = { op: 'created' | 'updated' | 'deleted'; super_type: string; id: string }; + +/** Render one basic message (item-island role). */ +export function renderItem(item: ItemNode): HTMLElement { + const li = document.createElement('li'); + li.className = 'cp-msg'; + li.dataset.itemId = item.id; + const payload = item.payload as { body?: unknown }; + li.textContent = + typeof payload?.body === 'string' ? payload.body : JSON.stringify(item.payload); + return li; +} + +/** Mount the basic channel as a live message list (channel-island role). */ +export async function mount(el: HTMLElement, ctx: { id: string; type_id: string }): Promise { + const list = document.createElement('ul'); + list.className = 'cp-msglist'; + const status = document.createElement('p'); + status.className = 'cp-status'; + + // Compose box: post a message as the current user. The server gates it (§18) — a signed-out or + // unauthorized attempt comes back 401/403; a successful post renders via the SSE stream below, so + // there is no optimistic append here. + const form = document.createElement('form'); + form.className = 'cp-compose'; + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Message'; + input.required = true; + const send = document.createElement('button'); + send.type = 'submit'; + send.textContent = 'Send'; + form.append(input, send); + form.addEventListener('submit', (ev) => { + ev.preventDefault(); + const body = input.value.trim(); + if (body) void post(body); + }); + el.replaceChildren(list, form, status); + + async function post(body: string): Promise { + send.disabled = true; + try { + const res = await fetch(`/api/channels/${encodeURIComponent(ctx.id)}/items`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ type_id: 'basic', payload: { body } }), + }); + if (res.ok) { + input.value = ''; + } else if (res.status === 401) { + status.textContent = 'Log in to post.'; + } else if (res.status === 403) { + status.textContent = "You don't have permission to post here."; + } else { + status.textContent = `Couldn't send (HTTP ${res.status}).`; + } + } catch (err) { + status.textContent = `Couldn't send: ${String(err)}`; + } finally { + send.disabled = false; + } + } + + // Load each item type's island once; delegate rendering to it. + const loaded = new Map(); + async function renderNode(item: ItemNode): Promise { + if (!loaded.has(item.type_id)) { + const load = islands.get(item.type_id); + if (load) loaded.set(item.type_id, await load()); + } + const island = loaded.get(item.type_id); + return island?.renderItem ? island.renderItem(item) : null; + } + + // Initial page. basic::contents returns { nodes, next } newest-first; reverse for top-to-bottom + // reading (oldest at top, newest at bottom, chat-style). + try { + const res = await fetch(`/api/channels/${encodeURIComponent(ctx.id)}/contents`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ limit: PAGE }), + }); + if (!res.ok) { + status.textContent = `Couldn't load messages (HTTP ${res.status}).`; + return; + } + const page = (await res.json()) as NodePage; + for (const item of [...page.nodes].reverse()) { + const node = await renderNode(item); + if (node) list.append(node); + } + status.textContent = page.nodes.length ? '' : 'No messages yet.'; + } catch (err) { + status.textContent = `Couldn't load messages: ${String(err)}`; + return; + } + + // Live updates: reflect this channel's change stream in place. + const events = new EventSource(`/api/events?scope=${encodeURIComponent(ctx.id)}`); + events.addEventListener('change', (ev) => { + void applyChange(JSON.parse((ev as MessageEvent).data) as Change); + }); + + async function applyChange(change: Change): Promise { + if (change.super_type !== 'item') return; + const existing = list.querySelector(`[data-item-id="${change.id}"]`); + if (change.op === 'deleted') { + existing?.remove(); + return; + } + // created / updated: the event carries no payload, so fetch the item envelope and (re)render it. + const res = await fetch(`/api/items/${encodeURIComponent(change.id)}`); + if (!res.ok) return; + const node = await renderNode((await res.json()) as ItemNode); + if (!node) return; + if (existing) { + existing.replaceWith(node); + } else { + list.append(node); // newest at the bottom + status.textContent = ''; + } + } } diff --git a/channel-party/kinds/canvas/Cargo.toml b/channel-party/kinds/canvas/Cargo.toml index 0f44b50..a5852c3 100644 --- a/channel-party/kinds/canvas/Cargo.toml +++ b/channel-party/kinds/canvas/Cargo.toml @@ -8,3 +8,7 @@ license.workspace = true cp-model.workspace = true async-trait.workspace = true +# canvas is an escape-hatch kind: it owns namespaced tables (§6) and reads/writes them via sqlx. +serde = { workspace = true } +serde_json.workspace = true +sqlx.workspace = true diff --git a/channel-party/kinds/canvas/migrations/0001_canvas_init.sql b/channel-party/kinds/canvas/migrations/0001_canvas_init.sql index 12f0eb6..64a6ac6 100644 --- a/channel-party/kinds/canvas/migrations/0001_canvas_init.sql +++ b/channel-party/kinds/canvas/migrations/0001_canvas_init.sql @@ -1,11 +1,23 @@ --- Type-owned spatial index for the canvas slice (namespaced `canvas_*`). Populated by the --- SpatialIndex RuntimeComponent (DESIGN §7); read by the canvas channel's viewport-bbox `contents` --- (§5). Core never learns its shape (§6). +-- Type-owned spatial index for the canvas slice (namespaced `canvas_*`). Core never learns its shape +-- (DESIGN §6). The kind's SpatialIndex RuntimeComponent (§7) maintains it off the change stream; the +-- canvas channel's viewport-bbox `contents` (§5) reads it. See `design/runtime.md`. -- --- Placeholder: a flat coordinate table. A real deployment would use SQLite's R*Tree virtual table --- (`CREATE VIRTUAL TABLE ... USING rtree`) for efficient bbox queries. -CREATE TABLE IF NOT EXISTS canvas_box_coords ( - item_id TEXT PRIMARY KEY, -- references items(id) (a canvas-text-box) - x REAL NOT NULL, - y REAL NOT NULL +-- Two tables: a denormalized box projection keyed by the ULID item id (so `contents` reconstructs the +-- envelope without touching core's `items`), and an R-tree keyed by an integer rowid (R-trees key on +-- integers) mapped from that id via `canvas_box.rid`. +CREATE TABLE IF NOT EXISTS canvas_box ( + rid INTEGER PRIMARY KEY AUTOINCREMENT, + item_id TEXT NOT NULL UNIQUE, -- the canvas-text-box item + container TEXT NOT NULL, -- the canvas channel this box lives in + x REAL NOT NULL, + y REAL NOT NULL, + w REAL NOT NULL, + h REAL NOT NULL, + text TEXT +); + +CREATE VIRTUAL TABLE IF NOT EXISTS canvas_box_rtree USING rtree( + rid, -- = canvas_box.rid + minX, maxX, -- [x, x + w] + minY, maxY -- [y, y + h] ); diff --git a/channel-party/kinds/canvas/src/lib.rs b/channel-party/kinds/canvas/src/lib.rs index 149ca59..366370b 100644 --- a/channel-party/kinds/canvas/src/lib.rs +++ b/channel-party/kinds/canvas/src/lib.rs @@ -1,13 +1,52 @@ -//! `canvas` — a spatial container. Its `contents` is a viewport-bbox query; items are text boxes -//! placed at coordinates, projected into a spatial (R-tree) index. Frontend- and index-heavy: the -//! island is a pan/zoom canvas that delegates drawing each box to the `canvas-text-box` item -//! island. See DESIGN §4/§5/§6. +//! `canvas` — a spatial container, and the reference **escape-hatch** slice (DESIGN §6): it owns +//! namespaced `canvas_*` tables that core knows nothing about. Its `SpatialIndex` `RuntimeComponent` +//! (§7) maintains an R-tree off the change stream; its `contents` is a viewport-bbox query reading that +//! R-tree; items are text boxes placed at coordinates. See DESIGN §4/§5/§6/§7 and `design/runtime.md`. use async_trait::async_trait; use cp_model::{ - Channel, ChannelKind, IndexEntry, Interests, ItemKind, Json, Migration, Migrations, Result, - RuntimeComponent, RuntimeCtx, StoreCtx, TypeId, WriteScope, + ChangeEvent, ChangeOp, Channel, ChannelKind, Cursor, EnvelopeRef, Error, Interests, Item, + ItemId, ItemKind, Json, Migration, Migrations, Node, NodePage, Result, RuntimeComponent, + RuntimeCtx, RuntimeEvent, StoreCtx, TypeId, WriteScope, }; +use serde::Deserialize; +use sqlx::sqlite::SqliteRow; +use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool}; + +/// The channel and item type strings this crate contributes. §4. +pub const CHANNEL_TYPE: &str = "canvas"; +pub const ITEM_TYPE: &str = "canvas-text-box"; + +/// Viewport page size when a query omits `limit`, and a ceiling so an unbounded value can't ask for a +/// whole canvas at once. The viewport is the real filter, so these are generous. +const DEFAULT_LIMIT: u32 = 500; +const MAX_LIMIT: u32 = 2000; + +/// The `contents` query for a `canvas`: the viewport rectangle plus optional pagination. Missing +/// corners default to the whole plane, so an empty query returns every box (bounded by `limit`). +#[derive(Debug, Deserialize)] +#[serde(default)] +struct CanvasQuery { + x0: f64, + y0: f64, + x1: f64, + y1: f64, + cursor: Option, + limit: Option, +} + +impl Default for CanvasQuery { + fn default() -> Self { + Self { + x0: f64::MIN, + y0: f64::MIN, + x1: f64::MAX, + y1: f64::MAX, + cursor: None, + limit: None, + } + } +} /// `channel-type:canvas`. struct Canvas { @@ -20,18 +59,57 @@ impl ChannelKind for Canvas { &self.type_id } - // §4 capability table: canvas validates its payloads. - fn validate(&self, _payload: &Json) -> Result<()> { - Ok(()) - } + async fn contents(&self, cx: &dyn StoreCtx, ch: &Channel, query: Json) -> Result { + // DESIGN §5/§6: a viewport bbox query over this canvas's own R-tree — a strategy the closed + // primitives can't express, so it uses the §6 escape hatch (`type_owned_db`). Reads only + // `canvas_*` tables, reconstructing the box envelopes from the kind's denormalized projection + // (no touch of core's `items`). Paginates by the id-keyset cursor, like `children`. + let q: CanvasQuery = if query.is_null() { + CanvasQuery::default() + } else { + serde_json::from_value(query).map_err(|e| Error::Validation(e.to_string()))? + }; + let limit = q.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + if limit == 0 { + return to_json(NodePage { + nodes: Vec::new(), + next: Cursor(None), + }); + } - async fn contents(&self, _cx: &dyn StoreCtx, _ch: &Channel, _query: Json) -> Result { - // DESIGN §5: a viewport bbox query over the spatial index (§6), returning the boxes in view. - todo!("canvas channel contents (DESIGN §5)") + let mut qb = QueryBuilder::::new( + "SELECT b.item_id, b.x, b.y, b.w, b.h, b.text \ + FROM canvas_box_rtree r JOIN canvas_box b ON b.rid = r.rid WHERE b.container = ", + ); + qb.push_bind(ch.id.to_string()); + // AABB overlap of the box [minX,maxX]×[minY,maxY] with the viewport. + qb.push(" AND r.maxX >= ").push_bind(q.x0); + qb.push(" AND r.minX <= ").push_bind(q.x1); + qb.push(" AND r.maxY >= ").push_bind(q.y0); + qb.push(" AND r.minY <= ").push_bind(q.y1); + if let Some(cursor) = &q.cursor { + qb.push(" AND b.item_id > ").push_bind(cursor.clone()); + } + qb.push(" ORDER BY b.item_id ASC LIMIT ") + .push_bind(i64::from(limit) + 1); + + let rows = qb.build().fetch_all(cx.type_owned_db()).await.map_err(db)?; + let mut nodes = rows + .iter() + .map(|r| box_node(ch, r)) + .collect::>>()?; + let next = if nodes.len() > limit as usize { + nodes.truncate(limit as usize); + Cursor(Some(node_item_id(nodes.last().expect("limit >= 1")))) + } else { + Cursor(None) + }; + to_json(NodePage { nodes, next }) } } -/// `item-type:canvas-text-box`. +/// `item-type:canvas-text-box`. No `index()`: its projection lives in the kind's own R-tree (written by +/// `SpatialIndex`), not a core substrate — the point of the escape hatch. Payload: `{x, y, w, h, text}`. struct CanvasTextBox { type_id: TypeId, } @@ -40,19 +118,10 @@ impl ItemKind for CanvasTextBox { fn type_id(&self) -> &TypeId { &self.type_id } - - fn index(&self, payload: &Json) -> Option { - // coord -> spatial index. §6. - let x = payload.get("x")?.as_f64()?; - let y = payload.get("y")?.as_f64()?; - Some(IndexEntry { - coord: Some((x, y)), - ..Default::default() - }) - } } -/// Maintains the spatial index off the change stream. Confined to `Derived` tables. §7. +/// Maintains the canvas R-tree off the change stream (DESIGN §7). `Derived`, so it structurally cannot +/// write core envelopes. Backfills existing boxes, then applies each `canvas-text-box` change. struct SpatialIndex; #[async_trait] @@ -68,26 +137,186 @@ impl RuntimeComponent for SpatialIndex { fn interests(&self) -> Interests { Interests { schedule_secs: None, - types: vec![TypeId::new("canvas-text-box")], + types: vec![TypeId::new(ITEM_TYPE)], + } + } + + async fn run(&self, cx: &dyn RuntimeCtx) -> Result<()> { + let pool = cx.type_owned_db(); + // A version bump rebuilds from scratch; backfill then reconstructs every existing box. + if cx.reset_requested() { + clear(pool).await?; + } + for node in cx.scan(&[TypeId::new(ITEM_TYPE)]).await? { + if let Node::Item(item) = node { + upsert_box(pool, &item).await?; + } } + // Steady state: keep the R-tree in sync with box create/update/delete. + while let Some(event) = cx.next_event().await { + let RuntimeEvent::Change(change) = event else { + continue; + }; + apply(cx, pool, &change).await?; + } + Ok(()) } +} + +/// Apply one box change to the R-tree. +async fn apply(cx: &dyn RuntimeCtx, pool: &SqlitePool, change: &ChangeEvent) -> Result<()> { + let EnvelopeRef::Item(id) = change.target else { + return Ok(()); + }; + match change.op { + ChangeOp::Deleted => delete_box(pool, id).await, + ChangeOp::Created | ChangeOp::Updated => match cx.get_item(id).await? { + Some(item) => upsert_box(pool, &item).await, + None => delete_box(pool, id).await, // deleted between the event and the read + }, + } +} + +/// Project a box into `canvas_box` + the R-tree (delete-then-insert, since R-trees have no UPSERT), all +/// in one transaction. Skips boxes with no container or no coordinates. +async fn upsert_box(pool: &SqlitePool, item: &Item) -> Result<()> { + let Some(container) = item.container else { + return Ok(()); + }; + let Some((x, y, w, h)) = box_rect(&item.payload) else { + return Ok(()); + }; + let text = item.payload.get("text").and_then(|v| v.as_str()); + + let mut tx = pool.begin().await.map_err(db)?; + let rid: i64 = sqlx::query_scalar( + "INSERT INTO canvas_box (item_id, container, x, y, w, h, text) VALUES (?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(item_id) DO UPDATE SET container = excluded.container, x = excluded.x, \ + y = excluded.y, w = excluded.w, h = excluded.h, text = excluded.text RETURNING rid", + ) + .bind(item.id.to_string()) + .bind(container.to_string()) + .bind(x) + .bind(y) + .bind(w) + .bind(h) + .bind(text) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + sqlx::query("DELETE FROM canvas_box_rtree WHERE rid = ?") + .bind(rid) + .execute(&mut *tx) + .await + .map_err(db)?; + sqlx::query( + "INSERT INTO canvas_box_rtree (rid, minX, maxX, minY, maxY) VALUES (?, ?, ?, ?, ?)", + ) + .bind(rid) + .bind(x) + .bind(x + w) + .bind(y) + .bind(y + h) + .execute(&mut *tx) + .await + .map_err(db)?; + tx.commit().await.map_err(db)?; + Ok(()) +} + +/// Remove a box from both tables (a no-op if it was never indexed). +async fn delete_box(pool: &SqlitePool, id: ItemId) -> Result<()> { + let mut tx = pool.begin().await.map_err(db)?; + let rid: Option = sqlx::query_scalar("SELECT rid FROM canvas_box WHERE item_id = ?") + .bind(id.to_string()) + .fetch_optional(&mut *tx) + .await + .map_err(db)?; + if let Some(rid) = rid { + sqlx::query("DELETE FROM canvas_box_rtree WHERE rid = ?") + .bind(rid) + .execute(&mut *tx) + .await + .map_err(db)?; + sqlx::query("DELETE FROM canvas_box WHERE item_id = ?") + .bind(id.to_string()) + .execute(&mut *tx) + .await + .map_err(db)?; + } + tx.commit().await.map_err(db)?; + Ok(()) +} + +/// Truncate the index (reset path). +async fn clear(pool: &SqlitePool) -> Result<()> { + sqlx::query("DELETE FROM canvas_box_rtree") + .execute(pool) + .await + .map_err(db)?; + sqlx::query("DELETE FROM canvas_box") + .execute(pool) + .await + .map_err(db)?; + Ok(()) +} + +fn db(e: sqlx::Error) -> Error { + Error::Other(e.to_string()) +} + +fn to_json(page: NodePage) -> Result { + serde_json::to_value(page).map_err(|e| Error::Other(e.to_string())) +} + +/// `(x, y, w, h)` from a box payload; `w`/`h` default to 0 (a point). Missing `x`/`y` ⇒ not placeable. +fn box_rect(payload: &Json) -> Option<(f64, f64, f64, f64)> { + let x = payload.get("x")?.as_f64()?; + let y = payload.get("y")?.as_f64()?; + let w = payload.get("w").and_then(Json::as_f64).unwrap_or(0.0); + let h = payload.get("h").and_then(Json::as_f64).unwrap_or(0.0); + Some((x, y, w, h)) +} + +/// Rebuild a box `Node::Item` from a `canvas_box` row — the envelope reconstructed from the kind's own +/// projection, so `contents` never reads core's `items`. +fn box_node(ch: &Channel, row: &SqliteRow) -> Result { + let item_id: String = row.try_get("item_id").map_err(db)?; + let id: ItemId = item_id + .parse() + .map_err(|_| Error::Other(format!("invalid item id in canvas_box: {item_id}")))?; + let x: f64 = row.try_get("x").map_err(db)?; + let y: f64 = row.try_get("y").map_err(db)?; + let w: f64 = row.try_get("w").map_err(db)?; + let h: f64 = row.try_get("h").map_err(db)?; + let text: Option = row.try_get("text").map_err(db)?; + Ok(Node::Item(Item { + id, + type_id: TypeId::new(ITEM_TYPE), + container: Some(ch.id), + external_key: None, + payload: serde_json::json!({ "x": x, "y": y, "w": w, "h": h, "text": text }), + })) +} - async fn run(&self, _cx: &dyn RuntimeCtx) -> Result<()> { - todo!("canvas spatial index: backfill, then react to box moves (DESIGN §7)") +fn node_item_id(node: &Node) -> String { + match node { + Node::Item(i) => i.id.to_string(), + Node::Channel(c) => c.id.to_string(), } } /// The `channel-type:canvas` kind, for the composition root. §10. pub fn channel() -> impl ChannelKind { Canvas { - type_id: TypeId::new("canvas"), + type_id: TypeId::new(CHANNEL_TYPE), } } /// The `item-type:canvas-text-box` kind, for the composition root. §10. pub fn text_box() -> impl ItemKind { CanvasTextBox { - type_id: TypeId::new("canvas-text-box"), + type_id: TypeId::new(ITEM_TYPE), } } diff --git a/channel-party/kinds/canvas/web/island.ts b/channel-party/kinds/canvas/web/island.ts index 4b72a57..b358ce4 100644 --- a/channel-party/kinds/canvas/web/island.ts +++ b/channel-party/kinds/canvas/web/island.ts @@ -1,5 +1,134 @@ -// `canvas` island: a pan/zoom canvas that owns placement and delegates drawing each box to the -// `canvas-text-box` item island via the same registry. Scaffold placeholder. See DESIGN §9. -export function mount(el: HTMLElement, ctx: { id: string; type_id: string }): void { - el.textContent = `[${ctx.type_id}] pan/zoom canvas island for channel ${ctx.id} — not yet implemented`; +// `canvas` island, serving both roles for the canvas slice. +// - channel: `mount` renders a pannable viewport — it POSTs the visible rectangle to `contents` +// (which runs the R-tree bbox query), draws each box through *its own* item island via the +// registry (the §9 recursive-render path), re-queries when a pan settles, and reflects the SSE +// change stream in place. +// - item: `renderItem` draws one text box, absolutely positioned in the canvas plane. +// See DESIGN §9 and `design/runtime.md`. +import { islands, type IslandModule, type ItemNode } from '../../island-registry'; + +type NodePage = { nodes: ItemNode[]; next: string | null }; +type BoxPayload = { x?: unknown; y?: unknown; w?: unknown; h?: unknown; text?: unknown }; + +/** A finite number from unknown JSON, else a default. */ +function num(v: unknown, d = 0): number { + return typeof v === 'number' && Number.isFinite(v) ? v : d; +} + +/** Draw one canvas-text-box, absolutely positioned in the plane (item-island role). */ +export function renderItem(item: ItemNode): HTMLElement { + const p = (item.payload ?? {}) as BoxPayload; + const el = document.createElement('div'); + el.dataset.itemId = item.id; + Object.assign(el.style, { + position: 'absolute', + left: `${num(p.x)}px`, + top: `${num(p.y)}px`, + width: `${num(p.w, 80)}px`, + height: `${num(p.h, 40)}px`, + border: '1px solid #888', + background: '#fff', + color: '#111', + padding: '4px', + boxSizing: 'border-box', + overflow: 'hidden', + font: '12px system-ui, sans-serif', + }); + el.textContent = typeof p.text === 'string' ? p.text : ''; + return el; +} + +/** Mount the canvas as a pannable viewport (channel-island role). */ +export async function mount(el: HTMLElement, ctx: { id: string; type_id: string }): Promise { + const status = document.createElement('p'); + status.className = 'cp-status'; + const plane = document.createElement('div'); + Object.assign(el.style, { + position: 'relative', + overflow: 'hidden', + height: '70vh', + border: '1px solid #ddd', + cursor: 'grab', + touchAction: 'none', + }); + Object.assign(plane.style, { position: 'absolute', top: '0', left: '0' }); + el.replaceChildren(status, plane); + + let panX = 0; + let panY = 0; + + // Delegate box drawing to the registered item island (recursive render, §9). + const loaded = new Map(); + async function draw(node: ItemNode): Promise { + if (!loaded.has(node.type_id)) { + const load = islands.get(node.type_id); + if (load) loaded.set(node.type_id, await load()); + } + return loaded.get(node.type_id)?.renderItem?.(node) ?? null; + } + + function applyPan(): void { + plane.style.transform = `translate(${panX}px, ${panY}px)`; + } + + // The visible canvas rectangle (plus a margin so boxes near the edge preload). + function viewport(): { x0: number; y0: number; x1: number; y1: number } { + const r = el.getBoundingClientRect(); + const m = 200; + return { x0: -panX - m, y0: -panY - m, x1: -panX + r.width + m, y1: -panY + r.height + m }; + } + + async function reload(): Promise { + try { + const res = await fetch(`/api/channels/${encodeURIComponent(ctx.id)}/contents`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(viewport()), + }); + if (!res.ok) { + status.textContent = `Couldn't load canvas (HTTP ${res.status}).`; + return; + } + const page = (await res.json()) as NodePage; + const boxes = await Promise.all(page.nodes.map(draw)); + plane.replaceChildren(...boxes.filter((b): b is HTMLElement => b !== null)); + status.textContent = page.nodes.length ? '' : 'Empty canvas — drag to pan.'; + } catch (err) { + status.textContent = `Couldn't load canvas: ${String(err)}`; + } + } + + applyPan(); + await reload(); + + // Drag to pan; re-query the viewport once the drag settles. + let dragging = false; + let startX = 0; + let startY = 0; + let baseX = 0; + let baseY = 0; + el.addEventListener('pointerdown', (e) => { + dragging = true; + startX = e.clientX; + startY = e.clientY; + baseX = panX; + baseY = panY; + el.setPointerCapture(e.pointerId); + }); + el.addEventListener('pointermove', (e) => { + if (!dragging) return; + panX = baseX + (e.clientX - startX); + panY = baseY + (e.clientY - startY); + applyPan(); + }); + el.addEventListener('pointerup', (e) => { + if (!dragging) return; + dragging = false; + el.releasePointerCapture(e.pointerId); + void reload(); + }); + + // Live updates: a box create/move/delete re-queries the current viewport in place. + const events = new EventSource(`/api/events?scope=${encodeURIComponent(ctx.id)}`); + events.addEventListener('change', () => void reload()); } diff --git a/channel-party/kinds/discord-compatible/Cargo.toml b/channel-party/kinds/discord-compatible/Cargo.toml index ba9d281..e03ed2c 100644 --- a/channel-party/kinds/discord-compatible/Cargo.toml +++ b/channel-party/kinds/discord-compatible/Cargo.toml @@ -8,3 +8,15 @@ license.workspace = true cp-model.workspace = true async-trait.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +twilight-http.workspace = true +twilight-model.workspace = true + +[dev-dependencies] +cp-core.workspace = true +serde_json.workspace = true +tempfile = "3" +tokio.workspace = true +wiremock.workspace = true diff --git a/channel-party/kinds/discord-compatible/src/client.rs b/channel-party/kinds/discord-compatible/src/client.rs new file mode 100644 index 0000000..a074c83 --- /dev/null +++ b/channel-party/kinds/discord-compatible/src/client.rs @@ -0,0 +1,63 @@ +//! A thin wrapper over `twilight-http` for the ingestion this slice needs: fetch a channel's recent +//! messages, normalized to the few fields the envelopes carry. The base URL is configurable via +//! twilight's proxy support, so tests point it at a local mock server — never live Discord (§12). See +//! `design/discord.md`. + +use twilight_http::Client; +use twilight_model::id::marker::ChannelMarker; +use twilight_model::id::Id; + +/// One fetched Discord message, normalized to what ingestion stores. +pub struct FetchedMessage { + pub id: u64, + pub author_id: u64, + pub author_name: String, + pub content: String, + pub timestamp_ms: i64, +} + +/// The shared Discord REST client — one per [`crate::DiscordBridge`], shared across the slice's runtime +/// components (a single rate-limited client for all bridged guilds, §7). +pub struct DiscordClient { + http: Client, +} + +impl DiscordClient { + /// Build the client. `proxy` (a `host:port`) routes REST calls over plain http to a local server for + /// tests; `None` targets Discord directly. `token` is the bot authorization value. + pub fn new(token: String, proxy: Option) -> Self { + let mut builder = Client::builder().token(token); + if let Some(proxy) = proxy { + builder = builder.proxy(proxy, true); + } + Self { + http: builder.build(), + } + } + + /// Fetch up to `limit` recent messages of a channel (Discord returns newest-first). + pub async fn channel_messages( + &self, + channel_id: u64, + limit: u16, + ) -> Result, String> { + let id = Id::::new(channel_id); + let response = self + .http + .channel_messages(id) + .limit(limit) + .await + .map_err(|e| e.to_string())?; + let messages = response.model().await.map_err(|e| e.to_string())?; + Ok(messages + .into_iter() + .map(|m| FetchedMessage { + id: m.id.get(), + author_id: m.author.id.get(), + author_name: m.author.name, + content: m.content, + timestamp_ms: m.timestamp.as_micros() / 1000, + }) + .collect()) + } +} diff --git a/channel-party/kinds/discord-compatible/src/lib.rs b/channel-party/kinds/discord-compatible/src/lib.rs index cd9b879..d627eb7 100644 --- a/channel-party/kinds/discord-compatible/src/lib.rs +++ b/channel-party/kinds/discord-compatible/src/lib.rs @@ -3,15 +3,35 @@ //! runtime. Grouped by namespace (not leaf) because they share a great deal: one rate-limited //! client, one search index. This mirrors the `namespace/leaf` id scheme. See DESIGN §4/§7. //! -//! It is runtime-heavy: a single `DiscordSync` component (WriteScope::Primary) ingests all bridged -//! guilds behind one shared rate-limited client, and a `DiscordSemanticIndex` (Derived) builds -//! embeddings off the change stream. Everything here is a scaffold stub. +//! It is runtime-heavy: a single `DiscordSync` component (`WriteScope::Primary`) ingests all bridged +//! guilds behind one shared rate-limited client (the [`DiscordBridge`]). Implemented so far (#10 +//! (a)+(b)+(d), `design/discord.md`): the shared client, message/user ingestion, guild/channel envelope +//! creation + dedup, and channel `contents`. Deferred: the `Derived` semantic index (c), webhook +//! `routes` (e), outbound (f), membership (g), reactions, and full section/forum structure. + +mod client; + +use std::collections::HashMap; +use std::sync::Arc; use async_trait::async_trait; use cp_model::{ - Channel, ChannelKind, Interests, ItemKind, Json, Migration, Migrations, Result, - RuntimeComponent, RuntimeCtx, StoreCtx, TypeId, WriteScope, + Channel, ChannelId, ChannelKind, Cursor, Error, Filter, Interests, ItemKind, Json, Migration, + Migrations, NewChannel, NewItem, Node, NodePage, Order, Page, Result, RuntimeComponent, + RuntimeCtx, RuntimeEvent, StoreCtx, SuperType, TypeId, WriteCtx, WriteScope, }; +use serde::Deserialize; + +use crate::client::{DiscordClient, FetchedMessage}; + +const GUILD: &str = "discord-compatible/guild"; +const CHANNEL: &str = "discord-compatible/channel"; +const FORUM: &str = "discord-compatible/forum"; +const CACHED_USER: &str = "discord-compatible/cached-user"; +const CACHED_MESSAGE: &str = "discord-compatible/cached-message"; + +/// Page size when a `contents` query omits `limit`. +const DEFAULT_LIMIT: u32 = 50; /// A channel kind in this namespace (guild / section / channel / forum). §4. struct DiscordChannel { @@ -29,10 +49,65 @@ impl ChannelKind for DiscordChannel { Ok(()) } - async fn contents(&self, _cx: &dyn StoreCtx, _ch: &Channel, _query: Json) -> Result { - // DESIGN §5: descendants(id, {Channel, [channel, section]}) -> the whole subtree at once; - // the guild island builds the tree. Recursive: opening a child mounts its own island. - todo!("discord channel contents (DESIGN §5)") + async fn contents(&self, cx: &dyn StoreCtx, ch: &Channel, query: Json) -> Result { + // Two strategies by kind (§5). This is the slice matching on *its own* types — never core. + let page = match self.type_id.as_str() { + // Leaf channels hold messages: a newest-first feed of cached-messages (like `basic`). + CHANNEL | FORUM => { + let q = DiscordQuery::parse(query)?; + cx.children( + ch.id, + Filter { + super_type: Some(SuperType::Item), + type_ids: Some(vec![TypeId::new(CACHED_MESSAGE)]), + }, + Page { + cursor: Cursor(q.cursor), + limit: q.limit.unwrap_or(DEFAULT_LIMIT), + }, + Order::TimeDesc, + ) + .await? + } + // Structural channels (guild / section) return their whole channel subtree in one call, so + // the island builds the tree from each node's `container`. + _ => { + let nodes = cx + .descendants( + ch.id, + Filter { + super_type: Some(SuperType::Channel), + type_ids: None, + }, + None, + ) + .await?; + NodePage { + nodes, + next: Cursor(None), + } + } + }; + serde_json::to_value(page).map_err(|e| Error::Other(e.to_string())) + } +} + +/// The `contents` query shared by discord channel kinds — all optional. Leaf channels page their +/// message feed with `cursor`/`limit`; structural channels ignore it (they return the whole subtree). +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct DiscordQuery { + cursor: Option, + limit: Option, +} + +impl DiscordQuery { + fn parse(query: Json) -> Result { + if query.is_null() { + Ok(Self::default()) + } else { + serde_json::from_value(query).map_err(|e| Error::Validation(e.to_string())) + } } } @@ -81,66 +156,241 @@ pub fn items() -> Vec> { .collect() } -/// Initial sync then poll: ingests messages / users / reactions behind one shared rate-limited -/// client. Writes `Primary` envelopes (the source of truth). Resets by re-fetching from Discord. §7. -struct DiscordSync; +/// How the composition root configures the bridge. The bridge creates the channel-party `guild` + +/// `channel` envelopes itself (deduped), so the operator supplies only Discord ids: `guild` and the +/// `channels` under it to ingest. `proxy` (a `host:port`) routes the client at a test mock; `None` +/// targets Discord. §10. +pub struct BridgeConfig { + pub token: String, + pub proxy: Option, + pub poll_secs: u64, + pub guild: u64, + pub channels: Vec, +} -#[async_trait] -impl RuntimeComponent for DiscordSync { - fn name(&self) -> &str { - "discord-sync" +impl BridgeConfig { + /// Read the bridge config from the environment, or `None` when unconfigured (no `CP_DISCORD_TOKEN` + /// or `CP_DISCORD_GUILD`) — in which case the composition root registers no Discord component. + /// `CP_DISCORD_CHANNELS` is comma-separated Discord channel ids; `CP_DISCORD_BASE_URL` is the test + /// proxy; `CP_DISCORD_POLL_SECS` defaults to 60. + pub fn from_env() -> Option { + let token = std::env::var("CP_DISCORD_TOKEN").ok()?; + let guild = std::env::var("CP_DISCORD_GUILD") + .ok()? + .trim() + .parse() + .ok()?; + let channels = std::env::var("CP_DISCORD_CHANNELS") + .ok() + .map(|s| parse_ids(&s)) + .unwrap_or_default(); + Some(Self { + token, + proxy: std::env::var("CP_DISCORD_BASE_URL").ok(), + poll_secs: std::env::var("CP_DISCORD_POLL_SECS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(60), + guild, + channels, + }) } +} - fn writes(&self) -> WriteScope { - WriteScope::Primary - } +fn parse_ids(spec: &str) -> Vec { + spec.split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect() +} - fn interests(&self) -> Interests { - Interests { - schedule_secs: Some(60), - types: Vec::new(), +/// The shared Discord bridge (sub-part (a), `design/discord.md`): one rate-limited client, from which +/// every runtime component in this slice is spun so they share it. Build it once at the composition root. +pub struct DiscordBridge { + client: Arc, + guild: u64, + channels: Arc>, + poll_secs: u64, +} + +impl DiscordBridge { + /// The Primary ingestor, sharing this bridge's client. (A future `semantic_index()` would share it + /// too — the point of a bridge.) §7/§10. + pub fn sync(&self) -> DiscordSync { + DiscordSync { + client: self.client.clone(), + guild: self.guild, + channels: self.channels.clone(), + poll_secs: self.poll_secs, } } +} - async fn run(&self, _cx: &dyn RuntimeCtx) -> Result<()> { - todo!("discord sync: initial sync, then poll (DESIGN §7)") +/// Build the shared bridge from config. §10. +pub fn bridge(config: BridgeConfig) -> DiscordBridge { + DiscordBridge { + client: Arc::new(DiscordClient::new(config.token, config.proxy)), + guild: config.guild, + channels: Arc::new(config.channels), + poll_secs: config.poll_secs, } } -/// Initial backfill then react to change events: embeddings for semantic search. Confined to -/// `Derived` tables, so a bug can't corrupt the source of truth. Resets by replaying local data. §7. -struct DiscordSemanticIndex; +/// Initial sync then poll: ensures the guild/channel envelope structure, then ingests each channel's +/// messages + authors behind the shared rate-limited client. Writes `Primary` envelopes (the source of +/// truth), deduped by `external_key` (items) / carried Discord id (channels). Resets by re-fetching from +/// Discord (idempotent upserts converge). §7. (Reactions are a later pass.) +pub struct DiscordSync { + client: Arc, + guild: u64, + channels: Arc>, + poll_secs: u64, +} #[async_trait] -impl RuntimeComponent for DiscordSemanticIndex { +impl RuntimeComponent for DiscordSync { fn name(&self) -> &str { - "discord-semantic-index" + "discord-sync" } fn writes(&self) -> WriteScope { - WriteScope::Derived + WriteScope::Primary } fn interests(&self) -> Interests { + // A schedule (re-poll); no change-stream interest — this component is a source, not a projector. Interests { - schedule_secs: None, - types: vec![TypeId::new("discord-compatible/cached-message")], + schedule_secs: Some(self.poll_secs), + types: Vec::new(), } } - async fn run(&self, _cx: &dyn RuntimeCtx) -> Result<()> { - todo!("discord semantic index: backfill, then react to change events (DESIGN §7)") + async fn run(&self, cx: &dyn RuntimeCtx) -> Result<()> { + let writer = cx + .writer() + .ok_or_else(|| Error::Other("discord-sync is Primary but got no writer".to_owned()))?; + // Initial sync == reset: both re-fetch from Discord (§7). Idempotent, so it converges. + self.sync_all(cx, writer).await?; + while let Some(event) = cx.next_event().await { + if let RuntimeEvent::Tick = event { + self.sync_all(cx, writer).await?; + } + } + Ok(()) } } -/// The Primary ingestor, for the composition root. §10. -pub fn sync() -> impl RuntimeComponent { - DiscordSync -} +impl DiscordSync { + async fn sync_all(&self, cx: &dyn RuntimeCtx, writer: &dyn WriteCtx) -> Result<()> { + let containers = self.ensure_structure(cx, writer).await?; + for discord_channel in self.channels.iter() { + let Some(&container) = containers.get(discord_channel) else { + continue; + }; + let messages = self + .client + .channel_messages(*discord_channel, 100) + .await + .map_err(Error::Other)?; + for message in messages { + self.ingest(writer, container, &message).await?; + } + } + Ok(()) + } + + /// Ensure the `guild` envelope and a `channel` envelope per configured Discord channel exist, + /// deduped by the Discord id each envelope carries in its payload. Channels have no `external_key`, + /// so dedup is *derived from the source of truth* — `scan` the existing envelopes and create only the + /// missing ones. Idempotent across re-syncs with no separate mapping table (and no crash window a + /// mapping table would introduce). Returns Discord channel id -> its channel-party container id. + async fn ensure_structure( + &self, + cx: &dyn RuntimeCtx, + writer: &dyn WriteCtx, + ) -> Result> { + let existing = cx.scan(&[TypeId::new(GUILD), TypeId::new(CHANNEL)]).await?; + let mut by_discord: HashMap = existing + .iter() + .filter_map(|node| { + let Node::Channel(ch) = node else { + return None; + }; + let discord_id = ch + .payload + .get("discord_id")? + .as_str()? + .parse::() + .ok()?; + Some((discord_id, ch.id)) + }) + .collect(); + + let guild = match by_discord.get(&self.guild).copied() { + Some(id) => id, + None => { + let id = writer + .create_channel(NewChannel { + type_id: TypeId::new(GUILD), + container: None, + payload: serde_json::json!({ "discord_id": self.guild.to_string() }), + }) + .await?; + by_discord.insert(self.guild, id); + id + } + }; -/// The Derived semantic indexer, for the composition root. §10. -pub fn semantic_index() -> impl RuntimeComponent { - DiscordSemanticIndex + for discord_channel in self.channels.iter() { + if !by_discord.contains_key(discord_channel) { + let id = writer + .create_channel(NewChannel { + type_id: TypeId::new(CHANNEL), + container: Some(guild), + payload: serde_json::json!({ "discord_id": discord_channel.to_string() }), + }) + .await?; + by_discord.insert(*discord_channel, id); + } + } + Ok(by_discord) + } + + /// Upsert one message and its author: a `cached-user` (one per Discord user, `external_key` dedup, + /// §3) then a `cached-message` under the mapped channel. Its author is a reference to the cached-user + /// by Discord id (resolvable to a native user via a `linked-users` link, §2/#19). + async fn ingest( + &self, + writer: &dyn WriteCtx, + container: ChannelId, + m: &FetchedMessage, + ) -> Result<()> { + writer + .upsert_item(NewItem { + type_id: TypeId::new(CACHED_USER), + container: None, + external_key: Some(format!("discord:user:{}", m.author_id)), + payload: serde_json::json!({ + "discord_id": m.author_id.to_string(), + "name": m.author_name, + }), + }) + .await?; + writer + .upsert_item(NewItem { + type_id: TypeId::new(CACHED_MESSAGE), + container: Some(container), + external_key: Some(format!("discord:message:{}", m.id)), + payload: serde_json::json!({ + "discord_id": m.id.to_string(), + "author_discord_id": m.author_id.to_string(), + "author_name": m.author_name, + "content": m.content, + "timestamp_ms": m.timestamp_ms, + }), + }) + .await?; + Ok(()) + } } /// Type-owned migrations (namespaced `discord_*`). Core never learns their shape. §6. diff --git a/channel-party/kinds/discord-compatible/tests/sync_ingest.rs b/channel-party/kinds/discord-compatible/tests/sync_ingest.rs new file mode 100644 index 0000000..2791f35 --- /dev/null +++ b/channel-party/kinds/discord-compatible/tests/sync_ingest.rs @@ -0,0 +1,176 @@ +//! `DiscordSync` ingestion + structure + contents (`TODO.md` #10 (a)+(b)+(d), `design/discord.md`) +//! against a **mock** Discord (wiremock via twilight's proxy — never the live API, §12). Proves the +//! `Primary` write path end to end: the component builds the guild/channel envelope tree (deduped), +//! upserts `cached-message` + `cached-user` envelopes through `writer()`, and the channel kinds' own +//! `contents` reads them back (guild → subtree via `descendants`, channel → message feed via `children`). + +use std::sync::Arc; +use std::time::Duration; + +use cp_core::{Core, Registry, Store}; +use cp_model::{Channel, Node, TypeId}; +use wiremock::matchers::{method, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const GUILD: &str = "discord-compatible/guild"; +const CHANNEL: &str = "discord-compatible/channel"; +const CACHED_MESSAGE: &str = "discord-compatible/cached-message"; +const CACHED_USER: &str = "discord-compatible/cached-user"; + +/// A minimal-but-valid Discord message object (only the fields twilight requires present). +fn message(id: u64, author_id: u64, author: &str, content: &str) -> serde_json::Value { + serde_json::json!({ + "id": id.to_string(), + "channel_id": "1", + "author": { "id": author_id.to_string(), "username": author, "discriminator": "0001" }, + "content": content, + "timestamp": "2024-01-01T00:00:00.000000+00:00", + "type": 0, + "attachments": [], + "embeds": [], + "mention_everyone": false, + "mention_roles": [], + "mentions": [], + "pinned": false, + "tts": false + }) +} + +async fn mock_channel(server: &MockServer, discord_channel: u64, messages: serde_json::Value) { + Mock::given(method("GET")) + .and(path_regex(format!( + r"/channels/{discord_channel}/messages$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(messages)) + .mount(server) + .await; +} + +async fn channels_of_type(store: &Arc, ty: &str) -> Vec { + store + .scan_by_types(&[TypeId::new(ty)]) + .await + .unwrap() + .into_iter() + .filter_map(|n| match n { + Node::Channel(c) => Some(c), + Node::Item(_) => None, + }) + .collect() +} + +async fn count(store: &Arc, ty: &str) -> usize { + store.scan_by_types(&[TypeId::new(ty)]).await.unwrap().len() +} + +#[tokio::test] +async fn ingests_structure_messages_and_serves_contents() { + // Mock Discord: guild 10 has two channels — 100 (two messages from alice) and 200 (one from bob). + let server = MockServer::start().await; + mock_channel( + &server, + 100, + serde_json::json!([ + message(1001, 555, "alice", "hello"), + message(1002, 555, "alice", "again"), + ]), + ) + .await; + mock_channel( + &server, + 200, + serde_json::json!([message(1003, 777, "bob", "hi")]), + ) + .await; + + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite:{}", dir.path().join("t.db").display()); + let config = cp_discord::BridgeConfig { + token: "Bot test.token".to_owned(), + proxy: Some(server.address().to_string()), + poll_secs: 1, + guild: 10, + channels: vec![100, 200], + }; + let registry = Registry::builder() + .channels(cp_discord::channels()) + .items(cp_discord::items()) + .migrations(cp_discord::MIGRATIONS) + .runtime(cp_discord::bridge(config).sync()) + .build(); + let core = Core::open(&url, registry.clone()).await.unwrap(); + let store = core.store(); + let handle = core.spawn_runtime(); + + // The initial sync builds the structure then ingests: wait until both channels + all 3 messages land. + for _ in 0..200 { + if channels_of_type(&store, CHANNEL).await.len() == 2 + && count(&store, CACHED_MESSAGE).await == 3 + { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert_eq!(count(&store, GUILD).await, 1, "one guild envelope created"); + assert_eq!( + count(&store, CHANNEL).await, + 2, + "two channel envelopes created under it" + ); + assert_eq!( + count(&store, CACHED_MESSAGE).await, + 3, + "all three messages ingested" + ); + assert_eq!( + count(&store, CACHED_USER).await, + 2, + "two distinct authors deduped by external_key" + ); + + // Guild `contents` returns its channel subtree (descendants). + let guild = channels_of_type(&store, GUILD).await.pop().unwrap(); + let out = cp_core::contents::dispatch(®istry, &*store, &guild, serde_json::json!({})) + .await + .unwrap(); + assert_eq!( + out["nodes"].as_array().unwrap().len(), + 2, + "guild contents = its two channels (descendants)" + ); + + // A channel's `contents` returns its message feed (children). + let channels = channels_of_type(&store, CHANNEL).await; + let ch100 = channels + .iter() + .find(|c| c.payload["discord_id"] == "100") + .expect("channel 100 envelope"); + let out = cp_core::contents::dispatch(®istry, &*store, ch100, serde_json::json!({})) + .await + .unwrap(); + assert_eq!( + out["nodes"].as_array().unwrap().len(), + 2, + "channel 100 contents = its two messages (children)" + ); + + // A scheduled re-poll (poll_secs = 1) rebuilds structure + re-fetches: nothing duplicates. + tokio::time::sleep(Duration::from_millis(1500)).await; + assert_eq!( + count(&store, GUILD).await, + 1, + "re-poll does not duplicate the guild" + ); + assert_eq!( + count(&store, CHANNEL).await, + 2, + "re-poll does not duplicate channels" + ); + assert_eq!( + count(&store, CACHED_MESSAGE).await, + 3, + "re-poll upserts messages, no duplicates" + ); + + handle.shutdown().await; +} diff --git a/channel-party/kinds/discord-compatible/web/island.ts b/channel-party/kinds/discord-compatible/web/island.ts index d32003e..cac2f99 100644 --- a/channel-party/kinds/discord-compatible/web/island.ts +++ b/channel-party/kinds/discord-compatible/web/island.ts @@ -1,6 +1,76 @@ -// `discord-compatible` island: a threaded guild/channel view. A guild island renders channel -// references; opening one recursively mounts that child's island. Scaffold placeholder. -// See DESIGN §5/§9. -export function mount(el: HTMLElement, ctx: { id: string; type_id: string }): void { - el.textContent = `[${ctx.type_id}] threaded view island for channel ${ctx.id} — not yet implemented`; +// `discord-compatible` island — one module for all four channel kinds, branching on `ctx.type_id` +// (DESIGN §5/§9, design/discord.md): +// - guild / section (structural): render the channel subtree as links the type-agnostic shell opens +// (recursive discovery — opening one mounts its own island). +// - channel / forum (leaf): render the ingested cached-message feed. +// Contents comes from this channel's own `contents` (guild → `descendants`, channel → `children`). + +type ChannelNode = { + super_type: 'channel'; + id: string; + type_id: string; + container: string | null; + payload: { discord_id?: string; name?: string }; +}; +type ItemNode = { + super_type: 'item'; + id: string; + type_id: string; + payload: { author_name?: string; content?: string }; +}; +type NodePage = { nodes: Array; next: string | null }; + +const STRUCTURAL = new Set(['discord-compatible/guild', 'discord-compatible/section']); + +async function fetchContents(id: string): Promise { + const res = await fetch(`/api/channels/${encodeURIComponent(id)}/contents`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + return res.ok ? ((await res.json()) as NodePage) : null; +} + +function channelLink(node: ChannelNode): HTMLElement { + const li = document.createElement('li'); + li.className = 'cp-msg'; + const a = document.createElement('a'); + a.href = `/channels/${encodeURIComponent(node.id)}`; + a.textContent = node.payload.name ?? `#${node.payload.discord_id ?? node.id}`; + li.append(a); + return li; +} + +function messageLine(node: ItemNode): HTMLElement { + const li = document.createElement('li'); + li.className = 'cp-msg'; + li.textContent = `${node.payload.author_name ?? 'unknown'}: ${node.payload.content ?? ''}`; + return li; +} + +/** Mount a discord channel: a tree of links (structural) or a message feed (leaf). */ +export async function mount(el: HTMLElement, ctx: { id: string; type_id: string }): Promise { + const list = document.createElement('ul'); + list.className = 'cp-msglist'; + const status = document.createElement('p'); + status.className = 'cp-status'; + el.replaceChildren(list, status); + + const page = await fetchContents(ctx.id); + if (!page) { + status.textContent = 'Could not load this channel.'; + return; + } + + const structural = STRUCTURAL.has(ctx.type_id); + // Leaf feeds arrive newest-first; show oldest-at-top, chat-style. + const nodes = structural ? page.nodes : [...page.nodes].reverse(); + for (const node of nodes) { + if (structural && node.super_type === 'channel') { + list.append(channelLink(node)); + } else if (node.super_type === 'item') { + list.append(messageLine(node)); + } + } + status.textContent = page.nodes.length ? '' : structural ? 'No channels yet.' : 'No messages yet.'; } diff --git a/channel-party/kinds/space/Cargo.toml b/channel-party/kinds/space/Cargo.toml index 3b7ad37..332d5e9 100644 --- a/channel-party/kinds/space/Cargo.toml +++ b/channel-party/kinds/space/Cargo.toml @@ -8,3 +8,5 @@ license.workspace = true cp-model.workspace = true async-trait.workspace = true +serde = { workspace = true } +serde_json.workspace = true diff --git a/channel-party/kinds/space/src/lib.rs b/channel-party/kinds/space/src/lib.rs index 2634253..47010b0 100644 --- a/channel-party/kinds/space/src/lib.rs +++ b/channel-party/kinds/space/src/lib.rs @@ -1,8 +1,25 @@ //! `space` — a container whose `contents` is a substring name-search over its descendant channels. -//! It is index-light and search-shaped: the island is a search UI. See DESIGN §4/§5. +//! It is index-light and search-shaped: the island is a search UI. See DESIGN §4/§5 and +//! `design/index-search.md`. use async_trait::async_trait; -use cp_model::{Channel, ChannelKind, Json, Result, StoreCtx, TypeId}; +use cp_model::{ + Channel, ChannelKind, Cursor, Error, Filter, Json, Page, Result, StoreCtx, SuperType, TypeId, +}; +use serde::Deserialize; + +/// Page size when a `space` query omits `limit`. +const DEFAULT_LIMIT: u32 = 50; + +/// The `contents` query for a `space` — a search string plus optional pagination. Opaque to core; the +/// island and this kind agree on the shape (§5/§9). An empty/short `q` yields an empty page. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct SpaceQuery { + q: String, + cursor: Option, + limit: Option, +} /// `channel-type:space`. struct Space { @@ -15,10 +32,33 @@ impl ChannelKind for Space { &self.type_id } - async fn contents(&self, _cx: &dyn StoreCtx, _ch: &Channel, _query: Json) -> Result { - // DESIGN §5: search(descendants(id), query.q, {Channel, [basic]}, page) -> paginated name - // matches over the `index()` projection (§6). Then serialize the NodePage to Json. - todo!("space channel contents (DESIGN §5)") + async fn contents(&self, cx: &dyn StoreCtx, ch: &Channel, query: Json) -> Result { + // DESIGN §5: search(scope = self, query.q, {Channel}, page) -> paginated channel matches over + // the FTS projection (§6), then serialize the NodePage. Deviation from §5's `{Channel, [basic]}`: + // we don't restrict to the `basic` type — hardcoding a peer kind's string would couple `space` + // to `basic`; `super_type: Channel` already excludes messages, and a space may hold any channel + // kind. `search` scopes to this space's subtree and short-circuits an empty/short `q`. + let q: SpaceQuery = if query.is_null() { + SpaceQuery::default() + } else { + serde_json::from_value(query).map_err(|e| Error::Validation(e.to_string()))? + }; + + let page = cx + .search( + ch.id, + &q.q, + Filter { + super_type: Some(SuperType::Channel), + type_ids: None, + }, + Page { + cursor: Cursor(q.cursor), + limit: q.limit.unwrap_or(DEFAULT_LIMIT), + }, + ) + .await?; + serde_json::to_value(page).map_err(|e| Error::Other(e.to_string())) } } diff --git a/channel-party/kinds/space/web/island.ts b/channel-party/kinds/space/web/island.ts index 0bd4974..8f86506 100644 --- a/channel-party/kinds/space/web/island.ts +++ b/channel-party/kinds/space/web/island.ts @@ -1,5 +1,81 @@ -// `space` channel island: a search UI over descendant channels by name. Scaffold placeholder. -// See DESIGN §9. +// `space` channel island: a search box over the space's descendant channels by name. Submitting a +// query POSTs it to this channel's `contents` (which runs core's FTS `search`), then lists each match +// as a link the type-agnostic shell opens — recursive discovery (DESIGN §9): opening a result mounts +// *its* island. See `design/index-search.md`. + +// One channel reference in a search result — a serialized cp_model::Node::Channel. +type ChannelNode = { + super_type: 'channel'; + id: string; + type_id: string; + container: string | null; + payload: unknown; +}; +type NodePage = { nodes: ChannelNode[]; next: string | null }; + +// One page of results. The offset cursor (page.next) exists, but the search box shows the first page +// only — finding a channel rarely needs to scroll past this. +const PAGE = 50; + +/** The channel's display name, falling back to its id. */ +function channelName(node: ChannelNode): string { + const payload = node.payload as { name?: unknown }; + return typeof payload?.name === 'string' ? payload.name : node.id; +} + +/** Mount the space as a channel-search UI (channel-island role). */ export function mount(el: HTMLElement, ctx: { id: string; type_id: string }): void { - el.textContent = `[${ctx.type_id}] search island for channel ${ctx.id} — not yet implemented`; + const form = document.createElement('form'); + const input = document.createElement('input'); + input.type = 'search'; + input.placeholder = 'Search channels…'; + input.autofocus = true; + const button = document.createElement('button'); + button.textContent = 'Search'; + form.append(input, button); + + const status = document.createElement('p'); + status.className = 'cp-status'; + const list = document.createElement('ul'); + list.className = 'cp-msglist'; + el.replaceChildren(form, status, list); + + async function run(q: string): Promise { + list.replaceChildren(); + // Mirror core's trigram floor (§ search): under 3 chars there is nothing to match. + if (q.length < 3) { + status.textContent = q ? 'Type at least 3 characters.' : ''; + return; + } + status.textContent = 'Searching…'; + try { + const res = await fetch(`/api/channels/${encodeURIComponent(ctx.id)}/contents`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ q, limit: PAGE }), + }); + if (!res.ok) { + status.textContent = `Search failed (HTTP ${res.status}).`; + return; + } + const page = (await res.json()) as NodePage; + for (const node of page.nodes) { + const li = document.createElement('li'); + li.className = 'cp-msg'; + const a = document.createElement('a'); + a.href = `/channels/${encodeURIComponent(node.id)}`; + a.textContent = channelName(node); + li.append(a); + list.append(li); + } + status.textContent = page.nodes.length ? '' : `No channels match "${q}".`; + } catch (err) { + status.textContent = `Search failed: ${String(err)}`; + } + } + + form.addEventListener('submit', (e) => { + e.preventDefault(); + void run(input.value.trim()); + }); } diff --git a/channel-party/web/astro.config.mjs b/channel-party/web/astro.config.mjs index 0abb110..dd90c63 100644 --- a/channel-party/web/astro.config.mjs +++ b/channel-party/web/astro.config.mjs @@ -5,8 +5,10 @@ import { defineConfig, passthroughImageService } from 'astro/config'; // client-side islands loaded via the build-time registry (src/generated/island-registry.ts). // cp-frontend serves this static build from CP_WEB_DIR. See DESIGN §9/§11. // -// A real deployment would add a server/hybrid adapter so channel routes render on demand; the -// scaffold is fully static and resolves channels client-side. +// Output stays fully static (no SSR adapter): Astro static mode can't prerender an arbitrary +// /channels/, so routing is client-side — cp-frontend falls back to index.html for unknown paths +// and the shell reads location.pathname to mount the channel's island. This keeps the Rust server a +// plain static file server. Revisit if per-request server rendering is ever needed (DESIGN §16/#16). export default defineConfig({ // No `sharp`: the scaffold has no images, and passthrough keeps the pnpm closure free of a native // dependency that would need network/compilation inside the Nix build sandbox. diff --git a/channel-party/web/scripts/gen-registry.mjs b/channel-party/web/scripts/gen-registry.mjs index 239169a..8d84d70 100644 --- a/channel-party/web/scripts/gen-registry.mjs +++ b/channel-party/web/scripts/gen-registry.mjs @@ -36,9 +36,21 @@ const lines = entries.map( ); const contents = `// AUTO-GENERATED by scripts/gen-registry.mjs — do not edit. DESIGN §9.\n` + - `// Maps a channel/item type_id to a lazy dynamic import of its island (code-split at build).\n` + + `// Maps a channel/item type_id to a lazy dynamic import of its island (code-split at build).\n\n` + + `// One node in a discovery result — a serialized cp_model::Item. Item islands render these.\n` + + `export type ItemNode = {\n` + + ` super_type: 'item';\n` + + ` id: string;\n` + + ` type_id: string;\n` + + ` container: string | null;\n` + + ` external_key: string | null;\n` + + ` payload: unknown;\n` + + `};\n\n` + + `// A kind's island. Channel kinds implement mount (render a whole channel); item kinds implement\n` + + `// renderItem (render a single item). A kind may provide either or both — basic serves both roles.\n` + `export type IslandModule = {\n` + - ` mount: (el: HTMLElement, ctx: { id: string; type_id: string }) => void;\n` + + ` mount?: (el: HTMLElement, ctx: { id: string; type_id: string }) => void | Promise;\n` + + ` renderItem?: (item: ItemNode) => HTMLElement;\n` + `};\n\n` + `export const islands = new Map Promise>([\n` + `${lines.join('\n')}\n` + diff --git a/channel-party/web/src/pages/channels/[id].astro b/channel-party/web/src/pages/channels/[id].astro deleted file mode 100644 index 5a076ae..0000000 --- a/channel-party/web/src/pages/channels/[id].astro +++ /dev/null @@ -1,53 +0,0 @@ ---- -// A channel page. It fetches { id, type_id }, then dynamically imports the island for that type -// from the build-time registry and mounts it. The island owns its own data fetching (POST -// .../contents), rendering, and interactions. Channel-kind islands render containers and delegate -// item rendering to item-kind islands via the same registry. See DESIGN §9. -// -// Static output: dynamic routes need `getStaticPaths`, so we prerender one placeholder and resolve -// the real channel client-side. A server/hybrid adapter would remove this. -export function getStaticPaths() { - return [{ params: { id: 'demo' } }]; -} -const { id } = Astro.params; ---- - - - - - - channel · channel-party - - -
Loading channel…
- - - diff --git a/channel-party/web/src/pages/index.astro b/channel-party/web/src/pages/index.astro index b49de0c..3b44da9 100644 --- a/channel-party/web/src/pages/index.astro +++ b/channel-party/web/src/pages/index.astro @@ -1,6 +1,9 @@ --- -// The type-agnostic app shell (nav / channel tree / layout). Entirely type-agnostic: it never -// knows about concrete channel or item kinds. Opening a channel loads that type's island. §9. +// The single, type-agnostic app shell. #16 decision: the build is fully static and routing happens +// client-side (no SSR adapter), so `cp-frontend` keeps serving one static bundle. Astro's static mode +// can't prerender an arbitrary /channels/, so cp-frontend falls back to this index.html for unknown +// paths; the script below reads location.pathname and mounts the matching channel's island. Any channel +// id works with no per-id prerender. The shell never knows about concrete kinds. See DESIGN §9/§16. --- @@ -8,16 +11,138 @@ channel-party +
-

channel-party

-

Headless, extensibility-first chat. The shell is type-agnostic; open a channel to load - its island.

+ channel-party +
- +
Loading…
+