Public: anyone can read this; the crew can change it.
Board routes leave api.ts for src/http/api/boards.ts #6943
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6326-board-routes-leave-api-ts-for-src-http-a → main 2 commits fork
3 files
changed +196
−169
src/http/api.ts +14 −167
| @@ -35,16 +35,11 @@ import { voteVariant } from "../actions/variants.ts"; | ||
| 35 | 35 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../core/route_timing.ts"; |
| 36 | 36 | import { parentForgeFor } from "../core/parent_releases.ts"; |
| 37 | 37 | 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"; | |
| 43 | 39 | import { recordDrill } from "../core/domain_drills.ts"; |
| 44 | 40 | import { drillVerdict } from "../kernel/domain_drill.ts"; |
| 45 | 41 | 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"; | |
| 48 | 43 | import { askCheck } from "../checks/again.ts"; |
| 49 | 44 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 50 | 45 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| @@ -451,6 +446,7 @@ import { frontInput, frontJson, setFront } from "../actions/front.ts"; | ||
| 451 | 446 | import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; |
| 452 | 447 | import { passportRoutes } from "./passport_edge.ts"; |
| 453 | 448 | import { egressGrantRoutes } from "./api/egress_grants.ts"; |
| 449 | import { boardRoutes } from "./api/boards.ts"; | |
| 454 | 450 | import { labelsRoutes } from "./api/labels.ts"; |
| 455 | 451 | import { secretRoutes } from "./api/secrets.ts"; |
| 456 | 452 | import type { Router } from "./router.ts"; |
| @@ -471,7 +467,7 @@ import { REPORT_RECEIVED } from "../actions/report_words.ts"; | ||
| 471 | 467 | import { ADMIN_WORDS } from "../actions/admin_words.ts"; |
| 472 | 468 | import { SETTING_WORDS } from "../actions/setting_words.ts"; |
| 473 | 469 | 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"; | |
| 475 | 471 | |
| 476 | 472 | /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */ |
| 477 | 473 | async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> { |
| @@ -1272,6 +1268,15 @@ function membersAnswer<T>(result: Result<T, AppMembersError>): Response { | ||
| 1272 | 1268 | return notFound(); |
| 1273 | 1269 | } |
| 1274 | 1270 | } |
| 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 | ||
| 1275 | 1280 | export function apiRoutes(router: Router<Handler>): void { |
| 1276 | 1281 | passportRoutes(router); |
| 1277 | 1282 | // ── accounts ───────────────────────────────────────────────────────── |
| @@ -1457,14 +1462,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1457 | 1462 | const accountAdmin = (ctx: Ctx, scope: "ro" | "admin"): Response | undefined => |
| 1458 | 1463 | ctx.auth.user?.isAdmin === true ? requireScope(ctx, scope) : notFound(); |
| 1459 | 1464 | |
| 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 | ||
| 1468 | 1465 | // Open read hooks (#1789): the owner, or an agent holding `telemetry:read`. |
| 1469 | 1466 | // Anyone else is refused; every agent read is logged. |
| 1470 | 1467 | const telemetryRead = |
| @@ -1492,19 +1489,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1492 | 1489 | return timePhase(ctx.request, "render", () => json(body)); |
| 1493 | 1490 | }); |
| 1494 | 1491 | |
| 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); | |
| 1508 | 1493 | |
| 1509 | 1494 | // A domain drill and its verdict (`kernel/domain_drill.ts`): an admin |
| 1510 | 1495 | // person's, kept whether green or red. |
| @@ -1534,144 +1519,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1534 | 1519 | return json(recordDrill(ctx.app.db, ctx.auth.user!.id, domain.key, observed, back, verdict), 201); |
| 1535 | 1520 | }); |
| 1536 | 1521 | |
| 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 | ||
| 1675 | 1522 | // The forge's capacity (`core/capacity.ts`): read and changed live by an |
| 1676 | 1523 | // admin, never by an agent; anyone else is told the door is not there. |
| 1677 | 1524 | 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 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { describe, expect, test } from "bun:test"; |
| 8 | import { readFileSync } from "node:fs"; | |
| 8 | import { readdirSync, readFileSync } from "node:fs"; | |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { |
| 11 | 11 | BOARD_NOT_SAVED, |
| @@ -15,8 +15,13 @@ import { | ||
| 15 | 15 | import { importsFrom, mcpSource } from "./helpers/mcp.ts"; |
| 16 | 16 | |
| 17 | 17 | 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"); | |
| 18 | 23 | 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"); | |
| 20 | 25 | |
| 21 | 26 | const SENTENCES = { |
| 22 | 27 | BOARD_NOT_SAVED: "that board could not be saved", |
new above
offline