Public: anyone can read this; the crew can change it.
A setting's out-of-range refusal and an app's members refusal live in one module, and the API and MCP read them from there #5577
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5563-a-setting-s-out-of-range-refusal-and-an → main 4 commits fork 1 thread · 1 unresolved
5 files
changed +118
−4
src/actions/setting_words.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | /** | |
| 2 | * The refusals a setting's door answers (#5563): a capacity value out of | |
| 3 | * range, and an app's people set by someone who does not write the | |
| 4 | * repository. The API and the MCP tools read them from here, so one door | |
| 5 | * cannot reword them without the other. Plain data. | |
| 6 | */ | |
| 7 | ||
| 8 | export const SETTING_WORDS = { | |
| 9 | /** A capacity value the setting does not take. */ | |
| 10 | outOfRange: "that value is outside what this setting takes", | |
| 11 | /** An app's members changed or read by an agent or a non-writer. */ | |
| 12 | appMembersForbidden: "the people an app names are its writers' to choose", | |
| 13 | } as const; |
src/http/api.ts +3 −2
| @@ -466,6 +466,7 @@ import { | ||
| 466 | 466 | } from "../actions/reports.ts"; |
| 467 | 467 | import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; |
| 468 | 468 | import { ADMIN_WORDS } from "../actions/admin_words.ts"; |
| 469 | import { SETTING_WORDS } from "../actions/setting_words.ts"; | |
| 469 | 470 | import { SPEND_WORDS } from "../actions/spend_words.ts"; |
| 470 | 471 | |
| 471 | 472 | /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */ |
| @@ -1259,7 +1260,7 @@ function membersAnswer<T>(result: Result<T, AppMembersError>): Response { | ||
| 1259 | 1260 | case "invalid": |
| 1260 | 1261 | return apiError("invalid", 422, result.error.message); |
| 1261 | 1262 | case "forbidden": |
| 1262 | return apiError("forbidden", 403, "the people an app names are its writers' to choose"); | |
| 1263 | return apiError("forbidden", 403, SETTING_WORDS.appMembersForbidden); | |
| 1263 | 1264 | case "unauthorized": |
| 1264 | 1265 | return unauthorized(); |
| 1265 | 1266 | default: |
| @@ -1689,7 +1690,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1689 | 1690 | 422, |
| 1690 | 1691 | changed.error === "no_such_setting" |
| 1691 | 1692 | ? "no such capacity setting" |
| 1692 | : "that value is outside what this setting takes", | |
| 1693 | : SETTING_WORDS.outOfRange, | |
| 1693 | 1694 | ); |
| 1694 | 1695 | }); |
| 1695 | 1696 |
src/http/mcp/tools/app_members.ts +2 −1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 2 | 2 | import { readAppMembers, setAppMembersThrough } from "../../../actions/app_members.ts"; |
| 3 | import { SETTING_WORDS } from "../../../actions/setting_words.ts"; | |
| 3 | 4 | import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| @@ -30,7 +31,7 @@ export const tool: Tool = { | ||
| 30 | 31 | : result.error.kind === "invalid" |
| 31 | 32 | ? bad("invalid", result.error.message) |
| 32 | 33 | : result.error.kind === "forbidden" |
| 33 | ? bad("forbidden", "the people an app names are its writers' to choose") | |
| 34 | ? bad("forbidden", SETTING_WORDS.appMembersForbidden) | |
| 34 | 35 | : bad("not_found", "no such app or repository (owner/name)"); |
| 35 | 36 | if (members === undefined) return answer(readAppMembers(ctx.app, user, repo)); |
| 36 | 37 | if (!Array.isArray(members) || !members.every((m) => typeof m === "string")) |
src/http/mcp/tools/capacity.ts +2 −1
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { changeCapacity, listCapacity } from "../../../actions/capacity.ts"; |
| 2 | import { SETTING_WORDS } from "../../../actions/setting_words.ts"; | |
| 2 | 3 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 3 | 4 | import { good, bad, type Tool } from "../shared.ts"; |
| 4 | 5 | |
| @@ -34,7 +35,7 @@ export const tool: Tool = { | ||
| 34 | 35 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) |
| 35 | 36 | return bad("forbidden", "changing the forge's capacity needs an admin-scope token"); |
| 36 | 37 | const changed = changeCapacity(ctx.app, user, key, value); |
| 37 | if (!changed.ok) return bad(changed.error, "that value is outside what this setting takes"); | |
| 38 | if (!changed.ok) return bad(changed.error, SETTING_WORDS.outOfRange); | |
| 38 | 39 | return good({ |
| 39 | 40 | says: `${key}: ${changed.value.from || "auto"} → ${changed.value.to || "auto"}, from the next pick on.`, |
| 40 | 41 | ...changed.value, |
test/setting-words.test.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | /** | |
| 2 | * A setting's refusals read the same from every door (#5563): a capacity | |
| 3 | * value out of range, and an app's people asked for by someone who does not | |
| 4 | * write the repository. The API and the MCP tool each answer the sentence | |
| 5 | * src/actions/setting_words.ts holds, so a door that retypes it fails here. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, readFile, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { SETTING_WORDS } from "../src/actions/setting_words.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { Glob } from "bun"; | |
| 19 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 20 | ||
| 21 | describe("a setting's refusals come from one module", () => { | |
| 22 | let dataDir: string; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let aliceAdmin: string; | |
| 26 | let bobAdmin: string; | |
| 27 | ||
| 28 | const person = async (username: string): Promise<User> => { | |
| 29 | const made = await createUser(app.db, { | |
| 30 | origin: "seed", | |
| 31 | trusted: true, | |
| 32 | username, | |
| 33 | email: `${username}@forge.local`, | |
| 34 | password: "a-good-passphrase", | |
| 35 | adminIfFirst: username === "alice", | |
| 36 | }); | |
| 37 | if (!made.ok) throw new Error(made.error); | |
| 38 | return made.value; | |
| 39 | }; | |
| 40 | const api = (token: string, method: string, path: string, body?: unknown) => | |
| 41 | fetch(`${servers.url}/api/v1${path}`, { | |
| 42 | method, | |
| 43 | headers: { | |
| 44 | authorization: `Bearer ${token}`, | |
| 45 | "content-type": "application/json", | |
| 46 | }, | |
| 47 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 48 | }); | |
| 49 | ||
| 50 | beforeAll(async () => { | |
| 51 | dataDir = await mkdtemp(join(tmpdir(), "forge-setting-words-")); | |
| 52 | app = createApp({ dataDir, port: 0 }); | |
| 53 | servers = await serve(app); | |
| 54 | const alice = await person("alice"); | |
| 55 | const bob = await person("bob"); | |
| 56 | const made = await createRepo(app, alice, { name: "garden", visibility: "public" }); | |
| 57 | if (!made.ok) throw new Error(String(made.error)); | |
| 58 | aliceAdmin = createToken(app.db, alice.id, "t", "admin").token; | |
| 59 | bobAdmin = createToken(app.db, bob.id, "t", "admin").token; | |
| 60 | }); | |
| 61 | ||
| 62 | afterAll(async () => { | |
| 63 | servers.stop(); | |
| 64 | app.close(); | |
| 65 | await rm(dataDir, { recursive: true, force: true }); | |
| 66 | }); | |
| 67 | ||
| 68 | test("a capacity value out of range: the API and MCP answer the module's sentence", async () => { | |
| 69 | const res = await api(aliceAdmin, "PUT", "/admin/capacity/checks.concurrency", { value: "0" }); | |
| 70 | expect(res.status).toBe(422); | |
| 71 | expect(((await res.json()) as any).message).toBe(SETTING_WORDS.outOfRange); | |
| 72 | const tool = await mcpTool(servers.url, aliceAdmin, "capacity", { | |
| 73 | key: "checks.concurrency", | |
| 74 | value: "0", | |
| 75 | dryRun: false, | |
| 76 | }); | |
| 77 | expect(tool.error).toContain(SETTING_WORDS.outOfRange); | |
| 78 | }); | |
| 79 | ||
| 80 | test("an app's people asked for by a non-writer: the API and MCP answer the module's sentence", async () => { | |
| 81 | const res = await api(bobAdmin, "GET", "/repos/alice/garden/app/members"); | |
| 82 | expect(res.status).toBe(403); | |
| 83 | expect(((await res.json()) as any).message).toBe(SETTING_WORDS.appMembersForbidden); | |
| 84 | const tool = await mcpTool(servers.url, bobAdmin, "app_members", { repo: "alice/garden" }); | |
| 85 | expect(tool.error).toContain(SETTING_WORDS.appMembersForbidden); | |
| 86 | }); | |
| 87 | ||
| 88 | test("no file under src/ but the module types these sentences out", async () => { | |
| 89 | const typed: string[] = []; | |
| 90 | for await (const path of new Glob("src/**/*.ts").scan(".")) { | |
| 91 | if (path.replaceAll("\\", "/") === "src/actions/setting_words.ts") continue; | |
| 92 | const text = await readFile(path, "utf8"); | |
| 93 | for (const words of Object.values(SETTING_WORDS)) | |
| 94 | if (text.includes(words)) typed.push(`${path}: ${words}`); | |
| 95 | } | |
| 96 | expect(typed).toEqual([]); | |
| 97 | }); | |
| 98 | }); |
new above
offline
Plain data, one concept, and named after the issue's own words. Good. A later pull could point the page's capitalized copy (
src/web/pages/capacity.ts:233) here too, but that is outside this issue.