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

Merged opened this

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

Merged

mason/forge:mason/5563-a-setting-s-out-of-range-refusal-and-an → main 4 commits fork 1 thread · 1 unresolved

  • Merged. mason/5563-a-setting-s-out-of-range-refusal-and-an was merged into main as a2190d0.

Review

Sign in to review this change.
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 = {

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.

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 {
466466 } from "../actions/reports.ts";
467467 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
468468 import { ADMIN_WORDS } from "../actions/admin_words.ts";
469 import { SETTING_WORDS } from "../actions/setting_words.ts";
469470 import { SPEND_WORDS } from "../actions/spend_words.ts";
470471
471472 /** 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 {
12591260 case "invalid":
12601261 return apiError("invalid", 422, result.error.message);
12611262 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);
12631264 case "unauthorized":
12641265 return unauthorized();
12651266 default:
@@ -1689,7 +1690,7 @@ export function apiRoutes(router: Router<Handler>): void {
16891690 422,
16901691 changed.error === "no_such_setting"
16911692 ? "no such capacity setting"
1692 : "that value is outside what this setting takes",
1693 : SETTING_WORDS.outOfRange,
16931694 );
16941695 });
16951696
src/http/mcp/tools/app_members.ts +2 −1
@@ -1,5 +1,6 @@
11 import { scopeAllows } from "../../../core/tokens.ts";
22 import { readAppMembers, setAppMembersThrough } from "../../../actions/app_members.ts";
3 import { SETTING_WORDS } from "../../../actions/setting_words.ts";
34 import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
45
56 export const tool: Tool = {
@@ -30,7 +31,7 @@ export const tool: Tool = {
3031 : result.error.kind === "invalid"
3132 ? bad("invalid", result.error.message)
3233 : result.error.kind === "forbidden"
33 ? bad("forbidden", "the people an app names are its writers' to choose")
34 ? bad("forbidden", SETTING_WORDS.appMembersForbidden)
3435 : bad("not_found", "no such app or repository (owner/name)");
3536 if (members === undefined) return answer(readAppMembers(ctx.app, user, repo));
3637 if (!Array.isArray(members) || !members.every((m) => typeof m === "string"))
src/http/mcp/tools/capacity.ts +2 −1
@@ -1,4 +1,5 @@
11 import { changeCapacity, listCapacity } from "../../../actions/capacity.ts";
2 import { SETTING_WORDS } from "../../../actions/setting_words.ts";
23 import { scopeAllows } from "../../../core/tokens.ts";
34 import { good, bad, type Tool } from "../shared.ts";
45
@@ -34,7 +35,7 @@ export const tool: Tool = {
3435 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
3536 return bad("forbidden", "changing the forge's capacity needs an admin-scope token");
3637 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);
3839 return good({
3940 says: `${key}: ${changed.value.from || "auto"} → ${changed.value.to || "auto"}, from the next pick on.`,
4041 ...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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs