Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
"tables": [
"account",
"session",
"sms_otp_session",
"two_factor",
"user",
"verification"
Expand All @@ -156,6 +157,8 @@
"identity.login",
"identity.logout",
"identity.me",
"identity.phoneLoginRequest",
"identity.phoneLoginVerify",
"identity.register",
"identity.requestPasswordReset",
"identity.resetPassword",
Expand Down Expand Up @@ -492,6 +495,16 @@
"packages/core/src/casino/gaming/plugin.ts"
]
},
{
"category": "sms",
"interface": "SmsAdapter",
"token": "SMS_ADAPTER",
"status": "wired",
"boundIn": [
"packages/core/src/pam/identity/adapters/mock/mock-sms-adapter.ts",
"packages/core/src/pam/identity/plugin.ts"
]
},
{
"category": "wallet-commands",
"interface": "WalletDebitArgs",
Expand Down Expand Up @@ -536,6 +549,8 @@
"identity.2fa.enabled",
"identity.email.verified",
"identity.password.reset",
"identity.phone_otp.cancelled",
"identity.phone_otp.requested",
"identity.profile.updated",
"identity.session.revoked",
"identity.sessions.revoked_all",
Expand All @@ -544,6 +559,7 @@
"identity.user.login",
"identity.user.login.failed",
"identity.user.logout",
"identity.user.phone_login",
"identity.user.reactivated",
"identity.user.registered",
"identity.user.unlocked",
Expand Down Expand Up @@ -717,6 +733,10 @@
"name": "Disable2faInputSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
},
{
"name": "E164PhoneSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
},
{
"name": "EffectivePermissionsSchema",
"file": "packages/core/src/iam/contract/index.ts"
Expand Down Expand Up @@ -901,6 +921,18 @@
"name": "PermissionLevelSchema",
"file": "packages/core/src/contracts/schemas/iam.ts"
},
{
"name": "PhoneLoginRequestInputSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
},
{
"name": "PhoneLoginRequestOutputSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
},
{
"name": "PhoneLoginVerifyInputSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
},
{
"name": "PlatformConfigSchema",
"file": "packages/core/src/contracts/schemas/platform-config.ts"
Expand Down Expand Up @@ -1283,6 +1315,8 @@
"POST /identity/password/change",
"POST /identity/password/forgot",
"POST /identity/password/reset",
"POST /identity/phone-login/request",
"POST /identity/phone-login/verify",
"POST /identity/register",
"POST /identity/sessions/revoke",
"POST /identity/sessions/revoke-all",
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/audit/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,31 @@ function mapEventToRecord(topic: string, p: Record<string, unknown>): RecordInpu
};
}

// Player authenticated via SMS OTP. actorId = resource = the player; success outcome.
if (topic === 'identity.user.phone_login') {
return {
...base,
actorType: 'player',
actorId: str(p['userId']),
resourceType: 'user',
resourceId: str(p['userId']),
result: 'success',
};
}

// A pending OTP was cancelled. `max_attempts` is a system-driven security event
// (guessing exhausted); recorded as a failure with the reason for the trail.
if (topic === 'identity.phone_otp.cancelled') {
return {
...base,
actorType: 'system',
resourceType: 'user',
resourceId: str(p['userId']),
result: 'failure',
after: { reason: p['reason'] ?? null },
};
}

// A lockout is a system-driven security control: the subject is the resource, not the
// actor, and it is a failure outcome (the base regex would otherwise mark it success).
if (topic === 'identity.user.lockout.triggered') {
Expand Down Expand Up @@ -291,6 +316,8 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [
'identity.user.lockout.triggered',
'identity.user.unlocked',
'identity.user.logout',
'identity.user.phone_login',
'identity.phone_otp.cancelled',
'identity.2fa.enabled',
'identity.2fa.disabled',
'identity.password.reset',
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/contracts/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ export { RNG_ADAPTER } from './rng.js';
export type { SendEmailPort } from './email.js';
export { SEND_EMAIL } from './email.js';

export type { SmsAdapter } from './sms.js';
export { SMS_ADAPTER } from './sms.js';

export type { AdminGrant, AdminPermissionResolver } from './admin-permission.js';
export { ADMIN_PERMISSION_RESOLVER } from './admin-permission.js';

Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/contracts/adapters/sms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// SMS delivery seam. The phone-login OTP flow sends codes through this adapter so the
// transport is swappable: the platform default (bound in identity/plugin.ts) is a
// log-to-stdout mock, safe for dev/stage. A consumer overlay rebinds SMS_ADAPTER to a
// real vendor (Twilio, AWS SNS) in production. See AGENTS.md "third-party integration".
import { createToken, type Token } from './token.js';

export type SmsAdapter = {
sendOtp(params: { to: string; code: string }): Promise<void>;
};

export const SMS_ADAPTER: Token<SmsAdapter> = createToken('SMS_ADAPTER');
17 changes: 17 additions & 0 deletions packages/core/src/contracts/schemas/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,25 @@ export const domainEventSchemas = {
'identity.user.login.failed': authContextBase.extend({
email: z.email(),
reason: z.string().nullable().optional(),
// Remaining credential attempts before lockout - surfaced so the client can show a
// "Forgot password?" CTA as the count runs down. Absent on non-credential errors.
attemptsRemaining: z.number().int().optional(),
}),
'identity.user.logout': z.object({ userId: UuidSchema }),
// A player authenticated via SMS OTP (phone login). TOTP 2FA does not apply here;
// the session is minted directly. `method` is a literal so the audit log can
// distinguish it from a password login.
'identity.user.phone_login': authContextBase.extend({
userId: UuidSchema,
method: z.literal('phone'),
}),
// A pending OTP was invalidated - either superseded by a fresh request or killed
// after too many wrong guesses (a security event when `max_attempts`).
'identity.phone_otp.requested': z.object({ userId: UuidSchema }),
'identity.phone_otp.cancelled': z.object({
userId: UuidSchema,
reason: z.enum(['max_attempts', 'new_otp_requested']),
}),
'identity.user.lockout.triggered': authContextBase.extend({
userId: UuidSchema,
email: z.email(),
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/contracts/schemas/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,35 @@ export const UserSchema = z.object({
image: z.url().nullable().optional(),
theme: ThemeSchema,
language: LanguageSchema,
// Phone-login fields. Optional so existing callers/serializers are unaffected; a
// player without a verified phone simply carries a null number and `false`.
phoneNumber: z.string().nullable().optional(),
phoneVerified: z.boolean().optional(),
createdAt: TimestampSchema,
updatedAt: TimestampSchema,
});

// E.164: '+' then a non-zero country digit and 7-14 more digits (8-15 total).
export const E164PhoneSchema = z.string().regex(/^\+[1-9]\d{7,14}$/);

export const PhoneLoginRequestInputSchema = z.object({ phone: E164PhoneSchema });

export const PhoneLoginRequestOutputSchema = z.object({
// When the OTP the caller just requested expires (now + 5 min).
expiresAt: TimestampSchema,
// The earliest a fresh OTP may be requested for this phone (now + 60 s).
resendAfter: TimestampSchema,
});

export const PhoneLoginVerifyInputSchema = z.object({
phone: E164PhoneSchema,
code: z
.string()
.length(6)
.regex(/^\d{6}$/),
rememberMe: z.boolean().optional(),
});

export const OrganizationSchema = z.object({
id: UuidSchema,
name: z.string().min(1).max(255),
Expand Down Expand Up @@ -117,3 +142,7 @@ export type VerifyEmailInput = z.infer<typeof VerifyEmailInputSchema>;
export type UpdateProfileInput = z.infer<typeof UpdateProfileInputSchema>;
export type ChangePasswordInput = z.infer<typeof ChangePasswordInputSchema>;
export type ChangeEmailInput = z.infer<typeof ChangeEmailInputSchema>;
export type E164Phone = z.infer<typeof E164PhoneSchema>;
export type PhoneLoginRequestInput = z.infer<typeof PhoneLoginRequestInputSchema>;
export type PhoneLoginRequestOutput = z.infer<typeof PhoneLoginRequestOutputSchema>;
export type PhoneLoginVerifyInput = z.infer<typeof PhoneLoginVerifyInputSchema>;
4 changes: 3 additions & 1 deletion packages/core/src/pam/identity/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

better-auth-backed authentication + account lifecycle (register, login, logout, 2FA, password reset, email verification, profile). The engine's `SessionResolver`/`AdminGuard` verify sessions against this module's tables (injected, never imported - ADR-0019/0025).

Login lockout: a per-account credential-failure counter (`user.failed_login_attempts` + `lockout_until`); after `maxAttempts` (default 5) failures the account locks for `durationMs` (default 15min). Configurable via `IDENTITY_OPTIONS`. This is correctness-per-account, distinct from the rate limiter below.
Login lockout: a per-account credential-failure counter (`user.failed_login_attempts` + `lockout_until`); after `maxAttempts` (default 5) failures the account locks. The duration is progressive - a repeat lockout inside a rolling 24h window escalates 1min -> 5min -> 15min (`user.lockout_count` + `user.last_lockout_at`; the window resets once `last_lockout_at` falls outside it). `IDENTITY_OPTIONS.lockout.durationMs` is the tier-3+ fallback. `identity.user.login.failed` carries `attemptsRemaining` so the client can prompt a password reset as the count runs down. This is correctness-per-account, distinct from the rate limiter below.

Phone login (SMS OTP): a standalone login method for players with a verified phone (`user.phone_number` E.164 + `user.phone_verified`; phone management routes are a separate story). `POST /identity/phone-login/request` sends a 6-digit code via the `SMS_ADAPTER` port (default `MockSmsAdapter` logs to stdout; an overlay rebinds it to Twilio/SNS) - anti-enumeration: it returns the same `{ expiresAt, resendAfter }` whether or not the phone is registered, with a 60s resend cooldown and one live code per user (SHA-256 hashed in `sms_otp_session`, 5min TTL). `POST /identity/phone-login/verify` exchanges a valid code for a session, minted DIRECTLY in the `session` table to bypass better-auth's TOTP plugin chain - TOTP 2FA does NOT apply to phone login by design. Wrong codes increment `failed_attempts` (5-attempt cap, then the session is cancelled). The RG login block is enforced after the OTP verifies, same as password login. Rate limits: request 3/15min, verify 10/5min (both fail closed). Events: `identity.user.phone_login`, `identity.phone_otp.requested`, `identity.phone_otp.cancelled`; the login + max-attempts cancel are audited.

Rate limiting: `login`, `register`, `requestPasswordReset`, `resetPassword`, `verify2fa`, and `sendEmailVerification` consume a per-identifier budget via the `RATE_LIMITER` port (keyed by normalized email/token/session, never IP) before doing work - exceeding it throws a 429 (`TOO_MANY_REQUESTS`) with `retryAfterMs`. Defaults are named constants in `identity.service.ts` (login 10/5min, register 5/15min, reset-request 3/15min, reset 5/15min, verify2fa 5/5min, email-verification 3/15min); an overlay rebinds `RATE_LIMITER` to a Redis backend to change policy backend, not the numbers.
Loading
Loading