diff --git a/Cargo.lock b/Cargo.lock index 52b10fc..c2ba21c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,6 +142,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -436,6 +459,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -445,6 +470,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.9.1" @@ -545,6 +576,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cmov" version = "0.5.4" @@ -557,12 +597,32 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[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" @@ -692,6 +752,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -838,6 +904,12 @@ dependencies = [ "num", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.33" @@ -963,9 +1035,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1126,6 +1200,21 @@ dependencies = [ "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", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1276,6 +1365,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1332,6 +1440,65 @@ 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 2.0.119", +] + +[[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 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -1364,7 +1531,7 @@ dependencies = [ "referencing", "regex", "regex-syntax", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "uuid-simd", @@ -1420,6 +1587,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1457,6 +1630,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.2" @@ -1597,6 +1780,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "outref" version = "0.5.2" @@ -1745,6 +1934,63 @@ dependencies = [ "yansi", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -1842,6 +2088,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1948,6 +2203,43 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1962,6 +2254,21 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[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" @@ -1981,6 +2288,7 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -1989,21 +2297,62 @@ dependencies = [ "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.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "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 = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2030,6 +2379,15 @@ dependencies = [ "cipher", ] +[[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 = "scarce-studio" version = "0.1.0" @@ -2044,6 +2402,7 @@ dependencies = [ "sqlx", "studio-api", "studio-buzz", + "studio-core", "studio-registry", "studio-store", "studio-types", @@ -2053,6 +2412,15 @@ dependencies = [ "uuid", ] +[[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 = "schemars" version = "1.2.2" @@ -2117,6 +2485,35 @@ dependencies = [ "cc", ] +[[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.229" @@ -2285,6 +2682,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +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" @@ -2526,6 +2939,8 @@ dependencies = [ "axum", "chrono", "http-body-util", + "include_dir", + "mime_guess", "serde", "serde_json", "sqlx", @@ -2542,10 +2957,14 @@ dependencies = [ name = "studio-buzz" version = "0.1.0" dependencies = [ + "base64", "buzz-sdk", "buzz-ws-client", "nostr", + "reqwest 0.13.4", "rustls", + "serde_json", + "sha2 0.10.9", "studio-core", "thiserror", "tokio", @@ -2976,6 +3395,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3097,6 +3522,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[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" @@ -3186,6 +3621,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -3210,6 +3664,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +[[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-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 04d0489..118732b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" [dependencies] studio-api = { path = "crates/studio-api" } +studio-core = { path = "crates/studio-core" } studio-registry = { path = "crates/studio-registry" } studio-buzz = { path = "crates/studio-buzz" } studio-store = { path = "crates/studio-store" } @@ -83,7 +84,14 @@ buzz-ws-client = { git = "https://github.com/block/buzz", rev = "22be8bb35177e27 # (GUIDELINES.md §3) — same pin as the other buzz crates. buzz-persona = { git = "https://github.com/block/buzz", rev = "22be8bb35177e27efc2dca2534df9a8dd871eae0" } toml = "1" +# Embedded project page (/project/{id}) — assets checked in under web/, +# compiled into the binary (same include_dir mechanism as pay's web-ui). +include_dir = "0.7" +mime_guess = { version = "2.0", default-features = false, features = ["rev-mappings"] } nostr = "0.44" +# Invite minting is HTTP (NIP-98 signed POST /api/invites) — pins match buzz. +reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } +base64 = "0.22" # WSS needs a process-level rustls CryptoProvider; every binary entry point # installs ring explicitly (same pin/reason as buzz-cli) — relying on feature # unification to pick one silently breaks when the dep graph shifts. diff --git a/README.md b/README.md index 84581bf..0b344c1 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,23 @@ Accept the quote (buyer, free, once — a second POST is `409`, and a lapsed quote refuses): ```bash -curl -s -X POST localhost:7380/api/v1/rfqs/$RFQ_ID/quote/accept | jq .status +curl -s -X POST localhost:7380/api/v1/rfqs/$RFQ_ID/quote/accept | jq '{status, project_url}' ``` Acceptance stands in for funding while payments are stubbed (PLAN.md §6 -override path): the contract starts. +override path): the contract starts, and the response carries +`project_url` — the shareable page for the engagement. + +## The project page + +`{public_url}/project/{id}` is a super-light web app embedded in the binary +(checked-in vanilla HTML/CSS/JS under `web/`, compiled in via `include_dir` — +no node toolchain). It renders `GET /api/v1/projects/{id}`: the public, +deliberately commercial-free view (title, state, milestone scope, timeline, +workroom name — never price, splits, budget, or policy) plus onboarding +links into Buzz. Set `public_url` in the config (e.g. `https://scarce.sh`) +to mint links against the deployed domain; unset, links use the bind +address for dev. ## Watch it in Buzz diff --git a/crates/studio-api/Cargo.toml b/crates/studio-api/Cargo.toml index 63737a8..a14cfe2 100644 --- a/crates/studio-api/Cargo.toml +++ b/crates/studio-api/Cargo.toml @@ -11,6 +11,8 @@ studio-store = { workspace = true } studio-types = { workspace = true } axum = { workspace = true } +include_dir = { workspace = true } +mime_guess = { workspace = true } chrono = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/studio-api/src/endpoints/accept_quote.rs b/crates/studio-api/src/endpoints/accept_quote.rs index 907054c..7024b2b 100644 --- a/crates/studio-api/src/endpoints/accept_quote.rs +++ b/crates/studio-api/src/endpoints/accept_quote.rs @@ -84,7 +84,10 @@ pub async fn handler( } } - (StatusCode::OK, Json(serde_json::json!(accepted))) + // The accepted quote plus the shareable page — the buyer's next click. + let mut body = serde_json::json!(accepted); + body["project_url"] = serde_json::json!(format!("{}/project/{rfq_id}", state.public_url)); + (StatusCode::OK, Json(body)) } fn refuse( diff --git a/crates/studio-api/src/endpoints/api_index.rs b/crates/studio-api/src/endpoints/api_index.rs index 06e1fbe..1c9651f 100644 --- a/crates/studio-api/src/endpoints/api_index.rs +++ b/crates/studio-api/src/endpoints/api_index.rs @@ -23,7 +23,9 @@ pub async fn handler() -> impl IntoResponse { { "method": "GET", "path": "/api/v1/rfqs/{id}", "description": "fetch one captured RFQ" }, { "method": "POST", "path": "/api/v1/rfqs/{id}/quote", "description": "issue the quote for an RFQ (studio bearer token; schema: quote)" }, { "method": "GET", "path": "/api/v1/rfqs/{id}/quote", "description": "fetch the quote for an RFQ (status fail-closed against expiry)" }, - { "method": "POST", "path": "/api/v1/rfqs/{id}/quote/accept", "description": "accept a live quote (buyer, free; once) — starts the contract" }, + { "method": "POST", "path": "/api/v1/rfqs/{id}/quote/accept", "description": "accept a live quote (buyer, free; once) — starts the contract, returns project_url" }, + { "method": "GET", "path": "/api/v1/projects/{id}", "description": "public project view — no commercial fields (schema: project)" }, + { "method": "GET", "path": "/project/{id}", "description": "the project page (embedded web app rendering the public view)" }, ], "schemas": schemas, "errors": "validation failures return 422 with { errors: [{ field, message }] }", diff --git a/crates/studio-api/src/endpoints/get_project.rs b/crates/studio-api/src/endpoints/get_project.rs new file mode 100644 index 0000000..6ca7788 --- /dev/null +++ b/crates/studio-api/src/endpoints/get_project.rs @@ -0,0 +1,72 @@ +//! `GET /api/v1/projects/{id}` — the public project view (schema: +//! `project`). Free read, deliberately commercial-free: this is what the +//! embedded `/project/{id}` page renders, and its URL is handed to buyers +//! who may share it onward. Assembly is `studio_core::project::view`. + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use studio_types::ProjectLinks; + +use crate::AppState; + +pub async fn handler( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let rfq = match studio_store::rfqs::get(&state.db, &id).await { + Ok(Some(rfq)) => rfq, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "project not found" })), + ) + } + Err(e) => { + tracing::error!(error = %e, project_id = %id, "project rfq read failed"); + return storage_failure(); + } + }; + let quote = match studio_store::quotes::get_by_rfq(&state.db, &id).await { + Ok(quote) => quote, + Err(e) => { + tracing::error!(error = %e, project_id = %id, "project quote read failed"); + return storage_failure(); + } + }; + let workroom = match studio_store::workrooms::get_by_rfq(&state.db, &id).await { + Ok(workroom) => workroom, + Err(e) => { + tracing::error!(error = %e, project_id = %id, "project workroom read failed"); + return storage_failure(); + } + }; + + let links = ProjectLinks { + invite: state.invite_url.read().ok().and_then(|url| url.clone()), + community_web: state.community_web_url.clone(), + buzz_desktop: crate::BUZZ_DESKTOP_URL.to_string(), + }; + let project = studio_core::project::view( + &rfq, + quote.as_ref(), + workroom + .as_ref() + .map(|w| (w.channel_id.as_str(), w.created_at)), + links, + chrono::Utc::now(), + ); + (StatusCode::OK, Json(serde_json::json!(project))) +} + +fn storage_failure() -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "storage failure" })), + ) +} diff --git a/crates/studio-api/src/endpoints/mod.rs b/crates/studio-api/src/endpoints/mod.rs index 98e6ded..a45b380 100644 --- a/crates/studio-api/src/endpoints/mod.rs +++ b/crates/studio-api/src/endpoints/mod.rs @@ -5,6 +5,7 @@ pub mod accept_quote; pub mod api_index; pub mod create_quote; pub mod create_rfq; +pub mod get_project; pub mod get_quote; pub mod get_rfq; pub mod get_schema; diff --git a/crates/studio-api/src/lib.rs b/crates/studio-api/src/lib.rs index f7e1cc5..359f61b 100644 --- a/crates/studio-api/src/lib.rs +++ b/crates/studio-api/src/lib.rs @@ -18,6 +18,11 @@ use sqlx::SqlitePool; use studio_types::{Quote, Rfq}; pub mod endpoints; +pub mod web; + +/// Buzz Desktop download link the project page offers — the canonical +/// releases page (the same fallback the relay's own invite landing uses). +pub const BUZZ_DESKTOP_URL: &str = "https://github.com/block/buzz/releases"; /// A lifecycle moment worth mirroring to the coordination substrate. The API /// emits these post-commit; the daemon's mirror task turns them into Buzz @@ -40,6 +45,16 @@ pub struct AppState { /// Lifecycle beat sink, consumed by the daemon's Buzz mirror task. /// `None` (tests, ledger-only runs) simply drops the beats. pub lifecycle: Option>, + /// Public base URL of this daemon (no trailing slash) — what + /// `/project/{id}` links are minted against, e.g. `https://scarce.sh`. + pub public_url: String, + /// Web entry to the studio's Buzz community, offered on the project + /// page. `None` (ledger-only runs) renders the page without a join link. + pub community_web_url: Option, + /// Latest minted community invite URL, refreshed by the daemon's invite + /// task (invites expire; the page always links the current one). `None` + /// when the studio key cannot mint or the run is ledger-only. + pub invite_url: std::sync::Arc>>, } impl AppState { @@ -75,6 +90,14 @@ pub fn router(state: Arc) -> Router { "/api/v1/rfqs/{id}/quote/accept", post(endpoints::accept_quote::handler), ) + .route( + "/api/v1/projects/{id}", + get(endpoints::get_project::handler), + ) + // The embedded project page and its assets — the public face of an + // engagement (`{public_url}/project/{id}` is what acceptance returns). + .route("/project/{id}", get(web::project_page)) + .route("/assets/{file}", get(web::asset)) .with_state(state) } @@ -115,6 +138,9 @@ mod tests { db, studio_token: None, lifecycle: None, + public_url: "http://127.0.0.1:7380".into(), + community_web_url: None, + invite_url: Default::default(), })); let response = app @@ -141,6 +167,9 @@ mod tests { db, studio_token: None, lifecycle: None, + public_url: "http://127.0.0.1:7380".into(), + community_web_url: None, + invite_url: Default::default(), })); let response = app diff --git a/crates/studio-api/src/web.rs b/crates/studio-api/src/web.rs new file mode 100644 index 0000000..e018a68 --- /dev/null +++ b/crates/studio-api/src/web.rs @@ -0,0 +1,55 @@ +//! The embedded project page — a super-light web app compiled into the +//! binary (same `include_dir` mechanism as pay's web-ui, minus the node +//! toolchain: the assets under `web/` are checked-in vanilla HTML/CSS/JS, +//! no build step). `/project/{id}` serves the shell; the shell fetches +//! `GET /api/v1/projects/{id}` and renders client-side. + +use axum::{ + extract::Path, + http::{header, StatusCode}, + response::IntoResponse, +}; +use include_dir::{include_dir, Dir}; + +static WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../web"); + +/// `GET /project/{id}` — the page shell. The id is client-side routing; +/// existence is the API's answer, so unknown ids render the page's own +/// not-found state (a link is shareable before and after its project +/// finishes). +pub async fn project_page() -> impl IntoResponse { + serve("index.html") +} + +/// `GET /assets/{file}` — css/js/logo, embedded at compile time. +pub async fn asset(Path(file): Path) -> impl IntoResponse { + // include_dir paths never contain `..`; a traversal attempt simply + // fails the lookup. + serve(&format!("assets/{file}")) +} + +fn serve(path: &str) -> impl IntoResponse { + match WEB.get_file(path) { + Some(file) => { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, mime.to_string()), + // Short cache: assets are versionless; five minutes keeps + // reloads cheap without wedging a stale page after deploys. + (header::CACHE_CONTROL, "public, max-age=300".to_string()), + ], + file.contents(), + ) + } + None => ( + StatusCode::NOT_FOUND, + [ + (header::CONTENT_TYPE, "text/plain".to_string()), + (header::CACHE_CONTROL, "no-store".to_string()), + ], + b"not found".as_slice(), + ), + } +} diff --git a/crates/studio-api/tests/project_api.rs b/crates/studio-api/tests/project_api.rs new file mode 100644 index 0000000..efbf8bb --- /dev/null +++ b/crates/studio-api/tests/project_api.rs @@ -0,0 +1,267 @@ +//! The public project surface: `GET /api/v1/projects/{id}` (commercial-free +//! JSON) and the embedded page + assets that render it. + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use http_body_util::BodyExt; +use studio_api::{router, AppState}; +use tower::ServiceExt; + +const GOOD_NPUB: &str = "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy"; +const TOKEN: &str = "test-studio-token"; + +async fn app() -> (axum::Router, sqlx::SqlitePool) { + let db = studio_store::open("sqlite::memory:").await.unwrap(); + let invite_url = std::sync::Arc::new(std::sync::RwLock::new(Some( + "https://scarce.communities.buzz.xyz/invite/v2.test".to_string(), + ))); + let app = router(Arc::new(AppState { + db: db.clone(), + studio_token: Some(TOKEN.into()), + lifecycle: None, + public_url: "https://scarce.sh".into(), + community_web_url: Some("https://scarce.communities.buzz.xyz".into()), + invite_url, + })); + (app, db) +} + +async fn send( + app: &axum::Router, + method: &str, + uri: &str, + bearer: Option<&str>, + body: Option, +) -> (StatusCode, serde_json::Value) { + let mut builder = Request::builder().method(method).uri(uri); + if let Some(token) = bearer { + builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}")); + } + let request = match body { + Some(json) => builder + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(json.to_string())) + .unwrap(), + None => builder.body(Body::empty()).unwrap(), + }; + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) +} + +/// Drive the whole ledger flow through the API: capture → quote → accept. +async fn contract(app: &axum::Router) -> String { + let (status, created) = send( + app, + "POST", + "/api/v1/rfqs", + None, + Some(serde_json::json!({ + "query": "solana priority fee forecast api", + "buyer_npub": GOOD_NPUB + })), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{created}"); + let rfq_id = created["id"].as_str().unwrap().to_string(); + + let (status, quote) = send( + app, + "POST", + &format!("/api/v1/rfqs/{rfq_id}/quote"), + Some(TOKEN), + Some(serde_json::json!({ + "price": { "amount": 250_000_000, "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }, + "milestones": [ + { "title": "Forecast model", "description": "p50/p90 per program id", "amount": 150_000_000 }, + { "title": "Gated endpoint", "description": "pay.sh-gated REST endpoint", "amount": 100_000_000 } + ], + "timeline": "2 weeks, weekly demos", + "payout_destination": { "kind": "splits", "splits": [ + { "recipient": "CrewAgentA111111111111111111111111111111111", "bps": 10000 } + ]}, + "channel": { "idle_timeout_seconds": 604_800 }, + "expires_at": "2099-01-01T00:00:00Z" + })), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{quote}"); + rfq_id +} + +#[tokio::test] +async fn accept_returns_the_shareable_project_url() { + let (app, _db) = app().await; + let rfq_id = contract(&app).await; + + let (status, accepted) = send( + &app, + "POST", + &format!("/api/v1/rfqs/{rfq_id}/quote/accept"), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{accepted}"); + assert_eq!(accepted["status"], "ACCEPTED"); + assert_eq!( + accepted["project_url"], + format!("https://scarce.sh/project/{rfq_id}") + ); +} + +#[tokio::test] +async fn project_view_walks_the_state_ladder_and_leaks_no_money() { + let (app, db) = app().await; + let rfq_id = contract(&app).await; + + // Quote issued, not yet accepted. + let (status, project) = send( + &app, + "GET", + &format!("/api/v1/projects/{rfq_id}"), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{project}"); + assert_eq!(project["state"], "QUOTED"); + assert_eq!(project["title"], "solana priority fee forecast api"); + assert_eq!(project["quote"]["milestones"][0]["title"], "Forecast model"); + assert_eq!( + project["links"]["community_web"], + "https://scarce.communities.buzz.xyz" + ); + // The invite CTA links the relay's own onboarding landing page. + assert_eq!( + project["links"]["invite"], + "https://scarce.communities.buzz.xyz/invite/v2.test" + ); + + // Accepted, workroom not yet provisioned (the mirror is async). + send( + &app, + "POST", + &format!("/api/v1/rfqs/{rfq_id}/quote/accept"), + None, + None, + ) + .await; + let (_, project) = send( + &app, + "GET", + &format!("/api/v1/projects/{rfq_id}"), + None, + None, + ) + .await; + assert_eq!(project["state"], "FUNDED"); + + // Workroom row lands (what the mirror records) → WORKROOM_ACTIVE. + studio_store::workrooms::record( + &db, + &studio_store::workrooms::Workroom { + rfq_id: rfq_id.clone(), + channel_id: "0b5b7a86-6a45-4f7f-9207-3e069b7f0b0e".into(), + create_event_id: "57fc8b6149f1c5d3ba5f3e801fc2219f92159311062c5876a4403d24ff98c431" + .into(), + created_at: chrono::Utc::now(), + }, + ) + .await + .unwrap(); + let (_, project) = send( + &app, + "GET", + &format!("/api/v1/projects/{rfq_id}"), + None, + None, + ) + .await; + assert_eq!(project["state"], "WORKROOM_ACTIVE"); + assert!(project["workroom"]["name"] + .as_str() + .unwrap() + .starts_with("proj-solana-priority")); + + // The public JSON must carry no commercial detail from the quote or rfq. + let json = project.to_string(); + for leak in [ + "price", + "amount", + "250000000", + "payout", + "splits", + "bps", + "npub", + "policy", + ] { + assert!( + !json.contains(leak), + "public project JSON leaks `{leak}`: {json}" + ); + } + + let (status, body) = send(&app, "GET", "/api/v1/projects/nope", None, None).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); +} + +#[tokio::test] +async fn page_and_assets_are_embedded() { + let (app, _db) = app().await; + + for (uri, content_type, marker) in [ + ("/project/anything", "text/html", "scarce"), + ("/assets/style.css", "text/css", "--grad"), + // mime db calls it text/ or application/javascript depending on rev + ("/assets/app.js", "javascript", "STATE_COPY"), + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{uri}"); + let ct = response + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert!(ct.contains(content_type), "{uri}: {ct}"); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert!( + String::from_utf8_lossy(&body).contains(marker), + "{uri} missing `{marker}`" + ); + } + + // The logo ships in the binary too. + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/assets/logo.png") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let response = app + .oneshot( + Request::builder() + .uri("/assets/nope.js") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/studio-api/tests/quote_api.rs b/crates/studio-api/tests/quote_api.rs index ca35b4d..9493aba 100644 --- a/crates/studio-api/tests/quote_api.rs +++ b/crates/studio-api/tests/quote_api.rs @@ -19,6 +19,9 @@ async fn app_with_token(token: Option<&str>) -> axum::Router { db, studio_token: token.map(String::from), lifecycle: None, + public_url: "https://scarce.sh".into(), + community_web_url: Some("https://scarce.communities.buzz.xyz".into()), + invite_url: Default::default(), })) } @@ -338,6 +341,9 @@ async fn lifecycle_beats_are_emitted_in_order() { db, studio_token: Some(TOKEN.into()), lifecycle: Some(tx), + public_url: "https://scarce.sh".into(), + community_web_url: Some("https://scarce.communities.buzz.xyz".into()), + invite_url: Default::default(), })); let rfq_id = capture_rfq(&app).await; diff --git a/crates/studio-api/tests/rfq_api.rs b/crates/studio-api/tests/rfq_api.rs index 4e3f297..8c50b14 100644 --- a/crates/studio-api/tests/rfq_api.rs +++ b/crates/studio-api/tests/rfq_api.rs @@ -16,6 +16,9 @@ async fn app() -> axum::Router { db, studio_token: None, lifecycle: None, + public_url: "https://scarce.sh".into(), + community_web_url: Some("https://scarce.communities.buzz.xyz".into()), + invite_url: Default::default(), })) } diff --git a/crates/studio-buzz/Cargo.toml b/crates/studio-buzz/Cargo.toml index d92978b..07bb731 100644 --- a/crates/studio-buzz/Cargo.toml +++ b/crates/studio-buzz/Cargo.toml @@ -9,10 +9,14 @@ publish = false studio-core = { workspace = true } thiserror = { workspace = true } +base64 = { workspace = true } buzz-sdk = { workspace = true } buzz-ws-client = { workspace = true } nostr = { workspace = true } +reqwest = { workspace = true } rustls = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } diff --git a/crates/studio-buzz/src/invite.rs b/crates/studio-buzz/src/invite.rs new file mode 100644 index 0000000..a5b7819 --- /dev/null +++ b/crates/studio-buzz/src/invite.rs @@ -0,0 +1,163 @@ +//! Relay invite minting — `POST /api/invites`, NIP-98 signed. +//! +//! The relay serves a complete onboarding page at +//! `https:///invite/` (Buzz Desktop deep link, platform-aware +//! download, in-browser claim), so a minted invite URL is the smartest thing +//! a project page can hand a visitor. Minting requires the signing key to +//! hold the `owner` or `admin` role in the community — a plain member gets +//! 403, which callers must treat as "run without invites", not a crash. + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine as _; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use sha2::{Digest, Sha256}; + +use crate::BuzzError; + +/// A minted invite: the shareable landing-page URL and the bare code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MintedInvite { + /// `https:///invite/` — what a page links to. + pub url: String, + pub code: String, +} + +/// `wss://host` → `https://host`, `ws://host` → `http://host` — the relay's +/// HTTP API lives on the same authority as its websocket. +pub fn api_base(relay_url: &str) -> String { + let relay_url = relay_url.trim_end_matches('/'); + if let Some(host) = relay_url.strip_prefix("wss://") { + format!("https://{host}") + } else if let Some(host) = relay_url.strip_prefix("ws://") { + format!("http://{host}") + } else { + relay_url.to_string() + } +} + +/// Sign a NIP-98 HTTP auth event (kind 27235) and return the +/// `Authorization` header value — same shape as the buzz CLI: `u`, `method` +/// and `nonce` tags, plus `payload` (SHA-256 hex) when a body rides along. +pub fn nip98_header( + keys: &Keys, + method: &str, + url: &str, + body: Option<&[u8]>, +) -> Result { + let tag = |parts: [&str; 2]| Tag::parse(parts).map_err(|e| BuzzError::Build(e.to_string())); + let mut tags = vec![ + tag(["u", url])?, + tag(["method", method])?, + // Nonce prevents replay rejection for rapid-fire identical requests. + tag(["nonce", &uuid::Uuid::new_v4().to_string()])?, + ]; + if let Some(body) = body { + let hash = format!("{:x}", Sha256::digest(body)); + tags.push(tag(["payload", &hash])?); + } + let event = EventBuilder::new(Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| BuzzError::Build(format!("NIP-98 signing failed: {e}")))?; + Ok(format!("Nostr {}", B64.encode(event.as_json().as_bytes()))) +} + +pub(crate) async fn mint( + relay_url: &str, + keys: &Keys, + auth_tag: Option<&Tag>, + ttl_secs: u64, + max_uses: Option, +) -> Result { + let url = format!("{}/api/invites", api_base(relay_url)); + let body = serde_json::to_vec(&serde_json::json!({ + "ttl_secs": ttl_secs, + "max_uses": max_uses, + })) + .expect("literal json serializes"); + + let auth = nip98_header(keys, "POST", &url, Some(&body))?; + let client = reqwest::Client::new(); + let mut request = client + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body); + if let Some(tag) = auth_tag { + // Same header the buzz CLI sends: managed identities carry their + // NIP-OA capability on HTTP API calls too. + let json = serde_json::to_string(tag) + .map_err(|e| BuzzError::AuthTag(format!("auth tag does not serialize: {e}")))?; + request = request.header("x-auth-tag", json); + } + + let response = request + .send() + .await + .map_err(|e| BuzzError::Api(format!("POST {url}: {e}")))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|e| BuzzError::Api(format!("POST {url}: reading body: {e}")))?; + if !status.is_success() { + return Err(BuzzError::Api(format!( + "POST {url} -> {status}: {} (minting requires the studio key to be a community owner/admin)", + text.trim() + ))); + } + let json: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| BuzzError::Api(format!("POST {url}: non-JSON body: {e}")))?; + match (json["url"].as_str(), json["code"].as_str()) { + (Some(url), Some(code)) => Ok(MintedInvite { + url: url.to_string(), + code: code.to_string(), + }), + _ => Err(BuzzError::Api(format!( + "POST {url}: response missing url/code: {text}" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn api_base_maps_ws_schemes() { + assert_eq!( + api_base("wss://scarce.communities.buzz.xyz/"), + "https://scarce.communities.buzz.xyz" + ); + assert_eq!(api_base("ws://localhost:3000"), "http://localhost:3000"); + } + + #[test] + fn nip98_header_is_a_signed_27235_with_the_right_tags() { + let keys = Keys::generate(); + let url = "https://relay.example/api/invites"; + let body = br#"{"ttl_secs":259200}"#; + let header = nip98_header(&keys, "POST", url, Some(body)).unwrap(); + + let encoded = header.strip_prefix("Nostr ").expect("Nostr scheme"); + let event = nostr::Event::from_json(B64.decode(encoded).unwrap()).unwrap(); + event.verify().expect("valid signature"); + assert_eq!(event.kind, Kind::Custom(27235)); + assert_eq!(event.pubkey, keys.public_key()); + + let tag_value = |name: &str| { + event + .tags + .iter() + .find(|t| t.as_slice()[0] == name) + .map(|t| t.as_slice()[1].clone()) + }; + assert_eq!(tag_value("u").as_deref(), Some(url)); + assert_eq!(tag_value("method").as_deref(), Some("POST")); + assert_eq!( + tag_value("payload").as_deref(), + Some(format!("{:x}", Sha256::digest(body)).as_str()) + ); + assert!(tag_value("nonce").is_some()); + } +} diff --git a/crates/studio-buzz/src/lib.rs b/crates/studio-buzz/src/lib.rs index d413d66..f6e4218 100644 --- a/crates/studio-buzz/src/lib.rs +++ b/crates/studio-buzz/src/lib.rs @@ -16,6 +16,10 @@ use std::future::Future; use nostr::{EventBuilder, Keys, Tag}; use uuid::Uuid; +pub mod invite; + +pub use invite::MintedInvite; + #[derive(Debug, thiserror::Error)] pub enum BuzzError { #[error("invalid studio key: {0}")] @@ -28,6 +32,8 @@ pub enum BuzzError { Transport(#[from] buzz_ws_client::WsClientError), #[error("relay rejected event {event_id}: {message}")] Rejected { event_id: String, message: String }, + #[error("relay http api: {0}")] + Api(String), } /// A channel the port created, with the relay-accepted create event id — @@ -62,6 +68,15 @@ pub trait BuzzPort: Send + Sync + 'static { channel_id: Uuid, pubkey_hex: &str, ) -> impl Future> + Send; + + /// Mint a community invite (`POST /api/invites`, NIP-98). Requires the + /// studio key to hold owner/admin in the community; callers treat a + /// failure as "no invite available", never as fatal. + fn mint_invite( + &self, + ttl_secs: u64, + max_uses: Option, + ) -> impl Future> + Send; } /// Install ring as the process-level rustls CryptoProvider — required before @@ -195,6 +210,21 @@ impl BuzzPort for RelayBuzz { .map_err(|e| BuzzError::Build(e.to_string()))?; self.publish(builder).await } + + async fn mint_invite( + &self, + ttl_secs: u64, + max_uses: Option, + ) -> Result { + invite::mint( + &self.relay_url, + &self.keys, + self.auth_tag.as_ref(), + ttl_secs, + max_uses, + ) + .await + } } /// Recording mock for orchestrator tests: deterministic ids, captured calls. @@ -217,6 +247,10 @@ pub enum MockCall { channel_id: Uuid, pubkey_hex: String, }, + MintInvite { + ttl_secs: u64, + max_uses: Option, + }, } impl BuzzPort for MockBuzz { @@ -250,4 +284,18 @@ impl BuzzPort for MockBuzz { }); Ok(format!("mock-member-event-{}", calls.len())) } + + async fn mint_invite( + &self, + ttl_secs: u64, + max_uses: Option, + ) -> Result { + let mut calls = self.calls.lock().unwrap(); + calls.push(MockCall::MintInvite { ttl_secs, max_uses }); + let n = calls.len(); + Ok(MintedInvite { + url: format!("https://mock.relay/invite/v2.mock-{n}"), + code: format!("v2.mock-{n}"), + }) + } } diff --git a/crates/studio-core/src/lib.rs b/crates/studio-core/src/lib.rs index 0ea1372..8676425 100644 --- a/crates/studio-core/src/lib.rs +++ b/crates/studio-core/src/lib.rs @@ -9,6 +9,7 @@ //! MCP) without duplicating logic. pub mod gate; +pub mod project; pub mod quote; pub mod rfq; @@ -18,5 +19,6 @@ pub use gate::{ }; pub use studio_types::{ Amount, ChannelParams, Edge, EdgePattern, FieldError, GatePolicy, GateSpec, MilestoneSpec, - NewQuote, NewRfq, PayoutDestination, ProjectState, Quote, QuoteStatus, Rfq, Split, + NewQuote, NewRfq, PayoutDestination, Project, ProjectLinks, ProjectState, Quote, QuoteStatus, + Rfq, Split, }; diff --git a/crates/studio-core/src/project.rs b/crates/studio-core/src/project.rs new file mode 100644 index 0000000..1ad2ee3 --- /dev/null +++ b/crates/studio-core/src/project.rs @@ -0,0 +1,234 @@ +//! The public project view — assembly and naming. Pure functions: the +//! handler fetches the rows, this module decides what an outsider sees. + +use chrono::{DateTime, Utc}; +use studio_types::{ + Project, ProjectLinks, ProjectMilestone, ProjectQuote, ProjectState, ProjectWorkroom, Quote, + QuoteStatus, Rfq, +}; + +/// `proj--`: slug from the demand query, short id for +/// uniqueness (channel names are not unique on the relay; the uuid is). +/// Lives here so the mirror (channel creation) and the project view (page) +/// can never disagree about a workroom's name. +pub fn workroom_name(rfq: &Rfq) -> String { + let slug: String = rfq + .query + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + let slug = slug.chars().take(32).collect::(); + let slug = slug.trim_end_matches('-'); + let short = rfq.id.chars().take(8).collect::(); + format!("proj-{slug}-{short}") +} + +/// Assemble the public view from the projection rows. The quote's status is +/// re-derived against `now` (fail-closed against sweep lag, same as the +/// quote read endpoint); everything commercial stays out by construction — +/// [`Project`] has no field that could carry an amount. +pub fn view( + rfq: &Rfq, + quote: Option<&Quote>, + workroom: Option<(&str, DateTime)>, + links: ProjectLinks, + now: DateTime, +) -> Project { + let quote = quote.map(|q| q.clone().at(now)); + let state = match "e { + None => ProjectState::RfqCaptured, + Some(q) => match q.status { + QuoteStatus::Quoted => ProjectState::Quoted, + QuoteStatus::Lapsed => ProjectState::Lapsed, + // ACCEPTED stands in for FUNDED while payments are stubbed + // (PLAN.md §6); the workroom row is the WORKROOM_ACTIVE evidence. + QuoteStatus::Accepted => match workroom { + Some(_) => ProjectState::WorkroomActive, + None => ProjectState::Funded, + }, + }, + }; + + Project { + id: rfq.id.clone(), + title: rfq.query.clone(), + state, + created_at: rfq.created_at, + quote: quote.map(|q| ProjectQuote { + milestones: q + .milestones + .iter() + .map(|m| ProjectMilestone { + title: m.title.clone(), + description: m.description.clone(), + }) + .collect(), + timeline: q.timeline.clone(), + expires_at: q.expires_at, + accepted_at: q.accepted_at, + }), + workroom: workroom.map(|(channel_id, since)| ProjectWorkroom { + name: workroom_name(rfq), + channel_id: channel_id.to_string(), + since, + }), + links, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use studio_types::{Amount, ChannelParams, MilestoneSpec, PayoutDestination, Split}; + + fn ts(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) + } + + fn rfq() -> Rfq { + Rfq { + id: "3f6b2c1a-0000-4000-8000-000000000000".into(), + query: "Solana priority fee forecast API".into(), + product: None, + monetization: None, + competition: vec![], + budget_ceiling: Some(Amount { + amount: 900_000_000, + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + }), + buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(), + buyer_signature: None, + created_at: ts("2026-08-01T14:00:00Z"), + } + } + + fn quote(status: QuoteStatus) -> Quote { + Quote { + rfq_id: rfq().id, + id: "q-1".into(), + price: Amount { + amount: 250_000_000, + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + }, + milestones: vec![MilestoneSpec { + title: "Forecast model".into(), + description: "p50/p90 per program id".into(), + amount: 250_000_000, + }], + timeline: "2 weeks, weekly demos".into(), + payout_destination: PayoutDestination::Splits { + splits: vec![Split { + recipient: "CrewAgentA111111111111111111111111111111111".into(), + bps: 10_000, + }], + }, + channel: ChannelParams { + grace_seconds: 172_800, + idle_timeout_seconds: 604_800, + }, + gate_policy: studio_types::GatePolicy::studio_default(), + policy_hash: "ab".repeat(32), + expires_at: ts("2026-09-01T00:00:00Z"), + status, + created_at: ts("2026-08-01T15:00:00Z"), + lapsed_at: None, + accepted_at: (status == QuoteStatus::Accepted).then(|| ts("2026-08-01T16:00:00Z")), + } + } + + fn links() -> ProjectLinks { + ProjectLinks { + invite: Some("https://scarce.communities.buzz.xyz/invite/v2.test".into()), + community_web: Some("https://scarce.communities.buzz.xyz".into()), + buzz_desktop: "https://github.com/block/buzz/releases".into(), + } + } + + const NOW: &str = "2026-08-02T00:00:00Z"; + + #[test] + fn state_ladder_matches_the_rows() { + let rfq = rfq(); + let now = ts(NOW); + let view_of = |q: Option<&Quote>, w| view(&rfq, q, w, links(), now); + + assert_eq!(view_of(None, None).state, ProjectState::RfqCaptured); + assert_eq!( + view_of(Some("e(QuoteStatus::Quoted)), None).state, + ProjectState::Quoted + ); + assert_eq!( + view_of(Some("e(QuoteStatus::Accepted)), None).state, + ProjectState::Funded + ); + let project = view_of( + Some("e(QuoteStatus::Accepted)), + Some(( + "0b5b7a86-6a45-4f7f-9207-3e069b7f0b0e", + ts("2026-08-01T17:00:00Z"), + )), + ); + assert_eq!(project.state, ProjectState::WorkroomActive); + let workroom = project.workroom.unwrap(); + assert_eq!( + workroom.name, + "proj-solana-priority-fee-forecast-api-3f6b2c1a" + ); + assert_eq!(workroom.channel_id, "0b5b7a86-6a45-4f7f-9207-3e069b7f0b0e"); + } + + #[test] + fn expiry_is_rederived_against_now() { + // Row still says QUOTED, but `now` is past expiry — the public view + // must not advertise an acceptable quote (same rule as the API read). + let expired = view( + &rfq(), + Some("e(QuoteStatus::Quoted)), + None, + links(), + ts("2026-09-02T00:00:00Z"), + ); + assert_eq!(expired.state, ProjectState::Lapsed); + } + + #[test] + fn no_commercial_field_survives_serialization() { + // The quote carries price, per-milestone amounts, payout splits and + // the rfq a budget ceiling; the public JSON must contain none of it. + let project = view( + &rfq(), + Some("e(QuoteStatus::Accepted)), + Some(( + "0b5b7a86-6a45-4f7f-9207-3e069b7f0b0e", + ts("2026-08-01T17:00:00Z"), + )), + links(), + ts(NOW), + ); + let json = serde_json::to_string(&project).unwrap(); + for leak in [ + "price", + "amount", + "250000000", + "900000000", + "payout", + "splits", + "bps", + "CrewAgentA", + "budget", + "gate_policy", + "policy_hash", + "npub", + ] { + assert!( + !json.contains(leak), + "public project JSON leaks `{leak}`: {json}" + ); + } + } +} diff --git a/crates/studio-types/src/lib.rs b/crates/studio-types/src/lib.rs index 8b00c4d..6edc2f2 100644 --- a/crates/studio-types/src/lib.rs +++ b/crates/studio-types/src/lib.rs @@ -8,12 +8,14 @@ //! machines and orchestration logic stay in `studio-core`. pub mod gate; +pub mod project; pub mod quote; pub mod rfq; pub mod schemas; pub mod state; pub use gate::{GatePolicy, GateSpec}; +pub use project::{Project, ProjectLinks, ProjectMilestone, ProjectQuote, ProjectWorkroom}; pub use quote::{ ChannelParams, MilestoneSpec, NewQuote, PayoutDestination, Quote, QuoteStatus, Split, }; diff --git a/crates/studio-types/src/project.rs b/crates/studio-types/src/project.rs new file mode 100644 index 0000000..b089705 --- /dev/null +++ b/crates/studio-types/src/project.rs @@ -0,0 +1,73 @@ +//! The public project view — what `GET /api/v1/projects/{id}` returns and +//! the embedded `/project/{id}` page renders. +//! +//! **Deliberately commercial-free.** The page is shareable (the accept +//! response hands its URL to the buyer, who may forward it); price, payout +//! splits, budget and gate policy stay inside the private workroom. This +//! type carries only what excites an outsider: what is being built, that +//! agents are on it, and how to join Buzz to watch. + +use chrono::{DateTime, Utc}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::state::ProjectState; + +/// Public projection of one engagement, keyed by RFQ id. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Project { + /// The RFQ id — also the page path segment (`/project/{id}`). + pub id: String, + /// The demand, verbatim — doubles as the project title. + pub title: String, + /// Lifecycle state (PLAN.md §2 vocabulary). + pub state: ProjectState, + /// When the demand was captured. + pub created_at: DateTime, + /// Public slice of the quote, present once one is issued. + pub quote: Option, + /// The Buzz workroom, present once the contract started. + pub workroom: Option, + pub links: ProjectLinks, +} + +/// Quote facts safe for a public page — scope and schedule, no money. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ProjectQuote { + pub milestones: Vec, + pub timeline: String, + pub expires_at: DateTime, + pub accepted_at: Option>, +} + +/// Milestone scope without its amount. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ProjectMilestone { + pub title: String, + pub description: String, +} + +/// The private Buzz channel where the crew works. Membership is required to +/// read it — the page can only point at the door. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ProjectWorkroom { + /// Channel display name (`proj--`). + pub name: String, + /// Channel uuid on the community relay. + pub channel_id: String, + /// When the workroom opened — the contract start. + pub since: DateTime, +} + +/// Onboarding links the page renders as calls to action. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ProjectLinks { + /// Community invite landing page (`https:///invite/`) — the + /// relay's own onboarding flow (desktop deep link, download, in-browser + /// claim). Present when the studio key can mint invites. + pub invite: Option, + /// Web entry to the studio's Buzz community, when one is configured. + pub community_web: Option, + /// Buzz Desktop download. + pub buzz_desktop: String, +} diff --git a/crates/studio-types/src/schemas.rs b/crates/studio-types/src/schemas.rs index 439aa36..60c577e 100644 --- a/crates/studio-types/src/schemas.rs +++ b/crates/studio-types/src/schemas.rs @@ -17,6 +17,7 @@ pub fn all() -> Vec<(&'static str, serde_json::Value)> { ("rfq", rfq()), ("quote", quote()), ("gate-policy", gate_policy()), + ("project", project()), ] } @@ -43,6 +44,13 @@ pub fn gate_policy() -> serde_json::Value { finalize("gate-policy", schema_for!(crate::gate::GatePolicy)) } +/// `schemas/project.json` — the public project view +/// (`GET /api/v1/projects/{id}`), rendered by the embedded `/project/{id}` +/// page. Read-only contract: deliberately carries no commercial fields. +pub fn project() -> serde_json::Value { + finalize("project", schema_for!(crate::project::Project)) +} + /// Stamp the registry-level `$id` onto a generated schema. `$schema`, title, /// and descriptions come from the type derives. fn finalize(name: &str, schema: schemars::Schema) -> serde_json::Value { diff --git a/crates/studio-types/src/state.rs b/crates/studio-types/src/state.rs index 2a79d0e..d858d20 100644 --- a/crates/studio-types/src/state.rs +++ b/crates/studio-types/src/state.rs @@ -2,12 +2,15 @@ //! edges are legal, what evidence each records) arrives with the orchestrator //! in M3; the gate engine needs the vocabulary now. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Every state a project can occupy (PLAN.md §2). Wire names are /// SCREAMING_SNAKE_CASE, matching the plan's diagrams and the gate-policy /// edge keys. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum ProjectState { RfqCaptured, diff --git a/scarced.example.yaml b/scarced.example.yaml index 8057386..3ea23ff 100644 --- a/scarced.example.yaml +++ b/scarced.example.yaml @@ -7,6 +7,10 @@ db: sqlite://scarced.db studio_token: dev-token sweep_seconds: 30 +# Public base URL project-page links are minted against — the accept +# response returns {public_url}/project/{rfq_id}. Unset = http://. +public_url: https://scarce.sh + # Studio repo root holding agents/*.persona.md, roster.toml and skills.toml. # Loaded fail-closed at boot: a registry that does not parse is a scarced # that does not start (GUIDELINES.md §3–§4). diff --git a/schemas/project.json b/schemas/project.json new file mode 100644 index 0000000..7b22bfc --- /dev/null +++ b/schemas/project.json @@ -0,0 +1,176 @@ +{ + "$defs": { + "ProjectLinks": { + "description": "Onboarding links the page renders as calls to action.", + "properties": { + "buzz_desktop": { + "description": "Buzz Desktop download.", + "type": "string" + }, + "community_web": { + "description": "Web entry to the studio's Buzz community, when one is configured.", + "type": [ + "string", + "null" + ] + }, + "invite": { + "description": "Community invite landing page (`https:///invite/`) — the\nrelay's own onboarding flow (desktop deep link, download, in-browser\nclaim). Present when the studio key can mint invites.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "buzz_desktop" + ], + "type": "object" + }, + "ProjectMilestone": { + "description": "Milestone scope without its amount.", + "properties": { + "description": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "title", + "description" + ], + "type": "object" + }, + "ProjectQuote": { + "description": "Quote facts safe for a public page — scope and schedule, no money.", + "properties": { + "accepted_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "expires_at": { + "format": "date-time", + "type": "string" + }, + "milestones": { + "items": { + "$ref": "#/$defs/ProjectMilestone" + }, + "type": "array" + }, + "timeline": { + "type": "string" + } + }, + "required": [ + "milestones", + "timeline", + "expires_at" + ], + "type": "object" + }, + "ProjectState": { + "description": "Every state a project can occupy (PLAN.md §2). Wire names are\nSCREAMING_SNAKE_CASE, matching the plan's diagrams and the gate-policy\nedge keys.", + "enum": [ + "RFQ_CAPTURED", + "QUOTED", + "LAPSED", + "FUNDED", + "WORKROOM_ACTIVE", + "BUILDING", + "DEMOED", + "ACCEPTED", + "DELIVERED", + "OPERATING", + "CLOSED_BY_BUYER", + "CLOSED_IDLE" + ], + "type": "string" + }, + "ProjectWorkroom": { + "description": "The private Buzz channel where the crew works. Membership is required to\nread it — the page can only point at the door.", + "properties": { + "channel_id": { + "description": "Channel uuid on the community relay.", + "type": "string" + }, + "name": { + "description": "Channel display name (`proj--`).", + "type": "string" + }, + "since": { + "description": "When the workroom opened — the contract start.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "name", + "channel_id", + "since" + ], + "type": "object" + } + }, + "$id": "https://scarce.studio/schemas/project.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Public projection of one engagement, keyed by RFQ id.", + "properties": { + "created_at": { + "description": "When the demand was captured.", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "The RFQ id — also the page path segment (`/project/{id}`).", + "type": "string" + }, + "links": { + "$ref": "#/$defs/ProjectLinks" + }, + "quote": { + "anyOf": [ + { + "$ref": "#/$defs/ProjectQuote" + }, + { + "type": "null" + } + ], + "description": "Public slice of the quote, present once one is issued." + }, + "state": { + "$ref": "#/$defs/ProjectState", + "description": "Lifecycle state (PLAN.md §2 vocabulary)." + }, + "title": { + "description": "The demand, verbatim — doubles as the project title.", + "type": "string" + }, + "workroom": { + "anyOf": [ + { + "$ref": "#/$defs/ProjectWorkroom" + }, + { + "type": "null" + } + ], + "description": "The Buzz workroom, present once the contract started." + } + }, + "required": [ + "id", + "title", + "state", + "created_at", + "links" + ], + "title": "Project", + "type": "object" +} diff --git a/src/config.rs b/src/config.rs index a980850..34d6ab0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,10 @@ pub struct Config { /// Quote-expiry sweep cadence, seconds. Reads are fail-closed against /// sweep lag either way; the sweep keeps the projection rows honest. pub sweep_seconds: u64, + /// Public base URL project-page links are minted against (no trailing + /// slash), e.g. `https://scarce.sh`. Unset = derived from `bind` for dev. + #[serde(default)] + pub public_url: Option, /// Studio repo root holding `agents/*.persona.md`, `roster.toml` and /// `skills.toml` (GUIDELINES.md §3–§4). Loaded fail-closed at boot: a /// registry that does not parse is a scarced that does not start. @@ -62,6 +66,7 @@ impl Default for Config { db: "sqlite://scarced.db".into(), studio_token: None, sweep_seconds: 30, + public_url: None, registry_dir: ".".into(), buzz: None, } @@ -97,6 +102,13 @@ impl Config { } config.studio_token = config.studio_token.filter(|t| !t.trim().is_empty()); + config.public_url = config.public_url.filter(|u| !u.trim().is_empty()); + if let Some(url) = &config.public_url { + anyhow::ensure!( + url.starts_with("http://") || url.starts_with("https://"), + "public_url (SCARCED_PUBLIC_URL) must be an http(s) URL, got `{url}`" + ); + } anyhow::ensure!( config.sweep_seconds > 0, "sweep_seconds (SCARCED_SWEEP_SECONDS) must be a positive integer" @@ -208,6 +220,20 @@ mod tests { }); } + #[test] + fn public_url_must_be_http_and_empty_disables() { + figment::Jail::expect_with(|jail| { + jail.create_file("scarced.yaml", "public_url: scarce.sh\n")?; + let err = Config::load(Some(Path::new("scarced.yaml"))).unwrap_err(); + assert!(err.to_string().contains("public_url"), "{err}"); + + jail.create_file("scarced2.yaml", "public_url: \"\"\n")?; + let config = Config::load(Some(Path::new("scarced2.yaml"))).expect("load"); + assert_eq!(config.public_url, None); + Ok(()) + }); + } + #[test] fn non_websocket_relay_url_rejected() { figment::Jail::expect_with(|jail| { diff --git a/src/main.rs b/src/main.rs index d48e3d1..9dba917 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,8 +50,28 @@ async fn main() -> anyhow::Result<()> { spawn_quote_expiry_sweep(db.clone(), config.sweep_seconds); + // Public base URL for /project/{id} links; defaults to the bind address + // for dev, `public_url: https://scarce.sh` in production config. + let public_url = config + .public_url + .clone() + .unwrap_or_else(|| format!("http://{}", config.bind)) + .trim_end_matches('/') + .to_string(); + // The page's "open in Buzz" link — the community's https host, derived + // from the relay URL (wss://host -> https://host). + let community_web_url = config.buzz.as_ref().map(|b| { + format!( + "https://{}", + b.relay_url + .trim_start_matches("wss://") + .trim_start_matches("ws://") + ) + }); + // Lifecycle mirror: config-gated. Fail-closed at startup — a bad key or // channel id refuses to boot rather than silently running ledger-only. + let invite_url: std::sync::Arc>> = Default::default(); let lifecycle = match &config.buzz { Some(buzz) => { let port = studio_buzz::RelayBuzz::new( @@ -65,8 +85,17 @@ async fn main() -> anyhow::Result<()> { tracing::info!(relay = %buzz.relay_url, ops_channel = %ops_channel, studio_pubkey = %port.public_key_hex(), "buzz lifecycle mirror enabled"); + spawn_invite_refresh( + studio_buzz::RelayBuzz::new( + &buzz.relay_url, + &buzz.private_key, + buzz.auth_tag.as_deref(), + ) + .context("building buzz relay port")?, + invite_url.clone(), + ); let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - mirror::spawn(port, ops_channel, db.clone(), rx); + mirror::spawn(port, ops_channel, db.clone(), public_url.clone(), rx); Some(tx) } None => { @@ -79,6 +108,9 @@ async fn main() -> anyhow::Result<()> { db, studio_token: config.studio_token, lifecycle, + public_url, + community_web_url, + invite_url, })); let listener = tokio::net::TcpListener::bind(&config.bind) .await @@ -93,6 +125,39 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// Keep a live community invite minted for the project page (the relay's +/// `/invite/` landing is the onboarding flow). Invites expire — mint +/// with the 72 h default and re-mint daily; on failure (typically 403: the +/// studio key is not a community owner/admin) the page simply runs without +/// an invite CTA, and the log says why. Fail-open by design: onboarding is +/// optional, the ledger is not. +fn spawn_invite_refresh( + buzz: studio_buzz::RelayBuzz, + slot: std::sync::Arc>>, +) { + use studio_buzz::BuzzPort; + const TTL_SECS: u64 = 72 * 60 * 60; + const REFRESH_OK: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); + const RETRY_ERR: std::time::Duration = std::time::Duration::from_secs(60 * 60); + tokio::spawn(async move { + loop { + let wait = match buzz.mint_invite(TTL_SECS, None).await { + Ok(invite) => { + tracing::info!(url = %invite.url, "community invite minted for the project page"); + *slot.write().expect("invite slot poisoned") = Some(invite.url); + REFRESH_OK + } + Err(e) => { + tracing::warn!(error = %e, + "invite mint failed — project page runs without an invite CTA"); + RETRY_ERR + } + }; + tokio::time::sleep(wait).await; + } + }); +} + /// QUOTED → LAPSED, on a timer (PLAN.md M2). Reads derive LAPSED past /// expiry on their own; the sweep stamps the projection rows so the ledger /// itself carries the transition timestamps. diff --git a/src/mirror.rs b/src/mirror.rs index 4ef65b5..55cfbc6 100644 --- a/src/mirror.rs +++ b/src/mirror.rs @@ -15,6 +15,7 @@ use sqlx::SqlitePool; use studio_api::LifecycleBeat; use studio_buzz::BuzzPort; +use studio_core::project::workroom_name; use studio_types::{Quote, Rfq}; use tokio::sync::mpsc::UnboundedReceiver; use uuid::Uuid; @@ -23,11 +24,12 @@ pub fn spawn( buzz: B, ops_channel: Uuid, db: SqlitePool, + public_url: String, mut rx: UnboundedReceiver, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { while let Some(beat) = rx.recv().await { - if let Err(e) = mirror_one(&buzz, ops_channel, &db, beat).await { + if let Err(e) = mirror_one(&buzz, ops_channel, &db, &public_url, beat).await { tracing::error!(error = %e, "lifecycle beat not mirrored to buzz"); } } @@ -39,6 +41,7 @@ async fn mirror_one( buzz: &B, ops_channel: Uuid, db: &SqlitePool, + public_url: &str, beat: LifecycleBeat, ) -> anyhow::Result<()> { match beat { @@ -87,12 +90,16 @@ async fn mirror_one( Err(e) => tracing::error!(error = %e, rfq_id = %rfq.id, "buyer npub does not decode; not added to workroom"), } - buzz.post(created.channel_id, &contract_post(&rfq, "e)) - .await?; + let project_url = format!("{public_url}/project/{}", rfq.id); + buzz.post( + created.channel_id, + &contract_post(&rfq, "e, &project_url), + ) + .await?; buzz.post( ops_channel, &format!( - "workroom `{name}` opened for rfq `{}` — channel {} (create event `{}`)", + "workroom `{name}` opened for rfq `{}` — channel {} (create event `{}`)\nproject page: {project_url}", rfq.id, created.channel_id, created.create_event_id ), ) @@ -111,25 +118,6 @@ fn workroom_about(rfq: &Rfq, quote: &Quote) -> String { ) } -/// `proj--`: slug from the demand query, short id for -/// uniqueness (channel names are not unique on the relay; the uuid is). -pub fn workroom_name(rfq: &Rfq) -> String { - let slug: String = rfq - .query - .to_lowercase() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect::() - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-"); - let slug = slug.chars().take(32).collect::(); - let slug = slug.trim_end_matches('-'); - let short = rfq.id.chars().take(8).collect::(); - format!("proj-{slug}-{short}") -} - fn budget_line(rfq: &Rfq) -> String { match &rfq.budget_ceiling { Some(amount) => format!("{} (mint `{}`)", amount.amount, amount.mint), @@ -174,7 +162,7 @@ fn accepted_post(rfq: &Rfq, quote: &Quote) -> String { ) } -fn contract_post(rfq: &Rfq, quote: &Quote) -> String { +fn contract_post(rfq: &Rfq, quote: &Quote, project_url: &str) -> String { let milestones = quote .milestones .iter() @@ -183,8 +171,8 @@ fn contract_post(rfq: &Rfq, quote: &Quote) -> String { .collect::>() .join("\n"); format!( - "🚀 contract starting — rfq `{}`\n> {}\n\nmilestones:\n{}\n\ntimeline: {} · policy `{}`\nThis channel is the workroom: demos, decisions, and delivery land here.", - rfq.id, rfq.query, milestones, quote.timeline, quote.policy_hash, + "🚀 contract starting — rfq `{}`\n> {}\n\nmilestones:\n{}\n\ntimeline: {} · policy `{}`\nproject page: {}\nThis channel is the workroom: demos, decisions, and delivery land here.", + rfq.id, rfq.query, milestones, quote.timeline, quote.policy_hash, project_url, ) } @@ -271,6 +259,7 @@ mod tests { &buzz, ops, &db, + "https://scarce.sh", LifecycleBeat::QuoteAccepted { rfq: Box::new(rfq()), quote: Box::new(quote()), @@ -291,8 +280,11 @@ mod tests { matches!(&calls[2], MockCall::AddMember { channel_id, pubkey_hex } if *channel_id != ops && *pubkey_hex == expected_buyer) ); - // contract post lands in the NEW channel, not ops - assert!(matches!(&calls[3], MockCall::Post { channel_id, .. } if *channel_id != ops)); + // contract post lands in the NEW channel, not ops, and carries the + // public project page URL the buyer can share + assert!(matches!(&calls[3], MockCall::Post { channel_id, content } + if *channel_id != ops + && content.contains("https://scarce.sh/project/9e342a83-429b-4887-9cae-6ddecd78f7c5"))); assert!(matches!(&calls[4], MockCall::Post { channel_id, .. } if *channel_id == ops)); // evidence row: channel-create event id recorded @@ -308,6 +300,7 @@ mod tests { &buzz, ops, &db, + "https://scarce.sh", LifecycleBeat::QuoteAccepted { rfq: Box::new(rfq()), quote: Box::new(quote()), @@ -330,6 +323,7 @@ mod tests { &buzz, ops, &db, + "https://scarce.sh", LifecycleBeat::DemandCaptured { rfq: Box::new(rfq()), }, @@ -340,6 +334,7 @@ mod tests { &buzz, ops, &db, + "https://scarce.sh", LifecycleBeat::QuoteIssued { quote: Box::new(quote()), }, diff --git a/web/assets/app.js b/web/assets/app.js new file mode 100644 index 0000000..491bbaa --- /dev/null +++ b/web/assets/app.js @@ -0,0 +1,153 @@ +// scarce project page — fetches the public project view and renders it. +// Vanilla, no framework, no innerHTML with data: every dynamic string goes +// through textContent. + +(function () { + "use strict"; + + var stage = document.getElementById("stage"); + + // States and their public voice. WORKROOM_ACTIVE and BUILDING are the + // exciting ones; everything else stays honest but warm. + var STATE_COPY = { + RFQ_CAPTURED: { badge: "demand captured", live: false, tagline: "The studio is sizing this one up." }, + QUOTED: { badge: "quote on the table", live: false, tagline: "A build plan is waiting for the buyer's green light." }, + LAPSED: { badge: "quote lapsed", live: false, tagline: "This quote expired before acceptance. Demand can be re-captured." }, + FUNDED: { badge: "contract signed", live: true, tagline: "Accepted. The workroom is being provisioned right now." }, + WORKROOM_ACTIVE: { badge: "live", live: true, tagline: "are actively working on this project." }, + BUILDING: { badge: "live", live: true, tagline: "are actively working on this project." }, + DEMOED: { badge: "demoed", live: true, tagline: "First demo delivered — review is underway." }, + ACCEPTED: { badge: "accepted", live: true, tagline: "The build was accepted. Delivery is in motion." }, + DELIVERED: { badge: "delivered", live: false, tagline: "Shipped. This artifact is in the buyer's hands." }, + OPERATING: { badge: "operating", live: true, tagline: "Live in production and earning." }, + CLOSED_BY_BUYER: { badge: "closed", live: false, tagline: "This engagement was closed by the buyer." }, + CLOSED_IDLE: { badge: "closed", live: false, tagline: "This engagement idled out and closed." } + }; + + function el(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function fmtDate(iso) { + try { + return new Date(iso).toLocaleString(undefined, { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit" + }); + } catch (e) { + return iso; + } + } + + function render(project) { + var copy = STATE_COPY[project.state] || { badge: project.state, live: false, tagline: "" }; + stage.textContent = ""; + + var badge = el("span", copy.live ? "badge live" : "badge"); + if (copy.live) badge.appendChild(el("span", "pulse")); + badge.appendChild(el("span", null, copy.badge)); + stage.appendChild(badge); + + stage.appendChild(el("h1", "title", project.title)); + + var tagline = el("p", "tagline"); + if (copy.live && (project.state === "WORKROOM_ACTIVE" || project.state === "BUILDING")) { + var agents = el("strong", null, "Agents"); + tagline.appendChild(agents); + tagline.appendChild(document.createTextNode(" " + copy.tagline)); + } else { + tagline.textContent = copy.tagline; + } + stage.appendChild(tagline); + + // Calls to action — join the community where the work happens. An + // invite link is the relay's own onboarding flow (desktop deep link, + // download, in-browser claim) and beats a bare community link. + var ctas = el("div", "cta-row"); + var links = project.links || {}; + if (links.invite) { + var invite = el("a", "cta primary", "Join the build on Buzz"); + invite.href = links.invite; + invite.target = "_blank"; + invite.rel = "noopener"; + ctas.appendChild(invite); + } else if (links.community_web) { + var join = el("a", "cta primary", "Watch it live on Buzz"); + join.href = links.community_web; + join.target = "_blank"; + join.rel = "noopener"; + ctas.appendChild(join); + } + if (links.buzz_desktop) { + var desktop = el("a", "cta ghost", "Get Buzz Desktop"); + desktop.href = links.buzz_desktop; + desktop.target = "_blank"; + desktop.rel = "noopener"; + ctas.appendChild(desktop); + } + if (ctas.childNodes.length) stage.appendChild(ctas); + + if (project.quote && project.quote.milestones && project.quote.milestones.length) { + stage.appendChild(el("p", "section-label", "Milestones")); + var list = el("ol", "milestones"); + project.quote.milestones.forEach(function (m) { + var item = el("li"); + item.appendChild(el("div", "ms-title", m.title)); + item.appendChild(el("div", "ms-desc", m.description)); + list.appendChild(item); + }); + stage.appendChild(list); + } + + stage.appendChild(el("p", "section-label", "Project")); + var meta = el("dl", "meta"); + function row(term, node) { + meta.appendChild(el("dt", null, term)); + var dd = el("dd"); + if (typeof node === "string") dd.textContent = node; + else dd.appendChild(node); + meta.appendChild(dd); + } + row("demand captured", fmtDate(project.created_at)); + if (project.quote) { + row("timeline", project.quote.timeline); + if (project.quote.accepted_at) row("contract signed", fmtDate(project.quote.accepted_at)); + } + if (project.workroom) { + row("contract started", fmtDate(project.workroom.since)); + var code = document.createElement("code"); + code.textContent = project.workroom.name; + row("workroom", code); + } + stage.appendChild(meta); + + if (project.workroom) { + stage.appendChild(el("p", "tagline", + "The workroom is a private Buzz channel — buyers are added on acceptance. " + + "Open the community and it is already in your sidebar.")); + } + } + + function fail(message) { + stage.textContent = ""; + stage.appendChild(el("p", "error", message)); + } + + var match = location.pathname.match(/^\/project\/([^/]+)\/?$/); + if (!match) { + fail("No project in this URL."); + return; + } + + fetch("/api/v1/projects/" + encodeURIComponent(match[1])) + .then(function (res) { + if (res.status === 404) throw new Error("This project does not exist (yet)."); + if (!res.ok) throw new Error("The studio is unreachable right now — try again shortly."); + return res.json(); + }) + .then(render) + .catch(function (err) { fail(err.message); }); +})(); diff --git a/web/assets/logo.png b/web/assets/logo.png new file mode 100644 index 0000000..0f41a40 Binary files /dev/null and b/web/assets/logo.png differ diff --git a/web/assets/style.css b/web/assets/style.css new file mode 100644 index 0000000..7fa0554 --- /dev/null +++ b/web/assets/style.css @@ -0,0 +1,169 @@ +/* scarce project page — dark stage, bolt gradient (cyan → violet → magenta), + system fonts only: the whole app must stay feather-light. */ + +:root { + --bg: #06060a; + --panel: #0e0e16; + --line: #1e1e2c; + --text: #e8e8f2; + --muted: #8a8aa0; + --cyan: #3ae7ff; + --violet: #9c4dff; + --magenta: #e44cff; + --grad: linear-gradient(135deg, var(--cyan), var(--violet) 55%, var(--magenta)); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; +} + +#app { + max-width: 720px; + margin: 0 auto; + padding: 40px 20px 48px; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.masthead { display: flex; align-items: center; gap: 14px; } +.logo { + border-radius: 18px; + filter: drop-shadow(0 0 18px rgba(97, 140, 255, 0.35)); +} +.wordmark { font-size: 1.5rem; font-weight: 700; letter-spacing: 0.02em; } +.wordmark .dot { + background: var(--grad); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.stage { flex: 1; padding-top: 40px; } +.loading, .error { color: var(--muted); } + +.badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel); + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--muted); +} +.badge.live { color: var(--text); border-color: rgba(58, 231, 255, 0.35); } +.pulse { + width: 9px; height: 9px; border-radius: 50%; + background: var(--grad); + animation: pulse 1.8s ease-out infinite; +} +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(58, 231, 255, 0.55); } + 70% { box-shadow: 0 0 0 12px rgba(58, 231, 255, 0); } + 100% { box-shadow: 0 0 0 0 rgba(58, 231, 255, 0); } +} + +h1.title { + margin: 20px 0 8px; + font-size: clamp(1.7rem, 5vw, 2.6rem); + line-height: 1.15; + letter-spacing: -0.015em; +} +.tagline { margin: 0 0 28px; font-size: 1.05rem; color: var(--muted); } +.tagline strong { + background: var(--grad); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.cta-row { display: flex; flex-wrap: wrap; gap: 12px; margin: 0 0 36px; } +.cta { + display: inline-block; + padding: 12px 22px; + border-radius: 12px; + font-weight: 650; + text-decoration: none; + transition: transform 0.12s ease, box-shadow 0.12s ease; +} +.cta.primary { + color: #05050a; + background: var(--grad); + box-shadow: 0 4px 24px rgba(156, 77, 255, 0.35); +} +.cta.ghost { color: var(--text); border: 1px solid var(--line); background: var(--panel); } +.cta:hover { transform: translateY(-1px); box-shadow: 0 6px 30px rgba(156, 77, 255, 0.45); } +.cta.ghost:hover { box-shadow: 0 4px 18px rgba(30, 30, 44, 0.8); } + +.section-label { + margin: 0 0 12px; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--muted); +} + +ol.milestones { list-style: none; margin: 0 0 36px; padding: 0; } +ol.milestones li { + position: relative; + padding: 14px 16px 14px 44px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); + margin-bottom: 10px; +} +ol.milestones li::before { + content: counter(ms); + counter-increment: ms; + position: absolute; + left: 14px; top: 15px; + width: 20px; height: 20px; + border-radius: 50%; + background: var(--grad); + color: #05050a; + font-size: 0.72rem; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; +} +ol.milestones { counter-reset: ms; } +ol.milestones .ms-title { font-weight: 650; } +ol.milestones .ms-desc { color: var(--muted); font-size: 0.92rem; margin-top: 2px; } + +dl.meta { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 18px; + margin: 0 0 36px; + font-size: 0.92rem; +} +dl.meta dt { color: var(--muted); } +dl.meta dd { margin: 0; overflow-wrap: anywhere; } +dl.meta code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85em; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 6px; + padding: 1px 6px; +} + +.footer { + border-top: 1px solid var(--line); + padding-top: 18px; + font-size: 0.85rem; + color: var(--muted); +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..29c821b --- /dev/null +++ b/web/index.html @@ -0,0 +1,27 @@ + + + + + + scarce — project + + + + + +
+
+ + scarce. +
+
+

summoning the crew…

+
+
+ an autonomous build by scarce — the agent studio +
+
+ + + +