@fonderie/auth 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -76,8 +76,10 @@ __export(index_exports, {
76
76
  importUser: () => importUser,
77
77
  normalizeEmail: () => normalizeEmail,
78
78
  normalizeEmailSafe: () => normalizeEmailSafe,
79
+ purgeSoftDeletedUsers: () => purgeSoftDeletedUsers,
79
80
  requireAuth: () => import_middlewares3.requireAuth,
80
81
  schemas: () => schemas_exports,
82
+ startUserRetention: () => startUserRetention,
81
83
  toUserDTO: () => toUserDTO,
82
84
  validate: () => import_middlewares.validate,
83
85
  validateAuthConfig: () => validateAuthConfig,
@@ -202,10 +204,18 @@ var updateProfileSchema = import_zod.z.object({
202
204
  var updatePreferencesSchema = import_zod.z.object({
203
205
  locale: import_zod.z.string().max(35).optional(),
204
206
  timezone: import_zod.z.string().max(64).optional(),
205
- notifications: import_zod.z.unknown().optional(),
206
- emailDigest: import_zod.z.unknown().optional(),
207
- dateFormat: import_zod.z.unknown().optional(),
208
- timeFormat: import_zod.z.unknown().optional()
207
+ // Typed at last — these four accepted `unknown`, letting null and
208
+ // arbitrary JSON into stored preferences that the user DTO then served
209
+ // against string-typed client fields.
210
+ notifications: import_zod.z.object({
211
+ email: import_zod.z.boolean().optional(),
212
+ inApp: import_zod.z.boolean().optional(),
213
+ sms: import_zod.z.boolean().optional(),
214
+ push: import_zod.z.boolean().optional()
215
+ }).optional(),
216
+ emailDigest: import_zod.z.string().max(20).optional(),
217
+ dateFormat: import_zod.z.string().max(30).optional(),
218
+ timeFormat: import_zod.z.string().max(30).optional()
209
219
  }).refine(
210
220
  (o) => Object.values(o).some((v) => v !== void 0),
211
221
  "Provide at least one preference field"
@@ -392,10 +402,16 @@ function generateTotpUri(email2, secret, issuer) {
392
402
  });
393
403
  return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(email2)}?${params}`;
394
404
  }
405
+ function safeCodeEqual(a, b) {
406
+ const bufA = Buffer.from(a);
407
+ const bufB = Buffer.from(b);
408
+ if (bufA.length !== bufB.length) return false;
409
+ return (0, import_node_crypto2.timingSafeEqual)(bufA, bufB);
410
+ }
395
411
  function verifyTotpToken(token, secret) {
396
412
  const t = timeCounter();
397
413
  for (let i = -DRIFT; i <= DRIFT; i++) {
398
- if (hotp(secret, t + i) === token) {
414
+ if (safeCodeEqual(hotp(secret, t + i), token)) {
399
415
  return true;
400
416
  }
401
417
  }
@@ -408,6 +424,41 @@ function generateBackupCodes(count = 8) {
408
424
  // src/controllers/mfa.controller.ts
409
425
  init_password();
410
426
 
427
+ // src/services/mfa-crypto.ts
428
+ var import_node_crypto3 = require("crypto");
429
+ var PREFIX = "mfa.v1:";
430
+ function makeMfaCipher(keyHex) {
431
+ if (!keyHex) {
432
+ return { encrypt: (plain) => plain, decrypt: (stored) => stored };
433
+ }
434
+ const key = Buffer.from(keyHex, "hex");
435
+ if (key.length !== 32) {
436
+ throw new Error("[auth] mfaSecretKey must be 32 bytes (64 hex chars, e.g. `openssl rand -hex 32`)");
437
+ }
438
+ return {
439
+ encrypt(plain) {
440
+ const iv = (0, import_node_crypto3.randomBytes)(12);
441
+ const cipher = (0, import_node_crypto3.createCipheriv)("aes-256-gcm", key, iv);
442
+ const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
443
+ const tag = cipher.getAuthTag();
444
+ return `${PREFIX}${iv.toString("hex")}:${tag.toString("hex")}:${enc.toString("hex")}`;
445
+ },
446
+ decrypt(stored) {
447
+ if (!stored.startsWith(PREFIX)) return stored;
448
+ const [ivHex, tagHex, dataHex] = stored.slice(PREFIX.length).split(":");
449
+ if (!ivHex || !tagHex || !dataHex) {
450
+ throw new Error("[auth] malformed mfa secret ciphertext");
451
+ }
452
+ const decipher = (0, import_node_crypto3.createDecipheriv)("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
453
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
454
+ return Buffer.concat([
455
+ decipher.update(Buffer.from(dataHex, "hex")),
456
+ decipher.final()
457
+ ]).toString("utf8");
458
+ }
459
+ };
460
+ }
461
+
411
462
  // src/dtos/user.ts
412
463
  var import_core2 = require("@fonderie/core");
413
464
  var DEFAULT_PREFERENCES = {
@@ -418,8 +469,27 @@ var DEFAULT_PREFERENCES = {
418
469
  dateFormat: "MM/DD/YYYY",
419
470
  timeFormat: "hh:mm A"
420
471
  };
472
+ function cleanPreferences(prefs) {
473
+ const out = {};
474
+ if (typeof prefs.locale === "string") out.locale = prefs.locale;
475
+ if (typeof prefs.timezone === "string") out.timezone = prefs.timezone;
476
+ if (typeof prefs.emailDigest === "string") out.emailDigest = prefs.emailDigest;
477
+ if (typeof prefs.dateFormat === "string") out.dateFormat = prefs.dateFormat;
478
+ if (typeof prefs.timeFormat === "string") out.timeFormat = prefs.timeFormat;
479
+ return out;
480
+ }
481
+ function cleanNotifications(value) {
482
+ if (!value || typeof value !== "object") return {};
483
+ const out = {};
484
+ for (const key of ["email", "inApp", "sms", "push"]) {
485
+ const v = value[key];
486
+ if (typeof v === "boolean") out[key] = v;
487
+ }
488
+ return out;
489
+ }
421
490
  function toUserDTO(user, phoneVerified = false) {
422
491
  const prefs = user.preferences ?? {};
492
+ const cleaned = cleanPreferences(prefs);
423
493
  return {
424
494
  id: (0, import_core2.stringOrEmpty)(user.id),
425
495
  email: (0, import_core2.stringOrEmpty)(user.email),
@@ -431,9 +501,13 @@ function toUserDTO(user, phoneVerified = false) {
431
501
  lastLogin: user.lastLogin instanceof Date ? user.lastLogin.toISOString() : "",
432
502
  preferences: {
433
503
  ...DEFAULT_PREFERENCES,
434
- ...prefs,
435
- locale: user.locale || prefs.locale || "en-US",
436
- timezone: user.timezone || prefs.timezone || "UTC"
504
+ ...cleaned,
505
+ notifications: {
506
+ ...DEFAULT_PREFERENCES.notifications,
507
+ ...cleanNotifications(prefs.notifications)
508
+ },
509
+ locale: user.locale || cleaned.locale || "en-US",
510
+ timezone: user.timezone || cleaned.timezone || "UTC"
437
511
  },
438
512
  isEmailVerified: user.emailVerifiedAt !== null,
439
513
  isPhoneVerified: phoneVerified,
@@ -464,7 +538,6 @@ var USER_COLUMNS = `
464
538
  whitelist,
465
539
  ip_whitelist AS "ipWhitelist",
466
540
  mfa_enabled AS "mfaEnabled",
467
- mfa_secret AS "mfaSecret",
468
541
  email_verified_at AS "emailVerifiedAt",
469
542
  deleted_at AS "deletedAt",
470
543
  created_at AS "createdAt",
@@ -673,6 +746,25 @@ var SessionModel = class {
673
746
  async delete(token) {
674
747
  await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
675
748
  }
749
+ // Revoke every session for a user (e.g. on password change). Access tokens
750
+ // bound to these sessions via the sid claim die on their next request.
751
+ async deleteByUser(userId) {
752
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [userId]);
753
+ }
754
+ // Session metadata for a user (no tokens) — for the data-export / SAR bundle.
755
+ async listByUser(userId) {
756
+ return this.store.query(
757
+ `SELECT id,
758
+ user_agent AS "userAgent",
759
+ ip_address AS "ipAddress",
760
+ created_at AS "createdAt",
761
+ expires_at AS "expiresAt"
762
+ FROM fonderie_sessions
763
+ WHERE user_id = $1
764
+ ORDER BY created_at DESC`,
765
+ [userId]
766
+ );
767
+ }
676
768
  async exists(token) {
677
769
  const rows = await this.store.query(
678
770
  `SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
@@ -730,6 +822,7 @@ function mfaController(store, config, issuer, bus) {
730
822
  const users = new UserModel(store);
731
823
  const sessions = new SessionModel(store);
732
824
  const backupCodes = new BackupCodeModel(store);
825
+ const mfaCipher = makeMfaCipher(config.mfaSecretKey);
733
826
  return {
734
827
  // ── 1. Setup ───────────────────────────────────────────────
735
828
  setup: async (ctx) => {
@@ -739,7 +832,7 @@ function mfaController(store, config, issuer, bus) {
739
832
  const codeHashes = await Promise.all(plainCodes.map((c) => hashPassword(c)));
740
833
  const qr = await import_qrcode.default.toDataURL(uri);
741
834
  await Promise.all([
742
- users.saveMfaPendingSecret(ctx.user.id, secret),
835
+ users.saveMfaPendingSecret(ctx.user.id, mfaCipher.encrypt(secret)),
743
836
  backupCodes.replace(ctx.user.id, codeHashes)
744
837
  ]);
745
838
  return (0, import_core3.setApiResponse)(
@@ -761,7 +854,8 @@ function mfaController(store, config, issuer, bus) {
761
854
  if (typeof token !== "string") {
762
855
  return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
763
856
  }
764
- const pendingSecret = await users.getMfaPendingSecret(ctx.user.id);
857
+ const pendingCipher = await users.getMfaPendingSecret(ctx.user.id);
858
+ const pendingSecret = pendingCipher ? mfaCipher.decrypt(pendingCipher) : null;
765
859
  if (pendingSecret) {
766
860
  if (!verifyTotpToken(token, pendingSecret)) {
767
861
  return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
@@ -808,10 +902,11 @@ function mfaController(store, config, issuer, bus) {
808
902
  "Use the mfaToken from the login response"
809
903
  );
810
904
  }
811
- const secret = await users.getMfaSecret(ctx.user.id);
812
- if (!secret) {
905
+ const storedSecret = await users.getMfaSecret(ctx.user.id);
906
+ if (!storedSecret) {
813
907
  return (0, import_core3.setApiResponse)(import_core3.HTTP.BAD_REQUEST, "MFA_NOT_CONFIGURED", "MFA not configured");
814
908
  }
909
+ const secret = mfaCipher.decrypt(storedSecret);
815
910
  if (!verifyTotpToken(token, secret)) {
816
911
  return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
817
912
  }
@@ -820,7 +915,8 @@ function mfaController(store, config, issuer, bus) {
820
915
  }
821
916
  }
822
917
  const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
823
- loginMethod: ctx.user.loginMethod
918
+ loginMethod: ctx.user.loginMethod,
919
+ phoneVerified: ctx.user.phoneVerified
824
920
  });
825
921
  await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
826
922
  const fullUser = await users.findById(ctx.user.id);
@@ -833,7 +929,7 @@ function mfaController(store, config, issuer, bus) {
833
929
  explanation: "MFA verified successfully.",
834
930
  result: {
835
931
  tokens: { access: accessToken, refresh: refreshToken },
836
- user: toUserDTO(fullUser)
932
+ user: toUserDTO(fullUser, ctx.user.phoneVerified)
837
933
  }
838
934
  },
839
935
  {
@@ -852,7 +948,8 @@ function mfaController(store, config, issuer, bus) {
852
948
  if (!ctx.user.mfaEnabled) {
853
949
  return (0, import_core3.setApiResponse)(import_core3.HTTP.BAD_REQUEST, "MFA_NOT_ENABLED", "MFA is not enabled");
854
950
  }
855
- const secret = await users.getMfaSecret(ctx.user.id);
951
+ const storedSecret = await users.getMfaSecret(ctx.user.id);
952
+ const secret = storedSecret ? mfaCipher.decrypt(storedSecret) : null;
856
953
  if (!secret || !verifyTotpToken(token, secret)) {
857
954
  return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid TOTP code");
858
955
  }
@@ -881,7 +978,8 @@ function mfaController(store, config, issuer, bus) {
881
978
  if (!user || !user.mfaEnabled) {
882
979
  return (0, import_core3.setApiResponse)(import_core3.HTTP.BAD_REQUEST, "MFA_NOT_ENABLED", "MFA is not enabled");
883
980
  }
884
- const secret = user.mfaSecret;
981
+ const storedSecret = await users.getMfaSecret(ctx.user.id);
982
+ const secret = storedSecret ? mfaCipher.decrypt(storedSecret) : null;
885
983
  if (!secret || !verifyTotpToken(token, secret)) {
886
984
  return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid TOTP code");
887
985
  }
@@ -899,7 +997,7 @@ function mfaController(store, config, issuer, bus) {
899
997
  }
900
998
 
901
999
  // src/controllers/auth.controller.ts
902
- var import_node_crypto3 = require("crypto");
1000
+ var import_node_crypto4 = require("crypto");
903
1001
  var import_events2 = require("@fonderie/events");
904
1002
  var import_core4 = require("@fonderie/core");
905
1003
 
@@ -1123,7 +1221,7 @@ function authController(store, config, bus) {
1123
1221
  if (!row) {
1124
1222
  return (0, import_core4.setApiResponse)(import_core4.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1125
1223
  }
1126
- const pin = (0, import_node_crypto3.randomInt)(1e5, 1e6).toString();
1224
+ const pin = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1127
1225
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1128
1226
  await emailVerif.create(row.id, pin, expiresAt);
1129
1227
  const user = await users.findById(row.id);
@@ -1187,7 +1285,7 @@ function authController(store, config, bus) {
1187
1285
  firstName ?? null,
1188
1286
  lastName ?? null
1189
1287
  );
1190
- const otp = (0, import_node_crypto3.randomInt)(1e5, 1e6).toString();
1288
+ const otp = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1191
1289
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
1192
1290
  await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
1193
1291
  const user = await users.findById(id);
@@ -1315,7 +1413,7 @@ function authController(store, config, bus) {
1315
1413
  "Account suspended. Please contact support."
1316
1414
  );
1317
1415
  }
1318
- const otp = (0, import_node_crypto3.randomInt)(1e5, 1e6).toString();
1416
+ const otp = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1319
1417
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
1320
1418
  await phoneVerif.upsert(user.id, normalizePhone(phone2), otp, expiresAt);
1321
1419
  bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -1430,7 +1528,7 @@ function authController(store, config, bus) {
1430
1528
  "Password reset email sent (if account exists)."
1431
1529
  );
1432
1530
  }
1433
- const pin = (0, import_node_crypto3.randomInt)(1e5, 1e6).toString();
1531
+ const pin = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1434
1532
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60);
1435
1533
  await passwordReset.create(user.id, pin, expiresAt);
1436
1534
  bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -1597,7 +1695,7 @@ function authController(store, config, bus) {
1597
1695
  }
1598
1696
  );
1599
1697
  }
1600
- const otp = (0, import_node_crypto3.randomInt)(1e5, 1e6).toString();
1698
+ const otp = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1601
1699
  const expiresAt2 = new Date(Date.now() + OTP_TTL_MS);
1602
1700
  await phoneVerif.upsert(ctx.user.id, phone2, otp, expiresAt2);
1603
1701
  bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -1637,7 +1735,7 @@ function authController(store, config, bus) {
1637
1735
  }
1638
1736
  );
1639
1737
  }
1640
- const pin = (0, import_node_crypto3.randomInt)(1e5, 1e6).toString();
1738
+ const pin = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1641
1739
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1642
1740
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
1643
1741
  bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -1655,7 +1753,7 @@ function authController(store, config, bus) {
1655
1753
  }
1656
1754
 
1657
1755
  // src/controllers/user.controller.ts
1658
- var import_node_crypto4 = require("crypto");
1756
+ var import_node_crypto5 = require("crypto");
1659
1757
  var import_core5 = require("@fonderie/core");
1660
1758
  var import_events3 = require("@fonderie/events");
1661
1759
  function normalizePhone2(phone2) {
@@ -1666,6 +1764,7 @@ function isValidPhone2(phone2) {
1666
1764
  }
1667
1765
  function userController(store, config, bus) {
1668
1766
  const users = new UserModel(store);
1767
+ const sessions = new SessionModel(store);
1669
1768
  const emailVerif = new EmailVerificationModel(store);
1670
1769
  const phoneVerif = new PhoneVerificationModel(store);
1671
1770
  return {
@@ -1748,7 +1847,7 @@ function userController(store, config, bus) {
1748
1847
  if (existing) {
1749
1848
  return (0, import_core5.setApiResponse)(import_core5.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
1750
1849
  }
1751
- const pin = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1850
+ const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1752
1851
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1753
1852
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
1754
1853
  await users.updateEmail(ctx.user.id, normalised);
@@ -1792,7 +1891,7 @@ function userController(store, config, bus) {
1792
1891
  if (existing) {
1793
1892
  return (0, import_core5.setApiResponse)(import_core5.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
1794
1893
  }
1795
- const otp = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1894
+ const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1796
1895
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
1797
1896
  await phoneVerif.upsert(ctx.user.id, normalised, otp, expiresAt);
1798
1897
  await users.updatePhone(ctx.user.id, normalised);
@@ -1840,6 +1939,7 @@ function userController(store, config, bus) {
1840
1939
  }
1841
1940
  const hash = await hashPassword2(newPassword);
1842
1941
  await users.updatePassword(ctx.user.id, hash);
1942
+ await sessions.deleteByUser(ctx.user.id);
1843
1943
  return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
1844
1944
  },
1845
1945
  deleteMe: async (ctx) => {
@@ -1859,6 +1959,42 @@ function userController(store, config, bus) {
1859
1959
  headers: cookieHeaders(clearedTokenCookies(config))
1860
1960
  }
1861
1961
  );
1962
+ },
1963
+ // Subject Access Request — the authenticated user's own data as a portable
1964
+ // JSON bundle. Only auth-owned data, and only safe fields: no password hash,
1965
+ // no MFA secret, no session tokens (session metadata only).
1966
+ exportMe: async (ctx) => {
1967
+ const user = await users.findById(ctx.user.id);
1968
+ if (!user) return (0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1969
+ const sessionRows = await sessions.listByUser(ctx.user.id);
1970
+ const bundle = {
1971
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
1972
+ profile: toUserDTO(user, ctx.user.phoneVerified),
1973
+ sessions: sessionRows.map((s) => ({
1974
+ id: s.id,
1975
+ userAgent: s.userAgent,
1976
+ ipAddress: s.ipAddress,
1977
+ createdAt: s.createdAt instanceof Date ? s.createdAt.toISOString() : s.createdAt,
1978
+ expiresAt: s.expiresAt instanceof Date ? s.expiresAt.toISOString() : s.expiresAt
1979
+ })),
1980
+ security: {
1981
+ mfaEnabled: user.mfaEnabled === true,
1982
+ emailVerified: user.emailVerifiedAt !== null
1983
+ }
1984
+ };
1985
+ const contributors = config.dataExportContributors ?? [];
1986
+ if (contributors.length > 0) {
1987
+ const modules = {};
1988
+ for (const c of contributors) {
1989
+ try {
1990
+ modules[c.name] = await c.collect(ctx.user.id);
1991
+ } catch {
1992
+ modules[c.name] = null;
1993
+ }
1994
+ }
1995
+ bundle["modules"] = modules;
1996
+ }
1997
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
1862
1998
  }
1863
1999
  };
1864
2000
  }
@@ -2003,6 +2139,7 @@ function buildAuthRoutes(store, config, bus) {
2003
2139
  R("updatePhone", "PUT", "/users/phone", import_middlewares2.requireAuth, verifyGate, (0, import_middlewares.validate)(updatePhoneSchema), user.updatePhone),
2004
2140
  R("changePassword", "PUT", "/users/password", import_middlewares2.requireAuth, (0, import_middlewares.validate)(changePasswordSchema), user.changePassword),
2005
2141
  R("deleteMe", "DELETE", "/users", import_middlewares2.requireAuth, verifyGate, user.deleteMe),
2142
+ R("exportMe", "GET", "/users/export", import_middlewares2.requireAuth, user.exportMe),
2006
2143
  // MFA (email sessions only — requireVerified is always enforced here
2007
2144
  // because MFA is a security feature and email verification is meaningful)
2008
2145
  R("mfaSetup", "POST", "/auth/mfa/setup", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, mfa.setup),
@@ -2044,10 +2181,40 @@ function collectAuthConfigProblems(config) {
2044
2181
  if (config.secureCookies === false) {
2045
2182
  problems.push({
2046
2183
  module: MODULE,
2047
- severity: "warning",
2184
+ severity: process.env["NODE_ENV"] === "production" ? "error" : "warning",
2048
2185
  message: "secureCookies is false \u2014 auth cookies may be sent over non-HTTPS connections in production"
2049
2186
  });
2050
2187
  }
2188
+ if (config.google) {
2189
+ const clientSecret = config.google.clientSecret ?? "";
2190
+ if (!clientSecret || !config.google.clientId || !config.google.redirectUri) {
2191
+ problems.push({
2192
+ module: MODULE,
2193
+ severity: "error",
2194
+ message: "google OAuth is configured but clientId, clientSecret, or redirectUri is missing"
2195
+ });
2196
+ } else if (PLACEHOLDER_SECRET.test(clientSecret)) {
2197
+ problems.push({
2198
+ module: MODULE,
2199
+ severity: "error",
2200
+ message: "google.clientSecret looks like a placeholder or dev-default value"
2201
+ });
2202
+ }
2203
+ }
2204
+ if (config.mfa && !config.mfaSecretKey) {
2205
+ problems.push({
2206
+ module: MODULE,
2207
+ severity: process.env["NODE_ENV"] === "production" ? "error" : "warning",
2208
+ message: "mfa is enabled without mfaSecretKey \u2014 TOTP secrets are stored plaintext at rest; set a 32-byte key (openssl rand -hex 32)"
2209
+ });
2210
+ }
2211
+ if (config.mfaSecretKey && !/^[0-9a-fA-F]{64}$/.test(config.mfaSecretKey)) {
2212
+ problems.push({
2213
+ module: MODULE,
2214
+ severity: "error",
2215
+ message: "mfaSecretKey must be 64 hex characters (32 bytes, e.g. `openssl rand -hex 32`)"
2216
+ });
2217
+ }
2051
2218
  return problems;
2052
2219
  }
2053
2220
  function validateAuthConfig(config) {
@@ -2168,6 +2335,41 @@ async function importUser(store, user) {
2168
2335
  if (!row) throw new Error("[auth] importUser: insert returned no row");
2169
2336
  return row;
2170
2337
  }
2338
+
2339
+ // src/services/retention.ts
2340
+ async function purgeSoftDeletedUsers(store, { olderThanDays }) {
2341
+ if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
2342
+ throw new Error("[auth] purgeSoftDeletedUsers: olderThanDays must be a non-negative number");
2343
+ }
2344
+ const rows = await store.query(
2345
+ `DELETE FROM fonderie_users
2346
+ WHERE deleted_at IS NOT NULL
2347
+ AND deleted_at < now() - make_interval(days => $1)
2348
+ RETURNING id`,
2349
+ [olderThanDays]
2350
+ );
2351
+ return rows.length;
2352
+ }
2353
+ function startUserRetention(store, options) {
2354
+ const intervalMs = options.intervalMs ?? 24 * 60 * 60 * 1e3;
2355
+ let stopped = false;
2356
+ const run = async () => {
2357
+ if (stopped) return;
2358
+ try {
2359
+ const deleted = await purgeSoftDeletedUsers(store, { olderThanDays: options.olderThanDays });
2360
+ options.onPurge?.(deleted);
2361
+ } catch (err) {
2362
+ console.error("[auth] scheduled user retention purge failed:", err);
2363
+ }
2364
+ };
2365
+ const timer = setInterval(run, intervalMs);
2366
+ if (typeof timer.unref === "function") timer.unref();
2367
+ void run();
2368
+ return { stop: () => {
2369
+ stopped = true;
2370
+ clearInterval(timer);
2371
+ } };
2372
+ }
2171
2373
  // Annotate the CommonJS export names for ESM import in node:
2172
2374
  0 && (module.exports = {
2173
2375
  AUTH_CONFIG_KEYS,
@@ -2178,8 +2380,10 @@ async function importUser(store, user) {
2178
2380
  importUser,
2179
2381
  normalizeEmail,
2180
2382
  normalizeEmailSafe,
2383
+ purgeSoftDeletedUsers,
2181
2384
  requireAuth,
2182
2385
  schemas,
2386
+ startUserRetention,
2183
2387
  toUserDTO,
2184
2388
  validate,
2185
2389
  validateAuthConfig,