feat(identity): verify registration email by otp and sign in on verify (BF-379) - #102
Merged
Merged
Conversation
…y (BF-379)
Registration mails a six-digit code instead of a magic link, and verifying
that code is what mints the session. Sign-up stays sessionless, which is what
keeps its duplicate-email answer indistinguishable, so a new player still
lands signed in without giving up account enumeration.
Email verification is no longer enforced on sign-in: it moves behind
registration.requireEmailVerification, default off, so unverified players stay
unrestricted while the KYC toggle is off.
The RG and suspension gate is now one shared implementation in
rg-guard.service.ts, used by password login, phone login, and the new
verification path, so a session cannot be handed out through a gate that only
some paths enforce.
BREAKING CHANGE: verifyEmail takes { email, otp } and returns the session;
sendEmailVerification takes { email } and is unauthenticated; the verifyEmail
email template renders an otp instead of a url; registration.webUrl is
replaced by registration.requireEmailVerification.
zaxovaiko
requested review from
agniev-a-hub,
damianrzepka,
klaudia-blazyczek-blurify and
marek-chmielowski-blurify
as code owners
August 22, 2026 22:24
Verifying the emailed code on a 2FA-enrolled account expired every session on the user, not only the one better-auth minted moments earlier, so the player was silently logged out of all their devices. The RG and backoffice branch keeps revoking everything - that one is deliberate.
…on-otp-verification
…p (BF-379)
The optional "complete your profile" step after email verification had nowhere to
write: `player` carried only country and currency, so first name, last name, date
of birth and a contact number were dropped on the floor.
`player` gains all four as nullable columns, surfaced on `PlayerSchema` and
writable through `PATCH /profile`. The step is skippable and pre-existing rows
keep reading, so nullable is the honest shape.
`phone` is deliberately not unique. `user.phoneNumber` is unique because it is a
phone-login credential; making the self-declared contact number unique too would
answer 409 for a number the caller does not own - a phone-enumeration oracle of
exactly the kind `PhoneLoginService.requestOtp` shadow-responses to avoid - and
would let anyone squat a stranger's number before they ever verify it. The two are
different things: a contact detail and a credential. A future phone-verification
flow promotes one to the other.
`dateOfBirth` is a calendar date in string mode, not a timestamp: the default Date
mode round-trips through a timezone and shifts the day for players either side of
UTC. It is rejected on input under 18, compared calendar-wise so a leap day cannot
move the boundary.
Also fixes a 500 on the same route. `UpdatePlayerProfileInputSchema` accepted an
update carrying no fields, which reached `db.update().set({})` and threw; it now
requires at least one and answers 400, matching the identity module's own profile
contract. `country` is held to an ISO 3166-1 alpha-2 code so it can be compared
against the igaming config's jurisdictions and blocked-country lists.
Adds the changeset the earlier OTP commit on this branch omitted, including the
`registration.webUrl` -> `requireEmailVerification` migration note - the config
schema is strict, so the stale key fails a consumer's boot rather than warning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184Pm2rzwzCHvDJxcpqYzXR
…ification' into feat/bf-379-registration-otp-verification
…icate-email mail (BF-379) A log of registration activity showed only the attempts that worked. Nothing recorded a rejection: not the rate limits, not a geo block, not a taken username, not a consent write that had to roll the user back. The trail existed once, in a consumer overlay that recorded `registration.attempted` with its outcome; handing registration to core deleted the overlay and never replaced what it did. `identity.user.registration.failed` carries the address, the attempted handle, the origin and a typed reason, and the audit plugin files it under a `registration` resource with `result: 'failure'`. The known-address branch emits it too. That branch is the delicate one: no account is created, yet the caller is deliberately told the attempt succeeded, because a truthful answer there is an enumeration oracle. The audit log records what happened; the response does not. Two publications, not a contradiction. The accepted terms now ride along on the success event. They are omitted when the consent write was discarded because a player row already existed - a trail that implies evidence which was never stored is worse than one that stays quiet. Separately, whoever tried to sign up with an address that already had an account received "Your password reset code is: 123456" and nothing else. better-auth issues that mail and a self-service reset through the same `forget-password` OTP type, so no renderer could tell them apart - the consumer could not fix this from outside. `createAuth` now takes a predicate and picks a new `existingAccountSignUp` template, whose copy says no new account was created and offers the code as a reset. The predicate is fed by a set held only for the duration of the send; the call chain is synchronous, so it needs no TTL and no cache. Password rules are now shared by the flows that set one. Sign-up had no upper bound while reset capped at better-auth's 128, so an over-length password passed the contract and came back as a generic "Registration is unavailable". Sign-in stays uncapped - no longer password was ever storable, so bounding it there would narrow a contract for nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0184Pm2rzwzCHvDJxcpqYzXR
zaxovaiko
marked this pull request as draft
August 25, 2026 13:09
zaxovaiko
marked this pull request as ready for review
August 25, 2026 14:23
damianrzepka
approved these changes
Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Registration now mails a six-digit code instead of a magic link, and verifying that code is what mints the session. Email verification enforcement moves behind
registration.requireEmailVerification(default off). BF-379.Why
Two acceptance criteria contradicted what shipped: "successful registration creates a session and redirects to the homepage", and "unverified players are not restricted while the KYC toggle is off".
They cannot both be satisfied at sign-up. better-auth only returns its indistinguishable duplicate-email response when
autoSignInis off or verification is required, so minting the session at sign-up would have traded away the "registering with an already used email does not reveal whether the account exists" criterion. Moving the session to the verification step satisfies all three: sign-up stays sessionless and indistinguishable, and the player is signed in the moment they enter the code.The verification gate on sign-in was unconditional, which locked out every unverified account. It is now operator config, defaulting off, so unverified players stay unrestricted until KYC is turned on.
The RG and suspension gate was duplicated across password login and phone login. The new verification path mints a session too, so it needed the same gate; rather than add a third copy, the check moved into one shared implementation in
rg-guard.service.tsthat all three call. A gate only some login paths enforce is not a gate.Alternatives considered
autoSignIn: trueat sign-up. Simplest reading of the ticket, but it turns off better-auth's generic duplicate-email response and hands back account enumeration on the registration form.emailOTP({ sendVerificationOnSignUp: true })to mail the code. Rejected: that hook fires off the sign-up response, which on the duplicate-email path carries a synthetic user with the real owner's address. It would mail a live code to someone who never registered, and verifying that code signs its bearer in. The code is therefore sent explicitly fromregister(), only in the genuinely-new-user branch.Risks
Breaking changes for downstream operators:
verifyEmailtakes{ email, otp }instead of{ token }and returns{ user, session }(or{ twoFactorRedirect: true }) instead of{ success }.sendEmailVerificationtakes{ email }and is unauthenticated, since the player has no session until the code is verified. It always answers success and only mails an account that exists and is still unverified.verifyEmailemail template renders anotpinstead of aurl/token; custom renderers need updating.registration.webUrlis removed andregistration.requireEmailVerificationadded.RegistrationConfigSchemais.strict(), so an operator config still carryingwebUrlnow fails validation at boot rather than being ignored.Operational:
registration.requireEmailVerification: true.createSession, which its twoFactor plugin does not hook, so the code alone would otherwise bypass the second factor. Such accounts gettwoFactorRedirectand sign in throughlogin.