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

A forge link says when it ends and its sender revokes it in one tap #10830

Merged opened this

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

Merged

mason/forge:mason/10725-a-forge-link-says-when-it-ends-and-its-s → main 2 commits fork

  • Merged. mason/10725-a-forge-link-says-when-it-ends-and-its-s was merged into main as ffd2caa.

Review

Sign in to review this change.
16 files changed +517 −19
docs/MCP.md +1 −1
@@ -78,7 +78,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
7878 | `branches` | `create`, `delete`, `branches`, `tags` | A repo's branches; `name`, `action` create (from `from`) or delete one, not the default | 407 |
7979 | `builds` | — | A repo's build pins (`refs/builds/`): label, commit, pinned by, note | 231 |
8080 | `capacity` | — | Forge capacity and guards, live | 390 |
81 | `claim_link` | — | A new claim link for an unclaimed child forge, only with `reveal: true`; the old stops | 289 |
81 | `claim_link` | `revoke`, `undo` | A child's claim link: `reveal` a new one, or `action` revoke/undo | 304 |
8282 | `close` | — | Close or reopen (`state` open) an issue or pull | 463 |
8383 | `cloud_account` | — | Change a cloud account by `verb` (admin) | 549 |
8484 | `cloud_accounts` | — | Cloud accounts (admin): each token's use | 131 |
src/contract/openapi/routes/getApiV1Forge.ts +1 −1
@@ -1,3 +1,3 @@
11 import type { Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: "/api/v1/forge", summary: "A spawned forge's word about itself: { claimed, lastActivityAt }; lastActivityAt is null unless the parent's secret is sent", auth: false, refusals: { not_found: "404: this forge was not spawned" } };
3 export const route: Route = { method: "get", path: "/api/v1/forge", summary: "A spawned forge's word about itself: { claimed, lastActivityAt, link? }; lastActivityAt and link ({ state, revokedAt }) are null or absent unless the parent's secret is sent", auth: false, refusals: { not_found: "404: this forge was not spawned" } };
src/core/event_audience.ts +2
@@ -111,6 +111,8 @@ const READER_KINDS: ReadonlySet<string> = new Set([
111111 "forge.removed",
112112 "forge.stopped",
113113 "forge.claim_reissued",
114 "forge.claim_revoked",
115 "forge.claim_restored",
114116 "agent.created",
115117 "agent.updated",
116118 "agent.deleted",
src/http/server.ts +3 −1
@@ -260,7 +260,7 @@ import {
260260 import { claimFragmentPage, claimOptions, claimSubmit } from "../web/pages/claim.ts";
261261 import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts";
262262 import { familyPage } from "../web/pages/family.ts";
263 import { forgeLinkSubmit, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit, forgesPage } from "../web/pages/forges.ts";
263 import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit, forgesPage } from "../web/pages/forges.ts";
264264 import { forgeItSubmit } from "../web/pages/forge-it.ts";
265265 import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts";
266266 import { askedPage } from "../web/studio/asked_page.ts";
@@ -818,6 +818,8 @@ export function buildRouter(): Router<Handler> {
818818 router.add("GET", "/forges", forgesPage);
819819 router.add("GET", "/family", familyPage);
820820 router.add("POST", "/forges/:name/claim-link", forgeLinkSubmit);
821 router.add("POST", "/forges/:name/revoke-link", forgeLinkRevoke);
822 router.add("POST", "/forges/:name/undo-link", forgeLinkUndo);
821823 router.add("POST", "/forges/:name/stop", forgeStopSubmit);
822824 router.add("GET", "/forges/:name/remove", forgeRemoveConfirm);
823825 router.add("POST", "/forges/:name/remove", forgeRemoveSubmit);
src/spawn/claim.ts +70 −1
@@ -49,11 +49,13 @@ interface Stored {
4949 readonly e: string;
5050 /** When it was used, ISO; null while it is unused. */
5151 readonly u: string | null;
52 /** When the spawner took it back, ISO; absent while it stands. */
53 readonly r?: string;
5254 }
5355
5456 export type ClaimState =
5557 | { readonly state: "ok"; readonly orgId: string }
56 | { readonly state: "used" | "expired" | "unknown" | "owned" };
58 | { readonly state: "used" | "expired" | "revoked" | "unknown" | "owned" };
5759
5860 /** What a spawned child's `parent.json` says, when it has one. */
5961 export interface Spawned {
@@ -167,6 +169,7 @@ function judge(
167169 const found = find(db, orgId, token);
168170 if (found === undefined) return { state: { state: "unknown" } };
169171 if (found.stored.u !== null) return { state: { state: "used" } };
172 if (found.stored.r !== undefined) return { state: { state: "revoked" } };
170173 if (Date.parse(found.stored.e) <= now) return { state: { state: "expired" } };
171174 if (hasAdmin(db)) return { state: { state: "owned" } };
172175 return {
@@ -235,3 +238,69 @@ export function handOver(db: Database, spawned: Spawned, userId: string): void {
235238 if (!spawned.keepAccess && spawner && spawner.id !== userId)
236239 removeTeamMember(db, team, spawner.id);
237240 }
241
242 /** How long a revoke can be taken back. */
243 export const REVOKE_UNDO_MS = 60_000;
244
245 /** A link as its sender sees it: open, used, or ended (revoked or past its day). */
246 export type LinkState = "open" | "used" | "ended";
247
248 /** The link's state, and when its sender took it back (ISO) when they did. */
249 export interface LinkInfo {
250 readonly state: LinkState;
251 readonly revokedAt: string | null;
252 }
253
254 /** True while an ended link can still be brought back: revoked in the last minute, in date, unspent. */
255 export const undoable = (link: LinkInfo, now = Date.now()): boolean =>
256 link.state === "ended" && link.revokedAt !== null && now - Date.parse(link.revokedAt) <= REVOKE_UNDO_MS;
257
258 type Found = { readonly value: string; readonly stored: Stored };
259
260 const readStored = (db: Database, orgId?: string): Found | undefined => {
261 const row =
262 orgId === undefined
263 ? db.query<{ value: string }, [string]>("SELECT value FROM user_setting WHERE key = ?1").get(KEY)
264 : db.query<{ value: string }, [string, string]>("SELECT value FROM user_setting WHERE user_id = ?2 AND key = ?1").get(KEY, orgId);
265 if (!row) return undefined;
266 try {
267 return { value: row.value, stored: JSON.parse(row.value) as Stored };
268 } catch {
269 return undefined;
270 }
271 };
272
273 const stateOf = (s: Stored, now: number): LinkState =>
274 s.u !== null ? "used" : s.r !== undefined || Date.parse(s.e) <= now ? "ended" : "open";
275
276 /** The claim link in this forge's own database; null when it has none. */
277 export function linkOf(db: Database, now = Date.now()): LinkInfo | null {
278 const found = readStored(db);
279 return found === undefined ? null : { state: stateOf(found.stored, now), revokedAt: found.stored.r ?? null };
280 }
281
282 /** Replace the stored link only if nobody changed it since it was read. */
283 function swap(db: Database, orgId: string, from: Found, next: Stored): boolean {
284 return (
285 db
286 .query("UPDATE user_setting SET value = ?1 WHERE user_id = ?2 AND key = ?3 AND value = ?4")
287 .run(JSON.stringify(next), orgId, KEY, from.value).changes === 1
288 );
289 }
290
291 /** Take back an open link: from now it answers "ended". False when it was not open. */
292 export function revokeClaim(db: Database, orgId: string, now = Date.now()): boolean {
293 const found = readStored(db, orgId);
294 if (found === undefined || stateOf(found.stored, now) !== "open") return false;
295 return swap(db, orgId, found, { ...found.stored, r: new Date(now).toISOString() });
296 }
297
298 /** Undo a revoke made in the last minute, if the link is still unspent and in date. */
299 export function restoreClaim(db: Database, orgId: string, now = Date.now()): boolean {
300 const found = readStored(db, orgId);
301 if (found === undefined) return false;
302 const { r, ...back } = found.stored;
303 if (r === undefined || back.u !== null || Date.parse(back.e) <= now) return false;
304 if (!undoable({ state: "ended", revokedAt: r }, now)) return false;
305 return swap(db, orgId, found, back);
306 }
src/spawn/clients.ts +23 −2
@@ -15,7 +15,7 @@ import { timingSafeEqual } from "node:crypto";
1515 import { sha256 } from "../kernel/hash.ts";
1616 import type { ChildAppsSummary } from "./child-apps.ts";
1717 import { childAwake, hostedChildApps } from "./host.ts";
18 import { hasAdmin, spawnedOrg } from "./claim.ts";
18 import { hasAdmin, linkOf, spawnedOrg, type LinkInfo, type LinkState } from "./claim.ts";
1919 import { lastHeard, type Beat } from "./heartbeat.ts";
2020 import { reachOf, reachWords, type ChildReach } from "./reach.ts";
2121 import type { Size } from "./sizes.ts";
@@ -25,6 +25,8 @@ import { forgeUse, type ForgeUse } from "./usage.ts";
2525 export interface ForgeState {
2626 readonly claimed: boolean;
2727 readonly lastActivityAt: string | null;
28 /** The claim link's state; only the parent's secret unlocks it. Absent from an older child. */
29 readonly link?: LinkInfo | null;
2830 }
2931
3032 /** One row of the client list; `claimed` is null when the child did not answer. */
@@ -44,6 +46,8 @@ export interface ClientRow {
4446 readonly running: ChildAppsSummary | null;
4547 /** What a visitor got from its public addresses when last asked (`spawn/reach.ts`); null before it was. */
4648 readonly reach: ChildReach | null;
49 /** The claim link's state (open, used, ended) and when it was taken back; null when the child did not say. */
50 readonly link: LinkInfo | null;
4751 /** Why this forge needs its owner now; null when it does not. */
4852 readonly attention: Attention | null;
4953 /** Dollars the child's crew spent today, from its last heartbeat; null when it has not said. */
@@ -123,7 +127,17 @@ export async function forgeState(
123127 )`,
124128 )
125129 .get();
126 return { claimed, lastActivityAt: last?.at ?? null };
130 return { claimed, lastActivityAt: last?.at ?? null, link: linkOf(app.db) };
131 }
132
133 const LINK_STATES: readonly LinkState[] = ["open", "used", "ended"];
134
135 /** The link a child reported, or null when it said nothing usable. */
136 function linkOfBody(v: unknown): LinkInfo | null {
137 if (typeof v !== "object" || v === null) return null;
138 const { state, revokedAt } = v as Record<string, unknown>;
139 if (!LINK_STATES.includes(state as LinkState)) return null;
140 return { state: state as LinkState, revokedAt: typeof revokedAt === "string" && !Number.isNaN(Date.parse(revokedAt)) ? revokedAt : null };
127141 }
128142
129143 async function probe(app: App, child: Child): Promise<ForgeState | undefined> {
@@ -141,6 +155,7 @@ async function probe(app: App, child: Child): Promise<ForgeState | undefined> {
141155 if (typeof body["claimed"] !== "boolean") return undefined;
142156 const at = body["lastActivityAt"];
143157 return {
158 link: linkOfBody(body["link"]),
144159 claimed: body["claimed"],
145160 lastActivityAt:
146161 typeof at === "string" && !Number.isNaN(Date.parse(at)) ? at : null,
@@ -161,6 +176,11 @@ const heard = new WeakMap<App, Map<string, Heard>>();
161176 const FRESH_MS = 1500;
162177 const asking = new WeakMap<App, Map<string, Promise<ForgeState | undefined>>>();
163178
179 /** Forget what a child last said, so the next list asks it again (after its link changed). */
180 export function forgetHeard(app: App, name: string): void {
181 heard.get(app)?.delete(name);
182 }
183
164184 function ask(app: App, child: Child): Promise<ForgeState | undefined> {
165185 let pending = asking.get(app);
166186 if (!pending) asking.set(app, (pending = new Map()));
@@ -214,6 +234,7 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
214234 size: c.size,
215235 use: forgeUse(c, childDirOf(app, c.bodyId)),
216236 lastHeartbeatAt: beats[i] === undefined ? null : new Date(beats[i]!.receivedAt).toISOString(),
237 link: states[i]?.link ?? null,
217238 nextStep: nextStepOf(states[i]?.claimed ?? null, beats[i]?.beat),
218239 })),
219240 now,
src/spawn/door.ts +12 −1
@@ -16,7 +16,8 @@ import { canRead, getRepo } from "../core/repos.ts";
1616 import { kindOfToken, normalizeToken, parseApiBase } from "../core/subscriptions.ts";
1717 import type { User } from "../core/users.ts";
1818 import { repoDir } from "../git/repos.ts";
19 import type { Ctx } from "../http/context.ts";
19 import { crossOrigin, type Ctx } from "../http/context.ts";
20 import { changeLink } from "./manage.ts";
2021 import { takeDoor } from "../http/strangers.ts";
2122 import {
2223 childPublicUrl,
@@ -347,3 +348,13 @@ export async function reissueDoor(ctx: Ctx, user: User, name: string): Promise<D
347348 return { status: 404, body: { error: "not_found", message: `no child forge named ${name}` } };
348349 return spawnWords(made.error);
349350 }
351
352 /** Take a child's unclaimed link back, or undo that within the minute: an admin's, same-origin. */
353 export async function changeDoor(ctx: Ctx, user: User, name: string, act: "revoke" | "undo"): Promise<Door> {
354 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return spawnWords({ kind: "cross_origin" });
355 if (!user.isAdmin) return { status: 403, body: { error: "forbidden", message: "only a site admin can end or bring back a claim link" } };
356 const done = await changeLink(ctx.app, user.id, name, act);
357 if (done.ok) return { status: 200, body: { name, says: done.says } };
358 if (done.missing) return { status: 404, body: { error: "not_found", message: `no child forge named ${name}` } };
359 return spawnWords({ kind: "refused", reason: "not_open", message: done.says });
360 }
src/spawn/manage.ts +34
@@ -8,6 +8,9 @@ import { rm } from "node:fs/promises";
88 import { join } from "node:path";
99 import type { App } from "../app.ts";
1010 import { childDirOf, listChildren } from "../actions/spawn.ts";
11 import { createApp } from "../app.ts";
12 import { restoreClaim, revokeClaim, spawnedOrg } from "./claim.ts";
13 import { forgetHeard } from "./clients.ts";
1114 import { sleepChild } from "./host.ts";
1215 import { CHILD_NAME } from "./placement.ts";
1316
@@ -35,3 +38,34 @@ export async function removeForge(app: App, name: string): Promise<Managed> {
3538 app.bus.append({ kind: "forge.removed", payload: { name } });
3639 return { ok: true, says: `${name} is removed, with its data` };
3740 }
41
42 /**
43 * Take a child's unclaimed link back, or undo that within the minute: the
44 * site admin's (callers check), receipted; the link's secret is in no event.
45 * `missing` says there is no such forge; otherwise a refusal means the link
46 * was not open (or the minute is over).
47 */
48 export async function changeLink(
49 app: App,
50 actorId: string,
51 name: string,
52 act: "revoke" | "undo",
53 ): Promise<Managed & { readonly missing?: true }> {
54 const child = await find(app, name);
55 if (child === undefined || !child.done) return { ok: false, says: `no forge named ${name}`, missing: true };
56 const own = createApp({ dataDir: childDirOf(app, child.bodyId) });
57 try {
58 const spawned = await spawnedOrg(own);
59 if (!spawned) return { ok: false, says: "That forge has no link to change." };
60 if (!(act === "revoke" ? revokeClaim : restoreClaim)(own.db, spawned.org.id))
61 return { ok: false, says: act === "revoke" ? "That link is not open." : "That link cannot be brought back now." };
62 } finally {
63 own.close();
64 }
65 forgetHeard(app, name);
66 app.bus.append({ kind: act === "revoke" ? "forge.claim_revoked" : "forge.claim_restored", actorId, payload: { name } });
67 return {
68 ok: true,
69 says: act === "revoke" ? `The link for ${name} has ended. You can undo this for a minute.` : `The link for ${name} is open again.`,
70 };
71 }
src/tui/screens/forges.ts +30 −3
@@ -13,7 +13,8 @@ import { card } from "../ui/widgets.ts";
1313 import { drawFacts, runAsync } from "./common.ts";
1414 import type { PaletteEntry } from "./entries.ts";
1515 import { clientRows } from "../../spawn/clients.ts";
16 import { removeForge, stopForge } from "../../spawn/manage.ts";
16 import { changeLink, removeForge, stopForge } from "../../spawn/manage.ts";
17 import { undoable, type LinkInfo } from "../../spawn/claim.ts";
1718 import { confirmScreen, pickerScreen } from "./common.ts";
1819 import type { Session } from "../session.ts";
1920
@@ -22,12 +23,18 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
2223 if (rows.length === 0) return [["", "You have not sent a forge yet"]];
2324 return rows.flatMap((r): [string, string][] => [[
2425 r.name,
25 [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], runningWords(r), r.nextStep.words, spendWords(r)?.words ?? null, useLine(r.size, r.use), r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
26 [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.nextStep.words, spendWords(r)?.words ?? null, useLine(r.size, r.use), r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
2627 .filter((m): m is string => m !== null)
2728 .join(" · "),
2829 ], ...addressLines(r)]);
2930 }
3031
32 /** "link open", "link ended (undo with u)": what the sender can do about the link, as /forges shows it. */
33 function linkWords(link: LinkInfo | null): string | null {
34 if (link === null || link.state === "used") return null;
35 return link.state === "open" ? "link open" : undoable(link) ? "link ended, undo with u" : "link ended";
36 }
37
3138 /** Under a forge that is running, not reachable: each public address and what it answered (#7959), as /forges lists them. */
3239 function addressLines(r: ClientRow): [string, string][] {
3340 if (r.reach === null || r.reach.ok) return [];
@@ -50,9 +57,27 @@ export function forgesScreen(ctx: Ctx, rows: readonly ClientRow[], session?: Ses
5057 if (items.length === 0 || session === undefined) return;
5158 ctx.push(pickerScreen(ctx, screen, { title, items, onPick: (ids) => { if (ids[0] !== undefined) act(ids[0]); } }));
5259 };
60 // Revoke and Undo are the page's buttons, through the same action (`changeLink`): an admin's, for the rows that allow it.
61 const changeOne = (act: "revoke" | "undo"): void => {
62 const user = session?.user;
63 if (session === undefined || user?.isAdmin !== true) return;
64 const items = rows
65 .filter((r) => r.link !== null && (act === "revoke" ? r.link.state === "open" : undoable(r.link)))
66 .map((r) => ({ id: r.name, label: r.name, hint: act === "revoke" ? "link open" : "link ended" }));
67 if (items.length === 0) return ctx.toast(act === "revoke" ? "No open link to end" : "No ended link to bring back");
68 ctx.push(
69 pickerScreen(ctx, screen, {
70 title: act === "revoke" ? "End which link?" : "Bring back which link?",
71 items,
72 onPick: (ids) => {
73 if (ids[0] !== undefined) void changeLink(session.app, user.id, ids[0], act).then((done) => ctx.toast(done.says, done.ok ? "success" : "danger"));
74 },
75 }),
76 );
77 };
5378 const screen: Screen = {
5479 title: "Forges you sent",
55 keys: [binding(["s"], "stop one (sleep now)"), binding(["x"], "remove one"), binding(["esc"], "close")],
80 keys: [binding(["s"], "stop one (sleep now)"), binding(["x"], "remove one"), binding(["l"], "end a link"), binding(["u"], "undo an ended link"), binding(["esc"], "close")],
5681 render(area, s) {
5782 const c = ctx.theme.c;
5883 const w = Math.min(100, Math.max(30, area.w - 6));
@@ -66,6 +91,8 @@ export function forgesScreen(ctx: Ctx, rows: readonly ClientRow[], session?: Ses
6691 if (e.key === "esc" || e.key === "q") ctx.pop();
6792 else if (e.key === "s" && session !== undefined)
6893 choose("Stop which forge?", (name) => void stopForge(session.app, name).then((done) => ctx.toast(done.says, done.ok ? "success" : "danger")));
94 else if (e.key === "l") changeOne("revoke");
95 else if (e.key === "u") changeOne("undo");
6996 else if (e.key === "x" && session !== undefined)
7097 choose("Remove which forge?", (name) =>
7198 ctx.push(
src/web/pages/claim.ts +5
@@ -87,6 +87,11 @@ function dead(ctx: Ctx, state: Exclude<ClaimState["state"], "ok">): Response {
8787 title: "This link has expired",
8888 text: "Claim links work for a day. Ask whoever sent it for a new one.",
8989 },
90 revoked: {
91 status: 410,
92 title: "This link has ended",
93 text: "Whoever sent it took it back. Ask them for a new one.",
94 },
9095 owned: {
9196 status: 410,
9297 title: "This forge already has an owner",
src/web/pages/forges.ts +44 −5
@@ -11,8 +11,9 @@ import { ATTENTION_WORDS, CLAIM_WORDS, claimState, clientRows, runningWords, spe
1111 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1212 import { notFoundPage, pageHead } from "../chrome.ts";
1313 import { html, type Html } from "../html.ts";
14 import { page } from "../layout.ts";
15 import { reissueDoor } from "../../spawn/door.ts";
14 import { page, redirect } from "../layout.ts";
15 import { undoable } from "../../spawn/claim.ts";
16 import { changeDoor, reissueDoor } from "../../spawn/door.ts";
1617 import { readForm } from "../form.ts";
1718 import { badge, btn, row, time } from "../ui.ts";
1819 import { noRoom } from "../../spawn/door.ts";
@@ -41,7 +42,7 @@ function spendNote(r: ClientRow): Html | null {
4142 : html`<span data-spend="ok">${s.words}</span>`;
4243 }
4344
44 function forgeRow(r: ClientRow): Html {
45 function forgeRow(r: ClientRow, now: number): Html {
4546 const state = claimState(r);
4647 const meta = [
4748 r.for === null ? null : html`for ${r.for}`,
@@ -86,7 +87,13 @@ function forgeRow(r: ClientRow): Html {
8687 ? html`<form method="post" action="/forges/${r.name}/claim-link">
8788 ${btn("Send a fresh link", { size: "sm", title: "A new claim link; the old one stops working" })}
8889 </form>`
89 : null}${r.made
90 : null}${r.link === null
91 ? null
92 : html`<span data-link="${r.link.state}">${badge(LINK_WORDS[r.link.state], { variant: "outline" as const })}</span>${r.link.state === "open"
93 ? html`<form method="post" action="/forges/${r.name}/revoke-link" class="inline">${btn("Revoke", { size: "sm", title: "End this link now; you can undo for a minute" })}</form>`
94 : undoable(r.link, now)
95 ? html`<form method="post" action="/forges/${r.name}/undo-link" class="inline">${btn("Undo", { size: "sm", title: "Bring the link back; possible for a minute after it ended" })}</form>`
96 : null}`}${r.made
9097 ? html`<form method="post" action="/forges/${r.name}/stop" class="inline">${btn("Stop", { size: "sm", title: "Put it to sleep now; its next visitor wakes it" })}</form>
9198 ${btn("Remove", { href: `/forges/${r.name}/remove`, size: "sm", title: "Asks first, then deletes this forge and its data for good" })}`
9299 : null}`,
@@ -149,6 +156,7 @@ export async function forgesPage(ctx: Ctx): Promise<Response> {
149156 sub: "Each forge you sent: who it is for and what runs there.",
150157 actions: action,
151158 })}
159 ${receipt(ctx, rows)}
152160 ${rows.length === 0
153161 ? html`<section class="settings-block">
154162 <h2>No forges sent yet</h2>
@@ -161,7 +169,7 @@ export async function forgesPage(ctx: Ctx): Promise<Response> {
161169 <section class="settings-block">
162170 <h2>Forges</h2>
163171 <ul class="list setting-list">
164 ${rows.map(forgeRow)}
172 ${rows.map((r) => forgeRow(r, Date.now()))}
165173 </ul>
166174 </section>`}
167175 <p class="mt"><a href="/fleet/graph">Fleet graph</a></p>
@@ -201,6 +209,37 @@ export async function forgeLinkSubmit(ctx: Ctx): Promise<Response> {
201209 return response;
202210 }
203211
212 const LINK_WORDS = { open: "Link open", used: "Link used", ended: "Link ended" } as const;
213
214 /** What `/forges?link=…&forge=…` says after a revoke or undo: fixed words, and only for a forge in the list. */
215 function receipt(ctx: Ctx, rows: readonly ClientRow[]): Html | null {
216 const name = ctx.url.searchParams.get("forge") ?? "";
217 if (!rows.some((r) => r.name === name)) return null;
218 const says = {
219 revoked: html`The link for <strong>${name}</strong> has ended. Undo is beside it for a minute.`,
220 restored: html`The link for <strong>${name}</strong> is open again.`,
221 refused: html`The link for <strong>${name}</strong> did not change: it was not open, or the minute is over.`,
222 }[ctx.url.searchParams.get("link") ?? ""];
223 return says === undefined ? null : html`<p role="status" class="muted" data-receipt>${says}</p>`;
224 }
225
226 // POST /forges/:name/revoke-link and /undo-link — take the link back, or bring it back for a minute; then the row, with a receipt.
227 function linkChange(act: "revoke" | "undo") {
228 return async (ctx: Ctx): Promise<Response> => {
229 const user = ctx.auth.user;
230 if (!user?.isAdmin) return notFoundPage(ctx);
231 const form = await readForm(ctx, "admin");
232 if (form instanceof Response) return form;
233 const name = ctx.params["name"] ?? "";
234 const door = await changeDoor(ctx, user, name, act);
235 if (door.status === 404) return notFoundPage(ctx);
236 const link = door.status >= 400 ? "refused" : act === "revoke" ? "revoked" : "restored";
237 return redirect(`/forges?link=${link}&forge=${encodeURIComponent(name)}`);
238 };
239 }
240 export const forgeLinkRevoke = linkChange("revoke");
241 export const forgeLinkUndo = linkChange("undo");
242
204243 // POST /forges/:name/stop and /remove — the owner's hands on a hosted forge.
205244 function manageSubmit(act: (app: Ctx["app"], name: string) => Promise<Managed>, title: string) {
206245 return async (ctx: Ctx): Promise<Response> => {
test/claim-revoke.test.ts added +243
@@ -0,0 +1,243 @@
1 /**
2 * A sender takes a child's link back (#10725): a revoked link answers a plain
3 * 410 and sets no session, a used link cannot be revoked, a cross-origin
4 * revoke is refused, and a revoke can be undone for a minute.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { childDirOf } from "../src/actions/spawn.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { CLAIM_TTL_MS, linkOf, REVOKE_UNDO_MS, restoreClaim, revokeClaim, spawnedOrg, undoable } from "../src/spawn/claim.ts";
17 import { changeLink } from "../src/spawn/manage.ts";
18 import { useChildFetch } from "../src/spawn/clients.ts";
19 import { firstBoot } from "../src/spawn/first-boot.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { passkeyFields } from "./helpers/passkey.ts";
22
23 quietLogs();
24
25 describe("revoking a forge link", () => {
26 let dir = "";
27 let parent: App;
28 let parentServers: Servers;
29 let alice: User;
30 const kids: Record<string, { app: App; servers: Servers; token: string; dir: string }> = {};
31
32 let rwToken = "";
33 let aliceCookie = "";
34 let bobCookie = "";
35 const state = (name: string) => linkOf(kids[name]!.app.db)?.state;
36 const orgOf = async (name: string) => (await spawnedOrg(kids[name]!.app))!.org.id;
37 /** The page's button, as a browser posts it. */
38 const post = (name: string, act: "revoke" | "undo", cookie = aliceCookie, origin = "https://forge.test") =>
39 fetch(`${parentServers.url}/forges/${name}/${act}-link`, {
40 method: "POST",
41 headers: { ...(cookie ? { cookie } : {}), origin },
42 redirect: "manual",
43 });
44 const revoke = async (name: string, act: "revoke" | "undo", origin = "https://forge.test") => ({
45 ok: (await post(name, act, aliceCookie, origin)).headers.get("location")?.includes(act === "revoke" ? "link=revoked" : "link=restored") === true,
46 });
47 const claim = (name: string) =>
48 fetch(`${kids[name]!.servers.url}/claim/options`, {
49 method: "POST",
50 headers: { origin: kids[name]!.servers.url, "content-type": "application/json" },
51 body: JSON.stringify({ token: kids[name]!.token, username: `${name}-owner`, email: `${name}@x.test` }),
52 });
53
54 beforeAll(async () => {
55 dir = await mkdtemp(join(tmpdir(), "forge-claim-revoke-"));
56 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
57 const made = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-claim-passphrase", isAdmin: true });
58 if (!made.ok) throw new Error("user");
59 alice = made.value;
60 aliceCookie = `forge_session=${createSession(parent.db, alice.id).token}`;
61 const bob = await createUser(parent.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-claim-passphrase" });
62 if (!bob.ok) throw new Error("bob");
63 bobCookie = `forge_session=${createSession(parent.db, bob.value.id).token}`;
64 const rw = createToken(parent.db, alice.id, "rw", "rw").token;
65 rwToken = rw;
66 parentServers = await serve(parent);
67 for (const name of ["one", "two"]) {
68 const res = await fetch(`${parentServers.url}/api/v1/forges`, {
69 method: "POST",
70 headers: { authorization: `Bearer ${rw}`, "content-type": "application/json" },
71 body: JSON.stringify({ name, apps: [], dryRun: false }),
72 });
73 if (res.status !== 201) throw new Error(`spawn ${res.status} ${await res.text()}`);
74 const body = (await res.json()) as { claimUrl: string; bodyId: string };
75 const path = childDirOf(parent, body.bodyId);
76 const app = createApp({ dataDir: path, port: 0 });
77 await firstBoot(app);
78 kids[name] = { app, servers: await serve(app), token: body.claimUrl.split("#")[1] ?? "", dir: path };
79 }
80 // The parent reaches each child at its own loopback port.
81 useChildFetch(parent, (input, init) => {
82 const at = new URL(String(input));
83 const kid = kids[at.hostname.split(/--|\./)[0] ?? ""];
84 return kid ? fetch(`${kid.servers.url}${at.pathname}`, init) : Promise.reject(new Error("no such child"));
85 });
86 }, 60_000);
87
88 afterAll(async () => {
89 for (const k of Object.values(kids)) {
90 k.servers.stop();
91 k.app.close();
92 }
93 parentServers.stop();
94 parent.close();
95 await rm(dir, { recursive: true, force: true });
96 });
97
98 test("revoke-then-use answers 410 in plain words and sets no session", async () => {
99 expect(state("one")).toBe("open");
100 const done = await revoke("one", "revoke");
101 expect(done.ok).toBe(true);
102 expect(state("one")).toBe("ended");
103 const res = await claim("one");
104 expect(res.status).toBe(410);
105 expect(res.headers.get("set-cookie")).toBeNull();
106 const form = { token: kids["one"]!.token, username: "x", email: "[email protected]" };
107 const post = await fetch(`${kids["one"]!.servers.url}/claim`, {
108 method: "POST",
109 headers: { origin: kids["one"]!.servers.url },
110 body: new URLSearchParams({ ...form, ...(await passkeyFields(kids["one"]!.servers.url, "/claim/options", form).catch(() => ({}))) }),
111 redirect: "manual",
112 });
113 expect(post.status).toBe(410);
114 expect(post.headers.get("set-cookie")).toBeNull();
115 expect(await post.text()).toContain("This link has ended");
116 });
117
118 test("a revoke is receipted, and undone within the minute", async () => {
119 const back = await revoke("one", "undo");
120 expect(back.ok).toBe(true);
121 expect(state("one")).toBe("open");
122 const kinds = parent.db.query("SELECT kind, payload FROM event WHERE kind LIKE 'forge.claim_re%'").all() as { kind: string; payload: string }[];
123 expect(kinds.map((k) => k.kind)).toEqual(["forge.claim_revoked", "forge.claim_restored"]);
124 expect(kinds.map((k) => k.payload).join()).not.toContain(kids["one"]!.token);
125 expect((await claim("one")).status).toBe(200);
126 });
127
128 test("a cross-origin revoke is refused and changes nothing", async () => {
129 const done = await revoke("two", "revoke", "https://evil.test");
130 expect(done.ok).toBe(false);
131 expect(state("two")).toBe("open");
132 });
133
134 test("use-then-revoke: a used link cannot be revoked", async () => {
135 const k = kids["two"]!;
136 const form = { token: k.token, username: "two-owner", email: "[email protected]" };
137 const res = await fetch(`${k.servers.url}/claim`, {
138 method: "POST",
139 headers: { origin: k.servers.url },
140 body: new URLSearchParams({ ...form, ...(await passkeyFields(k.servers.url, "/claim/options", form)) }),
141 redirect: "manual",
142 });
143 expect(res.status).toBe(303);
144 expect(state("two")).toBe("used");
145 const done = await revoke("two", "revoke");
146 expect(done.ok).toBe(false);
147 expect(state("two")).toBe("used");
148 });
149
150 test("an undo after a minute is refused, and an ended link past the minute offers no Undo", async () => {
151 const id = await orgOf("one");
152 const t = Date.now();
153 expect(revokeClaim(kids["one"]!.app.db, id, t)).toBe(true);
154 const info = linkOf(kids["one"]!.app.db, t)!;
155 expect(undoable(info, t + REVOKE_UNDO_MS)).toBe(true);
156 expect(undoable(info, t + REVOKE_UNDO_MS + 1_000)).toBe(false);
157 expect(restoreClaim(kids["one"]!.app.db, id, t + 61_000)).toBe(false);
158 expect(state("one")).toBe("ended");
159 expect(restoreClaim(kids["one"]!.app.db, id, t + 59_000)).toBe(true);
160 expect(state("one")).toBe("open");
161 });
162
163 test("an expired link cannot be revoked, nor brought back", async () => {
164 const id = await orgOf("one");
165 const later = Date.now() + 25 * 3_600_000;
166 expect(linkOf(kids["one"]!.app.db, later)?.state).toBe("ended");
167 expect(revokeClaim(kids["one"]!.app.db, id, later)).toBe(false);
168 // Revoked just before its day ends: the minute has not passed, the day has.
169 const edge = Date.now() + CLAIM_TTL_MS - 40_000;
170 expect(revokeClaim(kids["one"]!.app.db, id, edge)).toBe(true);
171 expect(restoreClaim(kids["one"]!.app.db, id, edge + 59_000)).toBe(false);
172 expect(restoreClaim(kids["one"]!.app.db, id)).toBe(true);
173 });
174
175 test("an undo of a link that was never revoked changes nothing", async () => {
176 expect(restoreClaim(kids["one"]!.app.db, await orgOf("one"))).toBe(false);
177 expect(state("one")).toBe("open");
178 });
179
180 test("the routes: a member and a guest get nothing, and another origin is refused", async () => {
181 for (const cookie of [bobCookie, ""]) {
182 for (const act of ["revoke", "undo"] as const) expect((await post("one", act, cookie)).status).toBe(404);
183 }
184 const crossed = await post("one", "revoke", aliceCookie, "https://evil.test");
185 expect(crossed.headers.get("location") ?? "").not.toContain("link=revoked");
186 expect(state("one")).toBe("open");
187 });
188
189 test("the page: a revoke goes back to the row with its receipt and Undo; an undo takes Undo away", async () => {
190 const res = await post("one", "revoke");
191 expect(res.status).toBe(303);
192 const where = res.headers.get("location")!;
193 expect(where).toBe("/forges?link=revoked&forge=one");
194 const list = await (await fetch(`${parentServers.url}${where}`, { headers: { cookie: aliceCookie } })).text();
195 expect(list).toContain("has ended. Undo is beside it");
196 expect(list).toContain('action="/forges/one/undo-link"');
197 expect(list).not.toContain('action="/forges/one/revoke-link"');
198 // Leaving the receipt behind, the row still offers Undo for the minute.
199 const plain = await (await fetch(`${parentServers.url}/forges`, { headers: { cookie: aliceCookie } })).text();
200 expect(plain).toContain('action="/forges/one/undo-link"');
201 expect(plain).not.toContain("data-receipt");
202 const back = await post("one", "undo");
203 expect(back.headers.get("location")).toBe("/forges?link=restored&forge=one");
204 const after = await (await fetch(`${parentServers.url}/forges`, { headers: { cookie: aliceCookie } })).text();
205 expect(after).not.toContain('action="/forges/one/undo-link"');
206 expect(after).toContain('action="/forges/one/revoke-link"');
207 });
208
209 test("a receipt names only a forge in the list, in fixed words", async () => {
210 const page = await (await fetch(`${parentServers.url}/forges?link=revoked&forge=<b>x</b>`, { headers: { cookie: aliceCookie } })).text();
211 expect(page).not.toContain("data-receipt");
212 expect(page).not.toContain("<b>x</b>");
213 });
214
215 test("the MCP tool ends and restores a link, and says what it did", async () => {
216 const call = async (action: string) => {
217 const res = await fetch(`${parentServers.url}/mcp`, {
218 method: "POST",
219 headers: { "content-type": "application/json", authorization: `Bearer ${rwToken}` },
220 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "claim_link", arguments: { name: "one", action } } }),
221 });
222 const r = ((await res.json()) as any).result;
223 return { error: r.isError === true, text: r.content[0].text as string };
224 };
225 const ended = await call("revoke");
226 expect(ended.error).toBe(false);
227 expect(ended.text).toContain("has ended");
228 expect(state("one")).toBe("ended");
229 expect((await call("revoke")).error).toBe(true);
230 expect((await call("undo")).error).toBe(false);
231 expect(state("one")).toBe("open");
232 });
233
234 test("the terminal's action ends and restores a link through the same code, and names a missing forge", async () => {
235 const ended = await changeLink(parent, alice.id, "one", "revoke");
236 expect(ended.ok).toBe(true);
237 expect(state("one")).toBe("ended");
238 expect(await changeLink(parent, alice.id, "one", "revoke")).toMatchObject({ ok: false });
239 expect((await changeLink(parent, alice.id, "one", "undo")).ok).toBe(true);
240 expect(state("one")).toBe("open");
241 expect(await changeLink(parent, alice.id, "nobody", "revoke")).toMatchObject({ ok: false, missing: true });
242 });
243 });
test/forges-attention.test.ts +1
@@ -21,6 +21,7 @@ const row = (name: string, o: Partial<ClientRow> = {}): Omit<ClientRow, "attenti
2121 lastActivityAt: ago(1),
2222 running: null,
2323 reach: null,
24 link: null,
2425 size: "small",
2526 use: { kind: "shared" },
2627 lastHeartbeatAt: null,
test/tui-forges.test.ts +28
@@ -26,6 +26,7 @@ const row = (over: Partial<ClientRow>): ClientRow => ({
2626 lastActivityAt: null,
2727 running: null,
2828 reach: null,
29 link: null,
2930 attention: null,
3031 spendTodayUsd: null,
3132 ceilingUsd: null,
@@ -57,6 +58,10 @@ describe("forges you sent in the terminal", () => {
5758 await settleApp(app);
5859 return app.screen.toText();
5960 },
61 async press(k: string) {
62 key(k);
63 await settleApp(app);
64 },
6065 async enter() {
6166 key("enter");
6267 await settleApp(app);
@@ -93,6 +98,29 @@ describe("forges you sent in the terminal", () => {
9398 h.stop();
9499 });
95100
101 test("the screen lists the keys for a link: end it, undo it; with none to change it says so", async () => {
102 const h = boot("alice");
103 await h.palette();
104 await h.enter();
105 expect(h.text()).toContain("end a link");
106 expect(h.text()).toContain("undo an ended link");
107 await h.press("l");
108 expect(h.text()).toContain("No open link to end");
109 await h.press("u");
110 expect(h.text()).toContain("No ended link to bring back");
111 h.stop();
112 });
113
114 test("a row says whether its link is open, ended, or can be undone; a used link says nothing", () => {
115 const line = (link: ClientRow["link"]) => forgeLines([row({ link })])[0]![1];
116 expect(line({ state: "open", revokedAt: null })).toContain("link open");
117 expect(line({ state: "ended", revokedAt: null })).toContain("link ended");
118 expect(line({ state: "ended", revokedAt: null })).not.toContain("undo");
119 expect(line({ state: "ended", revokedAt: new Date().toISOString() })).toContain("link ended, undo with u");
120 expect(line({ state: "used", revokedAt: null })).not.toContain("link");
121 expect(line(null)).not.toContain("link");
122 });
123
96124 test("a member and a guest are not offered it", async () => {
97125 for (const who of ["bob", null]) {
98126 const h = boot(who);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs