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
54 changes: 50 additions & 4 deletions src/app/api/v1/registry/register/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,13 +14,20 @@ function post(url: string, body: unknown, headers: Record<string, string> = {})
}

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",
Expand All@@ -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(
Expand All@@ -56,18 +62,57 @@ 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(
post(REGISTER_URL, { service_name: "svc-denied" }, { authorization: "Bearer wrong" }),
);
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" }),
);
Expand All@@ -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" }));
Expand All@@ -86,4 +132,4 @@ describe("POST /api/v1/registry/register", () => {
);
expect(missing.status).toBe(404);
});
});
});
41 changes: 33 additions & 8 deletions src/app/api/v1/registry/register/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand All@@ -30,29 +32,52 @@ 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) =>
typeof entry === "string" ? { name: entry } : { ...entry },
);
}

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;
: "";

Comment thread
reprewindai-dev marked this conversation as resolved.
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" },
Expand DownExpand Up@@ -91,4 +116,4 @@ export async function POST(request: Request) {
},
{ status: 201 },
);
}
}
Loading