Public: anyone can read this; the crew can change it.
The /api/v1/admin routes move out of src/http/api.ts into src/http/api/admin.ts, unchanged #7478
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7445-the-api-v1-admin-routes-move-out-of-src → main 2 commits fork 1 thread · 1 unresolved
2 files
changed +360
−347
src/http/api.ts +5 −347
| @@ -19,9 +19,7 @@ import { | ||
| 19 | 19 | } from "../actions/origin_app_words.ts"; |
| 20 | 20 | import { getProvenance, provenanceFacts } from "../core/provenance.ts"; |
| 21 | 21 | import { goldenPath } from "../apps/golden_path.ts"; |
| 22 | import { setRole } from "../actions/roles.ts"; | |
| 23 | import { listPeople, NOT_A_MEMBER } from "../core/roles.ts"; | |
| 24 | import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts"; | |
| 22 | import { NOT_A_MEMBER } from "../core/roles.ts"; | |
| 25 | 23 | import { storageLimitMessage } from "../core/storage.ts"; |
| 26 | 24 | import { voteVariant } from "../actions/variants.ts"; |
| 27 | 25 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../core/route_timing.ts"; |
| @@ -33,12 +31,7 @@ import { DOMAINS } from "../kernel/domains.ts"; | ||
| 33 | 31 | import { readBounded } from "./limits.ts"; |
| 34 | 32 | import { askCheck } from "../checks/again.ts"; |
| 35 | 33 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 36 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; | |
| 37 | import { changeLimit, listLimits } from "../actions/limits.ts"; | |
| 38 | import { changeTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts"; | |
| 39 | 34 | import { readScorecards } from "../actions/scorecard.ts"; |
| 40 | import { changeQuotas, readQuotas } from "../actions/quotas.ts"; | |
| 41 | import { changeSignup, readSignup } from "../actions/signup.ts"; | |
| 42 | 35 | import { |
| 43 | 36 | accountSignIns, |
| 44 | 37 | endAccountSession, |
| @@ -53,15 +46,6 @@ import { | ||
| 53 | 46 | isAskedShow, |
| 54 | 47 | } from "../core/asked.ts"; |
| 55 | 48 | import { streamJson, thoughtStream } from "../core/thought_stream.ts"; |
| 56 | import { | |
| 57 | listAccounts, | |
| 58 | removeAccount, | |
| 59 | retireAndRemoveAccount, | |
| 60 | retirementPlan, | |
| 61 | suspendAccount, | |
| 62 | unsuspendAccount, | |
| 63 | verifyAccount, | |
| 64 | } from "../actions/accounts.ts"; | |
| 65 | 49 | import { PROVE_FIRST } from "../actions/verify_words.ts"; |
| 66 | 50 | import { REPORT_LINK_TITLE } from "../actions/report_words.ts"; |
| 67 | 51 | import { editMessage } from "../actions/edit_words.ts"; |
| @@ -149,10 +133,6 @@ import { | ||
| 149 | 133 | type Issue, |
| 150 | 134 | type IssueState, |
| 151 | 135 | } from "../core/issues.ts"; |
| 152 | import { | |
| 153 | soleOwnedOrgs, | |
| 154 | lastOwnerSays, | |
| 155 | } from "../core/orgs.ts"; | |
| 156 | 136 | import { |
| 157 | 137 | getPull, |
| 158 | 138 | listPulls, |
| @@ -282,7 +262,6 @@ import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts"; | ||
| 282 | 262 | import { repoDir } from "../git/repos.ts"; |
| 283 | 263 | import { carriedPulls, pullPath } from "../story/wave_pulls.ts"; |
| 284 | 264 | import { pullRefs, readWave, readWaves } from "../story/waves.ts"; |
| 285 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../actions/invites.ts"; | |
| 286 | 265 | import { originForRepo, type OriginRepoRow } from "../core/origins.ts"; |
| 287 | 266 | import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts"; |
| 288 | 267 | import type { Origin } from "../origins/types.ts"; |
| @@ -338,6 +317,7 @@ import { agentRoutes } from "./api/agents.ts"; | ||
| 338 | 317 | import { boardRoutes } from "./api/boards.ts"; |
| 339 | 318 | import { followingRoutes } from "./api/following.ts"; |
| 340 | 319 | import { labelsRoutes } from "./api/labels.ts"; |
| 320 | import { accountAdmin, adminRoutes } from "./api/admin.ts"; | |
| 341 | 321 | import { issueRoutes, labelsHandler } from "./api/issues.ts"; |
| 342 | 322 | import { userRoutes } from "./api/user.ts"; |
| 343 | 323 | import { appRoutes } from "./api/app.ts"; |
| @@ -357,9 +337,7 @@ import { egressMetrics } from "../apps/egress_metrics.ts"; | ||
| 357 | 337 | import { forgeMetrics } from "../metrics/forge.ts"; |
| 358 | 338 | import { appSources } from "../metrics/app_sources.ts"; |
| 359 | 339 | import { ADMIN_WORDS } from "../actions/admin_words.ts"; |
| 360 | import { SETTING_WORDS } from "../actions/setting_words.ts"; | |
| 361 | import { SPEND_WORDS } from "../actions/spend_words.ts"; | |
| 362 | import { ANSWER_WORDS, ORDER_WORDS, SIGNUP_WORDS, VISIBILITY_WORDS } from "../core/arg_words.ts"; | |
| 340 | import { ANSWER_WORDS, ORDER_WORDS, VISIBILITY_WORDS } from "../core/arg_words.ts"; | |
| 363 | 341 | |
| 364 | 342 | /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */ |
| 365 | 343 | export async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> { |
| @@ -851,7 +829,7 @@ export function listQuery(ctx: Ctx) { | ||
| 851 | 829 | /** Room for the longest key: a pin's `owner/name` (up to ~40 + 1 + 100 characters). */ |
| 852 | 830 | const KEYED_CURSOR_MAX = 160; |
| 853 | 831 | |
| 854 | function offsetRefused(error: "invalid_limit" | "invalid_cursor"): Response { | |
| 832 | export function offsetRefused(error: "invalid_limit" | "invalid_cursor"): Response { | |
| 855 | 833 | return error === "invalid_limit" |
| 856 | 834 | ? apiError(error, 422, `limit is a whole number from 1 to ${OFFSET_LIMIT_MAX}`) |
| 857 | 835 | : apiError(error, 422, "cursor is the next_cursor of a page before"); |
| @@ -1163,21 +1141,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1163 | 1141 | }); |
| 1164 | 1142 | |
| 1165 | 1143 | userRoutes(router); |
| 1166 | ||
| 1167 | // ---- accounts: the owner's view of who joined (#660) ---- | |
| 1168 | // Hidden from everyone but a forge admin: the door is not there. | |
| 1169 | const accountError = (error: string): Response => | |
| 1170 | error === "not_found" | |
| 1171 | ? notFound() | |
| 1172 | : error === "not_allowed" | |
| 1173 | ? apiError( | |
| 1174 | "not_allowed", | |
| 1175 | 422, | |
| 1176 | ADMIN_WORDS.selfSuspend, | |
| 1177 | ) | |
| 1178 | : apiError(error, 500, "the account could not be removed"); | |
| 1179 | const accountAdmin = (ctx: Ctx, scope: "ro" | "admin"): Response | undefined => | |
| 1180 | ctx.auth.user?.isAdmin === true ? requireScope(ctx, scope) : notFound(); | |
| 1144 | adminRoutes(router); | |
| 1181 | 1145 | |
| 1182 | 1146 | // Open read hooks (#1789): the owner, or an agent holding `telemetry:read`. |
| 1183 | 1147 | // Anyone else is refused; every agent read is logged. |
| @@ -1236,64 +1200,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1236 | 1200 | return json(recordDrill(ctx.app.db, ctx.auth.user!.id, domain.key, observed, back, verdict), 201); |
| 1237 | 1201 | }); |
| 1238 | 1202 | |
| 1239 | // The forge's capacity (`core/capacity.ts`): read and changed live by an | |
| 1240 | // admin, never by an agent; anyone else is told the door is not there. | |
| 1241 | router.add("GET", "/api/v1/admin/capacity", (ctx) => { | |
| 1242 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1243 | const denied = accountAdmin(ctx, "ro"); | |
| 1244 | if (denied) return denied; | |
| 1245 | const listed = timePhase(ctx.request, "capacity", () => listCapacity(ctx.app, ctx.auth.user)); | |
| 1246 | return timePhase(ctx.request, "render", () => (listed.ok ? json({ items: listed.value.settings, changes: listed.value.changes, next_cursor: null }) : notFound())); | |
| 1247 | }); | |
| 1248 | ||
| 1249 | router.add("PUT", "/api/v1/admin/capacity/:key", async (ctx) => { | |
| 1250 | const denied = accountAdmin(ctx, "admin"); | |
| 1251 | if (denied) return denied; | |
| 1252 | const input = await body(ctx.request); | |
| 1253 | const value = input.value === null ? null : str(input.value); | |
| 1254 | const changed = changeCapacity(ctx.app, ctx.auth.user, ctx.params.key!, value); | |
| 1255 | if (changed.ok) return json(changed.value); | |
| 1256 | if (changed.error === "not_found") return notFound(); | |
| 1257 | return apiError( | |
| 1258 | changed.error, | |
| 1259 | 422, | |
| 1260 | changed.error === "no_such_setting" | |
| 1261 | ? "no such capacity setting" | |
| 1262 | : SETTING_WORDS.outOfRange, | |
| 1263 | ); | |
| 1264 | }); | |
| 1265 | ||
| 1266 | // The forge's limits (`settings/limits.ts`): the same door as capacity. | |
| 1267 | router.add("GET", "/api/v1/admin/limits", (ctx) => { | |
| 1268 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1269 | const denied = accountAdmin(ctx, "ro"); | |
| 1270 | if (denied) return denied; | |
| 1271 | const listed = timePhase(ctx.request, "limits", () => listLimits(ctx.app, ctx.auth.user)); | |
| 1272 | return timePhase(ctx.request, "render", () => (listed.ok ? json({ items: listed.value.limits, changes: listed.value.changes, next_cursor: null }) : notFound())); | |
| 1273 | }); | |
| 1274 | ||
| 1275 | router.add("PUT", "/api/v1/admin/limits/:key", async (ctx) => { | |
| 1276 | const denied = accountAdmin(ctx, "admin"); | |
| 1277 | if (denied) return denied; | |
| 1278 | const input = await body(ctx.request); | |
| 1279 | const value = | |
| 1280 | input.value === null | |
| 1281 | ? null | |
| 1282 | : typeof input.value === "number" && Number.isFinite(input.value) | |
| 1283 | ? String(input.value) | |
| 1284 | : str(input.value); | |
| 1285 | const changed = changeLimit(ctx.app, ctx.auth.user, ctx.params.key!, value); | |
| 1286 | if (changed.ok) return json(changed.value); | |
| 1287 | if (changed.error.kind === "not_found") return notFound(); | |
| 1288 | return apiError( | |
| 1289 | changed.error.kind, | |
| 1290 | 422, | |
| 1291 | changed.error.kind === "no_such_limit" | |
| 1292 | ? "no such limit" | |
| 1293 | : changed.error.message, | |
| 1294 | ); | |
| 1295 | }); | |
| 1296 | ||
| 1297 | 1203 | // The crew's scorecard (`core/scorecard.ts`): a read, so any scope of a |
| 1298 | 1204 | // forge admin's token, as the MCP tool; 401 without one, 403 for anyone else. |
| 1299 | 1205 | router.add("GET", "/api/v1/crew/scorecard", (ctx) => { |
| @@ -1305,254 +1211,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1305 | 1211 | return timePhase(ctx.request, "render", () => json(card)); |
| 1306 | 1212 | }); |
| 1307 | 1213 | |
| 1308 | // Crew teams (`core/teams.ts`): a forge admin who is a person; the page's door. | |
| 1309 | router.add("GET", "/api/v1/admin/teams", (ctx) => { | |
| 1310 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1311 | const denied = accountAdmin(ctx, "ro"); | |
| 1312 | if (denied) return denied; | |
| 1313 | const read = timePhase(ctx.request, "teams", () => readTeams(ctx.app, ctx.auth.user)); | |
| 1314 | return read.ok ? timePhase(ctx.request, "render", () => json({ teams: read.value.teams })) : notFound(); | |
| 1315 | }); | |
| 1316 | ||
| 1317 | router.add("PUT", "/api/v1/admin/teams/:slug", async (ctx) => { | |
| 1318 | const denied = accountAdmin(ctx, "admin"); | |
| 1319 | if (denied) return denied; | |
| 1320 | const input = await body(ctx.request); | |
| 1321 | const { charter, dial, members } = input; | |
| 1322 | if ( | |
| 1323 | (charter !== undefined && typeof charter !== "string") || | |
| 1324 | (dial !== undefined && typeof dial !== "number" && typeof dial !== "string") || | |
| 1325 | (members !== undefined && | |
| 1326 | (!Array.isArray(members) || !members.every((m) => typeof m === "string"))) | |
| 1327 | ) | |
| 1328 | return apiError("invalid_body", 422, "send charter (text), dial (a whole number) and members (agent ids)"); | |
| 1329 | const changed = changeTeam(ctx.app, ctx.auth.user, ctx.params.slug ?? "", { | |
| 1330 | ...(charter === undefined ? {} : { charter: charter as string }), | |
| 1331 | ...(dial === undefined ? {} : { dial: String(dial) }), | |
| 1332 | ...(members === undefined ? {} : { members: members as string[] }), | |
| 1333 | }); | |
| 1334 | if (changed.ok) return json(changed.value); | |
| 1335 | if (changed.error === "not_found") return notFound(); | |
| 1336 | return apiError(changed.error, 422, TEAM_SAYS[changed.error]); | |
| 1337 | }); | |
| 1338 | ||
| 1339 | // Per-account quotas (`core/quotas.ts`): null is no limit. Each is | |
| 1340 | // asked when the thing is made or started (`settings/quotas.ts`), never of a | |
| 1341 | // forge admin. | |
| 1342 | router.add("GET", "/api/v1/admin/settings", (ctx) => { | |
| 1343 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1344 | const denied = accountAdmin(ctx, "ro"); | |
| 1345 | if (denied) return denied; | |
| 1346 | const { read, signup } = timePhase(ctx.request, "settings", () => ({ | |
| 1347 | read: readQuotas(ctx.app, ctx.auth.user), | |
| 1348 | signup: readSignup(ctx.app, ctx.auth.user), | |
| 1349 | })); | |
| 1350 | return timePhase(ctx.request, "render", () => | |
| 1351 | read.ok && signup.ok ? json({ ...read.value, ...signup.value, signupChanges: signup.value.changes, changes: read.value.changes }) : notFound()); | |
| 1352 | }); | |
| 1353 | ||
| 1354 | router.add("PATCH", "/api/v1/admin/settings", async (ctx) => { | |
| 1355 | const denied = accountAdmin(ctx, "admin"); | |
| 1356 | if (denied) return denied; | |
| 1357 | const input = await body(ctx.request); | |
| 1358 | if (input.signup !== undefined) { | |
| 1359 | if (input.quotas !== undefined) | |
| 1360 | return apiError("invalid_body", 422, "send signup or quotas, not both"); | |
| 1361 | const set = changeSignup(ctx.app, ctx.auth.user, input.signup); | |
| 1362 | if (set.ok) return json({ signup: set.value }); | |
| 1363 | if (set.error === "not_found") return notFound(); | |
| 1364 | return apiError(set.error, 422, SIGNUP_WORDS); | |
| 1365 | } | |
| 1366 | const quotas = input.quotas; | |
| 1367 | if (typeof quotas !== "object" || quotas === null || Array.isArray(quotas)) | |
| 1368 | return apiError("invalid_body", 422, "send {\"signup\": \"closed\"} or {\"quotas\": {\"repositories\": 5, \"apps\": null}}"); | |
| 1369 | const changed = changeQuotas(ctx.app, ctx.auth.user, quotas as Record<string, unknown>); | |
| 1370 | if (changed.ok) return json({ quotas: changed.value }); | |
| 1371 | if (changed.error === "not_found") return notFound(); | |
| 1372 | return apiError( | |
| 1373 | changed.error, | |
| 1374 | 422, | |
| 1375 | changed.error === "no_such_quota" | |
| 1376 | ? "no such quota" | |
| 1377 | : "a quota is a positive whole number, or null for no limit", | |
| 1378 | ); | |
| 1379 | }); | |
| 1380 | ||
| 1381 | // Invite links (#2335): minted once, listed without tokens, revoked by id. | |
| 1382 | router.add("POST", "/api/v1/admin/invites", async (ctx) => { | |
| 1383 | const denied = accountAdmin(ctx, "admin"); | |
| 1384 | if (denied) return denied; | |
| 1385 | const input = await body(ctx.request); | |
| 1386 | const made = mintInviteLink(ctx.app, ctx.auth.user, input.days); | |
| 1387 | if (!made.ok) | |
| 1388 | return made.error === "invalid_days" | |
| 1389 | ? apiError("invalid", 422, SPEND_WORDS.inviteDays) | |
| 1390 | : notFound(); | |
| 1391 | const origin = ctx.app.config.url ?? ctx.url.origin; | |
| 1392 | return json( | |
| 1393 | { ...made.value.invite, token: made.value.token, link: inviteLink(origin, made.value.token) }, | |
| 1394 | 201, | |
| 1395 | ); | |
| 1396 | }); | |
| 1397 | ||
| 1398 | router.add("GET", "/api/v1/admin/invites", (ctx) => { | |
| 1399 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1400 | const denied = accountAdmin(ctx, "ro"); | |
| 1401 | if (denied) return denied; | |
| 1402 | const query = offsetQuery(ctx.url.searchParams); | |
| 1403 | if ("error" in query) return offsetRefused(query.error); | |
| 1404 | const listed = timePhase(ctx.request, "invites", () => listInviteLinks(ctx.app, ctx.auth.user)); | |
| 1405 | return listed.ok ? timePhase(ctx.request, "render", () => json(offsetPage(listed.value, query.limit, query.offset))) : notFound(); | |
| 1406 | }); | |
| 1407 | ||
| 1408 | router.add("DELETE", "/api/v1/admin/invites/:id", (ctx) => { | |
| 1409 | const denied = accountAdmin(ctx, "admin"); | |
| 1410 | if (denied) return denied; | |
| 1411 | const done = revokeInviteLink(ctx.app, ctx.auth.user, ctx.params.id!); | |
| 1412 | return done.ok ? json(done.value) : notFound(); | |
| 1413 | }); | |
| 1414 | ||
| 1415 | router.add("GET", "/api/v1/admin/accounts", (ctx) => { | |
| 1416 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1417 | const denied = accountAdmin(ctx, "ro"); | |
| 1418 | if (denied) return denied; | |
| 1419 | const query = offsetQuery(ctx.url.searchParams); | |
| 1420 | if ("error" in query) return offsetRefused(query.error); | |
| 1421 | const listed = timePhase(ctx.request, "accounts", () => listAccounts(ctx.app, ctx.auth.user)); | |
| 1422 | return listed.ok ? timePhase(ctx.request, "render", () => json(offsetPage(listed.value, query.limit, query.offset))) : notFound(); | |
| 1423 | }); | |
| 1424 | ||
| 1425 | router.add("POST", "/api/v1/admin/accounts/:username/suspend", async (ctx) => { | |
| 1426 | const denied = accountAdmin(ctx, "admin"); | |
| 1427 | if (denied) return denied; | |
| 1428 | const input = await body(ctx.request); | |
| 1429 | const done = await suspendAccount(ctx.app, ctx.auth.user, ctx.params.username!, { | |
| 1430 | confirmed: input.confirm === true, | |
| 1431 | }); | |
| 1432 | if (!done.ok && done.error === "last_owner") | |
| 1433 | return apiError( | |
| 1434 | "last_owner", | |
| 1435 | 409, | |
| 1436 | `${lastOwnerSays(ctx.app.db, ctx.params.username!)}, or send {"confirm": true} to suspend anyway`, | |
| 1437 | ); | |
| 1438 | return done.ok ? json({ username: done.value.username, suspendedAt: done.value.suspendedAt }) : accountError(done.error); | |
| 1439 | }); | |
| 1440 | ||
| 1441 | // A capped model grant, dollars a month; null (or 0) takes it back. | |
| 1442 | router.add("POST", "/api/v1/admin/accounts/:username/model-grant", async (ctx) => { | |
| 1443 | const denied = accountAdmin(ctx, "admin"); | |
| 1444 | if (denied) return denied; | |
| 1445 | const input = await body(ctx.request); | |
| 1446 | const raw = input.usdPerMonth; | |
| 1447 | if (raw !== null && (typeof raw !== "number" || !Number.isFinite(raw))) | |
| 1448 | return apiError("invalid", 422, "usdPerMonth is a number of dollars, or null to take the grant back"); | |
| 1449 | const done = setAccountGrant(ctx.app, ctx.auth.user, ctx.params.username!, raw === 0 ? null : raw); | |
| 1450 | if (done.ok) return json(done.value); | |
| 1451 | return done.error === "invalid_amount" | |
| 1452 | ? apiError("invalid", 422, SPEND_WORDS.usdPerMonth) | |
| 1453 | : done.error === "not_allowed" | |
| 1454 | ? apiError("not_allowed", 422, "a grant is for a person who is not an admin") | |
| 1455 | : notFound(); | |
| 1456 | }); | |
| 1457 | ||
| 1458 | // Every person and their role. | |
| 1459 | router.add("GET", "/api/v1/admin/people", (ctx) => { | |
| 1460 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1461 | const denied = accountAdmin(ctx, "admin"); | |
| 1462 | if (denied) return denied; | |
| 1463 | const query = offsetQuery(ctx.url.searchParams); | |
| 1464 | if ("error" in query) return offsetRefused(query.error); | |
| 1465 | const people = timePhase(ctx.request, "people", () => listPeople(ctx.app.db)); | |
| 1466 | return timePhase(ctx.request, "render", () => json(offsetPage(people, query.limit, query.offset))); | |
| 1467 | }); | |
| 1468 | ||
| 1469 | // Make a person a member (with an optional starter allowance, dollars a month) or a guest again. | |
| 1470 | router.add("POST", "/api/v1/admin/accounts/:username/role", async (ctx) => { | |
| 1471 | const denied = accountAdmin(ctx, "admin"); | |
| 1472 | if (denied) return denied; | |
| 1473 | const input = await body(ctx.request); | |
| 1474 | const { role, usdPerMonth } = input; | |
| 1475 | if ((role !== "member" && role !== "guest") || (usdPerMonth !== undefined && usdPerMonth !== null && (typeof usdPerMonth !== "number" || !Number.isFinite(usdPerMonth)))) | |
| 1476 | return apiError("invalid", 422, 'role is "member" or "guest"; usdPerMonth is dollars a month, or null'); | |
| 1477 | const done = setRole(ctx.app, ctx.auth.user, ctx.params.username!, role, typeof usdPerMonth === "number" ? usdPerMonth : null); | |
| 1478 | if (done.ok) return json(done.value); | |
| 1479 | return done.error === "invalid_amount" | |
| 1480 | ? apiError("invalid", 422, SPEND_WORDS.usdPerMonth) | |
| 1481 | : done.error === "not_allowed" | |
| 1482 | ? apiError("not_allowed", 422, "a role is for a person who is not an admin") | |
| 1483 | : notFound(); | |
| 1484 | }); | |
| 1485 | ||
| 1486 | // An account's daily and per-run model spend ceilings, dollars (any account, orgs too). | |
| 1487 | router.add("POST", "/api/v1/admin/accounts/:username/ceiling", async (ctx) => { | |
| 1488 | const denied = accountAdmin(ctx, "admin"); | |
| 1489 | if (denied) return denied; | |
| 1490 | const input = await body(ctx.request); | |
| 1491 | const { dailyUsd, perRunUsd } = input; | |
| 1492 | const amount = (v: unknown): v is number | null => | |
| 1493 | v === null || (typeof v === "number" && Number.isFinite(v)); | |
| 1494 | if (!amount(dailyUsd) || !amount(perRunUsd)) | |
| 1495 | return apiError("invalid", 422, "dailyUsd and perRunUsd are numbers of dollars, both null to take them back"); | |
| 1496 | const done = setAccountCeilings(ctx.app, ctx.auth.user, ctx.params.username!, dailyUsd, perRunUsd); | |
| 1497 | if (done.ok) return json(done.value); | |
| 1498 | return done.error === "invalid_amount" | |
| 1499 | ? apiError("invalid", 422, SPEND_WORDS.ceilings) | |
| 1500 | : notFound(); | |
| 1501 | }); | |
| 1502 | ||
| 1503 | router.add("POST", "/api/v1/admin/accounts/:username/unsuspend", (ctx) => { | |
| 1504 | const denied = accountAdmin(ctx, "admin"); | |
| 1505 | if (denied) return denied; | |
| 1506 | const done = unsuspendAccount(ctx.app, ctx.auth.user, ctx.params.username!); | |
| 1507 | return done.ok ? json({ username: done.value.username, suspendedAt: null }) : accountError(done.error); | |
| 1508 | }); | |
| 1509 | ||
| 1510 | router.add("POST", "/api/v1/admin/accounts/:username/verify", (ctx) => { | |
| 1511 | const denied = accountAdmin(ctx, "admin"); | |
| 1512 | if (denied) return denied; | |
| 1513 | const done = verifyAccount(ctx.app, ctx.auth.user, ctx.params.username!); | |
| 1514 | return done.ok | |
| 1515 | ? json({ username: done.value.username, verified: true }) | |
| 1516 | : accountError(done.error); | |
| 1517 | }); | |
| 1518 | ||
| 1519 | router.add("DELETE", "/api/v1/admin/accounts/:username", async (ctx) => { | |
| 1520 | const denied = accountAdmin(ctx, "admin"); | |
| 1521 | if (denied) return denied; | |
| 1522 | const input = await body(ctx.request); | |
| 1523 | if (input.retireOrgs === true) { | |
| 1524 | const name = ctx.params.username!; | |
| 1525 | if (input.confirm !== true) { | |
| 1526 | const plan = retirementPlan(ctx.app, ctx.auth.user, name); | |
| 1527 | return plan.ok ? json({ confirm_required: true, retire: plan.value.orgs, own: plan.value.own }) : accountError(plan.error); | |
| 1528 | } | |
| 1529 | const retired = await retireAndRemoveAccount(ctx.app, ctx.auth.user, name, { | |
| 1530 | confirmed: true, | |
| 1531 | }); | |
| 1532 | if (!retired.ok) return accountError(retired.error); | |
| 1533 | if (retired.value.failed !== null) | |
| 1534 | return json( | |
| 1535 | { error: "delete_failed", failed: retired.value.failed, removed: retired.value.removed }, | |
| 1536 | 500, | |
| 1537 | ); | |
| 1538 | return json({ removed: name, retired: retired.value.removed }); | |
| 1539 | } | |
| 1540 | const done = await removeAccount(ctx.app, ctx.auth.user, ctx.params.username!); | |
| 1541 | if (!done.ok && done.error === "last_owner") { | |
| 1542 | const user = getUserByName(ctx.app.db, ctx.params.username!); | |
| 1543 | const orgs = | |
| 1544 | user === undefined | |
| 1545 | ? [] | |
| 1546 | : soleOwnedOrgs(ctx.app.db, user.id).map((o) => o.username); | |
| 1547 | return apiError( | |
| 1548 | "last_owner", | |
| 1549 | 409, | |
| 1550 | `${ctx.params.username} is the only person owning ${orgs.join(", ")}: make someone else an owner first`, | |
| 1551 | ); | |
| 1552 | } | |
| 1553 | return done.ok ? json({ removed: ctx.params.username }) : accountError(done.error); | |
| 1554 | }); | |
| 1555 | ||
| 1556 | 1214 | // Tokens are minted, listed, and revoked with the account itself (a |
| 1557 | 1215 | // session or the password) — never with another token. |
| 1558 | 1216 | router.add("POST", "/api/v1/tokens", async (ctx) => { |
src/http/api/admin.ts added +355
| @@ -0,0 +1,355 @@ | ||
| 1 | /** | |
| 2 | * The forge's admin surface over the API: every `/api/v1/admin…` route — | |
| 3 | * capacity, limits, crew teams, settings, invites, and accounts and people. | |
| 4 | * Hidden from everyone but a forge admin (the gate is `http/admin_gate.ts`). | |
| 5 | * Moved out of `api.ts` (#7445), unchanged. | |
| 6 | */ | |
| 7 | ||
| 8 | ||
| 9 | import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts"; | |
| 10 | import { ADMIN_WORDS } from "../../actions/admin_words.ts"; | |
| 11 | import { changeCapacity, listCapacity } from "../../actions/capacity.ts"; | |
| 12 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts"; | |
| 13 | import { changeLimit, listLimits } from "../../actions/limits.ts"; | |
| 14 | import { setAccountCeilings, setAccountGrant } from "../../actions/model_grant.ts"; | |
| 15 | import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; | |
| 16 | import { setRole } from "../../actions/roles.ts"; | |
| 17 | import { SETTING_WORDS } from "../../actions/setting_words.ts"; | |
| 18 | import { changeSignup, readSignup } from "../../actions/signup.ts"; | |
| 19 | import { SPEND_WORDS } from "../../actions/spend_words.ts"; | |
| 20 | import { TEAM_SAYS, changeTeam, readTeams } from "../../actions/teams.ts"; | |
| 21 | import { SIGNUP_WORDS } from "../../core/arg_words.ts"; | |
| 22 | import { lastOwnerSays, soleOwnedOrgs } from "../../core/orgs.ts"; | |
| 23 | import { listPeople } from "../../core/roles.ts"; | |
| 24 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 25 | import { getUserByName } from "../../core/users.ts"; | |
| 26 | import { offsetPage, offsetQuery } from "../offset_page.ts"; | |
| 27 | import { body, json, str } from "../respond.ts"; | |
| 28 | import { offsetRefused } from "../api.ts"; | |
| 29 | import type { Ctx, Handler } from "../context.ts"; | |
| 30 | import { apiError, notFound, requireScope } from "../respond.ts"; | |
| 31 | import type { Router } from "../router.ts"; | |
| 32 | ||
| 33 | /** A forge admin's door: anyone else is told it is not there. */ | |
| 34 | export const accountAdmin = (ctx: Ctx, scope: "ro" | "admin"): Response | undefined => | |
| 35 | ctx.auth.user?.isAdmin === true ? requireScope(ctx, scope) : notFound(); | |
| 36 | ||
| 37 | export function adminRoutes(router: Router<Handler>): void { | |
| 38 | // ---- accounts: the owner's view of who joined (#660) ---- | |
| 39 | const accountError = (error: string): Response => | |
| 40 | error === "not_found" | |
| 41 | ? notFound() | |
| 42 | : error === "not_allowed" | |
| 43 | ? apiError( | |
| 44 | "not_allowed", | |
| 45 | 422, | |
| 46 | ADMIN_WORDS.selfSuspend, | |
| 47 | ) | |
| 48 | : apiError(error, 500, "the account could not be removed"); | |
| 49 | ||
| 50 | // The forge's capacity (`core/capacity.ts`): read and changed live by an | |
| 51 | // admin, never by an agent; anyone else is told the door is not there. | |
| 52 | router.add("GET", "/api/v1/admin/capacity", (ctx) => { | |
| 53 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 54 | const denied = accountAdmin(ctx, "ro"); | |
| 55 | if (denied) return denied; | |
| 56 | const listed = timePhase(ctx.request, "capacity", () => listCapacity(ctx.app, ctx.auth.user)); | |
| 57 | return timePhase(ctx.request, "render", () => (listed.ok ? json({ items: listed.value.settings, changes: listed.value.changes, next_cursor: null }) : notFound())); | |
| 58 | }); | |
| 59 | ||
| 60 | router.add("PUT", "/api/v1/admin/capacity/:key", async (ctx) => { | |
| 61 | const denied = accountAdmin(ctx, "admin"); | |
| 62 | if (denied) return denied; | |
| 63 | const input = await body(ctx.request); | |
| 64 | const value = input.value === null ? null : str(input.value); | |
| 65 | const changed = changeCapacity(ctx.app, ctx.auth.user, ctx.params.key!, value); | |
| 66 | if (changed.ok) return json(changed.value); | |
| 67 | if (changed.error === "not_found") return notFound(); | |
| 68 | return apiError( | |
| 69 | changed.error, | |
| 70 | 422, | |
| 71 | changed.error === "no_such_setting" | |
| 72 | ? "no such capacity setting" | |
| 73 | : SETTING_WORDS.outOfRange, | |
| 74 | ); | |
| 75 | }); | |
| 76 | ||
| 77 | // The forge's limits (`settings/limits.ts`): the same door as capacity. | |
| 78 | router.add("GET", "/api/v1/admin/limits", (ctx) => { | |
| 79 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 80 | const denied = accountAdmin(ctx, "ro"); | |
| 81 | if (denied) return denied; | |
| 82 | const listed = timePhase(ctx.request, "limits", () => listLimits(ctx.app, ctx.auth.user)); | |
| 83 | return timePhase(ctx.request, "render", () => (listed.ok ? json({ items: listed.value.limits, changes: listed.value.changes, next_cursor: null }) : notFound())); | |
| 84 | }); | |
| 85 | ||
| 86 | router.add("PUT", "/api/v1/admin/limits/:key", async (ctx) => { | |
| 87 | const denied = accountAdmin(ctx, "admin"); | |
| 88 | if (denied) return denied; | |
| 89 | const input = await body(ctx.request); | |
| 90 | const value = | |
| 91 | input.value === null | |
| 92 | ? null | |
| 93 | : typeof input.value === "number" && Number.isFinite(input.value) | |
| 94 | ? String(input.value) | |
| 95 | : str(input.value); | |
| 96 | const changed = changeLimit(ctx.app, ctx.auth.user, ctx.params.key!, value); | |
| 97 | if (changed.ok) return json(changed.value); | |
| 98 | if (changed.error.kind === "not_found") return notFound(); | |
| 99 | return apiError( | |
| 100 | changed.error.kind, | |
| 101 | 422, | |
| 102 | changed.error.kind === "no_such_limit" | |
| 103 | ? "no such limit" | |
| 104 | : changed.error.message, | |
| 105 | ); | |
| 106 | }); | |
| 107 | ||
| 108 | // Crew teams (`core/teams.ts`): a forge admin who is a person; the page's door. | |
| 109 | router.add("GET", "/api/v1/admin/teams", (ctx) => { | |
| 110 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 111 | const denied = accountAdmin(ctx, "ro"); | |
| 112 | if (denied) return denied; | |
| 113 | const read = timePhase(ctx.request, "teams", () => readTeams(ctx.app, ctx.auth.user)); | |
| 114 | return read.ok ? timePhase(ctx.request, "render", () => json({ teams: read.value.teams })) : notFound(); | |
| 115 | }); | |
| 116 | ||
| 117 | router.add("PUT", "/api/v1/admin/teams/:slug", async (ctx) => { | |
| 118 | const denied = accountAdmin(ctx, "admin"); | |
| 119 | if (denied) return denied; | |
| 120 | const input = await body(ctx.request); | |
| 121 | const { charter, dial, members } = input; | |
| 122 | if ( | |
| 123 | (charter !== undefined && typeof charter !== "string") || | |
| 124 | (dial !== undefined && typeof dial !== "number" && typeof dial !== "string") || | |
| 125 | (members !== undefined && | |
| 126 | (!Array.isArray(members) || !members.every((m) => typeof m === "string"))) | |
| 127 | ) | |
| 128 | return apiError("invalid_body", 422, "send charter (text), dial (a whole number) and members (agent ids)"); | |
| 129 | const changed = changeTeam(ctx.app, ctx.auth.user, ctx.params.slug ?? "", { | |
| 130 | ...(charter === undefined ? {} : { charter: charter as string }), | |
| 131 | ...(dial === undefined ? {} : { dial: String(dial) }), | |
| 132 | ...(members === undefined ? {} : { members: members as string[] }), | |
| 133 | }); | |
| 134 | if (changed.ok) return json(changed.value); | |
| 135 | if (changed.error === "not_found") return notFound(); | |
| 136 | return apiError(changed.error, 422, TEAM_SAYS[changed.error]); | |
| 137 | }); | |
| 138 | ||
| 139 | // Per-account quotas (`core/quotas.ts`): null is no limit. Each is | |
| 140 | // asked when the thing is made or started (`settings/quotas.ts`), never of a | |
| 141 | // forge admin. | |
| 142 | router.add("GET", "/api/v1/admin/settings", (ctx) => { | |
| 143 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 144 | const denied = accountAdmin(ctx, "ro"); | |
| 145 | if (denied) return denied; | |
| 146 | const { read, signup } = timePhase(ctx.request, "settings", () => ({ | |
| 147 | read: readQuotas(ctx.app, ctx.auth.user), | |
| 148 | signup: readSignup(ctx.app, ctx.auth.user), | |
| 149 | })); | |
| 150 | return timePhase(ctx.request, "render", () => | |
| 151 | read.ok && signup.ok ? json({ ...read.value, ...signup.value, signupChanges: signup.value.changes, changes: read.value.changes }) : notFound()); | |
| 152 | }); | |
| 153 | ||
| 154 | router.add("PATCH", "/api/v1/admin/settings", async (ctx) => { | |
| 155 | const denied = accountAdmin(ctx, "admin"); | |
| 156 | if (denied) return denied; | |
| 157 | const input = await body(ctx.request); | |
| 158 | if (input.signup !== undefined) { | |
| 159 | if (input.quotas !== undefined) | |
| 160 | return apiError("invalid_body", 422, "send signup or quotas, not both"); | |
| 161 | const set = changeSignup(ctx.app, ctx.auth.user, input.signup); | |
| 162 | if (set.ok) return json({ signup: set.value }); | |
| 163 | if (set.error === "not_found") return notFound(); | |
| 164 | return apiError(set.error, 422, SIGNUP_WORDS); | |
| 165 | } | |
| 166 | const quotas = input.quotas; | |
| 167 | if (typeof quotas !== "object" || quotas === null || Array.isArray(quotas)) | |
| 168 | return apiError("invalid_body", 422, "send {\"signup\": \"closed\"} or {\"quotas\": {\"repositories\": 5, \"apps\": null}}"); | |
| 169 | const changed = changeQuotas(ctx.app, ctx.auth.user, quotas as Record<string, unknown>); | |
| 170 | if (changed.ok) return json({ quotas: changed.value }); | |
| 171 | if (changed.error === "not_found") return notFound(); | |
| 172 | return apiError( | |
| 173 | changed.error, | |
| 174 | 422, | |
| 175 | changed.error === "no_such_quota" | |
| 176 | ? "no such quota" | |
| 177 | : "a quota is a positive whole number, or null for no limit", | |
| 178 | ); | |
| 179 | }); | |
| 180 | ||
| 181 | // Invite links (#2335): minted once, listed without tokens, revoked by id. | |
| 182 | router.add("POST", "/api/v1/admin/invites", async (ctx) => { | |
| 183 | const denied = accountAdmin(ctx, "admin"); | |
| 184 | if (denied) return denied; | |
| 185 | const input = await body(ctx.request); | |
| 186 | const made = mintInviteLink(ctx.app, ctx.auth.user, input.days); | |
| 187 | if (!made.ok) | |
| 188 | return made.error === "invalid_days" | |
| 189 | ? apiError("invalid", 422, SPEND_WORDS.inviteDays) | |
| 190 | : notFound(); | |
| 191 | const origin = ctx.app.config.url ?? ctx.url.origin; | |
| 192 | return json( | |
| 193 | { ...made.value.invite, token: made.value.token, link: inviteLink(origin, made.value.token) }, | |
| 194 | 201, | |
| 195 | ); | |
| 196 | }); | |
| 197 | ||
| 198 | router.add("GET", "/api/v1/admin/invites", (ctx) => { | |
| 199 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 200 | const denied = accountAdmin(ctx, "ro"); | |
| 201 | if (denied) return denied; | |
| 202 | const query = offsetQuery(ctx.url.searchParams); | |
| 203 | if ("error" in query) return offsetRefused(query.error); | |
| 204 | const listed = timePhase(ctx.request, "invites", () => listInviteLinks(ctx.app, ctx.auth.user)); | |
| 205 | return listed.ok ? timePhase(ctx.request, "render", () => json(offsetPage(listed.value, query.limit, query.offset))) : notFound(); | |
| 206 | }); | |
| 207 | ||
| 208 | router.add("DELETE", "/api/v1/admin/invites/:id", (ctx) => { | |
| 209 | const denied = accountAdmin(ctx, "admin"); | |
| 210 | if (denied) return denied; | |
| 211 | const done = revokeInviteLink(ctx.app, ctx.auth.user, ctx.params.id!); | |
| 212 | return done.ok ? json(done.value) : notFound(); | |
| 213 | }); | |
| 214 | ||
| 215 | router.add("GET", "/api/v1/admin/accounts", (ctx) => { | |
| 216 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 217 | const denied = accountAdmin(ctx, "ro"); | |
| 218 | if (denied) return denied; | |
| 219 | const query = offsetQuery(ctx.url.searchParams); | |
| 220 | if ("error" in query) return offsetRefused(query.error); | |
| 221 | const listed = timePhase(ctx.request, "accounts", () => listAccounts(ctx.app, ctx.auth.user)); | |
| 222 | return listed.ok ? timePhase(ctx.request, "render", () => json(offsetPage(listed.value, query.limit, query.offset))) : notFound(); | |
| 223 | }); | |
| 224 | ||
| 225 | router.add("POST", "/api/v1/admin/accounts/:username/suspend", async (ctx) => { | |
| 226 | const denied = accountAdmin(ctx, "admin"); | |
| 227 | if (denied) return denied; | |
| 228 | const input = await body(ctx.request); | |
| 229 | const done = await suspendAccount(ctx.app, ctx.auth.user, ctx.params.username!, { | |
| 230 | confirmed: input.confirm === true, | |
| 231 | }); | |
| 232 | if (!done.ok && done.error === "last_owner") | |
| 233 | return apiError( | |
| 234 | "last_owner", | |
| 235 | 409, | |
| 236 | `${lastOwnerSays(ctx.app.db, ctx.params.username!)}, or send {"confirm": true} to suspend anyway`, | |
| 237 | ); | |
| 238 | return done.ok ? json({ username: done.value.username, suspendedAt: done.value.suspendedAt }) : accountError(done.error); | |
| 239 | }); | |
| 240 | ||
| 241 | // A capped model grant, dollars a month; null (or 0) takes it back. | |
| 242 | router.add("POST", "/api/v1/admin/accounts/:username/model-grant", async (ctx) => { | |
| 243 | const denied = accountAdmin(ctx, "admin"); | |
| 244 | if (denied) return denied; | |
| 245 | const input = await body(ctx.request); | |
| 246 | const raw = input.usdPerMonth; | |
| 247 | if (raw !== null && (typeof raw !== "number" || !Number.isFinite(raw))) | |
| 248 | return apiError("invalid", 422, "usdPerMonth is a number of dollars, or null to take the grant back"); | |
| 249 | const done = setAccountGrant(ctx.app, ctx.auth.user, ctx.params.username!, raw === 0 ? null : raw); | |
| 250 | if (done.ok) return json(done.value); | |
| 251 | return done.error === "invalid_amount" | |
| 252 | ? apiError("invalid", 422, SPEND_WORDS.usdPerMonth) | |
| 253 | : done.error === "not_allowed" | |
| 254 | ? apiError("not_allowed", 422, "a grant is for a person who is not an admin") | |
| 255 | : notFound(); | |
| 256 | }); | |
| 257 | ||
| 258 | // Every person and their role. | |
| 259 | router.add("GET", "/api/v1/admin/people", (ctx) => { | |
| 260 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 261 | const denied = accountAdmin(ctx, "admin"); | |
| 262 | if (denied) return denied; | |
| 263 | const query = offsetQuery(ctx.url.searchParams); | |
| 264 | if ("error" in query) return offsetRefused(query.error); | |
| 265 | const people = timePhase(ctx.request, "people", () => listPeople(ctx.app.db)); | |
| 266 | return timePhase(ctx.request, "render", () => json(offsetPage(people, query.limit, query.offset))); | |
| 267 | }); | |
| 268 | ||
| 269 | // Make a person a member (with an optional starter allowance, dollars a month) or a guest again. | |
| 270 | router.add("POST", "/api/v1/admin/accounts/:username/role", async (ctx) => { | |
| 271 | const denied = accountAdmin(ctx, "admin"); | |
| 272 | if (denied) return denied; | |
| 273 | const input = await body(ctx.request); | |
| 274 | const { role, usdPerMonth } = input; | |
| 275 | if ((role !== "member" && role !== "guest") || (usdPerMonth !== undefined && usdPerMonth !== null && (typeof usdPerMonth !== "number" || !Number.isFinite(usdPerMonth)))) | |
| 276 | return apiError("invalid", 422, 'role is "member" or "guest"; usdPerMonth is dollars a month, or null'); | |
| 277 | const done = setRole(ctx.app, ctx.auth.user, ctx.params.username!, role, typeof usdPerMonth === "number" ? usdPerMonth : null); | |
| 278 | if (done.ok) return json(done.value); | |
| 279 | return done.error === "invalid_amount" | |
| 280 | ? apiError("invalid", 422, SPEND_WORDS.usdPerMonth) | |
| 281 | : done.error === "not_allowed" | |
| 282 | ? apiError("not_allowed", 422, "a role is for a person who is not an admin") | |
| 283 | : notFound(); | |
| 284 | }); | |
| 285 | ||
| 286 | // An account's daily and per-run model spend ceilings, dollars (any account, orgs too). | |
| 287 | router.add("POST", "/api/v1/admin/accounts/:username/ceiling", async (ctx) => { | |
| 288 | const denied = accountAdmin(ctx, "admin"); | |
| 289 | if (denied) return denied; | |
| 290 | const input = await body(ctx.request); | |
| 291 | const { dailyUsd, perRunUsd } = input; | |
| 292 | const amount = (v: unknown): v is number | null => | |
| 293 | v === null || (typeof v === "number" && Number.isFinite(v)); | |
| 294 | if (!amount(dailyUsd) || !amount(perRunUsd)) | |
| 295 | return apiError("invalid", 422, "dailyUsd and perRunUsd are numbers of dollars, both null to take them back"); | |
| 296 | const done = setAccountCeilings(ctx.app, ctx.auth.user, ctx.params.username!, dailyUsd, perRunUsd); | |
| 297 | if (done.ok) return json(done.value); | |
| 298 | return done.error === "invalid_amount" | |
| 299 | ? apiError("invalid", 422, SPEND_WORDS.ceilings) | |
| 300 | : notFound(); | |
| 301 | }); | |
| 302 | ||
| 303 | router.add("POST", "/api/v1/admin/accounts/:username/unsuspend", (ctx) => { | |
| 304 | const denied = accountAdmin(ctx, "admin"); | |
| 305 | if (denied) return denied; | |
| 306 | const done = unsuspendAccount(ctx.app, ctx.auth.user, ctx.params.username!); | |
| 307 | return done.ok ? json({ username: done.value.username, suspendedAt: null }) : accountError(done.error); | |
| 308 | }); | |
| 309 | ||
| 310 | router.add("POST", "/api/v1/admin/accounts/:username/verify", (ctx) => { | |
| 311 | const denied = accountAdmin(ctx, "admin"); | |
| 312 | if (denied) return denied; | |
| 313 | const done = verifyAccount(ctx.app, ctx.auth.user, ctx.params.username!); | |
| 314 | return done.ok | |
| 315 | ? json({ username: done.value.username, verified: true }) | |
| 316 | : accountError(done.error); | |
| 317 | }); | |
| 318 | ||
| 319 | router.add("DELETE", "/api/v1/admin/accounts/:username", async (ctx) => { | |
| 320 | const denied = accountAdmin(ctx, "admin"); | |
| 321 | if (denied) return denied; | |
| 322 | const input = await body(ctx.request); | |
| 323 | if (input.retireOrgs === true) { | |
| 324 | const name = ctx.params.username!; | |
| 325 | if (input.confirm !== true) { | |
| 326 | const plan = retirementPlan(ctx.app, ctx.auth.user, name); | |
| 327 | return plan.ok ? json({ confirm_required: true, retire: plan.value.orgs, own: plan.value.own }) : accountError(plan.error); | |
| 328 | } | |
| 329 | const retired = await retireAndRemoveAccount(ctx.app, ctx.auth.user, name, { | |
| 330 | confirmed: true, | |
| 331 | }); | |
| 332 | if (!retired.ok) return accountError(retired.error); | |
| 333 | if (retired.value.failed !== null) | |
| 334 | return json( | |
| 335 | { error: "delete_failed", failed: retired.value.failed, removed: retired.value.removed }, | |
| 336 | 500, | |
| 337 | ); | |
| 338 | return json({ removed: name, retired: retired.value.removed }); | |
| 339 | } | |
| 340 | const done = await removeAccount(ctx.app, ctx.auth.user, ctx.params.username!); | |
| 341 | if (!done.ok && done.error === "last_owner") { | |
| 342 | const user = getUserByName(ctx.app.db, ctx.params.username!); | |
| 343 | const orgs = | |
| 344 | user === undefined | |
| 345 | ? [] | |
| 346 | : soleOwnedOrgs(ctx.app.db, user.id).map((o) => o.username); | |
| 347 | return apiError( | |
| 348 | "last_owner", | |
| 349 | 409, | |
| 350 | `${ctx.params.username} is the only person owning ${orgs.join(", ")}: make someone else an owner first`, | |
| 351 | ); | |
| 352 | } | |
| 353 | return done.ok ? json({ removed: ctx.params.username }) : accountError(done.error); | |
| 354 | }); | |
| 355 | } |
new above
offline
Nit: this second import from
../respond.tscould be merged into the one on line 27, asuser.ts/labels.tsdo. The double blank line at 7–8 could also go. This does not block; fold it into a later touch.