Skip to main content

isb_server/server/
openapi.rs

1//! `GET /api/v1/openapi.json`: the whole HTTP surface as one OpenAPI 3.1
2//! document, generated from what serves it:
3//!
4//! - the REST tools, from the tool registry (one POST per tool the
5//!   listener offers);
6//! - the workspace resource, from [`WORKSPACE_ROUTES`] (the table its
7//!   router dispatches by) with each tool's schema as the body;
8//! - the identity endpoints, from [`crate::auth::http::spec::ROUTES`] (held
9//!   to their router by a test);
10//! - the streams, websockets, webhooks, logos, MCP and health, from
11//!   [`SURFACE`].
12//!
13//! Operations carry `x-isb-tool` (the MCP tool that does the same) or
14//! `x-isb-browser-only` (why there is none), which `docs/reference/parity.md`
15//! and the web UI's parity test read.
16
17use serde_json::{Map, Value, json};
18
19use super::Tool;
20
21/// The workspace resource (`/orgs/<org>/api/v1/workspace[ACTION]`):
22/// `(action, method, tool)`. The router dispatches by this table.
23pub const WORKSPACE_ROUTES: &[(&str, &str, &str)] = &[
24    ("", "GET", "workspace_get"),
25    ("", "POST", "workspace_create"),
26    ("", "PATCH", "workspace_update"),
27    ("", "DELETE", "workspace_delete"),
28    ("/start", "POST", "workspace_start"),
29    ("/stop", "POST", "workspace_stop"),
30    ("/restart", "POST", "workspace_restart"),
31    ("/rebuild", "POST", "workspace_rebuild"),
32    ("/token/rotate", "POST", "workspace_token_rotate"),
33    ("/settings", "GET", "workspace_settings"),
34    ("/settings", "PATCH", "workspace_settings"),
35];
36
37/// The workspace tool for `action` and `method`: `Ok(tool)`, or
38/// `Err(allowed methods)` (empty: no such action).
39pub fn workspace_tool(action: &str, method: &str) -> Result<&'static str, Vec<&'static str>> {
40    let action = if action == "/" { "" } else { action };
41    let mut allowed = Vec::new();
42    for (a, m, t) in WORKSPACE_ROUTES {
43        if *a == action {
44            if *m == method {
45                return Ok(t);
46            }
47            allowed.push(*m);
48        }
49    }
50    Err(allowed)
51}
52
53/// One route outside the tools and the identity endpoints.
54pub struct Surface {
55    pub method: &'static str,
56    pub path: &'static str,
57    pub summary: &'static str,
58    pub description: &'static str,
59    /// `json`, `sse`, `websocket`, `image`, `none`.
60    pub answers: &'static str,
61    /// Query parameters: `(name, type, description)`.
62    pub query: &'static [(&'static str, &'static str, &'static str)],
63    /// Whether it needs a credential.
64    pub signed_in: bool,
65    pub agents: Agents,
66}
67
68/// Where the same capability is for agents.
69pub enum Agents {
70    Tool(&'static str),
71    BrowserOnly(&'static str),
72    /// It is how agents (or their clients) call isb.
73    Itself,
74}
75
76/// The streams, websockets and the rest: the routes [`super::mcp::Endpoint`]
77/// and the daemon's own routes answer besides the tools and identity.
78pub const SURFACE: &[Surface] = &[
79    Surface {
80        method: "post",
81        path: "/mcp",
82        summary: "MCP (Streamable HTTP)",
83        description: "JSON-RPC 2.0: initialize, ping, tools/list, tools/call; stateless, plain JSON answers. Every tool takes an `org` argument. docs/reference/http-api.md#mcp.",
84        answers: "json",
85        query: &[],
86        signed_in: true,
87        agents: Agents::Itself,
88    },
89    Surface {
90        method: "post",
91        path: "/orgs/{org}/mcp",
92        summary: "MCP bound to one org",
93        description: "As /mcp, with `org` filled in; any other value is refused.",
94        answers: "json",
95        query: &[],
96        signed_in: true,
97        agents: Agents::Itself,
98    },
99    Surface {
100        method: "post",
101        path: "/orgs/{org}/api/v1/tools/{tool}",
102        summary: "A REST tool call in one org",
103        description: "As POST /api/v1/tools/{tool}, with `org` pinned.",
104        answers: "json",
105        query: &[],
106        signed_in: true,
107        agents: Agents::Itself,
108    },
109    Surface {
110        method: "get",
111        path: "/api/v1/tools",
112        summary: "The tools this listener offers",
113        description: "Each tool's name, title, description, input schema and annotations: MCP's tools/list over REST, plus `org_endpoint`: whether `/orgs/{org}/mcp` lists the tool (host, superadmin and platform tools are listed on `/mcp` only).",
114        answers: "json",
115        query: &[],
116        signed_in: true,
117        agents: Agents::Itself,
118    },
119    Surface {
120        method: "get",
121        path: "/api/v1/openapi.json",
122        summary: "This document",
123        description: "The whole HTTP surface, generated from the tool registry and the route tables.",
124        answers: "json",
125        query: &[],
126        signed_in: false,
127        agents: Agents::Itself,
128    },
129    Surface {
130        method: "get",
131        path: "/api/v1/events",
132        summary: "Event stream",
133        description: "Server-sent events: deploys, rollouts, health, restarts, failures, backups, jobs, certificates and deployment log lines in the caller's orgs. `id: <seq>`, `event: <level>`, JSON data; resumes from Last-Event-ID or ?since. The `events` tool is the same feed, polled.",
134        answers: "sse",
135        query: &[("since", "integer", "Resume after this sequence number.")],
136        signed_in: true,
137        agents: Agents::Tool("events"),
138    },
139    Surface {
140        method: "get",
141        path: "/api/v1/audit/stream",
142        summary: "Audit log tail",
143        description: "Server-sent `audit` events: new audit entries the caller may read (org owners and admins, platform admins). `audit_list` with `after` is the same, polled.",
144        answers: "sse",
145        query: &[
146            ("org", "string", "One org's entries."),
147            ("after", "integer", "Resume after this entry id."),
148        ],
149        signed_in: true,
150        agents: Agents::Tool("audit_list"),
151    },
152    Surface {
153        method: "get",
154        path: "/api/v1/history/stream",
155        summary: "History tail",
156        description: "Server-sent `history` events: new history items the caller may read; the event id is the cursor. `history_query` is the same, polled.",
157        answers: "sse",
158        query: &[("org", "string", "One org's history.")],
159        signed_in: true,
160        agents: Agents::Tool("history_query"),
161    },
162    Surface {
163        method: "get",
164        path: "/orgs/{org}/api/v1/terminal",
165        summary: "Web terminal (websocket)",
166        description: "Upgrades to a websocket bridged to a login shell in an app replica (?app, ?slot) or an instance (?instance), with a pseudo-terminal. Binary frames are terminal bytes; text frames are JSON resize/exit/error messages. Admitted as `sandbox_exec` in the org. docs/reference/http-api.md#the-web-terminal.",
167        answers: "websocket",
168        query: &[
169            ("app", "string", "An app to open a shell in."),
170            (
171                "slot",
172                "integer",
173                "Which replica (default: one in rotation).",
174            ),
175            (
176                "instance",
177                "string",
178                "Or an instance of the org (a workspace, a sandbox).",
179            ),
180            ("cols", "integer", "Initial columns."),
181            ("rows", "integer", "Initial rows."),
182        ],
183        signed_in: true,
184        agents: Agents::BrowserOnly(
185            "an interactive terminal for a person; agents run commands with sandbox_exec (or ssh through isb ssh-proxy)",
186        ),
187    },
188    Surface {
189        method: "get",
190        path: "/orgs/{org}/api/v1/ssh",
191        summary: "SSH over a websocket",
192        description: "Upgrades to a websocket carrying an SSH connection to `sshd -i` in an instance, for `isb ssh-proxy`. Admitted as the web terminal is. docs/reference/http-api.md#the-ssh-websocket.",
193        answers: "websocket",
194        query: &[
195            ("instance", "string", "The instance to reach."),
196            (
197                "as",
198                "string",
199                "Whose SSH keys to let in (the unix socket and superadmin tokens only).",
200            ),
201        ],
202        signed_in: true,
203        agents: Agents::Itself,
204    },
205    Surface {
206        method: "post",
207        path: "/api/v1/webhooks/{org}/{app}",
208        summary: "An app's push and pull request webhook",
209        description: "GitHub, GitLab, Gitea or generic deliveries, authenticated by the app's webhook secret (signature or token), not a session. A matching push deploys; a pull request opens, updates or closes a preview. `app_webhook` shows the URL and secret.",
210        answers: "json",
211        query: &[],
212        signed_in: false,
213        agents: Agents::BrowserOnly(
214            "called by a git host, not a person or an agent; app_deploy and preview_redeploy do the same by hand",
215        ),
216    },
217    Surface {
218        method: "get",
219        path: "/api/v1/templates/{catalog}/{id}/logo",
220        summary: "A template's logo",
221        description: "The logo image from isb's cached copy (SVG, PNG, JPEG or WebP).",
222        answers: "image",
223        query: &[],
224        signed_in: true,
225        agents: Agents::BrowserOnly(
226            "an image for the template gallery; template_get names the logo",
227        ),
228    },
229    Surface {
230        method: "get",
231        path: "/healthz",
232        summary: "Health",
233        description: "{ok, isb, stacks: [{name, converged}]}, without authentication.",
234        answers: "json",
235        query: &[],
236        signed_in: false,
237        agents: Agents::BrowserOnly(
238            "for load balancers and monitors; overview is the signed-in view",
239        ),
240    },
241];
242
243fn path_params(path: &str) -> Vec<Value> {
244    path.split('/')
245        .filter_map(|s| s.strip_prefix('{').and_then(|s| s.strip_suffix('}')))
246        .map(|p| {
247            let ty = if p == "id" { "integer" } else { "string" };
248            json!({"name": p, "in": "path", "required": true, "schema": {"type": ty}})
249        })
250        .collect()
251}
252
253fn tool_error() -> Value {
254    json!({"description": "An error: {error, message, data}", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ToolError"}}}})
255}
256
257fn tool_ok() -> Value {
258    json!({"description": "The tool's result", "content": {"application/json": {"schema": {"type": "object", "properties": {"result": {}}, "required": ["result"]}}}})
259}
260
261fn surface_op(s: &Surface) -> Value {
262    let mut params = path_params(s.path);
263    for (name, ty, desc) in s.query {
264        params.push(json!({"name": name, "in": "query", "required": false, "description": desc, "schema": {"type": ty}}));
265    }
266    let ok = match s.answers {
267        "sse" => {
268            json!({"200": {"description": "A stream of server-sent events", "content": {"text/event-stream": {"schema": {"type": "string"}}}}})
269        }
270        "websocket" => json!({"101": {"description": "Switching protocols: a websocket"}}),
271        "image" => {
272            json!({"200": {"description": "The image", "content": {"image/*": {"schema": {"type": "string", "format": "binary"}}}}})
273        }
274        _ => {
275            json!({"200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object"}}}}})
276        }
277    };
278    let mut op = json!({
279        "operationId": op_id(s.method, s.path),
280        "tags": ["surface"],
281        "summary": s.summary,
282        "description": s.description,
283        "responses": ok,
284    });
285    if !params.is_empty() {
286        op["parameters"] = json!(params);
287    }
288    if s.method == "post" {
289        op["requestBody"] = json!({"required": true, "content": {"application/json": {"schema": {"type": "object"}}}});
290    }
291    if !s.signed_in {
292        op["security"] = json!([]);
293    }
294    match s.agents {
295        Agents::Tool(t) => op["x-isb-tool"] = json!(t),
296        Agents::BrowserOnly(why) => op["x-isb-browser-only"] = json!(why),
297        Agents::Itself => {}
298    }
299    op
300}
301
302fn op_id(method: &str, path: &str) -> String {
303    let p: String = path
304        .chars()
305        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
306        .collect();
307    let p = p
308        .split('_')
309        .filter(|s| !s.is_empty())
310        .collect::<Vec<_>>()
311        .join("_");
312    format!("{method}_{p}")
313}
314
315/// The workspace resource's path items, for the workspace tools offered.
316fn workspace_paths(tools: &[&Tool], out: &mut Map<String, Value>) {
317    for (action, method, name) in WORKSPACE_ROUTES {
318        let Some(t) = tools.iter().find(|t| t.name == *name) else {
319            continue;
320        };
321        let path = format!("/orgs/{{org}}/api/v1/workspace{action}");
322        let m = method.to_ascii_lowercase();
323        let mut params = path_params(&path);
324        let mut op = json!({
325            "operationId": format!("workspace_resource_{m}{}", action.replace('/', "_")),
326            "tags": ["workspace"],
327            "summary": t.title.clone().unwrap_or_else(|| t.name.clone()),
328            "description": format!("The `{}` tool as a resource. {}", t.name, t.description),
329            "x-isb-tool": t.name,
330            "responses": {"200": tool_ok(), "default": tool_error()},
331        });
332        if m == "get" {
333            params.push(json!({"name": "name", "in": "query", "required": false, "schema": {"type": "string"}}));
334        } else {
335            op["requestBody"] = json!({"required": false, "content": {"application/json": {"schema": t.input_schema}}});
336        }
337        op["parameters"] = json!(params);
338        let item = out.entry(path).or_insert_with(|| json!({}));
339        item[m] = op;
340    }
341}
342
343/// The document, for the tools a listener offers.
344pub fn document(tools: &[&Tool]) -> Value {
345    let mut paths = Map::new();
346    for t in tools {
347        paths.insert(
348            format!("/api/v1/tools/{}", t.name),
349            json!({"post": {
350                "operationId": t.name,
351                "tags": ["tools"],
352                "summary": t.title.clone().unwrap_or_else(|| t.name.clone()),
353                "description": t.description,
354                "x-isb-tool": t.name,
355                "requestBody": {"required": true, "content": {"application/json": {"schema": t.input_schema}}},
356                "responses": {"200": tool_ok(), "default": tool_error()},
357            }}),
358        );
359    }
360    workspace_paths(tools, &mut paths);
361    paths.extend(crate::auth::http::spec::paths());
362    for s in SURFACE {
363        let item = paths.entry(s.path.to_string()).or_insert_with(|| json!({}));
364        item[s.method] = surface_op(s);
365    }
366    let mut schemas = crate::auth::http::spec::schemas();
367    schemas["ToolError"] = json!({"type": "object", "properties": {
368        "error": {"type": "string", "description": "invalid, unauthorized, forbidden, not_found, already_exists, ..."},
369        "message": {"type": "string"},
370        "data": {},
371    }, "required": ["error", "message"]});
372    json!({
373        "openapi": "3.1.0",
374        "info": {
375            "title": "isb",
376            "version": env!("CARGO_PKG_VERSION"),
377            "description": "isb serve's HTTP surface: the tools (also MCP), the workspace resource, the identity endpoints, streams, websockets and webhooks. docs/reference/http-api.md.",
378        },
379        "tags": [
380            {"name": "tools", "description": "One POST per tool; the same tools as MCP."},
381            {"name": "workspace", "description": "An org's workspace as a REST resource over the workspace_* tools."},
382            {"name": "identity", "description": "Sign-in, sessions, invitations, tokens, keys, members and users (docs/reference/identity-api.md)."},
383            {"name": "surface", "description": "MCP, streams, websockets, webhooks, logos and health."},
384        ],
385        "components": {
386            "securitySchemes": {
387                "token": {"type": "http", "scheme": "bearer", "description": "An API token (isb_tok_), a workspace token (isb_ws_) or a superadmin token (isb_sa_)."},
388                "session": {"type": "apiKey", "in": "cookie", "name": "isb_session", "description": "The web UI's session; writes need X-Isb-Csrf: 1."},
389            },
390            "schemas": schemas,
391        },
392        "security": [{"token": []}, {"session": []}],
393        "paths": paths,
394    })
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    /// `web/openapi.json` (the snapshot the web UI's typed client is
402    /// generated from) is exactly what this code makes of its tools: refresh
403    /// it from a daemon's `/api/v1/openapi.json` after changing either.
404    #[test]
405    fn the_web_snapshot_is_current() {
406        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../web/openapi.json");
407        let Ok(text) = std::fs::read_to_string(path) else {
408            return; // a packaged crate has no web/
409        };
410        let mut snap: Value = serde_json::from_str(&text).unwrap();
411        let tools: Vec<Tool> = snap["paths"]
412            .as_object()
413            .unwrap()
414            .iter()
415            .filter(|(p, _)| p.starts_with("/api/v1/tools/"))
416            .map(|(_, item)| {
417                let op = &item["post"];
418                Tool::new(
419                    op["operationId"].as_str().unwrap(),
420                    op["description"].as_str().unwrap(),
421                    op["requestBody"]["content"]["application/json"]["schema"].clone(),
422                    |_, _| Ok(json!({})),
423                )
424                .title(op["summary"].as_str().unwrap())
425            })
426            .collect();
427        let refs: Vec<&Tool> = tools.iter().collect();
428        let mut doc = document(&refs);
429        // The version moves with every release; the shape must not.
430        snap["info"]["version"] = json!("");
431        doc["info"]["version"] = json!("");
432        assert!(
433            snap == doc,
434            "web/openapi.json is stale: regenerate it from a daemon's /api/v1/openapi.json (then `bun run gen:api` in web/)"
435        );
436    }
437
438    #[test]
439    fn workspace_routes_dispatch() {
440        assert_eq!(workspace_tool("", "GET"), Ok("workspace_get"));
441        assert_eq!(workspace_tool("/", "DELETE"), Ok("workspace_delete"));
442        assert_eq!(
443            workspace_tool("/settings", "PATCH"),
444            Ok("workspace_settings")
445        );
446        assert_eq!(workspace_tool("/start", "GET"), Err(vec!["POST"]));
447        assert_eq!(workspace_tool("/nope", "POST"), Err(vec![]));
448    }
449
450    #[test]
451    fn the_document_covers_every_surface() {
452        let t = Tool::new(
453            "workspace_get",
454            "Show it",
455            json!({"type": "object"}),
456            |_, _| Ok(json!({})),
457        );
458        let doc = document(&[&t]);
459        let p = &doc["paths"];
460        assert!(p["/api/v1/tools/workspace_get"]["post"].is_object());
461        assert_eq!(
462            p["/orgs/{org}/api/v1/workspace"]["get"]["x-isb-tool"],
463            "workspace_get"
464        );
465        // Workspace actions whose tool is not offered are left out.
466        assert!(p["/orgs/{org}/api/v1/workspace/start"].is_null());
467        assert!(p["/api/v1/auth/me"]["get"].is_object());
468        assert!(
469            p["/api/v1/events"]["get"]["responses"]["200"]["content"]["text/event-stream"]
470                .is_object()
471        );
472        assert!(p["/orgs/{org}/api/v1/terminal"]["get"]["responses"]["101"].is_object());
473        assert_eq!(p["/healthz"]["get"]["security"], json!([]));
474        assert!(doc["components"]["schemas"]["User"].is_object());
475        // Operation ids are unique across the document.
476        let mut ids = Vec::new();
477        for item in p.as_object().unwrap().values() {
478            for op in item.as_object().unwrap().values() {
479                ids.push(op["operationId"].as_str().unwrap().to_string());
480            }
481        }
482        let n = ids.len();
483        ids.sort();
484        ids.dedup();
485        assert_eq!(n, ids.len());
486    }
487}