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