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
1 change: 1 addition & 0 deletions changelog.d/features/10771-health-root-endpoint.md
Original file line number Diff line number Diff line change
@@ -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)).
29 changes: 29 additions & 0 deletions src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -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" },
}
);
}
21 changes: 20 additions & 1 deletion src/shared/constants/publicApiRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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 = [
Expand Down Expand Up @@ -70,7 +78,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,
};
44 changes: 44 additions & 0 deletions tests/unit/health-root-public-liveness.test.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
});
});
Loading