Public: anyone can read this; the crew can change it.
GET /api/v1/version carries an ETag and answers a repeat with 304; a harness probing it on every connect pays a header #9257
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9251-get-api-v1-version-carries-an-etag-and-a → main 2 commits fork
2 files
changed +81
−1
src/http/api/forge_origin.ts +8 −1
| @@ -53,7 +53,14 @@ function whole(raw: string | null, fallback: number, max: number): number { | ||
| 53 | 53 | export function forgeOriginRoutes(router: Router<Handler>): void { |
| 54 | 54 | router.add("GET", "/api/v1/version", (ctx) => { |
| 55 | 55 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 56 | return timePhase(ctx.request, "render", () => json({ version: FORGE_API_VERSION, software: "forge" })); | |
| 56 | return timePhase(ctx.request, "render", () => { | |
| 57 | // The same body for everyone, so the tag is derived from it alone. | |
| 58 | const text = JSON.stringify({ version: FORGE_API_VERSION, software: "forge" }); | |
| 59 | const etag = weakEtag(text); | |
| 60 | const headers = { etag, "cache-control": "no-cache" }; | |
| 61 | if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers }); | |
| 62 | return new Response(text, { headers: { "content-type": "application/json", ...headers } }); | |
| 63 | }); | |
| 57 | 64 | }); |
| 58 | 65 | |
| 59 | 66 | // The signed-in account's own repositories, `page` and `limit` as Gitea |
test/version-etag.test.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** GET /api/v1/version carries an ETag; a repeat is a 304 until FORGE_API_VERSION changes. */ | |
| 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 { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { FORGE_API_VERSION } from "../src/http/api/forge_origin.ts"; | |
| 10 | import { weakEtag } from "../src/http/etag.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | ||
| 13 | describe("version ETag", () => { | |
| 14 | let dataDir: string; | |
| 15 | let app: App; | |
| 16 | let servers: Servers; | |
| 17 | let token: string; | |
| 18 | const V = "/api/v1/version"; | |
| 19 | const body = JSON.stringify({ version: FORGE_API_VERSION, software: "forge" }); | |
| 20 | const expected = weakEtag(body); | |
| 21 | ||
| 22 | const get = (extra: Record<string, string> = {}) => | |
| 23 | fetch(`${servers.url}${V}`, { headers: extra }); | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dataDir = await mkdtemp(join(tmpdir(), "forge-version-etag-")); | |
| 27 | app = createApp({ dataDir, port: 0 }); | |
| 28 | servers = await serve(app); | |
| 29 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase" }); | |
| 30 | if (!u.ok) throw new Error(u.error); | |
| 31 | token = createToken(app.db, u.value.id, "admin", "admin").token; | |
| 32 | }, 30_000); | |
| 33 | ||
| 34 | afterAll(async () => { | |
| 35 | servers.stop(); | |
| 36 | app.close(); | |
| 37 | await rm(dataDir, { recursive: true, force: true }); | |
| 38 | }); | |
| 39 | ||
| 40 | test("200 with a weak tag derived from the body, no-cache, no vary", async () => { | |
| 41 | const res = await get(); | |
| 42 | expect(res.status).toBe(200); | |
| 43 | expect(res.headers.get("etag")).toBe(expected); | |
| 44 | expect(res.headers.get("cache-control")).toBe("no-cache"); | |
| 45 | expect(res.headers.get("vary") ?? "").not.toMatch(/authorization|cookie/i); | |
| 46 | expect(await res.text()).toBe(body); | |
| 47 | }); | |
| 48 | ||
| 49 | test("a repeat is a 304 with the same headers, no body and the security floor", async () => { | |
| 50 | const res = await get({ "if-none-match": expected }); | |
| 51 | expect(res.status).toBe(304); | |
| 52 | expect(res.headers.get("etag")).toBe(expected); | |
| 53 | expect(res.headers.get("cache-control")).toBe("no-cache"); | |
| 54 | expect(res.headers.get("vary") ?? "").not.toMatch(/authorization|cookie/i); | |
| 55 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 56 | expect(res.headers.get("referrer-policy")).not.toBeNull(); | |
| 57 | expect(await res.text()).toBe(""); | |
| 58 | expect((await get({ "if-none-match": "*" })).status).toBe(304); | |
| 59 | }); | |
| 60 | ||
| 61 | test("a signed-in and an anonymous caller get the same tag", async () => { | |
| 62 | const anon = await get(); | |
| 63 | const user = await get({ authorization: `Bearer ${token}` }); | |
| 64 | expect(user.headers.get("etag")).toBe(anon.headers.get("etag")); | |
| 65 | expect((await get({ authorization: `Bearer ${token}`, "if-none-match": expected })).status).toBe(304); | |
| 66 | }); | |
| 67 | ||
| 68 | test("a stale tag gets a 200 with the body", async () => { | |
| 69 | const res = await get({ "if-none-match": weakEtag("{}") }); | |
| 70 | expect(res.status).toBe(200); | |
| 71 | expect(await res.text()).toBe(body); | |
| 72 | }); | |
| 73 | }); |
new above
offline