feat(api): answer GET /api/health without a key (#10771)

Merged via merge-train (release/v3.8.50, batch1 2026-08-20) — static gates (typecheck/file-size/complexity/cognitive/changelog) green on the combined tree; test:unit reds observed in the boarded run were verified pre-existing on the pure release tip (unrelated flake), not caused by this PR. Thanks for the contribution!
This commit is contained in:
Dizzle
2026-08-20 11:29:45 +02:00
committed by GitHub
parent ee230fa93a
commit 49a47cbe6f
4 changed files with 94 additions and 1 deletions

View File

@@ -0,0 +1 @@
- **feat(api):** `GET /api/health` now answers `{ status, timestamp }` without a key. Until now the path had no route, so the management-auth boundary answered first with a 401 — indistinguishable from a wrong key or an unknown route, which left Docker HEALTHCHECKs and Kubernetes probes unable to tell "down" from "misconfigured". Kept deliberately minimal: version, uptime and memory stay behind the authenticated `/api/monitoring/health` ([#PRNUM](https://github.com/diegosouzapw/OmniRoute/pull/10771)).

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
/**
* GET /api/health — canonical liveness probe, no auth required.
*
* Without this route, `/api/health` fell through to the `/api/*` catch-all, and the
* management-auth boundary answered before routing: an unauthenticated caller got a 401,
* which is exactly what a wrong or missing key returns. An orchestrator (Docker HEALTHCHECK,
* a Kubernetes probe, a monitoring curl) cannot tell "service down" from "bad credentials"
* from "no such route" — the ambiguity the #6424 catch-all was written to remove for
* authenticated callers, still intact for the one caller that never authenticates.
*
* Deliberately minimal: `{ status, timestamp }` and nothing else. Whatever this returns is
* public on an exposed instance, so version, uptime and memory stay behind the authenticated
* `/api/monitoring/health`. For a probe that also confirms the database answers, use
* `/api/health/ping`.
*/
export const dynamic = "force-dynamic";
export async function GET() {
return NextResponse.json(
{ status: "ok", timestamp: new Date().toISOString() },
{
status: 200,
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
}
);
}

View File

@@ -44,6 +44,14 @@ const PUBLIC_READONLY_API_ROUTE_PREFIXES = [
"/api/settings/require-login",
];
// Read-only routes public by EXACT path, never by prefix.
//
// `/api/health` has to be reachable without a key — a probe has none, and a 401 there is
// indistinguishable from a wrong key or a missing route. It cannot go in the prefix list
// above: `startsWith("/api/health")` would also expose `/api/health/degradation`, which is
// authenticated today.
const PUBLIC_READONLY_API_ROUTES_EXACT = new Set(["/api/health"]);
const PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const PUBLIC_CLOUD_API_ROUTES = [
@@ -76,7 +84,18 @@ export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
return false;
}
for (const route of PUBLIC_READONLY_API_ROUTES_EXACT) {
if (pathMatchesExactRoute(pathname, route)) {
return true;
}
}
return PUBLIC_READONLY_API_ROUTE_PREFIXES.some((route) => pathname.startsWith(route));
}
export { PUBLIC_API_ROUTE_PREFIXES, PUBLIC_READONLY_API_ROUTE_PREFIXES, PUBLIC_READONLY_METHODS };
export {
PUBLIC_API_ROUTE_PREFIXES,
PUBLIC_READONLY_API_ROUTE_PREFIXES,
PUBLIC_READONLY_API_ROUTES_EXACT,
PUBLIC_READONLY_METHODS,
};

View File

@@ -0,0 +1,44 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { isPublicApiRoute } from "../../src/shared/constants/publicApiRoutes.ts";
import { GET } from "../../src/app/api/health/route.ts";
// Without a root /api/health route, the path fell through to the /api/* catch-all and the
// management-auth boundary answered first: an unauthenticated probe got a 401, the same answer
// a wrong key returns. These assertions fail against the base — the route does not exist and
// isPublicApiRoute("/api/health") is false.
describe("GET /api/health is a public liveness probe", () => {
it("is reachable without a key, for read methods only", () => {
assert.equal(isPublicApiRoute("/api/health", "GET"), true);
assert.equal(isPublicApiRoute("/api/health", "HEAD"), true);
assert.equal(isPublicApiRoute("/api/health", "POST"), false);
});
it("does not open the rest of the health subtree", () => {
// A prefix entry would have exposed this one, which is authenticated today.
assert.equal(isPublicApiRoute("/api/health/degradation", "GET"), false);
assert.equal(isPublicApiRoute("/api/healthzzz", "GET"), false);
});
it("is reachable with a trailing slash, like the other exact-match public routes", () => {
// getRequestPathname() (src/shared/utils/apiAuth.ts) does not strip a trailing slash,
// unlike classify.ts's normalizePathname() — a raw Set.has() lookup would miss "/api/health/"
// even though it is the same probe. Same trailing-slash tolerance as PUBLIC_CLOUD_API_ROUTES.
assert.equal(isPublicApiRoute("/api/health/", "GET"), true);
});
it("answers 200 with the minimum an orchestrator needs", async () => {
const response = await GET();
const body = (await response.json()) as Record<string, unknown>;
assert.equal(response.status, 200);
assert.equal(body.status, "ok");
assert.equal(typeof body.timestamp, "string");
// Nothing that should not be public on an exposed instance.
for (const leak of ["version", "uptime", "memoryUsage", "system", "nodeVersion"]) {
assert.equal(leak in body, false, `${leak} must not be exposed without a key`);
}
});
});