From a2e9fee977be590adfb5a65e15808247c4c36a15 Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Fri, 3 Jul 2026 18:54:53 +0100 Subject: [PATCH] fix(auth): reconcile shadow user by email so api_keys FK holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CP can re-issue a user's UUID (e.g. after the DB migration), leaving a stale local user row with the same email under the old id. Because email is unique, the shadow upsert's plain insert of the new id threw on the email constraint (swallowed), so no row for the current id existed — and creating an API key failed with api_keys_user_id_user_id_fk. Reconcile in a transaction: free the email from any other shadow id (rename it uniquely, keeping old dependents valid), then upsert the current id with the real email. Self-heals on the next request. --- apps/web/lib/server-auth.ts | 56 ++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/apps/web/lib/server-auth.ts b/apps/web/lib/server-auth.ts index 7f12ac8..d469255 100644 --- a/apps/web/lib/server-auth.ts +++ b/apps/web/lib/server-auth.ts @@ -17,6 +17,7 @@ * the existing flow alive. */ import { headers } from "next/headers"; +import { and, eq, ne, sql } from "drizzle-orm"; import { db } from "@/db"; import { user as localUser } from "@/db/schema"; @@ -63,20 +64,49 @@ export async function getServerSession(): Promise { return null; } - // Best-effort shadow upsert. Failure here doesn't kill auth — it - // just means an old user predates this code or the local DB is - // unreachable, both of which the caller can survive. + // Best-effort shadow upsert. Failure here doesn't kill auth — it just + // means an old user predates this code or the local DB is unreachable, + // both of which the caller can survive. + // + // The CP can re-issue user UUIDs (e.g. after a DB migration), leaving a + // stale local row with the SAME email under an OLD id. Because email is + // unique, a plain insert of the new id then throws on the email + // constraint and no row for the current id ever lands — which breaks + // api_keys.user_id (and other) foreign keys. So we reconcile in a + // transaction: first free the email from any *other* shadow id (rename + // it uniquely so old dependent rows keep pointing at a row that still + // exists), then upsert the current id with the real email. try { - await db - .insert(localUser) - .values({ - id: user.id, - email: user.email, - name: user.name || user.email, - emailVerified: !!user.email_verified, - image: user.avatar_url || null, - }) - .onConflictDoNothing({ target: localUser.id }); + await db.transaction(async (tx) => { + await tx + .update(localUser) + .set({ + email: sql`${localUser.email} || '.stale.' || ${localUser.id}`, + }) + .where( + and(eq(localUser.email, user.email), ne(localUser.id, user.id)), + ); + + await tx + .insert(localUser) + .values({ + id: user.id, + email: user.email, + name: user.name || user.email, + emailVerified: !!user.email_verified, + image: user.avatar_url || null, + }) + .onConflictDoUpdate({ + target: localUser.id, + set: { + email: user.email, + name: user.name || user.email, + emailVerified: !!user.email_verified, + image: user.avatar_url || null, + updatedAt: new Date(), + }, + }); + }); } catch (err) { console.warn("[server-auth] shadow user upsert failed", err); }