From 4e25800e28c026edaabc93ee0eb95a0494adcf90 Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Sat, 1 Aug 2026 16:57:08 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20embedded=20project=20page=20?= =?UTF-8?q?=E2=80=94=20/project/{id},=20public=20project=20view,=20scarce?= =?UTF-8?q?=20web=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acceptance now returns project_url ({public_url}/project/{rfq_id}); the workroom contract post and ops post carry it. The page is a super-light web app embedded in the binary (checked-in vanilla HTML/CSS/JS under web/, include_dir — same mechanism as pay's web-ui, no node toolchain): scarce logo, live state badge, milestone scope, and Buzz onboarding links (community web derived from the relay URL, desktop download). GET /api/v1/projects/{id} serves the view (schema: project) — deliberately commercial-free: no price, splits, budget, policy or npub survives serialization (tested). workroom_name moved to studio-core so mirror and page can never disagree. New config key public_url. --- Cargo.lock | 38 +++ Cargo.toml | 5 + README.md | 16 +- crates/studio-api/Cargo.toml | 2 + .../studio-api/src/endpoints/accept_quote.rs | 5 +- crates/studio-api/src/endpoints/api_index.rs | 4 +- .../studio-api/src/endpoints/get_project.rs | 71 +++++ crates/studio-api/src/endpoints/mod.rs | 1 + crates/studio-api/src/lib.rs | 23 ++ crates/studio-api/src/web.rs | 55 ++++ crates/studio-api/tests/project_api.rs | 258 ++++++++++++++++++ crates/studio-api/tests/quote_api.rs | 4 + crates/studio-api/tests/rfq_api.rs | 2 + crates/studio-core/src/lib.rs | 4 +- crates/studio-core/src/project.rs | 233 ++++++++++++++++ crates/studio-types/src/lib.rs | 2 + crates/studio-types/src/project.rs | 69 +++++ crates/studio-types/src/schemas.rs | 8 + crates/studio-types/src/state.rs | 5 +- scarced.example.yaml | 4 + schemas/project.json | 169 ++++++++++++ src/config.rs | 26 ++ src/main.rs | 23 +- src/mirror.rs | 51 ++-- web/assets/app.js | 144 ++++++++++ web/assets/logo.png | Bin 0 -> 116460 bytes web/assets/style.css | 169 ++++++++++++ web/index.html | 27 ++ 28 files changed, 1383 insertions(+), 35 deletions(-) create mode 100644 crates/studio-api/src/endpoints/get_project.rs create mode 100644 crates/studio-api/src/web.rs create mode 100644 crates/studio-api/tests/project_api.rs create mode 100644 crates/studio-core/src/project.rs create mode 100644 crates/studio-types/src/project.rs create mode 100644 schemas/project.json create mode 100644 web/assets/app.js create mode 100644 web/assets/logo.png create mode 100644 web/assets/style.css create mode 100644 web/index.html diff --git a/Cargo.lock b/Cargo.lock index 52b10fc..52c13b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1276,6 +1276,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" @@ -1457,6 +1476,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" @@ -2044,6 +2073,7 @@ dependencies = [ "sqlx", "studio-api", "studio-buzz", + "studio-core", "studio-registry", "studio-store", "studio-types", @@ -2526,6 +2556,8 @@ dependencies = [ "axum", "chrono", "http-body-util", + "include_dir", + "mime_guess", "serde", "serde_json", "sqlx", @@ -2976,6 +3008,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" diff --git a/Cargo.toml b/Cargo.toml index 04d0489..e387b1e 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,6 +84,10 @@ 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" # WSS needs a process-level rustls CryptoProvider; every binary entry point # installs ring explicitly (same pin/reason as buzz-cli) — relying on feature 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..291da93 --- /dev/null +++ b/crates/studio-api/src/endpoints/get_project.rs @@ -0,0 +1,71 @@ +//! `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 { + 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..7f52c58 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. One place to change +/// when the canonical URL moves. +pub const BUZZ_DESKTOP_URL: &str = "https://buzz.xyz"; /// 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,12 @@ 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, } impl AppState { @@ -75,6 +86,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 +134,8 @@ mod tests { db, studio_token: None, lifecycle: None, + public_url: "http://127.0.0.1:7380".into(), + community_web_url: None, })); let response = app @@ -141,6 +162,8 @@ mod tests { db, studio_token: None, lifecycle: None, + public_url: "http://127.0.0.1:7380".into(), + community_web_url: None, })); 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..ee1477c --- /dev/null +++ b/crates/studio-api/tests/project_api.rs @@ -0,0 +1,258 @@ +//! 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 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()), + })); + (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" + ); + + // 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..fddd641 100644 --- a/crates/studio-api/tests/quote_api.rs +++ b/crates/studio-api/tests/quote_api.rs @@ -19,6 +19,8 @@ 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()), })) } @@ -338,6 +340,8 @@ 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()), })); 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..44b892c 100644 --- a/crates/studio-api/tests/rfq_api.rs +++ b/crates/studio-api/tests/rfq_api.rs @@ -16,6 +16,8 @@ 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()), })) } 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..fd0bcf1 --- /dev/null +++ b/crates/studio-core/src/project.rs @@ -0,0 +1,233 @@ +//! 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 { + community_web: Some("https://scarce.communities.buzz.xyz".into()), + buzz_desktop: "https://buzz.xyz".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..6b8dbf9 --- /dev/null +++ b/crates/studio-types/src/project.rs @@ -0,0 +1,69 @@ +//! 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 { + /// 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..36dd0d9 --- /dev/null +++ b/schemas/project.json @@ -0,0 +1,169 @@ +{ + "$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" + ] + } + }, + "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..dd235e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,25 @@ 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 lifecycle = match &config.buzz { @@ -66,7 +85,7 @@ async fn main() -> anyhow::Result<()> { studio_pubkey = %port.public_key_hex(), "buzz lifecycle mirror enabled"); 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 +98,8 @@ async fn main() -> anyhow::Result<()> { db, studio_token: config.studio_token, lifecycle, + public_url, + community_web_url, })); let listener = tokio::net::TcpListener::bind(&config.bind) .await 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..b98b80d --- /dev/null +++ b/web/assets/app.js @@ -0,0 +1,144 @@ +// 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. + var ctas = el("div", "cta-row"); + if (project.links && project.links.community_web) { + var join = el("a", "cta primary", "Watch it live on Buzz"); + join.href = project.links.community_web; + join.target = "_blank"; + join.rel = "noopener"; + ctas.appendChild(join); + } + if (project.links && project.links.buzz_desktop) { + var desktop = el("a", "cta ghost", "Get Buzz Desktop"); + desktop.href = project.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 0000000000000000000000000000000000000000..0f41a40760e161d0028fade1ffe012a872dd4886 GIT binary patch literal 116460 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
    ($-4&10FCwZ)l0|O)Q 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 + + + + + scarce — project + + + + + +
    +
    + + scarce. +
    +
    +

    summoning the crew…

    +
    +
    + an autonomous build by scarce — the agent studio +
    +
    + + + + From 4f8886e79162f816009fc5c70fda7750669d044a Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Sat, 1 Aug 2026 17:14:57 -0400 Subject: [PATCH 2/2] feat: mint community invites for the project page CTA The relay ships a complete onboarding flow at /invite/ (Buzz Desktop deep link, platform-aware download, in-browser claim), so the page now links a minted invite instead of a bare product URL. BuzzPort grows mint_invite (NIP-98 signed POST /api/invites, x-auth-tag for managed identities); scarced keeps a live invite minted (72h ttl, re-mint daily, hourly retry). Fail-open: without owner/admin role the mint 403s, the log says why, and the page falls back to the community link. Buzz Desktop button now points at the canonical releases page. --- Cargo.lock | 427 +++++++++++++++++- Cargo.toml | 3 + .../studio-api/src/endpoints/get_project.rs | 1 + crates/studio-api/src/lib.rs | 12 +- crates/studio-api/tests/project_api.rs | 9 + crates/studio-api/tests/quote_api.rs | 2 + crates/studio-api/tests/rfq_api.rs | 1 + crates/studio-buzz/Cargo.toml | 4 + crates/studio-buzz/src/invite.rs | 163 +++++++ crates/studio-buzz/src/lib.rs | 48 ++ crates/studio-core/src/project.rs | 3 +- crates/studio-types/src/project.rs | 4 + schemas/project.json | 7 + src/main.rs | 44 ++ web/assets/app.js | 19 +- 15 files changed, 737 insertions(+), 10 deletions(-) create mode 100644 crates/studio-buzz/src/invite.rs diff --git a/Cargo.lock b/Cargo.lock index 52c13b3..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" @@ -1351,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" @@ -1383,7 +1531,7 @@ dependencies = [ "referencing", "regex", "regex-syntax", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "uuid-simd", @@ -1439,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" @@ -1626,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" @@ -1774,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" @@ -1871,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" @@ -1977,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" @@ -1991,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" @@ -2010,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", @@ -2018,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", @@ -2059,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" @@ -2083,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" @@ -2147,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" @@ -2315,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" @@ -2574,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", @@ -3135,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" @@ -3224,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" @@ -3248,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 e387b1e..118732b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,9 @@ toml = "1" 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/crates/studio-api/src/endpoints/get_project.rs b/crates/studio-api/src/endpoints/get_project.rs index 291da93..6ca7788 100644 --- a/crates/studio-api/src/endpoints/get_project.rs +++ b/crates/studio-api/src/endpoints/get_project.rs @@ -48,6 +48,7 @@ pub async fn handler( }; 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(), }; diff --git a/crates/studio-api/src/lib.rs b/crates/studio-api/src/lib.rs index 7f52c58..359f61b 100644 --- a/crates/studio-api/src/lib.rs +++ b/crates/studio-api/src/lib.rs @@ -20,9 +20,9 @@ use studio_types::{Quote, Rfq}; pub mod endpoints; pub mod web; -/// Buzz Desktop download link the project page offers. One place to change -/// when the canonical URL moves. -pub const BUZZ_DESKTOP_URL: &str = "https://buzz.xyz"; +/// 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 @@ -51,6 +51,10 @@ pub struct AppState { /// 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 { @@ -136,6 +140,7 @@ mod tests { lifecycle: None, public_url: "http://127.0.0.1:7380".into(), community_web_url: None, + invite_url: Default::default(), })); let response = app @@ -164,6 +169,7 @@ mod tests { 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/tests/project_api.rs b/crates/studio-api/tests/project_api.rs index ee1477c..efbf8bb 100644 --- a/crates/studio-api/tests/project_api.rs +++ b/crates/studio-api/tests/project_api.rs @@ -14,12 +14,16 @@ 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) } @@ -132,6 +136,11 @@ async fn project_view_walks_the_state_ladder_and_leaks_no_money() { 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( diff --git a/crates/studio-api/tests/quote_api.rs b/crates/studio-api/tests/quote_api.rs index fddd641..9493aba 100644 --- a/crates/studio-api/tests/quote_api.rs +++ b/crates/studio-api/tests/quote_api.rs @@ -21,6 +21,7 @@ async fn app_with_token(token: Option<&str>) -> axum::Router { lifecycle: None, public_url: "https://scarce.sh".into(), community_web_url: Some("https://scarce.communities.buzz.xyz".into()), + invite_url: Default::default(), })) } @@ -342,6 +343,7 @@ async fn lifecycle_beats_are_emitted_in_order() { 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 44b892c..8c50b14 100644 --- a/crates/studio-api/tests/rfq_api.rs +++ b/crates/studio-api/tests/rfq_api.rs @@ -18,6 +18,7 @@ async fn app() -> axum::Router { 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/project.rs b/crates/studio-core/src/project.rs index fd0bcf1..1ad2ee3 100644 --- a/crates/studio-core/src/project.rs +++ b/crates/studio-core/src/project.rs @@ -143,8 +143,9 @@ mod tests { 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://buzz.xyz".into(), + buzz_desktop: "https://github.com/block/buzz/releases".into(), } } diff --git a/crates/studio-types/src/project.rs b/crates/studio-types/src/project.rs index 6b8dbf9..b089705 100644 --- a/crates/studio-types/src/project.rs +++ b/crates/studio-types/src/project.rs @@ -62,6 +62,10 @@ pub struct ProjectWorkroom { /// 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. diff --git a/schemas/project.json b/schemas/project.json index 36dd0d9..7b22bfc 100644 --- a/schemas/project.json +++ b/schemas/project.json @@ -13,6 +13,13 @@ "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": [ diff --git a/src/main.rs b/src/main.rs index dd235e9..9dba917 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,6 +71,7 @@ async fn main() -> anyhow::Result<()> { // 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( @@ -84,6 +85,15 @@ 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(), public_url.clone(), rx); Some(tx) @@ -100,6 +110,7 @@ async fn main() -> anyhow::Result<()> { lifecycle, public_url, community_web_url, + invite_url, })); let listener = tokio::net::TcpListener::bind(&config.bind) .await @@ -114,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/web/assets/app.js b/web/assets/app.js index b98b80d..491bbaa 100644 --- a/web/assets/app.js +++ b/web/assets/app.js @@ -63,18 +63,27 @@ } stage.appendChild(tagline); - // Calls to action — join the community where the work happens. + // 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"); - if (project.links && project.links.community_web) { + 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 = project.links.community_web; + join.href = links.community_web; join.target = "_blank"; join.rel = "noopener"; ctas.appendChild(join); } - if (project.links && project.links.buzz_desktop) { + if (links.buzz_desktop) { var desktop = el("a", "cta ghost", "Get Buzz Desktop"); - desktop.href = project.links.buzz_desktop; + desktop.href = links.buzz_desktop; desktop.target = "_blank"; desktop.rel = "noopener"; ctas.appendChild(desktop);