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

GET /api/v1/repos/:owner/:name/map carries an ETag and answers a repeat with 304 while the map snapshot has not changed #9159

Merged opened this

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

Merged

scout/forge:scout/9156-get-api-v1-repos-owner-name-map-carries → main 2 commits fork

  • Merged. scout/9156-get-api-v1-repos-owner-name-map-carries was merged into main as 306134b.

Review

Sign in to review this change.
3 files changed +147 −2
src/contract/openapi/routes/getApiV1ReposOwnerNameMap.ts +1 −1
@@ -1,4 +1,4 @@
11 import { REPO, type Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars; `issues` and `edges` hold open issues only unless ?done=1, counts.done says how many are done; `startable` lists the ready, unassigned, unheld numbers", auth: false,
3 export const route: Route = { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars; `issues` and `edges` hold open issues only unless ?done=1, counts.done says how many are done; `startable` lists the ready, unassigned, unheld numbers; the 200 carries a weak etag of the facts served and the reader's sealed scope, cache-control private, no-cache and vary authorization, cookie, and a repeat sending If-None-Match gets 304 with no body while the map has not changed", auth: false, alsoStatus: [304],
44 query: { done: "string" } };
src/http/api.ts +11 −1
@@ -239,6 +239,7 @@ import { forReader } from "../crew/handover.ts";
239239 import { globalMapJson } from "../web/pages/global-map.ts";
240240 import { mapJson, marksFor, withoutDone } from "../web/pages/map.ts";
241241 import { cachedMap } from "../web/map_snapshot.ts";
242 import { scopeKey } from "../web/map_cache.ts";
242243 import {
243244 milestoneData,
244245 milestonesJson,
@@ -2262,7 +2263,16 @@ export function apiRoutes(router: Router<Handler>): void {
22622263 if (!built)
22632264 for (const name of ["graph", "marks", "pillars", "asktolive", "held", "render"])
22642265 notePhase(request, name, 0, hit);
2265 return json(ctx.url.searchParams.get("done") === "1" ? value : withoutDone(value));
2266 // Tagged by the facts served (never an age that ticks) and the reader's sealed
2267 // scope, so two scopes never share a tag; access was checked by loadRepo.
2268 const text = JSON.stringify(ctx.url.searchParams.get("done") === "1" ? value : withoutDone(value));
2269 const etag = weakEtag(`${scopeKey(scope)}\n${text}`);
2270 const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" };
2271 if (holds(request, etag)) {
2272 notePhase(request, "not-modified", 0, true);
2273 return new Response(null, { status: 304, headers });
2274 }
2275 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
22662276 });
22672277 // What people asked of the crew in the repositories the caller can write —
22682278 // by default what has nothing merged yet, oldest first — and what became of each (core/asked.ts, the /asked page).
test/map-etag.test.ts added +135
@@ -0,0 +1,135 @@
1 /** GET …/map carries a weak ETag and answers a repeat with 304 while the map has not moved (#9156). */
2 import { afterAll, beforeAll, 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 { createRepo } from "../src/actions/repos.ts";
8 import { addDependency } from "../src/core/dependencies.ts";
9 import { createIssue } from "../src/core/issues.ts";
10 import { createRun } from "../src/core/runs.ts";
11 import { getRepoById, updateRepoSettings } from "../src/core/repos.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { seedAdmin } from "./helpers/admin.ts";
14
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 let repoId: string;
19 let userId: string;
20 let bearer = "";
21 const MAP = "/api/v1/repos/alice/notes/map";
22
23 const get = (headers: Record<string, string> = {}, auth = true, path = MAP) =>
24 fetch(`${servers.url}${path}`, {
25 headers: { ...(auth ? { authorization: `Bearer ${bearer}` } : {}), ...headers },
26 });
27 const mk = (title: string, sealed = false) => {
28 const r = createIssue(app.db, { repoId, authorId: userId, title, body: "", kind: "issue", sealed });
29 if (!r.ok) throw new Error(r.error);
30 return r.value;
31 };
32 /** The tag now, after proving the previous one no longer holds. */
33 async function changed(before: string, what: string): Promise<string> {
34 const res = await get({ "if-none-match": before });
35 expect(res.status, what).toBe(200);
36 const tag = res.headers.get("etag")!;
37 expect(tag, what).not.toBe(before);
38 await res.text();
39 return tag;
40 }
41
42 beforeAll(async () => {
43 dataDir = await mkdtemp(join(tmpdir(), "forge-mapetag-"));
44 app = createApp({ dataDir, port: 0 });
45 servers = await serve(app);
46 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
47 if (!admin.ok) throw new Error(admin.error);
48 userId = admin.value.id;
49 const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" });
50 if (!made.ok) throw new Error(String(made.error));
51 repoId = made.value.id;
52 updateRepoSettings(app.db, getRepoById(app.db, repoId)!, { plansNeedNod: false });
53 mk("First");
54 mk("Second");
55 const minted = await fetch(`${servers.url}/api/v1/tokens`, {
56 method: "POST",
57 headers: { "content-type": "application/json" },
58 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
59 });
60 bearer = ((await minted.json()) as { token: string }).token;
61 });
62
63 afterAll(async () => {
64 servers.stop();
65 app.close();
66 await rm(dataDir, { recursive: true, force: true });
67 });
68
69 test("the answer carries a weak tag, private no-cache and vary", async () => {
70 const res = await get();
71 expect(res.status).toBe(200);
72 expect(res.headers.get("etag")).toMatch(/^W\/"/);
73 expect(res.headers.get("cache-control")).toBe("private, no-cache");
74 expect(res.headers.get("vary")).toContain("authorization, cookie");
75 await res.text();
76 });
77
78 test("a matching If-None-Match is a bodiless 304 that runs no graph phase and says so", async () => {
79 const first = await get();
80 const tag = first.headers.get("etag")!;
81 await first.text();
82 const again = await get({ "if-none-match": tag });
83 expect(again.status).toBe(304);
84 expect(again.headers.get("etag")).toBe(tag);
85 expect(await again.text()).toBe("");
86 const timing = again.headers.get("server-timing") ?? "";
87 expect(timing).toContain("not-modified");
88 expect(timing).toMatch(/graph;dur=[\d.]+;desc="hit"/);
89 // Another poll with no change keeps the same tag.
90 const third = await get();
91 expect(third.headers.get("etag")).toBe(tag);
92 await third.text();
93 });
94
95 test("opening, blocking, closing, a run and a pillar each make a new tag", async () => {
96 let tag = (await get()).headers.get("etag")!;
97 const a = mk("Opened");
98 tag = await changed(tag, "open");
99 const b = mk("Blocker");
100 tag = await changed(tag, "open another");
101 const dep = addDependency(app.db, a, b, userId);
102 expect(dep.ok).toBe(true);
103 tag = await changed(tag, "block");
104 createRun(app.db, { repoId, kind: "agent", issueId: b.id, reason: "test" });
105 tag = await changed(tag, "run starts");
106 mk("P1 First pillar: umbrella");
107 tag = await changed(tag, "pillar");
108 app.db.run("UPDATE issue SET state = 'closed' WHERE id = ?1", [b.id]);
109 await changed(tag, "close");
110 });
111
112 test("readers with different sealed scopes never share a tag, and a sealed issue does not leak", async () => {
113 mk("Hidden", true);
114 const owner = await get();
115 const stranger = await get({}, false);
116 expect(owner.status).toBe(200);
117 expect(stranger.status).toBe(200);
118 const ownerTag = owner.headers.get("etag")!;
119 const strangerTag = stranger.headers.get("etag")!;
120 expect(ownerTag).not.toBe(strangerTag);
121 expect(await owner.text()).toContain("Hidden");
122 expect(await stranger.text()).not.toContain("Hidden");
123 // The owner's tag buys a stranger nothing.
124 const replay = await get({ "if-none-match": ownerTag }, false);
125 expect(replay.status).toBe(200);
126 expect(await replay.text()).not.toContain("Hidden");
127 });
128
129 test("?done=1 is tagged apart from the open-only answer", async () => {
130 const open = await get();
131 const all = await get({}, true, `${MAP}?done=1`);
132 expect(all.headers.get("etag")).not.toBe(open.headers.get("etag"));
133 await open.text();
134 await all.text();
135 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs