Public: anyone can read this; the crew can change it.
The OpenAPI document describes forges and the fleet: forges, sizes, claim links, fleet ops, front, health and drills #4882
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4873-the-openapi-document-describes-forges-an → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +131
−0
src/contract/openapi.ts +96
| @@ -143,6 +143,102 @@ export const ROUTES: readonly Route[] = [ | ||
| 143 | 143 | summary: "Stop an issue waiting for another", |
| 144 | 144 | auth: true, |
| 145 | 145 | }, |
| 146 | { method: "get", path: "/api/v1/forges", summary: "List the child forges (a site admin's, ro scope); answers { items, next_cursor: null }", auth: true }, | |
| 147 | { | |
| 148 | method: "post", | |
| 149 | path: "/api/v1/forges", | |
| 150 | summary: | |
| 151 | "Make a forge, as a link: `dryRun` is true unless it is false and then answers 200 with { dryRun: true, plan, firstAdmin } and makes nothing; made, it answers 201 (200 when it already exists) with { dryRun: false, name, url, claimUrl, ... }. A signed-in caller needs rw scope; a stranger may call it, same-origin, rate-limited, with no crew, model key or `for`", | |
| 152 | auth: true, | |
| 153 | status: 201, | |
| 154 | body: { | |
| 155 | name: "string", | |
| 156 | apps: "array", | |
| 157 | crew: "array", | |
| 158 | dryRun: "boolean", | |
| 159 | direction: "string", | |
| 160 | keepAccess: "boolean", | |
| 161 | size: "string", | |
| 162 | for: "string", | |
| 163 | fresh: "boolean", | |
| 164 | followMode: "string", | |
| 165 | budgetUsd: "number", | |
| 166 | modelKey: "string", | |
| 167 | modelEndpoint: "string", | |
| 168 | }, | |
| 169 | refusals: { | |
| 170 | invalid: "a field is missing or not valid", | |
| 171 | rate_limited: "a stranger has started enough forges today", | |
| 172 | }, | |
| 173 | }, | |
| 174 | { method: "get", path: "/api/v1/forges/sizes", summary: "What small, medium and large mean; public, cacheable by ETag", auth: false }, | |
| 175 | { | |
| 176 | method: "post", | |
| 177 | path: "/api/v1/forges/{name}/claim-link", | |
| 178 | summary: | |
| 179 | "Make a new claim link for a forge nobody has claimed (a site admin's, rw scope); answers 201 with { name, claimUrl }. The link is shown once, here, and not again: the old link dies, so keep this one", | |
| 180 | auth: true, | |
| 181 | status: 201, | |
| 182 | refusals: { not_found: "no child forge has this name", forbidden: "the caller is not a site admin", claimed: "the forge is already claimed" }, | |
| 183 | }, | |
| 184 | { method: "get", path: "/api/v1/forge", summary: "A spawned forge's word about itself: claimed or not, to anyone; when anything last happened, only to its parent; not_found when it was not spawned", auth: false }, | |
| 185 | { | |
| 186 | method: "get", | |
| 187 | path: "/api/v1/fleet/graph", | |
| 188 | summary: "Spend and runs per repository by UTC hour (24 h) and day (14 d); `repo` (owner/name) repeats, else every repository the caller may write that spent lately", | |
| 189 | auth: true, | |
| 190 | query: { repo: "string" }, | |
| 191 | refusals: { invalid_selection: "`repo` is not a list of owner/name" }, | |
| 192 | }, | |
| 193 | { | |
| 194 | method: "get", | |
| 195 | path: "/api/v1/fleet/ops", | |
| 196 | summary: "List the fleet operations, newest first; answers { items, next_cursor }", | |
| 197 | auth: true, | |
| 198 | query: { limit: "integer", cursor: "string" }, | |
| 199 | }, | |
| 200 | { method: "get", path: "/api/v1/fleet/ops/{id}", summary: "Read one fleet operation and what it changed", auth: false }, | |
| 201 | { | |
| 202 | method: "post", | |
| 203 | path: "/api/v1/fleet/ops", | |
| 204 | summary: | |
| 205 | "Plan or apply one action over the repositories a query selects; `dryRun` is true unless it is false: the plan needs ro scope and changes nothing (200, { dryRun: true, ... }), applying needs admin scope and answers 201 with { dryRun: false, ... }; each row is still checked against the actor's admin of its repository", | |
| 206 | auth: true, | |
| 207 | status: 201, | |
| 208 | body: { query: "string", action: "object", dryRun: "boolean" }, | |
| 209 | }, | |
| 210 | { method: "post", path: "/api/v1/fleet/ops/{id}/undo", summary: "Undo a fleet operation (admin scope); answers 201 with the undoing operation", auth: true, status: 201 }, | |
| 211 | { method: "get", path: "/api/v1/front", summary: "The front page: what a stranger is shown; a site admin also gets the picks", auth: false }, | |
| 212 | { | |
| 213 | method: "get", | |
| 214 | path: "/api/v1/health", | |
| 215 | summary: "How the forge is, uncached; anyone may ask, a stranger is told little, the release probe and a site admin the whole of it", | |
| 216 | auth: false, | |
| 217 | }, | |
| 218 | { method: "get", path: "/api/v1/map", summary: "The global map: every repository the caller can see, with its issues and dependency edges", auth: false }, | |
| 219 | { | |
| 220 | method: "post", | |
| 221 | path: "/api/v1/heartbeat", | |
| 222 | summary: | |
| 223 | "A child forge's signed heartbeat (counts and version, never data); only a child holding the parent's secret may call it, signed in the signature header, and body at most 1 KiB; answers { ok: true }, and every refusal is the same 404 `not_found`", | |
| 224 | auth: false, | |
| 225 | refusals: { not_found: "the beat is not signed by a child of this forge" }, | |
| 226 | }, | |
| 227 | { | |
| 228 | method: "post", | |
| 229 | path: "/api/v1/drills", | |
| 230 | summary: | |
| 231 | "Record a domain drill and its verdict, green or red (a site admin's, admin token only); answers 201 with the recorded drill", | |
| 232 | auth: true, | |
| 233 | status: 201, | |
| 234 | body: { domain: "string", seen: "object", backSeconds: "integer" }, | |
| 235 | refusals: { | |
| 236 | unknown_domain: "`domain` is not one of the forge's failure domains", | |
| 237 | invalid_seen: "`seen` needs health and board as text", | |
| 238 | invalid_back: "`backSeconds` is not a whole number of seconds, 0 to 31536000", | |
| 239 | too_large: "the body is too large", | |
| 240 | }, | |
| 241 | }, | |
| 146 | 242 | { |
| 147 | 243 | method: "post", |
| 148 | 244 | path: "/api/v1/migrate", |
test/openapi-oa-forges.test.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** The OpenAPI document describes forges, the fleet, front, health and drills. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { openapi } from "../src/contract/openapi.ts"; | |
| 4 | ||
| 5 | const WANT: readonly (readonly [string, string])[] = [ | |
| 6 | ["get", "/api/v1/forges"], | |
| 7 | ["post", "/api/v1/forges"], | |
| 8 | ["get", "/api/v1/forges/sizes"], | |
| 9 | ["post", "/api/v1/forges/{name}/claim-link"], | |
| 10 | ["get", "/api/v1/forge"], | |
| 11 | ["get", "/api/v1/fleet/graph"], | |
| 12 | ["get", "/api/v1/fleet/ops"], | |
| 13 | ["get", "/api/v1/fleet/ops/{id}"], | |
| 14 | ["post", "/api/v1/fleet/ops"], | |
| 15 | ["post", "/api/v1/fleet/ops/{id}/undo"], | |
| 16 | ["get", "/api/v1/front"], | |
| 17 | ["get", "/api/v1/health"], | |
| 18 | ["get", "/api/v1/map"], | |
| 19 | ["post", "/api/v1/heartbeat"], | |
| 20 | ["post", "/api/v1/drills"], | |
| 21 | ]; | |
| 22 | ||
| 23 | test("each forges and fleet path and method is in the document", () => { | |
| 24 | const doc = openapi("https://f.test", "1") as any; | |
| 25 | for (const [method, path] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); | |
| 26 | }); | |
| 27 | ||
| 28 | test("the claim link is shown once and the heartbeat and drills say who calls them", () => { | |
| 29 | const doc = openapi("https://f.test", "1") as any; | |
| 30 | expect(doc.paths["/api/v1/forges/{name}/claim-link"].post.summary).toContain("shown once"); | |
| 31 | expect(doc.paths["/api/v1/heartbeat"].post.summary).toContain("child"); | |
| 32 | expect(doc.paths["/api/v1/heartbeat"].post.security).toBeUndefined(); | |
| 33 | expect(doc.paths["/api/v1/drills"].post.summary).toContain("site admin"); | |
| 34 | expect(doc.paths["/api/v1/drills"].post.security).toBeDefined(); | |
| 35 | }); |
new above
offline
auth:false here but getFleetOp filters by caller; say so in the summary so clients send a token.