Public: anyone can read this; the crew can change it.
An admin's refusals (self-suspend, scorecard, front page) live in one module, and the API and MCP read them from there #5576
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5561-an-admin-s-refusals-self-suspend-scoreca → main 2 commits fork
5 files
changed +110
−7
src/actions/admin_words.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | /** | |
| 2 | * What a forge admin's doors say when they refuse (#5561): one place, so | |
| 3 | * the API, MCP and the OpenAPI contract read the same sentence and cannot | |
| 4 | * drift when one door is edited. | |
| 5 | */ | |
| 6 | ||
| 7 | export const ADMIN_WORDS = { | |
| 8 | /** account_suspend / remove: the owner acting on themselves, an admin, or an agent. */ | |
| 9 | selfSuspend: "an admin cannot suspend or remove themselves, another admin, or an agent", | |
| 10 | /** The crew scorecard, asked for by anyone but a forge admin. */ | |
| 11 | scorecard: "the crew's scorecard is a forge admin's to read", | |
| 12 | /** Setting the front page's picks, asked for by anyone but a site admin. */ | |
| 13 | frontPage: "the front page is a site admin's to set", | |
| 14 | } as const; |
src/contract/openapi.ts +2 −1
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | import { CAPACITY_KEYS } from "../core/capacity.ts"; |
| 11 | 11 | import { FAMILY_WORDS } from "../actions/family_words.ts"; |
| 12 | 12 | import { LIMIT_KEYS } from "../settings/limits.ts"; |
| 13 | import { ADMIN_WORDS } from "../actions/admin_words.ts"; | |
| 13 | 14 | import { SELF_WAIT } from "../actions/plan_words.ts"; |
| 14 | 15 | |
| 15 | 16 | interface Route { |
| @@ -1227,7 +1228,7 @@ export const ROUTES: readonly Route[] = [ | ||
| 1227 | 1228 | summary: "Set the front page's picks (a site admin's, rw scope): `pinned` and `hidden` are arrays of owner/name, `weights` an object of forks, ships, use, fresh; answers the picks", |
| 1228 | 1229 | auth: true, |
| 1229 | 1230 | body: { pinned: "array", hidden: "array", weights: "object" }, |
| 1230 | refusals: { invalid: "the picks are malformed or name no repository (422)", forbidden: "the front page is a site admin's to set (403)" }, | |
| 1231 | refusals: { invalid: "the picks are malformed or name no repository (422)", forbidden: `${ADMIN_WORDS.frontPage} (403)` }, | |
| 1231 | 1232 | }, |
| 1232 | 1233 | { |
| 1233 | 1234 | method: "get", |
src/http/api.ts +4 −3
| @@ -460,6 +460,7 @@ import { | ||
| 460 | 460 | waitingReports, |
| 461 | 461 | } from "../actions/reports.ts"; |
| 462 | 462 | import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; |
| 463 | import { ADMIN_WORDS } from "../actions/admin_words.ts"; | |
| 463 | 464 | import { SPEND_WORDS } from "../actions/spend_words.ts"; |
| 464 | 465 | |
| 465 | 466 | /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */ |
| @@ -1451,7 +1452,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1451 | 1452 | ? apiError( |
| 1452 | 1453 | "not_allowed", |
| 1453 | 1454 | 422, |
| 1454 | "an admin cannot suspend or remove themselves, another admin, or an agent", | |
| 1455 | ADMIN_WORDS.selfSuspend, | |
| 1455 | 1456 | ) |
| 1456 | 1457 | : apiError(error, 500, "the account could not be removed"); |
| 1457 | 1458 | const accountAdmin = (ctx: Ctx, scope: "ro" | "admin"): Response | undefined => |
| @@ -1736,7 +1737,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1736 | 1737 | const denied = requireScope(ctx, "ro"); |
| 1737 | 1738 | if (denied) return denied; |
| 1738 | 1739 | const card = timePhase(ctx.request, "scorecard", () => readScorecards(ctx.app, ctx.auth.user)); |
| 1739 | if (!card) return apiError("forbidden", 403, "the crew's scorecard is a forge admin's to read"); | |
| 1740 | if (!card) return apiError("forbidden", 403, ADMIN_WORDS.scorecard); | |
| 1740 | 1741 | return timePhase(ctx.request, "render", () => json(card)); |
| 1741 | 1742 | }); |
| 1742 | 1743 | |
| @@ -5417,7 +5418,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5417 | 5418 | const set = setFront(ctx.app, ctx.auth.user, picks); |
| 5418 | 5419 | if (!set.ok) |
| 5419 | 5420 | return set.error.kind === "forbidden" |
| 5420 | ? apiError("forbidden", 403, "the front page is a site admin's to set") | |
| 5421 | ? apiError("forbidden", 403, ADMIN_WORDS.frontPage) | |
| 5421 | 5422 | : apiError("invalid", 422, set.error.message); |
| 5422 | 5423 | return json(set.value); |
| 5423 | 5424 | }); |
src/http/mcp.ts +4 −3
| @@ -287,6 +287,7 @@ import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; | ||
| 287 | 287 | import { placementWords } from "../checks/placement.ts"; |
| 288 | 288 | import { everydayOf } from "../core/everyday.ts"; |
| 289 | 289 | import { nextStepsFor } from "../core/next-steps.ts"; |
| 290 | import { ADMIN_WORDS } from "../actions/admin_words.ts"; | |
| 290 | 291 | |
| 291 | 292 | const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"] as const; |
| 292 | 293 | /** Who answers: the forge, at the version its build stamped. */ |
| @@ -1866,7 +1867,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1866 | 1867 | if (done.ok) return good({ username: done.value.username, suspendedAt: done.value.suspendedAt }); |
| 1867 | 1868 | if (done.error === "not_found") return bad("not_found", "no such person"); |
| 1868 | 1869 | if (done.error === "last_owner") return bad("last_owner", lastOwnerSays(ctx.app.db, username)); |
| 1869 | return bad("not_allowed", "an admin cannot suspend or remove themselves, another admin, or an agent"); | |
| 1870 | return bad("not_allowed", ADMIN_WORDS.selfSuspend); | |
| 1870 | 1871 | }, |
| 1871 | 1872 | }, |
| 1872 | 1873 | { |
| @@ -3335,7 +3336,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3335 | 3336 | const set = setFront(ctx.app, user, picks, args["dryRun"] !== false); |
| 3336 | 3337 | if (!set.ok) |
| 3337 | 3338 | return set.error.kind === "forbidden" |
| 3338 | ? bad("forbidden", "the front page is a site admin's to set") | |
| 3339 | ? bad("forbidden", ADMIN_WORDS.frontPage) | |
| 3339 | 3340 | : bad("invalid", set.error.message); |
| 3340 | 3341 | return good({ |
| 3341 | 3342 | dryRun: args["dryRun"] !== false, |
| @@ -4386,7 +4387,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4386 | 4387 | adminOnly: true, |
| 4387 | 4388 | run(ctx, user) { |
| 4388 | 4389 | const card = readScorecards(ctx.app, user); |
| 4389 | return card ? good(card) : bad("forbidden", "the crew's scorecard is a forge admin's to read"); | |
| 4390 | return card ? good(card) : bad("forbidden", ADMIN_WORDS.scorecard); | |
| 4390 | 4391 | }, |
| 4391 | 4392 | }, |
| 4392 | 4393 | { |
test/admin-words.test.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * An admin's refusals read the same from every door (#5561): the API and MCP | |
| 3 | * each answer the sentence `src/actions/admin_words.ts` holds, so a door that | |
| 4 | * retypes the words fails here the moment they drift. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { ADMIN_WORDS } from "../src/actions/admin_words.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | describe("an admin's refusals, from every door", () => { | |
| 21 | let dataDir = ""; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let admin = ""; | |
| 25 | let person = ""; | |
| 26 | ||
| 27 | const api = async (token: string, method: string, path: string, body?: unknown) => { | |
| 28 | const res = await fetch(`${servers.url}${path}`, { | |
| 29 | method, | |
| 30 | headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, | |
| 31 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 32 | }); | |
| 33 | return { status: res.status, body: (await res.json()) as { error: string; message: string } }; | |
| 34 | }; | |
| 35 | const mcp = async (token: string, name: string, args: unknown) => { | |
| 36 | const res = await fetch(`${servers.url}/mcp`, { | |
| 37 | method: "POST", | |
| 38 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 39 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), | |
| 40 | }); | |
| 41 | const body = (await res.json()) as any; | |
| 42 | return { error: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 43 | }; | |
| 44 | ||
| 45 | beforeAll(async () => { | |
| 46 | dataDir = await mkdtemp(join(tmpdir(), "forge-admin-words-")); | |
| 47 | app = createApp({ dataDir, port: 0 }); | |
| 48 | servers = await serve(app); | |
| 49 | for (const [username, isAdmin] of [["alice", true], ["bob", false]] as const) { | |
| 50 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-long-words-passphrase", isAdmin }); | |
| 51 | if (!made.ok) throw new Error(username); | |
| 52 | } | |
| 53 | admin = createToken(app.db, getUserByName(app.db, "alice")!.id, "t", "admin").token; | |
| 54 | person = createToken(app.db, getUserByName(app.db, "bob")!.id, "t", "rw").token; | |
| 55 | }, 30_000); | |
| 56 | ||
| 57 | afterAll(async () => { | |
| 58 | servers.stop(); | |
| 59 | app.close(); | |
| 60 | await rm(dataDir, { recursive: true, force: true }); | |
| 61 | }); | |
| 62 | ||
| 63 | test("an admin suspending themselves: the API and MCP say the module's sentence", async () => { | |
| 64 | const r = await api(admin, "POST", "/api/v1/admin/accounts/alice/suspend", {}); | |
| 65 | expect(r.status).toBe(422); | |
| 66 | expect(r.body).toEqual({ error: "not_allowed", message: ADMIN_WORDS.selfSuspend }); | |
| 67 | const m = await mcp(admin, "account_suspend", { username: "alice", suspended: true }); | |
| 68 | expect(m.error).toBe(true); | |
| 69 | expect(m.text).toBe(`not_allowed: ${ADMIN_WORDS.selfSuspend}`); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a non-admin reading the scorecard: the API says the module's sentence", async () => { | |
| 73 | const r = await api(person, "GET", "/api/v1/crew/scorecard"); | |
| 74 | expect(r.status).toBe(403); | |
| 75 | expect(r.body).toEqual({ error: "forbidden", message: ADMIN_WORDS.scorecard }); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a non-admin setting the front page: the API and MCP say the module's sentence", async () => { | |
| 79 | const r = await api(person, "PUT", "/api/v1/front", { pinned: [], hidden: [] }); | |
| 80 | expect(r.status).toBe(403); | |
| 81 | expect(r.body).toEqual({ error: "forbidden", message: ADMIN_WORDS.frontPage }); | |
| 82 | const m = await mcp(person, "front_page", { pinned: [], hidden: [] }); | |
| 83 | expect(m.error).toBe(true); | |
| 84 | expect(m.text).toBe(`forbidden: ${ADMIN_WORDS.frontPage}`); | |
| 85 | }); | |
| 86 | }); |
new above
offline