Skip to content

feat(identity): verify registration email by otp and sign in on verify (BF-379) - #102

Merged
damianrzepka merged 8 commits into
devfrom
feat/bf-379-registration-otp-verification
Aug 25, 2026
Merged

feat(identity): verify registration email by otp and sign in on verify (BF-379)#102
damianrzepka merged 8 commits into
devfrom
feat/bf-379-registration-otp-verification

Conversation

@zaxovaiko

Copy link
Copy Markdown
Member

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 autoSignIn is 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.ts that all three call. A gate only some login paths enforce is not a gate.

Alternatives considered

  • autoSignIn: true at 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.
  • Keeping verification mandatory and treating the ticket as wrong. Rejected: the product decision is explicit that unverified players are unrestricted while KYC is off.
  • 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 from register(), only in the genuinely-new-user branch.
  • Leaving the link flow in place alongside the code. Rejected as dead surface once nothing issues links.

Risks

Breaking changes for downstream operators:

  • verifyEmail takes { email, otp } instead of { token } and returns { user, session } (or { twoFactorRedirect: true }) instead of { success }.
  • sendEmailVerification takes { 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.
  • The verifyEmail email template renders an otp instead of a url/token; custom renderers need updating.
  • registration.webUrl is removed and registration.requireEmailVerification added. RegistrationConfigSchema is .strict(), so an operator config still carrying webUrl now fails validation at boot rather than being ignored.

Operational:

  • With the gate off by default, an unverified account is fully functional: someone can register an address they do not control, play and cash out, and the real owner can no longer register that address. This follows the product decision that unverified players are unrestricted while KYC is off, and is flagged rather than overridden. Operators wanting the stricter behaviour set registration.requireEmailVerification: true.
  • A 2FA-enrolled account is verified but deliberately not signed in by the code: better-auth mints that session through createSession, which its twoFactor plugin does not hook, so the code alone would otherwise bypass the second factor. Such accounts get twoFactorRedirect and sign in through login.
  • The verify and resend rate-limit budgets fail closed, so a limiter outage rejects verification attempts rather than opening a brute-force window on a six-digit code.

…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.
Comment thread packages/core/src/pam/identity/service/identity.service.ts Outdated
Comment thread packages/core/src/pam/identity/service/identity.service.ts
Comment thread packages/core/src/contracts/schemas/platform-config.ts
zaxovaiko and others added 7 commits August 24, 2026 12:34
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.
…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
zaxovaiko marked this pull request as draft August 25, 2026 13:09
@zaxovaiko
zaxovaiko marked this pull request as ready for review August 25, 2026 14:23
@zaxovaiko
zaxovaiko requested a review from damianrzepka August 25, 2026 15:19
@damianrzepka
damianrzepka merged commit 2ecd6cb into dev Aug 25, 2026
2 checks passed
@damianrzepka
damianrzepka deleted the feat/bf-379-registration-otp-verification branch August 25, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants