1use serde_json::{Map, Value, json};
18
19use super::Tool;
20
21pub 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
37pub 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
53pub struct Surface {
55 pub method: &'static str,
56 pub path: &'static str,
57 pub summary: &'static str,
58 pub description: &'static str,
59 pub answers: &'static str,
61 pub query: &'static [(&'static str, &'static str, &'static str)],
63 pub signed_in: bool,
65 pub agents: Agents,
66}
67
68pub enum Agents {
70 Tool(&'static str),
71 BrowserOnly(&'static str),
72 Itself,
74}
75
76pub 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
315fn 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
343pub 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 #[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; };
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 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 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 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}