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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10725-a-forge-link-says-when-it-ends-and-its-s → main 2 commits fork
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`; | ||
| 78 | 78 | | `branches` | `create`, `delete`, `branches`, `tags` | A repo's branches; `name`, `action` create (from `from`) or delete one, not the default | 407 | |
| 79 | 79 | | `builds` | — | A repo's build pins (`refs/builds/`): label, commit, pinned by, note | 231 | |
| 80 | 80 | | `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 | | |
| 82 | 82 | | `close` | — | Close or reopen (`state` open) an issue or pull | 463 | |
| 83 | 83 | | `cloud_account` | — | Change a cloud account by `verb` (admin) | 549 | |
| 84 | 84 | | `cloud_accounts` | — | Cloud accounts (admin): each token's use | 131 | |
docs/features/forge-link-revoke.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # End a forge link, and undo it | |
| 2 | ||
| 3 | A forge you sent carries a claim link that works once and for a day. Its sender (any site admin) can end it sooner, and bring it back for a minute. | |
| 4 | ||
| 5 | - **Page.** On `/forges`, a forge with an open link shows *Link open* and **Revoke**. After a revoke the page returns to the row with a one-line receipt, and the row shows **Undo** for the next minute, whether or not you stay on the page. Once the minute is over, or the link was used or ran out its day, there is no button. | |
| 6 | - **Terminal.** Open *Forges you sent* from the palette (admins only). `l` picks a forge whose link is open and ends it; `u` picks one whose link ended in the last minute and brings it back. Each row says `link open` or `link ended, undo with u`. | |
| 7 | - **MCP.** `claim_link` with `{ name, action: "revoke" }` or `{ name, action: "undo" }`. Without `action`, it still re-issues, and only with `reveal: true`. | |
| 8 | ||
| 9 | What the client sees: an ended link answers a plain 410 ("This link has ended") and sets no session. A used link cannot be revoked; an expired or used link cannot be undone; an undo after 60 s is refused. | |
| 10 | ||
| 11 | Every change is an admin's, same-origin, and writes `forge.claim_revoked` or `forge.claim_restored`. The link's secret is in no event. The state shown on `/forges` is what the child last said to the parent's probe (no extra database open on the list); a revoke or undo forgets it, so the next list asks again. |
src/contract/openapi/routes/getApiV1Forge.ts +1 −1
| @@ -1,3 +1,3 @@ | ||
| 1 | 1 | import type { Route } from "../shared.ts"; |
| 2 | 2 | |
| 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([ | ||
| 111 | 111 | "forge.removed", |
| 112 | 112 | "forge.stopped", |
| 113 | 113 | "forge.claim_reissued", |
| 114 | "forge.claim_revoked", | |
| 115 | "forge.claim_restored", | |
| 114 | 116 | "agent.created", |
| 115 | 117 | "agent.updated", |
| 116 | 118 | "agent.deleted", |
src/http/mcp/tools/claim_link.ts +9 −4
| @@ -1,21 +1,26 @@ | ||
| 1 | import { reissueDoor } from "../../../spawn/door.ts"; | |
| 1 | import { changeDoor, reissueDoor } from "../../../spawn/door.ts"; | |
| 2 | 2 | import { good, bad, type Tool, str, REVEAL_ARG, LINK_KEPT, LINK_REVEALED } from "../shared.ts"; |
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "claim_link", |
| 6 | doc: "`{ name, reveal? }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's, and only `reveal: true` makes it", | |
| 7 | description: | |
| 8 | "A new claim link for an unclaimed child forge, only with `reveal: true`; the old stops.", | |
| 6 | doc: "`{ name, action?, reveal? }`: `action` `revoke`/`undo` ends the link or restores it within 1 min; else a new claim link for an unclaimed child, only with `reveal: true`; the old stops. Admin", | |
| 7 | description: "A child's claim link: `reveal` a new one, or `action` revoke/undo.", | |
| 9 | 8 | inputSchema: { |
| 10 | 9 | type: "object", |
| 11 | 10 | properties: { |
| 12 | 11 | name: { type: "string" }, |
| 12 | action: { enum: ["revoke", "undo"] }, | |
| 13 | 13 | reveal: REVEAL_ARG, |
| 14 | 14 | }, |
| 15 | 15 | required: ["name"], |
| 16 | 16 | }, |
| 17 | 17 | writes: true, |
| 18 | 18 | async run(ctx, user, args) { |
| 19 | const action = args["action"]; | |
| 20 | if (action === "revoke" || action === "undo") { | |
| 21 | const done = await changeDoor(ctx, user, str(args["name"]), action); | |
| 22 | return done.status >= 400 ? bad(String(done.body["error"]), String(done.body["message"])) : good(done.body); | |
| 23 | } | |
| 19 | 24 | if (args["reveal"] !== true) |
| 20 | 25 | return good({ says: `Nothing changed. ${LINK_KEPT}`, reissued: false }); |
| 21 | 26 | const door = await reissueDoor(ctx, user, str(args["name"])); |
src/http/server.ts +3 −1
| @@ -260,7 +260,7 @@ import { | ||
| 260 | 260 | import { claimFragmentPage, claimOptions, claimSubmit } from "../web/pages/claim.ts"; |
| 261 | 261 | import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts"; |
| 262 | 262 | 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"; | |
| 264 | 264 | import { forgeItSubmit } from "../web/pages/forge-it.ts"; |
| 265 | 265 | import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts"; |
| 266 | 266 | import { askedPage } from "../web/studio/asked_page.ts"; |
| @@ -818,6 +818,8 @@ export function buildRouter(): Router<Handler> { | ||
| 818 | 818 | router.add("GET", "/forges", forgesPage); |
| 819 | 819 | router.add("GET", "/family", familyPage); |
| 820 | 820 | 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); | |
| 821 | 823 | router.add("POST", "/forges/:name/stop", forgeStopSubmit); |
| 822 | 824 | router.add("GET", "/forges/:name/remove", forgeRemoveConfirm); |
| 823 | 825 | router.add("POST", "/forges/:name/remove", forgeRemoveSubmit); |
src/spawn/claim.ts +70 −1
| @@ -49,11 +49,13 @@ interface Stored { | ||
| 49 | 49 | readonly e: string; |
| 50 | 50 | /** When it was used, ISO; null while it is unused. */ |
| 51 | 51 | readonly u: string | null; |
| 52 | /** When the spawner took it back, ISO; absent while it stands. */ | |
| 53 | readonly r?: string; | |
| 52 | 54 | } |
| 53 | 55 | |
| 54 | 56 | export type ClaimState = |
| 55 | 57 | | { readonly state: "ok"; readonly orgId: string } |
| 56 | | { readonly state: "used" | "expired" | "unknown" | "owned" }; | |
| 58 | | { readonly state: "used" | "expired" | "revoked" | "unknown" | "owned" }; | |
| 57 | 59 | |
| 58 | 60 | /** What a spawned child's `parent.json` says, when it has one. */ |
| 59 | 61 | export interface Spawned { |
| @@ -167,6 +169,7 @@ function judge( | ||
| 167 | 169 | const found = find(db, orgId, token); |
| 168 | 170 | if (found === undefined) return { state: { state: "unknown" } }; |
| 169 | 171 | if (found.stored.u !== null) return { state: { state: "used" } }; |
| 172 | if (found.stored.r !== undefined) return { state: { state: "revoked" } }; | |
| 170 | 173 | if (Date.parse(found.stored.e) <= now) return { state: { state: "expired" } }; |
| 171 | 174 | if (hasAdmin(db)) return { state: { state: "owned" } }; |
| 172 | 175 | return { |
| @@ -235,3 +238,69 @@ export function handOver(db: Database, spawned: Spawned, userId: string): void { | ||
| 235 | 238 | if (!spawned.keepAccess && spawner && spawner.id !== userId) |
| 236 | 239 | removeTeamMember(db, team, spawner.id); |
| 237 | 240 | } |
| 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"; | ||
| 15 | 15 | import { sha256 } from "../kernel/hash.ts"; |
| 16 | 16 | import type { ChildAppsSummary } from "./child-apps.ts"; |
| 17 | 17 | 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"; | |
| 19 | 19 | import { lastHeard, type Beat } from "./heartbeat.ts"; |
| 20 | 20 | import { reachOf, reachWords, type ChildReach } from "./reach.ts"; |
| 21 | 21 | import type { Size } from "./sizes.ts"; |
| @@ -25,6 +25,8 @@ import { forgeUse, type ForgeUse } from "./usage.ts"; | ||
| 25 | 25 | export interface ForgeState { |
| 26 | 26 | readonly claimed: boolean; |
| 27 | 27 | 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; | |
| 28 | 30 | } |
| 29 | 31 | |
| 30 | 32 | /** One row of the client list; `claimed` is null when the child did not answer. */ |
| @@ -44,6 +46,8 @@ export interface ClientRow { | ||
| 44 | 46 | readonly running: ChildAppsSummary | null; |
| 45 | 47 | /** What a visitor got from its public addresses when last asked (`spawn/reach.ts`); null before it was. */ |
| 46 | 48 | 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; | |
| 47 | 51 | /** Why this forge needs its owner now; null when it does not. */ |
| 48 | 52 | readonly attention: Attention | null; |
| 49 | 53 | /** 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( | ||
| 123 | 127 | )`, |
| 124 | 128 | ) |
| 125 | 129 | .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 }; | |
| 127 | 141 | } |
| 128 | 142 | |
| 129 | 143 | async function probe(app: App, child: Child): Promise<ForgeState | undefined> { |
| @@ -141,6 +155,7 @@ async function probe(app: App, child: Child): Promise<ForgeState | undefined> { | ||
| 141 | 155 | if (typeof body["claimed"] !== "boolean") return undefined; |
| 142 | 156 | const at = body["lastActivityAt"]; |
| 143 | 157 | return { |
| 158 | link: linkOfBody(body["link"]), | |
| 144 | 159 | claimed: body["claimed"], |
| 145 | 160 | lastActivityAt: |
| 146 | 161 | typeof at === "string" && !Number.isNaN(Date.parse(at)) ? at : null, |
| @@ -161,6 +176,11 @@ const heard = new WeakMap<App, Map<string, Heard>>(); | ||
| 161 | 176 | const FRESH_MS = 1500; |
| 162 | 177 | const asking = new WeakMap<App, Map<string, Promise<ForgeState | undefined>>>(); |
| 163 | 178 | |
| 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 | ||
| 164 | 184 | function ask(app: App, child: Child): Promise<ForgeState | undefined> { |
| 165 | 185 | let pending = asking.get(app); |
| 166 | 186 | if (!pending) asking.set(app, (pending = new Map())); |
| @@ -214,6 +234,7 @@ export async function clientRows(app: App): Promise<ClientRow[]> { | ||
| 214 | 234 | size: c.size, |
| 215 | 235 | use: forgeUse(c, childDirOf(app, c.bodyId)), |
| 216 | 236 | lastHeartbeatAt: beats[i] === undefined ? null : new Date(beats[i]!.receivedAt).toISOString(), |
| 237 | link: states[i]?.link ?? null, | |
| 217 | 238 | nextStep: nextStepOf(states[i]?.claimed ?? null, beats[i]?.beat), |
| 218 | 239 | })), |
| 219 | 240 | now, |
src/spawn/door.ts +12 −1
| @@ -16,7 +16,8 @@ import { canRead, getRepo } from "../core/repos.ts"; | ||
| 16 | 16 | import { kindOfToken, normalizeToken, parseApiBase } from "../core/subscriptions.ts"; |
| 17 | 17 | import type { User } from "../core/users.ts"; |
| 18 | 18 | 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"; | |
| 20 | 21 | import { takeDoor } from "../http/strangers.ts"; |
| 21 | 22 | import { |
| 22 | 23 | childPublicUrl, |
| @@ -347,3 +348,13 @@ export async function reissueDoor(ctx: Ctx, user: User, name: string): Promise<D | ||
| 347 | 348 | return { status: 404, body: { error: "not_found", message: `no child forge named ${name}` } }; |
| 348 | 349 | return spawnWords(made.error); |
| 349 | 350 | } |
| 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"; | ||
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | 10 | 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"; | |
| 11 | 14 | import { sleepChild } from "./host.ts"; |
| 12 | 15 | import { CHILD_NAME } from "./placement.ts"; |
| 13 | 16 | |
| @@ -35,3 +38,34 @@ export async function removeForge(app: App, name: string): Promise<Managed> { | ||
| 35 | 38 | app.bus.append({ kind: "forge.removed", payload: { name } }); |
| 36 | 39 | return { ok: true, says: `${name} is removed, with its data` }; |
| 37 | 40 | } |
| 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"; | ||
| 13 | 13 | import { drawFacts, runAsync } from "./common.ts"; |
| 14 | 14 | import type { PaletteEntry } from "./entries.ts"; |
| 15 | 15 | 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"; | |
| 17 | 18 | import { confirmScreen, pickerScreen } from "./common.ts"; |
| 18 | 19 | import type { Session } from "../session.ts"; |
| 19 | 20 | |
| @@ -22,12 +23,18 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] { | ||
| 22 | 23 | if (rows.length === 0) return [["", "You have not sent a forge yet"]]; |
| 23 | 24 | return rows.flatMap((r): [string, string][] => [[ |
| 24 | 25 | 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)}`] | |
| 26 | 27 | .filter((m): m is string => m !== null) |
| 27 | 28 | .join(" · "), |
| 28 | 29 | ], ...addressLines(r)]); |
| 29 | 30 | } |
| 30 | 31 | |
| 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 | ||
| 31 | 38 | /** Under a forge that is running, not reachable: each public address and what it answered (#7959), as /forges lists them. */ |
| 32 | 39 | function addressLines(r: ClientRow): [string, string][] { |
| 33 | 40 | if (r.reach === null || r.reach.ok) return []; |
| @@ -50,9 +57,27 @@ export function forgesScreen(ctx: Ctx, rows: readonly ClientRow[], session?: Ses | ||
| 50 | 57 | if (items.length === 0 || session === undefined) return; |
| 51 | 58 | ctx.push(pickerScreen(ctx, screen, { title, items, onPick: (ids) => { if (ids[0] !== undefined) act(ids[0]); } })); |
| 52 | 59 | }; |
| 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 | }; | |
| 53 | 78 | const screen: Screen = { |
| 54 | 79 | 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")], | |
| 56 | 81 | render(area, s) { |
| 57 | 82 | const c = ctx.theme.c; |
| 58 | 83 | 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 | ||
| 66 | 91 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 67 | 92 | else if (e.key === "s" && session !== undefined) |
| 68 | 93 | 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"); | |
| 69 | 96 | else if (e.key === "x" && session !== undefined) |
| 70 | 97 | choose("Remove which forge?", (name) => |
| 71 | 98 | ctx.push( |
src/web/pages/claim.ts +5
| @@ -87,6 +87,11 @@ function dead(ctx: Ctx, state: Exclude<ClaimState["state"], "ok">): Response { | ||
| 87 | 87 | title: "This link has expired", |
| 88 | 88 | text: "Claim links work for a day. Ask whoever sent it for a new one.", |
| 89 | 89 | }, |
| 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 | }, | |
| 90 | 95 | owned: { |
| 91 | 96 | status: 410, |
| 92 | 97 | 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 | ||
| 11 | 11 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 12 | 12 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 13 | 13 | 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"; | |
| 16 | 17 | import { readForm } from "../form.ts"; |
| 17 | 18 | import { badge, btn, row, time } from "../ui.ts"; |
| 18 | 19 | import { noRoom } from "../../spawn/door.ts"; |
| @@ -41,7 +42,7 @@ function spendNote(r: ClientRow): Html | null { | ||
| 41 | 42 | : html`<span data-spend="ok">${s.words}</span>`; |
| 42 | 43 | } |
| 43 | 44 | |
| 44 | function forgeRow(r: ClientRow): Html { | |
| 45 | function forgeRow(r: ClientRow, now: number): Html { | |
| 45 | 46 | const state = claimState(r); |
| 46 | 47 | const meta = [ |
| 47 | 48 | r.for === null ? null : html`for ${r.for}`, |
| @@ -86,7 +87,13 @@ function forgeRow(r: ClientRow): Html { | ||
| 86 | 87 | ? html`<form method="post" action="/forges/${r.name}/claim-link"> |
| 87 | 88 | ${btn("Send a fresh link", { size: "sm", title: "A new claim link; the old one stops working" })} |
| 88 | 89 | </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 | |
| 90 | 97 | ? 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> |
| 91 | 98 | ${btn("Remove", { href: `/forges/${r.name}/remove`, size: "sm", title: "Asks first, then deletes this forge and its data for good" })}` |
| 92 | 99 | : null}`, |
| @@ -149,6 +156,7 @@ export async function forgesPage(ctx: Ctx): Promise<Response> { | ||
| 149 | 156 | sub: "Each forge you sent: who it is for and what runs there.", |
| 150 | 157 | actions: action, |
| 151 | 158 | })} |
| 159 | ${receipt(ctx, rows)} | |
| 152 | 160 | ${rows.length === 0 |
| 153 | 161 | ? html`<section class="settings-block"> |
| 154 | 162 | <h2>No forges sent yet</h2> |
| @@ -161,7 +169,7 @@ export async function forgesPage(ctx: Ctx): Promise<Response> { | ||
| 161 | 169 | <section class="settings-block"> |
| 162 | 170 | <h2>Forges</h2> |
| 163 | 171 | <ul class="list setting-list"> |
| 164 | ${rows.map(forgeRow)} | |
| 172 | ${rows.map((r) => forgeRow(r, Date.now()))} | |
| 165 | 173 | </ul> |
| 166 | 174 | </section>`} |
| 167 | 175 | <p class="mt"><a href="/fleet/graph">Fleet graph</a></p> |
| @@ -201,6 +209,37 @@ export async function forgeLinkSubmit(ctx: Ctx): Promise<Response> { | ||
| 201 | 209 | return response; |
| 202 | 210 | } |
| 203 | 211 | |
| 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 | ||
| 204 | 243 | // POST /forges/:name/stop and /remove — the owner's hands on a hosted forge. |
| 205 | 244 | function manageSubmit(act: (app: Ctx["app"], name: string) => Promise<Managed>, title: string) { |
| 206 | 245 | 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 | ||
| 21 | 21 | lastActivityAt: ago(1), |
| 22 | 22 | running: null, |
| 23 | 23 | reach: null, |
| 24 | link: null, | |
| 24 | 25 | size: "small", |
| 25 | 26 | use: { kind: "shared" }, |
| 26 | 27 | lastHeartbeatAt: null, |
test/tui-forges.test.ts +28
| @@ -26,6 +26,7 @@ const row = (over: Partial<ClientRow>): ClientRow => ({ | ||
| 26 | 26 | lastActivityAt: null, |
| 27 | 27 | running: null, |
| 28 | 28 | reach: null, |
| 29 | link: null, | |
| 29 | 30 | attention: null, |
| 30 | 31 | spendTodayUsd: null, |
| 31 | 32 | ceilingUsd: null, |
| @@ -57,6 +58,10 @@ describe("forges you sent in the terminal", () => { | ||
| 57 | 58 | await settleApp(app); |
| 58 | 59 | return app.screen.toText(); |
| 59 | 60 | }, |
| 61 | async press(k: string) { | |
| 62 | key(k); | |
| 63 | await settleApp(app); | |
| 64 | }, | |
| 60 | 65 | async enter() { |
| 61 | 66 | key("enter"); |
| 62 | 67 | await settleApp(app); |
| @@ -93,6 +98,29 @@ describe("forges you sent in the terminal", () => { | ||
| 93 | 98 | h.stop(); |
| 94 | 99 | }); |
| 95 | 100 | |
| 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 | ||
| 96 | 124 | test("a member and a guest are not offered it", async () => { |
| 97 | 125 | for (const who of ["bob", null]) { |
| 98 | 126 | const h = boot(who); |