Public: anyone can read this; the crew can change it.
The OpenAPI document describes the admin's account routes: accounts, roles, ceilings, grants, suspend, verify and invites #4875
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4866-the-openapi-document-describes-the-admin → main 1 commit fork
2 files
changed +120
−0
src/contract/openapi.ts +96
| @@ -49,6 +49,102 @@ export const ROUTES: readonly Route[] = [ | ||
| 49 | 49 | body: APP_BODY, |
| 50 | 50 | refusals: APP_REFUSALS, |
| 51 | 51 | }, |
| 52 | { | |
| 53 | method: "get", | |
| 54 | path: "/api/v1/admin/accounts", | |
| 55 | summary: | |
| 56 | "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 }] }", | |
| 57 | auth: true, | |
| 58 | }, | |
| 59 | { | |
| 60 | method: "delete", | |
| 61 | path: "/api/v1/admin/accounts/{username}", | |
| 62 | summary: | |
| 63 | "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 }", | |
| 64 | auth: true, | |
| 65 | body: { retireOrgs: "boolean", confirm: "boolean" }, | |
| 66 | refusals: { | |
| 67 | not_allowed: "an admin cannot remove themselves, another admin or an agent (422)", | |
| 68 | last_owner: "the person is the only one owning an organization (409)", | |
| 69 | }, | |
| 70 | }, | |
| 71 | { | |
| 72 | method: "post", | |
| 73 | path: "/api/v1/admin/accounts/{username}/role", | |
| 74 | 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", | |
| 75 | auth: true, | |
| 76 | body: { role: "string", usdPerMonth: "number" }, | |
| 77 | refusals: { | |
| 78 | invalid: "`role` is not member or guest, or `usdPerMonth` is not from 0.01 to 10000 dollars (422)", | |
| 79 | not_allowed: "the person is an admin (422)", | |
| 80 | }, | |
| 81 | }, | |
| 82 | { | |
| 83 | method: "post", | |
| 84 | path: "/api/v1/admin/accounts/{username}/ceiling", | |
| 85 | 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", | |
| 86 | auth: true, | |
| 87 | body: { dailyUsd: "number", perRunUsd: "number" }, | |
| 88 | refusals: { invalid: "an amount is not null or from 0.01 to 10000 dollars (422)" }, | |
| 89 | }, | |
| 90 | { | |
| 91 | method: "post", | |
| 92 | path: "/api/v1/admin/accounts/{username}/model-grant", | |
| 93 | summary: "Grant a person a capped model allowance in dollars a month; null or 0 takes it back (admin scope); answers the grant", | |
| 94 | auth: true, | |
| 95 | body: { usdPerMonth: "number" }, | |
| 96 | refusals: { | |
| 97 | invalid: "`usdPerMonth` is not null or from 0.01 to 10000 dollars (422)", | |
| 98 | not_allowed: "the person is an admin (422)", | |
| 99 | }, | |
| 100 | }, | |
| 101 | { | |
| 102 | method: "post", | |
| 103 | path: "/api/v1/admin/accounts/{username}/suspend", | |
| 104 | summary: "Suspend an account, undone by unsuspend (admin scope); answers { username, suspendedAt }", | |
| 105 | auth: true, | |
| 106 | body: { confirm: "boolean" }, | |
| 107 | refusals: { | |
| 108 | not_allowed: "an admin cannot suspend themselves, another admin or an agent (422)", | |
| 109 | last_owner: "the person is the only active owner of an organization; send confirm true to suspend anyway (409)", | |
| 110 | }, | |
| 111 | }, | |
| 112 | { | |
| 113 | method: "post", | |
| 114 | path: "/api/v1/admin/accounts/{username}/unsuspend", | |
| 115 | summary: "Lift an account's suspension (admin scope); answers { username, suspendedAt: null }", | |
| 116 | auth: true, | |
| 117 | refusals: { not_allowed: "the target is an admin, an agent or yourself (422)" }, | |
| 118 | }, | |
| 119 | { | |
| 120 | method: "post", | |
| 121 | path: "/api/v1/admin/accounts/{username}/verify", | |
| 122 | summary: "Mark an account's email verified by hand (admin scope); answers { username, verified: true }", | |
| 123 | auth: true, | |
| 124 | refusals: { not_allowed: "the target is an admin, an agent or yourself (422)" }, | |
| 125 | }, | |
| 126 | { | |
| 127 | method: "get", | |
| 128 | path: "/api/v1/admin/invites", | |
| 129 | summary: "List invite links without their tokens (an admin's, read scope); answers { items }", | |
| 130 | auth: true, | |
| 131 | }, | |
| 132 | { | |
| 133 | method: "post", | |
| 134 | path: "/api/v1/admin/invites", | |
| 135 | summary: | |
| 136 | "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", | |
| 137 | auth: true, | |
| 138 | status: 201, | |
| 139 | body: { days: "integer" }, | |
| 140 | refusals: { invalid: "`days` is not a whole number from 1 to 30 (422)" }, | |
| 141 | }, | |
| 142 | { | |
| 143 | method: "delete", | |
| 144 | path: "/api/v1/admin/invites/{id}", | |
| 145 | summary: "Revoke an invite link by id (admin scope); 404 when there is none", | |
| 146 | auth: true, | |
| 147 | }, | |
| 52 | 148 | { 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" } }, |
| 53 | 149 | { method: "get", path: "/api/v1/search", summary: "Search issues, pulls, repositories and code; `kind` narrows it", auth: false, query: { q: "string", kind: "string" } }, |
| 54 | 150 | { method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false }, |
test/openapi-oa-acct.test.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | /** The OpenAPI document describes the admin's account and invite routes. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { openapi } from "../src/contract/openapi.ts"; | |
| 4 | ||
| 5 | const A = "/api/v1/admin/accounts"; | |
| 6 | const WANT: readonly (readonly [string, string])[] = [ | |
| 7 | ["get", A], | |
| 8 | ["delete", `${A}/{username}`], | |
| 9 | ...["role", "ceiling", "model-grant", "suspend", "unsuspend", "verify"].map((a) => ["post", `${A}/{username}/${a}`] as const), | |
| 10 | ["get", "/api/v1/admin/invites"], | |
| 11 | ["post", "/api/v1/admin/invites"], | |
| 12 | ["delete", "/api/v1/admin/invites/{id}"], | |
| 13 | ]; | |
| 14 | ||
| 15 | test("each admin account and invite route is in the served document", () => { | |
| 16 | const doc = openapi("https://f.test", "1") as any; | |
| 17 | for (const [method, path] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); | |
| 18 | }); | |
| 19 | ||
| 20 | test("the invite row says the link is shown once", () => { | |
| 21 | const doc = openapi("https://f.test", "1") as any; | |
| 22 | expect(doc.paths["/api/v1/admin/invites"].post.summary).toContain("shown once"); | |
| 23 | expect(doc.paths["/api/v1/admin/invites"].post.security).toBeDefined(); | |
| 24 | }); |
new above
offline