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

Merged opened this

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

Merged

mason/forge:mason/5561-an-admin-s-refusals-self-suspend-scoreca → main 2 commits fork

  • Merged. mason/5561-an-admin-s-refusals-self-suspend-scoreca was merged into main as 13a3b93.

Review

Sign in to review this change.
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 @@
1010 import { CAPACITY_KEYS } from "../core/capacity.ts";
1111 import { FAMILY_WORDS } from "../actions/family_words.ts";
1212 import { LIMIT_KEYS } from "../settings/limits.ts";
13 import { ADMIN_WORDS } from "../actions/admin_words.ts";
1314 import { SELF_WAIT } from "../actions/plan_words.ts";
1415
1516 interface Route {
@@ -1227,7 +1228,7 @@ export const ROUTES: readonly Route[] = [
12271228 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",
12281229 auth: true,
12291230 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)` },
12311232 },
12321233 {
12331234 method: "get",
src/http/api.ts +4 −3
@@ -460,6 +460,7 @@ import {
460460 waitingReports,
461461 } from "../actions/reports.ts";
462462 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
463 import { ADMIN_WORDS } from "../actions/admin_words.ts";
463464 import { SPEND_WORDS } from "../actions/spend_words.ts";
464465
465466 /** 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 {
14511452 ? apiError(
14521453 "not_allowed",
14531454 422,
1454 "an admin cannot suspend or remove themselves, another admin, or an agent",
1455 ADMIN_WORDS.selfSuspend,
14551456 )
14561457 : apiError(error, 500, "the account could not be removed");
14571458 const accountAdmin = (ctx: Ctx, scope: "ro" | "admin"): Response | undefined =>
@@ -1736,7 +1737,7 @@ export function apiRoutes(router: Router<Handler>): void {
17361737 const denied = requireScope(ctx, "ro");
17371738 if (denied) return denied;
17381739 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);
17401741 return timePhase(ctx.request, "render", () => json(card));
17411742 });
17421743
@@ -5417,7 +5418,7 @@ export function apiRoutes(router: Router<Handler>): void {
54175418 const set = setFront(ctx.app, ctx.auth.user, picks);
54185419 if (!set.ok)
54195420 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)
54215422 : apiError("invalid", 422, set.error.message);
54225423 return json(set.value);
54235424 });
src/http/mcp.ts +4 −3
@@ -287,6 +287,7 @@ import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
287287 import { placementWords } from "../checks/placement.ts";
288288 import { everydayOf } from "../core/everyday.ts";
289289 import { nextStepsFor } from "../core/next-steps.ts";
290 import { ADMIN_WORDS } from "../actions/admin_words.ts";
290291
291292 const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"] as const;
292293 /** Who answers: the forge, at the version its build stamped. */
@@ -1866,7 +1867,7 @@ const TOOLS: readonly Tool[] = [
18661867 if (done.ok) return good({ username: done.value.username, suspendedAt: done.value.suspendedAt });
18671868 if (done.error === "not_found") return bad("not_found", "no such person");
18681869 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);
18701871 },
18711872 },
18721873 {
@@ -3335,7 +3336,7 @@ const TOOLS: readonly Tool[] = [
33353336 const set = setFront(ctx.app, user, picks, args["dryRun"] !== false);
33363337 if (!set.ok)
33373338 return set.error.kind === "forbidden"
3338 ? bad("forbidden", "the front page is a site admin's to set")
3339 ? bad("forbidden", ADMIN_WORDS.frontPage)
33393340 : bad("invalid", set.error.message);
33403341 return good({
33413342 dryRun: args["dryRun"] !== false,
@@ -4386,7 +4387,7 @@ const TOOLS: readonly Tool[] = [
43864387 adminOnly: true,
43874388 run(ctx, user) {
43884389 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);
43904391 },
43914392 },
43924393 {
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs