Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions services/keeper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ The status API is served by `npm run serve` (or any process that calls `createSt
| `GET` | `/healthz` | Liveness probe — cheap, suitable for load balancers |
| `GET` | `/` | Service info + endpoint list |

All endpoints only accept `GET` requests. Any non-`GET` method (`POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, etc.) is rejected at the server boundary with HTTP `405 Method Not Allowed`, an `Allow: GET` response header, and a JSON `{ "error": "method not allowed", "method": "<VERB>", "allow": ["GET"], "available": [...] }` body without invoking RPC or Drand readers.

### Configuration

| Env var | Default | Description |
Expand Down
54 changes: 53 additions & 1 deletion services/keeper/src/status-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,11 @@ function makeSource(overrides: Partial<BuildStatusSource> = {}): BuildStatusSour
async function invokeHandler(
source: BuildStatusSource,
path: string,
method = "GET",
): Promise<{ status: number; headers: Record<string, string>; body: unknown }> {
const handler = createStatusHandler(source);
const req = Object.assign(Readable.from([]), {
method: "GET",
method,
url: path,
headers: { host: "localhost" },
});
Expand Down Expand Up @@ -324,3 +325,54 @@ test("GET /status/health returns health shape", async () => {
assert.ok(["ok", "degraded", "down"].includes(health.drand as string));
});
});

test("rejects non-GET methods with 405 and Allow: GET at the server boundary", async () => {
const source = makeSource({
reader: {
getRound: async () => {
throw new Error("reader should not be called on non-GET requests");
},
getBidState: async () => {
throw new Error("reader should not be called on non-GET requests");
},
},
});

for (const method of ["POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) {
const direct = await invokeHandler(source, "/status", method);
assert.equal(direct.status, 405);
assert.equal(direct.headers["allow"], "GET");
assert.equal(direct.headers["cache-control"], "no-store");
const directBody = direct.body as Record<string, unknown>;
assert.equal(directBody.error, "method not allowed");
assert.equal(directBody.method, method);
assert.deepEqual(directBody.allow, ["GET"]);
}

await withServer(source, async (server) => {
const addr = server.address();
assert.ok(addr && typeof addr === "object");
const baseUrl = `http://127.0.0.1:${addr.port}`;

for (const [method, path] of [
["POST", "/status"],
["PUT", "/status/rounds/1"],
["DELETE", "/healthz"],
["PATCH", "/status/health"],
["POST", "/"],
] as const) {
const res = await fetch(`${baseUrl}${path}`, {
method,
headers: { "content-type": "application/json" },
body: method === "DELETE" ? undefined : JSON.stringify({ probe: true }),
});
assert.equal(res.status, 405, `expected 405 for ${method} ${path}`);
assert.equal(res.headers.get("allow"), "GET");
assert.equal(res.headers.get("cache-control"), "no-store");
const body = (await res.json()) as Record<string, unknown>;
assert.equal(body.error, "method not allowed");
assert.equal(body.method, method);
assert.deepEqual(body.allow, ["GET"]);
}
});
});
29 changes: 27 additions & 2 deletions services/keeper/src/status-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,13 @@ function send(
res: http.ServerResponse,
status: number,
body: unknown,
extraHeaders?: Record<string, string>,
): void {
const payload = JSON.stringify(body, bigintReplacer, 2);
res.writeHead(status, JSON_CONTENT);
res.writeHead(
status,
extraHeaders ? { ...JSON_CONTENT, ...extraHeaders } : JSON_CONTENT,
);
res.end(payload);
}

Expand Down Expand Up @@ -188,7 +192,28 @@ export function createStatusHandler(config: StatusServerConfig): http.RequestLis
const baseHost = req.headers.host ?? `${host}:${port}`;
const url = new URL(req.url ?? "/", `http://${baseHost}`);

if (req.method === "GET" && url.pathname === "/healthz") {
if (req.method !== "GET") {
req.resume?.();
send(
res,
405,
{
error: "method not allowed",
method: req.method ?? "UNKNOWN",
allow: ["GET"],
available: [
"GET /status",
"GET /status/rounds/:id",
"GET /healthz",
"GET /status/health",
],
},
{ allow: "GET" },
);
return;
}

if (url.pathname === "/healthz") {
void fastHealth(url).then((r) => send(res, r.status, r.body));
return;
}
Expand Down