Public: anyone can read this; the crew can change it.
GET /api/v1/admin/people, /admin/invites and /admin/accounts carry an ETag and answer a repeat with 304 until who has access changes #9229
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9224-get-api-v1-admin-people-admin-invites-an → main 2 commits fork
2 files
changed +217
−3
src/http/api/admin.ts +18 −3
| @@ -27,6 +27,7 @@ import { lastOwnerSays, soleOwnedOrgs } from "../../core/orgs.ts"; | ||
| 27 | 27 | import { listPeople } from "../../core/roles.ts"; |
| 28 | 28 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 29 | 29 | import { getUserByName } from "../../core/users.ts"; |
| 30 | import { holds, weakEtag } from "../etag.ts"; | |
| 30 | 31 | import { offsetPage, offsetQuery } from "../offset_page.ts"; |
| 31 | 32 | import { body, json, str } from "../respond.ts"; |
| 32 | 33 | import { offsetRefused } from "../api.ts"; |
| @@ -38,6 +39,20 @@ import type { Router } from "../router.ts"; | ||
| 38 | 39 | export const accountAdmin = (ctx: Ctx, scope: "ro" | "admin"): Response | undefined => |
| 39 | 40 | ctx.auth.user?.isAdmin === true ? requireScope(ctx, scope) : notFound(); |
| 40 | 41 | |
| 42 | /** | |
| 43 | * Who can reach the forge, polled often and changed rarely (#9224): the tag | |
| 44 | * is of the exact bytes sent, so a person, invite or page change moves it and | |
| 45 | * two viewers shown different bodies never share one. Called only past the | |
| 46 | * access and query checks, which carry no tag. | |
| 47 | */ | |
| 48 | const taggedJson = (ctx: Ctx, data: unknown): Response => { | |
| 49 | const text = JSON.stringify(data); | |
| 50 | const etag = weakEtag(text); | |
| 51 | const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" }; | |
| 52 | if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers }); | |
| 53 | return new Response(text, { headers: { "content-type": "application/json", ...headers } }); | |
| 54 | }; | |
| 55 | ||
| 41 | 56 | export function adminRoutes(router: Router<Handler>): void { |
| 42 | 57 | // ---- pinned forges (#8697): one deploy, and a change only by Update ---- |
| 43 | 58 | const pinnedFail = (error: string): Response => |
| @@ -297,7 +312,7 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 297 | 312 | const query = offsetQuery(ctx.url.searchParams); |
| 298 | 313 | if ("error" in query) return offsetRefused(query.error); |
| 299 | 314 | const listed = timePhase(ctx.request, "invites", () => listInviteLinks(ctx.app, ctx.auth.user)); |
| 300 | return listed.ok ? timePhase(ctx.request, "render", () => json(offsetPage(listed.value, query.limit, query.offset))) : notFound(); | |
| 315 | return listed.ok ? timePhase(ctx.request, "render", () => taggedJson(ctx, offsetPage(listed.value, query.limit, query.offset))) : notFound(); | |
| 301 | 316 | }); |
| 302 | 317 | |
| 303 | 318 | router.add("DELETE", "/api/v1/admin/invites/:id", (ctx) => { |
| @@ -314,7 +329,7 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 314 | 329 | const query = offsetQuery(ctx.url.searchParams); |
| 315 | 330 | if ("error" in query) return offsetRefused(query.error); |
| 316 | 331 | const listed = timePhase(ctx.request, "accounts", () => listAccounts(ctx.app, ctx.auth.user)); |
| 317 | return listed.ok ? timePhase(ctx.request, "render", () => json(offsetPage(listed.value, query.limit, query.offset))) : notFound(); | |
| 332 | return listed.ok ? timePhase(ctx.request, "render", () => taggedJson(ctx, offsetPage(listed.value, query.limit, query.offset))) : notFound(); | |
| 318 | 333 | }); |
| 319 | 334 | |
| 320 | 335 | router.add("POST", "/api/v1/admin/accounts/:username/suspend", async (ctx) => { |
| @@ -358,7 +373,7 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 358 | 373 | const query = offsetQuery(ctx.url.searchParams); |
| 359 | 374 | if ("error" in query) return offsetRefused(query.error); |
| 360 | 375 | const people = timePhase(ctx.request, "people", () => listPeople(ctx.app.db)); |
| 361 | return timePhase(ctx.request, "render", () => json(offsetPage(people, query.limit, query.offset))); | |
| 376 | return timePhase(ctx.request, "render", () => taggedJson(ctx, offsetPage(people, query.limit, query.offset))); | |
| 362 | 377 | }); |
| 363 | 378 | |
| 364 | 379 | // Make a person a member (with an optional starter allowance, dollars a month) or a guest again. |
test/admin-people-etag.test.ts added +199
| @@ -0,0 +1,199 @@ | ||
| 1 | /** GET /api/v1/admin/people, /admin/invites and /admin/accounts carry a weak ETag and answer a repeat with 304 until who has access changes (#9224). */ | |
| 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 { redeemInvite } from "../src/core/invites.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 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | import { proveAll } from "./helpers/verified.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | const ROUTES = ["people", "invites", "accounts"] as const; | |
| 17 | type Route = (typeof ROUTES)[number]; | |
| 18 | ||
| 19 | let dataDir = ""; | |
| 20 | let app: App, servers: Servers; | |
| 21 | let sam: User, bob: User; | |
| 22 | let adminTok = "", roTok = "", rwTok = "", bobTok = ""; | |
| 23 | const url = (route: Route, query = "") => `${servers.url}/api/v1/admin/${route}${query}`; | |
| 24 | const get = (route: Route, headers: Record<string, string> = {}, query = "") => fetch(url(route, query), { headers }); | |
| 25 | const asSam = (route: Route, headers: Record<string, string> = {}, query = "") => | |
| 26 | get(route, { authorization: `Bearer ${adminTok}`, ...headers }, query); | |
| 27 | const tags = async (): Promise<Record<Route, string>> => { | |
| 28 | const out = {} as Record<Route, string>; | |
| 29 | for (const route of ROUTES) { | |
| 30 | const r = await asSam(route); | |
| 31 | expect(r.status).toBe(200); | |
| 32 | await r.text(); | |
| 33 | out[route] = r.headers.get("etag")!; | |
| 34 | } | |
| 35 | return out; | |
| 36 | }; | |
| 37 | /** Each named route's tag moved and settles to a 304 again; the rest held still. */ | |
| 38 | async function moved(before: Record<Route, string>, what: string, which: Route[]): Promise<Record<Route, string>> { | |
| 39 | const after = await tags(); | |
| 40 | for (const route of ROUTES) { | |
| 41 | if (which.includes(route)) expect(after[route], `${what}: ${route}`).not.toBe(before[route]); | |
| 42 | const again = await asSam(route, { "if-none-match": after[route] }); | |
| 43 | expect(again.status, `${what}: ${route}`).toBe(304); | |
| 44 | } | |
| 45 | return after; | |
| 46 | } | |
| 47 | const mk = async (username: string) => { | |
| 48 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst: username === "sam" }); | |
| 49 | if (!r.ok) throw new Error("user"); | |
| 50 | return r.value; | |
| 51 | }; | |
| 52 | const post = (path: string, body: unknown, method = "POST") => | |
| 53 | fetch(`${servers.url}/api/v1/admin/${path}`, { | |
| 54 | method, | |
| 55 | headers: { "content-type": "application/json", authorization: `Bearer ${adminTok}` }, | |
| 56 | body: JSON.stringify(body), | |
| 57 | }); | |
| 58 | ||
| 59 | beforeAll(async () => { | |
| 60 | dataDir = await mkdtemp(join(tmpdir(), "forge-adminpeopleetag-")); | |
| 61 | app = createApp({ dataDir, port: 0 }); | |
| 62 | sam = await mk("sam"); | |
| 63 | bob = await mk("bob"); | |
| 64 | await mk("carol"); | |
| 65 | proveAll(app); | |
| 66 | servers = await serve(app); | |
| 67 | adminTok = createToken(app.db, sam.id, "admin", "admin").token; | |
| 68 | roTok = createToken(app.db, sam.id, "ro", "ro").token; | |
| 69 | rwTok = createToken(app.db, sam.id, "rw", "rw").token; | |
| 70 | bobTok = createToken(app.db, bob.id, "bob", "rw").token; | |
| 71 | }); | |
| 72 | afterAll(async () => { | |
| 73 | servers.stop(); | |
| 74 | app.close(); | |
| 75 | await rm(dataDir, { recursive: true, force: true }); | |
| 76 | }); | |
| 77 | ||
| 78 | test("each route answers 200 with a weak tag, private no-cache and vary over the same body; a repeat is a bodiless 304 with the floor headers", async () => { | |
| 79 | const made = await post("invites", { days: 7 }); | |
| 80 | expect(made.status).toBe(201); | |
| 81 | for (const route of ROUTES) { | |
| 82 | const res = await asSam(route); | |
| 83 | expect(res.status, route).toBe(200); | |
| 84 | const tag = res.headers.get("etag")!; | |
| 85 | expect(tag, route).toMatch(/^W\//); | |
| 86 | expect(res.headers.get("cache-control"), route).toBe("private, no-cache"); | |
| 87 | expect(res.headers.get("vary"), route).toContain("authorization, cookie"); | |
| 88 | expect(res.headers.get("content-type"), route).toBe("application/json"); | |
| 89 | const text = await res.text(); | |
| 90 | expect(JSON.parse(text).items.length, route).toBeGreaterThan(0); | |
| 91 | const again = await asSam(route, { "if-none-match": tag }); | |
| 92 | expect(again.status, route).toBe(304); | |
| 93 | expect(again.headers.get("etag"), route).toBe(tag); | |
| 94 | expect(again.headers.get("cache-control"), route).toBe("private, no-cache"); | |
| 95 | expect(again.headers.get("vary"), route).toContain("authorization, cookie"); | |
| 96 | expect(again.headers.get("x-content-type-options"), route).toBe("nosniff"); | |
| 97 | expect(again.headers.get("content-security-policy"), route).not.toBeNull(); | |
| 98 | expect(again.headers.get("referrer-policy"), route).not.toBeNull(); | |
| 99 | expect(await again.text(), route).toBe(""); | |
| 100 | } | |
| 101 | }); | |
| 102 | ||
| 103 | test("a person added, made admin, suspended or removed, and an invite made, used or revoked move the tag", async () => { | |
| 104 | let t = await tags(); | |
| 105 | ||
| 106 | const dave = await mk("dave"); | |
| 107 | t = await moved(t, "person added", ["people", "accounts"]); | |
| 108 | ||
| 109 | app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(dave.id); | |
| 110 | t = await moved(t, "made admin", ["people"]); | |
| 111 | app.db.query("UPDATE user SET is_admin = 0 WHERE id = ?1").run(dave.id); | |
| 112 | t = await moved(t, "admin taken back", ["people"]); | |
| 113 | ||
| 114 | const suspended = await post("accounts/carol/suspend", {}); | |
| 115 | expect(suspended.status).toBe(200); | |
| 116 | t = await moved(t, "suspended", ["accounts"]); | |
| 117 | ||
| 118 | const made = await post("invites", { days: 7 }); | |
| 119 | expect(made.status).toBe(201); | |
| 120 | const invite = (await made.json()) as { id: string; token: string }; | |
| 121 | t = await moved(t, "invite made", ["invites"]); | |
| 122 | ||
| 123 | const erin = await mk("erin"); | |
| 124 | expect(redeemInvite(app.db, invite.token, erin.id)).toBe(true); | |
| 125 | t = await moved(t, "invite used", ["invites"]); | |
| 126 | ||
| 127 | const other = (await (await post("invites", { days: 3 })).json()) as { id: string }; | |
| 128 | t = await tags(); | |
| 129 | const revoked = await fetch(url("invites") + `/${other.id}`, { method: "DELETE", headers: { authorization: `Bearer ${adminTok}` } }); | |
| 130 | expect(revoked.status).toBe(200); | |
| 131 | t = await moved(t, "invite revoked", ["invites"]); | |
| 132 | ||
| 133 | const removed = await fetch(url("accounts") + "/dave", { | |
| 134 | method: "DELETE", | |
| 135 | headers: { "content-type": "application/json", authorization: `Bearer ${adminTok}` }, | |
| 136 | body: JSON.stringify({ confirm: true }), | |
| 137 | }); | |
| 138 | expect(removed.status).toBeLessThan(300); | |
| 139 | await removed.text(); | |
| 140 | await moved(t, "person removed", ["people", "accounts"]); | |
| 141 | }); | |
| 142 | ||
| 143 | test("limit and cursor move the tag, and one page's tag buys another page nothing", async () => { | |
| 144 | for (const route of ROUTES) { | |
| 145 | const whole = await asSam(route); | |
| 146 | const wholeTag = whole.headers.get("etag")!; | |
| 147 | await whole.text(); | |
| 148 | const first = await asSam(route, {}, "?limit=1"); | |
| 149 | expect(first.status, route).toBe(200); | |
| 150 | const firstTag = first.headers.get("etag")!; | |
| 151 | await first.text(); | |
| 152 | const second = await asSam(route, {}, "?limit=1&cursor=1"); | |
| 153 | const secondTag = second.headers.get("etag")!; | |
| 154 | const secondBody = await second.text(); | |
| 155 | expect(firstTag, route).not.toBe(wholeTag); | |
| 156 | expect(secondTag, route).not.toBe(firstTag); | |
| 157 | const replay = await asSam(route, { "if-none-match": firstTag }, "?limit=1&cursor=1"); | |
| 158 | expect(replay.status, route).toBe(200); | |
| 159 | expect(await replay.text(), route).toBe(secondBody); | |
| 160 | const own = await asSam(route, { "if-none-match": secondTag }, "?limit=1&cursor=1"); | |
| 161 | expect(own.status, route).toBe(304); | |
| 162 | } | |
| 163 | }); | |
| 164 | ||
| 165 | test("a non-admin, a token short of the scope, and a bad limit or cursor answer as before, with no tag, even holding If-None-Match: *", async () => { | |
| 166 | const star = { "if-none-match": "*" }; | |
| 167 | for (const route of ROUTES) { | |
| 168 | const anon = await get(route, star); | |
| 169 | expect(anon.status, route).not.toBe(304); | |
| 170 | expect(anon.status, route).toBeGreaterThanOrEqual(400); | |
| 171 | expect(anon.headers.get("etag"), route).toBeNull(); | |
| 172 | ||
| 173 | const plain = await get(route, { authorization: `Bearer ${bobTok}` }); | |
| 174 | const notAdmin = await get(route, { authorization: `Bearer ${bobTok}`, ...star }); | |
| 175 | expect(notAdmin.status, route).toBeGreaterThanOrEqual(400); | |
| 176 | expect(notAdmin.status, route).toBe(plain.status); | |
| 177 | expect(await notAdmin.text(), route).toBe(await plain.text()); | |
| 178 | expect(notAdmin.headers.get("etag"), route).toBeNull(); | |
| 179 | ||
| 180 | for (const query of ["?limit=0", "?limit=abc", "?limit=999", "?cursor=0", "?cursor=x"]) { | |
| 181 | const bad = await asSam(route, star, query); | |
| 182 | expect(bad.status, `${route}${query}`).toBe(422); | |
| 183 | expect(bad.headers.get("etag"), `${route}${query}`).toBeNull(); | |
| 184 | expect(bad.headers.get("cache-control"), `${route}${query}`).not.toBe("private, no-cache"); | |
| 185 | } | |
| 186 | } | |
| 187 | ||
| 188 | // people asks for the admin scope; invites and accounts for ro. | |
| 189 | const shortOfAdmin = await get("people", { authorization: `Bearer ${roTok}`, ...star }); | |
| 190 | expect(shortOfAdmin.status).toBe(403); | |
| 191 | expect(shortOfAdmin.headers.get("etag")).toBeNull(); | |
| 192 | const rwPeople = await get("people", { authorization: `Bearer ${rwTok}`, ...star }); | |
| 193 | expect(rwPeople.status).toBe(403); | |
| 194 | expect(rwPeople.headers.get("etag")).toBeNull(); | |
| 195 | for (const route of ["invites", "accounts"] as const) { | |
| 196 | const ro = await get(route, { authorization: `Bearer ${roTok}`, ...star }); | |
| 197 | expect(ro.status, route).toBe(304); | |
| 198 | } | |
| 199 | }); |
new above
offline