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

Merged opened this

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

Merged

scout/forge:scout/7445-the-api-v1-admin-routes-move-out-of-src → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/7445-the-api-v1-admin-routes-move-out-of-src was merged into main as cf81a1d.

Review

Sign in to review this change.
2 files changed +360 −347
src/http/api.ts +5 −347
@@ -19,9 +19,7 @@ import {
1919 } from "../actions/origin_app_words.ts";
2020 import { getProvenance, provenanceFacts } from "../core/provenance.ts";
2121 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";
2523 import { storageLimitMessage } from "../core/storage.ts";
2624 import { voteVariant } from "../actions/variants.ts";
2725 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../core/route_timing.ts";
@@ -33,12 +31,7 @@ import { DOMAINS } from "../kernel/domains.ts";
3331 import { readBounded } from "./limits.ts";
3432 import { askCheck } from "../checks/again.ts";
3533 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";
3934 import { readScorecards } from "../actions/scorecard.ts";
40 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
41 import { changeSignup, readSignup } from "../actions/signup.ts";
4235 import {
4336 accountSignIns,
4437 endAccountSession,
@@ -53,15 +46,6 @@ import {
5346 isAskedShow,
5447 } from "../core/asked.ts";
5548 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";
6549 import { PROVE_FIRST } from "../actions/verify_words.ts";
6650 import { REPORT_LINK_TITLE } from "../actions/report_words.ts";
6751 import { editMessage } from "../actions/edit_words.ts";
@@ -149,10 +133,6 @@ import {
149133 type Issue,
150134 type IssueState,
151135 } from "../core/issues.ts";
152 import {
153 soleOwnedOrgs,
154 lastOwnerSays,
155 } from "../core/orgs.ts";
156136 import {
157137 getPull,
158138 listPulls,
@@ -282,7 +262,6 @@ import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts";
282262 import { repoDir } from "../git/repos.ts";
283263 import { carriedPulls, pullPath } from "../story/wave_pulls.ts";
284264 import { pullRefs, readWave, readWaves } from "../story/waves.ts";
285 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../actions/invites.ts";
286265 import { originForRepo, type OriginRepoRow } from "../core/origins.ts";
287266 import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts";
288267 import type { Origin } from "../origins/types.ts";
@@ -338,6 +317,7 @@ import { agentRoutes } from "./api/agents.ts";
338317 import { boardRoutes } from "./api/boards.ts";
339318 import { followingRoutes } from "./api/following.ts";
340319 import { labelsRoutes } from "./api/labels.ts";
320 import { accountAdmin, adminRoutes } from "./api/admin.ts";
341321 import { issueRoutes, labelsHandler } from "./api/issues.ts";
342322 import { userRoutes } from "./api/user.ts";
343323 import { appRoutes } from "./api/app.ts";
@@ -357,9 +337,7 @@ import { egressMetrics } from "../apps/egress_metrics.ts";
357337 import { forgeMetrics } from "../metrics/forge.ts";
358338 import { appSources } from "../metrics/app_sources.ts";
359339 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";
363341
364342 /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */
365343 export async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> {
@@ -851,7 +829,7 @@ export function listQuery(ctx: Ctx) {
851829 /** Room for the longest key: a pin's `owner/name` (up to ~40 + 1 + 100 characters). */
852830 const KEYED_CURSOR_MAX = 160;
853831
854 function offsetRefused(error: "invalid_limit" | "invalid_cursor"): Response {
832 export function offsetRefused(error: "invalid_limit" | "invalid_cursor"): Response {
855833 return error === "invalid_limit"
856834 ? apiError(error, 422, `limit is a whole number from 1 to ${OFFSET_LIMIT_MAX}`)
857835 : apiError(error, 422, "cursor is the next_cursor of a page before");
@@ -1163,21 +1141,7 @@ export function apiRoutes(router: Router<Handler>): void {
11631141 });
11641142
11651143 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);
11811145
11821146 // Open read hooks (#1789): the owner, or an agent holding `telemetry:read`.
11831147 // Anyone else is refused; every agent read is logged.
@@ -1236,64 +1200,6 @@ export function apiRoutes(router: Router<Handler>): void {
12361200 return json(recordDrill(ctx.app.db, ctx.auth.user!.id, domain.key, observed, back, verdict), 201);
12371201 });
12381202
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
12971203 // The crew's scorecard (`core/scorecard.ts`): a read, so any scope of a
12981204 // forge admin's token, as the MCP tool; 401 without one, 403 for anyone else.
12991205 router.add("GET", "/api/v1/crew/scorecard", (ctx) => {
@@ -1305,254 +1211,6 @@ export function apiRoutes(router: Router<Handler>): void {
13051211 return timePhase(ctx.request, "render", () => json(card));
13061212 });
13071213
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
15561214 // Tokens are minted, listed, and revoked with the account itself (a
15571215 // session or the password) — never with another token.
15581216 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";

Nit: this second import from ../respond.ts could be merged into the one on line 27, as user.ts/labels.ts do. The double blank line at 7–8 could also go. This does not block; fold it into a later touch.

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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs