Public: anyone can read this; the crew can change it.
The terminal's deploys screen probes a running app with `p`: every door tried as nobody, a user and an admin, read-only, as MCP `probe` does #7339
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7323-the-terminal-s-deploys-screen-probes-a-r → main 4 commits fork 6 threads · 6 unresolved
docs/APPS.md +4 −1
| @@ -169,7 +169,10 @@ Under the box, four starter sentences are plain links (no script): | ||
| 169 | 169 | (each with what it carries), `enter` for the log, `r` to refresh. A signed-in |
| 170 | 170 | reader also sees `Production: <url>` and, when a pull's preview is up or |
| 171 | 171 | asleep, `Newest preview: #N <title> <url>` — the pill's other side, same |
| 172 | gate; a guest sees neither. | |
| 172 | gate; a guest sees neither. A writer presses `p` there to probe the running app's | |
| 173 | doors as nobody, a user and an admin, read-only — one line per door with | |
| 174 | its status and `ok` or what was wrong, `r` to knock again; the result is | |
| 175 | kept and the mismatch issue opened or closed as for MCP `probe`. | |
| 173 | 176 | - A **private** app answers only to someone the forge has vouched for |
| 174 | 177 | (`canRead` on the repository, then a signed grant); a public one to anyone. |
| 175 | 178 | - **Every app has a terminal.** `ssh -p 2222 <name>@<forge>` runs the app's |
docs/MCP.md +2 −1
| @@ -21,7 +21,8 @@ for a tunnel and a forge that sleeps. | ||
| 21 | 21 | an `rw` token acts; a reading tool says so (`readOnlyHint: true`), and a tool without it writes, by MCP's default. |
| 22 | 22 | - **What you may not do on a page you may not do here.** A private app is |
| 23 | 23 | "no such app" to someone who cannot write its repository; stopping a run, and knocking |
| 24 | on an app with `probe`, are a writer's. | |
| 24 | on an app with `probe`, are a writer's; the terminal's deploys screen `p` | |
| 25 | is the same knock behind the same gate. | |
| 25 | 26 | |
| 26 | 27 | ## Tools |
| 27 | 28 |
src/contract/probe.ts +5 −1
| @@ -33,6 +33,8 @@ export interface Knock { | ||
| 33 | 33 | readonly resource: string; |
| 34 | 34 | readonly verb: Verb; |
| 35 | 35 | readonly as: Identity; |
| 36 | /** The door asked, `METHOD path`, when one was; older rows have none. */ | |
| 37 | readonly door?: string; | |
| 36 | 38 | /** What the table says. */ |
| 37 | 39 | readonly cell: Cell["answer"]; |
| 38 | 40 | /** What the door said, or why it was not asked. */ |
| @@ -134,7 +136,8 @@ export async function probe(input: ProbeInput): Promise<Probed | undefined> { | ||
| 134 | 136 | continue; |
| 135 | 137 | } |
| 136 | 138 | const door = DOOR[cell.verb]; |
| 137 | const url = `${base}${api}${door.row ? "/0" : ""}`; | |
| 139 | const path = `${api}${door.row ? "/0" : ""}`; | |
| 140 | const url = `${base}${path}`; | |
| 138 | 141 | const headers: Record<string, string> = { |
| 139 | 142 | accept: "application/json", |
| 140 | 143 | ...(door.method === "GET" ? {} : { "content-type": "application/json" }), |
| @@ -178,6 +181,7 @@ export async function probe(input: ProbeInput): Promise<Probed | undefined> { | ||
| 178 | 181 | resource: cell.resource, |
| 179 | 182 | verb: cell.verb, |
| 180 | 183 | as, |
| 184 | door: `${door.method} ${path}`, | |
| 181 | 185 | cell: cell.answer, |
| 182 | 186 | result, |
| 183 | 187 | verdict, |
src/tui/screens/deploys.ts +139 −1
| @@ -15,6 +15,10 @@ | ||
| 15 | 15 | * `app_deploy`'s actions — and is told the answer in the page's words |
| 16 | 16 | * (`env_words.ts`). A stopped production offers only `D`; a reader sees |
| 17 | 17 | * neither key. |
| 18 | * | |
| 19 | * A writer — whom the MCP tool `probe` lets knock — gets `p`: the same | |
| 20 | * `probeApp` against the running production app, every door tried as | |
| 21 | * nobody, a user and an admin, read-only, one line per door. | |
| 18 | 22 | */ |
| 19 | 23 | |
| 20 | 24 | import { |
| @@ -31,7 +35,11 @@ import { envLabel, envRefusalText, redeployedText, stoppedText } from "../../act | ||
| 31 | 35 | import { trendLine, visitorsLine } from "../../apps/vitals.ts"; |
| 32 | 36 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 33 | 37 | import { carriedByRuns, carriesText } from "../../core/carried.ts"; |
| 38 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 39 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 34 | 40 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| 41 | import type { Knock } from "../../contract/probe.ts"; | |
| 42 | import { probeApp, type ProbeRow } from "../../contract/probes.ts"; | |
| 35 | 43 | import type { Run } from "../../core/runs.ts"; |
| 36 | 44 | import type { Ctx, Screen } from "../app/app.ts"; |
| 37 | 45 | import { binding } from "../app/keys.ts"; |
| @@ -40,7 +48,7 @@ import { style } from "../term/style.ts"; | ||
| 40 | 48 | import { truncate } from "../term/width.ts"; |
| 41 | 49 | import { fill, len, split } from "../ui/layout.ts"; |
| 42 | 50 | import { emptyState } from "../ui/widgets.ts"; |
| 43 | import { confirmScreen, drawList, hitList, runAsync } from "./common.ts"; | |
| 51 | import { ListState, confirmScreen, drawList, hitList, runAsync, type ListRow } from "./common.ts"; | |
| 44 | 52 | import { RunList, openRunLog, runRows } from "./runlist.ts"; |
| 45 | 53 | |
| 46 | 54 | const LIMIT = 50; |
| @@ -111,6 +119,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 111 | 119 | }; |
| 112 | 120 | |
| 113 | 121 | const open = (run: Run): void => openRunLog(f, ctx, repo, run); |
| 122 | // The MCP tool's gate, read once: only a writer is offered the knock. | |
| 123 | const mayProbe = canWrite(f.db, repo, f.user); | |
| 114 | 124 | |
| 115 | 125 | const confirm = (verb: "Redeploy" | "Stop", act: () => Promise<{ text: string; refused: boolean }>): void => { |
| 116 | 126 | ctx.push( |
| @@ -156,6 +166,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 156 | 166 | binding(["r"], "refresh (the open pulls below the deploys: check, preview)"), |
| 157 | 167 | ...(production.redeploy ? [binding(["D"], "redeploy production")] : []), |
| 158 | 168 | ...(production.stop ? [binding(["S"], "stop production")] : []), |
| 169 | ...(mayProbe ? [binding(["p"], "probe the running app's doors")] : []), | |
| 159 | 170 | ]; |
| 160 | 171 | }, |
| 161 | 172 | load: async () => read(), |
| @@ -235,8 +246,135 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 235 | 246 | stop(); |
| 236 | 247 | return true; |
| 237 | 248 | } |
| 249 | if (e.key === "p" && mayProbe) { | |
| 250 | ctx.push(probeScreen(f, ctx, repo)); | |
outdated
Add |
||
| 251 | return true; | |
| 252 | } | |
| 238 | 253 | return false; |
| 239 | 254 | }, |
| 240 | 255 | }; |
| 241 | 256 | return screen; |
| 242 | 257 | } |
| 258 | ||
outdated
If probeApp rejects (the app dies mid-knock), probing stays |
||
| 259 | // ---- Probe: `p`, the MCP tool `probe` from the deploys screen ---- | |
| 260 | ||
| 261 | /** Control characters out of anything the app or the OS said. */ | |
| 262 | const clean = (text: string): string => | |
| 263 | text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ").trim(); | |
| 264 | ||
| 265 | /** `GET /api/notes/0 as anon · 401 · ok`, or what was wrong instead of `ok`. */ | |
| 266 | export function knockLine(k: Knock): string { | |
| 267 | const where = k.door ?? `${k.resource}.${k.verb}`; | |
| 268 | const r = k.result; | |
| 269 | const status = r.kind === "admitted" || r.kind === "refused" || r.kind === "no_door" ? String(r.status) : "—"; | |
| 270 | const said = | |
| 271 | k.verdict === "ok" | |
| 272 | ? "ok" | |
| 273 | : k.verdict === "mismatch" | |
| 274 | ? `wrong: the table says ${k.cell}, the door ${r.kind === "admitted" ? "let them in" : "refused"}` | |
| 275 | : r.kind === "unprobed" | |
| 276 | ? `not asked: ${r.why}` | |
| 277 | : r.kind === "no_door" | |
| 278 | ? "not asked: no such door" | |
| 279 | : r.kind === "error" | |
| 280 | ? `not asked: ${clean(r.message)}` | |
| 281 | : "not asked"; | |
| 282 | return `${where} as ${k.as} · ${status} · ${said}`; | |
| 283 | } | |
| 284 | ||
| 285 | const knockRow = (k: Knock): ListRow => ({ | |
| 286 | title: knockLine(k), | |
| 287 | tone: k.verdict === "ok" ? "success" : k.verdict === "mismatch" ? "danger" : "outline", | |
| 288 | }); | |
| 289 | ||
| 290 | type Probing = | |
| 291 | | { readonly kind: "reading" } | |
| 292 | /** Nothing was tried, and why, in words. */ | |
| 293 | | { readonly kind: "said"; readonly title: string; readonly body: string } | |
| 294 | | { readonly kind: "done"; readonly row: ProbeRow }; | |
| 295 | ||
| 296 | /** | |
| 297 | * Knock once, as the MCP tool does: same gate, same `probeApp`, so the row | |
| 298 | * is kept and the app's mismatch issue opened or closed the same way. An app | |
| 299 | * that is not running comes back `down` with no knock, and is said so. | |
| 300 | */ | |
| 301 | async function knock(f: Session, repo: Repo): Promise<Probing> { | |
| 302 | if (!canWrite(f.db, repo, f.user)) | |
| 303 | return { kind: "said", title: "Not yours to probe", body: "You need write access to knock." }; | |
| 304 | const record = getAppByRepo(f.db, repo.id); | |
| 305 | const runtime = appsRuntime(f.app); | |
| 306 | if (record === undefined || runtime === undefined) | |
| 307 | return { kind: "said", title: "Not an app here", body: "Nothing serves this repository here." }; | |
| 308 | // No status check of our own: `probeApp` keeps a `down` row for a stopped | |
| 309 | // app, as MCP `probe` and the audit page do, and reads the status once. | |
outdated
This early return means the terminal keeps no row for a stopped app, while MCP |
||
| 310 | const row = await probeApp(f.app, runtime, record); | |
| 311 | if (row === undefined) | |
| 312 | return { kind: "said", title: "No contract", body: "No sound contract.json to compare with." }; | |
| 313 | if (row.verdict === "down") | |
| 314 | return { | |
| 315 | kind: "said", | |
| 316 | title: "Not running", | |
| 317 | body: `No door was tried; it is ${runtime.envStatus(record, "production")?.state ?? "not deployed"}.`, | |
| 318 | }; | |
| 319 | if (row.knocks.length === 0) | |
| 320 | return { kind: "said", title: "No doors", body: "The contract names no door to try." }; | |
| 321 | return { kind: "done", row }; | |
| 322 | } | |
| 323 | ||
| 324 | function probeScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 325 | const state = new ListState(); | |
| 326 | let probing: Probing = { kind: "reading" }; | |
| 327 | const rows = (): ListRow[] => (probing.kind === "done" ? probing.row.knocks.map(knockRow) : []); | |
| 328 | // A probe that throws (the app died mid-knock) is said, never left on `Knocking…`. | |
| 329 | const run = async (): Promise<void> => { | |
| 330 | probing = { kind: "reading" }; | |
| 331 | ctx.invalidate(); | |
| 332 | try { | |
| 333 | probing = await knock(f, repo); | |
| 334 | } catch { | |
| 335 | probing = { kind: "said", title: "The probe failed", body: "The app stopped answering; press r to knock again." }; | |
| 336 | } | |
| 337 | ctx.invalidate(); | |
| 338 | }; | |
| 339 | return { | |
| 340 | title: "Probe", | |
| 341 | keys: [binding(["j", "k"], "move"), binding(["r"], "knock again"), binding(["esc"], "back")], | |
| 342 | load: async () => run(), | |
| 343 | render(area, s) { | |
| 344 | const c = ctx.theme.c; | |
| 345 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 346 | const summary = | |
| 347 | probing.kind === "done" | |
| 348 | ? (() => { | |
| 349 | const k = probing.row.knocks; | |
| 350 | const n = (v: Knock["verdict"]) => k.filter((x) => x.verdict === v).length; | |
| 351 | return ` · ${probing.row.commitOid.slice(0, 7)} · ${n("ok")} ok · ${n("mismatch")} wrong · ${n("unprobed")} not asked`; | |
| 352 | })() | |
| 353 | : ""; | |
| 354 | s.text( | |
| 355 | head!.x, | |
| 356 | head!.y, | |
| 357 | truncate(`${repo.ownerName}/${repo.name} · production doors as nobody, a user and an admin${summary}`, head!.w), | |
| 358 | style(c.mutedFg, c.bg), | |
outdated
With the check above removed, |
||
| 359 | head!.x + head!.w, | |
| 360 | ); | |
| 361 | if (probing.kind !== "done") { | |
| 362 | emptyState(s, body!, c, { | |
| 363 | title: probing.kind === "said" ? probing.title : "Knocking…", | |
| 364 | body: probing.kind === "said" ? probing.body : "Trying every door, read-only", | |
| 365 | }); | |
| 366 | return; | |
| 367 | } | |
| 368 | const list = rows(); | |
| 369 | drawList(s, body!, c, state, list); | |
| 370 | hitList(ctx, body!, state, list.length, {}); | |
| 371 | }, | |
| 372 | key(e) { | |
| 373 | if (e.key === "r") { | |
| 374 | if (probing.kind !== "reading") void ctx.track(run()); | |
| 375 | return true; | |
| 376 | } | |
| 377 | return state.key(e, rows().length); | |
| 378 | }, | |
| 379 | }; | |
| 380 | } |
test/tui-deploys-probe.test.ts added +284
| @@ -0,0 +1,284 @@ | ||
| 1 | /** | |
| 2 | * The deploys screen's `p`: the MCP tool `probe`'s knock, from the terminal. | |
| 3 | * A fake app that admits everyone stands behind a runtime that says it is | |
| 4 | * running; its table lets anyone list notes and only an admin create one, so | |
| 5 | * the anonymous list is `ok` and the anonymous create is wrong, and the row | |
| 6 | * the MCP tool keeps is the row stored. An app that is not running, has no | |
| 7 | * contract or is no app is said so in words; a reader, a stranger to a | |
| 8 | * private repository and a guest are not offered `p`, and pressing it tries | |
| 9 | * nothing. | |
| 10 | */ | |
| 11 | ||
| 12 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 13 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 14 | import { tmpdir } from "node:os"; | |
| 15 | import { join } from "node:path"; | |
| 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 17 | import { signUp } from "../src/actions/users.ts"; | |
| 18 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 19 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 20 | import { createApp as createAppRecord, type AppRecord } from "../src/core/apps.ts"; | |
| 21 | import type { Knock } from "../src/contract/probe.ts"; | |
| 22 | import { listProbes } from "../src/contract/probes.ts"; | |
| 23 | import type { Repo } from "../src/core/repos.ts"; | |
| 24 | import type { User } from "../src/core/users.ts"; | |
| 25 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 26 | import { deploysScreen, knockLine } from "../src/tui/screens/deploys.ts"; | |
| 27 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 28 | import { quietLogs } from "./helpers/globals.ts"; | |
| 29 | import { settleApp } from "./helpers/tui.ts"; | |
| 30 | import { fixtureSlot } from "./helpers/slot.ts"; | |
| 31 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 32 | ||
| 33 | quietLogs(); | |
| 34 | ||
| 35 | const CONTRACT = JSON.stringify({ | |
| 36 | roles: { | |
| 37 | anon: { above: [], held: { by: "everyone" } }, | |
| 38 | user: { above: ["anon"], held: { by: "identity", role: "user" } }, | |
| 39 | admin: { above: ["user"], held: { by: "identity", role: "admin" } }, | |
| 40 | }, | |
| 41 | resources: { | |
| 42 | note: { | |
| 43 | api: "/api/notes", | |
| 44 | can: { list: "anon", create: "admin", read: "admin", update: "admin", delete: "admin" }, | |
| 45 | }, | |
| 46 | }, | |
| 47 | }); | |
| 48 | ||
| 49 | let dir: string; | |
| 50 | let forge: Forge; | |
| 51 | let runtime: AppsRuntime; | |
| 52 | let alice: User; | |
| 53 | let bob: User; | |
| 54 | let carol: User; | |
| 55 | let repo: Repo; | |
| 56 | let bare: Repo; | |
| 57 | let plain: Repo; | |
| 58 | let vault: Repo; | |
| 59 | let record: AppRecord; | |
| 60 | let fake: ReturnType<typeof Bun.serve>; | |
| 61 | let knocks = 0; | |
| 62 | let running = true; | |
| 63 | /** When set, the probe's own status read throws: the app died mid-knock. */ | |
| 64 | let dies = false; | |
| 65 | ||
| 66 | const appOn = (r: Repo, slug: string): AppRecord => { | |
| 67 | const made = createAppRecord(forge.db, { repoId: r.id, slug, access: "public" }); | |
| 68 | if (!made.ok) throw new Error(made.error); | |
| 69 | return made.value; | |
| 70 | }; | |
| 71 | ||
| 72 | beforeAll(async () => { | |
| 73 | dir = await mkdtemp(join(tmpdir(), "forge-tui-deploys-probe-")); | |
| 74 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 75 | runtime = new AppsRuntime(forge, { domain: "apps.test", scheme: "https", forgeUrl: "https://forge.test", concurrency: 1 }); | |
| 76 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 77 | if (!a.ok) throw new Error("signup failed"); | |
| 78 | alice = a.value; | |
| 79 | const person = async (username: string): Promise<User> => { | |
| 80 | const made = await signUp( | |
| 81 | forge, | |
| 82 | { username, email: `${username}@forge.test`, password: "a-long-passphrase" }, | |
| 83 | { slot: fixtureSlot(forge) }, | |
| 84 | ); | |
| 85 | if (!made.ok) throw new Error("signup failed"); | |
| 86 | return made.value; | |
| 87 | }; | |
| 88 | bob = await person("bob"); | |
| 89 | carol = await person("carol"); | |
| 90 | const repoOf = async (name: string, visibility: "public" | "private"): Promise<Repo> => { | |
| 91 | const r = await createRepo(forge, alice, { name, visibility }); | |
| 92 | if (!r.ok) throw new Error("repo failed"); | |
| 93 | return r.value; | |
| 94 | }; | |
| 95 | repo = await repoOf("notes", "public"); | |
| 96 | bare = await repoOf("bare", "public"); | |
| 97 | plain = await repoOf("plain", "public"); | |
| 98 | vault = await repoOf("vault", "private"); | |
| 99 | for (const r of [repo, vault]) { | |
| 100 | const wrote = await commitToBranch(forge, alice, r, { | |
| 101 | message: "the table", | |
| 102 | files: [{ path: "contract.json", content: CONTRACT }], | |
| 103 | }); | |
| 104 | if (!wrote.ok) throw new Error(String(wrote.error)); | |
| 105 | } | |
| 106 | record = appOn(repo, "notes"); | |
| 107 | appOn(bare, "bare"); | |
| 108 | appOn(vault, "vault"); | |
| 109 | // Every door open: right for the list, wrong for an anonymous create. | |
| 110 | fake = Bun.serve({ | |
| 111 | port: 0, | |
| 112 | hostname: "127.0.0.1", | |
| 113 | fetch: () => { | |
| 114 | knocks++; | |
| 115 | return Response.json({ ok: true }); | |
| 116 | }, | |
| 117 | }); | |
| 118 | // The probe's own status read is the screen's only one before a knock. | |
| 119 | runtime.envStatus = ((_rec, env) => { | |
| 120 | if (env !== "production") return undefined; | |
| 121 | if (dies) throw new Error("the app died"); | |
| 122 | return running | |
| 123 | ? { state: "running", port: fake.port, commitOid: "c".repeat(40) } | |
| 124 | : { state: "stopped", commitOid: "c".repeat(40) }; | |
| 125 | }) as typeof runtime.envStatus; | |
| 126 | }); | |
| 127 | ||
| 128 | afterAll(async () => { | |
| 129 | fake?.stop(true); | |
| 130 | await runtime?.stop(); | |
| 131 | forge.close?.(); | |
outdated
This only asserts that nothing appears. Assert that the key bar lacks |
||
| 132 | await rm(dir, { recursive: true, force: true }); | |
| 133 | }); | |
| 134 | ||
| 135 | const key = (k: string) => ({ type: "key" as const, key: k, ctrl: false, alt: false, shift: false }); | |
| 136 | ||
| 137 | /** The deploys screen before `p`, then after each key pressed. */ | |
| 138 | const drive = async (who: User | null, of: Repo, keys: readonly string[] = ["p"]): Promise<string[]> => { | |
| 139 | const { app, session } = createForgeApp( | |
| 140 | forge, | |
| 141 | { sshUsername: who?.username ?? "guest", remoteAddress: "127.0.0.1", ...(who === null ? {} : { user: who }) }, | |
| 142 | { terminal: headlessTerminal(220, 50), exitHooks: false }, | |
| 143 | ); | |
| 144 | app.start(); | |
| 145 | await settleApp(app); | |
| 146 | app.push(deploysScreen(session, app.ctx, of)); | |
| 147 | await settleApp(app); | |
| 148 | const frames = [app.screen.toText()]; | |
| 149 | for (const k of keys) { | |
| 150 | app.dispatch(key(k)); | |
| 151 | await settleApp(app); | |
| 152 | frames.push(app.screen.toText()); | |
| 153 | } | |
| 154 | app.stop(); | |
| 155 | return frames; | |
| 156 | }; | |
| 157 | const pressP = async (who: User | null, of: Repo = repo): Promise<string> => (await drive(who, of)).at(-1)!; | |
| 158 | ||
| 159 | test("`p` knocks on every door and lists one ok and one wrong line", async () => { | |
| 160 | running = true; | |
| 161 | knocks = 0; | |
| 162 | const text = await pressP(alice); | |
| 163 | expect(text).toContain("GET /api/notes as anon · 200 · ok"); | |
| 164 | expect(text).toContain("POST /api/notes as anon · 200 · wrong: the table says no, the door let them in"); | |
| 165 | expect(text).toContain("POST /api/notes as admin · 200 · ok"); | |
| 166 | expect(knocks).toBeGreaterThan(0); | |
| 167 | }); | |
| 168 | ||
| 169 | test("the header counts the doors: five verbs as three identities, admin's all open", async () => { | |
| 170 | running = true; | |
| 171 | const text = await pressP(alice); | |
| 172 | // anon and user may only list (2 ok, 8 wrong); admin may all five (5 ok). | |
| 173 | expect(text).toContain("alice/notes · production doors as nobody, a user and an admin · ccccccc · 7 ok · 8 wrong · 0 not asked"); | |
| 174 | }); | |
| 175 | ||
| 176 | test("the knock is the MCP tool's: probeApp keeps the row it showed", async () => { | |
| 177 | running = true; | |
| 178 | const before = listProbes(forge.db, record.id).length; | |
| 179 | knocks = 0; | |
| 180 | await pressP(alice); | |
| 181 | const rows = listProbes(forge.db, record.id); | |
| 182 | expect(rows.length).toBe(before + 1); | |
| 183 | const row = rows[0]!; | |
| 184 | expect(row.verdict).toBe("mismatch"); | |
| 185 | expect(row.commitOid).toBe("c".repeat(40)); | |
| 186 | expect(row.knocks.length).toBe(15); | |
| 187 | expect(knocks).toBe(15); | |
| 188 | expect(row.knocks.find((k) => k.door === "POST /api/notes" && k.as === "anon")?.verdict).toBe("mismatch"); | |
| 189 | expect(row.said).toContain("note.create as anon: the table says no, the app said 200"); | |
| 190 | }); | |
| 191 | ||
| 192 | test("`r` on the probe knocks again and keeps another row", async () => { | |
| 193 | running = true; | |
| 194 | const before = listProbes(forge.db, record.id).length; | |
| 195 | const frames = await drive(alice, repo, ["p", "r"]); | |
| 196 | expect(frames.at(-1)).toContain("GET /api/notes as anon · 200 · ok"); | |
| 197 | expect(listProbes(forge.db, record.id).length).toBe(before + 2); | |
| 198 | }); | |
| 199 | ||
| 200 | test("an app that is not running says so, nothing is tried, and the `down` row is kept as MCP `probe` keeps it", async () => { | |
| 201 | running = false; | |
| 202 | knocks = 0; | |
| 203 | const before = listProbes(forge.db, record.id).length; | |
| 204 | const text = await pressP(alice); | |
| 205 | expect(text).toContain("Not running"); | |
| 206 | expect(text).toContain("No door was tried; it is stopped."); | |
| 207 | expect(text).not.toContain("GET /api/notes"); | |
| 208 | expect(text).not.toContain("No doors"); | |
| 209 | expect(knocks).toBe(0); | |
| 210 | const rows = listProbes(forge.db, record.id); | |
| 211 | expect(rows.length).toBe(before + 1); | |
| 212 | expect(rows[0]!.verdict).toBe("down"); | |
| 213 | expect(rows[0]!.knocks).toEqual([]); | |
| 214 | }); | |
| 215 | ||
| 216 | test("an app with no contract, and a repository that is no app, are said so", async () => { | |
| 217 | running = true; | |
| 218 | knocks = 0; | |
| 219 | const none = await pressP(alice, bare); | |
| 220 | expect(none).toContain("No contract"); | |
| 221 | expect(none).toContain("No sound contract.json to compare with."); | |
| 222 | const notApp = await pressP(alice, plain); | |
| 223 | expect(notApp).toContain("Not an app here"); | |
| 224 | expect(notApp).toContain("Nothing serves this repository here."); | |
| 225 | expect(knocks).toBe(0); | |
| 226 | }); | |
| 227 | ||
| 228 | test("a probe that throws is said, not left knocking", async () => { | |
| 229 | running = true; | |
| 230 | dies = true; | |
| 231 | try { | |
| 232 | const text = await pressP(alice); | |
| 233 | expect(text).toContain("The probe failed"); | |
| 234 | expect(text).not.toContain("Knocking"); | |
| 235 | } finally { | |
| 236 | dies = false; | |
| 237 | } | |
| 238 | }); | |
| 239 | ||
| 240 | test("the key bar offers `p` to a writer and not to a reader", async () => { | |
| 241 | running = true; | |
| 242 | const [writer] = await drive(alice, repo, []); | |
| 243 | expect(writer).toContain("probe the running app's doors"); | |
| 244 | const [reader] = await drive(bob, repo, []); | |
| 245 | expect(reader).not.toContain("probe the running app's doors"); | |
| 246 | }); | |
| 247 | ||
| 248 | test("a reader, a stranger to a private app and a guest press `p` and nothing is tried", async () => { | |
| 249 | running = true; | |
| 250 | knocks = 0; | |
| 251 | const before = listProbes(forge.db, record.id).length; | |
| 252 | for (const [who, of] of [ | |
| 253 | [bob, repo], | |
| 254 | [carol, vault], | |
| 255 | [null, repo], | |
| 256 | [null, vault], | |
| 257 | ] as const) { | |
| 258 | const [first, after] = await drive(who, of); | |
| 259 | expect(first).not.toContain("probe the running app's doors"); | |
| 260 | expect(after).not.toContain("› Probe"); | |
| 261 | expect(after).not.toContain("Knocking"); | |
| 262 | expect(after).not.toContain("/api/notes"); | |
| 263 | } | |
| 264 | expect(knocks).toBe(0); | |
| 265 | expect(listProbes(forge.db, record.id).length).toBe(before); | |
| 266 | }); | |
| 267 | ||
| 268 | test("knockLine: an old row's resource.verb, and each way a door goes unasked", () => { | |
| 269 | const base = { resource: "note", verb: "read", as: "user", cell: "yes" } as const; | |
| 270 | const line = (k: Omit<Knock, keyof typeof base> & Partial<Knock>) => knockLine({ ...base, ...k } as Knock); | |
| 271 | expect(line({ result: { kind: "admitted", status: 200 }, verdict: "ok" })).toBe("note.read as user · 200 · ok"); | |
| 272 | expect(line({ result: { kind: "unprobed", why: "needs a row that names the actor" }, verdict: "unprobed" })).toBe( | |
| 273 | "note.read as user · — · not asked: needs a row that names the actor", | |
| 274 | ); | |
| 275 | expect(line({ door: "GET /api/notes/0", result: { kind: "no_door", status: 404 }, verdict: "unprobed" })).toBe( | |
| 276 | "GET /api/notes/0 as user · 404 · not asked: no such door", | |
| 277 | ); | |
| 278 | expect( | |
| 279 | line({ door: "GET /api/notes/0", result: { kind: "error", message: "refused\u001b[31m\nconnect" }, verdict: "unprobed" }), | |
| 280 | ).toBe("GET /api/notes/0 as user · — · not asked: refused [31m connect"); | |
| 281 | expect( | |
| 282 | line({ door: "DELETE /api/notes/0", cell: "yes", result: { kind: "refused", status: 403 }, verdict: "mismatch" }), | |
| 283 | ).toBe("DELETE /api/notes/0 as user · 403 · wrong: the table says yes, the door refused"); | |
| 284 | }); |
Missing full stop: "a guest sees neither. A writer presses
p…"