diff --git a/relay-server/Cargo.lock b/relay-server/Cargo.lock index 8e39795..f46f6a5 100644 --- a/relay-server/Cargo.lock +++ b/relay-server/Cargo.lock @@ -2,6 +2,19 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +24,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -31,6 +50,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -51,7 +79,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", - "base64", + "base64 0.22.1", "bytes", "futures-util", "http", @@ -101,17 +129,32 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[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.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -170,6 +213,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -185,6 +234,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -243,7 +316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" dependencies = [ "cfg-if", - "hashbrown", + "hashbrown 0.14.5", "lock_api", "once_cell", "parking_lot_core", @@ -255,6 +328,17 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -262,7 +346,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -276,6 +362,27 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -286,12 +393,46 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -359,6 +500,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -470,6 +622,67 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] [[package]] name = "http" @@ -694,6 +907,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "itoa" version = "1.0.17" @@ -715,12 +938,50 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" -version = "0.2.180" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -749,6 +1010,16 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.6" @@ -761,6 +1032,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -778,6 +1055,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nonempty" version = "0.7.0" @@ -799,6 +1086,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -806,6 +1129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -832,11 +1156,26 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -875,6 +1214,39 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1010,6 +1382,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1040,25 +1421,112 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] -name = "rustversion" -version = "1.0.22" +name = "ring" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] [[package]] -name = "ryu" -version = "1.0.22" +name = "rsa" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" @@ -1134,6 +1602,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1159,6 +1638,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "slab" version = "0.4.12" @@ -1181,6 +1670,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "spinning_top" version = "0.3.0" @@ -1190,6 +1688,227 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" +dependencies = [ + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" +dependencies = [ + "ahash", + "atoi", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", + "urlencoding", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1200,6 +1919,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "station-relay-server" version = "0.1.0" dependencies = [ + "async-trait", "axum", "chrono", "futures-util", @@ -1207,6 +1927,7 @@ dependencies = [ "rand", "serde", "serde_json", + "sqlx", "tokio", "tower", "tower-http", @@ -1218,12 +1939,29 @@ dependencies = [ "validator", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -1231,6 +1969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -1262,6 +2001,19 @@ dependencies = [ "syn 2.0.114", ] +[[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 = "1.0.69" @@ -1344,6 +2096,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.24.0" @@ -1519,6 +2282,30 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[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" @@ -1596,6 +2383,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1617,6 +2410,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -1672,6 +2471,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1753,13 +2568,31 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -1771,6 +2604,37 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -1778,58 +2642,148 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "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", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[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_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -1912,6 +2866,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/relay-server/Cargo.toml b/relay-server/Cargo.toml index 03bbb0d..7a23c30 100644 --- a/relay-server/Cargo.toml +++ b/relay-server/Cargo.toml @@ -19,6 +19,8 @@ urlencoding = "2.1" tower_governor = "0.4" governor = "0.6" validator = { version = "0.18", features = ["derive"] } +sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "macros", "migrate"] } +async-trait = "0.1" [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/relay-server/README.md b/relay-server/README.md index 73c6a8c..9ba0c9d 100644 --- a/relay-server/README.md +++ b/relay-server/README.md @@ -72,6 +72,20 @@ Web screen sharing with up to 8 participants. - `GET /api/rtc-sessions/:id` → `{app_id, channel, host_uid}` - Get session info - `POST /api/rtc-sessions/:id/join {name}` → `{app_id, channel, token, uid}` - Join session (assigns unique UID) +### Vault +Durable, append-only, versioned shared context store for collaborating atems. +Backed by Postgres (`DATABASE_URL`). All requests require +`Authorization: session ` and `?id=`. + +- `POST /api/vault {summary}` → `{vault_id}` - Create a vault +- `GET /api/vault` → `[{vault_id, summary}]` - List readable vaults +- `GET /api/vault/:id [?since=&history=true]` → `[VaultEntry]` - Read (current view or history) +- `POST /api/vault/:id {text, entry_id?}` → `{entry_no, version, seq}` - Append (no `entry_id`) or override (with `entry_id`) +- `POST /api/vault/:id/summary {text}` → `{}` - Update summary + +Authz: in-session callers (same `work_session_id` = bound astation_id) get read+write; +out-of-session past content-writers get read-only; others are denied (403). + ## Astation Integration The Astation macOS app uses this relay server for: @@ -91,6 +105,7 @@ Config: Set `relay_url` and `ws_url` in `.atem/config.toml` | `PUBLIC_BASE_URL` | _(unset)_ | Public base URL used for generated session links (recommended in production) | | `PORT` | `3000` | Server port | | `RUST_LOG` | `info` | Log level (error, warn, info, debug, trace) | +| `DATABASE_URL` | _(unset)_ | Postgres connection string for **vault** storage (e.g. `postgres://vault:vault@localhost:5432/vault`). When unset, vault storage falls back to **in-memory** (non-durable) and logs a warning. Migrations in `migrations/` run automatically at startup. | **Production:** ```bash diff --git a/relay-server/docker-compose.yml b/relay-server/docker-compose.yml index 382a842..fa6b209 100644 --- a/relay-server/docker-compose.yml +++ b/relay-server/docker-compose.yml @@ -3,12 +3,16 @@ services: build: . image: station-relay-server:latest restart: unless-stopped + depends_on: + postgres: + condition: service_healthy environment: - CORS_ORIGIN=${CORS_ORIGIN:-https://station.agora.build} - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-https://station.agora.build} - PORT=${PORT:-3000} - RUST_LOG=${RUST_LOG:-info} + - DATABASE_URL=${DATABASE_URL:-postgres://vault:vault@postgres:5432/vault} ports: - "3000:3000" @@ -19,3 +23,21 @@ services: timeout: 10s retries: 3 start_period: 10s + + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + - POSTGRES_USER=${POSTGRES_USER:-vault} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-vault} + - POSTGRES_DB=${POSTGRES_DB:-vault} + volumes: + - vault_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vault}"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + vault_pgdata: diff --git a/relay-server/migrations/0001_vault.sql b/relay-server/migrations/0001_vault.sql new file mode 100644 index 0000000..7987bc4 --- /dev/null +++ b/relay-server/migrations/0001_vault.sql @@ -0,0 +1,24 @@ +-- Vault: durable, append-only, versioned shared context store. +CREATE TABLE IF NOT EXISTS vaults ( + vault_id TEXT PRIMARY KEY, -- short, URL-safe, e.g. "v-7Kf3qD" + summary TEXT NOT NULL DEFAULT '', -- mutable description + work_session_id TEXT NOT NULL, -- the work session (astation_id) this vault belongs to + created_by TEXT NOT NULL, -- client_id of creator + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + writer_list TEXT[] NOT NULL DEFAULT '{}', -- denormalized content-writer client_ids + next_entry_no INT NOT NULL DEFAULT 1 -- per-vault entry-number allocator +); + +CREATE TABLE IF NOT EXISTS vault_entries ( + seq BIGSERIAL PRIMARY KEY, -- global write order (also the --since cursor) + vault_id TEXT NOT NULL REFERENCES vaults(vault_id), + entry_no INT NOT NULL, -- per-vault: 1,2,3 -> shown as e1, e2, e3 + version INT NOT NULL, -- per-entry: 1,2,3 -> shown as v1, v2, v3 + kind TEXT NOT NULL, -- 'content' | 'summary' + writer_id TEXT NOT NULL, -- client_id that wrote this row + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (vault_id, entry_no, version) +); + +CREATE INDEX IF NOT EXISTS vault_entries_by_vault_seq ON vault_entries (vault_id, seq); diff --git a/relay-server/src/auth.rs b/relay-server/src/auth.rs index 3187395..c9972b9 100644 --- a/relay-server/src/auth.rs +++ b/relay-server/src/auth.rs @@ -21,6 +21,10 @@ pub struct Session { pub token: Option, pub created_at: DateTime, pub expires_at: DateTime, + /// The astation_id this session is bound to (the "work session"). Set when + /// known (e.g. at grant / verification time). Used as vault work_session_id. + #[serde(default)] + pub astation_id: Option, } /// Generate an 8-digit numeric OTP. @@ -49,6 +53,7 @@ pub fn create_session(hostname: &str) -> Session { token: None, created_at: now, expires_at: now + Duration::minutes(5), + astation_id: None, } } @@ -151,6 +156,7 @@ mod tests { token: None, created_at: now - Duration::minutes(10), expires_at: now - Duration::minutes(5), // Already expired + astation_id: None, }; assert!( !validate_otp(&session, "12345678"), diff --git a/relay-server/src/llm_proxy.rs b/relay-server/src/llm_proxy.rs index 647b5d0..29b0768 100644 --- a/relay-server/src/llm_proxy.rs +++ b/relay-server/src/llm_proxy.rs @@ -244,6 +244,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), } } diff --git a/relay-server/src/main.rs b/relay-server/src/main.rs index d6e2a5d..e8c33ca 100644 --- a/relay-server/src/main.rs +++ b/relay-server/src/main.rs @@ -7,6 +7,8 @@ mod session_verify; mod voice_session; mod voice_routes; mod llm_proxy; +mod vault_store; +mod vault_routes; mod web; use axum::http::{header, HeaderValue, Method}; @@ -30,6 +32,7 @@ pub struct AppState { pub rtc_sessions: RtcSessionStore, pub session_verify_cache: SessionVerifyCache, pub voice_sessions: VoiceSessionStore, + pub vault: Arc, } #[tokio::main] @@ -49,6 +52,32 @@ async fn main() { let session_verify_cache = SessionVerifyCache::new(); let voice_sessions = VoiceSessionStore::new(); + // Vault store: Postgres when DATABASE_URL is set (the durable path), else an + // in-memory fallback so the rest of the server still runs without a DB. + let vault: Arc = match std::env::var("DATABASE_URL") { + Ok(url) if !url.is_empty() => { + tracing::info!("Connecting to Postgres for vault storage..."); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&url) + .await + .expect("Failed to connect to DATABASE_URL for vault storage"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run vault migrations"); + tracing::info!("Vault storage ready (Postgres)"); + Arc::new(vault_store::PgVaultStore::new(pool)) + } + _ => { + tracing::warn!( + "DATABASE_URL not set — vault storage is IN-MEMORY (not durable). \ + Set DATABASE_URL to enable persistent vaults." + ); + Arc::new(vault_store::InMemoryVaultStore::new()) + } + }; + // Spawn background cleanup for expired sessions let cleanup_sessions = sessions.clone(); tokio::spawn(async move { @@ -109,6 +138,7 @@ async fn main() { rtc_sessions, session_verify_cache, voice_sessions, + vault, }; // Configure CORS - Allow specific origin or default to localhost for development @@ -210,6 +240,19 @@ async fn main() { "/api/llm/chat", post(llm_proxy::llm_chat_handler), ) + // Vault API routes + .route( + "/api/vault", + post(vault_routes::create_vault_handler).get(vault_routes::list_vaults_handler), + ) + .route( + "/api/vault/:id", + get(vault_routes::read_vault_handler).post(vault_routes::write_vault_handler), + ) + .route( + "/api/vault/:id/summary", + post(vault_routes::set_summary_handler), + ) // Relay API routes .route("/api/pair", post(relay::create_pair_handler)) .route("/api/pair/:code", get(relay::pair_status_handler).delete(relay::delete_pair_handler)); diff --git a/relay-server/src/relay.rs b/relay-server/src/relay.rs index b9cadc7..a8d6dad 100644 --- a/relay-server/src/relay.rs +++ b/relay-server/src/relay.rs @@ -300,17 +300,32 @@ pub async fn ws_handler( } } - // Sanitize atem_id to URL-safe chars (hostname may contain spaces or special chars) - let atem_id = params.atem_id - .as_deref() - .map(|s| s.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.').collect::()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("atem-{:x}", rand::thread_rng().gen::())); + // Sanitize atem_id. atem percent-encodes ids that may contain non-ASCII + // (CJK hostnames), so decode first, then keep non-ASCII while restricting + // ASCII to [A-Za-z0-9-] (matches atem's own identity rule). + let atem_id = sanitize_atem_id(params.atem_id.as_deref()); ws.on_upgrade(move |socket| handle_ws(hub, code, role, atem_id, socket)) .into_response() } +/// Percent-decode an incoming atem_id and filter to a safe id, preserving +/// non-ASCII characters. Falls back to a random id when empty/missing. +fn sanitize_atem_id(raw: Option<&str>) -> String { + let decoded = urlencoding::decode(raw.unwrap_or("")) + .map(|c| c.into_owned()) + .unwrap_or_default(); + let filtered: String = decoded + .chars() + .filter(|c| !c.is_ascii() || c.is_ascii_alphanumeric() || *c == '-') + .collect(); + if filtered.is_empty() { + format!("atem-{:x}", rand::thread_rng().gen::()) + } else { + filtered + } +} + /// Message routing protocol for multi-Atem rooms: /// /// Atem → Astation: relay WRAPS `{"atem_id":"","payload":}` @@ -719,6 +734,27 @@ mod tests { assert!(unique.len() > 1, "Pairing codes should vary"); } + #[test] + fn sanitize_atem_id_preserves_decoded_cjk() { + // "团队-mac" percent-encoded; non-ASCII preserved, ASCII restricted to [A-Za-z0-9-]. + let encoded = "%E5%9B%A2%E9%98%9F-mac"; + assert_eq!(sanitize_atem_id(Some(encoded)), "团队-mac"); + } + + #[test] + fn sanitize_atem_id_strips_unsafe_ascii() { + assert_eq!(sanitize_atem_id(Some("a_b.c d!e")), "abcde"); + assert_eq!(sanitize_atem_id(Some("keep-this-123")), "keep-this-123"); + } + + #[test] + fn sanitize_atem_id_falls_back_when_empty() { + let id = sanitize_atem_id(None); + assert!(id.starts_with("atem-")); + let id2 = sanitize_atem_id(Some("")); + assert!(id2.starts_with("atem-")); + } + #[test] fn pairing_code_no_ambiguous_chars() { for _ in 0..100 { @@ -846,6 +882,7 @@ mod tests { rtc_sessions: crate::rtc_session::RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; Router::new() .route("/api/pair", axum::routing::post(create_pair_handler)) @@ -1219,6 +1256,7 @@ mod tests { rtc_sessions: crate::rtc_session::RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; // Create pair diff --git a/relay-server/src/routes.rs b/relay-server/src/routes.rs index 436aef5..fd53328 100644 --- a/relay-server/src/routes.rs +++ b/relay-server/src/routes.rs @@ -272,6 +272,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; Router::new() .route("/api/sessions", post(create_session_handler)) @@ -334,6 +335,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; let app = Router::new() .route("/api/sessions", post(create_session_handler)) @@ -434,6 +436,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; let app = Router::new() .route("/api/sessions", post(create_session_handler)) @@ -504,6 +507,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; let app = Router::new() .route("/api/sessions", post(create_session_handler)) @@ -592,6 +596,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; let session = create_session("my-machine"); let session_id = session.id.clone(); @@ -645,6 +650,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; let app = Router::new() .route("/api/sessions", post(create_session_handler)) @@ -710,6 +716,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; let app = Router::new() .route("/api/sessions", post(create_session_handler)) @@ -774,6 +781,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; let app = Router::new() .route("/api/sessions", post(create_session_handler)) @@ -882,6 +890,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; let app = Router::new() .route("/api/sessions", post(create_session_handler)) @@ -947,6 +956,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; // Create an expired session manually @@ -959,6 +969,7 @@ mod tests { token: None, created_at: now - Duration::minutes(10), expires_at: now - Duration::minutes(5), + astation_id: None, }; let session_id = expired_session.id.clone(); state.sessions.create(expired_session).await; diff --git a/relay-server/src/rtc_session.rs b/relay-server/src/rtc_session.rs index 927ac80..467f49d 100644 --- a/relay-server/src/rtc_session.rs +++ b/relay-server/src/rtc_session.rs @@ -516,6 +516,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; Router::new() .route("/api/rtc-sessions", post(create_rtc_session_handler)) @@ -832,6 +833,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; state .rtc_sessions @@ -887,6 +889,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; state .rtc_sessions @@ -951,6 +954,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; state .rtc_sessions @@ -1004,6 +1008,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; let app = Router::new() .route("/api/rtc-sessions", post(create_rtc_session_handler)) @@ -1128,6 +1133,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), }; state .rtc_sessions diff --git a/relay-server/src/session_store.rs b/relay-server/src/session_store.rs index 423c7f3..4fa4ed6 100644 --- a/relay-server/src/session_store.rs +++ b/relay-server/src/session_store.rs @@ -132,6 +132,7 @@ mod tests { token: None, created_at: now - Duration::minutes(10), expires_at: now - Duration::minutes(5), + astation_id: None, }; let expired_id = expired_session.id.clone(); store.create(expired_session).await; @@ -150,6 +151,7 @@ mod tests { token: Some("some-token".to_string()), created_at: now - Duration::minutes(10), expires_at: now - Duration::minutes(5), + astation_id: None, }; let granted_id = granted_session.id.clone(); store.create(granted_session).await; diff --git a/relay-server/src/session_verify.rs b/relay-server/src/session_verify.rs index 118eedb..1a73720 100644 --- a/relay-server/src/session_verify.rs +++ b/relay-server/src/session_verify.rs @@ -49,6 +49,18 @@ impl SessionVerifyCache { None } + /// Return the bound astation_id for a session if cached, valid, and not expired. + pub async fn get_astation_id(&self, session_id: &str) -> Option { + let cache = self.cache.read().await; + let cached = cache.get(session_id)?; + let age = now_timestamp().saturating_sub(cached.cached_at); + if cached.valid && age < cached.ttl_seconds { + Some(cached.astation_id.clone()) + } else { + None + } + } + /// Cache a session validation result. pub async fn set(&self, session_id: String, astation_id: String, valid: bool, ttl_seconds: u64) { let mut cache = self.cache.write().await; diff --git a/relay-server/src/vault_routes.rs b/relay-server/src/vault_routes.rs new file mode 100644 index 0000000..38527c2 --- /dev/null +++ b/relay-server/src/vault_routes.rs @@ -0,0 +1,492 @@ +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + Json, +}; +use serde::Deserialize; +use serde_json::json; + +use crate::auth::SessionStatus; +use crate::vault_store::VaultMeta; +use crate::AppState; + +type ErrResp = (StatusCode, Json); + +fn err(status: StatusCode, msg: &str) -> ErrResp { + (status, Json(json!({ "error": msg }))) +} + +/// The authenticated caller of a vault request. +struct Caller { + work_session_id: String, + client_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct VaultQuery { + /// atem instance_id — the authorization principal. + pub id: Option, + /// Incremental read cursor (read endpoint only). + pub since: Option, + /// Whether to return full history (read endpoint only). + pub history: Option, +} + +/// Validate the session + extract the client_id. Resolves work_session_id to +/// the astation_id the session is bound to (Option A). 401 on bad session, +/// 400 on missing client id. +async fn resolve_caller( + state: &AppState, + headers: &HeaderMap, + query: &VaultQuery, +) -> Result { + // Authorization: session + let auth = headers + .get("authorization") + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + let session_id = auth + .strip_prefix("session ") + .or_else(|| auth.strip_prefix("Session ")) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "missing session authorization"))?; + + let client_id = query + .id + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or_else(|| err(StatusCode::BAD_REQUEST, "missing ?id="))? + .to_string(); + + // Resolve the bound astation_id (work_session_id). Primary source: a granted + // session in the SessionStore carrying astation_id (Option A). Fallback: the + // cross-service verify cache (which maps session -> astation_id). + let work_session_id = match state.sessions.get(session_id).await { + Some(s) if s.status == SessionStatus::Granted => { + if let Some(aid) = s.astation_id { + Some(aid) + } else { + state.session_verify_cache.get_astation_id(session_id).await + } + } + Some(_) => None, // exists but not granted + None => state.session_verify_cache.get_astation_id(session_id).await, + } + .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "invalid or unbound session"))?; + + Ok(Caller { + work_session_id, + client_id, + }) +} + +fn can_read(meta: &VaultMeta, caller: &Caller) -> bool { + caller.work_session_id == meta.work_session_id + || meta.writer_list.iter().any(|w| w == &caller.client_id) +} + +fn can_write(meta: &VaultMeta, caller: &Caller) -> bool { + caller.work_session_id == meta.work_session_id +} + +async fn load_meta(state: &AppState, vault_id: &str) -> Result { + state + .vault + .get_meta(vault_id) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))? + .ok_or_else(|| err(StatusCode::NOT_FOUND, "vault not found")) +} + +// ─────────────────────────── Handlers ─────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct CreateVaultRequest { + #[serde(default)] + pub summary: String, +} + +/// POST /api/vault {summary} -> {vault_id} +pub async fn create_vault_handler( + State(state): State, + headers: HeaderMap, + Query(query): Query, + Json(body): Json, +) -> Result, ErrResp> { + let caller = resolve_caller(&state, &headers, &query).await?; + let vault_id = state + .vault + .create_vault(&caller.work_session_id, &caller.client_id, &body.summary) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))?; + Ok(Json(json!({ "vault_id": vault_id }))) +} + +/// GET /api/vault -> [{vault_id, summary}] +pub async fn list_vaults_handler( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, ErrResp> { + let caller = resolve_caller(&state, &headers, &query).await?; + let items = state + .vault + .list_readable(&caller.work_session_id, &caller.client_id) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))?; + Ok(Json(serde_json::to_value(items).unwrap())) +} + +/// GET /api/vault/:id [?since&history] -> [VaultEntry] +pub async fn read_vault_handler( + State(state): State, + headers: HeaderMap, + Path(vault_id): Path, + Query(query): Query, +) -> Result, ErrResp> { + let caller = resolve_caller(&state, &headers, &query).await?; + let meta = load_meta(&state, &vault_id).await?; + if !can_read(&meta, &caller) { + return Err(err(StatusCode::FORBIDDEN, "not authorized to read this vault")); + } + let entries = state + .vault + .read(&vault_id, query.since, query.history.unwrap_or(false)) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))?; + Ok(Json(serde_json::to_value(entries).unwrap())) +} + +#[derive(Debug, Deserialize)] +pub struct WriteVaultRequest { + pub text: String, + #[serde(default)] + pub entry_id: Option, +} + +/// POST /api/vault/:id {text, entry_id?} -> {entry_no, version, seq} +pub async fn write_vault_handler( + State(state): State, + headers: HeaderMap, + Path(vault_id): Path, + Query(query): Query, + Json(body): Json, +) -> Result, ErrResp> { + let caller = resolve_caller(&state, &headers, &query).await?; + let meta = load_meta(&state, &vault_id).await?; + if !can_write(&meta, &caller) { + return Err(err(StatusCode::FORBIDDEN, "not authorized to write this vault")); + } + + let result = if let Some(entry_no) = body.entry_id { + state + .vault + .override_entry(&vault_id, entry_no, &caller.client_id, &body.text) + .await + } else { + state + .vault + .append(&vault_id, &caller.client_id, &body.text) + .await + } + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))?; + + // Record the content writer (dedup). + state + .vault + .add_writer(&vault_id, &caller.client_id) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))?; + + Ok(Json(json!({ + "entry_no": result.entry_no, + "version": result.version, + "seq": result.seq, + }))) +} + +#[derive(Debug, Deserialize)] +pub struct SetSummaryRequest { + pub text: String, +} + +/// POST /api/vault/:id/summary {text} -> {} +pub async fn set_summary_handler( + State(state): State, + headers: HeaderMap, + Path(vault_id): Path, + Query(query): Query, + Json(body): Json, +) -> Result, ErrResp> { + let caller = resolve_caller(&state, &headers, &query).await?; + let meta = load_meta(&state, &vault_id).await?; + // set-summary uses the read predicate (mutable, low-stakes). + if !can_read(&meta, &caller) { + return Err(err(StatusCode::FORBIDDEN, "not authorized for this vault")); + } + state + .vault + .set_summary(&vault_id, &body.text) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))?; + Ok(Json(json!({}))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::{create_session, SessionStatus}; + use crate::relay::RelayHub; + use crate::rtc_session::RtcSessionStore; + use crate::session_store::SessionStore; + use crate::session_verify::SessionVerifyCache; + use crate::vault_store::InMemoryVaultStore; + use crate::voice_session::VoiceSessionStore; + use axum::body::Body; + use axum::http::Request; + use axum::routing::{get, post}; + use axum::Router; + use std::sync::Arc; + use tower::ServiceExt; + + /// Build an AppState with an in-memory vault and a granted session bound to + /// `astation_id`. Returns (state, session_id). + async fn test_state(astation_id: &str) -> (AppState, String) { + let sessions = SessionStore::new(); + let mut session = create_session("test-host"); + session.status = SessionStatus::Granted; + session.astation_id = Some(astation_id.to_string()); + let session_id = session.id.clone(); + sessions.create(session).await; + + let state = AppState { + sessions, + relay: RelayHub::new(), + rtc_sessions: RtcSessionStore::new(), + session_verify_cache: SessionVerifyCache::new(), + voice_sessions: VoiceSessionStore::new(), + vault: Arc::new(InMemoryVaultStore::new()), + }; + (state, session_id) + } + + fn app(state: AppState) -> Router { + Router::new() + .route("/api/vault", post(create_vault_handler).get(list_vaults_handler)) + .route("/api/vault/:id", get(read_vault_handler).post(write_vault_handler)) + .route("/api/vault/:id/summary", post(set_summary_handler)) + .with_state(state) + } + + async fn body_json(resp: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) + } + + fn req(method: &str, uri: &str, session: &str, body: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header("authorization", format!("session {}", session)) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap() + } + + #[tokio::test] + async fn create_then_read_roundtrip() { + let (state, sess) = test_state("ws-1").await; + let app = app(state); + + let resp = app.clone() + .oneshot(req("POST", "/api/vault?id=client-a", &sess, r#"{"summary":"auth refactor"}"#)) + .await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let vault_id = body_json(resp).await["vault_id"].as_str().unwrap().to_string(); + + // Write an entry. + let resp = app.clone() + .oneshot(req("POST", &format!("/api/vault/{}?id=client-a", vault_id), &sess, r#"{"text":"decided: JWT in cookie"}"#)) + .await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let w = body_json(resp).await; + assert_eq!(w["entry_no"], 1); + assert_eq!(w["version"], 1); + + // Read current view. + let resp = app.clone() + .oneshot(req("GET", &format!("/api/vault/{}?id=client-a", vault_id), &sess, "")) + .await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let entries = body_json(resp).await; + assert_eq!(entries.as_array().unwrap().len(), 1); + assert_eq!(entries[0]["content"], "decided: JWT in cookie"); + } + + #[tokio::test] + async fn append_then_override_history() { + let (state, sess) = test_state("ws-1").await; + let app = app(state); + let vault_id = { + let resp = app.clone() + .oneshot(req("POST", "/api/vault?id=a", &sess, r#"{}"#)).await.unwrap(); + body_json(resp).await["vault_id"].as_str().unwrap().to_string() + }; + + app.clone().oneshot(req("POST", &format!("/api/vault/{}?id=a", vault_id), &sess, r#"{"text":"v1"}"#)).await.unwrap(); + app.clone().oneshot(req("POST", &format!("/api/vault/{}?id=a", vault_id), &sess, r#"{"text":"v2","entry_id":1}"#)).await.unwrap(); + + // Current view: only v2. + let current = body_json(app.clone().oneshot(req("GET", &format!("/api/vault/{}?id=a", vault_id), &sess, "")).await.unwrap()).await; + assert_eq!(current.as_array().unwrap().len(), 1); + assert_eq!(current[0]["version"], 2); + + // History: v1 + v2. + let history = body_json(app.clone().oneshot(req("GET", &format!("/api/vault/{}?id=a&history=true", vault_id), &sess, "")).await.unwrap()).await; + assert_eq!(history.as_array().unwrap().len(), 2); + } + + #[tokio::test] + async fn since_filters() { + let (state, sess) = test_state("ws-1").await; + let app = app(state); + let vault_id = body_json(app.clone().oneshot(req("POST", "/api/vault?id=a", &sess, r#"{}"#)).await.unwrap()).await["vault_id"].as_str().unwrap().to_string(); + let w1 = body_json(app.clone().oneshot(req("POST", &format!("/api/vault/{}?id=a", vault_id), &sess, r#"{"text":"first"}"#)).await.unwrap()).await; + app.clone().oneshot(req("POST", &format!("/api/vault/{}?id=a", vault_id), &sess, r#"{"text":"second"}"#)).await.unwrap(); + let seq1 = w1["seq"].as_i64().unwrap(); + + let after = body_json(app.clone().oneshot(req("GET", &format!("/api/vault/{}?id=a&history=true&since={}", vault_id, seq1), &sess, "")).await.unwrap()).await; + assert_eq!(after.as_array().unwrap().len(), 1); + assert_eq!(after[0]["content"], "second"); + } + + #[tokio::test] + async fn authz_out_of_session_past_writer_read_only() { + // Vault created in ws-1 by client-a, who becomes a writer. + let (state, sess1) = test_state("ws-1").await; + // Add a second granted session bound to a different work session ws-2. + let mut s2 = create_session("host2"); + s2.status = SessionStatus::Granted; + s2.astation_id = Some("ws-2".to_string()); + let sess2 = s2.id.clone(); + state.sessions.create(s2).await; + + let app = app(state); + let vault_id = body_json(app.clone().oneshot(req("POST", "/api/vault?id=client-a", &sess1, r#"{}"#)).await.unwrap()).await["vault_id"].as_str().unwrap().to_string(); + // client-a writes → becomes a past writer. + app.clone().oneshot(req("POST", &format!("/api/vault/{}?id=client-a", vault_id), &sess1, r#"{"text":"x"}"#)).await.unwrap(); + + // Out-of-session (ws-2) but past-writer client-a: read OK. + let read = app.clone().oneshot(req("GET", &format!("/api/vault/{}?id=client-a", vault_id), &sess2, "")).await.unwrap(); + assert_eq!(read.status(), StatusCode::OK); + + // Out-of-session past-writer: write FORBIDDEN. + let write = app.clone().oneshot(req("POST", &format!("/api/vault/{}?id=client-a", vault_id), &sess2, r#"{"text":"y"}"#)).await.unwrap(); + assert_eq!(write.status(), StatusCode::FORBIDDEN); + + // Stranger (ws-2, not a writer): read FORBIDDEN. + let stranger = app.clone().oneshot(req("GET", &format!("/api/vault/{}?id=stranger", vault_id), &sess2, "")).await.unwrap(); + assert_eq!(stranger.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn missing_session_is_401() { + let (state, _sess) = test_state("ws-1").await; + let app = app(state); + let resp = app.oneshot( + Request::builder() + .method("POST") + .uri("/api/vault?id=a") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn set_summary_ok() { + let (state, sess) = test_state("ws-1").await; + let app = app(state); + let vault_id = body_json(app.clone().oneshot(req("POST", "/api/vault?id=a", &sess, r#"{"summary":"old"}"#)).await.unwrap()).await["vault_id"].as_str().unwrap().to_string(); + let resp = app.clone().oneshot(req("POST", &format!("/api/vault/{}/summary?id=a", vault_id), &sess, r#"{"text":"new summary"}"#)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let list = body_json(app.clone().oneshot(req("GET", "/api/vault?id=a", &sess, "")).await.unwrap()).await; + assert_eq!(list[0]["summary"], "new summary"); + } + + #[tokio::test] + async fn read_missing_vault_is_404() { + let (state, sess) = test_state("ws-1").await; + let app = app(state); + let resp = app.oneshot(req("GET", "/api/vault/v-doesnotexist?id=a", &sess, "")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn missing_client_id_is_400() { + let (state, sess) = test_state("ws-1").await; + let app = app(state); + // No ?id= query param. + let resp = app.oneshot(req("POST", "/api/vault", &sess, r#"{"summary":"x"}"#)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn ungranted_session_is_401() { + // A session that exists but is not granted must not authenticate. + let sessions = SessionStore::new(); + let session = create_session("host"); // status defaults to Pending + let sess = session.id.clone(); + sessions.create(session).await; + let state = AppState { + sessions, + relay: RelayHub::new(), + rtc_sessions: RtcSessionStore::new(), + session_verify_cache: SessionVerifyCache::new(), + voice_sessions: VoiceSessionStore::new(), + vault: Arc::new(InMemoryVaultStore::new()), + }; + let app = app(state); + let resp = app.oneshot(req("POST", "/api/vault?id=a", &sess, r#"{}"#)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn list_is_isolated_by_work_session() { + let (state, sess1) = test_state("ws-1").await; + // Second granted session in ws-2. + let mut s2 = create_session("h2"); + s2.status = SessionStatus::Granted; + s2.astation_id = Some("ws-2".to_string()); + let sess2 = s2.id.clone(); + state.sessions.create(s2).await; + let app = app(state); + + // Create one vault in each work session. + app.clone().oneshot(req("POST", "/api/vault?id=a", &sess1, r#"{"summary":"in-ws1"}"#)).await.unwrap(); + app.clone().oneshot(req("POST", "/api/vault?id=b", &sess2, r#"{"summary":"in-ws2"}"#)).await.unwrap(); + + let list1 = body_json(app.clone().oneshot(req("GET", "/api/vault?id=a", &sess1, "")).await.unwrap()).await; + assert_eq!(list1.as_array().unwrap().len(), 1); + assert_eq!(list1[0]["summary"], "in-ws1"); + + let list2 = body_json(app.clone().oneshot(req("GET", "/api/vault?id=b", &sess2, "")).await.unwrap()).await; + assert_eq!(list2.as_array().unwrap().len(), 1); + assert_eq!(list2[0]["summary"], "in-ws2"); + } + + #[tokio::test] + async fn append_returns_incrementing_entry_nos_over_http() { + let (state, sess) = test_state("ws-1").await; + let app = app(state); + let vault_id = body_json(app.clone().oneshot(req("POST", "/api/vault?id=a", &sess, r#"{}"#)).await.unwrap()).await["vault_id"].as_str().unwrap().to_string(); + + let w1 = body_json(app.clone().oneshot(req("POST", &format!("/api/vault/{}?id=a", vault_id), &sess, r#"{"text":"one"}"#)).await.unwrap()).await; + let w2 = body_json(app.clone().oneshot(req("POST", &format!("/api/vault/{}?id=a", vault_id), &sess, r#"{"text":"two"}"#)).await.unwrap()).await; + assert_eq!(w1["entry_no"], 1); + assert_eq!(w2["entry_no"], 2); + } +} diff --git a/relay-server/src/vault_store.rs b/relay-server/src/vault_store.rs new file mode 100644 index 0000000..62df52a --- /dev/null +++ b/relay-server/src/vault_store.rs @@ -0,0 +1,736 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use rand::Rng; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// One row in a vault. Field names/types must match the atem client +/// (`Atem/src/vault_client.rs::VaultEntry`) exactly. +#[derive(Debug, Clone, Serialize)] +pub struct VaultEntry { + pub seq: i64, + pub entry_no: i32, + pub version: i32, + pub kind: String, + pub writer_id: String, + pub content: String, + pub created_at: DateTime, +} + +/// `POST /api/vault` response. +#[derive(Debug, Clone, Serialize)] +pub struct CreatedVault { + pub vault_id: String, +} + +/// `GET /api/vault` list item. +#[derive(Debug, Clone, Serialize)] +pub struct VaultListItem { + pub vault_id: String, + pub summary: String, +} + +/// Append/override write result. +#[derive(Debug, Clone, Serialize)] +pub struct WriteResult { + pub entry_no: i32, + pub version: i32, + pub seq: i64, +} + +/// Per-vault metadata used for authorization checks. +#[derive(Debug, Clone)] +pub struct VaultMeta { + pub work_session_id: String, + pub writer_list: Vec, +} + +#[derive(Debug)] +pub enum VaultError { + NotFound, + Db(String), +} + +impl std::fmt::Display for VaultError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VaultError::NotFound => write!(f, "vault not found"), + VaultError::Db(s) => write!(f, "database error: {}", s), + } + } +} + +/// Generate a short, URL-safe, non-sequential vault id: `v-` + 8 base62 chars. +pub fn generate_vault_id() -> String { + const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let mut rng = rand::thread_rng(); + let suffix: String = (0..8) + .map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char) + .collect(); + format!("v-{}", suffix) +} + +/// Storage abstraction for vaults. The handlers depend on this trait so tests +/// can use an in-memory implementation and production uses Postgres. +#[async_trait] +pub trait VaultStore: Send + Sync { + async fn create_vault( + &self, + work_session_id: &str, + created_by: &str, + summary: &str, + ) -> Result; + + async fn list_readable( + &self, + work_session_id: &str, + client_id: &str, + ) -> Result, VaultError>; + + async fn read( + &self, + vault_id: &str, + since: Option, + history: bool, + ) -> Result, VaultError>; + + /// Append a new content entry (version = 1, fresh entry_no). + async fn append( + &self, + vault_id: &str, + writer_id: &str, + text: &str, + ) -> Result; + + /// Override an existing entry_no with a new version (max version + 1). + async fn override_entry( + &self, + vault_id: &str, + entry_no: i32, + writer_id: &str, + text: &str, + ) -> Result; + + async fn set_summary(&self, vault_id: &str, text: &str) -> Result<(), VaultError>; + + async fn get_meta(&self, vault_id: &str) -> Result, VaultError>; + + async fn add_writer(&self, vault_id: &str, client_id: &str) -> Result<(), VaultError>; +} + +// ─────────────────────────── In-memory implementation ─────────────────────────── + +#[derive(Default)] +struct VaultRow { + summary: String, + work_session_id: String, + #[allow(dead_code)] + created_by: String, + writer_list: Vec, + next_entry_no: i32, + entries: Vec, +} + +/// In-memory vault store for tests and DB-less runs. Mirrors the existing +/// `RwLock>` style used by the other relay stores. +#[derive(Clone)] +pub struct InMemoryVaultStore { + vaults: Arc>>, + seq: Arc, +} + +impl InMemoryVaultStore { + pub fn new() -> Self { + Self { + vaults: Arc::new(RwLock::new(HashMap::new())), + seq: Arc::new(AtomicI64::new(0)), + } + } + + fn next_seq(&self) -> i64 { + self.seq.fetch_add(1, Ordering::SeqCst) + 1 + } +} + +impl Default for InMemoryVaultStore { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl VaultStore for InMemoryVaultStore { + async fn create_vault( + &self, + work_session_id: &str, + created_by: &str, + summary: &str, + ) -> Result { + let vault_id = generate_vault_id(); + let mut vaults = self.vaults.write().await; + vaults.insert( + vault_id.clone(), + VaultRow { + summary: summary.to_string(), + work_session_id: work_session_id.to_string(), + created_by: created_by.to_string(), + writer_list: Vec::new(), + next_entry_no: 1, + entries: Vec::new(), + }, + ); + Ok(vault_id) + } + + async fn list_readable( + &self, + work_session_id: &str, + client_id: &str, + ) -> Result, VaultError> { + let vaults = self.vaults.read().await; + let mut out: Vec<(i64, VaultListItem)> = vaults + .iter() + .filter(|(_, v)| { + v.work_session_id == work_session_id + || v.writer_list.iter().any(|w| w == client_id) + }) + .map(|(id, v)| { + let first_seq = v.entries.first().map(|e| e.seq).unwrap_or(i64::MAX); + ( + first_seq, + VaultListItem { + vault_id: id.clone(), + summary: v.summary.clone(), + }, + ) + }) + .collect(); + out.sort_by_key(|(s, _)| *s); + Ok(out.into_iter().map(|(_, item)| item).collect()) + } + + async fn read( + &self, + vault_id: &str, + since: Option, + history: bool, + ) -> Result, VaultError> { + let vaults = self.vaults.read().await; + let row = vaults.get(vault_id).ok_or(VaultError::NotFound)?; + let since = since.unwrap_or(0); + + if history { + // Every row, ordered by seq, filtered by since. + let mut entries: Vec = row + .entries + .iter() + .filter(|e| e.seq > since) + .cloned() + .collect(); + entries.sort_by_key(|e| e.seq); + Ok(entries) + } else { + // Current view: highest version per entry_no, ordered by entry_no. + let mut latest: HashMap = HashMap::new(); + for e in row.entries.iter().filter(|e| e.seq > since) { + match latest.get(&e.entry_no) { + Some(existing) if existing.version >= e.version => {} + _ => { + latest.insert(e.entry_no, e.clone()); + } + } + } + let mut entries: Vec = latest.into_values().collect(); + entries.sort_by_key(|e| e.entry_no); + Ok(entries) + } + } + + async fn append( + &self, + vault_id: &str, + writer_id: &str, + text: &str, + ) -> Result { + let seq = self.next_seq(); + let mut vaults = self.vaults.write().await; + let row = vaults.get_mut(vault_id).ok_or(VaultError::NotFound)?; + let entry_no = row.next_entry_no; + row.next_entry_no += 1; + let entry = VaultEntry { + seq, + entry_no, + version: 1, + kind: "content".to_string(), + writer_id: writer_id.to_string(), + content: text.to_string(), + created_at: Utc::now(), + }; + row.entries.push(entry); + Ok(WriteResult { + entry_no, + version: 1, + seq, + }) + } + + async fn override_entry( + &self, + vault_id: &str, + entry_no: i32, + writer_id: &str, + text: &str, + ) -> Result { + let seq = self.next_seq(); + let mut vaults = self.vaults.write().await; + let row = vaults.get_mut(vault_id).ok_or(VaultError::NotFound)?; + let max_version = row + .entries + .iter() + .filter(|e| e.entry_no == entry_no) + .map(|e| e.version) + .max() + .unwrap_or(0); + let version = max_version + 1; + let entry = VaultEntry { + seq, + entry_no, + version, + kind: "content".to_string(), + writer_id: writer_id.to_string(), + content: text.to_string(), + created_at: Utc::now(), + }; + row.entries.push(entry); + Ok(WriteResult { + entry_no, + version, + seq, + }) + } + + async fn set_summary(&self, vault_id: &str, text: &str) -> Result<(), VaultError> { + let mut vaults = self.vaults.write().await; + let row = vaults.get_mut(vault_id).ok_or(VaultError::NotFound)?; + row.summary = text.to_string(); + Ok(()) + } + + async fn get_meta(&self, vault_id: &str) -> Result, VaultError> { + let vaults = self.vaults.read().await; + Ok(vaults.get(vault_id).map(|v| VaultMeta { + work_session_id: v.work_session_id.clone(), + writer_list: v.writer_list.clone(), + })) + } + + async fn add_writer(&self, vault_id: &str, client_id: &str) -> Result<(), VaultError> { + let mut vaults = self.vaults.write().await; + let row = vaults.get_mut(vault_id).ok_or(VaultError::NotFound)?; + if !row.writer_list.iter().any(|w| w == client_id) { + row.writer_list.push(client_id.to_string()); + } + Ok(()) + } +} + +// ─────────────────────────── Postgres implementation ─────────────────────────── + +/// Postgres-backed vault store. Uses runtime-checked queries (no compile-time +/// DB needed). Append/override run inside a transaction so concurrent writers +/// can't collide on (vault_id, entry_no, version). +#[derive(Clone)] +pub struct PgVaultStore { + pool: sqlx::PgPool, +} + +impl PgVaultStore { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { pool } + } +} + +fn db_err(e: sqlx::Error) -> VaultError { + VaultError::Db(e.to_string()) +} + +#[async_trait] +impl VaultStore for PgVaultStore { + async fn create_vault( + &self, + work_session_id: &str, + created_by: &str, + summary: &str, + ) -> Result { + let vault_id = generate_vault_id(); + sqlx::query( + "INSERT INTO vaults (vault_id, summary, work_session_id, created_by) \ + VALUES ($1, $2, $3, $4)", + ) + .bind(&vault_id) + .bind(summary) + .bind(work_session_id) + .bind(created_by) + .execute(&self.pool) + .await + .map_err(db_err)?; + Ok(vault_id) + } + + async fn list_readable( + &self, + work_session_id: &str, + client_id: &str, + ) -> Result, VaultError> { + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT vault_id, summary FROM vaults \ + WHERE work_session_id = $1 OR $2 = ANY(writer_list) \ + ORDER BY created_at ASC", + ) + .bind(work_session_id) + .bind(client_id) + .fetch_all(&self.pool) + .await + .map_err(db_err)?; + Ok(rows + .into_iter() + .map(|(vault_id, summary)| VaultListItem { vault_id, summary }) + .collect()) + } + + async fn read( + &self, + vault_id: &str, + since: Option, + history: bool, + ) -> Result, VaultError> { + let since = since.unwrap_or(0); + let sql = if history { + "SELECT seq, entry_no, version, kind, writer_id, content, created_at \ + FROM vault_entries WHERE vault_id = $1 AND seq > $2 ORDER BY seq ASC" + } else { + "SELECT DISTINCT ON (entry_no) seq, entry_no, version, kind, writer_id, content, created_at \ + FROM vault_entries WHERE vault_id = $1 AND seq > $2 \ + ORDER BY entry_no ASC, version DESC" + }; + let rows: Vec = sqlx::query_as(sql) + .bind(vault_id) + .bind(since) + .fetch_all(&self.pool) + .await + .map_err(db_err)?; + Ok(rows.into_iter().map(Into::into).collect()) + } + + async fn append( + &self, + vault_id: &str, + writer_id: &str, + text: &str, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(db_err)?; + // Allocate entry_no atomically. + let entry_no: i32 = sqlx::query_scalar( + "UPDATE vaults SET next_entry_no = next_entry_no + 1 \ + WHERE vault_id = $1 RETURNING next_entry_no - 1", + ) + .bind(vault_id) + .fetch_optional(&mut *tx) + .await + .map_err(db_err)? + .ok_or(VaultError::NotFound)?; + + let seq: i64 = sqlx::query_scalar( + "INSERT INTO vault_entries (vault_id, entry_no, version, kind, writer_id, content) \ + VALUES ($1, $2, 1, 'content', $3, $4) RETURNING seq", + ) + .bind(vault_id) + .bind(entry_no) + .bind(writer_id) + .bind(text) + .fetch_one(&mut *tx) + .await + .map_err(db_err)?; + + tx.commit().await.map_err(db_err)?; + Ok(WriteResult { + entry_no, + version: 1, + seq, + }) + } + + async fn override_entry( + &self, + vault_id: &str, + entry_no: i32, + writer_id: &str, + text: &str, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(db_err)?; + let max_version: Option = sqlx::query_scalar( + "SELECT MAX(version) FROM vault_entries WHERE vault_id = $1 AND entry_no = $2", + ) + .bind(vault_id) + .bind(entry_no) + .fetch_one(&mut *tx) + .await + .map_err(db_err)?; + let version = max_version.unwrap_or(0) + 1; + + let seq: i64 = sqlx::query_scalar( + "INSERT INTO vault_entries (vault_id, entry_no, version, kind, writer_id, content) \ + VALUES ($1, $2, $3, 'content', $4, $5) RETURNING seq", + ) + .bind(vault_id) + .bind(entry_no) + .bind(version) + .bind(writer_id) + .bind(text) + .fetch_one(&mut *tx) + .await + .map_err(db_err)?; + + tx.commit().await.map_err(db_err)?; + Ok(WriteResult { + entry_no, + version, + seq, + }) + } + + async fn set_summary(&self, vault_id: &str, text: &str) -> Result<(), VaultError> { + let res = sqlx::query("UPDATE vaults SET summary = $1 WHERE vault_id = $2") + .bind(text) + .bind(vault_id) + .execute(&self.pool) + .await + .map_err(db_err)?; + if res.rows_affected() == 0 { + return Err(VaultError::NotFound); + } + Ok(()) + } + + async fn get_meta(&self, vault_id: &str) -> Result, VaultError> { + let row: Option<(String, Vec)> = sqlx::query_as( + "SELECT work_session_id, writer_list FROM vaults WHERE vault_id = $1", + ) + .bind(vault_id) + .fetch_optional(&self.pool) + .await + .map_err(db_err)?; + Ok(row.map(|(work_session_id, writer_list)| VaultMeta { + work_session_id, + writer_list, + })) + } + + async fn add_writer(&self, vault_id: &str, client_id: &str) -> Result<(), VaultError> { + // array_append only if not already present (dedup). + sqlx::query( + "UPDATE vaults SET writer_list = array_append(writer_list, $2) \ + WHERE vault_id = $1 AND NOT ($2 = ANY(writer_list))", + ) + .bind(vault_id) + .bind(client_id) + .execute(&self.pool) + .await + .map_err(db_err)?; + Ok(()) + } +} + +/// Row shape for sqlx FromRow mapping of vault_entries. +#[derive(sqlx::FromRow)] +struct VaultEntryRow { + seq: i64, + entry_no: i32, + version: i32, + kind: String, + writer_id: String, + content: String, + created_at: DateTime, +} + +impl From for VaultEntry { + fn from(r: VaultEntryRow) -> Self { + VaultEntry { + seq: r.seq, + entry_no: r.entry_no, + version: r.version, + kind: r.kind, + writer_id: r.writer_id, + content: r.content, + created_at: r.created_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn append_then_read_current_and_history() { + let store = InMemoryVaultStore::new(); + let id = store.create_vault("ws-1", "client-a", "auth refactor").await.unwrap(); + + let w1 = store.append(&id, "client-a", "decided: JWT in cookie").await.unwrap(); + assert_eq!(w1.entry_no, 1); + assert_eq!(w1.version, 1); + + // Override entry 1 → version 2. + let w2 = store.override_entry(&id, 1, "client-a", "JWT, 15m exp").await.unwrap(); + assert_eq!(w2.entry_no, 1); + assert_eq!(w2.version, 2); + + // Current view: only v2. + let current = store.read(&id, None, false).await.unwrap(); + assert_eq!(current.len(), 1); + assert_eq!(current[0].version, 2); + assert_eq!(current[0].content, "JWT, 15m exp"); + + // History: both v1 and v2. + let history = store.read(&id, None, true).await.unwrap(); + assert_eq!(history.len(), 2); + assert_eq!(history[0].version, 1); + assert_eq!(history[1].version, 2); + } + + #[tokio::test] + async fn since_filters_by_seq() { + let store = InMemoryVaultStore::new(); + let id = store.create_vault("ws-1", "a", "").await.unwrap(); + let w1 = store.append(&id, "a", "first").await.unwrap(); + let _w2 = store.append(&id, "a", "second").await.unwrap(); + + let after_first = store.read(&id, Some(w1.seq), true).await.unwrap(); + assert_eq!(after_first.len(), 1); + assert_eq!(after_first[0].content, "second"); + } + + #[tokio::test] + async fn writer_list_dedups() { + let store = InMemoryVaultStore::new(); + let id = store.create_vault("ws-1", "a", "").await.unwrap(); + store.add_writer(&id, "client-x").await.unwrap(); + store.add_writer(&id, "client-x").await.unwrap(); + let meta = store.get_meta(&id).await.unwrap().unwrap(); + assert_eq!(meta.writer_list, vec!["client-x".to_string()]); + assert_eq!(meta.work_session_id, "ws-1"); + } + + #[tokio::test] + async fn list_readable_in_session_and_past_writer() { + let store = InMemoryVaultStore::new(); + let id = store.create_vault("ws-1", "a", "vault one").await.unwrap(); + store.add_writer(&id, "past-writer").await.unwrap(); + + // In-session caller sees it. + let in_session = store.list_readable("ws-1", "anyone").await.unwrap(); + assert_eq!(in_session.len(), 1); + + // Out-of-session past writer sees it. + let past = store.list_readable("ws-other", "past-writer").await.unwrap(); + assert_eq!(past.len(), 1); + + // Out-of-session stranger does not. + let stranger = store.list_readable("ws-other", "stranger").await.unwrap(); + assert_eq!(stranger.len(), 0); + } + + #[tokio::test] + async fn multiple_appends_increment_entry_no_and_seq() { + let store = InMemoryVaultStore::new(); + let id = store.create_vault("ws-1", "a", "").await.unwrap(); + let w1 = store.append(&id, "a", "one").await.unwrap(); + let w2 = store.append(&id, "a", "two").await.unwrap(); + let w3 = store.append(&id, "a", "three").await.unwrap(); + assert_eq!((w1.entry_no, w2.entry_no, w3.entry_no), (1, 2, 3)); + assert!(w1.seq < w2.seq && w2.seq < w3.seq); + // All version 1 (distinct entries). + assert_eq!((w1.version, w2.version, w3.version), (1, 1, 1)); + + let current = store.read(&id, None, false).await.unwrap(); + assert_eq!(current.len(), 3); + } + + #[tokio::test] + async fn overrides_are_isolated_per_entry_no() { + let store = InMemoryVaultStore::new(); + let id = store.create_vault("ws-1", "a", "").await.unwrap(); + store.append(&id, "a", "e1v1").await.unwrap(); // entry 1 + store.append(&id, "a", "e2v1").await.unwrap(); // entry 2 + let o1 = store.override_entry(&id, 1, "a", "e1v2").await.unwrap(); + let o2 = store.override_entry(&id, 1, "a", "e1v3").await.unwrap(); + assert_eq!(o1.version, 2); + assert_eq!(o2.version, 3); + + let current = store.read(&id, None, false).await.unwrap(); + assert_eq!(current.len(), 2, "still two entries"); + // entry 1 shows v3, entry 2 untouched at v1. + assert_eq!(current[0].entry_no, 1); + assert_eq!(current[0].version, 3); + assert_eq!(current[0].content, "e1v3"); + assert_eq!(current[1].entry_no, 2); + assert_eq!(current[1].version, 1); + } + + #[tokio::test] + async fn override_unknown_entry_starts_at_version_1() { + let store = InMemoryVaultStore::new(); + let id = store.create_vault("ws-1", "a", "").await.unwrap(); + // Override an entry_no that was never appended. + let w = store.override_entry(&id, 7, "a", "ghost").await.unwrap(); + assert_eq!(w.entry_no, 7); + assert_eq!(w.version, 1); + } + + #[tokio::test] + async fn operations_on_missing_vault_return_not_found() { + let store = InMemoryVaultStore::new(); + assert!(matches!(store.read("nope", None, false).await, Err(VaultError::NotFound))); + assert!(matches!(store.append("nope", "a", "x").await, Err(VaultError::NotFound))); + assert!(matches!(store.override_entry("nope", 1, "a", "x").await, Err(VaultError::NotFound))); + assert!(matches!(store.set_summary("nope", "x").await, Err(VaultError::NotFound))); + assert!(matches!(store.add_writer("nope", "a").await, Err(VaultError::NotFound))); + assert!(store.get_meta("nope").await.unwrap().is_none()); + } + + #[tokio::test] + async fn vaults_are_isolated_from_each_other() { + let store = InMemoryVaultStore::new(); + let a = store.create_vault("ws-1", "x", "A").await.unwrap(); + let b = store.create_vault("ws-1", "x", "B").await.unwrap(); + assert_ne!(a, b); + store.append(&a, "x", "in-a").await.unwrap(); + + let a_entries = store.read(&a, None, true).await.unwrap(); + let b_entries = store.read(&b, None, true).await.unwrap(); + assert_eq!(a_entries.len(), 1); + assert_eq!(b_entries.len(), 0, "write to A must not leak into B"); + } + + #[tokio::test] + async fn set_summary_updates_list_view() { + let store = InMemoryVaultStore::new(); + let id = store.create_vault("ws-1", "a", "old").await.unwrap(); + store.set_summary(&id, "new").await.unwrap(); + let items = store.list_readable("ws-1", "a").await.unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].summary, "new"); + } + + #[test] + fn vault_id_is_prefixed_and_unique() { + let a = generate_vault_id(); + let b = generate_vault_id(); + assert!(a.starts_with("v-")); + assert_eq!(a.len(), 10, "v- + 8 chars"); + assert_ne!(a, b); + } +} diff --git a/relay-server/src/voice_routes.rs b/relay-server/src/voice_routes.rs index 1ee1423..0b2e63a 100644 --- a/relay-server/src/voice_routes.rs +++ b/relay-server/src/voice_routes.rs @@ -158,6 +158,7 @@ mod tests { rtc_sessions: RtcSessionStore::new(), session_verify_cache: SessionVerifyCache::new(), voice_sessions: VoiceSessionStore::new(), + vault: std::sync::Arc::new(crate::vault_store::InMemoryVaultStore::new()), } }