@rebasepro/server-postgres 0.17.3-canary.gdd23447 → 0.18.0

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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,1610 +0,0 @@
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
- RefreshTokenSession,
18
- PasswordResetTokenInfo,
19
- MagicLinkTokenInfo,
20
- UserIdentityData,
21
- ListUsersOptions,
22
- PaginatedUsersResult,
23
- MfaFactor,
24
- MfaChallengeInfo,
25
- RoleData as Role,
26
- ApiError
27
- } from "@rebasepro/server";
28
- import { toSnakeCase, camelCase } from "@rebasepro/utils";
29
- import { escapeLikePattern } from "../utils/drizzle-conditions";
30
- import { extractPgError } from "../utils/pg-error-utils";
31
-
32
- export type { Role };
33
-
34
- export interface AuthSchemaTables {
35
- users: RebasePgTable;
36
- refreshTokens: RebasePgTable;
37
- passwordResetTokens: RebasePgTable;
38
- appConfig: RebasePgTable;
39
- userIdentities: RebasePgTable;
40
- }
41
-
42
- function getColumnKey(table: RebasePgTable | undefined, ...keys: string[]): string | undefined {
43
- if (!table) return undefined;
44
- for (const key of keys) {
45
- if (key in table) return key;
46
- const snake = toSnakeCase(key);
47
- if (snake in table) return snake;
48
- const camel = camelCase(key);
49
- if (camel in table) return camel;
50
- }
51
- return undefined;
52
- }
53
-
54
- function getColumn(table: RebasePgTable | undefined, ...keys: string[]): RebasePgTable[string] | undefined {
55
- if (!table) return undefined;
56
- const key = getColumnKey(table, ...keys);
57
- return key ? table[key] : undefined;
58
- }
59
-
60
- /**
61
- * The single definition of what an email address looks like in storage.
62
- *
63
- * Reads have always folded case; writes did not, and normalising was left to
64
- * each caller. That asymmetry is only ever one forgotten `.toLowerCase()` away
65
- * from a row no lookup can find — the account exists, every sign-in path
66
- * reports no such user, and the byte-exact UNIQUE on the column does not stop a
67
- * duplicate differing only in case. Applied on both sides here so the guarantee
68
- * belongs to the repository rather than to its callers' discipline; the
69
- * `lower(email)` unique index added in `ensureAuthTablesExist` is the database
70
- * half of the same rule.
71
- *
72
- * Whitespace goes too: a trailing space survives the fold and reproduces the
73
- * problem exactly.
74
- *
75
- * Re-exported rather than defined here: `@rebasepro/server` and
76
- * `@rebasepro/server-mongo` write this column too, and a second copy of this
77
- * rule is the defect it exists to prevent.
78
- */
79
- import { normalizeEmail } from "@rebasepro/common";
80
- export { normalizeEmail };
81
-
82
- /**
83
- * PostgreSQL implementation of UserRepository.
84
- * Handles all user-related database operations using Drizzle ORM.
85
- */
86
- export class UserService implements UserRepository {
87
- private usersTable: RebasePgTable;
88
- private userIdentitiesTable: RebasePgTable;
89
-
90
- constructor(
91
- private db: NodePgDatabase,
92
- tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
93
- ) {
94
- if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).users)) {
95
- const tables = tableOrTables as Partial<AuthSchemaTables>;
96
- this.usersTable = (tables.users || users) as RebasePgTable;
97
- this.userIdentitiesTable = (tables.userIdentities || userIdentities) as RebasePgTable;
98
- } else {
99
- const table = tableOrTables as RebasePgTable | undefined;
100
- this.usersTable = table || (users as unknown as RebasePgTable);
101
- this.userIdentitiesTable = userIdentities as unknown as RebasePgTable;
102
- }
103
- }
104
-
105
- private getQualifiedUsersTableName(): string {
106
- const name = getTableName(this.usersTable);
107
- const schema = getTableConfig(this.usersTable).schema || "public";
108
- return `"${schema}"."${name}"`;
109
- }
110
-
111
- /**
112
- * Run a privileged auth write with an explicitly cleared RLS context.
113
- *
114
- * The auth services run on the base/owner connection, which by design
115
- * carries a NULL `app.uid` so the `rebase.uid() IS NULL` server-escape
116
- * in the default policies applies. That NULL is normally guaranteed by
117
- * `set_config(..., is_local = true)` resetting at transaction end — but a
118
- * GUC that survives on a pooled connection (or a connection role that
119
- * doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
120
- * turns the trusted write into an RLS-scoped one and denies it with
121
- * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
122
- * single chokepoint, makes the server context deterministic instead of
123
- * trusting whatever state the pool hands us. `rebase.uid()` reads '' as
124
- * NULL via NULLIF, so '' is the server context.
125
- */
126
- private async withServerContext<T>(fn: (db: NodePgDatabase) => Promise<T>): Promise<T> {
127
- return await this.db.transaction(async (tx) => {
128
- await tx.execute(sql`
129
- SELECT set_config('app.uid', '', true),
130
- set_config('app.user_id', '', true),
131
- set_config('app.user_roles', '', true),
132
- set_config('app.jwt', '', true)
133
- `);
134
- return await fn(tx as unknown as NodePgDatabase);
135
- });
136
- }
137
-
138
- private mapRowToUser(row: Record<string, unknown>): UserData {
139
- if (!row) return row as UserData;
140
-
141
- const id = (row.id ?? row.uid) as string;
142
- const email = row.email as string;
143
- const passwordHash = (row.password_hash ?? row.passwordHash ?? null) as string | null | undefined;
144
- const displayName = (row.display_name ?? row.displayName ?? null) as string | null | undefined;
145
- const photoUrl = (row.photo_url ?? row.photoUrl ?? row.photoURL ?? null) as string | null | undefined;
146
- const emailVerified = (row.email_verified ?? row.emailVerified ?? false) as boolean;
147
- const emailVerificationToken = (row.email_verification_token ?? row.emailVerificationToken ?? null) as string | null | undefined;
148
- const emailVerificationSentAt = (row.email_verification_sent_at ?? row.emailVerificationSentAt ?? null) as string | number | Date | null;
149
- const isAnonymous = (row.is_anonymous ?? row.isAnonymous ?? false) as boolean;
150
- const createdAt = (row.created_at ?? row.createdAt) as string | number | Date | undefined;
151
- const updatedAt = (row.updated_at ?? row.updatedAt) as string | number | Date | undefined;
152
-
153
- const metadata: Record<string, any> = { ...((row.metadata as Record<string, any> | undefined) || {}) };
154
-
155
- const knownKeys = new Set([
156
- "id", "uid", "email",
157
- "password_hash", "passwordHash",
158
- "display_name", "displayName",
159
- "photo_url", "photoUrl", "photoURL",
160
- "email_verified", "emailVerified",
161
- "email_verification_token", "emailVerificationToken",
162
- "email_verification_sent_at", "emailVerificationSentAt",
163
- "is_anonymous", "isAnonymous",
164
- "roles",
165
- "created_at", "createdAt",
166
- "updated_at", "updatedAt",
167
- "metadata"
168
- ]);
169
-
170
- for (const [key, val] of Object.entries(row)) {
171
- if (!knownKeys.has(key)) {
172
- const camelKey = camelCase(key);
173
- metadata[camelKey] = val;
174
- }
175
- }
176
-
177
- return {
178
- id,
179
- email,
180
- passwordHash,
181
- displayName,
182
- photoUrl,
183
- emailVerified,
184
- emailVerificationToken,
185
- emailVerificationSentAt: emailVerificationSentAt ? new Date(emailVerificationSentAt) : null,
186
- isAnonymous,
187
- createdAt: createdAt ? new Date(createdAt) : new Date(),
188
- updatedAt: updatedAt ? new Date(updatedAt) : new Date(),
189
- metadata
190
- };
191
- }
192
-
193
- private mapPayload(data: Partial<CreateUserData>): Record<string, unknown> {
194
- if (!data) return {};
195
-
196
- const payload: Record<string, unknown> = {};
197
-
198
- const idKey = getColumnKey(this.usersTable, "id") || "id";
199
- const emailKey = getColumnKey(this.usersTable, "email") || "email";
200
- const passwordHashKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
201
- const displayNameKey = getColumnKey(this.usersTable, "displayName", "display_name") || "displayName";
202
- const photoUrlKey = getColumnKey(this.usersTable, "photoUrl", "photo_url") || "photoUrl";
203
- const emailVerifiedKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
204
- const emailVerificationTokenKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
205
- const emailVerificationSentAtKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
206
- const isAnonymousKey = getColumnKey(this.usersTable, "isAnonymous", "is_anonymous") || "isAnonymous";
207
- const createdAtKey = getColumnKey(this.usersTable, "createdAt", "created_at") || "createdAt";
208
- const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
209
- const metadataKey = getColumnKey(this.usersTable, "metadata") || "metadata";
210
-
211
- if ("id" in data) payload[idKey] = data.id;
212
- if ("email" in data) payload[emailKey] = normalizeEmail(data.email);
213
- if ("passwordHash" in data) payload[passwordHashKey] = data.passwordHash;
214
- if ("displayName" in data) payload[displayNameKey] = data.displayName;
215
- if ("photoUrl" in data) payload[photoUrlKey] = data.photoUrl;
216
- if ("emailVerified" in data) payload[emailVerifiedKey] = data.emailVerified;
217
- if ("emailVerificationToken" in data) payload[emailVerificationTokenKey] = data.emailVerificationToken;
218
- if ("emailVerificationSentAt" in data) payload[emailVerificationSentAtKey] = data.emailVerificationSentAt;
219
- if ("isAnonymous" in data) payload[isAnonymousKey] = data.isAnonymous;
220
- if ("createdAt" in data) payload[createdAtKey] = data.createdAt;
221
- if ("updatedAt" in data) payload[updatedAtKey] = data.updatedAt;
222
-
223
- const metadata: Record<string, any> = { ...(data.metadata || {}) };
224
- const remainingMetadata: Record<string, any> = {};
225
-
226
- for (const [key, val] of Object.entries(metadata)) {
227
- const tableColKey = getColumnKey(this.usersTable, key);
228
- if (tableColKey &&
229
- tableColKey !== idKey &&
230
- tableColKey !== emailKey &&
231
- tableColKey !== passwordHashKey &&
232
- tableColKey !== displayNameKey &&
233
- tableColKey !== photoUrlKey &&
234
- tableColKey !== emailVerifiedKey &&
235
- tableColKey !== emailVerificationTokenKey &&
236
- tableColKey !== emailVerificationSentAtKey &&
237
- tableColKey !== isAnonymousKey &&
238
- tableColKey !== createdAtKey &&
239
- tableColKey !== updatedAtKey &&
240
- tableColKey !== metadataKey) {
241
- payload[tableColKey] = val;
242
- } else {
243
- remainingMetadata[key] = val;
244
- }
245
- }
246
-
247
- if (metadataKey in this.usersTable) {
248
- payload[metadataKey] = remainingMetadata;
249
- }
250
-
251
- return payload;
252
- }
253
-
254
- /**
255
- * @see UserRepository.createUser — an email already in use is a 409.
256
- *
257
- * The route checks first and answers 409; this is the same answer for the
258
- * requests that get past the check, which two clicks on a signup button
259
- * are enough to produce. `PersistService` has mapped `23505` to a conflict
260
- * for collection writes since the layer that holds the SQLSTATE was made
261
- * responsible for saying whose fault a failure is; the auth writes never
262
- * got the same treatment and reached the client as "Internal Server Error".
263
- */
264
- async createUser(data: CreateUserData): Promise<UserData> {
265
- const payload = this.mapPayload(data);
266
- try {
267
- const [row] = await this.withServerContext(async (db) =>
268
- (await db.insert(this.usersTable).values(payload).returning()) as Record<string, unknown>[]
269
- );
270
- return this.mapRowToUser(row);
271
- } catch (error) {
272
- // Drizzle wraps the pg error, so the SQLSTATE is down the `cause`
273
- // chain rather than on the error itself.
274
- if (extractPgError(error)?.code === "23505") {
275
- throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
276
- }
277
- throw error;
278
- }
279
- }
280
-
281
- async getUserById(id: string): Promise<UserData | null> {
282
- const idCol = getColumn(this.usersTable, "id");
283
- if (!idCol) return null;
284
- const [row] = await this.db.select().from(this.usersTable).where(eq(idCol, id));
285
- return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
286
- }
287
-
288
- async getUserByEmail(email: string): Promise<UserData | null> {
289
- const emailCol = getColumn(this.usersTable, "email");
290
- if (!emailCol) return null;
291
- const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, normalizeEmail(email)));
292
- return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
293
- }
294
-
295
- async getUserByIdentity(provider: string, providerId: string): Promise<UserData | null> {
296
- const userIdCol = getColumn(this.usersTable, "id");
297
- if (!userIdCol) return null;
298
-
299
- const result = await this.db
300
- .select({ user: this.usersTable })
301
- .from(this.usersTable)
302
- .innerJoin(this.userIdentitiesTable, eq(userIdCol, this.userIdentitiesTable.uid))
303
- .where(
304
- sql`${this.userIdentitiesTable.provider} = ${provider} AND ${this.userIdentitiesTable.providerId} = ${providerId}`
305
- )
306
- .limit(1);
307
-
308
- if (result.length === 0) return null;
309
- return this.mapRowToUser(result[0].user as Record<string, unknown>);
310
- }
311
-
312
- async getUserIdentities(uid: string): Promise<UserIdentityData[]> {
313
- const schema = getTableConfig(this.userIdentitiesTable).schema || "public";
314
- const result = await this.db.execute(sql`
315
- SELECT id, uid, provider, provider_id, profile_data, created_at, updated_at
316
- FROM ${sql.raw(`"${schema}"."user_identities"`)}
317
- WHERE uid = ${uid}
318
- `);
319
-
320
- return result.rows.map((row: Record<string, unknown>) => ({
321
- id: row.id as string,
322
- uid: row.uid as string,
323
- provider: row.provider as string,
324
- providerId: row.provider_id as string,
325
- profileData: (row.profile_data as Record<string, unknown> | null) ?? null,
326
- createdAt: row.created_at as Date,
327
- updatedAt: row.updated_at as Date
328
- }));
329
- }
330
-
331
- async linkUserIdentity(uid: string, provider: string, providerId: string, profileData?: Record<string, unknown>): Promise<void> {
332
- await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
333
- uid,
334
- provider,
335
- providerId,
336
- profileData: profileData || null
337
- }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
338
- }
339
-
340
- async updateUser(id: string, data: Partial<Omit<CreateUserData, "id">>): Promise<UserData | null> {
341
- const idCol = getColumn(this.usersTable, "id");
342
- if (!idCol) return null;
343
- const payload = this.mapPayload(data);
344
- const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
345
- payload[updatedAtKey] = new Date();
346
-
347
- const [row] = await this.withServerContext(async (db) =>
348
- (await db
349
- .update(this.usersTable)
350
- .set(payload)
351
- .where(eq(idCol, id))
352
- .returning()) as Record<string, unknown>[]
353
- );
354
- return row ? this.mapRowToUser(row) : null;
355
- }
356
-
357
- async deleteUser(id: string): Promise<void> {
358
- const idCol = getColumn(this.usersTable, "id");
359
- if (!idCol) return;
360
- await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
361
- }
362
-
363
- async listUsers(): Promise<UserData[]> {
364
- const rows = await this.db.select().from(this.usersTable);
365
- return (rows as Record<string, unknown>[]).map(row => this.mapRowToUser(row));
366
- }
367
-
368
- async listUsersPaginated(options?: ListUsersOptions): Promise<PaginatedUsersResult> {
369
- const limit = options?.limit ?? 25;
370
- const offset = options?.offset ?? 0;
371
- const search = options?.search?.trim() || "";
372
- const orderBy = options?.orderBy || "createdAt";
373
- const orderDir = options?.orderDir || "desc";
374
- const roleId = options?.roleId;
375
-
376
- const orderCol = getColumn(this.usersTable, orderBy);
377
- const orderColumn = orderCol ? orderCol.name : "created_at";
378
- const direction = orderDir === "asc" ? sql`ASC` : sql`DESC`;
379
-
380
- const emailCol = getColumn(this.usersTable, "email");
381
- const emailColumn = emailCol ? emailCol.name : "email";
382
- const displayNameCol = getColumn(this.usersTable, "displayName", "display_name");
383
- const displayNameColumn = displayNameCol ? displayNameCol.name : "display_name";
384
- const idCol = getColumn(this.usersTable, "id");
385
- const idColumn = idCol ? idCol.name : "id";
386
-
387
- const usersTableName = this.getQualifiedUsersTableName();
388
- const conditions = [];
389
- if (roleId) {
390
- conditions.push(sql`${roleId} = ANY(${sql.raw(usersTableName)}.roles)`);
391
- }
392
- if (search) {
393
- // `search` is a substring search over the admin user list, not a
394
- // pattern the caller writes: the same reasoning as the collection
395
- // search path, so it shares that path's helper rather than growing
396
- // a second copy that can drift. See `escapeLikePattern`.
397
- const pattern = `%${escapeLikePattern(search)}%`;
398
- conditions.push(sql`(${sql.raw(usersTableName)}.${sql.raw(emailColumn)} ILIKE ${pattern} OR ${sql.raw(usersTableName)}.${sql.raw(displayNameColumn)} ILIKE ${pattern})`);
399
- }
400
-
401
- const whereClause = conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``;
402
-
403
- // Sorting: users with roles first if no role filter, then by requested order
404
- const orderByClause = roleId
405
- ? sql`ORDER BY ${sql.raw(usersTableName)}.${sql.raw(orderColumn)} ${direction}`
406
- : sql`ORDER BY array_length(${sql.raw(usersTableName)}.roles, 1) DESC NULLS LAST, ${sql.raw(usersTableName)}.${sql.raw(orderColumn)} ${direction}`;
407
-
408
- const countResult = await this.db.execute(sql`
409
- SELECT count(*)::int as total FROM ${sql.raw(usersTableName)}
410
- ${whereClause}
411
- `);
412
- const total = (countResult.rows[0] as { total: number }).total;
413
-
414
- const dataResult = await this.db.execute(sql`
415
- SELECT * FROM ${sql.raw(usersTableName)}
416
- ${whereClause}
417
- ${orderByClause}
418
- LIMIT ${limit} OFFSET ${offset}
419
- `);
420
- const rows = dataResult.rows;
421
-
422
- // Map rows to camelCase UserData
423
- const mappedUsers: UserData[] = (rows as Record<string, unknown>[]).map((row) => this.mapRowToUser(row));
424
-
425
- return { users: mappedUsers,
426
- total,
427
- limit,
428
- offset };
429
- }
430
-
431
- /**
432
- * Update user's password hash
433
- */
434
- async updatePassword(id: string, passwordHash: string): Promise<void> {
435
- const idCol = getColumn(this.usersTable, "id");
436
- if (!idCol) return;
437
- const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
438
- const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
439
-
440
- await this.withServerContext(async (db) => db
441
- .update(this.usersTable)
442
- .set({
443
- [passwordHashColKey]: passwordHash,
444
- [updatedAtColKey]: new Date()
445
- })
446
- .where(eq(idCol, id)));
447
- }
448
-
449
- /**
450
- * Set email verification status
451
- */
452
- async setEmailVerified(id: string, verified: boolean): Promise<void> {
453
- const idCol = getColumn(this.usersTable, "id");
454
- if (!idCol) return;
455
- const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
456
- const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
457
- const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
458
-
459
- await this.withServerContext(async (db) => db
460
- .update(this.usersTable)
461
- .set({
462
- [emailVerifiedColKey]: verified,
463
- [emailVerificationTokenColKey]: null,
464
- [updatedAtColKey]: new Date()
465
- })
466
- .where(eq(idCol, id)));
467
- }
468
-
469
- /**
470
- * Set email verification token
471
- */
472
- async setVerificationToken(id: string, token: string | null): Promise<void> {
473
- const idCol = getColumn(this.usersTable, "id");
474
- if (!idCol) return;
475
- const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
476
- const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
477
- const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
478
-
479
- await this.withServerContext(async (db) => db
480
- .update(this.usersTable)
481
- .set({
482
- [emailVerificationTokenColKey]: token,
483
- [emailVerificationSentAtColKey]: token ? new Date() : null,
484
- [updatedAtColKey]: new Date()
485
- })
486
- .where(eq(idCol, id)));
487
- }
488
-
489
- /**
490
- * Find user by email verification token
491
- */
492
- async getUserByVerificationToken(token: string): Promise<UserData | null> {
493
- const tokenCol = getColumn(this.usersTable, "emailVerificationToken", "email_verification_token");
494
- if (!tokenCol) return null;
495
- const [row] = await this.db
496
- .select()
497
- .from(this.usersTable)
498
- .where(eq(tokenCol, token));
499
- return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
500
- }
501
-
502
- /**
503
- * Get roles for a user from database (inline TEXT[] column)
504
- */
505
- async getUserRoles(uid: string): Promise<Role[]> {
506
- const usersTableName = this.getQualifiedUsersTableName();
507
- const result = await this.db.execute(sql`
508
- SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${uid}
509
- `);
510
-
511
- if (result.rows.length === 0) return [];
512
-
513
- const row = result.rows[0] as { roles: string[] | null };
514
- const roleIds = row.roles ?? [];
515
-
516
- return roleIds.map(id => ({
517
- id,
518
- name: id,
519
- isAdmin: id === "admin",
520
- defaultPermissions: null,
521
- collectionPermissions: null
522
- }));
523
- }
524
-
525
- /**
526
- * Get role IDs for a user
527
- */
528
- async getUserRoleIds(uid: string): Promise<string[]> {
529
- const usersTableName = this.getQualifiedUsersTableName();
530
- const result = await this.db.execute(sql`
531
- SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${uid}
532
- `);
533
-
534
- if (result.rows.length === 0) return [];
535
-
536
- const row = result.rows[0] as { roles: string[] | null };
537
- return row.roles ?? [];
538
- }
539
-
540
- /**
541
- * Set roles for a user (replaces existing roles)
542
- */
543
- async setUserRoles(uid: string, roleIds: string[]): Promise<void> {
544
- const usersTableName = this.getQualifiedUsersTableName();
545
- const rolesArray = `{${roleIds.join(",")}}`;
546
- await this.withServerContext(async (db) => db.execute(sql`
547
- UPDATE ${sql.raw(usersTableName)}
548
- SET roles = ${rolesArray}::text[], updated_at = NOW()
549
- WHERE id = ${uid}
550
- `));
551
- }
552
-
553
- /**
554
- * Assign a specific role to new user (appends if not present)
555
- */
556
- async assignDefaultRole(uid: string, roleId: string): Promise<void> {
557
- const usersTableName = this.getQualifiedUsersTableName();
558
- await this.withServerContext(async (db) => db.execute(sql`
559
- UPDATE ${sql.raw(usersTableName)}
560
- SET roles = array_append(roles, ${roleId}), updated_at = NOW()
561
- WHERE id = ${uid} AND NOT (${roleId} = ANY(roles))
562
- `));
563
- }
564
-
565
- /**
566
- * Get user with their roles
567
- */
568
- async getUserWithRoles(uid: string): Promise<{ user: UserData; roles: Role[] } | null> {
569
- const user = await this.getUserById(uid);
570
- if (!user) return null;
571
-
572
- const roles = await this.getUserRoles(uid);
573
- return { user,
574
- roles };
575
- }
576
- }
577
-
578
-
579
- export class RefreshTokenService {
580
- private refreshTokensTable: RebasePgTable;
581
- private usersTable: RebasePgTable | null;
582
-
583
- constructor(
584
- private db: NodePgDatabase,
585
- tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
586
- ) {
587
- if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).refreshTokens || (tableOrTables as Partial<AuthSchemaTables>).users)) {
588
- this.refreshTokensTable = ((tableOrTables as Partial<AuthSchemaTables>).refreshTokens || refreshTokens) as RebasePgTable;
589
- this.usersTable = ((tableOrTables as Partial<AuthSchemaTables>).users || users) as RebasePgTable;
590
- } else {
591
- this.refreshTokensTable = (tableOrTables as RebasePgTable) || (refreshTokens as unknown as RebasePgTable);
592
- this.usersTable = users as unknown as RebasePgTable;
593
- }
594
- }
595
-
596
- /**
597
- * Whether the table actually carries a column, so a host application that
598
- * supplied its own `refresh_tokens` table — one that predates session
599
- * grouping — degrades instead of throwing on every sign-in.
600
- */
601
- private has(column: string): boolean {
602
- return Boolean((this.refreshTokensTable as unknown as Record<string, unknown>)[column]);
603
- }
604
-
605
- private col(column: string) {
606
- return (this.refreshTokensTable as unknown as Record<string, never>)[column];
607
- }
608
-
609
- /** The columns to read back, narrowed to the ones this table has. */
610
- private selection() {
611
- const selection: Record<string, never> = {
612
- id: this.refreshTokensTable.id,
613
- uid: this.refreshTokensTable.uid,
614
- tokenHash: this.refreshTokensTable.tokenHash,
615
- expiresAt: this.refreshTokensTable.expiresAt,
616
- createdAt: this.refreshTokensTable.createdAt,
617
- userAgent: this.refreshTokensTable.userAgent,
618
- ipAddress: this.refreshTokensTable.ipAddress
619
- } as unknown as Record<string, never>;
620
- for (const optional of ["sessionId", "rotatedAt", "revoked", "sessionStartedAt", "aal"]) {
621
- if (this.has(optional)) selection[optional] = this.col(optional);
622
- }
623
- return selection;
624
- }
625
-
626
- async createToken(
627
- uid: string,
628
- tokenHash: string,
629
- expiresAt: Date,
630
- userAgent?: string,
631
- ipAddress?: string,
632
- session?: RefreshTokenSession
633
- ): Promise<void> {
634
- // Empty strings rather than NULLs: the device-session UNIQUE constraint
635
- // that needed them is gone, but sessions-list UIs already render "" as
636
- // "unknown device" and would start showing blanks otherwise.
637
- const safeUserAgent = userAgent || "";
638
- const safeIpAddress = ipAddress || "";
639
-
640
- // A plain INSERT. Rotation ADDS a token; it does not replace a device's
641
- // row. Two refreshes racing on the same session therefore both succeed
642
- // and both end holding a usable token, where the previous upsert had
643
- // them overwrite each other and logged one of the two tabs out.
644
- const values: Record<string, unknown> = {
645
- uid,
646
- tokenHash,
647
- expiresAt,
648
- userAgent: safeUserAgent,
649
- ipAddress: safeIpAddress
650
- };
651
- if (session && this.has("sessionId")) values.sessionId = session.id;
652
- if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
653
- // Written on every token of the session, including the ones rotation
654
- // mints, because refresh reads the level off whichever row was
655
- // presented. A table without the column degrades to `aal1` on read,
656
- // which is the restrictive answer rather than a bypass.
657
- if (session?.aal && this.has("aal")) values.aal = session.aal;
658
-
659
- await this.db.insert(this.refreshTokensTable).values(values);
660
- }
661
-
662
- async findByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
663
- const [token] = await this.db
664
- .select(this.selection())
665
- .from(this.refreshTokensTable)
666
- .where(eq(this.refreshTokensTable.tokenHash, tokenHash));
667
-
668
- return (token as unknown as RefreshTokenInfo) || null;
669
- }
670
-
671
- /**
672
- * Record that a token was rotated away, keeping the row.
673
- *
674
- * The row is what lets `/auth/refresh` distinguish "you already used this,
675
- * here is a fresh one" from "no idea what this is". Deleting it — which is
676
- * what this used to do — collapsed both into a 401 and signed the user out
677
- * for the crime of losing a response.
678
- */
679
- async markRotated(tokenHash: string): Promise<void> {
680
- if (!this.has("rotatedAt")) {
681
- await this.deleteByHash(tokenHash);
682
- return;
683
- }
684
- await this.db
685
- .update(this.refreshTokensTable)
686
- .set({ rotatedAt: new Date() })
687
- .where(eq(this.refreshTokensTable.tokenHash, tokenHash));
688
- }
689
-
690
- /** Final kill of one sign-in: logout, or revoking a device remotely. */
691
- async revokeSession(sessionId: string): Promise<void> {
692
- if (!this.has("sessionId")) return;
693
- if (this.has("revoked")) {
694
- await this.db
695
- .update(this.refreshTokensTable)
696
- .set({ revoked: true, ...(this.has("rotatedAt") ? { rotatedAt: new Date() } : {}) })
697
- .where(eq(this.col("sessionId"), sessionId));
698
- return;
699
- }
700
- await this.db.delete(this.refreshTokensTable).where(eq(this.col("sessionId"), sessionId));
701
- }
702
-
703
- /**
704
- * Housekeeping: rotation would otherwise leave a row per refresh forever.
705
- * Superseded rows are only needed for as long as a straggler might still
706
- * present them, and expired ones are dead weight everywhere.
707
- */
708
- async prune(uid: string, sessionId: string, supersededBefore: Date): Promise<void> {
709
- const uidCol = this.refreshTokensTable.uid;
710
- const expiresCol = this.refreshTokensTable.expiresAt;
711
- if (!this.has("rotatedAt") || !this.has("sessionId")) {
712
- await this.db.delete(this.refreshTokensTable)
713
- .where(sql`${uidCol} = ${uid} AND ${expiresCol} < NOW()`);
714
- return;
715
- }
716
- const rotatedCol = this.col("rotatedAt");
717
- const sessionCol = this.col("sessionId");
718
- await this.db.delete(this.refreshTokensTable).where(sql`
719
- ${uidCol} = ${uid}
720
- AND (
721
- ${expiresCol} < NOW()
722
- OR (
723
- ${sessionCol} = ${sessionId}
724
- AND ${rotatedCol} IS NOT NULL
725
- AND ${rotatedCol} < ${supersededBefore}
726
- )
727
- )
728
- `);
729
- }
730
-
731
- async getTokensValidAfter(uid: string): Promise<Date | null> {
732
- if (!this.usersTable || !(this.usersTable as unknown as Record<string, unknown>).tokensValidAfter) return null;
733
- const [row] = await this.db
734
- .select({ tokensValidAfter: (this.usersTable as unknown as Record<string, never>).tokensValidAfter })
735
- .from(this.usersTable)
736
- .where(eq(this.usersTable.id, uid));
737
- const value = (row as { tokensValidAfter?: Date | string | null } | undefined)?.tokensValidAfter;
738
- return value ? new Date(value) : null;
739
- }
740
-
741
- async setTokensValidAfter(uid: string, at: Date): Promise<void> {
742
- if (!this.usersTable || !(this.usersTable as unknown as Record<string, unknown>).tokensValidAfter) return;
743
- await this.db
744
- .update(this.usersTable)
745
- .set({ tokensValidAfter: at })
746
- .where(eq(this.usersTable.id, uid));
747
- }
748
-
749
- async deleteByHash(tokenHash: string): Promise<void> {
750
- await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
751
- }
752
-
753
- async deleteAllForUser(uid: string): Promise<void> {
754
- await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid));
755
- }
756
-
757
- async listForUser(uid: string): Promise<RefreshTokenInfo[]> {
758
- const tokens = await this.db
759
- .select(this.selection())
760
- .from(this.refreshTokensTable)
761
- .where(eq(this.refreshTokensTable.uid, uid))
762
- .orderBy(this.refreshTokensTable.createdAt);
763
-
764
- return tokens as unknown as RefreshTokenInfo[];
765
- }
766
-
767
- async deleteById(id: string, uid: string): Promise<void> {
768
- await this.db.delete(this.refreshTokensTable)
769
- .where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.uid} = ${uid}`);
770
- }
771
- }
772
-
773
- /**
774
- * Password reset token service
775
- */
776
- export class PasswordResetTokenService {
777
- private passwordResetTokensTable: RebasePgTable;
778
-
779
- constructor(
780
- private db: NodePgDatabase,
781
- tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
782
- ) {
783
- if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).passwordResetTokens || (tableOrTables as Partial<AuthSchemaTables>).users)) {
784
- this.passwordResetTokensTable = ((tableOrTables as Partial<AuthSchemaTables>).passwordResetTokens || passwordResetTokens) as RebasePgTable;
785
- } else {
786
- this.passwordResetTokensTable = (tableOrTables as RebasePgTable) || (passwordResetTokens as unknown as RebasePgTable);
787
- }
788
- }
789
-
790
- private getQualifiedPasswordResetTokensTableName(): string {
791
- const name = getTableName(this.passwordResetTokensTable);
792
- const schema = getTableConfig(this.passwordResetTokensTable).schema || "public";
793
- return `"${schema}"."${name}"`;
794
- }
795
-
796
- /**
797
- * Create a password reset token
798
- */
799
- async createToken(uid: string, tokenHash: string, expiresAt: Date): Promise<void> {
800
- // Delete any existing unused tokens for this user
801
- const tableName = this.getQualifiedPasswordResetTokensTableName();
802
- await this.db.execute(sql`
803
- DELETE FROM ${sql.raw(tableName)}
804
- WHERE uid = ${uid} AND used_at IS NULL
805
- `);
806
-
807
- await this.db.insert(this.passwordResetTokensTable).values({
808
- uid,
809
- tokenHash,
810
- expiresAt
811
- });
812
- }
813
-
814
- /**
815
- * Find a valid (not expired, not used) token by hash
816
- */
817
- async findValidByHash(tokenHash: string): Promise<{ uid: string; expiresAt: Date } | null> {
818
- const [token] = await this.db
819
- .select({
820
- uid: this.passwordResetTokensTable.uid,
821
- expiresAt: this.passwordResetTokensTable.expiresAt
822
- })
823
- .from(this.passwordResetTokensTable)
824
- .where(eq(this.passwordResetTokensTable.tokenHash, tokenHash)) as unknown as Array<{ uid: string; expiresAt: Date }>;
825
-
826
- if (!token) return null;
827
-
828
- // Check if expired or used
829
- const tableName = this.getQualifiedPasswordResetTokensTableName();
830
- const result = await this.db.execute(sql`
831
- SELECT uid, expires_at
832
- FROM ${sql.raw(tableName)}
833
- WHERE token_hash = ${tokenHash}
834
- AND used_at IS NULL
835
- AND expires_at > NOW()
836
- `);
837
-
838
- if (result.rows.length === 0) return null;
839
-
840
- const row = result.rows[0] as { uid: string; expires_at: string | number | Date };
841
- return {
842
- uid: row.uid,
843
- expiresAt: new Date(row.expires_at)
844
- };
845
- }
846
-
847
- /**
848
- * Mark token as used
849
- */
850
- async markAsUsed(tokenHash: string): Promise<void> {
851
- await this.db
852
- .update(this.passwordResetTokensTable)
853
- .set({ usedAt: new Date() })
854
- .where(eq(this.passwordResetTokensTable.tokenHash, tokenHash));
855
- }
856
-
857
- /**
858
- * Delete all tokens for a user
859
- */
860
- async deleteAllForUser(uid: string): Promise<void> {
861
- await this.db.delete(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.uid, uid));
862
- }
863
-
864
- /**
865
- * Clean up expired tokens
866
- */
867
- async deleteExpired(): Promise<void> {
868
- const tableName = this.getQualifiedPasswordResetTokensTableName();
869
- await this.db.execute(sql`
870
- DELETE FROM ${sql.raw(tableName)}
871
- WHERE expires_at < NOW()
872
- `);
873
- }
874
- }
875
-
876
- /**
877
- * Magic link token service.
878
- * Handles magic link token storage for passwordless email login.
879
- */
880
- export class MagicLinkTokenService {
881
- private magicLinkTokensTable: RebasePgTable;
882
-
883
- constructor(
884
- private db: NodePgDatabase,
885
- tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
886
- ) {
887
- this.magicLinkTokensTable = (magicLinkTokens as unknown as RebasePgTable);
888
- }
889
-
890
- private getQualifiedTableName(): string {
891
- const name = getTableName(this.magicLinkTokensTable);
892
- const schema = getTableConfig(this.magicLinkTokensTable).schema || "public";
893
- return `"${schema}"."${name}"`;
894
- }
895
-
896
- async createToken(uid: string, tokenHash: string, expiresAt: Date): Promise<void> {
897
- // Delete any existing unused tokens for this user
898
- const tableName = this.getQualifiedTableName();
899
- await this.db.execute(sql`
900
- DELETE FROM ${sql.raw(tableName)}
901
- WHERE uid = ${uid} AND used_at IS NULL
902
- `);
903
-
904
- await this.db.insert(this.magicLinkTokensTable).values({
905
- uid,
906
- tokenHash,
907
- expiresAt
908
- });
909
- }
910
-
911
- async findValidByHash(tokenHash: string): Promise<MagicLinkTokenInfo | null> {
912
- const tableName = this.getQualifiedTableName();
913
- const result = await this.db.execute(sql`
914
- SELECT uid, expires_at
915
- FROM ${sql.raw(tableName)}
916
- WHERE token_hash = ${tokenHash}
917
- AND used_at IS NULL
918
- AND expires_at > NOW()
919
- `);
920
-
921
- if (result.rows.length === 0) return null;
922
-
923
- const row = result.rows[0] as { uid: string; expires_at: string | number | Date };
924
- return {
925
- uid: row.uid,
926
- expiresAt: new Date(row.expires_at)
927
- };
928
- }
929
-
930
- async markAsUsed(tokenHash: string): Promise<void> {
931
- await this.db
932
- .update(this.magicLinkTokensTable)
933
- .set({ usedAt: new Date() })
934
- .where(eq(this.magicLinkTokensTable.tokenHash, tokenHash));
935
- }
936
- }
937
-
938
- /**
939
- * PostgreSQL implementation of TokenRepository.
940
- * Combines refresh token and password reset token operations.
941
- */
942
- export class PostgresTokenRepository implements TokenRepository {
943
- private refreshTokenService: RefreshTokenService;
944
- private passwordResetTokenService: PasswordResetTokenService;
945
- private magicLinkTokenService: MagicLinkTokenService;
946
-
947
- constructor(
948
- private db: NodePgDatabase,
949
- tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
950
- ) {
951
- this.refreshTokenService = new RefreshTokenService(db, tableOrTables);
952
- this.passwordResetTokenService = new PasswordResetTokenService(db, tableOrTables);
953
- this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
954
- }
955
-
956
- // Refresh token operations
957
-
958
- async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string, session?: RefreshTokenSession): Promise<void> {
959
- await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
960
- }
961
-
962
- async markRefreshTokenRotated(tokenHash: string): Promise<void> {
963
- await this.refreshTokenService.markRotated(tokenHash);
964
- }
965
-
966
- async revokeRefreshTokenSession(sessionId: string): Promise<void> {
967
- await this.refreshTokenService.revokeSession(sessionId);
968
- }
969
-
970
- async pruneRefreshTokens(uid: string, sessionId: string, supersededBefore: Date): Promise<void> {
971
- await this.refreshTokenService.prune(uid, sessionId, supersededBefore);
972
- }
973
-
974
- async getTokensValidAfter(uid: string): Promise<Date | null> {
975
- return this.refreshTokenService.getTokensValidAfter(uid);
976
- }
977
-
978
- async setTokensValidAfter(uid: string, at: Date): Promise<void> {
979
- await this.refreshTokenService.setTokensValidAfter(uid, at);
980
- }
981
-
982
- async findRefreshTokenByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
983
- return this.refreshTokenService.findByHash(tokenHash);
984
- }
985
-
986
- async deleteRefreshToken(tokenHash: string): Promise<void> {
987
- await this.refreshTokenService.deleteByHash(tokenHash);
988
- }
989
-
990
- async deleteAllRefreshTokensForUser(uid: string): Promise<void> {
991
- await this.refreshTokenService.deleteAllForUser(uid);
992
- }
993
-
994
- async listRefreshTokensForUser(uid: string): Promise<RefreshTokenInfo[]> {
995
- return this.refreshTokenService.listForUser(uid);
996
- }
997
-
998
- async deleteRefreshTokenById(id: string, uid: string): Promise<void> {
999
- await this.refreshTokenService.deleteById(id, uid);
1000
- }
1001
-
1002
- // Password reset token operations
1003
-
1004
- async createPasswordResetToken(uid: string, tokenHash: string, expiresAt: Date): Promise<void> {
1005
- await this.passwordResetTokenService.createToken(uid, tokenHash, expiresAt);
1006
- }
1007
-
1008
- async findValidPasswordResetToken(tokenHash: string): Promise<PasswordResetTokenInfo | null> {
1009
- return this.passwordResetTokenService.findValidByHash(tokenHash);
1010
- }
1011
-
1012
- async markPasswordResetTokenUsed(tokenHash: string): Promise<void> {
1013
- await this.passwordResetTokenService.markAsUsed(tokenHash);
1014
- }
1015
-
1016
- async deleteAllPasswordResetTokensForUser(uid: string): Promise<void> {
1017
- await this.passwordResetTokenService.deleteAllForUser(uid);
1018
- }
1019
-
1020
- async deleteExpiredTokens(): Promise<void> {
1021
- await this.passwordResetTokenService.deleteExpired();
1022
- }
1023
-
1024
- // Magic link token operations
1025
-
1026
- async createMagicLinkToken(uid: string, tokenHash: string, expiresAt: Date): Promise<void> {
1027
- await this.magicLinkTokenService.createToken(uid, tokenHash, expiresAt);
1028
- }
1029
-
1030
- async findValidMagicLinkToken(tokenHash: string): Promise<MagicLinkTokenInfo | null> {
1031
- return this.magicLinkTokenService.findValidByHash(tokenHash);
1032
- }
1033
-
1034
- async markMagicLinkTokenUsed(tokenHash: string): Promise<void> {
1035
- await this.magicLinkTokenService.markAsUsed(tokenHash);
1036
- }
1037
- }
1038
-
1039
- /**
1040
- * PostgreSQL implementation of AuthRepository.
1041
- * Combines user, role, and token repository operations.
1042
- * This provides a convenient single-class interface for all auth operations.
1043
- */
1044
- export class PostgresAuthRepository implements AuthRepository {
1045
- private userService: UserService;
1046
- private tokenRepository: PostgresTokenRepository;
1047
-
1048
- constructor(
1049
- private db: NodePgDatabase,
1050
- tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
1051
- ) {
1052
- this.userService = new UserService(db, tableOrTables);
1053
- this.tokenRepository = new PostgresTokenRepository(db, tableOrTables);
1054
- }
1055
-
1056
- // User operations (delegate to UserService)
1057
-
1058
- async createUser(data: CreateUserData): Promise<UserData> {
1059
- return this.userService.createUser(data);
1060
- }
1061
-
1062
- async getUserById(id: string): Promise<UserData | null> {
1063
- return this.userService.getUserById(id);
1064
- }
1065
-
1066
- async getUserByEmail(email: string): Promise<UserData | null> {
1067
- return this.userService.getUserByEmail(email);
1068
- }
1069
-
1070
- async getUserByIdentity(provider: string, providerId: string): Promise<UserData | null> {
1071
- return this.userService.getUserByIdentity(provider, providerId);
1072
- }
1073
-
1074
- async getUserIdentities(uid: string): Promise<UserIdentityData[]> {
1075
- return this.userService.getUserIdentities(uid);
1076
- }
1077
-
1078
- async linkUserIdentity(uid: string, provider: string, providerId: string, profileData?: Record<string, unknown>): Promise<void> {
1079
- return this.userService.linkUserIdentity(uid, provider, providerId, profileData);
1080
- }
1081
-
1082
- async updateUser(id: string, data: Partial<Omit<CreateUserData, "id">>): Promise<UserData | null> {
1083
- return this.userService.updateUser(id, data);
1084
- }
1085
-
1086
- async deleteUser(id: string): Promise<void> {
1087
- await this.userService.deleteUser(id);
1088
- }
1089
-
1090
- async listUsers(): Promise<UserData[]> {
1091
- return this.userService.listUsers();
1092
- }
1093
-
1094
- async listUsersPaginated(options?: ListUsersOptions): Promise<PaginatedUsersResult> {
1095
- return this.userService.listUsersPaginated(options);
1096
- }
1097
-
1098
- async updatePassword(id: string, passwordHash: string): Promise<void> {
1099
- await this.userService.updatePassword(id, passwordHash);
1100
- }
1101
-
1102
- async setEmailVerified(id: string, verified: boolean): Promise<void> {
1103
- await this.userService.setEmailVerified(id, verified);
1104
- }
1105
-
1106
- async setVerificationToken(id: string, token: string | null): Promise<void> {
1107
- await this.userService.setVerificationToken(id, token);
1108
- }
1109
-
1110
- async getUserByVerificationToken(token: string): Promise<UserData | null> {
1111
- return this.userService.getUserByVerificationToken(token);
1112
- }
1113
-
1114
- async getUserRoles(uid: string): Promise<RoleData[]> {
1115
- return this.userService.getUserRoles(uid);
1116
- }
1117
-
1118
- async getUserRoleIds(uid: string): Promise<string[]> {
1119
- return this.userService.getUserRoleIds(uid);
1120
- }
1121
-
1122
- async setUserRoles(uid: string, roleIds: string[]): Promise<void> {
1123
- await this.userService.setUserRoles(uid, roleIds);
1124
- }
1125
-
1126
- async assignDefaultRole(uid: string, roleId: string): Promise<void> {
1127
- await this.userService.assignDefaultRole(uid, roleId);
1128
- }
1129
-
1130
- async getUserWithRoles(uid: string): Promise<{ user: UserData; roles: RoleData[] } | null> {
1131
- return this.userService.getUserWithRoles(uid);
1132
- }
1133
-
1134
- // Role operations (roles are inline on users, synthesized from string IDs)
1135
-
1136
- async getRoleById(id: string): Promise<RoleData | null> {
1137
- return {
1138
- id,
1139
- name: id,
1140
- isAdmin: id === "admin",
1141
- defaultPermissions: null,
1142
- collectionPermissions: null
1143
- };
1144
- }
1145
-
1146
- async listRoles(): Promise<RoleData[]> {
1147
- return [
1148
- { id: "admin",
1149
- name: "Admin",
1150
- isAdmin: true,
1151
- defaultPermissions: null,
1152
- collectionPermissions: null },
1153
- { id: "editor",
1154
- name: "Editor",
1155
- isAdmin: false,
1156
- defaultPermissions: null,
1157
- collectionPermissions: null },
1158
- { id: "viewer",
1159
- name: "Viewer",
1160
- isAdmin: false,
1161
- defaultPermissions: null,
1162
- collectionPermissions: null }
1163
- ];
1164
- }
1165
-
1166
- async createRole(_data: CreateRoleData): Promise<RoleData> {
1167
- return {
1168
- id: _data.id,
1169
- name: _data.name,
1170
- isAdmin: _data.isAdmin ?? false,
1171
- defaultPermissions: _data.defaultPermissions ?? null,
1172
- collectionPermissions: _data.collectionPermissions ?? null
1173
- };
1174
- }
1175
-
1176
- async updateRole(id: string, data: Partial<Omit<RoleData, "id">>): Promise<RoleData | null> {
1177
- return {
1178
- id,
1179
- name: data.name ?? id,
1180
- isAdmin: data.isAdmin ?? (id === "admin"),
1181
- defaultPermissions: data.defaultPermissions ?? null,
1182
- collectionPermissions: data.collectionPermissions ?? null
1183
- };
1184
- }
1185
-
1186
- async deleteRole(_id: string): Promise<void> {
1187
- // No-op: roles are inline strings on users
1188
- }
1189
-
1190
- // Token operations (delegate to PostgresTokenRepository)
1191
-
1192
- async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string, session?: RefreshTokenSession): Promise<void> {
1193
- await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
1194
- }
1195
-
1196
- async markRefreshTokenRotated(tokenHash: string): Promise<void> {
1197
- await this.tokenRepository.markRefreshTokenRotated(tokenHash);
1198
- }
1199
-
1200
- async revokeRefreshTokenSession(sessionId: string): Promise<void> {
1201
- await this.tokenRepository.revokeRefreshTokenSession(sessionId);
1202
- }
1203
-
1204
- async pruneRefreshTokens(uid: string, sessionId: string, supersededBefore: Date): Promise<void> {
1205
- await this.tokenRepository.pruneRefreshTokens(uid, sessionId, supersededBefore);
1206
- }
1207
-
1208
- async getTokensValidAfter(uid: string): Promise<Date | null> {
1209
- return this.tokenRepository.getTokensValidAfter(uid);
1210
- }
1211
-
1212
- async setTokensValidAfter(uid: string, at: Date): Promise<void> {
1213
- await this.tokenRepository.setTokensValidAfter(uid, at);
1214
- }
1215
-
1216
- async findRefreshTokenByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
1217
- return this.tokenRepository.findRefreshTokenByHash(tokenHash);
1218
- }
1219
-
1220
- async deleteRefreshToken(tokenHash: string): Promise<void> {
1221
- await this.tokenRepository.deleteRefreshToken(tokenHash);
1222
- }
1223
-
1224
- async deleteAllRefreshTokensForUser(uid: string): Promise<void> {
1225
- await this.tokenRepository.deleteAllRefreshTokensForUser(uid);
1226
- }
1227
-
1228
- async listRefreshTokensForUser(uid: string): Promise<RefreshTokenInfo[]> {
1229
- return this.tokenRepository.listRefreshTokensForUser(uid);
1230
- }
1231
-
1232
- async deleteRefreshTokenById(id: string, uid: string): Promise<void> {
1233
- await this.tokenRepository.deleteRefreshTokenById(id, uid);
1234
- }
1235
-
1236
- async createPasswordResetToken(uid: string, tokenHash: string, expiresAt: Date): Promise<void> {
1237
- await this.tokenRepository.createPasswordResetToken(uid, tokenHash, expiresAt);
1238
- }
1239
-
1240
- async findValidPasswordResetToken(tokenHash: string): Promise<PasswordResetTokenInfo | null> {
1241
- return this.tokenRepository.findValidPasswordResetToken(tokenHash);
1242
- }
1243
-
1244
- async markPasswordResetTokenUsed(tokenHash: string): Promise<void> {
1245
- await this.tokenRepository.markPasswordResetTokenUsed(tokenHash);
1246
- }
1247
-
1248
- async deleteAllPasswordResetTokensForUser(uid: string): Promise<void> {
1249
- await this.tokenRepository.deleteAllPasswordResetTokensForUser(uid);
1250
- }
1251
-
1252
- async deleteExpiredTokens(): Promise<void> {
1253
- await this.tokenRepository.deleteExpiredTokens();
1254
- }
1255
-
1256
- // Magic link token operations
1257
-
1258
- async createMagicLinkToken(uid: string, tokenHash: string, expiresAt: Date): Promise<void> {
1259
- await this.tokenRepository.createMagicLinkToken(uid, tokenHash, expiresAt);
1260
- }
1261
-
1262
- async findValidMagicLinkToken(tokenHash: string): Promise<MagicLinkTokenInfo | null> {
1263
- return this.tokenRepository.findValidMagicLinkToken(tokenHash);
1264
- }
1265
-
1266
- async markMagicLinkTokenUsed(tokenHash: string): Promise<void> {
1267
- await this.tokenRepository.markMagicLinkTokenUsed(tokenHash);
1268
- }
1269
-
1270
- // MFA operations (delegate to MfaService)
1271
-
1272
- private _mfaService: MfaService | null = null;
1273
- private getMfaService(): MfaService {
1274
- if (!this._mfaService) {
1275
- this._mfaService = new MfaService(this.db);
1276
- }
1277
- return this._mfaService;
1278
- }
1279
-
1280
- async createMfaFactor(uid: string, factorType: "totp", secretEncrypted: string, friendlyName?: string): Promise<MfaFactor> {
1281
- return this.getMfaService().createMfaFactor(uid, factorType, secretEncrypted, friendlyName);
1282
- }
1283
-
1284
- async getMfaFactors(uid: string): Promise<MfaFactor[]> {
1285
- return this.getMfaService().getMfaFactors(uid);
1286
- }
1287
-
1288
- async getMfaFactorById(factorId: string): Promise<(MfaFactor & { secretEncrypted: string }) | null> {
1289
- return this.getMfaService().getMfaFactorById(factorId);
1290
- }
1291
-
1292
- async verifyMfaFactor(factorId: string): Promise<void> {
1293
- return this.getMfaService().verifyMfaFactor(factorId);
1294
- }
1295
-
1296
- async updateMfaFactorSecret(factorId: string, secretEncrypted: string): Promise<void> {
1297
- return this.getMfaService().updateMfaFactorSecret(factorId, secretEncrypted);
1298
- }
1299
-
1300
- async deleteMfaFactor(factorId: string, uid: string): Promise<void> {
1301
- return this.getMfaService().deleteMfaFactor(factorId, uid);
1302
- }
1303
-
1304
- async createMfaChallenge(factorId: string, ipAddress?: string): Promise<MfaChallengeInfo> {
1305
- return this.getMfaService().createMfaChallenge(factorId, ipAddress);
1306
- }
1307
-
1308
- async getMfaChallengeById(challengeId: string): Promise<MfaChallengeInfo | null> {
1309
- return this.getMfaService().getMfaChallengeById(challengeId);
1310
- }
1311
-
1312
- async verifyMfaChallenge(challengeId: string): Promise<void> {
1313
- return this.getMfaService().verifyMfaChallenge(challengeId);
1314
- }
1315
-
1316
- async createRecoveryCodes(uid: string, codeHashes: string[]): Promise<void> {
1317
- return this.getMfaService().createRecoveryCodes(uid, codeHashes);
1318
- }
1319
-
1320
- async useRecoveryCode(uid: string, codeHash: string): Promise<boolean> {
1321
- return this.getMfaService().useRecoveryCode(uid, codeHash);
1322
- }
1323
-
1324
- async getUnusedRecoveryCodeCount(uid: string): Promise<number> {
1325
- return this.getMfaService().getUnusedRecoveryCodeCount(uid);
1326
- }
1327
-
1328
- async deleteAllRecoveryCodes(uid: string): Promise<void> {
1329
- return this.getMfaService().deleteAllRecoveryCodes(uid);
1330
- }
1331
-
1332
- async hasVerifiedMfaFactors(uid: string): Promise<boolean> {
1333
- return this.getMfaService().hasVerifiedMfaFactors(uid);
1334
- }
1335
-
1336
- async claimMfaFactorCounter(factorId: string, counter: number): Promise<boolean> {
1337
- return this.getMfaService().claimMfaFactorCounter(factorId, counter);
1338
- }
1339
-
1340
- async recordMfaChallengeAttempt(challengeId: string): Promise<number> {
1341
- return this.getMfaService().recordMfaChallengeAttempt(challengeId);
1342
- }
1343
- }
1344
-
1345
- // =============================================================================
1346
- // MFA SERVICE
1347
- // =============================================================================
1348
-
1349
- /**
1350
- * PostgreSQL implementation of MfaRepository.
1351
- * Handles all MFA-related database operations.
1352
- */
1353
- export class MfaService implements MfaRepository {
1354
- constructor(private db: NodePgDatabase, private schemaName = "rebase") {}
1355
-
1356
- private qualify(tableName: string): string {
1357
- return `"${this.schemaName}"."${tableName}"`;
1358
- }
1359
-
1360
- async createMfaFactor(
1361
- uid: string,
1362
- factorType: "totp",
1363
- secretEncrypted: string,
1364
- friendlyName?: string
1365
- ): Promise<MfaFactor> {
1366
- const tableName = this.qualify("mfa_factors");
1367
- const result = await this.db.execute(sql`
1368
- INSERT INTO ${sql.raw(tableName)} (uid, factor_type, secret_encrypted, friendly_name)
1369
- VALUES (${uid}, ${factorType}, ${secretEncrypted}, ${friendlyName ?? null})
1370
- RETURNING id, uid, factor_type, friendly_name, verified, created_at, updated_at
1371
- `);
1372
-
1373
- const row = result.rows[0] as Record<string, unknown>;
1374
- return {
1375
- id: row.id as string,
1376
- uid: row.uid as string,
1377
- factorType: row.factor_type as "totp",
1378
- friendlyName: (row.friendly_name as string | null) ?? undefined,
1379
- verified: row.verified as boolean,
1380
- createdAt: new Date(row.created_at as string),
1381
- updatedAt: new Date(row.updated_at as string)
1382
- };
1383
- }
1384
-
1385
- async getMfaFactors(uid: string): Promise<MfaFactor[]> {
1386
- const tableName = this.qualify("mfa_factors");
1387
- const result = await this.db.execute(sql`
1388
- SELECT id, uid, factor_type, friendly_name, verified, created_at, updated_at
1389
- FROM ${sql.raw(tableName)}
1390
- WHERE uid = ${uid}
1391
- ORDER BY created_at
1392
- `);
1393
-
1394
- return (result.rows as Array<Record<string, unknown>>).map(row => ({
1395
- id: row.id as string,
1396
- uid: row.uid as string,
1397
- factorType: row.factor_type as "totp",
1398
- friendlyName: (row.friendly_name as string | null) ?? undefined,
1399
- verified: row.verified as boolean,
1400
- createdAt: new Date(row.created_at as string),
1401
- updatedAt: new Date(row.updated_at as string)
1402
- }));
1403
- }
1404
-
1405
- async getMfaFactorById(factorId: string): Promise<(MfaFactor & { secretEncrypted: string }) | null> {
1406
- const tableName = this.qualify("mfa_factors");
1407
- const result = await this.db.execute(sql`
1408
- SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, last_used_counter, created_at, updated_at
1409
- FROM ${sql.raw(tableName)}
1410
- WHERE id = ${factorId}
1411
- `);
1412
-
1413
- if (result.rows.length === 0) return null;
1414
-
1415
- const row = result.rows[0] as Record<string, unknown>;
1416
- return {
1417
- id: row.id as string,
1418
- uid: row.uid as string,
1419
- factorType: row.factor_type as "totp",
1420
- secretEncrypted: row.secret_encrypted as string,
1421
- friendlyName: (row.friendly_name as string | null) ?? undefined,
1422
- verified: row.verified as boolean,
1423
- // BIGINT comes back as a string from node-postgres.
1424
- lastUsedCounter: row.last_used_counter === null || row.last_used_counter === undefined
1425
- ? null
1426
- : Number(row.last_used_counter),
1427
- createdAt: new Date(row.created_at as string),
1428
- updatedAt: new Date(row.updated_at as string)
1429
- };
1430
- }
1431
-
1432
- /**
1433
- * Spend a TOTP time step, once and only once.
1434
- *
1435
- * One statement: the `WHERE` is the check, the `UPDATE` is the act, and
1436
- * `RETURNING` reports which of two concurrent requests carrying the same
1437
- * six digits won. Reading the counter and then writing it would let both
1438
- * pass — the exact replay this closes.
1439
- */
1440
- async claimMfaFactorCounter(factorId: string, counter: number): Promise<boolean> {
1441
- const tableName = this.qualify("mfa_factors");
1442
- const result = await this.db.execute(sql`
1443
- UPDATE ${sql.raw(tableName)}
1444
- SET last_used_counter = ${counter}, updated_at = NOW()
1445
- WHERE id = ${factorId}
1446
- AND (last_used_counter IS NULL OR last_used_counter < ${counter})
1447
- RETURNING id
1448
- `);
1449
-
1450
- return result.rows.length > 0;
1451
- }
1452
-
1453
- async verifyMfaFactor(factorId: string): Promise<void> {
1454
- const tableName = this.qualify("mfa_factors");
1455
- await this.db.execute(sql`
1456
- UPDATE ${sql.raw(tableName)}
1457
- SET verified = TRUE, updated_at = NOW()
1458
- WHERE id = ${factorId}
1459
- `);
1460
- }
1461
-
1462
- async updateMfaFactorSecret(factorId: string, secretEncrypted: string): Promise<void> {
1463
- const tableName = this.qualify("mfa_factors");
1464
- await this.db.execute(sql`
1465
- UPDATE ${sql.raw(tableName)}
1466
- SET secret_encrypted = ${secretEncrypted}, updated_at = NOW()
1467
- WHERE id = ${factorId}
1468
- `);
1469
- }
1470
-
1471
- async deleteMfaFactor(factorId: string, uid: string): Promise<void> {
1472
- const tableName = this.qualify("mfa_factors");
1473
- await this.db.execute(sql`
1474
- DELETE FROM ${sql.raw(tableName)}
1475
- WHERE id = ${factorId} AND uid = ${uid}
1476
- `);
1477
- }
1478
-
1479
- async createMfaChallenge(factorId: string, ipAddress?: string): Promise<MfaChallengeInfo> {
1480
- const tableName = this.qualify("mfa_challenges");
1481
- // Challenges expire in 5 minutes
1482
- const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
1483
- const result = await this.db.execute(sql`
1484
- INSERT INTO ${sql.raw(tableName)} (factor_id, ip_address, expires_at)
1485
- VALUES (${factorId}, ${ipAddress ?? null}, ${expiresAt})
1486
- RETURNING id, factor_id, created_at, verified_at, ip_address
1487
- `);
1488
-
1489
- const row = result.rows[0] as Record<string, unknown>;
1490
- return {
1491
- id: row.id as string,
1492
- factorId: row.factor_id as string,
1493
- createdAt: new Date(row.created_at as string),
1494
- verifiedAt: row.verified_at ? new Date(row.verified_at as string) : undefined,
1495
- ipAddress: (row.ip_address as string | null) ?? undefined
1496
- };
1497
- }
1498
-
1499
- async getMfaChallengeById(challengeId: string): Promise<MfaChallengeInfo | null> {
1500
- const tableName = this.qualify("mfa_challenges");
1501
- const result = await this.db.execute(sql`
1502
- SELECT id, factor_id, created_at, verified_at, ip_address, attempts, expires_at
1503
- FROM ${sql.raw(tableName)}
1504
- WHERE id = ${challengeId} AND expires_at > NOW() AND verified_at IS NULL
1505
- `);
1506
-
1507
- if (result.rows.length === 0) return null;
1508
-
1509
- const row = result.rows[0] as Record<string, unknown>;
1510
- return {
1511
- id: row.id as string,
1512
- factorId: row.factor_id as string,
1513
- createdAt: new Date(row.created_at as string),
1514
- verifiedAt: row.verified_at ? new Date(row.verified_at as string) : undefined,
1515
- ipAddress: (row.ip_address as string | null) ?? undefined,
1516
- attempts: Number(row.attempts ?? 0)
1517
- };
1518
- }
1519
-
1520
- /**
1521
- * Count one failed guess against a challenge and report the new total.
1522
- *
1523
- * Incremented in the database rather than in the route so that guesses
1524
- * arriving in parallel — the shape any real brute-force takes — cannot
1525
- * share a single increment.
1526
- */
1527
- async recordMfaChallengeAttempt(challengeId: string): Promise<number> {
1528
- const tableName = this.qualify("mfa_challenges");
1529
- const result = await this.db.execute(sql`
1530
- UPDATE ${sql.raw(tableName)}
1531
- SET attempts = attempts + 1
1532
- WHERE id = ${challengeId}
1533
- RETURNING attempts
1534
- `);
1535
-
1536
- if (result.rows.length === 0) return 0;
1537
- return Number((result.rows[0] as { attempts: number | string }).attempts);
1538
- }
1539
-
1540
- async verifyMfaChallenge(challengeId: string): Promise<void> {
1541
- const tableName = this.qualify("mfa_challenges");
1542
- await this.db.execute(sql`
1543
- UPDATE ${sql.raw(tableName)}
1544
- SET verified_at = NOW()
1545
- WHERE id = ${challengeId}
1546
- `);
1547
- }
1548
-
1549
- async createRecoveryCodes(uid: string, codeHashes: string[]): Promise<void> {
1550
- const tableName = this.qualify("recovery_codes");
1551
- // Delete existing codes first
1552
- await this.db.execute(sql`
1553
- DELETE FROM ${sql.raw(tableName)} WHERE uid = ${uid}
1554
- `);
1555
-
1556
- // Insert new codes
1557
- for (const hash of codeHashes) {
1558
- await this.db.execute(sql`
1559
- INSERT INTO ${sql.raw(tableName)} (uid, code_hash)
1560
- VALUES (${uid}, ${hash})
1561
- `);
1562
- }
1563
- }
1564
-
1565
- async useRecoveryCode(uid: string, codeHash: string): Promise<boolean> {
1566
- const tableName = this.qualify("recovery_codes");
1567
- const result = await this.db.execute(sql`
1568
- UPDATE ${sql.raw(tableName)}
1569
- SET used_at = NOW()
1570
- WHERE uid = ${uid} AND code_hash = ${codeHash} AND used_at IS NULL
1571
- RETURNING id
1572
- `);
1573
-
1574
- return result.rows.length > 0;
1575
- }
1576
-
1577
- async getUnusedRecoveryCodeCount(uid: string): Promise<number> {
1578
- const tableName = this.qualify("recovery_codes");
1579
- const result = await this.db.execute(sql`
1580
- SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
1581
- WHERE uid = ${uid} AND used_at IS NULL
1582
- `);
1583
-
1584
- return (result.rows[0] as { count: number }).count;
1585
- }
1586
-
1587
- async deleteAllRecoveryCodes(uid: string): Promise<void> {
1588
- const tableName = this.qualify("recovery_codes");
1589
- await this.db.execute(sql`
1590
- DELETE FROM ${sql.raw(tableName)} WHERE uid = ${uid}
1591
- `);
1592
- }
1593
-
1594
- async hasVerifiedMfaFactors(uid: string): Promise<boolean> {
1595
- const tableName = this.qualify("mfa_factors");
1596
- const result = await this.db.execute(sql`
1597
- SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
1598
- WHERE uid = ${uid} AND verified = TRUE
1599
- `);
1600
-
1601
- return (result.rows[0] as { count: number }).count > 0;
1602
- }
1603
- }
1604
-
1605
- // =============================================================================
1606
- // PostgreSQL Type Aliases (for consistent naming with other implementations)
1607
- // =============================================================================
1608
-
1609
- /** PostgreSQL user repository implementation */
1610
- export type PostgresUserRepository = UserService;