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

GET /api/v1/repos/:owner/:name/prompts carries an ETag and answers a repeat with 304 until a prompt source changes #9221

Merged opened this

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

Merged

mason/forge:mason/9201-get-api-v1-repos-owner-name-prompts-carr → main 2 commits fork

  • Merged. mason/9201-get-api-v1-repos-owner-name-prompts-carr was merged into main as e2ea633.

Review

Sign in to review this change.
2 files changed +117 −1
src/http/api/prompts.ts +10 −1
@@ -13,6 +13,7 @@ import {
1313 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1414 import { loadRepo } from "../api.ts";
1515 import type { Handler } from "../context.ts";
16 import { holds, weakEtag } from "../etag.ts";
1617 import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts";
1718 import type { Router } from "../router.ts";
1819
@@ -28,7 +29,15 @@ export function promptRoutes(router: Router<Handler>): void {
2829 if (!repo) return notFound();
2930 const listed = timePhase(ctx.request, "prompts", () => listPromptSources(ctx.app, ctx.auth.user, repo));
3031 if (!listed.ok) return respondAccess(listed.error, 422);
31 return timePhase(ctx.request, "render", () => json({ items: listed.value, next_cursor: null }));
32 // Tagged only after the access check, over the exact bytes sent: an added, edited, run or removed
33 // source moves it, and a viewer shown a different body gets a different tag.
34 return timePhase(ctx.request, "render", () => {
35 const text = JSON.stringify({ items: listed.value, next_cursor: null });
36 const etag = weakEtag(text);
37 const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" };
38 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
39 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
40 });
3241 });
3342
3443 router.add("POST", "/api/v1/repos/:owner/:name/prompts", async (ctx) => {
test/prompts-etag.test.ts added +107
@@ -0,0 +1,107 @@
1 /** GET …/prompts carries a weak ETag and answers a repeat with 304 until a prompt source moves (#9201). */
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 { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { addPromptSource, listPromptSources, removePromptSource } from "../src/core/prompts.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir = "";
17 let app: App, servers: Servers;
18 let repoId = "";
19 const tok: Record<string, string> = {};
20 const get = (token: string | null, path = "ada/box/prompts", headers: Record<string, string> = {}) =>
21 fetch(`${servers.url}/api/v1/repos/${path}`, { headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...headers } });
22 const tagOf = async () => {
23 const r = await get(tok["admin"]!);
24 expect(r.status).toBe(200);
25 await r.text();
26 return r.headers.get("etag")!;
27 };
28 const add = (at: string) => {
29 const r = addPromptSource(app.db, dataDir, { repoId, kind: "instagram", token: "tok-123", atUtc: at });
30 if (!r.ok) throw new Error(r.error);
31 return r.value;
32 };
33
34 beforeAll(async () => {
35 dataDir = await mkdtemp(join(tmpdir(), "forge-prompts-etag-"));
36 app = createApp({ dataDir, port: 0 });
37 servers = await serve(app);
38 const mk = async (username: string, first: boolean) => {
39 const made = await createUser(app.db, {
40 origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first,
41 });
42 if (!made.ok) throw new Error(String(made.error));
43 return made.value;
44 };
45 const ada = await mk("ada", true);
46 const bob = await mk("bob", false);
47 const r = await createRepo(app, ada, { name: "box", visibility: "public" });
48 if (!r.ok) throw new Error("repo");
49 repoId = r.value.id;
50 tok["admin"] = createToken(app.db, ada.id, "a", "admin").token;
51 tok["ro"] = createToken(app.db, ada.id, "r", "ro").token;
52 tok["bob"] = createToken(app.db, bob.id, "b", "admin").token;
53 }, 30_000);
54
55 afterAll(async () => {
56 servers?.stop();
57 app?.close();
58 await rm(dataDir, { recursive: true, force: true });
59 });
60
61 test("200 carries a weak tag, private no-cache and vary; a repeat is a bodiless 304 with the floor headers", async () => {
62 const res = await get(tok["admin"]!);
63 expect(res.status).toBe(200);
64 const tag = res.headers.get("etag")!;
65 expect(tag).toMatch(/^W\//);
66 expect(res.headers.get("cache-control")).toBe("private, no-cache");
67 expect(res.headers.get("vary")).toContain("authorization, cookie");
68 expect(res.headers.get("content-type")).toBe("application/json");
69 expect(await res.json()).toEqual({ items: [], next_cursor: null });
70 const again = await get(tok["admin"]!, "ada/box/prompts", { "if-none-match": tag });
71 expect(again.status).toBe(304);
72 expect(again.headers.get("etag")).toBe(tag);
73 expect(again.headers.get("cache-control")).toBe("private, no-cache");
74 expect(again.headers.get("vary")).toContain("authorization, cookie");
75 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
76 expect(again.headers.get("content-security-policy")).not.toBeNull();
77 expect(await again.text()).toBe("");
78 });
79
80 test("adding, editing and removing a source each move the tag", async () => {
81 const empty = await tagOf();
82 const source = add("09:00");
83 const added = await tagOf();
84 expect(added).not.toBe(empty);
85 expect((await get(tok["admin"]!, "ada/box/prompts", { "if-none-match": empty })).status).toBe(200);
86 app.db.query("UPDATE prompt_source SET last_said = 'no new Reel' WHERE id = ?1").run(source.id);
87 const edited = await tagOf();
88 expect(edited).not.toBe(added);
89 expect((await get(tok["admin"]!, "ada/box/prompts", { "if-none-match": edited })).status).toBe(304);
90 expect(removePromptSource(app.db, source.id)).toBe(true);
91 expect(listPromptSources(app.db, repoId)).toHaveLength(0);
92 expect(await tagOf()).toBe(empty);
93 });
94
95 test("a token short of admin, a non-admin and a missing repository answer as before, with no tag, even for *", async () => {
96 add("10:00");
97 for (const [token, path, status] of [
98 [tok["ro"]!, "ada/box/prompts", 403],
99 [tok["bob"]!, "ada/box/prompts", 404],
100 [tok["admin"]!, "ada/nope/prompts", 404],
101 [null, "ada/box/prompts", 401],
102 ] as const) {
103 const res = await get(token, path, { "if-none-match": "*" });
104 expect(res.status).toBe(status);
105 expect(res.headers.get("etag")).toBeNull();
106 }
107 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs