Public: anyone can read this; the crew can change it.
GET /api/v1/orgs/:org/teams and …/teams/:team/members carry an ETag and answer a repeat with 304 until a team or its members change #9171
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9165-get-api-v1-orgs-org-teams-and-teams-team → main 2 commits fork
4 files
changed +154
−4
src/contract/openapi/routes/getApiV1OrgsOrgTeams.ts +1 −1
| @@ -1,4 +1,4 @@ | ||
| 1 | 1 | import { type Route, limitTo } from "../shared.ts"; |
| 2 | 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; |
| 3 | 3 | |
| 4 | export const route: Route = { method: "get", path: "/api/v1/orgs/{org}/teams", summary: "List an organization's teams; answers { items, next_cursor }, paged by `limit` and `cursor`: all for an org admin, only the caller's own teams for a member, [] for a stranger or anonymous caller", auth: false, query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" } }; | |
| 4 | export const route: Route = { method: "get", path: "/api/v1/orgs/{org}/teams", summary: "List an organization's teams; answers { items, next_cursor }, paged by `limit` and `cursor`: all for an org admin, only the caller's own teams for a member, [] for a stranger or anonymous caller; the 200 carries a weak etag of the exact JSON and cache-control private, no-cache, and a repeat sending If-None-Match gets 304 with no body once access is checked", auth: false, alsoStatus: [304], query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" } }; |
src/contract/openapi/routes/getApiV1OrgsOrgTeamsTeamMembers.ts +1 −1
| @@ -1,4 +1,4 @@ | ||
| 1 | 1 | import { type Route, limitTo } from "../shared.ts"; |
| 2 | 2 | import { LIST_LIMIT_MAX } from "../../../core/issues.ts"; |
| 3 | 3 | |
| 4 | export const route: Route = { method: "get", path: "/api/v1/orgs/{org}/teams/{team}/members", summary: "List a team's members as public profiles; answers { items, next_cursor }, paged by `limit` and `cursor`; 404 when the caller may not see the team", auth: false, query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" } }; | |
| 4 | export const route: Route = { method: "get", path: "/api/v1/orgs/{org}/teams/{team}/members", summary: "List a team's members as public profiles; answers { items, next_cursor }, paged by `limit` and `cursor`; 404 when the caller may not see the team; the 200 carries a weak etag of the exact JSON and cache-control private, no-cache, and a repeat sending If-None-Match gets 304 with no body once access is checked", auth: false, alsoStatus: [304], query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" } }; |
src/http/api/orgs.ts +16 −2
| @@ -34,6 +34,7 @@ import { | ||
| 34 | 34 | str, |
| 35 | 35 | } from "../respond.ts"; |
| 36 | 36 | import type { Router } from "../router.ts"; |
| 37 | import { holds, weakEtag } from "../etag.ts"; | |
| 37 | 38 | |
| 38 | 39 | /** An org by name plus the team a route names (by name, case-insensitive). */ |
| 39 | 40 | function loadTeam(ctx: Ctx): { org: User; team: Team } | { error: Response } { |
| @@ -53,6 +54,19 @@ function teamVisible(ctx: Ctx, org: User, team: Team): boolean { | ||
| 53 | 54 | ); |
| 54 | 55 | } |
| 55 | 56 | |
| 57 | /** | |
| 58 | * A reader-scoped JSON answer tagged by its exact bytes: a repeat holding the | |
| 59 | * tag gets 304. Callers check access first, so a 304 confirms nothing to | |
| 60 | * someone who may not read it. | |
| 61 | */ | |
| 62 | function tagged(request: Request, data: unknown): Response { | |
| 63 | const text = JSON.stringify(data); | |
| 64 | const etag = weakEtag(text); | |
| 65 | const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" }; | |
| 66 | if (holds(request, etag)) return new Response(null, { status: 304, headers }); | |
| 67 | return new Response(text, { headers: { "content-type": "application/json", ...headers } }); | |
| 68 | } | |
| 69 | ||
| 56 | 70 | const permissionOf = (raw: unknown): Permission => { |
| 57 | 71 | const p = str(raw); |
| 58 | 72 | return p === "write" || p === "admin" ? p : "read"; |
| @@ -93,7 +107,7 @@ export function orgRoutes(router: Router<Handler>): void { | ||
| 93 | 107 | listTeamsVisibleTo(ctx.app.db, org.id, ctx.auth.user), |
| 94 | 108 | ); |
| 95 | 109 | return timePhase(ctx.request, "render", () => |
| 96 | json(keyedPage(teams, (t) => t.name, window)), | |
| 110 | tagged(ctx.request, keyedPage(teams, (t) => t.name, window)), | |
| 97 | 111 | ); |
| 98 | 112 | }); |
| 99 | 113 | |
| @@ -135,7 +149,7 @@ export function orgRoutes(router: Router<Handler>): void { | ||
| 135 | 149 | listTeamMembers(ctx.app.db, found.team.id).map(publicProfile), |
| 136 | 150 | ); |
| 137 | 151 | return timePhase(ctx.request, "render", () => |
| 138 | json(keyedPage(members, (u) => u.username, window)), | |
| 152 | tagged(ctx.request, keyedPage(members, (u) => u.username, window)), | |
| 139 | 153 | ); |
| 140 | 154 | }); |
| 141 | 155 |
test/org-teams-etag.test.ts added +136
| @@ -0,0 +1,136 @@ | ||
| 1 | /** The org teams and team members lists carry an ETag; a repeat is a 304 until a team or its members change. */ | |
| 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 { createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | ||
| 12 | describe("org teams ETag", () => { | |
| 13 | let dataDir: string; | |
| 14 | let app: App; | |
| 15 | let servers: Servers; | |
| 16 | let owner: string; | |
| 17 | let stranger: string; | |
| 18 | ||
| 19 | const send = (path: string, token: string | null, method = "GET", extra: Record<string, string> = {}, payload?: unknown) => | |
| 20 | fetch(`${servers.url}${path}`, { | |
| 21 | method, | |
| 22 | headers: { | |
| 23 | ...(token ? { authorization: `Bearer ${token}` } : {}), | |
| 24 | "content-type": "application/json", | |
| 25 | ...extra, | |
| 26 | }, | |
| 27 | ...(payload === undefined ? {} : { body: JSON.stringify(payload) }), | |
| 28 | }); | |
| 29 | const T = "/api/v1/orgs/acme/teams"; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | dataDir = await mkdtemp(join(tmpdir(), "forge-org-teams-etag-")); | |
| 33 | app = createApp({ dataDir, port: 0 }); | |
| 34 | servers = await serve(app); | |
| 35 | const mk = async (username: string) => { | |
| 36 | const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase" }); | |
| 37 | if (!u.ok) throw new Error(u.error); | |
| 38 | return u.value; | |
| 39 | }; | |
| 40 | const a = await mk("alice"); | |
| 41 | await mk("bob"); | |
| 42 | await mk("dave"); | |
| 43 | const c = await mk("carol"); | |
| 44 | owner = createToken(app.db, a.id, "admin", "admin").token; | |
| 45 | stranger = createToken(app.db, c.id, "admin", "admin").token; | |
| 46 | const org = createOrg(app.db, "acme", a.id); | |
| 47 | if (!org.ok) throw new Error(org.error); | |
| 48 | for (const name of ["docs", "ops"]) { | |
| 49 | const team = createTeam(app.db, org.value.id, name, "write"); | |
| 50 | if (!team.ok) throw new Error(team.error); | |
| 51 | } | |
| 52 | }, 30_000); | |
| 53 | ||
| 54 | afterAll(async () => { | |
| 55 | servers.stop(); | |
| 56 | app.close(); | |
| 57 | await rm(dataDir, { recursive: true, force: true }); | |
| 58 | }); | |
| 59 | ||
| 60 | test("the teams list is 200 with a weak tag, then 304 with the same headers and no body", async () => { | |
| 61 | const first = await send(T, owner); | |
| 62 | expect(first.status).toBe(200); | |
| 63 | const tag = first.headers.get("etag")!; | |
| 64 | expect(tag).toMatch(/^W\/"/); | |
| 65 | expect(first.headers.get("cache-control")).toBe("private, no-cache"); | |
| 66 | expect(first.headers.get("vary")).toContain("authorization, cookie"); | |
| 67 | expect(((await first.json()) as { items: unknown[] }).items.length).toBe(3); | |
| 68 | const again = await send(T, owner, "GET", { "if-none-match": tag }); | |
| 69 | expect(again.status).toBe(304); | |
| 70 | expect(again.headers.get("etag")).toBe(tag); | |
| 71 | expect(again.headers.get("cache-control")).toBe("private, no-cache"); | |
| 72 | expect(again.headers.get("vary")).toContain("authorization, cookie"); | |
| 73 | expect(again.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 74 | expect(again.headers.get("referrer-policy")).not.toBeNull(); | |
| 75 | expect(await again.text()).toBe(""); | |
| 76 | }); | |
| 77 | ||
| 78 | test("creating and removing a team moves the teams tag", async () => { | |
| 79 | const t0 = (await send(T, owner)).headers.get("etag")!; | |
| 80 | expect((await send(T, owner, "POST", {}, { name: "extra" })).status).toBe(201); | |
| 81 | const t1 = await send(T, owner, "GET", { "if-none-match": t0 }); | |
| 82 | expect(t1.status).toBe(200); | |
| 83 | const tag1 = t1.headers.get("etag")!; | |
| 84 | expect(tag1).not.toBe(t0); | |
| 85 | expect((await send(`${T}/extra`, owner, "DELETE")).status).toBe(204); | |
| 86 | const t2 = await send(T, owner, "GET", { "if-none-match": tag1 }); | |
| 87 | expect(t2.status).toBe(200); | |
| 88 | expect(t2.headers.get("etag")).not.toBe(tag1); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a member added or removed moves that team's tag and not another's", async () => { | |
| 92 | const docs = `${T}/docs/members`; | |
| 93 | const ops = `${T}/ops/members`; | |
| 94 | const d0 = (await send(docs, owner)).headers.get("etag")!; | |
| 95 | const o0 = (await send(ops, owner)).headers.get("etag")!; | |
| 96 | expect(d0).toMatch(/^W\//); | |
| 97 | expect((await send(docs, owner, "GET", { "if-none-match": d0 })).status).toBe(304); | |
| 98 | expect((await send(docs, owner, "POST", {}, { user: "bob" })).status).toBe(201); | |
| 99 | const d1 = await send(docs, owner, "GET", { "if-none-match": d0 }); | |
| 100 | expect(d1.status).toBe(200); | |
| 101 | const dTag = d1.headers.get("etag")!; | |
| 102 | expect(dTag).not.toBe(d0); | |
| 103 | expect((await send(ops, owner, "GET", { "if-none-match": o0 })).status).toBe(304); | |
| 104 | expect((await send(`${T}/docs/members/bob`, owner, "DELETE")).status).toBe(204); | |
| 105 | const d2 = await send(docs, owner, "GET", { "if-none-match": dTag }); | |
| 106 | expect(d2.status).toBe(200); | |
| 107 | expect(d2.headers.get("etag")).not.toBe(dTag); | |
| 108 | expect((await send(ops, owner, "GET", { "if-none-match": o0 })).status).toBe(304); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a 404 carries no tag and a viewer who cannot see the team never gets a 304", async () => { | |
| 112 | const own = (await send(`${T}/docs/members`, owner)).headers.get("etag")!; | |
| 113 | for (const path of ["/api/v1/orgs/nope/teams", "/api/v1/orgs/nope/teams/x/members", `${T}/nope/members`]) { | |
| 114 | const out = await send(path, owner, "GET", { "if-none-match": own }); | |
| 115 | expect(out.status, path).toBe(404); | |
| 116 | expect(out.headers.get("etag"), path).toBeNull(); | |
| 117 | } | |
| 118 | for (const who of [stranger, null]) { | |
| 119 | for (const t of [own, "*"]) { | |
| 120 | const out = await send(`${T}/docs/members`, who, "GET", { "if-none-match": t }); | |
| 121 | expect(out.status).toBe(404); | |
| 122 | expect(out.headers.get("etag")).toBeNull(); | |
| 123 | } | |
| 124 | } | |
| 125 | const ownTeams = (await send(T, owner)).headers.get("etag")!; | |
| 126 | const theirs = await send(T, stranger, "GET", { "if-none-match": ownTeams }); | |
| 127 | expect(theirs.status).toBe(200); | |
| 128 | expect(theirs.headers.get("etag")).not.toBe(ownTeams); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a refused write carries no tag", async () => { | |
| 132 | const out = await send(T, stranger, "POST", {}, { name: "mine" }); | |
| 133 | expect(out.status).toBeGreaterThanOrEqual(400); | |
| 134 | expect(out.headers.get("etag")).toBeNull(); | |
| 135 | }); | |
| 136 | }); |
new above
offline