🐛 fix(auth): use Better Auth native JWT validation with EdDSA

- Replace jsonwebtoken RS256 validation with jose EdDSA
- Add JWKS endpoint to expose Better Auth public keys
- Use createRemoteJWKSet for token validation
- Fix issuer mismatch (use env var consistently)
- Add jwks table to schema for Better Auth JWT plugin
- Install jose library for JWT verification
This commit is contained in:
Wuesteon 2025-12-01 15:18:57 +01:00
parent 2a002bf6be
commit 8dd1e4326c
10 changed files with 573 additions and 555 deletions

View file

@ -5,8 +5,9 @@ export default defineConfig({
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore',
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/manacore',
},
schemaFilter: ['auth', 'credits', 'public'],
verbose: true,
strict: true,
});

View file

@ -33,6 +33,7 @@
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"helmet": "^8.0.0",
"jose": "^6.1.2",
"jsonwebtoken": "^9.0.2",
"nanoid": "^5.0.9",
"postgres": "^3.4.5",

View file

@ -126,6 +126,17 @@ export class AuthController {
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
// =========================================================================

View file

@ -4,11 +4,14 @@
* This file configures Better Auth with:
* - Email/password authentication
* - Organization plugin for B2B (multi-tenant)
* - JWT plugin with custom claims (credit_balance, customer_type, organization)
* - 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
* @see BETTER_AUTH_FINAL_PLAN.md
*/
import { betterAuth } from 'better-auth';
@ -16,16 +19,32 @@ 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 { eq, and } from 'drizzle-orm';
import { balances } from '../db/schema/credits.schema';
import { organizations, members } from '../db/schema/organizations.schema';
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
*
* Defines the structure of custom claims included in JWT tokens.
* These claims are added to the standard JWT payload (sub, iat, exp, etc.)
* 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) */
@ -37,145 +56,13 @@ export interface JWTCustomPayload {
/** User role (user, admin, service) */
role: string;
/** Customer type: B2C (individual) or B2B (organization member) */
customer_type: 'b2c' | 'b2b';
/** Organization context (null for B2C users) */
organization: {
id: string;
name: string;
role: 'owner' | 'admin' | 'member';
} | null;
/** User's credit balance (personal for B2C, allocated for B2B) */
credit_balance: number;
/** Application ID (memoro, chat, picture, etc.) */
app_id?: string;
/** Device ID (for mobile apps) */
device_id?: string;
}
/**
* Helper function to get personal credit balance (B2C users)
*
* @param userId - User ID
* @param databaseUrl - Database connection URL
* @returns Credit balance or 0 if not found
*/
async function getPersonalCreditBalance(userId: string, databaseUrl: string): Promise<number> {
try {
const db = getDb(databaseUrl);
const [balance] = await db
.select({ balance: balances.balance })
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
return balance?.balance ?? 0;
} catch (error) {
console.error('Error fetching personal credit balance:', error);
return 0;
}
}
/**
* Helper function to get employee credit balance (B2B users)
*
* For B2B employees, this returns their allocated credit balance.
* The balance is stored in the same balances table but tracked separately per employee.
*
* @param userId - Employee user ID
* @param organizationId - Organization ID
* @param databaseUrl - Database connection URL
* @returns Allocated credit balance or 0 if not found
*/
async function getEmployeeCreditBalance(
userId: string,
organizationId: string,
databaseUrl: string
): Promise<number> {
try {
const db = getDb(databaseUrl);
// Get employee's personal balance (which represents their allocated credits from the org)
const [balance] = await db
.select({ balance: balances.balance })
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
return balance?.balance ?? 0;
} catch (error) {
console.error('Error fetching employee credit balance:', error);
return 0;
}
}
/**
* Helper function to get organization membership data
*
* Queries the organization and member tables to get:
* - Organization name
* - User's role in the organization
*
* @param userId - User ID
* @param organizationId - Organization ID
* @param databaseUrl - Database connection URL
* @returns Organization data with name and role, or null if not found
*/
async function getOrganizationMembership(
userId: string,
organizationId: string,
databaseUrl: string
): Promise<{ name: string; role: 'owner' | 'admin' | 'member' } | null> {
try {
const db = getDb(databaseUrl);
// Query member table to get user's role in the organization
const [memberRecord] = await db
.select({
role: members.role,
})
.from(members)
.where(and(eq(members.userId, userId), eq(members.organizationId, organizationId)))
.limit(1);
if (!memberRecord) {
return null;
}
// Query organization table to get organization name
const [orgRecord] = await db
.select({
name: organizations.name,
})
.from(organizations)
.where(eq(organizations.id, organizationId))
.limit(1);
if (!orgRecord) {
return null;
}
return {
name: orgRecord.name,
role: memberRecord.role as 'owner' | 'admin' | 'member',
};
} catch (error) {
console.error('Error fetching organization membership:', error);
return null;
}
/** Session ID for reference */
sid: string;
}
/**
* Create Better Auth instance
*
* This function initializes Better Auth with the database connection URL.
* It must be called with the database URL from the configuration.
*
* @param databaseUrl - PostgreSQL connection URL
* @returns Better Auth instance
*/
@ -187,16 +74,19 @@ export function createBetterAuth(databaseUrl: string) {
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
// Auth tables
user: 'auth.users',
session: 'auth.sessions',
account: 'auth.accounts',
verification: 'auth.verification_tokens',
// Auth tables (actual Drizzle table objects)
user: users,
session: sessions,
account: accounts,
verification: verificationTokens,
// Organization tables (Better Auth creates these schemas)
organization: 'auth.organizations',
member: 'auth.members',
invitation: 'auth.invitations',
// Organization tables
organization: organizations,
member: members,
invitation: invitations,
// JWT plugin table
jwks: jwks,
},
}),
@ -226,7 +116,12 @@ export function createBetterAuth(databaseUrl: string) {
* - Create/update/delete organizations
* - Invite/add/remove members
* - Role-based access control
* - Email-based invitations
* - 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
@ -242,22 +137,10 @@ export function createBetterAuth(databaseUrl: string) {
organization: organization.name,
invitationId: data.id,
});
// Example email template:
// Subject: Join ${organization.name} on Mana Universe
// Body: You've been invited to join ${organization.name}
// Click here to accept: ${baseURL}/invite/${data.id}
},
// Custom roles and permissions
organizationRole: {
/**
* Owner Role
* - Full organization control
* - Can delete organization
* - Can manage all members
* - Can allocate credits to employees
*/
owner: {
permissions: [
'organization:update',
@ -265,17 +148,10 @@ export function createBetterAuth(databaseUrl: string) {
'members:invite',
'members:remove',
'members:update_role',
'credits:allocate', // Custom permission
'credits:view_all', // Custom permission
'credits:allocate',
'credits:view_all',
],
},
/**
* Admin Role
* - Can update organization settings
* - Can invite and remove members
* - Can view all credit usage
*/
admin: {
permissions: [
'organization:update',
@ -284,12 +160,6 @@ export function createBetterAuth(databaseUrl: string) {
'credits:view_all',
],
},
/**
* Member Role
* - Basic organization access
* - Can only view their own credits
*/
member: {
permissions: ['credits:view_own'],
},
@ -299,99 +169,35 @@ export function createBetterAuth(databaseUrl: string) {
/**
* JWT Plugin
*
* Generates JWT tokens with custom claims for:
* - Credit balance
* - Customer type (B2C vs B2B)
* - Organization context
* - App/device metadata
* 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: 'mana-core',
issuer: process.env.JWT_ISSUER || 'manacore',
audience: process.env.JWT_AUDIENCE || 'manacore',
expirationTime: '15m', // 15 minutes for access tokens
expirationTime: '15m',
/**
* Define custom JWT payload
* Define minimal JWT payload
*
* This function is called when generating a JWT token.
* It enriches the standard JWT claims with custom data.
*
* @param context - JWT payload context with user and session
* @returns Custom JWT payload
* Only includes static user info that doesn't change frequently.
*/
async definePayload({ user, session }: JWTPayloadContext) {
// Get user's active organization (from session metadata or first membership)
const activeOrgId = session.activeOrganizationId;
let organizationData: JWTCustomPayload['organization'] = null;
let creditBalance = 0;
let customerType: 'b2c' | 'b2b' = 'b2c';
if (activeOrgId) {
// B2B user - get organization membership from database
try {
// Query actual organization and membership data
const membership = await getOrganizationMembership(
user.id,
activeOrgId,
databaseUrl
);
if (membership) {
// Get employee's allocated credit balance
creditBalance = await getEmployeeCreditBalance(
user.id,
activeOrgId,
databaseUrl
);
organizationData = {
id: activeOrgId,
name: membership.name,
role: membership.role,
};
customerType = 'b2b';
} else {
// User is not a member of this organization, fall back to B2C
console.warn(
`User ${user.id} is not a member of organization ${activeOrgId}`
);
creditBalance = await getPersonalCreditBalance(user.id, databaseUrl);
}
} catch (error) {
console.error('Error fetching organization data:', error);
// Fall back to B2C on error
creditBalance = await getPersonalCreditBalance(user.id, databaseUrl);
}
} else {
// B2C user - get personal credit balance
creditBalance = await getPersonalCreditBalance(user.id, databaseUrl);
}
// Build custom JWT payload
const payload: Partial<JWTCustomPayload> = {
// Standard claims
definePayload({ user, session }: JWTPayloadContext) {
return {
sub: user.id,
email: user.email,
role: user.role || 'user',
// Customer type
customer_type: customerType,
// Organization (null for B2C)
organization: organizationData,
// Credits
credit_balance: creditBalance,
// App metadata (from session)
app_id: (session.metadata?.appId as string) || undefined,
device_id: (session.metadata?.deviceId as string) || undefined,
role: (user as { role?: string }).role || 'user',
sid: session.id,
};
return payload;
},
},
}),

View file

@ -62,6 +62,7 @@ import type {
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 {
@ -418,6 +419,73 @@ export class BetterAuthService {
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,
@ -425,7 +493,9 @@ export class BetterAuthService {
name: user.name,
role: (user as BetterAuthUser).role,
},
token: hasToken(result) ? result.token : '',
accessToken,
refreshToken: sessionToken,
expiresIn: 15 * 60, // 15 minutes in seconds
};
} catch (error: unknown) {
if (error instanceof Error) {
@ -617,7 +687,7 @@ export class BetterAuthService {
}
// Check if refresh token is expired
if (new Date() > session.refreshTokenExpiresAt) {
if (!session.refreshTokenExpiresAt || new Date() > session.refreshTokenExpiresAt) {
throw new UnauthorizedException('Refresh token expired');
}
@ -715,26 +785,44 @@ export class BetterAuthService {
*/
async validateToken(token: string): Promise<ValidateTokenResult> {
try {
const publicKey = this.configService.get<string>('jwt.publicKey');
if (!publicKey) {
throw new Error('JWT public key not configured');
}
console.log('[validateToken] Token (first 50 chars):', token?.substring(0, 50));
const audience = this.configService.get<string>('jwt.audience');
const issuer = this.configService.get<string>('jwt.issuer');
// Decode to check the algorithm
const decoded = jwt.decode(token, { complete: true });
console.log('[validateToken] Decoded header:', decoded?.header);
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience,
// 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,
}) as TokenPayload;
audience,
});
console.log('[validateToken] Verification SUCCESS');
console.log('[validateToken] Payload:', payload);
return {
valid: true,
payload,
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,
@ -742,6 +830,46 @@ export class BetterAuthService {
}
}
/**
* 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
// =========================================================================

View file

@ -441,9 +441,9 @@ export interface SignInResult {
name: string | null;
role?: string;
};
token: string;
refreshToken?: string;
expiresIn?: number;
accessToken: string;
refreshToken: string;
expiresIn: number;
}
/**

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

@ -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

@ -34,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(),
@ -43,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(),
});
@ -53,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(),
@ -61,10 +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(),
organizationId: text('organization_id').references(() => organizations.id), // NULL for B2C, set for B2B
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 }),
@ -83,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(),
@ -98,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),
@ -124,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(),
@ -143,12 +143,12 @@ export const organizationBalances = creditsSchema.table('organization_balances',
organizationId: text('organization_id')
.primaryKey()
.references(() => organizations.id, { onDelete: 'cascade' }),
balance: integer('balance').default(0).notNull(), // Total purchased credits
allocatedCredits: integer('allocated_credits').default(0).notNull(), // Sum of credits allocated to employees
availableCredits: integer('available_credits').default(0).notNull(), // balance - allocated_credits
totalPurchased: integer('total_purchased').default(0).notNull(), // Total credits ever purchased
totalAllocated: integer('total_allocated').default(0).notNull(), // Total ever allocated
version: integer('version').default(0).notNull(), // For optimistic locking
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(),
});
@ -161,17 +161,17 @@ export const creditAllocations = creditsSchema.table(
organizationId: text('organization_id')
.references(() => organizations.id, { onDelete: 'cascade' })
.notNull(),
employeeId: uuid('employee_id')
employeeId: text('employee_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
amount: integer('amount').notNull(), // Amount allocated (can be positive or negative)
allocatedBy: uuid('allocated_by')
amount: integer('amount').notNull(),
allocatedBy: text('allocated_by')
.references(() => users.id)
.notNull(), // Owner or admin who made the allocation
reason: text('reason'), // Optional reason for allocation
balanceBefore: integer('balance_before').notNull(), // Employee balance before
balanceAfter: integer('balance_after').notNull(), // Employee balance after
metadata: jsonb('metadata'), // Additional context
.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) => ({