🔀 merge: auth/complete branch with Better Auth implementation

Merged auth/complete into main with resolved conflicts:
- Kept Better Auth system (EdDSA JWT via JWKS)
- Removed all Coolify references
- Added dev:auth and dev:chat:full scripts for auth development
- Combined zitare scripts from main with auth scripts
- Exported both feedback.schema and organizations.schema

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Wuesteon 2025-12-01 15:25:38 +01:00
commit 8a43bbfc25
84 changed files with 13452 additions and 6778 deletions

View file

@ -0,0 +1,363 @@
/**
* Mock Factories for Testing
*
* Centralized factory functions for creating test data
*/
import { nanoid } from 'nanoid';
import * as bcrypt from 'bcrypt';
/**
* Mock User Factory
*/
export const mockUserFactory = {
create: (overrides: Partial<any> = {}) => ({
id: nanoid(),
email: `test-${nanoid(6)}@example.com`,
emailVerified: true,
name: 'Test User',
avatarUrl: null,
role: 'user',
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
}),
createMany: (count: number, overrides: Partial<any> = {}) => {
return Array.from({ length: count }, () => mockUserFactory.create(overrides));
},
};
/**
* Mock Session Factory
*/
export const mockSessionFactory = {
create: (userId: string, overrides: Partial<any> = {}) => ({
id: nanoid(),
userId,
token: nanoid(),
refreshToken: nanoid(64),
refreshTokenExpiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
ipAddress: '127.0.0.1',
userAgent: 'Mozilla/5.0 Test',
deviceId: null,
deviceName: null,
lastActivityAt: new Date(),
createdAt: new Date(),
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
revokedAt: null,
...overrides,
}),
};
/**
* Mock Password Factory
*/
export const mockPasswordFactory = {
create: async (userId: string, password: string = 'TestPassword123!') => ({
userId,
hashedPassword: await bcrypt.hash(password, 12),
createdAt: new Date(),
updatedAt: new Date(),
}),
createSync: (userId: string, password: string = 'TestPassword123!') => ({
userId,
hashedPassword: bcrypt.hashSync(password, 12),
createdAt: new Date(),
updatedAt: new Date(),
}),
};
/**
* Mock Balance Factory
*/
export const mockBalanceFactory = {
create: (userId: string, overrides: Partial<any> = {}) => ({
userId,
balance: 0,
freeCreditsRemaining: 150,
dailyFreeCredits: 5,
lastDailyResetAt: new Date(),
totalEarned: 0,
totalSpent: 0,
version: 0,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}),
withBalance: (userId: string, balance: number, freeCredits: number = 0) => {
return mockBalanceFactory.create(userId, {
balance,
freeCreditsRemaining: freeCredits,
});
},
};
/**
* Mock Transaction Factory
*/
export const mockTransactionFactory = {
create: (userId: string, overrides: Partial<any> = {}) => ({
id: nanoid(),
userId,
type: 'usage',
status: 'completed',
amount: -10,
balanceBefore: 100,
balanceAfter: 90,
appId: 'test-app',
description: 'Test transaction',
metadata: null,
idempotencyKey: null,
createdAt: new Date(),
completedAt: new Date(),
...overrides,
}),
createMany: (userId: string, count: number) => {
return Array.from({ length: count }, (_, i) =>
mockTransactionFactory.create(userId, {
amount: -(i + 1) * 10,
})
);
},
};
/**
* Mock Package Factory
*/
export const mockPackageFactory = {
create: (overrides: Partial<any> = {}) => ({
id: nanoid(),
name: 'Test Package',
description: '100 credits',
credits: 100,
priceEuroCents: 100,
stripePriceId: `price_${nanoid()}`,
active: true,
sortOrder: 0,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}),
createMany: (count: number) => {
return Array.from({ length: count }, (_, i) =>
mockPackageFactory.create({
name: `Package ${i + 1}`,
credits: (i + 1) * 100,
priceEuroCents: (i + 1) * 100,
sortOrder: i,
})
);
},
};
/**
* Mock Purchase Factory
*/
export const mockPurchaseFactory = {
create: (userId: string, packageId: string, overrides: Partial<any> = {}) => ({
id: nanoid(),
userId,
packageId,
credits: 100,
priceEuroCents: 100,
stripePaymentIntentId: `pi_${nanoid()}`,
stripeCustomerId: `cus_${nanoid()}`,
status: 'completed',
metadata: null,
createdAt: new Date(),
completedAt: new Date(),
...overrides,
}),
};
/**
* Mock DTO Factory
*/
export const mockDtoFactory = {
register: (overrides: Partial<any> = {}) => ({
email: `test-${nanoid(6)}@example.com`,
password: 'SecurePassword123!',
name: 'Test User',
...overrides,
}),
login: (overrides: Partial<any> = {}) => ({
email: 'test@example.com',
password: 'SecurePassword123!',
deviceId: undefined,
deviceName: undefined,
...overrides,
}),
useCredits: (overrides: Partial<any> = {}) => ({
amount: 10,
appId: 'test-app',
description: 'Test operation',
metadata: undefined,
idempotencyKey: undefined,
...overrides,
}),
};
/**
* Mock JWT Tokens
*/
export const mockTokenFactory = {
validPayload: (overrides: Partial<any> = {}) => ({
sub: nanoid(),
email: 'test@example.com',
role: 'user',
sessionId: nanoid(),
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 15 * 60, // 15 minutes
...overrides,
}),
expiredPayload: (overrides: Partial<any> = {}) => ({
sub: nanoid(),
email: 'test@example.com',
role: 'user',
sessionId: nanoid(),
iat: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
exp: Math.floor(Date.now() / 1000) - 1800, // 30 minutes ago (expired)
...overrides,
}),
};
/**
* Mock Organization Factory
*/
export const mockOrganizationFactory = {
create: (overrides: Partial<any> = {}) => ({
id: nanoid(),
name: 'Test Organization',
slug: `test-org-${nanoid(6)}`,
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}),
};
/**
* Mock Organization Balance Factory
*/
export const mockOrganizationBalanceFactory = {
create: (organizationId: string, overrides: Partial<any> = {}) => ({
organizationId,
balance: 0,
allocatedCredits: 0,
availableCredits: 0,
totalPurchased: 0,
totalAllocated: 0,
version: 0,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}),
withBalance: (organizationId: string, balance: number, allocated: number = 0) => {
return mockOrganizationBalanceFactory.create(organizationId, {
balance,
allocatedCredits: allocated,
availableCredits: balance - allocated,
});
},
};
/**
* Mock Member Factory
*/
export const mockMemberFactory = {
create: (organizationId: string, userId: string, overrides: Partial<any> = {}) => ({
id: nanoid(),
organizationId,
userId,
role: 'member',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}),
createOwner: (organizationId: string, userId: string) => {
return mockMemberFactory.create(organizationId, userId, {
role: 'owner',
});
},
createEmployee: (organizationId: string, userId: string) => {
return mockMemberFactory.create(organizationId, userId, {
role: 'member',
});
},
};
/**
* Mock Credit Allocation Factory
*/
export const mockCreditAllocationFactory = {
create: (organizationId: string, employeeId: string, allocatedBy: string, overrides: Partial<any> = {}) => ({
id: nanoid(),
organizationId,
employeeId,
amount: 100,
allocatedBy,
reason: 'Credit allocation',
balanceBefore: 0,
balanceAfter: 100,
createdAt: new Date(),
...overrides,
}),
};
/**
* Mock Database Responses
*/
export const mockDbFactory = {
createSelectMock: () => ({
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
for: jest.fn().mockReturnThis(),
returning: jest.fn(),
}),
createInsertMock: () => ({
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn(),
}),
createUpdateMock: () => ({
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
returning: jest.fn(),
}),
createTransactionMock: () => ({
transaction: jest.fn((callback) => callback(mockDbFactory.createSelectMock())),
}),
createFullMock: () => ({
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
for: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
returning: jest.fn(),
transaction: jest.fn((callback) => callback(this)),
}),
};

View file

@ -0,0 +1,293 @@
/**
* Test Helper Utilities
*
* Common utilities for writing tests
*/
import { ConfigService } from '@nestjs/config';
/**
* Create mock ConfigService
*/
export const createMockConfigService = (overrides: Record<string, any> = {}): ConfigService => {
const defaultConfig: Record<string, any> = {
'database.url': 'postgresql://test:test@localhost:5432/test',
'jwt.privateKey': 'mock-private-key',
'jwt.publicKey': 'mock-public-key',
'jwt.accessTokenExpiry': '15m',
'jwt.refreshTokenExpiry': '7d',
'jwt.issuer': 'mana-core',
'jwt.audience': 'mana-universe',
'credits.signupBonus': 150,
'credits.dailyFreeCredits': 5,
'redis.host': 'localhost',
'redis.port': 6379,
'redis.password': 'test',
...overrides,
};
return {
get: jest.fn((key: string) => defaultConfig[key]),
getOrThrow: jest.fn((key: string) => {
if (!defaultConfig[key]) {
throw new Error(`Configuration key ${key} not found`);
}
return defaultConfig[key];
}),
} as unknown as ConfigService;
};
/**
* Create a test date with specific offset
*/
export const createTestDate = (offsetMs: number = 0): Date => {
return new Date(Date.now() + offsetMs);
};
/**
* Mock timer utilities
*/
export const timerUtils = {
/**
* Fast-forward time
*/
advance: (ms: number) => {
jest.advanceTimersByTime(ms);
},
/**
* Use fake timers
*/
useFake: () => {
jest.useFakeTimers();
},
/**
* Use real timers
*/
useReal: () => {
jest.useRealTimers();
},
};
/**
* Assert helpers for common patterns
*/
export const assertHelpers = {
/**
* Assert that a function throws a specific error
*/
assertThrowsAsync: async (fn: () => Promise<any>, expectedError: string | RegExp) => {
await expect(fn()).rejects.toThrow(expectedError);
},
/**
* Assert that an object has specific properties
*/
assertHasProperties: (obj: any, properties: string[]) => {
properties.forEach((prop) => {
expect(obj).toHaveProperty(prop);
});
},
/**
* Assert that an object does NOT have specific properties
*/
assertLacksProperties: (obj: any, properties: string[]) => {
properties.forEach((prop) => {
expect(obj).not.toHaveProperty(prop);
});
},
/**
* Assert that a value is a valid UUID
*/
assertIsUuid: (value: string) => {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
expect(value).toMatch(uuidRegex);
},
/**
* Assert that a date is recent (within last N seconds)
*/
assertIsRecent: (date: Date, withinSeconds: number = 5) => {
const now = Date.now();
const dateMs = date.getTime();
const diff = Math.abs(now - dateMs);
expect(diff).toBeLessThan(withinSeconds * 1000);
},
/**
* Assert that a value is between min and max
*/
assertBetween: (value: number, min: number, max: number) => {
expect(value).toBeGreaterThanOrEqual(min);
expect(value).toBeLessThanOrEqual(max);
},
};
/**
* Database test helpers
*/
export const dbTestHelpers = {
/**
* Create a mock database connection
*/
createMockDb: () => ({
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
for: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
returning: jest.fn(),
transaction: jest.fn(),
}),
/**
* Mock successful query result
*/
mockSuccessResult: (data: any) => ({
data,
error: null,
}),
/**
* Mock error result
*/
mockErrorResult: (error: Error) => ({
data: null,
error,
}),
};
/**
* Security test helpers
*/
export const securityTestHelpers = {
/**
* Common SQL injection payloads
*/
sqlInjectionPayloads: [
"'; DROP TABLE users; --",
"' OR '1'='1",
"' OR '1'='1' --",
"' OR '1'='1' /*",
"admin'--",
"' UNION SELECT NULL--",
],
/**
* Common XSS payloads
*/
xssPayloads: [
'<script>alert("xss")</script>',
'<img src=x onerror=alert("xss")>',
'<svg onload=alert("xss")>',
'javascript:alert("xss")',
],
/**
* Test for timing attacks
*/
measureExecutionTime: async (fn: () => Promise<any>): Promise<number> => {
const start = process.hrtime.bigint();
await fn();
const end = process.hrtime.bigint();
return Number(end - start) / 1_000_000; // Convert to milliseconds
},
/**
* Test for constant-time comparison
*/
isConstantTime: async (
fn1: () => Promise<any>,
fn2: () => Promise<any>,
threshold: number = 10
): Promise<boolean> => {
const time1 = await securityTestHelpers.measureExecutionTime(fn1);
const time2 = await securityTestHelpers.measureExecutionTime(fn2);
const diff = Math.abs(time1 - time2);
return diff < threshold;
},
};
/**
* Mock HTTP request/response
*/
export const httpMockHelpers = {
/**
* Create mock Express request
*/
createMockRequest: (overrides: Partial<any> = {}) => ({
headers: {},
body: {},
query: {},
params: {},
ip: '127.0.0.1',
user: null,
...overrides,
}),
/**
* Create mock Express response
*/
createMockResponse: () => {
const res: any = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
};
return res;
},
/**
* Create mock NestJS ExecutionContext
*/
createMockExecutionContext: (request: any) => ({
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => httpMockHelpers.createMockResponse(),
}),
getClass: () => ({}),
getHandler: () => ({}),
}),
};
/**
* Performance test helpers
*/
export const performanceHelpers = {
/**
* Run a function N times and measure average execution time
*/
benchmark: async (fn: () => Promise<any>, iterations: number = 100): Promise<number> => {
const times: number[] = [];
for (let i = 0; i < iterations; i++) {
const start = process.hrtime.bigint();
await fn();
const end = process.hrtime.bigint();
times.push(Number(end - start) / 1_000_000);
}
const avg = times.reduce((a, b) => a + b, 0) / times.length;
return avg;
},
/**
* Assert function execution is under a time limit
*/
assertExecutionTime: async (fn: () => Promise<any>, maxMs: number) => {
const start = process.hrtime.bigint();
await fn();
const end = process.hrtime.bigint();
const duration = Number(end - start) / 1_000_000;
expect(duration).toBeLessThan(maxMs);
},
};

View file

@ -0,0 +1,699 @@
/**
* AuthController Unit Tests
*
* Tests all authentication controller endpoints using BetterAuthService:
*
* B2C Endpoints:
* - POST /auth/register - User registration
* - POST /auth/login - User login
* - POST /auth/logout - User logout
* - POST /auth/refresh - Token refresh
* - GET /auth/session - Get current session
* - POST /auth/validate - Token validation
*
* B2B Endpoints:
* - POST /auth/register/b2b - Organization registration
* - GET /auth/organizations - List organizations
* - GET /auth/organizations/:id - Get organization
* - GET /auth/organizations/:id/members - Get organization members
* - POST /auth/organizations/:id/invite - Invite employee
* - POST /auth/organizations/accept-invitation - Accept invitation
* - DELETE /auth/organizations/:id/members/:memberId - Remove member
* - POST /auth/organizations/set-active - Set active organization
*/
import { Test, TestingModule } from '@nestjs/testing';
import {
UnauthorizedException,
ConflictException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { AuthController } from './auth.controller';
import { BetterAuthService } from './services/better-auth.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { mockDtoFactory } from '../__tests__/utils/mock-factories';
describe('AuthController', () => {
let controller: AuthController;
let betterAuthService: jest.Mocked<BetterAuthService>;
// Common test data
const mockAuthHeader = 'Bearer valid-jwt-token';
const mockToken = 'valid-jwt-token';
beforeEach(async () => {
// Create mock BetterAuthService with all methods
const mockBetterAuthService = {
registerB2C: jest.fn(),
registerB2B: jest.fn(),
signIn: jest.fn(),
signOut: jest.fn(),
getSession: jest.fn(),
listOrganizations: jest.fn(),
getOrganization: jest.fn(),
getOrganizationMembers: jest.fn(),
inviteEmployee: jest.fn(),
acceptInvitation: jest.fn(),
removeMember: jest.fn(),
setActiveOrganization: jest.fn(),
refreshToken: jest.fn(),
validateToken: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{
provide: BetterAuthService,
useValue: mockBetterAuthService,
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: jest.fn(() => true) })
.compile();
controller = module.get<AuthController>(AuthController);
betterAuthService = module.get(BetterAuthService);
});
afterEach(() => {
jest.clearAllMocks();
});
// ============================================================================
// POST /auth/register (B2C)
// ============================================================================
describe('POST /auth/register', () => {
it('should successfully register a new B2C user', async () => {
const registerDto = mockDtoFactory.register({
email: 'newuser@example.com',
password: 'SecurePassword123!',
name: 'New User',
});
const expectedResult = {
user: {
id: 'user-123',
email: registerDto.email,
name: registerDto.name,
},
token: 'jwt-token',
};
betterAuthService.registerB2C.mockResolvedValue(expectedResult);
const result = await controller.register(registerDto);
expect(result).toEqual(expectedResult);
expect(betterAuthService.registerB2C).toHaveBeenCalledWith({
email: registerDto.email,
password: registerDto.password,
name: registerDto.name,
});
});
it('should handle registration without name', async () => {
const registerDto = {
email: 'noname@example.com',
password: 'SecurePassword123!',
};
const expectedResult = {
user: { id: 'user-456', email: registerDto.email, name: '' },
token: 'jwt-token',
};
betterAuthService.registerB2C.mockResolvedValue(expectedResult);
const result = await controller.register(registerDto as any);
expect(result).toEqual(expectedResult);
expect(betterAuthService.registerB2C).toHaveBeenCalledWith({
email: registerDto.email,
password: registerDto.password,
name: '',
});
});
it('should propagate ConflictException when user exists', async () => {
const registerDto = mockDtoFactory.register({ email: 'existing@example.com' });
betterAuthService.registerB2C.mockRejectedValue(
new ConflictException('User with this email already exists')
);
await expect(controller.register(registerDto)).rejects.toThrow(ConflictException);
});
});
// ============================================================================
// POST /auth/login
// ============================================================================
describe('POST /auth/login', () => {
it('should successfully login a user', async () => {
const loginDto = mockDtoFactory.login({
email: 'user@example.com',
password: 'SecurePassword123!',
});
const expectedResult = {
user: {
id: 'user-123',
email: loginDto.email,
name: 'Test User',
role: 'user',
},
accessToken: 'jwt-access-token',
refreshToken: 'session-refresh-token',
expiresIn: 900,
};
betterAuthService.signIn.mockResolvedValue(expectedResult);
const result = await controller.login(loginDto);
expect(result).toEqual(expectedResult);
expect(betterAuthService.signIn).toHaveBeenCalledWith({
email: loginDto.email,
password: loginDto.password,
deviceId: undefined,
deviceName: undefined,
});
});
it('should pass device info when provided', async () => {
const loginDto = {
email: 'user@example.com',
password: 'SecurePassword123!',
deviceId: 'device-abc-123',
deviceName: 'iPhone 15 Pro',
};
betterAuthService.signIn.mockResolvedValue({
user: { id: '123', email: 'user@example.com', name: 'Test', role: 'user' },
accessToken: 'jwt-token',
refreshToken: 'refresh-token',
expiresIn: 900,
});
await controller.login(loginDto);
expect(betterAuthService.signIn).toHaveBeenCalledWith({
email: loginDto.email,
password: loginDto.password,
deviceId: 'device-abc-123',
deviceName: 'iPhone 15 Pro',
});
});
it('should propagate UnauthorizedException for invalid credentials', async () => {
const loginDto = mockDtoFactory.login({ password: 'WrongPassword' });
betterAuthService.signIn.mockRejectedValue(
new UnauthorizedException('Invalid email or password')
);
await expect(controller.login(loginDto)).rejects.toThrow(UnauthorizedException);
});
});
// ============================================================================
// POST /auth/logout
// ============================================================================
describe('POST /auth/logout', () => {
it('should successfully logout a user', async () => {
const expectedResult = { success: true, message: 'Signed out successfully' };
betterAuthService.signOut.mockResolvedValue(expectedResult);
const result = await controller.logout(mockAuthHeader);
expect(result).toEqual(expectedResult);
expect(betterAuthService.signOut).toHaveBeenCalledWith(mockToken);
});
it('should extract token from Bearer header', async () => {
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' });
await controller.logout('Bearer my-secret-token');
expect(betterAuthService.signOut).toHaveBeenCalledWith('my-secret-token');
});
it('should handle raw token without Bearer prefix', async () => {
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' });
await controller.logout('raw-token');
expect(betterAuthService.signOut).toHaveBeenCalledWith('raw-token');
});
});
// ============================================================================
// POST /auth/refresh
// ============================================================================
describe('POST /auth/refresh', () => {
it('should successfully refresh tokens', async () => {
const refreshTokenDto = { refreshToken: 'valid-refresh-token' };
const expectedResult = {
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresIn: 900,
tokenType: 'Bearer',
user: { id: 'user-123', email: 'user@example.com', name: 'Test', role: 'user' as const },
};
betterAuthService.refreshToken.mockResolvedValue(expectedResult);
const result = await controller.refresh(refreshTokenDto);
expect(result).toEqual(expectedResult);
expect(betterAuthService.refreshToken).toHaveBeenCalledWith('valid-refresh-token');
});
it('should propagate UnauthorizedException for invalid refresh token', async () => {
const refreshTokenDto = { refreshToken: 'invalid-token' };
betterAuthService.refreshToken.mockRejectedValue(
new UnauthorizedException('Invalid refresh token')
);
await expect(controller.refresh(refreshTokenDto)).rejects.toThrow(UnauthorizedException);
});
});
// ============================================================================
// GET /auth/session
// ============================================================================
describe('GET /auth/session', () => {
it('should return current session', async () => {
const expectedResult = {
user: { id: 'user-123', email: 'user@example.com', name: 'Test' },
session: { id: 'session-123', activeOrganizationId: null },
};
betterAuthService.getSession.mockResolvedValue(expectedResult as any);
const result = await controller.getSession(mockAuthHeader);
expect(result).toEqual(expectedResult);
expect(betterAuthService.getSession).toHaveBeenCalledWith(mockToken);
});
it('should propagate UnauthorizedException for invalid session', async () => {
betterAuthService.getSession.mockRejectedValue(
new UnauthorizedException('Invalid or expired session')
);
await expect(controller.getSession(mockAuthHeader)).rejects.toThrow(UnauthorizedException);
});
});
// ============================================================================
// POST /auth/validate
// ============================================================================
describe('POST /auth/validate', () => {
it('should return valid for a valid token', async () => {
const body = { token: 'valid-jwt-token' };
const expectedResult = {
valid: true,
payload: { sub: 'user-123', email: 'user@example.com', role: 'user' },
};
betterAuthService.validateToken.mockResolvedValue(expectedResult as any);
const result = await controller.validate(body);
expect(result).toEqual(expectedResult);
expect(betterAuthService.validateToken).toHaveBeenCalledWith(body.token);
});
it('should return invalid for expired token', async () => {
const body = { token: 'expired-token' };
betterAuthService.validateToken.mockResolvedValue({ valid: false, error: 'Token expired' } as any);
const result = await controller.validate(body);
expect((result as any).valid).toBe(false);
});
});
// ============================================================================
// POST /auth/register/b2b
// ============================================================================
describe('POST /auth/register/b2b', () => {
it('should successfully register a B2B organization', async () => {
const registerDto = {
ownerEmail: 'owner@acme.com',
password: 'SecurePassword123!',
ownerName: 'John Owner',
organizationName: 'Acme Corporation',
};
const expectedResult = {
user: { id: 'user-123', email: registerDto.ownerEmail, name: registerDto.ownerName },
organization: { id: 'org-456', name: 'Acme Corporation', slug: 'acme-corporation' },
token: 'jwt-token',
};
betterAuthService.registerB2B.mockResolvedValue(expectedResult as any);
const result = await controller.registerB2B(registerDto);
expect(result).toEqual(expectedResult);
expect(betterAuthService.registerB2B).toHaveBeenCalledWith(registerDto);
});
it('should propagate ConflictException when owner email exists', async () => {
const registerDto = {
ownerEmail: 'existing@acme.com',
password: 'SecurePassword123!',
ownerName: 'John',
organizationName: 'Acme',
};
betterAuthService.registerB2B.mockRejectedValue(
new ConflictException('Owner email already exists')
);
await expect(controller.registerB2B(registerDto)).rejects.toThrow(ConflictException);
});
});
// ============================================================================
// GET /auth/organizations
// ============================================================================
describe('GET /auth/organizations', () => {
it('should list user organizations', async () => {
const expectedResult = {
organizations: [
{ id: 'org-1', name: 'Org One', slug: 'org-one' },
{ id: 'org-2', name: 'Org Two', slug: 'org-two' },
],
};
betterAuthService.listOrganizations.mockResolvedValue(expectedResult as any);
const result = await controller.listOrganizations(mockAuthHeader);
expect(result).toEqual(expectedResult);
expect(betterAuthService.listOrganizations).toHaveBeenCalledWith(mockToken);
});
it('should return empty array when user has no organizations', async () => {
betterAuthService.listOrganizations.mockResolvedValue({ organizations: [] });
const result = await controller.listOrganizations(mockAuthHeader);
expect(result.organizations).toEqual([]);
});
});
// ============================================================================
// GET /auth/organizations/:id
// ============================================================================
describe('GET /auth/organizations/:id', () => {
it('should get organization details', async () => {
const orgId = 'org-123';
const expectedResult = {
id: orgId,
name: 'Acme Corp',
slug: 'acme-corp',
members: [{ id: 'member-1', userId: 'user-1', role: 'owner' }],
};
betterAuthService.getOrganization.mockResolvedValue(expectedResult as any);
const result = await controller.getOrganization(orgId, mockAuthHeader);
expect(result).toEqual(expectedResult);
expect(betterAuthService.getOrganization).toHaveBeenCalledWith(orgId, mockToken);
});
it('should throw NotFoundException when organization not found', async () => {
betterAuthService.getOrganization.mockRejectedValue(
new NotFoundException('Organization not found')
);
await expect(controller.getOrganization('invalid-id', mockAuthHeader)).rejects.toThrow(
NotFoundException
);
});
});
// ============================================================================
// GET /auth/organizations/:id/members
// ============================================================================
describe('GET /auth/organizations/:id/members', () => {
it('should get organization members', async () => {
const orgId = 'org-123';
const expectedMembers = [
{ id: 'member-1', userId: 'user-1', organizationId: orgId, role: 'owner' },
{ id: 'member-2', userId: 'user-2', organizationId: orgId, role: 'member' },
];
betterAuthService.getOrganizationMembers.mockResolvedValue(expectedMembers as any);
const result = await controller.getOrganizationMembers(orgId);
expect(result).toEqual(expectedMembers);
expect(betterAuthService.getOrganizationMembers).toHaveBeenCalledWith(orgId);
});
});
// ============================================================================
// POST /auth/organizations/:id/invite
// ============================================================================
describe('POST /auth/organizations/:id/invite', () => {
it('should invite an employee to organization', async () => {
const orgId = 'org-123';
const inviteDto = { organizationId: orgId, employeeEmail: 'employee@acme.com', role: 'member' as const };
const expectedResult = {
id: 'invitation-123',
email: 'employee@acme.com',
organizationId: orgId,
role: 'member',
status: 'pending',
};
betterAuthService.inviteEmployee.mockResolvedValue(expectedResult as any);
const result = await controller.inviteEmployee(orgId, inviteDto, mockAuthHeader);
expect(result).toEqual(expectedResult);
expect(betterAuthService.inviteEmployee).toHaveBeenCalledWith({
organizationId: orgId,
employeeEmail: 'employee@acme.com',
role: 'member',
inviterToken: mockToken,
});
});
it('should throw ForbiddenException when inviter lacks permission', async () => {
const orgId = 'org-123';
const inviteDto = { organizationId: orgId, employeeEmail: 'employee@acme.com', role: 'member' as const };
betterAuthService.inviteEmployee.mockRejectedValue(
new ForbiddenException('You do not have permission to invite members')
);
await expect(controller.inviteEmployee(orgId, inviteDto, mockAuthHeader)).rejects.toThrow(
ForbiddenException
);
});
});
// ============================================================================
// POST /auth/organizations/accept-invitation
// ============================================================================
describe('POST /auth/organizations/accept-invitation', () => {
it('should accept an invitation', async () => {
const acceptDto = { invitationId: 'invitation-123' };
const expectedResult = {
member: { id: 'member-123', userId: 'user-456', organizationId: 'org-123', role: 'member' },
organization: { id: 'org-123', name: 'Acme Corp' },
};
betterAuthService.acceptInvitation.mockResolvedValue(expectedResult as any);
const result = await controller.acceptInvitation(acceptDto, mockAuthHeader);
expect(result).toEqual(expectedResult);
expect(betterAuthService.acceptInvitation).toHaveBeenCalledWith({
invitationId: 'invitation-123',
userToken: mockToken,
});
});
it('should throw NotFoundException when invitation not found', async () => {
const acceptDto = { invitationId: 'invalid-invitation' };
betterAuthService.acceptInvitation.mockRejectedValue(
new NotFoundException('Invitation not found or expired')
);
await expect(controller.acceptInvitation(acceptDto, mockAuthHeader)).rejects.toThrow(
NotFoundException
);
});
});
// ============================================================================
// DELETE /auth/organizations/:id/members/:memberId
// ============================================================================
describe('DELETE /auth/organizations/:id/members/:memberId', () => {
it('should remove a member from organization', async () => {
const orgId = 'org-123';
const memberId = 'member-456';
const expectedResult = { success: true, message: 'Member removed successfully' };
betterAuthService.removeMember.mockResolvedValue(expectedResult);
const result = await controller.removeMember(orgId, memberId, mockAuthHeader);
expect(result).toEqual(expectedResult);
expect(betterAuthService.removeMember).toHaveBeenCalledWith({
organizationId: orgId,
memberId,
removerToken: mockToken,
});
});
it('should throw ForbiddenException when remover lacks permission', async () => {
betterAuthService.removeMember.mockRejectedValue(
new ForbiddenException('You do not have permission to remove members')
);
await expect(controller.removeMember('org-123', 'member-456', mockAuthHeader)).rejects.toThrow(
ForbiddenException
);
});
});
// ============================================================================
// POST /auth/organizations/set-active
// ============================================================================
describe('POST /auth/organizations/set-active', () => {
it('should set active organization', async () => {
const setActiveDto = { organizationId: 'org-123' };
const expectedResult = {
userId: 'user-123',
activeOrganizationId: 'org-123',
};
betterAuthService.setActiveOrganization.mockResolvedValue(expectedResult as any);
const result = await controller.setActiveOrganization(setActiveDto, mockAuthHeader);
expect(result).toEqual(expectedResult);
expect(betterAuthService.setActiveOrganization).toHaveBeenCalledWith({
organizationId: 'org-123',
userToken: mockToken,
});
});
it('should throw NotFoundException when not a member', async () => {
const setActiveDto = { organizationId: 'org-999' };
betterAuthService.setActiveOrganization.mockRejectedValue(
new NotFoundException('Organization not found or you are not a member')
);
await expect(controller.setActiveOrganization(setActiveDto, mockAuthHeader)).rejects.toThrow(
NotFoundException
);
});
});
// ============================================================================
// Guard Tests
// ============================================================================
describe('Guards', () => {
it('should have JwtAuthGuard on protected endpoints', () => {
const protectedEndpoints: (keyof AuthController)[] = [
'logout',
'getSession',
'listOrganizations',
'getOrganization',
'getOrganizationMembers',
'inviteEmployee',
'acceptInvitation',
'removeMember',
'setActiveOrganization',
];
protectedEndpoints.forEach((endpoint) => {
const guards = Reflect.getMetadata(
'__guards__',
AuthController.prototype[endpoint as keyof AuthController]
);
expect(guards).toBeDefined();
expect(guards).toContain(JwtAuthGuard);
});
});
it('should NOT have JwtAuthGuard on public endpoints', () => {
const publicEndpoints: (keyof AuthController)[] = [
'register',
'login',
'refresh',
'validate',
'registerB2B',
];
publicEndpoints.forEach((endpoint) => {
const guards = Reflect.getMetadata(
'__guards__',
AuthController.prototype[endpoint as keyof AuthController]
);
expect(guards).toBeUndefined();
});
});
});
// ============================================================================
// Token Extraction Helper
// ============================================================================
describe('Token Extraction', () => {
it('should extract token from Bearer authorization header', async () => {
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' });
await controller.logout('Bearer my-token-123');
expect(betterAuthService.signOut).toHaveBeenCalledWith('my-token-123');
});
it('should handle missing authorization header', async () => {
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' });
await controller.logout('');
expect(betterAuthService.signOut).toHaveBeenCalledWith('');
});
});
});

View file

@ -1,53 +1,295 @@
import { Controller, Post, Body, UseGuards, Req, Ip, Headers } from '@nestjs/common';
import { Request } from 'express';
import { AuthService } from './auth.service';
import {
Controller,
Post,
Get,
Delete,
Body,
Param,
UseGuards,
Headers,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { BetterAuthService } from './services/better-auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RegisterB2BDto } from './dto/register-b2b.dto';
import { InviteEmployeeDto } from './dto/invite-employee.dto';
import { AcceptInvitationDto } from './dto/accept-invitation.dto';
import { SetActiveOrganizationDto } from './dto/set-active-organization.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
/**
* Auth Controller
*
* Handles authentication and organization management endpoints.
*
* B2C Endpoints:
* - POST /auth/register - Register individual user
* - POST /auth/login - Sign in with email/password
* - POST /auth/logout - Sign out
* - POST /auth/refresh - Refresh access token
* - GET /auth/session - Get current session
*
* B2B Endpoints:
* - POST /auth/register/b2b - Register organization with owner
* - GET /auth/organizations - List user's organizations
* - GET /auth/organizations/:id - Get organization details
* - POST /auth/organizations/:id/invite - Invite employee
* - POST /auth/organizations/accept-invitation - Accept invitation
* - DELETE /auth/organizations/:id/members/:memberId - Remove member
* - POST /auth/organizations/set-active - Switch active organization
*/
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(private readonly betterAuthService: BetterAuthService) {}
// =========================================================================
// B2C Authentication Endpoints
// =========================================================================
/**
* Register a new B2C user (individual)
*
* Creates a user account and initializes their credit balance.
*/
@Post('register')
async register(
@Body() registerDto: RegisterDto,
@Ip() ipAddress: string,
@Headers('user-agent') userAgent: string
) {
return this.authService.register(registerDto, ipAddress, userAgent);
async register(@Body() registerDto: RegisterDto) {
return this.betterAuthService.registerB2C({
email: registerDto.email,
password: registerDto.password,
name: registerDto.name || '',
});
}
/**
* Sign in with email and password
*
* Returns user data and JWT token.
*/
@Post('login')
async login(
@Body() loginDto: LoginDto,
@Ip() ipAddress: string,
@Headers('user-agent') userAgent: string
) {
return this.authService.login(loginDto, ipAddress, userAgent);
}
@Post('refresh')
async refresh(
@Body() refreshTokenDto: RefreshTokenDto,
@Ip() ipAddress: string,
@Headers('user-agent') userAgent: string
) {
return this.authService.refreshToken(refreshTokenDto.refreshToken, ipAddress, userAgent);
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto) {
return this.betterAuthService.signIn({
email: loginDto.email,
password: loginDto.password,
deviceId: loginDto.deviceId,
deviceName: loginDto.deviceName,
});
}
/**
* Sign out current user
*
* Invalidates the user's session.
*/
@Post('logout')
@UseGuards(JwtAuthGuard)
async logout(@Req() req: Request & { user: CurrentUserData }) {
// Extract sessionId from JWT (would need to be added to the CurrentUserData interface)
// For now, we'll use a placeholder
return this.authService.logout('session-id');
@HttpCode(HttpStatus.OK)
async logout(@Headers('authorization') authorization: string) {
const token = this.extractToken(authorization);
return this.betterAuthService.signOut(token);
}
/**
* Refresh access token
*
* Uses refresh token rotation to issue new access and refresh tokens.
*/
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@Body() refreshTokenDto: RefreshTokenDto) {
return this.betterAuthService.refreshToken(refreshTokenDto.refreshToken);
}
/**
* Get current session
*
* Returns the current user and session data.
*/
@Get('session')
@UseGuards(JwtAuthGuard)
async getSession(@Headers('authorization') authorization: string) {
const token = this.extractToken(authorization);
return this.betterAuthService.getSession(token);
}
/**
* Validate a token
*
* Checks if a token is valid and returns the payload.
*/
@Post('validate')
@HttpCode(HttpStatus.OK)
async validate(@Body() body: { token: string }) {
return this.authService.validateToken(body.token);
return this.betterAuthService.validateToken(body.token);
}
/**
* Get JWKS (JSON Web Key Set)
*
* Returns public keys for JWT verification.
* This is a passthrough to Better Auth's JWKS.
*/
@Get('jwks')
async getJwks() {
return this.betterAuthService.getJwks();
}
// =========================================================================
// B2B Registration
// =========================================================================
/**
* Register a new B2B organization
*
* Creates an organization with the registering user as owner.
* Also creates organization credit balance.
*/
@Post('register/b2b')
async registerB2B(@Body() registerDto: RegisterB2BDto) {
return this.betterAuthService.registerB2B(registerDto);
}
// =========================================================================
// Organization Management Endpoints
// =========================================================================
/**
* List user's organizations
*
* Returns all organizations the current user is a member of.
*/
@Get('organizations')
@UseGuards(JwtAuthGuard)
async listOrganizations(@Headers('authorization') authorization: string) {
const token = this.extractToken(authorization);
return this.betterAuthService.listOrganizations(token);
}
/**
* Get organization details
*
* Returns full organization info including members.
*/
@Get('organizations/:id')
@UseGuards(JwtAuthGuard)
async getOrganization(
@Param('id') organizationId: string,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.getOrganization(organizationId, token);
}
/**
* Get organization members
*
* Returns all members of an organization with their roles.
*/
@Get('organizations/:id/members')
@UseGuards(JwtAuthGuard)
async getOrganizationMembers(@Param('id') organizationId: string) {
return this.betterAuthService.getOrganizationMembers(organizationId);
}
/**
* Invite employee to organization
*
* Sends an invitation email to join the organization.
* Requires owner or admin role.
*/
@Post('organizations/:id/invite')
@UseGuards(JwtAuthGuard)
async inviteEmployee(
@Param('id') organizationId: string,
@Body() inviteDto: InviteEmployeeDto,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.inviteEmployee({
organizationId,
employeeEmail: inviteDto.employeeEmail,
role: inviteDto.role,
inviterToken: token,
});
}
/**
* Accept organization invitation
*
* Accepts a pending invitation and adds user to organization.
*/
@Post('organizations/accept-invitation')
@UseGuards(JwtAuthGuard)
async acceptInvitation(
@Body() acceptDto: AcceptInvitationDto,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.acceptInvitation({
invitationId: acceptDto.invitationId,
userToken: token,
});
}
/**
* Remove member from organization
*
* Removes a member from the organization.
* Requires owner or admin role.
*/
@Delete('organizations/:id/members/:memberId')
@UseGuards(JwtAuthGuard)
async removeMember(
@Param('id') organizationId: string,
@Param('memberId') memberId: string,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.removeMember({
organizationId,
memberId,
removerToken: token,
});
}
/**
* Set active organization
*
* Switches the user's active organization context.
* Affects JWT claims and credit balance.
*/
@Post('organizations/set-active')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
async setActiveOrganization(
@Body() setActiveDto: SetActiveOrganizationDto,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.setActiveOrganization({
organizationId: setActiveDto.organizationId,
userToken: token,
});
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* Extract token from Authorization header
*/
private extractToken(authorization: string): string {
if (!authorization) {
return '';
}
// Handle both "Bearer token" and raw token formats
if (authorization.startsWith('Bearer ')) {
return authorization.substring(7);
}
return authorization;
}
}

View file

@ -1,10 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { BetterAuthService } from './services/better-auth.service';
@Module({
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
providers: [BetterAuthService],
exports: [BetterAuthService],
})
export class AuthModule {}

View file

@ -1,291 +0,0 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, isNull } from 'drizzle-orm';
import * as bcrypt from 'bcrypt';
import * as jwt from 'jsonwebtoken';
import { nanoid } from 'nanoid';
import { randomUUID } from 'crypto';
import { getDb } from '../db/connection';
import { users, passwords, sessions } from '../db/schema';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
export interface TokenPayload {
sub: string;
email: string;
role: string;
sessionId: string;
deviceId?: string;
}
@Injectable()
export class AuthService {
constructor(private configService: ConfigService) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
async register(registerDto: RegisterDto, ipAddress?: string, userAgent?: string) {
const db = this.getDb();
// Check if user already exists
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, registerDto.email.toLowerCase()))
.limit(1);
if (existingUser.length > 0) {
throw new ConflictException('User with this email already exists');
}
// Hash password
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
// Create user
const [newUser] = await db
.insert(users)
.values({
email: registerDto.email.toLowerCase(),
name: registerDto.name,
role: 'user',
})
.returning();
// Store password
await db.insert(passwords).values({
userId: newUser.id,
hashedPassword,
});
// Initialize credit balance (done via trigger or separate service call)
// This will be handled by the credits service
return {
id: newUser.id,
email: newUser.email,
name: newUser.name,
createdAt: newUser.createdAt,
};
}
async login(loginDto: LoginDto, ipAddress?: string, userAgent?: string) {
const db = this.getDb();
// Find user
const [user] = await db
.select()
.from(users)
.where(eq(users.email, loginDto.email.toLowerCase()))
.limit(1);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Check if user is soft-deleted
if (user.deletedAt) {
throw new UnauthorizedException('Account has been deleted');
}
// Get password
const [passwordRecord] = await db
.select()
.from(passwords)
.where(eq(passwords.userId, user.id))
.limit(1);
if (!passwordRecord) {
throw new UnauthorizedException('Invalid credentials');
}
// Verify password
const isPasswordValid = await bcrypt.compare(loginDto.password, passwordRecord.hashedPassword);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
// Generate tokens
const tokenData = await this.generateTokens(
user.id,
user.email,
user.role,
loginDto.deviceId,
loginDto.deviceName,
ipAddress,
userAgent
);
return {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
...tokenData,
};
}
async refreshToken(refreshToken: string, ipAddress?: string, userAgent?: string) {
const db = this.getDb();
// Find session by refresh token
const [session] = await db
.select()
.from(sessions)
.where(and(eq(sessions.refreshToken, refreshToken), isNull(sessions.revokedAt)))
.limit(1);
if (!session) {
throw new UnauthorizedException('Invalid refresh token');
}
// Check if refresh token is expired
if (new Date() > session.refreshTokenExpiresAt) {
throw new UnauthorizedException('Refresh token expired');
}
// Get user
const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1);
if (!user || user.deletedAt) {
throw new UnauthorizedException('User not found');
}
// Revoke old session (refresh token rotation)
await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, session.id));
// Generate new tokens
const tokenData = await this.generateTokens(
user.id,
user.email,
user.role,
session.deviceId ?? undefined,
session.deviceName ?? undefined,
ipAddress,
userAgent
);
return {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
...tokenData,
};
}
async logout(sessionId: string) {
const db = this.getDb();
await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, sessionId));
return { message: 'Logged out successfully' };
}
private async generateTokens(
userId: string,
email: string,
role: string,
deviceId?: string,
deviceName?: string,
ipAddress?: string,
userAgent?: string
) {
const db = this.getDb();
const privateKeyRaw = this.configService.get<string>('jwt.privateKey');
if (!privateKeyRaw) {
throw new Error('JWT private key not configured');
}
// Convert escaped newlines to actual newlines (for Docker env vars)
const privateKey: string = privateKeyRaw.replace(/\\n/g, '\n');
const accessTokenExpiry = this.configService.get<string>('jwt.accessTokenExpiry') || '15m';
const refreshTokenExpiry = this.configService.get<string>('jwt.refreshTokenExpiry') || '7d';
const issuer = this.configService.get<string>('jwt.issuer');
const audience = this.configService.get<string>('jwt.audience');
// Generate session ID (must be UUID for database)
const sessionId = randomUUID();
// Create session record
const refreshTokenString = nanoid(64);
const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const accessTokenExpiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await db.insert(sessions).values({
id: sessionId,
userId,
token: sessionId,
refreshToken: refreshTokenString,
refreshTokenExpiresAt,
ipAddress,
userAgent,
deviceId,
deviceName,
expiresAt: accessTokenExpiresAt,
});
// Generate JWT payload
const tokenPayload: Record<string, unknown> = {
sub: userId,
email,
role,
sessionId,
...(deviceId && { deviceId }),
};
// Sign access token
const accessToken = jwt.sign(tokenPayload, privateKey, {
algorithm: 'RS256' as const,
expiresIn: accessTokenExpiry as jwt.SignOptions['expiresIn'],
...(issuer && { issuer }),
...(audience && { audience }),
});
return {
accessToken,
refreshToken: refreshTokenString,
expiresIn: 15 * 60, // 15 minutes in seconds
tokenType: 'Bearer',
};
}
async validateToken(token: string) {
try {
const publicKey = this.configService.get<string>('jwt.publicKey');
if (!publicKey) {
throw new Error('JWT public key not configured');
}
const audience = this.configService.get<string>('jwt.audience');
const issuer = this.configService.get<string>('jwt.issuer');
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience,
issuer,
}) as TokenPayload;
return {
valid: true,
payload,
};
} catch (error) {
return {
valid: false,
error: error.message,
};
}
}
}

View file

@ -0,0 +1,211 @@
/**
* Better Auth Configuration
*
* This file configures Better Auth with:
* - Email/password authentication
* - Organization plugin for B2B (multi-tenant)
* - JWT plugin with minimal claims
* - Drizzle adapter for PostgreSQL
*
* ARCHITECTURE DECISION (2024-12):
* We use MINIMAL JWT claims. Organization and credit data should be fetched
* via API calls, not embedded in JWTs. See docs/AUTHENTICATION_ARCHITECTURE.md
*
* @see https://www.better-auth.com/docs
*/
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { jwt } from 'better-auth/plugins/jwt';
import { organization } from 'better-auth/plugins/organization';
import { getDb } from '../db/connection';
import {
organizations,
members,
invitations,
} from '../db/schema/organizations.schema';
import {
users,
sessions,
accounts,
verificationTokens,
jwks,
} from '../db/schema/auth.schema';
import type { JWTPayloadContext } from './types/better-auth.types';
/**
* JWT Custom Payload Interface
*
* MINIMAL claims only. Organization context and credits are available via:
* - GET /organization/get-active-member - org membership & role
* - GET /api/v1/credits/balance - credit balance
*
* Why minimal claims?
* 1. Credit balance changes frequently - JWT would be stale
* 2. Organization context available via Better Auth org plugin APIs
* 3. Smaller tokens = better performance
* 4. Follows Better Auth's session-based design
*/
export interface JWTCustomPayload {
/** User ID (standard JWT claim) */
sub: string;
/** User email */
email: string;
/** User role (user, admin, service) */
role: string;
/** Session ID for reference */
sid: string;
}
/**
* Create Better Auth instance
*
* @param databaseUrl - PostgreSQL connection URL
* @returns Better Auth instance
*/
export function createBetterAuth(databaseUrl: string) {
const db = getDb(databaseUrl);
return betterAuth({
// Database adapter (Drizzle with PostgreSQL)
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
// Auth tables (actual Drizzle table objects)
user: users,
session: sessions,
account: accounts,
verification: verificationTokens,
// Organization tables
organization: organizations,
member: members,
invitation: invitations,
// JWT plugin table
jwks: jwks,
},
}),
// Email/password authentication only
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // Can enable later
minPasswordLength: 12,
maxPasswordLength: 128,
},
// Session configuration
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Update session once per day
},
// Base URL for callbacks and redirects
baseURL: process.env.BASE_URL || 'http://localhost:3001',
// Plugins
plugins: [
/**
* Organization Plugin (B2B)
*
* Provides complete organization management:
* - Create/update/delete organizations
* - Invite/add/remove members
* - Role-based access control
* - Active organization tracking (session.activeOrganizationId)
*
* Client apps use these endpoints for org context:
* - GET /organization/get-active-member
* - GET /organization/get-active-member-role
* - POST /organization/set-active
*/
organization({
// Allow users to create their own organizations
allowUserToCreateOrganization: true,
// Email invitation handler
async sendInvitationEmail(data) {
const { email, organization } = data;
// TODO: Implement email sending service
console.log('TODO: Send invitation email', {
to: email,
organization: organization.name,
invitationId: data.id,
});
},
// Custom roles and permissions
organizationRole: {
owner: {
permissions: [
'organization:update',
'organization:delete',
'members:invite',
'members:remove',
'members:update_role',
'credits:allocate',
'credits:view_all',
],
},
admin: {
permissions: [
'organization:update',
'members:invite',
'members:remove',
'credits:view_all',
],
},
member: {
permissions: ['credits:view_own'],
},
},
}),
/**
* JWT Plugin
*
* Generates JWT tokens with MINIMAL claims.
*
* DO NOT add complex claims like:
* - credit_balance (stale after 15min, fetch via API instead)
* - organization details (use Better Auth org plugin APIs)
* - customer_type (derive from activeOrganizationId presence)
*
* Apps should call APIs for dynamic data:
* - Credits: GET /api/v1/credits/balance
* - Org info: GET /organization/get-active-member
*/
jwt({
jwt: {
issuer: process.env.JWT_ISSUER || 'manacore',
audience: process.env.JWT_AUDIENCE || 'manacore',
expirationTime: '15m',
/**
* Define minimal JWT payload
*
* Only includes static user info that doesn't change frequently.
*/
definePayload({ user, session }: JWTPayloadContext) {
return {
sub: user.id,
email: user.email,
role: (user as { role?: string }).role || 'user',
sid: session.id,
};
},
},
}),
],
});
}
/**
* Export type for Better Auth instance
*/
export type BetterAuthInstance = ReturnType<typeof createBetterAuth>;

View file

@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
/**
* DTO for accepting an organization invitation
*/
export class AcceptInvitationDto {
@IsString()
invitationId: string;
}

View file

@ -0,0 +1,16 @@
/**
* Auth DTOs Index
*
* Re-exports all authentication-related DTOs
*/
// Core auth DTOs
export { RegisterDto } from './register.dto';
export { LoginDto } from './login.dto';
export { RefreshTokenDto } from './refresh-token.dto';
// B2B organization DTOs
export { RegisterB2BDto } from './register-b2b.dto';
export { InviteEmployeeDto } from './invite-employee.dto';
export { AcceptInvitationDto } from './accept-invitation.dto';
export { SetActiveOrganizationDto } from './set-active-organization.dto';

View file

@ -0,0 +1,18 @@
import { IsEmail, IsString, IsIn } from 'class-validator';
/**
* DTO for inviting an employee to an organization
*
* Only owners and admins can invite new members.
*/
export class InviteEmployeeDto {
@IsString()
organizationId: string;
@IsEmail()
employeeEmail: string;
@IsString()
@IsIn(['admin', 'member'])
role: 'admin' | 'member';
}

View file

@ -0,0 +1,25 @@
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
/**
* DTO for B2B organization registration
*
* Creates an organization with the registering user as owner.
*/
export class RegisterB2BDto {
@IsEmail()
ownerEmail: string;
@IsString()
@MinLength(12)
@MaxLength(128)
password: string;
@IsString()
@MaxLength(255)
ownerName: string;
@IsString()
@MinLength(2)
@MaxLength(255)
organizationName: string;
}

View file

@ -0,0 +1,11 @@
import { IsString } from 'class-validator';
/**
* DTO for setting the active organization
*
* Used to switch between organizations for users with multiple memberships.
*/
export class SetActiveOrganizationDto {
@IsString()
organizationId: string;
}

View file

@ -0,0 +1,566 @@
/**
* JWT Token Validation Tests (Minimal Claims)
*
* Tests for JWT token validation with minimal claims:
* - sub (user ID)
* - email
* - role
* - sid (session ID)
*
* ARCHITECTURE DECISION (2024-12):
* We use MINIMAL JWT claims. Organization and credit data should be fetched
* via API calls, not embedded in JWTs. See docs/AUTHENTICATION_ARCHITECTURE.md
*
* Why minimal claims?
* 1. Credit balance changes frequently - JWT would be stale
* 2. Organization context available via Better Auth org plugin APIs
* 3. Smaller tokens = better performance
* 4. Follows Better Auth's session-based design
*/
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
import { JWTCustomPayload } from './better-auth.config';
import { createMockConfigService } from '../__tests__/utils/test-helpers';
import { mockUserFactory } from '../__tests__/utils/mock-factories';
// Mock external dependencies
jest.mock('../db/connection');
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-nanoid-123'),
}));
describe('JWT Token Validation (Minimal Claims)', () => {
let configService: ConfigService;
let mockDb: any;
let secret: string;
beforeEach(async () => {
// Use HS256 for testing (symmetric key) for simplicity
// In production, mana-core uses RS256 (asymmetric)
secret = 'test-secret-key-for-jwt-validation';
// Create mock database
mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
returning: jest.fn(),
transaction: jest.fn(),
};
// Mock getDb
const { getDb } = require('../db/connection');
getDb.mockReturnValue(mockDb);
configService = createMockConfigService({
'jwt.secret': secret,
'jwt.issuer': 'mana-core',
'jwt.audience': 'manacore',
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Minimal JWT Claims Structure', () => {
it('should generate token with minimal claims only', () => {
const user = mockUserFactory.create({
id: 'user-123',
email: 'user@example.com',
role: 'user',
});
const payload: JWTCustomPayload = {
sub: user.id,
email: user.email,
role: user.role,
sid: 'session-abc-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'mana-core',
audience: 'manacore',
}) as JWTCustomPayload;
expect(decoded).toMatchObject({
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-abc-123',
});
// Verify NO complex claims are present
expect((decoded as any).customer_type).toBeUndefined();
expect((decoded as any).organization).toBeUndefined();
expect((decoded as any).credit_balance).toBeUndefined();
expect((decoded as any).app_id).toBeUndefined();
expect((decoded as any).device_id).toBeUndefined();
});
it('should include standard JWT claims (sub, iat, exp, iss, aud)', () => {
const now = Math.floor(Date.now() / 1000);
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded: any = jwt.verify(token, secret, {
algorithms: ['HS256'],
});
// Standard JWT claims
expect(decoded.sub).toBe('user-123');
expect(decoded.iat).toBeGreaterThanOrEqual(now);
expect(decoded.exp).toBeGreaterThan(decoded.iat);
expect(decoded.iss).toBe('mana-core');
expect(decoded.aud).toBe('manacore');
});
it('should support different user roles', () => {
const roles = ['user', 'admin', 'service'];
roles.forEach((role) => {
const payload: JWTCustomPayload = {
sub: `${role}-user-123`,
email: `${role}@example.com`,
role,
sid: `session-${role}`,
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
}) as JWTCustomPayload;
expect(decoded.role).toBe(role);
});
});
});
describe('Token Validation - Security', () => {
it('should validate HS256 signature correctly', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
// Should successfully verify with correct secret
expect(() => {
jwt.verify(token, secret, {
algorithms: ['HS256'],
});
}).not.toThrow();
});
it('should reject expired tokens', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
// Create token that expires immediately
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '0s', // Expired immediately
issuer: 'mana-core',
audience: 'manacore',
});
// Wait a moment to ensure expiry
return new Promise((resolve) => {
setTimeout(() => {
expect(() => {
jwt.verify(token, secret, {
algorithms: ['HS256'],
});
}).toThrow('jwt expired');
resolve(true);
}, 100);
});
});
it('should reject tokens with wrong issuer', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'wrong-issuer', // Wrong issuer
audience: 'manacore',
});
expect(() => {
jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'mana-core', // Expect correct issuer
audience: 'manacore',
});
}).toThrow('jwt issuer invalid');
});
it('should reject tokens with wrong audience', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'wrong-audience', // Wrong audience
});
expect(() => {
jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'mana-core',
audience: 'manacore', // Expect correct audience
});
}).toThrow('jwt audience invalid');
});
it('should reject tampered tokens', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
// Tamper with the token - try to change role to admin
const parts = token.split('.');
const tamperedPayload = Buffer.from(
JSON.stringify({ ...payload, role: 'admin' })
).toString('base64url');
const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
expect(() => {
jwt.verify(tamperedToken, secret, {
algorithms: ['HS256'],
});
}).toThrow('invalid signature');
});
it('should reject tokens signed with wrong secret', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
// Sign with different secret
const token = jwt.sign(payload, 'wrong-secret-key', {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
// Try to verify with correct secret
expect(() => {
jwt.verify(token, secret, {
algorithms: ['HS256'],
});
}).toThrow();
});
});
describe('Token Expiration Times', () => {
it('should use 15 minutes for access tokens', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded: any = jwt.verify(token, secret, {
algorithms: ['HS256'],
});
const expiryTime = decoded.exp - decoded.iat;
expect(expiryTime).toBe(15 * 60); // 15 minutes = 900 seconds
});
it('should validate token is not yet valid (nbf claim)', () => {
const futureTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour in future
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
notBefore: futureTime, // Not valid until 1 hour from now
issuer: 'mana-core',
audience: 'manacore',
});
expect(() => {
jwt.verify(token, secret, {
algorithms: ['HS256'],
});
}).toThrow('jwt not active');
});
});
describe('Edge Cases', () => {
it('should handle malformed JWT gracefully', () => {
const malformedToken = 'this.is.not.a.valid.jwt';
expect(() => {
jwt.verify(malformedToken, secret, {
algorithms: ['HS256'],
});
}).toThrow('jwt malformed');
});
it('should handle empty token', () => {
expect(() => {
jwt.verify('', secret, {
algorithms: ['HS256'],
});
}).toThrow('jwt must be provided');
});
it('should handle token with missing required claims', () => {
// Token with only sub (missing email, role, sid)
const minimalPayload = { sub: 'user-123' };
const token = jwt.sign(minimalPayload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
// Token is technically valid, but application should validate claims
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
}) as any;
expect(decoded.sub).toBe('user-123');
expect(decoded.email).toBeUndefined();
expect(decoded.role).toBeUndefined();
expect(decoded.sid).toBeUndefined();
});
});
describe('Token Refresh Behavior', () => {
it('should issue new token with same user claims', () => {
const originalPayload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-original',
};
const originalToken = jwt.sign(originalPayload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
// Refresh creates new token with new session ID
const refreshedPayload: JWTCustomPayload = {
...originalPayload,
sid: 'session-refreshed', // New session ID
};
const refreshedToken = jwt.sign(refreshedPayload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(refreshedToken, secret, {
algorithms: ['HS256'],
}) as JWTCustomPayload;
// User claims should be maintained
expect(decoded.sub).toBe('user-123');
expect(decoded.email).toBe('user@example.com');
expect(decoded.role).toBe('user');
// Session ID should be new
expect(decoded.sid).toBe('session-refreshed');
});
it('should maintain user role across refreshes', () => {
const adminPayload: JWTCustomPayload = {
sub: 'admin-123',
email: 'admin@example.com',
role: 'admin',
sid: 'session-123',
};
const token = jwt.sign(adminPayload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
}) as JWTCustomPayload;
// Admin role should be preserved
expect(decoded.role).toBe('admin');
});
});
describe('Architecture Decision Documentation', () => {
/**
* This test documents what is NOT in the JWT by design.
* See docs/AUTHENTICATION_ARCHITECTURE.md for full explanation.
*/
it('should NOT contain organization data (fetch via API instead)', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
}) as any;
// Organization data should be fetched via:
// - session.activeOrganizationId (from Better Auth session)
// - GET /organization/get-active-member (for details)
expect(decoded.organization).toBeUndefined();
expect(decoded.organizationId).toBeUndefined();
});
it('should NOT contain credit balance (fetch via API instead)', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
}) as any;
// Credit balance should be fetched via:
// - GET /api/v1/credits/balance
// Credit balance changes too frequently to embed in JWT
expect(decoded.credit_balance).toBeUndefined();
expect(decoded.credits).toBeUndefined();
});
it('should NOT contain customer_type (derive from session instead)', () => {
const payload: JWTCustomPayload = {
sub: 'user-123',
email: 'user@example.com',
role: 'user',
sid: 'session-123',
};
const token = jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'mana-core',
audience: 'manacore',
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
}) as any;
// Customer type should be derived from:
// - B2B = session.activeOrganizationId != null
// - B2C = session.activeOrganizationId == null
expect(decoded.customer_type).toBeUndefined();
});
});
});

View file

@ -0,0 +1,999 @@
/**
* BetterAuthService Unit Tests
*
* Tests all Better Auth integration flows:
* - B2C user registration
* - B2B organization registration
* - Organization member management
* - Employee invitations
* - Credit balance initialization
*/
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import {
ConflictException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { BetterAuthService } from './better-auth.service';
import { createMockConfigService } from '../../__tests__/utils/test-helpers';
// Mock nanoid before importing factories
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-nanoid-123'),
}));
// Mock database connection
jest.mock('../../db/connection');
// Import after mocks
import { mockUserFactory } from '../../__tests__/utils/mock-factories';
// Mock Better Auth configuration
const mockAuthApi = {
signUpEmail: jest.fn(),
createOrganization: jest.fn(),
inviteMember: jest.fn(),
acceptInvitation: jest.fn(),
getFullOrganization: jest.fn(),
removeMember: jest.fn(),
setActiveOrganization: jest.fn(),
};
jest.mock('../better-auth.config', () => ({
createBetterAuth: jest.fn(() => ({
api: mockAuthApi,
})),
}));
describe('BetterAuthService', () => {
let service: BetterAuthService;
let configService: ConfigService;
let mockDb: any;
beforeEach(async () => {
// Create mock database
mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
returning: jest.fn(),
};
// Mock getDb
const { getDb } = require('../../db/connection');
getDb.mockReturnValue(mockDb);
const module: TestingModule = await Test.createTestingModule({
providers: [
BetterAuthService,
{
provide: ConfigService,
useValue: createMockConfigService({
'database.url': 'postgresql://test:test@localhost:5432/test',
}),
},
],
}).compile();
service = module.get<BetterAuthService>(BetterAuthService);
configService = module.get<ConfigService>(ConfigService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('registerB2C', () => {
it('should register a new B2C user successfully', async () => {
const registerDto = {
email: 'newuser@example.com',
password: 'SecurePassword123!',
name: 'New User',
};
const mockUser = mockUserFactory.create({
id: 'user-123',
email: registerDto.email,
name: registerDto.name,
});
// Mock Better Auth signup response
mockAuthApi.signUpEmail.mockResolvedValue({
user: mockUser,
token: 'mock-session-token',
});
// Mock credit balance creation (success)
mockDb.returning.mockResolvedValue([]);
const result = await service.registerB2C(registerDto);
// Verify Better Auth API was called correctly
expect(mockAuthApi.signUpEmail).toHaveBeenCalledWith({
body: {
email: registerDto.email,
password: registerDto.password,
name: registerDto.name,
},
});
// Verify personal credit balance was created
expect(mockDb.insert).toHaveBeenCalled();
expect(mockDb.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-123',
balance: 0,
freeCreditsRemaining: 150,
dailyFreeCredits: 5,
totalEarned: 0,
totalSpent: 0,
})
);
// Verify response structure
expect(result).toEqual({
user: {
id: 'user-123',
email: 'newuser@example.com',
name: 'New User',
},
token: 'mock-session-token',
});
});
it('should throw ConflictException if user already exists', async () => {
const registerDto = {
email: 'existing@example.com',
password: 'SecurePassword123!',
name: 'Existing User',
};
// Mock Better Auth error for existing user
mockAuthApi.signUpEmail.mockRejectedValue(
new Error('User with this email already exists')
);
await expect(service.registerB2C(registerDto)).rejects.toThrow(ConflictException);
await expect(service.registerB2C(registerDto)).rejects.toThrow(
'User with this email already exists'
);
// Verify no credit balance was created
expect(mockDb.insert).not.toHaveBeenCalled();
});
it('should normalize email to lowercase', async () => {
const registerDto = {
email: 'NewUser@EXAMPLE.COM',
password: 'SecurePassword123!',
name: 'New User',
};
const mockUser = mockUserFactory.create({
email: 'NewUser@EXAMPLE.COM', // Better Auth should handle normalization
});
mockAuthApi.signUpEmail.mockResolvedValue({
user: mockUser,
token: 'mock-token',
});
mockDb.returning.mockResolvedValue([]);
await service.registerB2C(registerDto);
// Verify email was passed as-is (Better Auth normalizes internally)
expect(mockAuthApi.signUpEmail).toHaveBeenCalledWith({
body: expect.objectContaining({
email: 'NewUser@EXAMPLE.COM',
}),
});
});
it('should create personal credit balance with signup bonus', async () => {
const registerDto = {
email: 'test@example.com',
password: 'SecurePassword123!',
name: 'Test User',
};
const mockUser = mockUserFactory.create({ id: 'user-123' });
mockAuthApi.signUpEmail.mockResolvedValue({
user: mockUser,
token: 'mock-token',
});
mockDb.returning.mockResolvedValue([]);
await service.registerB2C(registerDto);
// Verify credit balance initialization
expect(mockDb.values).toHaveBeenCalledWith({
userId: 'user-123',
balance: 0,
freeCreditsRemaining: 150, // Signup bonus
dailyFreeCredits: 5,
totalEarned: 0,
totalSpent: 0,
});
});
it('should continue registration even if credit balance creation fails', async () => {
const registerDto = {
email: 'test@example.com',
password: 'SecurePassword123!',
name: 'Test User',
};
const mockUser = mockUserFactory.create({ id: 'user-123' });
mockAuthApi.signUpEmail.mockResolvedValue({
user: mockUser,
token: 'mock-token',
});
// Mock database error for credit balance creation
mockDb.returning.mockRejectedValue(new Error('Database error'));
// Should not throw - registration should complete
const result = await service.registerB2C(registerDto);
expect(result.user.id).toBe('user-123');
});
});
describe('registerB2B', () => {
it('should register organization owner successfully', async () => {
const registerDto = {
ownerEmail: 'owner@company.com',
password: 'SecurePassword123!',
ownerName: 'John Owner',
organizationName: 'Acme Corporation',
};
const mockUser = mockUserFactory.create({
id: 'owner-123',
email: registerDto.ownerEmail,
name: registerDto.ownerName,
});
const mockOrg = {
id: 'org-123',
name: 'Acme Corporation',
slug: 'acme-corporation',
};
// Mock user creation
mockAuthApi.signUpEmail.mockResolvedValue({
user: mockUser,
token: 'mock-session-token',
});
// Mock organization creation
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
// Mock credit balance creation
mockDb.returning.mockResolvedValue([]);
const result = await service.registerB2B(registerDto);
// Verify user creation
expect(mockAuthApi.signUpEmail).toHaveBeenCalledWith({
body: {
email: registerDto.ownerEmail,
password: registerDto.password,
name: registerDto.ownerName,
},
});
// Verify organization creation
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
body: {
name: 'Acme Corporation',
slug: 'acme-corporation',
},
headers: {
authorization: 'Bearer mock-session-token',
},
});
// Verify both credit balances were created
expect(mockDb.insert).toHaveBeenCalledTimes(2);
// Verify response structure
expect(result).toEqual({
user: mockUser,
organization: mockOrg,
token: 'mock-session-token',
});
});
it('should create organization credit balance', async () => {
const registerDto = {
ownerEmail: 'owner@company.com',
password: 'SecurePassword123!',
ownerName: 'John Owner',
organizationName: 'Acme Corporation',
};
const mockUser = mockUserFactory.create({ id: 'owner-123' });
const mockOrg = { id: 'org-123', name: 'Acme Corporation' };
mockAuthApi.signUpEmail.mockResolvedValue({
user: mockUser,
token: 'token',
});
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
mockDb.returning.mockResolvedValue([]);
await service.registerB2B(registerDto);
// Verify organization credit balance was created
expect(mockDb.values).toHaveBeenCalledWith(
expect.objectContaining({
organizationId: 'org-123',
balance: 0,
allocatedCredits: 0,
availableCredits: 0,
totalPurchased: 0,
totalAllocated: 0,
})
);
});
it('should handle organization creation failure', async () => {
const registerDto = {
ownerEmail: 'owner@company.com',
password: 'SecurePassword123!',
ownerName: 'John Owner',
organizationName: 'Acme Corporation',
};
const mockUser = mockUserFactory.create({ id: 'owner-123' });
mockAuthApi.signUpEmail.mockResolvedValue({
user: mockUser,
token: 'token',
});
// Mock organization creation failure
mockAuthApi.createOrganization.mockRejectedValue(
new Error('Failed to create organization')
);
await expect(service.registerB2B(registerDto)).rejects.toThrow(
'Failed to create organization'
);
});
it('should generate valid slug from organization name', async () => {
const registerDto = {
ownerEmail: 'owner@company.com',
password: 'SecurePassword123!',
ownerName: 'John Owner',
organizationName: 'My Awesome Company!!!',
};
const mockUser = mockUserFactory.create({ id: 'owner-123' });
const mockOrg = { id: 'org-123', name: 'My Awesome Company' };
mockAuthApi.signUpEmail.mockResolvedValue({
user: mockUser,
token: 'token',
});
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
mockDb.returning.mockResolvedValue([]);
await service.registerB2B(registerDto);
// Verify slug was sanitized
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
body: expect.objectContaining({
slug: 'my-awesome-company',
}),
headers: expect.anything(),
});
});
it('should throw ConflictException if owner email already exists', async () => {
const registerDto = {
ownerEmail: 'existing@company.com',
password: 'SecurePassword123!',
ownerName: 'John Owner',
organizationName: 'Acme Corporation',
};
mockAuthApi.signUpEmail.mockRejectedValue(
new Error('User with this email already exists')
);
await expect(service.registerB2B(registerDto)).rejects.toThrow(ConflictException);
await expect(service.registerB2B(registerDto)).rejects.toThrow('Owner email already exists');
// Verify organization was never created
expect(mockAuthApi.createOrganization).not.toHaveBeenCalled();
});
it('should create both organization and personal credit balances', async () => {
const registerDto = {
ownerEmail: 'owner@company.com',
password: 'SecurePassword123!',
ownerName: 'John Owner',
organizationName: 'Acme Corporation',
};
const mockUser = mockUserFactory.create({ id: 'owner-123' });
const mockOrg = { id: 'org-123', name: 'Acme Corporation' };
mockAuthApi.signUpEmail.mockResolvedValue({
user: mockUser,
token: 'token',
});
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
mockDb.returning.mockResolvedValue([]);
await service.registerB2B(registerDto);
// Verify two credit balances were created
expect(mockDb.insert).toHaveBeenCalledTimes(2);
// First call: organization balance
expect(mockDb.values).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
organizationId: 'org-123',
})
);
// Second call: personal balance
expect(mockDb.values).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
userId: 'owner-123',
})
);
});
});
describe('inviteEmployee', () => {
it('should send invitation successfully', async () => {
const inviteDto = {
organizationId: 'org-123',
employeeEmail: 'employee@example.com',
role: 'member' as const,
inviterToken: 'inviter-session-token',
};
const mockInvitation = {
id: 'invitation-123',
email: 'employee@example.com',
organizationId: 'org-123',
role: 'member',
};
mockAuthApi.inviteMember.mockResolvedValue(mockInvitation);
const result = await service.inviteEmployee(inviteDto);
// Verify Better Auth API was called
expect(mockAuthApi.inviteMember).toHaveBeenCalledWith({
body: {
organizationId: 'org-123',
email: 'employee@example.com',
role: 'member',
},
headers: {
authorization: 'Bearer inviter-session-token',
},
});
expect(result).toEqual(mockInvitation);
});
it('should pass correct role to Better Auth API', async () => {
const inviteDto = {
organizationId: 'org-123',
employeeEmail: 'admin@example.com',
role: 'admin' as const,
inviterToken: 'inviter-token',
};
mockAuthApi.inviteMember.mockResolvedValue({});
await service.inviteEmployee(inviteDto);
expect(mockAuthApi.inviteMember).toHaveBeenCalledWith({
body: expect.objectContaining({
role: 'admin',
}),
headers: expect.anything(),
});
});
it('should handle invitation to existing member', async () => {
const inviteDto = {
organizationId: 'org-123',
employeeEmail: 'existing@example.com',
role: 'member' as const,
inviterToken: 'inviter-token',
};
mockAuthApi.inviteMember.mockRejectedValue(
new Error('User is already a member')
);
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(
'User is already a member'
);
});
it('should throw ForbiddenException if inviter lacks permission', async () => {
const inviteDto = {
organizationId: 'org-123',
employeeEmail: 'employee@example.com',
role: 'member' as const,
inviterToken: 'invalid-token',
};
mockAuthApi.inviteMember.mockRejectedValue(
new Error('You do not have permission to invite members')
);
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(ForbiddenException);
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(
'You do not have permission to invite members'
);
});
});
describe('acceptInvitation', () => {
it('should accept invitation and add user to org', async () => {
const acceptDto = {
invitationId: 'invitation-123',
userToken: 'user-session-token',
};
const mockMembership = {
userId: 'user-123',
organizationId: 'org-123',
role: 'member',
};
mockAuthApi.acceptInvitation.mockResolvedValue(mockMembership);
const result = await service.acceptInvitation(acceptDto);
// Verify Better Auth API was called
expect(mockAuthApi.acceptInvitation).toHaveBeenCalledWith({
body: { invitationId: 'invitation-123' },
headers: {
authorization: 'Bearer user-session-token',
},
});
expect(result).toEqual(mockMembership);
});
it('should handle expired invitation', async () => {
const acceptDto = {
invitationId: 'expired-invitation',
userToken: 'user-token',
};
mockAuthApi.acceptInvitation.mockRejectedValue(
new Error('Invitation expired')
);
await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(NotFoundException);
await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(
'Invitation not found or expired'
);
});
it('should handle already accepted invitation', async () => {
const acceptDto = {
invitationId: 'used-invitation',
userToken: 'user-token',
};
mockAuthApi.acceptInvitation.mockRejectedValue(
new Error('Invitation not found')
);
await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(NotFoundException);
});
});
describe('getOrganizationMembers', () => {
it('should return list of members', async () => {
const mockMembers = [
{
userId: 'user-1',
organizationId: 'org-123',
role: 'owner',
name: 'John Owner',
email: 'owner@example.com',
},
{
userId: 'user-2',
organizationId: 'org-123',
role: 'member',
name: 'Jane Member',
email: 'member@example.com',
},
];
mockAuthApi.getFullOrganization.mockResolvedValue({ members: mockMembers });
const result = await service.getOrganizationMembers('org-123');
expect(mockAuthApi.getFullOrganization).toHaveBeenCalledWith({
query: { organizationId: 'org-123' },
});
expect(result).toEqual(mockMembers);
expect(result).toHaveLength(2);
});
it('should handle empty organization', async () => {
mockAuthApi.getFullOrganization.mockResolvedValue({ members: [] });
const result = await service.getOrganizationMembers('org-123');
expect(result).toEqual([]);
});
it('should return empty array on error', async () => {
mockAuthApi.getFullOrganization.mockRejectedValue(
new Error('Database error')
);
const result = await service.getOrganizationMembers('org-123');
// Should not throw, but return empty array
expect(result).toEqual([]);
});
});
describe('removeMember', () => {
it('should remove member successfully', async () => {
const removeDto = {
organizationId: 'org-123',
memberId: 'user-456',
removerToken: 'admin-token',
};
mockAuthApi.removeMember.mockResolvedValue({ success: true });
const result = await service.removeMember(removeDto);
expect(mockAuthApi.removeMember).toHaveBeenCalledWith({
body: {
memberIdOrEmail: 'user-456',
organizationId: 'org-123',
},
headers: {
authorization: 'Bearer admin-token',
},
});
expect(result).toEqual({
success: true,
message: 'Member removed successfully',
});
});
it('should handle removing non-existent member', async () => {
const removeDto = {
organizationId: 'org-123',
memberId: 'non-existent',
removerToken: 'admin-token',
};
mockAuthApi.removeMember.mockRejectedValue(
new Error('Member not found')
);
await expect(service.removeMember(removeDto)).rejects.toThrow(
'Member not found'
);
});
it('should throw ForbiddenException if remover lacks permission', async () => {
const removeDto = {
organizationId: 'org-123',
memberId: 'user-456',
removerToken: 'member-token', // Regular member cannot remove
};
mockAuthApi.removeMember.mockRejectedValue(
new Error('You do not have permission to remove members')
);
await expect(service.removeMember(removeDto)).rejects.toThrow(ForbiddenException);
await expect(service.removeMember(removeDto)).rejects.toThrow(
'You do not have permission to remove members'
);
});
});
describe('setActiveOrganization', () => {
it('should switch organization successfully', async () => {
const setActiveDto = {
organizationId: 'org-456',
userToken: 'user-token',
};
const mockSession = {
userId: 'user-123',
activeOrganizationId: 'org-456',
};
mockAuthApi.setActiveOrganization.mockResolvedValue(mockSession);
const result = await service.setActiveOrganization(setActiveDto);
expect(mockAuthApi.setActiveOrganization).toHaveBeenCalledWith({
body: { organizationId: 'org-456' },
headers: {
authorization: 'Bearer user-token',
},
});
expect(result).toEqual(mockSession);
});
it('should update session context', async () => {
const setActiveDto = {
organizationId: 'org-789',
userToken: 'user-token',
};
const mockSession = {
userId: 'user-123',
activeOrganizationId: 'org-789',
metadata: {
previousOrg: 'org-456',
},
};
mockAuthApi.setActiveOrganization.mockResolvedValue(mockSession);
const result = await service.setActiveOrganization(setActiveDto);
expect(result.activeOrganizationId).toBe('org-789');
});
it('should throw NotFoundException for invalid organization', async () => {
const setActiveDto = {
organizationId: 'non-existent-org',
userToken: 'user-token',
};
mockAuthApi.setActiveOrganization.mockRejectedValue(
new Error('Organization not found or you are not a member')
);
await expect(service.setActiveOrganization(setActiveDto)).rejects.toThrow(
NotFoundException
);
await expect(service.setActiveOrganization(setActiveDto)).rejects.toThrow(
'Organization not found or you are not a member'
);
});
});
describe('slugify (private method)', () => {
it('should convert organization name to lowercase slug', async () => {
const registerDto = {
ownerEmail: 'owner@company.com',
password: 'SecurePassword123!',
ownerName: 'John Owner',
organizationName: 'My Company',
};
const mockUser = mockUserFactory.create({ id: 'owner-123' });
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' });
mockDb.returning.mockResolvedValue([]);
await service.registerB2B(registerDto);
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
body: expect.objectContaining({
slug: 'my-company',
}),
headers: expect.anything(),
});
});
it('should remove special characters from slug', async () => {
const registerDto = {
ownerEmail: 'owner@company.com',
password: 'SecurePassword123!',
ownerName: 'John Owner',
organizationName: 'Company #1 (Best!)',
};
const mockUser = mockUserFactory.create({ id: 'owner-123' });
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' });
mockDb.returning.mockResolvedValue([]);
await service.registerB2B(registerDto);
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
body: expect.objectContaining({
slug: 'company-1-best',
}),
headers: expect.anything(),
});
});
it('should replace spaces with hyphens', async () => {
const registerDto = {
ownerEmail: 'owner@company.com',
password: 'SecurePassword123!',
ownerName: 'John Owner',
organizationName: 'Multi Word Company Name',
};
const mockUser = mockUserFactory.create({ id: 'owner-123' });
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' });
mockDb.returning.mockResolvedValue([]);
await service.registerB2B(registerDto);
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
body: expect.objectContaining({
slug: 'multi-word-company-name',
}),
headers: expect.anything(),
});
});
it('should handle multiple consecutive spaces', async () => {
const registerDto = {
ownerEmail: 'owner@company.com',
password: 'SecurePassword123!',
ownerName: 'John Owner',
organizationName: 'Company With Spaces',
};
const mockUser = mockUserFactory.create({ id: 'owner-123' });
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' });
mockDb.returning.mockResolvedValue([]);
await service.registerB2B(registerDto);
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
body: expect.objectContaining({
slug: 'company-with-spaces',
}),
headers: expect.anything(),
});
});
});
describe('Credit Balance Initialization', () => {
it('should initialize B2C user with signup bonus credits', async () => {
const registerDto = {
email: 'test@example.com',
password: 'SecurePassword123!',
name: 'Test User',
};
const mockUser = mockUserFactory.create({ id: 'user-123' });
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
mockDb.returning.mockResolvedValue([]);
await service.registerB2C(registerDto);
// Verify credit balance was initialized with correct values
expect(mockDb.values).toHaveBeenCalledWith({
userId: 'user-123',
balance: 0,
freeCreditsRemaining: 150,
dailyFreeCredits: 5,
totalEarned: 0,
totalSpent: 0,
});
});
it('should initialize organization balance with zero credits', async () => {
const registerDto = {
ownerEmail: 'owner@company.com',
password: 'SecurePassword123!',
ownerName: 'John Owner',
organizationName: 'Acme Corporation',
};
const mockUser = mockUserFactory.create({ id: 'owner-123' });
const mockOrg = { id: 'org-123', name: 'Acme Corporation' };
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
mockDb.returning.mockResolvedValue([]);
await service.registerB2B(registerDto);
// Verify organization balance was initialized
expect(mockDb.values).toHaveBeenCalledWith(
expect.objectContaining({
organizationId: 'org-123',
balance: 0,
allocatedCredits: 0,
availableCredits: 0,
totalPurchased: 0,
totalAllocated: 0,
})
);
});
it('should not fail registration if credit balance creation errors', async () => {
const registerDto = {
email: 'test@example.com',
password: 'SecurePassword123!',
name: 'Test User',
};
const mockUser = mockUserFactory.create({ id: 'user-123' });
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
// Mock database error
mockDb.insert.mockImplementation(() => {
throw new Error('Database connection failed');
});
// Should not throw - registration should complete despite credit error
const result = await service.registerB2C(registerDto);
expect(result.user.id).toBe('user-123');
});
});
describe('Error Handling', () => {
it('should handle generic errors from Better Auth', async () => {
const registerDto = {
email: 'test@example.com',
password: 'SecurePassword123!',
name: 'Test User',
};
mockAuthApi.signUpEmail.mockRejectedValue(
new Error('Unexpected server error')
);
await expect(service.registerB2C(registerDto)).rejects.toThrow(
'Unexpected server error'
);
});
it('should propagate network errors', async () => {
const inviteDto = {
organizationId: 'org-123',
employeeEmail: 'employee@example.com',
role: 'member' as const,
inviterToken: 'token',
};
mockAuthApi.inviteMember.mockRejectedValue(
new Error('Network timeout')
);
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(
'Network timeout'
);
});
});
});

View file

@ -0,0 +1,955 @@
/**
* Better Auth Service
*
* NestJS service that wraps Better Auth functionality for:
* - B2C user registration
* - B2B organization registration
* - Organization member management
* - Employee invitations
*
* This service uses Better Auth's organization plugin for all B2B operations,
* eliminating the need to build custom organization management.
*
* @see BETTER_AUTH_FINAL_PLAN.md
*/
import {
Injectable,
ConflictException,
NotFoundException,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createBetterAuth, type BetterAuthInstance } from '../better-auth.config';
import { getDb } from '../../db/connection';
import { balances, organizationBalances } from '../../db/schema/credits.schema';
import {
hasUser,
hasToken,
hasMember,
hasMembers,
hasSession,
} from '../types/better-auth.types';
import type {
RegisterB2CDto,
RegisterB2BDto,
InviteEmployeeDto,
AcceptInvitationDto,
RemoveMemberDto,
SetActiveOrganizationDto,
SignInDto,
RegisterB2CResult,
RegisterB2BResult,
InviteEmployeeResult,
AcceptInvitationResult,
RemoveMemberResult,
SetActiveOrganizationResult,
SignInResult,
SignOutResult,
GetSessionResult,
ListOrganizationsResult,
RefreshTokenResult,
ValidateTokenResult,
TokenPayload,
OrganizationMember,
Organization,
BetterAuthAPI,
SignUpResponse,
SignInResponse,
CreateOrganizationResponse,
BetterAuthUser,
BetterAuthSession,
} from '../types/better-auth.types';
import * as jwt from 'jsonwebtoken';
import { jwtVerify, createRemoteJWKSet } from 'jose';
// Re-export DTOs and result types for external use
export type {
RegisterB2CDto,
RegisterB2BDto,
InviteEmployeeDto,
AcceptInvitationDto,
RemoveMemberDto,
SetActiveOrganizationDto,
SignInDto,
SignInResult,
SignOutResult,
GetSessionResult,
ListOrganizationsResult,
RefreshTokenResult,
ValidateTokenResult,
TokenPayload,
};
@Injectable()
export class BetterAuthService {
private auth: BetterAuthInstance;
private databaseUrl: string;
/**
* Typed accessor for organization plugin API methods
* Better Auth's organization plugin adds methods dynamically, so we provide
* a typed accessor to avoid casting throughout the service.
*/
private get orgApi(): BetterAuthAPI {
return this.auth.api as unknown as BetterAuthAPI;
}
constructor(private configService: ConfigService) {
this.databaseUrl = this.configService.get<string>('database.url')!;
this.auth = createBetterAuth(this.databaseUrl);
}
/**
* Register a B2C user (individual)
*
* Creates a new user account with email/password and initializes their
* personal credit balance.
*
* @param dto - Registration data
* @returns User data and session
* @throws ConflictException if email already exists
*/
async registerB2C(dto: RegisterB2CDto): Promise<RegisterB2CResult> {
try {
// Create user via Better Auth
const result = await this.auth.api.signUpEmail({
body: {
email: dto.email,
password: dto.password,
name: dto.name,
},
});
// Use type guards for safe access
if (!hasUser(result)) {
throw new Error('Invalid response from Better Auth: missing user');
}
const { user } = result;
// Create personal credit balance
await this.createPersonalCreditBalance(user.id);
return {
user: {
id: user.id,
email: user.email,
name: user.name,
},
token: hasToken(result) ? result.token : undefined,
};
} catch (error: unknown) {
if (error instanceof Error && error.message?.includes('already exists')) {
throw new ConflictException('User with this email already exists');
}
throw error;
}
}
/**
* Register a B2B organization (company)
*
* Creates:
* 1. Owner user account
* 2. Organization (via Better Auth organization plugin)
* 3. Automatic owner membership (Better Auth handles this)
* 4. Organization credit balance
*
* @param dto - Organization registration data
* @returns User, organization, and session data
* @throws ConflictException if owner email already exists
*/
async registerB2B(dto: RegisterB2BDto): Promise<RegisterB2BResult> {
try {
// Step 1: Create owner user account
const userResult = await this.auth.api.signUpEmail({
body: {
email: dto.ownerEmail,
password: dto.password,
name: dto.ownerName,
},
});
// Use type guards for safe access
if (!hasUser(userResult)) {
throw new Error('Invalid response from Better Auth: missing user');
}
const { user } = userResult;
const ownerId = user.id;
const sessionToken = hasToken(userResult) ? userResult.token : '';
// Step 2: Create organization (Better Auth handles owner membership automatically)
// Note: createOrganization is typed via BetterAuthAPI but we need to cast for org plugin methods
const orgResult = (await this.auth.api.createOrganization({
body: {
name: dto.organizationName,
slug: this.slugify(dto.organizationName),
},
headers: {
authorization: `Bearer ${sessionToken}`,
},
})) as CreateOrganizationResponse;
const organizationId = orgResult.id;
// Step 3: Create organization credit balance
await this.createOrganizationCreditBalance(organizationId);
// Step 4: Create owner's personal balance (for when they use credits)
await this.createPersonalCreditBalance(ownerId);
return {
user,
organization: orgResult,
token: sessionToken,
};
} catch (error: unknown) {
if (error instanceof Error && error.message?.includes('already exists')) {
throw new ConflictException('Owner email already exists');
}
throw error;
}
}
/**
* Invite employee to organization
*
* Uses Better Auth organization plugin to:
* 1. Validate inviter has permission (owner/admin)
* 2. Create invitation record
* 3. Send invitation email
*
* @param dto - Invitation data
* @returns Invitation record
* @throws ForbiddenException if inviter lacks permission
*/
async inviteEmployee(dto: InviteEmployeeDto): Promise<InviteEmployeeResult> {
try {
// Better Auth organization plugin uses auth.api.inviteMember
// See: https://www.better-auth.com/docs/plugins/organization
const result = await this.orgApi.inviteMember({
body: {
email: dto.employeeEmail,
role: dto.role,
organizationId: dto.organizationId,
},
headers: {
authorization: `Bearer ${dto.inviterToken}`,
},
});
return result;
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('permission') || error.message?.includes('unauthorized')) {
throw new ForbiddenException('You do not have permission to invite members');
}
}
throw error;
}
}
/**
* Accept organization invitation
*
* When a user accepts an invitation, Better Auth:
* 1. Adds user to organization as member
* 2. Sets the role from invitation
* 3. Marks invitation as accepted
*
* After acceptance, we create the user's personal balance for tracking
* their allocated credits from the organization.
*
* @param dto - Acceptance data
* @returns Membership data
* @throws NotFoundException if invitation not found or expired
*/
async acceptInvitation(dto: AcceptInvitationDto): Promise<AcceptInvitationResult> {
try {
// Better Auth organization plugin uses auth.api.acceptInvitation
// See: https://www.better-auth.com/docs/plugins/organization
const result = await this.orgApi.acceptInvitation({
body: { invitationId: dto.invitationId },
headers: {
authorization: `Bearer ${dto.userToken}`,
},
});
// Extract user ID from the result to create their personal balance
// Use type guard for safe access
const userId = hasMember(result) ? result.member.userId : undefined;
if (userId) {
await this.createPersonalCreditBalance(userId);
}
return result;
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found') || error.message?.includes('expired')) {
throw new NotFoundException('Invitation not found or expired');
}
}
throw error;
}
}
/**
* Get organization members
*
* Lists all members of an organization with their roles.
* Uses getFullOrganization which returns org details with members.
*
* @param organizationId - Organization ID
* @returns List of members
*/
async getOrganizationMembers(organizationId: string): Promise<OrganizationMember[]> {
try {
// Better Auth uses getFullOrganization to get org with members
// See: https://www.better-auth.com/docs/plugins/organization
const result = await this.orgApi.getFullOrganization({
query: { organizationId },
});
// Use type guard for safe access
return hasMembers(result) ? result.members : [];
} catch (error) {
console.error('Error fetching organization members:', error);
return [];
}
}
/**
* Remove member from organization
*
* Uses Better Auth to:
* 1. Validate remover has permission (owner/admin)
* 2. Remove member from organization
* 3. Clean up member's access
*
* @param dto - Remove member data
* @returns Success status
* @throws ForbiddenException if remover lacks permission
*/
async removeMember(dto: RemoveMemberDto): Promise<RemoveMemberResult> {
try {
// Better Auth organization plugin uses auth.api.removeMember
// Accepts memberIdOrEmail parameter
// See: https://www.better-auth.com/docs/plugins/organization
await this.orgApi.removeMember({
body: {
memberIdOrEmail: dto.memberId,
organizationId: dto.organizationId,
},
headers: {
authorization: `Bearer ${dto.removerToken}`,
},
});
return { success: true, message: 'Member removed successfully' };
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('permission') || error.message?.includes('unauthorized')) {
throw new ForbiddenException('You do not have permission to remove members');
}
}
throw error;
}
}
/**
* Set active organization for user
*
* For users who belong to multiple organizations, this switches
* the active organization context. The active organization is used
* for JWT claims and credit balance calculations.
*
* @param dto - Active organization data
* @returns Updated session data
*/
async setActiveOrganization(dto: SetActiveOrganizationDto): Promise<SetActiveOrganizationResult> {
try {
// Better Auth organization plugin uses auth.api.setActiveOrganization
// See: https://www.better-auth.com/docs/plugins/organization
const result = await this.orgApi.setActiveOrganization({
body: { organizationId: dto.organizationId },
headers: {
authorization: `Bearer ${dto.userToken}`,
},
});
return result;
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found') || error.message?.includes('not a member')) {
throw new NotFoundException('Organization not found or you are not a member');
}
}
throw error;
}
}
// =========================================================================
// Authentication Methods (Sign In / Sign Out / Session)
// =========================================================================
/**
* Sign in user with email and password
*
* Authenticates a user and returns their session with JWT token.
*
* @param dto - Sign in credentials
* @returns User data and authentication token
* @throws UnauthorizedException if credentials are invalid
*/
async signIn(dto: SignInDto): Promise<SignInResult> {
try {
const result = await this.auth.api.signInEmail({
body: {
email: dto.email,
password: dto.password,
},
});
if (!hasUser(result)) {
throw new UnauthorizedException('Invalid credentials');
}
const { user } = result;
// Get session token (used as refresh token)
const session = hasSession(result) ? result.session : null;
const sessionToken = session?.token || (hasToken(result) ? result.token : '');
// Generate JWT access token using Better Auth's JWT plugin
let accessToken = '';
try {
const api = this.auth.api as any;
// Use Better Auth's signJWT with the jwks table
const jwtResult = await api.signJWT({
body: {
payload: {
sub: user.id,
email: user.email,
role: (user as BetterAuthUser).role || 'user',
sid: session?.id || '',
},
},
});
accessToken = jwtResult?.token || '';
// Fallback to manual JWT if Better Auth fails
if (!accessToken) {
throw new Error('Better Auth signJWT returned empty token');
}
} catch (jwtError) {
console.warn('[signIn] Better Auth signJWT failed, using manual JWT generation:', jwtError);
// Fallback: Generate JWT manually using jsonwebtoken
const privateKey = this.configService.get<string>('jwt.privateKey');
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
console.log('[signIn] Private key exists:', !!privateKey);
console.log('[signIn] Private key length:', privateKey?.length);
console.log('[signIn] Private key starts with:', privateKey?.substring(0, 30));
console.log('[signIn] Issuer:', issuer);
console.log('[signIn] Audience:', audience);
if (privateKey) {
const payload = {
sub: user.id,
email: user.email,
role: (user as BetterAuthUser).role || 'user',
sid: session?.id || '',
};
accessToken = jwt.sign(payload, privateKey, {
algorithm: 'RS256',
expiresIn: '15m',
issuer,
audience,
});
console.log('[signIn] Generated JWT (first 50 chars):', accessToken?.substring(0, 50));
// Decode to verify
const decoded = jwt.decode(accessToken, { complete: true });
console.log('[signIn] Generated JWT header:', decoded?.header);
console.log('[signIn] Generated JWT payload:', decoded?.payload);
} else {
console.error('[signIn] No JWT private key configured');
accessToken = sessionToken;
}
}
return {
user: {
id: user.id,
email: user.email,
name: user.name,
role: (user as BetterAuthUser).role,
},
accessToken,
refreshToken: sessionToken,
expiresIn: 15 * 60, // 15 minutes in seconds
};
} catch (error: unknown) {
if (error instanceof Error) {
if (
error.message?.includes('invalid') ||
error.message?.includes('credentials') ||
error.message?.includes('not found')
) {
throw new UnauthorizedException('Invalid email or password');
}
}
throw error;
}
}
/**
* Sign out user
*
* Invalidates the user's session.
*
* @param token - User's authentication token
* @returns Success status
*/
async signOut(token: string): Promise<SignOutResult> {
try {
// Better Auth uses auth.api.signOut
await (this.auth.api as any).signOut({
headers: {
authorization: `Bearer ${token}`,
},
});
return { success: true, message: 'Signed out successfully' };
} catch (error: unknown) {
// Even if signOut fails, we treat it as success for the user
// The session will expire naturally
console.error('Error during sign out:', error);
return { success: true, message: 'Signed out successfully' };
}
}
/**
* Get current session
*
* Retrieves the current user's session data.
*
* @param token - User's authentication token
* @returns User and session data
* @throws UnauthorizedException if session is invalid
*/
async getSession(token: string): Promise<GetSessionResult> {
try {
// Better Auth uses auth.api.getSession
const result = await (this.auth.api as any).getSession({
headers: {
authorization: `Bearer ${token}`,
},
});
if (!hasSession(result)) {
throw new UnauthorizedException('Invalid or expired session');
}
return {
user: result.user,
session: result.session,
};
} catch (error: unknown) {
if (error instanceof Error) {
if (
error.message?.includes('invalid') ||
error.message?.includes('expired') ||
error.message?.includes('not found')
) {
throw new UnauthorizedException('Invalid or expired session');
}
}
throw error;
}
}
/**
* List user's organizations
*
* Returns all organizations the user is a member of.
*
* @param token - User's authentication token
* @returns List of organizations
*/
async listOrganizations(token: string): Promise<ListOrganizationsResult> {
try {
const result = await this.orgApi.listOrganizations({
headers: {
authorization: `Bearer ${token}`,
},
});
// Result is an array of organizations
const organizations = Array.isArray(result) ? result : [];
return { organizations };
} catch (error: unknown) {
console.error('Error listing organizations:', error);
return { organizations: [] };
}
}
/**
* Get organization by ID
*
* Returns the full organization details including members.
*
* @param organizationId - Organization ID
* @param token - User's authentication token (optional for public orgs)
* @returns Organization with members
* @throws NotFoundException if organization not found
*/
async getOrganization(
organizationId: string,
token?: string
): Promise<Organization & { members?: OrganizationMember[] }> {
try {
const result = await this.orgApi.getFullOrganization({
query: { organizationId },
...(token && {
headers: {
authorization: `Bearer ${token}`,
},
}),
} as any);
if (!result || !result.id) {
throw new NotFoundException('Organization not found');
}
return {
id: result.id,
name: result.name,
slug: result.slug,
logo: result.logo,
metadata: result.metadata,
createdAt: result.createdAt,
members: hasMembers(result) ? result.members : undefined,
};
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found')) {
throw new NotFoundException('Organization not found');
}
}
throw error;
}
}
// =========================================================================
// Token Management Methods
// =========================================================================
/**
* Refresh access token
*
* Validates the refresh token and issues new access/refresh tokens.
* Implements refresh token rotation for security.
*
* @param refreshToken - The refresh token to validate
* @returns New access token, refresh token, and user data
* @throws UnauthorizedException if refresh token is invalid or expired
*/
async refreshToken(refreshToken: string): Promise<RefreshTokenResult> {
const db = getDb(this.databaseUrl);
try {
// Import sessions schema for refresh token lookup
const { sessions } = await import('../../db/schema');
const { users } = await import('../../db/schema');
const { eq, and, isNull } = await import('drizzle-orm');
const { nanoid } = await import('nanoid');
const { randomUUID } = await import('crypto');
// Find session by refresh token
const [session] = await db
.select()
.from(sessions)
.where(and(eq(sessions.refreshToken, refreshToken), isNull(sessions.revokedAt)))
.limit(1);
if (!session) {
throw new UnauthorizedException('Invalid refresh token');
}
// Check if refresh token is expired
if (!session.refreshTokenExpiresAt || new Date() > session.refreshTokenExpiresAt) {
throw new UnauthorizedException('Refresh token expired');
}
// Get user
const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1);
if (!user || user.deletedAt) {
throw new UnauthorizedException('User not found');
}
// Revoke old session (refresh token rotation)
await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, session.id));
// Generate new session
const sessionId = randomUUID();
const newRefreshToken = nanoid(64);
const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const accessTokenExpiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await db.insert(sessions).values({
id: sessionId,
userId: user.id,
token: sessionId,
refreshToken: newRefreshToken,
refreshTokenExpiresAt,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
deviceId: session.deviceId,
deviceName: session.deviceName,
expiresAt: accessTokenExpiresAt,
});
// Generate new JWT
const privateKey = this.configService.get<string>('jwt.privateKey');
if (!privateKey) {
throw new Error('JWT private key not configured');
}
const accessTokenExpiry = this.configService.get<string>('jwt.accessTokenExpiry') || '15m';
const issuer = this.configService.get<string>('jwt.issuer');
const audience = this.configService.get<string>('jwt.audience');
const tokenPayload: Record<string, unknown> = {
sub: user.id,
email: user.email,
role: user.role,
sessionId,
...(session.deviceId && { deviceId: session.deviceId }),
};
const accessToken = jwt.sign(tokenPayload, privateKey, {
algorithm: 'RS256' as const,
expiresIn: accessTokenExpiry as jwt.SignOptions['expiresIn'],
...(issuer && { issuer }),
...(audience && { audience }),
});
return {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
accessToken,
refreshToken: newRefreshToken,
expiresIn: 15 * 60, // 15 minutes in seconds
tokenType: 'Bearer',
};
} catch (error: unknown) {
if (error instanceof UnauthorizedException) {
throw error;
}
if (error instanceof Error) {
if (
error.message?.includes('invalid') ||
error.message?.includes('expired') ||
error.message?.includes('not found')
) {
throw new UnauthorizedException('Invalid or expired refresh token');
}
}
throw error;
}
}
/**
* Validate a JWT token
*
* Verifies the token signature and expiration.
* Returns the decoded payload if valid.
*
* @param token - The JWT token to validate
* @returns Validation result with payload or error
*/
async validateToken(token: string): Promise<ValidateTokenResult> {
try {
console.log('[validateToken] Token (first 50 chars):', token?.substring(0, 50));
// Decode to check the algorithm
const decoded = jwt.decode(token, { complete: true });
console.log('[validateToken] Decoded header:', decoded?.header);
// Use our JWKS endpoint (NestJS prefix: /api/v1)
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
console.log('[validateToken] Using JWKS from:', jwksUrl.toString());
// Create JWKS fetcher
const JWKS = createRemoteJWKSet(jwksUrl);
// Get issuer/audience from config (Better Auth uses BASE_URL by default)
const issuer = this.configService.get<string>('jwt.issuer') || baseUrl;
const audience = this.configService.get<string>('jwt.audience') || baseUrl;
console.log('[validateToken] Issuer:', issuer);
console.log('[validateToken] Audience:', audience);
// Verify using jose library with Better Auth's JWKS
const { payload } = await jwtVerify(token, JWKS, {
issuer,
audience,
});
console.log('[validateToken] Verification SUCCESS');
console.log('[validateToken] Payload:', payload);
return {
valid: true,
payload: payload as unknown as TokenPayload,
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('[validateToken] Verification FAILED:', errorMessage);
return {
valid: false,
error: errorMessage,
};
}
}
/**
* Get JWKS (JSON Web Key Set)
*
* Returns public keys for JWT verification.
* Proxies to Better Auth's internal JWKS.
*
* @returns JWKS with public keys
*/
async getJwks(): Promise<{ keys: unknown[] }> {
try {
// Better Auth exposes JWKS via auth.api
const api = this.auth.api as any;
// Try to get JWKS from Better Auth
if (api.getJwks) {
const result = await api.getJwks();
return result;
}
// Fallback: read from jwks table directly
const db = getDb(this.databaseUrl);
const { jwks } = await import('../../db/schema/auth.schema');
const keys = await db.select().from(jwks);
// Convert to JWKS format (EdDSA public keys)
return {
keys: keys.map((key) => {
try {
return JSON.parse(key.publicKey);
} catch {
return { kid: key.id, publicKey: key.publicKey };
}
}),
};
} catch (error) {
console.error('[getJwks] Error:', error);
return { keys: [] };
}
}
// =========================================================================
// Private Helper Methods
// =========================================================================
/**
* Create personal credit balance for user
*
* Initializes a user's credit balance with:
* - 0 purchased credits
* - 150 free signup credits
* - 5 daily free credits
*
* @param userId - User ID
* @private
*/
private async createPersonalCreditBalance(userId: string) {
const db = getDb(this.databaseUrl);
try {
await db.insert(balances).values({
userId: userId as any, // Cast to handle UUID type
balance: 0,
freeCreditsRemaining: 150, // Signup bonus
dailyFreeCredits: 5,
totalEarned: 0,
totalSpent: 0,
});
} catch (error) {
console.error('Error creating personal credit balance:', error);
// Don't throw - this is a non-critical operation
}
}
/**
* Create organization credit balance
*
* Initializes an organization's credit pool with:
* - 0 purchased credits
* - 0 allocated credits
* - 0 available credits
*
* The organization owner must purchase credits before allocating to employees.
*
* @param organizationId - Organization ID
* @private
*/
private async createOrganizationCreditBalance(organizationId: string) {
const db = getDb(this.databaseUrl);
try {
await db.insert(organizationBalances).values({
organizationId,
balance: 0,
allocatedCredits: 0,
availableCredits: 0,
totalPurchased: 0,
totalAllocated: 0,
});
} catch (error) {
console.error('Error creating organization credit balance:', error);
// Don't throw - this is a non-critical operation
}
}
/**
* Helper function to create URL-safe slugs
*
* Converts organization name to lowercase, URL-safe slug.
* Example: "Acme Corporation" -> "acme-corporation"
*
* @param text - Text to slugify
* @returns URL-safe slug
* @private
*/
private slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/--+/g, '-') // Replace multiple hyphens with single
.trim();
}
}

View file

@ -0,0 +1,600 @@
/**
* Better Auth Type Definitions
*
* This file provides types for Better Auth integration.
*
* STRATEGY: Import base types from Better Auth packages, extend only when needed.
*
* From 'better-auth/types':
* - User, Session, Account, Auth, BetterAuthOptions, etc.
*
* From 'better-auth/plugins/organization':
* - Organization, Member, Invitation, OrganizationRole, InvitationStatus
*
* This file defines:
* 1. Extended types (adding fields Better Auth doesn't have)
* 2. API response/request types for our service layer
* 3. Service-specific DTOs and result types
* 4. Type guards for runtime safety
*
* @see https://www.better-auth.com/docs/concepts/typescript
* @see https://www.better-auth.com/docs/plugins/organization
*/
// =============================================================================
// Import core types from Better Auth packages
// =============================================================================
import type { User, Session } from 'better-auth/types';
import type {
Organization as BetterAuthOrganization,
Member as BetterAuthMember,
Invitation as BetterAuthInvitation,
OrganizationRole as BetterAuthOrganizationRole,
InvitationStatus as BetterAuthInvitationStatus,
} from 'better-auth/plugins/organization';
// Re-export base types for convenience
export type { User, Session };
export type {
BetterAuthOrganization,
BetterAuthMember,
BetterAuthInvitation,
BetterAuthOrganizationRole,
BetterAuthInvitationStatus,
};
/**
* Extended User type with our additional fields
* Better Auth's User type is the base, we extend it for our app
*/
export interface BetterAuthUser extends User {
role?: string;
}
/**
* Extended Session type with organization support
* Better Auth's Session type is the base, organization plugin adds activeOrganizationId
*/
export interface BetterAuthSession extends Session {
activeOrganizationId?: string | null;
metadata?: Record<string, unknown>;
}
/**
* JWT Payload context passed to definePayload
*/
export interface JWTPayloadContext {
user: BetterAuthUser;
session: BetterAuthSession;
}
// =============================================================================
// Organization Types (aligned with Better Auth but with explicit fields)
// =============================================================================
/**
* Organization entity - mirrors Better Auth's Organization type
* We define explicitly to ensure type safety in our service layer
*/
export interface Organization {
id: string;
name: string;
slug: string;
logo?: string | null;
metadata?: Record<string, unknown>;
createdAt: Date;
updatedAt?: Date;
}
/**
* Organization member - mirrors Better Auth's Member type
*/
export interface OrganizationMember {
id: string;
userId: string;
organizationId: string;
role: OrganizationRole;
createdAt: Date;
updatedAt?: Date;
}
/**
* Organization role types - aligned with Better Auth defaults
*/
export type OrganizationRole = 'owner' | 'admin' | 'member';
/**
* Organization invitation - mirrors Better Auth's Invitation type
*/
export interface OrganizationInvitation {
id: string;
email: string;
organizationId: string;
role: OrganizationRole;
status: 'pending' | 'accepted' | 'rejected' | 'expired';
inviterId: string;
expiresAt: Date;
createdAt: Date;
}
// =============================================================================
// API Response Types
// =============================================================================
/**
* Sign up response from Better Auth
*/
export interface SignUpResponse {
user: BetterAuthUser;
token?: string;
session?: BetterAuthSession;
}
/**
* Sign in response from Better Auth
*/
export interface SignInResponse {
user: BetterAuthUser;
token: string;
session: BetterAuthSession;
}
/**
* Create organization response
*/
export interface CreateOrganizationResponse extends Organization {
// Organization fields are returned directly
}
/**
* Invite member response
*/
export interface InviteMemberResponse {
id: string;
email: string;
organizationId: string;
role: OrganizationRole;
status: 'pending';
expiresAt: Date;
}
/**
* Accept invitation response
*/
export interface AcceptInvitationResponse {
member: OrganizationMember;
organization: Organization;
}
/**
* Get full organization response
*/
export interface GetFullOrganizationResponse extends Organization {
members: Array<OrganizationMember & { user?: BetterAuthUser }>;
invitations?: OrganizationInvitation[];
}
/**
* Set active organization response
*/
export interface SetActiveOrganizationResponse {
userId: string;
activeOrganizationId: string;
metadata?: Record<string, unknown>;
session?: BetterAuthSession;
}
// =============================================================================
// API Request Types
// =============================================================================
/**
* Sign up request body
*/
export interface SignUpEmailBody {
email: string;
password: string;
name: string;
}
/**
* Create organization request body
*/
export interface CreateOrganizationBody {
name: string;
slug: string;
logo?: string;
metadata?: Record<string, unknown>;
}
/**
* Invite member request body
*/
export interface InviteMemberBody {
email: string;
role: OrganizationRole;
organizationId: string;
}
/**
* Accept invitation request body
*/
export interface AcceptInvitationBody {
invitationId: string;
}
/**
* Remove member request body
*/
export interface RemoveMemberBody {
memberIdOrEmail: string;
organizationId: string;
}
/**
* Set active organization request body
*/
export interface SetActiveOrganizationBody {
organizationId: string;
}
/**
* Get full organization query
*/
export interface GetFullOrganizationQuery {
organizationId?: string;
organizationSlug?: string;
membersLimit?: number;
}
// =============================================================================
// API Method Types (with headers)
// =============================================================================
export interface AuthenticatedRequest<TBody = unknown, TQuery = unknown> {
body?: TBody;
query?: TQuery;
headers: {
authorization: string;
};
}
// =============================================================================
// Better Auth API Interface
// =============================================================================
/**
* Typed Better Auth API interface
*
* This interface describes the methods available on auth.api
* when using the organization plugin.
*/
export interface BetterAuthAPI {
// Core auth methods
signUpEmail(params: { body: SignUpEmailBody }): Promise<SignUpResponse>;
signInEmail(params: { body: { email: string; password: string } }): Promise<SignInResponse>;
// Organization methods
createOrganization(
params: AuthenticatedRequest<CreateOrganizationBody>
): Promise<CreateOrganizationResponse>;
inviteMember(params: AuthenticatedRequest<InviteMemberBody>): Promise<InviteMemberResponse>;
acceptInvitation(
params: AuthenticatedRequest<AcceptInvitationBody>
): Promise<AcceptInvitationResponse>;
getFullOrganization(params: {
query: GetFullOrganizationQuery;
}): Promise<GetFullOrganizationResponse>;
removeMember(params: AuthenticatedRequest<RemoveMemberBody>): Promise<{ success: boolean }>;
setActiveOrganization(
params: AuthenticatedRequest<SetActiveOrganizationBody>
): Promise<SetActiveOrganizationResponse>;
listOrganizations(params: AuthenticatedRequest): Promise<Organization[]>;
}
// =============================================================================
// Service Response Types
// =============================================================================
/**
* B2C Registration result
*/
export interface RegisterB2CResult {
user: {
id: string;
email: string;
name: string | null;
};
token?: string;
}
/**
* B2B Registration result
*/
export interface RegisterB2BResult {
user: BetterAuthUser;
organization: Organization;
token: string;
}
/**
* Invite employee result
*/
export interface InviteEmployeeResult {
id: string;
email: string;
organizationId: string;
role: OrganizationRole;
status: 'pending';
expiresAt: Date;
}
/**
* Accept invitation result
*/
export interface AcceptInvitationResult {
member: OrganizationMember;
organization?: Organization;
userId?: string;
}
/**
* Remove member result
*/
export interface RemoveMemberResult {
success: boolean;
message: string;
}
/**
* Set active organization result
* Returns session data with the active organization ID
*/
export interface SetActiveOrganizationResult {
userId: string;
activeOrganizationId: string;
metadata?: Record<string, unknown>;
session?: BetterAuthSession;
}
// =============================================================================
// DTO Types (for NestJS controllers)
// =============================================================================
/**
* DTO for B2C user registration
*/
export interface RegisterB2CDto {
email: string;
password: string;
name: string;
}
/**
* DTO for B2B organization registration
*/
export interface RegisterB2BDto {
ownerEmail: string;
password: string;
ownerName: string;
organizationName: string;
}
/**
* DTO for employee invitation
*/
export interface InviteEmployeeDto {
organizationId: string;
employeeEmail: string;
role: 'admin' | 'member';
inviterToken: string;
}
/**
* DTO for accepting invitation
*/
export interface AcceptInvitationDto {
invitationId: string;
userToken: string;
}
/**
* DTO for removing organization member
*/
export interface RemoveMemberDto {
organizationId: string;
memberId: string;
removerToken: string;
}
/**
* DTO for setting active organization
*/
export interface SetActiveOrganizationDto {
organizationId: string;
userToken: string;
}
/**
* DTO for user sign in
*/
export interface SignInDto {
email: string;
password: string;
deviceId?: string;
deviceName?: string;
}
/**
* Sign in result
*/
export interface SignInResult {
user: {
id: string;
email: string;
name: string | null;
role?: string;
};
accessToken: string;
refreshToken: string;
expiresIn: number;
}
/**
* DTO for sign out
*/
export interface SignOutDto {
token: string;
}
/**
* Sign out result
*/
export interface SignOutResult {
success: boolean;
message: string;
}
/**
* Get session result
*/
export interface GetSessionResult {
user: BetterAuthUser;
session: BetterAuthSession;
}
/**
* List user organizations result
*/
export interface ListOrganizationsResult {
organizations: Organization[];
}
/**
* DTO for refresh token
*/
export interface RefreshTokenDto {
refreshToken: string;
}
/**
* Refresh token result
*/
export interface RefreshTokenResult {
user: {
id: string;
email: string;
name: string | null;
role?: string;
};
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: string;
}
/**
* DTO for token validation
*/
export interface ValidateTokenDto {
token: string;
}
/**
* Token payload structure (JWT claims)
*/
export interface TokenPayload {
sub: string;
email: string;
role: string;
sessionId: string;
deviceId?: string;
organizationId?: string;
iat?: number;
exp?: number;
iss?: string;
aud?: string | string[];
}
/**
* Validate token result
*/
export interface ValidateTokenResult {
valid: boolean;
payload?: TokenPayload;
error?: string;
}
// =============================================================================
// Type Guards
// =============================================================================
/**
* Type guard to check if response has user property
*/
export function hasUser(response: unknown): response is { user: BetterAuthUser } {
return (
typeof response === 'object' &&
response !== null &&
'user' in response &&
typeof (response as { user: unknown }).user === 'object'
);
}
/**
* Type guard to check if response has token property
*/
export function hasToken(response: unknown): response is { token: string } {
return (
typeof response === 'object' &&
response !== null &&
'token' in response &&
typeof (response as { token: unknown }).token === 'string'
);
}
/**
* Type guard to check if response has member property
*/
export function hasMember(response: unknown): response is { member: OrganizationMember } {
return (
typeof response === 'object' &&
response !== null &&
'member' in response &&
typeof (response as { member: unknown }).member === 'object'
);
}
/**
* Type guard to check if response has members array
*/
export function hasMembers(response: unknown): response is { members: OrganizationMember[] } {
return (
typeof response === 'object' &&
response !== null &&
'members' in response &&
Array.isArray((response as { members: unknown }).members)
);
}
/**
* Type guard to check if response has session property
*/
export function hasSession(
response: unknown
): response is { user: BetterAuthUser; session: BetterAuthSession } {
return (
typeof response === 'object' &&
response !== null &&
'user' in response &&
'session' in response &&
typeof (response as { user: unknown }).user === 'object' &&
typeof (response as { session: unknown }).session === 'object'
);
}

View file

@ -0,0 +1,7 @@
/**
* Auth Types Index
*
* Re-exports all authentication-related types
*/
export * from './better-auth.types';

View file

@ -7,8 +7,9 @@ export default () => ({
},
jwt: {
publicKey: process.env.JWT_PUBLIC_KEY || '',
privateKey: process.env.JWT_PRIVATE_KEY || '',
// Convert \n string literals to actual newlines for PEM format
publicKey: (process.env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'),
privateKey: (process.env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'),
accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m',
refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d',
issuer: process.env.JWT_ISSUER || 'manacore',

View file

@ -0,0 +1,764 @@
/**
* CreditsController Unit Tests
*
* Tests all credits controller endpoints:
*
* B2C (Personal) Endpoints:
* - GET /credits/balance - Get user balance
* - POST /credits/use - Use credits
* - GET /credits/transactions - Get transaction history
* - GET /credits/purchases - Get purchase history
* - GET /credits/packages - Get available packages
*
* B2B (Organization) Endpoints:
* - POST /credits/organization/allocate - Allocate credits to employee
* - GET /credits/organization/:orgId/balance - Get org balance
* - GET /credits/organization/:orgId/employee/:empId/balance - Get employee balance
* - POST /credits/organization/:orgId/use - Use credits with org tracking
*/
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { CreditsController } from './credits.controller';
import { CreditsService } from './credits.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUserData } from '../common/decorators/current-user.decorator';
import {
mockBalanceFactory,
mockTransactionFactory,
mockPackageFactory,
mockPurchaseFactory,
mockOrganizationBalanceFactory,
mockDtoFactory,
} from '../__tests__/utils/mock-factories';
import { nanoid } from 'nanoid';
describe('CreditsController', () => {
let controller: CreditsController;
let creditsService: jest.Mocked<CreditsService>;
// Common test user data
const mockUser: CurrentUserData = {
userId: 'user-123',
email: 'user@example.com',
role: 'user',
};
const mockOrgOwner: CurrentUserData = {
userId: 'owner-456',
email: 'owner@company.com',
role: 'user',
};
beforeEach(async () => {
// Create mock CreditsService
const mockCreditsService = {
getBalance: jest.fn(),
useCredits: jest.fn(),
getTransactionHistory: jest.fn(),
getPurchaseHistory: jest.fn(),
getPackages: jest.fn(),
allocateCredits: jest.fn(),
getOrganizationBalance: jest.fn(),
getEmployeeCreditBalance: jest.fn(),
deductCredits: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [CreditsController],
providers: [
{
provide: CreditsService,
useValue: mockCreditsService,
},
],
})
// Override the guard to allow all requests in tests
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: jest.fn(() => true) })
.compile();
controller = module.get<CreditsController>(CreditsController);
creditsService = module.get(CreditsService);
});
afterEach(() => {
jest.clearAllMocks();
});
// ============================================================================
// B2C ENDPOINTS - Personal Credits
// ============================================================================
describe('B2C Endpoints', () => {
// --------------------------------------------------------------------------
// GET /credits/balance
// --------------------------------------------------------------------------
describe('GET /credits/balance', () => {
it('should return user balance', async () => {
const expectedBalance = mockBalanceFactory.withBalance(mockUser.userId, 500, 100);
creditsService.getBalance.mockResolvedValue(expectedBalance);
const result = await controller.getBalance(mockUser);
expect(result).toEqual(expectedBalance);
expect(creditsService.getBalance).toHaveBeenCalledWith(mockUser.userId);
});
it('should return zero balance for new user', async () => {
const newUserBalance = mockBalanceFactory.create(mockUser.userId, {
balance: 0,
freeCreditsRemaining: 150,
});
creditsService.getBalance.mockResolvedValue(newUserBalance);
const result = await controller.getBalance(mockUser);
expect(result.balance).toBe(0);
expect(result.freeCreditsRemaining).toBe(150);
});
it('should handle balance with daily free credits', async () => {
const balanceWithDailyCredits = mockBalanceFactory.create(mockUser.userId, {
balance: 100,
freeCreditsRemaining: 50,
dailyFreeCredits: 5,
});
creditsService.getBalance.mockResolvedValue(balanceWithDailyCredits);
const result = await controller.getBalance(mockUser);
expect(result.dailyFreeCredits).toBe(5);
});
});
// --------------------------------------------------------------------------
// POST /credits/use
// --------------------------------------------------------------------------
describe('POST /credits/use', () => {
it('should successfully use credits', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 10,
appId: 'memoro',
description: 'AI transcription',
});
const expectedResult = {
success: true,
transaction: mockTransactionFactory.create(mockUser.userId, {
amount: -10,
appId: 'memoro',
}),
newBalance: 90,
};
creditsService.useCredits.mockResolvedValue(expectedResult as any);
const result = await controller.useCredits(mockUser, useCreditsDto);
expect(result).toEqual(expectedResult);
expect(creditsService.useCredits).toHaveBeenCalledWith(mockUser.userId, useCreditsDto);
});
it('should pass idempotency key for duplicate prevention', async () => {
const idempotencyKey = `idempotency-${nanoid()}`;
const useCreditsDto = mockDtoFactory.useCredits({
amount: 25,
appId: 'chat',
description: 'Message generation',
idempotencyKey,
});
creditsService.useCredits.mockResolvedValue({ success: true } as any);
await controller.useCredits(mockUser, useCreditsDto);
expect(creditsService.useCredits).toHaveBeenCalledWith(
mockUser.userId,
expect.objectContaining({ idempotencyKey })
);
});
it('should propagate BadRequestException for insufficient credits', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 1000,
appId: 'picture',
description: 'Image generation',
});
creditsService.useCredits.mockRejectedValue(
new BadRequestException('Insufficient credits')
);
await expect(controller.useCredits(mockUser, useCreditsDto)).rejects.toThrow(
BadRequestException
);
});
it('should handle metadata in credit usage', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 5,
appId: 'wisekeep',
description: 'Video analysis',
metadata: {
videoId: 'vid-123',
duration: 120,
model: 'gpt-4',
},
});
creditsService.useCredits.mockResolvedValue({ success: true } as any);
await controller.useCredits(mockUser, useCreditsDto);
expect(creditsService.useCredits).toHaveBeenCalledWith(
mockUser.userId,
expect.objectContaining({
metadata: {
videoId: 'vid-123',
duration: 120,
model: 'gpt-4',
},
})
);
});
});
// --------------------------------------------------------------------------
// GET /credits/transactions
// --------------------------------------------------------------------------
describe('GET /credits/transactions', () => {
it('should return transaction history with default pagination', async () => {
const transactions = mockTransactionFactory.createMany(mockUser.userId, 5);
creditsService.getTransactionHistory.mockResolvedValue(transactions as any);
const result = await controller.getTransactionHistory(mockUser);
expect(result).toEqual(transactions);
expect(creditsService.getTransactionHistory).toHaveBeenCalledWith(
mockUser.userId,
undefined,
undefined
);
});
it('should pass limit parameter', async () => {
const limit = 10;
creditsService.getTransactionHistory.mockResolvedValue([]);
await controller.getTransactionHistory(mockUser, limit);
expect(creditsService.getTransactionHistory).toHaveBeenCalledWith(
mockUser.userId,
limit,
undefined
);
});
it('should pass offset parameter', async () => {
const limit = 20;
const offset = 40;
creditsService.getTransactionHistory.mockResolvedValue([]);
await controller.getTransactionHistory(mockUser, limit, offset);
expect(creditsService.getTransactionHistory).toHaveBeenCalledWith(
mockUser.userId,
limit,
offset
);
});
it('should return empty array for user with no transactions', async () => {
creditsService.getTransactionHistory.mockResolvedValue([]);
const result = await controller.getTransactionHistory(mockUser);
expect(result).toEqual([]);
});
});
// --------------------------------------------------------------------------
// GET /credits/purchases
// --------------------------------------------------------------------------
describe('GET /credits/purchases', () => {
it('should return purchase history', async () => {
const packageId = 'pkg-123';
const purchases = [
mockPurchaseFactory.create(mockUser.userId, packageId, {
credits: 100,
priceEuroCents: 100,
}),
mockPurchaseFactory.create(mockUser.userId, packageId, {
credits: 500,
priceEuroCents: 450,
}),
];
creditsService.getPurchaseHistory.mockResolvedValue(purchases as any);
const result = await controller.getPurchaseHistory(mockUser);
expect(result).toEqual(purchases);
expect(creditsService.getPurchaseHistory).toHaveBeenCalledWith(mockUser.userId);
});
it('should return empty array for user with no purchases', async () => {
creditsService.getPurchaseHistory.mockResolvedValue([]);
const result = await controller.getPurchaseHistory(mockUser);
expect(result).toEqual([]);
});
});
// --------------------------------------------------------------------------
// GET /credits/packages
// --------------------------------------------------------------------------
describe('GET /credits/packages', () => {
it('should return all available packages', async () => {
const packages = mockPackageFactory.createMany(3);
creditsService.getPackages.mockResolvedValue(packages);
const result = await controller.getPackages();
expect(result).toEqual(packages);
expect(creditsService.getPackages).toHaveBeenCalled();
});
it('should return only active packages', async () => {
const activePackages = mockPackageFactory.createMany(2).map((pkg) => ({
...pkg,
active: true,
}));
creditsService.getPackages.mockResolvedValue(activePackages);
const result = await controller.getPackages();
expect(result.every((pkg: any) => pkg.active === true)).toBe(true);
});
it('should return empty array when no packages available', async () => {
creditsService.getPackages.mockResolvedValue([]);
const result = await controller.getPackages();
expect(result).toEqual([]);
});
});
});
// ============================================================================
// B2B ENDPOINTS - Organization Credits
// ============================================================================
describe('B2B Endpoints', () => {
const organizationId = 'org-123';
const employeeId = 'emp-789';
// --------------------------------------------------------------------------
// POST /credits/organization/allocate
// --------------------------------------------------------------------------
describe('POST /credits/organization/allocate', () => {
it('should successfully allocate credits to employee', async () => {
const allocateDto = {
organizationId,
employeeId,
amount: 100,
reason: 'Monthly allocation',
};
const expectedResult = {
success: true,
allocation: {
id: 'alloc-123',
organizationId,
employeeId,
amount: 100,
allocatedBy: mockOrgOwner.userId,
},
newOrgBalance: 900,
newEmployeeBalance: 100,
};
creditsService.allocateCredits.mockResolvedValue(expectedResult as any);
const result = await controller.allocateCredits(mockOrgOwner, allocateDto);
expect(result).toEqual(expectedResult);
expect(creditsService.allocateCredits).toHaveBeenCalledWith(
mockOrgOwner.userId,
allocateDto
);
});
it('should propagate ForbiddenException for non-owners', async () => {
const allocateDto = {
organizationId,
employeeId,
amount: 50,
};
creditsService.allocateCredits.mockRejectedValue(
new ForbiddenException('Only organization owners can allocate credits')
);
await expect(controller.allocateCredits(mockUser, allocateDto)).rejects.toThrow(
ForbiddenException
);
});
it('should propagate BadRequestException for insufficient org credits', async () => {
const allocateDto = {
organizationId,
employeeId,
amount: 10000,
};
creditsService.allocateCredits.mockRejectedValue(
new BadRequestException('Insufficient organization credits')
);
await expect(controller.allocateCredits(mockOrgOwner, allocateDto)).rejects.toThrow(
BadRequestException
);
});
it('should pass optional reason parameter', async () => {
const allocateDto = {
organizationId,
employeeId,
amount: 200,
reason: 'Bonus for project completion',
};
creditsService.allocateCredits.mockResolvedValue({ success: true } as any);
await controller.allocateCredits(mockOrgOwner, allocateDto);
expect(creditsService.allocateCredits).toHaveBeenCalledWith(
mockOrgOwner.userId,
expect.objectContaining({ reason: 'Bonus for project completion' })
);
});
});
// --------------------------------------------------------------------------
// GET /credits/organization/:organizationId/balance
// --------------------------------------------------------------------------
describe('GET /credits/organization/:organizationId/balance', () => {
it('should return organization balance', async () => {
const expectedBalance = mockOrganizationBalanceFactory.withBalance(
organizationId,
1000,
300
);
creditsService.getOrganizationBalance.mockResolvedValue(expectedBalance as any);
const result = await controller.getOrganizationBalance(organizationId);
expect(result).toEqual(expectedBalance);
expect(creditsService.getOrganizationBalance).toHaveBeenCalledWith(organizationId);
});
it('should return balance breakdown with allocations', async () => {
const orgBalance = mockOrganizationBalanceFactory.create(organizationId, {
balance: 5000,
allocatedCredits: 2000,
availableCredits: 3000,
totalPurchased: 6000,
totalAllocated: 3500,
});
creditsService.getOrganizationBalance.mockResolvedValue(orgBalance as any);
const result = await controller.getOrganizationBalance(organizationId);
expect(result.balance).toBe(5000);
expect(result.allocatedCredits).toBe(2000);
expect(result.availableCredits).toBe(3000);
});
it('should propagate NotFoundException for non-existent org', async () => {
creditsService.getOrganizationBalance.mockRejectedValue(
new NotFoundException('Organization not found')
);
await expect(controller.getOrganizationBalance('non-existent-org')).rejects.toThrow(
NotFoundException
);
});
});
// --------------------------------------------------------------------------
// GET /credits/organization/:organizationId/employee/:employeeId/balance
// --------------------------------------------------------------------------
describe('GET /credits/organization/:organizationId/employee/:employeeId/balance', () => {
it('should return employee balance within organization', async () => {
const expectedBalance = {
employeeId,
organizationId,
balance: 250,
allocatedTotal: 500,
usedTotal: 250,
};
creditsService.getEmployeeCreditBalance.mockResolvedValue(expectedBalance as any);
const result = await controller.getEmployeeBalance(organizationId, employeeId);
expect(result).toEqual(expectedBalance);
expect(creditsService.getEmployeeCreditBalance).toHaveBeenCalledWith(
employeeId,
organizationId
);
});
it('should return zero for employee with no allocations', async () => {
const zeroBalance = {
employeeId,
organizationId,
balance: 0,
allocatedTotal: 0,
usedTotal: 0,
};
creditsService.getEmployeeCreditBalance.mockResolvedValue(zeroBalance as any);
const result = await controller.getEmployeeBalance(organizationId, employeeId);
expect(result!.balance).toBe(0);
});
it('should propagate NotFoundException for non-existent employee', async () => {
creditsService.getEmployeeCreditBalance.mockRejectedValue(
new NotFoundException('Employee not found in organization')
);
await expect(
controller.getEmployeeBalance(organizationId, 'non-existent-emp')
).rejects.toThrow(NotFoundException);
});
});
// --------------------------------------------------------------------------
// POST /credits/organization/:organizationId/use
// --------------------------------------------------------------------------
describe('POST /credits/organization/:organizationId/use', () => {
it('should deduct credits with organization tracking', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 15,
appId: 'chat',
description: 'Team chat usage',
});
const expectedResult = {
success: true,
transaction: mockTransactionFactory.create(mockUser.userId, {
amount: -15,
organizationId,
}),
newBalance: 85,
};
creditsService.deductCredits.mockResolvedValue(expectedResult as any);
const result = await controller.deductCreditsWithOrgTracking(
mockUser,
organizationId,
useCreditsDto
);
expect(result).toEqual(expectedResult);
expect(creditsService.deductCredits).toHaveBeenCalledWith(
mockUser.userId,
useCreditsDto,
organizationId
);
});
it('should track organization ID in transaction', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 20,
appId: 'picture',
description: 'Image generation for team',
});
creditsService.deductCredits.mockResolvedValue({ success: true } as any);
await controller.deductCreditsWithOrgTracking(mockUser, organizationId, useCreditsDto);
expect(creditsService.deductCredits).toHaveBeenCalledWith(
mockUser.userId,
useCreditsDto,
organizationId
);
});
it('should propagate BadRequestException for insufficient employee credits', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 500,
appId: 'wisekeep',
description: 'Video analysis',
});
creditsService.deductCredits.mockRejectedValue(
new BadRequestException('Insufficient credits')
);
await expect(
controller.deductCreditsWithOrgTracking(mockUser, organizationId, useCreditsDto)
).rejects.toThrow(BadRequestException);
});
it('should handle idempotency for organization credit usage', async () => {
const idempotencyKey = `org-usage-${nanoid()}`;
const useCreditsDto = mockDtoFactory.useCredits({
amount: 30,
appId: 'memoro',
description: 'Voice transcription',
idempotencyKey,
});
creditsService.deductCredits.mockResolvedValue({ success: true } as any);
await controller.deductCreditsWithOrgTracking(mockUser, organizationId, useCreditsDto);
expect(creditsService.deductCredits).toHaveBeenCalledWith(
mockUser.userId,
expect.objectContaining({ idempotencyKey }),
organizationId
);
});
});
});
// ============================================================================
// Guard Tests
// ============================================================================
describe('Guards', () => {
it('should have JwtAuthGuard applied at class level', async () => {
const guards = Reflect.getMetadata('__guards__', CreditsController);
expect(guards).toBeDefined();
expect(guards).toContain(JwtAuthGuard);
});
it('should require authentication for all endpoints', () => {
// All credits endpoints require authentication
// This is handled at the class level with @UseGuards(JwtAuthGuard)
const classGuards = Reflect.getMetadata('__guards__', CreditsController);
expect(classGuards).toContain(JwtAuthGuard);
});
});
// ============================================================================
// Error Handling
// ============================================================================
describe('Error Handling', () => {
it('should propagate service errors correctly', async () => {
const error = new Error('Database connection failed');
creditsService.getBalance.mockRejectedValue(error);
await expect(controller.getBalance(mockUser)).rejects.toThrow('Database connection failed');
});
it('should handle concurrent request errors', async () => {
const useCreditsDto = mockDtoFactory.useCredits({ amount: 10 });
creditsService.useCredits.mockRejectedValue(
new BadRequestException('Concurrent modification detected, please retry')
);
await expect(controller.useCredits(mockUser, useCreditsDto)).rejects.toThrow(
BadRequestException
);
});
it('should handle validation errors in allocation', async () => {
const invalidDto = {
organizationId: '',
employeeId: 'emp-123',
amount: -100, // Invalid negative amount
};
creditsService.allocateCredits.mockRejectedValue(
new BadRequestException('Amount must be positive')
);
await expect(controller.allocateCredits(mockOrgOwner, invalidDto)).rejects.toThrow(
BadRequestException
);
});
});
// ============================================================================
// Edge Cases
// ============================================================================
describe('Edge Cases', () => {
it('should handle zero credit usage', async () => {
const useCreditsDto = mockDtoFactory.useCredits({ amount: 0 });
creditsService.useCredits.mockRejectedValue(
new BadRequestException('Amount must be greater than zero')
);
await expect(controller.useCredits(mockUser, useCreditsDto)).rejects.toThrow(
BadRequestException
);
});
it('should handle very large credit amounts', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 999999999,
appId: 'test',
description: 'Large transaction',
});
creditsService.useCredits.mockRejectedValue(new BadRequestException('Amount exceeds limit'));
await expect(controller.useCredits(mockUser, useCreditsDto)).rejects.toThrow(
BadRequestException
);
});
it('should handle special characters in description', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 5,
appId: 'chat',
description: 'Test with émojis 🎉 and "quotes"',
});
creditsService.useCredits.mockResolvedValue({ success: true } as any);
await controller.useCredits(mockUser, useCreditsDto);
expect(creditsService.useCredits).toHaveBeenCalledWith(
mockUser.userId,
expect.objectContaining({
description: 'Test with émojis 🎉 and "quotes"',
})
);
});
});
});

View file

@ -1,14 +1,19 @@
import { Controller, Get, Post, Body, UseGuards, Query, ParseIntPipe } from '@nestjs/common';
import { Controller, Get, Post, Body, UseGuards, Query, ParseIntPipe, Param } from '@nestjs/common';
import { CreditsService } from './credits.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
import { UseCreditsDto } from './dto/use-credits.dto';
import { AllocateCreditsDto } from './dto/allocate-credits.dto';
@Controller('credits')
@UseGuards(JwtAuthGuard)
export class CreditsController {
constructor(private readonly creditsService: CreditsService) {}
// ============================================================================
// PERSONAL / B2C ENDPOINTS
// ============================================================================
@Get('balance')
async getBalance(@CurrentUser() user: CurrentUserData) {
return this.creditsService.getBalance(user.userId);
@ -37,4 +42,51 @@ export class CreditsController {
async getPackages() {
return this.creditsService.getPackages();
}
// ============================================================================
// ORGANIZATION / B2B ENDPOINTS
// ============================================================================
/**
* Allocate credits from organization to employee
* Only organization owners can allocate credits
*/
@Post('organization/allocate')
async allocateCredits(
@CurrentUser() user: CurrentUserData,
@Body() allocateDto: AllocateCreditsDto
) {
return this.creditsService.allocateCredits(user.userId, allocateDto);
}
/**
* Get organization credit balance and allocation stats
*/
@Get('organization/:organizationId/balance')
async getOrganizationBalance(@Param('organizationId') organizationId: string) {
return this.creditsService.getOrganizationBalance(organizationId);
}
/**
* Get employee's credit balance within an organization context
*/
@Get('organization/:organizationId/employee/:employeeId/balance')
async getEmployeeBalance(
@Param('organizationId') organizationId: string,
@Param('employeeId') employeeId: string
) {
return this.creditsService.getEmployeeCreditBalance(employeeId, organizationId);
}
/**
* Deduct credits with organization tracking (for B2B usage)
*/
@Post('organization/:organizationId/use')
async deductCreditsWithOrgTracking(
@CurrentUser() user: CurrentUserData,
@Param('organizationId') organizationId: string,
@Body() useCreditsDto: UseCreditsDto
) {
return this.creditsService.deductCredits(user.userId, useCreditsDto, organizationId);
}
}

File diff suppressed because it is too large Load diff

View file

@ -3,12 +3,24 @@ import {
BadRequestException,
NotFoundException,
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, sql, desc } from 'drizzle-orm';
import { eq, and, sql, desc, sum } from 'drizzle-orm';
import { getDb } from '../db/connection';
import { balances, transactions, purchases, packages, usageStats } from '../db/schema';
import {
balances,
transactions,
purchases,
packages,
usageStats,
organizationBalances,
creditAllocations,
members,
organizations,
} from '../db/schema';
import { UseCreditsDto } from './dto/use-credits.dto';
import { AllocateCreditsDto } from './dto/allocate-credits.dto';
@Injectable()
export class CreditsService {
@ -269,4 +281,405 @@ export class CreditsService {
});
}
}
// ============================================================================
// ORGANIZATION CREDIT METHODS (B2B)
// ============================================================================
/**
* Create organization credit balance
* Called when a new organization is created
*/
async createOrganizationCreditBalance(organizationId: string) {
const db = this.getDb();
// Check if balance already exists
const [existingBalance] = await db
.select()
.from(organizationBalances)
.where(eq(organizationBalances.organizationId, organizationId))
.limit(1);
if (existingBalance) {
return existingBalance;
}
// Create initial balance
const [balance] = await db
.insert(organizationBalances)
.values({
organizationId,
balance: 0,
allocatedCredits: 0,
availableCredits: 0,
totalPurchased: 0,
totalAllocated: 0,
})
.returning();
return balance;
}
/**
* Create personal credit balance (B2C user)
* Alias for initializeUserBalance for clarity
*/
async createPersonalCreditBalance(userId: string) {
return this.initializeUserBalance(userId);
}
/**
* Allocate credits from organization to employee
* Only organization owners can allocate credits
*/
async allocateCredits(allocatorUserId: string, allocateDto: AllocateCreditsDto) {
const db = this.getDb();
const { organizationId, employeeId, amount, reason } = allocateDto;
return await db.transaction(async (tx) => {
// 1. Verify allocator has 'owner' role in the organization
const [member] = await tx
.select()
.from(members)
.where(
and(
eq(members.organizationId, organizationId),
eq(members.userId, allocatorUserId)
)
)
.limit(1);
if (!member || member.role !== 'owner') {
throw new ForbiddenException(
'Only organization owners can allocate credits'
);
}
// 2. Get organization balance with row lock
const [orgBalance] = await tx
.select()
.from(organizationBalances)
.where(eq(organizationBalances.organizationId, organizationId))
.for('update')
.limit(1);
if (!orgBalance) {
throw new NotFoundException('Organization balance not found');
}
// 3. Check if organization has sufficient available credits
if (orgBalance.availableCredits < amount) {
throw new BadRequestException(
`Insufficient organization credits. Available: ${orgBalance.availableCredits}, Requested: ${amount}`
);
}
// 4. Get or create employee balance with row lock
let employeeBalance = await tx
.select()
.from(balances)
.where(eq(balances.userId, employeeId))
.for('update')
.limit(1)
.then((rows) => rows[0]);
if (!employeeBalance) {
// Initialize employee balance within the transaction
const signupBonus = this.configService.get<number>('credits.signupBonus') || 150;
const dailyFreeCredits = this.configService.get<number>('credits.dailyFreeCredits') || 5;
const [newBalance] = await tx
.insert(balances)
.values({
userId: employeeId,
balance: 0,
freeCreditsRemaining: signupBonus,
dailyFreeCredits,
lastDailyResetAt: new Date(),
})
.returning();
employeeBalance = newBalance;
}
const currentEmployeeBalance = employeeBalance.balance;
const newEmployeeBalance = currentEmployeeBalance + amount;
// 5. Update organization balance
const newAllocatedCredits = orgBalance.allocatedCredits + amount;
const newAvailableCredits = orgBalance.balance - newAllocatedCredits;
const updateOrgResult = await tx
.update(organizationBalances)
.set({
allocatedCredits: newAllocatedCredits,
availableCredits: newAvailableCredits,
totalAllocated: orgBalance.totalAllocated + amount,
version: orgBalance.version + 1,
updatedAt: new Date(),
})
.where(
and(
eq(organizationBalances.organizationId, organizationId),
eq(organizationBalances.version, orgBalance.version)
)
)
.returning();
if (updateOrgResult.length === 0) {
throw new ConflictException(
'Organization balance was modified by another transaction. Please retry.'
);
}
// 6. Update employee balance
const updateEmployeeResult = await tx
.update(balances)
.set({
balance: newEmployeeBalance,
totalEarned: employeeBalance.totalEarned + amount,
version: employeeBalance.version + 1,
updatedAt: new Date(),
})
.where(
and(
eq(balances.userId, employeeId),
eq(balances.version, employeeBalance.version)
)
)
.returning();
if (updateEmployeeResult.length === 0) {
throw new ConflictException(
'Employee balance was modified by another transaction. Please retry.'
);
}
// 7. Create allocation record (audit trail)
const [allocation] = await tx
.insert(creditAllocations)
.values({
organizationId,
employeeId,
amount,
allocatedBy: allocatorUserId,
reason: reason || 'Credit allocation',
balanceBefore: currentEmployeeBalance,
balanceAfter: newEmployeeBalance,
})
.returning();
// 8. Create transaction record for employee
await tx.insert(transactions).values({
userId: employeeId,
type: 'bonus',
status: 'completed',
amount,
balanceBefore: currentEmployeeBalance,
balanceAfter: newEmployeeBalance,
appId: 'organization',
description: `Credit allocation from organization: ${reason || 'N/A'}`,
organizationId,
completedAt: new Date(),
});
return {
success: true,
allocation,
organizationBalance: {
balance: orgBalance.balance,
allocatedCredits: newAllocatedCredits,
availableCredits: newAvailableCredits,
},
employeeBalance: {
balance: newEmployeeBalance,
},
};
});
}
/**
* Get employee's credit balance (allocated from organization)
* Returns the employee's personal balance
*/
async getEmployeeCreditBalance(userId: string, organizationId?: string) {
const db = this.getDb();
// Get employee's personal balance
const [balance] = await db
.select()
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
if (!balance) {
return null;
}
return {
balance: balance.balance,
freeCreditsRemaining: balance.freeCreditsRemaining,
totalEarned: balance.totalEarned,
totalSpent: balance.totalSpent,
};
}
/**
* Get personal credit balance (B2C user)
* Alias for getBalance for clarity
*/
async getPersonalCreditBalance(userId: string) {
return this.getBalance(userId);
}
/**
* Get organization balance and allocation statistics
*/
async getOrganizationBalance(organizationId: string) {
const db = this.getDb();
// Get organization balance
const [orgBalance] = await db
.select()
.from(organizationBalances)
.where(eq(organizationBalances.organizationId, organizationId))
.limit(1);
if (!orgBalance) {
throw new NotFoundException('Organization balance not found');
}
// Get allocation statistics
const allocations = await db
.select()
.from(creditAllocations)
.where(eq(creditAllocations.organizationId, organizationId))
.orderBy(desc(creditAllocations.createdAt))
.limit(10); // Last 10 allocations
return {
balance: orgBalance.balance,
allocatedCredits: orgBalance.allocatedCredits,
availableCredits: orgBalance.availableCredits,
totalPurchased: orgBalance.totalPurchased,
totalAllocated: orgBalance.totalAllocated,
recentAllocations: allocations,
};
}
/**
* Deduct credits with organization tracking
* Enhanced version of useCredits that tracks organization_id for B2B users
*/
async deductCredits(
userId: string,
useCreditsDto: UseCreditsDto,
organizationId?: string
) {
const db = this.getDb();
// Check for idempotency
if (useCreditsDto.idempotencyKey) {
const [existingTransaction] = await db
.select()
.from(transactions)
.where(eq(transactions.idempotencyKey, useCreditsDto.idempotencyKey))
.limit(1);
if (existingTransaction) {
return {
success: true,
transaction: existingTransaction,
message: 'Transaction already processed',
};
}
}
// Use a transaction for atomicity
return await db.transaction(async (tx) => {
// Get current balance with row lock
const [currentBalance] = await tx
.select()
.from(balances)
.where(eq(balances.userId, userId))
.for('update')
.limit(1);
if (!currentBalance) {
throw new NotFoundException('User balance not found');
}
const totalAvailable = currentBalance.balance + currentBalance.freeCreditsRemaining;
if (totalAvailable < useCreditsDto.amount) {
throw new BadRequestException('Insufficient credits');
}
// Calculate deduction from free and paid credits
let freeCreditsUsed = Math.min(useCreditsDto.amount, currentBalance.freeCreditsRemaining);
let paidCreditsUsed = useCreditsDto.amount - freeCreditsUsed;
const newFreeCredits = currentBalance.freeCreditsRemaining - freeCreditsUsed;
const newBalance = currentBalance.balance - paidCreditsUsed;
const newTotalSpent = currentBalance.totalSpent + useCreditsDto.amount;
// Update balance
const updateResult = await tx
.update(balances)
.set({
balance: newBalance,
freeCreditsRemaining: newFreeCredits,
totalSpent: newTotalSpent,
version: currentBalance.version + 1,
updatedAt: new Date(),
})
.where(and(eq(balances.userId, userId), eq(balances.version, currentBalance.version)))
.returning();
if (updateResult.length === 0) {
throw new ConflictException('Balance was modified by another transaction. Please retry.');
}
// Create transaction record with organization_id
const [transaction] = await tx
.insert(transactions)
.values({
userId,
type: 'usage',
status: 'completed',
amount: -useCreditsDto.amount,
balanceBefore: currentBalance.balance + currentBalance.freeCreditsRemaining,
balanceAfter: newBalance + newFreeCredits,
appId: useCreditsDto.appId,
description: useCreditsDto.description,
organizationId: organizationId || null, // Track organization for B2B
metadata: useCreditsDto.metadata,
idempotencyKey: useCreditsDto.idempotencyKey,
completedAt: new Date(),
})
.returning();
// Track usage stats
const today = new Date();
today.setHours(0, 0, 0, 0);
await tx.insert(usageStats).values({
userId,
appId: useCreditsDto.appId,
creditsUsed: useCreditsDto.amount,
date: today,
metadata: useCreditsDto.metadata,
});
return {
success: true,
transaction,
newBalance: {
balance: newBalance,
freeCreditsRemaining: newFreeCredits,
totalSpent: newTotalSpent,
},
};
});
}
}

View file

@ -0,0 +1,17 @@
import { IsUUID, IsInt, IsString, IsOptional, Min } from 'class-validator';
export class AllocateCreditsDto {
@IsString()
organizationId: string;
@IsUUID()
employeeId: string;
@IsInt()
@Min(1)
amount: number;
@IsString()
@IsOptional()
reason?: string;
}

View file

@ -1,29 +0,0 @@
import { config } from 'dotenv';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { getDb, closeConnection } from './connection';
// Load environment variables
config();
async function runMigrations() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
console.log('Running migrations...');
try {
const db = getDb(databaseUrl);
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations completed successfully');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
await closeConnection();
}
}
runMigrations();

View file

@ -1,179 +0,0 @@
CREATE SCHEMA "auth";
--> statement-breakpoint
CREATE SCHEMA "credits";
--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('user', 'admin', 'service');--> statement-breakpoint
CREATE TYPE "public"."transaction_status" AS ENUM('pending', 'completed', 'failed', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."transaction_type" AS ENUM('purchase', 'usage', 'refund', 'bonus', 'expiry', 'adjustment');--> statement-breakpoint
CREATE TABLE "auth"."accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"provider" text NOT NULL,
"provider_account_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"expires_at" timestamp with time zone,
"token_type" text,
"scope" text,
"id_token" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."passwords" (
"user_id" uuid PRIMARY KEY NOT NULL,
"hashed_password" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."security_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid,
"event_type" text NOT NULL,
"ip_address" text,
"user_agent" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token" text NOT NULL,
"refresh_token" text NOT NULL,
"refresh_token_expires_at" timestamp with time zone NOT NULL,
"ip_address" text,
"user_agent" text,
"device_id" text,
"device_name" text,
"last_activity_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"revoked_at" timestamp with time zone,
CONSTRAINT "sessions_token_unique" UNIQUE("token"),
CONSTRAINT "sessions_refresh_token_unique" UNIQUE("refresh_token")
);
--> statement-breakpoint
CREATE TABLE "auth"."two_factor_auth" (
"user_id" uuid PRIMARY KEY NOT NULL,
"secret" text NOT NULL,
"enabled" boolean DEFAULT false NOT NULL,
"backup_codes" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"enabled_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "auth"."users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"name" text,
"avatar_url" text,
"role" "user_role" DEFAULT 'user' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "auth"."verification_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token" text NOT NULL,
"type" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"used_at" timestamp with time zone,
CONSTRAINT "verification_tokens_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "credits"."balances" (
"user_id" uuid PRIMARY KEY NOT NULL,
"balance" integer DEFAULT 0 NOT NULL,
"free_credits_remaining" integer DEFAULT 150 NOT NULL,
"daily_free_credits" integer DEFAULT 5 NOT NULL,
"last_daily_reset_at" timestamp with time zone DEFAULT now(),
"total_earned" integer DEFAULT 0 NOT NULL,
"total_spent" integer DEFAULT 0 NOT NULL,
"version" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "credits"."packages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"credits" integer NOT NULL,
"price_euro_cents" integer NOT NULL,
"stripe_price_id" text,
"active" boolean DEFAULT true NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "packages_stripe_price_id_unique" UNIQUE("stripe_price_id")
);
--> statement-breakpoint
CREATE TABLE "credits"."purchases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"package_id" uuid,
"credits" integer NOT NULL,
"price_euro_cents" integer NOT NULL,
"stripe_payment_intent_id" text,
"stripe_customer_id" text,
"status" "transaction_status" DEFAULT 'pending' NOT NULL,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
CONSTRAINT "purchases_stripe_payment_intent_id_unique" UNIQUE("stripe_payment_intent_id")
);
--> statement-breakpoint
CREATE TABLE "credits"."transactions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"type" "transaction_type" NOT NULL,
"status" "transaction_status" DEFAULT 'pending' NOT NULL,
"amount" integer NOT NULL,
"balance_before" integer NOT NULL,
"balance_after" integer NOT NULL,
"app_id" text NOT NULL,
"description" text NOT NULL,
"metadata" jsonb,
"idempotency_key" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
CONSTRAINT "transactions_idempotency_key_unique" UNIQUE("idempotency_key")
);
--> statement-breakpoint
CREATE TABLE "credits"."usage_stats" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"app_id" text NOT NULL,
"credits_used" integer NOT NULL,
"date" timestamp with time zone NOT NULL,
"metadata" jsonb
);
--> statement-breakpoint
ALTER TABLE "auth"."accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."passwords" ADD CONSTRAINT "passwords_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."security_events" ADD CONSTRAINT "security_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."two_factor_auth" ADD CONSTRAINT "two_factor_auth_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."verification_tokens" ADD CONSTRAINT "verification_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."balances" ADD CONSTRAINT "balances_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."purchases" ADD CONSTRAINT "purchases_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."purchases" ADD CONSTRAINT "purchases_package_id_packages_id_fk" FOREIGN KEY ("package_id") REFERENCES "credits"."packages"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."transactions" ADD CONSTRAINT "transactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."usage_stats" ADD CONSTRAINT "usage_stats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "purchases_user_id_idx" ON "credits"."purchases" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "purchases_stripe_payment_intent_id_idx" ON "credits"."purchases" USING btree ("stripe_payment_intent_id");--> statement-breakpoint
CREATE INDEX "transactions_user_id_idx" ON "credits"."transactions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "transactions_app_id_idx" ON "credits"."transactions" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "transactions_created_at_idx" ON "credits"."transactions" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "transactions_idempotency_key_idx" ON "credits"."transactions" USING btree ("idempotency_key");--> statement-breakpoint
CREATE INDEX "usage_stats_user_id_date_idx" ON "credits"."usage_stats" USING btree ("user_id","date");--> statement-breakpoint
CREATE INDEX "usage_stats_app_id_date_idx" ON "credits"."usage_stats" USING btree ("app_id","date");

View file

@ -1,20 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764089133415,
"tag": "0000_lush_ironclad",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1764448681401,
"tag": "0001_zippy_ma_gnuci",
"breakpoints": true
}
]
}

View file

@ -1,78 +1,83 @@
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum, index } from 'drizzle-orm/pg-core';
export const authSchema = pgSchema('auth');
// Enum for user roles
export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'service']);
// Users table
// Users table (Better Auth schema)
export const users = authSchema.table('users', {
id: uuid('id').primaryKey().defaultRandom(),
id: text('id').primaryKey(), // Better Auth generates nanoid
name: text('name').notNull(),
email: text('email').unique().notNull(),
emailVerified: boolean('email_verified').default(false).notNull(),
name: text('name'),
avatarUrl: text('avatar_url'),
role: userRoleEnum('role').default('user').notNull(),
image: text('image'), // Better Auth uses 'image' not 'avatarUrl'
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
// Custom fields (not required by Better Auth)
role: userRoleEnum('role').default('user').notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
});
// Sessions table
// Sessions table (Better Auth schema)
export const sessions = authSchema.table('sessions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
id: text('id').primaryKey(), // Better Auth generates nanoid
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
token: text('token').unique().notNull(),
refreshToken: text('refresh_token').unique().notNull(),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
// Custom fields (not required by Better Auth)
refreshToken: text('refresh_token').unique(),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
deviceId: text('device_id'),
deviceName: text('device_name'),
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow(),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
});
// Accounts table (for OAuth providers)
// Accounts table (for OAuth providers and credentials - Better Auth schema)
export const accounts = authSchema.table('accounts', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
id: text('id').primaryKey(), // Better Auth generates nanoid
accountId: text('account_id').notNull(), // Better Auth field
providerId: text('provider_id').notNull(), // Better Auth field (was 'provider')
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
provider: text('provider').notNull(), // 'google', 'github', 'apple', etc.
providerAccountId: text('provider_account_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
expiresAt: timestamp('expires_at', { withTimezone: true }),
tokenType: text('token_type'),
scope: text('scope'),
idToken: text('id_token'),
metadata: jsonb('metadata'),
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
scope: text('scope'),
password: text('password'), // Better Auth stores hashed password here for credential provider
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Verification tokens (for email verification, password reset)
export const verificationTokens = authSchema.table('verification_tokens', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
token: text('token').unique().notNull(),
type: text('type').notNull(), // 'email_verification', 'password_reset'
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
});
// Verification table (Better Auth schema - for email verification, password reset)
export const verificationTokens = authSchema.table(
'verification',
{
id: text('id').primaryKey(), // Better Auth generates nanoid
identifier: text('identifier').notNull(), // Better Auth uses identifier (e.g., email)
value: text('value').notNull(), // Better Auth uses value (the token)
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
identifierIdx: index('verification_identifier_idx').on(table.identifier),
})
);
// Password table (separate for security)
export const passwords = authSchema.table('passwords', {
userId: uuid('user_id')
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
hashedPassword: text('hashed_password').notNull(),
@ -82,23 +87,31 @@ export const passwords = authSchema.table('passwords', {
// Two-factor authentication
export const twoFactorAuth = authSchema.table('two_factor_auth', {
userId: uuid('user_id')
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
secret: text('secret').notNull(),
enabled: boolean('enabled').default(false).notNull(),
backupCodes: jsonb('backup_codes'), // Array of hashed backup codes
backupCodes: jsonb('backup_codes'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
enabledAt: timestamp('enabled_at', { withTimezone: true }),
});
// Security events log
export const securityEvents = authSchema.table('security_events', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(), // 'login', 'logout', 'password_reset', 'suspicious_activity'
id: uuid('id').primaryKey().defaultRandom(), // Our table, can keep UUID
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
// JWKS table (Better Auth JWT plugin - stores signing keys)
export const jwks = authSchema.table('jwks', {
id: text('id').primaryKey(),
publicKey: text('public_key').notNull(),
privateKey: text('private_key').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

View file

@ -10,6 +10,7 @@ import {
boolean,
} from 'drizzle-orm/pg-core';
import { users } from './auth.schema';
import { organizations } from './organizations.schema';
export const creditsSchema = pgSchema('credits');
@ -33,7 +34,7 @@ export const transactionStatusEnum = pgEnum('transaction_status', [
// Credit balances (one per user)
export const balances = creditsSchema.table('balances', {
userId: uuid('user_id')
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
balance: integer('balance').default(0).notNull(),
@ -42,7 +43,7 @@ export const balances = creditsSchema.table('balances', {
lastDailyResetAt: timestamp('last_daily_reset_at', { withTimezone: true }).defaultNow(),
totalEarned: integer('total_earned').default(0).notNull(),
totalSpent: integer('total_spent').default(0).notNull(),
version: integer('version').default(0).notNull(), // For optimistic locking
version: integer('version').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
@ -52,7 +53,7 @@ export const transactions = creditsSchema.table(
'transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
type: transactionTypeEnum('type').notNull(),
@ -60,9 +61,10 @@ export const transactions = creditsSchema.table(
amount: integer('amount').notNull(),
balanceBefore: integer('balance_before').notNull(),
balanceAfter: integer('balance_after').notNull(),
appId: text('app_id').notNull(), // 'memoro', 'chat', 'picture', etc.
appId: text('app_id').notNull(),
description: text('description').notNull(),
metadata: jsonb('metadata'), // Additional context
organizationId: text('organization_id').references(() => organizations.id),
metadata: jsonb('metadata'),
idempotencyKey: text('idempotency_key').unique(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
@ -70,6 +72,7 @@ export const transactions = creditsSchema.table(
(table) => ({
userIdIdx: index('transactions_user_id_idx').on(table.userId),
appIdIdx: index('transactions_app_id_idx').on(table.appId),
organizationIdIdx: index('transactions_organization_id_idx').on(table.organizationId),
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
})
@ -80,8 +83,8 @@ export const packages = creditsSchema.table('packages', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
credits: integer('credits').notNull(), // Number of credits
priceEuroCents: integer('price_euro_cents').notNull(), // Price in euro cents
credits: integer('credits').notNull(),
priceEuroCents: integer('price_euro_cents').notNull(),
stripePriceId: text('stripe_price_id').unique(),
active: boolean('active').default(true).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
@ -95,7 +98,7 @@ export const purchases = creditsSchema.table(
'purchases',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
packageId: uuid('package_id').references(() => packages.id),
@ -121,7 +124,7 @@ export const usageStats = creditsSchema.table(
'usage_stats',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
appId: text('app_id').notNull(),
@ -134,3 +137,47 @@ export const usageStats = creditsSchema.table(
appIdDateIdx: index('usage_stats_app_id_date_idx').on(table.appId, table.date),
})
);
// Organization credit balances (B2B)
export const organizationBalances = creditsSchema.table('organization_balances', {
organizationId: text('organization_id')
.primaryKey()
.references(() => organizations.id, { onDelete: 'cascade' }),
balance: integer('balance').default(0).notNull(),
allocatedCredits: integer('allocated_credits').default(0).notNull(),
availableCredits: integer('available_credits').default(0).notNull(),
totalPurchased: integer('total_purchased').default(0).notNull(),
totalAllocated: integer('total_allocated').default(0).notNull(),
version: integer('version').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Credit allocations (B2B - tracking allocations from org to employees)
export const creditAllocations = creditsSchema.table(
'credit_allocations',
{
id: uuid('id').primaryKey().defaultRandom(),
organizationId: text('organization_id')
.references(() => organizations.id, { onDelete: 'cascade' })
.notNull(),
employeeId: text('employee_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
amount: integer('amount').notNull(),
allocatedBy: text('allocated_by')
.references(() => users.id)
.notNull(),
reason: text('reason'),
balanceBefore: integer('balance_before').notNull(),
balanceAfter: integer('balance_after').notNull(),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
organizationIdIdx: index('credit_allocations_organization_id_idx').on(table.organizationId),
employeeIdIdx: index('credit_allocations_employee_id_idx').on(table.employeeId),
allocatedByIdx: index('credit_allocations_allocated_by_idx').on(table.allocatedBy),
createdAtIdx: index('credit_allocations_created_at_idx').on(table.createdAt),
})
);

View file

@ -1,3 +1,4 @@
export * from './auth.schema';
export * from './credits.schema';
export * from './feedback.schema';
export * from './organizations.schema';

View file

@ -0,0 +1,72 @@
import { pgSchema, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
import { authSchema, users } from './auth.schema';
/**
* Better Auth Organization Tables
* These tables follow Better Auth's organization plugin schema requirements
* @see https://www.better-auth.com/docs/plugins/organization
*
* Note: Better Auth uses TEXT for IDs (nanoid/ULID), but we use UUID for users.
* The foreign key constraints will be added via raw SQL migration to handle the type difference.
*/
// Organizations table
export const organizations = authSchema.table(
'organizations',
{
id: text('id').primaryKey(), // Better Auth uses TEXT IDs (ULIDs/nanoids)
name: text('name').notNull(),
slug: text('slug').unique(),
logo: text('logo'),
metadata: jsonb('metadata'), // Additional organization data
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
slugIdx: index('organizations_slug_idx').on(table.slug),
})
);
// Members table (links users to organizations with roles)
export const members = authSchema.table(
'members',
{
id: text('id').primaryKey(), // Better Auth uses TEXT IDs
organizationId: text('organization_id')
.references(() => organizations.id, { onDelete: 'cascade' })
.notNull(),
userId: text('user_id').notNull(), // References auth.users.id (UUID cast to TEXT)
role: text('role').notNull(), // 'owner', 'admin', 'member', or custom roles
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
organizationIdIdx: index('members_organization_id_idx').on(table.organizationId),
userIdIdx: index('members_user_id_idx').on(table.userId),
organizationUserIdx: index('members_organization_user_idx').on(
table.organizationId,
table.userId
),
})
);
// Invitations table (for inviting users to organizations)
export const invitations = authSchema.table(
'invitations',
{
id: text('id').primaryKey(), // Better Auth uses TEXT IDs
organizationId: text('organization_id')
.references(() => organizations.id, { onDelete: 'cascade' })
.notNull(),
email: text('email').notNull(),
role: text('role').notNull(), // Role they'll have when they accept
status: text('status').notNull(), // 'pending', 'accepted', 'rejected', 'canceled'
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
inviterId: text('inviter_id'), // References auth.users.id (UUID cast to TEXT)
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
organizationIdIdx: index('invitations_organization_id_idx').on(table.organizationId),
emailIdx: index('invitations_email_idx').on(table.email),
statusIdx: index('invitations_status_idx').on(table.status),
})
);