Account Dedup + Merge — Implementation Plan
Status: Building per ADR-022 (Accepted 2026-06-19, amended 2026-06-20). Supabase-native phone auth: phone as a verified login + recovery factor, GoTrue-native uniqueness, linkIdentity() for OAuth consolidation, a partial unique index as a DB backstop. Priority: get verified phones onto accounts (recovery); dedup-prevention is the free consequence. Spec: spec.md. Research: research.md.
What we're building (plain terms)
Two goals, in priority order:
- Verified phones on accounts — so a member who forgets, or loses access to, the email they signed up with can still log in (phone-OTP recovery). This is the point.
- One person → one account — once phones are verified, Supabase blocks a second account on the same number for free.
The handful of pre-existing duplicate accounts are cleaned up manually, with Aaron (spec Part B / Phase B below) — not part of this build.
How a verified phone flows through the system
updateUser({ phone }) → SMS OTP (Supabase's Twilio provider) → verifyOtp({ type: 'phone_change' }) sets auth.users.phone + phone_confirmed_at. A trigger on auth.users UPDATE mirrors those to public.users.phone + phone_verified_at (the app reads public.users). The custom_access_token_hook adds a phone_verified JWT claim; the authenticated layout gates on it.
Keep / discard from the earlier (superseded) work
Keep: Phase 0 schema (phone_verified_at, account_merges); the user audit-phone-dupes script + the 28-cluster characterization; Part B manual remediation.
Discard (unwound in Phase 1): PhoneVerificationService, PhoneIdentityService, the /api/auth/phone/{start,check}-verification routes, and their tests — OTP and dedup are now Supabase's job. No app-level OTP routes; no raw-SQL dedup query.
TDD throughout (Aaron's rule). Each phase ships independently. Characterization (2026-06-19, user audit-phone-dupes): 28 clusters — 22 empty-dupes, 5 split, 0 both-active-subs, 1 noise. The merge tool was dropped (over-built for a one-time job); remediation is manual and case-by-case. See spec.md Part B.
Phase 0 — Schema foundation ✅ (done)
- [x]
phoneVerifiedAt DateTime? @map("phone_verified_at")onUser. - [x]
AccountMergemodel (account_merges, append-only audit, bareUuidcolumns, winner/loser indexes). - [x] Migration
20260619082900_add_phone_verified_at_and_account_merge;prisma generate; typecheck.
Phase 1 — Unwind superseded code ✅ (done, f4de89c3a)
- [x] Delete
PhoneVerificationService,PhoneIdentityService,/api/auth/phone/start-verification,/api/auth/phone/check-verification. - [x] Delete their tests:
PhoneVerificationService.unit.test.ts,PhoneIdentityService.unit.test.ts,phone-verification.api.test.ts. - [x] Remove the two services from the
authbarrel (services/auth/index.ts). - [x] Typecheck clean.
No behavior change — removed the self-managed approach.
Phase 2 — Supabase config + SMS provider ✅ (done, f3d58472d)
- [x]
config.toml:enable_manual_linking = true;[auth.sms.twilio_verify] enabled = true;[auth.sms] enable_signup = true(corrected 2026-07-10: this is GoTrue's phone-provider toggle and gates ALL of/otp— false kills phone login withphone_provider_disabled; email-required is enforced by thehandle_new_userguard instead, migration20260710150000);[auth.sms.test_otp]for local. - [x]
[auth.sms] enable_confirmations = true— critical: with itfalse, GoTrue auto-confirms a phone change with no OTP (would mark phones "verified" for free). Caught during local testing. - [ ] Aaron — prod (Supabase dashboard, separate):
config.tomlgoverns the LOCAL stack only (no remote link/branching). In the dashboard: enable Twilio Verify + creds, require phone confirmations, enable manual linking, and enable the Phone provider (it gates phone login; phone signups are blocked by thehandle_new_useremail guard, not the provider toggle). - [x] Verified a local OTP round-trip via
test_otp.
Local-testing notes: test_otp keys must be GoTrue's normalized form (digits, no +, e.g. 15555550123). test_otp bypasses the provider for updateUser/verifyOtp, so no Twilio creds are needed locally for that number. Corrected 2026-07-10: the earlier "Unsupported phone provider is a local GoTrue limitation" conclusion was wrong — the cause was config.toml's env(SUPABASE_AUTH_SMS_TWILIO_*) references passing through as literal placeholder strings because the vars weren't in the CLI's environment at supabase start. The CLI auto-loads supabase/.env for that substitution — the Twilio creds (incl. TWILIO_VERIFY_SERVICE_SID) live there alongside the Google OAuth secret, and scripts/worktree.sh copies the file into new worktrees. With it present, real-number sends work locally, including the signInWithOtp send path.
Phase 3 — Mirror trigger + JWT claim + index (DB) ✅ (done, 7b97d80f6)
Hand-written raw-SQL migration 20260620095938_add_phone_mirror_trigger_and_verified_claim in apps/api/prisma/migrations/ (prod deploys via prisma migrate deploy; chronologically last so its CREATE OR REPLACE wins all pipelines).
- [x] Trigger
mirror_auth_user_phone()(AFTER UPDATE onauth.users,IS DISTINCTguard onphone/phone_confirmed_at): mirrors + normalizes (+1XXXXXXXXXX) ontopublic.usersmatched byid(=auth.users.idpost-unify).SECURITY DEFINER,search_path=''. - [x]
custom_access_token_hookgainsapp_metadata.phone_verified(reproduces the current body + the new claim). - [x] Partial unique index
users(phone) WHERE phone_verified_at IS NOT NULL AND deleted_at IS NULL— backstop (ADR-022 amendment). - [x] Validated against the live local GoTrue stack: mirror + normalize, the index rejecting a second verified dupe (fires inside GoTrue's
verifyOtpconfirm), the no-op guard, and thephone_verifiedclaim (false→true) all pass.
Phase 4 + 5 — Verify screen + gate (CONVERGED) ✅ (done, 2b23f48e8, 910a2f33f, 8e146bcce)
Phase 4 and 5 collapsed into one screen + one gate (Aaron, 2026-06-20): new signups and existing members both reach phone verification the same way — the gate redirects any authenticated, non-exempt user with phone_verified = false to /auth/verify-phone. No per-form OTP wiring into complete-profile/AuthForm signup is needed; the gate catches everyone uniformly. Auth becomes multi-step (signup/login → complete-profile for name → verify-phone for OTP → app).
- [x] Reusable
PhoneVerificationmolecule (phone →updateUser({phone}), HeroUIInputOtp→verifyOtp({type:'phone_change'})), duplicate detection viaerr.code === 'phone_exists'(routes to existing login), resend, Sentry on unexpected errors. Unit-tested. - [x]
/auth/verify-phonescreen (AuthSurface) — prefills the known phone, skips out if already verified, refreshes the session and routes on success. - [x]
phoneVerifiedonSessionDatafrom thephone_verifiedJWT claim (dal.ts). - [x] Dark-launch feature flag
isPhoneAuthEnabled()(Edge ConfigphoneAuth+PHONE_AUTHenv, mirrors maintenance mode), off by default. Gates the whole phone-auth surface: the verify gate (account layout + all three checkout pages, with staff/admin + active impersonation exempt), the/auth/verify-phoneand/auth/phone-loginpages, and the "Can't access your email?" recovery link on the sign-in tab. Flag-test green. - [x] Outage fallback (Decision 7) handled operationally by the flag (flip off if SMS is down, the kill switch) instead of an in-code skip.
- [ ] OAuth catch-and-clean (delete the empty just-created fork on a duplicate) — deferred; the gate + manual Part B cover dupes. Note (verified locally):
auth.admin.deleteUserdoes NOT cascade-delete thepublic.usersrow, so a catch-and-clean must delete both.
Activation prerequisites (Aaron, before flipping the flag): deploy the migration to prod (prisma migrate deploy), configure Twilio Verify + require phone confirmations + manual linking + enable the Phone provider in the Supabase dashboard (phone signups stay impossible via the handle_new_user email guard). Then flip phoneAuth on. The flag is also the rollback/kill switch.
Phase 6 — Phone-OTP recovery login (the payoff) ✅ (done, efa7591d1)
- [x] "Can't access your email?" link on the AuthForm sign-in tab →
/auth/phone-login: phone →signInWithOtp({ phone }, { shouldCreateUser: false })→ OTP →verifyOtp({ type: 'sms' })→ session → role-based/returnTo redirect. ReusablePhoneLoginmolecule. - [x] Unknown/unverified numbers can't create an account (
shouldCreateUser:falseclient-side + thehandle_new_useremail-required guard at the DB layer); friendly message, same enumeration posture ascheck-account-state. Verified live 2026-07-10: login send 200, unknown numberotp_disabled, raw phone-signup attempt rejected with zero orphan rows, full send→verify→session round trip. - [x] Unit-tested (E.164 send, unknown-number message, sms verify → onSuccess, invalid code). Local note: the
signInWithOtpsend needs real Twilio creds insupabase/.env(see corrected local-testing notes above). Verified live 2026-07-10 with a real number end to end.
Phase 7 — Provider consolidation ✅ (done)
- [x]
SignInMethodssection on/account/profile(behind thephoneAuthflag):getUserIdentities()lists what's linked,linkIdentity({ provider })for Google/Apple (redirects through/auth/callback?next=/account/profile),unlinkIdentity()shown only at ≥2 identities. Display-only phone row (Verify link when unverified). Identity-taken errors surfaced ("already connected to another account"). - [x] Tests: identity listing, link call shape (provider + callback redirect), unlink guard at 1 identity, unlink + refresh at ≥2, phone row states, identity-taken error.
Done when: a member can attach a second provider to their one account. ✅
Team decision (2026-07-10): phone-OTP login is a first-class login method (visible "Sign in with phone" in the sign-in provider group), not recovery-only; email remains always required ([auth.sms] enable_signup = false unchanged).
Phase 8 — Consistent onboarding steps (2026-07-10) ✅ (done)
Aaron: make the steps identical for every auth method and collect each fact exactly once.
- [x] Step 1 credentials-only: name/phone fields removed from the signup tab (
compactSignupbehavior is now universal; the context flag only affects modal width). - [x] SMS marketing consent moved off the signup tab entirely (reviewer finding: with no phone at step 1, the consent PATCH hit ConsentService with an empty contact point and 500'd silently). Consent now lives only at complete-profile, next to the phone it applies to; signup keeps the ToS/privacy line and syncs email opt-in only.
- [x] Step 2
/auth/complete-profilecollects name + phone for all methods. Flag on: saves name only, fires the phone-change OTP, and shows the code step inline (step 3); phone reachespublic.usersonly via the mirror; consent sync runs post-verify. Flag off: prior attribute-save behavior. - [x] Shared
usePhoneChangeOtphook (send/verify/resend) backs bothPhoneVerificationand complete-profile. - [x] DB invariant:
clear_phone_verified_on_phone_changeBEFORE UPDATE trigger nullsphone_verified_aton any direct phone write (migration20260710113000, validated against the local stack: direct write clears, mirror write keeps, unrelated update keeps). -
[x] Tests: AuthForm credentials-only assertions; CompleteProfileContent flag-off save shape, flag-on name-only PATCH + OTP send + code step, duplicate-phone stay, verify → refresh → redirect.
-
[x] Profile page phone management (same day, Aaron: no deferrals): with the flag on, the phone field stays editable but saving a changed number fires the OTP and a code-entry modal instead of writing it (Aaron's UX call: edit-then-confirm, matching the email field's pattern; the first read-only-field-plus-button version was scrapped). Inline Verify action for an unverified saved number. Phone never rides the profile save payload.
GET /profilenow returnsphoneVerifiedAt.SignInMethodsshows the phone row only when verified, display-only.
Phase B — Existing-duplicate remediation (deferred, manual)
Not a built tool. Characterization (user audit-phone-dupes, 2026-06-19): 28 clusters — 22 empty-dupes, 5 split, 0 both-active-subs, 1 noise. Resolved case-by-case, together with Aaron, reasoning about each cluster. No automated writes to prod users.
- [x] Characterize the clusters —
scripts/user.ts audit-phone-dupes(read-only). - [ ] Empty-dupes (22): per cluster, confirm the shells have zero reservations/credits/subs, then delete them. Never auto-touch internal
@metrognome.comaccounts. - [ ] Split (5): per cluster, decide the survivor and reparent the loser's FKs in one
withTransaction(audit.source='staff:account-merge'): reservations, credit balances/transfers, access codes, consent events/states, roles (dedup), waitlist, attribution, profile image; carryphone_verified_at+ winner-null profile fields; re-pointstripe_customer_idif the loser holds the active sub (vacate first,@unique); soft-delete loser, thenauth.admin.deleteUser(loserId),revokeUserSessions(winnerId); write anAccountMergerow. - [ ] Before any delete: enumerate every
user_idFK inschema.prismaand assert the loser has no remaining inbound references. - [ ] Re-run
audit-phone-dupesafter each batch.
Done when: the existing clusters are resolved (with Aaron), each audited.
Suggested sequence
1 (unwind) ✅ → 2 (config) ✅ → 3 (DB) ✅ → 4+5 (verify screen + flag-gated gate) ✅ → 6 (recovery payoff) ✅ → 7 (linkIdentity, follow-up). Dedup-prevention goes live when the flag is flipped (after the prod prerequisites). Phase B is independent and manual. Shipping as a single PR (Aaron); Phase 7 deferred to a follow-up.
Risks / watch-items
- Phone is now a login vector (SIM-swap). Account-takeover path that didn't exist with email/OAuth-only. Mitigate with GoTrue rate limits (
auth.rate_limit) + the provider's fraud signals. Accepted tradeoff (ADR-022). - Transition dedup gap. GoTrue's uniqueness catches collisions only between accounts that have verified a phone. Existing accounts' phones are unverified until backfill (Phase 5), so a brand-new duplicate of a not-yet-verified member can still slip through during rollout. Low harm at our scale; the existing handful are cleaned in Phase B regardless. The backfill closes the gap as the base verifies.
- Backfill verifies whichever account the member is signed into, not necessarily their "main" one. For a pre-existing duplicate, "phone on the right account" is settled in Phase B, not by the backfill. Don't read "everyone has a verified phone" as "every duplicate resolved."
- Mirror-trigger correctness. If the auth→public trigger drifts or is bypassed,
public.userscould disagree withauth.users. The partial unique index is the loud backstop; verify the trigger fires on bothphone_confirmed_at-set andphone-change. phone_changecolumn isn't unique in GoTrue (a documented Supabase gotcha) — only relevant to number recycling, which is out of scope (ADR-022). We only ever callupdateUseron the logged-in user's own session.- OAuth catch-and-clean must never delete an account with real data — guard on zero reservations/credits/subs before deleting an empty fork.
- FK enumeration completeness (Phase B split cases) — a missed
user_idFK orphans data on a reparent. Enumerate the schema exhaustively; assert no remaining inbound references before delete. - No prod-user writes without Aaron — remediation is manual and reasoned per cluster.
db.userssoft-delete is a hard cascade delete (Prisma) — a Phase B reparent must complete before delete or data is lost.- Shared real numbers (bands/couples) are forced to distinct numbers — expect a few support cases; no technical exception.