Public: anyone can read this; the crew can change it.

Split src/contract/openapi.ts into one file per route #5949

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/5936-split-src-contract-openapi-ts-into-one-f → main 7 commits fork

  • Merged. mason/5936-split-src-contract-openapi-ts-into-one-f was merged into main as d8980aa.

Review

Sign in to review this change.
212 files changed +2331 −1591
CLAUDE.md +3
@@ -251,6 +251,9 @@ flag exists only in the stdio dev runner and is never reachable over SSH.
251251 `{ items, next_cursor }` via `paged(...)`, never a bare array
252252 (`test/api-list-shape.test.ts` fails on one). Web forms follow the same shape
253253 via `readForm(ctx, scope)`.
254 Describe it in OpenAPI as one file, `contract/openapi/routes/<operationId>.ts`,
255 exporting `route`; then `bun scripts/openapi-routes.ts` writes its line in the
256 generated index. Never list it by hand.
254257 - **Add a TUI screen:** one file under `tui/screens/`, exporting
255258 `(f: Session, ctx: Ctx, …) => Screen`. Compose `ui/widgets`; take navigation
256259 from `screens/common.ts` (`ListState`, `Scroll`, `Filter`, `drawList`,
scripts/openapi-routes.ts added +45
@@ -0,0 +1,45 @@
1 /**
2 * Writes `src/contract/openapi/routes/index.ts` from the route files beside
3 * it (#5936): one sorted line per file, so a pull that describes a route adds
4 * one file and one line, and never edits another pull's.
5 *
6 * bun scripts/openapi-routes.ts write it
7 * bun scripts/openapi-routes.ts --check exit 1 when it is stale
8 *
9 * `test/openapi-routes.test.ts` runs the same comparison, so a pull that adds
10 * a route file without running this goes red with the command to run.
11 */
12 import { readdirSync, readFileSync, writeFileSync } from "node:fs";
13 import { join } from "node:path";
14
15 const DIR = join(import.meta.dir, "..", "src/contract/openapi/routes");
16 const INDEX = join(DIR, "index.ts");
17
18 /** The route files, by name: every `<operationId>.ts` here but the index. */
19 export function routeFiles(dir = DIR): string[] {
20 return readdirSync(dir)
21 .filter((f) => /^[a-z][A-Za-z0-9]*\.ts$/.test(f) && f !== "index.ts")
22 .map((f) => f.slice(0, -3))
23 .sort();
24 }
25
26 export function indexText(names: readonly string[]): string {
27 return [
28 "// Generated by `bun scripts/openapi-routes.ts` from the files beside it; do not edit.",
29 "// One line per route, sorted: a new route is a new file and one new line here.",
30 ...names.map((n) => `export { route as ${n} } from "./${n}.ts";`),
31 "",
32 ].join("\n");
33 }
34
35 if (import.meta.main) {
36 const check = process.argv.includes("--check");
37 const index = indexText(routeFiles());
38 const current = readFileSync(INDEX, "utf8") === index;
39 if (check && !current) {
40 console.error("stale: src/contract/openapi/routes/index.ts; run `bun scripts/openapi-routes.ts`");
41 process.exit(1);
42 }
43 if (!check && !current) writeFileSync(INDEX, index);
44 if (!check) console.log(current ? "current" : "wrote src/contract/openapi/routes/index.ts");
45 }
src/contract/openapi.ts +13 −1588
@@ -5,1598 +5,23 @@
55 * forge without a hand-written wrapper. Plain data in, plain data out;
66 * `GET /contract/openapi.json` serves it. A route added to the manifest is
77 * added here, and `test/openapi.test.ts` fails until it is.
8 *
9 * Each row is its own file, `openapi/routes/<operationId>.ts`, gathered by
10 * the generated `openapi/routes/index.ts` (`bun scripts/openapi-routes.ts`),
11 * so pulls describing different routes do not collide (#5936).
812 */
913
10 import { CAPACITY_KEYS } from "../core/capacity.ts";
11 import { FAMILY_WORDS } from "../actions/family_words.ts";
12 import { LIMIT_KEYS } from "../settings/limits.ts";
13 import { ADMIN_WORDS } from "../actions/admin_words.ts";
14 import { SELF_WAIT } from "../actions/plan_words.ts";
14 import type { Route } from "./openapi/shared.ts";
15 import * as each from "./openapi/routes/index.ts";
1516
16 interface Route {
17 readonly method: "get" | "post" | "put" | "patch" | "delete";
18 readonly path: string;
19 readonly summary: string;
20 /** Whether it needs a token; reads of public things do not. */
21 readonly auth: boolean;
22 /** A JSON body's fields, when it takes one. */
23 readonly body?: Readonly<Record<string, string>>;
24 /** The query parameters its handler reads, by name and type. */
25 readonly query?: Readonly<Record<string, string>>;
26 /** The success status, when it is not 200. */
27 readonly status?: number;
28 /** The success body's fields, by name and type, when it is worth a schema. */
29 readonly reply?: Readonly<Record<string, string>>;
30 /** The refusals it answers, by stable `error` code. */
31 readonly refusals?: Readonly<Record<string, string>>;
17 /** Every row, sorted by operationId: the index's order. */
18 export const ROUTES: readonly Route[] = Object.values(each);
19
20 /** A route's operationId, and the name of the file that holds it. */
21 export function operationId(r: Pick<Route, "method" | "path">): string {
22 return `${r.method}${r.path.replace(/[^A-Za-z0-9]+(.)?/g, (_, c: string | undefined) => (c ?? "").toUpperCase())}`;
3223 }
3324
34 /** What `listQuery` in `http/api.ts` reads from a list of issues or pulls. */
35 const LIST_QUERY = { state: "string", limit: "integer", cursor: "integer", label: "string", author: "string" };
36
37 /** What `POST /api/v1/apps` reads from its body, and the refusals it answers. */
38 const APP_BODY = { name: "string", owner: "string", description: "string", visibility: "string", agent: "string", pick: "boolean" };
39 const APP_REFUSALS = {
40 unverified: "the account has not verified its email",
41 app_limit: "the owner is at the app limit",
42 no_such_agent: "`agent` names no one on the crew",
43 slug_taken: "another app already has this name's slug",
44 name_taken: "the owner already has a repository of this name",
45 };
46
47 /** What the label routes for issues and pulls refuse by stable `error` code. */
48 const LABEL_REFUSALS = {
49 unknown_label: "a name or id in the set names no label of the repository",
50 forbidden: "the caller cannot write to the repository",
51 mirrored: "the repository mirrors an origin; do it there",
52 origin_owner_only: "only the person who connected the origin can write through it",
53 origin_unreachable: "the origin could not be reached (502)",
54 origin_refused: "the origin refused the change (409)",
55 };
56
57 const REPO = "/api/v1/repos/{owner}/{name}";
58
59 /** Browsing a repository the caller's origin holds; `{id}` is the origin. */
60 const BROWSE = "/api/v1/user/origins/{id}/browse/{owner}/{name}";
61 const BROWSE_LIST_QUERY = { state: "string", label: "string", author: "string", q: "string", cursor: "string" };
62 const BROWSE_REFUSALS = {
63 unauthorized: "401: no signed-in account",
64 forbidden: "403: a cross-origin browser request, or a token without admin scope",
65 not_found: "404: no such origin of yours, or the origin has no such repository, issue or pull",
66 rate_limited: "429: the origin is rate-limiting this forge",
67 origin_unauthorized: "502: the origin refused this forge's credential; reconnect it",
68 origin_unreachable: "502: the origin could not be reached",
69 };
70
71 export const ROUTES: readonly Route[] = [
72 { method: "get", path: "/api/v1/apps", summary: "List the apps whose repositories you can read; answers { items, next_cursor }", auth: false },
73 {
74 method: "post",
75 path: "/api/v1/apps",
76 summary: "Make an app: a repository, its app and a first issue; answers 201 with { repo, app, issue }",
77 auth: true,
78 status: 201,
79 body: APP_BODY,
80 refusals: APP_REFUSALS,
81 },
82 {
83 method: "get",
84 path: "/api/v1/admin/accounts",
85 summary:
86 "List every person and agent, newest first (a forge admin's, read scope; anyone else gets 404); answers { items: [{ username, kind, displayName, isAdmin, createdAt, via, signsInThrough, address, repos, apps, spentUsd, suspendedAt, removedAt, verified }], next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422",
87 auth: true,
88 query: { limit: "integer", cursor: "string" },
89 },
90 {
91 method: "delete",
92 path: "/api/v1/admin/accounts/{username}",
93 summary:
94 "Remove an account for good (an admin's, admin scope); answers { removed }. With retireOrgs true and no confirm it answers { confirm_required, retire, own } and removes nothing; with both it retires the organizations the person solely owns and answers { removed, retired }, or 500 { error: \"delete_failed\", failed, removed }",
95 auth: true,
96 body: { retireOrgs: "boolean", confirm: "boolean" },
97 refusals: {
98 not_allowed: "an admin cannot remove themselves, another admin or an agent (422)",
99 last_owner: "the person is the only one owning an organization (409)",
100 },
101 },
102 {
103 method: "post",
104 path: "/api/v1/admin/accounts/{username}/role",
105 summary: "Make a person a member (with an optional starter allowance in dollars a month) or a guest again (admin scope); answers the new role",
106 auth: true,
107 body: { role: "string", usdPerMonth: "number" },
108 refusals: {
109 invalid: "`role` is not member or guest, or `usdPerMonth` is not from 0.01 to 10000 dollars (422)",
110 not_allowed: "the person is an admin (422)",
111 },
112 },
113 {
114 method: "post",
115 path: "/api/v1/admin/accounts/{username}/ceiling",
116 summary: "Set an account's daily and per-run model spend ceilings in dollars, both null to take them back (admin scope); answers the ceilings",
117 auth: true,
118 body: { dailyUsd: "number", perRunUsd: "number" },
119 refusals: { invalid: "an amount is not null or from 0.01 to 10000 dollars (422)" },
120 },
121 {
122 method: "post",
123 path: "/api/v1/admin/accounts/{username}/model-grant",
124 summary: "Grant a person a capped model allowance in dollars a month; null or 0 takes it back (admin scope); answers the grant",
125 auth: true,
126 body: { usdPerMonth: "number" },
127 refusals: {
128 invalid: "`usdPerMonth` is not null or from 0.01 to 10000 dollars (422)",
129 not_allowed: "the person is an admin (422)",
130 },
131 },
132 {
133 method: "post",
134 path: "/api/v1/admin/accounts/{username}/suspend",
135 summary: "Suspend an account, undone by unsuspend (admin scope); answers { username, suspendedAt }",
136 auth: true,
137 body: { confirm: "boolean" },
138 refusals: {
139 not_allowed: "an admin cannot suspend themselves, another admin or an agent (422)",
140 last_owner: "the person is the only active owner of an organization; send confirm true to suspend anyway (409)",
141 },
142 },
143 {
144 method: "post",
145 path: "/api/v1/admin/accounts/{username}/unsuspend",
146 summary: "Lift an account's suspension (admin scope); answers { username, suspendedAt: null }",
147 auth: true,
148 refusals: { not_allowed: "the target is an admin, an agent or yourself (422)" },
149 },
150 {
151 method: "post",
152 path: "/api/v1/admin/accounts/{username}/verify",
153 summary: "Mark an account's email verified by hand (admin scope); answers { username, verified: true }",
154 auth: true,
155 refusals: { not_allowed: "the target is an admin, an agent or yourself (422)" },
156 },
157 {
158 method: "get",
159 path: "/api/v1/admin/invites",
160 summary: "List invite links without their tokens (an admin's, read scope); answers { items, next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422",
161 auth: true,
162 query: { limit: "integer", cursor: "string" },
163 },
164 {
165 method: "post",
166 path: "/api/v1/admin/invites",
167 summary:
168 "Mint an invite link (admin scope); answers 201 with the invite and its `token` and `link`: the link is shown once, here, and cannot be read again, so keep it",
169 auth: true,
170 status: 201,
171 body: { days: "integer" },
172 refusals: { invalid: "`days` is not a whole number from 1 to 30 (422)" },
173 },
174 {
175 method: "delete",
176 path: "/api/v1/admin/invites/{id}",
177 summary: "Revoke an invite link by id (admin scope); 404 when there is none",
178 auth: true,
179 },
180 { method: "get", path: "/api/v1/explore", summary: "Explore apps and repositories; `q` takes words and `kind:`/`owner:` filters; answers { query, items }", auth: false, query: { q: "string" } },
181 { method: "get", path: "/api/v1/search", summary: "Search issues, pulls, repositories and code; `kind` narrows it", auth: false, query: { q: "string", kind: "string" } },
182 {
183 method: "post",
184 path: "/api/v1/feed/tells",
185 summary: "Tell an app's agents what you want, from a card or any harness (rw scope); `app` is owner/name, `text` 1 to 2000 characters, `variants` 1 to 4 tries (default 1), `from` the forge path the tell was sent from (a leading single /, at most 512 characters, no spaces); answers 201 with { issue } and, for variants, { issue, issues, assigned, variantGroup }",
186 auth: true,
187 body: { app: "string", text: "string", variants: "integer", from: "string" },
188 status: 201,
189 refusals: {
190 invalid_text: "`text` is empty or over 2000 characters (422)",
191 invalid_variants: "`variants` is not a whole number from 1 to 4 (400)",
192 invalid_from: "`from` is not a same-forge path of at most 512 characters (400)",
193 not_found: "`app` names no repository the caller can read (404)",
194 forbidden: "the caller may not direct the crew (403)",
195 unverified: "the account has not verified its email (403)",
196 refused: "the tell could not be told (403 or 422)",
197 },
198 },
199 { method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false },
200 {
201 method: "post",
202 path: "/api/v1/repos",
203 summary:
204 "Make a repository under you or an organisation you can write to (rw scope; an agent owns none); `visibility` is public, anything else private; answers 201 with the repository",
205 auth: true,
206 status: 201,
207 body: { name: "string", owner: "string", visibility: "string", description: "string" },
208 refusals: {
209 invalid_name: "422: not a name a repository may have",
210 name_taken: "422: the owner already has a repository of this name",
211 forbidden_owner: "403: `owner` is not you or an organisation you can write to, or the caller is an agent",
212 repository_limit: "403: at the repository limit",
213 storage_limit: "403: at the storage limit",
214 unverified: "403: the account has not verified its email",
215 },
216 },
217 {
218 method: "get",
219 path: REPO,
220 summary:
221 "Read one repository; `provenance` is where a migrated one came from ({ source, sourceUrl, parents: [{ fullName, url }], migratedAt }), null when it was not migrated or its owner hid it; `upstream` is the stored follow check ({ repo, behind, checkedAt, conflict }), shown to an admin of the repository, null when it follows nothing or to anyone else",
222 auth: false,
223 refusals: { not_found: "404: no such repository, or it is not readable by the caller" },
224 },
225 {
226 method: "post",
227 path: `${REPO}/reports`,
228 summary: "Report a problem in a public repository, without an account",
229 auth: false,
230 body: { title: "string", body: "string", page: "string" },
231 },
232 {
233 method: "get",
234 path: "/api/v1/admin/settings",
235 summary:
236 "Read the forge's signup mode and account quotas (a person admin's token, any scope); answers { quotas, signup, signupChanges, changes }",
237 auth: true,
238 refusals: { not_found: "the token is not a person who administers the forge" },
239 },
240 {
241 method: "patch",
242 path: "/api/v1/admin/settings",
243 summary:
244 "Change the signup mode (open, invite or closed) or the account quotas, one of the two per call (a person admin's token, admin scope); answers { signup } or { quotas }; a quota is a positive whole number, or null for no limit",
245 auth: true,
246 body: { signup: "string", quotas: "object" },
247 refusals: {
248 invalid_body: "neither signup nor quotas was sent, or both were",
249 invalid_signup: "signup is not open, invite or closed",
250 no_such_quota: "a quota key is unknown",
251 invalid_quota: "a quota is not a positive whole number or null",
252 not_found: "the token is not a person who administers the forge",
253 },
254 },
255 {
256 method: "get",
257 path: "/api/v1/admin/limits",
258 summary:
259 "Read the forge's limits (a person admin's token, any scope); answers { items: [{ key, value, isSet, defaultValue, ... }], changes, next_cursor }",
260 auth: true,
261 refusals: { not_found: "the token is not a person who administers the forge" },
262 }, {
263 method: "put",
264 path: "/api/v1/admin/limits/{key}",
265 summary: `Change one limit live, or send value null to take it back to its default (a person admin's token, admin scope); {key} is one of ${LIMIT_KEYS.join(", ")}; answers the changed limit`,
266 auth: true,
267 body: { value: "integer or null" },
268 refusals: {
269 not_found: "the token is not a person who administers the forge (404)",
270 no_such_limit: "{key} is not a limit (422)",
271 out_of_bounds: "the value is outside the limit's bounds, named in the message (422)",
272 },
273 },
274
275 {
276 method: "get",
277 path: "/api/v1/admin/capacity",
278 summary:
279 "Read the forge's capacity settings (a person admin's token, any scope); answers { items: [{ key, label, help, value, source, default, env, changed? }], changes, next_cursor }",
280 auth: true,
281 refusals: { not_found: "the token is not a person who administers the forge" },
282 }, {
283 method: "put",
284 path: "/api/v1/admin/capacity/{key}",
285 summary: `Change one capacity setting live, or send value null to take it back to its default (a person admin's token, admin scope); {key} is one of ${CAPACITY_KEYS.join(", ")}; answers the changed setting`,
286 auth: true,
287 body: { value: "string or null" },
288 refusals: {
289 not_found: "the token is not a person who administers the forge (404)",
290 no_such_setting: "{key} is not a capacity setting (422)",
291 invalid_value: "the value is outside what this setting takes (422)",
292 no_credential: "the setting needs a credential the forge does not hold (422)",
293 },
294 },
295
296 {
297 method: "get",
298 path: "/api/v1/admin/teams",
299 summary:
300 "Read the crew's teams (a person admin's token, any scope); answers { teams: [{ slug, charter, dial, members, ... }] }",
301 auth: true,
302 refusals: { not_found: "the token is not a person who administers the forge" },
303 }, {
304 method: "put",
305 path: "/api/v1/admin/teams/{slug}",
306 summary: "Change a crew team's charter, dial or members; send only what changes (a person admin's token, admin scope); answers the team",
307 auth: true,
308 body: { charter: "string", dial: "integer", members: "array of agent ids" },
309 refusals: {
310 invalid_body: "charter is not text, dial not a number, or members not a list of agent ids (422)",
311 not_found: "no such team, or the token is not a person who administers the forge (404)",
312 bad_dial: "dial is not a whole number in range (422)",
313 bad_charter: "the charter is too long (422)",
314 over_100: "the teams' dials together would pass 100% (422)",
315 bad_member: "a member is not an agent on this forge (422)",
316 },
317 },
318
319 {
320 method: "get",
321 path: "/api/v1/admin/people",
322 summary:
323 "List every person and their role (admin scope); answers { items: [{ username, role, usdPerMonth }], next_cursor }, role being admin, member or guest; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422",
324 auth: true,
325 query: { limit: "integer", cursor: "string" },
326 },
327 { method: "get", path: `${REPO}/issues`, summary: "List a repository's issues; fields=brief leaves out bodies and comments, any other fields value is a 422 invalid_fields", auth: false, query: { ...LIST_QUERY, fields: "string" } },
328 { method: "get", path: `${REPO}/pulls`, summary: "List a repository's pulls", auth: false, query: LIST_QUERY },
329 {
330 method: "get",
331 path: `${REPO}/branches`,
332 summary:
333 "List a repository's branches; answers { items: [{ name, oid, isDefault, ahead, behind, protected }], next_cursor }; ahead and behind are null past the comparison cap; limit 1 to 200, cursor from next_cursor; a private repository needs a token",
334 auth: false,
335 query: { limit: "integer", cursor: "string" },
336 refusals: {
337 invalid_limit: "limit is not a whole number from 1 to 200 (422)",
338 invalid_cursor: "cursor is not the next_cursor of a page before (422)",
339 },
340 },
341 { method: "get", path: `${REPO}/commits`, summary: "List commits newest first from `ref` (default: the default branch) as a bare array of { oid, parents, author, email, date, subject, body }; limit 1 to 100 (default 30); an unsafe ref is a 422 bad_ref; a private repository needs a token", auth: false, query: { ref: "string", limit: "integer" } },
342 { method: "get", path: `${REPO}/commits/{oid}`, summary: "Read one commit (a full or abbreviated id, or a ref) as { oid, parents, author, email, date, subject, body, diff: { files, additions, deletions } }; a private repository needs a token", auth: false },
343 { method: "get", path: `${REPO}/contents`, summary: "Read the tree or a file at `ref` (default: the default branch); no `path` or a directory answers { type: \"dir\", ref, path, entries }, a file answers { type: \"file\", ref, path, size, encoding: \"utf-8\" | \"base64\", content }, a file over 5 MB is a 413 { type: \"file\", tooLarge: true, size }; a private repository needs a token", auth: false, query: { path: "string", ref: "string" } },
344 { method: "get", path: `${REPO}/labels`, summary: "List a repository's labels by name as a bare array of { id, name, color }; a private repository needs a token", auth: false },
345 { method: "get", path: `${REPO}/milestones`, summary: "Read the plan: { repo: { owner, name }, limbs }, one limb per milestone with its weights (and askToLive for a pillar's umbrella); a private repository needs a token", auth: false },
346 { method: "get", path: `${REPO}/milestones/{number}`, summary: "Read one milestone (a 404 for an issue that waits for nothing) as { repo, ...milestone, askToLive?, watching? }; watching is present only for a signed-in user; a private repository needs a token", auth: false },
347 { method: "get", path: `${REPO}/waves`, summary: "List what shipped, newest first, as { items: [{ version, date, headline }], next_cursor: null }; a file that is not a wave is left out; a private repository needs a token", auth: false, reply: { items: "array", next_cursor: "null" }, refusals: { not_found: "404: no such repository, or it is not readable" } },
348 { method: "get", path: `${REPO}/waves/{version}`, summary: "Read one wave as { version, date, headline, highlights, pulls: [{ number, title, url }] }; number is null when the line names no pull; a private repository needs a token", auth: false, reply: { version: "string", date: "string", headline: "string", highlights: "array", pulls: "array" }, refusals: { not_found: "404: no such repository, or it is not readable, or no such wave" } },
349 {
350 method: "post",
351 path: `${REPO}/issues`,
352 summary: "Open an issue",
353 auth: true,
354 body: { title: "string", body: "string" },
355 },
356 { method: "get", path: `${REPO}/issues/{number}`, summary: "Read one issue with its comments; comments=N (0 to 200) answers only the newest N and commentCount, any other value is a 422 invalid_comments", auth: false, query: { comments: "integer" } },
357 {
358 method: "patch",
359 path: `${REPO}/issues/{number}`,
360 summary: "Close or reopen an issue (state, with a reason and the issue it went into)",
361 auth: true,
362 body: { state: "string", reason: "string", into: "integer" },
363 },
364 {
365 method: "get",
366 path: "/api/v1/secrets/{scope}",
367 summary: "List a scope's secret names and timestamps, never a value; admin only; `ref` names an org or `owner/name`, the forge scope takes none; answers { items, next_cursor }",
368 auth: true,
369 query: { ref: "string" },
370 refusals: { not_found: "the scope's owner is unknown", invalid_scope: "the scope is not one the forge keeps secrets for" },
371 },
372 {
373 method: "put",
374 path: "/api/v1/secrets/{scope}/{name}",
375 summary: "Set a secret by name (admin scope, a site admin's); the value is write-only, the read never returns it; `ref` names an org or `owner/name`; answers the name and timestamps, never the value",
376 auth: true,
377 body: { value: "string", ref: "string" },
378 refusals: {
379 invalid_name: "`name` is not an acceptable secret name (422)",
380 invalid_value: "`value` is not acceptable (422)",
381 invalid_scope: "the scope is not one the forge keeps secrets for (422)",
382 },
383 },
384 {
385 method: "delete",
386 path: "/api/v1/secrets/{scope}/{name}",
387 summary: "Remove a secret by name (admin scope token); a value is never read back; answers {}",
388 auth: true,
389 query: { ref: "string" },
390 refusals: { not_found: "no such secret or scope owner", invalid_scope: "the scope is not one the forge keeps secrets for" },
391 },
392 {
393 method: "get",
394 path: "/api/v1/tokens",
395 summary: "List your own tokens; a bearer token is refused with 403, call it from a browser session or with a password",
396 auth: false,
397 refusals: { forbidden: "the caller holds a bearer token, which cannot manage tokens" },
398 },
399 {
400 method: "post",
401 path: "/api/v1/tokens",
402 summary: "Mint a token of scope ro, rw or admin; needs a browser session or a username and password, a bearer token is refused with 403; a site admin must send adminConfirm: true; answers 201 with the token once",
403 auth: false,
404 status: 201,
405 body: { name: "string", scope: "string", username: "string", password: "string", adminConfirm: "boolean" },
406 refusals: {
407 invalid_scope: "scope is not ro, rw or admin",
408 admin_confirm: "a site admin did not send adminConfirm: true",
409 forbidden: "a bearer token cannot mint tokens, or the account may not hold an admin token",
410 },
411 },
412 {
413 method: "delete",
414 path: "/api/v1/tokens/{id}",
415 summary: "Revoke one of your tokens; a bearer token is refused with 403, call it from a browser session or with a password; answers 204",
416 auth: false,
417 status: 204,
418 refusals: { forbidden: "the caller holds a bearer token, which cannot manage tokens" },
419 },
420 {
421 method: "get",
422 path: "/api/v1/users/{username}/signins",
423 summary: "List where a person is signed in: answers { tokens, sessions }, each token { id, name, scope, createdAt, lastUsedAt, expiresAt } and each live browser session { id, createdAt, lastSeenAt, expiresAt, current }; a session's secret and a token's value are never answered; the person themself or the forge's owner, with a browser session, a password or an admin-scope token",
424 auth: true,
425 refusals: {
426 unauthorized: "no credentials were sent",
427 forbidden: "the caller holds a token below admin scope",
428 not_found: "no such account, or it is not one the caller may see",
429 },
430 },
431 {
432 method: "delete",
433 path: "/api/v1/users/{username}/sessions/{id}",
434 summary: "End one browser session of a person, as when a device is lost; the person themself or the forge's owner, with a browser session, a password or an admin-scope token; answers 204 and never a session secret",
435 auth: true,
436 status: 204,
437 refusals: {
438 unauthorized: "no credentials were sent",
439 forbidden: "the caller holds a token below admin scope",
440 not_found: "no such account or session, or it is not one the caller may end",
441 },
442 },
443 { method: "get", path: "/api/v1/telemetry/system", summary: "Read the forge's system view; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true },
444 { method: "get", path: "/api/v1/telemetry/flow", summary: "Read the forge's flow view; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true },
445 { method: "get", path: "/api/v1/telemetry/home", summary: "Read the figures the home is scored against: landed in 24 h, live version, builders and why idle, spend against the ceiling, what waits on the caller; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true },
446 { method: "get", path: "/api/v1/telemetry/sources", summary: "List telemetry sources; answers { sources }; the owner or an agent holding telemetry:read", auth: true },
447 { method: "get", path: "/api/v1/telemetry/history", summary: "Read telemetry history; answers { points }; the owner or an agent holding telemetry:read", auth: true },
448 { method: "get", path: `${REPO}/pulls/{number}`, summary: "Read one pull with its diff and review gate (diff=0 leaves the diff out)", auth: false, query: { diff: "string" } },
449 {
450 method: "patch",
451 path: `${REPO}/pulls/{number}`,
452 summary: "Edit a pull's title or body and open or close it (rw scope); answers the pull",
453 auth: true,
454 body: { title: "string", body: "string", state: "string" },
455 refusals: {
456 invalid_text: "`title` or `body` is not a string (422)",
457 invalid_state: "`state` is neither open nor closed (422)",
458 invalid_reason: "`reason` or `into` is given; a pull has none (422)",
459 nothing_to_change: "none of `title`, `body` and `state` is given (422)",
460 taken_up: "someone who directs the crew has acted on the words, so an agent may not edit them (403)",
461 forbidden: "you may not open or close this pull (403); a planner may close only an agent's pull whose issue is closed",
462 },
463 },
464 { method: "get", path: `${REPO}/issues/{number}/comments`, summary: "Page through an issue's or pull's comments, oldest first: {comments, next}; limit 1 to 100 (default 50), after a comment id", auth: false, query: { after: "string", limit: "integer" } },
465 {
466 method: "post",
467 path: `${REPO}/issues/{number}/comments`,
468 summary: "Comment on an issue or pull",
469 auth: true,
470 body: { body: "string" },
471 },
472 { method: "get", path: `${REPO}/runs`, summary: "List a repository's runs, newest first: { items, next_cursor }; kind deploy or agent, env an environment name, limit 1 to 200 (default 50), cursor a run id; a stranger's runs carry no cost, turns, agent session or node", auth: false, query: { kind: "string", env: "string", limit: "integer", cursor: "string" } },
473 { method: "get", path: `${REPO}/runs/{id}`, summary: "Read one run as this reader may see it, with `carries`, the merged pulls a finished production deploy carries", auth: false },
474 { method: "get", path: `${REPO}/runs/{id}/log`, summary: "Read a run's log as text/plain, one line per line; follow=1 or Accept: text/event-stream tails it live as server-sent events (line, then done); 404 when the log is hidden", auth: false, query: { follow: "string" } },
475 {
476 method: "post",
477 path: `${REPO}/runs/{id}/cancel`,
478 summary: "Cancel a run that has not finished (needs rw scope and write access); answers the cancelled run",
479 auth: true,
480 refusals: { finished: "the run has already finished (409)" },
481 },
482 {
483 method: "get",
484 path: `${REPO}/usage`,
485 summary: "What the crew's runs here spent over a window, the owner's own facts: { app, days, since, total, bySubscription, byAgent, byIssue, recent, standing, spend, guard }; days 1 to 90; 403 to a reader who cannot write",
486 auth: true,
487 query: { days: "integer" },
488 refusals: { invalid_days: "days is not a whole number from 1 to 90" },
489 },
490 {
491 method: "post",
492 path: `${REPO}/spend`,
493 summary: "Report what an outside session spent on this repository's work (needs rw scope; writers and the crew); answers 201 with the record",
494 auth: true,
495 status: 201,
496 body: { who: "string", tokensIn: "integer", tokensOut: "integer", usd: "number", model: "string", note: "string", issue: "integer" },
497 refusals: { invalid: "who is 1 to 100 characters and some spend is given (tokensIn, tokensOut whole numbers; usd 0 to 10000); note is at most 500" },
498 },
499 { method: "get", path: `${REPO}/events`, summary: "Page through a repository's events as the caller may see them, newest first: {items, next_cursor}; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", auth: false, query: { limit: "integer", cursor: "string" } },
500 { method: "post", path: `${REPO}/forks`, summary: "Fork a repository", auth: true },
501 {
502 method: "post",
503 path: `${REPO}/pulls`,
504 summary: "Offer a change as a pull",
505 auth: true,
506 body: { title: "string", head: "string", headRepo: "string" },
507 },
508 { method: "get", path: `${REPO}/crew`, summary: "List the repository's crew; answers { items, next_cursor } (names and roles for every reader, brief, model and budget for writers)", auth: false },
509 {
510 method: "put",
511 path: `${REPO}/crew/{username}`,
512 summary: "Put an agent on the repository's crew (admin scope, a repository admin's); answers the agent",
513 auth: true,
514 refusals: {
515 no_such_agent: "`username` names no one (422)",
516 not_an_agent: "`username` is a person, not an agent (422)",
517 unverified: "the caller's email is unproven (403)",
518 },
519 },
520 {
521 method: "delete",
522 path: `${REPO}/crew/{username}`,
523 summary: "Take an agent off the repository's crew (admin scope, a repository admin's); answers {}",
524 auth: true,
525 refusals: { no_such_agent: "`username` names no one on the crew" },
526 },
527 { method: "get", path: `${REPO}/prompts`, summary: "List the repository's prompt sources (admin scope); answers { items, next_cursor }, tokens never come back", auth: true },
528 {
529 method: "post",
530 path: `${REPO}/prompts`,
531 summary: "Add a prompt source (admin scope); answers 201 with the source",
532 auth: true,
533 status: 201,
534 body: { kind: "string", token: "string", at: "string" },
535 refusals: { invalid: "`kind`, `token` or `at` is not acceptable (422)" },
536 },
537 { method: "delete", path: `${REPO}/prompts/{id}`, summary: "Remove a prompt source (admin scope); answers {}", auth: true },
538 { method: "get", path: `${REPO}/egress-grants`, summary: "List the destinations the app may reach (admin scope); answers { items, changes, next_cursor }", auth: true },
539 {
540 method: "post",
541 path: `${REPO}/egress-grants`,
542 summary: "Grant the app one egress destination (admin scope); answers 201 with the grant",
543 auth: true,
544 status: 201,
545 body: { destination: "string" },
546 refusals: { invalid: "`destination` is not acceptable (422)" },
547 },
548 {
549 method: "delete",
550 path: `${REPO}/egress-grants`,
551 summary: "Revoke one egress destination, named in the body (admin scope); answers {}",
552 auth: true,
553 body: { destination: "string" },
554 },
555 { method: "get", path: `${REPO}/hooks`, summary: "List the repository's webhooks (admin scope); answers an array of webhooks", auth: true },
556 {
557 method: "post",
558 path: `${REPO}/hooks`,
559 summary: "Add a webhook (admin scope); answers 201 with the webhook and its `secret`, shown once",
560 auth: true,
561 status: 201,
562 body: { url: "string", kinds: "string", secret: "string" },
563 refusals: { invalid: "`url` or `kinds` is not acceptable (422)" },
564 },
565 { method: "delete", path: `${REPO}/hooks/{id}`, summary: "Remove a webhook (admin scope); answers {}, 404 when it is not there", auth: true },
566 {
567 method: "get",
568 path: `${REPO}/decisions`,
569 summary: "Read the decision ledger; `kind` is next, land, retry or guard; answers { decisions, week }",
570 auth: false,
571 query: { kind: "string" },
572 },
573 { method: "get", path: `${REPO}/pulls/{number}/reviews`, summary: "List a pull's line-anchored review comments, each with its `anchor` and whether it is `outdated`", auth: false },
574 {
575 method: "post",
576 path: `${REPO}/pulls/{number}/reviews`,
577 summary: "Comment on one line of a pull's diff (rw scope); answers 201 with the comment; a verdict goes to …/review-states",
578 auth: true,
579 status: 201,
580 body: { path: "string", line: "integer", side: "string", body: "string" },
581 refusals: { bad_anchor: "`path`, `line` and `side` (old or new) name no line of the pull's current diff (422)" },
582 },
583 { method: "post", path: `${REPO}/pulls/{number}/merge`, summary: "Merge a pull (a person's)", auth: true },
584 { method: "get", path: `${REPO}/pulls/{number}/threads`, summary: "List a pull's review threads: every review comment sharing one anchor, and whether the conversation is resolved", auth: false },
585 {
586 method: "post",
587 path: `${REPO}/pulls/{number}/threads/{anchor}/resolve`,
588 summary: "Mark a review thread resolved (rw scope; the thread's author or a writer); answers the thread",
589 auth: true,
590 refusals: {
591 not_found: "the pull has no thread at `anchor` (404)",
592 forbidden: "you neither wrote the thread's comments nor can write to the repository (403)",
593 },
594 },
595 {
596 method: "delete",
597 path: `${REPO}/pulls/{number}/threads/{anchor}/resolve`,
598 summary: "Reopen a resolved review thread (rw scope; the thread's author or a writer); answers the thread",
599 auth: true,
600 refusals: {
601 not_found: "the pull has no thread at `anchor` (404)",
602 forbidden: "you neither wrote the thread's comments nor can write to the repository (403)",
603 },
604 },
605 {
606 method: "get",
607 path: `${REPO}/pulls/{number}/checks`,
608 summary: "A pull's checks at its current head; answers { head, state, required, run, runs } with state `none` when no run exists",
609 auth: false,
610 reply: { head: "string", state: "string", required: "boolean", run: "object", runs: "array" },
611 },
612 {
613 method: "post",
614 path: `${REPO}/pulls/{number}/checks`,
615 summary: "Ask for a check again at the pull's head as it is now (rw scope, a writer's); answers 202 with { run }",
616 auth: true,
617 status: 202,
618 reply: { run: "object" },
619 refusals: {
620 forbidden: "only a writer can ask for a check (403)",
621 not_open: "only an open pull is checked (409)",
622 no_checks: "this forge runs no checks (409)",
623 no_check_declared: "this head declares no check, or waits for a writer's approval (409)",
624 },
625 },
626 {
627 method: "post",
628 path: `${REPO}/pulls/{number}/approve-here`,
629 summary: "Let an origin fork's pull run here: a writer approves its current head (rw scope); answers 201",
630 auth: true,
631 status: 201,
632 refusals: {
633 not_foreign: "the pull needs no approval to run (422)",
634 self_review: "you cannot approve your own pull (422)",
635 not_open: "the pull is closed (422)",
636 },
637 },
638 {
639 method: "put",
640 path: `${REPO}/issues/{number}/assignee`,
641 summary: "Assign an issue to a person or crew member, or to no one with null (rw scope); answers the issue",
642 auth: true,
643 body: { assignee: "string" },
644 refusals: {
645 invalid_assignee: "`assignee` is neither a username nor null (422)",
646 no_such_user: "`assignee` names no account (422)",
647 },
648 },
649 {
650 method: "put",
651 path: `${REPO}/pulls/{number}/assignee`,
652 summary: "Assign a pull to a person or crew member, or to no one with null (rw scope); answers the pull",
653 auth: true,
654 body: { assignee: "string" },
655 refusals: {
656 invalid_assignee: "`assignee` is neither a username nor null (422)",
657 no_such_user: "`assignee` names no account (422)",
658 },
659 },
660 {
661 method: "patch",
662 path: REPO,
663 summary:
664 "Change a repository's settings (an admin's); `showProvenance` shows or hides where a migrated one came from (a person's, never an agent's)",
665 auth: true,
666 body: {
667 description: "string",
668 visibility: "string",
669 archived: "boolean",
670 showProvenance: "boolean",
671 defaultBranch: "string",
672 protectDefault: "boolean",
673 requireReview: "boolean",
674 requireChecks: "boolean",
675 crewOutsiders: "boolean",
676 decisionsBy: "string",
677 plansNeedNod: "boolean",
678 },
679 refusals: {
680 bad_archived: "422: archived is true or false",
681 bad_decisions_by: "422: decisionsBy is me, decider or decider_tell",
682 bad_plans_need_nod: "422: plansNeedNod is true or false",
683 bad_show_provenance: "422: showProvenance is true or false",
684 no_provenance: `422: ${FAMILY_WORDS.no_provenance}`,
685 forbidden: "403: only an admin archives or wakes",
686 not_found: "404: no such repository, or it is not readable by the caller",
687 },
688 },
689 {
690 method: "delete",
691 path: REPO,
692 summary: "Delete a repository for good (admin scope, an admin's); answers {}",
693 auth: true,
694 refusals: { not_found: "404: no such repository, or the caller is not its admin", delete_failed: "500: the repository could not be removed" },
695 },
696 {
697 method: "get",
698 path: `${REPO}/following`,
699 summary:
700 "A fork's standing against its parent, read from git: { chain, severedAt, upstream, parent, parentBranch, parentFrozen, branch, state, behind, ahead, clean, conflicts, forkTip, parentTip, parentForge, converge }; a repository that only follows across forges answers { chain, severedAt, upstream }",
701 auth: false,
702 refusals: { not_a_fork: "404: this repository follows nothing" },
703 },
704 {
705 method: "get",
706 path: `${REPO}/following/proposals`,
707 summary: "How each change proposed to the parent forge stands, as the parent last said; answers { items }",
708 auth: false,
709 },
710 {
711 method: "post",
712 path: `${REPO}/following/converge`,
713 summary: "Ask the crew to converge a diverged fork with its parent; answers { kind, issue, pull, agent } (202 when queued, 200 when working or proposed)",
714 auth: true,
715 refusals: {
716 not_a_fork: "404: this repository follows nothing",
717 forbidden: "403: only someone who may direct the crew",
718 archived: "409: the repository is archived",
719 ceiling: "429: the crew is at its ceiling",
720 },
721 },
722 {
723 method: "post",
724 path: `${REPO}/following/propose`,
725 summary: "Open a pull on the parent with what the fork is ahead by; answers { number, opened, changes, title }",
726 auth: true,
727 refusals: {
728 not_a_fork: "404: this repository follows nothing",
729 nothing_to_propose: "409: nothing here is ahead of the parent",
730 refused: "409: the parent refused, with the reason",
731 },
732 },
733 {
734 method: "post",
735 path: `${REPO}/following/propose-parent`,
736 summary: "Across forges: push this forge's change to the parent forge and open the pull there; answers 201 when opened, 200 when one stands",
737 auth: true,
738 refusals: {
739 no_parent_forge: "404: this repository has no parent forge to propose to",
740 nothing_to_propose: `409: ${FAMILY_WORDS.nothing_to_propose}`,
741 parent_unreachable: "502: the parent forge did not answer",
742 refused: "409: the parent forge refused, with the reason",
743 },
744 },
745 {
746 method: "post",
747 path: `${REPO}/following/sever`,
748 summary: "Stop following the parent forge; answers { severedAt }",
749 auth: true,
750 refusals: { not_following: `404: ${FAMILY_WORDS.not_following}` },
751 },
752 {
753 method: "post",
754 path: `${REPO}/following/take`,
755 summary:
756 "Take the parent's updates: a fork merges (the same answer as sync); the repository carrying this forge's code takes as a pull",
757 auth: true,
758 refusals: {
759 not_a_fork: "404: this repository follows nothing",
760 not_following: `404: ${FAMILY_WORDS.not_following}`,
761 conflict: "409: both sides changed these files; the crew was told; { files, converge, issue }",
762 moved: "409: something moved while this was read; try again",
763 },
764 },
765 {
766 method: "patch",
767 path: "/api/v1/forge/following",
768 summary: "Choose how this forge follows the forge its code came from (its code repository's admin); answers { mode }",
769 auth: true,
770 body: { mode: "string" },
771 refusals: {
772 not_following: `404: ${FAMILY_WORDS.not_following}`,
773 forbidden: "403: only this forge's owner can change how it follows",
774 invalid_mode: "422: mode is not one the forge knows",
775 severed: "409: this forge is severed and follows nothing",
776 },
777 },
778 {
779 method: "put",
780 path: `${REPO}/upstream`,
781 summary: "Follow an outside upstream, or stop (admin scope, repository admin); answers { following }",
782 auth: true,
783 body: { url: "string", branch: "string", enabled: "boolean" },
784 refusals: {
785 bad_enabled: "422: enabled is true or false",
786 invalid_url: "422: not an https or http git address",
787 invalid_branch: "422: not a branch name git accepts",
788 not_found: "404: no such repository, or it is not readable by the caller",
789 },
790 },
791 {
792 method: "get",
793 path: `${REPO}/upstream`,
794 summary: "The outside upstream this repository follows, for an admin: { following } (null when it follows none)",
795 auth: true,
796 },
797 {
798 method: "post",
799 path: `${REPO}/upstream/check`,
800 summary: "Check the followed upstream now (admin scope); answers the check's result",
801 auth: true,
802 refusals: { busy: "409: a check is already running", invalid_url: "422: not an https or http git address" },
803 },
804 {
805 method: "post",
806 path: `${REPO}/sync`,
807 summary: "Sync a mirror with its origin, or let a fork take its parent's updates (a fork needs a writer)",
808 auth: true,
809 refusals: {
810 not_a_fork: "404: this repository follows nothing",
811 conflict: "409: both sides changed these files; the crew was told",
812 moved: "409: something moved while this was read; try again",
813 },
814 },
815 {
816 method: "post",
817 path: `${REPO}/make-it-yours`,
818 summary:
819 "Take an app as your own: a fork with its app; a dry run (the default, ro scope) answers the plan, dryRun false (rw scope) answers 201 with the plan and `url`",
820 auth: true,
821 body: { dryRun: "boolean", name: "string", visibility: "string" },
822 refusals: {
823 invalid: "422: visibility is public or private",
824 own: "409: this app is already yours",
825 unverified: "403: prove your email first",
826 limit: "429: at most so many copies a day; { resetsAt }",
827 repository_limit: "403: at the repository limit",
828 storage_limit: "403: at the storage limit",
829 not_member: "403: not a member",
830 },
831 },
832 {
833 method: "post",
834 path: `${REPO}/origin/reconnect`,
835 summary: "Reconnect a frozen mirror through one of your origins (admin scope, repository admin); answers the repository",
836 auth: true,
837 body: { originId: "string" },
838 },
839 {
840 method: "post",
841 path: "/api/v1/user/origins/{id}/mirror",
842 summary: "Mirror a repository from one of your origins as a job; answers 202 with { job } to poll or stream",
843 auth: true,
844 body: { fullName: "string", name: "string" },
845 status: 202,
846 refusals: { not_found: "404: no such origin of yours, or no such repository there" },
847 },
848 {
849 method: "post",
850 path: "/api/v1/user/origins/{id}/fork",
851 summary: "Fork a repository at its origin, then mirror the fork as a job; answers 202 with { remote, existed, job, mirroredAs }",
852 auth: true,
853 body: { fullName: "string" },
854 status: 202,
855 refusals: {
856 not_found: "404: no such origin of yours",
857 origin_unreachable: "422: the origin could not be reached",
858 fork_timeout: "504: the origin took the fork and it has not appeared yet; try again shortly",
859 mirror_failed: "502: a local copy of the fork could not be made",
860 },
861 },
862 {
863 method: "get",
864 path: "/api/v1/user/origins/{id}/mirror/{job}",
865 summary: "Read a mirror job's stage: { job }; stages run creating, fetching, labels, issues, pulls, comments, crew, then done or failed",
866 auth: true,
867 refusals: { not_found: "404: no such origin or job of yours" },
868 },
869 {
870 method: "get",
871 path: "/api/v1/user/origins/{id}/mirror/{job}/stream",
872 summary: "Watch a mirror job live; answers text/event-stream, one event per stage (mirror.creating, mirror.fetching, mirror.labels, mirror.issues, mirror.pulls, mirror.comments, mirror.crew, mirror.done, mirror.failed) carrying the job, and ends after done or failed",
873 auth: true,
874 refusals: { not_found: "404: no such origin or job of yours" },
875 },
876 {
877 method: "post",
878 path: `${BROWSE}/contribute`,
879 summary:
880 "Contribute to a repository you only browse (admin scope): fork it if you cannot push, branch, commit files and optionally open a pull upstream; answers 201 with the contribution",
881 auth: true,
882 status: 201,
883 body: { branch: "string", base: "string", message: "string", files: "array", openPull: "object" },
884 refusals: {
885 not_found: "404: no such origin of yours, or no such repository there",
886 unverified: "403: prove your email first",
887 origin_unreachable: "422: the origin could not be reached",
888 invalid_branch: "422: that branch is not one this forge can use",
889 unknown_base: "422: that base branch is not one this forge can use",
890 fork_timeout: "504: the origin took the fork and it has not appeared yet; try again shortly",
891 mirror_failed: "502: a local copy of the repository could not be made",
892 pull_failed: "422: the commit landed but the pull did not open; { reason, commit }",
893 },
894 },
895 { method: "get", path: "/api/v1/user/origin-apps", summary: "List your registered OAuth applications, one per origin host", auth: true },
896 {
897 method: "post",
898 path: "/api/v1/user/origin-apps",
899 summary: "Register an OAuth application for an origin host (a forgejo or gitea needs its own; github may fall back to the forge's); answers 201 with the application",
900 auth: true,
901 body: { host: "string", kind: "string", clientId: "string", clientSecret: "string" },
902 status: 201,
903 refusals: {
904 unknown_host: "422: that does not look like a forge host",
905 private_host: "422: that address is inside a network, not on the internet",
906 unreachable: "422: that host could not be reached",
907 },
908 },
909 {
910 method: "delete",
911 path: "/api/v1/user/origin-apps/{id}",
912 summary: "Remove a registered OAuth application of yours; answers 204",
913 auth: true,
914 status: 204,
915 refusals: { not_found: "404: no such application of yours" },
916 },
917 { method: "get", path: `${REPO}/forks`, summary: "List the forks of a repository you can read", auth: false },
918 { method: "get", path: `${REPO}/tags`, summary: "List a repository's tags: [{ name, oid, commit }]", auth: false },
919 {
920 method: "get",
921 path: "/api/v1/family",
922 summary: "The forge's family: { parent, here, children, more }; the parent for anyone, the children for a site admin",
923 auth: false,
924 },
925 { method: "get", path: `${REPO}/app/production/open`, summary: "Open the running app", auth: false },
926 {
927 method: "get",
928 path: `${REPO}/app/{env}/open`,
929 summary:
930 "The URL to open an environment at, as { url }; for a private app it carries a grant for the caller alone and is not cached",
931 auth: false,
932 refusals: {
933 not_found: "404: no such repository or app, or not the caller's to see",
934 no_such_env: "422: `env` is not an environment name",
935 no_runtime: "422: this forge runs no apps",
936 unauthorized: "401: a private app and no signed-in account",
937 },
938 },
939 { method: "get", path: `${REPO}/app`, summary: "Read the repository's app: its settings, environments and state", auth: false },
940 {
941 method: "post",
942 path: `${REPO}/app`,
943 summary: "Turn the repository into an app (admin scope); `access` is public or private, `previewData` is copy or empty; answers 201 with the app",
944 auth: true,
945 body: { slug: "string", access: "string", previewData: "string" },
946 status: 201,
947 refusals: { app_limit: "the owner is at the app limit" },
948 },
949 {
950 method: "patch",
951 path: `${REPO}/app`,
952 summary: "Change the app's settings (admin scope); `access` is public or private, `previewData` is copy or empty",
953 auth: true,
954 body: { slug: "string", access: "string", previewData: "string" },
955 },
956 { method: "delete", path: `${REPO}/app`, summary: "Turn the app off (admin scope); answers {}", auth: true },
957 { method: "get", path: `${REPO}/app/members`, summary: "Read the people a private app names; answers the members", auth: true },
958 {
959 method: "put",
960 path: `${REPO}/app/members`,
961 summary: "Set the people a private app names (admin scope, a person's, never an agent's); a plan unless dryRun is false",
962 auth: true,
963 body: { members: "array", dryRun: "boolean" },
964 refusals: { invalid: "`members` is not a list of usernames" },
965 },
966 { method: "get", path: `${REPO}/app/domains`, summary: "List the app's own domains (admin scope); answers { items, next_cursor }", auth: true },
967 {
968 method: "post",
969 path: `${REPO}/app/domains`,
970 summary: "Add a domain of your own to the app (admin scope); answers 201 with the domain",
971 auth: true,
972 body: { host: "string" },
973 status: 201,
974 },
975 { method: "delete", path: `${REPO}/app/domains/{host}`, summary: "Remove a domain from the app (admin scope); answers {}", auth: true },
976 { method: "post", path: `${REPO}/app/domains/{host}/check`, summary: "Check that a domain points at the forge (admin scope); answers the domain", auth: true },
977 { method: "get", path: `${REPO}/app/env`, summary: "List the names of the app's environment variables (admin scope); a value is written and never read back; answers { items, next_cursor }", auth: true },
978 {
979 method: "put",
980 path: `${REPO}/app/env/{var}`,
981 summary: "Set an environment variable (admin scope); the value is written and never read back; scope is all (default), production or preview",
982 auth: true,
983 body: { value: "string", scope: "string" },
984 },
985 {
986 method: "delete",
987 path: `${REPO}/app/env/{var}`,
988 summary: "Unset an environment variable (admin scope); scope is all (default), production or preview; answers {}",
989 auth: true,
990 query: { scope: "string" },
991 },
992 {
993 method: "post",
994 path: `${REPO}/app/{env}/redeploy`,
995 summary: "Redeploy an environment (rw scope); answers 202 with { run }",
996 auth: true,
997 status: 202,
998 },
999 { method: "post", path: `${REPO}/app/{env}/stop`, summary: "Stop an environment (rw scope); answers {}", auth: true },
1000 { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars", auth: false },
1001 {
1002 method: "post",
1003 path: `${REPO}/plan`,
1004 summary: "Open a plan of issues and their dependencies, all or nothing",
1005 auth: true,
1006 body: { issues: "array" },
1007 },
1008 {
1009 method: "post",
1010 path: "/api/v1/agents",
1011 summary: "Make an agent on the crew (an admin token, or a person's session); answers 201 with the agent",
1012 auth: true,
1013 status: 201,
1014 body: { username: "string", displayName: "string", model: "string", brief: "string", budgetUsd: "integer" },
1015 refusals: { forbidden: "a token below admin scope", unauthorized: "no account" },
1016 },
1017 { method: "get", path: "/api/v1/agents/{username}", summary: "Read one agent; the brief, model and budget show to those who may see them", auth: false, refusals: { not_found: "no such agent, or not yours to see" } },
1018 {
1019 method: "patch",
1020 path: "/api/v1/agents/{username}",
1021 summary: "Change an agent's model, brief or budget (an admin token, or a person's session); answers the agent",
1022 auth: true,
1023 body: { model: "string", brief: "string", budgetUsd: "integer" },
1024 refusals: { forbidden: "a token below admin scope" },
1025 },
1026 { method: "delete", path: "/api/v1/agents/{username}", summary: "Remove an agent (an admin token, or a person's session); answers {}", auth: true, refusals: { forbidden: "a token below admin scope", not_found: "no such agent" } },
1027 {
1028 method: "post",
1029 path: "/api/v1/orgs",
1030 summary: "Make an organisation (rw scope); answers 201 with { id, username, kind: \"org\" }",
1031 auth: true,
1032 status: 201,
1033 body: { name: "string" },
1034 },
1035 {
1036 method: "post",
1037 path: "/api/v1/orgs/{org}/teams",
1038 summary: "Make a team in an organisation (admin scope); answers 201 with the team",
1039 auth: true,
1040 status: 201,
1041 body: { name: "string", permission: "string" },
1042 refusals: { not_found: "no such organisation, or not yours to see" },
1043 },
1044 {
1045 method: "delete",
1046 path: "/api/v1/orgs/{org}/teams/{team}",
1047 summary: "Remove a team (admin scope); answers 204",
1048 auth: true,
1049 status: 204,
1050 refusals: { not_found: "no such team" },
1051 },
1052 {
1053 method: "post",
1054 path: "/api/v1/orgs/{org}/teams/{team}/members",
1055 summary: "Add a user to a team (admin scope); answers 201 with { added: true }, or 200 when they were already in",
1056 auth: true,
1057 status: 201,
1058 body: { user: "string" },
1059 },
1060 {
1061 method: "delete",
1062 path: "/api/v1/orgs/{org}/teams/{team}/members/{username}",
1063 summary: "Take a user out of a team (admin scope); answers 204",
1064 auth: true,
1065 status: 204,
1066 refusals: {
1067 not_found: "no such team, or the user is not in it",
1068 last_owner: "409, an organisation keeps at least one owner; make someone else an owner first",
1069 },
1070 },
1071 { method: "get", path: "/api/v1/contribute", summary: "What an agent that arrives should know: { forge, bottleneck, welcomeLabel, issues, propose, telemetry }; issues are the open welcome-labelled ones in public repositories", auth: false },
1072 {
1073 method: "post",
1074 path: `${REPO}/contribute`,
1075 summary:
1076 "Send files to a mirror's upstream as a branch, and optionally a pull (rw scope); answers 201 with the contribution; a repository that is not a mirror answers not_found",
1077 auth: true,
1078 status: 201,
1079 body: { branch: "string", base: "string", message: "string", files: "array", openPull: "object" },
1080 },
1081 {
1082 method: "post",
1083 path: `${REPO}/edits`,
1084 summary:
1085 "Commit files without git (rw scope): files is [{ path, content }], content null deletes; answers 201 with { commit, branch, pull }",
1086 auth: true,
1087 status: 201,
1088 body: { branch: "string", base: "string", message: "string", newBranch: "string", files: "array", openPull: "object" },
1089 refusals: { no_files: "files holds nothing to write" },
1090 },
1091 {
1092 method: "post",
1093 path: `${REPO}/branches`,
1094 summary:
1095 "Create a branch (rw scope), from `from` or the default branch; answers 201 with the branch",
1096 auth: true,
1097 status: 201,
1098 body: { name: "string", from: "string" },
1099 refusals: {
1100 invalid_name: "name is not a branch name",
1101 unknown_start: "from names nothing in the repository",
1102 exists: "the branch is already there (409)",
1103 origin_refused: "a mirror's origin refused the branch",
1104 },
1105 },
1106 {
1107 method: "delete",
1108 path: `${REPO}/branches/{branch}`,
1109 summary:
1110 "Delete a branch (rw scope); the name may hold slashes; answers 204 with no body",
1111 auth: true,
1112 status: 204,
1113 refusals: {
1114 invalid_name: "not a branch name",
1115 default_branch: "the default branch cannot be deleted (409)",
1116 unknown_branch: "no such branch (404)",
1117 origin_refused: "a mirror's origin refused the deletion",
1118 },
1119 },
1120 {
1121 method: "post",
1122 path: `${REPO}/contents`,
1123 summary:
1124 "Commit files to a branch without git (rw scope): files is [{ path, content }]; answers 201 with the commit",
1125 auth: true,
1126 status: 201,
1127 body: { branch: "string", base: "string", message: "string", files: "array" },
1128 refusals: {
1129 invalid_branch: "branch or base is not a safe ref name",
1130 invalid_path: "a file path is not a safe relative path",
1131 path_conflict: "a path is a directory in the tree, or an ancestor of it is a file",
1132 no_files: "files holds nothing to write",
1133 protected_branch: "the default branch is protected and the caller is not a repo admin",
1134 file_too_large: "a file is over the per-file limit",
1135 commit_too_large: "the files together are over the per-commit limit",
1136 branch_moved: "the branch moved while committing (409)",
1137 land_refused: "the commit could not be landed on the branch",
1138 git: "git failed while committing",
1139 },
1140 },
1141 {
1142 method: "post",
1143 path: "/api/v1/thoughts",
1144 summary: "Keep a thought (rw scope); answers 201 with { id }",
1145 auth: true,
1146 status: 201,
1147 body: { text: "string" },
1148 refusals: { invalid: "text is not a string within the length limit" },
1149 },
1150 {
1151 method: "get",
1152 path: "/api/v1/asked",
1153 summary: "What people asked, and the caller's thought stream; answers { asked, thoughts } (ro scope)",
1154 auth: true,
1155 query: { show: "string", order: "string", repo: "string" },
1156 refusals: { invalid_show: "show is all, unshipped or flagged", invalid_order: "order is oldest or newest" },
1157 },
1158 {
1159 method: "get",
1160 path: "/api/v1/returns",
1161 summary: "What the work returned over the last days, for the caller or one repository; never cached",
1162 auth: true,
1163 query: { repo: "string", days: "integer" },
1164 refusals: { invalid_days: "days is not a whole number in range" },
1165 },
1166 { method: "get", path: "/api/v1/here", summary: "The manifest of what you can do at `path` (default /), given who you are: { here, you, actions, mcp, docs }; never cached", auth: false, query: { path: "string" }, refusals: { not_found: "404: the path names no page you can read" } },
1167 {
1168 method: "get",
1169 path: `${REPO}/issues/{number}/dependencies`,
1170 summary: "What the issue waits for and what waits for it: { blockedBy, blocks }, each a list of { number, title, state, closeReason? }; issues the caller cannot see are left out",
1171 auth: false,
1172 },
1173 {
1174 method: "post",
1175 path: `${REPO}/issues/{number}/dependencies`,
1176 summary: "Make an issue wait for another (rw scope); answers 201 with { issue, blockedBy }",
1177 auth: true,
1178 status: 201,
1179 body: { blockedBy: "integer" },
1180 refusals: {
1181 no_such_issue: "`blockedBy` names no issue in this repository (422)",
1182 self: `${SELF_WAIT} (422)`,
1183 cycle: "that would make a loop (422)",
1184 cross_repo: "dependencies stay inside one repository (422)",
1185 not_an_issue: "only issues can depend on issues (422)",
1186 },
1187 },
1188 {
1189 method: "delete",
1190 path: `${REPO}/issues/{number}/dependencies/{blocker}`,
1191 summary: "Stop an issue waiting for another",
1192 auth: true,
1193 },
1194 { method: "get", path: "/api/v1/forges", summary: "List the child forges (a site admin's, ro scope); answers { items, next_cursor: null }", auth: true },
1195 {
1196 method: "post",
1197 path: "/api/v1/forges",
1198 summary:
1199 "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`",
1200 auth: true,
1201 status: 201,
1202 body: {
1203 name: "string",
1204 apps: "array",
1205 crew: "array",
1206 dryRun: "boolean",
1207 direction: "string",
1208 keepAccess: "boolean",
1209 size: "string",
1210 for: "string",
1211 fresh: "boolean",
1212 followMode: "string",
1213 budgetUsd: "number",
1214 modelKey: "string",
1215 modelEndpoint: "string",
1216 },
1217 refusals: {
1218 invalid: "a field is missing or not valid",
1219 rate_limited: "a stranger has started enough forges today",
1220 },
1221 },
1222 { method: "get", path: "/api/v1/forges/sizes", summary: "What small, medium and large mean; public, cacheable by ETag", auth: false },
1223 {
1224 method: "post",
1225 path: "/api/v1/forges/{name}/claim-link",
1226 summary:
1227 "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",
1228 auth: true,
1229 status: 201,
1230 refusals: { not_found: "no child forge has this name", forbidden: "the caller is not a site admin", claimed: "the forge is already claimed" },
1231 },
1232 { method: "get", path: "/api/v1/forge", summary: "A spawned forge's word about itself: { claimed, lastActivityAt }; lastActivityAt is null unless the parent's secret is sent", auth: false, refusals: { not_found: "404: this forge was not spawned" } },
1233 {
1234 method: "get",
1235 path: "/api/v1/fleet/graph",
1236 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",
1237 auth: true,
1238 query: { repo: "string" },
1239 refusals: { invalid_selection: "`repo` is not a list of owner/name" },
1240 },
1241 {
1242 method: "get",
1243 path: "/api/v1/fleet/ops",
1244 summary: "List the fleet operations, newest first; answers { items, next_cursor }",
1245 auth: true,
1246 query: { limit: "integer", cursor: "string" },
1247 },
1248 { method: "get", path: "/api/v1/fleet/ops/{id}", summary: "Read one fleet operation and what it changed", auth: false },
1249 {
1250 method: "post",
1251 path: "/api/v1/fleet/ops",
1252 summary:
1253 "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",
1254 auth: true,
1255 status: 201,
1256 body: { query: "string", action: "object", dryRun: "boolean" },
1257 },
1258 { 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 },
1259 { method: "get", path: "/api/v1/front", summary: "The front page a stranger is shown: { featured, apps, total }, each entry { repo, description, url, state, picture, pinned, score, signals }; a site admin also gets `picks`", auth: false },
1260 {
1261 method: "put",
1262 path: "/api/v1/front",
1263 summary: "Set the front page's picks (a site admin's, rw scope): `pinned` and `hidden` are arrays of owner/name, `weights` an object of forks, ships, use, fresh; answers the picks",
1264 auth: true,
1265 body: { pinned: "array", hidden: "array", weights: "object" },
1266 refusals: { invalid: "the picks are malformed or name no repository (422)", forbidden: `${ADMIN_WORDS.frontPage} (403)` },
1267 },
1268 {
1269 method: "get",
1270 path: "/api/v1/health",
1271 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",
1272 auth: false,
1273 },
1274 {
1275 method: "get",
1276 path: "/api/v1/passport/health",
1277 summary:
1278 "The forge's public health, for a caller holding a passport (`Authorization: Passport <token>`, audience `forge`, scope `health:read`) rather than a token; answers the same body as the anonymous health, uncached",
1279 auth: false,
1280 refusals: { unauthorized: "the passport is missing, malformed, expired, or not for this audience and scope (always 401)" },
1281 },
1282 { method: "get", path: "/api/v1/map", summary: "The global map: every repository the caller can see, with its issues and dependency edges", auth: false },
1283 {
1284 method: "post",
1285 path: "/api/v1/heartbeat",
1286 summary:
1287 "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`",
1288 auth: false,
1289 refusals: { not_found: "the beat is not signed by a child of this forge" },
1290 },
1291 {
1292 method: "post",
1293 path: "/api/v1/drills",
1294 summary:
1295 "Record a domain drill and its verdict, green or red (a site admin's, admin token only); answers 201 with the recorded drill",
1296 auth: true,
1297 status: 201,
1298 body: { domain: "string", seen: "object", backSeconds: "integer" },
1299 refusals: {
1300 unknown_domain: "`domain` is not one of the forge's failure domains",
1301 invalid_seen: "`seen` needs health and board as text",
1302 invalid_back: "`backSeconds` is not a whole number of seconds, 0 to 31536000",
1303 too_large: "the body is too large",
1304 },
1305 },
1306 {
1307 method: "post",
1308 path: "/api/v1/migrate",
1309 summary:
1310 "Migrate a repository here: source is { url } (needs rw scope) or { originId, fullName } (needs admin scope); after is leave, archive or mirror; branch defaults to the one with the newest commits; crew is { agent, autonomous }; answers 202 with { repo, job }, the clone job watched at /api/v1/migrate/{job}",
1311 auth: true,
1312 status: 202,
1313 body: { source: "object", owner: "string", name: "string", visibility: "string", branch: "string", after: "string", crew: "object" },
1314 refusals: {
1315 unauthorized: "401: no signed-in account",
1316 invalid_source: "422: source is neither { url } nor { originId, fullName }",
1317 invalid_after: "400: `after` is not leave, archive or mirror",
1318 unverified: "403: the account has not verified its email",
1319 repository_limit: "403: the owner is at the repository limit",
1320 storage_limit: "403: the owner is at the storage limit",
1321 invalid_url: "422: the URL is not one this forge will clone from",
1322 private_host: "422: the URL is inside the network",
1323 no_such_origin: "422: the origin is not the caller's, or has no such repository",
1324 unreachable: "422: the source could not be reached",
1325 invalid_branch: "422: the branch is not in the source",
1326 no_such_agent: "422: `crew.agent` names no one on the crew",
1327 },
1328 },
1329 {
1330 method: "get",
1331 path: "/api/v1/migrate/{job}",
1332 summary: "Read a migration job's stage as { job }; only the account that started it can",
1333 auth: true,
1334 refusals: { unauthorized: "401: no signed-in account", not_found: "404: no such job, or not the caller's" },
1335 },
1336 {
1337 method: "get",
1338 path: "/api/v1/migrate/{job}/stream",
1339 summary:
1340 "Watch a migration job as an event stream (text/event-stream, not JSON): one `migrate.<stage>` event per stage with the job as data, closing after done or failed",
1341 auth: true,
1342 refusals: { unauthorized: "401: no signed-in account", not_found: "404: no such job, or not the caller's" },
1343 },
1344 {
1345 method: "get",
1346 path: `${REPO}/issues/{number}/teams`,
1347 summary: "Read the crew teams named on an issue; answers { teams }, a list of team slugs",
1348 auth: false,
1349 },
1350 {
1351 method: "put",
1352 path: `${REPO}/issues/{number}/teams`,
1353 summary: "Replace the crew teams named on an issue (at most two); the pull that closes it is reviewed by each; answers { teams }, the slugs now named. A body without a list of slug strings is a 400",
1354 auth: true,
1355 body: { teams: "array" },
1356 refusals: {
1357 too_many: "more teams than an issue may name",
1358 no_such_team: "a slug names no team",
1359 mirrored: "the repository mirrors an origin; do it there",
1360 forbidden: "the caller cannot write to the repository",
1361 },
1362 },
1363 {
1364 method: "put",
1365 path: `${REPO}/issues/{number}/labels`,
1366 summary: "Replace the label set of an issue (names or ids; the set is all or nothing); answers the labels it now carries as a JSON array",
1367 auth: true,
1368 body: { labels: "array" },
1369 refusals: LABEL_REFUSALS,
1370 },
1371 {
1372 method: "put",
1373 path: `${REPO}/pulls/{number}/labels`,
1374 summary: "Replace the label set of a pull (names or ids; the set is all or nothing); answers the labels it now carries as a JSON array",
1375 auth: true,
1376 body: { labels: "array" },
1377 refusals: LABEL_REFUSALS,
1378 },
1379 {
1380 method: "put",
1381 path: `${REPO}/milestones/{number}/watch`,
1382 summary: "Watch a milestone: the caller's own watch, so a write token and nothing more (a person's; an agent is refused); answers { watching }",
1383 auth: true,
1384 refusals: {
1385 unauthorized: "401: no signed-in account",
1386 forbidden: "403: only a person watches a milestone",
1387 not_found: "404: no such repository, or it is not readable, or the number is no milestone",
1388 },
1389 },
1390 {
1391 method: "delete",
1392 path: `${REPO}/milestones/{number}/watch`,
1393 summary: "Stop watching a milestone: the caller's own watch, so a write token and nothing more (a person's; an agent is refused); answers { watching }",
1394 auth: true,
1395 refusals: {
1396 unauthorized: "401: no signed-in account",
1397 forbidden: "403: only a person watches a milestone",
1398 not_found: "404: no such repository, or it is not readable, or the number is no milestone",
1399 },
1400 },
1401 {
1402 method: "post",
1403 path: `${REPO}/issues/{number}/private`,
1404 summary: "Make, or find, the private repository where a sealed report is fixed by hand; answers 201 { repo, clone }: `owner/name` and its clone URL",
1405 auth: true,
1406 status: 201,
1407 refusals: {
1408 not_found: "no such issue, or sealed reports are not the caller's to see",
1409 not_sealed: "the issue is not sealed, so it has nothing to hide",
1410 unverified: "the account has not verified its email",
1411 name_taken: "the owner already has a repository of the private name",
1412 limit: "the owner is at the daily fork limit",
1413 fork_failed: "the private repository could not be made",
1414 },
1415 },
1416 {
1417 method: "get",
1418 path: `${REPO}/flow`,
1419 summary: "Read how work moves through a repository: { open, live }, where open lists each open issue (and merged ones production has not yet served) as { number, title, stage, since, stalled, unblock, pull } and live lists what went live in the last 24 hours, newest first, the same with liveAt; sealed rows only for those who may see them",
1420 auth: false,
1421 refusals: { not_found: "no such repository, or it is not readable by the caller" },
1422 },
1423 {
1424 method: "get",
1425 path: "/api/v1/crew/scorecard",
1426 summary: "Read the crew's scorecard (a forge admin's, any token scope): { agents: [{ username, all, recent, splits }] }, where all and recent are { opened, landed, reviewRounds, redCheckRate, reverts, meanLandMs } or null, and each split adds model, prompt and its own recent",
1427 auth: true,
1428 refusals: { unauthorized: "no valid credentials were sent", forbidden: "the caller is not a forge admin" },
1429 },
1430 {
1431 method: "post",
1432 path: "/api/v1/users",
1433 summary:
1434 "Sign up: answers 201 with the new account { id, username, displayName, isAdmin, commitEmail }, never the admin; `email` is optional; `invite` is an invite link's token, `turnstile_token` the human check when the forge asks for one; refused from another origin with 403 forbidden",
1435 auth: false,
1436 status: 201,
1437 body: { username: "string", email: "string", password: "string", invite: "string", turnstile_token: "string" },
1438 refusals: {
1439 rate_limited: "429, too many sign-ups; retry-after says when",
1440 too_large: "413, the body is over the limit for a stranger's",
1441 signup_closed: "403, this forge makes no new accounts (or the invite is not one)",
1442 forbidden: "403, the name is not one this edge allows",
1443 passkey_required: "422, this forge signs up with a passkey on the web page",
1444 human_check: "422, the human check failed",
1445 invalid_username: "422, not a name an account may have",
1446 invalid_email: "422, not an email address",
1447 weak_password: "422, the password is too short",
1448 password_too_long: "422, the password is over the cap",
1449 username_taken: "422, another account has the name",
1450 username_reserved: "422, the name is reserved",
1451 email_taken: "422, another account has the address",
1452 },
1453 },
1454 {
1455 method: "post",
1456 path: "/api/v1/sessions",
1457 summary:
1458 "Sign in with a username and password: answers 201 with the account { id, username, displayName, isAdmin, commitEmail } and sets the HttpOnly forge_session cookie; refused from another origin with 403 forbidden",
1459 auth: false,
1460 status: 201,
1461 body: { username: "string", password: "string" },
1462 refusals: {
1463 unauthorized: "401, the username or password is wrong",
1464 too_many_attempts: "429, past the sign-in windows; retry-after says when",
1465 too_large: "413, the body is over the limit for a stranger's",
1466 },
1467 },
1468 {
1469 method: "delete",
1470 path: "/api/v1/sessions",
1471 summary: "Sign out the session in the forge_session cookie: answers 200 with {} and clears the cookie; refused from another origin with 403 forbidden",
1472 auth: false,
1473 },
1474 {
1475 method: "post",
1476 path: "/api/v1/first-admin",
1477 summary:
1478 "Make the forge's first admin with the operator's one-time code, no session or password: answers 201 with { user, scope, id, token, expiresAt, expiresIn }, the token shown once; scope is ro, rw or admin; expiresIn is whole seconds from 60 to a month (default a day); refused from another origin with 403 forbidden",
1479 auth: false,
1480 status: 201,
1481 body: { code: "string", username: "string", email: "string", scope: "string", name: "string", expiresIn: "integer" },
1482 refusals: {
1483 too_large: "413, the body is over the limit for a stranger's",
1484 invalid_scope: "422, scope is not ro, rw or admin",
1485 invalid_expiry: "422, expiresIn is not whole seconds in range",
1486 rate_limited: "429, too many attempts from here; retry-after says when",
1487 refused: "403, the code does not work or this forge already has an admin",
1488 invalid_username: "422, not a name an account may have",
1489 invalid_email: "422, not an email address",
1490 username_taken: "422, another account has the name",
1491 email_taken: "422, another account has the address",
1492 username_reserved: "422, the name is reserved",
1493 unfinished: "422, the account could not be finished; send again",
1494 },
1495 },
1496 {
1497 method: "post",
1498 path: "/api/v1/first-admin/recover",
1499 summary:
1500 "Get a stranded admin a new expiring token with the operator's recovery code; no account is made: answers 201 with { user, scope, id, token, expiresAt, expiresIn }; scope and expiresIn as for first-admin; refused from another origin with 403 forbidden",
1501 auth: false,
1502 status: 201,
1503 body: { code: "string", scope: "string", name: "string", expiresIn: "integer" },
1504 refusals: {
1505 too_large: "413, the body is over the limit for a stranger's",
1506 invalid_scope: "422, scope is not ro, rw or admin",
1507 invalid_expiry: "422, expiresIn is not whole seconds in range",
1508 rate_limited: "429, too many attempts from here; retry-after says when",
1509 refused: "403, the code does not work",
1510 },
1511 },
1512 {
1513 method: "get",
1514 path: "/api/v1/openapi.json",
1515 summary: "The address an agent tries first: answers 308 to /contract/openapi.json, this document",
1516 auth: false,
1517 status: 308,
1518 },
1519 {
1520 method: "get",
1521 path: "/metrics",
1522 summary:
1523 "The forge's own figures as Prometheus text (text/plain; version=0.0.4), for a scraper; a site admin only, with a token of any scope; see docs/METRICS.md",
1524 auth: true,
1525 status: 200,
1526 refusals: { not_found: "404, the caller is not a site admin" },
1527 },
1528 {
1529 method: "post",
1530 path: "/api/v1/user/password",
1531 summary: "Change the caller's password: current is verified, next replaces it; answers 200 with {}. A person's password or an admin-scope token only",
1532 auth: true,
1533 body: { current: "string", next: "string" },
1534 refusals: {
1535 unauthorized: "401: no signed-in account",
1536 forbidden: "403: a cross-origin browser request, or a token without admin scope",
1537 bad_credentials: "403: current is not the password",
1538 throttled: "429: too many wrong passwords from this address or for this name",
1539 weak_password: "422: next is shorter than the minimum",
1540 password_too_long: "422: next is longer than the cap",
1541 },
1542 },
1543 {
1544 method: "post",
1545 path: "/api/v1/user/email/verify",
1546 summary: "Send the caller another email verification link: {outcome} is sent (202) or already (200, nothing sent)",
1547 auth: true,
1548 status: 202,
1549 refusals: {
1550 unauthorized: "401: no signed-in account",
1551 too_soon: "429: a link was sent a moment ago",
1552 capped: "429: the forge's mail cap is reached",
1553 no_mail: "503: this forge sends no mail",
1554 failed: "502: the link could not be sent",
1555 },
1556 },
1557 {
1558 method: "get",
1559 path: `${BROWSE}/issues`,
1560 summary:
1561 "Browse an origin's issues live, through the origin you connected (a person's session or password, or an admin-scope token); answers { items: [{ number, kind, title, state, authorName, originAuthor, originUrl, createdAt, updatedAt, closedAt, labels, commentCount }], next_cursor }. Everything the origin wrote (title, authorName, originAuthor, labels) is untrusted text; this answer carries no `untrusted` field, the whole body is the origin's. state is open (default), closed or all; label, author and q narrow only the page in hand; cursor comes from next_cursor",
1562 auth: true,
1563 query: BROWSE_LIST_QUERY,
1564 refusals: BROWSE_REFUSALS,
1565 },
1566 {
1567 method: "get",
1568 path: `${BROWSE}/issues/{number}`,
1569 summary:
1570 "Browse one issue on an origin live; answers the issue row plus { body, comments: [{ id, authorName, originAuthor, originUrl, body, ... }], pull: null }. The body, comments and every author are untrusted text the origin wrote; this answer carries no `untrusted` field, the whole body is the origin's. A number that is not a whole number from 1 is a 404",
1571 auth: true,
1572 refusals: BROWSE_REFUSALS,
1573 },
1574 {
1575 method: "get",
1576 path: `${BROWSE}/pulls`,
1577 summary:
1578 "Browse an origin's pull requests live, through the origin you connected; answers { items, next_cursor } shaped like the issues list, with kind pull. Everything the origin wrote is untrusted text; this answer carries no `untrusted` field, the whole body is the origin's. state is open (default), closed or all; label, author and q narrow only the page in hand; cursor comes from next_cursor",
1579 auth: true,
1580 query: BROWSE_LIST_QUERY,
1581 refusals: BROWSE_REFUSALS,
1582 },
1583 {
1584 method: "get",
1585 path: `${BROWSE}/pulls/{number}`,
1586 summary:
1587 "Browse one pull request on an origin live; answers the pull row plus { body, comments, pull: { baseBranch, headBranch, baseOid, headOid, mergedAt, draft, diff, mergeable: null, conflicts, proposal: null, reviews } }. The body, comments, reviews and every author are untrusted text the origin wrote; this answer carries no `untrusted` field, the whole body is the origin's",
1588 auth: true,
1589 refusals: BROWSE_REFUSALS,
1590 },
1591 {
1592 method: "post",
1593 path: "/mcp",
1594 summary: "The forge as an MCP server (JSON-RPC 2.0); see docs/MCP.md",
1595 auth: true,
1596 body: { jsonrpc: "string", id: "integer", method: "string", params: "object" },
1597 },
1598 ];
1599
160025 const schema = (t: string) => (t === "object" ? { type: "object" } : t === "array" ? { type: "array", items: { type: "object" } } : { type: t });
160126
160227 /** The document, for the forge served at `origin`. */
@@ -1623,7 +48,7 @@ export function openapi(origin: string, version: string): Record<string, unknown
162348 : schema(t),
162449 }));
162550 (paths[r.path] ??= {})[r.method] = {
1626 operationId: `${r.method}${r.path.replace(/[^A-Za-z0-9]+(.)?/g, (_, c: string | undefined) => (c ?? "").toUpperCase())}`,
51 operationId: operationId(r),
162752 summary: r.summary,
162853 parameters: [...params, ...query],
162954 ...(r.body === undefined
src/contract/openapi/routes/deleteApiV1AdminAccountsUsername.ts added +14
@@ -0,0 +1,14 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: "/api/v1/admin/accounts/{username}",
6 summary:
7 "Remove an account for good (an admin's, admin scope); answers { removed }. With retireOrgs true and no confirm it answers { confirm_required, retire, own } and removes nothing; with both it retires the organizations the person solely owns and answers { removed, retired }, or 500 { error: \"delete_failed\", failed, removed }",
8 auth: true,
9 body: { retireOrgs: "boolean", confirm: "boolean" },
10 refusals: {
11 not_allowed: "an admin cannot remove themselves, another admin or an agent (422)",
12 last_owner: "the person is the only one owning an organization (409)",
13 },
14 };
src/contract/openapi/routes/deleteApiV1AdminInvitesId.ts added +8
@@ -0,0 +1,8 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: "/api/v1/admin/invites/{id}",
6 summary: "Revoke an invite link by id (admin scope); 404 when there is none",
7 auth: true,
8 };
src/contract/openapi/routes/deleteApiV1AgentsUsername.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "delete", path: "/api/v1/agents/{username}", summary: "Remove an agent (an admin token, or a person's session); answers {}", auth: true, refusals: { forbidden: "a token below admin scope", not_found: "no such agent" } };
src/contract/openapi/routes/deleteApiV1OrgsOrgTeamsTeam.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: "/api/v1/orgs/{org}/teams/{team}",
6 summary: "Remove a team (admin scope); answers 204",
7 auth: true,
8 status: 204,
9 refusals: { not_found: "no such team" },
10 };
src/contract/openapi/routes/deleteApiV1OrgsOrgTeamsTeamMembersUsername.ts added +13
@@ -0,0 +1,13 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: "/api/v1/orgs/{org}/teams/{team}/members/{username}",
6 summary: "Take a user out of a team (admin scope); answers 204",
7 auth: true,
8 status: 204,
9 refusals: {
10 not_found: "no such team, or the user is not in it",
11 last_owner: "409, an organisation keeps at least one owner; make someone else an owner first",
12 },
13 };
src/contract/openapi/routes/deleteApiV1ReposOwnerName.ts added +9
@@ -0,0 +1,9 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: REPO,
6 summary: "Delete a repository for good (admin scope, an admin's); answers {}",
7 auth: true,
8 refusals: { not_found: "404: no such repository, or the caller is not its admin", delete_failed: "500: the repository could not be removed" },
9 };
src/contract/openapi/routes/deleteApiV1ReposOwnerNameApp.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "delete", path: `${REPO}/app`, summary: "Turn the app off (admin scope); answers {}", auth: true };
src/contract/openapi/routes/deleteApiV1ReposOwnerNameAppDomainsHost.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "delete", path: `${REPO}/app/domains/{host}`, summary: "Remove a domain from the app (admin scope); answers {}", auth: true };
src/contract/openapi/routes/deleteApiV1ReposOwnerNameAppEnvVar.ts added +9
@@ -0,0 +1,9 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: `${REPO}/app/env/{var}`,
6 summary: "Unset an environment variable (admin scope); scope is all (default), production or preview; answers {}",
7 auth: true,
8 query: { scope: "string" },
9 };
src/contract/openapi/routes/deleteApiV1ReposOwnerNameBranchesBranch.ts added +16
@@ -0,0 +1,16 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: `${REPO}/branches/{branch}`,
6 summary:
7 "Delete a branch (rw scope); the name may hold slashes; answers 204 with no body",
8 auth: true,
9 status: 204,
10 refusals: {
11 invalid_name: "not a branch name",
12 default_branch: "the default branch cannot be deleted (409)",
13 unknown_branch: "no such branch (404)",
14 origin_refused: "a mirror's origin refused the deletion",
15 },
16 };
src/contract/openapi/routes/deleteApiV1ReposOwnerNameCrewUsername.ts added +9
@@ -0,0 +1,9 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: `${REPO}/crew/{username}`,
6 summary: "Take an agent off the repository's crew (admin scope, a repository admin's); answers {}",
7 auth: true,
8 refusals: { no_such_agent: "`username` names no one on the crew" },
9 };
src/contract/openapi/routes/deleteApiV1ReposOwnerNameEgressGrants.ts added +9
@@ -0,0 +1,9 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: `${REPO}/egress-grants`,
6 summary: "Revoke one egress destination, named in the body (admin scope); answers {}",
7 auth: true,
8 body: { destination: "string" },
9 };
src/contract/openapi/routes/deleteApiV1ReposOwnerNameHooksId.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "delete", path: `${REPO}/hooks/{id}`, summary: "Remove a webhook (admin scope); answers {}, 404 when it is not there", auth: true };
src/contract/openapi/routes/deleteApiV1ReposOwnerNameIssuesNumberDependenciesBlocker.ts added +8
@@ -0,0 +1,8 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: `${REPO}/issues/{number}/dependencies/{blocker}`,
6 summary: "Stop an issue waiting for another",
7 auth: true,
8 };
src/contract/openapi/routes/deleteApiV1ReposOwnerNameMilestonesNumberWatch.ts added +13
@@ -0,0 +1,13 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: `${REPO}/milestones/{number}/watch`,
6 summary: "Stop watching a milestone: the caller's own watch, so a write token and nothing more (a person's; an agent is refused); answers { watching }",
7 auth: true,
8 refusals: {
9 unauthorized: "401: no signed-in account",
10 forbidden: "403: only a person watches a milestone",
11 not_found: "404: no such repository, or it is not readable, or the number is no milestone",
12 },
13 };
src/contract/openapi/routes/deleteApiV1ReposOwnerNamePromptsId.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "delete", path: `${REPO}/prompts/{id}`, summary: "Remove a prompt source (admin scope); answers {}", auth: true };
src/contract/openapi/routes/deleteApiV1ReposOwnerNamePullsNumberThreadsAnchorResolve.ts added +12
@@ -0,0 +1,12 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: `${REPO}/pulls/{number}/threads/{anchor}/resolve`,
6 summary: "Reopen a resolved review thread (rw scope; the thread's author or a writer); answers the thread",
7 auth: true,
8 refusals: {
9 not_found: "the pull has no thread at `anchor` (404)",
10 forbidden: "you neither wrote the thread's comments nor can write to the repository (403)",
11 },
12 };
src/contract/openapi/routes/deleteApiV1SecretsScopeName.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: "/api/v1/secrets/{scope}/{name}",
6 summary: "Remove a secret by name (admin scope token); a value is never read back; answers {}",
7 auth: true,
8 query: { ref: "string" },
9 refusals: { not_found: "no such secret or scope owner", invalid_scope: "the scope is not one the forge keeps secrets for" },
10 };
src/contract/openapi/routes/deleteApiV1Sessions.ts added +8
@@ -0,0 +1,8 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: "/api/v1/sessions",
6 summary: "Sign out the session in the forge_session cookie: answers 200 with {} and clears the cookie; refused from another origin with 403 forbidden",
7 auth: false,
8 };
src/contract/openapi/routes/deleteApiV1TokensId.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: "/api/v1/tokens/{id}",
6 summary: "Revoke one of your tokens; a bearer token is refused with 403, call it from a browser session or with a password; answers 204",
7 auth: false,
8 status: 204,
9 refusals: { forbidden: "the caller holds a bearer token, which cannot manage tokens" },
10 };
src/contract/openapi/routes/deleteApiV1UserOriginAppsId.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: "/api/v1/user/origin-apps/{id}",
6 summary: "Remove a registered OAuth application of yours; answers 204",
7 auth: true,
8 status: 204,
9 refusals: { not_found: "404: no such application of yours" },
10 };
src/contract/openapi/routes/deleteApiV1UsersUsernameSessionsId.ts added +14
@@ -0,0 +1,14 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "delete",
5 path: "/api/v1/users/{username}/sessions/{id}",
6 summary: "End one browser session of a person, as when a device is lost; the person themself or the forge's owner, with a browser session, a password or an admin-scope token; answers 204 and never a session secret",
7 auth: true,
8 status: 204,
9 refusals: {
10 unauthorized: "no credentials were sent",
11 forbidden: "the caller holds a token below admin scope",
12 not_found: "no such account or session, or it is not one the caller may end",
13 },
14 };
src/contract/openapi/routes/getApiV1AdminAccounts.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/accounts",
6 summary:
7 "List every person and agent, newest first (a forge admin's, read scope; anyone else gets 404); answers { items: [{ username, kind, displayName, isAdmin, createdAt, via, signsInThrough, address, repos, apps, spentUsd, suspendedAt, removedAt, verified }], next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422",
8 auth: true,
9 query: { limit: "integer", cursor: "string" },
10 };
src/contract/openapi/routes/getApiV1AdminCapacity.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/capacity",
6 summary:
7 "Read the forge's capacity settings (a person admin's token, any scope); answers { items: [{ key, label, help, value, source, default, env, changed? }], changes, next_cursor }",
8 auth: true,
9 refusals: { not_found: "the token is not a person who administers the forge" },
10 };
src/contract/openapi/routes/getApiV1AdminInvites.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/invites",
6 summary: "List invite links without their tokens (an admin's, read scope); answers { items, next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422",
7 auth: true,
8 query: { limit: "integer", cursor: "string" },
9 };
src/contract/openapi/routes/getApiV1AdminLimits.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/limits",
6 summary:
7 "Read the forge's limits (a person admin's token, any scope); answers { items: [{ key, value, isSet, defaultValue, ... }], changes, next_cursor }",
8 auth: true,
9 refusals: { not_found: "the token is not a person who administers the forge" },
10 };
src/contract/openapi/routes/getApiV1AdminPeople.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/people",
6 summary:
7 "List every person and their role (admin scope); answers { items: [{ username, role, usdPerMonth }], next_cursor }, role being admin, member or guest; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422",
8 auth: true,
9 query: { limit: "integer", cursor: "string" },
10 };
src/contract/openapi/routes/getApiV1AdminSettings.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/settings",
6 summary:
7 "Read the forge's signup mode and account quotas (a person admin's token, any scope); answers { quotas, signup, signupChanges, changes }",
8 auth: true,
9 refusals: { not_found: "the token is not a person who administers the forge" },
10 };
src/contract/openapi/routes/getApiV1AdminTeams.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/teams",
6 summary:
7 "Read the crew's teams (a person admin's token, any scope); answers { teams: [{ slug, charter, dial, members, ... }] }",
8 auth: true,
9 refusals: { not_found: "the token is not a person who administers the forge" },
10 };
src/contract/openapi/routes/getApiV1AgentsUsername.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/agents/{username}", summary: "Read one agent; the brief, model and budget show to those who may see them", auth: false, refusals: { not_found: "no such agent, or not yours to see" } };
src/contract/openapi/routes/getApiV1Apps.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/apps", summary: "List the apps whose repositories you can read; answers { items, next_cursor }", auth: false };
src/contract/openapi/routes/getApiV1Asked.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/asked",
6 summary: "What people asked, and the caller's thought stream; answers { asked, thoughts } (ro scope)",
7 auth: true,
8 query: { show: "string", order: "string", repo: "string" },
9 refusals: { invalid_show: "show is all, unshipped or flagged", invalid_order: "order is oldest or newest" },
10 };
src/contract/openapi/routes/getApiV1Contribute.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/contribute", summary: "What an agent that arrives should know: { forge, bottleneck, welcomeLabel, issues, propose, telemetry }; issues are the open welcome-labelled ones in public repositories", auth: false };
src/contract/openapi/routes/getApiV1CrewScorecard.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/crew/scorecard",
6 summary: "Read the crew's scorecard (a forge admin's, any token scope): { agents: [{ username, all, recent, splits }] }, where all and recent are { opened, landed, reviewRounds, redCheckRate, reverts, meanLandMs } or null, and each split adds model, prompt and its own recent",
7 auth: true,
8 refusals: { unauthorized: "no valid credentials were sent", forbidden: "the caller is not a forge admin" },
9 };
src/contract/openapi/routes/getApiV1Explore.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/explore", summary: "Explore apps and repositories; `q` takes words and `kind:`/`owner:` filters; answers { query, items }", auth: false, query: { q: "string" } };
src/contract/openapi/routes/getApiV1Family.ts added +8
@@ -0,0 +1,8 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/family",
6 summary: "The forge's family: { parent, here, children, more }; the parent for anyone, the children for a site admin",
7 auth: false,
8 };
src/contract/openapi/routes/getApiV1FleetGraph.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/fleet/graph",
6 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",
7 auth: true,
8 query: { repo: "string" },
9 refusals: { invalid_selection: "`repo` is not a list of owner/name" },
10 };
src/contract/openapi/routes/getApiV1FleetOps.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/fleet/ops",
6 summary: "List the fleet operations, newest first; answers { items, next_cursor }",
7 auth: true,
8 query: { limit: "integer", cursor: "string" },
9 };
src/contract/openapi/routes/getApiV1FleetOpsId.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/fleet/ops/{id}", summary: "Read one fleet operation and what it changed", auth: false };
src/contract/openapi/routes/getApiV1Forge.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/forge", summary: "A spawned forge's word about itself: { claimed, lastActivityAt }; lastActivityAt is null unless the parent's secret is sent", auth: false, refusals: { not_found: "404: this forge was not spawned" } };
src/contract/openapi/routes/getApiV1Forges.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/forges", summary: "List the child forges (a site admin's, ro scope); answers { items, next_cursor: null }", auth: true };
src/contract/openapi/routes/getApiV1ForgesSizes.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/forges/sizes", summary: "What small, medium and large mean; public, cacheable by ETag", auth: false };
src/contract/openapi/routes/getApiV1Front.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/front", summary: "The front page a stranger is shown: { featured, apps, total }, each entry { repo, description, url, state, picture, pinned, score, signals }; a site admin also gets `picks`", auth: false };
src/contract/openapi/routes/getApiV1Health.ts added +8
@@ -0,0 +1,8 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/health",
6 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",
7 auth: false,
8 };
src/contract/openapi/routes/getApiV1Here.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/here", summary: "The manifest of what you can do at `path` (default /), given who you are: { here, you, actions, mcp, docs }; never cached", auth: false, query: { path: "string" }, refusals: { not_found: "404: the path names no page you can read" } };
src/contract/openapi/routes/getApiV1Map.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/map", summary: "The global map: every repository the caller can see, with its issues and dependency edges", auth: false };
src/contract/openapi/routes/getApiV1MigrateJob.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/migrate/{job}",
6 summary: "Read a migration job's stage as { job }; only the account that started it can",
7 auth: true,
8 refusals: { unauthorized: "401: no signed-in account", not_found: "404: no such job, or not the caller's" },
9 };
src/contract/openapi/routes/getApiV1MigrateJobStream.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/migrate/{job}/stream",
6 summary:
7 "Watch a migration job as an event stream (text/event-stream, not JSON): one `migrate.<stage>` event per stage with the job as data, closing after done or failed",
8 auth: true,
9 refusals: { unauthorized: "401: no signed-in account", not_found: "404: no such job, or not the caller's" },
10 };
src/contract/openapi/routes/getApiV1OpenapiJson.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/openapi.json",
6 summary: "The address an agent tries first: answers 308 to /contract/openapi.json, this document",
7 auth: false,
8 status: 308,
9 };
src/contract/openapi/routes/getApiV1PassportHealth.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/passport/health",
6 summary:
7 "The forge's public health, for a caller holding a passport (`Authorization: Passport <token>`, audience `forge`, scope `health:read`) rather than a token; answers the same body as the anonymous health, uncached",
8 auth: false,
9 refusals: { unauthorized: "the passport is missing, malformed, expired, or not for this audience and scope (always 401)" },
10 };
src/contract/openapi/routes/getApiV1Repos.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerName.ts added +10
@@ -0,0 +1,10 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: REPO,
6 summary:
7 "Read one repository; `provenance` is where a migrated one came from ({ source, sourceUrl, parents: [{ fullName, url }], migratedAt }), null when it was not migrated or its owner hid it; `upstream` is the stored follow check ({ repo, behind, checkedAt, conflict }), shown to an admin of the repository, null when it follows nothing or to anyone else",
8 auth: false,
9 refusals: { not_found: "404: no such repository, or it is not readable by the caller" },
10 };
src/contract/openapi/routes/getApiV1ReposOwnerNameApp.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/app`, summary: "Read the repository's app: its settings, environments and state", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNameAppDomains.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/app/domains`, summary: "List the app's own domains (admin scope); answers { items, next_cursor }", auth: true };
src/contract/openapi/routes/getApiV1ReposOwnerNameAppEnv.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/app/env`, summary: "List the names of the app's environment variables (admin scope); a value is written and never read back; answers { items, next_cursor }", auth: true };
src/contract/openapi/routes/getApiV1ReposOwnerNameAppEnvOpen.ts added +15
@@ -0,0 +1,15 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/app/{env}/open`,
6 summary:
7 "The URL to open an environment at, as { url }; for a private app it carries a grant for the caller alone and is not cached",
8 auth: false,
9 refusals: {
10 not_found: "404: no such repository or app, or not the caller's to see",
11 no_such_env: "422: `env` is not an environment name",
12 no_runtime: "422: this forge runs no apps",
13 unauthorized: "401: a private app and no signed-in account",
14 },
15 };
src/contract/openapi/routes/getApiV1ReposOwnerNameAppMembers.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/app/members`, summary: "Read the people a private app names; answers the members", auth: true };
src/contract/openapi/routes/getApiV1ReposOwnerNameAppProductionOpen.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/app/production/open`, summary: "Open the running app", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNameBranches.ts added +14
@@ -0,0 +1,14 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/branches`,
6 summary:
7 "List a repository's branches; answers { items: [{ name, oid, isDefault, ahead, behind, protected }], next_cursor }; ahead and behind are null past the comparison cap; limit 1 to 200, cursor from next_cursor; a private repository needs a token",
8 auth: false,
9 query: { limit: "integer", cursor: "string" },
10 refusals: {
11 invalid_limit: "limit is not a whole number from 1 to 200 (422)",
12 invalid_cursor: "cursor is not the next_cursor of a page before (422)",
13 },
14 };
src/contract/openapi/routes/getApiV1ReposOwnerNameCommits.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/commits`, summary: "List commits newest first from `ref` (default: the default branch) as a bare array of { oid, parents, author, email, date, subject, body }; limit 1 to 100 (default 30); an unsafe ref is a 422 bad_ref; a private repository needs a token", auth: false, query: { ref: "string", limit: "integer" } };
src/contract/openapi/routes/getApiV1ReposOwnerNameCommitsOid.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/commits/{oid}`, summary: "Read one commit (a full or abbreviated id, or a ref) as { oid, parents, author, email, date, subject, body, diff: { files, additions, deletions } }; a private repository needs a token", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNameContents.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/contents`, summary: "Read the tree or a file at `ref` (default: the default branch); no `path` or a directory answers { type: \"dir\", ref, path, entries }, a file answers { type: \"file\", ref, path, size, encoding: \"utf-8\" | \"base64\", content }, a file over 5 MB is a 413 { type: \"file\", tooLarge: true, size }; a private repository needs a token", auth: false, query: { path: "string", ref: "string" } };
src/contract/openapi/routes/getApiV1ReposOwnerNameCrew.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/crew`, summary: "List the repository's crew; answers { items, next_cursor } (names and roles for every reader, brief, model and budget for writers)", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNameDecisions.ts added +9
@@ -0,0 +1,9 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/decisions`,
6 summary: "Read the decision ledger; `kind` is next, land, retry or guard; answers { decisions, week }",
7 auth: false,
8 query: { kind: "string" },
9 };
src/contract/openapi/routes/getApiV1ReposOwnerNameEgressGrants.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/egress-grants`, summary: "List the destinations the app may reach (admin scope); answers { items, changes, next_cursor }", auth: true };
src/contract/openapi/routes/getApiV1ReposOwnerNameEvents.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/events`, summary: "Page through a repository's events as the caller may see them, newest first: {items, next_cursor}; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", auth: false, query: { limit: "integer", cursor: "string" } };
src/contract/openapi/routes/getApiV1ReposOwnerNameFlow.ts added +9
@@ -0,0 +1,9 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/flow`,
6 summary: "Read how work moves through a repository: { open, live }, where open lists each open issue (and merged ones production has not yet served) as { number, title, stage, since, stalled, unblock, pull } and live lists what went live in the last 24 hours, newest first, the same with liveAt; sealed rows only for those who may see them",
7 auth: false,
8 refusals: { not_found: "no such repository, or it is not readable by the caller" },
9 };
src/contract/openapi/routes/getApiV1ReposOwnerNameFollowing.ts added +10
@@ -0,0 +1,10 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/following`,
6 summary:
7 "A fork's standing against its parent, read from git: { chain, severedAt, upstream, parent, parentBranch, parentFrozen, branch, state, behind, ahead, clean, conflicts, forkTip, parentTip, parentForge, converge }; a repository that only follows across forges answers { chain, severedAt, upstream }",
8 auth: false,
9 refusals: { not_a_fork: "404: this repository follows nothing" },
10 };
src/contract/openapi/routes/getApiV1ReposOwnerNameFollowingProposals.ts added +8
@@ -0,0 +1,8 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/following/proposals`,
6 summary: "How each change proposed to the parent forge stands, as the parent last said; answers { items }",
7 auth: false,
8 };
src/contract/openapi/routes/getApiV1ReposOwnerNameForks.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/forks`, summary: "List the forks of a repository you can read", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNameHooks.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/hooks`, summary: "List the repository's webhooks (admin scope); answers an array of webhooks", auth: true };
src/contract/openapi/routes/getApiV1ReposOwnerNameIssues.ts added +3
@@ -0,0 +1,3 @@
1 import { LIST_QUERY, REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/issues`, summary: "List a repository's issues; fields=brief leaves out bodies and comments, any other fields value is a 422 invalid_fields", auth: false, query: { ...LIST_QUERY, fields: "string" } };
src/contract/openapi/routes/getApiV1ReposOwnerNameIssuesNumber.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/issues/{number}`, summary: "Read one issue with its comments; comments=N (0 to 200) answers only the newest N and commentCount, any other value is a 422 invalid_comments", auth: false, query: { comments: "integer" } };
src/contract/openapi/routes/getApiV1ReposOwnerNameIssuesNumberComments.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/issues/{number}/comments`, summary: "Page through an issue's or pull's comments, oldest first: {comments, next}; limit 1 to 100 (default 50), after a comment id", auth: false, query: { after: "string", limit: "integer" } };
src/contract/openapi/routes/getApiV1ReposOwnerNameIssuesNumberDependencies.ts added +8
@@ -0,0 +1,8 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/issues/{number}/dependencies`,
6 summary: "What the issue waits for and what waits for it: { blockedBy, blocks }, each a list of { number, title, state, closeReason? }; issues the caller cannot see are left out",
7 auth: false,
8 };
src/contract/openapi/routes/getApiV1ReposOwnerNameIssuesNumberTeams.ts added +8
@@ -0,0 +1,8 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/issues/{number}/teams`,
6 summary: "Read the crew teams named on an issue; answers { teams }, a list of team slugs",
7 auth: false,
8 };
src/contract/openapi/routes/getApiV1ReposOwnerNameLabels.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/labels`, summary: "List a repository's labels by name as a bare array of { id, name, color }; a private repository needs a token", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNameMap.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNameMilestones.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/milestones`, summary: "Read the plan: { repo: { owner, name }, limbs }, one limb per milestone with its weights (and askToLive for a pillar's umbrella); a private repository needs a token", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNameMilestonesNumber.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/milestones/{number}`, summary: "Read one milestone (a 404 for an issue that waits for nothing) as { repo, ...milestone, askToLive?, watching? }; watching is present only for a signed-in user; a private repository needs a token", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNamePrompts.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/prompts`, summary: "List the repository's prompt sources (admin scope); answers { items, next_cursor }, tokens never come back", auth: true };
src/contract/openapi/routes/getApiV1ReposOwnerNamePulls.ts added +3
@@ -0,0 +1,3 @@
1 import { LIST_QUERY, REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/pulls`, summary: "List a repository's pulls", auth: false, query: LIST_QUERY };
src/contract/openapi/routes/getApiV1ReposOwnerNamePullsNumber.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/pulls/{number}`, summary: "Read one pull with its diff and review gate (diff=0 leaves the diff out)", auth: false, query: { diff: "string" } };
src/contract/openapi/routes/getApiV1ReposOwnerNamePullsNumberChecks.ts added +9
@@ -0,0 +1,9 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/pulls/{number}/checks`,
6 summary: "A pull's checks at its current head; answers { head, state, required, run, runs } with state `none` when no run exists",
7 auth: false,
8 reply: { head: "string", state: "string", required: "boolean", run: "object", runs: "array" },
9 };
src/contract/openapi/routes/getApiV1ReposOwnerNamePullsNumberReviews.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/pulls/{number}/reviews`, summary: "List a pull's line-anchored review comments, each with its `anchor` and whether it is `outdated`", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNamePullsNumberThreads.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/pulls/{number}/threads`, summary: "List a pull's review threads: every review comment sharing one anchor, and whether the conversation is resolved", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNameRuns.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/runs`, summary: "List a repository's runs, newest first: { items, next_cursor }; kind deploy or agent, env an environment name, limit 1 to 200 (default 50), cursor a run id; a stranger's runs carry no cost, turns, agent session or node", auth: false, query: { kind: "string", env: "string", limit: "integer", cursor: "string" } };
src/contract/openapi/routes/getApiV1ReposOwnerNameRunsId.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/runs/{id}`, summary: "Read one run as this reader may see it, with `carries`, the merged pulls a finished production deploy carries", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNameRunsIdLog.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/runs/{id}/log`, summary: "Read a run's log as text/plain, one line per line; follow=1 or Accept: text/event-stream tails it live as server-sent events (line, then done); 404 when the log is hidden", auth: false, query: { follow: "string" } };
src/contract/openapi/routes/getApiV1ReposOwnerNameTags.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/tags`, summary: "List a repository's tags: [{ name, oid, commit }]", auth: false };
src/contract/openapi/routes/getApiV1ReposOwnerNameUpstream.ts added +8
@@ -0,0 +1,8 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/upstream`,
6 summary: "The outside upstream this repository follows, for an admin: { following } (null when it follows none)",
7 auth: true,
8 };
src/contract/openapi/routes/getApiV1ReposOwnerNameUsage.ts added +10
@@ -0,0 +1,10 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/usage`,
6 summary: "What the crew's runs here spent over a window, the owner's own facts: { app, days, since, total, bySubscription, byAgent, byIssue, recent, standing, spend, guard }; days 1 to 90; 403 to a reader who cannot write",
7 auth: true,
8 query: { days: "integer" },
9 refusals: { invalid_days: "days is not a whole number from 1 to 90" },
10 };
src/contract/openapi/routes/getApiV1ReposOwnerNameWaves.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/waves`, summary: "List what shipped, newest first, as { items: [{ version, date, headline }], next_cursor: null }; a file that is not a wave is left out; a private repository needs a token", auth: false, reply: { items: "array", next_cursor: "null" }, refusals: { not_found: "404: no such repository, or it is not readable" } };
src/contract/openapi/routes/getApiV1ReposOwnerNameWavesVersion.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/waves/{version}`, summary: "Read one wave as { version, date, headline, highlights, pulls: [{ number, title, url }] }; number is null when the line names no pull; a private repository needs a token", auth: false, reply: { version: "string", date: "string", headline: "string", highlights: "array", pulls: "array" }, refusals: { not_found: "404: no such repository, or it is not readable, or no such wave" } };
src/contract/openapi/routes/getApiV1Returns.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/returns",
6 summary: "What the work returned over the last days, for the caller or one repository; never cached",
7 auth: true,
8 query: { repo: "string", days: "integer" },
9 refusals: { invalid_days: "days is not a whole number in range" },
10 };
src/contract/openapi/routes/getApiV1Search.ts added +3
@@ -0,0 +1,3 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: "/api/v1/search", summary: "Search issues, pulls, repositories and code; `kind` narrows it", auth: false, query: { q: "string", kind: "string" } };
src/contract/openapi/routes/getApiV1SecretsScope.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/secrets/{scope}",
6 summary: "List a scope's secret names and timestamps, never a value; admin only; `ref` names an org or `owner/name`, the forge scope takes none; answers { items, next_cursor }",
7 auth: true,
8 query: { ref: "string" },
9 refusals: { not_found: "the scope's owner is unknown", invalid_scope: "the scope is not one the forge keeps secrets for" },
10 };

112 more files not shown.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs