@fonderie/auth 1.0.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/dtos/user.cjs +66 -0
  4. package/dist/dtos/user.cjs.map +1 -0
  5. package/dist/dtos/user.d.cts +24 -0
  6. package/dist/dtos/user.d.ts +24 -0
  7. package/dist/dtos/user.js +41 -0
  8. package/dist/dtos/user.js.map +1 -0
  9. package/dist/index.cjs +1952 -0
  10. package/dist/index.cjs.map +1 -0
  11. package/dist/index.d.cts +22 -0
  12. package/dist/index.d.ts +22 -0
  13. package/dist/index.js +1915 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/middlewares/index.cjs +303 -0
  16. package/dist/middlewares/index.cjs.map +1 -0
  17. package/dist/middlewares/index.d.cts +10 -0
  18. package/dist/middlewares/index.d.ts +10 -0
  19. package/dist/middlewares/index.js +270 -0
  20. package/dist/middlewares/index.js.map +1 -0
  21. package/dist/migrations/index.js +7 -0
  22. package/dist/migrations/index.js.map +1 -0
  23. package/dist/migrations/sql/001_auth.sql +73 -0
  24. package/dist/migrations/sql/002_phone_auth.sql +20 -0
  25. package/dist/migrations/sql/003_phone_registration_name.sql +4 -0
  26. package/dist/migrations/sql/004_drop_phone_verif_name_cols.sql +5 -0
  27. package/dist/migrations/sql/005_phone_verification_user_id.sql +6 -0
  28. package/dist/migrations/sql/006_drop_phone_verified_at.sql +1 -0
  29. package/dist/migrations/sql/007_email_verif_user_id_pk.sql +14 -0
  30. package/dist/migrations/sql/008_password_reset_pin.sql +4 -0
  31. package/dist/migrations/sql/009_password_reset_created_at.sql +1 -0
  32. package/dist/migrations/sql/010_mfa_pending_secret.sql +3 -0
  33. package/dist/migrations/sql/011_mfa_backup_codes.sql +10 -0
  34. package/dist/migrations/sql/012_drop_skills.sql +1 -0
  35. package/dist/session-CLD1WPJs.d.cts +41 -0
  36. package/dist/session-CLD1WPJs.d.ts +41 -0
  37. package/dist/types.cjs +19 -0
  38. package/dist/types.cjs.map +1 -0
  39. package/dist/types.d.cts +52 -0
  40. package/dist/types.d.ts +52 -0
  41. package/dist/types.js +1 -0
  42. package/dist/types.js.map +1 -0
  43. package/package.json +100 -0
package/dist/index.cjs ADDED
@@ -0,0 +1,1952 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res) => function __init() {
9
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
+ };
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") {
17
+ for (let key of __getOwnPropNames(from))
18
+ if (!__hasOwnProp.call(to, key) && key !== except)
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
+ // If the importer is in node compatibility mode or this is not an ESM
25
+ // file that has been converted to a CommonJS file using a Babel-
26
+ // compatible transform (i.e. "__esModule" has not been set), then set
27
+ // "default" to the CommonJS "module.exports" for node compatibility.
28
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
+ mod
30
+ ));
31
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
+
33
+ // src/services/password.ts
34
+ var password_exports = {};
35
+ __export(password_exports, {
36
+ hashPassword: () => hashPassword,
37
+ verifyPassword: () => verifyPassword
38
+ });
39
+ async function hashPassword(plain) {
40
+ return import_bcryptjs.default.hash(plain, ROUNDS);
41
+ }
42
+ async function verifyPassword(plain, hash) {
43
+ return import_bcryptjs.default.compare(plain, hash);
44
+ }
45
+ var import_bcryptjs, ROUNDS;
46
+ var init_password = __esm({
47
+ "src/services/password.ts"() {
48
+ "use strict";
49
+ import_bcryptjs = __toESM(require("bcryptjs"), 1);
50
+ ROUNDS = 12;
51
+ }
52
+ });
53
+
54
+ // src/index.ts
55
+ var index_exports = {};
56
+ __export(index_exports, {
57
+ AUTH_CONFIG_KEYS: () => AUTH_CONFIG_KEYS,
58
+ AuthModule: () => AuthModule,
59
+ MESSAGE_KEYS: () => MESSAGE_KEYS,
60
+ normalizeEmail: () => normalizeEmail,
61
+ normalizeEmailSafe: () => normalizeEmailSafe,
62
+ requireAuth: () => import_middlewares2.requireAuth,
63
+ toUserDTO: () => toUserDTO,
64
+ withSession: () => withSession
65
+ });
66
+ module.exports = __toCommonJS(index_exports);
67
+
68
+ // src/routes.ts
69
+ var import_middlewares = require("@fonderie/core/middlewares");
70
+
71
+ // src/middlewares/require-email-login.ts
72
+ var import_core = require("@fonderie/core");
73
+ var requireEmailLogin = async (ctx, next) => {
74
+ if (ctx.user.loginMethod !== "email") {
75
+ return (0, import_core.setApiResponse)(
76
+ import_core.HTTP.FORBIDDEN,
77
+ "EMAIL_LOGIN_REQUIRED",
78
+ "This action requires email authentication"
79
+ );
80
+ }
81
+ return next();
82
+ };
83
+
84
+ // src/controllers/mfa.controller.ts
85
+ var import_qrcode = __toESM(require("qrcode"), 1);
86
+ var import_core3 = require("@fonderie/core");
87
+ var import_events = require("@fonderie/events");
88
+
89
+ // src/config.ts
90
+ var DEFAULT_VERIFICATION_COOLDOWN = 5 * 60 * 1e3;
91
+ var AUTH_CONFIG_KEYS = {
92
+ sessionDuration: "auth.session.duration",
93
+ verificationCooldown: "auth.verification.cooldown",
94
+ mfa: "auth.mfa.enabled",
95
+ requireVerification: "auth.verification.required"
96
+ };
97
+ var MESSAGE_KEYS = {
98
+ emailRegistration: "email-registration",
99
+ emailVerification: "email-verification",
100
+ passwordReset: "password-reset",
101
+ phoneOtp: "phone-otp",
102
+ mfaEnabled: "mfa-enabled",
103
+ mfaDisabled: "mfa-disabled",
104
+ mfaBackupCodesRegenerated: "mfa-backup-codes-regenerated",
105
+ emailChanged: "email-changed",
106
+ phoneChanged: "phone-changed"
107
+ };
108
+ var EVENT_KEYS = {
109
+ userRegistered: "fonderie.user.registered",
110
+ userDeleted: "fonderie.user.deleted",
111
+ emailVerified: "fonderie.user.email_verified",
112
+ passwordChanged: "fonderie.user.password_changed"
113
+ };
114
+
115
+ // src/services/jwt.ts
116
+ var import_jsonwebtoken = __toESM(require("jsonwebtoken"), 1);
117
+ function issueMfaPendingToken(userId, config, loginMethod) {
118
+ return import_jsonwebtoken.default.sign(
119
+ {
120
+ sub: userId,
121
+ type: "access",
122
+ loginMethod,
123
+ phoneVerified: false,
124
+ mfaPending: true
125
+ },
126
+ config.jwtSecret,
127
+ { expiresIn: "5m" }
128
+ );
129
+ }
130
+ function issueTokenPair(userId, config, options) {
131
+ const duration = config.sessionDuration ?? "7d";
132
+ const loginMethod = options.loginMethod;
133
+ const phoneVerified = options.phoneVerified ?? false;
134
+ const accessToken = import_jsonwebtoken.default.sign(
135
+ { sub: userId, type: "access", loginMethod, phoneVerified },
136
+ config.jwtSecret,
137
+ { expiresIn: "24h" }
138
+ );
139
+ const refreshToken = import_jsonwebtoken.default.sign(
140
+ { sub: userId, type: "refresh", loginMethod, phoneVerified },
141
+ config.jwtSecret,
142
+ { expiresIn: duration }
143
+ );
144
+ return { accessToken, refreshToken };
145
+ }
146
+ function refreshTokenExpiry(token) {
147
+ const decoded = import_jsonwebtoken.default.decode(token);
148
+ return decoded?.exp ? new Date(decoded.exp * 1e3) : new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3);
149
+ }
150
+ function verifyToken(token, config) {
151
+ try {
152
+ return import_jsonwebtoken.default.verify(token, config.jwtSecret);
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ // src/services/mfa.ts
159
+ var import_node_crypto = require("crypto");
160
+ var STEP = 30;
161
+ var DRIFT = 1;
162
+ var DIGITS = 6;
163
+ function hotp(secret, counter) {
164
+ const buf = Buffer.alloc(8);
165
+ let c = counter;
166
+ for (let i = 7; i >= 0; i--) {
167
+ buf[i] = c & 255;
168
+ c >>= 8;
169
+ }
170
+ const key = Buffer.from(base32Decode(secret));
171
+ const hmac = (0, import_node_crypto.createHmac)("sha1", key).update(buf).digest();
172
+ const offset = (hmac[19] ?? 0) & 15;
173
+ const code = (((hmac[offset] ?? 0) & 127) << 24 | ((hmac[offset + 1] ?? 0) & 255) << 16 | ((hmac[offset + 2] ?? 0) & 255) << 8 | (hmac[offset + 3] ?? 0) & 255) % Math.pow(10, DIGITS);
174
+ return code.toString().padStart(DIGITS, "0");
175
+ }
176
+ function timeCounter() {
177
+ return Math.floor(Date.now() / 1e3 / STEP);
178
+ }
179
+ function base32Decode(input) {
180
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
181
+ const clean = input.toUpperCase().replace(/=+$/, "");
182
+ let bits = 0;
183
+ let value = 0;
184
+ const output = [];
185
+ for (const char of clean) {
186
+ const idx = ALPHABET.indexOf(char);
187
+ if (idx === -1) {
188
+ continue;
189
+ }
190
+ value = value << 5 | idx;
191
+ bits += 5;
192
+ if (bits >= 8) {
193
+ output.push(value >>> bits - 8 & 255);
194
+ bits -= 8;
195
+ }
196
+ }
197
+ return Buffer.from(output);
198
+ }
199
+ function base32Encode(buf) {
200
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
201
+ let bits = 0;
202
+ let value = 0;
203
+ let output = "";
204
+ for (const byte of buf) {
205
+ value = value << 8 | byte;
206
+ bits += 8;
207
+ while (bits >= 5) {
208
+ output += ALPHABET[value >>> bits - 5 & 31];
209
+ bits -= 5;
210
+ }
211
+ }
212
+ if (bits > 0) {
213
+ output += ALPHABET[value << 5 - bits & 31];
214
+ }
215
+ return output;
216
+ }
217
+ function generateTotpSecret() {
218
+ return base32Encode((0, import_node_crypto.randomBytes)(20));
219
+ }
220
+ function generateTotpUri(email, secret, issuer) {
221
+ const params = new URLSearchParams({
222
+ secret,
223
+ issuer,
224
+ algorithm: "SHA1",
225
+ digits: String(DIGITS),
226
+ period: String(STEP)
227
+ });
228
+ return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(email)}?${params}`;
229
+ }
230
+ function verifyTotpToken(token, secret) {
231
+ const t = timeCounter();
232
+ for (let i = -DRIFT; i <= DRIFT; i++) {
233
+ if (hotp(secret, t + i) === token) {
234
+ return true;
235
+ }
236
+ }
237
+ return false;
238
+ }
239
+ function generateBackupCodes(count = 8) {
240
+ return Array.from({ length: count }, () => (0, import_node_crypto.randomBytes)(4).toString("hex").toUpperCase());
241
+ }
242
+
243
+ // src/controllers/mfa.controller.ts
244
+ init_password();
245
+
246
+ // src/dtos/user.ts
247
+ var import_core2 = require("@fonderie/core");
248
+ var DEFAULT_PREFERENCES = {
249
+ locale: "en-US",
250
+ timezone: "UTC",
251
+ notifications: { email: true, inApp: true, sms: false, push: false },
252
+ emailDigest: "immediate",
253
+ dateFormat: "MM/DD/YYYY",
254
+ timeFormat: "hh:mm A"
255
+ };
256
+ function toUserDTO(user, phoneVerified = false) {
257
+ const prefs = user.preferences ?? {};
258
+ return {
259
+ id: (0, import_core2.stringOrEmpty)(user.id),
260
+ email: (0, import_core2.stringOrEmpty)(user.email),
261
+ firstName: (0, import_core2.stringOrEmpty)(user.firstName),
262
+ lastName: (0, import_core2.stringOrEmpty)(user.lastName),
263
+ phone: (0, import_core2.stringOrEmpty)(user.phone),
264
+ profileImageUrl: (0, import_core2.stringOrEmpty)(user.profileImageUrl),
265
+ isActive: typeof user.isActive === "boolean" ? user.isActive : true,
266
+ lastLogin: user.lastLogin instanceof Date ? user.lastLogin.toISOString() : "",
267
+ preferences: {
268
+ ...DEFAULT_PREFERENCES,
269
+ ...prefs,
270
+ locale: user.locale || prefs.locale || "en-US",
271
+ timezone: user.timezone || prefs.timezone || "UTC"
272
+ },
273
+ isEmailVerified: user.emailVerifiedAt !== null,
274
+ isPhoneVerified: phoneVerified,
275
+ mfaEnabled: (0, import_core2.booleanOrFalse)(user.mfaEnabled),
276
+ suspended: (0, import_core2.booleanOrFalse)(user.suspended),
277
+ whitelist: (0, import_core2.booleanOrFalse)(user.whitelist),
278
+ ipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],
279
+ createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : "",
280
+ updatedAt: user.updatedAt instanceof Date ? user.updatedAt.toISOString() : ""
281
+ };
282
+ }
283
+
284
+ // src/models/user.model.ts
285
+ var USER_COLUMNS = `
286
+ id,
287
+ email,
288
+ password_hash AS "passwordHash",
289
+ first_name AS "firstName",
290
+ last_name AS "lastName",
291
+ phone,
292
+ profile_image_url AS "profileImageUrl",
293
+ locale,
294
+ timezone,
295
+ is_active AS "isActive",
296
+ last_login AS "lastLogin",
297
+ preferences,
298
+ suspended,
299
+ whitelist,
300
+ ip_whitelist AS "ipWhitelist",
301
+ mfa_enabled AS "mfaEnabled",
302
+ mfa_secret AS "mfaSecret",
303
+ email_verified_at AS "emailVerifiedAt",
304
+ deleted_at AS "deletedAt",
305
+ created_at AS "createdAt",
306
+ updated_at AS "updatedAt"
307
+ `;
308
+ var UserModel = class {
309
+ constructor(store) {
310
+ this.store = store;
311
+ }
312
+ store;
313
+ async findById(id) {
314
+ const [row] = await this.store.query(
315
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1 AND deleted_at IS NULL`,
316
+ [id]
317
+ );
318
+ return row ?? null;
319
+ }
320
+ async findByEmail(email) {
321
+ const [row] = await this.store.query(
322
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE email = $1 AND deleted_at IS NULL`,
323
+ [email]
324
+ );
325
+ return row ?? null;
326
+ }
327
+ async findByPhone(phone) {
328
+ const [row] = await this.store.query(
329
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NULL`,
330
+ [phone]
331
+ );
332
+ return row ?? null;
333
+ }
334
+ async findOrCreateByPhone(phone, firstName = null, lastName = null) {
335
+ const [row] = await this.store.query(
336
+ `INSERT INTO fonderie_users (phone, first_name, last_name)
337
+ VALUES ($1, $2, $3)
338
+ ON CONFLICT (phone) DO UPDATE
339
+ SET first_name = COALESCE(EXCLUDED.first_name, fonderie_users.first_name),
340
+ last_name = COALESCE(EXCLUDED.last_name, fonderie_users.last_name),
341
+ updated_at = now()
342
+ RETURNING id`,
343
+ [phone, firstName, lastName]
344
+ );
345
+ return row;
346
+ }
347
+ async create(email, passwordHash, firstName, lastName) {
348
+ const [row] = await this.store.query(
349
+ `INSERT INTO fonderie_users (email, password_hash, first_name, last_name)
350
+ VALUES ($1, $2, $3, $4)
351
+ RETURNING id`,
352
+ [email.toLowerCase().trim(), passwordHash, firstName, lastName]
353
+ );
354
+ return row ?? null;
355
+ }
356
+ async update(id, fields) {
357
+ const columnMap = {
358
+ firstName: "first_name",
359
+ lastName: "last_name",
360
+ phoneNumber: "phone",
361
+ avatarUrl: "profile_image_url",
362
+ locale: "locale",
363
+ timezone: "timezone",
364
+ preferences: "preferences"
365
+ };
366
+ const sets = [];
367
+ const values = [];
368
+ for (const [key, col] of Object.entries(columnMap)) {
369
+ if (fields[key] !== void 0) {
370
+ values.push(fields[key]);
371
+ sets.push(`${col} = $${values.length}`);
372
+ }
373
+ }
374
+ values.push(id);
375
+ const [row] = await this.store.query(
376
+ `UPDATE fonderie_users SET ${sets.join(", ")}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,
377
+ values
378
+ );
379
+ return row ?? null;
380
+ }
381
+ async updatePassword(id, passwordHash) {
382
+ await this.store.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [
383
+ passwordHash,
384
+ id
385
+ ]);
386
+ }
387
+ async markEmailVerified(id) {
388
+ await this.store.query(
389
+ `UPDATE fonderie_users SET email_verified_at = now(), updated_at = now() WHERE id = $1`,
390
+ [id]
391
+ );
392
+ }
393
+ async softDelete(id) {
394
+ await this.store.query(
395
+ `UPDATE fonderie_users SET deleted_at = now(), updated_at = now() WHERE id = $1`,
396
+ [id]
397
+ );
398
+ }
399
+ async saveMfaSecret(id, secret) {
400
+ await this.store.query(`UPDATE fonderie_users SET mfa_secret = $1 WHERE id = $2`, [secret, id]);
401
+ }
402
+ async saveMfaPendingSecret(id, secret) {
403
+ await this.store.query(
404
+ `UPDATE fonderie_users
405
+ SET mfa_secret_pending = $1, mfa_secret_pending_expires_at = now() + interval '15 minutes'
406
+ WHERE id = $2`,
407
+ [secret, id]
408
+ );
409
+ }
410
+ async getMfaPendingSecret(id) {
411
+ const [row] = await this.store.query(
412
+ `SELECT mfa_secret_pending FROM fonderie_users
413
+ WHERE id = $1 AND mfa_secret_pending_expires_at > now()`,
414
+ [id]
415
+ );
416
+ return row?.mfa_secret_pending ?? null;
417
+ }
418
+ async confirmMfaSecret(id) {
419
+ await this.store.query(
420
+ `UPDATE fonderie_users
421
+ SET mfa_secret = mfa_secret_pending,
422
+ mfa_secret_pending = NULL,
423
+ mfa_secret_pending_expires_at = NULL,
424
+ mfa_enabled = true,
425
+ updated_at = now()
426
+ WHERE id = $1`,
427
+ [id]
428
+ );
429
+ }
430
+ async enableMfa(id) {
431
+ await this.store.query(`UPDATE fonderie_users SET mfa_enabled = true WHERE id = $1`, [id]);
432
+ }
433
+ async disableMfa(id) {
434
+ await this.store.query(
435
+ `UPDATE fonderie_users SET mfa_enabled = false, mfa_secret = NULL, updated_at = now() WHERE id = $1`,
436
+ [id]
437
+ );
438
+ }
439
+ async updateEmail(id, email) {
440
+ await this.store.query(
441
+ `UPDATE fonderie_users SET email = $1, email_verified_at = NULL, updated_at = now() WHERE id = $2`,
442
+ [email.toLowerCase().trim(), id]
443
+ );
444
+ }
445
+ async updatePhone(id, phone) {
446
+ await this.store.query(
447
+ `UPDATE fonderie_users SET phone = $1, updated_at = now() WHERE id = $2`,
448
+ [phone, id]
449
+ );
450
+ }
451
+ async updatePreferences(id, fields) {
452
+ const sets = [];
453
+ const values = [];
454
+ if (fields.locale !== void 0) {
455
+ values.push(fields.locale);
456
+ sets.push(`locale = $${values.length}`);
457
+ }
458
+ if (fields.timezone !== void 0) {
459
+ values.push(fields.timezone);
460
+ sets.push(`timezone = $${values.length}`);
461
+ }
462
+ if (fields.patch && Object.keys(fields.patch).length > 0) {
463
+ values.push(JSON.stringify(fields.patch));
464
+ sets.push(`preferences = preferences || $${values.length}::jsonb`);
465
+ }
466
+ if (sets.length === 0) return null;
467
+ values.push(id);
468
+ const [row] = await this.store.query(
469
+ `UPDATE fonderie_users SET ${sets.join(", ")}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,
470
+ values
471
+ );
472
+ return row ?? null;
473
+ }
474
+ async getMfaSecret(id) {
475
+ const [row] = await this.store.query(
476
+ `SELECT mfa_secret FROM fonderie_users WHERE id = $1`,
477
+ [id]
478
+ );
479
+ return row?.mfa_secret ?? null;
480
+ }
481
+ async upsertByProvider(email, provider, providerId) {
482
+ const [row] = await this.store.query(
483
+ `INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
484
+ VALUES ($1, now(), $2, $3)
485
+ ON CONFLICT (email) DO UPDATE
486
+ SET provider = $2, provider_id = $3
487
+ RETURNING id`,
488
+ [email, provider, providerId]
489
+ );
490
+ return row ?? null;
491
+ }
492
+ };
493
+
494
+ // src/models/session.model.ts
495
+ var SessionModel = class {
496
+ constructor(store) {
497
+ this.store = store;
498
+ }
499
+ store;
500
+ async create(userId, token, expiresAt) {
501
+ await this.store.query(
502
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at)
503
+ VALUES ($1, $2, $3)
504
+ ON CONFLICT (token) DO NOTHING`,
505
+ [userId, token, expiresAt]
506
+ );
507
+ }
508
+ async delete(token) {
509
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
510
+ }
511
+ async exists(token) {
512
+ const rows = await this.store.query(
513
+ `SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
514
+ [token]
515
+ );
516
+ return rows.length > 0;
517
+ }
518
+ };
519
+
520
+ // src/models/backup-code.model.ts
521
+ var BackupCodeModel = class {
522
+ constructor(store) {
523
+ this.store = store;
524
+ }
525
+ store;
526
+ async replace(userId, codeHashes) {
527
+ await this.store.transaction(async (tx) => {
528
+ await tx.query(`DELETE FROM fonderie_mfa_backup_codes WHERE user_id = $1`, [userId]);
529
+ if (codeHashes.length === 0) return;
530
+ const placeholders = codeHashes.map((_, i) => `($1, $${i + 2})`).join(", ");
531
+ await tx.query(
532
+ `INSERT INTO fonderie_mfa_backup_codes (user_id, code_hash) VALUES ${placeholders}`,
533
+ [userId, ...codeHashes]
534
+ );
535
+ });
536
+ }
537
+ async findUnused(userId) {
538
+ const rows = await this.store.query(
539
+ `SELECT id, code_hash FROM fonderie_mfa_backup_codes
540
+ WHERE user_id = $1 AND used_at IS NULL`,
541
+ [userId]
542
+ );
543
+ return rows.map((r) => ({ id: r.id, codeHash: r.code_hash }));
544
+ }
545
+ async consume(id) {
546
+ await this.store.query(`UPDATE fonderie_mfa_backup_codes SET used_at = now() WHERE id = $1`, [
547
+ id
548
+ ]);
549
+ }
550
+ async deleteByUser(userId) {
551
+ await this.store.query(`DELETE FROM fonderie_mfa_backup_codes WHERE user_id = $1`, [userId]);
552
+ }
553
+ };
554
+
555
+ // src/controllers/mfa.controller.ts
556
+ function mfaController(store, config, issuer, bus) {
557
+ const users = new UserModel(store);
558
+ const sessions = new SessionModel(store);
559
+ const backupCodes = new BackupCodeModel(store);
560
+ return {
561
+ // ── 1. Setup ───────────────────────────────────────────────
562
+ setup: async (ctx) => {
563
+ const secret = generateTotpSecret();
564
+ const uri = generateTotpUri(ctx.user.email ?? ctx.user.id, secret, issuer);
565
+ const plainCodes = generateBackupCodes();
566
+ const codeHashes = await Promise.all(plainCodes.map((c) => hashPassword(c)));
567
+ const qr = await import_qrcode.default.toDataURL(uri);
568
+ await Promise.all([
569
+ users.saveMfaPendingSecret(ctx.user.id, secret),
570
+ backupCodes.replace(ctx.user.id, codeHashes)
571
+ ]);
572
+ return (0, import_core3.setApiResponse)(
573
+ import_core3.HTTP.OK,
574
+ "MFA_SETUP_INITIATED",
575
+ "Scan the QR code with your authenticator app.",
576
+ {
577
+ qr,
578
+ // uri — expose when adding a "manual entry" flow in the UI (otpauth:// URI lets
579
+ // users add the credential by typing the secret instead of scanning the QR code)
580
+ backupCodes: plainCodes
581
+ }
582
+ );
583
+ },
584
+ // ── 2. Verify (setup confirm · TOTP login · backup code login) ──
585
+ verify: async (ctx) => {
586
+ const body = ctx.meta["body"];
587
+ const token = body?.["token"];
588
+ if (typeof token !== "string") {
589
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
590
+ }
591
+ const pendingSecret = await users.getMfaPendingSecret(ctx.user.id);
592
+ if (pendingSecret) {
593
+ if (!verifyTotpToken(token, pendingSecret)) {
594
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
595
+ }
596
+ await users.confirmMfaSecret(ctx.user.id);
597
+ bus?.emit(import_events.NOTIFICATION_EVENT, {
598
+ type: MESSAGE_KEYS.mfaEnabled,
599
+ data: {},
600
+ recipient: { email: ctx.user.email, phone: null, deviceToken: null }
601
+ }).catch(() => {
602
+ });
603
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "MFA_VERIFIED", "MFA verified successfully.", {
604
+ mfaEnabled: true
605
+ });
606
+ } else if (/^[A-Z0-9]{8}$/i.test(token)) {
607
+ if (!ctx.user.mfaPending) {
608
+ return (0, import_core3.setApiResponse)(
609
+ import_core3.HTTP.FORBIDDEN,
610
+ "MFA_NOT_PENDING",
611
+ "Use the mfaToken from the login response"
612
+ );
613
+ }
614
+ if (!ctx.user.mfaEnabled) {
615
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.BAD_REQUEST, "MFA_NOT_CONFIGURED", "MFA not configured");
616
+ }
617
+ const unused = await backupCodes.findUnused(ctx.user.id);
618
+ const checks = await Promise.all(
619
+ unused.map(async (row) => ({
620
+ id: row.id,
621
+ match: await verifyPassword(token.toUpperCase(), row.codeHash)
622
+ }))
623
+ );
624
+ const matched = checks.find((r) => r.match);
625
+ if (!matched) {
626
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
627
+ }
628
+ await backupCodes.consume(matched.id);
629
+ } else {
630
+ if (!ctx.user.mfaPending) {
631
+ return (0, import_core3.setApiResponse)(
632
+ import_core3.HTTP.FORBIDDEN,
633
+ "MFA_NOT_PENDING",
634
+ "Use the mfaToken from the login response"
635
+ );
636
+ }
637
+ const secret = await users.getMfaSecret(ctx.user.id);
638
+ if (!secret) {
639
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.BAD_REQUEST, "MFA_NOT_CONFIGURED", "MFA not configured");
640
+ }
641
+ if (!verifyTotpToken(token, secret)) {
642
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
643
+ }
644
+ if (!ctx.user.mfaEnabled) {
645
+ await users.enableMfa(ctx.user.id);
646
+ }
647
+ }
648
+ const { accessToken, refreshToken } = issueTokenPair(ctx.user.id, config, {
649
+ loginMethod: ctx.user.loginMethod
650
+ });
651
+ await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken));
652
+ const fullUser = await users.findById(ctx.user.id);
653
+ if (!fullUser) {
654
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.SERVER_ERROR, "SERVER_ERROR", "User not found after MFA verify");
655
+ }
656
+ return Response.json(
657
+ {
658
+ reason: "MFA_VERIFIED",
659
+ explanation: "MFA verified successfully.",
660
+ result: {
661
+ tokens: { access: accessToken, refresh: refreshToken },
662
+ user: toUserDTO(fullUser)
663
+ }
664
+ },
665
+ {
666
+ status: 200,
667
+ headers: {
668
+ "Set-Cookie": [
669
+ `access_token=${accessToken}; HttpOnly; SameSite=Strict; Path=/`,
670
+ `refresh_token=${refreshToken}; HttpOnly; SameSite=Strict; Path=/auth/refresh`
671
+ ].join(", ")
672
+ }
673
+ }
674
+ );
675
+ },
676
+ // ── 3. Regenerate backup codes ────────────────────────────
677
+ regenerateBackupCodes: async (ctx) => {
678
+ const body = ctx.meta["body"];
679
+ const token = body?.["token"];
680
+ if (typeof token !== "string") {
681
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "TOTP token is required");
682
+ }
683
+ if (!ctx.user.mfaEnabled) {
684
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.BAD_REQUEST, "MFA_NOT_ENABLED", "MFA is not enabled");
685
+ }
686
+ const secret = await users.getMfaSecret(ctx.user.id);
687
+ if (!secret || !verifyTotpToken(token, secret)) {
688
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid TOTP code");
689
+ }
690
+ const plainCodes = generateBackupCodes();
691
+ const codeHashes = await Promise.all(plainCodes.map((c) => hashPassword(c)));
692
+ await backupCodes.replace(ctx.user.id, codeHashes);
693
+ bus?.emit(import_events.NOTIFICATION_EVENT, {
694
+ type: MESSAGE_KEYS.mfaBackupCodesRegenerated,
695
+ data: {},
696
+ recipient: { email: ctx.user.email, phone: null, deviceToken: null }
697
+ }).catch(() => {
698
+ });
699
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "BACKUP_CODES_REGENERATED", "Backup codes regenerated.", {
700
+ backupCodes: plainCodes
701
+ });
702
+ },
703
+ // ── 4. Disable ─────────────────────────────────────────────
704
+ disable: async (ctx) => {
705
+ const body = ctx.meta["body"];
706
+ const token = body?.["token"];
707
+ if (typeof token !== "string") {
708
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "TOTP token is required");
709
+ }
710
+ const user = await users.findById(ctx.user.id);
711
+ if (!user || !user.mfaEnabled) {
712
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.BAD_REQUEST, "MFA_NOT_ENABLED", "MFA is not enabled");
713
+ }
714
+ const secret = user.mfaSecret;
715
+ if (!secret || !verifyTotpToken(token, secret)) {
716
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid TOTP code");
717
+ }
718
+ await Promise.all([users.disableMfa(ctx.user.id), backupCodes.deleteByUser(ctx.user.id)]);
719
+ bus?.emit(import_events.NOTIFICATION_EVENT, {
720
+ type: MESSAGE_KEYS.mfaDisabled,
721
+ data: {},
722
+ recipient: { email: user.email, phone: null, deviceToken: null }
723
+ }).catch(() => {
724
+ });
725
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "MFA_DISABLED", "MFA disabled successfully.");
726
+ }
727
+ };
728
+ }
729
+
730
+ // src/controllers/auth.controller.ts
731
+ var import_node_crypto2 = require("crypto");
732
+ var import_events2 = require("@fonderie/events");
733
+ var import_core4 = require("@fonderie/core");
734
+
735
+ // src/services/cooldown.ts
736
+ function checkCooldown(lastSentAt, cooldownMs) {
737
+ if (!lastSentAt) return 0;
738
+ return Math.max(0, cooldownMs - (Date.now() - lastSentAt.getTime()));
739
+ }
740
+
741
+ // src/controllers/auth.controller.ts
742
+ init_password();
743
+
744
+ // src/services/email.ts
745
+ function normalizeEmail(email) {
746
+ if (typeof email !== "string" || email.length === 0) {
747
+ throw new Error("Invalid email");
748
+ }
749
+ const lower = email.trim().toLowerCase();
750
+ if (lower.length === 0) {
751
+ throw new Error("Email cannot be empty");
752
+ }
753
+ const atIndex = lower.indexOf("@");
754
+ if (atIndex === -1 || lower.lastIndexOf("@") !== atIndex) {
755
+ throw new Error("Invalid email format");
756
+ }
757
+ const local = lower.substring(0, atIndex);
758
+ const domain = lower.substring(atIndex + 1);
759
+ if (!local || !domain) {
760
+ throw new Error("Invalid email format");
761
+ }
762
+ const plusIndex = local.indexOf("+");
763
+ const normalizedLocal = plusIndex === -1 ? local : local.substring(0, plusIndex);
764
+ if (!normalizedLocal) {
765
+ throw new Error("Invalid email format");
766
+ }
767
+ return `${normalizedLocal}@${domain}`;
768
+ }
769
+ function normalizeEmailSafe(email) {
770
+ try {
771
+ return normalizeEmail(email);
772
+ } catch {
773
+ return null;
774
+ }
775
+ }
776
+
777
+ // src/models/password-reset.model.ts
778
+ var PasswordResetModel = class {
779
+ constructor(store) {
780
+ this.store = store;
781
+ }
782
+ store;
783
+ async create(userId, pin, expiresAt) {
784
+ await this.store.query(
785
+ `INSERT INTO fonderie_password_resets (user_id, pin, expires_at, created_at)
786
+ VALUES ($1, $2, $3, now())
787
+ ON CONFLICT (user_id) DO UPDATE
788
+ SET pin = $2, expires_at = $3, created_at = now()`,
789
+ [userId, pin, expiresAt]
790
+ );
791
+ }
792
+ async findLastSentAt(userId) {
793
+ const [row] = await this.store.query(
794
+ `SELECT created_at FROM fonderie_password_resets WHERE user_id = $1`,
795
+ [userId]
796
+ );
797
+ if (!row) return null;
798
+ return new Date(row.created_at);
799
+ }
800
+ async findByPin(pin) {
801
+ const [row] = await this.store.query(
802
+ `SELECT user_id, expires_at FROM fonderie_password_resets WHERE pin = $1`,
803
+ [pin]
804
+ );
805
+ if (!row) return null;
806
+ return { userId: row.user_id, expiresAt: new Date(row.expires_at) };
807
+ }
808
+ async deleteByUser(userId) {
809
+ await this.store.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [userId]);
810
+ }
811
+ };
812
+
813
+ // src/models/email-verification.model.ts
814
+ var EmailVerificationModel = class {
815
+ constructor(store) {
816
+ this.store = store;
817
+ }
818
+ store;
819
+ async create(userId, pin, expiresAt) {
820
+ await this.store.query(
821
+ `INSERT INTO fonderie_email_verifications (user_id, token, expires_at)
822
+ VALUES ($1, $2, $3)
823
+ ON CONFLICT (user_id) DO UPDATE SET token = $2, expires_at = $3`,
824
+ [userId, pin, expiresAt]
825
+ );
826
+ }
827
+ async find(pin) {
828
+ const [row] = await this.store.query(
829
+ `SELECT user_id, expires_at FROM fonderie_email_verifications WHERE token = $1`,
830
+ [pin]
831
+ );
832
+ if (!row) return null;
833
+ return { userId: row.user_id, expiresAt: new Date(row.expires_at) };
834
+ }
835
+ async delete(pin) {
836
+ await this.store.query(`DELETE FROM fonderie_email_verifications WHERE token = $1`, [pin]);
837
+ }
838
+ async findByUser(userId, pin) {
839
+ const [row] = await this.store.query(
840
+ `SELECT expires_at FROM fonderie_email_verifications WHERE user_id = $1 AND token = $2`,
841
+ [userId, pin]
842
+ );
843
+ if (!row) return null;
844
+ return { expiresAt: new Date(row.expires_at) };
845
+ }
846
+ async findLastSentAt(userId) {
847
+ const [row] = await this.store.query(
848
+ `SELECT created_at FROM fonderie_email_verifications WHERE user_id = $1`,
849
+ [userId]
850
+ );
851
+ if (!row) return null;
852
+ return new Date(row.created_at);
853
+ }
854
+ async replace(userId, pin, expiresAt) {
855
+ await this.store.transaction(async (tx) => {
856
+ await tx.query(`DELETE FROM fonderie_email_verifications WHERE user_id = $1`, [userId]);
857
+ await tx.query(
858
+ `INSERT INTO fonderie_email_verifications (token, user_id, expires_at)
859
+ VALUES ($1, $2, $3)`,
860
+ [pin, userId, expiresAt]
861
+ );
862
+ });
863
+ }
864
+ };
865
+
866
+ // src/models/phone-verification.model.ts
867
+ var PhoneVerificationModel = class {
868
+ constructor(store) {
869
+ this.store = store;
870
+ }
871
+ store;
872
+ async upsert(userId, phone, otp, expiresAt) {
873
+ await this.store.query(
874
+ `INSERT INTO fonderie_phone_verifications (phone, user_id, otp, expires_at)
875
+ VALUES ($1, $2, $3, $4)
876
+ ON CONFLICT (phone) DO UPDATE
877
+ SET user_id = $2, otp = $3, expires_at = $4, created_at = now()`,
878
+ [phone, userId, otp, expiresAt]
879
+ );
880
+ }
881
+ async findByUser(userId, otp) {
882
+ const [row] = await this.store.query(
883
+ `SELECT phone, expires_at FROM fonderie_phone_verifications WHERE user_id = $1 AND otp = $2`,
884
+ [userId, otp]
885
+ );
886
+ if (!row) return null;
887
+ return { phone: row.phone, expiresAt: new Date(row.expires_at) };
888
+ }
889
+ async findLastSentAt(userId) {
890
+ const [row] = await this.store.query(
891
+ `SELECT created_at FROM fonderie_phone_verifications WHERE user_id = $1`,
892
+ [userId]
893
+ );
894
+ if (!row) return null;
895
+ return new Date(row.created_at);
896
+ }
897
+ async deleteByUser(userId) {
898
+ await this.store.query(`DELETE FROM fonderie_phone_verifications WHERE user_id = $1`, [userId]);
899
+ }
900
+ };
901
+
902
+ // src/controllers/auth.controller.ts
903
+ function normalizePhone(phone) {
904
+ return phone.trim().replace(/[\s()\-\.]/g, "");
905
+ }
906
+ function isValidPhone(phone) {
907
+ return typeof phone === "string" && /^\+?[1-9]\d{6,14}$/.test(normalizePhone(phone));
908
+ }
909
+ function extractRefreshToken(ctx) {
910
+ const body = ctx.meta["body"];
911
+ if (typeof body?.["refreshToken"] === "string") {
912
+ return body["refreshToken"];
913
+ }
914
+ const cookie = ctx.request.headers.get("cookie") ?? "";
915
+ const match = cookie.match(/(?:^|;\s*)refresh_token=([^;]+)/);
916
+ return match?.[1] ?? null;
917
+ }
918
+ function authController(store, config, bus) {
919
+ const users = new UserModel(store);
920
+ const sessions = new SessionModel(store);
921
+ const passwordReset = new PasswordResetModel(store);
922
+ const emailVerif = new EmailVerificationModel(store);
923
+ const phoneVerif = new PhoneVerificationModel(store);
924
+ const OTP_TTL_MS = 10 * 60 * 1e3;
925
+ return {
926
+ register: async (ctx) => {
927
+ const body = ctx.meta["body"];
928
+ const { email, password, phone, firstName = null, lastName = null } = body ?? {};
929
+ if (typeof email === "string" && typeof password === "string") {
930
+ const normalizedEmail = normalizeEmailSafe(email);
931
+ if (!normalizedEmail) {
932
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
933
+ }
934
+ if (password.length < 8) {
935
+ return (0, import_core4.setApiResponse)(
936
+ import_core4.HTTP.UNPROCESSABLE,
937
+ "INVALID_PARAMETER",
938
+ "password must be at least 8 characters"
939
+ );
940
+ }
941
+ const existing = await users.findByEmail(normalizedEmail);
942
+ if (existing) {
943
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.CONFLICT, "USER_ALREADY_EXISTS", "Email already registered");
944
+ }
945
+ const passwordHash = await hashPassword(password);
946
+ const row = await users.create(
947
+ normalizedEmail,
948
+ passwordHash,
949
+ firstName,
950
+ lastName
951
+ );
952
+ if (!row) {
953
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
954
+ }
955
+ const pin = (0, import_node_crypto2.randomInt)(1e5, 1e6).toString();
956
+ const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
957
+ await emailVerif.create(row.id, pin, expiresAt);
958
+ const user = await users.findById(row.id);
959
+ if (!user) {
960
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
961
+ }
962
+ const reqId = ctx.meta["requestId"];
963
+ const reqOpts = reqId !== void 0 ? { requestId: reqId } : void 0;
964
+ bus?.emit(
965
+ import_events2.NOTIFICATION_EVENT,
966
+ {
967
+ type: MESSAGE_KEYS.emailRegistration,
968
+ data: { pin, firstName: firstName ?? "" },
969
+ recipient: { email: normalizedEmail, phone: null, deviceToken: null }
970
+ },
971
+ reqOpts
972
+ ).catch(() => {
973
+ });
974
+ bus?.emit(
975
+ EVENT_KEYS.userRegistered,
976
+ {
977
+ userId: user.id,
978
+ email: user.email,
979
+ firstName: user.firstName,
980
+ lastName: user.lastName,
981
+ loginMethod: "email"
982
+ },
983
+ reqOpts
984
+ ).catch(() => {
985
+ });
986
+ const { accessToken, refreshToken } = issueTokenPair(user.id, config, {
987
+ loginMethod: "email"
988
+ });
989
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken));
990
+ const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
991
+ const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
992
+ return Response.json(
993
+ {
994
+ reason: "USER_EMAIL_REGISTERED",
995
+ explanation: "Account created. Check your email for a verification code.",
996
+ result: {
997
+ tokens: { access: accessToken, refresh: refreshToken },
998
+ user: toUserDTO(user),
999
+ requiresVerification
1000
+ }
1001
+ },
1002
+ {
1003
+ status: 201,
1004
+ headers: {
1005
+ "Set-Cookie": [
1006
+ `access_token=${accessToken}; HttpOnly; SameSite=Strict; Path=/`,
1007
+ `refresh_token=${refreshToken}; HttpOnly; SameSite=Strict; Path=/auth/refresh`
1008
+ ].join(", ")
1009
+ }
1010
+ }
1011
+ );
1012
+ }
1013
+ if (isValidPhone(phone)) {
1014
+ const existing = await users.findByPhone(normalizePhone(phone));
1015
+ if (existing) {
1016
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.CONFLICT, "USER_ALREADY_EXISTS", "Phone already registered");
1017
+ }
1018
+ const { id } = await users.findOrCreateByPhone(
1019
+ normalizePhone(phone),
1020
+ firstName ?? null,
1021
+ lastName ?? null
1022
+ );
1023
+ const otp = (0, import_node_crypto2.randomInt)(1e5, 1e6).toString();
1024
+ const expiresAt = new Date(Date.now() + OTP_TTL_MS);
1025
+ await phoneVerif.upsert(id, normalizePhone(phone), otp, expiresAt);
1026
+ const user = await users.findById(id);
1027
+ if (!user) {
1028
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1029
+ }
1030
+ const reqId2 = ctx.meta["requestId"];
1031
+ const reqOpts2 = reqId2 !== void 0 ? { requestId: reqId2 } : void 0;
1032
+ bus?.emit(
1033
+ import_events2.NOTIFICATION_EVENT,
1034
+ {
1035
+ type: MESSAGE_KEYS.phoneOtp,
1036
+ data: { otp },
1037
+ recipient: { email: null, phone: normalizePhone(phone), deviceToken: null }
1038
+ },
1039
+ reqOpts2
1040
+ ).catch(() => {
1041
+ });
1042
+ bus?.emit(
1043
+ EVENT_KEYS.userRegistered,
1044
+ {
1045
+ userId: user.id,
1046
+ email: user.email,
1047
+ firstName: user.firstName,
1048
+ lastName: user.lastName,
1049
+ loginMethod: "phone"
1050
+ },
1051
+ reqOpts2
1052
+ ).catch(() => {
1053
+ });
1054
+ const { accessToken, refreshToken } = issueTokenPair(user.id, config, {
1055
+ loginMethod: "phone"
1056
+ });
1057
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken));
1058
+ return Response.json(
1059
+ {
1060
+ reason: "USER_PHONE_REGISTERED",
1061
+ explanation: "Account created. A verification code has been sent to your phone.",
1062
+ result: {
1063
+ tokens: { access: accessToken, refresh: refreshToken },
1064
+ user: toUserDTO(user)
1065
+ }
1066
+ },
1067
+ {
1068
+ status: 202,
1069
+ headers: {
1070
+ "Set-Cookie": [
1071
+ `access_token=${accessToken}; HttpOnly; SameSite=Strict; Path=/`,
1072
+ `refresh_token=${refreshToken}; HttpOnly; SameSite=Strict; Path=/auth/refresh`
1073
+ ].join(", ")
1074
+ }
1075
+ }
1076
+ );
1077
+ }
1078
+ return (0, import_core4.setApiResponse)(
1079
+ import_core4.HTTP.UNPROCESSABLE,
1080
+ "INVALID_PARAMETER",
1081
+ "Provide email + password or a valid phone number"
1082
+ );
1083
+ },
1084
+ login: async (ctx) => {
1085
+ const body = ctx.meta["body"];
1086
+ if (typeof body?.["email"] === "string" && typeof body?.["password"] === "string") {
1087
+ const { email: rawEmail, password } = body;
1088
+ const email = normalizeEmailSafe(rawEmail);
1089
+ if (!email) {
1090
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1091
+ }
1092
+ const user = await users.findByEmail(email);
1093
+ if (!user || !user.passwordHash) {
1094
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1095
+ }
1096
+ const valid = await verifyPassword(password, user.passwordHash);
1097
+ if (!valid) {
1098
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1099
+ }
1100
+ if (user.suspended) {
1101
+ return (0, import_core4.setApiResponse)(
1102
+ import_core4.HTTP.FORBIDDEN,
1103
+ "ACCOUNT_SUSPENDED",
1104
+ "Account suspended. Please contact support."
1105
+ );
1106
+ }
1107
+ if (user.mfaEnabled) {
1108
+ const mfaToken = issueMfaPendingToken(user.id, config, "email");
1109
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.OK, "MFA_REQUIRED", "Multi-factor authentication required", {
1110
+ mfaToken
1111
+ });
1112
+ }
1113
+ const { accessToken, refreshToken } = issueTokenPair(user.id, config, {
1114
+ loginMethod: "email"
1115
+ });
1116
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken));
1117
+ const resolvedLogin = { ...config, ...config.resolve?.(ctx) };
1118
+ const requiresVerification = !!resolvedLogin.requireVerification && !user.emailVerifiedAt;
1119
+ return Response.json(
1120
+ {
1121
+ reason: "USER_EMAIL_LOGIN",
1122
+ explanation: "Login successful.",
1123
+ result: {
1124
+ tokens: { access: accessToken, refresh: refreshToken },
1125
+ user: toUserDTO(user),
1126
+ requiresVerification
1127
+ }
1128
+ },
1129
+ {
1130
+ status: 200,
1131
+ headers: {
1132
+ "Set-Cookie": [
1133
+ `access_token=${accessToken}; HttpOnly; SameSite=Strict; Path=/`,
1134
+ `refresh_token=${refreshToken}; HttpOnly; SameSite=Strict; Path=/auth/refresh`
1135
+ ].join(", ")
1136
+ }
1137
+ }
1138
+ );
1139
+ }
1140
+ const phone = body?.["phone"];
1141
+ if (isValidPhone(phone)) {
1142
+ const user = await users.findByPhone(normalizePhone(phone));
1143
+ if (!user) {
1144
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1145
+ }
1146
+ if (user.suspended) {
1147
+ return (0, import_core4.setApiResponse)(
1148
+ import_core4.HTTP.FORBIDDEN,
1149
+ "ACCOUNT_SUSPENDED",
1150
+ "Account suspended. Please contact support."
1151
+ );
1152
+ }
1153
+ const otp = (0, import_node_crypto2.randomInt)(1e5, 1e6).toString();
1154
+ const expiresAt = new Date(Date.now() + OTP_TTL_MS);
1155
+ await phoneVerif.upsert(user.id, normalizePhone(phone), otp, expiresAt);
1156
+ bus?.emit(import_events2.NOTIFICATION_EVENT, {
1157
+ type: MESSAGE_KEYS.phoneOtp,
1158
+ data: { otp },
1159
+ recipient: { email: null, phone: normalizePhone(phone), deviceToken: null }
1160
+ }).catch(() => {
1161
+ });
1162
+ const { accessToken, refreshToken } = issueTokenPair(user.id, config, {
1163
+ loginMethod: "phone"
1164
+ });
1165
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken));
1166
+ return Response.json(
1167
+ {
1168
+ reason: "USER_PHONE_OTP_SENT",
1169
+ explanation: "A verification code has been sent to your phone.",
1170
+ result: {
1171
+ tokens: { access: accessToken, refresh: refreshToken },
1172
+ user: toUserDTO(user, false)
1173
+ }
1174
+ },
1175
+ {
1176
+ status: 202,
1177
+ headers: {
1178
+ "Set-Cookie": [
1179
+ `access_token=${accessToken}; HttpOnly; SameSite=Strict; Path=/`,
1180
+ `refresh_token=${refreshToken}; HttpOnly; SameSite=Strict; Path=/auth/refresh`
1181
+ ].join(", ")
1182
+ }
1183
+ }
1184
+ );
1185
+ }
1186
+ return (0, import_core4.setApiResponse)(
1187
+ import_core4.HTTP.UNPROCESSABLE,
1188
+ "INVALID_PARAMETER",
1189
+ "Provide email + password or a valid phone number"
1190
+ );
1191
+ },
1192
+ logout: async (ctx) => {
1193
+ const token = extractRefreshToken(ctx);
1194
+ if (token) {
1195
+ await sessions.delete(token).catch(() => void 0);
1196
+ }
1197
+ return Response.json(
1198
+ { reason: "USER_LOGOUT", explanation: "Logged out successfully." },
1199
+ {
1200
+ status: 200,
1201
+ headers: {
1202
+ "Set-Cookie": [
1203
+ "access_token=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
1204
+ "refresh_token=; HttpOnly; SameSite=Strict; Path=/auth/refresh; Max-Age=0"
1205
+ ].join(", ")
1206
+ }
1207
+ }
1208
+ );
1209
+ },
1210
+ refresh: async (ctx) => {
1211
+ const token = extractRefreshToken(ctx);
1212
+ if (!token) {
1213
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_PARAMETER", "No refresh token provided");
1214
+ }
1215
+ const payload = verifyToken(token, config);
1216
+ if (!payload || payload.type !== "refresh") {
1217
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
1218
+ }
1219
+ const valid = await sessions.exists(token);
1220
+ if (!valid) {
1221
+ return (0, import_core4.setApiResponse)(
1222
+ import_core4.HTTP.UNAUTHORIZED,
1223
+ "TOKEN_REFRESH_FAILED",
1224
+ "Session expired or already revoked"
1225
+ );
1226
+ }
1227
+ const user = await users.findById(payload.sub);
1228
+ if (!user || user.suspended || user.deletedAt) {
1229
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
1230
+ }
1231
+ await sessions.delete(token);
1232
+ const { accessToken, refreshToken } = issueTokenPair(user.id, config, {
1233
+ loginMethod: payload.loginMethod ?? "email",
1234
+ phoneVerified: payload.phoneVerified ?? false
1235
+ });
1236
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken));
1237
+ return Response.json(
1238
+ {
1239
+ reason: "TOKENS_REFRESHED",
1240
+ explanation: "Tokens refreshed successfully.",
1241
+ result: { tokens: { access: accessToken, refresh: refreshToken } }
1242
+ },
1243
+ {
1244
+ status: 200,
1245
+ headers: {
1246
+ "Set-Cookie": [
1247
+ `access_token=${accessToken}; HttpOnly; SameSite=Strict; Path=/`,
1248
+ `refresh_token=${refreshToken}; HttpOnly; SameSite=Strict; Path=/auth/refresh`
1249
+ ].join(", ")
1250
+ }
1251
+ }
1252
+ );
1253
+ },
1254
+ forgotPassword: async (ctx) => {
1255
+ const body = ctx.meta["body"];
1256
+ const rawEmail = body?.["email"];
1257
+ if (typeof rawEmail !== "string") {
1258
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "email is required");
1259
+ }
1260
+ const email = normalizeEmailSafe(rawEmail);
1261
+ if (!email) {
1262
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1263
+ }
1264
+ const user = await users.findByEmail(email);
1265
+ if (!user) {
1266
+ return (0, import_core4.setApiResponse)(
1267
+ import_core4.HTTP.OK,
1268
+ "PASSWORD_RESET_EMAIL_SENT",
1269
+ "Password reset email sent (if account exists)."
1270
+ );
1271
+ }
1272
+ const resolved = { ...config, ...config.resolve?.(ctx) };
1273
+ const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
1274
+ const remaining = checkCooldown(await passwordReset.findLastSentAt(user.id), cooldown);
1275
+ if (remaining > 0) {
1276
+ return (0, import_core4.setApiResponse)(
1277
+ import_core4.HTTP.TOO_MANY_REQUESTS,
1278
+ "VERIFICATION_COOLDOWN",
1279
+ "Please wait before requesting a new password reset code.",
1280
+ {
1281
+ retryAfter: Math.ceil(remaining / 1e3)
1282
+ }
1283
+ );
1284
+ }
1285
+ const pin = (0, import_node_crypto2.randomInt)(1e5, 1e6).toString();
1286
+ const expiresAt = new Date(Date.now() + 1e3 * 60 * 60);
1287
+ await passwordReset.create(user.id, pin, expiresAt);
1288
+ bus?.emit(import_events2.NOTIFICATION_EVENT, {
1289
+ type: MESSAGE_KEYS.passwordReset,
1290
+ recipient: { email, phone: null, deviceToken: null },
1291
+ data: { pin }
1292
+ }).catch(() => {
1293
+ });
1294
+ return (0, import_core4.setApiResponse)(
1295
+ import_core4.HTTP.OK,
1296
+ "PASSWORD_RESET_EMAIL_SENT",
1297
+ "Password reset email sent (if account exists)."
1298
+ );
1299
+ },
1300
+ resetPassword: async (ctx) => {
1301
+ const body = ctx.meta["body"];
1302
+ const raw = body?.["pin"];
1303
+ const password = body?.["password"];
1304
+ if (typeof raw !== "string" || typeof password !== "string") {
1305
+ return (0, import_core4.setApiResponse)(
1306
+ import_core4.HTTP.UNPROCESSABLE,
1307
+ "INVALID_PARAMETER",
1308
+ "pin and password are required"
1309
+ );
1310
+ }
1311
+ if (!/^\d{6}$/.test(raw.trim())) {
1312
+ return (0, import_core4.setApiResponse)(
1313
+ import_core4.HTTP.UNPROCESSABLE,
1314
+ "INVALID_PARAMETER",
1315
+ "pin must be a 6-digit code"
1316
+ );
1317
+ }
1318
+ if (password.length < 8) {
1319
+ return (0, import_core4.setApiResponse)(
1320
+ import_core4.HTTP.UNPROCESSABLE,
1321
+ "INVALID_PARAMETER",
1322
+ "password must be at least 8 characters"
1323
+ );
1324
+ }
1325
+ const pin = raw.trim();
1326
+ const row = await passwordReset.findByPin(pin);
1327
+ if (!row || /* @__PURE__ */ new Date() > row.expiresAt) {
1328
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.BAD_REQUEST, "PASSWORD_RESET_FAILED", "Invalid or expired pin");
1329
+ }
1330
+ const passwordHash = await hashPassword(password);
1331
+ await store.transaction(async (tx) => {
1332
+ await Promise.all([
1333
+ tx.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [
1334
+ passwordHash,
1335
+ row.userId
1336
+ ]),
1337
+ tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId])
1338
+ ]);
1339
+ });
1340
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
1341
+ },
1342
+ verify: async (ctx) => {
1343
+ if (ctx.user.loginMethod !== "phone" && ctx.user.emailVerifiedAt) {
1344
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.OK, "VERIFIED", "Email verified successfully.", {
1345
+ verified: true,
1346
+ email: ctx.user.email
1347
+ });
1348
+ }
1349
+ const body = ctx.meta["body"];
1350
+ const raw = body?.["token"];
1351
+ if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
1352
+ return (0, import_core4.setApiResponse)(
1353
+ import_core4.HTTP.UNPROCESSABLE,
1354
+ "INVALID_PARAMETER",
1355
+ "token must be a 6-digit code"
1356
+ );
1357
+ }
1358
+ const pin = raw.trim();
1359
+ if (ctx.user.loginMethod === "phone") {
1360
+ const record = await phoneVerif.findByUser(ctx.user.id, pin);
1361
+ if (!record) {
1362
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
1363
+ }
1364
+ if (/* @__PURE__ */ new Date() > record.expiresAt) {
1365
+ await phoneVerif.deleteByUser(ctx.user.id);
1366
+ return (0, import_core4.setApiResponse)(
1367
+ import_core4.HTTP.BAD_REQUEST,
1368
+ "VERIFICATION_FAILED",
1369
+ "Verification code expired"
1370
+ );
1371
+ }
1372
+ await phoneVerif.deleteByUser(ctx.user.id);
1373
+ const { accessToken, refreshToken } = issueTokenPair(ctx.user.id, config, {
1374
+ loginMethod: "phone",
1375
+ phoneVerified: true
1376
+ });
1377
+ await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken));
1378
+ const verifiedUser = await users.findById(ctx.user.id);
1379
+ if (!verifiedUser) {
1380
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1381
+ }
1382
+ if (verifiedUser.suspended) {
1383
+ return (0, import_core4.setApiResponse)(
1384
+ import_core4.HTTP.FORBIDDEN,
1385
+ "ACCOUNT_SUSPENDED",
1386
+ "Account suspended. Please contact support."
1387
+ );
1388
+ }
1389
+ return Response.json(
1390
+ {
1391
+ reason: "VERIFIED",
1392
+ explanation: "Phone verified successfully.",
1393
+ result: {
1394
+ tokens: { access: accessToken, refresh: refreshToken },
1395
+ user: toUserDTO(verifiedUser, true)
1396
+ }
1397
+ },
1398
+ {
1399
+ status: 200,
1400
+ headers: {
1401
+ "Set-Cookie": [
1402
+ `access_token=${accessToken}; HttpOnly; SameSite=Strict; Path=/`,
1403
+ `refresh_token=${refreshToken}; HttpOnly; SameSite=Strict; Path=/auth/refresh`
1404
+ ].join(", ")
1405
+ }
1406
+ }
1407
+ );
1408
+ }
1409
+ const row = await emailVerif.findByUser(ctx.user.id, pin);
1410
+ if (!row) {
1411
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
1412
+ }
1413
+ if (/* @__PURE__ */ new Date() > row.expiresAt) {
1414
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Pin expired");
1415
+ }
1416
+ await store.transaction(async (tx) => {
1417
+ await Promise.all([
1418
+ tx.query(
1419
+ `UPDATE fonderie_users SET email_verified_at = now(), updated_at = now() WHERE id = $1`,
1420
+ [ctx.user.id]
1421
+ ),
1422
+ tx.query(`DELETE FROM fonderie_email_verifications WHERE user_id = $1 AND token = $2`, [
1423
+ ctx.user.id,
1424
+ pin
1425
+ ])
1426
+ ]);
1427
+ });
1428
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.OK, "VERIFIED", "Email verified successfully.", {
1429
+ verified: true,
1430
+ email: ctx.user.email
1431
+ });
1432
+ },
1433
+ sendVerification: async (ctx) => {
1434
+ const resolved = { ...config, ...config.resolve?.(ctx) };
1435
+ const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
1436
+ if (ctx.user.loginMethod === "phone") {
1437
+ const phone = ctx.user.phone;
1438
+ if (!phone) {
1439
+ return (0, import_core4.setApiResponse)(
1440
+ import_core4.HTTP.BAD_REQUEST,
1441
+ "NO_PHONE_ON_ACCOUNT",
1442
+ "No phone number associated with this account"
1443
+ );
1444
+ }
1445
+ const remaining2 = checkCooldown(await phoneVerif.findLastSentAt(ctx.user.id), cooldown);
1446
+ if (remaining2 > 0) {
1447
+ return (0, import_core4.setApiResponse)(
1448
+ import_core4.HTTP.TOO_MANY_REQUESTS,
1449
+ "VERIFICATION_COOLDOWN",
1450
+ "Please wait before requesting a new code.",
1451
+ {
1452
+ retryAfter: Math.ceil(remaining2 / 1e3)
1453
+ }
1454
+ );
1455
+ }
1456
+ const otp = (0, import_node_crypto2.randomInt)(1e5, 1e6).toString();
1457
+ const expiresAt2 = new Date(Date.now() + OTP_TTL_MS);
1458
+ await phoneVerif.upsert(ctx.user.id, phone, otp, expiresAt2);
1459
+ bus?.emit(import_events2.NOTIFICATION_EVENT, {
1460
+ type: MESSAGE_KEYS.phoneOtp,
1461
+ data: { otp },
1462
+ recipient: { email: null, phone, deviceToken: null }
1463
+ }).catch(() => {
1464
+ });
1465
+ return (0, import_core4.setApiResponse)(
1466
+ import_core4.HTTP.OK,
1467
+ "VERIFICATION_SENT",
1468
+ "A verification code has been sent to your phone."
1469
+ );
1470
+ }
1471
+ if (!ctx.user.email) {
1472
+ return (0, import_core4.setApiResponse)(
1473
+ import_core4.HTTP.BAD_REQUEST,
1474
+ "NO_EMAIL_ON_ACCOUNT",
1475
+ "No email address associated with this account"
1476
+ );
1477
+ }
1478
+ if (ctx.user.emailVerifiedAt) {
1479
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.OK, "EMAIL_VERIFIED", "Email already verified.", {
1480
+ verified: true,
1481
+ email: ctx.user.email
1482
+ });
1483
+ }
1484
+ const remaining = checkCooldown(await emailVerif.findLastSentAt(ctx.user.id), cooldown);
1485
+ if (remaining > 0) {
1486
+ return (0, import_core4.setApiResponse)(
1487
+ import_core4.HTTP.TOO_MANY_REQUESTS,
1488
+ "VERIFICATION_COOLDOWN",
1489
+ "Please wait before requesting a new code.",
1490
+ {
1491
+ retryAfter: Math.ceil(remaining / 1e3)
1492
+ }
1493
+ );
1494
+ }
1495
+ const pin = (0, import_node_crypto2.randomInt)(1e5, 1e6).toString();
1496
+ const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1497
+ await emailVerif.replace(ctx.user.id, pin, expiresAt);
1498
+ bus?.emit(import_events2.NOTIFICATION_EVENT, {
1499
+ type: MESSAGE_KEYS.emailVerification,
1500
+ recipient: { email: ctx.user.email, phone: null, deviceToken: null },
1501
+ data: { pin }
1502
+ }).catch(() => {
1503
+ });
1504
+ return (0, import_core4.setApiResponse)(import_core4.HTTP.OK, "VERIFICATION_SENT", "Verification email sent.", {
1505
+ email: ctx.user.email
1506
+ });
1507
+ }
1508
+ };
1509
+ }
1510
+
1511
+ // src/controllers/user.controller.ts
1512
+ var import_node_crypto3 = require("crypto");
1513
+ var import_core5 = require("@fonderie/core");
1514
+ var import_events3 = require("@fonderie/events");
1515
+ function normalizePhone2(phone) {
1516
+ return phone.trim().replace(/[\s()\-\.]/g, "");
1517
+ }
1518
+ function isValidPhone2(phone) {
1519
+ return typeof phone === "string" && /^\+?[1-9]\d{6,14}$/.test(normalizePhone2(phone));
1520
+ }
1521
+ function userController(store, bus) {
1522
+ const users = new UserModel(store);
1523
+ const emailVerif = new EmailVerificationModel(store);
1524
+ const phoneVerif = new PhoneVerificationModel(store);
1525
+ return {
1526
+ me: async (ctx) => {
1527
+ const user = await users.findById(ctx.user.id);
1528
+ if (!user) {
1529
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1530
+ }
1531
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
1532
+ user: toUserDTO(user, ctx.user.phoneVerified)
1533
+ });
1534
+ },
1535
+ updateProfile: async (ctx) => {
1536
+ const body = ctx.meta["body"];
1537
+ const allowed = ["firstName", "lastName", "avatarUrl"];
1538
+ const fields = {};
1539
+ for (const key of allowed) {
1540
+ if (body?.[key] !== void 0) fields[key] = body[key];
1541
+ }
1542
+ if (Object.keys(fields).length === 0) {
1543
+ return (0, import_core5.setApiResponse)(
1544
+ import_core5.HTTP.UNPROCESSABLE,
1545
+ "INVALID_PARAMETER",
1546
+ "Provide at least one of: firstName, lastName, avatarUrl"
1547
+ );
1548
+ }
1549
+ const row = await users.update(ctx.user.id, fields);
1550
+ if (!row) {
1551
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1552
+ }
1553
+ const updated = await users.findById(ctx.user.id);
1554
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "PROFILE_UPDATED", "Profile updated.", {
1555
+ user: toUserDTO(updated, ctx.user.phoneVerified)
1556
+ });
1557
+ },
1558
+ updatePreferences: async (ctx) => {
1559
+ const body = ctx.meta["body"];
1560
+ const fields = {};
1561
+ if (typeof body?.["locale"] === "string") fields.locale = body["locale"];
1562
+ if (typeof body?.["timezone"] === "string") fields.timezone = body["timezone"];
1563
+ const prefKeys = ["notifications", "emailDigest", "dateFormat", "timeFormat"];
1564
+ const patch = {};
1565
+ for (const key of prefKeys) {
1566
+ if (body?.[key] !== void 0) patch[key] = body[key];
1567
+ }
1568
+ if (Object.keys(patch).length > 0) fields.patch = patch;
1569
+ const row = await users.updatePreferences(ctx.user.id, fields);
1570
+ if (!row) {
1571
+ return (0, import_core5.setApiResponse)(
1572
+ import_core5.HTTP.UNPROCESSABLE,
1573
+ "INVALID_PARAMETER",
1574
+ "Provide at least one preference field"
1575
+ );
1576
+ }
1577
+ const updated = await users.findById(ctx.user.id);
1578
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
1579
+ user: toUserDTO(updated, ctx.user.phoneVerified)
1580
+ });
1581
+ },
1582
+ updateEmail: async (ctx) => {
1583
+ const body = ctx.meta["body"];
1584
+ const newEmail = body?.["email"];
1585
+ const normalised = typeof newEmail === "string" ? normalizeEmailSafe(newEmail) : null;
1586
+ if (!normalised) {
1587
+ return (0, import_core5.setApiResponse)(
1588
+ import_core5.HTTP.UNPROCESSABLE,
1589
+ "INVALID_PARAMETER",
1590
+ "A valid email address is required"
1591
+ );
1592
+ }
1593
+ const oldEmail = ctx.user.email;
1594
+ if (normalised === oldEmail) {
1595
+ return (0, import_core5.setApiResponse)(
1596
+ import_core5.HTTP.UNPROCESSABLE,
1597
+ "INVALID_PARAMETER",
1598
+ "New email must differ from current email"
1599
+ );
1600
+ }
1601
+ const existing = await users.findByEmail(normalised);
1602
+ if (existing) {
1603
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
1604
+ }
1605
+ const pin = (0, import_node_crypto3.randomInt)(1e5, 1e6).toString();
1606
+ const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1607
+ await emailVerif.replace(ctx.user.id, pin, expiresAt);
1608
+ await users.updateEmail(ctx.user.id, normalised);
1609
+ bus?.emit(import_events3.NOTIFICATION_EVENT, {
1610
+ type: MESSAGE_KEYS.emailVerification,
1611
+ data: { pin },
1612
+ recipient: { email: normalised, phone: null, deviceToken: null }
1613
+ }).catch(() => {
1614
+ });
1615
+ if (oldEmail) {
1616
+ bus?.emit(import_events3.NOTIFICATION_EVENT, {
1617
+ type: MESSAGE_KEYS.emailChanged,
1618
+ data: { newEmail: normalised },
1619
+ recipient: { email: oldEmail, phone: null, deviceToken: null }
1620
+ }).catch(() => {
1621
+ });
1622
+ }
1623
+ return (0, import_core5.setApiResponse)(
1624
+ import_core5.HTTP.OK,
1625
+ "EMAIL_UPDATED",
1626
+ "Email updated. A verification code has been sent to your new address.",
1627
+ {
1628
+ email: normalised
1629
+ }
1630
+ );
1631
+ },
1632
+ updatePhone: async (ctx) => {
1633
+ const body = ctx.meta["body"];
1634
+ const newPhone = body?.["phone"];
1635
+ if (!isValidPhone2(newPhone)) {
1636
+ return (0, import_core5.setApiResponse)(
1637
+ import_core5.HTTP.UNPROCESSABLE,
1638
+ "INVALID_PARAMETER",
1639
+ "A valid phone number is required"
1640
+ );
1641
+ }
1642
+ const normalised = normalizePhone2(newPhone);
1643
+ const existing = await users.findByPhone(normalised);
1644
+ if (existing) {
1645
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
1646
+ }
1647
+ const otp = (0, import_node_crypto3.randomInt)(1e5, 1e6).toString();
1648
+ const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
1649
+ await phoneVerif.upsert(ctx.user.id, normalised, otp, expiresAt);
1650
+ await users.updatePhone(ctx.user.id, normalised);
1651
+ bus?.emit(import_events3.NOTIFICATION_EVENT, {
1652
+ type: MESSAGE_KEYS.phoneOtp,
1653
+ data: { otp },
1654
+ recipient: { email: null, phone: normalised, deviceToken: null }
1655
+ }).catch(() => {
1656
+ });
1657
+ if (ctx.user.email) {
1658
+ bus?.emit(import_events3.NOTIFICATION_EVENT, {
1659
+ type: MESSAGE_KEYS.phoneChanged,
1660
+ data: {},
1661
+ recipient: { email: ctx.user.email, phone: null, deviceToken: null }
1662
+ }).catch(() => {
1663
+ });
1664
+ }
1665
+ return (0, import_core5.setApiResponse)(
1666
+ import_core5.HTTP.OK,
1667
+ "PHONE_UPDATED",
1668
+ "Phone number updated. A verification code has been sent to your new number.",
1669
+ {
1670
+ phone: normalised
1671
+ }
1672
+ );
1673
+ },
1674
+ changePassword: async (ctx) => {
1675
+ const body = ctx.meta["body"];
1676
+ const currentPassword = body?.["currentPassword"];
1677
+ const newPassword = body?.["newPassword"];
1678
+ if (typeof currentPassword !== "string" || !currentPassword) {
1679
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
1680
+ }
1681
+ if (typeof newPassword !== "string" || newPassword.length < 8) {
1682
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
1683
+ }
1684
+ const user = await users.findById(ctx.user.id);
1685
+ if (!user) return (0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1686
+ const { hashPassword: hashPassword2, verifyPassword: verifyPassword2 } = await Promise.resolve().then(() => (init_password(), password_exports));
1687
+ const valid = await verifyPassword2(currentPassword, user.passwordHash ?? "");
1688
+ if (!valid) {
1689
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
1690
+ }
1691
+ const hash = await hashPassword2(newPassword);
1692
+ await users.updatePassword(ctx.user.id, hash);
1693
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
1694
+ },
1695
+ deleteMe: async (ctx) => {
1696
+ const userId = ctx.user.id;
1697
+ await users.softDelete(userId);
1698
+ const reqId = ctx.meta["requestId"];
1699
+ bus?.emit(
1700
+ EVENT_KEYS.userDeleted,
1701
+ { userId },
1702
+ reqId !== void 0 ? { requestId: reqId } : void 0
1703
+ ).catch(() => {
1704
+ });
1705
+ return Response.json(
1706
+ { reason: "ACCOUNT_DELETED", explanation: "Account successfully deleted." },
1707
+ {
1708
+ status: 200,
1709
+ headers: {
1710
+ "Set-Cookie": [
1711
+ "access_token=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
1712
+ "refresh_token=; HttpOnly; SameSite=Strict; Path=/auth/refresh; Max-Age=0"
1713
+ ].join(", ")
1714
+ }
1715
+ }
1716
+ );
1717
+ }
1718
+ };
1719
+ }
1720
+
1721
+ // src/controllers/oauth.controller.ts
1722
+ var import_core6 = require("@fonderie/core");
1723
+ function oauthController(store, config) {
1724
+ const users = new UserModel(store);
1725
+ const sessions = new SessionModel(store);
1726
+ return {
1727
+ googleInit: async (_ctx) => {
1728
+ const google = config.google;
1729
+ if (!google) {
1730
+ return (0, import_core6.setApiResponse)(
1731
+ import_core6.HTTP.NOT_IMPLEMENTED,
1732
+ "NOT_CONFIGURED",
1733
+ "Google OAuth not configured"
1734
+ );
1735
+ }
1736
+ const params = new URLSearchParams({
1737
+ client_id: google.clientId,
1738
+ redirect_uri: google.redirectUri,
1739
+ response_type: "code",
1740
+ scope: "openid email profile"
1741
+ });
1742
+ const url = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
1743
+ return (0, import_core6.setApiResponse)(
1744
+ import_core6.HTTP.OK,
1745
+ "GOOGLE_AUTH_URL",
1746
+ "Redirect the user to the returned URL to begin Google OAuth.",
1747
+ { url }
1748
+ );
1749
+ },
1750
+ googleCallback: async (ctx) => {
1751
+ const google = config.google;
1752
+ if (!google) {
1753
+ return (0, import_core6.setApiResponse)(
1754
+ import_core6.HTTP.NOT_IMPLEMENTED,
1755
+ "NOT_CONFIGURED",
1756
+ "Google OAuth not configured"
1757
+ );
1758
+ }
1759
+ const url = new URL(ctx.request.url);
1760
+ const code = url.searchParams.get("code");
1761
+ if (!code) {
1762
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
1763
+ }
1764
+ const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
1765
+ method: "POST",
1766
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1767
+ body: new URLSearchParams({
1768
+ code,
1769
+ client_id: google.clientId,
1770
+ client_secret: google.clientSecret,
1771
+ redirect_uri: google.redirectUri,
1772
+ grant_type: "authorization_code"
1773
+ })
1774
+ });
1775
+ const tokenData = await tokenRes.json();
1776
+ if (!tokenData.id_token) {
1777
+ return (0, import_core6.setApiResponse)(
1778
+ import_core6.HTTP.BAD_REQUEST,
1779
+ "GOOGLE_AUTH_FAILED",
1780
+ "OAuth token exchange failed"
1781
+ );
1782
+ }
1783
+ const payload = JSON.parse(
1784
+ Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
1785
+ );
1786
+ if (!payload.email) {
1787
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
1788
+ }
1789
+ const normalizedEmail = normalizeEmailSafe(payload.email);
1790
+ if (!normalizedEmail) {
1791
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
1792
+ }
1793
+ const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
1794
+ if (!upserted) {
1795
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
1796
+ }
1797
+ const fullUser = await users.findById(upserted.id);
1798
+ if (!fullUser) {
1799
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
1800
+ }
1801
+ const { accessToken, refreshToken } = issueTokenPair(upserted.id, config, {
1802
+ loginMethod: "google"
1803
+ });
1804
+ await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken));
1805
+ return Response.json(
1806
+ {
1807
+ reason: "GOOGLE_AUTH_SUCCESS",
1808
+ explanation: "Google authentication successful.",
1809
+ result: {
1810
+ tokens: { access: accessToken, refresh: refreshToken },
1811
+ user: toUserDTO(fullUser)
1812
+ }
1813
+ },
1814
+ {
1815
+ status: 200,
1816
+ headers: {
1817
+ "Set-Cookie": [
1818
+ `access_token=${accessToken}; HttpOnly; SameSite=Strict; Path=/`,
1819
+ `refresh_token=${refreshToken}; HttpOnly; SameSite=Strict; Path=/auth/refresh`
1820
+ ].join(", ")
1821
+ }
1822
+ }
1823
+ );
1824
+ }
1825
+ };
1826
+ }
1827
+
1828
+ // src/routes.ts
1829
+ function buildAuthRoutes(store, config, bus) {
1830
+ const user = userController(store, bus);
1831
+ const auth = authController(store, config, bus);
1832
+ const oauth = oauthController(store, config);
1833
+ const mfa = mfaController(store, config, config.appName ?? "Fonderie", bus);
1834
+ const verifyGate = config.requireVerification ? import_middlewares.requireVerified : (_ctx, next) => next();
1835
+ const routes = [
1836
+ // Registration & Login (Public)
1837
+ ["POST", "/auth/register", auth.register],
1838
+ ["POST", "/auth/login", auth.login],
1839
+ // Token Management (Public)
1840
+ ["POST", "/auth/refresh", auth.refresh],
1841
+ // Email — Password Recovery (Public)
1842
+ ["POST", "/auth/email/forgot", auth.forgotPassword],
1843
+ ["POST", "/auth/email/reset", auth.resetPassword],
1844
+ // Verification (Protected — email or phone, determined by loginMethod)
1845
+ ["POST", "/auth/verify", import_middlewares.requireAuth, auth.verify],
1846
+ ["GET", "/auth/send-verification", import_middlewares.requireAuth, auth.sendVerification],
1847
+ // Account Management (Protected)
1848
+ ["POST", "/auth/logout", import_middlewares.requireAuth, auth.logout],
1849
+ // User Profile (Protected; writes also gate on requireVerification)
1850
+ ["GET", "/users", import_middlewares.requireAuth, user.me],
1851
+ ["PUT", "/users/profile", import_middlewares.requireAuth, verifyGate, user.updateProfile],
1852
+ ["PUT", "/users/preferences", import_middlewares.requireAuth, verifyGate, user.updatePreferences],
1853
+ ["PUT", "/users/email", import_middlewares.requireAuth, verifyGate, user.updateEmail],
1854
+ ["PUT", "/users/phone", import_middlewares.requireAuth, verifyGate, user.updatePhone],
1855
+ ["PUT", "/users/password", import_middlewares.requireAuth, user.changePassword],
1856
+ ["DELETE", "/users", import_middlewares.requireAuth, verifyGate, user.deleteMe],
1857
+ // MFA (email sessions only — requireVerified is always enforced here
1858
+ // because MFA is a security feature and email verification is meaningful)
1859
+ ["POST", "/auth/mfa/setup", import_middlewares.requireAuth, requireEmailLogin, import_middlewares.requireVerified, mfa.setup],
1860
+ // /auth/mfa/verify accepts both mfaPending tokens (TOTP/backup-code login)
1861
+ // and full tokens (setup confirmation), so requireAnyAuth is used here.
1862
+ ["POST", "/auth/mfa/verify", import_middlewares.requireAnyAuth, requireEmailLogin, import_middlewares.requireVerified, mfa.verify],
1863
+ ["POST", "/auth/mfa/disable", import_middlewares.requireAuth, requireEmailLogin, import_middlewares.requireVerified, mfa.disable],
1864
+ [
1865
+ "POST",
1866
+ "/auth/mfa/backup-codes",
1867
+ import_middlewares.requireAuth,
1868
+ requireEmailLogin,
1869
+ import_middlewares.requireVerified,
1870
+ mfa.regenerateBackupCodes
1871
+ ]
1872
+ ];
1873
+ if (config.providers.includes("google")) {
1874
+ routes.push(
1875
+ ["GET", "/auth/google", oauth.googleInit],
1876
+ ["GET", "/auth/google/callback", oauth.googleCallback]
1877
+ );
1878
+ }
1879
+ return routes;
1880
+ }
1881
+
1882
+ // src/middlewares/session.ts
1883
+ function withSession(store, config) {
1884
+ const users = new UserModel(store);
1885
+ return async (ctx, next) => {
1886
+ const token = extractToken(ctx.request);
1887
+ if (!token) {
1888
+ return next();
1889
+ }
1890
+ const payload = verifyToken(token, config);
1891
+ if (!payload || payload.type !== "access") {
1892
+ return next();
1893
+ }
1894
+ const user = await users.findById(payload.sub);
1895
+ if (!user || user.suspended || user.deletedAt) {
1896
+ return next();
1897
+ }
1898
+ Object.assign(ctx, {
1899
+ user: {
1900
+ ...user,
1901
+ loginMethod: payload.loginMethod ?? "email",
1902
+ phoneVerified: payload.phoneVerified ?? false,
1903
+ mfaPending: payload.mfaPending ?? false
1904
+ }
1905
+ });
1906
+ return next();
1907
+ };
1908
+ }
1909
+ function extractToken(request) {
1910
+ const auth = request.headers.get("authorization");
1911
+ if (auth?.startsWith("Bearer ")) {
1912
+ return auth.slice(7);
1913
+ }
1914
+ const cookie = request.headers.get("cookie") ?? "";
1915
+ const match = cookie.match(/(?:^|;\s*)access_token=([^;]+)/);
1916
+ return match?.[1] ?? null;
1917
+ }
1918
+
1919
+ // src/module.ts
1920
+ var AuthModule = class {
1921
+ constructor(store, config, bus) {
1922
+ this.store = store;
1923
+ this.config = config;
1924
+ this.bus = bus;
1925
+ }
1926
+ store;
1927
+ config;
1928
+ bus;
1929
+ name = "@fonderie/auth";
1930
+ install(app) {
1931
+ app.use(withSession(this.store, this.config));
1932
+ const routes = buildAuthRoutes(this.store, this.config, this.bus);
1933
+ for (const [method, path, ...handlers] of routes) {
1934
+ app.addRoute(method, path, ...handlers);
1935
+ }
1936
+ }
1937
+ };
1938
+
1939
+ // src/middlewares/require-auth.ts
1940
+ var import_middlewares2 = require("@fonderie/core/middlewares");
1941
+ // Annotate the CommonJS export names for ESM import in node:
1942
+ 0 && (module.exports = {
1943
+ AUTH_CONFIG_KEYS,
1944
+ AuthModule,
1945
+ MESSAGE_KEYS,
1946
+ normalizeEmail,
1947
+ normalizeEmailSafe,
1948
+ requireAuth,
1949
+ toUserDTO,
1950
+ withSession
1951
+ });
1952
+ //# sourceMappingURL=index.cjs.map