Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
465 changes: 464 additions & 1 deletion Cargo.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions crates/studio-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
5 changes: 4 additions & 1 deletion crates/studio-api/src/endpoints/accept_quote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion crates/studio-api/src/endpoints/api_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }] }",
Expand Down
72 changes: 72 additions & 0 deletions crates/studio-api/src/endpoints/get_project.rs
Original file line number Diff line number Diff line change
@@ -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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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<serde_json::Value>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "storage failure" })),
)
}
1 change: 1 addition & 0 deletions crates/studio-api/src/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions crates/studio-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<tokio::sync::mpsc::UnboundedSender<LifecycleBeat>>,
/// 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<String>,
/// 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<std::sync::RwLock<Option<String>>>,
}

impl AppState {
Expand Down Expand Up @@ -75,6 +90,14 @@ pub fn router(state: Arc<AppState>) -> 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)
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
55 changes: 55 additions & 0 deletions crates/studio-api/src/web.rs
Original file line number Diff line number Diff line change
@@ -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<String>) -> 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(),
),
}
}
Loading
Loading