@rebasepro/server-postgres 0.0.1-canary.4829d6e

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 (121) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +86 -0
  3. package/dist/PostgresAdapter.d.ts +6 -0
  4. package/dist/PostgresBackendDriver.d.ts +150 -0
  5. package/dist/PostgresBootstrapper.d.ts +51 -0
  6. package/dist/auth/ensure-tables.d.ts +10 -0
  7. package/dist/auth/services.d.ts +250 -0
  8. package/dist/backup/backup-cli.d.ts +3 -0
  9. package/dist/backup/backup-cron.d.ts +53 -0
  10. package/dist/backup/backup-service.d.ts +85 -0
  11. package/dist/backup/index.d.ts +12 -0
  12. package/dist/backup/pg-tools.d.ts +110 -0
  13. package/dist/backup/retention.d.ts +35 -0
  14. package/dist/chunk-DSJWtz9O.js +40 -0
  15. package/dist/cli-errors.d.ts +42 -0
  16. package/dist/cli-helpers.d.ts +7 -0
  17. package/dist/cli.d.ts +1 -0
  18. package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
  19. package/dist/connection.d.ts +65 -0
  20. package/dist/data-transformer.d.ts +55 -0
  21. package/dist/databasePoolManager.d.ts +20 -0
  22. package/dist/history/HistoryService.d.ts +71 -0
  23. package/dist/history/ensure-history-table.d.ts +7 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.es.js +23535 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/interfaces.d.ts +18 -0
  28. package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
  29. package/dist/schema/auth-default-policies.d.ts +10 -0
  30. package/dist/schema/auth-schema.d.ts +2376 -0
  31. package/dist/schema/doctor-cli.d.ts +2 -0
  32. package/dist/schema/doctor.d.ts +58 -0
  33. package/dist/schema/dynamic-tables.d.ts +31 -0
  34. package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
  35. package/dist/schema/generate-drizzle-schema.d.ts +1 -0
  36. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  37. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  38. package/dist/schema/introspect-db-inference.d.ts +5 -0
  39. package/dist/schema/introspect-db-logic.d.ts +118 -0
  40. package/dist/schema/introspect-db.d.ts +1 -0
  41. package/dist/schema/introspect-runtime.d.ts +57 -0
  42. package/dist/schema/test-schema.d.ts +24 -0
  43. package/dist/security/policy-drift.d.ts +57 -0
  44. package/dist/security/rls-enforcement.d.ts +122 -0
  45. package/dist/services/BranchService.d.ts +47 -0
  46. package/dist/services/FetchService.d.ts +214 -0
  47. package/dist/services/PersistService.d.ts +39 -0
  48. package/dist/services/RelationService.d.ts +109 -0
  49. package/dist/services/cdc/CdcListener.d.ts +54 -0
  50. package/dist/services/cdc/trigger-cdc.d.ts +64 -0
  51. package/dist/services/collection-helpers.d.ts +38 -0
  52. package/dist/services/dataService.d.ts +110 -0
  53. package/dist/services/index.d.ts +4 -0
  54. package/dist/services/realtimeService.d.ts +298 -0
  55. package/dist/src-Eh-CZosp.js +595 -0
  56. package/dist/src-Eh-CZosp.js.map +1 -0
  57. package/dist/types.d.ts +3 -0
  58. package/dist/utils/drizzle-conditions.d.ts +138 -0
  59. package/dist/utils/pg-array-null-patch.d.ts +16 -0
  60. package/dist/utils/pg-error-utils.d.ts +65 -0
  61. package/dist/utils/table-classification.d.ts +8 -0
  62. package/dist/websocket.d.ts +18 -0
  63. package/package.json +113 -0
  64. package/src/PostgresAdapter.ts +58 -0
  65. package/src/PostgresBackendDriver.ts +1387 -0
  66. package/src/PostgresBootstrapper.ts +581 -0
  67. package/src/auth/ensure-tables.ts +367 -0
  68. package/src/auth/services.ts +1321 -0
  69. package/src/backup/backup-cli.ts +383 -0
  70. package/src/backup/backup-cron.ts +189 -0
  71. package/src/backup/backup-service.ts +299 -0
  72. package/src/backup/index.ts +12 -0
  73. package/src/backup/pg-tools.ts +231 -0
  74. package/src/backup/retention.ts +75 -0
  75. package/src/cli-errors.ts +265 -0
  76. package/src/cli-helpers.ts +196 -0
  77. package/src/cli.ts +786 -0
  78. package/src/collections/PostgresCollectionRegistry.ts +103 -0
  79. package/src/connection.ts +166 -0
  80. package/src/data-transformer.ts +733 -0
  81. package/src/databasePoolManager.ts +87 -0
  82. package/src/history/HistoryService.ts +272 -0
  83. package/src/history/ensure-history-table.ts +46 -0
  84. package/src/index.ts +15 -0
  85. package/src/interfaces.ts +60 -0
  86. package/src/schema/auth-bootstrap-sql.ts +41 -0
  87. package/src/schema/auth-default-policies.ts +125 -0
  88. package/src/schema/auth-schema.ts +233 -0
  89. package/src/schema/doctor-cli.ts +113 -0
  90. package/src/schema/doctor.ts +733 -0
  91. package/src/schema/dynamic-tables.test.ts +302 -0
  92. package/src/schema/dynamic-tables.ts +293 -0
  93. package/src/schema/generate-drizzle-schema-logic.ts +850 -0
  94. package/src/schema/generate-drizzle-schema.ts +131 -0
  95. package/src/schema/generate-postgres-ddl-logic.ts +490 -0
  96. package/src/schema/generate-postgres-ddl.ts +92 -0
  97. package/src/schema/introspect-db-inference.ts +238 -0
  98. package/src/schema/introspect-db-logic.ts +910 -0
  99. package/src/schema/introspect-db.ts +266 -0
  100. package/src/schema/introspect-runtime.test.ts +212 -0
  101. package/src/schema/introspect-runtime.ts +293 -0
  102. package/src/schema/test-schema.ts +11 -0
  103. package/src/security/policy-drift.test.ts +122 -0
  104. package/src/security/policy-drift.ts +159 -0
  105. package/src/security/rls-enforcement.ts +295 -0
  106. package/src/services/BranchService.ts +251 -0
  107. package/src/services/FetchService.ts +1661 -0
  108. package/src/services/PersistService.ts +329 -0
  109. package/src/services/RelationService.ts +1306 -0
  110. package/src/services/cdc/CdcListener.ts +167 -0
  111. package/src/services/cdc/trigger-cdc.ts +169 -0
  112. package/src/services/collection-helpers.ts +151 -0
  113. package/src/services/dataService.ts +246 -0
  114. package/src/services/index.ts +13 -0
  115. package/src/services/realtimeService.ts +1502 -0
  116. package/src/types.ts +4 -0
  117. package/src/utils/drizzle-conditions.ts +1162 -0
  118. package/src/utils/pg-array-null-patch.ts +42 -0
  119. package/src/utils/pg-error-utils.ts +227 -0
  120. package/src/utils/table-classification.ts +16 -0
  121. package/src/websocket.ts +640 -0
@@ -0,0 +1,1321 @@
1
+ import { eq, getTableName, sql } from "drizzle-orm";
2
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+ import { getTableConfig } from "drizzle-orm/pg-core";
4
+ import type { RebasePgTable } from "../types";
5
+ import { users, refreshTokens, passwordResetTokens, userIdentities, magicLinkTokens } from "../schema/auth-schema";
6
+ import {
7
+ UserRepository,
8
+ RoleRepository,
9
+ TokenRepository,
10
+ MfaRepository,
11
+ AuthRepository,
12
+ UserData,
13
+ CreateUserData,
14
+ RoleData,
15
+ CreateRoleData,
16
+ RefreshTokenInfo,
17
+ PasswordResetTokenInfo,
18
+ MagicLinkTokenInfo,
19
+ UserIdentityData,
20
+ ListUsersOptions,
21
+ PaginatedUsersResult,
22
+ MfaFactor,
23
+ MfaChallengeInfo,
24
+ RoleData as Role
25
+ } from "@rebasepro/server";
26
+ import { toSnakeCase, camelCase } from "@rebasepro/utils";
27
+
28
+ export type { Role };
29
+
30
+ export interface AuthSchemaTables {
31
+ users: RebasePgTable;
32
+ refreshTokens: RebasePgTable;
33
+ passwordResetTokens: RebasePgTable;
34
+ appConfig: RebasePgTable;
35
+ userIdentities: RebasePgTable;
36
+ }
37
+
38
+ function getColumnKey(table: RebasePgTable | undefined, ...keys: string[]): string | undefined {
39
+ if (!table) return undefined;
40
+ for (const key of keys) {
41
+ if (key in table) return key;
42
+ const snake = toSnakeCase(key);
43
+ if (snake in table) return snake;
44
+ const camel = camelCase(key);
45
+ if (camel in table) return camel;
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ function getColumn(table: RebasePgTable | undefined, ...keys: string[]): RebasePgTable[string] | undefined {
51
+ if (!table) return undefined;
52
+ const key = getColumnKey(table, ...keys);
53
+ return key ? table[key] : undefined;
54
+ }
55
+
56
+ /**
57
+ * PostgreSQL implementation of UserRepository.
58
+ * Handles all user-related database operations using Drizzle ORM.
59
+ */
60
+ export class UserService implements UserRepository {
61
+ private usersTable: RebasePgTable;
62
+ private userIdentitiesTable: RebasePgTable;
63
+
64
+ constructor(
65
+ private db: NodePgDatabase,
66
+ tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
67
+ ) {
68
+ if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).users)) {
69
+ const tables = tableOrTables as Partial<AuthSchemaTables>;
70
+ this.usersTable = (tables.users || users) as RebasePgTable;
71
+ this.userIdentitiesTable = (tables.userIdentities || userIdentities) as RebasePgTable;
72
+ } else {
73
+ const table = tableOrTables as RebasePgTable | undefined;
74
+ this.usersTable = table || (users as unknown as RebasePgTable);
75
+ this.userIdentitiesTable = userIdentities as unknown as RebasePgTable;
76
+ }
77
+ }
78
+
79
+ private getQualifiedUsersTableName(): string {
80
+ const name = getTableName(this.usersTable);
81
+ const schema = getTableConfig(this.usersTable).schema || "public";
82
+ return `"${schema}"."${name}"`;
83
+ }
84
+
85
+ private mapRowToUser(row: Record<string, unknown>): UserData {
86
+ if (!row) return row as UserData;
87
+
88
+ const id = (row.id ?? row.uid) as string;
89
+ const email = row.email as string;
90
+ const passwordHash = (row.password_hash ?? row.passwordHash ?? null) as string | null | undefined;
91
+ const displayName = (row.display_name ?? row.displayName ?? null) as string | null | undefined;
92
+ const photoUrl = (row.photo_url ?? row.photoUrl ?? row.photoURL ?? null) as string | null | undefined;
93
+ const emailVerified = (row.email_verified ?? row.emailVerified ?? false) as boolean;
94
+ const emailVerificationToken = (row.email_verification_token ?? row.emailVerificationToken ?? null) as string | null | undefined;
95
+ const emailVerificationSentAt = (row.email_verification_sent_at ?? row.emailVerificationSentAt ?? null) as string | number | Date | null;
96
+ const isAnonymous = (row.is_anonymous ?? row.isAnonymous ?? false) as boolean;
97
+ const createdAt = (row.created_at ?? row.createdAt) as string | number | Date | undefined;
98
+ const updatedAt = (row.updated_at ?? row.updatedAt) as string | number | Date | undefined;
99
+
100
+ const metadata: Record<string, any> = { ...((row.metadata as Record<string, any> | undefined) || {}) };
101
+
102
+ const knownKeys = new Set([
103
+ "id", "uid", "email",
104
+ "password_hash", "passwordHash",
105
+ "display_name", "displayName",
106
+ "photo_url", "photoUrl", "photoURL",
107
+ "email_verified", "emailVerified",
108
+ "email_verification_token", "emailVerificationToken",
109
+ "email_verification_sent_at", "emailVerificationSentAt",
110
+ "is_anonymous", "isAnonymous",
111
+ "roles",
112
+ "created_at", "createdAt",
113
+ "updated_at", "updatedAt",
114
+ "metadata"
115
+ ]);
116
+
117
+ for (const [key, val] of Object.entries(row)) {
118
+ if (!knownKeys.has(key)) {
119
+ const camelKey = camelCase(key);
120
+ metadata[camelKey] = val;
121
+ }
122
+ }
123
+
124
+ return {
125
+ id,
126
+ email,
127
+ passwordHash,
128
+ displayName,
129
+ photoUrl,
130
+ emailVerified,
131
+ emailVerificationToken,
132
+ emailVerificationSentAt: emailVerificationSentAt ? new Date(emailVerificationSentAt) : null,
133
+ isAnonymous,
134
+ createdAt: createdAt ? new Date(createdAt) : new Date(),
135
+ updatedAt: updatedAt ? new Date(updatedAt) : new Date(),
136
+ metadata
137
+ };
138
+ }
139
+
140
+ private mapPayload(data: Partial<CreateUserData>): Record<string, unknown> {
141
+ if (!data) return {};
142
+
143
+ const payload: Record<string, unknown> = {};
144
+
145
+ const idKey = getColumnKey(this.usersTable, "id") || "id";
146
+ const emailKey = getColumnKey(this.usersTable, "email") || "email";
147
+ const passwordHashKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
148
+ const displayNameKey = getColumnKey(this.usersTable, "displayName", "display_name") || "displayName";
149
+ const photoUrlKey = getColumnKey(this.usersTable, "photoUrl", "photo_url") || "photoUrl";
150
+ const emailVerifiedKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
151
+ const emailVerificationTokenKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
152
+ const emailVerificationSentAtKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
153
+ const isAnonymousKey = getColumnKey(this.usersTable, "isAnonymous", "is_anonymous") || "isAnonymous";
154
+ const createdAtKey = getColumnKey(this.usersTable, "createdAt", "created_at") || "createdAt";
155
+ const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
156
+ const metadataKey = getColumnKey(this.usersTable, "metadata") || "metadata";
157
+
158
+ if ("id" in data) payload[idKey] = data.id;
159
+ if ("email" in data) payload[emailKey] = data.email;
160
+ if ("passwordHash" in data) payload[passwordHashKey] = data.passwordHash;
161
+ if ("displayName" in data) payload[displayNameKey] = data.displayName;
162
+ if ("photoUrl" in data) payload[photoUrlKey] = data.photoUrl;
163
+ if ("emailVerified" in data) payload[emailVerifiedKey] = data.emailVerified;
164
+ if ("emailVerificationToken" in data) payload[emailVerificationTokenKey] = data.emailVerificationToken;
165
+ if ("emailVerificationSentAt" in data) payload[emailVerificationSentAtKey] = data.emailVerificationSentAt;
166
+ if ("isAnonymous" in data) payload[isAnonymousKey] = data.isAnonymous;
167
+ if ("createdAt" in data) payload[createdAtKey] = data.createdAt;
168
+ if ("updatedAt" in data) payload[updatedAtKey] = data.updatedAt;
169
+
170
+ const metadata: Record<string, any> = { ...(data.metadata || {}) };
171
+ const remainingMetadata: Record<string, any> = {};
172
+
173
+ for (const [key, val] of Object.entries(metadata)) {
174
+ const tableColKey = getColumnKey(this.usersTable, key);
175
+ if (tableColKey &&
176
+ tableColKey !== idKey &&
177
+ tableColKey !== emailKey &&
178
+ tableColKey !== passwordHashKey &&
179
+ tableColKey !== displayNameKey &&
180
+ tableColKey !== photoUrlKey &&
181
+ tableColKey !== emailVerifiedKey &&
182
+ tableColKey !== emailVerificationTokenKey &&
183
+ tableColKey !== emailVerificationSentAtKey &&
184
+ tableColKey !== isAnonymousKey &&
185
+ tableColKey !== createdAtKey &&
186
+ tableColKey !== updatedAtKey &&
187
+ tableColKey !== metadataKey) {
188
+ payload[tableColKey] = val;
189
+ } else {
190
+ remainingMetadata[key] = val;
191
+ }
192
+ }
193
+
194
+ if (metadataKey in this.usersTable) {
195
+ payload[metadataKey] = remainingMetadata;
196
+ }
197
+
198
+ return payload;
199
+ }
200
+
201
+ async createUser(data: CreateUserData): Promise<UserData> {
202
+ const payload = this.mapPayload(data);
203
+ const [row] = (await this.db.insert(this.usersTable).values(payload).returning()) as Record<string, unknown>[];
204
+ return this.mapRowToUser(row);
205
+ }
206
+
207
+ async getUserById(id: string): Promise<UserData | null> {
208
+ const idCol = getColumn(this.usersTable, "id");
209
+ if (!idCol) return null;
210
+ const [row] = await this.db.select().from(this.usersTable).where(eq(idCol, id));
211
+ return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
212
+ }
213
+
214
+ async getUserByEmail(email: string): Promise<UserData | null> {
215
+ const emailCol = getColumn(this.usersTable, "email");
216
+ if (!emailCol) return null;
217
+ const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, email.toLowerCase()));
218
+ return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
219
+ }
220
+
221
+ async getUserByIdentity(provider: string, providerId: string): Promise<UserData | null> {
222
+ const userIdCol = getColumn(this.usersTable, "id");
223
+ if (!userIdCol) return null;
224
+
225
+ const result = await this.db
226
+ .select({ user: this.usersTable })
227
+ .from(this.usersTable)
228
+ .innerJoin(this.userIdentitiesTable, eq(userIdCol, this.userIdentitiesTable.userId))
229
+ .where(
230
+ sql`${this.userIdentitiesTable.provider} = ${provider} AND ${this.userIdentitiesTable.providerId} = ${providerId}`
231
+ )
232
+ .limit(1);
233
+
234
+ if (result.length === 0) return null;
235
+ return this.mapRowToUser(result[0].user as Record<string, unknown>);
236
+ }
237
+
238
+ async getUserIdentities(userId: string): Promise<UserIdentityData[]> {
239
+ const schema = getTableConfig(this.userIdentitiesTable).schema || "public";
240
+ const result = await this.db.execute(sql`
241
+ SELECT id, user_id, provider, provider_id, profile_data, created_at, updated_at
242
+ FROM ${sql.raw(`"${schema}"."user_identities"`)}
243
+ WHERE user_id = ${userId}
244
+ `);
245
+
246
+ return result.rows.map((row: Record<string, unknown>) => ({
247
+ id: row.id as string,
248
+ userId: row.user_id as string,
249
+ provider: row.provider as string,
250
+ providerId: row.provider_id as string,
251
+ profileData: (row.profile_data as Record<string, unknown> | null) ?? null,
252
+ createdAt: row.created_at as Date,
253
+ updatedAt: row.updated_at as Date
254
+ }));
255
+ }
256
+
257
+ async linkUserIdentity(userId: string, provider: string, providerId: string, profileData?: Record<string, unknown>): Promise<void> {
258
+ await this.db.insert(this.userIdentitiesTable).values({
259
+ userId,
260
+ provider,
261
+ providerId,
262
+ profileData: profileData || null
263
+ }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
264
+ }
265
+
266
+ async updateUser(id: string, data: Partial<Omit<CreateUserData, "id">>): Promise<UserData | null> {
267
+ const idCol = getColumn(this.usersTable, "id");
268
+ if (!idCol) return null;
269
+ const payload = this.mapPayload(data);
270
+ const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
271
+ payload[updatedAtKey] = new Date();
272
+
273
+ const [row] = (await this.db
274
+ .update(this.usersTable)
275
+ .set(payload)
276
+ .where(eq(idCol, id))
277
+ .returning()) as Record<string, unknown>[];
278
+ return row ? this.mapRowToUser(row) : null;
279
+ }
280
+
281
+ async deleteUser(id: string): Promise<void> {
282
+ const idCol = getColumn(this.usersTable, "id");
283
+ if (!idCol) return;
284
+ await this.db.delete(this.usersTable).where(eq(idCol, id));
285
+ }
286
+
287
+ async listUsers(): Promise<UserData[]> {
288
+ const rows = await this.db.select().from(this.usersTable);
289
+ return (rows as Record<string, unknown>[]).map(row => this.mapRowToUser(row));
290
+ }
291
+
292
+ async listUsersPaginated(options?: ListUsersOptions): Promise<PaginatedUsersResult> {
293
+ const limit = options?.limit ?? 25;
294
+ const offset = options?.offset ?? 0;
295
+ const search = options?.search?.trim() || "";
296
+ const orderBy = options?.orderBy || "createdAt";
297
+ const orderDir = options?.orderDir || "desc";
298
+ const roleId = options?.roleId;
299
+
300
+ const orderCol = getColumn(this.usersTable, orderBy);
301
+ const orderColumn = orderCol ? orderCol.name : "created_at";
302
+ const direction = orderDir === "asc" ? sql`ASC` : sql`DESC`;
303
+
304
+ const emailCol = getColumn(this.usersTable, "email");
305
+ const emailColumn = emailCol ? emailCol.name : "email";
306
+ const displayNameCol = getColumn(this.usersTable, "displayName", "display_name");
307
+ const displayNameColumn = displayNameCol ? displayNameCol.name : "display_name";
308
+ const idCol = getColumn(this.usersTable, "id");
309
+ const idColumn = idCol ? idCol.name : "id";
310
+
311
+ const usersTableName = this.getQualifiedUsersTableName();
312
+ const conditions = [];
313
+ if (roleId) {
314
+ conditions.push(sql`${roleId} = ANY(${sql.raw(usersTableName)}.roles)`);
315
+ }
316
+ if (search) {
317
+ const pattern = `%${search}%`;
318
+ conditions.push(sql`(${sql.raw(usersTableName)}.${sql.raw(emailColumn)} ILIKE ${pattern} OR ${sql.raw(usersTableName)}.${sql.raw(displayNameColumn)} ILIKE ${pattern})`);
319
+ }
320
+
321
+ const whereClause = conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``;
322
+
323
+ // Sorting: users with roles first if no role filter, then by requested order
324
+ const orderByClause = roleId
325
+ ? sql`ORDER BY ${sql.raw(usersTableName)}.${sql.raw(orderColumn)} ${direction}`
326
+ : sql`ORDER BY array_length(${sql.raw(usersTableName)}.roles, 1) DESC NULLS LAST, ${sql.raw(usersTableName)}.${sql.raw(orderColumn)} ${direction}`;
327
+
328
+ const countResult = await this.db.execute(sql`
329
+ SELECT count(*)::int as total FROM ${sql.raw(usersTableName)}
330
+ ${whereClause}
331
+ `);
332
+ const total = (countResult.rows[0] as { total: number }).total;
333
+
334
+ const dataResult = await this.db.execute(sql`
335
+ SELECT * FROM ${sql.raw(usersTableName)}
336
+ ${whereClause}
337
+ ${orderByClause}
338
+ LIMIT ${limit} OFFSET ${offset}
339
+ `);
340
+ const rows = dataResult.rows;
341
+
342
+ // Map rows to camelCase UserData
343
+ const mappedUsers: UserData[] = (rows as Record<string, unknown>[]).map((row) => this.mapRowToUser(row));
344
+
345
+ return { users: mappedUsers,
346
+ total,
347
+ limit,
348
+ offset };
349
+ }
350
+
351
+ /**
352
+ * Update user's password hash
353
+ */
354
+ async updatePassword(id: string, passwordHash: string): Promise<void> {
355
+ const idCol = getColumn(this.usersTable, "id");
356
+ if (!idCol) return;
357
+ const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
358
+ const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
359
+
360
+ await this.db
361
+ .update(this.usersTable)
362
+ .set({
363
+ [passwordHashColKey]: passwordHash,
364
+ [updatedAtColKey]: new Date()
365
+ })
366
+ .where(eq(idCol, id));
367
+ }
368
+
369
+ /**
370
+ * Set email verification status
371
+ */
372
+ async setEmailVerified(id: string, verified: boolean): Promise<void> {
373
+ const idCol = getColumn(this.usersTable, "id");
374
+ if (!idCol) return;
375
+ const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
376
+ const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
377
+ const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
378
+
379
+ await this.db
380
+ .update(this.usersTable)
381
+ .set({
382
+ [emailVerifiedColKey]: verified,
383
+ [emailVerificationTokenColKey]: null,
384
+ [updatedAtColKey]: new Date()
385
+ })
386
+ .where(eq(idCol, id));
387
+ }
388
+
389
+ /**
390
+ * Set email verification token
391
+ */
392
+ async setVerificationToken(id: string, token: string | null): Promise<void> {
393
+ const idCol = getColumn(this.usersTable, "id");
394
+ if (!idCol) return;
395
+ const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
396
+ const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
397
+ const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
398
+
399
+ await this.db
400
+ .update(this.usersTable)
401
+ .set({
402
+ [emailVerificationTokenColKey]: token,
403
+ [emailVerificationSentAtColKey]: token ? new Date() : null,
404
+ [updatedAtColKey]: new Date()
405
+ })
406
+ .where(eq(idCol, id));
407
+ }
408
+
409
+ /**
410
+ * Find user by email verification token
411
+ */
412
+ async getUserByVerificationToken(token: string): Promise<UserData | null> {
413
+ const tokenCol = getColumn(this.usersTable, "emailVerificationToken", "email_verification_token");
414
+ if (!tokenCol) return null;
415
+ const [row] = await this.db
416
+ .select()
417
+ .from(this.usersTable)
418
+ .where(eq(tokenCol, token));
419
+ return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
420
+ }
421
+
422
+ /**
423
+ * Get roles for a user from database (inline TEXT[] column)
424
+ */
425
+ async getUserRoles(userId: string): Promise<Role[]> {
426
+ const usersTableName = this.getQualifiedUsersTableName();
427
+ const result = await this.db.execute(sql`
428
+ SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${userId}
429
+ `);
430
+
431
+ if (result.rows.length === 0) return [];
432
+
433
+ const row = result.rows[0] as { roles: string[] | null };
434
+ const roleIds = row.roles ?? [];
435
+
436
+ return roleIds.map(id => ({
437
+ id,
438
+ name: id,
439
+ isAdmin: id === "admin",
440
+ defaultPermissions: null,
441
+ collectionPermissions: null
442
+ }));
443
+ }
444
+
445
+ /**
446
+ * Get role IDs for a user
447
+ */
448
+ async getUserRoleIds(userId: string): Promise<string[]> {
449
+ const usersTableName = this.getQualifiedUsersTableName();
450
+ const result = await this.db.execute(sql`
451
+ SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${userId}
452
+ `);
453
+
454
+ if (result.rows.length === 0) return [];
455
+
456
+ const row = result.rows[0] as { roles: string[] | null };
457
+ return row.roles ?? [];
458
+ }
459
+
460
+ /**
461
+ * Set roles for a user (replaces existing roles)
462
+ */
463
+ async setUserRoles(userId: string, roleIds: string[]): Promise<void> {
464
+ const usersTableName = this.getQualifiedUsersTableName();
465
+ const rolesArray = `{${roleIds.join(",")}}`;
466
+ await this.db.execute(sql`
467
+ UPDATE ${sql.raw(usersTableName)}
468
+ SET roles = ${rolesArray}::text[], updated_at = NOW()
469
+ WHERE id = ${userId}
470
+ `);
471
+ }
472
+
473
+ /**
474
+ * Assign a specific role to new user (appends if not present)
475
+ */
476
+ async assignDefaultRole(userId: string, roleId: string): Promise<void> {
477
+ const usersTableName = this.getQualifiedUsersTableName();
478
+ await this.db.execute(sql`
479
+ UPDATE ${sql.raw(usersTableName)}
480
+ SET roles = array_append(roles, ${roleId}), updated_at = NOW()
481
+ WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
482
+ `);
483
+ }
484
+
485
+ /**
486
+ * Get user with their roles
487
+ */
488
+ async getUserWithRoles(userId: string): Promise<{ user: UserData; roles: Role[] } | null> {
489
+ const user = await this.getUserById(userId);
490
+ if (!user) return null;
491
+
492
+ const roles = await this.getUserRoles(userId);
493
+ return { user,
494
+ roles };
495
+ }
496
+ }
497
+
498
+
499
+ export class RefreshTokenService {
500
+ private refreshTokensTable: RebasePgTable;
501
+
502
+ constructor(
503
+ private db: NodePgDatabase,
504
+ tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
505
+ ) {
506
+ if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).refreshTokens || (tableOrTables as Partial<AuthSchemaTables>).users)) {
507
+ this.refreshTokensTable = ((tableOrTables as Partial<AuthSchemaTables>).refreshTokens || refreshTokens) as RebasePgTable;
508
+ } else {
509
+ this.refreshTokensTable = (tableOrTables as RebasePgTable) || (refreshTokens as unknown as RebasePgTable);
510
+ }
511
+ }
512
+
513
+ async createToken(userId: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string): Promise<void> {
514
+ // Fallback to empty string because UNIQUE constraints treat NULLs as strictly distinct in standard Postgres.
515
+ // We want (userId, NULL, NULL) to collide and overwrite, so we map undefined/null to empty strings.
516
+ const safeUserAgent = userAgent || "";
517
+ const safeIpAddress = ipAddress || "";
518
+
519
+ // Atomic upsert keyed on the device session (user_id, user_agent, ip_address).
520
+ // A DELETE-then-INSERT races under concurrent refreshes — cookie-mode boot can
521
+ // fire two /refresh calls at once (both carrying the same refresh cookie): both
522
+ // DELETE, then the second INSERT violates `unique_device_session` and 500s.
523
+ // A single INSERT ... ON CONFLICT DO UPDATE rotates the token atomically.
524
+ await this.db.insert(this.refreshTokensTable)
525
+ .values({
526
+ userId,
527
+ tokenHash,
528
+ expiresAt,
529
+ userAgent: safeUserAgent,
530
+ ipAddress: safeIpAddress
531
+ })
532
+ .onConflictDoUpdate({
533
+ target: [
534
+ this.refreshTokensTable.userId,
535
+ this.refreshTokensTable.userAgent,
536
+ this.refreshTokensTable.ipAddress
537
+ ],
538
+ set: { tokenHash, expiresAt }
539
+ });
540
+ }
541
+
542
+ async findByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
543
+ const [token] = await this.db
544
+ .select({
545
+ id: this.refreshTokensTable.id,
546
+ userId: this.refreshTokensTable.userId,
547
+ tokenHash: this.refreshTokensTable.tokenHash,
548
+ expiresAt: this.refreshTokensTable.expiresAt,
549
+ createdAt: this.refreshTokensTable.createdAt,
550
+ userAgent: this.refreshTokensTable.userAgent,
551
+ ipAddress: this.refreshTokensTable.ipAddress
552
+ })
553
+ .from(this.refreshTokensTable)
554
+ .where(eq(this.refreshTokensTable.tokenHash, tokenHash));
555
+
556
+ return (token as RefreshTokenInfo) || null;
557
+ }
558
+
559
+ async deleteByHash(tokenHash: string): Promise<void> {
560
+ await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
561
+ }
562
+
563
+ async deleteAllForUser(userId: string): Promise<void> {
564
+ await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.userId, userId));
565
+ }
566
+
567
+ async listForUser(userId: string): Promise<RefreshTokenInfo[]> {
568
+ const tokens = await this.db
569
+ .select({
570
+ id: this.refreshTokensTable.id,
571
+ userId: this.refreshTokensTable.userId,
572
+ tokenHash: this.refreshTokensTable.tokenHash,
573
+ expiresAt: this.refreshTokensTable.expiresAt,
574
+ createdAt: this.refreshTokensTable.createdAt,
575
+ userAgent: this.refreshTokensTable.userAgent,
576
+ ipAddress: this.refreshTokensTable.ipAddress
577
+ })
578
+ .from(this.refreshTokensTable)
579
+ .where(eq(this.refreshTokensTable.userId, userId))
580
+ .orderBy(this.refreshTokensTable.createdAt);
581
+
582
+ return tokens as RefreshTokenInfo[];
583
+ }
584
+
585
+ async deleteById(id: string, userId: string): Promise<void> {
586
+ await this.db.delete(this.refreshTokensTable)
587
+ .where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.userId} = ${userId}`);
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Password reset token service
593
+ */
594
+ export class PasswordResetTokenService {
595
+ private passwordResetTokensTable: RebasePgTable;
596
+
597
+ constructor(
598
+ private db: NodePgDatabase,
599
+ tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
600
+ ) {
601
+ if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).passwordResetTokens || (tableOrTables as Partial<AuthSchemaTables>).users)) {
602
+ this.passwordResetTokensTable = ((tableOrTables as Partial<AuthSchemaTables>).passwordResetTokens || passwordResetTokens) as RebasePgTable;
603
+ } else {
604
+ this.passwordResetTokensTable = (tableOrTables as RebasePgTable) || (passwordResetTokens as unknown as RebasePgTable);
605
+ }
606
+ }
607
+
608
+ private getQualifiedPasswordResetTokensTableName(): string {
609
+ const name = getTableName(this.passwordResetTokensTable);
610
+ const schema = getTableConfig(this.passwordResetTokensTable).schema || "public";
611
+ return `"${schema}"."${name}"`;
612
+ }
613
+
614
+ /**
615
+ * Create a password reset token
616
+ */
617
+ async createToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
618
+ // Delete any existing unused tokens for this user
619
+ const tableName = this.getQualifiedPasswordResetTokensTableName();
620
+ await this.db.execute(sql`
621
+ DELETE FROM ${sql.raw(tableName)}
622
+ WHERE user_id = ${userId} AND used_at IS NULL
623
+ `);
624
+
625
+ await this.db.insert(this.passwordResetTokensTable).values({
626
+ userId,
627
+ tokenHash,
628
+ expiresAt
629
+ });
630
+ }
631
+
632
+ /**
633
+ * Find a valid (not expired, not used) token by hash
634
+ */
635
+ async findValidByHash(tokenHash: string): Promise<{ userId: string; expiresAt: Date } | null> {
636
+ const [token] = await this.db
637
+ .select({
638
+ userId: this.passwordResetTokensTable.userId,
639
+ expiresAt: this.passwordResetTokensTable.expiresAt
640
+ })
641
+ .from(this.passwordResetTokensTable)
642
+ .where(eq(this.passwordResetTokensTable.tokenHash, tokenHash)) as unknown as Array<{ userId: string; expiresAt: Date }>;
643
+
644
+ if (!token) return null;
645
+
646
+ // Check if expired or used
647
+ const tableName = this.getQualifiedPasswordResetTokensTableName();
648
+ const result = await this.db.execute(sql`
649
+ SELECT user_id, expires_at
650
+ FROM ${sql.raw(tableName)}
651
+ WHERE token_hash = ${tokenHash}
652
+ AND used_at IS NULL
653
+ AND expires_at > NOW()
654
+ `);
655
+
656
+ if (result.rows.length === 0) return null;
657
+
658
+ const row = result.rows[0] as { user_id: string; expires_at: string | number | Date };
659
+ return {
660
+ userId: row.user_id,
661
+ expiresAt: new Date(row.expires_at)
662
+ };
663
+ }
664
+
665
+ /**
666
+ * Mark token as used
667
+ */
668
+ async markAsUsed(tokenHash: string): Promise<void> {
669
+ await this.db
670
+ .update(this.passwordResetTokensTable)
671
+ .set({ usedAt: new Date() })
672
+ .where(eq(this.passwordResetTokensTable.tokenHash, tokenHash));
673
+ }
674
+
675
+ /**
676
+ * Delete all tokens for a user
677
+ */
678
+ async deleteAllForUser(userId: string): Promise<void> {
679
+ await this.db.delete(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.userId, userId));
680
+ }
681
+
682
+ /**
683
+ * Clean up expired tokens
684
+ */
685
+ async deleteExpired(): Promise<void> {
686
+ const tableName = this.getQualifiedPasswordResetTokensTableName();
687
+ await this.db.execute(sql`
688
+ DELETE FROM ${sql.raw(tableName)}
689
+ WHERE expires_at < NOW()
690
+ `);
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Magic link token service.
696
+ * Handles magic link token storage for passwordless email login.
697
+ */
698
+ export class MagicLinkTokenService {
699
+ private magicLinkTokensTable: RebasePgTable;
700
+
701
+ constructor(
702
+ private db: NodePgDatabase,
703
+ tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
704
+ ) {
705
+ this.magicLinkTokensTable = (magicLinkTokens as unknown as RebasePgTable);
706
+ }
707
+
708
+ private getQualifiedTableName(): string {
709
+ const name = getTableName(this.magicLinkTokensTable);
710
+ const schema = getTableConfig(this.magicLinkTokensTable).schema || "public";
711
+ return `"${schema}"."${name}"`;
712
+ }
713
+
714
+ async createToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
715
+ // Delete any existing unused tokens for this user
716
+ const tableName = this.getQualifiedTableName();
717
+ await this.db.execute(sql`
718
+ DELETE FROM ${sql.raw(tableName)}
719
+ WHERE user_id = ${userId} AND used_at IS NULL
720
+ `);
721
+
722
+ await this.db.insert(this.magicLinkTokensTable).values({
723
+ userId,
724
+ tokenHash,
725
+ expiresAt
726
+ });
727
+ }
728
+
729
+ async findValidByHash(tokenHash: string): Promise<MagicLinkTokenInfo | null> {
730
+ const tableName = this.getQualifiedTableName();
731
+ const result = await this.db.execute(sql`
732
+ SELECT user_id, expires_at
733
+ FROM ${sql.raw(tableName)}
734
+ WHERE token_hash = ${tokenHash}
735
+ AND used_at IS NULL
736
+ AND expires_at > NOW()
737
+ `);
738
+
739
+ if (result.rows.length === 0) return null;
740
+
741
+ const row = result.rows[0] as { user_id: string; expires_at: string | number | Date };
742
+ return {
743
+ userId: row.user_id,
744
+ expiresAt: new Date(row.expires_at)
745
+ };
746
+ }
747
+
748
+ async markAsUsed(tokenHash: string): Promise<void> {
749
+ await this.db
750
+ .update(this.magicLinkTokensTable)
751
+ .set({ usedAt: new Date() })
752
+ .where(eq(this.magicLinkTokensTable.tokenHash, tokenHash));
753
+ }
754
+ }
755
+
756
+ /**
757
+ * PostgreSQL implementation of TokenRepository.
758
+ * Combines refresh token and password reset token operations.
759
+ */
760
+ export class PostgresTokenRepository implements TokenRepository {
761
+ private refreshTokenService: RefreshTokenService;
762
+ private passwordResetTokenService: PasswordResetTokenService;
763
+ private magicLinkTokenService: MagicLinkTokenService;
764
+
765
+ constructor(
766
+ private db: NodePgDatabase,
767
+ tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
768
+ ) {
769
+ this.refreshTokenService = new RefreshTokenService(db, tableOrTables);
770
+ this.passwordResetTokenService = new PasswordResetTokenService(db, tableOrTables);
771
+ this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
772
+ }
773
+
774
+ // Refresh token operations
775
+
776
+ async createRefreshToken(userId: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string): Promise<void> {
777
+ await this.refreshTokenService.createToken(userId, tokenHash, expiresAt, userAgent, ipAddress);
778
+ }
779
+
780
+ async findRefreshTokenByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
781
+ return this.refreshTokenService.findByHash(tokenHash);
782
+ }
783
+
784
+ async deleteRefreshToken(tokenHash: string): Promise<void> {
785
+ await this.refreshTokenService.deleteByHash(tokenHash);
786
+ }
787
+
788
+ async deleteAllRefreshTokensForUser(userId: string): Promise<void> {
789
+ await this.refreshTokenService.deleteAllForUser(userId);
790
+ }
791
+
792
+ async listRefreshTokensForUser(userId: string): Promise<RefreshTokenInfo[]> {
793
+ return this.refreshTokenService.listForUser(userId);
794
+ }
795
+
796
+ async deleteRefreshTokenById(id: string, userId: string): Promise<void> {
797
+ await this.refreshTokenService.deleteById(id, userId);
798
+ }
799
+
800
+ // Password reset token operations
801
+
802
+ async createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
803
+ await this.passwordResetTokenService.createToken(userId, tokenHash, expiresAt);
804
+ }
805
+
806
+ async findValidPasswordResetToken(tokenHash: string): Promise<PasswordResetTokenInfo | null> {
807
+ return this.passwordResetTokenService.findValidByHash(tokenHash);
808
+ }
809
+
810
+ async markPasswordResetTokenUsed(tokenHash: string): Promise<void> {
811
+ await this.passwordResetTokenService.markAsUsed(tokenHash);
812
+ }
813
+
814
+ async deleteAllPasswordResetTokensForUser(userId: string): Promise<void> {
815
+ await this.passwordResetTokenService.deleteAllForUser(userId);
816
+ }
817
+
818
+ async deleteExpiredTokens(): Promise<void> {
819
+ await this.passwordResetTokenService.deleteExpired();
820
+ }
821
+
822
+ // Magic link token operations
823
+
824
+ async createMagicLinkToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
825
+ await this.magicLinkTokenService.createToken(userId, tokenHash, expiresAt);
826
+ }
827
+
828
+ async findValidMagicLinkToken(tokenHash: string): Promise<MagicLinkTokenInfo | null> {
829
+ return this.magicLinkTokenService.findValidByHash(tokenHash);
830
+ }
831
+
832
+ async markMagicLinkTokenUsed(tokenHash: string): Promise<void> {
833
+ await this.magicLinkTokenService.markAsUsed(tokenHash);
834
+ }
835
+ }
836
+
837
+ /**
838
+ * PostgreSQL implementation of AuthRepository.
839
+ * Combines user, role, and token repository operations.
840
+ * This provides a convenient single-class interface for all auth operations.
841
+ */
842
+ export class PostgresAuthRepository implements AuthRepository {
843
+ private userService: UserService;
844
+ private tokenRepository: PostgresTokenRepository;
845
+
846
+ constructor(
847
+ private db: NodePgDatabase,
848
+ tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
849
+ ) {
850
+ this.userService = new UserService(db, tableOrTables);
851
+ this.tokenRepository = new PostgresTokenRepository(db, tableOrTables);
852
+ }
853
+
854
+ // User operations (delegate to UserService)
855
+
856
+ async createUser(data: CreateUserData): Promise<UserData> {
857
+ return this.userService.createUser(data);
858
+ }
859
+
860
+ async getUserById(id: string): Promise<UserData | null> {
861
+ return this.userService.getUserById(id);
862
+ }
863
+
864
+ async getUserByEmail(email: string): Promise<UserData | null> {
865
+ return this.userService.getUserByEmail(email);
866
+ }
867
+
868
+ async getUserByIdentity(provider: string, providerId: string): Promise<UserData | null> {
869
+ return this.userService.getUserByIdentity(provider, providerId);
870
+ }
871
+
872
+ async getUserIdentities(userId: string): Promise<UserIdentityData[]> {
873
+ return this.userService.getUserIdentities(userId);
874
+ }
875
+
876
+ async linkUserIdentity(userId: string, provider: string, providerId: string, profileData?: Record<string, unknown>): Promise<void> {
877
+ return this.userService.linkUserIdentity(userId, provider, providerId, profileData);
878
+ }
879
+
880
+ async updateUser(id: string, data: Partial<Omit<CreateUserData, "id">>): Promise<UserData | null> {
881
+ return this.userService.updateUser(id, data);
882
+ }
883
+
884
+ async deleteUser(id: string): Promise<void> {
885
+ await this.userService.deleteUser(id);
886
+ }
887
+
888
+ async listUsers(): Promise<UserData[]> {
889
+ return this.userService.listUsers();
890
+ }
891
+
892
+ async listUsersPaginated(options?: ListUsersOptions): Promise<PaginatedUsersResult> {
893
+ return this.userService.listUsersPaginated(options);
894
+ }
895
+
896
+ async updatePassword(id: string, passwordHash: string): Promise<void> {
897
+ await this.userService.updatePassword(id, passwordHash);
898
+ }
899
+
900
+ async setEmailVerified(id: string, verified: boolean): Promise<void> {
901
+ await this.userService.setEmailVerified(id, verified);
902
+ }
903
+
904
+ async setVerificationToken(id: string, token: string | null): Promise<void> {
905
+ await this.userService.setVerificationToken(id, token);
906
+ }
907
+
908
+ async getUserByVerificationToken(token: string): Promise<UserData | null> {
909
+ return this.userService.getUserByVerificationToken(token);
910
+ }
911
+
912
+ async getUserRoles(userId: string): Promise<RoleData[]> {
913
+ return this.userService.getUserRoles(userId);
914
+ }
915
+
916
+ async getUserRoleIds(userId: string): Promise<string[]> {
917
+ return this.userService.getUserRoleIds(userId);
918
+ }
919
+
920
+ async setUserRoles(userId: string, roleIds: string[]): Promise<void> {
921
+ await this.userService.setUserRoles(userId, roleIds);
922
+ }
923
+
924
+ async assignDefaultRole(userId: string, roleId: string): Promise<void> {
925
+ await this.userService.assignDefaultRole(userId, roleId);
926
+ }
927
+
928
+ async getUserWithRoles(userId: string): Promise<{ user: UserData; roles: RoleData[] } | null> {
929
+ return this.userService.getUserWithRoles(userId);
930
+ }
931
+
932
+ // Role operations (roles are inline on users, synthesized from string IDs)
933
+
934
+ async getRoleById(id: string): Promise<RoleData | null> {
935
+ return {
936
+ id,
937
+ name: id,
938
+ isAdmin: id === "admin",
939
+ defaultPermissions: null,
940
+ collectionPermissions: null
941
+ };
942
+ }
943
+
944
+ async listRoles(): Promise<RoleData[]> {
945
+ return [
946
+ { id: "admin",
947
+ name: "Admin",
948
+ isAdmin: true,
949
+ defaultPermissions: null,
950
+ collectionPermissions: null },
951
+ { id: "editor",
952
+ name: "Editor",
953
+ isAdmin: false,
954
+ defaultPermissions: null,
955
+ collectionPermissions: null },
956
+ { id: "viewer",
957
+ name: "Viewer",
958
+ isAdmin: false,
959
+ defaultPermissions: null,
960
+ collectionPermissions: null }
961
+ ];
962
+ }
963
+
964
+ async createRole(_data: CreateRoleData): Promise<RoleData> {
965
+ return {
966
+ id: _data.id,
967
+ name: _data.name,
968
+ isAdmin: _data.isAdmin ?? false,
969
+ defaultPermissions: _data.defaultPermissions ?? null,
970
+ collectionPermissions: _data.collectionPermissions ?? null
971
+ };
972
+ }
973
+
974
+ async updateRole(id: string, data: Partial<Omit<RoleData, "id">>): Promise<RoleData | null> {
975
+ return {
976
+ id,
977
+ name: data.name ?? id,
978
+ isAdmin: data.isAdmin ?? (id === "admin"),
979
+ defaultPermissions: data.defaultPermissions ?? null,
980
+ collectionPermissions: data.collectionPermissions ?? null
981
+ };
982
+ }
983
+
984
+ async deleteRole(_id: string): Promise<void> {
985
+ // No-op: roles are inline strings on users
986
+ }
987
+
988
+ // Token operations (delegate to PostgresTokenRepository)
989
+
990
+ async createRefreshToken(userId: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string): Promise<void> {
991
+ await this.tokenRepository.createRefreshToken(userId, tokenHash, expiresAt, userAgent, ipAddress);
992
+ }
993
+
994
+ async findRefreshTokenByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
995
+ return this.tokenRepository.findRefreshTokenByHash(tokenHash);
996
+ }
997
+
998
+ async deleteRefreshToken(tokenHash: string): Promise<void> {
999
+ await this.tokenRepository.deleteRefreshToken(tokenHash);
1000
+ }
1001
+
1002
+ async deleteAllRefreshTokensForUser(userId: string): Promise<void> {
1003
+ await this.tokenRepository.deleteAllRefreshTokensForUser(userId);
1004
+ }
1005
+
1006
+ async listRefreshTokensForUser(userId: string): Promise<RefreshTokenInfo[]> {
1007
+ return this.tokenRepository.listRefreshTokensForUser(userId);
1008
+ }
1009
+
1010
+ async deleteRefreshTokenById(id: string, userId: string): Promise<void> {
1011
+ await this.tokenRepository.deleteRefreshTokenById(id, userId);
1012
+ }
1013
+
1014
+ async createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
1015
+ await this.tokenRepository.createPasswordResetToken(userId, tokenHash, expiresAt);
1016
+ }
1017
+
1018
+ async findValidPasswordResetToken(tokenHash: string): Promise<PasswordResetTokenInfo | null> {
1019
+ return this.tokenRepository.findValidPasswordResetToken(tokenHash);
1020
+ }
1021
+
1022
+ async markPasswordResetTokenUsed(tokenHash: string): Promise<void> {
1023
+ await this.tokenRepository.markPasswordResetTokenUsed(tokenHash);
1024
+ }
1025
+
1026
+ async deleteAllPasswordResetTokensForUser(userId: string): Promise<void> {
1027
+ await this.tokenRepository.deleteAllPasswordResetTokensForUser(userId);
1028
+ }
1029
+
1030
+ async deleteExpiredTokens(): Promise<void> {
1031
+ await this.tokenRepository.deleteExpiredTokens();
1032
+ }
1033
+
1034
+ // Magic link token operations
1035
+
1036
+ async createMagicLinkToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
1037
+ await this.tokenRepository.createMagicLinkToken(userId, tokenHash, expiresAt);
1038
+ }
1039
+
1040
+ async findValidMagicLinkToken(tokenHash: string): Promise<MagicLinkTokenInfo | null> {
1041
+ return this.tokenRepository.findValidMagicLinkToken(tokenHash);
1042
+ }
1043
+
1044
+ async markMagicLinkTokenUsed(tokenHash: string): Promise<void> {
1045
+ await this.tokenRepository.markMagicLinkTokenUsed(tokenHash);
1046
+ }
1047
+
1048
+ // MFA operations (delegate to MfaService)
1049
+
1050
+ private _mfaService: MfaService | null = null;
1051
+ private getMfaService(): MfaService {
1052
+ if (!this._mfaService) {
1053
+ this._mfaService = new MfaService(this.db);
1054
+ }
1055
+ return this._mfaService;
1056
+ }
1057
+
1058
+ async createMfaFactor(userId: string, factorType: "totp", secretEncrypted: string, friendlyName?: string): Promise<MfaFactor> {
1059
+ return this.getMfaService().createMfaFactor(userId, factorType, secretEncrypted, friendlyName);
1060
+ }
1061
+
1062
+ async getMfaFactors(userId: string): Promise<MfaFactor[]> {
1063
+ return this.getMfaService().getMfaFactors(userId);
1064
+ }
1065
+
1066
+ async getMfaFactorById(factorId: string): Promise<(MfaFactor & { secretEncrypted: string }) | null> {
1067
+ return this.getMfaService().getMfaFactorById(factorId);
1068
+ }
1069
+
1070
+ async verifyMfaFactor(factorId: string): Promise<void> {
1071
+ return this.getMfaService().verifyMfaFactor(factorId);
1072
+ }
1073
+
1074
+ async deleteMfaFactor(factorId: string, userId: string): Promise<void> {
1075
+ return this.getMfaService().deleteMfaFactor(factorId, userId);
1076
+ }
1077
+
1078
+ async createMfaChallenge(factorId: string, ipAddress?: string): Promise<MfaChallengeInfo> {
1079
+ return this.getMfaService().createMfaChallenge(factorId, ipAddress);
1080
+ }
1081
+
1082
+ async getMfaChallengeById(challengeId: string): Promise<MfaChallengeInfo | null> {
1083
+ return this.getMfaService().getMfaChallengeById(challengeId);
1084
+ }
1085
+
1086
+ async verifyMfaChallenge(challengeId: string): Promise<void> {
1087
+ return this.getMfaService().verifyMfaChallenge(challengeId);
1088
+ }
1089
+
1090
+ async createRecoveryCodes(userId: string, codeHashes: string[]): Promise<void> {
1091
+ return this.getMfaService().createRecoveryCodes(userId, codeHashes);
1092
+ }
1093
+
1094
+ async useRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
1095
+ return this.getMfaService().useRecoveryCode(userId, codeHash);
1096
+ }
1097
+
1098
+ async getUnusedRecoveryCodeCount(userId: string): Promise<number> {
1099
+ return this.getMfaService().getUnusedRecoveryCodeCount(userId);
1100
+ }
1101
+
1102
+ async deleteAllRecoveryCodes(userId: string): Promise<void> {
1103
+ return this.getMfaService().deleteAllRecoveryCodes(userId);
1104
+ }
1105
+
1106
+ async hasVerifiedMfaFactors(userId: string): Promise<boolean> {
1107
+ return this.getMfaService().hasVerifiedMfaFactors(userId);
1108
+ }
1109
+ }
1110
+
1111
+ // =============================================================================
1112
+ // MFA SERVICE
1113
+ // =============================================================================
1114
+
1115
+ /**
1116
+ * PostgreSQL implementation of MfaRepository.
1117
+ * Handles all MFA-related database operations.
1118
+ */
1119
+ export class MfaService implements MfaRepository {
1120
+ constructor(private db: NodePgDatabase, private schemaName = "rebase") {}
1121
+
1122
+ private qualify(tableName: string): string {
1123
+ return `"${this.schemaName}"."${tableName}"`;
1124
+ }
1125
+
1126
+ async createMfaFactor(
1127
+ userId: string,
1128
+ factorType: "totp",
1129
+ secretEncrypted: string,
1130
+ friendlyName?: string
1131
+ ): Promise<MfaFactor> {
1132
+ const tableName = this.qualify("mfa_factors");
1133
+ const result = await this.db.execute(sql`
1134
+ INSERT INTO ${sql.raw(tableName)} (user_id, factor_type, secret_encrypted, friendly_name)
1135
+ VALUES (${userId}, ${factorType}, ${secretEncrypted}, ${friendlyName ?? null})
1136
+ RETURNING id, user_id, factor_type, friendly_name, verified, created_at, updated_at
1137
+ `);
1138
+
1139
+ const row = result.rows[0] as Record<string, unknown>;
1140
+ return {
1141
+ id: row.id as string,
1142
+ userId: row.user_id as string,
1143
+ factorType: row.factor_type as "totp",
1144
+ friendlyName: (row.friendly_name as string | null) ?? undefined,
1145
+ verified: row.verified as boolean,
1146
+ createdAt: new Date(row.created_at as string),
1147
+ updatedAt: new Date(row.updated_at as string)
1148
+ };
1149
+ }
1150
+
1151
+ async getMfaFactors(userId: string): Promise<MfaFactor[]> {
1152
+ const tableName = this.qualify("mfa_factors");
1153
+ const result = await this.db.execute(sql`
1154
+ SELECT id, user_id, factor_type, friendly_name, verified, created_at, updated_at
1155
+ FROM ${sql.raw(tableName)}
1156
+ WHERE user_id = ${userId}
1157
+ ORDER BY created_at
1158
+ `);
1159
+
1160
+ return (result.rows as Array<Record<string, unknown>>).map(row => ({
1161
+ id: row.id as string,
1162
+ userId: row.user_id as string,
1163
+ factorType: row.factor_type as "totp",
1164
+ friendlyName: (row.friendly_name as string | null) ?? undefined,
1165
+ verified: row.verified as boolean,
1166
+ createdAt: new Date(row.created_at as string),
1167
+ updatedAt: new Date(row.updated_at as string)
1168
+ }));
1169
+ }
1170
+
1171
+ async getMfaFactorById(factorId: string): Promise<(MfaFactor & { secretEncrypted: string }) | null> {
1172
+ const tableName = this.qualify("mfa_factors");
1173
+ const result = await this.db.execute(sql`
1174
+ SELECT id, user_id, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
1175
+ FROM ${sql.raw(tableName)}
1176
+ WHERE id = ${factorId}
1177
+ `);
1178
+
1179
+ if (result.rows.length === 0) return null;
1180
+
1181
+ const row = result.rows[0] as Record<string, unknown>;
1182
+ return {
1183
+ id: row.id as string,
1184
+ userId: row.user_id as string,
1185
+ factorType: row.factor_type as "totp",
1186
+ secretEncrypted: row.secret_encrypted as string,
1187
+ friendlyName: (row.friendly_name as string | null) ?? undefined,
1188
+ verified: row.verified as boolean,
1189
+ createdAt: new Date(row.created_at as string),
1190
+ updatedAt: new Date(row.updated_at as string)
1191
+ };
1192
+ }
1193
+
1194
+ async verifyMfaFactor(factorId: string): Promise<void> {
1195
+ const tableName = this.qualify("mfa_factors");
1196
+ await this.db.execute(sql`
1197
+ UPDATE ${sql.raw(tableName)}
1198
+ SET verified = TRUE, updated_at = NOW()
1199
+ WHERE id = ${factorId}
1200
+ `);
1201
+ }
1202
+
1203
+ async deleteMfaFactor(factorId: string, userId: string): Promise<void> {
1204
+ const tableName = this.qualify("mfa_factors");
1205
+ await this.db.execute(sql`
1206
+ DELETE FROM ${sql.raw(tableName)}
1207
+ WHERE id = ${factorId} AND user_id = ${userId}
1208
+ `);
1209
+ }
1210
+
1211
+ async createMfaChallenge(factorId: string, ipAddress?: string): Promise<MfaChallengeInfo> {
1212
+ const tableName = this.qualify("mfa_challenges");
1213
+ // Challenges expire in 5 minutes
1214
+ const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
1215
+ const result = await this.db.execute(sql`
1216
+ INSERT INTO ${sql.raw(tableName)} (factor_id, ip_address, expires_at)
1217
+ VALUES (${factorId}, ${ipAddress ?? null}, ${expiresAt})
1218
+ RETURNING id, factor_id, created_at, verified_at, ip_address
1219
+ `);
1220
+
1221
+ const row = result.rows[0] as Record<string, unknown>;
1222
+ return {
1223
+ id: row.id as string,
1224
+ factorId: row.factor_id as string,
1225
+ createdAt: new Date(row.created_at as string),
1226
+ verifiedAt: row.verified_at ? new Date(row.verified_at as string) : undefined,
1227
+ ipAddress: (row.ip_address as string | null) ?? undefined
1228
+ };
1229
+ }
1230
+
1231
+ async getMfaChallengeById(challengeId: string): Promise<MfaChallengeInfo | null> {
1232
+ const tableName = this.qualify("mfa_challenges");
1233
+ const result = await this.db.execute(sql`
1234
+ SELECT id, factor_id, created_at, verified_at, ip_address, expires_at
1235
+ FROM ${sql.raw(tableName)}
1236
+ WHERE id = ${challengeId} AND expires_at > NOW() AND verified_at IS NULL
1237
+ `);
1238
+
1239
+ if (result.rows.length === 0) return null;
1240
+
1241
+ const row = result.rows[0] as Record<string, unknown>;
1242
+ return {
1243
+ id: row.id as string,
1244
+ factorId: row.factor_id as string,
1245
+ createdAt: new Date(row.created_at as string),
1246
+ verifiedAt: row.verified_at ? new Date(row.verified_at as string) : undefined,
1247
+ ipAddress: (row.ip_address as string | null) ?? undefined
1248
+ };
1249
+ }
1250
+
1251
+ async verifyMfaChallenge(challengeId: string): Promise<void> {
1252
+ const tableName = this.qualify("mfa_challenges");
1253
+ await this.db.execute(sql`
1254
+ UPDATE ${sql.raw(tableName)}
1255
+ SET verified_at = NOW()
1256
+ WHERE id = ${challengeId}
1257
+ `);
1258
+ }
1259
+
1260
+ async createRecoveryCodes(userId: string, codeHashes: string[]): Promise<void> {
1261
+ const tableName = this.qualify("recovery_codes");
1262
+ // Delete existing codes first
1263
+ await this.db.execute(sql`
1264
+ DELETE FROM ${sql.raw(tableName)} WHERE user_id = ${userId}
1265
+ `);
1266
+
1267
+ // Insert new codes
1268
+ for (const hash of codeHashes) {
1269
+ await this.db.execute(sql`
1270
+ INSERT INTO ${sql.raw(tableName)} (user_id, code_hash)
1271
+ VALUES (${userId}, ${hash})
1272
+ `);
1273
+ }
1274
+ }
1275
+
1276
+ async useRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
1277
+ const tableName = this.qualify("recovery_codes");
1278
+ const result = await this.db.execute(sql`
1279
+ UPDATE ${sql.raw(tableName)}
1280
+ SET used_at = NOW()
1281
+ WHERE user_id = ${userId} AND code_hash = ${codeHash} AND used_at IS NULL
1282
+ RETURNING id
1283
+ `);
1284
+
1285
+ return result.rows.length > 0;
1286
+ }
1287
+
1288
+ async getUnusedRecoveryCodeCount(userId: string): Promise<number> {
1289
+ const tableName = this.qualify("recovery_codes");
1290
+ const result = await this.db.execute(sql`
1291
+ SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
1292
+ WHERE user_id = ${userId} AND used_at IS NULL
1293
+ `);
1294
+
1295
+ return (result.rows[0] as { count: number }).count;
1296
+ }
1297
+
1298
+ async deleteAllRecoveryCodes(userId: string): Promise<void> {
1299
+ const tableName = this.qualify("recovery_codes");
1300
+ await this.db.execute(sql`
1301
+ DELETE FROM ${sql.raw(tableName)} WHERE user_id = ${userId}
1302
+ `);
1303
+ }
1304
+
1305
+ async hasVerifiedMfaFactors(userId: string): Promise<boolean> {
1306
+ const tableName = this.qualify("mfa_factors");
1307
+ const result = await this.db.execute(sql`
1308
+ SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
1309
+ WHERE user_id = ${userId} AND verified = TRUE
1310
+ `);
1311
+
1312
+ return (result.rows[0] as { count: number }).count > 0;
1313
+ }
1314
+ }
1315
+
1316
+ // =============================================================================
1317
+ // PostgreSQL Type Aliases (for consistent naming with other implementations)
1318
+ // =============================================================================
1319
+
1320
+ /** PostgreSQL user repository implementation */
1321
+ export type PostgresUserRepository = UserService;