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

Merged opened this

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

Merged

torque/forge:torque/7323-the-terminal-s-deploys-screen-probes-a-r → main 4 commits fork 6 threads · 6 unresolved

  • Merged. torque/7323-the-terminal-s-deploys-screen-probes-a-r was merged into main as 9260e8f.

Review

Sign in to review this change.
5 files changed +434 −4
docs/APPS.md +4 −1
@@ -169,7 +169,10 @@ Under the box, four starter sentences are plain links (no script):
169169 (each with what it carries), `enter` for the log, `r` to refresh. A signed-in
170170 reader also sees `Production: <url>` and, when a pull's preview is up or
171171 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
outdated

Missing full stop: "a guest sees neither. A writer presses p…"

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`.
173176 - A **private** app answers only to someone the forge has vouched for
174177 (`canRead` on the repository, then a signed grant); a public one to anyone.
175178 - **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.
2121 an `rw` token acts; a reading tool says so (`readOnlyHint: true`), and a tool without it writes, by MCP's default.
2222 - **What you may not do on a page you may not do here.** A private app is
2323 "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.
2526
2627 ## Tools
2728
src/contract/probe.ts +5 −1
@@ -33,6 +33,8 @@ export interface Knock {
3333 readonly resource: string;
3434 readonly verb: Verb;
3535 readonly as: Identity;
36 /** The door asked, `METHOD path`, when one was; older rows have none. */
37 readonly door?: string;
3638 /** What the table says. */
3739 readonly cell: Cell["answer"];
3840 /** What the door said, or why it was not asked. */
@@ -134,7 +136,8 @@ export async function probe(input: ProbeInput): Promise<Probed | undefined> {
134136 continue;
135137 }
136138 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}`;
138141 const headers: Record<string, string> = {
139142 accept: "application/json",
140143 ...(door.method === "GET" ? {} : { "content-type": "application/json" }),
@@ -178,6 +181,7 @@ export async function probe(input: ProbeInput): Promise<Probed | undefined> {
178181 resource: cell.resource,
179182 verb: cell.verb,
180183 as,
184 door: `${door.method} ${path}`,
181185 cell: cell.answer,
182186 result,
183187 verdict,
src/tui/screens/deploys.ts +139 −1
@@ -15,6 +15,10 @@
1515 * `app_deploy`'s actions — and is told the answer in the page's words
1616 * (`env_words.ts`). A stopped production offers only `D`; a reader sees
1717 * 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.
1822 */
1923
2024 import {
@@ -31,7 +35,11 @@ import { envLabel, envRefusalText, redeployedText, stoppedText } from "../../act
3135 import { trendLine, visitorsLine } from "../../apps/vitals.ts";
3236 import { listRepoRuns } from "../../actions/runs.ts";
3337 import { carriedByRuns, carriesText } from "../../core/carried.ts";
38 import { appsRuntime } from "../../apps/runtime.ts";
39 import { getAppByRepo } from "../../core/apps.ts";
3440 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";
3543 import type { Run } from "../../core/runs.ts";
3644 import type { Ctx, Screen } from "../app/app.ts";
3745 import { binding } from "../app/keys.ts";
@@ -40,7 +48,7 @@ import { style } from "../term/style.ts";
4048 import { truncate } from "../term/width.ts";
4149 import { fill, len, split } from "../ui/layout.ts";
4250 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";
4452 import { RunList, openRunLog, runRows } from "./runlist.ts";
4553
4654 const LIMIT = 50;
@@ -111,6 +119,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
111119 };
112120
113121 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);
114124
115125 const confirm = (verb: "Redeploy" | "Stop", act: () => Promise<{ text: string; refused: boolean }>): void => {
116126 ctx.push(
@@ -156,6 +166,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
156166 binding(["r"], "refresh (the open pulls below the deploys: check, preview)"),
157167 ...(production.redeploy ? [binding(["D"], "redeploy production")] : []),
158168 ...(production.stop ? [binding(["S"], "stop production")] : []),
169 ...(mayProbe ? [binding(["p"], "probe the running app's doors")] : []),
159170 ];
160171 },
161172 load: async () => read(),
@@ -235,8 +246,135 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
235246 stop();
236247 return true;
237248 }
249 if (e.key === "p" && mayProbe) {
250 ctx.push(probeScreen(f, ctx, repo));
outdated

Add binding(["r"], "knock again") and handle it, or the only way to re-probe is to leave and re-enter.

251 return true;
252 }
238253 return false;
239254 },
240255 };
241256 return screen;
242257 }
258
outdated

If probeApp rejects (the app dies mid-knock), probing stays reading forever. Catch it and set a said state with a human sentence.

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 probe and the audit page do: probeApp → probe() → verdict down, which is recorded. If the app stops between this read and probeApp's own read, the screen says "No doors / The contract names no door to try", which is wrong. Remove this check, call probeApp, and branch on row.verdict (down / empty) the way auditProbeSubmit does.

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, knocks.length === 0 can mean either down or empty. Choose the empty-state title and body from probing.row.verdict, so a stopped app never reads as "The contract names no door to try."

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 probe for bob and has it for alice, and add the no-contract, not-an-app and stored-row cases.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs