Skip to content

Commit 92f7c13

Browse files
authored
fix(auth): reconcile shadow user by email so api_keys FK holds (#11)
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.
1 parent ad3c339 commit 92f7c13

1 file changed

Lines changed: 43 additions & 13 deletions

File tree

apps/web/lib/server-auth.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* the existing flow alive.
1818
*/
1919
import { headers } from "next/headers";
20+
import { and, eq, ne, sql } from "drizzle-orm";
2021
import { db } from "@/db";
2122
import { user as localUser } from "@/db/schema";
2223

@@ -63,20 +64,49 @@ export async function getServerSession(): Promise<ServerSession | null> {
6364
return null;
6465
}
6566

66-
// Best-effort shadow upsert. Failure here doesn't kill auth — it
67-
// just means an old user predates this code or the local DB is
68-
// unreachable, both of which the caller can survive.
67+
// Best-effort shadow upsert. Failure here doesn't kill auth — it just
68+
// means an old user predates this code or the local DB is unreachable,
69+
// both of which the caller can survive.
70+
//
71+
// The CP can re-issue user UUIDs (e.g. after a DB migration), leaving a
72+
// stale local row with the SAME email under an OLD id. Because email is
73+
// unique, a plain insert of the new id then throws on the email
74+
// constraint and no row for the current id ever lands — which breaks
75+
// api_keys.user_id (and other) foreign keys. So we reconcile in a
76+
// transaction: first free the email from any *other* shadow id (rename
77+
// it uniquely so old dependent rows keep pointing at a row that still
78+
// exists), then upsert the current id with the real email.
6979
try {
70-
await db
71-
.insert(localUser)
72-
.values({
73-
id: user.id,
74-
email: user.email,
75-
name: user.name || user.email,
76-
emailVerified: !!user.email_verified,
77-
image: user.avatar_url || null,
78-
})
79-
.onConflictDoNothing({ target: localUser.id });
80+
await db.transaction(async (tx) => {
81+
await tx
82+
.update(localUser)
83+
.set({
84+
email: sql`${localUser.email} || '.stale.' || ${localUser.id}`,
85+
})
86+
.where(
87+
and(eq(localUser.email, user.email), ne(localUser.id, user.id)),
88+
);
89+
90+
await tx
91+
.insert(localUser)
92+
.values({
93+
id: user.id,
94+
email: user.email,
95+
name: user.name || user.email,
96+
emailVerified: !!user.email_verified,
97+
image: user.avatar_url || null,
98+
})
99+
.onConflictDoUpdate({
100+
target: localUser.id,
101+
set: {
102+
email: user.email,
103+
name: user.name || user.email,
104+
emailVerified: !!user.email_verified,
105+
image: user.avatar_url || null,
106+
updatedAt: new Date(),
107+
},
108+
});
109+
});
80110
} catch (err) {
81111
console.warn("[server-auth] shadow user upsert failed", err);
82112
}

0 commit comments

Comments
 (0)