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

Board routes leave api.ts for src/http/api/boards.ts #6943

Merged opened this

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

Merged

mason/forge:mason/6326-board-routes-leave-api-ts-for-src-http-a → main 2 commits fork

  • Merged. mason/6326-board-routes-leave-api-ts-for-src-http-a was merged into main as 3c2d3dd.

Review

Sign in to review this change.
3 files changed +196 −169
src/http/api.ts +14 −167
@@ -35,16 +35,11 @@ import { voteVariant } from "../actions/variants.ts";
3535 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../core/route_timing.ts";
3636 import { parentForgeFor } from "../core/parent_releases.ts";
3737 import { readFlow } from "../flow/read.ts";
38 import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel, type PinError } from "../actions/board-pins.ts";
39 import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts";
40 import { BOARD_NOT_SAVED, MILESTONE_WATCH_PERSON_ONLY, VOTE_NOT_RECORDED } from "../actions/board_words.ts";
41 import { panelData, parseWindow, rowsCsv } from "../boards/panel_data.ts";
42 import { runQuery } from "../boards/query.ts";
38 import { MILESTONE_WATCH_PERSON_ONLY, VOTE_NOT_RECORDED } from "../actions/board_words.ts";
4339 import { recordDrill } from "../core/domain_drills.ts";
4440 import { drillVerdict } from "../kernel/domain_drill.ts";
4541 import { DOMAINS } from "../kernel/domains.ts";
46 import { SOURCES } from "../boards/sources.ts";
47 import { bodyCapFor, readBounded } from "./limits.ts";
42 import { readBounded } from "./limits.ts";
4843 import { askCheck } from "../checks/again.ts";
4944 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
5045 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
@@ -451,6 +446,7 @@ import { frontInput, frontJson, setFront } from "../actions/front.ts";
451446 import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
452447 import { passportRoutes } from "./passport_edge.ts";
453448 import { egressGrantRoutes } from "./api/egress_grants.ts";
449 import { boardRoutes } from "./api/boards.ts";
454450 import { labelsRoutes } from "./api/labels.ts";
455451 import { secretRoutes } from "./api/secrets.ts";
456452 import type { Router } from "./router.ts";
@@ -471,7 +467,7 @@ import { REPORT_RECEIVED } from "../actions/report_words.ts";
471467 import { ADMIN_WORDS } from "../actions/admin_words.ts";
472468 import { SETTING_WORDS } from "../actions/setting_words.ts";
473469 import { SPEND_WORDS } from "../actions/spend_words.ts";
474 import { ANSWER_WORDS, ENABLED_WORDS, ORDER_WORDS, PIN_OP_WORDS, SIGNUP_WORDS, VISIBILITY_WORDS } from "../core/arg_words.ts";
470 import { ANSWER_WORDS, ENABLED_WORDS, ORDER_WORDS, SIGNUP_WORDS, VISIBILITY_WORDS } from "../core/arg_words.ts";
475471
476472 /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */
477473 async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> {
@@ -1272,6 +1268,15 @@ function membersAnswer<T>(result: Result<T, AppMembersError>): Response {
12721268 return notFound();
12731269 }
12741270 }
1271
1272 /** Boards (`boards/query.ts`): a site admin who is a person, never an agent. */
1273 export function boardsAdmin(ctx: Ctx): Response | undefined {
1274 if (!ctx.auth.user) return unauthorized();
1275 return ctx.auth.user.isAdmin && ctx.auth.user.kind !== "agent"
1276 ? undefined
1277 : forbidden();
1278 }
1279
12751280 export function apiRoutes(router: Router<Handler>): void {
12761281 passportRoutes(router);
12771282 // ── accounts ─────────────────────────────────────────────────────────
@@ -1457,14 +1462,6 @@ export function apiRoutes(router: Router<Handler>): void {
14571462 const accountAdmin = (ctx: Ctx, scope: "ro" | "admin"): Response | undefined =>
14581463 ctx.auth.user?.isAdmin === true ? requireScope(ctx, scope) : notFound();
14591464
1460 // Boards (`boards/query.ts`): a site admin who is a person, never an agent.
1461 const boardsAdmin = (ctx: Ctx): Response | undefined => {
1462 if (!ctx.auth.user) return unauthorized();
1463 return ctx.auth.user.isAdmin && ctx.auth.user.kind !== "agent"
1464 ? undefined
1465 : forbidden();
1466 };
1467
14681465 // Open read hooks (#1789): the owner, or an agent holding `telemetry:read`.
14691466 // Anyone else is refused; every agent read is logged.
14701467 const telemetryRead =
@@ -1492,19 +1489,7 @@ export function apiRoutes(router: Router<Handler>): void {
14921489 return timePhase(ctx.request, "render", () => json(body));
14931490 });
14941491
1495 router.add("GET", "/api/v1/boards/sources", (ctx) => {
1496 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1497 const denied = boardsAdmin(ctx);
1498 if (denied) return denied;
1499 const sources = timePhase(ctx.request, "sources", () =>
1500 Object.entries(SOURCES).map(([name, s]) => ({
1501 name,
1502 time: s.time,
1503 columns: s.columns,
1504 ...(s.docs ? { docs: s.docs } : {}),
1505 })));
1506 return timePhase(ctx.request, "render", () => json({ sources }));
1507 });
1492 boardRoutes(router);
15081493
15091494 // A domain drill and its verdict (`kernel/domain_drill.ts`): an admin
15101495 // person's, kept whether green or red.
@@ -1534,144 +1519,6 @@ export function apiRoutes(router: Router<Handler>): void {
15341519 return json(recordDrill(ctx.app.db, ctx.auth.user!.id, domain.key, observed, back, verdict), 201);
15351520 });
15361521
1537 router.add("POST", "/api/v1/boards/query", async (ctx) => {
1538 const denied = boardsAdmin(ctx) ?? requireAccount(ctx, { adminToken: true });
1539 if (denied) return denied;
1540 const bytes = await readBounded(ctx.request, bodyCapFor(ctx.request));
1541 if (bytes === "too_large") return apiError("too_large", 413, "that is too large to read here");
1542 let query: unknown;
1543 try {
1544 query = JSON.parse(new TextDecoder().decode(bytes));
1545 } catch {
1546 return apiError("bad_json", 400, "the body must be a JSON query object");
1547 }
1548 const answer = runQuery(ctx.app.db, query, { repoIds: "all" });
1549 return answer.ok
1550 ? json({ rows: answer.rows })
1551 : apiError("bad_query", 400, answer.error);
1552 });
1553
1554 const boardFailed = (e: BoardError): Response => {
1555 if (e === "not_found") return notFound();
1556 if (typeof e === "object") return apiError("refused", 422, e.refused);
1557 return apiError(e, e === "exists" || e === "branch_moved" ? 409 : 422, BOARD_NOT_SAVED);
1558 };
1559
1560 router.add("GET", "/api/v1/boards", async (ctx) => {
1561 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1562 const denied = boardsAdmin(ctx);
1563 if (denied) return denied;
1564 const listed = await timePhaseAsync(ctx.request, "boards", () => listAllBoards(ctx.app, ctx.auth.user));
1565 return timePhase(ctx.request, "render", () =>
1566 listed.ok
1567 ? json({ boards: listed.value.map((b) => ({ slug: b.slug, ...b.board })) })
1568 : notFound());
1569 });
1570
1571 // The signed-in admin's pins for home: GET lists them in order, POST
1572 // {op: pin|unpin|move, slug, title, by?} changes them. Above `:slug`.
1573 router.add("GET", "/api/v1/boards/pins", async (ctx) => {
1574 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1575 const denied = boardsAdmin(ctx);
1576 if (denied) return denied;
1577 const listed = await timePhaseAsync(ctx.request, "boards", () => listPinnedPanels(ctx.app, ctx.auth.user));
1578 return timePhase(ctx.request, "render", () =>
1579 listed.ok ? json({ pins: listed.value.map((p) => ({ slug: p.slug, title: p.title, panel: p.panel })) }) : notFound());
1580 });
1581
1582 router.add("POST", "/api/v1/boards/pins", async (ctx) => {
1583 const denied = boardsAdmin(ctx) ?? requireAccount(ctx, { adminToken: true });
1584 if (denied) return denied;
1585 const bytes = await readBounded(ctx.request, bodyCapFor(ctx.request));
1586 if (bytes === "too_large") return apiError("too_large", 413, "that is too large to read here");
1587 let input: Record<string, unknown>;
1588 try {
1589 const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
1590 if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
1591 input = parsed as Record<string, unknown>;
1592 } catch {
1593 return apiError("bad_json", 400, "the body must be a JSON object");
1594 }
1595 const { op, slug, title, by } = input;
1596 const done =
1597 op === "pin" ? await pinPanel(ctx.app, ctx.auth.user, slug, title)
1598 : op === "unpin" ? unpinPanel(ctx.app, ctx.auth.user, slug, title)
1599 : op === "move" ? movePinned(ctx.app, ctx.auth.user, slug, title, by)
1600 : undefined;
1601 if (!done) return apiError("invalid", 422, PIN_OP_WORDS);
1602 if (done.ok) return json({ ...done.value });
1603 const e: PinError = done.error;
1604 return e === "not_found" ? notFound() : apiError(e, 422, e === "too_many" ? "too many pins" : "that is not a panel");
1605 });
1606
1607 router.add("GET", "/api/v1/boards/:slug", async (ctx) => {
1608 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1609 const denied = boardsAdmin(ctx);
1610 if (denied) return denied;
1611 const shown = await timePhaseAsync(ctx.request, "boards", () => showBoard(ctx.app, ctx.auth.user, ctx.params.slug!));
1612 return timePhase(ctx.request, "render", () =>
1613 shown.ok ? json({ slug: ctx.params.slug, ...shown.value }) : notFound());
1614 });
1615
1616 // One panel's data (`boards/panel_data.ts`): `:id` is its place on the
1617 // board from 0; `from`, `to` and `step` (300, 5m, 1h) the window;
1618 // `format=csv` the same rows as a table file.
1619 router.add("GET", "/api/v1/boards/:slug/panels/:id", async (ctx) => {
1620 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1621 const denied = boardsAdmin(ctx);
1622 if (denied) return denied;
1623 const shown = await timePhaseAsync(ctx.request, "board", () =>
1624 showBoard(ctx.app, ctx.auth.user, ctx.params.slug!),
1625 );
1626 const id = ctx.params.id!;
1627 const panel = shown.ok && /^\d{1,2}$/.test(id) ? shown.value.panels[Number(id)] : undefined;
1628 if (!shown.ok || !panel) return notFound();
1629 const format = ctx.url.searchParams.get("format") ?? "json";
1630 if (format !== "json" && format !== "csv") return apiError("invalid_format", 422, "format must be json or csv");
1631 const window = parseWindow(ctx.url.searchParams);
1632 if (!window.ok) return apiError("invalid_window", 422, window.error);
1633 const data = timePhase(ctx.request, "query", () =>
1634 panelData(ctx.app.db, shown.value, panel, window.window),
1635 );
1636 if (!data.ok) return apiError("bad_query", 400, data.error);
1637 const headers = { "cache-control": "private, no-store" };
1638 return timePhase(ctx.request, "render", () => {
1639 if (format === "csv") {
1640 return new Response(rowsCsv(data.rows), {
1641 headers: {
1642 ...headers,
1643 "content-type": "text/csv; charset=utf-8",
1644 "content-disposition": `attachment; filename="${ctx.params.slug}-${id}.csv"`,
1645 },
1646 });
1647 }
1648 const { ok: _, ...out } = data;
1649 return json({ board: ctx.params.slug, panel: Number(id), title: panel.title, chart: panel.chart, ...out }, 200, headers);
1650 });
1651 });
1652
1653 router.add("PUT", "/api/v1/boards/:slug", async (ctx) => {
1654 const denied = boardsAdmin(ctx) ?? requireAccount(ctx, { adminToken: true });
1655 if (denied) return denied;
1656 const bytes = await readBounded(ctx.request, bodyCapFor(ctx.request));
1657 if (bytes === "too_large") return apiError("too_large", 413, "that is too large to read here");
1658 let input: unknown;
1659 try {
1660 input = JSON.parse(new TextDecoder().decode(bytes));
1661 } catch {
1662 return apiError("bad_json", 400, "the body must be a JSON board");
1663 }
1664 const done = await writeBoard(ctx.app, ctx.auth.user, ctx.params.slug!, input);
1665 return done.ok ? json({ slug: ctx.params.slug, ...done.value }) : boardFailed(done.error);
1666 });
1667
1668 router.add("DELETE", "/api/v1/boards/:slug", async (ctx) => {
1669 const denied = boardsAdmin(ctx) ?? requireAccount(ctx, { adminToken: true });
1670 if (denied) return denied;
1671 const done = await deleteBoard(ctx.app, ctx.auth.user, ctx.params.slug!);
1672 return done.ok ? json({ deleted: true }) : boardFailed(done.error);
1673 });
1674
16751522 // The forge's capacity (`core/capacity.ts`): read and changed live by an
16761523 // admin, never by an agent; anyone else is told the door is not there.
16771524 router.add("GET", "/api/v1/admin/capacity", (ctx) => {
src/http/api/boards.ts added +175
@@ -0,0 +1,175 @@
1 /**
2 * The forge's boards over the API: `/api/v1/boards` and what hangs under it —
3 * the sources a query may read, a query run once, the board list, the home
4 * pins, one board, one panel's data, and a board written or deleted. Moved
5 * out of `api.ts` (#6326); the rules live in `actions/boards.ts`,
6 * `actions/board-pins.ts` and `boards/`.
7 */
8
9 import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel, type PinError } from "../../actions/board-pins.ts";
10 import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../../actions/boards.ts";
11 import { BOARD_NOT_SAVED } from "../../actions/board_words.ts";
12 import { panelData, parseWindow, rowsCsv } from "../../boards/panel_data.ts";
13 import { runQuery } from "../../boards/query.ts";
14 import { SOURCES } from "../../boards/sources.ts";
15 import { PIN_OP_WORDS } from "../../core/arg_words.ts";
16 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
17 import { boardsAdmin } from "../api.ts";
18 import type { Handler } from "../context.ts";
19 import { bodyCapFor, readBounded } from "../limits.ts";
20 import { apiError, json, notFound, requireAccount } from "../respond.ts";
21 import type { Router } from "../router.ts";
22
23 export function boardRoutes(router: Router<Handler>): void {
24 router.add("GET", "/api/v1/boards/sources", (ctx) => {
25 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
26 const denied = boardsAdmin(ctx);
27 if (denied) return denied;
28 const sources = timePhase(ctx.request, "sources", () =>
29 Object.entries(SOURCES).map(([name, s]) => ({
30 name,
31 time: s.time,
32 columns: s.columns,
33 ...(s.docs ? { docs: s.docs } : {}),
34 })));
35 return timePhase(ctx.request, "render", () => json({ sources }));
36 });
37
38 router.add("POST", "/api/v1/boards/query", async (ctx) => {
39 const denied = boardsAdmin(ctx) ?? requireAccount(ctx, { adminToken: true });
40 if (denied) return denied;
41 const bytes = await readBounded(ctx.request, bodyCapFor(ctx.request));
42 if (bytes === "too_large") return apiError("too_large", 413, "that is too large to read here");
43 let query: unknown;
44 try {
45 query = JSON.parse(new TextDecoder().decode(bytes));
46 } catch {
47 return apiError("bad_json", 400, "the body must be a JSON query object");
48 }
49 const answer = runQuery(ctx.app.db, query, { repoIds: "all" });
50 return answer.ok
51 ? json({ rows: answer.rows })
52 : apiError("bad_query", 400, answer.error);
53 });
54
55 const boardFailed = (e: BoardError): Response => {
56 if (e === "not_found") return notFound();
57 if (typeof e === "object") return apiError("refused", 422, e.refused);
58 return apiError(e, e === "exists" || e === "branch_moved" ? 409 : 422, BOARD_NOT_SAVED);
59 };
60
61 router.add("GET", "/api/v1/boards", async (ctx) => {
62 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
63 const denied = boardsAdmin(ctx);
64 if (denied) return denied;
65 const listed = await timePhaseAsync(ctx.request, "boards", () => listAllBoards(ctx.app, ctx.auth.user));
66 return timePhase(ctx.request, "render", () =>
67 listed.ok
68 ? json({ boards: listed.value.map((b) => ({ slug: b.slug, ...b.board })) })
69 : notFound());
70 });
71
72 // The signed-in admin's pins for home: GET lists them in order, POST
73 // {op: pin|unpin|move, slug, title, by?} changes them. Above `:slug`.
74 router.add("GET", "/api/v1/boards/pins", async (ctx) => {
75 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
76 const denied = boardsAdmin(ctx);
77 if (denied) return denied;
78 const listed = await timePhaseAsync(ctx.request, "boards", () => listPinnedPanels(ctx.app, ctx.auth.user));
79 return timePhase(ctx.request, "render", () =>
80 listed.ok ? json({ pins: listed.value.map((p) => ({ slug: p.slug, title: p.title, panel: p.panel })) }) : notFound());
81 });
82
83 router.add("POST", "/api/v1/boards/pins", async (ctx) => {
84 const denied = boardsAdmin(ctx) ?? requireAccount(ctx, { adminToken: true });
85 if (denied) return denied;
86 const bytes = await readBounded(ctx.request, bodyCapFor(ctx.request));
87 if (bytes === "too_large") return apiError("too_large", 413, "that is too large to read here");
88 let input: Record<string, unknown>;
89 try {
90 const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
91 if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
92 input = parsed as Record<string, unknown>;
93 } catch {
94 return apiError("bad_json", 400, "the body must be a JSON object");
95 }
96 const { op, slug, title, by } = input;
97 const done =
98 op === "pin" ? await pinPanel(ctx.app, ctx.auth.user, slug, title)
99 : op === "unpin" ? unpinPanel(ctx.app, ctx.auth.user, slug, title)
100 : op === "move" ? movePinned(ctx.app, ctx.auth.user, slug, title, by)
101 : undefined;
102 if (!done) return apiError("invalid", 422, PIN_OP_WORDS);
103 if (done.ok) return json({ ...done.value });
104 const e: PinError = done.error;
105 return e === "not_found" ? notFound() : apiError(e, 422, e === "too_many" ? "too many pins" : "that is not a panel");
106 });
107
108 router.add("GET", "/api/v1/boards/:slug", async (ctx) => {
109 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
110 const denied = boardsAdmin(ctx);
111 if (denied) return denied;
112 const shown = await timePhaseAsync(ctx.request, "boards", () => showBoard(ctx.app, ctx.auth.user, ctx.params.slug!));
113 return timePhase(ctx.request, "render", () =>
114 shown.ok ? json({ slug: ctx.params.slug, ...shown.value }) : notFound());
115 });
116
117 // One panel's data (`boards/panel_data.ts`): `:id` is its place on the
118 // board from 0; `from`, `to` and `step` (300, 5m, 1h) the window;
119 // `format=csv` the same rows as a table file.
120 router.add("GET", "/api/v1/boards/:slug/panels/:id", async (ctx) => {
121 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
122 const denied = boardsAdmin(ctx);
123 if (denied) return denied;
124 const shown = await timePhaseAsync(ctx.request, "board", () =>
125 showBoard(ctx.app, ctx.auth.user, ctx.params.slug!),
126 );
127 const id = ctx.params.id!;
128 const panel = shown.ok && /^\d{1,2}$/.test(id) ? shown.value.panels[Number(id)] : undefined;
129 if (!shown.ok || !panel) return notFound();
130 const format = ctx.url.searchParams.get("format") ?? "json";
131 if (format !== "json" && format !== "csv") return apiError("invalid_format", 422, "format must be json or csv");
132 const window = parseWindow(ctx.url.searchParams);
133 if (!window.ok) return apiError("invalid_window", 422, window.error);
134 const data = timePhase(ctx.request, "query", () =>
135 panelData(ctx.app.db, shown.value, panel, window.window),
136 );
137 if (!data.ok) return apiError("bad_query", 400, data.error);
138 const headers = { "cache-control": "private, no-store" };
139 return timePhase(ctx.request, "render", () => {
140 if (format === "csv") {
141 return new Response(rowsCsv(data.rows), {
142 headers: {
143 ...headers,
144 "content-type": "text/csv; charset=utf-8",
145 "content-disposition": `attachment; filename="${ctx.params.slug}-${id}.csv"`,
146 },
147 });
148 }
149 const { ok: _, ...out } = data;
150 return json({ board: ctx.params.slug, panel: Number(id), title: panel.title, chart: panel.chart, ...out }, 200, headers);
151 });
152 });
153
154 router.add("PUT", "/api/v1/boards/:slug", async (ctx) => {
155 const denied = boardsAdmin(ctx) ?? requireAccount(ctx, { adminToken: true });
156 if (denied) return denied;
157 const bytes = await readBounded(ctx.request, bodyCapFor(ctx.request));
158 if (bytes === "too_large") return apiError("too_large", 413, "that is too large to read here");
159 let input: unknown;
160 try {
161 input = JSON.parse(new TextDecoder().decode(bytes));
162 } catch {
163 return apiError("bad_json", 400, "the body must be a JSON board");
164 }
165 const done = await writeBoard(ctx.app, ctx.auth.user, ctx.params.slug!, input);
166 return done.ok ? json({ slug: ctx.params.slug, ...done.value }) : boardFailed(done.error);
167 });
168
169 router.add("DELETE", "/api/v1/boards/:slug", async (ctx) => {
170 const denied = boardsAdmin(ctx) ?? requireAccount(ctx, { adminToken: true });
171 if (denied) return denied;
172 const done = await deleteBoard(ctx.app, ctx.auth.user, ctx.params.slug!);
173 return done.ok ? json({ deleted: true }) : boardFailed(done.error);
174 });
175 }
test/board-words.test.ts +7 −2
@@ -5,7 +5,7 @@
55 */
66
77 import { describe, expect, test } from "bun:test";
8 import { readFileSync } from "node:fs";
8 import { readdirSync, readFileSync } from "node:fs";
99 import { join } from "node:path";
1010 import {
1111 BOARD_NOT_SAVED,
@@ -15,8 +15,13 @@ import {
1515 import { importsFrom, mcpSource } from "./helpers/mcp.ts";
1616
1717 const ROOT = join(import.meta.dir, "..");
18 /** api.ts with the route modules moved out of it (src/http/api/), as mcpSource does for the MCP. */
19 const apiSource = (): string =>
20 ["src/http/api.ts", ...readdirSync(join(ROOT, "src/http/api")).filter((f) => f.endsWith(".ts")).sort().map((f) => `src/http/api/${f}`)]
21 .map((f) => readFileSync(join(ROOT, f), "utf8"))
22 .join("\n");
1823 const read = (path: string): string =>
19 path === "src/http/mcp.ts" ? mcpSource(ROOT) : readFileSync(join(ROOT, path), "utf8");
24 path === "src/http/mcp.ts" ? mcpSource(ROOT) : path === "src/http/api.ts" ? apiSource() : readFileSync(join(ROOT, path), "utf8");
2025
2126 const SENTENCES = {
2227 BOARD_NOT_SAVED: "that board could not be saved",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs