Public: anyone can read this; the crew can change it.

Spend and budget range sentences live in one module, and the API and MCP read them from there #5575

Merged opened this

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

Merged

mason/forge:mason/5559-spend-and-budget-range-sentences-live-in → main 1 commit fork 1 thread · 1 unresolved

  • Merged. mason/5559-spend-and-budget-range-sentences-live-in was merged into main as 4a3a7c4.

Review

Sign in to review this change.
4 files changed +123 −8
src/actions/spend_words.ts added +9
@@ -0,0 +1,9 @@
1 /**
2 * The sentences a spend window or budget out of range answers with (#5559).
3 * The API and MCP read them from here, so every door says the same words.
4 */
5 export const SPEND_WORDS = {
6 usdPerMonth: "usdPerMonth is from 0.01 to 10000 dollars",
7 ceilings: "dailyUsd and perRunUsd are each from 0.01 to 10000 dollars, or both null",
8 inviteDays: "days is a whole number from 1 to 30",

Not blocking: inviteDays isn't a spend sentence, but #5559 named it explicitly, so keeping it here is right for this pull. If invite words ever grow, they would sit better next to src/core/invites.ts.

9 } as const;
src/http/api.ts +5 −4
@@ -449,6 +449,7 @@ import {
449449 waitingReports,
450450 } from "../actions/reports.ts";
451451 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
452 import { SPEND_WORDS } from "../actions/spend_words.ts";
452453
453454 /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */
454455 async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> {
@@ -1827,7 +1828,7 @@ export function apiRoutes(router: Router<Handler>): void {
18271828 const made = mintInviteLink(ctx.app, ctx.auth.user, input.days);
18281829 if (!made.ok)
18291830 return made.error === "invalid_days"
1830 ? apiError("invalid", 422, "days is a whole number from 1 to 30")
1831 ? apiError("invalid", 422, SPEND_WORDS.inviteDays)
18311832 : notFound();
18321833 const origin = ctx.app.config.url ?? ctx.url.origin;
18331834 return json(
@@ -1890,7 +1891,7 @@ export function apiRoutes(router: Router<Handler>): void {
18901891 const done = setAccountGrant(ctx.app, ctx.auth.user, ctx.params.username!, raw === 0 ? null : raw);
18911892 if (done.ok) return json(done.value);
18921893 return done.error === "invalid_amount"
1893 ? apiError("invalid", 422, "usdPerMonth is from 0.01 to 10000 dollars")
1894 ? apiError("invalid", 422, SPEND_WORDS.usdPerMonth)
18941895 : done.error === "not_allowed"
18951896 ? apiError("not_allowed", 422, "a grant is for a person who is not an admin")
18961897 : notFound();
@@ -1918,7 +1919,7 @@ export function apiRoutes(router: Router<Handler>): void {
19181919 const done = setRole(ctx.app, ctx.auth.user, ctx.params.username!, role, typeof usdPerMonth === "number" ? usdPerMonth : null);
19191920 if (done.ok) return json(done.value);
19201921 return done.error === "invalid_amount"
1921 ? apiError("invalid", 422, "usdPerMonth is from 0.01 to 10000 dollars")
1922 ? apiError("invalid", 422, SPEND_WORDS.usdPerMonth)
19221923 : done.error === "not_allowed"
19231924 ? apiError("not_allowed", 422, "a role is for a person who is not an admin")
19241925 : notFound();
@@ -1937,7 +1938,7 @@ export function apiRoutes(router: Router<Handler>): void {
19371938 const done = setAccountCeilings(ctx.app, ctx.auth.user, ctx.params.username!, dailyUsd, perRunUsd);
19381939 if (done.ok) return json(done.value);
19391940 return done.error === "invalid_amount"
1940 ? apiError("invalid", 422, "dailyUsd and perRunUsd are each from 0.01 to 10000 dollars, or both null")
1941 ? apiError("invalid", 422, SPEND_WORDS.ceilings)
19411942 : notFound();
19421943 });
19431944
src/http/mcp.ts +5 −4
@@ -46,6 +46,7 @@ import { PROVIDERS } from "../cloud/providers.ts";
4646 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
4747 import { resendVerification } from "../actions/verify.ts";
4848 import { CAPPED_WORDS } from "../actions/mail.ts";
49 import { SPEND_WORDS } from "../actions/spend_words.ts";
4950 import { changeSignup, readSignup } from "../actions/signup.ts";
5051 import { SIGNUP_MODES } from "../settings/signup.ts";
5152 import { QUOTA_KEYS } from "../settings/quotas.ts";
@@ -1616,7 +1617,7 @@ const TOOLS: readonly Tool[] = [
16161617 return bad(
16171618 set.error === "invalid_amount" ? "invalid" : "forbidden",
16181619 set.error === "invalid_amount"
1619 ? "usdPerMonth is from 0.01 to 10000 dollars"
1620 ? SPEND_WORDS.usdPerMonth
16201621 : "no such person, or not one a grant is for",
16211622 );
16221623 return good(set.value);
@@ -1649,7 +1650,7 @@ const TOOLS: readonly Tool[] = [
16491650 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
16501651 return bad("forbidden", "an invite needs an admin token");
16511652 const made = mintInviteLink(ctx.app, user, args["days"]);
1652 if (!made.ok) return bad("invalid", "days is a whole number from 1 to 30");
1653 if (!made.ok) return bad("invalid", SPEND_WORDS.inviteDays);
16531654 const origin = ctx.app.config.url ?? ctx.url.origin;
16541655 return good({ ...made.value.invite, link: inviteLink(origin, made.value.token) });
16551656 },
@@ -1724,7 +1725,7 @@ const TOOLS: readonly Tool[] = [
17241725 return bad(
17251726 set.error === "invalid_amount" ? "invalid" : "forbidden",
17261727 set.error === "invalid_amount"
1727 ? "usdPerMonth is from 0.01 to 10000 dollars"
1728 ? SPEND_WORDS.usdPerMonth
17281729 : "no such person, or not one a role is for",
17291730 );
17301731 return good(set.value);
@@ -1792,7 +1793,7 @@ const TOOLS: readonly Tool[] = [
17921793 const set = setAccountCeilings(ctx.app, user, username, dailyUsd, perRunUsd);
17931794 if (!set.ok)
17941795 return set.error === "invalid_amount"
1795 ? bad("invalid", "dailyUsd and perRunUsd are each from 0.01 to 10000 dollars, or both null")
1796 ? bad("invalid", SPEND_WORDS.ceilings)
17961797 : bad("forbidden", "no such account");
17971798 return good(set.value);
17981799 },
test/spend-words.test.ts added +104
@@ -0,0 +1,104 @@
1 /**
2 * A spend window or budget out of range reads the same from every door
3 * (#5559): the API and MCP both answer the sentence src/actions/spend_words.ts
4 * holds, and no other file under src/ types it out.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { SPEND_WORDS } from "../src/actions/spend_words.ts";
14 import { seedAdmin } from "./helpers/admin.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 let dir: string;
21 let app: App;
22 let servers: Servers;
23 let admin = "";
24 let id = 1;
25
26 const api = async (path: string, payload: unknown) => {
27 const res = await fetch(`${servers.url}${path}`, {
28 method: "POST",
29 headers: { "content-type": "application/json", authorization: `Bearer ${admin}` },
30 body: JSON.stringify(payload),
31 });
32 return { status: res.status, body: (await res.json()) as { error: string; message: string } };
33 };
34
35 const tool = async (name: string, args: unknown) => {
36 const res = await fetch(`${servers.url}/mcp`, {
37 method: "POST",
38 headers: {
39 "content-type": "application/json",
40 accept: "application/json, text/event-stream",
41 authorization: `Bearer ${admin}`,
42 },
43 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name, arguments: args } }),
44 });
45 const body = (await res.json()) as any;
46 return { error: body.result.isError === true, text: body.result.content[0].text as string };
47 };
48
49 beforeAll(async () => {
50 dir = await mkdtemp(join(tmpdir(), "forge-spend-words-"));
51 app = createApp({ dataDir: dir, port: 0 });
52 servers = await serve(app);
53 const a = await seedAdmin(app, { username: "olive", email: "[email protected]", password: "owner-passphrase" });
54 if (!a.ok) throw new Error(a.error);
55 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
56 if (!b.ok) throw new Error(b.error);
57 admin = createToken(app.db, a.value.id, "admin", "admin").token;
58 }, 30_000);
59
60 afterAll(async () => {
61 servers.stop();
62 app.close();
63 await rm(dir, { recursive: true, force: true });
64 });
65
66 test("a grant out of range answers the module's sentence from the API and MCP", async () => {
67 const viaApi = await api("/api/v1/admin/accounts/bob/model-grant", { usdPerMonth: 20000 });
68 expect(viaApi).toEqual({ status: 422, body: { error: "invalid", message: SPEND_WORDS.usdPerMonth } });
69 const viaMcp = await tool("model_grant", { username: "bob", usdPerMonth: 20000, dryRun: false });
70 expect(viaMcp).toEqual({ error: true, text: `invalid: ${SPEND_WORDS.usdPerMonth}` });
71 });
72
73 test("a role's allowance out of range answers the module's sentence from the API and MCP", async () => {
74 const viaApi = await api("/api/v1/admin/accounts/bob/role", { role: "member", usdPerMonth: 20000 });
75 expect(viaApi).toEqual({ status: 422, body: { error: "invalid", message: SPEND_WORDS.usdPerMonth } });
76 const viaMcp = await tool("account_role", { username: "bob", role: "member", usdPerMonth: 20000, dryRun: false });
77 expect(viaMcp).toEqual({ error: true, text: `invalid: ${SPEND_WORDS.usdPerMonth}` });
78 });
79
80 test("a ceiling out of range answers the module's sentence from the API and MCP", async () => {
81 const viaApi = await api("/api/v1/admin/accounts/bob/ceiling", { dailyUsd: 20000, perRunUsd: 1 });
82 expect(viaApi).toEqual({ status: 422, body: { error: "invalid", message: SPEND_WORDS.ceilings } });
83 const viaMcp = await tool("model_ceiling", { username: "bob", dailyUsd: 20000, perRunUsd: 1, dryRun: false });
84 expect(viaMcp).toEqual({ error: true, text: `invalid: ${SPEND_WORDS.ceilings}` });
85 });
86
87 test("an invite's days out of range answers the module's sentence from the API and MCP", async () => {
88 const viaApi = await api("/api/v1/admin/invites", { days: 31 });
89 expect(viaApi).toEqual({ status: 422, body: { error: "invalid", message: SPEND_WORDS.inviteDays } });
90 const viaMcp = await tool("mint_invite", { days: 31 });
91 expect(viaMcp).toEqual({ error: true, text: `invalid: ${SPEND_WORDS.inviteDays}` });
92 });
93
94 test("no file under src/ but the module types the sentences out", async () => {
95 const root = join(import.meta.dir, "..", "src");
96 const files = (await readdir(root, { recursive: true })).filter((f) => f.endsWith(".ts"));
97 const retyped: string[] = [];
98 for (const file of files) {
99 if (file === join("actions", "spend_words.ts")) continue;
100 const text = await readFile(join(root, file), "utf8");
101 for (const sentence of Object.values(SPEND_WORDS)) if (text.includes(sentence)) retyped.push(`${file}: ${sentence}`);
102 }
103 expect(retyped).toEqual([]);
104 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs