Skip to content
Open
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
33 changes: 33 additions & 0 deletions frontend/app/api/protocol/passport/[id]/revoke/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { getPassport, revokePassport } from "../../../../../../src/lib/passport/webhook-store";
import { notifyPassportEvent } from "../../../../../../src/lib/passport/webhook-notifier";
import { addNotification } from "../../../../../../src/lib/notifications/notification-store";

export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const { id } = params;

const passport = getPassport(id);
if (!passport) {
revokePassport(id);
await notifyPassportEvent("passport.revoked", id, "unknown-agent");
return NextResponse.json({ ok: true });
}

const agentId = passport.agentId;
const wasAlreadyRevoked = passport.revoked;

revokePassport(id);

if (!wasAlreadyRevoked) {
addNotification(agentId, {
title: "Passport Revoked",
message: `Passport ${id} has been revoked`,
});
await notifyPassportEvent("passport.revoked", id, agentId);
}

return NextResponse.json({ ok: true });
}
11 changes: 11 additions & 0 deletions frontend/app/api/protocol/passport/cron/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { checkAndExpirePassports } from "../../../../../src/lib/passport/webhook-store";
import { notifyPassportEvent } from "../../../../../src/lib/passport/webhook-notifier";

export async function POST() {
const expired = checkAndExpirePassports();
for (const p of expired) {
await notifyPassportEvent("passport.expired", p.id, p.agentId);
}
return NextResponse.json({ ok: true, expiredCount: expired.length });
}
196 changes: 196 additions & 0 deletions frontend/app/api/protocol/passport/revoke-batch/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import crypto from "crypto";
import { POST } from "./route";
import { registerPassport, _reset as resetStore } from "../../../../../src/lib/passport/webhook-store";
import { _reset as resetNotifications, getNotifications } from "../../../../../src/lib/notifications/notification-store";
import { POST as subscribeRoute } from "../webhooks/route";
import { NextRequest } from "next/server";

interface RequestInitWithHeaders extends RequestInit {
headers?: Record<string, string>;
}

let fetchCalls: { url: string; init?: RequestInitWithHeaders }[] = [];
let fetchResponses: (() => Response)[] = [];

vi.mock("next/server", () => {
return {
NextResponse: {
json: (body: unknown, init?: { status?: number; headers?: Record<string, string> }) => {
const headers = new Headers(init?.headers);
return {
status: init?.status ?? 200,
headers,
json: async () => body,
} as unknown as Response;
},
},
NextRequest: class {},
};
});

// Stub global fetch to capture webhook requests
vi.stubGlobal("fetch", async (url: string, init?: RequestInitWithHeaders) => {
fetchCalls.push({ url, init });
if (fetchResponses.length > 0) {
const nextResponse = fetchResponses.shift()!;
return nextResponse();
}
return { ok: true, status: 200 } as Response;
});

function createJsonRequest(body: unknown, headers?: Record<string, string>) {
return new Request("https://example.com", {
method: "POST",
headers: {
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
}) as unknown as NextRequest;
}

describe("POST /api/protocol/passport/revoke-batch", () => {
beforeEach(() => {
resetStore();
resetNotifications();
fetchCalls = [];
fetchResponses = [];
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("revokes 2 valid and lists 1 unknown passport ID", async () => {
// 1. Setup active passports in the store
registerPassport("pp_aaa", "agent-1");
registerPassport("pp_bbb", "agent-2");

// 2. Call batch revoke
const req = createJsonRequest({
passportIds: ["pp_aaa", "pp_bbb", "pp_ccc"],
reason: "fleet_decommission",
});

const res = await POST(req);
expect(res.status).toBe(200);

const data = (await res.json()) as {
ok: boolean;
revoked: string[];
notFound: string[];
alreadyRevoked: string[];
total: number;
};

expect(data.ok).toBe(true);
expect(data.revoked).toEqual(["pp_aaa", "pp_bbb"]);
expect(data.notFound).toEqual(["pp_ccc"]);
expect(data.alreadyRevoked).toEqual([]);
expect(data.total).toBe(2);

// 3. Verify audit log (notification) entries were created
const notifs1 = getNotifications("agent-1");
expect(notifs1).toHaveLength(1);
expect(notifs1[0].title).toBe("Passport Revoked");
expect(notifs1[0].message).toBe("Passport pp_aaa has been revoked");

const notifs2 = getNotifications("agent-2");
expect(notifs2).toHaveLength(1);
expect(notifs2[0].title).toBe("Passport Revoked");
expect(notifs2[0].message).toBe("Passport pp_bbb has been revoked");
});

it("returns 400 when over 50 IDs are provided", async () => {
const passportIds = Array.from({ length: 51 }, (_, i) => `pp_${i}`);
const req = createJsonRequest({ passportIds });

const res = await POST(req);
expect(res.status).toBe(400);

const data = (await res.json()) as { error: string; max: number };
expect(data.error).toBe("batch_too_large");
expect(data.max).toBe(50);
});

it("lists already-revoked passports in alreadyRevoked and does not re-revoke them", async () => {
// 1. Setup active passport
registerPassport("pp_aaa", "agent-1");

// 2. Revoke it once
const req1 = createJsonRequest({ passportIds: ["pp_aaa"] });
const res1 = await POST(req1);
expect(res1.status).toBe(200);

// Reset captured webhooks and audit logs
fetchCalls = [];
resetNotifications();

// 3. Try to revoke it again
const req2 = createJsonRequest({ passportIds: ["pp_aaa"] });
const res2 = await POST(req2);
expect(res2.status).toBe(200);

const data2 = (await res2.json()) as {
ok: boolean;
revoked: string[];
notFound: string[];
alreadyRevoked: string[];
total: number;
};

expect(data2.ok).toBe(true);
expect(data2.revoked).toEqual([]);
expect(data2.alreadyRevoked).toEqual(["pp_aaa"]);
expect(data2.total).toBe(0);

// 4. Verify no new webhooks or audit logs were created
expect(fetchCalls).toHaveLength(0);
expect(getNotifications("agent-1")).toHaveLength(0);
});

it("fires a passport.revoked webhook event for each revoked passport", async () => {
// 1. Subscribe to passport.revoked event
const subReq = createJsonRequest({
url: "https://myservice.com/webhook",
events: ["passport.revoked"],
secret: "my_shared_secret",
});
await subscribeRoute(subReq);

// 2. Register and revoke
registerPassport("pp_aaa", "agent-1");
registerPassport("pp_bbb", "agent-2");

fetchCalls = [];

const req = createJsonRequest({
passportIds: ["pp_aaa", "pp_bbb"],
});
const res = await POST(req);
expect(res.status).toBe(200);

// 3. Verify webhook delivery
expect(fetchCalls).toHaveLength(2);

const call1 = fetchCalls.find((c) => {
const payload = JSON.parse(c.init?.body as string);
return payload.passportId === "pp_aaa";
});
expect(call1).toBeDefined();
const payload1 = JSON.parse(call1!.init?.body as string);
expect(payload1.event).toBe("passport.revoked");
expect(payload1.agentId).toBe("agent-1");

const call2 = fetchCalls.find((c) => {
const payload = JSON.parse(c.init?.body as string);
return payload.passportId === "pp_bbb";
});
expect(call2).toBeDefined();
const payload2 = JSON.parse(call2!.init?.body as string);
expect(payload2.event).toBe("passport.revoked");
expect(payload2.agentId).toBe("agent-2");
});
});
59 changes: 59 additions & 0 deletions frontend/app/api/protocol/passport/revoke-batch/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { getPassport, revokePassport } from "../../../../../src/lib/passport/webhook-store";
import { notifyPassportEvent } from "../../../../../src/lib/passport/webhook-notifier";
import { addNotification } from "../../../../../src/lib/notifications/notification-store";

export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { passportIds } = body;

if (!passportIds || !Array.isArray(passportIds) || passportIds.length === 0) {
return NextResponse.json(
{ error: "passportIds must be a non-empty array" },
{ status: 400 }
);
}

if (passportIds.length > 50) {
return NextResponse.json(
{ error: "batch_too_large", max: 50 },
{ status: 400 }
);
}

const revoked: string[] = [];
const notFound: string[] = [];
const alreadyRevoked: string[] = [];

for (const id of passportIds) {
const passport = getPassport(id);
if (!passport) {
notFound.push(id);
} else if (passport.revoked) {
alreadyRevoked.push(id);
} else {
revokePassport(id);
addNotification(passport.agentId, {
title: "Passport Revoked",
message: `Passport ${id} has been revoked`,
});
await notifyPassportEvent("passport.revoked", id, passport.agentId);
revoked.push(id);
}
}

return NextResponse.json({
ok: true,
revoked,
notFound,
alreadyRevoked,
total: revoked.length,
});
} catch {
return NextResponse.json(
{ error: "Invalid JSON body" },
{ status: 400 }
);
}
}
83 changes: 83 additions & 0 deletions frontend/app/api/protocol/passport/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { POST } from "./route";
import { _reset } from "../../../../src/lib/rate-limit";
import { NextRequest } from "next/server";

// Mock next/server since next is not installed in the Vite frontend workspace
vi.mock("next/server", () => {
return {
NextResponse: {
json: (body: unknown, init?: { status?: number; headers?: Record<string, string> }) => {
const headers = new Headers(init?.headers);
return {
status: init?.status ?? 200,
headers,
json: async () => body,
} as unknown as Response;
},
},
NextRequest: class {},
};
});

function req(ip = "1.2.3.4") {
return new Request("https://example.com/api/protocol/passport", {
method: "POST",
headers: { "x-forwarded-for": ip },
}) as unknown as NextRequest;
}

describe("POST /api/protocol/passport rate limiting", () => {
beforeEach(() => {
_reset();
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("allows 5 requests from the same IP, but blocks the 6th with 429", async () => {
for (let i = 0; i < 5; i++) {
const res = await POST(req("1.1.1.1"));
expect(res.status).toBe(200);
const data = (await res.json()) as { ok: boolean };
expect(data.ok).toBe(true);
}

const res6 = await POST(req("1.1.1.1"));
expect(res6.status).toBe(429);
expect(res6.headers.get("Retry-After")).toBe("60");
const data6 = (await res6.json()) as { ok: boolean };
expect(data6.ok).toBe(false);
});

it("handles different IPs independently", async () => {
for (let i = 0; i < 5; i++) {
await POST(req("1.1.1.1"));
}

// 6th request from 1.1.1.1 is blocked
const resBlocked = await POST(req("1.1.1.1"));
expect(resBlocked.status).toBe(429);

// 1st request from 2.2.2.2 is allowed
const resAllowed = await POST(req("2.2.2.2"));
expect(resAllowed.status).toBe(200);
});

it("allows requests again after the rate limit window resets", async () => {
for (let i = 0; i < 5; i++) {
await POST(req("1.1.1.1"));
}

const resBlocked = await POST(req("1.1.1.1"));
expect(resBlocked.status).toBe(429);

// Advance time by 60 seconds (windowMs is 60_000)
vi.advanceTimersByTime(60_001);

const resAllowed = await POST(req("1.1.1.1"));
expect(resAllowed.status).toBe(200);
});
});
Loading