Public: anyone can read this; the crew can change it.
The forge family's refusals live in one module, and the API and MCP read them from there #5579
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5562-the-forge-family-s-refusals-live-in-one → main 1 commit fork
6 files
changed +120
−14
src/actions/family_words.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | /** | |
| 2 | * The forge family's refusals: what a repository outside the fork tree's | |
| 3 | * rules is told, typed once so the API, MCP and the contract read the same | |
| 4 | * sentence from one place. | |
| 5 | */ | |
| 6 | export const FAMILY_WORDS = { | |
| 7 | /** Proposing to the parent forge with no commit ahead of it. */ | |
| 8 | nothing_to_propose: "nothing here is ahead of the parent forge", | |
| 9 | /** Taking, severing or changing the mode of a repository with no chain across forges. */ | |
| 10 | not_following: "this repository follows nothing across forges", | |
| 11 | /** Showing or hiding a migration trail on a repository that was never migrated. */ | |
| 12 | no_provenance: "this repository was not migrated here", | |
| 13 | } as const; |
src/actions/follow.ts +2 −1
| @@ -13,6 +13,7 @@ import { ARCHIVED_WHY, canAdmin, canWrite, forkParentFor, type Repo } from "../c | ||
| 13 | 13 | import type { User } from "../core/users.ts"; |
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | 15 | import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts"; |
| 16 | import { FAMILY_WORDS } from "./family_words.ts"; | |
| 16 | 17 | import { requireUser } from "./access.ts"; |
| 17 | 18 | import { takeFromParent, type Taken } from "../spawn/take.ts"; |
| 18 | 19 | import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts"; |
| @@ -102,7 +103,7 @@ export type FollowModeError = SeverError | "invalid_mode" | "severed"; | ||
| 102 | 103 | export const FOLLOW_MODE_REFUSALS: Record<FollowModeError, { status: number; message: string }> = { |
| 103 | 104 | unauthorized: { status: 401, message: "sign in to change how this forge follows its parent" }, |
| 104 | 105 | forbidden: { status: 403, message: "only this forge's owner can change how it follows" }, |
| 105 | not_following: { status: 404, message: "this repository follows nothing across forges" }, | |
| 106 | not_following: { status: 404, message: FAMILY_WORDS.not_following }, | |
| 106 | 107 | invalid_mode: { status: 422, message: `mode is one of: ${FOLLOW_MODES.join(", ")}` }, |
| 107 | 108 | severed: { status: 409, message: "this forge is severed and follows nothing; there is no mode to change" }, |
| 108 | 109 | }; |
src/contract/openapi.ts +6 −5
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { CAPACITY_KEYS } from "../core/capacity.ts"; |
| 11 | import { FAMILY_WORDS } from "../actions/family_words.ts"; | |
| 11 | 12 | import { LIMIT_KEYS } from "../settings/limits.ts"; |
| 12 | 13 | |
| 13 | 14 | interface Route { |
| @@ -589,7 +590,7 @@ export const ROUTES: readonly Route[] = [ | ||
| 589 | 590 | bad_decisions_by: "422: decisionsBy is me, decider or decider_tell", |
| 590 | 591 | bad_plans_need_nod: "422: plansNeedNod is true or false", |
| 591 | 592 | bad_show_provenance: "422: showProvenance is true or false", |
| 592 | no_provenance: "422: this repository was not migrated here", | |
| 593 | no_provenance: `422: ${FAMILY_WORDS.no_provenance}`, | |
| 593 | 594 | forbidden: "403: only an admin archives or wakes", |
| 594 | 595 | not_found: "404: no such repository, or it is not readable by the caller", |
| 595 | 596 | }, |
| @@ -645,7 +646,7 @@ export const ROUTES: readonly Route[] = [ | ||
| 645 | 646 | auth: true, |
| 646 | 647 | refusals: { |
| 647 | 648 | no_parent_forge: "404: this repository has no parent forge to propose to", |
| 648 | nothing_to_propose: "409: nothing here is ahead of the parent forge", | |
| 649 | nothing_to_propose: `409: ${FAMILY_WORDS.nothing_to_propose}`, | |
| 649 | 650 | parent_unreachable: "502: the parent forge did not answer", |
| 650 | 651 | refused: "409: the parent forge refused, with the reason", |
| 651 | 652 | }, |
| @@ -655,7 +656,7 @@ export const ROUTES: readonly Route[] = [ | ||
| 655 | 656 | path: `${REPO}/following/sever`, |
| 656 | 657 | summary: "Stop following the parent forge; answers { severedAt }", |
| 657 | 658 | auth: true, |
| 658 | refusals: { not_following: "404: this repository follows nothing across forges" }, | |
| 659 | refusals: { not_following: `404: ${FAMILY_WORDS.not_following}` }, | |
| 659 | 660 | }, |
| 660 | 661 | { |
| 661 | 662 | method: "post", |
| @@ -665,7 +666,7 @@ export const ROUTES: readonly Route[] = [ | ||
| 665 | 666 | auth: true, |
| 666 | 667 | refusals: { |
| 667 | 668 | not_a_fork: "404: this repository follows nothing", |
| 668 | not_following: "404: this repository follows nothing across forges", | |
| 669 | not_following: `404: ${FAMILY_WORDS.not_following}`, | |
| 669 | 670 | conflict: "409: both sides changed these files; the crew was told; { files, converge, issue }", |
| 670 | 671 | moved: "409: something moved while this was read; try again", |
| 671 | 672 | }, |
| @@ -677,7 +678,7 @@ export const ROUTES: readonly Route[] = [ | ||
| 677 | 678 | auth: true, |
| 678 | 679 | body: { mode: "string" }, |
| 679 | 680 | refusals: { |
| 680 | not_following: "404: this repository follows nothing across forges", | |
| 681 | not_following: `404: ${FAMILY_WORDS.not_following}`, | |
| 681 | 682 | forbidden: "403: only this forge's owner can change how it follows", |
| 682 | 683 | invalid_mode: "422: mode is not one the forge knows", |
| 683 | 684 | severed: "409: this forge is severed and follows nothing", |
src/http/api.ts +5 −4
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | setFollowUpstream, |
| 16 | 16 | } from "../actions/upstream_follow.ts"; |
| 17 | 17 | import { getFollow } from "../core/upstream_follow.ts"; |
| 18 | import { FAMILY_WORDS } from "../actions/family_words.ts"; | |
| 18 | 19 | import { showProvenance } from "../actions/provenance.ts"; |
| 19 | 20 | import { getProvenance, provenanceFacts } from "../core/provenance.ts"; |
| 20 | 21 | import { goldenPath } from "../apps/golden_path.ts"; |
| @@ -2519,7 +2520,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2519 | 2520 | if (ctx.auth.user?.kind === "agent" || !canAdmin(ctx.app.db, repo, ctx.auth.user)) |
| 2520 | 2521 | return respondAccess("not_found", 422); |
| 2521 | 2522 | if (getProvenance(ctx.app.db, repo.id) === undefined) |
| 2522 | return apiError("no_provenance", 422, "this repository was not migrated here"); | |
| 2523 | return apiError("no_provenance", 422, FAMILY_WORDS.no_provenance); | |
| 2523 | 2524 | } |
| 2524 | 2525 | const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, { |
| 2525 | 2526 | ...(input.visibility === "public" || input.visibility === "private" |
| @@ -2703,7 +2704,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2703 | 2704 | const took = await takeForgeCode(ctx.app, ctx.auth.user, repo); |
| 2704 | 2705 | if (!took.ok) |
| 2705 | 2706 | return took.error === "not_following" |
| 2706 | ? apiError("not_following", 404, "this repository follows nothing across forges") | |
| 2707 | ? apiError("not_following", 404, FAMILY_WORDS.not_following) | |
| 2707 | 2708 | : respondAccess(took.error, 403); |
| 2708 | 2709 | return json({ ...took.value, ...takeForgeWords(took.value) }); |
| 2709 | 2710 | }, |
| @@ -2720,7 +2721,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2720 | 2721 | const cut = await severForge(ctx.app, ctx.auth.user, repo); |
| 2721 | 2722 | if (!cut.ok) |
| 2722 | 2723 | return cut.error === "not_following" |
| 2723 | ? apiError("not_following", 404, "this repository follows nothing across forges") | |
| 2724 | ? apiError("not_following", 404, FAMILY_WORDS.not_following) | |
| 2724 | 2725 | : respondAccess(cut.error, 403); |
| 2725 | 2726 | return json({ severedAt: cut.value }); |
| 2726 | 2727 | }, |
| @@ -2784,7 +2785,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2784 | 2785 | if (e === "no_parent_forge") |
| 2785 | 2786 | return apiError("no_parent_forge", 404, "this repository has no parent forge to propose to"); |
| 2786 | 2787 | if (e === "nothing_to_propose") |
| 2787 | return apiError("nothing_to_propose", 409, "nothing here is ahead of the parent forge"); | |
| 2788 | return apiError("nothing_to_propose", 409, FAMILY_WORDS.nothing_to_propose); | |
| 2788 | 2789 | if (typeof e === "string") return respondAccess(e, 403); |
| 2789 | 2790 | return e.kind === "unreachable" |
| 2790 | 2791 | ? apiError("parent_unreachable", 502, "parent unreachable") |
src/http/mcp.ts +5 −4
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | |
| 20 | 20 | import { listRemoteRepos, listUserOrigins, syncOriginNow } from "../actions/origins.ts"; |
| 21 | 21 | import { originJson, remoteRepoJson } from "./api.ts"; |
| 22 | import { FAMILY_WORDS } from "../actions/family_words.ts"; | |
| 22 | 23 | import { showProvenance } from "../actions/provenance.ts"; |
| 23 | 24 | import { migrateMessage, migrateRepo, migrationJobFor } from "../actions/migrate.ts"; |
| 24 | 25 | import type { MirrorJob } from "../origins/jobs.ts"; |
| @@ -2022,7 +2023,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2022 | 2023 | const set = showProvenance(ctx.app, user, repo, shown, dryRun); |
| 2023 | 2024 | if (!set.ok) |
| 2024 | 2025 | return set.error === "no_provenance" |
| 2025 | ? bad("no_provenance", "this repository was not migrated here") | |
| 2026 | ? bad("no_provenance", FAMILY_WORDS.no_provenance) | |
| 2026 | 2027 | : bad("forbidden", "only an admin of this repository shows or hides its trail"); |
| 2027 | 2028 | return good({ |
| 2028 | 2029 | ...set.value, |
| @@ -2550,7 +2551,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2550 | 2551 | const across = await takeForgeCode(ctx.app, user, repo); |
| 2551 | 2552 | if (across.ok) return good({ ...across.value, ...takeForgeWords(across.value) }); |
| 2552 | 2553 | return across.error === "not_following" |
| 2553 | ? bad("not_following", "this repository follows nothing across forges") | |
| 2554 | ? bad("not_following", FAMILY_WORDS.not_following) | |
| 2554 | 2555 | : bad(across.error, "only this repository's admin can take the parent forge's code"); |
| 2555 | 2556 | } |
| 2556 | 2557 | const took = await takeOrTell(ctx.app, user, repo); |
| @@ -2601,7 +2602,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2601 | 2602 | if (cut.ok) return good({ says: `Severed: following nothing since ${cut.value.slice(0, 10)}.`, severedAt: cut.value }); |
| 2602 | 2603 | return bad( |
| 2603 | 2604 | cut.error, |
| 2604 | cut.error === "not_following" ? "this repository follows nothing across forges" : "only this forge's owner can sever it", | |
| 2605 | cut.error === "not_following" ? FAMILY_WORDS.not_following : "only this forge's owner can sever it", | |
| 2605 | 2606 | ); |
| 2606 | 2607 | }, |
| 2607 | 2608 | }, |
| @@ -2693,7 +2694,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2693 | 2694 | }); |
| 2694 | 2695 | const e = made.error; |
| 2695 | 2696 | if (e === "no_parent_forge") return bad(e, "this forge has no parent forge to propose to"); |
| 2696 | if (e === "nothing_to_propose") return bad(e, "nothing here is ahead of the parent forge"); | |
| 2697 | if (e === "nothing_to_propose") return bad(e, FAMILY_WORDS.nothing_to_propose); | |
| 2697 | 2698 | if (typeof e === "string") return bad(e, "you need write access to propose from here"); |
| 2698 | 2699 | return e.kind === "unreachable" |
| 2699 | 2700 | ? bad("parent_unreachable", "parent unreachable: nothing was proposed") |
test/family-words.test.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | /** | |
| 2 | * The forge family's refusals (#5562): the API and MCP answer the sentence | |
| 3 | * src/actions/family_words.ts holds, and no other file under src/ types it. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { FAMILY_WORDS } from "../src/actions/family_words.ts"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createRepo } from "../src/core/repos.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let token = ""; | |
| 24 | ||
| 25 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 26 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 27 | return r.value; | |
| 28 | }; | |
| 29 | ||
| 30 | const api = (method: string, path: string, body?: unknown) => | |
| 31 | fetch(`${servers.url}/api/v1${path}`, { | |
| 32 | method, | |
| 33 | headers: { | |
| 34 | authorization: `Bearer ${token}`, | |
| 35 | "content-type": "application/json", | |
| 36 | origin: servers.url, | |
| 37 | }, | |
| 38 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 39 | }); | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | dataDir = await mkdtemp(join(tmpdir(), "forge-family-words-")); | |
| 43 | app = createApp({ dataDir, port: 0 }); | |
| 44 | servers = await serve(app); | |
| 45 | const ada = ok( | |
| 46 | await createUser(app.db, { | |
| 47 | origin: "seed", | |
| 48 | trusted: true, | |
| 49 | username: "ada", | |
| 50 | email: "[email protected]", | |
| 51 | password: "a-good-passphrase", | |
| 52 | adminIfFirst: true, | |
| 53 | }), | |
| 54 | ); | |
| 55 | ok(await createRepo(app.db, app.reposRoot, { ownerId: ada.id, name: "lib", visibility: "public" })); | |
| 56 | token = createToken(app.db, ada.id, "t", "admin").token; | |
| 57 | }, 30_000); | |
| 58 | ||
| 59 | afterAll(async () => { | |
| 60 | servers?.stop(); | |
| 61 | app?.close(); | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | test("severing a repository that follows nothing answers the module's sentence at both doors", async () => { | |
| 66 | const res = await api("POST", "/repos/ada/lib/following/sever"); | |
| 67 | expect(res.status).toBe(404); | |
| 68 | expect(await res.json()).toEqual({ error: "not_following", message: FAMILY_WORDS.not_following }); | |
| 69 | const mcp = await mcpTool(servers.url, token, "sever", { repo: "ada/lib" }); | |
| 70 | expect(mcp.error).toBe(`not_following: ${FAMILY_WORDS.not_following}`); | |
| 71 | }); | |
| 72 | ||
| 73 | test("showing a trail on a repository never migrated answers the module's sentence at both doors", async () => { | |
| 74 | const res = await api("PATCH", "/repos/ada/lib", { showProvenance: true }); | |
| 75 | expect(res.status).toBe(422); | |
| 76 | expect(await res.json()).toEqual({ error: "no_provenance", message: FAMILY_WORDS.no_provenance }); | |
| 77 | const mcp = await mcpTool(servers.url, token, "trail", { repo: "ada/lib", shown: true, dryRun: false }); | |
| 78 | expect(mcp.error).toBe(`no_provenance: ${FAMILY_WORDS.no_provenance}`); | |
| 79 | }); | |
| 80 | ||
| 81 | test("no file under src/ but the module types a family sentence", async () => { | |
| 82 | const root = join(import.meta.dir, "..", "src"); | |
| 83 | const files = (await readdir(root, { recursive: true })).filter((f) => f.endsWith(".ts")); | |
| 84 | for (const sentence of Object.values(FAMILY_WORDS)) { | |
| 85 | const typed: string[] = []; | |
| 86 | for (const f of files) if ((await readFile(join(root, f), "utf8")).includes(sentence)) typed.push(f); | |
| 87 | expect(typed).toEqual([join("actions", "family_words.ts")]); | |
| 88 | } | |
| 89 | }); |
new above
offline