diff --git a/crates/studio-api/src/endpoints/api_index.rs b/crates/studio-api/src/endpoints/api_index.rs index 1c9651f..9921395 100644 --- a/crates/studio-api/src/endpoints/api_index.rs +++ b/crates/studio-api/src/endpoints/api_index.rs @@ -16,6 +16,7 @@ pub async fn handler() -> impl IntoResponse { "service": "scarce-studio", "version": env!("CARGO_PKG_VERSION"), "endpoints": [ + { "method": "GET", "path": "/openapi.json", "description": "OpenAPI 3.1 description of this surface" }, { "method": "GET", "path": "/api/v1", "description": "this index" }, { "method": "GET", "path": "/api/v1/schemas/{name}", "description": "JSON Schema of a wire type" }, { "method": "POST", "path": "/api/v1/rfqs", "description": "capture a demand record (schema: rfq)" }, diff --git a/crates/studio-api/src/endpoints/get_openapi.rs b/crates/studio-api/src/endpoints/get_openapi.rs new file mode 100644 index 0000000..df670c7 --- /dev/null +++ b/crates/studio-api/src/endpoints/get_openapi.rs @@ -0,0 +1,16 @@ +//! `GET /openapi.json` — serves the assembled OpenAPI 3.1 document (see +//! `crate::openapi`). Root-mounted by convention: this is the well-known +//! discovery surface a payment gateway (or any client) probes first. + +use std::sync::Arc; + +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; + +use crate::AppState; + +pub async fn handler(State(state): State>) -> impl IntoResponse { + ( + StatusCode::OK, + Json(crate::openapi::document(&state.public_url)), + ) +} diff --git a/crates/studio-api/src/endpoints/mod.rs b/crates/studio-api/src/endpoints/mod.rs index a45b380..1e1a960 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_openapi; pub mod get_project; pub mod get_quote; pub mod get_rfq; diff --git a/crates/studio-api/src/lib.rs b/crates/studio-api/src/lib.rs index 359f61b..a1a7429 100644 --- a/crates/studio-api/src/lib.rs +++ b/crates/studio-api/src/lib.rs @@ -18,6 +18,7 @@ use sqlx::SqlitePool; use studio_types::{Quote, Rfq}; pub mod endpoints; +pub mod openapi; pub mod web; /// Buzz Desktop download link the project page offers — the canonical @@ -72,6 +73,9 @@ pub fn router(state: Arc) -> Router { // `/api/v1` so the contract can evolve without breaking callers. Router::new() .route("/healthz", get(healthz)) + // Unversioned like /healthz: the well-known discovery location a + // gateway (or any client) probes first. + .route("/openapi.json", get(endpoints::get_openapi::handler)) .route("/api/v1", get(endpoints::api_index::handler)) .route( "/api/v1/schemas/{name}", diff --git a/crates/studio-api/src/openapi.rs b/crates/studio-api/src/openapi.rs new file mode 100644 index 0000000..df851ff --- /dev/null +++ b/crates/studio-api/src/openapi.rs @@ -0,0 +1,379 @@ +//! `GET /openapi.json` — the OpenAPI 3.1 description of this surface. +//! +//! Assembled, not hand-maintained: every component schema comes from the +//! published registry (`studio_types::schemas`), i.e. from the same +//! schemars derives that generate `schemas/*.json` — the OpenAPI contract +//! cannot drift from the wire types. OpenAPI 3.1 speaks JSON Schema +//! 2020-12 natively, so the registry schemas embed unchanged except for +//! one mechanical transform: each schema's `$defs` are hoisted into +//! `#/components/schemas` (deduplicated, refs rewritten) so the document +//! is plain-pointer resolvable by tooling that mishandles embedded `$id`. +//! +//! This document is the discovery surface a payment gateway gates against; +//! `tests/openapi_api.rs` holds the drift guards (every documented +//! operation is routed; every advertised endpoint is documented). + +use serde_json::{json, Map, Value}; + +/// Build the document. `public_url` becomes the `servers` entry, so the +/// served description is addressable as deployed (e.g. behind a gateway). +pub fn document(public_url: &str) -> Value { + json!({ + "openapi": "3.1.0", + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "info": { + "title": "scarce-studio", + "version": env!("CARGO_PKG_VERSION"), + "description": "HTTP surface of scarced — RFQ capture, quote issuance, buyer acceptance, and the public project view. Buyer surfaces are free and unauthenticated (ARCHITECTURE.md §2.1); quote issuance is the studio's own door (bearer token). Validation failures return 422 with { errors: [{ field, message }] }. Raw JSON Schemas: GET /api/v1/schemas/{name}.", + "license": { "name": "Apache-2.0", "identifier": "Apache-2.0" }, + }, + "servers": [{ "url": public_url }], + // Public by default (buyers never authenticate); the one + // studio-authenticated operation overrides with studio_bearer. + "security": [], + "paths": paths(), + "components": { + "schemas": component_schemas(), + "securitySchemes": { + "studio_bearer": { + "type": "http", + "scheme": "bearer", + "description": "The studio's own door (SCARCED_STUDIO_TOKEN). Never a buyer surface — buyers never authenticate.", + }, + }, + }, + }) +} + +/// Every route the router serves, described. Ordered as in `router()`. +fn paths() -> Value { + json!({ + "/healthz": { + "get": { + "operationId": "healthz", + "summary": "Liveness + readiness — 200 only when the projection store answers", + "responses": { + "200": json_response("service and store healthy", json!({ + "type": "object", + "required": ["status", "version"], + "properties": { + "status": { "const": "ok" }, + "version": { "type": "string" }, + }, + })), + "503": json_response("projection store unreachable", json!({ + "type": "object", + "properties": { "status": { "const": "degraded" }, "store": { "type": "string" } }, + })), + }, + }, + }, + "/openapi.json": { + "get": { + "operationId": "openapi", + "summary": "This document", + "responses": { + "200": json_response("the OpenAPI 3.1 description of this surface", json!({ "type": "object" })), + }, + }, + }, + "/api/v1": { + "get": { + "operationId": "api_index", + "summary": "Discovery index — endpoints and published schemas, from the base URL alone", + "responses": { + "200": json_response("the index", json!({ + "type": "object", + "required": ["service", "version", "endpoints", "schemas"], + "properties": { + "service": { "const": "scarce-studio" }, + "version": { "type": "string" }, + "endpoints": { "type": "array", "items": { "type": "object" } }, + "schemas": { "type": "array", "items": { "type": "object" } }, + "errors": { "type": "string" }, + }, + })), + }, + }, + }, + "/api/v1/schemas/{name}": { + "get": { + "operationId": "get_schema", + "summary": "JSON Schema of a wire type (same values as the checked-in schemas/*.json)", + "parameters": [path_param("name", "wire name of a published schema (see GET /api/v1)")], + "responses": { + "200": json_response("the JSON Schema", json!({ "type": "object" })), + "404": json_response("unknown schema name; body lists the available ones", json!({ + "type": "object", + "required": ["error", "available"], + "properties": { + "error": { "type": "string" }, + "available": { "type": "array", "items": { "type": "string" } }, + }, + })), + }, + }, + }, + "/api/v1/rfqs": { + "post": { + "operationId": "create_rfq", + "summary": "Capture a demand record — free, unsigned, frictionless (never tax the order book)", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": schema_ref("rfq") } }, + }, + "responses": { + "201": ref_response("the captured record, with server-assigned id and created_at", "rfq-record"), + "422": ref_response("validation failure", "validation-error"), + "500": ref_response("storage failure", "validation-error"), + }, + }, + "get": { + "operationId": "list_rfqs", + "summary": "The order book — captured RFQs, oldest first", + "parameters": [json!({ + "name": "since", + "in": "query", + "required": false, + "schema": { "type": "string", "format": "date-time" }, + "description": "RFC 3339 timestamp; only RFQs captured at or after it are returned", + })], + "responses": { + "200": json_response("the RFQs", json!({ + "type": "object", + "required": ["rfqs"], + "properties": { "rfqs": { "type": "array", "items": schema_ref("rfq-record") } }, + })), + "422": ref_response("malformed since parameter", "validation-error"), + "500": ref_response("storage failure", "error"), + }, + }, + }, + "/api/v1/rfqs/{id}": { + "get": { + "operationId": "get_rfq", + "summary": "Fetch one captured RFQ — free read", + "parameters": [path_param("id", "RFQ id")], + "responses": { + "200": ref_response("the record", "rfq-record"), + "404": ref_response("no such RFQ", "error"), + "500": ref_response("storage failure", "error"), + }, + }, + }, + "/api/v1/rfqs/{id}/quote": { + "post": { + "operationId": "create_quote", + "summary": "Issue the quote for an RFQ — studio-authenticated; fail-closed when no token is configured", + "security": [{ "studio_bearer": [] }], + "parameters": [path_param("id", "RFQ id")], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": schema_ref("quote") } }, + }, + "responses": { + "201": ref_response("the issued quote, with policy_hash and expiry", "quote-record"), + "401": ref_response("missing or wrong bearer token", "error"), + "404": ref_response("no such RFQ", "error"), + "409": ref_response("a quote already exists for this RFQ", "error"), + "422": ref_response("validation failure", "validation-error"), + "500": ref_response("storage failure", "error"), + "503": ref_response("quote issuance disabled (no studio token configured)", "error"), + }, + }, + "get": { + "operationId": "get_quote", + "summary": "The buyer's free read — status fail-closed against expiry (past-expiry reads LAPSED before the sweep stamps it)", + "parameters": [path_param("id", "RFQ id")], + "responses": { + "200": ref_response("the quote, status as of now", "quote-record"), + "404": ref_response("no quote for this RFQ", "error"), + "500": ref_response("storage failure", "error"), + }, + }, + }, + "/api/v1/rfqs/{id}/quote/accept": { + "post": { + "operationId": "accept_quote", + "summary": "Accept a live quote — buyer, free, exactly once; starts the contract", + "parameters": [path_param("id", "RFQ id")], + "responses": { + "200": ref_response("the accepted quote plus project_url — the buyer's next click", "quote-accepted"), + "404": ref_response("no quote exists for this RFQ", "error"), + "409": ref_response("already accepted, or lapsed and no longer acceptable", "error"), + "500": ref_response("storage failure", "error"), + }, + }, + }, + "/api/v1/projects/{id}": { + "get": { + "operationId": "get_project", + "summary": "Public project view — deliberately commercial-free; what /project/{id} renders", + "parameters": [path_param("id", "project id (= RFQ id)")], + "responses": { + "200": ref_response("the public view", "project"), + "404": ref_response("no such project", "error"), + "500": ref_response("storage failure", "error"), + }, + }, + }, + "/project/{id}": { + "get": { + "operationId": "project_page", + "summary": "The project page — embedded web shell rendering the public view client-side", + "parameters": [path_param("id", "project id; existence is the API's answer, unknown ids render the page's own not-found state")], + "responses": { + "200": { "description": "the page shell", "content": { "text/html": {} } }, + }, + }, + }, + "/assets/{file}": { + "get": { + "operationId": "asset", + "summary": "Embedded page assets (css/js/logo), compiled into the binary", + "parameters": [path_param("file", "asset filename")], + "responses": { + "200": { "description": "the asset", "content": { "*/*": {} } }, + "404": { "description": "no such asset", "content": { "text/plain": {} } }, + }, + }, + }, + }) +} + +/// `#/components/schemas`: the published registry, plus the composite +/// response shapes the handlers assemble around it. +fn component_schemas() -> Value { + let mut components = Map::new(); + + // Wrappers first so a registry name could never be silently shadowed — + // `insert_unique` panics (test-caught, the set is static) on collision. + insert_unique( + &mut components, + "error", + json!({ + "title": "Error", + "type": "object", + "required": ["error"], + "properties": { "error": { "type": "string" } }, + }), + ); + insert_unique( + &mut components, + "validation-error", + json!({ + "title": "Validation failure", + "type": "object", + "required": ["errors"], + "properties": { + "errors": { "type": "array", "items": schema_ref("field-error") }, + }, + }), + ); + insert_unique( + &mut components, + "quote-accepted", + json!({ + "title": "Accepted quote", + "allOf": [ + schema_ref("quote-record"), + { + "type": "object", + "required": ["project_url"], + "properties": { + "project_url": { + "type": "string", + "format": "uri", + "description": "The shareable project page — the buyer's next click.", + }, + }, + }, + ], + }), + ); + + for (name, schema) in studio_types::schemas::all() { + let hoisted = hoist(schema, &mut components); + insert_unique(&mut components, name, hoisted); + } + + Value::Object(components) +} + +/// Embed one registry schema: drop the standalone-document keywords +/// (`$schema`, `$id`), hoist its `$defs` into the shared component map, and +/// rewrite internal refs accordingly. Identical defs (the shared types — +/// `Amount`, `GatePolicy`, …) deduplicate; a same-name different-shape def +/// panics (test-caught: the registry is static). +fn hoist(mut schema: Value, components: &mut Map) -> Value { + let object = schema + .as_object_mut() + .expect("registry schema is an object"); + object.remove("$schema"); + object.remove("$id"); + if let Some(Value::Object(defs)) = object.remove("$defs") { + for (def_name, mut def) in defs { + rewrite_refs(&mut def); + match components.get(&def_name) { + None => { + components.insert(def_name, def); + } + Some(existing) => assert_eq!( + *existing, def, + "component {def_name:?} generated with two different shapes" + ), + } + } + } + rewrite_refs(&mut schema); + schema +} + +/// `#/$defs/X` → `#/components/schemas/X`, recursively. +fn rewrite_refs(value: &mut Value) { + match value { + Value::Object(object) => { + for (key, entry) in object.iter_mut() { + if key == "$ref" { + if let Some(target) = entry.as_str().and_then(|r| r.strip_prefix("#/$defs/")) { + *entry = Value::String(format!("#/components/schemas/{target}")); + continue; + } + } + rewrite_refs(entry); + } + } + Value::Array(items) => items.iter_mut().for_each(rewrite_refs), + _ => {} + } +} + +fn insert_unique(components: &mut Map, name: &str, schema: Value) { + let previous = components.insert(name.to_string(), schema); + assert!(previous.is_none(), "component {name:?} defined twice"); +} + +fn schema_ref(name: &str) -> Value { + json!({ "$ref": format!("#/components/schemas/{name}") }) +} + +fn json_response(description: &str, schema: Value) -> Value { + json!({ + "description": description, + "content": { "application/json": { "schema": schema } }, + }) +} + +fn ref_response(description: &str, component: &str) -> Value { + json_response(description, schema_ref(component)) +} + +fn path_param(name: &str, description: &str) -> Value { + json!({ + "name": name, + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": description, + }) +} diff --git a/crates/studio-api/tests/openapi_api.rs b/crates/studio-api/tests/openapi_api.rs new file mode 100644 index 0000000..4e2a63b --- /dev/null +++ b/crates/studio-api/tests/openapi_api.rs @@ -0,0 +1,178 @@ +//! `/openapi.json` drift guards. The components cannot drift from the wire +//! types (they are assembled from `studio_types::schemas` at request time); +//! what CAN drift is the path table vs the router and the index — these +//! tests pin both directions we can observe. + +use std::sync::Arc; + +use axum::body::{Body, Bytes}; +use axum::http::{header, Request, StatusCode}; +use http_body_util::BodyExt; +use studio_api::{router, AppState}; +use tower::ServiceExt; + +const PUBLIC_URL: &str = "https://scarce.sh"; + +async fn app() -> axum::Router { + let db = studio_store::open("sqlite::memory:").await.unwrap(); + router(Arc::new(AppState { + db, + studio_token: None, + lifecycle: None, + public_url: PUBLIC_URL.into(), + community_web_url: Some("https://scarce.communities.buzz.xyz".into()), + invite_url: Default::default(), + })) +} + +async fn send( + app: &axum::Router, + method: &str, + uri: &str, + body: Option, +) -> (StatusCode, Bytes) { + let request = match body { + Some(json) => Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(json.to_string())) + .unwrap(), + None => Request::builder() + .method(method) + .uri(uri) + .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(); + (status, bytes) +} + +async fn document(app: &axum::Router) -> serde_json::Value { + let (status, bytes) = send(app, "GET", "/openapi.json", None).await; + assert_eq!(status, StatusCode::OK); + serde_json::from_slice(&bytes).unwrap() +} + +#[tokio::test] +async fn serves_a_well_formed_document() { + let app = app().await; + let doc = document(&app).await; + + assert_eq!(doc["openapi"], "3.1.0"); + assert_eq!(doc["info"]["title"], "scarce-studio"); + assert_eq!(doc["info"]["version"], env!("CARGO_PKG_VERSION")); + // Addressable as deployed: servers comes from the configured public_url. + assert_eq!(doc["servers"][0]["url"], PUBLIC_URL); +} + +/// Every operation the document claims must actually be routed. An unmatched +/// path falls through to axum's bare fallback (404, empty body) and a wrong +/// method yields 405 — any documented operation producing either is a lie. +#[tokio::test] +async fn every_documented_operation_is_routed() { + let app = app().await; + let doc = document(&app).await; + + for (path, item) in doc["paths"].as_object().unwrap() { + for (method, _) in item.as_object().unwrap() { + let uri = path + .replace("{name}", "rfq") + .replace("{id}", "no-such-id") + .replace("{file}", "style.css"); + let body = (method.as_str() == "post").then(|| serde_json::json!({})); + let (status, bytes) = send(&app, &method.to_uppercase(), &uri, body).await; + + assert_ne!( + status, + StatusCode::METHOD_NOT_ALLOWED, + "{method} {path} is documented but the router rejects the method" + ); + assert!( + status != StatusCode::NOT_FOUND || !bytes.is_empty(), + "{method} {path} is documented but hit the router's bare fallback" + ); + } + } +} + +/// The other direction we can observe: everything `GET /api/v1` advertises +/// (the hand-maintained index, updated whenever routes accrete) must appear +/// in the document. +#[tokio::test] +async fn documents_every_advertised_endpoint() { + let app = app().await; + let doc = document(&app).await; + + let (status, bytes) = send(&app, "GET", "/api/v1", None).await; + assert_eq!(status, StatusCode::OK); + let index: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + for endpoint in index["endpoints"].as_array().unwrap() { + let path = endpoint["path"].as_str().unwrap(); + let method = endpoint["method"].as_str().unwrap().to_lowercase(); + assert!( + doc["paths"][path][&method].is_object(), + "index advertises {method} {path} but /openapi.json does not document it" + ); + } +} + +/// Every `$ref` in the document points at an existing component — the +/// `$defs`-hoisting transform must leave no dangling pointer. +#[tokio::test] +async fn every_ref_resolves() { + let app = app().await; + let doc = document(&app).await; + let components = doc["components"]["schemas"].as_object().unwrap(); + assert!(!components.is_empty()); + + let mut refs = Vec::new(); + collect_refs(&doc, &mut refs); + assert!(!refs.is_empty()); + for reference in refs { + let target = reference + .strip_prefix("#/components/schemas/") + .unwrap_or_else(|| panic!("non-component ref survived hoisting: {reference}")); + assert!( + components.contains_key(target), + "dangling $ref: {reference}" + ); + } +} + +/// The whole published registry is embedded: anything served at +/// `GET /api/v1/schemas/{name}` is also a named component of the document. +#[tokio::test] +async fn components_include_the_published_registry() { + let app = app().await; + let doc = document(&app).await; + let components = doc["components"]["schemas"].as_object().unwrap(); + + for (name, _) in studio_types::schemas::all() { + assert!( + components.contains_key(name), + "published schema {name:?} missing from components" + ); + } +} + +fn collect_refs(value: &serde_json::Value, out: &mut Vec) { + match value { + serde_json::Value::Object(object) => { + for (key, entry) in object { + if key == "$ref" { + if let Some(reference) = entry.as_str() { + out.push(reference.to_string()); + continue; + } + } + collect_refs(entry, out); + } + } + serde_json::Value::Array(items) => items.iter().for_each(|v| collect_refs(v, out)), + _ => {} + } +} diff --git a/crates/studio-types/src/quote.rs b/crates/studio-types/src/quote.rs index c8b6fda..34f6e62 100644 --- a/crates/studio-types/src/quote.rs +++ b/crates/studio-types/src/quote.rs @@ -99,7 +99,7 @@ fn default_grace_seconds() -> u64 { /// quote never lapses; the engagement it started owns the clock from there). /// ACCEPTED stands in for FUNDED while payments are stubbed (PLAN.md §6 /// override path, ludovic 2026-08-01). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum QuoteStatus { Quoted, @@ -108,7 +108,8 @@ pub enum QuoteStatus { } /// An issued quote — what the quote endpoints return. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(title = "Quote record")] pub struct Quote { pub id: String, pub rfq_id: String, diff --git a/crates/studio-types/src/rfq.rs b/crates/studio-types/src/rfq.rs index 3488214..822feeb 100644 --- a/crates/studio-types/src/rfq.rs +++ b/crates/studio-types/src/rfq.rs @@ -64,7 +64,8 @@ pub struct Amount { } /// A captured demand record — what `POST /api/v1/rfqs` returns. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(title = "RFQ record")] pub struct Rfq { pub id: String, pub query: String, @@ -81,9 +82,11 @@ pub struct Rfq { /// One field-level validation failure — serialized into 422 bodies. /// `field` is a path (e.g. `milestones[1].amount`), so it is owned. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq, JsonSchema)] pub struct FieldError { + /// Path of the offending field (e.g. `milestones[1].amount`). pub field: String, + /// What a valid value looks like — actionable, not a bare "invalid". pub message: String, } diff --git a/crates/studio-types/src/schemas.rs b/crates/studio-types/src/schemas.rs index 60c577e..6b43710 100644 --- a/crates/studio-types/src/schemas.rs +++ b/crates/studio-types/src/schemas.rs @@ -15,9 +15,12 @@ const ID_BASE: &str = "https://scarce.studio/schemas"; pub fn all() -> Vec<(&'static str, serde_json::Value)> { vec![ ("rfq", rfq()), + ("rfq-record", rfq_record()), ("quote", quote()), + ("quote-record", quote_record()), ("gate-policy", gate_policy()), ("project", project()), + ("field-error", field_error()), ] } @@ -38,6 +41,26 @@ pub fn quote() -> serde_json::Value { finalize("quote", schema_for!(crate::quote::NewQuote)) } +/// `schemas/rfq-record.json` — the captured demand record, as returned by +/// `POST /api/v1/rfqs` and the RFQ reads (submission + server-assigned +/// `id` / `created_at`). +pub fn rfq_record() -> serde_json::Value { + finalize("rfq-record", schema_for!(crate::rfq::Rfq)) +} + +/// `schemas/quote-record.json` — the issued quote, as returned by the quote +/// endpoints (submission + identity, `policy_hash`, status, lifecycle +/// timestamps). +pub fn quote_record() -> serde_json::Value { + finalize("quote-record", schema_for!(crate::quote::Quote)) +} + +/// `schemas/field-error.json` — one field-level validation failure; 422 +/// bodies are `{ "errors": [field-error, …] }`. +pub fn field_error() -> serde_json::Value { + finalize("field-error", schema_for!(crate::rfq::FieldError)) +} + /// `schemas/gate-policy.json` — the standalone gate-policy contract, for /// consumers that exchange policies outside a quote. pub fn gate_policy() -> serde_json::Value { diff --git a/schemas/field-error.json b/schemas/field-error.json new file mode 100644 index 0000000..3f9081b --- /dev/null +++ b/schemas/field-error.json @@ -0,0 +1,21 @@ +{ + "$id": "https://scarce.studio/schemas/field-error.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "One field-level validation failure — serialized into 422 bodies.\n`field` is a path (e.g. `milestones[1].amount`), so it is owned.", + "properties": { + "field": { + "description": "Path of the offending field (e.g. `milestones[1].amount`).", + "type": "string" + }, + "message": { + "description": "What a valid value looks like — actionable, not a bare \"invalid\".", + "type": "string" + } + }, + "required": [ + "field", + "message" + ], + "title": "FieldError", + "type": "object" +} diff --git a/schemas/quote-record.json b/schemas/quote-record.json new file mode 100644 index 0000000..f685693 --- /dev/null +++ b/schemas/quote-record.json @@ -0,0 +1,366 @@ +{ + "$defs": { + "Amount": { + "additionalProperties": false, + "description": "Token amount in minor units of `mint`.", + "properties": { + "amount": { + "description": "Token amount in minor units of `mint`.", + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "mint": { + "description": "SPL mint address (e.g. USDC). Free-form here; enforced at quote time.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "amount", + "mint" + ], + "type": "object" + }, + "ChannelParams": { + "additionalProperties": false, + "description": "MPP session channel parameters the quote commits to.", + "properties": { + "grace_seconds": { + "default": 172800, + "description": "Buyer-exit grace window, seconds. Default 172800 (48h) per PLAN.md M2.", + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "idle_timeout_seconds": { + "description": "Idle window after which the studio settles at watermark and closes.\nNo default on purpose: the quote must commit to it explicitly.", + "format": "uint64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "idle_timeout_seconds" + ], + "type": "object" + }, + "GatePolicy": { + "additionalProperties": false, + "description": "A project's procedural law (PLAN.md §2.1): state-machine edge → ordered hard gates. Negotiated in the Quote, hash-committed at FUNDED, tamper-evident thereafter. Semantics enforced by the engine, not expressible here: fail-closed evaluation, denial-with-note as recorded history, loud audited overrides. The implementation is additionally stricter than this schema: self-loop edge keys are rejected, agent lists must be duplicate-free, and k must not exceed the number of named agents.", + "properties": { + "edges": { + "additionalProperties": false, + "description": "Edge key `FROM->TO` (or wildcard `any->TO`) → ordered gates. A\nconcrete edge is guarded by its exact entry followed by any matching\nwildcard entry — both apply.", + "patternProperties": { + "^(any|RFQ_CAPTURED|QUOTED|LAPSED|FUNDED|WORKROOM_ACTIVE|BUILDING|DEMOED|ACCEPTED|DELIVERED|OPERATING|CLOSED_BY_BUYER|CLOSED_IDLE)->(RFQ_CAPTURED|QUOTED|LAPSED|FUNDED|WORKROOM_ACTIVE|BUILDING|DEMOED|ACCEPTED|DELIVERED|OPERATING|CLOSED_BY_BUYER|CLOSED_IDLE)$": { + "items": { + "$ref": "#/$defs/GateSpec" + }, + "type": "array" + } + }, + "type": "object" + } + }, + "required": [ + "edges" + ], + "title": "Gate policy", + "type": "object" + }, + "GateSpec": { + "description": "One gate on a state-machine edge (internally tagged on `type`).", + "oneOf": [ + { + "additionalProperties": false, + "description": "A named principal must approve. Primary mechanism: native Buzz\nworkflow approval tokens; fallback: signed channel message with fixed\ngrammar. The principal is an opaque label (npub or a role like\n`buyer`) — evidence must carry the same label.", + "properties": { + "escalate_after_seconds": { + "description": "Blocked longer than this (from edge eligibility) → escalate to\nthe gate's principal, then the studio owner. Never auto-passes.", + "format": "uint64", + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "principal": { + "description": "Opaque principal label — an npub or a role like `buyer`.\nEvidence must carry the same label.", + "minLength": 1, + "type": "string" + }, + "type": { + "const": "human_approval", + "type": "string" + } + }, + "required": [ + "type", + "principal" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "k-of-n signed sign-offs from named crew agents.", + "properties": { + "agents": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "escalate_after_seconds": { + "format": "uint64", + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "k": { + "format": "uint32", + "minimum": 1, + "type": "integer" + }, + "type": { + "const": "agent_signoff", + "type": "string" + } + }, + "required": [ + "type", + "agents", + "k" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A named verifiable predicate (CI green, endpoint answers its 402\nchallenge, schema validation passes). Reruns are natural, so the\nlatest result wins; a stale pass is expired evidence and blocks.", + "properties": { + "check": { + "minLength": 1, + "type": "string" + }, + "max_age_seconds": { + "format": "uint64", + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "type": { + "const": "machine_check", + "type": "string" + } + }, + "required": [ + "type", + "check" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "`PayPort` status: operator record in stub mode, tx signature live.", + "properties": { + "type": { + "const": "payment_evidence", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Minimum elapsed review window since the edge became eligible.", + "properties": { + "min_seconds": { + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "type": { + "const": "timelock", + "type": "string" + } + }, + "required": [ + "type", + "min_seconds" + ], + "type": "object" + } + ] + }, + "MilestoneSpec": { + "additionalProperties": false, + "description": "One milestone: a demoable, acceptable, priced unit of work.", + "properties": { + "amount": { + "description": "Minor units of the quote's `price.mint`.", + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "description": { + "minLength": 1, + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "title", + "description", + "amount" + ], + "type": "object" + }, + "PayoutDestination": { + "description": "Where settled funds go. v0: direct channel splits, exactly DESIGN.md\n§4.3(a) — escrow pays the crew, no studio custody. The `vault` variant\n(v1, ARCHITECTURE.md §2.3) will slot in beside `splits`.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "splits", + "type": "string" + }, + "splits": { + "items": { + "$ref": "#/$defs/Split" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "kind", + "splits" + ], + "type": "object" + } + ] + }, + "QuoteStatus": { + "description": "Quote lifecycle (PLAN.md §2): issued → QUOTED; expiry sweep or read-side\nderivation → LAPSED; buyer acceptance → ACCEPTED (sticky — an accepted\nquote never lapses; the engagement it started owns the clock from there).\nACCEPTED stands in for FUNDED while payments are stubbed (PLAN.md §6\noverride path, ludovic 2026-08-01).", + "enum": [ + "QUOTED", + "LAPSED", + "ACCEPTED" + ], + "type": "string" + }, + "Split": { + "additionalProperties": false, + "description": "One recipient's share, in basis points. Splits must sum to exactly\n10_000 bps — every lamport of a settlement is accounted for.", + "properties": { + "bps": { + "format": "uint32", + "maximum": 10000, + "minimum": 1, + "type": "integer" + }, + "recipient": { + "description": "Solana address. Free-form here, like `Amount::mint`; enforced when\nthe live PayPort builds real session terms (M5).", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "recipient", + "bps" + ], + "type": "object" + } + }, + "$id": "https://scarce.studio/schemas/quote-record.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "An issued quote — what the quote endpoints return.", + "properties": { + "accepted_at": { + "description": "Buyer acceptance instant. Set exactly once; never on a lapsed quote.", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "channel": { + "$ref": "#/$defs/ChannelParams" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "expires_at": { + "format": "date-time", + "type": "string" + }, + "gate_policy": { + "$ref": "#/$defs/GatePolicy" + }, + "id": { + "type": "string" + }, + "lapsed_at": { + "description": "Set by the expiry sweep; `expires_at` when derived at read time.", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "milestones": { + "items": { + "$ref": "#/$defs/MilestoneSpec" + }, + "type": "array" + }, + "payout_destination": { + "$ref": "#/$defs/PayoutDestination" + }, + "policy_hash": { + "description": "`studio-core::gate::commitment_hash(&gate_policy)`, precomputed at\nissue time. Recorded again (and enforced) at the FUNDED transition —\nPLAN.md §2.1(4).", + "type": "string" + }, + "price": { + "$ref": "#/$defs/Amount" + }, + "rfq_id": { + "type": "string" + }, + "status": { + "$ref": "#/$defs/QuoteStatus" + }, + "timeline": { + "type": "string" + } + }, + "required": [ + "id", + "rfq_id", + "price", + "milestones", + "timeline", + "payout_destination", + "channel", + "gate_policy", + "policy_hash", + "expires_at", + "status", + "created_at" + ], + "title": "Quote record", + "type": "object" +} diff --git a/schemas/rfq-record.json b/schemas/rfq-record.json new file mode 100644 index 0000000..9e93c0e --- /dev/null +++ b/schemas/rfq-record.json @@ -0,0 +1,89 @@ +{ + "$defs": { + "Amount": { + "additionalProperties": false, + "description": "Token amount in minor units of `mint`.", + "properties": { + "amount": { + "description": "Token amount in minor units of `mint`.", + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "mint": { + "description": "SPL mint address (e.g. USDC). Free-form here; enforced at quote time.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "amount", + "mint" + ], + "type": "object" + } + }, + "$id": "https://scarce.studio/schemas/rfq-record.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A captured demand record — what `POST /api/v1/rfqs` returns.", + "properties": { + "budget_ceiling": { + "anyOf": [ + { + "$ref": "#/$defs/Amount" + }, + { + "type": "null" + } + ] + }, + "buyer_npub": { + "type": "string" + }, + "buyer_signature": { + "description": "Reserved (see `NewRfq::buyer_signature`); recorded, not verified.", + "type": [ + "string", + "null" + ] + }, + "competition": { + "items": { + "type": "string" + }, + "type": "array" + }, + "created_at": { + "description": "RFC 3339, UTC, server-assigned at capture.", + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "monetization": { + "type": [ + "string", + "null" + ] + }, + "product": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": "string" + } + }, + "required": [ + "id", + "query", + "competition", + "buyer_npub", + "created_at" + ], + "title": "RFQ record", + "type": "object" +}