@rebasepro/server-postgresql 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/common/src/collections/default-collections.d.ts +12 -0
  2. package/dist/common/src/collections/index.d.ts +1 -0
  3. package/dist/common/src/util/permissions.d.ts +1 -0
  4. package/dist/index.es.js +844 -160
  5. package/dist/index.es.js.map +1 -1
  6. package/dist/index.umd.js +842 -158
  7. package/dist/index.umd.js.map +1 -1
  8. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +1 -1
  9. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +1 -0
  10. package/dist/server-postgresql/src/auth/services.d.ts +43 -1
  11. package/dist/server-postgresql/src/connection.d.ts +25 -0
  12. package/dist/server-postgresql/src/schema/auth-schema.d.ts +2382 -35
  13. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +4 -0
  14. package/dist/server-postgresql/src/services/entityService.d.ts +2 -0
  15. package/dist/server-postgresql/src/services/realtimeService.d.ts +20 -0
  16. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +18 -0
  17. package/dist/types/src/controllers/auth.d.ts +2 -24
  18. package/dist/types/src/controllers/client.d.ts +0 -3
  19. package/dist/types/src/controllers/collection_registry.d.ts +1 -1
  20. package/dist/types/src/controllers/data_driver.d.ts +18 -0
  21. package/dist/types/src/controllers/registry.d.ts +5 -4
  22. package/dist/types/src/rebase_context.d.ts +1 -1
  23. package/dist/types/src/types/auth_adapter.d.ts +2 -4
  24. package/dist/types/src/types/collections.d.ts +0 -4
  25. package/dist/types/src/types/component_ref.d.ts +1 -1
  26. package/dist/types/src/types/cron.d.ts +1 -1
  27. package/dist/types/src/types/entity_views.d.ts +1 -0
  28. package/dist/types/src/types/export_import.d.ts +1 -1
  29. package/dist/types/src/types/formex.d.ts +2 -2
  30. package/dist/types/src/types/properties.d.ts +2 -2
  31. package/dist/types/src/types/translations.d.ts +28 -12
  32. package/dist/types/src/types/user_management_delegate.d.ts +6 -4
  33. package/dist/types/src/users/roles.d.ts +0 -8
  34. package/package.json +6 -6
  35. package/src/PostgresBackendDriver.ts +4 -2
  36. package/src/PostgresBootstrapper.ts +27 -8
  37. package/src/auth/ensure-tables.ts +79 -17
  38. package/src/auth/services.ts +292 -23
  39. package/src/connection.ts +77 -0
  40. package/src/data-transformer.ts +2 -2
  41. package/src/schema/auth-schema.ts +80 -14
  42. package/src/schema/generate-drizzle-schema.ts +6 -6
  43. package/src/services/EntityFetchService.ts +69 -10
  44. package/src/services/entityService.ts +2 -0
  45. package/src/services/realtimeService.ts +214 -2
  46. package/src/utils/drizzle-conditions.ts +74 -2
  47. package/src/websocket.ts +10 -2
  48. package/test/auth-services.test.ts +15 -28
  49. package/test/drizzle-conditions.test.ts +168 -0
  50. package/vite.config.ts +1 -1
@@ -3,6 +3,7 @@ import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
3
  import { getTableConfig, AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
4
4
  import { getColumnMeta } from "../services/entity-helpers";
5
5
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
6
+ import { logger } from "@rebasepro/server-core";
6
7
 
7
8
  /**
8
9
  * Default roles to seed on first run
@@ -12,22 +13,19 @@ const DEFAULT_ROLES = [
12
13
  id: "admin",
13
14
  name: "Admin",
14
15
  is_admin: true,
15
- default_permissions: { read: true, create: true, edit: true, delete: true },
16
- config: { createCollections: true, editCollections: "all", deleteCollections: "all" }
16
+ default_permissions: { read: true, create: true, edit: true, delete: true }
17
17
  },
18
18
  {
19
19
  id: "editor",
20
20
  name: "Editor",
21
21
  is_admin: false,
22
- default_permissions: { read: true, create: true, edit: true, delete: true },
23
- config: { createCollections: true, editCollections: "own", deleteCollections: "own" }
22
+ default_permissions: { read: true, create: true, edit: true, delete: true }
24
23
  },
25
24
  {
26
25
  id: "viewer",
27
26
  name: "Viewer",
28
27
  is_admin: false,
29
- default_permissions: { read: true, create: false, edit: false, delete: false },
30
- config: null
28
+ default_permissions: { read: true, create: false, edit: false, delete: false }
31
29
  }
32
30
  ];
33
31
 
@@ -36,7 +34,7 @@ const DEFAULT_ROLES = [
36
34
  * This runs on startup to ensure the database is ready for auth
37
35
  */
38
36
  export async function ensureAuthTablesExist(db: NodePgDatabase, registry?: PostgresCollectionRegistry): Promise<void> {
39
- console.log("🔍 Checking auth tables...");
37
+ logger.info("🔍 Checking auth tables...");
40
38
 
41
39
  try {
42
40
  // Resolve dynamic user table name and ID type
@@ -123,7 +121,6 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, registry?: Postg
123
121
  is_admin BOOLEAN DEFAULT FALSE,
124
122
  default_permissions JSONB,
125
123
  collection_permissions JSONB,
126
- config JSONB,
127
124
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
128
125
  )
129
126
  `);
@@ -234,10 +231,76 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, registry?: Postg
234
231
  // Seed default roles if none exist
235
232
  await seedDefaultRoles(db, rolesTableName);
236
233
 
237
- console.log("✅ Auth tables ready");
234
+ // ── Migration: Add is_anonymous column (safe for existing tables) ────
235
+ await db.execute(sql`
236
+ ALTER TABLE ${sql.raw(usersTableName)}
237
+ ADD COLUMN IF NOT EXISTS is_anonymous BOOLEAN DEFAULT FALSE
238
+ `);
239
+
240
+ // ── MFA tables ──────────────────────────────────────────────────────
241
+ const mfaFactorsTableName = `"${rolesSchema}"."mfa_factors"`;
242
+ const mfaChallengesTableName = `"${rolesSchema}"."mfa_challenges"`;
243
+ const recoveryCodesTableName = `"${rolesSchema}"."recovery_codes"`;
244
+
245
+ // Create mfa_factors table
246
+ await db.execute(sql`
247
+ CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (
248
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
249
+ user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
250
+ factor_type TEXT NOT NULL DEFAULT 'totp',
251
+ secret_encrypted TEXT NOT NULL,
252
+ friendly_name TEXT,
253
+ verified BOOLEAN DEFAULT FALSE,
254
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
255
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
256
+ )
257
+ `);
258
+
259
+ // Create indexes on mfa_factors
260
+ await db.execute(sql`
261
+ CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
262
+ ON ${sql.raw(mfaFactorsTableName)}(user_id)
263
+ `);
264
+
265
+ // Create mfa_challenges table
266
+ await db.execute(sql`
267
+ CREATE TABLE IF NOT EXISTS ${sql.raw(mfaChallengesTableName)} (
268
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
269
+ factor_id TEXT NOT NULL REFERENCES ${sql.raw(mfaFactorsTableName)}(id) ON DELETE CASCADE,
270
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
271
+ verified_at TIMESTAMP WITH TIME ZONE,
272
+ ip_address TEXT,
273
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL
274
+ )
275
+ `);
276
+
277
+ // Create indexes on mfa_challenges
278
+ await db.execute(sql`
279
+ CREATE INDEX IF NOT EXISTS idx_mfa_challenges_factor
280
+ ON ${sql.raw(mfaChallengesTableName)}(factor_id)
281
+ `);
282
+
283
+ // Create recovery_codes table
284
+ await db.execute(sql`
285
+ CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
286
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
287
+ user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
288
+ code_hash TEXT NOT NULL,
289
+ used_at TIMESTAMP WITH TIME ZONE,
290
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
291
+ )
292
+ `);
293
+
294
+ // Create indexes on recovery_codes
295
+ await db.execute(sql`
296
+ CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
297
+ ON ${sql.raw(recoveryCodesTableName)}(user_id)
298
+ `);
299
+
300
+ logger.info("✅ Auth tables ready");
238
301
  } catch (error) {
239
- console.error("❌ Failed to create auth tables:", error);
240
- console.warn("⚠️ Continuing without creating auth tables.");
302
+ logger.error("❌ Failed to create auth tables", { error });
303
+ logger.warn("⚠️ Continuing without creating auth tables.");
241
304
  }
242
305
  }
243
306
 
@@ -250,25 +313,24 @@ async function seedDefaultRoles(db: NodePgDatabase, rolesTableName: string): Pro
250
313
  const count = parseInt((result.rows[0] as Record<string, string | number>)?.count as string || "0", 10);
251
314
 
252
315
  if (count > 0) {
253
- console.log(`📋 Found ${count} existing roles`);
316
+ logger.info(`📋 Found ${count} existing roles`);
254
317
  return;
255
318
  }
256
319
 
257
- console.log("🌱 Seeding default roles...");
320
+ logger.info("🌱 Seeding default roles...");
258
321
 
259
322
  for (const role of DEFAULT_ROLES) {
260
323
  await db.execute(sql`
261
- INSERT INTO ${sql.raw(rolesTableName)} (id, name, is_admin, default_permissions, config)
324
+ INSERT INTO ${sql.raw(rolesTableName)} (id, name, is_admin, default_permissions)
262
325
  VALUES (
263
326
  ${role.id},
264
327
  ${role.name},
265
328
  ${role.is_admin},
266
- ${JSON.stringify(role.default_permissions)}::jsonb,
267
- ${role.config ? JSON.stringify(role.config) : null}::jsonb
329
+ ${JSON.stringify(role.default_permissions)}::jsonb
268
330
  )
269
331
  ON CONFLICT (id) DO NOTHING
270
332
  `);
271
333
  }
272
334
 
273
- console.log("✅ Default roles created: admin, editor, viewer");
335
+ logger.info("✅ Default roles created: admin, editor, viewer");
274
336
  }
@@ -6,6 +6,7 @@ import {
6
6
  UserRepository,
7
7
  RoleRepository,
8
8
  TokenRepository,
9
+ MfaRepository,
9
10
  AuthRepository,
10
11
  UserData,
11
12
  CreateUserData,
@@ -16,6 +17,8 @@ import {
16
17
  UserIdentityData,
17
18
  ListUsersOptions,
18
19
  PaginatedUsersResult,
20
+ MfaFactor,
21
+ MfaChallengeInfo,
19
22
  RoleData as Role
20
23
  // @ts-ignore
21
24
  } from "@rebasepro/server-core";
@@ -97,6 +100,7 @@ export class UserService implements UserRepository {
97
100
  const emailVerified = (row.email_verified ?? row.emailVerified ?? false) as boolean;
98
101
  const emailVerificationToken = (row.email_verification_token ?? row.emailVerificationToken ?? null) as string | null | undefined;
99
102
  const emailVerificationSentAt = (row.email_verification_sent_at ?? row.emailVerificationSentAt ?? null) as string | number | Date | null;
103
+ const isAnonymous = (row.is_anonymous ?? row.isAnonymous ?? false) as boolean;
100
104
  const createdAt = (row.created_at ?? row.createdAt) as string | number | Date | undefined;
101
105
  const updatedAt = (row.updated_at ?? row.updatedAt) as string | number | Date | undefined;
102
106
 
@@ -110,6 +114,7 @@ export class UserService implements UserRepository {
110
114
  "email_verified", "emailVerified",
111
115
  "email_verification_token", "emailVerificationToken",
112
116
  "email_verification_sent_at", "emailVerificationSentAt",
117
+ "is_anonymous", "isAnonymous",
113
118
  "created_at", "createdAt",
114
119
  "updated_at", "updatedAt",
115
120
  "metadata"
@@ -131,6 +136,7 @@ export class UserService implements UserRepository {
131
136
  emailVerified,
132
137
  emailVerificationToken,
133
138
  emailVerificationSentAt: emailVerificationSentAt ? new Date(emailVerificationSentAt) : null,
139
+ isAnonymous,
134
140
  createdAt: createdAt ? new Date(createdAt) : new Date(),
135
141
  updatedAt: updatedAt ? new Date(updatedAt) : new Date(),
136
142
  metadata
@@ -150,6 +156,7 @@ export class UserService implements UserRepository {
150
156
  const emailVerifiedKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
151
157
  const emailVerificationTokenKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
152
158
  const emailVerificationSentAtKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
159
+ const isAnonymousKey = getColumnKey(this.usersTable, "isAnonymous", "is_anonymous") || "isAnonymous";
153
160
  const createdAtKey = getColumnKey(this.usersTable, "createdAt", "created_at") || "createdAt";
154
161
  const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
155
162
  const metadataKey = getColumnKey(this.usersTable, "metadata") || "metadata";
@@ -162,6 +169,7 @@ export class UserService implements UserRepository {
162
169
  if ("emailVerified" in data) payload[emailVerifiedKey] = data.emailVerified;
163
170
  if ("emailVerificationToken" in data) payload[emailVerificationTokenKey] = data.emailVerificationToken;
164
171
  if ("emailVerificationSentAt" in data) payload[emailVerificationSentAtKey] = data.emailVerificationSentAt;
172
+ if ("isAnonymous" in data) payload[isAnonymousKey] = data.isAnonymous;
165
173
  if ("createdAt" in data) payload[createdAtKey] = data.createdAt;
166
174
  if ("updatedAt" in data) payload[updatedAtKey] = data.updatedAt;
167
175
 
@@ -179,6 +187,7 @@ export class UserService implements UserRepository {
179
187
  tableColKey !== emailVerifiedKey &&
180
188
  tableColKey !== emailVerificationTokenKey &&
181
189
  tableColKey !== emailVerificationSentAtKey &&
190
+ tableColKey !== isAnonymousKey &&
182
191
  tableColKey !== createdAtKey &&
183
192
  tableColKey !== updatedAtKey &&
184
193
  tableColKey !== metadataKey) {
@@ -424,19 +433,18 @@ export class UserService implements UserRepository {
424
433
  async getUserRoles(userId: string): Promise<Role[]> {
425
434
  const rolesSchema = getTableConfig(this.rolesTable).schema || "public";
426
435
  const result = await this.db.execute(sql`
427
- SELECT r.id, r.name, r.is_admin, r.default_permissions, r.collection_permissions, r.config
436
+ SELECT r.id, r.name, r.is_admin, r.default_permissions, r.collection_permissions
428
437
  FROM ${sql.raw(`"${rolesSchema}"."roles"`)} r
429
438
  INNER JOIN ${sql.raw(`"${rolesSchema}"."user_roles"`)} ur ON r.id = ur.role_id
430
439
  WHERE ur.user_id = ${userId}
431
440
  `);
432
441
 
433
- return (result.rows as Array<{ id: string; name: string; is_admin: boolean; default_permissions: Record<string, boolean> | null; collection_permissions: Record<string, Record<string, boolean>> | null; config: Record<string, unknown> | null }>).map(row => ({
442
+ return (result.rows as Array<{ id: string; name: string; is_admin: boolean; default_permissions: Record<string, boolean> | null; collection_permissions: Record<string, Record<string, boolean>> | null }>).map(row => ({
434
443
  id: row.id,
435
444
  name: row.name,
436
445
  isAdmin: row.is_admin,
437
446
  defaultPermissions: row.default_permissions,
438
- collectionPermissions: row.collection_permissions,
439
- config: row.config
447
+ collectionPermissions: row.collection_permissions
440
448
  }));
441
449
  }
442
450
 
@@ -518,65 +526,61 @@ export class RoleService implements RoleRepository {
518
526
  async getRoleById(id: string): Promise<Role | null> {
519
527
  const tableName = this.getQualifiedRolesTableName();
520
528
  const result = await this.db.execute(sql`
521
- SELECT id, name, is_admin, default_permissions, collection_permissions, config
529
+ SELECT id, name, is_admin, default_permissions, collection_permissions
522
530
  FROM ${sql.raw(tableName)}
523
531
  WHERE id = ${id}
524
532
  `);
525
533
 
526
534
  if (result.rows.length === 0) return null;
527
535
 
528
- const row = result.rows[0] as { id: string; name: string; is_admin: boolean; default_permissions: Record<string, boolean> | null; collection_permissions: Record<string, Record<string, boolean>> | null; config: Record<string, unknown> | null };
536
+ const row = result.rows[0] as { id: string; name: string; is_admin: boolean; default_permissions: Record<string, boolean> | null; collection_permissions: Record<string, Record<string, boolean>> | null };
529
537
  return {
530
538
  id: row.id,
531
539
  name: row.name,
532
540
  isAdmin: row.is_admin,
533
541
  defaultPermissions: row.default_permissions,
534
- collectionPermissions: row.collection_permissions,
535
- config: row.config
542
+ collectionPermissions: row.collection_permissions
536
543
  };
537
544
  }
538
545
 
539
546
  async listRoles(): Promise<Role[]> {
540
547
  const tableName = this.getQualifiedRolesTableName();
541
548
  const result = await this.db.execute(sql`
542
- SELECT id, name, is_admin, default_permissions, collection_permissions, config
549
+ SELECT id, name, is_admin, default_permissions, collection_permissions
543
550
  FROM ${sql.raw(tableName)}
544
551
  ORDER BY name
545
552
  `);
546
553
 
547
- return (result.rows as Array<{ id: string; name: string; is_admin: boolean; default_permissions: Record<string, boolean> | null; collection_permissions: Record<string, Record<string, boolean>> | null; config: Record<string, unknown> | null }>).map(row => ({
554
+ return (result.rows as Array<{ id: string; name: string; is_admin: boolean; default_permissions: Record<string, boolean> | null; collection_permissions: Record<string, Record<string, boolean>> | null }>).map(row => ({
548
555
  id: row.id,
549
556
  name: row.name,
550
557
  isAdmin: row.is_admin,
551
558
  defaultPermissions: row.default_permissions,
552
- collectionPermissions: row.collection_permissions,
553
- config: row.config
559
+ collectionPermissions: row.collection_permissions
554
560
  }));
555
561
  }
556
562
 
557
563
  async createRole(data: Omit<Role, "isAdmin" | "collectionPermissions"> & { isAdmin?: boolean; collectionPermissions?: Role["collectionPermissions"] }): Promise<Role> {
558
564
  const tableName = this.getQualifiedRolesTableName();
559
565
  const result = await this.db.execute(sql`
560
- INSERT INTO ${sql.raw(tableName)} (id, name, is_admin, default_permissions, collection_permissions, config)
566
+ INSERT INTO ${sql.raw(tableName)} (id, name, is_admin, default_permissions, collection_permissions)
561
567
  VALUES (
562
568
  ${data.id},
563
569
  ${data.name},
564
570
  ${data.isAdmin ?? false},
565
571
  ${data.defaultPermissions ? JSON.stringify(data.defaultPermissions) : null}::jsonb,
566
- ${data.collectionPermissions ? JSON.stringify(data.collectionPermissions) : null}::jsonb,
567
- ${data.config ? JSON.stringify(data.config) : null}::jsonb
572
+ ${data.collectionPermissions ? JSON.stringify(data.collectionPermissions) : null}::jsonb
568
573
  )
569
- RETURNING id, name, is_admin, default_permissions, collection_permissions, config
574
+ RETURNING id, name, is_admin, default_permissions, collection_permissions
570
575
  `);
571
576
 
572
- const row = result.rows[0] as { id: string; name: string; is_admin: boolean; default_permissions: Record<string, boolean> | null; collection_permissions: Record<string, Record<string, boolean>> | null; config: Record<string, unknown> | null };
577
+ const row = result.rows[0] as { id: string; name: string; is_admin: boolean; default_permissions: Record<string, boolean> | null; collection_permissions: Record<string, Record<string, boolean>> | null };
573
578
  return {
574
579
  id: row.id,
575
580
  name: row.name,
576
581
  isAdmin: row.is_admin,
577
582
  defaultPermissions: row.default_permissions,
578
- collectionPermissions: row.collection_permissions,
579
- config: row.config
583
+ collectionPermissions: row.collection_permissions
580
584
  };
581
585
  }
582
586
 
@@ -591,8 +595,7 @@ export class RoleService implements RoleRepository {
591
595
  name = ${data.name ?? existing.name},
592
596
  is_admin = ${data.isAdmin ?? existing.isAdmin},
593
597
  default_permissions = ${data.defaultPermissions ? JSON.stringify(data.defaultPermissions) : JSON.stringify(existing.defaultPermissions)}::jsonb,
594
- collection_permissions = ${data.collectionPermissions !== undefined ? (data.collectionPermissions ? JSON.stringify(data.collectionPermissions) : null) : (existing.collectionPermissions ? JSON.stringify(existing.collectionPermissions) : null)}::jsonb,
595
- config = ${data.config ? JSON.stringify(data.config) : (existing.config ? JSON.stringify(existing.config) : null)}::jsonb
598
+ collection_permissions = ${data.collectionPermissions !== undefined ? (data.collectionPermissions ? JSON.stringify(data.collectionPermissions) : null) : (existing.collectionPermissions ? JSON.stringify(existing.collectionPermissions) : null)}::jsonb
596
599
  WHERE id = ${id}
597
600
  `);
598
601
 
@@ -979,8 +982,7 @@ export class PostgresAuthRepository implements AuthRepository {
979
982
  return this.roleService.createRole({
980
983
  ...data,
981
984
  defaultPermissions: data.defaultPermissions ?? null,
982
- collectionPermissions: data.collectionPermissions ?? null,
983
- config: data.config ?? null
985
+ collectionPermissions: data.collectionPermissions ?? null
984
986
  });
985
987
  }
986
988
 
@@ -1037,6 +1039,273 @@ export class PostgresAuthRepository implements AuthRepository {
1037
1039
  async deleteExpiredTokens(): Promise<void> {
1038
1040
  await this.tokenRepository.deleteExpiredTokens();
1039
1041
  }
1042
+
1043
+ // MFA operations (delegate to MfaService)
1044
+
1045
+ private _mfaService: MfaService | null = null;
1046
+ private getMfaService(): MfaService {
1047
+ if (!this._mfaService) {
1048
+ this._mfaService = new MfaService(this.db);
1049
+ }
1050
+ return this._mfaService;
1051
+ }
1052
+
1053
+ async createMfaFactor(userId: string, factorType: "totp", secretEncrypted: string, friendlyName?: string): Promise<MfaFactor> {
1054
+ return this.getMfaService().createMfaFactor(userId, factorType, secretEncrypted, friendlyName);
1055
+ }
1056
+
1057
+ async getMfaFactors(userId: string): Promise<MfaFactor[]> {
1058
+ return this.getMfaService().getMfaFactors(userId);
1059
+ }
1060
+
1061
+ async getMfaFactorById(factorId: string): Promise<(MfaFactor & { secretEncrypted: string }) | null> {
1062
+ return this.getMfaService().getMfaFactorById(factorId);
1063
+ }
1064
+
1065
+ async verifyMfaFactor(factorId: string): Promise<void> {
1066
+ return this.getMfaService().verifyMfaFactor(factorId);
1067
+ }
1068
+
1069
+ async deleteMfaFactor(factorId: string, userId: string): Promise<void> {
1070
+ return this.getMfaService().deleteMfaFactor(factorId, userId);
1071
+ }
1072
+
1073
+ async createMfaChallenge(factorId: string, ipAddress?: string): Promise<MfaChallengeInfo> {
1074
+ return this.getMfaService().createMfaChallenge(factorId, ipAddress);
1075
+ }
1076
+
1077
+ async getMfaChallengeById(challengeId: string): Promise<MfaChallengeInfo | null> {
1078
+ return this.getMfaService().getMfaChallengeById(challengeId);
1079
+ }
1080
+
1081
+ async verifyMfaChallenge(challengeId: string): Promise<void> {
1082
+ return this.getMfaService().verifyMfaChallenge(challengeId);
1083
+ }
1084
+
1085
+ async createRecoveryCodes(userId: string, codeHashes: string[]): Promise<void> {
1086
+ return this.getMfaService().createRecoveryCodes(userId, codeHashes);
1087
+ }
1088
+
1089
+ async useRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
1090
+ return this.getMfaService().useRecoveryCode(userId, codeHash);
1091
+ }
1092
+
1093
+ async getUnusedRecoveryCodeCount(userId: string): Promise<number> {
1094
+ return this.getMfaService().getUnusedRecoveryCodeCount(userId);
1095
+ }
1096
+
1097
+ async deleteAllRecoveryCodes(userId: string): Promise<void> {
1098
+ return this.getMfaService().deleteAllRecoveryCodes(userId);
1099
+ }
1100
+
1101
+ async hasVerifiedMfaFactors(userId: string): Promise<boolean> {
1102
+ return this.getMfaService().hasVerifiedMfaFactors(userId);
1103
+ }
1104
+ }
1105
+
1106
+ // =============================================================================
1107
+ // MFA SERVICE
1108
+ // =============================================================================
1109
+
1110
+ /**
1111
+ * PostgreSQL implementation of MfaRepository.
1112
+ * Handles all MFA-related database operations.
1113
+ */
1114
+ export class MfaService implements MfaRepository {
1115
+ constructor(private db: NodePgDatabase, private schemaName: string = "rebase") {}
1116
+
1117
+ private qualify(tableName: string): string {
1118
+ return `"${this.schemaName}"."${tableName}"`;
1119
+ }
1120
+
1121
+ async createMfaFactor(
1122
+ userId: string,
1123
+ factorType: "totp",
1124
+ secretEncrypted: string,
1125
+ friendlyName?: string
1126
+ ): Promise<MfaFactor> {
1127
+ const tableName = this.qualify("mfa_factors");
1128
+ const result = await this.db.execute(sql`
1129
+ INSERT INTO ${sql.raw(tableName)} (user_id, factor_type, secret_encrypted, friendly_name)
1130
+ VALUES (${userId}, ${factorType}, ${secretEncrypted}, ${friendlyName ?? null})
1131
+ RETURNING id, user_id, factor_type, friendly_name, verified, created_at, updated_at
1132
+ `);
1133
+
1134
+ const row = result.rows[0] as Record<string, unknown>;
1135
+ return {
1136
+ id: row.id as string,
1137
+ userId: row.user_id as string,
1138
+ factorType: row.factor_type as "totp",
1139
+ friendlyName: (row.friendly_name as string | null) ?? undefined,
1140
+ verified: row.verified as boolean,
1141
+ createdAt: new Date(row.created_at as string),
1142
+ updatedAt: new Date(row.updated_at as string)
1143
+ };
1144
+ }
1145
+
1146
+ async getMfaFactors(userId: string): Promise<MfaFactor[]> {
1147
+ const tableName = this.qualify("mfa_factors");
1148
+ const result = await this.db.execute(sql`
1149
+ SELECT id, user_id, factor_type, friendly_name, verified, created_at, updated_at
1150
+ FROM ${sql.raw(tableName)}
1151
+ WHERE user_id = ${userId}
1152
+ ORDER BY created_at
1153
+ `);
1154
+
1155
+ return (result.rows as Array<Record<string, unknown>>).map(row => ({
1156
+ id: row.id as string,
1157
+ userId: row.user_id as string,
1158
+ factorType: row.factor_type as "totp",
1159
+ friendlyName: (row.friendly_name as string | null) ?? undefined,
1160
+ verified: row.verified as boolean,
1161
+ createdAt: new Date(row.created_at as string),
1162
+ updatedAt: new Date(row.updated_at as string)
1163
+ }));
1164
+ }
1165
+
1166
+ async getMfaFactorById(factorId: string): Promise<(MfaFactor & { secretEncrypted: string }) | null> {
1167
+ const tableName = this.qualify("mfa_factors");
1168
+ const result = await this.db.execute(sql`
1169
+ SELECT id, user_id, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
1170
+ FROM ${sql.raw(tableName)}
1171
+ WHERE id = ${factorId}
1172
+ `);
1173
+
1174
+ if (result.rows.length === 0) return null;
1175
+
1176
+ const row = result.rows[0] as Record<string, unknown>;
1177
+ return {
1178
+ id: row.id as string,
1179
+ userId: row.user_id as string,
1180
+ factorType: row.factor_type as "totp",
1181
+ secretEncrypted: row.secret_encrypted as string,
1182
+ friendlyName: (row.friendly_name as string | null) ?? undefined,
1183
+ verified: row.verified as boolean,
1184
+ createdAt: new Date(row.created_at as string),
1185
+ updatedAt: new Date(row.updated_at as string)
1186
+ };
1187
+ }
1188
+
1189
+ async verifyMfaFactor(factorId: string): Promise<void> {
1190
+ const tableName = this.qualify("mfa_factors");
1191
+ await this.db.execute(sql`
1192
+ UPDATE ${sql.raw(tableName)}
1193
+ SET verified = TRUE, updated_at = NOW()
1194
+ WHERE id = ${factorId}
1195
+ `);
1196
+ }
1197
+
1198
+ async deleteMfaFactor(factorId: string, userId: string): Promise<void> {
1199
+ const tableName = this.qualify("mfa_factors");
1200
+ await this.db.execute(sql`
1201
+ DELETE FROM ${sql.raw(tableName)}
1202
+ WHERE id = ${factorId} AND user_id = ${userId}
1203
+ `);
1204
+ }
1205
+
1206
+ async createMfaChallenge(factorId: string, ipAddress?: string): Promise<MfaChallengeInfo> {
1207
+ const tableName = this.qualify("mfa_challenges");
1208
+ // Challenges expire in 5 minutes
1209
+ const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
1210
+ const result = await this.db.execute(sql`
1211
+ INSERT INTO ${sql.raw(tableName)} (factor_id, ip_address, expires_at)
1212
+ VALUES (${factorId}, ${ipAddress ?? null}, ${expiresAt})
1213
+ RETURNING id, factor_id, created_at, verified_at, ip_address
1214
+ `);
1215
+
1216
+ const row = result.rows[0] as Record<string, unknown>;
1217
+ return {
1218
+ id: row.id as string,
1219
+ factorId: row.factor_id as string,
1220
+ createdAt: new Date(row.created_at as string),
1221
+ verifiedAt: row.verified_at ? new Date(row.verified_at as string) : undefined,
1222
+ ipAddress: (row.ip_address as string | null) ?? undefined
1223
+ };
1224
+ }
1225
+
1226
+ async getMfaChallengeById(challengeId: string): Promise<MfaChallengeInfo | null> {
1227
+ const tableName = this.qualify("mfa_challenges");
1228
+ const result = await this.db.execute(sql`
1229
+ SELECT id, factor_id, created_at, verified_at, ip_address, expires_at
1230
+ FROM ${sql.raw(tableName)}
1231
+ WHERE id = ${challengeId} AND expires_at > NOW() AND verified_at IS NULL
1232
+ `);
1233
+
1234
+ if (result.rows.length === 0) return null;
1235
+
1236
+ const row = result.rows[0] as Record<string, unknown>;
1237
+ return {
1238
+ id: row.id as string,
1239
+ factorId: row.factor_id as string,
1240
+ createdAt: new Date(row.created_at as string),
1241
+ verifiedAt: row.verified_at ? new Date(row.verified_at as string) : undefined,
1242
+ ipAddress: (row.ip_address as string | null) ?? undefined
1243
+ };
1244
+ }
1245
+
1246
+ async verifyMfaChallenge(challengeId: string): Promise<void> {
1247
+ const tableName = this.qualify("mfa_challenges");
1248
+ await this.db.execute(sql`
1249
+ UPDATE ${sql.raw(tableName)}
1250
+ SET verified_at = NOW()
1251
+ WHERE id = ${challengeId}
1252
+ `);
1253
+ }
1254
+
1255
+ async createRecoveryCodes(userId: string, codeHashes: string[]): Promise<void> {
1256
+ const tableName = this.qualify("recovery_codes");
1257
+ // Delete existing codes first
1258
+ await this.db.execute(sql`
1259
+ DELETE FROM ${sql.raw(tableName)} WHERE user_id = ${userId}
1260
+ `);
1261
+
1262
+ // Insert new codes
1263
+ for (const hash of codeHashes) {
1264
+ await this.db.execute(sql`
1265
+ INSERT INTO ${sql.raw(tableName)} (user_id, code_hash)
1266
+ VALUES (${userId}, ${hash})
1267
+ `);
1268
+ }
1269
+ }
1270
+
1271
+ async useRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
1272
+ const tableName = this.qualify("recovery_codes");
1273
+ const result = await this.db.execute(sql`
1274
+ UPDATE ${sql.raw(tableName)}
1275
+ SET used_at = NOW()
1276
+ WHERE user_id = ${userId} AND code_hash = ${codeHash} AND used_at IS NULL
1277
+ RETURNING id
1278
+ `);
1279
+
1280
+ return result.rows.length > 0;
1281
+ }
1282
+
1283
+ async getUnusedRecoveryCodeCount(userId: string): Promise<number> {
1284
+ const tableName = this.qualify("recovery_codes");
1285
+ const result = await this.db.execute(sql`
1286
+ SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
1287
+ WHERE user_id = ${userId} AND used_at IS NULL
1288
+ `);
1289
+
1290
+ return (result.rows[0] as { count: number }).count;
1291
+ }
1292
+
1293
+ async deleteAllRecoveryCodes(userId: string): Promise<void> {
1294
+ const tableName = this.qualify("recovery_codes");
1295
+ await this.db.execute(sql`
1296
+ DELETE FROM ${sql.raw(tableName)} WHERE user_id = ${userId}
1297
+ `);
1298
+ }
1299
+
1300
+ async hasVerifiedMfaFactors(userId: string): Promise<boolean> {
1301
+ const tableName = this.qualify("mfa_factors");
1302
+ const result = await this.db.execute(sql`
1303
+ SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
1304
+ WHERE user_id = ${userId} AND verified = TRUE
1305
+ `);
1306
+
1307
+ return (result.rows[0] as { count: number }).count > 0;
1308
+ }
1040
1309
  }
1041
1310
 
1042
1311
  // =============================================================================