Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/player-profile-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@openora/core': minor
---

The player profile gains the fields a registration flow's optional profile step collects: `firstName`, `lastName`, `dateOfBirth`, and `phone` on the `player` table, surfaced on `PlayerSchema` and writable through `PATCH /profile`. All four are nullable - the step is skippable, and rows written before this release keep reading fine.

- `phone` is the player's self-declared contact number and is deliberately **not** unique. `user.phoneNumber` stays the unique, verified phone-login credential; making this one unique too would turn an optional profile field into a phone-enumeration oracle (a duplicate would answer `409` for a number the caller does not own) and would let anyone permanently squat a stranger's number before they verify it. A future phone-verification flow promotes `player.phone` to `user.phoneNumber`.
- `dateOfBirth` is a plain calendar date (`YYYY-MM-DD`, no timezone) and is rejected on input when it puts the player under 18.
- `country` on this route is now held to an ISO 3166-1 alpha-2 code rather than any string, so it can actually be compared against the `jurisdictions` and `blockedCountries` lists in the igaming config.

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 field and answers `400`, matching the identity module's own profile-update contract.

Consumers apply the `profile` module's `0003` migration (`pnpm db:migrate`).
17 changes: 17 additions & 0 deletions .changeset/registration-audit-and-duplicate-email.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@openora/core': minor
---

Registration now leaves an audit trail whether or not it produces an account, and a sign-up on an address that already has one gets its own email instead of a bare password-reset code.

**Rejected attempts are audited.** New `identity.user.registration.failed` domain event, emitted by every path in `IdentityService.register` that ends without an account - registration not configured, either rate limit, a geo block, a taken username, a failed sign-up, and a consent write that had to roll the user back. It carries the address, the attempted username, the origin (IP and user agent) and a typed `reason`; the audit plugin subscribes to it and files it under `resourceType: 'registration'` with `result: 'failure'`. Previously only successes were recorded, so a log of registration activity showed nothing but the attempts that worked.

The known-address branch emits it too, with `reason: 'email_already_registered'`. The HTTP response is unchanged and still indistinguishable from a fresh sign-up - the audit log records what happened, the response deliberately does not, because a truthful answer there is an account-enumeration oracle.

**The accepted terms reach the audit log.** `identity.user.registered` now carries `termsVersion`, `acceptedTerms` and `acceptedAge`. They are omitted when the consent write was discarded because a player row already existed, so the trail never implies evidence that was not stored. The `player` row already held the current values; it is not a record of the act.

**New `existingAccountSignUp` email template key.** better-auth issues both the self-service reset and the duplicate-sign-up notice through the same `forget-password` OTP type, so a renderer could not tell them apart and the second one went out as "Your password reset code is: …" to a player who had asked for no such thing. `createAuth` takes an `isExistingAccountSignUp` predicate and picks the new key; the shipped copy explains that no new account was created and offers the code as a reset.

Adding the key is minor-breaking for a consumer whose renderer is backed by a full `Record<EmailTemplateKey, …>` map - it will not compile until the new entry exists. A renderer that switches on the key with a fallback is unaffected.

**Password rules match across the flows that set one.** `PasswordSchema` (min 8, max 128) is now shared by sign-up, password change and password reset. Sign-up previously had no upper bound, so an over-length password passed the contract and was rejected by better-auth as a generic "Registration is unavailable". Sign-in is deliberately left uncapped: no longer password was ever storable, so a bound there could only narrow an existing contract.
15 changes: 15 additions & 0 deletions .changeset/registration-email-otp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@openora/core': minor
---

Registration now 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 moved 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 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.

Upgrading a consumer takes three edits:

- **`registration.webUrl` is gone, replaced by `registration.requireEmailVerification`.** `PlatformConfigSchema` is `.strict()`, so leaving the old key in place does not warn - it throws `Invalid platform config: registration: Unrecognized key: "webUrl"` the first time `PLATFORM_CONFIG` resolves, which is during router construction and therefore before the server ever listens.
- **`verifyEmail` takes `{ email, otp }` instead of `{ token }`** and returns `{ user?, session?, twoFactorRedirect? }` rather than `{ success: true }`. A 2FA-enrolled account is verified but deliberately not signed in: it gets `twoFactorRedirect` and completes the challenge through `login`.
- **`sendEmailVerification` takes `{ email }` and is unauthenticated**, since the player has no session until the code is verified. It always answers success, so it never reveals whether the address has an account.

The `verifyEmail` email template renders an `otp` instead of a `url` - a consumer with its own template renderer must update it, or the mail goes out with an empty body.
13 changes: 13 additions & 0 deletions docs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,7 @@
"identity.user.phone_login",
"identity.user.reactivated",
"identity.user.registered",
"identity.user.registration.failed",
"identity.user.unauthorized_access",
"identity.user.unlocked",
"notifications.created",
Expand Down Expand Up @@ -1739,6 +1740,10 @@
"name": "PaginatedPlayerSearchArgsSchema",
"file": "packages/core/src/contracts/schemas/player.ts"
},
{
"name": "PasswordSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
},
{
"name": "PaymentWebhookInputSchema",
"file": "packages/core/src/wallet/contract/index.ts"
Expand Down Expand Up @@ -1911,6 +1916,10 @@
"name": "RegistrationConfigSchema",
"file": "packages/core/src/contracts/schemas/platform-config.ts"
},
{
"name": "RegistrationFailureReasonSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
},
{
"name": "RegistrationsOverTimePointSchema",
"file": "packages/core/src/admin-console/contract/index.ts"
Expand Down Expand Up @@ -1947,6 +1956,10 @@
"name": "RequestPasswordResetInputSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
},
{
"name": "ResendEmailVerificationInputSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
},
{
"name": "ResetPasswordInputSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/audit/__tests__/map-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,34 @@ describe('mapEventToRecord: identity.session.revoked', () => {
expect(row).toMatchObject({ actorType: 'admin', actorId: adminId, resourceId: sessionId });
});
});

describe('mapEventToRecord: identity.user.registration.failed', () => {
it('records a rejected attempt as a failure against the registration resource', async () => {
const row = await mapEventToRecord('identity.user.registration.failed', {
email: 'taken@example.com',
username: 'taken_handle',
reason: 'username_taken',
ip: '203.0.113.7',
userAgent: 'Mozilla/5.0',
});

expect(row).toMatchObject({
result: 'failure',
resourceType: 'registration',
actorType: 'system',
resourceId: null,
ip: '203.0.113.7',
userAgent: 'Mozilla/5.0',
});
});

it('carries the address and reason through, since a rejected attempt has no actor', async () => {
const row = await mapEventToRecord('identity.user.registration.failed', {
email: 'blocked@example.com',
reason: 'geo_blocked',
});

expect(row.after).toMatchObject({ email: 'blocked@example.com', reason: 'geo_blocked' });
expect(row.actorId).toBeUndefined();
});
});
8 changes: 8 additions & 0 deletions packages/core/src/audit/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,13 @@ export async function mapEventToRecord(
return { ...base, actorId: str(p['playerId']), actorType: 'player' };
}

// A rejected attempt has no account behind it, so there is no actor to name - only the
// address that was tried, which `after` already carries. `resourceType` is narrowed off
// the default 'identity' so an auditor can pull registration attempts on their own.
if (topic === 'identity.user.registration.failed') {
return { ...base, resourceType: 'registration' };
}

// Shared identity self-action topics: the same `/identity/*` endpoints serve
// both player and admin accounts, so playerId only resolves for a player. A
// null playerId means the account has no player row - attribute to the
Expand Down Expand Up @@ -645,6 +652,7 @@ export async function mapEventToRecord(

const SUBSCRIBED_TOPICS: DomainEventName[] = [
'identity.user.registered',
'identity.user.registration.failed',
'identity.user.login',
'identity.user.login.failed',
'identity.user.lockout.triggered',
Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/contracts/__tests__/identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import {
ChangePasswordInputSchema,
LoginInputSchema,
RegisterInputSchema,
ResetPasswordInputSchema,
} from '../schemas/identity.js';

const OVER_LENGTH = 'a'.repeat(129);

const registerInput = (password: string) => ({
email: 'player@example.com',
password,
username: 'new_player',
acceptedTerms: true as const,
acceptedAge: true as const,
});

describe('password rules', () => {
it('holds sign-up to the same bounds as the password reset flow', () => {
expect(RegisterInputSchema.safeParse(registerInput('short')).success).toBe(false);
expect(RegisterInputSchema.safeParse(registerInput('password123')).success).toBe(true);
});

// better-auth caps at 128 itself, but only after the fact: on sign-up that surfaces as
// a generic "Registration is unavailable", and on reset it burns the one-time code
// first. Both must be rejected at the contract instead.
it.each([
['register', () => RegisterInputSchema.safeParse(registerInput(OVER_LENGTH))],
[
'reset',
() =>
ResetPasswordInputSchema.safeParse({
email: 'player@example.com',
otp: '123456',
newPassword: OVER_LENGTH,
}),
],
[
'change',
() =>
ChangePasswordInputSchema.safeParse({
currentPassword: 'password123',
newPassword: OVER_LENGTH,
}),
],
])('rejects a password over 128 characters on %s', (_name, parse) => {
expect(parse().success).toBe(false);
});

// Sign-in is deliberately left uncapped: no longer password was ever storable, so a
// bound there could only narrow an existing contract for nothing.
it('does not cap the password on sign-in', () => {
const result = LoginInputSchema.safeParse({
email: 'player@example.com',
password: OVER_LENGTH,
});

expect(result.success).toBe(true);
});
});
71 changes: 71 additions & 0 deletions packages/core/src/contracts/__tests__/player.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import {
MIN_PLAYER_AGE_YEARS,
UpdatePlayerProfileInputSchema,
isAdultDateOfBirth,
} from '../schemas/player.js';

const NOW = new Date('2026-08-24T00:00:00Z');

function isoBirthday(yearsAgo: number, offsetDays = 0) {
const d = new Date(NOW);
d.setUTCFullYear(d.getUTCFullYear() - yearsAgo);
d.setUTCDate(d.getUTCDate() + offsetDays);
return d.toISOString().slice(0, 10);
}

describe('isAdultDateOfBirth', () => {
it('passes a player on their own 18th birthday', () => {
expect(isAdultDateOfBirth(isoBirthday(MIN_PLAYER_AGE_YEARS), NOW)).toBe(true);
});

it('rejects a player one day short of 18', () => {
expect(isAdultDateOfBirth(isoBirthday(MIN_PLAYER_AGE_YEARS, 1), NOW)).toBe(false);
});

it('rejects a date in the future', () => {
expect(isAdultDateOfBirth('2030-01-01', NOW)).toBe(false);
});
});

describe('UpdatePlayerProfileInputSchema', () => {
it('accepts the full profile the signup step collects', () => {
const input = {
firstName: 'Ada',
lastName: 'Lovelace',
dateOfBirth: '1990-05-17',
phone: '+441632960001',
country: 'GB',
};

expect(UpdatePlayerProfileInputSchema.parse(input)).toEqual(input);
});

it('accepts null to clear an optional field', () => {
expect(UpdatePlayerProfileInputSchema.safeParse({ phone: null }).success).toBe(true);
});

// Without this the handler reaches `db.update().set({})`, which drizzle rejects at
// runtime - a 500 on a form where the player simply changed nothing.
it('rejects an update that carries no fields', () => {
expect(UpdatePlayerProfileInputSchema.safeParse({}).success).toBe(false);
});

it('rejects a country display name instead of an ISO 3166-1 alpha-2 code', () => {
expect(UpdatePlayerProfileInputSchema.safeParse({ country: 'United Kingdom' }).success).toBe(
false,
);
});

it('rejects a phone number that is not E.164', () => {
expect(UpdatePlayerProfileInputSchema.safeParse({ phone: '+44 1632 960001' }).success).toBe(
false,
);
});

it('rejects a date of birth under the minimum age', () => {
const result = UpdatePlayerProfileInputSchema.safeParse({ dateOfBirth: '2020-01-01' });

expect(result.success).toBe(false);
});
});
18 changes: 16 additions & 2 deletions packages/core/src/contracts/adapters/email-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import { createToken, type Token } from './token.js';
export type EmailTemplateKey =
| 'verifyEmail'
| 'resetPasswordOtp'
| 'existingAccountSignUp'
| 'rgLimitUpdated'
| 'rgCoolingOffActivated'
| 'rgCoolingOffLifted'
| 'rgSelfExclusionActivated'
| 'rgSelfExclusionLifted';

export type EmailTemplateData = {
verifyEmail: { url: string; token: string };
verifyEmail: { otp: string };
resetPasswordOtp: { otp: string; email: string };
existingAccountSignUp: { otp: string; email: string };
rgLimitUpdated: { period: string; type: string; description: string };
rgCoolingOffActivated: { expiresAt: Date };
rgCoolingOffLifted: Record<string, never>;
Expand Down Expand Up @@ -52,12 +54,24 @@ export const DEFAULT_EMAIL_TEMPLATES: {
} = {
verifyEmail: (data) => ({
subject: 'Verify your email',
body: `Verify your email using this link: ${data.url}\n\nVerification token: ${data.token}`,
body: `Your email verification code is: ${data.otp}`,
}),
resetPasswordOtp: (data) => ({
subject: 'Reset your password',
body: `Your password reset code is: ${data.otp}`,
}),
// Sent when someone tries to sign up with an address that already has an account. It
// must never confirm or deny that the account exists to anyone but its owner, so the
// wording addresses the owner and the sign-up response stays identical either way.
// Carries a reset code rather than only a notice: whoever tried is most likely the
// owner having forgotten they registered.
existingAccountSignUp: (data) => ({
subject: 'You already have an account',
body:
`Someone tried to create an account with this email address. ` +
`You already have one, so no new account was created. ` +
`If it was you, sign in as usual - or use this code to reset your password: ${data.otp}`,
}),
rgLimitUpdated: (data) => ({
subject: 'Your gambling limit was updated',
body: `A ${data.period} ${data.type} limit of ${data.description} is now active on your account.`,
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/contracts/schemas/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { TagKeySchema } from './tag.js';
import { CurrencyCodeSchema, CountryCodeSchema } from './igaming-config.js';
import { PermissionLevelSchema } from './iam.js';
import { RegistrationFailureReasonSchema, UsernameSchema } from './identity.js';
import { KycStatusSchema, KycStatusSourceSchema, PlayerStatusSchema } from './player.js';

// Optional request-origin metadata shared by HTTP-triggered events; both fields may be absent.
Expand Down Expand Up @@ -53,6 +54,19 @@ export const domainEventSchemas = {
'identity.user.registered': authContextBase.extend({
userId: UuidSchema,
playerId: UuidSchema.nullable(),
// The consent the player actually gave, carried so the audit trail holds it too -
// the `player` row alone is current state, not a record of the act. Absent when the
// consent write was discarded because a player row already existed.
termsVersion: z.string().optional(),
acceptedTerms: z.literal(true).optional(),
acceptedAge: z.literal(true).optional(),
}),
// No `userId`: registration is unauthenticated, so the address is the only subject
// there is - the same shape `identity.user.login.failed` settled on below.
'identity.user.registration.failed': authContextBase.extend({
email: z.email(),
username: UsernameSchema.optional(),
reason: RegistrationFailureReasonSchema,
}),
'identity.user.login': authContextBase.extend({
userId: UuidSchema,
Expand Down
Loading
Loading