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

An argument's allowed-values refusals live in one module, read by the API, the feed and MCP #5686

Merged opened this

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

Merged

mason/forge:mason/5644-an-argument-s-allowed-values-refusals-li → main 9 commits fork 2 threads · 2 unresolved

  • Merged. mason/5644-an-argument-s-allowed-values-refusals-li was merged into main as 9c7fc12.

Review

Sign in to review this change.
16 files changed +128 −21
src/contract/openapi/routes/getApiV1Asked.ts +2 −1
@@ -1,4 +1,5 @@
11 import type { Route } from "../shared.ts";
2 import { ORDER_WORDS } from "../../../core/arg_words.ts";
23
34 export const route: Route = {
45 method: "get",
@@ -6,5 +7,5 @@ export const route: Route = {
67 summary: "What people asked, and the caller's thought stream; answers { asked, thoughts } (ro scope)",
78 auth: true,
89 query: { show: "string", order: "string", repo: "string" },
9 refusals: { invalid_show: "show is all, unshipped or flagged", invalid_order: "order is oldest or newest" },
10 refusals: { invalid_show: "show is all, unshipped or flagged", invalid_order: ORDER_WORDS },
1011 };
src/contract/openapi/routes/postApiV1ReposOwnerNameMakeItYours.ts +2 −1
@@ -1,5 +1,6 @@
11 import { PROVE_FIRST } from "../../../actions/verify_words.ts";
22 import { REPO, type Route } from "../shared.ts";
3 import { VISIBILITY_WORDS } from "../../../core/arg_words.ts";
34
45 export const route: Route = {
56 method: "post",
@@ -9,7 +10,7 @@ export const route: Route = {
910 auth: true,
1011 body: { dryRun: "boolean", name: "string", visibility: "string" },
1112 refusals: {
12 invalid: "422: visibility is public or private",
13 invalid: `422: ${VISIBILITY_WORDS}`,
1314 own: "409: this app is already yours",
1415 unverified: `403: ${PROVE_FIRST}`,
1516 limit: "429: at most so many copies a day; { resetsAt }",
src/contract/openapi/routes/putApiV1ReposOwnerNameUpstream.ts +2 −1
@@ -1,4 +1,5 @@
11 import { REPO, type Route } from "../shared.ts";
2 import { ENABLED_WORDS } from "../../../core/arg_words.ts";
23
34 export const route: Route = {
45 method: "put",
@@ -7,7 +8,7 @@ export const route: Route = {
78 auth: true,
89 body: { url: "string", branch: "string", enabled: "boolean" },
910 refusals: {
10 bad_enabled: "422: enabled is true or false",
11 bad_enabled: `422: ${ENABLED_WORDS}`,
1112 invalid_url: "422: not an https or http git address",
1213 invalid_branch: "422: not a branch name git accepts",
1314 not_found: "404: no such repository, or it is not readable by the caller",
src/core/arg_words.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * What a caller is told when an argument is outside its allowed values
3 * (#5644). The API, the feed and MCP refuse the same argument with the same
4 * sentence, so each is written here once and read by every door.
5 */
6
7 /** An answer to a pull: `like` or `dislike`. */
8 export const ANSWER_WORDS = "answer is like or dislike";
9
10 /** Whether an upstream is followed: `enabled`, a boolean. */
11 export const ENABLED_WORDS = "enabled is true or false";
12
13 /** Whether a feed chip is hidden: a boolean. */
14 export const HIDDEN_WORDS = "hidden is true or false";
15
16 /** A board pin's `op`: `pin`, `unpin` or `move`. */
17 export const PIN_OP_WORDS = "op must be pin, unpin or move";
18
19 /** A listing's `order`: `oldest` or `newest`. */
20 export const ORDER_WORDS = "order is oldest or newest";
21
22 /** The forge's `signup` setting: `open`, `invite` or `closed`. */
23 export const SIGNUP_WORDS = "signup is open, invite or closed";
24
25 /** A repository's `visibility`: `public` or `private`. */
26 export const VISIBILITY_WORDS = "visibility is public or private";
src/http/api.ts +7 −6
@@ -471,6 +471,7 @@ import { REPORT_RECEIVED } from "../actions/report_words.ts";
471471 import { ADMIN_WORDS } from "../actions/admin_words.ts";
472472 import { SETTING_WORDS } from "../actions/setting_words.ts";
473473 import { SPEND_WORDS } from "../actions/spend_words.ts";
474 import { ANSWER_WORDS, ENABLED_WORDS, ORDER_WORDS, PIN_OP_WORDS, SIGNUP_WORDS, VISIBILITY_WORDS } from "../core/arg_words.ts";
474475
475476 /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */
476477 async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> {
@@ -1596,7 +1597,7 @@ export function apiRoutes(router: Router<Handler>): void {
15961597 : op === "unpin" ? unpinPanel(ctx.app, ctx.auth.user, slug, title)
15971598 : op === "move" ? movePinned(ctx.app, ctx.auth.user, slug, title, by)
15981599 : undefined;
1599 if (!done) return apiError("invalid", 422, "op must be pin, unpin or move");
1600 if (!done) return apiError("invalid", 422, PIN_OP_WORDS);
16001601 if (done.ok) return json({ ...done.value });
16011602 const e: PinError = done.error;
16021603 return e === "not_found" ? notFound() : apiError(e, 422, e === "too_many" ? "too many pins" : "that is not a panel");
@@ -1794,7 +1795,7 @@ export function apiRoutes(router: Router<Handler>): void {
17941795 const set = changeSignup(ctx.app, ctx.auth.user, input.signup);
17951796 if (set.ok) return json({ signup: set.value });
17961797 if (set.error === "not_found") return notFound();
1797 return apiError(set.error, 422, "signup is open, invite or closed");
1798 return apiError(set.error, 422, SIGNUP_WORDS);
17981799 }
17991800 const quotas = input.quotas;
18001801 if (typeof quotas !== "object" || quotas === null || Array.isArray(quotas))
@@ -2461,7 +2462,7 @@ export function apiRoutes(router: Router<Handler>): void {
24612462 if (!repo) return notFound();
24622463 const input = await body(ctx.request);
24632464 if (typeof input.enabled !== "boolean")
2464 return apiError("bad_enabled", 422, "enabled is true or false");
2465 return apiError("bad_enabled", 422, ENABLED_WORDS);
24652466 const saved = setFollowUpstream(ctx.app, ctx.auth.user, repo, {
24662467 url: str(input.url),
24672468 branch: str(input.branch),
@@ -3858,7 +3859,7 @@ export function apiRoutes(router: Router<Handler>): void {
38583859 return apiError("invalid_show", 422, "show is all, unshipped or flagged");
38593860 const order = ctx.url.searchParams.get("order");
38603861 if (order !== null && !isAskedOrder(order))
3861 return apiError("invalid_order", 422, "order is oldest or newest");
3862 return apiError("invalid_order", 422, ORDER_WORDS);
38623863 const read = timePhase(ctx.request, "asked", () => {
38633864 const repos = askedRepos(ctx.app.db, ctx.auth.user, ctx.url.searchParams.get("repo"));
38643865 return {
@@ -4556,7 +4557,7 @@ export function apiRoutes(router: Router<Handler>): void {
45564557 const input = await body(ctx.request);
45574558 const answer = str(input.answer);
45584559 if (answer !== "like" && answer !== "dislike")
4559 return apiError("invalid_answer", 422, "answer is like or dislike");
4560 return apiError("invalid_answer", 422, ANSWER_WORDS);
45604561 const voted = voteVariant(
45614562 ctx.app,
45624563 ctx.auth.user,
@@ -5388,7 +5389,7 @@ export function apiRoutes(router: Router<Handler>): void {
53885389 const name = str(input.name);
53895390 const visibility = str(input.visibility);
53905391 if (visibility !== "" && visibility !== "public" && visibility !== "private")
5391 return apiError("invalid", 422, "visibility is public or private");
5392 return apiError("invalid", 422, VISIBILITY_WORDS);
53925393 const made = await makeItYours(ctx.app, ctx.auth.user, repo, {
53935394 dryRun,
53945395 ...(name === "" ? {} : { name }),
src/http/feed.ts +2 −1
@@ -43,6 +43,7 @@ import type { FleetError } from "../actions/fleet.ts";
4343 import { notePhase, takeSessionTime, timePhase } from "../core/route_timing.ts";
4444 import type { Handler } from "./context.ts";
4545 import type { Router } from "./router.ts";
46 import { HIDDEN_WORDS } from "../core/arg_words.ts";
4647 import {
4748 apiError,
4849 body,
@@ -129,7 +130,7 @@ export function feedRoutes(router: Router<Handler>): void {
129130 const input = await body(ctx.request);
130131 const value = str(input.value);
131132 if (typeof input.hidden !== "boolean")
132 return apiError("invalid_hidden", 422, "hidden is true or false");
133 return apiError("invalid_hidden", 422, HIDDEN_WORDS);
133134 if (input.kind === "kind") {
134135 if (!isKindChip(value))
135136 return apiError("invalid_chip", 422, "that kind has no chip");
src/http/mcp/tools/asked.ts +2 −1
@@ -1,6 +1,7 @@
11 import { askedIn, askedJson, askedRepos, isAskedOrder, isAskedShow } from "../../../core/asked.ts";
22 import { streamJson, thoughtStream } from "../../../core/thought_stream.ts";
33 import { good, bad, type Tool, str, REPO_ARG } from "../shared.ts";
4 import { ORDER_WORDS } from "../../../core/arg_words.ts";
45
56 export const tool: Tool = {
67 name: "asked",
@@ -22,7 +23,7 @@ export const tool: Tool = {
2223 return bad("invalid_show", "show is unshipped, flagged or all");
2324 const order = args["order"] ?? "oldest";
2425 if (!isAskedOrder(order))
25 return bad("invalid_order", "order is oldest or newest");
26 return bad("invalid_order", ORDER_WORDS);
2627 const spec = args["repo"] === undefined ? null : str(args["repo"]);
2728 const repos = askedRepos(ctx.app.db, user, spec);
2829 if (spec !== null && repos.length === 0)
src/http/mcp/tools/board_pins.ts +2 −1
@@ -1,5 +1,6 @@
11 import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel } from "../../../actions/board-pins.ts";
22 import { good, bad, type Tool, boardRefused, mayBoards } from "../shared.ts";
3 import { PIN_OP_WORDS } from "../../../core/arg_words.ts";
34
45 export const tool: Tool = {
56 name: "board_pins",
@@ -29,7 +30,7 @@ export const tool: Tool = {
2930 : op === "unpin" ? unpinPanel(ctx.app, user, slug, title)
3031 : op === "move" ? movePinned(ctx.app, user, slug, title, args["by"])
3132 : undefined;
32 if (!done) return bad("invalid", "op must be pin, unpin or move");
33 if (!done) return bad("invalid", PIN_OP_WORDS);
3334 return done.ok ? good({ ...done.value }) : bad(done.error, "that could not be pinned");
3435 },
3536 };
src/http/mcp/tools/feed_chips.ts +2 −1
@@ -1,6 +1,7 @@
11 import { getUserByName, getUsersByIds } from "../../../core/users.ts";
22 import { chipsOf, isKindChip, setChip } from "../../../core/feed_chips.ts";
33 import { good, bad, type Tool, str } from "../shared.ts";
4 import { HIDDEN_WORDS } from "../../../core/arg_words.ts";
45
56 export const tool: Tool = {
67 name: "feed_chips",
@@ -21,7 +22,7 @@ export const tool: Tool = {
2122 if (args["kind"] !== undefined) {
2223 const value = str(args["value"]);
2324 const hidden = args["hidden"];
24 if (typeof hidden !== "boolean") return bad("invalid", "hidden is true or false");
25 if (typeof hidden !== "boolean") return bad("invalid", HIDDEN_WORDS);
2526 if (args["kind"] === "kind") {
2627 if (!isKindChip(value)) return bad("invalid", "that kind has no chip");
2728 setChip(ctx.app.db, user.id, { kind: "kind", value }, hidden);
src/http/mcp/tools/make_it_yours.ts +2 −1
@@ -3,6 +3,7 @@ import { NOT_A_MEMBER } from "../../../core/roles.ts";
33 import { COPIES_PER_DAY, makeItYours } from "../../../actions/make_it_yours.ts";
44 import { PROVE_FIRST } from "../../../actions/verify_words.ts";
55 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
6 import { VISIBILITY_WORDS } from "../../../core/arg_words.ts";
67
78 export const tool: Tool = {
89 name: "make_it_yours",
@@ -27,7 +28,7 @@ export const tool: Tool = {
2728 const name = str(args["name"]);
2829 const visibility = str(args["visibility"]);
2930 if (visibility !== "" && visibility !== "public" && visibility !== "private")
30 return bad("invalid", "visibility is public or private");
31 return bad("invalid", VISIBILITY_WORDS);
3132 const made = await makeItYours(ctx.app, user, repo, {
3233 dryRun: args["dryRun"] !== false,
3334 ...(name === "" ? {} : { name }),
src/http/mcp/tools/repo_settings.ts +2 −1
@@ -1,5 +1,6 @@
11 import { updateRepoSettings } from "../../../actions/repos.ts";
22 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
3 import { VISIBILITY_WORDS } from "../../../core/arg_words.ts";
34
45 export const tool: Tool = {
56 name: "repo_settings",
@@ -24,7 +25,7 @@ export const tool: Tool = {
2425 if (description !== undefined && typeof description !== "string")
2526 return bad("invalid", "description is text");
2627 if (visibility !== undefined && visibility !== "public" && visibility !== "private")
27 return bad("invalid", "visibility is public or private");
28 return bad("invalid", VISIBILITY_WORDS);
2829 const updated = await updateRepoSettings(ctx.app, user, repo, {
2930 ...(visibility !== undefined ? { visibility } : {}),
3031 ...(description !== undefined ? { description } : {}),
src/http/mcp/tools/signup.ts +3 −2
@@ -2,6 +2,7 @@ import { changeSignup, readSignup } from "../../../actions/signup.ts";
22 import { SIGNUP_MODES } from "../../../settings/signup.ts";
33 import { scopeAllows } from "../../../core/tokens.ts";
44 import { good, bad, type Tool } from "../shared.ts";
5 import { SIGNUP_WORDS } from "../../../core/arg_words.ts";
56
67 export const tool: Tool = {
78 name: "signup",
@@ -22,7 +23,7 @@ export const tool: Tool = {
2223 if (!read.ok) return bad("not_found", "no such tool here");
2324 if (args["mode"] === undefined) return good(read.value);
2425 if (!(SIGNUP_MODES as readonly unknown[]).includes(args["mode"]))
25 return bad("invalid_signup", "signup is open, invite or closed");
26 return bad("invalid_signup", SIGNUP_WORDS);
2627 if (args["dryRun"] !== false)
2728 return good({
2829 says: `Would set signup from ${read.value.signup} to ${String(args["mode"])}. Send dryRun: false to change it.`,
@@ -30,7 +31,7 @@ export const tool: Tool = {
3031 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
3132 return bad("forbidden", "changing the forge's signup needs an admin-scope token");
3233 const changed = changeSignup(ctx.app, user, args["mode"]);
33 if (!changed.ok) return bad(changed.error, "signup is open, invite or closed");
34 if (!changed.ok) return bad(changed.error, SIGNUP_WORDS);
3435 return good({
3536 says: `signup: ${read.value.signup} → ${changed.value}.`,
3637 signup: changed.value,
src/http/mcp/tools/upstream.ts +2 −1
@@ -3,6 +3,7 @@ import { getFollow } from "../../../core/upstream_follow.ts";
33 import { canAdmin } from "../../../core/repos.ts";
44 import { scopeAllows } from "../../../core/tokens.ts";
55 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
6 import { ENABLED_WORDS } from "../../../core/arg_words.ts";
67
78 export const tool: Tool = {
89 name: "upstream",
@@ -41,7 +42,7 @@ export const tool: Tool = {
4142 return checked.ok ? good(checked.value) : refuse(checked.error);
4243 }
4344 const { enabled, url, branch } = args;
44 if (typeof enabled !== "boolean") return bad("bad_enabled", "enabled is true or false");
45 if (typeof enabled !== "boolean") return bad("bad_enabled", ENABLED_WORDS);
4546 if ((url !== undefined && typeof url !== "string") || (branch !== undefined && typeof branch !== "string"))
4647 return bad("invalid", "url and branch are text");
4748 const saved = setFollowUpstream(ctx.app, user, repo, { url: str(url), branch: str(branch), enabled });
src/http/mcp/tools/variant.ts +2 −1
@@ -3,6 +3,7 @@ import { mayView } from "../../../core/sealed.ts";
33 import { getPull } from "../../../core/pulls.ts";
44 import { VOTE_NOT_RECORDED } from "../../../actions/board_words.ts";
55 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
6 import { ANSWER_WORDS } from "../../../core/arg_words.ts";
67
78 export const tool: Tool = {
89 name: "variant",
@@ -32,7 +33,7 @@ export const tool: Tool = {
3233 return bad("no_such_pull", `no pull #${String(n)}`);
3334 const answer = args["answer"];
3435 if (answer !== "like" && answer !== "dislike")
35 return bad("invalid_answer", "answer is like or dislike");
36 return bad("invalid_answer", ANSWER_WORDS);
3637 const voted = voteVariant(
3738 ctx.app,
3839 user,
test/arg-words.test.ts added +67
@@ -0,0 +1,67 @@
1 /**
2 * An argument outside its allowed values is refused with one sentence, said
3 * from src/core/arg_words.ts and read by every door (#5644). A copy typed
4 * back into a door, or into the published contract, fails here.
5 */
6
7 import { describe, expect, test } from "bun:test";
8 import { readFileSync } from "node:fs";
9 import { join } from "node:path";
10 import * as words from "../src/core/arg_words.ts";
11
12 const ROOT = join(import.meta.dir, "..");
13 /** Three folders down: an MCP tool file or a contract route file. */
14 const DEEP = "../../../core/arg_words.ts";
15 /** Each file that refuses with these words, and how it imports them. */
16 const DOORS: Record<string, string> = {
17 "src/http/api.ts": "../core/arg_words.ts",
18 "src/http/feed.ts": "../core/arg_words.ts",
19 "src/http/mcp/tools/asked.ts": DEEP,
20 "src/http/mcp/tools/board_pins.ts": DEEP,
21 "src/http/mcp/tools/feed_chips.ts": DEEP,
22 "src/http/mcp/tools/make_it_yours.ts": DEEP,
23 "src/http/mcp/tools/repo_settings.ts": DEEP,
24 "src/http/mcp/tools/signup.ts": DEEP,
25 "src/http/mcp/tools/upstream.ts": DEEP,
26 "src/http/mcp/tools/variant.ts": DEEP,
27 "src/contract/openapi/routes/getApiV1Asked.ts": DEEP,
28 "src/contract/openapi/routes/postApiV1ReposOwnerNameMakeItYours.ts": DEEP,
29 "src/contract/openapi/routes/putApiV1ReposOwnerNameUpstream.ts": DEEP,
30 };
31
32 describe("an argument's allowed-values refusals", () => {
33 test("the module says each sentence", () => {
34 expect(Object.values(words).sort()).toEqual([
35 "answer is like or dislike",
36 "enabled is true or false",
37 "hidden is true or false",
38 "op must be pin, unpin or move",
39 "order is oldest or newest",
40 "signup is open, invite or closed",
41 "visibility is public or private",
42 ]);
43 });
44
45 for (const [door, from] of Object.entries(DOORS)) {
46 test(`${door} reads them and types none of them out`, () => {
47 const text = readFileSync(join(ROOT, door), "utf8");
48 expect(text).toContain(`from "${from}"`);
49 for (const sentence of Object.values(words)) {
50 expect(text).not.toContain(sentence);
51 }
52 });
53 }
54
55 test("no other source file types one of them out", () => {
56 const glob = new Bun.Glob("src/**/*.ts");
57 const copies: string[] = [];
58 for (const file of glob.scanSync(ROOT)) {
59 if (file === "src/core/arg_words.ts") continue;
60 const text = readFileSync(join(ROOT, file), "utf8");
61 for (const sentence of Object.values(words)) {
62 if (text.includes(`"${sentence}"`) || text.includes(`"422: ${sentence}"`)) copies.push(`${file}: ${sentence}`);
63 }
64 }
65 expect(copies).toEqual([]);
66 });
67 });
test/sitemap.test.ts +3 −2
@@ -34,7 +34,8 @@ afterAll(async () => {
3434 await rm(dataDir, { recursive: true, force: true });
3535 });
3636
37 const phases = (h: string) => [...h.matchAll(/(\w+);dur=([\d.]+)/g)].reduce((n, m) => n + Number(m[2]), 0);
37 /** The whole request's time: `app` already holds the named phases, so they are not added again. */
38 const total = (h: string) => Number(/(?:^|, )app;dur=([\d.]+)/.exec(h)?.[1] ?? Infinity);

Right fix. app already contains repos/render/etag (and db), as the preview's header shows, so summing them was counting the same time twice. Missing header gives Infinity, so the test fails closed.

3839
3940 test("lists the 100 public repositories and none of the 20 private ones, built in under 15 ms", async () => {
4041 let best = Infinity;
@@ -44,7 +45,7 @@ test("lists the 100 public repositories and none of the 20 private ones, built i
4445 forgetListings(app.db);
4546 res = await fetch(`${servers.url}/sitemap.xml`);
4647 body = await res.text();
47 best = Math.min(best, phases(res.headers.get("server-timing") ?? ""));
48 best = Math.min(best, total(res.headers.get("server-timing") ?? ""));
4849 }
4950 expect(body.match(/\/alice\/pub\d+</g)?.length).toBe(100);
5051 expect(body).not.toContain("priv");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs