diff --git a/packages/auth/src/__tests__/auth.service.test.ts b/packages/auth/src/__tests__/auth.service.test.ts index e6ae761..1e793b0 100644 --- a/packages/auth/src/__tests__/auth.service.test.ts +++ b/packages/auth/src/__tests__/auth.service.test.ts @@ -10,6 +10,7 @@ import { createHmacAccessTokenService } from '../access-token-service'; import { createAuthService } from '../auth.service'; import { ERROR_MESSAGES } from '../constants'; import type { AuthRepository, EmailService } from '../types'; +import type { Session } from '../repositories/types'; import { sha256 } from '@orkait/crypto/hash'; import { randomBytes } from '@orkait/crypto/random'; @@ -33,6 +34,7 @@ class MemoryAuthRepository implements AuthRepository { users = new Map(); refreshTokens = new Map(); emailVerificationTokens = new Map(); + sessions = new Map(); failOnGetUserByEmail = false; failOnRotateRefreshToken = false; failOnConsumeEmailVerificationToken = false; @@ -185,6 +187,94 @@ class MemoryAuthRepository implements AuthRepository { } } } + + async createSession(session: Session): Promise { + this.sessions.set(session.id, { ...session }); + } + + async createSessionWithToken(session: Session, token: RefreshToken): Promise { + this.sessions.set(session.id, { ...session }); + this.refreshTokens.set(token.tokenHash, { ...token }); + } + + async updateSession(id: string, updates: Partial): Promise { + const current = this.sessions.get(id); + if (current) { + this.sessions.set(id, { ...current, ...updates }); + } + } + + async getSessionByRefreshTokenHash(tokenHash: string): Promise { + return ( + Array.from(this.sessions.values()).find( + (s) => s.refreshTokenHash === tokenHash && !s.revokedAt + ) ?? null + ); + } + + async getUserSessions(userId: string): Promise { + return Array.from(this.sessions.values()).filter( + (s) => s.userId === userId && !s.revokedAt + ); + } + + async revokeSession(id: string): Promise { + const current = this.sessions.get(id); + if (current) { + this.sessions.set(id, { ...current, revokedAt: Date.now() }); + } + } + + async revokeUserSessions(userId: string): Promise { + for (const [id, session] of this.sessions.entries()) { + if (session.userId === userId && !session.revokedAt) { + this.sessions.set(id, { ...session, revokedAt: Date.now() }); + } + } + } + + async rotateSessionToken(oldTokenHash: string, nextToken: RefreshToken): Promise { + if (this.failOnRotateRefreshToken) { + throw new Error('Failed to rotate refresh token'); + } + + const current = this.refreshTokens.get(oldTokenHash); + if (!current || current.revokedAt) { + return false; + } + + const session = Array.from(this.sessions.values()).find( + (s) => s.refreshTokenHash === oldTokenHash && !s.revokedAt + ); + + this.refreshTokens.set(nextToken.tokenHash, { ...nextToken }); + this.refreshTokens.set(oldTokenHash, { ...current, revokedAt: nextToken.createdAt }); + + if (session) { + this.sessions.set(session.id, { + ...session, + refreshTokenHash: nextToken.tokenHash, + updatedAt: nextToken.createdAt, + }); + } + + return true; + } + + async revokeTokenAndSession(tokenHash: string): Promise { + const now = Date.now(); + const token = this.refreshTokens.get(tokenHash); + if (token) { + this.refreshTokens.set(tokenHash, { ...token, revokedAt: token.revokedAt ?? now }); + } + + const session = Array.from(this.sessions.values()).find( + (s) => s.refreshTokenHash === tokenHash && !s.revokedAt + ); + if (session) { + this.sessions.set(session.id, { ...session, revokedAt: now }); + } + } } class MemoryEmailService implements EmailService { @@ -400,4 +490,138 @@ describe('AuthService', () => { expect(userBefore?.emailVerified).toBe(false); expect(userAfter?.emailVerified).toBe(false); }); + + it('creates a session on login', async () => { + const service = createService(); + await service.signup({ + email: 'session-login@example.com', + password: 'correct horse battery staple', + }); + + repository.sessions.clear(); + + const login = await service.login({ + email: 'session-login@example.com', + password: 'correct horse battery staple', + }); + + expect(login.success).toBe(true); + expect(repository.sessions.size).toBe(1); + + const session = Array.from(repository.sessions.values())[0]!; + expect(session.userId).toBe(login.data!.user.id); + expect(session.service).toBe('auth'); + expect(session.revokedAt).toBeNull(); + expect(session.refreshTokenHash).toBeTruthy(); + }); + + it('revokes the session on logout', async () => { + const service = createService(); + const signup = await service.signup({ + email: 'session-logout@example.com', + password: 'correct horse battery staple', + }); + + expect(signup.success).toBe(true); + expect(repository.sessions.size).toBe(1); + + const sessionBefore = Array.from(repository.sessions.values())[0]!; + expect(sessionBefore.revokedAt).toBeNull(); + + await service.logout(signup.data!.refreshToken); + + const sessionAfter = repository.sessions.get(sessionBefore.id)!; + expect(sessionAfter.revokedAt).not.toBeNull(); + }); + + it('revokes all sessions on logoutAll', async () => { + const service = createService(); + const signup = await service.signup({ + email: 'session-logout-all@example.com', + password: 'correct horse battery staple', + }); + + expect(signup.success).toBe(true); + + const login = await service.login({ + email: 'session-logout-all@example.com', + password: 'correct horse battery staple', + }); + + expect(login.success).toBe(true); + expect(repository.sessions.size).toBe(2); + + const activeBefore = Array.from(repository.sessions.values()).filter((s) => !s.revokedAt); + expect(activeBefore).toHaveLength(2); + + await service.logoutAll(signup.data!.user.id); + + const activeAfter = Array.from(repository.sessions.values()).filter((s) => !s.revokedAt); + expect(activeAfter).toHaveLength(0); + }); + + it('embeds session_id in the access token JWT', async () => { + const service = createService(); + const signup = await service.signup({ + email: 'jwt-session@example.com', + password: 'correct horse battery staple', + }); + + expect(signup.success).toBe(true); + + const payload = await service.verifyAccessToken(signup.data!.accessToken); + expect(typeof payload?.session_id).toBe('string'); + expect(payload!.session_id!.length).toBeGreaterThan(0); + expect(payload!.session_id).toMatch(/^sess/); + }); + + it('creates a session on googleAuth', async () => { + const googleClientId = 'test-google-client-id'; + const service = createAuthService( + { + repository, + accessTokenService: createHmacAccessTokenService({ + secret: randomBytes(32), + }), + emailService, + }, + { + clock, + accessTokenExpiresInSeconds: 300, + refreshTokenExpiresInSeconds: 600, + googleClientId, + } + ); + + const idToken = 'fake-google-id-token'; + const googleSub = 'google-sub-12345'; + const googleEmail = 'google-user@example.com'; + + const mockFetch = async () => ({ + ok: true, + json: async () => ({ + aud: googleClientId, + iss: 'https://accounts.google.com', + sub: googleSub, + email: googleEmail, + name: 'Google User', + }), + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = mockFetch as typeof fetch; + + try { + const result = await service.googleAuth(idToken); + expect(result.success).toBe(true); + expect(repository.sessions.size).toBe(1); + + const session = Array.from(repository.sessions.values())[0]!; + expect(session.userId).toBe(result.data!.user.id); + expect(session.refreshTokenHash).toBeTruthy(); + expect(session.revokedAt).toBeNull(); + } finally { + globalThis.fetch = originalFetch; + } + }); }); diff --git a/packages/auth/src/access-token-service.ts b/packages/auth/src/access-token-service.ts index a863a21..6f963fc 100644 --- a/packages/auth/src/access-token-service.ts +++ b/packages/auth/src/access-token-service.ts @@ -2,7 +2,7 @@ import type { JWTPayload, User } from '@orkait/common'; import { JWTService } from '@orkait/crypto/jwt'; export interface AccessTokenService { - signAccessToken(user: Pick, expiresInSeconds: number): Promise; + signAccessToken(user: Pick, expiresInSeconds: number, sessionId?: string): Promise; verifyAccessToken(token: string): Promise; } @@ -23,11 +23,12 @@ export function createHmacAccessTokenService( }); return { - async signAccessToken(user, expiresInSeconds) { + async signAccessToken(user, expiresInSeconds, sessionId?) { return service.signJWT( { sub: user.id, email: user.email, + ...(sessionId ? { session_id: sessionId } : {}), }, { expiresInSeconds } ); diff --git a/packages/auth/src/auth.builders.ts b/packages/auth/src/auth.builders.ts index 7d572be..1b0333d 100644 --- a/packages/auth/src/auth.builders.ts +++ b/packages/auth/src/auth.builders.ts @@ -23,7 +23,7 @@ export function buildLocalUser(params: { status: AUTH_USER_STATUS.ACTIVE, createdAt: params.now, updatedAt: params.now, - lastLoginAt: null, + lastLoginAt: params.now, failedLoginCount: 0, lockedUntil: null, }; diff --git a/packages/auth/src/auth.service.ts b/packages/auth/src/auth.service.ts index 0c8b6e7..80e3457 100644 --- a/packages/auth/src/auth.service.ts +++ b/packages/auth/src/auth.service.ts @@ -10,8 +10,10 @@ import { err, ok, systemClock, type Clock } from '@orkait/common'; import { sha256 } from '@orkait/crypto/hash'; import { randomToken } from '@orkait/crypto/random'; import { hashPassword, verifyPassword } from '@orkait/crypto/password'; +import { createId } from '@orkait/crypto/id'; import { AUTH_DEFAULTS, + AUTH_ID_PREFIX, AUTH_LIMITS, AUTH_USER_STATUS, ERROR_MESSAGES, @@ -31,6 +33,8 @@ import { sanitizeDisplayName, toGoogleAuthPayload, toPublicUser, + validateEmail, + validatePassword, } from './auth.helpers'; import type { AuthRepository, @@ -40,6 +44,7 @@ import type { SignupInput, } from './types'; import type { AccessTokenService } from './access-token-service'; +import type { Session } from './repositories/types'; interface RefreshTokenMaterial { plainTextToken: string; @@ -74,6 +79,12 @@ export class AuthService { async signup(input: SignupInput): Promise> { return this.executeServiceResult('signup', async () => { + const emailError = validateEmail(input.email); + if (emailError) return err(emailError); + + const passwordError = validatePassword(input.password); + if (passwordError) return err(passwordError); + const normalizedEmail = normalizeEmail(input.email); const passwordHash = await hashPassword(input.password); const existing = await this.repository.getUserByEmail(normalizedEmail); @@ -90,7 +101,14 @@ export class AuthService { now, }); - await this.repository.createUser(user); + try { + await this.repository.createUser(user); + } catch (e) { + if (e instanceof Error && e.message === 'Email already registered') { + return err(ERROR_MESSAGES.AUTH.EMAIL_ALREADY_REGISTERED); + } + throw e; + } await this.sendVerificationEmailIfConfigured(user); return ok(await this.createAuthResult(user)); @@ -99,6 +117,12 @@ export class AuthService { async login(input: LoginInput): Promise> { return this.executeServiceResult('login', async () => { + const emailError = validateEmail(input.email); + if (emailError) return err(ERROR_MESSAGES.AUTH.INVALID_CREDENTIALS); + + const passwordError = validatePassword(input.password); + if (passwordError) return err(ERROR_MESSAGES.AUTH.INVALID_CREDENTIALS); + const normalizedEmail = normalizeEmail(input.email); const user = await this.repository.getUserByEmail(normalizedEmail); @@ -157,6 +181,8 @@ export class AuthService { now, }); await this.repository.createUser(user); + } else if (user.status !== AUTH_USER_STATUS.ACTIVE) { + return err(ERROR_MESSAGES.AUTH.INVALID_CREDENTIALS); } else { user = await this.updateUserAndReload(user, { emailVerified: true, @@ -199,12 +225,9 @@ export class AuthService { return err(ERROR_MESSAGES.USER.NOT_FOUND); } - const accessToken = await this.accessTokenService.signAccessToken( - { id: user.id, email: user.email }, - this.accessTokenExpiresInSeconds - ); + const session = await this.repository.getSessionByRefreshTokenHash(tokenHash); const nextRefreshToken = await this.issueRefreshToken(user.id); - const rotated = await this.repository.rotateRefreshToken( + const rotated = await this.repository.rotateSessionToken( tokenHash, nextRefreshToken.record ); @@ -213,6 +236,13 @@ export class AuthService { return err(ERROR_MESSAGES.AUTH.INVALID_REFRESH_TOKEN); } + const sessionId = session?.id; + const accessToken = await this.accessTokenService.signAccessToken( + { id: user.id, email: user.email }, + this.accessTokenExpiresInSeconds, + sessionId + ); + return ok({ accessToken, refreshToken: nextRefreshToken.plainTextToken, @@ -222,23 +252,20 @@ export class AuthService { }); } - async logout(refreshToken: string): Promise { - try { + async logout(refreshToken: string): Promise> { + return this.executeServiceResult('logout', async () => { const tokenHash = await sha256(refreshToken); - await this.repository.revokeRefreshToken(tokenHash); - } catch (error) { - logger.error('Auth operation failed', error, { operation: 'logout' }); - throw error; - } + await this.repository.revokeTokenAndSession(tokenHash); + return ok(undefined); + }); } - async logoutAll(userId: string): Promise { - try { + async logoutAll(userId: string): Promise> { + return this.executeServiceResult('logoutAll', async () => { await this.repository.revokeAllUserRefreshTokens(userId); - } catch (error) { - logger.error('Auth operation failed', error, { operation: 'logoutAll' }); - throw error; - } + await this.repository.revokeUserSessions(userId); + return ok(undefined); + }); } async verifyAccessToken(token: string): Promise { @@ -269,6 +296,10 @@ export class AuthService { return err(ERROR_MESSAGES.AUTH.INVALID_VERIFICATION_TOKEN); } + if (verificationToken.verifiedAt !== null) { + return err(ERROR_MESSAGES.AUTH.VERIFICATION_TOKEN_ALREADY_USED); + } + const verifiedAt = this.clock.nowMs(); if (verificationToken.expiresAt < verifiedAt) { return err(ERROR_MESSAGES.AUTH.VERIFICATION_TOKEN_EXPIRED); @@ -325,28 +356,6 @@ export class AuthService { }); } - private async generateTokens( - user: User - ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { - const accessToken = await this.accessTokenService.signAccessToken( - { id: user.id, email: user.email }, - this.accessTokenExpiresInSeconds - ); - const refreshToken = await this.createRefreshToken(user.id); - - return { - accessToken, - refreshToken, - expiresIn: this.accessTokenExpiresInSeconds, - }; - } - - private async createRefreshToken(userId: string): Promise { - const refreshToken = await this.issueRefreshToken(userId); - await this.repository.createRefreshToken(refreshToken.record); - return refreshToken.plainTextToken; - } - private async issueRefreshToken(userId: string): Promise { const token = randomToken(AUTH_LIMITS.REFRESH_TOKEN_LENGTH); const tokenHash = await sha256(token); @@ -433,16 +442,47 @@ export class AuthService { } private async createAuthResult(user: User): Promise { - const tokens = await this.generateTokens(user); - return { ...tokens, user: toPublicUser(user) }; + const sessionId = createId(AUTH_ID_PREFIX.SESSION); + const refreshTokenMaterial = await this.issueRefreshToken(user.id); + + const accessToken = await this.accessTokenService.signAccessToken( + { id: user.id, email: user.email }, + this.accessTokenExpiresInSeconds, + sessionId + ); + + const now = this.clock.nowMs(); + const session: Session = { + id: sessionId, + userId: user.id, + tenantId: null, + service: 'auth', + refreshTokenHash: refreshTokenMaterial.record.tokenHash, + deviceInfo: null, + ipAddress: null, + expiresAt: now + this.refreshTokenExpiresInSeconds * 1000, + createdAt: now, + updatedAt: now, + revokedAt: null, + }; + + await this.repository.createSessionWithToken(session, refreshTokenMaterial.record); + + return { + accessToken, + refreshToken: refreshTokenMaterial.plainTextToken, + expiresIn: this.accessTokenExpiresInSeconds, + user: toPublicUser(user), + }; } private async updateUserAndReload(user: User, updates: Partial): Promise { await this.repository.updateUser(user.id, updates); - return (await this.repository.getUserById(user.id)) ?? { - ...user, - ...updates, - }; + const reloaded = await this.repository.getUserById(user.id); + if (!reloaded) { + throw new Error(`User ${user.id} not found after update`); + } + return reloaded; } private async executeServiceResult( diff --git a/packages/auth/src/constants.ts b/packages/auth/src/constants.ts index 726dbf9..63dae4e 100644 --- a/packages/auth/src/constants.ts +++ b/packages/auth/src/constants.ts @@ -16,10 +16,17 @@ export const ERROR_MESSAGES = { EMAIL_ALREADY_VERIFIED: 'Email already verified', VERIFICATION_TOKEN_EXPIRED: 'Verification token has expired', INVALID_VERIFICATION_TOKEN: 'Invalid or expired verification token', + VERIFICATION_TOKEN_ALREADY_USED: 'Email verification link has already been used', }, USER: { NOT_FOUND: 'User not found or suspended', }, + VALIDATION: { + PASSWORD_TOO_SHORT: `Password must be at least 8 characters`, + PASSWORD_TOO_LONG: 'Password exceeds maximum length', + INVALID_EMAIL: 'Invalid email address', + EMAIL_TOO_LONG: 'Email address exceeds maximum length', + }, EMAIL: { SERVICE_NOT_CONFIGURED: 'Email service not configured', VERIFICATION_SENT_IF_EXISTS: 'If the email exists, a verification link has been sent', @@ -39,6 +46,9 @@ export const AUTH_DEFAULTS = { export const AUTH_LIMITS = { MAX_NAME_LENGTH: 128, + MIN_PASSWORD_LENGTH: 8, + MAX_PASSWORD_BYTES: 72, + MAX_EMAIL_LENGTH: 254, MAX_FAILED_LOGIN_ATTEMPTS: 5, ACCOUNT_LOCKOUT_DURATION_MS: 15 * 60 * 1000, REFRESH_TOKEN_LENGTH: 64, @@ -50,6 +60,7 @@ export const AUTH_ID_PREFIX = { USER: 'usr', REFRESH_TOKEN: 'rt', EMAIL_VERIFICATION_TOKEN: 'evt', + SESSION: 'sess', } as const; export const AUTH_USER_STATUS = { diff --git a/packages/auth/src/db/row-types.ts b/packages/auth/src/db/row-types.ts index 2c924dc..6aeb006 100644 --- a/packages/auth/src/db/row-types.ts +++ b/packages/auth/src/db/row-types.ts @@ -1,233 +1,79 @@ -import type { Row } from "@orkait/database/sql"; - -/** -* Shared database row types (snake_case to match D1 schema). -* These are the common row types used across adapters and repositories. -*/ - -export interface UserRow { - [key: string]: unknown; - id: string; - email: string; - password_hash: string | null; - email_verified: number; - google_id: string | null; - name: string | null; - avatar_url: string | null; - status: string; - created_at: number; - updated_at: number; - last_login_at: number | null; - locked_until: number | null; - failed_login_count: number; -} - -export interface RefreshTokenRow { - [key: string]: unknown; - id: string; - user_id: string; - token_hash: string; - device_info: string | null; - ip_address: string | null; - expires_at: number; - created_at: number; - revoked_at: number | null; -} - -export interface EmailVerificationTokenRow { - [key: string]: unknown; - id: string; - user_id: string; - token: string; - token_hash: string; - expires_at: number; - created_at: number; - verified_at: number | null; -} - -export interface TenantRow { - [key: string]: unknown; - id: string; - name: string; - global_quota_limit: number | null; - created_at: number; - updated_at: number; -} - -export interface TenantUserRow { - [key: string]: unknown; - tenant_id: string; - user_id: string; - role: string; - created_at: number; -} - -export interface SessionRow extends Row { - [key: string]: unknown; - id: string; - user_id: string; - tenant_id: string | null; - service: string; - refresh_token_hash: string | null; - device_info: string | null; - ip_address: string | null; - expires_at: number; - created_at: number; - updated_at: number; - revoked_at: number | null; -} - -export interface ProductRow { - [key: string]: unknown; - id: string; - name: string; - slug: string; - description: string | null; - status: string; - created_at: number; - updated_at: number; -} - -export interface TierRow { - [key: string]: unknown; - id: string; - product_id: string; - name: string; - slug: string; - api_calls_limit: number; - resource_limit: number; - rate_limit_rpm: number; - features: string | null; - status: string; - created_at: number; -} - -export interface SubscriptionRow { - [key: string]: unknown; - id: string; - user_id: string; - product_id: string; - tier_id: string; - status: string; - current_period_start: number; - current_period_end: number; - external_subscription_id: string | null; - created_at: number; - updated_at: number; - cancelled_at: number | null; -} - -export interface ApiKeyRow { - [key: string]: unknown; - id: string; - subscription_id: string; - user_id: string; - key_hash: string; - key_prefix: string; - name: string | null; - status: string; - allowed_ips: string | null; - allowed_origins: string | null; - last_used_at: number | null; - created_at: number; - revoked_at: number | null; -} - -export interface UsageRow { - [key: string]: unknown; - id: string; - subscription_id: string; - api_key_id: string | null; - period_start: number; - period_end: number; - api_calls: number; - resource_count: number; - current_window_start: number | null; - current_window_count: number; - created_at: number; - updated_at: number; -} - -export interface WebhookConfigRow { - [key: string]: unknown; - id: string; - user_id: string; - url: string; - secret: string; - events: string; - status: string; - last_success_at: number | null; - last_failure_at: number | null; - consecutive_failures: number; - created_at: number; - updated_at: number; -} - -export interface WebhookDeliveryRow { - [key: string]: unknown; - id: string; - webhook_config_id: string; - event_type: string; - event_id: string; - payload: string; - status: string; - attempts: number; - response_status: number | null; - response_body: string | null; - created_at: number; - delivered_at: number | null; -} - -// Joined row types for complex queries -export interface SubscriptionWithTierRow extends SubscriptionRow { - [key: string]: unknown; - tier_id_full: string; - tier_product_id: string; - tier_name: string; - tier_slug: string; - api_calls_limit: number; - resource_limit: number; - rate_limit_rpm: number; - features: string | null; - tier_status: string; - tier_created_at: number; - prod_id: string; - prod_name: string; - prod_slug: string; - prod_description: string | null; - prod_status: string; - prod_created_at: number; - prod_updated_at: number; -} - -export interface ApiKeyWithSubscriptionRow extends ApiKeyRow { - [key: string]: unknown; - sub_id: string; - sub_user_id: string; - sub_product_id: string; - sub_tier_id: string; - sub_status: string; - current_period_start: number; - current_period_end: number; - external_subscription_id: string | null; - sub_created_at: number; - sub_updated_at: number; - cancelled_at: number | null; - tier_id_full: string; - tier_product_id: string; - tier_name: string; - tier_slug: string; - api_calls_limit: number; - resource_limit: number; - rate_limit_rpm: number; - features: string | null; - tier_status: string; - tier_created_at: number; - prod_id: string; - prod_name: string; - prod_slug: string; - prod_description: string | null; - prod_status: string; - prod_created_at: number; - prod_updated_at: number; -} +import type { Row } from "@orkait/database/sql"; + +/** +* Shared database row types (snake_case to match D1 schema). +* These are the common row types used across adapters and repositories. +*/ + +export interface UserRow { + [key: string]: unknown; + id: string; + email: string; + password_hash: string | null; + email_verified: number; + google_id: string | null; + name: string | null; + avatar_url: string | null; + status: string; + created_at: number; + updated_at: number; + last_login_at: number | null; + locked_until: number | null; + failed_login_count: number; +} + +export interface RefreshTokenRow { + [key: string]: unknown; + id: string; + user_id: string; + token_hash: string; + device_info: string | null; + ip_address: string | null; + expires_at: number; + created_at: number; + revoked_at: number | null; +} + +export interface EmailVerificationTokenRow { + [key: string]: unknown; + id: string; + user_id: string; + token: string; + token_hash: string; + expires_at: number; + created_at: number; + verified_at: number | null; +} + +export interface TenantRow { + [key: string]: unknown; + id: string; + name: string; + global_quota_limit: number | null; + created_at: number; + updated_at: number; +} + +export interface TenantUserRow { + [key: string]: unknown; + tenant_id: string; + user_id: string; + role: string; + created_at: number; +} + +export interface SessionRow extends Row { + [key: string]: unknown; + id: string; + user_id: string; + tenant_id: string | null; + service: string; + refresh_token_hash: string | null; + device_info: string | null; + ip_address: string | null; + expires_at: number; + created_at: number; + updated_at: number; + revoked_at: number | null; +} + diff --git a/packages/auth/src/repositories/auth-repository.ts b/packages/auth/src/repositories/auth-repository.ts index 4c1a764..ad384c4 100644 --- a/packages/auth/src/repositories/auth-repository.ts +++ b/packages/auth/src/repositories/auth-repository.ts @@ -1,385 +1,442 @@ -/** - * AuthRepository - Typed repository for auth-critical database operations. - * - * This repository uses explicit SQL queries with strong consistency for all - * auth-path reads. It composes domain-specific repositories for modularity. - * - * Usage: - * const db = new D1Adapter(env.DB, 'first-primary'); - * const repo = new AuthRepository(db); - * const user = await repo.getUserById(userId); - */ - -import type { DatabaseAdapter, QueryResult, QueryMeta, Row } from '@orkait/database/sql'; -import type { User, RefreshToken, EmailVerificationToken } from '@orkait/common'; -import type { Tenant, TenantUser, TenantRole, Session, BatchStatement } from './types'; -import { UserRepository } from './users'; -import { TenantRepository } from './tenants'; -import { TenantUserRepository } from './tenant-users'; -import { SessionRepository } from './sessions'; -import { RefreshTokenRepository } from './tokens'; -import { EmailVerificationTokenRepository } from './email-verification-tokens'; -import { requireResult } from './utils'; - -export class AuthRepository { - private db: DatabaseAdapter; - private users: UserRepository; - private tenants: TenantRepository; - private tenantUsers: TenantUserRepository; - private sessions: SessionRepository; - private tokens: RefreshTokenRepository; - private emailVerificationTokens: EmailVerificationTokenRepository; - - constructor(db: DatabaseAdapter) { - this.db = db; - this.users = new UserRepository(this.db); - this.tenants = new TenantRepository(this.db); - this.tenantUsers = new TenantUserRepository(this.db); - this.sessions = new SessionRepository(this.db); - this.tokens = new RefreshTokenRepository(this.db); - this.emailVerificationTokens = new EmailVerificationTokenRepository(this.db); - } - - /** - * Get the underlying AuthDB instance for use with extracted service packages - */ - getDB(): DatabaseAdapter { - return this.db; - } - - // ======================================================================== - // User Operations - // ======================================================================== - - getUserById(id: string): Promise { - return this.users.getById(id); - } - - getUserByEmail(email: string): Promise { - return this.users.getByEmail(email); - } - - getUserByGoogleId(googleId: string): Promise { - return this.users.getByGoogleId(googleId); - } - - async createUser(user: User): Promise { - await this.users.create(user); - } - - async updateUser(id: string, updates: Partial): Promise { - await this.users.update(id, updates); - } - - // ======================================================================== - // Tenant Operations - // ======================================================================== - - getTenantById(id: string): Promise { - return this.tenants.getById(id); - } - - getTenantByName(name: string): Promise { - return this.tenants.getByName(name); - } - - async createTenant(tenant: Tenant): Promise { - await this.tenants.create(tenant); - } - - async updateTenant(id: string, updates: Partial): Promise { - await this.tenants.update(id, updates); - } - - async deleteTenant(id: string): Promise { - await this.tenants.delete(id); - } - - // ======================================================================== - // Tenant User Operations - // ======================================================================== - - getTenantUser(tenantId: string, userId: string): Promise { - return this.tenantUsers.get(tenantId, userId); - } - - getTenantUsers(tenantId: string): Promise { - return this.tenantUsers.getByTenant(tenantId); - } - - getUserTenants(userId: string): Promise { - return this.tenantUsers.getByUser(userId); - } - - async addUserToTenant(tenantId: string, userId: string, role: TenantRole): Promise { - await this.tenantUsers.add(tenantId, userId, role); - } - - async updateTenantUserRole(tenantId: string, userId: string, role: TenantRole): Promise { - await this.tenantUsers.updateRole(tenantId, userId, role); - } - - async removeUserFromTenant(tenantId: string, userId: string): Promise { - await this.tenantUsers.remove(tenantId, userId); - } - - countTenantOwners(tenantId: string): Promise { - return this.tenantUsers.countOwners(tenantId); - } - - // ======================================================================== - // Session Operations - // ======================================================================== - - getSessionById(id: string): Promise { - return this.sessions.getById(id); - } - - getSessionByUserAndService( - userId: string, - tenantId: string | null, - service: string - ): Promise { - return this.sessions.getByUserAndService(userId, tenantId, service); - } - - getSessionByRefreshTokenHash(tokenHash: string): Promise { - return this.sessions.getByRefreshTokenHash(tokenHash); - } - - getUserSessions(userId: string): Promise { - return this.sessions.getByUser(userId); - } - - async createSession(session: Session): Promise { - await this.sessions.create(session); - } - - async updateSession(id: string, updates: Partial): Promise { - await this.sessions.update(id, updates); - } - - async revokeSession(id: string): Promise { - await this.sessions.revoke(id); - } - - async revokeUserSessions(userId: string): Promise { - await this.sessions.revokeByUser(userId); - } - - async revokeUserServiceSession(userId: string, service: string): Promise { - await this.sessions.revokeByUserAndService(userId, service); - } - - // ======================================================================== - // Refresh Token Operations - // ======================================================================== - - getRefreshToken(tokenHash: string): Promise { - return this.tokens.get(tokenHash); - } - - async createRefreshToken(token: RefreshToken): Promise { - await this.tokens.create(token); - } - - async rotateRefreshToken( - currentTokenHash: string, - nextToken: RefreshToken - ): Promise { - const [insertResult, revokeResult] = await this.batch([ - { - sql: `INSERT INTO refresh_tokens (id, user_id, token_hash, device_info, ip_address, expires_at, created_at, revoked_at) - SELECT ?, ?, ?, ?, ?, ?, ?, ? - WHERE EXISTS ( - SELECT 1 - FROM refresh_tokens - WHERE token_hash = ? AND revoked_at IS NULL - )`, - params: [ - nextToken.id, - nextToken.userId, - nextToken.tokenHash, - nextToken.deviceInfo, - nextToken.ipAddress, - nextToken.expiresAt, - nextToken.createdAt, - nextToken.revokedAt, - currentTokenHash, - ], - }, - { - sql: 'UPDATE refresh_tokens SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL', - params: [nextToken.createdAt, currentTokenHash], - }, - ]); - - return (insertResult?.meta.changes ?? 0) > 0 && (revokeResult?.meta.changes ?? 0) > 0; - } - - async revokeRefreshToken(tokenHash: string): Promise { - await this.tokens.revoke(tokenHash); - } - - async revokeAllUserRefreshTokens(userId: string): Promise { - await this.tokens.revokeAllForUser(userId); - } - - // ======================================================================== - // Email Verification Token Operations - // ======================================================================== - - getEmailVerificationTokenByHash(tokenHash: string): Promise { - return this.emailVerificationTokens.getByTokenHash(tokenHash); - } - - getEmailVerificationTokenByUserId(userId: string): Promise { - return this.emailVerificationTokens.getByUserId(userId); - } - - async createEmailVerificationToken(token: EmailVerificationToken): Promise { - await this.emailVerificationTokens.create(token); - } - - async consumeEmailVerificationToken( - tokenHash: string, - userId: string, - verifiedAt: number - ): Promise { - const [userUpdateResult, tokenUpdateResult] = await this.batch([ - { - sql: `UPDATE users - SET email_verified = 1, updated_at = ? - WHERE id = ? - AND EXISTS ( - SELECT 1 - FROM email_verification_tokens - WHERE token_hash = ? - AND user_id = ? - AND verified_at IS NULL - AND expires_at >= ? - )`, - params: [verifiedAt, userId, tokenHash, userId, verifiedAt], - }, - { - sql: `UPDATE email_verification_tokens - SET verified_at = ? - WHERE token_hash = ? - AND user_id = ? - AND verified_at IS NULL - AND expires_at >= ?`, - params: [verifiedAt, tokenHash, userId, verifiedAt], - }, - ]); - - return (userUpdateResult?.meta.changes ?? 0) > 0 && (tokenUpdateResult?.meta.changes ?? 0) > 0; - } - - async markEmailAsVerified(tokenHash: string): Promise { - await this.emailVerificationTokens.markAsVerified(tokenHash); - } - - async deleteExpiredEmailVerificationTokens(): Promise { - await this.emailVerificationTokens.deleteExpired(); - } - - async deleteEmailVerificationTokensForUser(userId: string): Promise { - await this.emailVerificationTokens.deleteForUser(userId); - } - - // ======================================================================== - // Batch Operations - // ======================================================================== - - async batch( - statements: BatchStatement[] - ): Promise[]> { - const result = await this.db.batch(statements); - const data = requireResult(result, 'Failed to execute batch'); - return data; - } - - async createSessionWithToken(session: Session, token: RefreshToken): Promise { - await this.batch([ - { - sql: `INSERT INTO sessions (id, user_id, tenant_id, service, refresh_token_hash, device_info, ip_address, expires_at, created_at, updated_at, revoked_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - params: [ - session.id, - session.userId, - session.tenantId, - session.service, - session.refreshTokenHash, - session.deviceInfo, - session.ipAddress, - session.expiresAt, - session.createdAt, - session.updatedAt, - session.revokedAt, - ], - }, - { - sql: `INSERT INTO refresh_tokens (id, user_id, token_hash, device_info, ip_address, expires_at, created_at, revoked_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - params: [ - token.id, - token.userId, - token.tokenHash, - token.deviceInfo, - token.ipAddress, - token.expiresAt, - token.createdAt, - token.revokedAt, - ], - }, - ]); - } - - async createTenantWithOwner(tenant: Tenant, ownerId: string): Promise { - await this.batch([ - { - sql: `INSERT INTO tenants (id, name, global_quota_limit, created_at, updated_at) - VALUES (?, ?, ?, ?, ?)`, - params: [ - tenant.id, - tenant.name, - tenant.globalQuotaLimit, - tenant.createdAt, - tenant.updatedAt, - ], - }, - { - sql: `INSERT INTO tenant_users (tenant_id, user_id, role, created_at) - VALUES (?, ?, 'owner', ?)`, - params: [tenant.id, ownerId, Date.now()], - }, - ]); - } - - // ======================================================================== - // Raw Access (for advanced queries) - // ======================================================================== - async rawFirst>( - sql: string, - params?: unknown[] - ): Promise { - const result = await this.db.first(sql, params); - const data = requireResult(result, 'Failed to execute query'); - return data; - } - - async rawAll>( - sql: string, - params?: unknown[] - ): Promise> { - const result = await this.db.all(sql, params); - const data = requireResult(result, 'Failed to execute query'); - return data; - } - - async rawRun(sql: string, params?: unknown[]): Promise { - const result = await this.db.run(sql, params); - const data = requireResult(result, 'Failed to execute statement'); - return data.meta; - } -} +/** + * AuthRepository - Typed repository for auth-critical database operations. + * + * This repository uses explicit SQL queries with strong consistency for all + * auth-path reads. It composes domain-specific repositories for modularity. + * + * Usage: + * const db = new D1Adapter(env.DB, 'first-primary'); + * const repo = new AuthRepository(db); + * const user = await repo.getUserById(userId); + */ + +import type { DatabaseAdapter, QueryResult, QueryMeta, Row } from '@orkait/database/sql'; +import type { User, RefreshToken, EmailVerificationToken } from '@orkait/common'; +import { systemClock, type Clock } from '@orkait/common'; +import type { Tenant, TenantUser, TenantRole, Session, BatchStatement } from './types'; +import { UserRepository } from './users'; +import { TenantRepository } from './tenants'; +import { TenantUserRepository } from './tenant-users'; +import { SessionRepository } from './sessions'; +import { RefreshTokenRepository } from './tokens'; +import { EmailVerificationTokenRepository } from './email-verification-tokens'; +import { requireResult } from './utils'; + +export class AuthRepository { + private db: DatabaseAdapter; + private clock: Clock; + private users: UserRepository; + private tenants: TenantRepository; + private tenantUsers: TenantUserRepository; + private sessions: SessionRepository; + private tokens: RefreshTokenRepository; + private emailVerificationTokens: EmailVerificationTokenRepository; + + constructor(db: DatabaseAdapter, clock: Clock = systemClock) { + this.db = db; + this.clock = clock; + this.users = new UserRepository(this.db); + this.tenants = new TenantRepository(this.db); + this.tenantUsers = new TenantUserRepository(this.db); + this.sessions = new SessionRepository(this.db, this.clock); + this.tokens = new RefreshTokenRepository(this.db); + this.emailVerificationTokens = new EmailVerificationTokenRepository(this.db); + } + + /** + * Get the underlying AuthDB instance for use with extracted service packages + */ + getDB(): DatabaseAdapter { + return this.db; + } + + // ======================================================================== + // User Operations + // ======================================================================== + + getUserById(id: string): Promise { + return this.users.getById(id); + } + + getUserByEmail(email: string): Promise { + return this.users.getByEmail(email); + } + + getUserByGoogleId(googleId: string): Promise { + return this.users.getByGoogleId(googleId); + } + + async createUser(user: User): Promise { + await this.users.create(user); + } + + async updateUser(id: string, updates: Partial): Promise { + await this.users.update(id, updates); + } + + // ======================================================================== + // Tenant Operations + // ======================================================================== + + getTenantById(id: string): Promise { + return this.tenants.getById(id); + } + + getTenantByName(name: string): Promise { + return this.tenants.getByName(name); + } + + async createTenant(tenant: Tenant): Promise { + await this.tenants.create(tenant); + } + + async updateTenant(id: string, updates: Partial): Promise { + await this.tenants.update(id, updates); + } + + async deleteTenant(id: string): Promise { + await this.tenants.delete(id); + } + + // ======================================================================== + // Tenant User Operations + // ======================================================================== + + getTenantUser(tenantId: string, userId: string): Promise { + return this.tenantUsers.get(tenantId, userId); + } + + getTenantUsers(tenantId: string): Promise { + return this.tenantUsers.getByTenant(tenantId); + } + + getUserTenants(userId: string): Promise { + return this.tenantUsers.getByUser(userId); + } + + async addUserToTenant(tenantId: string, userId: string, role: TenantRole): Promise { + await this.tenantUsers.add(tenantId, userId, role); + } + + async updateTenantUserRole(tenantId: string, userId: string, role: TenantRole): Promise { + await this.tenantUsers.updateRole(tenantId, userId, role); + } + + async removeUserFromTenant(tenantId: string, userId: string): Promise { + await this.tenantUsers.remove(tenantId, userId); + } + + countTenantOwners(tenantId: string): Promise { + return this.tenantUsers.countOwners(tenantId); + } + + // ======================================================================== + // Session Operations + // ======================================================================== + + getSessionById(id: string): Promise { + return this.sessions.getById(id); + } + + getSessionByUserAndService( + userId: string, + tenantId: string | null, + service: string + ): Promise { + return this.sessions.getByUserAndService(userId, tenantId, service); + } + + getSessionByRefreshTokenHash(tokenHash: string): Promise { + return this.sessions.getByRefreshTokenHash(tokenHash); + } + + getUserSessions(userId: string): Promise { + return this.sessions.getByUser(userId); + } + + async createSession(session: Session): Promise { + await this.sessions.create(session); + } + + async updateSession(id: string, updates: Partial): Promise { + await this.sessions.update(id, updates); + } + + async revokeSession(id: string): Promise { + await this.sessions.revoke(id); + } + + async revokeUserSessions(userId: string): Promise { + await this.sessions.revokeByUser(userId); + } + + async revokeUserServiceSession(userId: string, service: string): Promise { + await this.sessions.revokeByUserAndService(userId, service); + } + + // ======================================================================== + // Refresh Token Operations + // ======================================================================== + + getRefreshToken(tokenHash: string): Promise { + return this.tokens.get(tokenHash); + } + + async createRefreshToken(token: RefreshToken): Promise { + await this.tokens.create(token); + } + + async rotateRefreshToken( + currentTokenHash: string, + nextToken: RefreshToken + ): Promise { + const [insertResult, revokeResult] = await this.batch([ + { + sql: `INSERT INTO refresh_tokens (id, user_id, token_hash, device_info, ip_address, expires_at, created_at, revoked_at) + SELECT ?, ?, ?, ?, ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 + FROM refresh_tokens + WHERE token_hash = ? AND revoked_at IS NULL + )`, + params: [ + nextToken.id, + nextToken.userId, + nextToken.tokenHash, + nextToken.deviceInfo, + nextToken.ipAddress, + nextToken.expiresAt, + nextToken.createdAt, + nextToken.revokedAt, + currentTokenHash, + ], + }, + { + sql: 'UPDATE refresh_tokens SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL', + params: [nextToken.createdAt, currentTokenHash], + }, + ]); + + return (insertResult?.meta.changes ?? 0) > 0 && (revokeResult?.meta.changes ?? 0) > 0; + } + + async revokeRefreshToken(tokenHash: string): Promise { + await this.tokens.revoke(tokenHash); + } + + async revokeAllUserRefreshTokens(userId: string): Promise { + await this.tokens.revokeAllForUser(userId); + } + + // ======================================================================== + // Email Verification Token Operations + // ======================================================================== + + getEmailVerificationTokenByHash(tokenHash: string): Promise { + return this.emailVerificationTokens.getByTokenHash(tokenHash); + } + + getEmailVerificationTokenByUserId(userId: string): Promise { + return this.emailVerificationTokens.getByUserId(userId); + } + + async createEmailVerificationToken(token: EmailVerificationToken): Promise { + await this.emailVerificationTokens.create(token); + } + + async consumeEmailVerificationToken( + tokenHash: string, + userId: string, + verifiedAt: number + ): Promise { + const [userUpdateResult, tokenUpdateResult] = await this.batch([ + { + sql: `UPDATE users + SET email_verified = 1, updated_at = ? + WHERE id = ? + AND EXISTS ( + SELECT 1 + FROM email_verification_tokens + WHERE token_hash = ? + AND user_id = ? + AND verified_at IS NULL + AND expires_at >= ? + )`, + params: [verifiedAt, userId, tokenHash, userId, verifiedAt], + }, + { + sql: `UPDATE email_verification_tokens + SET verified_at = ? + WHERE token_hash = ? + AND user_id = ? + AND verified_at IS NULL + AND expires_at >= ?`, + params: [verifiedAt, tokenHash, userId, verifiedAt], + }, + ]); + + return (userUpdateResult?.meta.changes ?? 0) > 0 && (tokenUpdateResult?.meta.changes ?? 0) > 0; + } + + async markEmailAsVerified(tokenHash: string): Promise { + await this.emailVerificationTokens.markAsVerified(tokenHash); + } + + async deleteExpiredEmailVerificationTokens(): Promise { + await this.emailVerificationTokens.deleteExpired(); + } + + async deleteEmailVerificationTokensForUser(userId: string): Promise { + await this.emailVerificationTokens.deleteForUser(userId); + } + + // ======================================================================== + // Batch Operations + // ======================================================================== + + async batch( + statements: BatchStatement[] + ): Promise[]> { + const result = await this.db.batch(statements); + const data = requireResult(result, 'Failed to execute batch'); + return data; + } + + async createSessionWithToken(session: Session, token: RefreshToken): Promise { + await this.batch([ + { + sql: `INSERT INTO sessions (id, user_id, tenant_id, service, refresh_token_hash, device_info, ip_address, expires_at, created_at, updated_at, revoked_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + params: [ + session.id, + session.userId, + session.tenantId, + session.service, + session.refreshTokenHash, + session.deviceInfo, + session.ipAddress, + session.expiresAt, + session.createdAt, + session.updatedAt, + session.revokedAt, + ], + }, + { + sql: `INSERT INTO refresh_tokens (id, user_id, token_hash, device_info, ip_address, expires_at, created_at, revoked_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + params: [ + token.id, + token.userId, + token.tokenHash, + token.deviceInfo, + token.ipAddress, + token.expiresAt, + token.createdAt, + token.revokedAt, + ], + }, + ]); + } + + async rotateSessionToken(oldTokenHash: string, nextToken: RefreshToken): Promise { + const now = nextToken.createdAt; + const [insertResult, revokeTokenResult, updateSessionResult] = await this.batch([ + { + sql: `INSERT INTO refresh_tokens (id, user_id, token_hash, device_info, ip_address, expires_at, created_at, revoked_at) + SELECT ?, ?, ?, ?, ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 + FROM refresh_tokens + WHERE token_hash = ? AND revoked_at IS NULL + )`, + params: [ + nextToken.id, + nextToken.userId, + nextToken.tokenHash, + nextToken.deviceInfo, + nextToken.ipAddress, + nextToken.expiresAt, + nextToken.createdAt, + nextToken.revokedAt, + oldTokenHash, + ], + }, + { + sql: 'UPDATE refresh_tokens SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL', + params: [now, oldTokenHash], + }, + { + sql: 'UPDATE sessions SET refresh_token_hash = ?, updated_at = ? WHERE refresh_token_hash = ? AND revoked_at IS NULL', + params: [nextToken.tokenHash, now, oldTokenHash], + }, + ]); + + return ( + (insertResult?.meta.changes ?? 0) > 0 && + (revokeTokenResult?.meta.changes ?? 0) > 0 && + (updateSessionResult?.meta.changes ?? 0) > 0 + ); + } + + async revokeTokenAndSession(tokenHash: string): Promise { + const now = this.clock.nowMs(); + await this.batch([ + { + sql: 'UPDATE refresh_tokens SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL', + params: [now, tokenHash], + }, + { + sql: 'UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE refresh_token_hash = ? AND revoked_at IS NULL', + params: [now, now, tokenHash], + }, + ]); + } + + async createTenantWithOwner(tenant: Tenant, ownerId: string): Promise { + await this.batch([ + { + sql: `INSERT INTO tenants (id, name, global_quota_limit, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`, + params: [ + tenant.id, + tenant.name, + tenant.globalQuotaLimit, + tenant.createdAt, + tenant.updatedAt, + ], + }, + { + sql: `INSERT INTO tenant_users (tenant_id, user_id, role, created_at) + VALUES (?, ?, 'owner', ?)`, + params: [tenant.id, ownerId, this.clock.nowMs()], + }, + ]); + } + + // ======================================================================== + // Raw Access (for advanced queries) + // ======================================================================== + async rawFirst>( + sql: string, + params?: unknown[] + ): Promise { + const result = await this.db.first(sql, params); + const data = requireResult(result, 'Failed to execute query'); + return data; + } + + async rawAll>( + sql: string, + params?: unknown[] + ): Promise> { + const result = await this.db.all(sql, params); + const data = requireResult(result, 'Failed to execute query'); + return data; + } + + async rawRun(sql: string, params?: unknown[]): Promise { + const result = await this.db.run(sql, params); + const data = requireResult(result, 'Failed to execute statement'); + return data.meta; + } +} diff --git a/packages/auth/src/repositories/sessions.ts b/packages/auth/src/repositories/sessions.ts index cb49e4d..6e0e56d 100644 --- a/packages/auth/src/repositories/sessions.ts +++ b/packages/auth/src/repositories/sessions.ts @@ -1,4 +1,5 @@ import type { DatabaseAdapter } from '@orkait/database/sql'; +import { systemClock, type Clock } from '@orkait/common'; import type { Session, SessionRow } from './types'; import { mapSession } from './mappers'; import { executeUpdate, requireResult } from './utils'; @@ -12,7 +13,10 @@ const SESSION_UPDATE_MAPPINGS = [ ]; export class SessionRepository { - constructor(private db: DatabaseAdapter) { } + constructor( + private db: DatabaseAdapter, + private clock: Clock = systemClock + ) { } async getById(id: string): Promise { const result = await this.db.first( @@ -81,7 +85,7 @@ export class SessionRepository { } async revoke(id: string): Promise { - const now = Date.now(); + const now = this.clock.nowMs(); const result = await this.db.run( 'UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE id = ?', [now, now, id] @@ -90,7 +94,7 @@ export class SessionRepository { } async revokeByUser(userId: string): Promise { - const now = Date.now(); + const now = this.clock.nowMs(); const result = await this.db.run( 'UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE user_id = ? AND revoked_at IS NULL', [now, now, userId] @@ -99,7 +103,7 @@ export class SessionRepository { } async revokeByUserAndService(userId: string, service: string): Promise { - const now = Date.now(); + const now = this.clock.nowMs(); const result = await this.db.run( 'UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE user_id = ? AND service = ? AND revoked_at IS NULL', [now, now, userId, service] diff --git a/packages/auth/src/repositories/users.ts b/packages/auth/src/repositories/users.ts index 9f5f9be..5cab1dd 100644 --- a/packages/auth/src/repositories/users.ts +++ b/packages/auth/src/repositories/users.ts @@ -51,7 +51,8 @@ export class UserRepository { async create(user: User): Promise { const result = await this.db.run( `INSERT INTO users (id, email, password_hash, email_verified, google_id, name, avatar_url, status, created_at, updated_at, last_login_at, locked_until, failed_login_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM users WHERE email = ?)`, [ user.id, user.email, @@ -66,9 +67,13 @@ export class UserRepository { user.lastLoginAt, user.lockedUntil, user.failedLoginCount, + user.email, ] ); - requireResult(result, 'Failed to create user'); + const data = requireResult(result, 'Failed to create user'); + if (data.meta.changes === 0) { + throw new Error('Email already registered'); + } } async update(id: string, updates: Partial): Promise { diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index 03c25ea..85f2f1b 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -4,6 +4,7 @@ import type { Clock, EmailVerificationToken, RefreshToken, User } from '@orkait/common'; import type { AccessTokenService } from './access-token-service'; +import type { Session } from './repositories/types'; export interface SignupInput { email: string; @@ -69,4 +70,13 @@ export interface AuthRepository { ): Promise; markEmailAsVerified(tokenHash: string): Promise; deleteEmailVerificationTokensForUser(userId: string): Promise; + createSession(session: Session): Promise; + createSessionWithToken(session: Session, token: RefreshToken): Promise; + updateSession(id: string, updates: Partial): Promise; + getSessionByRefreshTokenHash(tokenHash: string): Promise; + getUserSessions(userId: string): Promise; + revokeSession(id: string): Promise; + revokeUserSessions(userId: string): Promise; + rotateSessionToken(oldTokenHash: string, nextToken: RefreshToken): Promise; + revokeTokenAndSession(tokenHash: string): Promise; } diff --git a/packages/auth/vitest.config.ts b/packages/auth/vitest.config.ts index 4d7500f..af9004d 100644 --- a/packages/auth/vitest.config.ts +++ b/packages/auth/vitest.config.ts @@ -1,3 +1,25 @@ +import { resolve } from 'node:path'; import { createPackageVitestConfig } from '../../tools/vitest/create-package-vitest-config'; +import { mergeConfig } from 'vitest/config'; -export default createPackageVitestConfig(import.meta.url); +const cryptoRoot = resolve(__dirname, '../crypto/src'); +const commonRoot = resolve(__dirname, '../common/src'); +const databaseRoot = resolve(__dirname, '../database/src'); + +export default mergeConfig(createPackageVitestConfig(import.meta.url), { + resolve: { + alias: { + '@orkait/crypto/jwt': resolve(cryptoRoot, 'jwt/index.ts'), + '@orkait/crypto/jwks': resolve(cryptoRoot, 'jwks/index.ts'), + '@orkait/crypto/encoding': resolve(cryptoRoot, 'encoding/index.ts'), + '@orkait/crypto/hash': resolve(cryptoRoot, 'hash.ts'), + '@orkait/crypto/id': resolve(cryptoRoot, 'id.ts'), + '@orkait/crypto/random': resolve(cryptoRoot, 'random.ts'), + '@orkait/crypto/password': resolve(cryptoRoot, 'password.ts'), + '@orkait/crypto': resolve(cryptoRoot, 'index.ts'), + '@orkait/common': resolve(commonRoot, 'index.ts'), + '@orkait/database/sql': resolve(databaseRoot, 'sql/index.ts'), + '@orkait/database': resolve(databaseRoot, 'sql/index.ts'), + }, + }, +}); diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 670271b..d424eb6 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -1,126 +1,130 @@ -/** - * Common types shared across packages - */ - -// Utility types -export type ID = string; -export type Timestamp = number; - -export interface ServiceResult { - success: boolean; - data?: T; - error?: string; -} - -// User types -export type UserStatus = "active" | "suspended" | "deleted"; - -export interface User { - id: string; - email: string; - passwordHash: string | null; - emailVerified: boolean; - googleId: string | null; - name: string | null; - avatarUrl: string | null; - status: UserStatus; - createdAt: number; - updatedAt: number; - lastLoginAt: number | null; - lockedUntil: number | null; - failedLoginCount: number; -} - -export interface UserPublic { - id: string; - email: string; - emailVerified: boolean; - name: string | null; - avatarUrl: string | null; - status: UserStatus; - createdAt: number; -} - -// Tenant types -export type TenantRole = "owner" | "admin" | "member"; - -export interface Tenant { - id: string; - name: string; - slug: string; - createdAt: number; - updatedAt: number; -} - -export interface TenantUser { - id: string; - tenantId: string; - userId: string; - role: TenantRole; - createdAt: number; -} - -// Session types -export interface Session { - id: string; - userId: string; - tenantId: string; - service: string; - expiresAt: number; - createdAt: number; - lastAccessedAt: number; -} - -// Auth types -export interface AuthTokens { - accessToken: string; - refreshToken: string; - expiresIn: number; -} - -export interface AuthResult extends AuthTokens { - user: UserPublic; -} - -export interface JWTPayload { - sub: string; - email?: string; - tenant_id?: string; - session_id?: string; - iat: number; - exp: number; -} - -export interface RefreshToken { - id: string; - userId: string; - tokenHash: string; - deviceInfo: string | null; - ipAddress: string | null; - expiresAt: number; - createdAt: number; - revokedAt: number | null; -} - -export interface EmailVerificationToken { - id: string; - userId: string; - token: string; - tokenHash: string; - expiresAt: number; - createdAt: number; - verifiedAt: number | null; -} - -// API types -export interface ApiError { - error: string; - message?: string; - details?: unknown; -} - -export interface ApiResponse { - success: boolean; - data?: T; - error?: string; -} +/** + * Common types shared across packages + */ + +// Utility types +export type ID = string; +export type Timestamp = number; + +export interface ServiceResult { + success: boolean; + data?: T; + error?: string; +} + +// User types +export type UserStatus = "active" | "suspended" | "deleted"; + +export interface User { + id: string; + email: string; + passwordHash: string | null; + emailVerified: boolean; + googleId: string | null; + name: string | null; + avatarUrl: string | null; + status: UserStatus; + createdAt: number; + updatedAt: number; + lastLoginAt: number | null; + lockedUntil: number | null; + failedLoginCount: number; +} + +export interface UserPublic { + id: string; + email: string; + emailVerified: boolean; + name: string | null; + avatarUrl: string | null; + status: UserStatus; + createdAt: number; +} + +// Tenant types +export type TenantRole = "owner" | "admin" | "member"; + +export interface Tenant { + id: string; + name: string; + slug: string; + createdAt: number; + updatedAt: number; +} + +export interface TenantUser { + id: string; + tenantId: string; + userId: string; + role: TenantRole; + createdAt: number; +} + +// Session types +export interface Session { + id: string; + userId: string; + tenantId: string | null; + service: string; + refreshTokenHash: string | null; + deviceInfo: string | null; + ipAddress: string | null; + expiresAt: number; + createdAt: number; + updatedAt: number; + revokedAt: number | null; +} + +// Auth types +export interface AuthTokens { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export interface AuthResult extends AuthTokens { + user: UserPublic; +} + +export interface JWTPayload { + sub: string; + email?: string; + tenant_id?: string; + session_id?: string; + iat: number; + exp: number; +} + +export interface RefreshToken { + id: string; + userId: string; + tokenHash: string; + deviceInfo: string | null; + ipAddress: string | null; + expiresAt: number; + createdAt: number; + revokedAt: number | null; +} + +export interface EmailVerificationToken { + id: string; + userId: string; + token: string; + tokenHash: string; + expiresAt: number; + createdAt: number; + verifiedAt: number | null; +} + +// API types +export interface ApiError { + error: string; + message?: string; + details?: unknown; +} + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; +} diff --git a/packages/mailer/src/email-service.ts b/packages/mailer/src/email-service.ts index 6e05760..01e116a 100644 --- a/packages/mailer/src/email-service.ts +++ b/packages/mailer/src/email-service.ts @@ -1,73 +1,85 @@ -import { z } from 'zod'; -import type { BaseEmailProvider } from './providers'; -import type { EmailStrategy } from './strategies'; -import { SingleProviderStrategy } from './strategies'; -import type { EmailData } from './types'; -import type { EmailTemplate } from './templates'; -import { BaseEmailSchema } from './types'; -import { EmailServiceConfigSchema, resolveEmailData } from './email-service.schema'; - -export interface EmailServiceConfig { - providers: BaseEmailProvider[]; - strategy?: EmailStrategy; - defaultFrom?: string; -} - -export class EmailService { - private providers: BaseEmailProvider[]; - private strategy: EmailStrategy; - private defaultFrom?: string; - - constructor(config: EmailServiceConfig) { - const validated = EmailServiceConfigSchema.parse(config); - - this.providers = validated.providers; - this.strategy = validated.strategy || new SingleProviderStrategy(); - this.defaultFrom = validated.defaultFrom; - } - - async send(emailData: Partial): Promise> { - const validated = BaseEmailSchema.parse(resolveEmailData(emailData, this.defaultFrom)); - return this.strategy.send(validated, this.providers); - } - - async sendWithTemplate( - template: EmailTemplate, - context: z.infer, - emailData: Omit, 'html' | 'text'> - ): Promise> { - const { html, text } = template.render(context); - - return this.send({ - ...emailData, - html, - text, - }); - } - - addProvider(provider: BaseEmailProvider): void { - this.providers.push(provider); - } - - removeProvider(providerName: string): void { - this.providers = this.providers.filter(p => p.getName() !== providerName); - } - - getProviders(): BaseEmailProvider[] { - return [...this.providers]; - } - - setStrategy(strategy: EmailStrategy): void { - this.strategy = strategy; - } - - async verifyProviders(): Promise> { - const results: Record = {}; - - for (const provider of this.providers) { - results[provider.getName()] = await provider.verify(); - } - - return results; - } -} +import { z } from 'zod'; +import type { BaseEmailProvider } from './providers'; +import type { EmailStrategy } from './strategies'; +import { SingleProviderStrategy } from './strategies'; +import type { EmailData } from './types'; +import type { EmailTemplate } from './templates'; +import { BaseEmailSchema } from './types'; +import { EmailServiceConfigSchema, resolveEmailData } from './email-service.schema'; + +export interface EmailServiceConfig { + providers: BaseEmailProvider[]; + strategy?: EmailStrategy; + defaultFrom?: string; +} + +export class EmailService { + private providers: BaseEmailProvider[]; + private strategy: EmailStrategy; + private defaultFrom?: string; + + constructor(config: EmailServiceConfig) { + const validated = EmailServiceConfigSchema.parse(config); + + this.providers = validated.providers; + this.strategy = validated.strategy || new SingleProviderStrategy(); + this.defaultFrom = validated.defaultFrom; + } + + async send(emailData: Partial): Promise> { + let validated: EmailData; + try { + validated = BaseEmailSchema.parse(resolveEmailData(emailData, this.defaultFrom)); + } catch (error) { + const message = error instanceof z.ZodError + ? error.errors.map(e => e.message).join(', ') + : 'Invalid email data'; + return { success: false, error: message, provider: 'validation' }; + } + return this.strategy.send(validated, this.providers); + } + + async sendWithTemplate( + template: EmailTemplate, + context: z.infer, + emailData: Omit, 'html' | 'text'> + ): Promise> { + const { html, text } = template.render(context); + + return this.send({ + ...emailData, + html, + text, + }); + } + + addProvider(provider: BaseEmailProvider): void { + this.providers.push(provider); + } + + removeProvider(providerName: string): void { + const next = this.providers.filter(p => p.getName() !== providerName); + if (next.length === 0) { + throw new Error('Cannot remove the last email provider'); + } + this.providers = next; + } + + getProviders(): BaseEmailProvider[] { + return [...this.providers]; + } + + setStrategy(strategy: EmailStrategy): void { + this.strategy = strategy; + } + + async verifyProviders(): Promise> { + const results: Record = {}; + + for (const provider of this.providers) { + results[provider.getName()] = await provider.verify(); + } + + return results; + } +} diff --git a/packages/rate-limit/src/__tests__/rate-limit.service.test.ts b/packages/rate-limit/src/__tests__/rate-limit.service.test.ts new file mode 100644 index 0000000..ae09dc1 --- /dev/null +++ b/packages/rate-limit/src/__tests__/rate-limit.service.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RateLimitService } from '../rate-limit.service'; + +interface MockClock { + nowMs: () => number; + nowSeconds: () => number; + advance: (ms: number) => void; +} + +function createMockRedis() { + const store = new Map(); + + const pipeline = () => { + const commands: Array<() => unknown> = []; + return { + get(key: string) { + commands.push(() => store.get(key)?.toString() ?? null); + return this; + }, + incr(key: string) { + commands.push(() => { + const val = (store.get(key) ?? 0) + 1; + store.set(key, val); + return val; + }); + return this; + }, + pexpire(_key: string, _ms: number) { + commands.push(() => 1); + return this; + }, + async exec(): Promise { + return commands.map(fn => fn()) as T; + }, + }; + }; + + return { + get: vi.fn(async (key: string) => store.get(key)?.toString() ?? null), + del: vi.fn(async (...keys: string[]) => { + let count = 0; + for (const key of keys) { + if (store.delete(key)) count++; + } + return count; + }), + pipeline, + _store: store, + }; +} + +function createMockClock(startMs: number): MockClock { + let now = startMs; + return { + nowMs: () => now, + nowSeconds: () => Math.floor(now / 1000), + advance: (ms: number) => { now += ms; }, + }; +} + +describe('RateLimitService', () => { + let redis: ReturnType; + let clock: MockClock; + let service: RateLimitService; + + beforeEach(() => { + redis = createMockRedis(); + clock = createMockClock(1_000_000); + service = new RateLimitService(redis as any, clock); + }); + + describe('validation', () => { + it('rejects limit < 1', async () => { + const result = await service.check('user:1', { limit: 0, windowMs: 60_000 }); + expect(result.success).toBe(false); + }); + + it('rejects non-integer limit', async () => { + const result = await service.check('user:1', { limit: 1.5, windowMs: 60_000 }); + expect(result.success).toBe(false); + }); + + it('rejects windowMs < 1', async () => { + const result = await service.check('user:1', { limit: 10, windowMs: 0 }); + expect(result.success).toBe(false); + }); + }); + + describe('fixed-window', () => { + const config = { limit: 3, windowMs: 60_000, algorithm: 'fixed-window' as const }; + + it('allows requests under limit', async () => { + const r1 = await service.check('user:1', config); + expect(r1.success).toBe(true); + expect(r1.data!.allowed).toBe(true); + expect(r1.data!.remaining).toBe(2); + }); + + it('blocks after limit is reached', async () => { + await service.check('user:1', config); + await service.check('user:1', config); + await service.check('user:1', config); + const r4 = await service.check('user:1', config); + + expect(r4.data!.allowed).toBe(false); + expect(r4.data!.remaining).toBe(0); + }); + + it('resets after window passes', async () => { + await service.check('user:1', config); + await service.check('user:1', config); + await service.check('user:1', config); + + clock.advance(60_001); + + const r = await service.check('user:1', config); + expect(r.data!.allowed).toBe(true); + expect(r.data!.remaining).toBe(2); + }); + + it('isolates different identifiers', async () => { + await service.check('user:1', config); + await service.check('user:1', config); + await service.check('user:1', config); + + const r = await service.check('user:2', config); + expect(r.data!.allowed).toBe(true); + expect(r.data!.remaining).toBe(2); + }); + }); + + describe('sliding-window', () => { + const config = { limit: 10, windowMs: 60_000, algorithm: 'sliding-window' as const }; + + it('allows requests under limit', async () => { + const r = await service.check('user:1', config); + expect(r.data!.allowed).toBe(true); + expect(r.data!.remaining).toBe(9); + }); + + it('blocks after limit reached', async () => { + for (let i = 0; i < 10; i++) { + await service.check('user:1', config); + } + const r = await service.check('user:1', config); + expect(r.data!.allowed).toBe(false); + expect(r.data!.remaining).toBe(0); + }); + + it('carries weight from previous window', async () => { + // clock starts at 1_000_000. windowMs = 60_000. + // Current window = floor(1_000_000/60_000) = 16 + for (let i = 0; i < 8; i++) { + await service.check('user:1', config); + } + // 8 requests stored in key rl:user:1:16 + + // Advance to 50% through window 17. + // Window 17 starts at 17 * 60_000 = 1_020_000 + // 50% through = 1_050_000. Advance = 1_050_000 - 1_000_000 = 50_000 + clock.advance(50_000); + // Now at 1_050_000. current window = 17, previous = 16. + // elapsedRatio = (1_050_000 % 60_000) / 60_000 = 30_000/60_000 = 0.5 + + const r = await service.check('user:1', config); + expect(r.data!.allowed).toBe(true); + + // Previous window (16): 8 requests, weighted by (1 - 0.5) = 4 + // Current window (17): 1 request (this one) + // Weighted total: floor(4) + 1 = 5, remaining: 10 - 5 = 5 + expect(r.data!.remaining).toBe(5); + }); + }); + + describe('fail-open', () => { + let svc: RateLimitService; + + beforeEach(() => { + const failingPipeline = () => ({ + get() { return this; }, + incr() { return this; }, + pexpire() { return this; }, + async exec() { throw new Error('connection refused'); }, + }); + const failingGet = vi.fn(async () => { throw new Error('connection refused'); }); + const badRedis = { pipeline: failingPipeline, get: failingGet, del: vi.fn() }; + svc = new RateLimitService(badRedis as any, clock); + }); + + it('allows requests when Redis errors via check', async () => { + const r = await svc.check('user:1', { limit: 5, windowMs: 60_000 }); + expect(r.data!.allowed).toBe(true); + }); + + it('sets degraded: true when Redis errors', async () => { + const r = await svc.check('user:1', { limit: 5, windowMs: 60_000 }); + expect(r.data!.degraded).toBe(true); + }); + + it('sets remaining: 0 in degraded mode (not full limit)', async () => { + const r = await svc.check('user:1', { limit: 5, windowMs: 60_000 }); + expect(r.data!.remaining).toBe(0); + }); + + it('sets degraded: true on peek when Redis errors', async () => { + const r = await svc.peek('user:1', { limit: 5, windowMs: 60_000, algorithm: 'fixed-window' }); + expect(r.data!.degraded).toBe(true); + expect(r.data!.remaining).toBe(0); + }); + }); + + describe('peek', () => { + const config = { limit: 5, windowMs: 60_000, algorithm: 'fixed-window' as const }; + + it('reads count without incrementing', async () => { + await service.check('user:1', config); + await service.check('user:1', config); + + const peek = await service.peek('user:1', config); + expect(peek.data!.remaining).toBe(3); + + // Next check should be 3rd request, not 4th (peek didn't increment) + const nextCheck = await service.check('user:1', config); + expect(nextCheck.data!.remaining).toBe(2); + }); + + it('does not set degraded when Redis is healthy', async () => { + const r = await service.peek('user:1', config); + expect(r.data!.degraded).toBeUndefined(); + }); + + // Boundary: at count == limit-1 (4 of 5), peek says allowed (one more can be made) + // then check consumes that last slot (count becomes 5 == limit), still allowed + it('peek and check agree at limit-1 boundary', async () => { + for (let i = 0; i < 4; i++) { + await service.check('user:1', config); + } + const peekResult = await service.peek('user:1', config); + // count=4, limit=5: 4 < 5 = true → one more request can be made + expect(peekResult.data!.allowed).toBe(true); + expect(peekResult.data!.remaining).toBe(1); + + // consuming that last slot: count becomes 5 == limit → still allowed + const checkResult = await service.check('user:1', config); + expect(checkResult.data!.allowed).toBe(true); + expect(checkResult.data!.remaining).toBe(0); + }); + + // Boundary: at count == limit (5 of 5), peek says NOT allowed (no more can be made) + // then check on the 6th attempt is also blocked + it('peek and check agree at limit boundary', async () => { + for (let i = 0; i < 5; i++) { + await service.check('user:1', config); + } + const peekResult = await service.peek('user:1', config); + // count=5, limit=5: 5 < 5 = false → no more requests can be made + expect(peekResult.data!.allowed).toBe(false); + expect(peekResult.data!.remaining).toBe(0); + + // 6th request: count becomes 6 > limit → blocked + const checkResult = await service.check('user:1', config); + expect(checkResult.data!.allowed).toBe(false); + expect(checkResult.data!.remaining).toBe(0); + }); + }); + + describe('reset', () => { + it('clears rate limit state', async () => { + const config = { limit: 2, windowMs: 60_000, algorithm: 'fixed-window' as const }; + await service.check('user:1', config); + await service.check('user:1', config); + + const blocked = await service.check('user:1', config); + expect(blocked.data!.allowed).toBe(false); + + await service.reset('user:1', config); + + const afterReset = await service.check('user:1', config); + expect(afterReset.data!.allowed).toBe(true); + }); + }); +}); diff --git a/packages/rate-limit/src/rate-limit.service.ts b/packages/rate-limit/src/rate-limit.service.ts new file mode 100644 index 0000000..12f189c --- /dev/null +++ b/packages/rate-limit/src/rate-limit.service.ts @@ -0,0 +1,235 @@ +import type { Redis } from '@upstash/redis'; +import type { Clock, ServiceResult } from '@orkait/common'; +import { ok, err, systemClock } from '@orkait/common'; +import type { RateLimitConfig, RateLimitResult, Algorithm } from './types'; +import { DEFAULTS, ERRORS } from './constants'; + +export class RateLimitService { + private readonly redis: Redis; + private readonly clock: Clock; + + constructor(redis: Redis, clock: Clock = systemClock) { + this.redis = redis; + this.clock = clock; + } + + async check( + identifier: string, + config: RateLimitConfig + ): Promise> { + const validationError = this.validateConfig(config); + if (validationError) return err(validationError); + + const algorithm = config.algorithm ?? DEFAULTS.ALGORITHM; + const prefix = config.prefix ?? DEFAULTS.PREFIX; + + try { + if (algorithm === 'fixed-window') { + return ok(await this.fixedWindow(identifier, prefix, config.limit, config.windowMs)); + } + return ok(await this.slidingWindow(identifier, prefix, config.limit, config.windowMs)); + } catch { + return ok(this.failOpen(config.limit)); + } + } + + async peek( + identifier: string, + config: RateLimitConfig + ): Promise> { + const validationError = this.validateConfig(config); + if (validationError) return err(validationError); + + const algorithm = config.algorithm ?? DEFAULTS.ALGORITHM; + const prefix = config.prefix ?? DEFAULTS.PREFIX; + + try { + if (algorithm === 'fixed-window') { + return ok(await this.peekFixedWindow(identifier, prefix, config.limit, config.windowMs)); + } + return ok(await this.peekSlidingWindow(identifier, prefix, config.limit, config.windowMs)); + } catch { + return ok(this.failOpen(config.limit)); + } + } + + async reset( + identifier: string, + config: Pick + ): Promise> { + const prefix = config.prefix ?? DEFAULTS.PREFIX; + const algorithm = config.algorithm ?? DEFAULTS.ALGORITHM; + + try { + if (algorithm === 'fixed-window') { + const now = this.clock.nowMs(); + const window = Math.floor(now / config.windowMs); + await this.redis.del(`${prefix}:${identifier}:${window}`); + } else { + const now = this.clock.nowMs(); + const currentWindow = Math.floor(now / config.windowMs); + const previousWindow = currentWindow - 1; + await this.redis.del( + `${prefix}:${identifier}:${currentWindow}`, + `${prefix}:${identifier}:${previousWindow}` + ); + } + return ok(undefined); + } catch { + return ok(undefined); + } + } + + /** + * Fixed window: simple counter per time bucket. + * Two Redis commands (pipelined): INCR + PEXPIRE. + * + * Tradeoff: can allow 2x burst at window boundary. + * Example: limit=10, window=60s. At t=59s, 10 requests. At t=61s (new window), 10 more. + * That's 20 requests in 2 seconds. Use sliding window to prevent this. + */ + private async fixedWindow( + identifier: string, + prefix: string, + limit: number, + windowMs: number + ): Promise { + const now = this.clock.nowMs(); + const window = Math.floor(now / windowMs); + const key = `${prefix}:${identifier}:${window}`; + const windowExpireMs = windowMs - (now % windowMs); + + const pipeline = this.redis.pipeline(); + pipeline.incr(key); + pipeline.pexpire(key, windowExpireMs); + const results = await pipeline.exec<[number, number]>(); + + const count = results[0] ?? 1; + const allowed = count <= limit; + const remaining = Math.max(0, limit - count); + const resetAt = (window + 1) * windowMs; + + return { allowed, limit, remaining, resetAt }; + } + + /** + * Sliding window: weighted count across current + previous window. + * Adapted from @upstash/ratelimit's approach. + * + * Uses two fixed-window keys. The previous window's count is weighted + * by how much of the current window has elapsed: + * + * weightedCount = previousCount * (1 - elapsedRatio) + currentCount + * + * Three Redis commands (pipelined): GET prev + INCR current + PEXPIRE current. + */ + private async slidingWindow( + identifier: string, + prefix: string, + limit: number, + windowMs: number + ): Promise { + const now = this.clock.nowMs(); + const currentWindow = Math.floor(now / windowMs); + const previousWindow = currentWindow - 1; + const currentKey = `${prefix}:${identifier}:${currentWindow}`; + const previousKey = `${prefix}:${identifier}:${previousWindow}`; + const windowExpireMs = 2 * windowMs; + + const pipeline = this.redis.pipeline(); + pipeline.get(previousKey); + pipeline.incr(currentKey); + pipeline.pexpire(currentKey, windowExpireMs); + const results = await pipeline.exec<[string | null, number, number]>(); + + const previousCount = Number(results[0] ?? 0); + const currentCount = results[1] ?? 1; + + const elapsedRatio = (now % windowMs) / windowMs; + const weightedCount = Math.floor(previousCount * (1 - elapsedRatio)) + currentCount; + + const allowed = weightedCount <= limit; + const remaining = Math.max(0, limit - weightedCount); + const resetAt = (currentWindow + 1) * windowMs; + + return { allowed, limit, remaining, resetAt }; + } + + private async peekFixedWindow( + identifier: string, + prefix: string, + limit: number, + windowMs: number + ): Promise { + const now = this.clock.nowMs(); + const window = Math.floor(now / windowMs); + const key = `${prefix}:${identifier}:${window}`; + + const count = Number(await this.redis.get(key) ?? 0); + const allowed = count < limit; + const remaining = Math.max(0, limit - count); + const resetAt = (window + 1) * windowMs; + + return { allowed, limit, remaining, resetAt }; + } + + private async peekSlidingWindow( + identifier: string, + prefix: string, + limit: number, + windowMs: number + ): Promise { + const now = this.clock.nowMs(); + const currentWindow = Math.floor(now / windowMs); + const previousWindow = currentWindow - 1; + + const pipeline = this.redis.pipeline(); + pipeline.get(`${prefix}:${identifier}:${previousWindow}`); + pipeline.get(`${prefix}:${identifier}:${currentWindow}`); + const results = await pipeline.exec<[string | null, string | null]>(); + + const previousCount = Number(results[0] ?? 0); + const currentCount = Number(results[1] ?? 0); + const elapsedRatio = (now % windowMs) / windowMs; + const weightedCount = Math.floor(previousCount * (1 - elapsedRatio)) + currentCount; + + const allowed = weightedCount < limit; + const remaining = Math.max(0, limit - weightedCount); + const resetAt = (currentWindow + 1) * windowMs; + + return { allowed, limit, remaining, resetAt }; + } + + /** + * peek uses `count < limit` (current count, before any increment) while + * check uses `count <= limit` (post-INCR count). Both are correct: + * - check: INCR then allow if count <= limit (5th request at count=5 is allowed, 6th at count=6 is blocked) + * - peek: GET then report if count < limit ("can I make one more?" - true only if next INCR would stay <= limit) + */ + private failOpen(limit: number): RateLimitResult { + return { + allowed: true, + limit, + remaining: 0, + resetAt: this.clock.nowMs() + 60_000, + degraded: true, + }; + } + + private validateConfig(config: RateLimitConfig): string | null { + if (!Number.isInteger(config.limit) || config.limit < 1) { + return ERRORS.INVALID_LIMIT; + } + if (!Number.isInteger(config.windowMs) || config.windowMs < 1) { + return ERRORS.INVALID_WINDOW; + } + return null; + } +} + +export function createRateLimitService( + redis: Redis, + clock?: Clock +): RateLimitService { + return new RateLimitService(redis, clock); +} diff --git a/packages/rate-limit/src/types.ts b/packages/rate-limit/src/types.ts new file mode 100644 index 0000000..bf0a7df --- /dev/null +++ b/packages/rate-limit/src/types.ts @@ -0,0 +1,39 @@ +export interface RateLimitResult { + allowed: boolean; + limit: number; + remaining: number; + resetAt: number; + /** + * True when the result was produced in fail-open mode (Redis unavailable). + * The actual rate-limit state is unknown - callers should treat this as degraded. + */ + degraded?: boolean; +} + +export type Algorithm = 'fixed-window' | 'sliding-window'; + +export interface RateLimitConfig { + /** + * Maximum number of requests allowed in the window. + */ + limit: number; + + /** + * Window duration in milliseconds. + */ + windowMs: number; + + /** + * Algorithm to use. Default: 'sliding-window'. + * - 'fixed-window': simple counter per time bucket. Cheaper (2 Redis commands). + * Can allow 2x burst at window boundary. + * - 'sliding-window': weighted count across current + previous window. + * Smoother rate enforcement. 3 Redis commands. + */ + algorithm?: Algorithm; + + /** + * Key prefix for Redis keys. Default: 'rl'. + */ + prefix?: string; +}