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

The owner changes how their forge follows its parent from the chain page, the API and MCP #2638

Merged opened this

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

Merged

scout/forge:scout/2632-the-owner-changes-how-their-forge-follow → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/2632-the-owner-changes-how-their-forge-follow was merged into main as 616e5fd.

Review

Sign in to review this change.
12 files changed +339 −21
README.md +7
@@ -187,6 +187,13 @@ the default branch is pushed to `child/<name>/<branch>-<tip>` at the parent,
187187 and the pull opens there, titled by the change that landed last and naming it
188188 (`org/forge#7`). Asking again for the same tip returns the pull already made.
189189
190 The repository's admin changes how the forge follows its parent (`tell-me` or

Once the terminal binding exists, name it here too: "…or press m on the chain screen in the terminal".

191 `take-updates`) from the radio form on the same page, with `PATCH
192 /api/v1/forge/following {"mode"}`, with the MCP `follow_mode` tool, or by pressing `m` on the chain screen in the terminal. The
193 record in `parent.json` is rewritten atomically and a `forge.follow_mode` event
194 is appended. A non-admin is refused (403), a value outside the two modes is
195 422, and a severed forge is 409; each leaves `parent.json` untouched.
196
190197 The page and `GET …/following/proposals` show each proposal as open, merged or
191198 closed, read from the parent (the token may read back only pulls it opened) and
192199 cached for a minute (only a signed-in viewer makes the parent get asked); a merged or closed one is final. A parent that does not
src/actions/follow.ts +35 −1
@@ -12,7 +12,7 @@ import { convergeFork, type Converge } from "../crew/converge.ts";
1212 import { ARCHIVED_WHY, canAdmin, canWrite, forkParentFor, type Repo } from "../core/repos.ts";
1313 import type { User } from "../core/users.ts";
1414 import { err, ok, type Result } from "../kernel/result.ts";
15 import { chainOfRepo, severChain } from "../spawn/chain.ts";
15 import { chainOfRepo, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts";
1616 import { requireUser } from "./access.ts";
1717 import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts";
1818
@@ -62,6 +62,40 @@ export async function severForge(
6262 return ok(cut.value);
6363 }
6464
65 export type FollowModeError = SeverError | "invalid_mode" | "severed";
66
67 /** The one answer every door gives a refused mode change, with the status an HTTP door sends. */
68 export const FOLLOW_MODE_REFUSALS: Record<FollowModeError, { status: number; message: string }> = {
69 unauthorized: { status: 401, message: "sign in to change how this forge follows its parent" },
70 forbidden: { status: 403, message: "only this forge's owner can change how it follows" },
71 not_following: { status: 404, message: "this repository follows nothing across forges" },
72 invalid_mode: { status: 422, message: `mode is one of: ${FOLLOW_MODES.join(", ")}` },
73 severed: { status: 409, message: "this forge is severed and follows nothing; there is no mode to change" },
74 };
75
76 /** Choose how a forge follows its parent: the repository's admin only; the record changes atomically. */
77 export async function setFollowMode(
78 app: App,
79 actor: User | undefined,
80 repo: Repo,
81 mode: unknown,
82 ): Promise<Result<FollowMode, FollowModeError>> {
83 const who = requireUser(actor);
84 if (!who.ok) return err("unauthorized");
85 if ((await chainOfRepo(app, repo)) === undefined) return err("not_following");
86 if (!canAdmin(app.db, repo, who.value) || !canWrite(app.db, repo, who.value)) return err("forbidden");
87 if (!isFollowMode(mode)) return err("invalid_mode");
88 const set = await setChainMode(app, mode);
89 if (!set.ok) return err(set.error);
90 app.bus.append({
91 kind: "forge.follow_mode",
92 repoId: repo.id,
93 actorId: who.value.id,
94 payload: { repo: `${repo.ownerName}/${repo.name}`, mode },
95 });
96 return ok(mode);
97 }
98
6599 /** What the crew did about a conflict, in a sentence; `danger` when nothing is in hand. */
66100 export function convergeWords(
67101 outcome: Converge,
src/http/api.ts +17 −2
@@ -62,9 +62,9 @@ import { receiveBeat, SIGNATURE_HEADER } from "../spawn/heartbeat.ts";
6262 import { reissueDoor, spawnDoor } from "../spawn/door.ts";
6363 import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts";
6464 import { forgeProposals, proposeToParentForge, proposeUpstream } from "../actions/upstream.ts";
65 import { severForge, takeOrTell } from "../actions/follow.ts";
65 import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeOrTell } from "../actions/follow.ts";
6666 import { relationOf } from "../actions/forks.ts";
67 import { chainOfRepo } from "../spawn/chain.ts";
67 import { chainOfRepo, forgeLinkOf } from "../spawn/chain.ts";
6868 import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts";
6969 import { flowFor, historyFor, sourcesOut, systemFor, welcome } from "../system/telemetry.ts";
7070 import { convergeFork, convergeState } from "../crew/converge.ts";
@@ -2355,6 +2355,21 @@ export function apiRoutes(router: Router<Handler>): void {
23552355 },
23562356 );
23572357
2358 // How this forge follows the forge its code came from: its own code
2359 // repository's admin only, the same words as the page and MCP.
2360 router.add("PATCH", "/api/v1/forge/following", async (ctx) => {
2361 const denied = requireScope(ctx, "rw");
2362 if (denied) return denied;
2363 const link = await forgeLinkOf(ctx.app);
2364 const [owner, name] = (link?.repo ?? "/").split("/") as [string, string];
2365 const repo = link === undefined ? undefined : getRepo(ctx.app.db, owner, name);
2366 if (repo === undefined) return apiError("not_following", 404, FOLLOW_MODE_REFUSALS.not_following.message);
2367 const set = await setFollowMode(ctx.app, ctx.auth.user, repo, (await body(ctx.request)).mode);
2368 if (set.ok) return json({ mode: set.value });
2369 const r = FOLLOW_MODE_REFUSALS[set.error];
2370 return apiError(set.error, r.status, r.message);
2371 });
2372
23582373 router.add(
23592374 "POST",
23602375 "/api/v1/repos/:owner/:name/following/propose",
src/http/mcp.ts +23 −2
@@ -32,9 +32,9 @@ import { changeSignup, readSignup } from "../actions/signup.ts";
3232 import { SIGNUP_MODES } from "../settings/signup.ts";
3333 import { QUOTA_KEYS } from "../settings/quotas.ts";
3434 import { LIMIT_KEYS } from "../settings/limits.ts";
35 import { convergeWords, severForge, takeOrTell } from "../actions/follow.ts";
35 import { convergeWords, FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeOrTell } from "../actions/follow.ts";
3636 import { relationOf } from "../actions/forks.ts";
37 import { chainOfRepo } from "../spawn/chain.ts";
37 import { chainOfRepo, FOLLOW_MODES, forgeLinkOf } from "../spawn/chain.ts";
3838 import { watchMilestone } from "../actions/watches.ts";
3939 import {
4040 addCommentThrough,
@@ -1594,6 +1594,27 @@ const TOOLS: readonly Tool[] = [
15941594 );
15951595 },
15961596 },
1597 {
1598 name: "follow_mode",
1599 description:
1600 "Choose how a spawned forge follows the forge its code came from: `tell-me` or `take-updates` (its `forge` repository's admin only). Refused on a severed forge. The same words as the radio form on the following page.",
1601 inputSchema: {
1602 type: "object",
1603 properties: {
1604 repo: REPO_ARG,
1605 mode: { type: "string", enum: [...FOLLOW_MODES], description: "How this forge follows its parent." },
1606 },
1607 required: ["repo", "mode"],
1608 },
1609 writes: true,
1610 async run(ctx, user, args) {
1611 const repo = repoArg(ctx, user, args);
1612 if (isAnswer(repo)) return repo;
1613 const set = await setFollowMode(ctx.app, user, repo, args["mode"]);
1614 if (set.ok) return good({ says: `Now following in ${set.value} mode.`, mode: set.value });
1615 return bad(set.error, FOLLOW_MODE_REFUSALS[set.error].message);
1616 },
1617 },
15971618 {
15981619 name: "propose_to_parent_forge",
15991620 description:
src/http/server.ts +2 −1
@@ -128,7 +128,7 @@ import {
128128 signinPasskey,
129129 } from "../web/pages/passkeys.ts";
130130 import { confirmPage, searchPage } from "../web/pages/search.ts";
131 import { chainPage, proposeParentSubmit, severSubmit } from "../web/pages/chain.ts";
131 import { chainPage, modeSubmit, proposeParentSubmit, severSubmit } from "../web/pages/chain.ts";
132132 import { followingSubmit } from "../web/pages/following.ts";
133133 import { reconnectSubmit } from "../web/pages/frozen.ts";
134134 import { activityAtom } from "../web/feeds.ts";
@@ -1272,6 +1272,7 @@ export function buildRouter(): Router<Handler> {
12721272 router.add("POST", "/:owner/:name/following", followingSubmit);
12731273 router.add("GET", "/:owner/:name/following", chainPage);
12741274 router.add("POST", "/:owner/:name/following/sever", severSubmit);
1275 router.add("POST", "/:owner/:name/following/mode", modeSubmit);
12751276 router.add("POST", "/:owner/:name/following/propose", proposeParentSubmit);
12761277 // A mirror pulls on demand; a pin says "keep this on my machine". Both are
12771278 // ordinary repo-scoped writes, not account-level ones.
src/spawn/chain.ts +20 −6
@@ -197,15 +197,29 @@ export async function severChain(app: App, now = new Date()): Promise<Result<str
197197 if (doc === undefined || link === undefined) return err("not_following");
198198 if (link.severedAt !== undefined) return ok(link.severedAt);
199199 const at = now.toISOString();
200 const path = join(app.config.dataDir, "parent.json");
201 await Bun.write(
202 `${path}.tmp`,
203 JSON.stringify({ ...doc, forgeCode: { ...(doc["forgeCode"] as object), severedAt: at } }),
204 );
205 await rename(`${path}.tmp`, path);
200 await writeLink(app, doc, { severedAt: at });
206201 return ok(at);
207202 }
208203
204 /** Rewrite `forgeCode` with `patch` laid over it, atomically: the file is whole before and after. */
205 async function writeLink(app: App, doc: Record<string, unknown>, patch: object): Promise<void> {
206 const path = join(app.config.dataDir, "parent.json");
207 await Bun.write(`${path}.tmp`, JSON.stringify({ ...doc, forgeCode: { ...(doc["forgeCode"] as object), ...patch } }));
208 await rename(`${path}.tmp`, path);
209 }
210
211 /** Record how this forge follows its parent from now on; a severed forge follows nothing, so it refuses. */
212 export async function setChainMode(app: App, mode: FollowMode): Promise<Result<FollowMode, "not_following" | "severed">> {
213 const doc = await readDoc(app);
214 const link = await forgeLinkOf(app);
215 if (doc === undefined || link === undefined) return err("not_following");
216 if (link.severedAt !== undefined) return err("severed");
217 await writeLink(app, doc, { mode });

Low: setChainMode and severChain both read parent.json, then await, then write the whole file back from what they read. If a sever and a mode change interleave, for example the owner presses Save in one tab while severing in another, the mode write can land second and drop severedAt, which silently un-severs the forge. The two paths also share one .tmp name. Fix: serialise writes to this file through a single in-process promise chain, or re-read inside one critical section in writeLink. A follow-up is fine.

218 return ok(mode);
219 }
220
221 export const isFollowMode = isMode;

Nit: isFollowMode is just an alias for isMode. Exporting isMode under one name, or renaming it, would avoid having two names for the same predicate.

222
209223 /** The chain, when `repo` is this forge's own code repository; undefined for any other. */
210224 export async function chainOfRepo(app: App, repo: { ownerName: string; name: string }): Promise<ChainView | undefined> {
211225 const link = await forgeLinkOf(app);
src/tui/screens/chain.ts +14 −5
@@ -1,16 +1,17 @@
11 /**
22 * The chain of forges this forge's code descends from, in a card. The gate is
33 * the page's: only someone who can read `<org>/forge` sees it, and only that
4 * repository's owner is offered Sever, which asks for the same typed word.
4 * repository's owner is offered Sever, which asks for the same typed word,
5 * and `m`, which flips the follow mode.
56 */
67
78 import type { App as Forge } from "../../app.ts";
8 import { severForge } from "../../actions/follow.ts";
9 import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge } from "../../actions/follow.ts";
910 import { forgeProposalLine, forgeProposals, proposeToParentForge } from "../../actions/upstream.ts";
1011 import { parentCredential } from "../../spawn/parent_credential.ts";
1112 import { canAdmin, canRead, canWrite, getRepo, type Repo } from "../../core/repos.ts";
1213 import type { User } from "../../core/users.ts";
13 import { chainLines, chainOfRepo, forgeLinkOf, type ChainView } from "../../spawn/chain.ts";
14 import { chainLines, chainOfRepo, forgeLinkOf, type ChainView, type FollowMode } from "../../spawn/chain.ts";
1415 import type { Ctx, Screen } from "../app/app.ts";
1516 import { binding } from "../app/keys.ts";
1617 import { card } from "../ui/widgets.ts";
@@ -39,7 +40,7 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx):
3940 const writer = user !== undefined && view.severedAt === null && canWrite(app.db, repo, user);
4041 const proposing = view.severedAt === null && (await parentCredential(app)) !== undefined;
4142 const linesOf = async (): Promise<[string, string][]> => [
42 ...chainLines(view),
43 ...chainLines((await chainOfRepo(app, repo)) ?? view),
4344 ...(proposing
4445 ? (await forgeProposals(app, repo, Date.now(), user !== undefined)).map(
4546 (p): [string, string] => ["proposal", forgeProposalLine(p)],
@@ -52,7 +53,7 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx):
5253 title: "Chain",
5354 keys: [
5455 ...(writer && proposing ? [binding(["p"], "propose")] : []),
55 ...(owner ? [binding(["s"], "sever")] : []),
56 ...(owner ? [binding(["m"], "follow mode"), binding(["s"], "sever")] : []),
5657 binding(["esc"], "close"),
5758 ],
5859 render(area, s) {
@@ -83,6 +84,14 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx):
8384 rows = await linesOf();
8485 ctx.toast(`Proposed to the parent forge as #${made.value.number}`, "success");
8586 });
87 else if (e.key === "m" && owner)
88 void forgeLinkOf(app).then(async (link) => {
89 const next: FollowMode = link?.mode === "take-updates" ? "tell-me" : "take-updates";
90 const set = await setFollowMode(app, user, repo, next);
91 if (!set.ok) return ctx.toast(FOLLOW_MODE_REFUSALS[set.error].message, "danger");
92 rows = await linesOf();
93 ctx.toast(`Now following in ${set.value} mode.`, "success");
94 });
8695 else if (e.key === "s" && owner)
8796 ctx.push(
8897 promptScreen(ctx, self, {
src/web/pages/chain.ts +40 −3
@@ -5,11 +5,11 @@
55 * sever. A severed forge says so with the date and nothing is deleted.
66 */
77
8 import { severForge } from "../../actions/follow.ts";
8 import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge } from "../../actions/follow.ts";
99 import { forgeProposals, proposeToParentForge, type ForgeProposal } from "../../actions/upstream.ts";
1010 import { canAdmin, canWrite } from "../../core/repos.ts";
1111 import type { Ctx } from "../../http/context.ts";
12 import { chainView, forgeLinkOf, type ChainLine } from "../../spawn/chain.ts";
12 import { chainView, FOLLOW_MODES, forgeLinkOf, type ChainLine, type FollowMode } from "../../spawn/chain.ts";
1313 import { parentCredential } from "../../spawn/parent_credential.ts";
1414 import { loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts";
1515 import { field, readForm } from "../form.ts";
@@ -18,7 +18,30 @@ import { flashOrJson, page, redirect } from "../layout.ts";
1818 import { btn, card } from "../ui.ts";
1919
2020 /** The events that change what the chain page says. */
21 export const CHAIN_LIVE = "forge.severed,forge.proposal";
21 export const CHAIN_LIVE = "forge.severed,forge.proposal,forge.follow_mode";
22
23 const MODE_LABELS: Record<FollowMode, string> = {
24 "tell-me": "Tell me when the parent has something new",
25 "take-updates": "Take the parent's updates",
26 };
27
28 function modeCard(base: string, current: FollowMode | null): Html {

The page gets the mode picker here, but the terminal's chain screen (src/tui/screens/chain.ts, next to the s sever binding) gets nothing. Add an owner-only key there that calls setFollowMode, and add a test for it.

29 return card(
30 html`<form method="post" action="${base}/following/mode">
31 <fieldset class="form-field">
32 <legend>How this forge follows its parent</legend>
33 ${FOLLOW_MODES.map(
34 (m) => html`<label class="row"
35 ><input type="radio" name="mode" value="${m}" ${m === current ? "checked" : null} />
36 ${MODE_LABELS[m]}</label
37 >`,
38 )}
39 </fieldset>
40 <div class="form-actions">${btn("Save", { type: "submit" })}</div>
41 </form>`,
42 { head: html`<h2>Follow mode</h2>` },
43 );
44 }
2245
2346 const MODE_WORDS = { "tell-me": "recorded: tell me (nothing tells it yet)", "take-updates": "recorded: take updates (nothing acts on it yet)" } as const;
2447
@@ -111,6 +134,7 @@ export async function chainPage(ctx: Ctx): Promise<Response> {
111134 </ul>`,
112135 { pad: false },
113136 )}
137 ${owner && view.severedAt === null ? modeCard(base, view.lines[view.lines.length - 1]?.mode ?? null) : null}
114138 ${proposing ? proposalCard(base, proposals, writer) : null}
115139 ${view.severedAt !== null
116140 ? html`<p class="muted" id="severed">
@@ -158,6 +182,19 @@ export async function severSubmit(ctx: Ctx): Promise<Response> {
158182 return flashOrJson(ctx, to, { message: `Severed: following nothing since ${cut.value.slice(0, 10)}. Nothing was deleted.` });
159183 }
160184
185 /** POST /:owner/:name/following/mode {mode}; the owner only. */
186 export async function modeSubmit(ctx: Ctx): Promise<Response> {
187 const repo = loadRepo(ctx);
188 if (!repo) return notFoundPage(ctx);
189 if (!ctx.auth.user) return signInFirst(ctx);
190 const form = await readForm(ctx, "rw");
191 if (form instanceof Response) return form;
192 const to = `${repoBase(repo)}/following`;
193 const set = await setFollowMode(ctx.app, ctx.auth.user, repo, field(form, "mode"));
194 if (!set.ok) return flashOrJson(ctx, to, { tone: "danger", message: FOLLOW_MODE_REFUSALS[set.error].message });
195 return flashOrJson(ctx, to, { message: `Now following in ${set.value} mode.` });
196 }
197
161198 /** POST /:owner/:name/following/propose: push to the parent forge and open the pull there; a writer only. */
162199 export async function proposeParentSubmit(ctx: Ctx): Promise<Response> {
163200 const repo = loadRepo(ctx);
test/following.test.ts +1 −1
@@ -1092,7 +1092,7 @@ describe("an open chain page hears a sever", () => {
10921092 test("the page carries a live region for forge.severed, scoped to its repository", async () => {
10931093 const page = await chainPage();
10941094 expect(page).toContain('id="live-chain"');
1095 expect(page).toContain('data-live="forge.severed,forge.proposal"');
1095 expect(page).toContain('data-live="forge.severed,forge.proposal,forge.follow_mode"');
10961096 expect(page).toContain('data-live-repo="kid/forge"');
10971097 expect(page).not.toContain("follows nothing since");
10981098 });
test/mcp.test.ts +1
@@ -199,6 +199,7 @@ describe("the forge over MCP", () => {
199199 "claim_link",
200200 "take_updates",
201201 "sever",
202 "follow_mode",
202203 "propose_to_parent_forge",
203204 "forge_proposals",
204205 "converge",
test/spawn-chain-doors.test.ts +23
@@ -11,6 +11,7 @@ import { join } from "node:path";
1111 import { createRepo } from "../src/actions/repos.ts";
1212 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
1313 import { createApp, type App } from "../src/app.ts";
14 import { listEvents } from "../src/core/events.ts";
1415 import { getRepo, updateRepoSettings } from "../src/core/repos.ts";
1516 import { createToken } from "../src/core/tokens.ts";
1617 import { createUser, getUserByName, type User } from "../src/core/users.ts";
@@ -168,6 +169,28 @@ describe("the chain at the MCP and terminal doors", () => {
168169 expect((await forgeLinkOf(child))?.severedAt).toBeUndefined();
169170 });
170171
172 test("the terminal's owner flips the follow mode with m; a reader has no such key", async () => {
173 const was = (await forgeLinkOf(child))!.mode;
174 const next = was === "tell-me" ? "take-updates" : "tell-me";
175 const events = () => listEvents(child.db, { kind: "forge.follow_mode" }).length;
176 const before = events();
177 const guest = boot(reader);
178 await guest.openChain();
179 expect(guest.text()).not.toContain("follow mode");
180 await guest.press("m");
181 expect((await forgeLinkOf(child))?.mode).toBe(was);
182 expect(events()).toBe(before);
183 guest.app.stop();
184 const owner = boot(alice);
185 await owner.openChain();
186 expect(owner.text()).toContain("follow mode");
187 await owner.press("m");
188 await settleUntil(owner.app, `Now following in ${next} mode`);
189 expect((await forgeLinkOf(child))?.mode).toBe(next);
190 expect(events()).toBe(before + 1);
191 owner.app.stop();
192 });
193
171194 test("the terminal's owner types the word to sever; a wrong word does nothing", async () => {
172195 const owner = boot(alice);
173196 await owner.openChain();
test/spawn-chain-mode.test.ts added +156
@@ -0,0 +1,156 @@
1 /**
2 * The owner changes how their forge follows its parent (#2632): one action,
3 * three doors (page, API, MCP) wording the same answer. A refusal leaves
4 * parent.json byte for byte as it was.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { listEvents } from "../src/core/events.ts";
15 import { createSession } from "../src/core/sessions.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { createUser, getUserByName } from "../src/core/users.ts";
18 import { repoDir } from "../src/git/repos.ts";
19 import { commitFiles } from "../src/git/write.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { forgeLinkOf, severChain } from "../src/spawn/chain.ts";
22 import { hostedPlacement } from "../src/spawn/placement.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24
25 quietLogs();
26
27 describe("choosing how a forge follows its parent", () => {
28 let dir = "";
29 let root: App;
30 let child: App;
31 let servers: Servers;
32 let cookie = "";
33 let ownerTok = "";
34 let readerTok = "";
35 let readerCookie = "";
36 let id = 1;
37 const file = () => join(child.config.dataDir, "parent.json");
38 const raw = () => Bun.file(file()).text();
39 const patch = (tok: string, body: unknown) =>
40 fetch(`${servers.url}/api/v1/forge/following`, {
41 method: "PATCH",
42 headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" },
43 body: JSON.stringify(body),
44 });
45 const tool = async (tok: string, args: unknown) => {
46 const res = await fetch(`${servers.url}/mcp`, {
47 method: "POST",
48 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok}` },
49 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "follow_mode", arguments: args } }),
50 });
51 const j = (await res.json()) as any;
52 return { error: j.result.isError === true, text: String(j.result.content[0].text) };
53 };
54 const form = (ck: string, mode: string) =>
55 fetch(`${servers.url}/kid/forge/following/mode`, {
56 method: "POST",
57 headers: { cookie: ck, origin: servers.url },
58 body: new URLSearchParams({ mode }),
59 redirect: "manual",
60 });
61
62 beforeAll(async () => {
63 dir = await mkdtemp(join(tmpdir(), "forge-chain-mode-"));
64 root = createApp({ dataDir: dir, url: "https://root.test", port: 0, forgeRepo: "alice/forge-code" });
65 const alice = await createUser(root.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", isAdmin: true });
66 if (!alice.ok) throw new Error("user");
67 const made = await createRepo(root, alice.value, { name: "forge-code", visibility: "public" });
68 if (!made.ok) throw new Error("repo");
69 const c = await commitFiles(repoDir(root.reposRoot, "alice", "forge-code"), {
70 branch: "main",
71 message: "forge v1",
72 files: [{ path: "README.md", content: "# forge\n" }],
73 author: { name: "a", email: "[email protected]" },
74 });
75 if (!c.ok) throw new Error("commit");
76 const a = await spawnForge(
77 root,
78 alice.value,
79 { name: "kid", org: "kid", apps: [], carryBriefs: false, carrySubscriptions: false },
80 { catalog: [], briefs: [], subscriptions: [] },
81 {
82 placement: hostedPlacement({ host: "root.test" }),
83 request: new Request("https://root.test/x", { method: "POST", headers: { origin: "https://root.test" } }),
84 url: new URL("https://root.test/x"),
85 },
86 );
87 if (!a.ok) throw new Error(JSON.stringify(a.error));
88 child = createApp({ dataDir: childDirOf(root, a.value.bodyId), port: 0 });
89 servers = await serve(child);
90 const owner = getUserByName(child.db, "alice")!;
91 cookie = `forge_session=${createSession(child.db, owner.id).token}`;
92 ownerTok = createToken(child.db, owner.id, "t", "rw").token;
93 const reader = await createUser(child.db, { origin: "seed", trusted: true, username: "reader", email: "[email protected]" });
94 if (!reader.ok) throw new Error("reader");
95 readerTok = createToken(child.db, reader.value.id, "t", "rw").token;
96 readerCookie = `forge_session=${createSession(child.db, reader.value.id).token}`;
97 }, 60_000);
98
99 afterAll(async () => {
100 servers.stop();
101 child.close();
102 root.close();
103 await rm(dir, { recursive: true, force: true });
104 });
105
106 test("refusals change nothing and say why", async () => {
107 const before = await raw();
108 expect((await patch(readerTok, { mode: "take-updates" })).status).toBe(403);
109 expect((await patch(ownerTok, { mode: "yolo" })).status).toBe(422);
110 expect((await patch(ownerTok, {})).status).toBe(422);
111 expect((await form(readerCookie, "take-updates")).status).toBe(303);
112 expect((await form(cookie, "yolo")).status).toBe(303);
113 expect((await tool(readerTok, { repo: "kid/forge", mode: "take-updates" })).error).toBe(true);
114 expect((await tool(ownerTok, { repo: "kid/forge", mode: "yolo" })).error).toBe(true);
115 const cross = await fetch(`${servers.url}/kid/forge/following/mode`, {
116 method: "POST",
117 headers: { cookie, origin: "https://evil.test" },
118 body: new URLSearchParams({ mode: "take-updates" }),
119 });
120 expect(cross.status).toBe(403);
121 expect(await raw()).toBe(before);
122 expect(listEvents(child.db, { kind: "forge.follow_mode" })).toHaveLength(0);
123 });
124
125 test("the owner changes it from the page, the API and MCP; the page shows it", async () => {
126 expect((await forgeLinkOf(child))?.mode).toBe("tell-me");
127 const page = async () => (await (await fetch(`${servers.url}/kid/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " ");
128 const html = await page();
129 expect(html).toContain('action="/kid/forge/following/mode"');
130 expect(html).toMatch(/value="tell-me" checked/);
131 expect(html).not.toContain("<script>");
132
133 expect((await form(cookie, "take-updates")).status).toBe(303);
134 expect((await forgeLinkOf(child))?.mode).toBe("take-updates");
135 expect(await page()).toMatch(/value="take-updates" checked/);
136
137 const res = await patch(ownerTok, { mode: "tell-me" });
138 expect(res.status).toBe(200);
139 expect(((await res.json()) as any).mode).toBe("tell-me");
140 const viaTool = await tool(ownerTok, { repo: "kid/forge", mode: "take-updates" });
141 expect(viaTool.error).toBe(false);
142 expect((await forgeLinkOf(child))?.mode).toBe("take-updates");
143 expect(listEvents(child.db, { kind: "forge.follow_mode" })).toHaveLength(3);
144 });
145
146 test("a severed forge refuses with 409 and keeps its record", async () => {
147 expect((await severChain(child)).ok).toBe(true);
148 const before = await raw();
149 expect((await patch(ownerTok, { mode: "tell-me" })).status).toBe(409);
150 expect((await tool(ownerTok, { repo: "kid/forge", mode: "tell-me" })).error).toBe(true);
151 await form(cookie, "tell-me");
152 expect(await raw()).toBe(before);
153 const html = await (await fetch(`${servers.url}/kid/forge/following`, { headers: { cookie } })).text();
154 expect(html).not.toContain("/following/mode");
155 });
156 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs