Background
#437 creates a neon_auth.user row at email submit so that Neon dispatches the send.otp webhook. A consequence is that anyone who enters an email and never completes the OTP leaves behind a permanent unverified auth row — typos, bots, and abandoned sign-ups all accumulate.
This does not pollute business data. The admin users list reads prisma.user.findMany (prisma/data/users.ts:9), and a public User row is only created on first successful authentication. So this is housekeeping inside the auth directory, not a data-integrity problem.
An example already exists on dev: winkler.b+2@northeastern.edu, created 2026-06-26, emailVerified: false, no session.
Prune predicate
Remove neon_auth.user rows where all of the following hold:
emailVerified = false
- no row in
neon_auth.session for that user
- no public
User row with a matching neonAuthId
createdAt older than the retention window (suggest 7 days — confirm before implementing)
The third clause is load-bearing and must not be dropped: it protects pending admin invitations. #239 creates the public row immediately after the auth row and rolls back with deleteNeonAuthUser if that insert fails, so an admin-invited user always has a public row. An auth row without one can only be an abandoned self-service sign-up.
Deletion
Use deleteNeonAuthUser() from lib/auth/admin.ts. Do not issue DELETE against neon_auth directly — Neon manages that schema.
Trigger
Opportunistic rather than scheduled. Vercel Cron would require an API route, which this repo forbids.
- Persist a
lastPrunedAt marker
- Claim the run atomically (
UPDATE … WHERE lastPrunedAt < threshold RETURNING) so concurrent requests cannot double-run it
- Execute off the response path so no user absorbs the latency. Next 16's
after() is the natural primitive, but it is not covered in .claude/docs/nextjs-notes.md — verify current behaviour before relying on it
- Host it on a low-traffic authenticated path such as an admin page load, not
resolveRealUser, which runs on nearly every request
Acceptance criteria
Background
#437 creates a
neon_auth.userrow at email submit so that Neon dispatches thesend.otpwebhook. A consequence is that anyone who enters an email and never completes the OTP leaves behind a permanent unverified auth row — typos, bots, and abandoned sign-ups all accumulate.This does not pollute business data. The admin users list reads
prisma.user.findMany(prisma/data/users.ts:9), and a publicUserrow is only created on first successful authentication. So this is housekeeping inside the auth directory, not a data-integrity problem.An example already exists on dev:
winkler.b+2@northeastern.edu, created 2026-06-26,emailVerified: false, no session.Prune predicate
Remove
neon_auth.userrows where all of the following hold:emailVerified = falseneon_auth.sessionfor that userUserrow with a matchingneonAuthIdcreatedAtolder than the retention window (suggest 7 days — confirm before implementing)The third clause is load-bearing and must not be dropped: it protects pending admin invitations. #239 creates the public row immediately after the auth row and rolls back with
deleteNeonAuthUserif that insert fails, so an admin-invited user always has a public row. An auth row without one can only be an abandoned self-service sign-up.Deletion
Use
deleteNeonAuthUser()fromlib/auth/admin.ts. Do not issueDELETEagainstneon_authdirectly — Neon manages that schema.Trigger
Opportunistic rather than scheduled. Vercel Cron would require an API route, which this repo forbids.
lastPrunedAtmarkerUPDATE … WHERE lastPrunedAt < threshold RETURNING) so concurrent requests cannot double-run itafter()is the natural primitive, but it is not covered in.claude/docs/nextjs-notes.md— verify current behaviour before relying on itresolveRealUser, which runs on nearly every requestAcceptance criteria