diff --git a/src/http/AGENTS.md b/src/http/AGENTS.md index 643be27f..797a9385 100644 --- a/src/http/AGENTS.md +++ b/src/http/AGENTS.md @@ -35,6 +35,8 @@ Surface: [remote-agents.mdx](../../website/src/content/docs/docs/using-devices/r falls back to `index.html`. An unbuilt console answers 404 and logs one warning. 2. Log middleware — one `request` line per answer (method, path, status, `durationMs`, `requesterId`). Not for `/v1/healthz`. + Inside it, a JSON `200` to a `GET` gets an `ETag`, and a matching `If-None-Match` turns it + into `304` with no body. Streams and mounts are skipped, so their bodies are never read. 3. `requireAuth` on every route but `GET /v1/healthz`. It asks `src/auth` with the request's headers: a bearer token, or the JWT the sign-in proxy adds when `http.proxyAuth` is set. Neither valid → 401 `UNAUTHENTICATED`. A `worker` join token → 403 on every route. diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 9e63e620..f49ecd0b 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -3229,3 +3229,64 @@ describe("the stream mount's listed route", () => { await found.body?.cancel(); }); }); + +describe("ETag on a JSON read under /v1", () => { + it("answers an unchanged JSON read with 304 and no body, and anything else with 200", async () => { + const tokens: { id: string }[] = [{ id: "tok_1" }]; + const { app, dispatcher, eventBus } = buildHarness({ + mounts: [ + { + prefix: "json-mount", + handle: () => Response.json({ ok: true }), + }, + ] as never, + }); + dispatcher.handlers["token.list"] = () => ({ tokens: [...tokens] }); + dispatcher.handlers["events.subscribe"] = (_input, session) => ({ + subscribed: true, + subscriptionId: session.manageEventSubscription(true), + }); + const read = (path: string, headers: Record = {}) => + app.request(path, { headers: { ...operatorAuth, ...headers } }); + + const first = await read("/v1/tokens"); + const tag = first.headers.get("etag"); + expect(first.status).toBe(200); + expect(tag).toMatch(/^"[^"]+"$/); + + const unchanged = await read("/v1/tokens", { "If-None-Match": tag ?? "" }); + expect(unchanged.status).toBe(304); + expect(await unchanged.text()).toBe(""); + expect(unchanged.headers.get("etag")).toBe(tag); + + const stale = await read("/v1/tokens", { "If-None-Match": '"stale"' }); + expect(stale.status).toBe(200); + expect(stale.headers.get("etag")).toBe(tag); + + tokens.push({ id: "tok_2" }); + const changed = await read("/v1/tokens", { "If-None-Match": tag ?? "" }); + expect(changed.status).toBe(200); + expect(changed.headers.get("etag")).not.toBe(tag); + + const missing = await read("/v1/leases/lse_none"); + expect(missing.status).toBe(404); + expect(missing.headers.get("etag")).toBeNull(); + + const mount = await read("/v1/json-mount/x"); + expect(mount.status).toBe(200); + expect(mount.headers.get("content-type")).toContain("application/json"); + expect(mount.headers.get("etag")).toBeNull(); + + const streamOrStall = await Promise.race([ + read("/v1/events/stream"), + new Promise<"stalled">((resolve) => setTimeout(() => resolve("stalled"), 500)), + ]); + if (streamOrStall === "stalled") expect.fail("the event stream was buffered, not answered"); + const stream = streamOrStall; + expect(stream.headers.get("etag")).toBeNull(); + const framesPromise = readSseFrames(stream, 1); + await Promise.resolve(); + eventBus.emit("daemon.stopping", { reason: "live" }, "test"); + expect((await framesPromise)[0]?.event).toBe("daemon.stopping"); + }); +}); diff --git a/src/http/app.ts b/src/http/app.ts index 30159f4c..1e11342e 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -1,5 +1,6 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import type { Context } from "hono"; +import { etag } from "hono/etag"; import { z } from "zod"; import type { EventBus } from "../bus/index.js"; @@ -211,6 +212,20 @@ export function createHttpApp(deps: HttpGatewayDeps): OpenAPIHono & HttpApp }); }); + // why: Hono's `etag()` buffers the body to hash it, so it runs only under /v1 (a `*` step, so no `/v1/*` route is registered) on a JSON answer to a GET that is not a mount's; a stream or a mount's own body is never read. + const mountPrefixes = (deps.mounts ?? []).map((mount) => `/v1/${mount.prefix}`); + const etagStep = etag(); + app.use("*", async (c, next) => { + await next(); + const path = c.req.path; + if (!path.startsWith("/v1/")) return; + const mounted = mountPrefixes.some( + (prefix) => path === prefix || path.startsWith(`${prefix}/`), + ); + if (mounted || !c.res.headers.get("content-type")?.startsWith("application/json")) return; + await etagStep(c, () => Promise.resolve()); + }); + app.openapi(healthzRoute, (c) => c.json({ ok: true as const }, 200)); app.openapi({ ...statusRoute, middleware: [agentAuth] }, async (c) => diff --git a/website/src/content/docs/docs/using-devices/remote-agents.mdx b/website/src/content/docs/docs/using-devices/remote-agents.mdx index 994fe2b3..8d352e41 100644 --- a/website/src/content/docs/docs/using-devices/remote-agents.mdx +++ b/website/src/content/docs/docs/using-devices/remote-agents.mdx @@ -171,6 +171,8 @@ export SIMLOCK_TOKEN=slk_d2FybS1wb29sLWFnZW50LXRva2VuLWV4YW1wbGUtMDM An `Idempotency-Key` header on `POST /v1/lease-requests` (1 to 200 characters) makes the request safe to send again: the same key returns the stored request instead of queueing a second one, for 10 minutes after it finished. +A `GET` under `/v1` that answers `200` with JSON carries an `ETag` header. Send it back as `If-None-Match` on the next read of the same route: while the answer is unchanged, the reply is `304 Not Modified` with no body. Event streams carry no `ETag`. + ## Through a gateway Send the same calls to the gateway, with a token created on the gateway. The gateway picks a worker for you and forwards renew, release and exec to it.