diff --git a/src/app/api/v1/registry/register/route.test.ts b/src/app/api/v1/registry/register/route.test.ts index 2862cf9..fc8a2f2 100644 --- a/src/app/api/v1/registry/register/route.test.ts +++ b/src/app/api/v1/registry/register/route.test.ts @@ -14,13 +14,20 @@ function post(url: string, body: unknown, headers: Record = {}) } const REGISTER_URL = "http://localhost/api/v1/registry/register"; +const ORIGINAL_NODE_ENV = process.env.NODE_ENV; afterEach(() => { delete process.env.CAPI_REGISTRY_TOKEN; + if (ORIGINAL_NODE_ENV === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = ORIGINAL_NODE_ENV; + } }); describe("POST /api/v1/registry/register", () => { it("registers a service, mirrors executable capabilities, and records declared-only names", async () => { + process.env.NODE_ENV = "test"; const res = await register( post(REGISTER_URL, { service_name: "lockerphycer-test-a", @@ -36,12 +43,11 @@ describe("POST /api/v1/registry/register", () => { expect(res.status).toBe(201); const body = await res.json(); expect(body.ok).toBe(true); - expect(body.authenticated).toBe(false); // no CAPI_REGISTRY_TOKEN configured + expect(body.authenticated).toBe(false); // explicitly test-only unauthenticated posture expect(body.capabilities_registered).toBe(1); expect(body.declared_capabilities).toEqual(["identity", "sso"]); expect(body.executable_capability_ids).toContain("svc::lockerphycer-test-a::verify_token"); - // The executable capability and the service are now visible in live state. const snapshot = await (await state()).json(); expect(snapshot.services.some((s: { service_name: string }) => s.service_name === "lockerphycer-test-a")).toBe(true); expect( @@ -56,11 +62,45 @@ describe("POST /api/v1/registry/register", () => { }); it("rejects an invalid body", async () => { + process.env.NODE_ENV = "test"; const res = await register(post(REGISTER_URL, { capabilities: ["x"] })); expect(res.status).toBe(400); }); - it("enforces the registry token when configured", async () => { + it.each([undefined, "production", "staging", "preview-unknown"])( + "fails closed without registry authentication when NODE_ENV=%s", + async (environment) => { + if (environment === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = environment; + } + delete process.env.CAPI_REGISTRY_TOKEN; + const serviceName = `svc-missing-token-${environment ?? "unset"}`; + + const denied = await register(post(REGISTER_URL, { service_name: serviceName })); + expect(denied.status).toBe(503); + expect(await denied.json()).toEqual({ error: "Registry authentication is not configured" }); + + const svc = await (await services()).json(); + expect(svc.services.some((s: { service_name: string }) => s.service_name === serviceName)).toBe(false); + }, + ); + + it.each(["local", "development", "test"])( + "permits explicitly unauthenticated registration in %s only", + async (environment) => { + process.env.NODE_ENV = environment; + delete process.env.CAPI_REGISTRY_TOKEN; + + const res = await register(post(REGISTER_URL, { service_name: `svc-${environment}` })); + expect(res.status).toBe(201); + expect((await res.json()).authenticated).toBe(false); + }, + ); + + it("enforces the registry token when configured and requires Bearer", async () => { + process.env.NODE_ENV = "production"; process.env.CAPI_REGISTRY_TOKEN = "s3cret-token"; const denied = await register( @@ -68,6 +108,11 @@ describe("POST /api/v1/registry/register", () => { ); expect(denied.status).toBe(401); + const rawTokenDenied = await register( + post(REGISTER_URL, { service_name: "svc-raw-token-denied" }, { authorization: "s3cret-token" }), + ); + expect(rawTokenDenied.status).toBe(401); + const allowed = await register( post(REGISTER_URL, { service_name: "svc-allowed" }, { authorization: "Bearer s3cret-token" }), ); @@ -76,6 +121,7 @@ describe("POST /api/v1/registry/register", () => { }); it("heartbeat refreshes a known service and 404s an unknown one", async () => { + process.env.NODE_ENV = "test"; await register(post(REGISTER_URL, { service_name: "svc-hb" })); const ok = await heartbeat(post("http://localhost/api/v1/registry/heartbeat", { service_name: "svc-hb" })); @@ -86,4 +132,4 @@ describe("POST /api/v1/registry/register", () => { ); expect(missing.status).toBe(404); }); -}); +}); \ No newline at end of file diff --git a/src/app/api/v1/registry/register/route.ts b/src/app/api/v1/registry/register/route.ts index 24255e8..7a9b4a8 100644 --- a/src/app/api/v1/registry/register/route.ts +++ b/src/app/api/v1/registry/register/route.ts @@ -8,7 +8,9 @@ * * Auth: when CAPI_REGISTRY_TOKEN is set, a matching `Authorization: Bearer …` * is required; the registration is flagged `authenticated`. When the token is - * unset (local/dev), the call is accepted but flagged `authenticated: false`. + * unset, unauthenticated registration is permitted only in explicitly local, + * development, or test environments. Unset/unknown/staging/production values + * fail closed when registry authentication is not configured. */ import { NextResponse } from "next/server"; @@ -30,6 +32,14 @@ type CapabilityEntry = requires_approval?: boolean; }; +type AuthCheck = { + ok: boolean; + authenticated: boolean; + configurationError: boolean; +}; + +const UNAUTHENTICATED_REGISTRY_ENVIRONMENTS = new Set(["local", "development", "test"]); + function normalizeCapabilities(entries: CapabilityEntry[] | undefined): RegisteredCapability[] { if (!entries) return []; return entries.map((entry) => @@ -37,22 +47,37 @@ function normalizeCapabilities(entries: CapabilityEntry[] | undefined): Register ); } -function checkAuth(request: Request): { ok: boolean; authenticated: boolean } { +function checkAuth(request: Request): AuthCheck { const expected = process.env.CAPI_REGISTRY_TOKEN?.trim(); const header = request.headers.get("authorization")?.trim() ?? ""; const presented = header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() - : header; + : ""; + if (!expected) { - // No token configured — accept but mark unauthenticated (dev posture). - return { ok: true, authenticated: false }; + const environment = process.env.NODE_ENV?.trim().toLowerCase() ?? ""; + if (!UNAUTHENTICATED_REGISTRY_ENVIRONMENTS.has(environment)) { + return { ok: false, authenticated: false, configurationError: true }; + } + // Explicit local/dev/test posture: accept but mark unauthenticated. + return { ok: true, authenticated: false, configurationError: false }; + } + + if (presented && presented === expected) { + return { ok: true, authenticated: true, configurationError: false }; } - if (presented && presented === expected) return { ok: true, authenticated: true }; - return { ok: false, authenticated: false }; + + return { ok: false, authenticated: false, configurationError: false }; } export async function POST(request: Request) { const auth = checkAuth(request); + if (auth.configurationError) { + return NextResponse.json( + { error: "Registry authentication is not configured" }, + { status: 503 }, + ); + } if (!auth.ok) { return NextResponse.json( { error: "Invalid or missing registry token" }, @@ -91,4 +116,4 @@ export async function POST(request: Request) { }, { status: 201 }, ); -} +} \ No newline at end of file