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

GET /api/v1/boards, /boards/pins and /boards/:slug carry an ETag and answer a repeat with 304 until a board or its pins change #9254

Merged opened this

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

Merged

scout/forge:scout/9242-get-api-v1-boards-boards-pins-and-boards → main 2 commits fork

  • Merged. scout/9242-get-api-v1-boards-boards-pins-and-boards was merged into main as b13a874.

Review

Sign in to review this change.
2 files changed +176 −3
src/http/api/boards.ts +17 −3
@@ -16,10 +16,24 @@ import { PIN_OP_WORDS } from "../../core/arg_words.ts";
1616 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1717 import { boardsAdmin } from "../api.ts";
1818 import type { Handler } from "../context.ts";
19 import { holds, weakEtag } from "../etag.ts";
1920 import { bodyCapFor, readBounded } from "../limits.ts";
2021 import { apiError, json, notFound, requireAccount } from "../respond.ts";
2122 import type { Router } from "../router.ts";
2223
24 /**
25 * An owner-scoped JSON answer tagged by its exact bytes: a repeat holding the
26 * tag gets 304. Callers check access first, so a 304 confirms nothing to
27 * someone who may not read it.
28 */
29 function tagged(request: Request, data: unknown): Response {
30 const text = JSON.stringify(data);
31 const etag = weakEtag(text);
32 const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" };
33 if (holds(request, etag)) return new Response(null, { status: 304, headers });
34 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
35 }
36
2337 export function boardRoutes(router: Router<Handler>): void {
2438 router.add("GET", "/api/v1/boards/sources", (ctx) => {
2539 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -65,7 +79,7 @@ export function boardRoutes(router: Router<Handler>): void {
6579 const listed = await timePhaseAsync(ctx.request, "boards", () => listAllBoards(ctx.app, ctx.auth.user));
6680 return timePhase(ctx.request, "render", () =>
6781 listed.ok
68 ? json({ boards: listed.value.map((b) => ({ slug: b.slug, ...b.board })) })
82 ? tagged(ctx.request, { boards: listed.value.map((b) => ({ slug: b.slug, ...b.board })) })
6983 : notFound());
7084 });
7185
@@ -77,7 +91,7 @@ export function boardRoutes(router: Router<Handler>): void {
7791 if (denied) return denied;
7892 const listed = await timePhaseAsync(ctx.request, "boards", () => listPinnedPanels(ctx.app, ctx.auth.user));
7993 return timePhase(ctx.request, "render", () =>
80 listed.ok ? json({ pins: listed.value.map((p) => ({ slug: p.slug, title: p.title, panel: p.panel })) }) : notFound());
94 listed.ok ? tagged(ctx.request, { pins: listed.value.map((p) => ({ slug: p.slug, title: p.title, panel: p.panel })) }) : notFound());
8195 });
8296
8397 router.add("POST", "/api/v1/boards/pins", async (ctx) => {
@@ -111,7 +125,7 @@ export function boardRoutes(router: Router<Handler>): void {
111125 if (denied) return denied;
112126 const shown = await timePhaseAsync(ctx.request, "boards", () => showBoard(ctx.app, ctx.auth.user, ctx.params.slug!));
113127 return timePhase(ctx.request, "render", () =>
114 shown.ok ? json({ slug: ctx.params.slug, ...shown.value }) : notFound());
128 shown.ok ? tagged(ctx.request, { slug: ctx.params.slug, ...shown.value }) : notFound());
115129 });
116130
117131 // One panel's data (`boards/panel_data.ts`): `:id` is its place on the
test/boards-etag.test.ts added +159
@@ -0,0 +1,159 @@
1 /** GET /api/v1/boards, /boards/pins and /boards/:slug carry an ETag; a repeat is a 304 until a board or its pins change. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { writeBoard } from "../src/actions/boards.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser, type User } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 const panel = (title: string) => ({ title, chart: "line", query: { source: "runs", aggregate: "count", groupBy: "kind" }, w: 1, h: 1 });
13 const board = (title: string, panels: unknown[]) => ({ title, range: "all", refresh: "off", panels });
14
15 describe("boards ETag", () => {
16 let dir: string;
17 let app: App;
18 let servers: Servers;
19 let alice: User;
20 const tok: Record<string, string> = {};
21
22 const req = (path: string, who: string | null, extra: Record<string, string> = {}, method = "GET", body?: unknown) =>
23 fetch(`${servers.url}${path}`, {
24 method,
25 headers: {
26 ...(who ? { authorization: `Bearer ${tok[who]}` } : {}),
27 ...(body !== undefined ? { "content-type": "application/json" } : {}),
28 ...extra,
29 },
30 ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
31 });
32 const get = (path: string, who: string | null, extra: Record<string, string> = {}) => req(path, who, extra);
33 const pin = (who: string, op: string, slug: string, title: string) =>
34 req("/api/v1/boards/pins", who, {}, "POST", { op, slug, title });
35 /** Fetch, then assert that holding the old tag now gets a new body and tag. */
36 const moved = async (path: string, who: string, old: string) => {
37 const out = await get(path, who, { "if-none-match": old });
38 expect(out.status).toBe(200);
39 const tag = out.headers.get("etag")!;
40 expect(tag).not.toBe(old);
41 await out.text();
42 return tag;
43 };
44
45 beforeAll(async () => {
46 dir = await mkdtemp(join(tmpdir(), "forge-boards-etag-"));
47 app = createApp({ dataDir: dir, port: 0 });
48 servers = await serve(app);
49 const mk = async (username: string, adminIfFirst?: boolean): Promise<User> => {
50 const r = await createUser(app.db, {
51 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
52 password: "a-good-passphrase", ...(adminIfFirst ? { adminIfFirst } : {}),
53 });
54 if (!r.ok) throw new Error(r.error);
55 return r.value;
56 };
57 alice = await mk("alice", true);
58 const carol = await mk("carol");
59 app.db.run("UPDATE user SET is_admin = 1 WHERE id = ?", [carol.id]);
60 const bob = await mk("bob");
61 tok.alice = createToken(app.db, alice.id, "t", "admin").token;
62 tok.carol = createToken(app.db, carol.id, "t", "admin").token;
63 tok.bob = createToken(app.db, bob.id, "t", "admin").token;
64 const w = await writeBoard(app, alice, "mine", board("Mine", [panel("Pin A"), panel("Pin B")]));
65 if (!w.ok) throw new Error(JSON.stringify(w.error));
66 }, 30_000);
67
68 afterAll(async () => {
69 servers.stop();
70 app.close();
71 await rm(dir, { recursive: true, force: true });
72 });
73
74 for (const path of ["/api/v1/boards", "/api/v1/boards/pins", "/api/v1/boards/mine"]) {
75 test(`${path}: 200 with a weak tag, then 304 with the same headers and no body`, async () => {
76 const first = await get(path, "alice");
77 expect(first.status).toBe(200);
78 const tag = first.headers.get("etag")!;
79 expect(tag).toMatch(/^W\/"/);
80 expect(first.headers.get("cache-control")).toBe("private, no-cache");
81 expect(first.headers.get("vary")).toContain("authorization, cookie");
82 expect(first.headers.get("content-type")).toContain("application/json");
83 JSON.parse(await first.text());
84 const again = await get(path, "alice", { "if-none-match": tag });
85 expect(again.status).toBe(304);
86 expect(again.headers.get("etag")).toBe(tag);
87 expect(again.headers.get("cache-control")).toBe("private, no-cache");
88 expect(again.headers.get("vary")).toContain("authorization, cookie");
89 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
90 expect(again.headers.get("referrer-policy")).not.toBeNull();
91 expect(await again.text()).toBe("");
92 });
93 }
94
95 test("the list moves when a board is created, renamed or deleted", async () => {
96 let tag = (await get("/api/v1/boards", "alice")).headers.get("etag")!;
97 expect((await req("/api/v1/boards/other", "alice", {}, "PUT", board("Other", []))).status).toBe(200);
98 tag = await moved("/api/v1/boards", "alice", tag);
99 expect((await req("/api/v1/boards/other", "alice", {}, "PUT", board("Other renamed", []))).status).toBe(200);
100 tag = await moved("/api/v1/boards", "alice", tag);
101 expect((await req("/api/v1/boards/other", "alice", {}, "DELETE")).status).toBe(200);
102 await moved("/api/v1/boards", "alice", tag);
103 });
104
105 test("pins move when a pin is added or removed", async () => {
106 let tag = (await get("/api/v1/boards/pins", "alice")).headers.get("etag")!;
107 expect((await pin("alice", "pin", "mine", "Pin A")).status).toBe(200);
108 tag = await moved("/api/v1/boards/pins", "alice", tag);
109 expect((await pin("alice", "unpin", "mine", "Pin A")).status).toBe(200);
110 await moved("/api/v1/boards/pins", "alice", tag);
111 });
112
113 test("a board moves when a panel on it is written, and each slug has its own tag", async () => {
114 expect((await req("/api/v1/boards/second", "alice", {}, "PUT", board("Second", []))).status).toBe(200);
115 const mine = (await get("/api/v1/boards/mine", "alice")).headers.get("etag")!;
116 const second = (await get("/api/v1/boards/second", "alice")).headers.get("etag")!;
117 expect(second).not.toBe(mine);
118 expect((await get("/api/v1/boards/second", "alice", { "if-none-match": mine })).status).toBe(200);
119 const written = await req("/api/v1/boards/mine", "alice", {}, "PUT", board("Mine", [panel("Pin A"), panel("Pin B"), panel("Pin C")]));
120 expect(written.status).toBe(200);
121 await moved("/api/v1/boards/mine", "alice", mine);
122 expect((await get("/api/v1/boards/second", "alice", { "if-none-match": second })).status).toBe(304);
123 });
124
125 test("viewers shown different pins never share a tag", async () => {
126 expect((await pin("alice", "pin", "mine", "Pin B")).status).toBe(200);
127 const a = (await get("/api/v1/boards/pins", "alice")).headers.get("etag")!;
128 const c = (await get("/api/v1/boards/pins", "carol")).headers.get("etag")!;
129 expect(a).not.toBe(c);
130 expect((await get("/api/v1/boards/pins", "carol", { "if-none-match": a })).status).toBe(200);
131 expect((await get("/api/v1/boards/pins", "alice", { "if-none-match": c })).status).toBe(200);
132 });
133
134 test("no session, no admin and unknown boards answer as before, with no tag, even for *", async () => {
135 const tag = (await get("/api/v1/boards/mine", "alice")).headers.get("etag")!;
136 for (const path of ["/api/v1/boards", "/api/v1/boards/pins", "/api/v1/boards/mine"]) {
137 for (const held of [tag, "*"]) {
138 for (const who of [null, "bob"]) {
139 const out = await get(path, who, { "if-none-match": held });
140 expect([401, 403]).toContain(out.status);
141 expect(out.headers.get("etag")).toBeNull();
142 }
143 }
144 }
145 for (const held of [tag, "*"]) {
146 const out = await get("/api/v1/boards/nope", "alice", { "if-none-match": held });
147 expect(out.status).toBe(404);
148 expect(out.headers.get("etag")).toBeNull();
149 }
150 });
151
152 test("sources and panel data carry no tag", async () => {
153 expect((await get("/api/v1/boards/sources", "alice")).headers.get("etag")).toBeNull();
154 const p = await get("/api/v1/boards/mine/panels/0", "alice");
155 expect(p.status).toBe(200);
156 expect(p.headers.get("etag")).toBeNull();
157 expect(p.headers.get("cache-control")).toBe("private, no-store");
158 });
159 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs