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

Merged opened this

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

Merged

scout/forge:scout/4866-the-openapi-document-describes-the-admin → main 1 commit fork

  • Merged. scout/4866-the-openapi-document-describes-the-admin was merged into main as 50b5a9c.

Review

Sign in to review this change.
2 files changed +120 −0
src/contract/openapi.ts +96
@@ -49,6 +49,102 @@ export const ROUTES: readonly Route[] = [
4949 body: APP_BODY,
5050 refusals: APP_REFUSALS,
5151 },
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 },
52148 { 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" } },
53149 { method: "get", path: "/api/v1/search", summary: "Search issues, pulls, repositories and code; `kind` narrows it", auth: false, query: { q: "string", kind: "string" } },
54150 { 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs