@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.
- package/LICENSE +22 -0
- package/README.md +86 -0
- package/dist/PostgresAdapter.d.ts +6 -0
- package/dist/PostgresBackendDriver.d.ts +150 -0
- package/dist/PostgresBootstrapper.d.ts +51 -0
- package/dist/auth/ensure-tables.d.ts +10 -0
- package/dist/auth/services.d.ts +250 -0
- package/dist/backup/backup-cli.d.ts +3 -0
- package/dist/backup/backup-cron.d.ts +53 -0
- package/dist/backup/backup-service.d.ts +85 -0
- package/dist/backup/index.d.ts +12 -0
- package/dist/backup/pg-tools.d.ts +110 -0
- package/dist/backup/retention.d.ts +35 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/cli.d.ts +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
- package/dist/connection.d.ts +65 -0
- package/dist/data-transformer.d.ts +55 -0
- package/dist/databasePoolManager.d.ts +20 -0
- package/dist/history/HistoryService.d.ts +71 -0
- package/dist/history/ensure-history-table.d.ts +7 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +23535 -0
- package/dist/index.es.js.map +1 -0
- package/dist/interfaces.d.ts +18 -0
- package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/schema/auth-schema.d.ts +2376 -0
- package/dist/schema/doctor-cli.d.ts +2 -0
- package/dist/schema/doctor.d.ts +58 -0
- package/dist/schema/dynamic-tables.d.ts +31 -0
- package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/introspect-db-inference.d.ts +5 -0
- package/dist/schema/introspect-db-logic.d.ts +118 -0
- package/dist/schema/introspect-db.d.ts +1 -0
- package/dist/schema/introspect-runtime.d.ts +57 -0
- package/dist/schema/test-schema.d.ts +24 -0
- package/dist/security/policy-drift.d.ts +57 -0
- package/dist/security/rls-enforcement.d.ts +122 -0
- package/dist/services/BranchService.d.ts +47 -0
- package/dist/services/FetchService.d.ts +214 -0
- package/dist/services/PersistService.d.ts +39 -0
- package/dist/services/RelationService.d.ts +109 -0
- package/dist/services/cdc/CdcListener.d.ts +54 -0
- package/dist/services/cdc/trigger-cdc.d.ts +64 -0
- package/dist/services/collection-helpers.d.ts +38 -0
- package/dist/services/dataService.d.ts +110 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/realtimeService.d.ts +298 -0
- package/dist/src-Eh-CZosp.js +595 -0
- package/dist/src-Eh-CZosp.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/drizzle-conditions.d.ts +138 -0
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- package/dist/utils/pg-error-utils.d.ts +65 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/dist/websocket.d.ts +18 -0
- package/package.json +113 -0
- package/src/PostgresAdapter.ts +58 -0
- package/src/PostgresBackendDriver.ts +1387 -0
- package/src/PostgresBootstrapper.ts +581 -0
- package/src/auth/ensure-tables.ts +367 -0
- package/src/auth/services.ts +1321 -0
- package/src/backup/backup-cli.ts +383 -0
- package/src/backup/backup-cron.ts +189 -0
- package/src/backup/backup-service.ts +299 -0
- package/src/backup/index.ts +12 -0
- package/src/backup/pg-tools.ts +231 -0
- package/src/backup/retention.ts +75 -0
- package/src/cli-errors.ts +265 -0
- package/src/cli-helpers.ts +196 -0
- package/src/cli.ts +786 -0
- package/src/collections/PostgresCollectionRegistry.ts +103 -0
- package/src/connection.ts +166 -0
- package/src/data-transformer.ts +733 -0
- package/src/databasePoolManager.ts +87 -0
- package/src/history/HistoryService.ts +272 -0
- package/src/history/ensure-history-table.ts +46 -0
- package/src/index.ts +15 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-bootstrap-sql.ts +41 -0
- package/src/schema/auth-default-policies.ts +125 -0
- package/src/schema/auth-schema.ts +233 -0
- package/src/schema/doctor-cli.ts +113 -0
- package/src/schema/doctor.ts +733 -0
- package/src/schema/dynamic-tables.test.ts +302 -0
- package/src/schema/dynamic-tables.ts +293 -0
- package/src/schema/generate-drizzle-schema-logic.ts +850 -0
- package/src/schema/generate-drizzle-schema.ts +131 -0
- package/src/schema/generate-postgres-ddl-logic.ts +490 -0
- package/src/schema/generate-postgres-ddl.ts +92 -0
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +910 -0
- package/src/schema/introspect-db.ts +266 -0
- package/src/schema/introspect-runtime.test.ts +212 -0
- package/src/schema/introspect-runtime.ts +293 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/security/policy-drift.test.ts +122 -0
- package/src/security/policy-drift.ts +159 -0
- package/src/security/rls-enforcement.ts +295 -0
- package/src/services/BranchService.ts +251 -0
- package/src/services/FetchService.ts +1661 -0
- package/src/services/PersistService.ts +329 -0
- package/src/services/RelationService.ts +1306 -0
- package/src/services/cdc/CdcListener.ts +167 -0
- package/src/services/cdc/trigger-cdc.ts +169 -0
- package/src/services/collection-helpers.ts +151 -0
- package/src/services/dataService.ts +246 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1502 -0
- package/src/types.ts +4 -0
- package/src/utils/drizzle-conditions.ts +1162 -0
- package/src/utils/pg-array-null-patch.ts +42 -0
- package/src/utils/pg-error-utils.ts +227 -0
- package/src/utils/table-classification.ts +16 -0
- 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;
|