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

Each backup lane says what it backs up and who reads it after spin-down, and a lane without that line says so on the backup tile #9078

Merged opened this

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

Merged

mason/forge:mason/9067-each-backup-lane-says-what-it-backs-up-a → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/9067-each-backup-lane-says-what-it-backs-up-a was merged into main as 4916469.

Review

Sign in to review this change.
7 files changed +262 −27
docs/design/areas/backups.md +2
@@ -1,3 +1,5 @@
11 # Backups
22
33 - `.restore-command` — the one-line restore command on /settings/backups: a `<pre>` that wraps, scrolls with the keyboard (`tabindex="0"`) and takes its height from its text, so a one-liner is not a log pane.
4
5 - The lanes card on /settings/backups adds no class: each lane is a `row()` in a `.setting-list`, its lead glyph (✓, !, –) coloured by the row's state (green is `open`, red `closed`, retired `draft`), a retired lane also carrying an outline `badge`, so no state is colour alone.
docs/features/backup-truth.md +15 −15
@@ -17,7 +17,9 @@ the forge's configuration, never in this repository:
1717
1818 ```json
1919 [{ "name": "backup/postgres", "endpoint": "https://s3.example.com", "bucket": "platform-backups",
20 "prefix": "postgres/", "region": "us-east-1", "accessKey": "…", "secretKey": "…" }]
20 "prefix": "postgres/", "region": "us-east-1", "accessKey": "…", "secretKey": "…",
21 "backsUp": "the platform's Postgres dump, nightly",
22 "afterSpinDown": "ops reads it from the bucket with the restore runbook" }]
2123 ```
2224
2325 - **List-only keys.** `src/snapshot/lanes.ts` only lists; give each lane keys
@@ -36,6 +38,12 @@ the forge's configuration, never in this repository:
3638 - **Nothing unsafe printed.** Errors are scrubbed of keys, and every key or
3739 error the store wrote has its control bytes replaced with `?` before it
3840 reaches a terminal or an MCP client.
41 - **A line per lane (#9067).** `backsUp` (one sentence) and `afterSpinDown` (who
42 reads it once the platform is gone, or `retired`) are optional strings of at
43 most 200 characters, control bytes replaced. A lane missing either shows
44 `No line yet: say what it backs up and who reads it after spin-down` beside
45 its newest object: information, never red. A lane marked `retired` is listed,
46 never read, and never turns the tile red.
3947 - **Where to see it.** MCP `backups` → `verdict.lanes`; the terminal's
4048 Resilience dialog; home's backup tile reads the same verdict.
4149
@@ -103,20 +111,12 @@ Owner's answer: _pending_. Until it is written, the dump stays where it is.
103111
104112 ### A line per lane
105113
106 The lane list lives in the forge's configuration, never in the repository, so
107 the names are filled in from the Resilience dialog (`verdict.lanes` in the MCP
108 `backups` tool), one row each. Every row says what it backs up and one of:
109 **read after spin-down** (who, how) or **retired** (on what date, by whom).
110
111 | Lane | Backs up | After spin-down |
112 | --- | --- | --- |
113 | `primary` | the forge's snapshot, platform bucket | the app's bucket; read by the in-process check |
114 | `mirror` | the same snapshot, B2 | B2; read by the in-process check and `scripts/backup-probe.ts` |
115 | each `backup/<name>` | _fill in from the dialog_ | _read after spin-down, or retired_ |
116
117 A lane with no reader after spin-down is retired, and removed from
118 `FORGE_BACKUP_LANES` in the same step, so the tile does not go red for a lane
119 nobody keeps.
114 Each lane's `backsUp` and `afterSpinDown` now live beside it in
115 `FORGE_BACKUP_LANES` (the forge's own `primary` and `mirror` say theirs in
116 `src/snapshot/bucket.ts`) and show next to its newest object in MCP `backups` →
117 `verdict.lanes`, the Resilience dialog and `/settings/backups`. A lane with no
118 reader after spin-down is marked `retired` there, so the tile does not go red
119 for a lane nobody keeps.
120120
121121 ### Restore drill with the platform unreachable
122122
src/snapshot/bucket.ts +39 −4
@@ -41,11 +41,38 @@ export interface LaneRead {
4141 readonly error?: string;
4242 /** When this read ended. */
4343 readonly readAt: string;
44 /** What the lane backs up, one sentence, from the configuration (#9067). */
45 readonly backsUp?: string;
46 /** Who reads the lane once the platform is gone, or `retired`. */
47 readonly afterSpinDown?: string;
4448 }
4549
46 export interface LaneVerdict extends LaneRead {
47 readonly state: "green" | "red";
50 /** `retired` in any case: the lane is listed, never read, never red. */
51 export const isRetired = (afterSpinDown: string | undefined): boolean => afterSpinDown?.trim().toLowerCase() === "retired";
52
53 /** The forge's own lanes say their line here; the platform's say it in `FORGE_BACKUP_LANES`. */
54 const OWN_LINES: Record<string, { backsUp: string; afterSpinDown: string }> = {
55 primary: { backsUp: "the forge's snapshot, platform bucket", afterSpinDown: "the app's bucket; read by the in-process check" },
56 mirror: { backsUp: "the same snapshot, B2", afterSpinDown: "B2; read by the in-process check and scripts/backup-probe.ts" },
57 };
58
59 /** Shown on a lane that has no line: information, never red. */
60 export const NO_LINE = "No line yet: say what it backs up and who reads it after spin-down";
61
62 /** The two values beside the newest object: each as given, the missing one named; `NO_LINE` when neither is. */
63 export function laneLine(backsUp: string | null, afterSpinDown: string | null): string {
64 if (backsUp === null && afterSpinDown === null) return NO_LINE;
65 return `backs up ${backsUp === null ? "(not said)" : printable(backsUp)}; after spin-down: ${afterSpinDown === null ? "(not said)" : printable(afterSpinDown)}`;
66 }
67
68 export interface LaneVerdict extends Omit<LaneRead, "backsUp" | "afterSpinDown"> {
69 readonly state: "green" | "red" | "retired";
4870 readonly says: string;
71 /** Both values, or null where the lane has none. */
72 readonly backsUp: string | null;
73 readonly afterSpinDown: string | null;
74 /** What to show beside the newest object: the two values (a missing one named), or `NO_LINE`. */
75 readonly line: string;
4976 }
5077
5178 export interface BackupVerdict {
@@ -216,6 +243,14 @@ export function judge(
216243 // Every word a store or the configuration supplied is made printable before it joins the line.
outdated

A lane with only one value set shows NO_LINE and hides what it did say. Show the given value and name which one is missing.

217244 const lane = printable(r.lane);
218245 const where = printable(r.where);
246 const own = OWN_LINES[r.lane];
247 const backsUp = r.backsUp ?? own?.backsUp ?? null;
248 const afterSpinDown = r.afterSpinDown ?? own?.afterSpinDown ?? null;
249 const line = laneLine(backsUp, afterSpinDown);
250 if (isRetired(afterSpinDown ?? undefined)) {
251 lanes.push({ ...r, backsUp, afterSpinDown, state: "retired", says: `${lane} retired${backsUp === null ? "" : `: ${printable(backsUp)}`}`, line });
252 continue;
253 }
219254 if (r.error !== undefined) says = `${lane} (${where}) cannot be read: ${printable(r.error)}`;
220255 else if (r.newestAt === null) says = `${lane} (${where}) holds no snapshot`;
221256 else {
@@ -232,9 +267,9 @@ export function judge(
232267 causes.push(r.error !== undefined ? "unreadable" : r.newestAt === null ? "empty" : "late");
233268 reasons.push(`${says[0]!.toUpperCase()}${says.slice(1)}.`);
234269 }
235 lanes.push({ ...r, state: red ? "red" : "green", says });
270 lanes.push({ ...r, backsUp, afterSpinDown, state: red ? "red" : "green", says, line });
236271 }
237 if (state !== undefined && state.lanes.length === 0) {
272 if (state !== undefined && !lanes.some((l) => l.state !== "retired")) {
238273 causes.push("no destination");
239274 reasons.push("No place is given for snapshots; none is being taken.");
240275 }
src/snapshot/lanes.ts +47 −5
@@ -24,7 +24,7 @@
2424 * error is one line with the lane's keys struck out (`scrubError`).
2525 */
2626
27 import { LATE_MS, printable, scrubError, type LaneRead } from "./bucket.ts";
27 import { LATE_MS, isRetired, printable, scrubError, type LaneRead } from "./bucket.ts";
2828 import { isOffBox } from "./off_box.ts";
2929 import { s3Where, type S3Target } from "./store.ts";
3030
@@ -161,10 +161,27 @@ export function s3Lister(target: S3Target): Lister {
161161 const NAME = /^[A-Za-z0-9][A-Za-z0-9 ._:/-]{0,63}$/;
162162 const BUCKET = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
163163
164 /** The longest `backsUp` or `afterSpinDown` a lane may carry. */
165 export const LINE_MAX = 200;
166
167 /** What a lane says of itself: what it backs up, and who reads it once the platform is gone (or `retired`). */
168 export interface LaneLine {
169 readonly backsUp?: string | undefined;
170 readonly afterSpinDown?: string | undefined;
171 }
172
173 /** One optional sentence: a string of at most `LINE_MAX`, control bytes replaced; `false` when it is not one. */
174 function sentence(v: unknown): string | undefined | false {
175 if (v === undefined || v === null) return undefined;
176 if (typeof v !== "string" || v.length > LINE_MAX) return false;
177 const t = printable(v).trim();
178 return t === "" ? undefined : t;
179 }
180
164181 const text = (v: unknown): string | undefined => (typeof v === "string" && v.trim() !== "" ? v.trim() : undefined);
165182
166183 /** One declared lane, or why it is refused; the sentence never carries a value but the name. */
167 function declared(raw: unknown, i: number): { name: string; target: S3Target; prefix: string } | { name: string; refused: string } {
184 function declared(raw: unknown, i: number): { name: string; target: S3Target; prefix: string; line: LaneLine } | { name: string; refused: string } {
168185 const o = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
169186 const given = text(o["name"]);
170187 const name = given !== undefined && NAME.test(given) ? given : `lane ${i + 1}`;
@@ -190,11 +207,20 @@ function declared(raw: unknown, i: number): { name: string; target: S3Target; pr
190207 const region = o["region"] === undefined ? undefined : text(o["region"]);
191208 if (o["region"] !== undefined && (region === undefined || !/^[A-Za-z0-9-]{1,32}$/.test(region)))
192209 return { name, refused: "its region is not a region name" };
193 return { name, prefix, target: { endpoint: url.href.replace(/\/$/, ""), bucket, accessKeyId, secretAccessKey, region } };
210 const backsUp = sentence(o["backsUp"]);
211 const afterSpinDown = sentence(o["afterSpinDown"]);
212 if (backsUp === false || afterSpinDown === false)
213 return { name, refused: `its backsUp or afterSpinDown is not a string of at most ${LINE_MAX} characters` };
214 return {
215 name,
216 prefix,
217 line: { backsUp, afterSpinDown },
218 target: { endpoint: url.href.replace(/\/$/, ""), bucket, accessKeyId, secretAccessKey, region },
219 };
194220 }
195221
196222 export interface Declared {
197 readonly lanes: readonly { name: string; target: S3Target; prefix: string }[];
223 readonly lanes: readonly { name: string; target: S3Target; prefix: string; line: LaneLine }[];
198224 /** Lanes the configuration names but that do not read whole: each a red lane on the screen. */
199225 readonly refused: readonly { name: string; says: string }[];
200226 }
@@ -239,7 +265,23 @@ export async function readDeclaredLanes(
239265 ): Promise<LaneRead[]> {
240266 const { lanes, refused } = parseLanes(env["FORGE_BACKUP_LANES"]);
241267 const read = await Promise.all(
242 lanes.map((d) => readObjectLane({ name: d.name, prefix: d.prefix, lister: listerFor(d.target), target: d.target }, now)),
268 lanes.map(async (d): Promise<LaneRead> => {
269 const line = { ...(d.line.backsUp === undefined ? {} : { backsUp: d.line.backsUp }), ...(d.line.afterSpinDown === undefined ? {} : { afterSpinDown: d.line.afterSpinDown }) };
270 // A retired lane is listed, never read: its store may be gone.
271 if (isRetired(d.line.afterSpinDown))
272 return {
273 lane: d.name,
274 where: s3Where(d.target),
275 offBox: false,
276 newestAt: null,
277 newestName: null,
278 newestBytes: null,
279 count: 0,
280 readAt: new Date(now).toISOString(),
281 ...line,
282 };
283 return { ...(await readObjectLane({ name: d.name, prefix: d.prefix, lister: listerFor(d.target), target: d.target }, now)), ...line };
284 }),
243285 );
244286 return [
245287 ...read,
src/tui/screens/resilience.ts +1 −1
@@ -108,7 +108,7 @@ export function verdictLines(f: Session): string[] {
108108 return [
109109 ...wrap(`Backups ${mark}: ${v.line}`, INNER),
110110 ...wrap(` ${v.checkLine}`, INNER),
111 ...v.lanes.flatMap((l) => wrap(` ${l.state === "red" ? "!" : " "} ${l.says}`, INNER)),
111 ...v.lanes.flatMap((l) => wrap(` ${l.state === "red" ? "!" : " "} ${l.says}`, INNER).concat(wrap(` ${l.line}`, INNER))),

New terminal line, no test. Add one asserting verdictLines carries the line for a lane.

112112 ...[...v.reasons, ...v.unknowns].flatMap((r) => wrap(` - ${r}`, INNER)),
113113 ];
114114 }
src/web/pages/backups.ts +30 −1
@@ -26,6 +26,7 @@ import { resolveDestination, sealedSet } from "../../snapshot/destination.ts";
2626 import { keyOf } from "../../snapshot/snapshot.ts";
2727 import { formatMinutes, formatSpan, timeByPart, type DrillResult } from "../../snapshot/drill.ts";
2828 import { lastDrill } from "../../snapshot/drills.ts";
29 import { backupVerdict } from "../../snapshot/bucket.ts";
2930 import type { Store } from "../../snapshot/store.ts";
3031 import { snapshotsOf, type SnapshotsHandle } from "../../snapshot/service.ts";
3132 import { drillNow, mirrorNow, snapshotNow } from "../../actions/backups_now.ts";
@@ -338,7 +339,7 @@ async function movingPart(
338339 const restore = snapshots[0]
339340 ? html`<pre class="restore-command" tabindex="0" aria-label="Restore command">bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
340341 : null;
341 return html`<div id="backups-live" data-live="backup.*" data-live-src="/settings/backups/live">${recovery}${card(
342 return html`<div id="backups-live" data-live="backup.*" data-live-src="/settings/backups/live">${recovery}${lanesCard(ctx)}${card(
342343 html`<p>
343344 Every night at 08:00 UTC the forge writes what cannot be rebuilt — its
344345 database, its keys, every repository, every app's production data, the
@@ -635,6 +636,34 @@ export async function backupsNow(ctx: Ctx): Promise<Response> {
635636 );
636637 }
637638
639 /**
640 * Each lane beside its newest object and its line (#9067); a lane without a
641 * line says so, as information. Nothing when no lane has been read. The
642 * verdict is the in-memory read the tile uses: no disk read on the live path.
643 */
outdated

Hand-rolled list markup instead of the named list row; and the retired state has no styling. Use row()/.setting-list per DESIGN.md §5 and add a test rendering this card.

644 export function lanesCard(ctx: Ctx): Html | "" {
645 const lanes = backupVerdict(ctx.app.config.dataDir, lastDrill(ctx.app.db)).lanes;
646 if (lanes.length === 0) return "";
647 // The glyph is the mark that is not colour alone: ok, !, and a dash for a lane that is retired.
648 const mark = { green: "✓", red: "!", retired: "–" } as const;
649 const lead = { green: "open", red: "closed", retired: "draft" } as const;
650 return card(
651 html`<ul id="backup-lanes" class="list setting-list">
652 ${lanes.map((l) =>
653 row({
654 lead: html`<span aria-hidden="true">${mark[l.state]}</span>`,
655 state: lead[l.state],
656 title: l.says,
657 meta: html`${l.line}`,
658 ...(l.state === "retired" ? { trail: badge("retired", { variant: "outline" }) } : {}),
659 data: { lane: l.lane, "lane-state": l.state },
660 }),
661 )}
662 </ul>`,
663 { head: html`<strong>Lanes</strong>` },
664 );
665 }
666
638667 /** POST /settings/backups/drill — restore the newest snapshot, keep what came of it. */
639668 export async function backupsDrill(ctx: Ctx): Promise<Response> {
640669 if (!ctx.auth.user) return signInFirst(ctx);
test/backup-lanes.test.ts +128 −1
@@ -25,6 +25,7 @@ import {
2525 type LanePage,
2626 type Lister,
2727 } from "../src/snapshot/lanes.ts";
28 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
2829 import { dirStore } from "../src/snapshot/store.ts";
2930 import { verdictLines } from "../src/tui/screens/resilience.ts";
3031
@@ -193,7 +194,7 @@ describe("configuration", () => {
193194 const got = parseLanes(JSON.stringify([lane]));
194195 expect(got.refused).toEqual([]);
195196 expect(got.lanes).toEqual([
196 { name: "backup/postgres", prefix: "pg/", target: { endpoint: "https://s3.test", bucket: "backups", accessKeyId: ACCESS, secretAccessKey: SECRET, region: undefined } },
197 { name: "backup/postgres", prefix: "pg/", line: { backsUp: undefined, afterSpinDown: undefined }, target: { endpoint: "https://s3.test", bucket: "backups", accessKeyId: ACCESS, secretAccessKey: SECRET, region: undefined } },
197198 ]);
198199 });
199200
@@ -367,3 +368,129 @@ describe("the S3 lister asks for one delimited page", () => {
367368 });
368369 });
369370
371 describe("a line per lane (#9067)", () => {
372 const lane = { name: "backup/postgres", endpoint: "https://s3.test", bucket: "backups", prefix: "pg/", accessKey: ACCESS, secretKey: SECRET };
373 const fresh = { "pg/2026-11-02T10:00:00Z/dump": { bytes: 10, at: NOW - H } };
374 const env = (lanes: object[]) => ({ FORGE_BACKUP_LANES: JSON.stringify(lanes) });
375 const verdictOf = async (lanes: object[], listerFor?: (t: unknown) => Lister) => {
376 const read = await readDeclaredLanes(env(lanes), NOW, listerFor as never ?? (() => fakeStore(fresh).lister));
377 return judge({ lanes: read, checkedAt: new Date(NOW).toISOString() }, undefined, NOW);
378 };
379
380 test("a lane with both values shows them", async () => {
381 const v = await verdictOf([{ ...lane, backsUp: "the postgres dump", afterSpinDown: "ops, from B2" }]);
382 expect(v.lanes[0]!.line).toBe("backs up the postgres dump; after spin-down: ops, from B2");
383 expect(v.lanes[0]!.state).toBe("green");
384 });
385
386 test("a lane with neither says there is no line, and stays green", async () => {
387 const v = await verdictOf([lane]);
388 expect(v.lanes[0]!.line).toBe("No line yet: say what it backs up and who reads it after spin-down");
389 expect(v.state).not.toBe("red");
390 expect(v.lanes[0]!.state).toBe("green");
391 });
392
393 test("a lane with one value shows it and names the missing one", async () => {
394 const a = await verdictOf([{ ...lane, backsUp: "the postgres dump" }]);
395 expect(a.lanes[0]!.line).toBe("backs up the postgres dump; after spin-down: (not said)");
396 const b = await verdictOf([{ ...lane, afterSpinDown: "ops, from B2" }]);
397 expect(b.lanes[0]!.line).toBe("backs up (not said); after spin-down: ops, from B2");
398 });
399
400 test("values are bounded, typed and printable; no key reaches the line", async () => {
401 expect(parseLanes(JSON.stringify([{ ...lane, backsUp: 7 }])).refused).toHaveLength(1);
402 expect(parseLanes(JSON.stringify([{ ...lane, afterSpinDown: "x".repeat(201) }])).refused).toHaveLength(1);
403 const v = await verdictOf([{ ...lane, backsUp: "a\u001b[31mb", afterSpinDown: "ops" }]);
404 expect(v.lanes[0]!.line).not.toContain("\u001b");
405 expect(JSON.stringify(v)).not.toContain(SECRET);
406 });
407
408 test("a retired lane with an unreachable store is listed, never read, and the verdict stays green", async () => {
409 const boom: Lister = { where: "gone.test/b", async page() { throw new Error("connection refused"); } };
410 const v = await verdictOf(
411 [lane, { ...lane, name: "backup/old", bucket: "oldb", backsUp: "the old thing", afterSpinDown: "retired" }],
412 (t: any) => (t.bucket === "oldb" ? boom : fakeStore(fresh).lister),
413 );
414 const old = v.lanes.find((l) => l.lane === "backup/old")!;
415 expect(old.state).toBe("retired");
416 expect(old.says).toContain("retired");
417 expect(v.reasons).toEqual([]);
418 expect(v.state).not.toBe("red");
419 });
420
421 test("a retired lane's store is never asked", async () => {
422 let asked = 0;
423 const read = await readDeclaredLanes(env([{ ...lane, afterSpinDown: "Retired" }]), NOW, () => {
424 asked++;
425 return fakeStore(fresh).lister;
426 });
427 expect(read[0]!.error).toBeUndefined();
428 expect(asked).toBe(0);
429 expect(read[0]!.newestAt).toBeNull();
430 });
431 });
432
433 describe("the lanes in the web door and the terminal (#9067)", () => {
434 let root = "";
435 let app: App;
436 let servers: Servers;
437 let stop: () => void;
438 let svc: SnapshotsHandle;
439 const fresh = { "pg/2026-11-02T10:00:00Z/dump": { bytes: 10, at: Date.now() - H } };
440 const lane = { name: "backup/pg", endpoint: "https://s3.test", bucket: "backups", prefix: "pg/", accessKey: ACCESS, secretKey: SECRET };
441
442 beforeAll(async () => {
443 root = await mkdtemp(join(tmpdir(), "forge-lanes-door-"));
444 app = createApp({ dataDir: root, port: 0 });
445 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
446 if (!u.ok) throw new Error(u.error);
447 // The page draws only where a snapshot service is running.
448 svc = startSnapshots(app, dirStore(join(root, "store")), { version: "t", nightly: false });
449 servers = await serve(app);
450 const env = {
451 FORGE_BACKUP_LANES: JSON.stringify([
452 { ...lane, backsUp: "the postgres dump", afterSpinDown: "ops, from B2" },
453 { ...lane, name: "backup/bare", bucket: "bare" },
454 { ...lane, name: "backup/old", bucket: "old", backsUp: "the old thing", afterSpinDown: "retired" },
455 ]),
456 };
457 const w = watchBucket(root, () => [], 3_600_000, 3_600_000, () => [], (now) => readDeclaredLanes(env, now, () => fakeStore(fresh).lister));
458 stop = w.stop;
459 await w.pass();
460 });
461 afterAll(async () => {
462 stop();
463 await svc.stop();
464 await servers.stop();
465 await rm(root, { recursive: true, force: true });
466 });
467
468 test("/settings/backups lists each lane as a row, with its line, the missing line said, and a retired mark", async () => {
469 const r = await fetch(`${servers.url}/settings/backups`, { headers: { cookie: await login() } });
470 const t = await r.text();
471 const li = (name: string) => t.split("<li").find((x) => x.includes(`data-lane="${name}"`)) ?? "";
472 expect(li("backup/pg")).toContain("backs up the postgres dump; after spin-down: ops, from B2");
473 expect(li("backup/pg")).toContain('data-lane-state="green"');
474 expect(li("backup/bare")).toContain("No line yet: say what it backs up and who reads it after spin-down");
475 expect(li("backup/old")).toContain('data-lane-state="retired"');
476 expect(li("backup/old")).toContain("retired");
477 expect(li("backup/old")).toContain('class="badge"');
478 expect(t).toContain('class="list setting-list"');
479 });
480
481 test("the terminal's verdict lines carry each lane's line", () => {
482 const text = verdictLines({ app } as never).join("\n");
483 expect(text).toContain("backs up the postgres dump; after spin-down: ops, from B2");
484 expect(text).toContain("No line yet");
485 });
486
487 async function login(): Promise<string> {
488 const r = await fetch(`${servers.url}/signin`, {
489 method: "POST",
490 headers: { origin: servers.url },
491 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
492 redirect: "manual",
493 });
494 return (r.headers.get("set-cookie") ?? "").split(";")[0]!;
495 }
496 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs