Public: anyone can read this; the crew can change it.
GET /api/v1/repos/:owner/:name/app and …/app/env carry an ETag and answer a repeat with 304 until the app or its environment changes #9232
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9227-get-api-v1-repos-owner-name-app-and-app → main 2 commits fork
2 files
changed +162
−2
src/http/api/app.ts +2 −2
| @@ -72,7 +72,7 @@ export function appRoutes(router: Router<Handler>): void { | ||
| 72 | 72 | }); |
| 73 | 73 | if (!read) return notFound(); |
| 74 | 74 | return timePhase(ctx.request, "render", () => |
| 75 | json({ | |
| 75 | tagged(ctx, { | |
| 76 | 76 | ...appJson(ctx, read.view.app, repo, appsRuntime(ctx.app)), |
| 77 | 77 | ...(read.vitals === undefined ? {} : { vitals: read.vitals }), |
| 78 | 78 | }), |
| @@ -226,7 +226,7 @@ export function appRoutes(router: Router<Handler>): void { | ||
| 226 | 226 | if (!repo) return notFound(); |
| 227 | 227 | const listed = timePhase(ctx.request, "env", () => listAppEnv(ctx.app, ctx.auth.user, repo)); |
| 228 | 228 | if (!listed.ok) return notFound(); |
| 229 | return timePhase(ctx.request, "render", () => json({ items: listed.value, next_cursor: null })); | |
| 229 | return timePhase(ctx.request, "render", () => tagged(ctx, { items: listed.value, next_cursor: null })); | |
| 230 | 230 | }); |
| 231 | 231 | |
| 232 | 232 | router.add("PUT", "/api/v1/repos/:owner/:name/app/env/:var", async (ctx) => { |
test/app-state-etag.test.ts added +160
| @@ -0,0 +1,160 @@ | ||
| 1 | /** GET …/app and …/app/env carry an ETag and answer a repeat with 304 until the app or its environment moves (#9227). */ | |
| 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 { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 9 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | const APP = "/api/v1/repos/alice/notes/app"; | |
| 18 | const ENV = `${APP}/env`; | |
| 19 | ||
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let runtime: AppsRuntime; | |
| 24 | let adminTok = ""; | |
| 25 | let roTok = ""; | |
| 26 | let bobTok = ""; | |
| 27 | let shown: EnvStatus[] = []; | |
| 28 | ||
| 29 | const call = (method: string, path: string, tok: string | null, headers: Record<string, string> = {}, body?: unknown) => | |
| 30 | fetch(`${servers.url}${path}`, { | |
| 31 | method, | |
| 32 | headers: { ...(tok ? { authorization: `Bearer ${tok}` } : {}), "content-type": "application/json", ...headers }, | |
| 33 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 34 | }); | |
| 35 | const tagOf = async (path: string, tok = adminTok) => { | |
| 36 | const res = await call("GET", path, tok); | |
| 37 | expect(res.status).toBe(200); | |
| 38 | await res.text(); | |
| 39 | return res.headers.get("etag")!; | |
| 40 | }; | |
| 41 | const status = async (path: string, tag: string) => { | |
| 42 | const res = await call("GET", path, adminTok, { "if-none-match": tag }); | |
| 43 | await res.text(); | |
| 44 | return res.status; | |
| 45 | }; | |
| 46 | const env = (state: string, commitOid: string): EnvStatus => | |
| 47 | ({ env: "production", state, commitOid, url: "http://x.test" }) as EnvStatus; | |
| 48 | ||
| 49 | beforeAll(async () => { | |
| 50 | dataDir = await mkdtemp(join(tmpdir(), "forge-app-state-etag-")); | |
| 51 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 52 | servers = await serve(app); | |
| 53 | runtime = new AppsRuntime(app, { | |
| 54 | domain: "apps.test", scheme: "http", publicPort: servers.port, forgeUrl: servers.url, concurrency: 1, | |
| 55 | }); | |
| 56 | await runtime.start(); | |
| 57 | runtime.status = () => shown; | |
| 58 | const mk = async (username: string, first: boolean) => { | |
| 59 | const made = await createUser(app.db, { | |
| 60 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first, | |
| 61 | }); | |
| 62 | if (!made.ok) throw new Error(String(made.error)); | |
| 63 | return made.value; | |
| 64 | }; | |
| 65 | const alice = await mk("alice", true); | |
| 66 | const bob = await mk("bob", false); | |
| 67 | const repo = await createRepo(app, alice, { name: "notes", visibility: "public", description: "" }); | |
| 68 | if (!repo.ok) throw new Error("repo"); | |
| 69 | const row = insertApp(app.db, { repoId: repo.value.id, slug: "notes", access: "public" }); | |
| 70 | if (!row.ok) throw new Error(row.error); | |
| 71 | adminTok = createToken(app.db, alice.id, "a", "admin").token; | |
| 72 | roTok = createToken(app.db, alice.id, "r", "ro").token; | |
| 73 | bobTok = createToken(app.db, bob.id, "b", "admin").token; | |
| 74 | }); | |
| 75 | afterAll(async () => { | |
| 76 | await runtime?.stop(); | |
| 77 | servers?.stop(); | |
| 78 | app?.close(); | |
| 79 | if (dataDir) await rm(dataDir, { recursive: true, force: true }); | |
| 80 | }); | |
| 81 | ||
| 82 | for (const path of [APP, ENV]) { | |
| 83 | test(`${path}: weak tag, private no-cache, vary; a repeat is a bodiless 304 with the floor`, async () => { | |
| 84 | const res = await call("GET", path, adminTok); | |
| 85 | expect(res.status).toBe(200); | |
| 86 | const tag = res.headers.get("etag")!; | |
| 87 | expect(tag).toMatch(/^W\/"/); | |
| 88 | expect(res.headers.get("cache-control")).toBe("private, no-cache"); | |
| 89 | expect(res.headers.get("vary")).toContain("authorization, cookie"); | |
| 90 | expect(res.headers.get("content-type")).toContain("application/json"); | |
| 91 | await res.json(); | |
| 92 | const again = await call("GET", path, adminTok, { "if-none-match": tag }); | |
| 93 | expect(again.status).toBe(304); | |
| 94 | expect(again.headers.get("etag")).toBe(tag); | |
| 95 | expect(again.headers.get("cache-control")).toBe("private, no-cache"); | |
| 96 | expect(again.headers.get("vary")).toContain("authorization, cookie"); | |
| 97 | expect(again.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 98 | expect(again.headers.get("referrer-policy")).not.toBeNull(); | |
| 99 | expect(again.headers.get("content-security-policy")).not.toBeNull(); | |
| 100 | expect(await again.text()).toBe(""); | |
| 101 | }); | |
| 102 | } | |
| 103 | ||
| 104 | test("starting, stopping and deploying a new commit each move the /app tag", async () => { | |
| 105 | shown = []; | |
| 106 | const none = await tagOf(APP); | |
| 107 | shown = [env("running", "a".repeat(40))]; | |
| 108 | const started = await tagOf(APP); | |
| 109 | expect(started).not.toBe(none); | |
| 110 | expect(await status(APP, started)).toBe(304); | |
| 111 | expect(await status(APP, none)).toBe(200); | |
| 112 | shown = [env("running", "b".repeat(40))]; | |
| 113 | const deployed = await tagOf(APP); | |
| 114 | expect(deployed).not.toBe(started); | |
| 115 | shown = [env("stopped", "b".repeat(40))]; | |
| 116 | const stopped = await tagOf(APP); | |
| 117 | expect(stopped).not.toBe(deployed); | |
| 118 | shown = []; | |
| 119 | expect(await tagOf(APP)).toBe(none); | |
| 120 | }); | |
| 121 | ||
| 122 | test("setting and removing a variable move the /env tag; the value never feeds it", async () => { | |
| 123 | const empty = await tagOf(ENV); | |
| 124 | const set = await call("PUT", `${ENV}/GREETING`, adminTok, {}, { value: "secret-one" }); | |
| 125 | expect(set.status).toBe(200); | |
| 126 | await set.text(); | |
| 127 | const added = await tagOf(ENV); | |
| 128 | expect(added).not.toBe(empty); | |
| 129 | expect(await status(ENV, added)).toBe(304); | |
| 130 | const again = await call("PUT", `${ENV}/GREETING`, adminTok, {}, { value: "secret-two" }); | |
| 131 | await again.text(); | |
| 132 | const body = await (await call("GET", ENV, adminTok)).text(); | |
| 133 | expect(body).not.toContain("secret"); | |
| 134 | const gone = await call("DELETE", `${ENV}/GREETING`, adminTok); | |
| 135 | expect(gone.status).toBe(200); | |
| 136 | await gone.text(); | |
| 137 | expect(await tagOf(ENV)).toBe(empty); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a body that differs for another viewer does not share the tag", async () => { | |
| 141 | const mine = await tagOf(APP); | |
| 142 | const theirs = await tagOf(APP, bobTok); | |
| 143 | expect(theirs).not.toBe(mine); | |
| 144 | expect(await status(APP, theirs)).toBe(200); | |
| 145 | }); | |
| 146 | ||
| 147 | test("refusals and a missing repository answer as before, with no tag, even for *", async () => { | |
| 148 | for (const [token, path, code] of [ | |
| 149 | [roTok, ENV, 403], | |
| 150 | [bobTok, ENV, 404], | |
| 151 | [null, ENV, 401], | |
| 152 | [adminTok, "/api/v1/repos/alice/nope/app", 404], | |
| 153 | [adminTok, "/api/v1/repos/alice/nope/app/env", 404], | |
| 154 | ] as const) { | |
| 155 | const res = await call("GET", path, token, { "if-none-match": "*" }); | |
| 156 | expect(res.status).toBe(code); | |
| 157 | expect(res.headers.get("etag")).toBeNull(); | |
| 158 | await res.text(); | |
| 159 | } | |
| 160 | }); |
new above
offline