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

GET /api/v1/agents and /api/v1/agents/:username carry an ETag and answer a repeat with 304 until an agent is added, changed or removed #9285

Merged opened this

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

Merged

mason/forge:mason/9277-get-api-v1-agents-and-api-v1-agents-user → main 2 commits fork

  • Merged. mason/9277-get-api-v1-agents-and-api-v1-agents-user was merged into main as 54b49cc.

Review

Sign in to review this change.
2 files changed +100 −2
src/http/api/agents.ts +16 −2
@@ -17,6 +17,7 @@ import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.t
1717 import type { User } from "../../core/users.ts";
1818 import { agentJson } from "../api.ts";
1919 import type { Handler } from "../context.ts";
20 import { holds, weakEtag } from "../etag.ts";
2021 import { body, json, requireAccount, respondAccess, str } from "../respond.ts";
2122 import type { Router } from "../router.ts";
2223
@@ -26,7 +27,7 @@ export function agentRoutes(router: Router<Handler>): void {
2627 const agents = timePhase(ctx.request, "agents", () => listAllAgents(ctx.app, ctx.auth.user));
2728 if (!agents.ok) return respondAccess(agents.error, 401);
2829 return timePhase(ctx.request, "render", () =>
29 json({
30 tagged(ctx.request, ctx.auth.user, {
3031 items: agents.value.map((a) => agentFor(a, ctx.auth.user)),
3132 next_cursor: null,
3233 }));
@@ -60,7 +61,7 @@ export function agentRoutes(router: Router<Handler>): void {
6061 if (!agent.ok) return respondAccess(agent.error, 404);
6162 const view = agent.value;
6263 return timePhase(ctx.request, "render", () =>
63 json(agentFor(view, ctx.auth.user)),
64 tagged(ctx.request, ctx.auth.user, agentFor(view, ctx.auth.user)),
6465 );
6566 });
6667
@@ -112,3 +113,16 @@ export function agentFor(agent: Agent, viewer: User | undefined) {
112113 createdAt: agent.createdAt,
113114 };
114115 }
116
117 /**
118 * A read answer with a weak ETag from its bytes and the viewer, so a repeat
119 * holding it costs a 304 and one viewer's tag never matches another's. Any
120 * change to an agent changes what it shows, so the tag moves with it.
121 */
122 function tagged(request: Request, viewer: User | undefined, data: unknown): Response {
123 const text = JSON.stringify(data);
124 const etag = weakEtag(`${viewer?.id ?? ""}\n${text}`);
125 const headers = { etag, "cache-control": "private", vary: "authorization, cookie" };
126 if (holds(request, etag)) return new Response(null, { status: 304, headers });
127 return json(data, 200, headers);
128 }
test/agents-etag.test.ts added +84
@@ -0,0 +1,84 @@
1 /** GET /api/v1/agents and /agents/:username carry a weak ETag and answer a repeat with 304 until an agent changes (#9277). */
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 { createToken } from "../src/core/tokens.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { quietLogs } from "./helpers/globals.ts";
11
12 quietLogs();
13
14 let dataDir = "";
15 let app: App, servers: Servers;
16 const tok: Record<string, string> = {};
17 const call = (method: string, path: string, token: string, extra: Record<string, string> = {}, body?: unknown) =>
18 fetch(`${servers.url}/api/v1/${path}`, {
19 method,
20 headers: { authorization: `Bearer ${token}`, ...(body ? { "content-type": "application/json" } : {}), ...extra },
21 ...(body ? { body: JSON.stringify(body) } : {}),
22 });
23 const tagOf = async (path: string, token = tok["admin"]!) => {
24 const r = await call("GET", path, token);
25 expect(r.status).toBe(200);
26 await r.text();
27 return r.headers.get("etag")!;
28 };
29
30 beforeAll(async () => {
31 dataDir = await mkdtemp(join(tmpdir(), "forge-agents-etag-"));
32 app = createApp({ dataDir, port: 0 });
33 servers = await serve(app);
34 const mk = async (username: string, first: boolean) => {
35 const made = await createUser(app.db, {
36 origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first,
37 });
38 if (!made.ok) throw new Error(String(made.error));
39 return made.value;
40 };
41 const ada = await mk("ada", true);
42 const bob = await mk("bob", false);
43 tok["admin"] = createToken(app.db, ada.id, "a", "admin").token;
44 tok["bob"] = createToken(app.db, bob.id, "b", "admin").token;
45 });
46
47 afterAll(async () => {
48 servers?.stop();
49 app?.close();
50 await rm(dataDir, { recursive: true, force: true });
51 });
52
53 test("a repeat is 304; every write moves the tag; another viewer's tag does not match", async () => {
54 const made = await call("POST", "agents", tok["admin"]!, {}, { username: "bot", brief: "one" });
55 expect(made.status).toBe(201);
56 for (const path of ["agents", "agents/bot"]) {
57 const r = await call("GET", path, tok["admin"]!);
58 const tag = r.headers.get("etag")!;
59 expect(tag).toMatch(/^W\//);
60 expect(r.headers.get("cache-control")).toBe("private");
61 expect(r.headers.get("vary")).toContain("authorization, cookie");
62 const again = await call("GET", path, tok["admin"]!, { "if-none-match": tag });
63 expect(again.status).toBe(304);
64 expect(again.headers.get("etag")).toBe(tag);
65 expect(again.headers.get("vary")).toContain("authorization, cookie");
66 const other = await call("GET", path, tok["bob"]!, { "if-none-match": tag });
67 expect(other.status).toBe(200);
68 expect(other.headers.get("etag")).not.toBe(tag);
69 }
70 const listTag = await tagOf("agents");
71 const oneTag = await tagOf("agents/bot");
72 expect((await call("PATCH", "agents/bot", tok["admin"]!, {}, { brief: "two" })).status).toBe(200);
73 const stale = await call("GET", "agents/bot", tok["admin"]!, { "if-none-match": oneTag });
74 expect(stale.status).toBe(200);
75 expect(stale.headers.get("etag")).not.toBe(oneTag);
76 expect((await call("GET", "agents", tok["admin"]!, { "if-none-match": listTag })).status).toBe(200);
77
78 const afterPatch = await tagOf("agents");
79 expect((await call("POST", "agents", tok["admin"]!, {}, { username: "bot2" })).status).toBe(201);
80 expect(await tagOf("agents")).not.toBe(afterPatch);
81 const afterPost = await tagOf("agents");
82 expect((await call("DELETE", "agents/bot2", tok["admin"]!)).status).toBe(200);
83 expect(await tagOf("agents")).not.toBe(afterPost);
84 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs